//! Headless automation server (`OpenCADStudio --serve`). //! //! Drives the app without a GUI over a line-based JSON protocol: one request //! object per line on stdin, one response object per line on stdout. State (the //! active document) persists across requests, so an external process — a script //! or an AI agent — can act, observe, and act again. //! //! Operations: //! - `{"op":"new"}` — start an empty document //! - `{"op":"open","path":"file.dwg"}` — load a drawing //! - `{"op":"run","cmd":"LAYER Walls"}` — run a command (the same dispatcher //! the GUI command line uses) //! - `{"op":"entities"}` — summary count by entity type //! - `{"op":"save","path":"out.dwg"}` — write the document (path optional //! once opened/saved) use std::io::{BufRead, Write}; use std::path::PathBuf; use serde_json::{json, Value}; use super::OpenCADStudio; /// Run the headless JSON server. Default transport is stdin/stdout; with /// `--port ` it instead listens on `127.0.0.1:` and serves one client at /// a time (the document session persists across reconnects). pub fn serve() { let mut app = OpenCADStudio::new(); match port_arg() { Some(port) => serve_socket(&mut app, port), None => serve_stdio(&mut app), } } /// Headless one-shot format conversion (`--export IN OUT`). Loads `input`, /// writes `output` (format chosen from `output`'s extension), and returns a /// process exit code (0 on success). No window is created. pub fn export_headless(input: &std::path::Path, output: &std::path::Path) -> i32 { let doc = match crate::io::load_file(input) { Ok(doc) => doc, Err(e) => { eprintln!("export: cannot read {}: {e}", input.display()); return 1; } }; match crate::io::save(&doc, output) { Ok(()) => { println!("Exported {} → {}", input.display(), output.display()); 0 } Err(e) => { eprintln!("export: cannot write {}: {e}", output.display()); 1 } } } /// `--port ` if present on the command line. fn port_arg() -> Option { let mut args = std::env::args(); while let Some(a) = args.next() { if a == "--port" { return args.next().and_then(|s| s.parse().ok()); } } None } fn ready() -> Value { json!({ "ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION") }) } fn serve_stdio(app: &mut OpenCADStudio) { let stdin = std::io::stdin(); let stdout = std::io::stdout(); { let mut o = stdout.lock(); let _ = writeln!(o, "{}", ready()); let _ = o.flush(); } for line in stdin.lock().lines() { let Ok(line) = line else { break }; let line = line.trim(); if line.is_empty() { continue; } let resp = app.automation_op(line); let mut o = stdout.lock(); let _ = writeln!(o, "{resp}"); let _ = o.flush(); } } fn serve_socket(app: &mut OpenCADStudio, port: u16) { let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { eprintln!("--serve: cannot bind 127.0.0.1:{port}: {e}"); return; } }; eprintln!("OpenCADStudio --serve listening on 127.0.0.1:{port}"); for stream in listener.incoming().flatten() { let Ok(read_half) = stream.try_clone() else { continue; }; let reader = std::io::BufReader::new(read_half); let mut writer = stream; let _ = writeln!(writer, "{}", ready()); let _ = writer.flush(); for line in reader.lines() { let Ok(line) = line else { break }; let line = line.trim(); if line.is_empty() { continue; } let resp = app.automation_op(line); if writeln!(writer, "{resp}").is_err() { break; } let _ = writer.flush(); } } } fn err(msg: impl std::fmt::Display) -> Value { json!({ "ok": false, "error": msg.to_string() }) } fn v3(v: acadrust::types::Vector3) -> Value { json!([v.x, v.y, v.z]) } /// One entity as JSON: handle, type, layer, plus basic geometry for the common /// types (others carry only the common fields). fn entity_json(e: &acadrust::EntityType) -> Value { use acadrust::EntityType as E; let c = e.common(); let mut obj = json!({ "handle": format!("{:X}", c.handle.value()), "type": crate::entities::names::ui_name(e), "layer": c.layer, }); let map = obj.as_object_mut().expect("json object"); match e { E::Line(l) => { map.insert("start".into(), v3(l.start)); map.insert("end".into(), v3(l.end)); } E::Circle(cc) => { map.insert("center".into(), v3(cc.center)); map.insert("radius".into(), json!(cc.radius)); } E::Arc(a) => { map.insert("center".into(), v3(a.center)); map.insert("radius".into(), json!(a.radius)); map.insert("start_angle".into(), json!(a.start_angle)); map.insert("end_angle".into(), json!(a.end_angle)); } E::Point(p) => { map.insert("location".into(), v3(p.location)); } E::Ellipse(el) => { map.insert("center".into(), v3(el.center)); map.insert("major_axis".into(), v3(el.major_axis)); } E::Text(t) => { map.insert("value".into(), json!(t.value)); map.insert("position".into(), v3(t.insertion_point)); map.insert("height".into(), json!(t.height)); } E::MText(t) => { map.insert("value".into(), json!(t.value)); map.insert("position".into(), v3(t.insertion_point)); map.insert("height".into(), json!(t.height)); } E::LwPolyline(pl) => { let pts: Vec = pl .vertices .iter() .map(|v| json!([v.location.x, v.location.y])) .collect(); map.insert("vertices".into(), json!(pts)); } E::Insert(ins) => { map.insert("block".into(), json!(ins.block_name)); } _ => {} } obj } impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. pub(crate) fn automation_op(&mut self, line: &str) -> Value { let req: Value = match serde_json::from_str(line) { Ok(v) => v, Err(e) => return err(format!("invalid JSON: {e}")), }; match req["op"].as_str().unwrap_or("") { "new" => { let i = self.active_tab; self.tabs[i].scene.document = acadrust::CadDocument::new(); self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which // blocks drawing commands; turn it into a real drawing. self.tabs[i].is_start = false; self.tabs[i].scene.bump_geometry(); self.entity_summary() } "open" => { let Some(path) = req["path"].as_str() else { return err("open: missing \"path\""); }; let bytes = match std::fs::read(path) { Ok(b) => b, Err(e) => return err(format!("open: {e}")), }; let name = PathBuf::from(path) .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string()); match crate::io::load_bytes(&name, bytes) { Ok(doc) => { let i = self.active_tab; self.tabs[i].scene.document = doc; crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, ); self.tabs[i].adopt_active_ucs_from_header(); self.tabs[i].current_path = Some(PathBuf::from(path)); self.tabs[i].is_start = false; self.tabs[i].scene.bump_geometry(); self.entity_summary() } Err(e) => err(format!("open: {e}")), } } "run" => { let cmd = req["cmd"].as_str().unwrap_or("").to_string(); if cmd.is_empty() { return err("run: missing \"cmd\""); } let i = self.active_tab; let before = self.tabs[i].scene.document.entities().count(); self.run_headless(&cmd); let after = self.tabs[i].scene.document.entities().count(); json!({ "ok": true, "cmd": cmd, "entities": after, "added": after as i64 - before as i64, }) } "entities" => self.entity_summary(), "query" => self.entity_query(&req), "layers" => { let i = self.active_tab; let layers: Vec = self .tabs[i] .scene .document .layers .iter() .map(|l| { let mut o = json!({ "name": l.name, "off": l.is_off(), "frozen": l.is_frozen(), "locked": l.is_locked(), }); let m = o.as_object_mut().expect("json object"); if let Some(aci) = l.color.index() { m.insert("color".into(), json!(aci)); } if let Some((r, g, b)) = l.color.rgb() { m.insert("rgb".into(), json!([r, g, b])); } o }) .collect(); json!({ "ok": true, "current": self.tabs[i].scene.document.header.current_layer_name, "layers": layers, }) } "header" => { let h = &self.tabs[self.active_tab].scene.document.header; json!({ "ok": true, "current_layer": h.current_layer_name, "current_text_style": h.current_text_style_name, "insertion_units": h.insertion_units, "pdmode": h.point_display_mode, "pdsize": h.point_display_size, "ltscale": h.linetype_scale, "annotation_scale_value": h.annotation_scale_value, }) } "undo" => { let _ = self.update(super::Message::Undo); self.entity_summary() } "redo" => { let _ = self.update(super::Message::Redo); self.entity_summary() } "select" => { let i = self.active_tab; self.tabs[i].scene.deselect_all(); if req["clear"].as_bool() != Some(true) { // By explicit handles (hex, as returned by `query`). if let Some(arr) = req["handles"].as_array() { for h in arr.iter().filter_map(|h| h.as_str()) { if let Ok(v) = u64::from_str_radix(h.trim_start_matches("0x"), 16) { self.tabs[i].scene.select_entity(acadrust::Handle::new(v), false); } } } // Or by type / layer. let type_filter = req["type"].as_str(); let layer_filter = req["layer"].as_str(); if type_filter.is_some() || layer_filter.is_some() { let handles: Vec = self.tabs[i] .scene .document .entities() .filter(|e| { type_filter.is_none_or(|t| { crate::entities::names::ui_name(e).eq_ignore_ascii_case(t) }) }) .filter(|e| layer_filter.is_none_or(|l| e.common().layer == l)) .map(|e| e.common().handle) .collect(); for h in handles { self.tabs[i].scene.select_entity(h, false); } } } json!({ "ok": true, "selected": self.tabs[i].scene.selected_entities().len() }) } "save" => { let i = self.active_tab; let path = req["path"] .as_str() .map(PathBuf::from) .or_else(|| self.tabs[i].current_path.clone()); let Some(path) = path else { return err("save: no \"path\" and the document has none"); }; #[cfg(not(target_arch = "wasm32"))] let result = self.save_tab_synchronously_protected(i, path.clone(), true); #[cfg(target_arch = "wasm32")] let result = crate::io::save(&self.tabs[i].scene.document, &path) .map_err(crate::io::SaveFailure::other); match result { Ok(()) => { json!({ "ok": true, "saved": path.to_string_lossy() }) } Err(e) => err(format!("save: {e}")), } } "" => err("missing \"op\""), other => err(format!("unknown op: {other}")), } } /// Run a command line headlessly. Thin wrapper over the shared /// [`OpenCADStudio::run_command_line`] (see `cmd_result.rs`), which the GUI /// command line uses too so both process `UCS Z 90` / `LINE 0,0 10,10` / /// `PDMODE 3` identically. fn run_headless(&mut self, cmd: &str) { let _ = self.run_command_line(cmd); } /// List entities (handle, type, layer, basic geometry), optionally filtered /// by `type` and/or `layer`, capped by `limit` (default 1000). fn entity_query(&self, req: &Value) -> Value { let i = self.active_tab; let type_filter = req["type"].as_str(); let layer_filter = req["layer"].as_str(); let limit = req["limit"].as_u64().unwrap_or(1000) as usize; let mut entities = Vec::new(); let mut matched = 0u64; for e in self.tabs[i].scene.document.entities() { if let Some(tf) = type_filter { if !crate::entities::names::ui_name(e).eq_ignore_ascii_case(tf) { continue; } } if let Some(lf) = layer_filter { if e.common().layer != lf { continue; } } matched += 1; if entities.len() < limit { entities.push(entity_json(e)); } } json!({ "ok": true, "count": matched, "returned": entities.len(), "entities": entities, }) } /// Count of entities in the active document, total and by type. fn entity_summary(&self) -> Value { let i = self.active_tab; let mut by_type: std::collections::BTreeMap = Default::default(); let mut total = 0u64; for e in self.tabs[i].scene.document.entities() { *by_type .entry(crate::entities::names::ui_name(e).to_string()) .or_default() += 1; total += 1; } json!({ "ok": true, "total": total, "by_type": by_type }) } } #[cfg(test)] mod tests { use crate::app::OpenCADStudio; #[test] fn automation_ops_round_trip() { let mut app = OpenCADStudio::new_for_test(); let r = app.automation_op(r#"{"op":"new"}"#); assert_eq!(r["ok"], true); assert_eq!(r["total"], 0); // A synchronous command runs through the real dispatcher. let r = app.automation_op(r#"{"op":"run","cmd":"PDMODE 3"}"#); assert_eq!(r["ok"], true); assert_eq!(r["cmd"], "PDMODE 3"); // A draw command with coordinates creates real geometry. let r = app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10 10,20"}"#); assert_eq!(r["ok"], true); assert_eq!(r["added"], 2); // two segments → two Line entities let r = app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#); assert_eq!(r["added"], 1); let r = app.automation_op(r#"{"op":"entities"}"#); assert_eq!(r["ok"], true); assert_eq!(r["total"], 3); assert_eq!(r["by_type"]["Line"], 2); assert_eq!(r["by_type"]["Circle"], 1); // query returns per-entity detail and honours a type filter. let r = app.automation_op(r#"{"op":"query","type":"Circle"}"#); assert_eq!(r["count"], 1); assert_eq!(r["entities"][0]["type"], "Circle"); assert_eq!(r["entities"][0]["radius"], 3.0); // select by type, then a selection command acts on it. let r = app.automation_op(r#"{"op":"select","type":"Line"}"#); assert_eq!(r["selected"], 2); app.automation_op(r#"{"op":"run","cmd":"ERASE"}"#); let r = app.automation_op(r#"{"op":"entities"}"#); assert_eq!(r["total"], 1); // only the Circle remains // undo restores the erased lines. let r = app.automation_op(r#"{"op":"undo"}"#); assert_eq!(r["total"], 3); // move a selected entity by a displacement. app.automation_op(r#"{"op":"select","type":"Circle"}"#); app.automation_op(r#"{"op":"run","cmd":"MOVE 0,0 100,0"}"#); let r = app.automation_op(r#"{"op":"query","type":"Circle"}"#); assert_eq!(r["entities"][0]["center"][0], 105.0); // 5 + 100 // Errors are reported, never panics. assert_eq!(app.automation_op(r#"{"op":"bogus"}"#)["ok"], false); assert_eq!(app.automation_op("not json")["ok"], false); assert_eq!(app.automation_op(r#"{"op":"run"}"#)["ok"], false); } #[test] fn ucs_interactive_inline_args() { // `UCS Z 90` must drive the interactive UCS command step-by-step (option // "Z" then value "90") and rotate the active UCS 90° about Z. (#169) let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op(r#"{"op":"run","cmd":"UCS Z 90"}"#); let i = app.active_tab; let ucs = app.tabs[i] .active_ucs .as_ref() .expect("UCS Z 90 should set an active UCS"); // 90° about Z sends the X axis (1,0,0) → (0,1,0). assert!( ucs.x_axis.x.abs() < 1e-6 && (ucs.x_axis.y - 1.0).abs() < 1e-6, "x_axis after UCS Z 90 = ({}, {})", ucs.x_axis.x, ucs.x_axis.y ); } #[test] fn translated_and_rotated_ucs_resolves_absolute_relative_and_polar_input() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 100,200,300"}"#); app.automation_op(r#"{"op":"run","cmd":"UCS Z 90"}"#); app.automation_op(r#"{"op":"run","cmd":"LINE 2,3 @5<0"}"#); let line = app.tabs[app.active_tab] .scene .document .entities() .find_map(|entity| match entity { acadrust::EntityType::Line(line) => Some(line), _ => None, }) .expect("LINE should create one segment"); let close = |a: f64, b: f64| (a - b).abs() < 1e-9; assert!(close(line.start.x, 97.0)); assert!(close(line.start.y, 202.0)); assert!(close(line.start.z, 300.0)); assert!(close(line.end.x, 97.0)); assert!(close(line.end.y, 207.0)); assert!(close(line.end.z, 300.0)); } #[test] fn tilted_ucs_places_planar_entities_with_the_plane_normal() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op( r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#, ); app.automation_op(r#"{"op":"run","cmd":"CIRCLE 2,3 1"}"#); let circle = app.tabs[app.active_tab] .scene .document .entities() .find_map(|entity| match entity { acadrust::EntityType::Circle(circle) => Some(circle), _ => None, }) .expect("CIRCLE should create one entity"); let close = |a: f64, b: f64| (a - b).abs() < 1e-9; assert!(close(circle.center.x, 2.0)); assert!(close(circle.center.y, 0.0)); assert!(close(circle.center.z, 3.0)); assert!(close(circle.normal.x, 0.0)); assert!(close(circle.normal.y, -1.0)); assert!(close(circle.normal.z, 0.0)); } #[test] fn value_prompt_commands_inline_args() { // A single-value setting command entered with its value on one line // drives the interactive front-end (start + value step) and applies via // the inline handler. (F4) let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op(r#"{"op":"run","cmd":"PDMODE 3"}"#); app.automation_op(r#"{"op":"run","cmd":"LTSCALE 2.5"}"#); let i = app.active_tab; let h = &app.tabs[i].scene.document.header; assert_eq!(h.point_display_mode, 3, "PDMODE 3 should set point mode"); assert!( (h.linetype_scale - 2.5).abs() < 1e-9, "LTSCALE 2.5 should set scale, got {}", h.linetype_scale ); // No command should be left dangling. assert!(app.tabs[i].active_cmd.is_none(), "command must have finished"); } #[test] fn rotate_by_typed_angle_after_center() { // ROTATE: after picking the centre, typing the angle directly must // rotate the selection (the reference point is optional, as the prompt // says). Before the fix this did nothing and the command cancelled, so // the objects never rotated. Regression for #159. let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); app.automation_op(r#"{"op":"select","type":"Line"}"#); // Centre (0,0) then 90° — no reference point. app.automation_op(r#"{"op":"run","cmd":"ROTATE 0,0 90"}"#); let q = app.automation_op(r#"{"op":"query","type":"Line"}"#); assert_eq!(q["count"], 1, "the line must survive the rotate"); let ex = q["entities"][0]["end"][0].as_f64().unwrap(); let ey = q["entities"][0]["end"][1].as_f64().unwrap(); // (10,0) rotated 90° about the origin → (0,10). assert!( ex.abs() < 1e-3 && (ey - 10.0).abs() < 1e-3, "line end after ROTATE 90 = ({ex}, {ey})" ); } #[test] fn start_page_runs_tools_that_need_no_drawing_but_still_refuses_the_rest() { // The welcome page's own buttons (Donate / Send Feedback / OCS Web) and // Manage > About route through RibbonToolClick, so a blanket is_start // refusal killed them outright — by definition they are only ever // clickable while is_start holds. `dispatch_command` owns the list of // commands that stand alone; this door must not shadow it. (#388, #389) use crate::app::Message; use crate::modules::ModuleEvent; // Fresh app = welcome tab, no drawing. let mut app = OpenCADStudio::new_for_test(); assert!( app.tabs[app.active_tab].is_start, "test needs the welcome tab" ); // ABOUT is safe to drive: it opens a modal. DONATE / WEBVERSION / REPORT // take the same path but shell out to a browser, so they are covered by // the allowlist assertion below rather than by dispatching them here. let start = app.command_line.history.len(); let _ = app.update(Message::RibbonToolClick { tool_id: "ABOUT".to_string(), event: ModuleEvent::Command("ABOUT".to_string()), }); let out: String = app.command_line.history[start..] .iter() .map(|e| e.text.as_str()) .collect::>() .join("\n"); assert!( !out.contains("No drawing open"), "ABOUT needs no drawing and must not be refused on the welcome page: {out:?}" ); // …but a tool that does need a drawing is still turned away (#299). let start = app.command_line.history.len(); let _ = app.update(Message::RibbonToolClick { tool_id: "LINE".to_string(), event: ModuleEvent::Command("LINE".to_string()), }); let out: String = app.command_line.history[start..] .iter() .map(|e| e.text.as_str()) .collect::>() .join("\n"); assert!( out.contains("No drawing open"), "LINE must still be refused on the welcome page: {out:?}" ); assert!( app.tabs[app.active_tab].active_cmd.is_none(), "LINE must not have started" ); // A non-command tool event touches the scene, so it stays inert too. let start = app.command_line.history.len(); let _ = app.update(Message::RibbonToolClick { tool_id: "LAYERS".to_string(), event: ModuleEvent::ToggleLayers, }); let out: String = app.command_line.history[start..] .iter() .map(|e| e.text.as_str()) .collect::>() .join("\n"); assert!( out.contains("No drawing open"), "a scene-touching event must stay inert on the welcome page: {out:?}" ); // Every welcome-page button must also clear dispatch's own gate, or the // fix above just moves the refusal one door down. Asserted on the source // rather than by dispatching: DONATE / REPORT / WEBVERSION shell out to // a real browser, which a test must not do. let dispatch_src = include_str!("commands/mod.rs"); // The gate's allow-list lives in the `start_allowed` fn (#388/389); // its first `}` closes the `matches!` body, past every name. let gate = dispatch_src .split("pub fn start_allowed") .nth(1) .and_then(|s| s.split('}').next()) .expect("the start_allowed gate moved — re-point this test"); // DONATE/REPORT/WEBVERSION are the welcome page's own buttons; the rest // are ribbon tools that configure the application, not a drawing. let standalone = [ "DONATE", "REPORT", "WEBVERSION", "ABOUT", "CHANGELOG", "CUI", "ALIASEDIT", ]; for cmd in standalone { assert!( gate.contains(&format!("\"{cmd}\"")), "{cmd} needs no drawing but is missing from dispatch's standalone \ list, so it is refused on the welcome page" ); } // …and each must actually have somewhere to land. let view_src = include_str!("commands/view.rs"); for cmd in standalone { assert!( view_src.contains(&format!("\"{cmd}\" =>")) || view_src.contains(&format!("\"{cmd}\" |")), "{cmd} has no dispatch arm" ); } } /// PICKADD / PICKDRAG (#226): the command flips the live flag both via the /// inline form and the two-step ValuePrompt flow. #[test] fn lasso_press_drag_selects() { // Press-drag lasso must select crossed entities — regression probe // for the #226 PICKDRAG work (both PICKDRAG modes complete through // the poly path). use crate::app::Message; for (add, rect) in [(true, false), (true, true), (false, false), (false, true)] { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; app.pick_add = add; app.pick_drag_rect = rect; let _ = app.run_command_line("LINE 0,0 10,10"); app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); let _ = app.run_command_line("ZOOM EXTENTS"); // Both directions. Crossing = a right → left diagonal sweep (the // freeform path may be degenerate — crossing counts hits). // Window = a left → right perimeter walk so the freeform ring // actually ENCLOSES the line (a diagonal has no area). let path: Vec<(f32, f32)> = if !add && rect { // Rectangle window: a simple left → right diagonal spans it. (0..=10) .map(|k| { let t = k as f32 / 10.0; (20.0 + t * 760.0, 20.0 + t * 560.0) }) .collect() } else if add { let (sx, sy, ex, ey) = (780.0f32, 580.0f32, 20.0f32, 20.0f32); (0..=10) .map(|k| { let t = k as f32 / 10.0; (sx + t * (ex - sx), sy + t * (ey - sy)) }) .collect() } else { vec![ (20.0, 20.0), (400.0, 20.0), (780.0, 20.0), (780.0, 300.0), (780.0, 580.0), (400.0, 580.0), (20.0, 580.0), (20.0, 300.0), ] }; let _ = app.update(Message::ViewportMove(iced::Point::new(path[0].0, path[0].1))); let _ = app.update(Message::ViewportLeftPress); std::thread::sleep(std::time::Duration::from_millis(180)); for &(x, y) in &path { let _ = app.update(Message::ViewportMove(iced::Point::new(x, y))); } { let sel = app.tabs[i].scene.selection.borrow(); assert!(sel.left_dragging, "drag must start (add={add} rect={rect})"); if rect { // Rectangle mode drives the box machinery, not the lasso. assert!( sel.box_anchor.is_some() && sel.box_current.is_some() && !sel.poly_active, "rect marquee must arm the box (add={add})" ); } else { assert!(sel.poly_active, "lasso must start (add={add})"); assert!(sel.poly_points.len() >= 3, "lasso points (add={add})"); } } let _ = app.update(Message::ViewportLeftRelease); assert!( !app.tabs[i].scene.selected.is_empty(), "marquee must select the line (add={add} rect={rect})" ); } } #[test] fn pickadd_command_flips_flag() { let mut app = OpenCADStudio::new_for_test(); // The Start tab blocks drawing commands — open a drawing first. app.automation_op(r#"{"op":"new"}"#); // The boot path may have restored a persisted value — normalize. app.pick_add = true; app.pick_drag_rect = false; let _ = app.run_command_line("PICKADD 0"); assert!(!app.pick_add, "PICKADD 0 must switch to replace mode"); let _ = app.run_command_line("PICKADD 1"); assert!(app.pick_add); // Two-step: bare command then the value, like typing 1 + Enter // (feed_active_cmd is the same path the GUI submit offers first). let _ = app.run_command_line("PICKDRAG"); assert!(app.tabs[app.active_tab].active_cmd.is_some(), "prompt must open"); app.feed_active_cmd("1"); assert!(app.pick_drag_rect, "PICKDRAG 1 via the prompt must switch"); } #[test] fn matchprop_matches_text_style_and_height() { // MATCHPROP between text objects must carry the text-specific // properties (style, height) to TEXT and MTEXT destinations, not just // the generic layer/color/linetype set. Regression for #361. use crate::command::StepInput; use acadrust::{EntityType, MText, Text}; let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; let mut src = Text::new(); src.value = "SRC".into(); src.height = 5.0; src.style = "BIG".into(); let src_h = app.tabs[i].scene.add_entity(EntityType::Text(src)); let mut dst_text = Text::new(); dst_text.value = "DST".into(); dst_text.height = 1.0; let dst_text_h = app.tabs[i].scene.add_entity(EntityType::Text(dst_text)); let mut dst_mtext = MText::new(); dst_mtext.value = "DSTM".into(); dst_mtext.height = 2.0; let dst_mtext_h = app.tabs[i].scene.add_entity(EntityType::MText(dst_mtext)); // Drive the interactive command exactly as the viewport does: // phase 1 source pick, phase 2 destination selection. let _ = app.run_command_line("MATCHPROP"); assert!(app.tabs[i].active_cmd.is_some(), "MATCHPROP must start"); let _ = app.feed_command(StepInput::EntityPick(src_h, glam::DVec3::ZERO)); let _ = app.feed_command(StepInput::SelectionComplete(vec![ dst_text_h, dst_mtext_h, ])); let doc = &app.tabs[i].scene.document; match doc.get_entity(dst_text_h) { Some(EntityType::Text(t)) => { assert_eq!(t.style, "BIG", "TEXT destination must take source style"); assert!( (t.height - 5.0).abs() < 1e-9, "TEXT destination must take source height, got {}", t.height ); } other => panic!("dest TEXT missing: {other:?}"), } match doc.get_entity(dst_mtext_h) { Some(EntityType::MText(m)) => { assert_eq!(m.style, "BIG", "MTEXT destination must take source style"); assert!( (m.height - 5.0).abs() < 1e-9, "MTEXT destination must take source height, got {}", m.height ); } other => panic!("dest MTEXT missing: {other:?}"), } } #[test] fn handing_over_an_already_open_drawing_switches_to_its_tab() { // Double-clicking a drawing that is already open should land on the tab // showing it, not load a second copy of the same file. use crate::app::Message; let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let path = std::env::temp_dir().join("ocs_already_open.dwg"); std::fs::write(&path, b"x").unwrap(); let canon = std::fs::canonicalize(&path).unwrap(); // Two tabs, the second holding the drawing; leave the first active. app.tabs .push(crate::app::document::DocumentTab::new_drawing(99)); let target = app.tabs.len() - 1; app.tabs[target].current_path = Some(canon.clone()); app.active_tab = 0; let _ = app.update(Message::OpenExternal(canon.clone())); assert_eq!(app.active_tab, target, "should have switched to the tab"); assert!( app.opening.is_none(), "an already-open drawing must not start a load" ); assert!(app.pending_opens.is_empty(), "and must not queue one either"); // The same file spelled differently (a `..` hop) is still the same file. let indirect = canon.parent().unwrap().join("..").join( canon .strip_prefix(canon.parent().unwrap().parent().unwrap()) .unwrap(), ); app.active_tab = 0; let _ = app.update(Message::OpenExternal(indirect)); assert_eq!( app.active_tab, target, "an unresolved spelling of the same path must still match the tab" ); assert!(app.opening.is_none(), "still no second load"); let _ = std::fs::remove_file(&path); } #[test] fn a_second_handoff_queues_instead_of_displacing_the_first() { // `opening` is one slot, and `on_file_opened` drops any result that // arrives once it is clear — so without the queue, two drawings handed // over at the same moment (select several files in a file manager: one // process each, all arriving together) would leave one tab and silently // lose the rest. use crate::app::Message; let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); // Any existing file will do: OpenRecent only stats it, and the actual // load is an async Task this test drops. let dir = std::env::temp_dir(); let (a, b) = (dir.join("ocs_si_a.dwg"), dir.join("ocs_si_b.dwg")); std::fs::write(&a, b"x").unwrap(); std::fs::write(&b, b"x").unwrap(); let _ = app.update(Message::OpenExternal(a.clone())); assert!( app.opening.is_some(), "first handoff should start an open, not queue" ); assert!(app.pending_opens.is_empty(), "nothing to queue yet"); let _ = app.update(Message::OpenExternal(b.clone())); assert_eq!( app.pending_opens.len(), 1, "second handoff arriving mid-open must queue, not be dropped" ); assert_eq!(app.pending_opens.front(), Some(&b)); // A failed drawing pauses the queue while its recovery report is shown. let open_id = app.opening.as_ref().map(|opening| opening.id).unwrap(); let _ = app.update(Message::FileOpened(open_id, Err("boom".into()))); assert!( app.active_modal == Some(crate::app::ModalKind::Recovery), "failed open should show its recovery report" ); assert_eq!( app.pending_opens.len(), 1, "queued drawing should wait until the report is acknowledged" ); let _ = app.update(Message::RecoveryClose); assert!( app.pending_opens.is_empty(), "closing the report must release the queued drawing" ); let _ = std::fs::remove_file(&a); let _ = std::fs::remove_file(&b); } #[test] fn save_then_open_round_trips() { let mut app = OpenCADStudio::new_for_test(); let path = std::env::temp_dir().join("ocs_automation_test.dxf"); let p = path.to_string_lossy().replace('\\', "\\\\"); app.automation_op(r#"{"op":"new"}"#); assert_eq!( app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"], true ); assert_eq!( app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"], true ); let _ = std::fs::remove_file(&path); } }