vectorcraft/crates/mcp/src/tests.rs

967 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::io::{BufRead, BufReader, Write};
use serde_json::{Value, json};
use crate::{Backend, Headless, PROTOCOL_VERSION, Remote, Server, tool_definitions};
fn server() -> Server {
Server::new(Box::new(Headless::with_document()))
}
fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let reply = s.handle_line(&line).expect("reply");
let v: Value = serde_json::from_str(&reply).expect("reply is JSON");
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], id);
v
}
fn call(s: &mut Server, id: u64, name: &str, args: Value) -> Value {
let v = rpc(s, id, "tools/call", json!({"name": name, "arguments": args}));
assert!(v.get("error").is_none(), "{v}");
v["result"].clone()
}
fn text_of(result: &Value) -> String {
result["content"].as_array().unwrap().iter().filter(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).collect()
}
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
// ---------- framing ----------
#[test]
fn framing_basics() {
let mut s = server();
// Blank lines and notifications produce no output.
assert_eq!(s.handle_line(" "), None);
assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), None);
assert!(s.is_initialized());
// Parse error → -32700 with null id.
let v: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap();
assert_eq!(v["error"]["code"], -32700);
assert_eq!(v["id"], Value::Null);
// Unknown method → -32601, id echoed (string ids too).
let v: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":"abc","method":"nope"}"#).unwrap()).unwrap();
assert_eq!(v["error"]["code"], -32601);
assert_eq!(v["id"], "abc");
// Missing method → invalid request.
let v: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":3}"#).unwrap()).unwrap();
assert_eq!(v["error"]["code"], -32600);
// Responses from the client are ignored.
assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","id":9,"result":{}}"#), None);
// ping
assert_eq!(rpc(&mut s, 4, "ping", json!({}))["result"], json!({}));
// Batch (legacy clients).
let v: Value = serde_json::from_str(
&s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/initialized"}]"#).unwrap(),
)
.unwrap();
assert_eq!(v.as_array().unwrap().len(), 1);
}
#[test]
fn serve_loop_writes_one_line_per_request() {
let mut s = server();
let input = [
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"t","version":"0"}}}).to_string(),
json!({"jsonrpc":"2.0","method":"notifications/initialized"}).to_string(),
json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}).to_string(),
]
.join("\n");
let mut out = Vec::new();
s.serve(input.as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["result"]["protocolVersion"], PROTOCOL_VERSION);
assert_eq!(lines[0]["result"]["serverInfo"]["name"], "vectorcraft");
assert!(lines[0]["result"]["capabilities"]["tools"].is_object());
assert!(lines[0]["result"]["capabilities"]["resources"].is_object());
assert!(lines[1]["result"]["tools"].as_array().unwrap().len() >= 19);
}
#[test]
fn initialize_negotiates_version() {
let mut s = server();
let v = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26"}));
assert_eq!(v["result"]["protocolVersion"], "2025-03-26");
let v = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
}
// ---------- tools/list ----------
#[test]
fn tool_schemas_are_valid() {
let tools = tool_definitions();
let mut names = std::collections::HashSet::new();
for t in &tools {
let name = t["name"].as_str().expect("name");
assert!(!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), "{name}");
assert!(names.insert(name), "duplicate tool {name}");
assert!(t["description"].as_str().is_some_and(|d| d.len() > 10), "{name} description");
let schema = &t["inputSchema"];
assert_eq!(schema["type"], "object", "{name}");
let props = schema["properties"].as_object().unwrap_or_else(|| panic!("{name} properties"));
for r in schema.get("required").and_then(Value::as_array).into_iter().flatten() {
assert!(props.contains_key(r.as_str().unwrap()), "{name}: required {r} not in properties");
}
for (k, p) in props {
assert!(p.is_object(), "{name}.{k}");
assert!(p.get("type").is_some() || p.get("anyOf").is_some(), "{name}.{k} has no type");
}
}
for want in [
"list_commands",
"run_command",
"inspect_document",
"inspect_ui",
"select_tool",
"pointer_gesture",
"draw_path",
"draw_shape",
"set_paint",
"press_key",
"invoke_menu",
"open_panel",
"screenshot",
"open_file",
"save_file",
"export",
"undo",
"redo",
] {
assert!(names.contains(want), "missing tool {want}");
}
}
// ---------- headless end-to-end ----------
#[test]
fn headless_end_to_end() {
let mut s = server();
rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION}));
s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
let r = call(
&mut s,
2,
"draw_shape",
json!({"shape": "rectangle", "x": 100, "y": 100, "width": 200, "height": 120, "fill": "#ff0000", "stroke": "none"}),
);
assert_eq!(r["isError"], false, "{r}");
let created: Value = serde_json::from_str(&text_of(&r)).unwrap();
let id = created["id"].as_u64().expect("id");
let r = call(
&mut s,
3,
"draw_shape",
json!({"shape": "star", "cx": 400, "cy": 400, "radius1": 80, "radius2": 40, "fill": [0, 0, 1], "strokeWidth": 3}),
);
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 4, "inspect_document", json!({}));
let doc: Value = serde_json::from_str(&text_of(&r)).unwrap();
assert!(doc["objects"].as_u64().unwrap() >= 3, "{doc}");
let layer = &doc["layers"][0];
let rect = layer["children"].as_array().unwrap().iter().find(|c| c["id"] == id).expect("rect in layer");
assert_eq!(rect["bounds"]["width"], 200.0);
assert_eq!(rect["fill"], "#ff0000", "{rect}");
assert_eq!(rect["stroke"], "None");
// A depth-0 inspect is the skeleton: top layers with counts, nothing below them;
// a bad slice is a tool error, not a full dump.
let r = call(&mut s, 41, "inspect_document", json!({"depth": 0}));
let skel: Value = serde_json::from_str(&text_of(&r)).unwrap();
assert!(skel["layers"][0].get("children").is_none(), "{skel}");
assert_eq!(skel["layers"][0]["childCount"], layer["children"].as_array().unwrap().len());
assert_eq!(skel["artboards"], doc["artboards"]);
assert_eq!(call(&mut s, 42, "inspect_document", json!({"depth": -1}))["isError"], true);
// Screenshot returns image content (base64 PNG) and text.
let shot_path = tmp("shot.png");
let r = call(&mut s, 5, "screenshot", json!({"path": shot_path.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
let img = r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").expect("image content");
assert_eq!(img["mimeType"], "image/png");
let png = vectorcraft_format::base64_decode(img["data"].as_str().unwrap()).unwrap();
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
assert_eq!(std::fs::read(&shot_path).unwrap(), png);
// Export SVG.
let svg_path = tmp("out.svg");
let r = call(&mut s, 6, "export", json!({"path": svg_path.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
let svg = std::fs::read_to_string(&svg_path).unwrap();
assert!(svg.contains("<svg") && (svg.contains("<rect") || svg.contains("<path")), "{svg}");
// Save native, open it back.
let dc = tmp("out.vectorcraft");
assert_eq!(call(&mut s, 7, "save_file", json!({"path": dc.to_str().unwrap()}))["isError"], false);
let r = call(&mut s, 8, "open_file", json!({"path": dc.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 9, "open_file", json!({"path": svg_path.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
// PDF and PDF-compatible .ai open headless too.
let pdf_path = tmp("out.pdf");
assert_eq!(call(&mut s, 20, "export", json!({"path": pdf_path.to_str().unwrap()}))["isError"], false);
let ai_path = tmp("out.ai");
std::fs::copy(&pdf_path, &ai_path).unwrap();
for (id, p) in [(21, &pdf_path), (22, &ai_path)] {
let r = call(&mut s, id, "open_file", json!({"path": p.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
let doc: Value = serde_json::from_str(&text_of(&call(&mut s, id + 10, "inspect_document", json!({})))).unwrap();
assert!(doc["objects"].as_u64().unwrap() >= 2, "{doc}");
}
// Resources.
let v = rpc(&mut s, 10, "resources/list", json!({}));
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 3);
let v = rpc(&mut s, 11, "resources/read", json!({"uri": "vectorcraft://document"}));
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
assert!(serde_json::from_str::<Value>(text).unwrap()["layers"].is_array());
let v = rpc(&mut s, 12, "resources/read", json!({"uri": "vectorcraft://document/json"}));
assert!(v["result"]["contents"][0]["text"].as_str().unwrap().len() > 10);
let v = rpc(&mut s, 13, "resources/read", json!({"uri": "vectorcraft://nope"}));
assert_eq!(v["error"]["code"], -32002);
}
#[test]
fn headless_path_gesture_undo() {
let mut s = server();
let count = |s: &mut Server| -> u64 {
let r = call(s, 90, "inspect_document", json!({}));
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["objects"].as_u64().unwrap()
};
let base = count(&mut s);
let r = call(&mut s, 1, "draw_path", json!({"points": [[10, 10], [100, 10], [50, 90]], "closed": true, "fill": "#00ff00"}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "draw_path", json!({"d": "M0 0 C 10 10 20 10 30 0", "stroke": "#000000", "strokeWidth": 2}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(count(&mut s), base + 2);
// Drag out an ellipse with the ellipse tool.
let r = call(
&mut s,
3,
"pointer_gesture",
json!({"tool": "ellipse", "events": [{"kind": "down", "x": 200, "y": 200}, {"kind": "drag", "x": 260, "y": 240}, {"kind": "up", "x": 300, "y": 280}]}),
);
assert_eq!(r["isError"], false, "{r}");
assert_eq!(count(&mut s), base + 3);
assert_eq!(call(&mut s, 4, "undo", json!({}))["isError"], false);
assert_eq!(count(&mut s), base + 2);
assert_eq!(call(&mut s, 5, "redo", json!({}))["isError"], false);
assert_eq!(count(&mut s), base + 3);
// Keyboard shortcut headless: Cmd+Z undoes.
let r = call(&mut s, 6, "press_key", json!({"key": "Z", "mods": {"cmd": true}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(count(&mut s), base + 2);
// Long tail through run_command + list_commands filter.
let r = call(&mut s, 7, "run_command", json!({"command": "select.all"}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 8, "run_command", json!({"command": "object.group"}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 9, "list_commands", json!({"filter": "group"}));
let v: Value = serde_json::from_str(&text_of(&r)).unwrap();
assert!(v["commands"].as_array().unwrap().iter().any(|c| c["id"] == "object.group"));
assert!(
v["commands"]
.as_array()
.unwrap()
.iter()
.all(|c| c["id"].as_str().unwrap().contains("group") || c["label"].as_str().unwrap().to_lowercase().contains("group"))
);
// set_paint on the selection.
let r = call(&mut s, 10, "set_paint", json!({"fill": "#123456", "strokeWidth": 4}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 11, "select_tool", json!({"tool": "pen"}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 12, "export", json!({"path": tmp("x.png").to_str().unwrap(), "scale": 0.5}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(&std::fs::read(tmp("x.png")).unwrap()[..4], b"\x89PNG");
}
/// inspect_document reads back the paint type's characters show, as add_text and set_paint give it.
#[test]
fn inspect_document_reports_the_paint_of_type() {
let mut s = server();
let r = call(&mut s, 2, "add_text", json!({"text": "HI", "x": 50, "y": 60, "size": 24, "color": "#ff0000"}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let node = |s: &mut Server, n: u64| {
let v: Value = serde_json::from_str(&text_of(&call(s, n, "inspect_document", json!({})))).unwrap();
v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap()
};
let t = node(&mut s, 3);
assert_eq!((t["fill"].as_str(), t["stroke"].as_str()), (Some("#ff0000"), Some("None")), "{t}");
let r = call(&mut s, 4, "set_paint", json!({"fill": "#0000ff", "stroke": "#00ff00", "strokeWidth": 3, "ids": [id]}));
assert_eq!(r["isError"], false, "{r}");
let t = node(&mut s, 5);
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
}
/// #1003: type made on or in a path takes add_text's `color` too, over the current fill.
#[test]
fn add_text_on_or_in_a_path_takes_its_color() {
for mode in ["onPath", "area"] {
let mut s = server();
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 40, "y": 40, "width": 180, "height": 60, "fill": "#0000ff"}));
let path = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let r = call(&mut s, 3, "add_text", json!({"text": "Hello", "path": path, "mode": mode, "color": "#ff0000"}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let v: Value = serde_json::from_str(&text_of(&call(&mut s, 4, "inspect_document", json!({})))).unwrap();
let t = v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap();
assert_eq!(t["fill"].as_str(), Some("#ff0000"), "{mode}: {t}");
}
}
/// #1748: add_text's `justify` centres point type on (x, y) or ends it there.
#[test]
fn add_text_justify_anchors_point_type_on_x() {
let mut s = server();
let mut ids = Vec::new();
for (n, justify) in [(2, "left"), (3, "center"), (4, "right")] {
let r = call(&mut s, n, "add_text", json!({"text": "Title", "x": 300, "y": 100, "size": 24, "justify": justify}));
assert_eq!(r["isError"], false, "{r}");
ids.push(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone());
}
let doc: Value = serde_json::from_str(&text_of(&call(&mut s, 5, "inspect_document", json!({})))).unwrap();
let b = |id: &Value| {
let t = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| &c["id"] == id).unwrap();
(t["bounds"]["x"].as_f64().unwrap(), t["bounds"]["width"].as_f64().unwrap())
};
let ((l, lw), (c, cw), (r, rw)) = (b(&ids[0]), b(&ids[1]), b(&ids[2]));
assert!(lw > 10.0 && (l - 300.0).abs() < 1.0, "{l} {lw}");
assert!((c + cw / 2.0 - 300.0).abs() < 0.5, "{c} {cw}");
assert!((r + rw - 300.0).abs() < 1.0, "{r} {rw}");
}
/// save_file says it saves in the document's own format or the one the path's extension picks.
#[test]
fn save_file_describes_the_formats_it_writes() {
let tools = tool_definitions();
let d = tools.iter().find(|t| t["name"] == "save_file").and_then(|t| t["description"].as_str()).unwrap();
for ext in [".vectorcraft", ".vctemplate", ".pdf", ".svg", ".svgz", ".ai"] {
assert!(d.contains(ext), "{ext}: {d}");
}
assert!(d.contains("its own format"), "{d}");
}
#[test]
fn errors_are_tool_results_not_crashes() {
let mut s = server();
let cases = [
("no_such_tool", json!({})),
("draw_shape", json!({"shape": "hexagon"})),
("draw_shape", json!({"shape": "rectangle", "x": "left"})),
("draw_shape", json!({})),
("draw_path", json!({"points": [[1]]})),
("draw_path", json!({})),
("run_command", json!({"command": "does.not.exist"})),
("run_command", json!({"command": "object.move", "params": 5})),
("run_command", json!({})),
("pointer_gesture", json!({"events": [{"kind": "wiggle", "x": 0, "y": 0}]})),
("pointer_gesture", json!({"events": []})),
("select_tool", json!({"tool": "laser"})),
("set_paint", json!({})),
("set_paint", json!({"fill": "#zzzzzz"})),
("inspect_ui", json!({})),
("open_panel", json!({"panel": "Layers"})),
("type_text", json!({"text": "hi"})),
("screenshot", json!({"window": true})),
("open_file", json!({"path": "/definitely/not/here.svg"})),
("export", json!({"path": "/tmp/out.dwg"})),
("redo", json!({})),
("press_key", json!({"key": "F13"})),
];
for (i, (name, args)) in cases.iter().enumerate() {
let r = call(&mut s, i as u64, name, args.clone());
assert_eq!(r["isError"], true, "{name} {args} → {r}");
assert!(!text_of(&r).is_empty());
}
// Arguments that aren't an object.
let v = rpc(&mut s, 100, "tools/call", json!({"name": "undo", "arguments": [1, 2]}));
assert_eq!(v["result"]["isError"], true);
// Missing name is a protocol error.
let v = rpc(&mut s, 101, "tools/call", json!({}));
assert_eq!(v["error"]["code"], -32602);
// The server still works afterwards.
let r = call(&mut s, 102, "inspect_document", json!({}));
assert_eq!(r["isError"], false);
}
// ---------- remote ----------
/// A fake control server: answers `document.inspect`, echoes `engine.execute`, errors otherwise.
fn fake_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut out = stream.try_clone().unwrap();
let mut seen = vec![];
for line in BufReader::new(stream).lines() {
let msg: Value = serde_json::from_str(&line.unwrap()).unwrap();
let reply = match msg["method"].as_str().unwrap() {
"document.inspect" => json!({"ok": true, "result": {"title": "Remote", "layers": []}}),
"engine.execute" => json!({"ok": true, "result": {"echo": msg["params"]}}),
"ui.inspect" => json!({"ok": true, "result": {"tool": "selection"}}),
_ => json!({"ok": false, "error": "nope"}),
};
let mut reply = reply;
reply["id"] = msg["id"].clone();
writeln!(out, "{reply}").unwrap();
seen.push(msg);
}
seen
});
(addr, h)
}
#[test]
fn remote_forwards_methods() {
let (addr, h) = fake_app();
let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap()));
let r = call(&mut s, 1, "inspect_document", json!({}));
assert!(text_of(&r).contains("Remote"));
let r = call(&mut s, 2, "run_command", json!({"command": "object.group"}));
assert!(text_of(&r).contains("object.group"), "{r}");
let r = call(&mut s, 3, "inspect_ui", json!({}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 4, "open_panel", json!({"panel": "Layers"}));
assert_eq!(r["isError"], true);
assert!(text_of(&r).contains("nope"));
drop(s);
let seen = h.join().unwrap();
let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect();
assert_eq!(methods, ["document.inspect", "engine.execute", "ui.inspect", "ui.set"]);
assert_eq!(seen[1]["params"], json!({"command": "object.group", "params": {}}));
}
#[test]
fn remote_does_not_replay_when_edit_reply_is_lost() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let timeout = std::time::Duration::from_secs(5);
let mut backend = Headless::with_document();
let base = backend.call("document.inspect", json!({})).unwrap()["objects"].as_u64().unwrap();
for connection in 0..2 {
let deadline = std::time::Instant::now() + timeout;
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => std::thread::yield_now(),
Err(e) => panic!("accept failed: {e}"),
}
};
stream.set_nonblocking(false).unwrap();
stream.set_read_timeout(Some(timeout)).unwrap();
stream.set_write_timeout(Some(timeout)).unwrap();
let mut out = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
for _ in 0..2 {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let msg: Value = serde_json::from_str(&line).unwrap();
let method = msg["method"].as_str().unwrap();
let result = backend.call(method, msg["params"].clone()).unwrap();
if connection == 0 {
assert_eq!(method, "engine.execute");
break;
}
let reply = json!({"id": msg["id"], "ok": true, "result": result});
writeln!(out, "{reply}").unwrap();
if method == "document.inspect" {
return result["objects"].as_u64().unwrap() - base;
}
assert_eq!(method, "engine.execute");
}
}
panic!("document.inspect was not received");
});
let mut remote = Remote::connect(&addr).unwrap();
let first = remote.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}}));
let next = remote.call("document.inspect", json!({}));
let edits = h.join().unwrap();
assert_eq!(edits, 1, "a lost reply must not duplicate the edit");
let error = first.expect_err("the lost reply must report uncertainty");
assert!(error.contains("operation may have completed"), "{error}");
assert!(next.is_ok(), "the next call must reconnect: {next:?}");
}
#[test]
fn remote_rejects_a_reply_with_a_mismatched_id_and_reconnects() {
// A fake control server that answers the first request with another call's id, then serves
// correctly: the client must reject the stray reply, drop the connection, and reconnect.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let mut seen = vec![];
for stream in listener.incoming().flatten().take(2) {
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
let mut out = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
continue;
}
let msg: Value = serde_json::from_str(&line).unwrap();
// First connection: somebody else's answer. Later ones: the right echo.
let id = if seen.is_empty() { json!(msg["id"].as_u64().unwrap_or(0) + 1000) } else { msg["id"].clone() };
let reply = json!({"ok": true, "result": {"echo": msg["params"]}, "id": id});
writeln!(out, "{reply}").unwrap();
seen.push(msg);
}
seen
});
let mut remote = Remote::connect(&addr).unwrap();
let err = remote.call("engine.execute", json!({"command": "shape.rectangle", "params": {}})).expect_err("a stray reply must not be trusted");
assert!(err.contains("mismatch"), "{err}");
let good = remote.call("engine.execute", json!({"command": "shape.rectangle", "params": {}}));
assert!(good.is_ok(), "the next call reconnects: {good:?}");
drop(remote);
let seen = h.join().unwrap();
assert_eq!(seen.len(), 2, "one request per connection: {seen:?}");
assert_ne!(seen[0]["id"], seen[1]["id"]);
}
/// The app's refusals carry no id (a 17th connection gets one and is closed). The call reports the
/// app's reason, not a mismatch, says nothing ran, and the next call starts a fresh connection.
#[test]
fn remote_reports_the_reason_of_a_refusal_without_an_id() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let (mut refused, _) = listener.accept().unwrap();
// The request is read first, so the refusal answers it and the close is orderly.
refused.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
let _ = BufReader::new(refused.try_clone().unwrap()).read_line(&mut String::new());
writeln!(refused, "{}", json!({"ok": false, "error": "too many control connections"})).unwrap();
drop(refused);
let (stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
let mut out = stream.try_clone().unwrap();
let mut line = String::new();
BufReader::new(stream).read_line(&mut line).unwrap();
let msg: Value = serde_json::from_str(&line).unwrap();
writeln!(out, "{}", json!({"ok": true, "result": 7, "id": msg["id"]})).unwrap();
});
let mut remote = Remote::connect(&addr).unwrap();
let err = remote.call("document.inspect", json!({})).expect_err("a refusal is an error");
assert!(err.contains("too many control connections") && err.contains("nothing was run"), "{err}");
assert!(!err.contains("mismatch") && !err.contains("may have completed"), "{err}");
assert_eq!(remote.call("document.inspect", json!({})), Ok(json!(7)), "the next call reconnects");
h.join().unwrap();
}
#[test]
fn remote_connect_fails_fast() {
// A port this test holds, bound but not listening, so connecting to it is refused. (A port
// bound and dropped could be taken by another process or test before the connect.)
let held = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap();
held.bind(&std::net::SocketAddr::from(([127, 0, 0, 1], 0)).into()).unwrap();
let port = held.local_addr().unwrap().as_socket().unwrap().port();
let start = std::time::Instant::now();
assert!(Remote::connect(&format!("127.0.0.1:{port}")).is_err());
assert!(start.elapsed() < std::time::Duration::from_secs(5), "took {:?}", start.elapsed());
// Nothing else could listen there meanwhile.
assert!(std::net::TcpListener::bind(format!("127.0.0.1:{port}")).is_err());
drop(held);
}
fn reusable_listener(addr: std::net::SocketAddr) -> std::net::TcpListener {
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap();
socket.set_reuse_address(true).unwrap();
socket.bind(&addr.into()).unwrap();
socket.listen(1).unwrap();
socket.into()
}
fn wait_for_remote_eof(remote: &Remote) {
let probe = remote.connection_probe();
probe.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
assert_eq!(probe.peek(&mut [0]).unwrap(), 0);
}
#[test]
fn remote_edit_succeeds_once_when_app_restarts() {
let listener = reusable_listener(std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
let addr = listener.local_addr().unwrap();
let mut remote = Remote::connect(&addr.to_string()).unwrap();
let (old_connection, _) = listener.accept().unwrap();
let old_handle = std::thread::spawn(move || {
let timeout = std::time::Duration::from_secs(2);
old_connection.set_read_timeout(Some(timeout)).unwrap();
old_connection.set_write_timeout(Some(timeout)).unwrap();
let mut reader = BufReader::new(old_connection.try_clone().unwrap());
let mut writer = old_connection;
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let message: Value = serde_json::from_str(&line).unwrap();
assert_eq!(message["method"], "document.inspect");
let result = Headless::with_document().call("document.inspect", message["params"].clone()).unwrap();
writeln!(writer, "{}", json!({"id": message["id"], "ok": true, "result": result})).unwrap();
writer
});
let inspected = remote.call("document.inspect", json!({})).unwrap();
assert!(inspected["objects"].as_u64().unwrap() > 0);
drop(old_handle.join().unwrap());
drop(listener);
wait_for_remote_eof(&remote);
let listener = reusable_listener(addr);
listener.set_nonblocking(true).unwrap();
let handle = std::thread::spawn(move || {
let timeout = std::time::Duration::from_secs(2);
let deadline = std::time::Instant::now() + timeout;
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if std::time::Instant::now() >= deadline {
return (0, 0);
}
std::thread::yield_now();
}
Err(error) => panic!("accept failed: {error}"),
}
};
stream.set_nonblocking(false).unwrap();
stream.set_read_timeout(Some(timeout)).unwrap();
stream.set_write_timeout(Some(timeout)).unwrap();
let mut writer = stream.try_clone().unwrap();
let mut backend = Headless::with_document();
let base = backend.call("document.inspect", json!({})).unwrap()["objects"].as_u64().unwrap();
let mut requests = 0;
for line in BufReader::new(stream).lines() {
let message: Value = serde_json::from_str(&line.unwrap()).unwrap();
let result = backend.call(message["method"].as_str().unwrap(), message["params"].clone()).unwrap();
requests += 1;
writeln!(writer, "{}", json!({"id": message["id"], "ok": true, "result": result})).unwrap();
}
let objects = backend.call("document.inspect", json!({})).unwrap()["objects"].as_u64().unwrap();
(requests, objects - base)
});
let edit = remote.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}}));
drop(remote);
let (requests, edits) = handle.join().unwrap();
assert!(edit.is_ok(), "the first edit after restart must succeed: {edit:?}");
assert_eq!((requests, edits), (1, 1), "the edit must be sent and applied exactly once");
}
/// A connection holding a line nobody asked for (an app that says why it closes an idle
/// connection) is not used: the call goes out once, on a fresh connection, and succeeds.
#[test]
fn remote_does_not_send_on_a_connection_with_a_stray_line() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut remote = Remote::connect(&addr).unwrap();
let (mut stale, _) = listener.accept().unwrap();
writeln!(stale, "{}", json!({"ok": false, "error": "idle"})).unwrap();
let probe = remote.connection_probe();
probe.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
assert_eq!(probe.peek(&mut [0]).unwrap(), 1, "the stray line arrived");
drop(probe);
let h = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
let mut out = stream.try_clone().unwrap();
let mut line = String::new();
BufReader::new(stream).read_line(&mut line).unwrap();
let msg: Value = serde_json::from_str(&line).unwrap();
writeln!(out, "{}", json!({"ok": true, "result": 7, "id": msg["id"]})).unwrap();
});
assert_eq!(remote.call("document.inspect", json!({})), Ok(json!(7)));
h.join().unwrap();
// Nothing was written to the stale connection: it ends (or is reset, its line unread) without a byte.
stale.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
let mut rest = String::new();
assert_eq!(BufReader::new(stale).read_line(&mut rest).unwrap_or(0), 0, "{rest}");
}
#[test]
fn remote_call_reports_nothing_sent_when_reconnect_is_refused() {
let listener = reusable_listener(std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
let addr = listener.local_addr().unwrap();
let mut remote = Remote::connect(&addr.to_string()).unwrap();
let (old_connection, _) = listener.accept().unwrap();
drop(old_connection);
drop(listener);
wait_for_remote_eof(&remote);
let held = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap();
held.set_reuse_address(true).unwrap();
held.bind(&addr.into()).unwrap();
let start = std::time::Instant::now();
let error = remote.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 20, "height": 20}})).unwrap_err();
assert!(start.elapsed() < std::time::Duration::from_secs(2));
assert!(error.contains("not reachable"), "{error}");
assert!(error.contains("nothing was sent"), "{error}");
assert!(!error.contains("operation may have completed"), "{error}");
}
#[test]
fn headless_backend_methods() {
let mut h = Headless::new();
assert!(h.call("document.inspect", json!({})).is_err(), "no document yet");
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 200}})).unwrap();
let r = h.call("ui.render", json!({"scale": 2})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(600), Some(400)));
let cmds = h.call("engine.commands", json!({})).unwrap();
assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "file.export"));
assert!(h.call("ui.tool.list", json!({})).unwrap().is_array());
assert!(h.call("ui.resize", json!({})).is_err());
}
// ---------- workflow tools ----------
fn id_of(result: &Value) -> u64 {
let v: Value = serde_json::from_str(&text_of(result)).unwrap();
v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}"))
}
fn doc_json(s: &mut Server) -> Value {
let r = call(s, 900, "run_command", json!({"command": "document.json"}));
serde_json::from_str(&text_of(&r)).unwrap()
}
#[test]
fn text_effect_pathfinder_transform_graph_and_wrap_tools() {
let mut s = server();
// Text: point, area and on a path with an effect.
let t = id_of(&call(&mut s, 1, "add_text", json!({"text": "Hello", "x": 50, "y": 60, "size": 24})));
assert!(t > 0);
let area = id_of(&call(&mut s, 2, "add_text", json!({"text": "word ".repeat(80), "x": 40, "y": 100, "width": 300, "height": 200})));
let p = call(&mut s, 3, "draw_path", json!({"d": "M40 400 C150 300 250 500 360 400"}));
let pid = id_of(&p);
let ot = id_of(&call(&mut s, 4, "add_text", json!({"text": "On a wave", "path": pid, "mode": "onPath", "pathEffect": "skew"})));
let d = doc_json(&mut s);
assert!(d.to_string().contains("\"pathEffect\":\"skew\""), "{ot}");
// Effects: catalogue, then a drop shadow on the selection.
let cat = call(&mut s, 5, "apply_effect", json!({}));
assert!(text_of(&cat).contains("stylize.dropShadow"));
let r1 = id_of(&call(&mut s, 6, "draw_shape", json!({"shape": "rectangle", "x": 400, "y": 50, "width": 100, "height": 100})));
let r2 = id_of(&call(&mut s, 7, "draw_shape", json!({"shape": "rectangle", "x": 450, "y": 100, "width": 100, "height": 100})));
let fx = call(&mut s, 8, "apply_effect", json!({"effect": "stylize.dropShadow", "params": {"x": 4}, "ids": [r2]}));
assert!(fx.get("isError").is_none_or(|e| e == false), "{fx}");
// Pathfinder unite over two ids.
let u = call(&mut s, 9, "pathfinder", json!({"operation": "unite", "ids": [r1, r2]}));
assert!(text_of(&u).contains("ids"), "{u}");
// Transform: move + rotate on the selection.
let tr = call(&mut s, 10, "transform", json!({"dx": 10, "rotate": 45}));
assert!(text_of(&tr).contains("rotate"));
let bad = call(&mut s, 11, "transform", json!({}));
assert_eq!(bad["isError"], true);
// Graph.
let g = id_of(&call(&mut s, 12, "create_graph", json!({"type": "pie", "x": 50, "y": 450, "width": 200, "height": 150, "csv": ",A,B\nX,1,3"})));
assert!(doc_json(&mut s).to_string().contains("\"kind\":\"pie\""), "{g}");
// Text wrap: a circle over the area type.
let c = id_of(&call(&mut s, 13, "draw_shape", json!({"shape": "ellipse", "x": 40, "y": 120, "width": 120, "height": 120})));
let w = call(&mut s, 14, "text_wrap", json!({"ids": [c], "offset": 8}));
assert!(w.get("isError").is_none_or(|e| e == false), "{w}");
let d = doc_json(&mut s);
let ds = d.to_string();
// The wrap object carries the options and the area type resolved its shape.
assert!(ds.contains("\"wrap\":{\"invert\":false,\"offset\":8.0}") && ds.contains("\"wrap\":[{\"invert\":false,\"offset\":8.0"), "{area}");
call(&mut s, 15, "text_wrap", json!({"ids": [c], "release": true}));
assert!(!doc_json(&mut s).to_string().contains("\"offset\":8.0"));
}
#[test]
fn oversized_screenshot_and_png_export_are_errors() {
// 16383 pt at the maximum scale (16) is 262 128 px a side: an error result, not a crashed server.
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "file.new", "params": {"width": 16383, "height": 16383}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "screenshot", json!({"scale": 16}));
assert_eq!(r["isError"], true, "{r}");
assert!(text_of(&r).contains("pixels"), "{r}");
let path = tmp("huge.png");
let r = call(&mut s, 3, "export", json!({"path": path.to_str().unwrap(), "scale": 16}));
assert_eq!(r["isError"], true, "{r}");
assert!(!path.exists());
// The session is still alive and a small screenshot works.
let r = call(&mut s, 4, "screenshot", json!({"scale": 0.01}));
assert_eq!(r["isError"], false, "{r}");
}
/// Preferences reach agents' gestures (#394): `prefs.set` Object Selection by Path Only, then a
/// click inside a filled square selects nothing; Command Click to Select Objects Behind, a
/// Cmd-click selects the square underneath.
#[test]
fn selection_preferences_apply_to_pointer_gestures() {
let mut s = server();
let square = |s: &mut Server, id: u64, x: u64| {
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 100, "height": 100, "fill": "#ff0000"}));
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
};
let (back, front) = (square(&mut s, 1, 100), square(&mut s, 2, 150));
let click = |s: &mut Server, id: u64, x: f64, mods: Value| {
let events = json!([{"kind": "down", "x": x, "y": 125}, {"kind": "up", "x": x, "y": 125}]);
let r = call(s, id, "pointer_gesture", json!({"tool": "selection", "events": events, "mods": mods}));
assert_eq!(r["isError"], false, "{r}");
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"].clone()
};
assert_eq!(click(&mut s, 3, 175.0, json!({})), json!([front]));
assert_eq!(click(&mut s, 4, 175.0, json!({"cmd": true})), json!([back]), "Cmd-click selects behind");
let r = call(&mut s, 5, "run_command", json!({"command": "prefs.set", "params": {"key": "objectSelectionByPathOnly", "value": true}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(click(&mut s, 6, 225.0, json!({})), json!([]), "path only: the fill doesn't select");
assert_eq!(click(&mut s, 7, 250.0, json!({})), json!([front]), "the path does");
}
/// A Shift-drag marquee reaches agents (#483): `pointer_gesture` with `mods.shift` toggles the
/// objects it reaches, so a selected one leaves the selection and an unselected one joins it.
#[test]
fn shift_marquee_gesture_toggles_the_selection() {
let mut s = server();
let square = |s: &mut Server, id: u64, x: u64| {
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 50, "height": 50}));
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
};
let (a, b, c) = (square(&mut s, 1, 100), square(&mut s, 2, 200), square(&mut s, 3, 300));
let r = call(&mut s, 4, "run_command", json!({"command": "select.set", "params": {"ids": [a, b]}}));
assert_eq!(r["isError"], false, "{r}");
let events = json!([{"kind": "down", "x": 180, "y": 80}, {"kind": "drag", "x": 300, "y": 200}, {"kind": "up", "x": 380, "y": 200}]);
for tool in ["selection", "directSelection"] {
let r = call(&mut s, 5, "pointer_gesture", json!({"tool": tool, "events": events, "mods": {"shift": true}}));
assert_eq!(r["isError"], false, "{r}");
let sel = &serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"];
// The Selection tool takes b out and adds c; Direct Selection's marquee then toggles their
// anchors back: b's are selected again and c, whole, leaves.
let want = if tool == "selection" { json!([a, c]) } else { json!([a, b]) };
assert_eq!(*sel, want, "{tool}");
}
}
/// A click on one of two selected objects makes it the key object: `pointer_gesture` replies
/// with it as `key` and `inspect_document` lists it, and a click on the key again lets it go.
#[test]
fn pointer_gestures_report_the_key_object() {
let mut s = server();
let square = |s: &mut Server, id: u64, x: u64| {
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 50, "height": 50, "fill": "#ff0000"}));
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
};
let (a, b) = (square(&mut s, 1, 100), square(&mut s, 2, 200));
let r = call(&mut s, 3, "run_command", json!({"command": "select.set", "params": {"ids": [a, b]}}));
assert_eq!(r["isError"], false, "{r}");
let click = |s: &mut Server, id: u64| {
let events = json!([{"kind": "down", "x": 225, "y": 125}, {"kind": "up", "x": 225, "y": 125}]);
let r = call(s, id, "pointer_gesture", json!({"tool": "selection", "events": events}));
assert_eq!(r["isError"], false, "{r}");
serde_json::from_str::<Value>(&text_of(&r)).unwrap().get("key").cloned()
};
assert_eq!(click(&mut s, 4), Some(json!(b)), "a click on a selected object makes it the key");
let doc: Value = serde_json::from_str(&text_of(&call(&mut s, 5, "inspect_document", json!({})))).unwrap();
assert_eq!(doc.get("key"), Some(&json!(b)));
assert_eq!(click(&mut s, 6), Some(Value::Null), "a click on the key again lets it go");
}
/// Preferences › Type › Show Font Names in English (#394): agents set it with `prefs.set`;
/// `text.fontList` stays the English (canonical) family names used by `text.setStyle`, not the
/// native UI labels.
#[test]
fn show_font_names_in_english_does_not_change_font_list_for_agents() {
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "prefs.set", "params": {"key": "fontNamesInEnglish", "value": false}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "run_command", json!({"command": "prefs.get", "params": {"key": "fontNamesInEnglish"}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap(), json!(false));
let r = call(&mut s, 3, "run_command", json!({"command": "text.fontList", "params": {}}));
assert_eq!(r["isError"], false, "{r}");
let families = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["families"].as_array().unwrap().clone();
// Canonical English name for commands, whatever the UI labels show with the preference off.
assert!(families.iter().any(|f| f.as_str() == Some("Source Sans 3")), "{families:?}");
}
/// Type preferences reach agents (#394): type the Type tool places starts with placeholder text,
/// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading.
#[test]
fn type_preferences_apply_to_agents() {
let mut s = server();
let events = json!([{"kind": "down", "x": 100, "y": 100}, {"kind": "up", "x": 100, "y": 100}]);
let r = call(&mut s, 1, "pointer_gesture", json!({"tool": "type", "events": events}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].as_u64().unwrap();
let style = |s: &mut Server, n: u64| {
let r = call(s, n, "run_command", json!({"command": "text.getRange", "params": {"id": id}}));
let v = serde_json::from_str::<Value>(&text_of(&r)).unwrap();
assert!(v["text"].as_str().unwrap().len() > 10, "placeholder text: {v}");
let st = &v["runs"][0]["style"];
(st["size"].as_f64().unwrap(), st["tracking"].as_f64().unwrap())
};
let (size, tracking) = style(&mut s, 2);
let r = call(&mut s, 3, "run_command", json!({"command": "prefs.set", "params": {"values": {"typeSizeIncrement": 4, "trackingIncrement": 50}}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 4, "press_key", json!({"key": "Right", "mods": {"alt": true}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 5, "press_key", json!({"key": ".", "mods": {"cmd": true, "shift": true}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(style(&mut s, 6), (size + 4.0, tracking + 50.0));
}
/// The drawing tools snap to Smart Guides over MCP as with the mouse (#506): a Rectangle drawn
/// from near another object's corner starts on it, and a Pen anchor placed beside that object's
/// centre lines up with it.
#[test]
fn drawing_gestures_snap_to_smart_guides() {
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "file.new", "params": {"width": 800, "height": 600}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 100, "height": 100}));
assert_eq!(r["isError"], false, "{r}");
let bounds = |s: &mut Server, n: u64, events: Value, tool: &str| {
let r = call(s, n, "pointer_gesture", json!({"tool": tool, "events": events}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].clone();
let doc: Value = serde_json::from_str(&text_of(&call(s, n + 1, "inspect_document", json!({})))).unwrap();
let made = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).expect("drawn").clone();
let b = &made["bounds"];
[&b["x"], &b["y"], &b["width"], &b["height"]].map(|v| v.as_f64().unwrap())
};
let events = json!([{"kind": "move", "x": 202, "y": 203}, {"kind": "down", "x": 202, "y": 203}, {"kind": "drag", "x": 330, "y": 341}, {"kind": "up", "x": 330, "y": 341}]);
assert_eq!(bounds(&mut s, 3, events, "rectangle"), [200.0, 200.0, 130.0, 141.0]);
let r = call(&mut s, 5, "run_command", json!({"command": "select.none", "params": {}}));
assert_eq!(r["isError"], false, "{r}");
let events = json!([{"kind": "down", "x": 300, "y": 420}, {"kind": "up", "x": 300, "y": 420}, {"kind": "move", "x": 151.5, "y": 431}, {"kind": "down", "x": 151.5, "y": 431}, {"kind": "up", "x": 151.5, "y": 431}]);
assert_eq!(bounds(&mut s, 6, events, "pen"), [150.0, 420.0, 150.0, 11.0]);
}
/// #1471: add_text says when the text doesn't fit its frame, and takes area type's `fit`.
#[test]
fn add_text_reports_overflow_and_takes_a_fit() {
let mut s = server();
let made = |r: &Value| serde_json::from_str::<Value>(&text_of(r)).unwrap();
let headline = json!({"text": "NEON DRIVE", "x": 0, "y": 20, "width": 400, "height": 60, "size": 60});
let low = made(&call(&mut s, 1, "add_text", headline.clone()));
assert_eq!(low["overflow"], true, "{low}");
let mut auto = headline;
auto["fit"] = json!("autoHeight");
let auto = made(&call(&mut s, 2, "add_text", auto));
assert!(auto["id"].is_u64() && auto.get("overflow").is_none(), "{auto}");
let point = made(&call(&mut s, 3, "add_text", json!({"text": "NEON DRIVE", "x": 0, "y": 300, "size": 60})));
assert!(point.get("overflow").is_none(), "{point}");
}