//! An agent builds parts through MCP only and verifies them through MCP only. use std::f64::consts::PI; use serde_json::{Value, json}; use crate::{Headless, PROTOCOL_VERSION, Server, tool_definitions}; fn server() -> Server { Server::new(Box::new(Headless::default())) } /// Screenshots are drawn on the GPU by the renderer the host installs (`solidcraft-cli mcp`); the /// tests install the same one. `false`, with the reason printed, on a machine without a GPU /// adapter: the screenshots are then left out of the test. fn renders() -> bool { match solidcraft_gpu::capture::shared() { Ok(c) => { solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| { c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string()) })); true } Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => { eprintln!("screenshots left out: {e}"); false } Err(e) => panic!("{e}"), } } fn call(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 r = s.handle_line(&line).expect("reply"); serde_json::from_str(&r).unwrap() } fn raw_tool(s: &mut Server, name: &str, args: Value) -> Value { call(s, 99, "tools/call", json!({"name": name, "arguments": args})) } /// Call a tool that must succeed; returns its JSON text (or the first content block). fn tool(s: &mut Server, name: &str, args: Value) -> Value { let r = raw_tool(s, name, args); let c = &r["result"]["content"][0]; assert_ne!(r["result"]["isError"], json!(true), "{name} failed: {c}"); assert!(r.get("error").is_none(), "{name}: {r}"); if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() } } #[test] fn crash_reports_are_listed_and_read_without_changing_the_design() { let mut s = server(); let list = tool(&mut s, "crash_reports", json!({"limit": 3})); assert!(list["reports"].as_array().is_some_and(|r| r.len() <= 3), "{list}"); assert!(list["dir"].is_string(), "{list}"); let r = raw_tool(&mut s, "crash_reports", json!({"id": "../../etc"})); assert_eq!(r["result"]["isError"], json!(true), "{r}"); let r = raw_tool(&mut s, "crash_reports", json!({"id": 5})); assert!(r.get("error").is_some() || r["result"]["isError"] == json!(true), "{r}"); } fn volume(s: &mut Server) -> f64 { tool(s, "measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap() } fn rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-12) } #[test] fn handshake_and_tools_list() { let mut s = server(); let init = call(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}})); assert_eq!(init["result"]["protocolVersion"], "2025-03-26"); assert_eq!(init["result"]["serverInfo"]["name"], "solidcraft"); assert!(init["result"]["capabilities"]["tools"].is_object()); assert!(init["result"]["instructions"].as_str().unwrap().contains("headless")); // An unknown version gets ours. let init = call(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"})); assert_eq!(init["result"]["protocolVersion"], PROTOCOL_VERSION); assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none()); assert!(s.is_initialized()); assert_eq!(call(&mut s, 3, "ping", json!({}))["result"], json!({})); let tools = call(&mut s, 4, "tools/list", json!({})); let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); for want in [ "list_commands", "execute", "batch", "inspect_design", "measure", "body_topology", "set_parameter", "screenshot", "export", "undo", "redo", "crash_reports", "new_design", "open", "save", ] { assert!(names.contains(&want), "{want} missing from {names:?}"); } for t in tools["result"]["tools"].as_array().unwrap() { assert_eq!(t["inputSchema"]["type"], "object", "{t}"); assert!(t["description"].as_str().unwrap().len() > 20, "{t}"); } let cmds = tool(&mut s, "list_commands", json!({"filter": "fillet"})); assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "solid.fillet"), "{cmds}"); assert!(cmds.as_array().unwrap().len() < 5); let res = call(&mut s, 5, "resources/list", json!({})); assert_eq!(res["result"]["resources"].as_array().unwrap().len(), 2); let d = call(&mut s, 6, "resources/read", json!({"uri": "solidcraft://design"})); assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline")); assert_eq!(call(&mut s, 7, "resources/read", json!({"uri": "solidcraft://nope"}))["error"]["code"], -32002); assert_eq!(call(&mut s, 8, "nope/nope", json!({}))["error"]["code"], -32601); } /// A URDF goes through `export` with its own options (#719). #[test] fn export_tool_writes_a_urdf_package() { let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}})); let dir = std::env::temp_dir().join(format!("solidcraft-mcp-urdf-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let urdf = dir.join("robot.urdf"); let r = tool(&mut s, "export", json!({"path": urdf.to_string_lossy(), "mesh_prefix": "package://robot/meshes/"})); assert_eq!(r["format"], "Urdf", "{r}"); assert!(std::fs::read_to_string(&urdf).unwrap().contains(r#"filename="package://robot/meshes/robot-base.stl""#)); assert!(dir.join("robot-base.stl").exists()); // effort and velocity reach file.export, which refuses a bad one. let r = raw_tool(&mut s, "export", json!({"path": urdf.to_string_lossy(), "effort": -1})); assert_eq!(r["result"]["isError"], json!(true), "{r}"); let _ = std::fs::remove_dir_all(&dir); } /// Box → fillet → hole in one batch, then verify with measure / inspect_design and look at it. #[test] fn agent_builds_box_fillet_hole() { let mut s = server(); let r = tool( &mut s, "batch", json!({"commands": [ {"command": "sketch.create", "params": {"plane": "XY"}}, {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}}, {"command": "sketch.finish"}, {"command": "solid.extrude", "params": {"distance": 20}}, {"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}}, {"command": "sketch.create", "params": {"plane": "XY"}}, {"command": "sketch.circle.center", "params": {"center": [20, 15], "radius": 5}}, {"command": "solid.extrude", "params": {"distance": 20, "operation": "cut"}}, ]}), ); assert_eq!(r["completed"], 8, "{r}"); let expect = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0 - PI * 25.0 * 20.0; let v = volume(&mut s); assert!(rel(v, expect) < 5e-4, "{v} vs {expect}"); let m = tool(&mut s, "measure", json!({"body": "Body1"})); // 6 box faces + fillet + hole wall (the kernel may keep the wall as two halves). assert!((8..=9).contains(&m["bodies"][0]["faces"].as_u64().unwrap()), "{m}"); assert!(rel(m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap(), 40.0) < 1e-6, "{m}"); let d = tool(&mut s, "inspect_design", json!({})); assert_eq!(d["timeline"].as_array().unwrap().len(), 5, "{d}"); assert!(d["timeline"].as_array().unwrap().iter().all(|f| f["error"].is_null()), "{d}"); assert_eq!(d["sketches"][0]["curves"].as_array().unwrap().len(), 4, "{d}"); assert!(d["sketches"][0]["status"].is_string(), "{d}"); assert!(d["bodies"][0]["volume_mm3"].as_f64().unwrap() > 0.0); let topo = tool(&mut s, "body_topology", json!({"body": "Body1"})); assert!(topo["edges"].as_array().unwrap().len() >= 12, "{topo}"); assert!(topo["faces"].as_array().unwrap().len() >= 8, "{topo}"); // Undo the hole, redo it. tool(&mut s, "undo", json!({})); assert!(rel(volume(&mut s), 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0) < 5e-4); tool(&mut s, "redo", json!({})); assert!(rel(volume(&mut s), expect) < 5e-4); // Look at it from the top: a real PNG comes back as an image block. if renders() { let img = tool(&mut s, "screenshot", json!({"view": "top", "width": 320, "height": 200})); assert_eq!(img["type"], "image"); assert_eq!(img["mimeType"], "image/png"); assert!(img["data"].as_str().unwrap().starts_with("iVBORw0KGgo"), "not a PNG"); } // Export, save, start over, open. let dir = std::env::temp_dir().join(format!("solidcraft-mcp-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let step = dir.join("part.step"); tool(&mut s, "export", json!({"path": step.to_string_lossy()})); assert!(std::fs::read_to_string(&step).unwrap().contains("ISO-10303-21")); let stl = dir.join("part.stl"); tool(&mut s, "export", json!({"path": stl.to_string_lossy(), "format": "stl"})); assert!(std::fs::metadata(&stl).unwrap().len() > 1000); let shot = dir.join("shot.png"); if renders() { tool(&mut s, "screenshot", json!({"path": shot.to_string_lossy(), "width": 64, "height": 64})); assert!(std::fs::metadata(&shot).unwrap().len() > 100); } let design = dir.join("part.solidcraft"); tool(&mut s, "save", json!({"path": design.to_string_lossy()})); tool(&mut s, "new_design", json!({"name": "Empty"})); assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 0); tool(&mut s, "open", json!({"path": design.to_string_lossy()})); assert!(rel(volume(&mut s), expect) < 5e-4); // A STEP file opens as a new design with the same body. let r = tool(&mut s, "open", json!({"path": step.to_string_lossy()})); assert_eq!(r["features"], 1, "{r}"); assert!(rel(volume(&mut s), expect) < 5e-4); let _ = std::fs::remove_dir_all(&dir); } /// `open` hands `up` and `names` to the STEP import, and the tool's schema refuses other values. #[test] fn open_stands_a_y_up_step_file_up() { let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 40, "height": 5}})); let dir = std::env::temp_dir().join(format!("solidcraft-mcp-up-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let step = dir.join("tall.step").to_string_lossy().to_string(); tool(&mut s, "export", json!({"path": step})); let height = |s: &mut Server| { let bbox = tool(s, "measure", json!({}))["bodies"][0]["bbox"].clone(); bbox["max"][2].as_f64().unwrap() - bbox["min"][2].as_f64().unwrap() }; let r = tool(&mut s, "open", json!({"path": step})); assert_eq!(r["features"], 1, "{r}"); assert!((height(&mut s) - 5.0).abs() < 1e-6, "as written"); let r = tool(&mut s, "open", json!({"path": step, "up": "y", "names": "product"})); assert_eq!(r["features"], 1, "the import, its component turned: {r}"); assert!((height(&mut s) - 40.0).abs() < 1e-6, "Y became Z"); for args in [json!({"path": step, "up": "x"}), json!({"path": step, "names": "label"}), json!({"path": step, "up": 1})] { let r = raw_tool(&mut s, "open", args.clone()); assert_eq!(r["error"]["code"], -32602, "{args}: {r}"); } let _ = std::fs::remove_dir_all(&dir); } #[test] fn save_selects_container_or_json_and_reopens_by_contents() { let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 20, "height": 30}})); let dir = std::env::temp_dir().join(format!("solidcraft-mcp-encodings-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); for (name, format, is_json) in [ ("default.solidcraft", None, false), ("explicit.solidcraft", Some("json"), true), ("legacy.solvecraft", None, false), ("inferred.json", None, true), ("container.json", Some("container"), false), ] { let path = dir.join(name); let mut args = json!({"path": path.to_string_lossy()}); if let Some(format) = format { args["format"] = json!(format); } tool(&mut s, "save", args); let bytes = std::fs::read(&path).unwrap(); if is_json { assert!(serde_json::from_slice::(&bytes).unwrap().is_object()); } else { assert!(bytes.starts_with(b"PK\x03\x04")); } tool(&mut s, "new_design", json!({})); tool(&mut s, "open", json!({"path": path.to_string_lossy()})); assert!(rel(volume(&mut s), 6000.0) < 1e-9); } std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn parameters_drive_the_design() { let mut s = server(); tool(&mut s, "set_parameter", json!({"name": "len", "value": "40 mm"})); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": "len", "width": 30, "height": "len / 2", "corner": [0, 0, 0]}})); assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9); let r = tool(&mut s, "set_parameter", json!({"name": "len", "value": 50})); assert_eq!(r["value"], 50.0, "{r}"); assert_eq!(r["errors"].as_array().unwrap().len(), 0, "{r}"); assert!(rel(volume(&mut s), 50.0 * 30.0 * 25.0) < 1e-9); let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); assert!(d["params"].as_array().unwrap().iter().any(|p| p["name"] == "len" && p["value"] == 50.0), "{d}"); } /// Design properties (#1015) are set through `execute` and come back in `inspect_design`. #[test] fn design_properties_show_in_inspect_design() { let mut s = server(); let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); assert_eq!(d["metadata"], json!({}), "{d}"); let params = json!({"values": {"author": "Agent", "com.example.agent": {"session": 7}}}); tool(&mut s, "execute", json!({"command": "document.metadata.set", "params": params})); let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); assert_eq!(d["metadata"], json!({"author": "Agent", "com.example.agent": {"session": 7}}), "{d}"); } #[test] fn batch_stops_at_first_error_and_rolls_back() { let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}})); let r = raw_tool( &mut s, "batch", json!({"commands": [ {"command": "solid.sphere", "params": {"radius": 3, "center": [50, 0, 0]}}, {"command": "solid.extrude", "params": {"distance": "bogus +"}}, {"command": "solid.sphere", "params": {"radius": 3, "center": [80, 0, 0]}}, ]}), ); assert_eq!(r["result"]["isError"], true, "{r}"); let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(body["failed"]["index"], 1, "{body}"); assert_eq!(body["failed"]["command"], "solid.extrude"); assert_eq!(body["undone"], 1, "{body}"); assert_eq!(body["results"].as_array().unwrap().len(), 1); // Only the first box is left, and its undo step is intact. assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 1); assert_eq!(tool(&mut s, "inspect_design", json!({"measure": false}))["undo"], 1); // Without rollback the completed steps stay. let r = raw_tool( &mut s, "batch", json!({"rollback": false, "commands": [{"command": "solid.sphere", "params": {"radius": 3, "center": [50, 0, 0]}}, {"command": "NoSuchCommand"}]}), ); assert_eq!(r["result"]["isError"], true); assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("NoSuchCommand")); assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2); } /// Regression: with a full undo history a failed batch reported `undone: 0` and left its /// sketches behind (an empty "Sketch1", two sketches named "TapHole"). #[test] fn batch_rolls_back_with_a_full_undo_history() { let mut s = server(); let fill: Vec = (0..230).map(|i| json!({"command": "parameters.add", "params": {"name": format!("p{i}"), "expression": "1 mm"}})).collect(); tool(&mut s, "batch", json!({"commands": fill})); let undo = tool(&mut s, "inspect_design", json!({"measure": false}))["undo"].clone(); for _ in 0..2 { let r = raw_tool( &mut s, "batch", json!({"commands": [ {"command": "sketch.create", "params": {"plane": "XY", "name": "TapHole"}}, {"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}}, {"command": "sketch.dimension", "params": {"entities": ["nope"], "value": 3}}, ]}), ); let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(body["failed"]["index"], 2, "{body}"); assert_eq!(body["undone"], 2, "{body}"); } let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); assert_eq!(d["sketches"].as_array().map(Vec::len), Some(0), "{d}"); assert!(d["active_sketch"].is_null(), "{d}"); assert_eq!(d["undo"], undo); } #[test] fn helper_sketches_can_be_hidden() { let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 40, "height": 2}})); let sk = tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}))["sketch"].clone(); tool(&mut s, "execute", json!({"command": "sketch.line", "params": {"points": [[0, 0], [40, 0]], "construction": true}})); tool(&mut s, "execute", json!({"command": "sketch.finish"})); let visible = |s: &mut Server| tool(s, "inspect_design", json!({"measure": false}))["sketches"][0]["visible"].clone(); assert_eq!(visible(&mut s), true); tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": [sk], "visible": false}})); assert_eq!(visible(&mut s), false); let r = raw_tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Nope"], "visible": false}})); assert_eq!(r["result"]["isError"], true); tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "sketches", "visible": true}})); assert_eq!(visible(&mut s), true); } #[test] fn unknown_tools_and_bad_arguments_are_clean_errors() { let mut s = server(); let r = raw_tool(&mut s, "make_coffee", json!({})); assert_eq!(r["error"]["code"], -32602, "{r}"); assert!(r["error"]["message"].as_str().unwrap().contains("make_coffee")); assert_eq!(raw_tool(&mut s, "execute", json!({}))["error"]["code"], -32602); assert_eq!(raw_tool(&mut s, "execute", json!({"command": 5}))["error"]["code"], -32602); assert_eq!(raw_tool(&mut s, "measure", json!({"bogus": 1}))["error"]["code"], -32602); assert_eq!(raw_tool(&mut s, "screenshot", json!({"source": "sideways"}))["error"]["code"], -32602); // `view` also takes saved named views, so an unknown name is a tool error naming it. let r = raw_tool(&mut s, "screenshot", json!({"view": "sideways"})); assert_eq!(r["result"]["isError"], true, "{r}"); assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("sideways"), "{r}"); assert_eq!(call(&mut s, 1, "tools/call", json!({"arguments": {}}))["error"]["code"], -32602); // Engine errors are tool errors the model can read. let r = raw_tool(&mut s, "execute", json!({"command": "solid.extrude", "params": {"distance": 10}})); assert_eq!(r["result"]["isError"], true, "{r}"); let r = raw_tool(&mut s, "execute", json!({"command": "NoSuchCommand"})); assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("unknown command")); // Framing errors. assert!(s.handle_line("{not json").unwrap().contains("-32700")); assert!(s.handle_line("[]").unwrap().contains("-32600")); assert!(s.handle_line("42").unwrap().contains("-32600")); assert!(s.handle_line(" ").is_none()); assert!(s.handle_line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#).is_none()); let both = s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/x"}]"#).unwrap(); assert_eq!(serde_json::from_str::(&both).unwrap().as_array().unwrap().len(), 1); } fn hostile_values() -> Vec { vec![ Value::Null, json!(true), json!(-1), json!(0), json!(1e308), json!(-1e308), json!(""), json!("nope"), json!("../../../../../../nonexistent/dir/x.step"), json!("1/0"), json!([]), json!([1e308, -1e308]), json!([{"command": "solid.extrude", "params": {"distance": 1e308}}]), json!([{"command": 7}, null, "x"]), json!({}), json!({"x": 1}), ] } /// Every tool, every argument it documents, every hostile value: replies are well-formed /// JSON-RPC and nothing panics. #[test] fn hostile_arguments_never_panic() { let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}})); let defs = tool_definitions(); let mut n = 0; for def in &defs { let name = def["name"].as_str().unwrap(); // Hostile strings are relative paths: don't let the sweep write files into the crate. let writes = matches!(name, "save" | "screenshot" | "export"); let keys: Vec = def["inputSchema"]["properties"].as_object().unwrap().keys().filter(|k| !(writes && *k == "path")).cloned().collect(); for k in &keys { for v in hostile_values() { let mut args = json!({}); // Fill the other required arguments with something plausible. for r in def["inputSchema"]["required"].as_array().into_iter().flatten() { args[r.as_str().unwrap()] = json!("Body1"); } args[k.as_str()] = v; if name == "screenshot" && (k == "width" || k == "height") { args["view"] = json!("front"); } let r = raw_tool(&mut s, name, args.clone()); assert!(r.get("result").is_some() || r["error"]["code"].is_i64(), "{name} {args}: {r}"); n += 1; } } } assert!(n > 100); // Execute every command with hostile params through MCP too. let cmds = tool(&mut s, "list_commands", json!({})); for c in cmds.as_array().unwrap() { let id = c["id"].as_str().unwrap(); if matches!(id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh") { continue; } for v in hostile_values() { let r = raw_tool(&mut s, "execute", json!({"command": id, "params": {"distance": v.clone(), "edges": v.clone(), "plane": v}})); assert!(r.get("result").is_some(), "{id}: {r}"); } } // Arguments that are not an object. for v in hostile_values().into_iter().filter(|v| !v.is_object() && !v.is_null()) { let r = call(&mut s, 1, "tools/call", json!({"name": "measure", "arguments": v})); assert_eq!(r["error"]["code"], -32602, "{r}"); } } #[test] fn base64_matches_rfc4648() { use crate::tools::base64; for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] { assert_eq!(base64(i.as_bytes()), o); } } /// Filtering by a command id returns that command, not every command whose docs mention it (#269). #[test] fn list_commands_filter_by_a_command_id_matches_ids() { let mut s = server(); let cmds = tool(&mut s, "list_commands", json!({"filter": "sketch.delete"})); let ids: Vec<&str> = cmds.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); assert!(ids.contains(&"sketch.delete"), "{ids:?}"); assert!(ids.iter().all(|id| id.contains("sketch.delete")), "{} commands matched: {ids:?}", ids.len()); } /// Compatibility shims (docs/compatibility.md): a design saved before the rename (legacy /// extension and format tag) opens through MCP, and the legacy resource URIs still read. #[test] fn legacy_design_files_and_resource_uris() { let mut s = server(); let saved = String::from_utf8(solidcraft_engine::io::write_design(&solidcraft_engine::doc::Document::default())).unwrap(); assert!(saved.contains("\"solidcraft/"), "{saved}"); let legacy = saved.replace("\"solidcraft/", "\"solvecraft/"); let path = std::env::temp_dir().join(format!("solidcraft-mcp-legacy-{}.solvecraft", std::process::id())); std::fs::write(&path, legacy).unwrap(); tool(&mut s, "open", json!({"path": path.to_string_lossy()})); let _ = std::fs::remove_file(&path); let d = call(&mut s, 1, "resources/read", json!({"uri": "solvecraft://design"})); assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline")); } /// The default folders (#205): the recovery folder works out of the box, `screenshot` without a /// `path` uses the server's own scratch folder, and the open design's folder counts while the /// design is open there, without its hidden files. The working folder doesn't count at all. #[test] fn the_default_folders_are_the_designs_the_recovery_folder_and_the_scratch_folder() { let base = solidcraft_engine::io::vfs::temp_dir().join(format!("solidcraft-mcp-defaults-{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); let (proj, other, recovery) = (base.join("proj"), base.join("other"), base.join("recovery")); std::fs::create_dir_all(&proj).unwrap(); std::fs::create_dir_all(&other).unwrap(); std::fs::write(proj.join(".env"), "TOKEN=secret").unwrap(); let at = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().to_string(); let scope = crate::agent_scope_with(Some(recovery.clone()), []); let cwd = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap(); assert!(!scope.roots().contains(&cwd), "the working folder is not a default folder: {scope:?}"); assert!(scope.roots().contains(&std::fs::canonicalize(&recovery).unwrap()), "{scope:?}"); assert!(scope.roots().contains(&std::fs::canonicalize(crate::scratch_dir().unwrap()).unwrap()), "{scope:?}"); let mut session = solidcraft_engine::Session::default(); session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap(); session.execute("file.save_as", &json!({"path": at(&proj, "part.solidcraft")})).unwrap(); let mut s = Server::new(Box::new(Headless::new(session))).with_path_scope(Some(scope)); // Legitimate work in the design's folder: export, save, open, screenshots, the recovery folder. tool(&mut s, "export", json!({"path": at(&proj, "part.step")})); tool(&mut s, "execute", json!({"command": "parameters.export", "params": {"path": at(&proj, "params.csv")}})); tool(&mut s, "save", json!({"path": at(&proj, "copy.solidcraft")})); tool(&mut s, "open", json!({"path": at(&proj, "part.solidcraft")})); if renders() { tool(&mut s, "screenshot", json!({"width": 32, "height": 32})); } tool(&mut s, "execute", json!({"command": "doc.recovery_list", "params": {"dir": recovery.to_string_lossy()}})); assert!(proj.join("part.step").exists() && proj.join("copy.solidcraft").exists()); // Hidden files in it, and folders beside it, stay out of reach. for (name, args) in [ ("export", json!({"path": at(&proj, ".env")})), ("execute", json!({"command": "parameters.import", "params": {"path": at(&proj, ".env")}})), ("export", json!({"path": at(&proj, ".git/x.step")})), ("export", json!({"path": at(&other, "x.step")})), ] { let r = raw_tool(&mut s, name, args); assert_eq!(r["result"]["isError"], json!(true), "{name}: {r}"); } assert_eq!(std::fs::read_to_string(proj.join(".env")).unwrap(), "TOKEN=secret"); assert_eq!(std::fs::read_dir(&other).unwrap().count(), 0); let _ = std::fs::remove_dir_all(&base); } /// Tool calls reach files only inside the server's folders (#205): the usual workflow (save, /// export, open, screenshots) works there; a path outside, directly, through `..` or through a /// symlink that leads out, is refused with a clear error and nothing is written. #[test] fn tool_calls_stay_inside_the_agents_folders() { use solidcraft_engine::io::vfs::PathScope; let base = solidcraft_engine::io::vfs::temp_dir().join(format!("solidcraft-mcp-scope-{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); let (root, out) = (base.join("root"), base.join("out")); std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir_all(&out).unwrap(); let at = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().to_string(); let defaults = crate::agent_scope_with(None, [root.clone()]); assert!(defaults.roots().contains(&std::fs::canonicalize(&root).unwrap()), "{defaults:?}"); assert!(!defaults.roots().contains(&std::fs::canonicalize(std::env::temp_dir()).unwrap()), "{defaults:?}"); // Only `root` here, to tell inside from outside within the temporary folder. let mut s = Server::new(Box::new(Headless::default())).with_path_scope(Some(PathScope::new([root.clone()]))); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}})); tool(&mut s, "save", json!({"path": at(&root, "part.solidcraft")})); tool(&mut s, "export", json!({"path": at(&root, "part.step")})); tool(&mut s, "open", json!({"path": at(&root, "part.solidcraft")})); if renders() { tool(&mut s, "screenshot", json!({"path": at(&root, "shot.png"), "width": 64, "height": 64})); } let escapes = vec![at(&out, "x.step"), at(&root, "../out/x.step")]; #[cfg(unix)] let mut escapes = escapes; #[cfg(unix)] { std::os::unix::fs::symlink(&out, root.join("link")).unwrap(); escapes.push(at(&root, "link/x.step")); } for p in escapes { for (name, args) in [ ("export", json!({"path": p})), ("save", json!({"path": p})), ("open", json!({"path": p})), ("screenshot", json!({"path": p, "width": 64, "height": 64})), ("execute", json!({"command": "file.save_as", "params": {"path": p}})), ("batch", json!({"commands": [{"command": "file.export", "params": {"path": p}}]})), ] { let r = raw_tool(&mut s, name, args); assert_eq!(r["result"]["isError"], json!(true), "{name} {p}: {r}"); assert!(r.to_string().contains("outside the folders this agent may use"), "{name} {p}: {r}"); } } assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written outside"); let _ = std::fs::remove_dir_all(&base); } /// The short (8.3) spelling of an existing `path` as `cmd` gives it (`NAME~1.XYZ`), or `None` /// when it has no short name (the volume makes none). #[cfg(windows)] fn short_spelling(path: &std::path::Path) -> Option { use std::os::windows::process::CommandExt; let out = std::process::Command::new("cmd").raw_arg(format!("/d /c for %I in (\"{}\") do @echo %~sI", path.display())).output().ok()?; Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) .filter(|s| solidcraft_engine::io::vfs::windows_refusal(s).is_some_and(|why| why.contains("8.3"))) .map(Into::into) } /// On Windows the temporary folder's path often has a short (8.3) name (the user folder's /// `NAME~1.XYZ` when its name is long). The server's own folders are spelled with long names /// there, so `screenshot` without a `path` (the scratch folder) and `doc.recovery_list` (the /// recovery folder) work; on such a machine both were refused as short names. Run in a child /// process whose `TMP`, `TEMP` and `SOLIDCRAFT_RECOVERY_DIR` are short spellings. #[cfg(windows)] #[test] fn the_servers_own_folders_work_when_the_temporary_folder_has_a_short_name() { let base = solidcraft_engine::io::vfs::temp_dir().join(format!("solidcraft-mcp-short-names-{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); let (temp, recovery) = (base.join("temporary"), base.join("recovery folder")); std::fs::create_dir_all(&temp).unwrap(); std::fs::create_dir_all(&recovery).unwrap(); let (Some(short_temp), Some(short_recovery)) = (short_spelling(&temp), short_spelling(&recovery)) else { eprintln!("skipped: {} has no short (8.3) name on this volume", base.display()); let _ = std::fs::remove_dir_all(&base); return; }; let out = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "tests::short_names_child", "--test-threads=1", "--nocapture"]) .env("TMP", &short_temp) .env("TEMP", &short_temp) .env("SOLIDCRAFT_RECOVERY_DIR", &short_recovery) .env("SOLIDCRAFT_MCP_SHORT_NAMES_CHILD", "1") .output() .unwrap(); let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); assert!(out.status.success() && text.contains("short names: ok"), "{text}"); let _ = std::fs::remove_dir_all(&base); } /// The child process of the test above; does nothing on its own. #[cfg(windows)] #[test] fn short_names_child() { if std::env::var_os("SOLIDCRAFT_MCP_SHORT_NAMES_CHILD").is_none() { return; } use solidcraft_engine::io::vfs::windows_refusal; let temp = std::env::temp_dir(); assert!(windows_refusal(&temp.to_string_lossy()).is_some_and(|why| why.contains("8.3")), "{}", temp.display()); let mut s = Server::new(Box::new(Headless::default())).with_path_scope(Some(crate::agent_scope([]))); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}})); if renders() { tool(&mut s, "screenshot", json!({"width": 32, "height": 32})); } tool(&mut s, "execute", json!({"command": "doc.recovery_list", "params": {}})); // An agent's own short spelling of the scratch folder stays refused. let scratch = crate::scratch_dir().unwrap(); let short = std::env::temp_dir().join(scratch.file_name().unwrap()).join("shot.png"); let r = raw_tool(&mut s, "screenshot", json!({"path": short.to_string_lossy(), "width": 32, "height": 32})); assert!(r.to_string().contains("short (8.3) names"), "{r}"); println!("short names: ok"); } /// A bridged server sends the agent's folders with every request, so the app confines the /// request's files the same way (`allow_paths`, crates/ui-egui/src/control.rs). #[test] fn a_bridged_request_carries_the_agents_folders() { use std::io::{BufRead, BufReader, Write}; use solidcraft_engine::io::vfs::{PathScope, scoped}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let echo = std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); let mut line = String::new(); BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); let req: Value = serde_json::from_str(&line).unwrap(); let mut result = req["params"].clone(); result["sent_token"] = req["token"].clone(); writeln!(&stream, "{}", json!({"id": req["id"], "ok": true, "result": result})).unwrap(); }); // The app's control token goes with the request too (#205). let mut b = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap(); let dir = std::fs::canonicalize(std::env::temp_dir()).unwrap(); let got = scoped(Some(PathScope::new([dir.clone()])), || crate::Backend::call(&mut b, "engine.execute", json!({"command": "file.new"}))).unwrap(); assert_eq!(got["allow_paths"], json!([dir.to_string_lossy()]), "{got}"); assert_eq!(got["command"], "file.new"); assert_eq!(got["sent_token"], "0123456789abcdef0123456789abcdef"); echo.join().unwrap(); } /// #528: an agent saves the angle of a product photo as a named view once, then renders the /// model from that view after each change. The render must use the saved camera, not a /// standard view. #[test] fn screenshot_renders_a_saved_named_view() { if !renders() { return; } let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10}})); let camera = json!({"target": [20, 10, 5], "yaw": 0.5, "pitch": -0.6, "distance": 150}); tool(&mut s, "execute", json!({"command": "view.save", "params": {"name": "Photo", "camera": camera.clone()}})); let shot = |s: &mut Server, view: &str| { let img = tool(s, "screenshot", json!({"view": view, "width": 96, "height": 64})); img["data"].as_str().unwrap_or_default().to_string() }; let iso = shot(&mut s, "iso"); let photo = shot(&mut s, "Photo"); assert!(photo.starts_with("iVBORw0KGgo"), "not a PNG"); assert_ne!(photo, iso, "the named view rendered like the standard iso view"); // The same camera given directly renders the same picture; a standard view turns back. let direct = tool(&mut s, "screenshot", json!({"camera": camera, "width": 96, "height": 64})); assert_eq!(direct["data"].as_str().unwrap_or_default(), photo); assert_eq!(shot(&mut s, "iso"), iso); } /// The ground grid lies on Z 0 and cuts through a part that straddles it: `grid: false` and /// `axes: false` leave it and the origin's axes out of the render. An axis hidden with /// `browser.visibility` stays out whatever `axes` says. #[test] fn screenshot_can_leave_the_grid_and_the_axes_out() { if !renders() { return; } let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]}})); let shot = |s: &mut Server, extra: Value| { let mut args = json!({"view": "front", "width": 96, "height": 64}); args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); tool(s, "screenshot", args)["data"].as_str().unwrap_or_default().to_string() }; let all = shot(&mut s, json!({})); let no_grid = shot(&mut s, json!({"grid": false})); let no_axes = shot(&mut s, json!({"axes": false})); let bare = shot(&mut s, json!({"grid": false, "axes": false})); assert!(all.starts_with("iVBORw0KGgo"), "not a PNG"); let pictures = [&all, &no_grid, &no_axes, &bare]; for (i, a) in pictures.iter().enumerate() { for b in &pictures[i + 1..] { assert_ne!(a, b, "two of the pictures are the same"); } } // Asking for them is the default. assert_eq!(shot(&mut s, json!({"grid": true, "axes": true})), all); // Hiding the origin leaves the axes out, with or without `axes`; the grid is not the origin. tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "origin", "visible": false}})); assert_eq!(shot(&mut s, json!({})), no_axes); assert_eq!(shot(&mut s, json!({"axes": true})), no_axes); assert_eq!(shot(&mut s, json!({"grid": false})), bare); // Other than booleans are schema errors. for bad in [json!({"grid": "no"}), json!({"axes": 0})] { assert_eq!(raw_tool(&mut s, "screenshot", bad)["error"]["code"], -32602); } } /// Bridged to an app, `grid` and `axes` ask for a model render and travel with it; without them /// the window is captured as before; the window cannot be asked to leave things out. #[test] fn bridged_screenshots_with_grid_or_axes_are_model_renders() { struct App { calls: Vec<(String, Value)>, } impl crate::backend::Backend for App { fn call(&mut self, method: &str, params: Value) -> Result { self.calls.push((method.to_string(), params.clone())); match method { "ui.render" | "ui.screenshot" => { let path = params["path"].as_str().ok_or("missing `path`")?; std::fs::write(path, b"\x89PNG").map_err(|e| e.to_string())?; Ok(json!({"path": path, "width": 64, "height": 48})) } other => Err(format!("unknown method `{other}`")), } } fn has_ui(&self) -> bool { true } fn describe(&self) -> String { "app".into() } } let dir = std::env::temp_dir().join(format!("solidcraft-mcp-guides-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("shot.png").to_string_lossy().to_string(); let shoot = |extra: Value| { let mut args = json!({"path": path, "width": 64, "height": 48}); args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); let mut app = App { calls: Vec::new() }; let r = crate::tools::call_tool(&mut app, "screenshot", &args); (app.calls, r) }; let (calls, r) = shoot(json!({})); assert!(r["isError"] != json!(true), "{r}"); assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::>(), ["ui.screenshot"]); let (calls, r) = shoot(json!({"grid": false, "axes": false})); assert!(r["isError"] != json!(true), "{r}"); assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "grid": false, "axes": false}))]); let (calls, _) = shoot(json!({"axes": true})); assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "axes": true}))]); let (calls, r) = shoot(json!({"source": "model", "grid": false})); assert!(r["isError"] != json!(true), "{r}"); assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "grid": false}))]); // The window shows what it shows: nothing is called, and the error says what to ask for. let (calls, r) = shoot(json!({"source": "window", "grid": false, "view": "top"})); assert_eq!(r["isError"], json!(true), "{r}"); assert!(r["content"][0]["text"].as_str().unwrap().contains("source"), "{r}"); assert!(calls.is_empty(), "{calls:?}"); let _ = std::fs::remove_dir_all(&dir); } /// #1677: a hidden window gives no picture. With no `source`, a model render comes back and says /// it is one (`fallback`); asked for the window, the tool says why there is none, at once. #[test] fn a_hidden_window_screenshot_says_so() { struct Hidden { calls: Vec, } impl crate::backend::Backend for Hidden { fn call(&mut self, method: &str, params: Value) -> Result { self.calls.push(method.to_string()); match method { "ui.screenshot" => Err("no frame was presented (window hidden?); use ui.render".into()), "ui.render" => { let path = params["path"].as_str().ok_or("missing `path`")?; std::fs::write(path, b"\x89PNG").map_err(|e| e.to_string())?; Ok(json!({"path": path, "width": 64, "height": 48})) } other => Err(format!("unknown method `{other}`")), } } fn has_ui(&self) -> bool { true } fn describe(&self) -> String { "app".into() } } let dir = std::env::temp_dir().join(format!("solidcraft-mcp-hidden-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("shot.png").to_string_lossy().to_string(); let shoot = |extra: Value| { let mut args = json!({"path": path, "width": 64, "height": 48}); args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); let mut app = Hidden { calls: Vec::new() }; let r = crate::tools::call_tool(&mut app, "screenshot", &args); (app.calls, r) }; // The default: the window first, then a model render that says why. let (calls, r) = shoot(json!({})); assert!(r["isError"] != json!(true), "{r}"); assert_eq!(calls, ["ui.screenshot", "ui.render"]); let info: Value = serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap(); assert_eq!(info["source"], "model", "{info}"); assert!(info["fallback"].as_str().is_some_and(|f| f.contains("window hidden")), "{info}"); // Asked for the window: an error naming the cause and the way out, and no render. let (calls, r) = shoot(json!({"source": "window"})); assert_eq!(r["isError"], json!(true), "{r}"); let text = r["content"][0]["text"].as_str().unwrap(); assert!(text.contains("window hidden") && text.contains("source \"model\""), "{text}"); assert_eq!(calls, ["ui.screenshot"]); let _ = std::fs::remove_dir_all(&dir); } /// #530: in a design with many bodies every feature result lists all of them, so a batch reply /// is mostly the same body list once per step. A compact batch reply names only what each step /// added or changed. #[test] fn compact_batch_reply_does_not_repeat_the_body_list() { let mut s = server(); let cube = |x: i64, y: i64| json!({"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1, "corner": [x, y, 0], "operation": "new"}}); let existing: Vec = (0..50).map(|i| cube(2 * i, 0)).collect(); tool(&mut s, "batch", json!({"commands": existing})); let more: Vec = (0..3).map(|i| cube(2 * i, 5)).collect(); let r = raw_tool(&mut s, "batch", json!({"commands": more, "verbose": false})); assert!(r.get("error").is_none() && r["result"]["isError"] != json!(true), "{r}"); let reply = r["result"]["content"][0]["text"].as_str().unwrap_or_default(); assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 53); // The new bodies are named; the 50 untouched ones are not repeated. for new in ["Body51", "Body52", "Body53"] { assert!(reply.contains(&format!("\"{new}\"")), "{new} missing from the compact reply: {reply}"); } for old in ["Body1", "Body25", "Body50"] { assert!(!reply.contains(&format!("\"{old}\"")), "untouched {old} repeated in the compact reply ({} bytes): {reply}", reply.len()); } } /// A compact batch reply keeps what each step did: the body a cut changed, and a sketch /// command's new ids once (under `created`); the default reply is unchanged. #[test] fn compact_batch_reply_keeps_changed_bodies_and_created_ids() { let mut s = server(); let steps = json!([ {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}}, {"command": "solid.box", "params": {"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["Base"]}}, {"command": "sketch.create", "params": {"plane": "XY"}}, {"command": "sketch.rectangle.two_point", "params": {"p0": [20, 0], "p1": [30, 10]}}, ]); let full = tool(&mut s, "batch", json!({"commands": steps})); assert_eq!(full["results"][1]["bodies"], json!(["Base"]), "{full}"); assert_eq!(full["results"][3]["curves"], full["results"][3]["created"]["curves"], "{full}"); tool(&mut s, "new_design", json!({})); let r = tool(&mut s, "batch", json!({"commands": steps, "verbose": false})); let cut = &r["results"][1]; assert!(cut.get("bodies").is_none(), "{r}"); assert_eq!(cut["changed_bodies"], json!(["Base"]), "{r}"); let rect = &r["results"][3]; assert!(rect.get("curves").is_none() && rect.get("constraints").is_none(), "{r}"); assert_eq!(rect["created"]["curves"].as_array().map(Vec::len), Some(4), "{r}"); assert!(rect["sides"].is_object(), "results that are not repeated stay: {r}"); } /// #531: a product model keeps an optional variant hidden in its default presentation. Hiding /// it must survive saving and reopening the design (suppressing it would change the design). #[test] fn hidden_body_stays_hidden_after_save_and_reopen() { let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}})); tool( &mut s, "execute", json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0], "operation": "new", "body_name": "Variant"}}), ); tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Variant"], "visible": false}})); let visible = |s: &mut Server, body: &str| { let d = tool(s, "inspect_design", json!({"measure": false})); d["bodies"].as_array().into_iter().flatten().find(|b| b["name"] == body).map(|b| b["visible"].clone()).unwrap_or(Value::Null) }; assert_eq!(visible(&mut s, "Variant"), false); let dir = std::env::temp_dir().join(format!("solidcraft-mcp-visibility-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let design = dir.join("variants.solidcraft"); tool(&mut s, "save", json!({"path": design.to_string_lossy()})); tool(&mut s, "new_design", json!({"name": "Empty"})); tool(&mut s, "open", json!({"path": design.to_string_lossy()})); let (base, variant) = (visible(&mut s, "Base"), visible(&mut s, "Variant")); let _ = std::fs::remove_dir_all(&dir); assert_eq!(base, true); assert_eq!(variant, false, "the hidden body is visible again after reopening"); } /// Single execute calls share batch compaction without changing geometry or inspection. #[test] fn compact_execute_keeps_changes_ids_and_default_replies() { let mut s = server(); let commands = json!([ {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}}, {"command": "solid.box", "params": {"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["Base"]}}, {"command": "sketch.create", "params": {"plane": "XY"}}, {"command": "sketch.rectangle.two_point", "params": {"p0": [20, 0], "p1": [30, 10]}} ]); let full: Vec = commands.as_array().unwrap().iter().map(|a| tool(&mut s, "execute", a.clone())).collect(); assert_eq!(full[0]["bodies"], json!(["Base"])); assert_eq!(full[3]["curves"], full[3]["created"]["curves"]); let expected_volume = volume(&mut s); for verbose in [true, false] { tool(&mut s, "new_design", json!({})); for (a, expected) in commands.as_array().unwrap().iter().zip(&full) { let mut a = a.clone(); a["verbose"] = json!(verbose); let mut actual = tool(&mut s, "execute", a); let mut expected = expected.clone(); // Independent executions have different timings; the field must still be present. if expected.get("ms").is_some() { assert!(actual["ms"].is_number()); actual.as_object_mut().unwrap().remove("ms"); expected.as_object_mut().unwrap().remove("ms"); } if !verbose { // Compare against the full result with only the documented repeated fields removed. let o = expected.as_object_mut().unwrap(); if o.contains_key("created_bodies") { o.remove("bodies"); } for key in ["points", "curves", "constraints", "dimensions"] { if o.get(key).is_some_and(|v| o.get("created").and_then(|c| c.get(key)) == Some(v)) { o.remove(key); } } } assert_eq!(actual, expected); } assert!((volume(&mut s) - expected_volume).abs() < 1e-9); let inspect = tool(&mut s, "execute", json!({"command": "sketch.inspect", "verbose": false})); assert_eq!(inspect["curves"].as_array().map(Vec::len), Some(4)); let invalid = raw_tool(&mut s, "execute", json!({"command": "solid.box", "verbose": "false"})); assert_eq!(invalid["error"]["code"], -32602); assert!((volume(&mut s) - expected_volume).abs() < 1e-9); let error = raw_tool(&mut s, "execute", json!({"command": "not.a.command", "verbose": false})); assert_eq!(error["result"]["isError"], true); } } /// A connected app sees exactly one engine call; no inventory inspection is needed. #[test] fn compact_execute_uses_one_backend_call_and_keeps_removed_bodies() { struct ReplyOnly { calls: usize, } impl crate::backend::Backend for ReplyOnly { fn call(&mut self, method: &str, params: Value) -> Result { assert_eq!(method, "engine.execute"); assert_eq!(params, json!({"command": "test.feature", "params": {}})); self.calls += 1; Ok( json!({"feature": 7, "name": "Combine1", "bodies": ["Base"], "created_bodies": [], "changed_bodies": ["Base"], "removed_bodies": ["Tool"]}), ) } fn has_ui(&self) -> bool { true } fn describe(&self) -> String { "test".into() } } let mut b = ReplyOnly { calls: 0 }; let r = crate::tools::call_tool(&mut b, "execute", &json!({"command": "test.feature", "verbose": false})); let v: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(b.calls, 1); assert_eq!(v, json!({"feature": 7, "name": "Combine1", "created_bodies": [], "changed_bodies": ["Base"], "removed_bodies": ["Tool"]})); } /// The worked example in `skills/solidcraft-modeling/reference.md` is what agents copy, so it has /// to keep building with the volume the file documents, and follow a parameter change. #[test] fn skill_reference_example_builds_as_documented() { let doc = include_str!("../../../skills/solidcraft-modeling/reference.md"); let start = doc.find("{\"commands\":[").expect("the reference has a worked example"); let end = start + doc[start..].find("```").expect("the example's code block ends"); let example: Value = serde_json::from_str(&doc[start..end]).expect("the example is JSON"); let mut s = server(); tool(&mut s, "batch", example); // Plate with four r5 corners, minus four counterbored holes (Ø3.4 through, Ø6.5 × 3.3). let expected = |l: f64| { let (w, t) = (50.0, 6.0); let plate = l * w * t - 4.0 * 25.0 * (1.0 - PI / 4.0) * t; let hole = PI * 1.7 * 1.7 * (t - 3.3) + PI * 3.25 * 3.25 * 3.3; plate - 4.0 * hole }; assert!((expected(80.0) - 23335.2).abs() < 0.05, "the reference states {}", expected(80.0)); // Volume can't see where the holes are: each counterbore floor (z = T - 3.3) is centred on its // hole, at (±(L/2 - inset), ±(W/2 - inset)). let holes_at = |s: &mut Server, x: f64| { let topo = tool(s, "body_topology", json!({"body": "Body1"})); let mut floors: Vec<(i64, i64)> = topo["faces"] .as_array() .unwrap() .iter() .filter_map(|f| f["centroid"].as_array()) .filter(|c| (c[2].as_f64().unwrap() - 2.7).abs() < 1e-6) .map(|c| ((c[0].as_f64().unwrap() * 1000.0).round() as i64, (c[1].as_f64().unwrap() * 1000.0).round() as i64)) .collect(); floors.sort_unstable(); let x = (x * 1000.0).round() as i64; assert_eq!(floors, vec![(-x, -17000), (-x, 17000), (x, -17000), (x, 17000)], "{topo}"); }; let m = tool(&mut s, "measure", json!({})); assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), expected(80.0)) < 1e-5, "{m}"); assert_eq!(m["bodies"][0]["faces"], json!(22), "{m}"); holes_at(&mut s, 32.0); let r = tool(&mut s, "set_parameter", json!({"name": "L", "value": "120 mm"})); assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}"); assert!(rel(volume(&mut s), expected(120.0)) < 1e-5); holes_at(&mut s, 52.0); } /// The skill reference's sketch-plane table: where sketch (10, 20) lands and which way +distance goes. #[test] fn sketch_plane_axes_match_the_skill_reference() { let mut s = server(); for plane in ["XY", "XZ", "YZ"] { tool( &mut s, "batch", json!({"commands": [ {"command": "sketch.create", "params": {"plane": plane}}, {"command": "sketch.circle.center", "params": {"center": [10, 20], "radius": 1}}, {"command": "sketch.finish"}, {"command": "solid.extrude", "params": {"distance": 3, "operation": "new"}}, ]}), ); } let m = tool(&mut s, "measure", json!({})); let centres: Vec> = m["bodies"] .as_array() .unwrap() .iter() .map(|b| b["center_of_mass"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()) .collect(); let expected = [[10.0, 20.0, 1.5], [10.0, 1.5, -20.0], [1.5, 10.0, 20.0]]; assert_eq!(centres.len(), 3, "{m}"); for (got, want) in centres.iter().zip(expected) { assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?} != {want:?}"); } } /// The worked example in `skills/solidcraft-mechanisms/reference.md` is what agents copy: the /// actuator has to follow the hinge with the stroke the file computes, the mirrored panel its link, /// and the drive has to stop where the rod reaches its limit. #[test] fn mechanisms_skill_example_moves_as_documented() { let doc = include_str!("../../../skills/solidcraft-mechanisms/reference.md"); let start = doc.find("{\"commands\":[").expect("the reference has a worked example"); let end = start + doc[start..].find("```").expect("the example's code block ends"); let example: Value = serde_json::from_str(&doc[start..end]).expect("the example is JSON"); let mut s = server(); tool(&mut s, "batch", example); let exec = |s: &mut Server, command: &str, params: Value| tool(s, "execute", json!({"command": command, "params": params})); let value = |list: &Value, joint: &str| { let j = list["joints"].as_array().unwrap().iter().find(|j| j["name"] == joint).unwrap_or_else(|| panic!("no joint {joint}: {list}")); j["values"][0]["value"].as_f64().unwrap() }; let com = |s: &mut Server, occurrence: &str| -> Vec { let p = exec(s, "occurrence.properties", json!({"occurrence": occurrence})); p["total"]["center_of_mass"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() }; // Frame 0, the panels and the actuator one motion between them; nothing open. let list = exec(&mut s, "joint.list", json!({})); assert_eq!(list["conflicts"], json!([]), "{list}"); let mut dof: Vec = list["dof"].as_array().unwrap().iter().map(|d| d["dof"].as_u64().unwrap()).collect(); dof.sort_unstable(); assert_eq!(dof, [0, 0, 1, 1, 1, 1], "root, Frame, then Wing, Wing 2, barrel and rod: {list}"); let near = |got: &[f64], want: [f64; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3); let wing = com(&mut s, "Wing:1"); assert!(near(&wing, [0.0, 20.0, 1.5]), "joined where it was modelled: {wing:?}"); // The tip at (0, 30 cos θ, 30 sin θ), the root at (0, 0, -40): L² = 2500 + 2400 sin θ. let stroke = |deg: f64| (2500.0 + 2400.0 * deg.to_radians().sin()).sqrt() - 50.0; assert!((stroke(20.0) - 7.6268).abs() < 5e-5, "the reference states {}", stroke(20.0)); exec(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 20})); let list = exec(&mut s, "joint.list", json!({})); assert!((value(&list, "Stroke") - stroke(20.0)).abs() < 1e-6, "{list}"); assert!((value(&list, "Hinge 2") + 20.0).abs() < 1e-9, "{list}"); let (wing, wing2) = (com(&mut s, "Wing:1"), com(&mut s, "Wing 2:1")); assert!(near(&wing, [0.0, 18.281, 8.250]), "{wing:?}"); assert!(near(&wing2, [0.0, -18.281, 8.250]), "{wing2:?}"); // The rod reaches +10 mm where L = 60: sin θ = 11/24. let stop = (11.0f64 / 24.0).asin().to_degrees(); assert!((stop - 27.280).abs() < 5e-4, "the reference states {stop}"); let r = exec(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 45})); assert_eq!(r["stopped_by_loop"]["joint"], "Stroke", "{r}"); let list = exec(&mut s, "joint.list", json!({})); let hinge = value(&list, "Hinge"); assert!((hinge - stop).abs() < 0.01, "stopped at {hinge}, the rod's limit is at {stop}"); assert!(value(&list, "Stroke") <= 10.0 + 1e-6 && (value(&list, "Stroke") - stroke(hinge)).abs() < 1e-6, "{list}"); } /// Every tool and command the agent skills call exists, so a rename or removal can't leave the /// skills sending agents to names the server no longer has. #[test] fn skills_name_only_real_tools_and_commands() { let docs = [ include_str!("../../../skills/solidcraft-modeling/SKILL.md"), include_str!("../../../skills/solidcraft-modeling/reference.md"), include_str!("../../../skills/solidcraft-editing/SKILL.md"), include_str!("../../../skills/solidcraft-checking/SKILL.md"), include_str!("../../../skills/solidcraft-checking/reference/porting.md"), include_str!("../../../skills/solidcraft-mechanisms/SKILL.md"), include_str!("../../../skills/solidcraft-mechanisms/reference.md"), ]; let mut s = server(); let tools: Vec = tool_definitions().iter().filter_map(|t| t["name"].as_str()).map(str::to_owned).collect(); let commands: Vec = tool(&mut s, "list_commands", json!({})).as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).map(str::to_owned).collect(); let mut checked = 0; for doc in docs { // A call in backticks is `name {}`, `name {"json":…}` or `name {placeholder}`: a tool when the // name is snake_case, a command when it is a dotted id. Field lists such as // `sides {bottom,right,top,left}` and `created: {…}` aren't calls. JSON examples name // commands as "command":"id". let ident = |w: &str| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.'); let calls = doc.split('`').skip(1).step_by(2).filter_map(|span| { let (head, args) = span.split_once(" {")?; let name = head.rsplit(' ').next()?; let args = args.strip_suffix('}')?; let call_like = args.is_empty() || args.starts_with('"') || args.chars().all(|c| c.is_ascii_lowercase() || c == '_'); (ident(name) && call_like).then_some(name) }); let in_json = doc.split("\"command\":\"").skip(1).filter_map(|rest| rest.split('"').next()); for name in calls.chain(in_json) { let known = if name.contains('.') { commands.iter().any(|c| c == name) } else { tools.iter().any(|t| t == name) }; assert!(known, "a skill calls `{name}`, which is neither a tool nor a command"); checked += 1; } } assert!(checked > 30, "only {checked} tool and command names found in the skills"); } /// Every tool the server instructions name (a bare `snake_case` word in backticks) is a real tool. #[test] fn instructions_name_only_real_tools() { let mut s = server(); let init = call(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION, "capabilities": {}})); let text = init["result"]["instructions"].as_str().unwrap().to_owned(); let tools: Vec = tool_definitions().iter().map(|t| t["name"].as_str().unwrap().to_owned()).collect(); let named: Vec<&str> = text.split('`').skip(1).step_by(2).filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c == '_')).collect(); assert!(named.contains(&"body_topology"), "{named:?}"); for name in named { assert!(tools.iter().any(|t| t == name), "the instructions name `{name}`, which is not a tool"); } } /// A fake app that records what it is asked and answers `engine.batch` as configured. struct Recorder { calls: Vec<(String, Value)>, batch: Result, } impl crate::backend::Backend for Recorder { fn call(&mut self, method: &str, params: Value) -> Result { self.calls.push((method.to_string(), params.clone())); match method { "engine.batch" => self.batch.clone(), "engine.execute" => match params["command"].as_str() { Some("edit.checkpoint") => Ok(json!({"checkpoint": 3})), Some(c) => Ok(json!({"ran": c})), None => Err("missing `command`".into()), }, "document.inspect" => Ok(json!({"undo": 0})), other => Err(format!("unknown method `{other}`")), } } fn has_ui(&self) -> bool { true } fn describe(&self) -> String { "recorder".into() } } fn recorded_batch(batch: Result, args: Value) -> (Recorder, Value, bool) { let mut b = Recorder { calls: Vec::new(), batch }; let r = crate::tools::call_tool(&mut b, "batch", &args); let body = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap_or_else(|_| r["content"][0]["text"].clone()); let is_error = r["isError"] == json!(true); (b, body, is_error) } fn methods(b: &Recorder) -> Vec<&str> { b.calls.iter().map(|(m, _)| m.as_str()).collect() } /// #743: bridged to an app that has `engine.batch`, a batch is one control request. #[test] fn batch_is_one_engine_batch_call() { let reply = json!({"completed": 2, "results": [{"feature": 1, "bodies": ["B"], "created_bodies": ["B"]}, {"x": 2}], "undone": 0}); let steps = json!([{"command": "a.one", "params": {"v": 1}}, {"command": "a.two"}]); let (b, body, is_error) = recorded_batch(Ok(reply.clone()), json!({"commands": steps})); assert_eq!(methods(&b), ["engine.batch"]); assert_eq!(b.calls[0].1, json!({"commands": steps, "rollback": true})); assert!(!is_error); assert_eq!(body, json!({"ok": true, "completed": 2, "results": reply["results"]})); // `verbose: false` compacts each step's result as the per-step path does. let (_, body, _) = recorded_batch(Ok(reply), json!({"commands": steps, "verbose": false})); assert_eq!(body["results"][0], json!({"feature": 1, "created_bodies": ["B"]}), "{body}"); } #[test] fn batch_reports_a_failed_engine_batch_like_before() { let reply = json!({"completed": 1, "results": [{"x": 1}], "failed": {"index": 1, "command": "a.two", "error": "boom"}, "undone": 1}); let (b, body, is_error) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}]})); assert_eq!(methods(&b), ["engine.batch"]); assert!(is_error); assert_eq!( body, json!({"ok": false, "completed": 1, "failed": {"index": 1, "command": "a.two", "error": "boom"}, "rolled_back": true, "undone": 1, "results": [{"x": 1}]}) ); } #[test] fn batch_falls_back_when_the_app_lacks_engine_batch() { let (b, body, is_error) = recorded_batch(Err("unknown method `engine.batch`".into()), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}]})); assert!(!is_error, "{body}"); let ran: Vec<&str> = b.calls.iter().filter(|(m, _)| m == "engine.execute").map(|(_, p)| p["command"].as_str().unwrap_or("")).collect(); assert!(ran.contains(&"a.one") && ran.contains(&"a.two"), "{ran:?}"); assert_eq!(body["results"], json!([{"ran": "a.one"}, {"ran": "a.two"}])); } #[test] fn batch_falls_back_while_the_agent_cursor_is_shown() { let (b, body, is_error) = recorded_batch(Err("engine.batch unavailable: the agent cursor is shown".into()), json!({"commands": [{"command": "a.one"}]})); assert!(!is_error, "{body}"); assert!(b.calls.iter().any(|(m, p)| m == "engine.execute" && p["command"] == "a.one"), "{:?}", b.calls); } /// The app may have run the batch: sending the steps again would apply them twice. #[test] fn batch_does_not_resend_after_a_transport_error() { let (b, body, is_error) = recorded_batch(Err("SolidCraft at 127.0.0.1:1: timed out".into()), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}]})); assert_eq!(methods(&b), ["engine.batch"]); assert!(is_error); assert!(body.as_str().is_some_and(|t| t.contains("may have run") && t.contains("timed out")), "{body}"); } /// A batch longer than one control line goes step by step instead of being refused unrun. #[test] fn batch_too_large_for_one_control_line_goes_per_step() { let big = "x".repeat(solidcraft_engine::MAX_LINE); let (b, body, is_error) = recorded_batch(Ok(json!({})), json!({"commands": [{"command": "a.one", "params": {"text": big}}]})); assert!(!is_error, "{body}"); assert!(!methods(&b).contains(&"engine.batch"), "{:?}", methods(&b)); assert!(b.calls.iter().any(|(m, p)| m == "engine.execute" && p["command"] == "a.one")); } /// A fake app on a loopback port: counts connections and `engine.batch` lines, never replies to /// them (`close_first`: close a connection as soon as it is accepted). fn silent_app(close_first: bool) -> (String, std::sync::Arc>) { use std::io::BufRead; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let seen = std::sync::Arc::new(std::sync::Mutex::new((0usize, 0usize))); let counts = seen.clone(); std::thread::spawn(move || { for stream in listener.incoming().flatten() { counts.lock().unwrap().0 += 1; let counts = counts.clone(); std::thread::spawn(move || { if close_first && counts.lock().unwrap().0 > 1 { return; } let mut r = std::io::BufReader::new(stream); let mut line = String::new(); while r.read_line(&mut line).is_ok_and(|n| n > 0) { if line.contains("engine.batch") { counts.lock().unwrap().1 += 1; return; } line.clear(); } }); } }); (addr, seen) } #[test] fn remote_engine_batch_is_not_resent_after_a_lost_reply() { use crate::backend::Backend; for close_first in [false, true] { let (addr, seen) = silent_app(close_first); let mut remote = crate::Remote::connect(&addr).unwrap(); let r = remote.call("engine.batch", json!({"commands": [{"command": "solid.box"}]})); assert!(r.is_err(), "{r:?}"); std::thread::sleep(std::time::Duration::from_millis(300)); let (connections, batches) = *seen.lock().unwrap(); // The connection from `connect`, and one fresh one for the batch: no resend. assert_eq!(connections, 2, "close_first {close_first}: {connections} connections"); assert!(batches <= 1, "close_first {close_first}: the batch arrived {batches} times"); if !close_first { assert_eq!(batches, 1); } } } /// A bridge made before the app runs keeps its tools (#270): calls say the app is not running and /// how to start it (a batch was never sent, so it may not "have run"), and reach the app once it /// listens. #[test] fn a_bridge_made_before_the_app_runs_connects_once_it_does() { use std::io::{BufRead, BufReader, Write}; use crate::backend::Backend; let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); let addr = format!("127.0.0.1:{port}"); let mut remote = crate::Remote::new(&addr, Some("0123456789abcdef0123456789abcdef")); // Still "connected to the SolidCraft app", which the skills read as bridged. assert!(remote.describe().starts_with("connected to the SolidCraft app") && remote.describe().contains("not running"), "{}", remote.describe()); let e = remote.call("engine.execute", json!({"command": "inspect.measure"})).unwrap_err(); assert!(e.starts_with(crate::backend::NOT_RUNNING) && e.contains(&format!("solidcraft --control {port}")), "{e}"); let batch = crate::call_tool(&mut remote, "batch", &json!({"commands": [{"command": "solid.box"}]})); let text = batch["content"][0]["text"].as_str().unwrap_or_default(); assert_eq!(batch["isError"], true); assert!(text.starts_with(crate::backend::NOT_RUNNING) && !text.contains("may have run"), "{text}"); let listener = std::net::TcpListener::bind(&addr).unwrap(); let app = std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); let mut line = String::new(); BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); let req: Value = serde_json::from_str(&line).unwrap(); writeln!(&stream, "{}", json!({"id": req["id"], "ok": true, "result": {"answered": req["params"]["command"]}})).unwrap(); }); let got = remote.call("engine.execute", json!({"command": "inspect.measure"})).unwrap(); assert_eq!(got["answered"], "inspect.measure"); assert!(remote.describe().starts_with("connected") && !remote.describe().contains("not running"), "{}", remote.describe()); app.join().unwrap(); } /// An app that quits while it runs a request may have applied it: the retry then finds nothing /// listening, and the error says the call may have run, not just that the app is not running. #[test] fn an_app_that_quits_during_a_call_may_have_run_it() { use std::io::{BufRead, BufReader}; use crate::backend::Backend; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let app = std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); let mut line = String::new(); BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); // Quit without answering: the port closes first, so the retry is refused. drop(listener); drop(stream); }); let mut remote = crate::Remote::connect(&addr).unwrap(); let e = remote.call("engine.execute", json!({"command": "solid.box"})).unwrap_err(); app.join().unwrap(); assert!(!e.starts_with(crate::backend::NOT_RUNNING) && e.contains("may have quit") && e.contains("Inspect the design"), "{e}"); } /// Tool annotations never understate what a tool may do (#205): `screenshot` writes a file when /// given a `path`, and `execute`, `batch`, `save`, `export` and `open` can overwrite or delete /// files, so none of them is read-only and all are destructive and reach the file system. #[test] fn tool_annotations_tell_the_whole_truth() { let tools = crate::tool_definitions(); let hints = |name: &str| { let t = tools.iter().find(|t| t["name"] == name).unwrap_or_else(|| panic!("no tool {name}")); let a = &t["annotations"]; (a["readOnlyHint"].as_bool().unwrap(), a["destructiveHint"].as_bool().unwrap(), a["openWorldHint"].as_bool().unwrap()) }; for name in ["execute", "batch", "save", "export", "open", "screenshot"] { assert_eq!(hints(name), (false, true, true), "{name}"); } for name in ["list_commands", "inspect_design", "measure", "body_topology", "crash_reports"] { assert_eq!(hints(name), (true, false, false), "{name}"); } assert_eq!(hints("new_design"), (false, true, false)); for name in ["set_parameter", "undo", "redo"] { assert_eq!(hints(name), (false, false, false), "{name}"); } assert!(tools.iter().all(|t| t["annotations"]["readOnlyHint"].is_boolean()), "every tool is annotated"); } /// `measure` with `items` (#1392): a sketch circle's diameter and radius, and two circles' /// centre-to-centre with the closest and farthest distances. #[test] fn measure_items_gives_diameters_and_centre_distances() { let mut s = server(); tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}})); let a = tool(&mut s, "execute", json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3}})); let b = tool(&mut s, "execute", json!({"command": "sketch.circle.center", "params": {"center": [30, 40], "radius": 2}})); let id = |r: &Value| r["curves"][0].as_str().or(r["result"]["curves"][0].as_str()).unwrap().to_string(); let (a, b) = (id(&a), id(&b)); let one = tool(&mut s, "measure", json!({"items": [{"type": "sketch_curve", "id": a}]})); assert_eq!((one["diameter"].as_f64(), one["radius"].as_f64()), (Some(6.0), Some(3.0)), "{one}"); let two = tool(&mut s, "measure", json!({"items": [{"type": "sketch_curve", "id": a}, {"type": "sketch_curve", "id": b}]})); assert!((two["center_distance"].as_f64().unwrap() - 50.0).abs() < 1e-9, "{two}"); assert!((two["min"].as_f64().unwrap() - 45.0).abs() < 1e-9 && (two["max"].as_f64().unwrap() - 55.0).abs() < 1e-9, "{two}"); assert!(two["distance_mm"].is_number() && two["from"].is_array(), "{two}"); } /// #1833: `timings` / `profile` time every step run, the failing one included, headless with the /// kernel's share; bridged, the app is asked to time them and its numbers are passed on. #[test] fn batch_reports_timings_and_profile() { let mut s = server(); let steps = json!([ {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}, {"command": "solid.box", "params": {"length": 4, "width": 4, "height": 30, "operation": "join"}}, ]); let r = raw_tool(&mut s, "batch", json!({"commands": steps, "profile": true})); let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); let rows = body["timings"].as_array().unwrap(); assert_eq!(rows.len(), 2, "{body}"); assert_eq!((rows[1]["index"].as_u64(), rows[1]["command"].as_str()), (Some(1), Some("solid.box"))); assert!(rows[1]["profile"]["boolean_ms"].as_f64().unwrap() > 0.0, "{body}"); assert!(body["total_ms"].as_f64().is_some(), "{body}"); let r = raw_tool( &mut s, "batch", json!({"timings": true, "commands": [{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}, {"command": "NoSuchCommand"}]}), ); let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(body["timings"].as_array().map(Vec::len), Some(2), "the failing step is timed too: {body}"); assert!(body["timings"][0].get("profile").is_none()); // Not asked: no timings. let d = tool(&mut s, "batch", json!({"commands": [{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}]})); assert!(d.get("timings").is_none(), "{d}"); let reply = json!({"completed": 1, "results": [{"x": 1}], "undone": 0, "timings": [{"index": 0, "command": "a.one", "ms": 2.5}], "total_ms": 2.6}); let (b, body, _) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}], "profile": true})); assert_eq!(b.calls[0].1["timings"], true); assert_eq!(b.calls[0].1["profile"], true); assert_eq!((body["timings"][0]["ms"].as_f64(), body["total_ms"].as_f64()), (Some(2.5), Some(2.6)), "{body}"); // An app without engine.batch is timed step by step here, without a kernel split it can't measure. let (_, body, _) = recorded_batch(Err("unknown method `engine.batch`".into()), json!({"commands": [{"command": "a.one"}], "profile": true})); assert_eq!(body["timings"][0]["command"], "a.one", "{body}"); assert!(body["timings"][0].get("profile").is_none(), "{body}"); }