Files
OneCAD-Draft/src/app/control/p1_tests.rs
T
Hakan Seven cdf8e60fbf Merge pull request #1688 from YsDirector/pr/d3-spline-api
Create and serialize splines through automation

# Conflicts:
#	src/app/control/entities.rs
2026-10-11 23:26:06 +03:00

972 lines
39 KiB
Rust

//! Integration tests for the P1/P2 automation operations — template flows,
//! close, sysvar, layouts/page setups, cross-document copy, groups,
//! selection sets, per-page plotting, where filters and hatch readout.
#![cfg(test)]
use crate::app::OpenCADStudio;
use serde_json::{json, Value};
/// Envelope mutation against the active document: fills `{doc}` with the
/// document_id read from state, like every external client must.
fn mutate(app: &mut OpenCADStudio, request: &str) -> Value {
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
app.automation_op(&request.replacen("{doc}", &doc.to_string(), 1))
}
fn count_type(app: &mut OpenCADStudio, kind: &str) -> u64 {
let q = app.automation_op(&format!(
r#"{{"op":"query","type":"{kind}","detail":"summary"}}"#
));
q["count"].as_u64().unwrap()
}
#[test]
fn query_where_filters_on_entity_properties() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 1"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 20,0 5"}"#);
// radius > 2 keeps only the big circle (the filter runs over
// RFC 6901 property paths, same operators as records).
let q = app.automation_op(
r#"{"op":"query","type":"Circle","detail":"geometry","where":[{"path":"/radius","op":"gt","value":2}]}"#,
);
assert_eq!(q["ok"], true, "{}", q["error"]);
assert_eq!(q["count"], 1);
assert_eq!(q["entities"][0]["radius"], 5.0);
// An invalid pointer is refused up front, as is an unknown operator.
let q = app.automation_op(
r#"{"op":"query","type":"Circle","where":[{"path":"radius","op":"eq","value":1}]}"#,
);
assert_eq!(q["ok"], false);
let q = app.automation_op(
r#"{"op":"query","type":"Circle","where":[{"path":"/radius","op":"between","value":1}]}"#,
);
assert_eq!(q["ok"], false);
}
#[test]
fn close_op_guards_dirty_documents_and_discards() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,1"}"#);
// Dirty without discard: refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"close","request_id":"cl0","document_id":{doc}}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "document_dirty");
// Discard closes the tab (single tab → replaced by a fresh drawing).
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"close","request_id":"cl1","document_id":{doc},"discard":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["closed"], true);
assert_eq!(
app.tabs[app.active_tab].scene.document.entities().count(),
0
);
// Unknown document id: document_closed.
let r =
app.automation_op(r#"{"protocol":1,"op":"close","request_id":"cl2","document_id":9999}"#);
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "document_closed");
}
#[test]
fn sysvar_round_trips_and_refuses_unknowns() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv1","document_id":{doc},"set":{{"ltscale":2.5,"mirrtext":0}}}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
let header = &app.tabs[app.active_tab].scene.document.header;
assert!((header.linetype_scale - 2.5).abs() < 1e-9);
assert!(!header.mirror_text);
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv2","document_id":{doc},"get":["ltscale","mirrtext"]}}"#
));
assert_eq!(r["result"]["values"]["ltscale"], 2.5);
assert_eq!(r["result"]["values"]["mirrtext"], 0);
// clayer only accepts an existing layer.
mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"sv2b","document_id":{doc},"entities":[{"type":"Point","location":[0,0],"layer":"Walls"}]}"#,
);
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv3","document_id":{doc},"set":{{"clayer":"Walls"}}}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(
app.tabs[app.active_tab]
.scene
.document
.header
.current_layer_name,
"Walls"
);
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv4","document_id":{doc},"set":{{"clayer":"NOPE"}}}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "invalid_sysvar_value");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv5","document_id":{doc},"set":{{"not_a_sysvar":1}}}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "unknown_sysvar");
}
#[test]
fn sysvar_batches_validate_atomically_and_undo_as_one_step() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"sv-atomic-layer","document_id":{doc},"entities":[{"type":"Point","location":[0,0],"layer":"Walls"}]}"#,
);
let i = app.active_tab;
// Isolate this operation's dirty/history assertions from the layer setup.
app.tabs[i].dirty = false;
let history_before = app.tabs[i].history.undo_stack.len();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv-atomic-ok","document_id":{doc},"set":{{"ltscale":2.5,"clayer":"Walls"}}}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(app.tabs[i].scene.document.header.current_layer_name, "Walls");
assert_eq!(app.tabs[i].scene.document.header.linetype_scale, 2.5);
assert_eq!(app.tabs[i].active_layer, "Walls");
assert_eq!(app.tabs[i].layers.current_layer, "Walls");
assert_eq!(app.ribbon.active_layer, "Walls");
assert!(app.tabs[i].dirty);
assert_eq!(app.tabs[i].history.undo_stack.len(), history_before + 1);
// A bad later value must not leak the valid earlier value or an undo entry.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv-atomic-bad","document_id":{doc},"set":{{"ltscale":9.0,"clayer":"NOPE"}}}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "invalid_sysvar_value");
assert_eq!(app.tabs[i].scene.document.header.linetype_scale, 2.5);
assert_eq!(app.tabs[i].scene.document.header.current_layer_name, "Walls");
assert_eq!(app.tabs[i].history.undo_stack.len(), history_before + 1);
// One undo restores both header variables and all current-layer mirrors.
app.automation_op(r#"{"op":"undo"}"#);
assert_eq!(app.tabs[i].scene.document.header.linetype_scale, 1.0);
assert_eq!(app.tabs[i].scene.document.header.current_layer_name, "0");
assert_eq!(app.tabs[i].active_layer, "0");
assert_eq!(app.tabs[i].layers.current_layer, "0");
assert_eq!(app.ribbon.active_layer, "0");
}
#[test]
fn layout_create_and_page_setup_configure_sheets() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"layout_create","request_id":"lc1","document_id":{doc},"name":"PLAN"}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert!(r["result"]["layouts"]
.as_array()
.unwrap()
.iter()
.any(|n| n == "PLAN"));
// Duplicate refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"layout_create","request_id":"lc2","document_id":{doc},"name":"PLAN"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "layout_exists");
// Page setup on the new layout: A4 landscape, fit, centered.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"page_setup_set","request_id":"ps1","document_id":{doc},"layout":"PLAN","paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
let ps = app.tabs[app.active_tab]
.scene
.plot_settings_for("PLAN")
.expect("stored page setup");
assert!(ps.paper_size.contains("ISO_A4"));
assert!(ps.flags.plot_centered);
assert!(ps.flags.use_standard_scale);
// Bad scale string refused; unknown layout refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"page_setup_set","request_id":"ps2","document_id":{doc},"layout":"PLAN","scale":"nonsense"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "invalid_scale");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"page_setup_set","request_id":"ps3","document_id":{doc},"layout":"GHOST"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "layout_missing");
}
#[test]
fn new_from_template_and_dwt_save_round_trip() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#);
let dwt = std::env::temp_dir().join(format!("ocs_tpl_{}.dwt", std::process::id()));
let _ = std::fs::remove_file(&dwt);
let p = dwt.to_string_lossy().replace('\\', "\\\\");
let saved = app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#));
assert_eq!(saved["ok"], true, "{}", saved["error"]);
assert!(dwt.exists(), ".dwt file written");
// A fresh template-based drawing carries the template's entities and
// no source path. The read may race the file-system rename on Windows.
let mut r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"new","request_id":"nt1","template":"{p}"}}"#
));
for attempt in 0..5 {
if r["ok"] == true {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"new","request_id":"nt-retry-{attempt}","template":"{p}"}}"#
));
}
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["total"], 1);
assert!(app.tabs[app.active_tab].current_path.is_none());
drop(app);
let sidecar = dwt.with_file_name(format!(
".{}.ocs.lock",
dwt.file_name().unwrap().to_string_lossy()
));
let _ = std::fs::remove_file(sidecar);
let _ = std::fs::remove_file(&dwt);
}
#[test]
fn wblock_with_template_base_keeps_template_tables() {
// The template drawing carries layer TPL; the exported entity set lands
// in a copy of that template so TPL survives (Catalog §2.1).
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"wt0","document_id":{doc},"entities":[{"type":"Line","start":[0,0],"end":[5,5],"layer":"TPL"}]}"#,
);
let tpl = std::env::temp_dir().join(format!("ocs_wbtpl_{}.dwg", std::process::id()));
let _ = std::fs::remove_file(&tpl);
let tp = tpl.to_string_lossy().replace('\\', "\\\\");
assert_eq!(
app.automation_op(&format!(r#"{{"op":"save","path":"{tp}"}}"#))["ok"],
true
);
// A new drawing holds the one entity to export.
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 1,1 2"}"#);
let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)
["entities"][0]["handle"]
.as_str()
.unwrap()
.to_owned();
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let out = std::env::temp_dir().join(format!("ocs_wb_out_{}.dxf", std::process::id()));
let _ = std::fs::remove_file(&out);
let op = out.to_string_lossy().replace('\\', "\\\\");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"wblock","request_id":"wt1","document_id":{doc},"path":"{op}","template":"{tp}","handles":["{handle}"]}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
// The export contains the circle AND the template's TPL layer.
let opened = app.automation_op(&format!(r#"{{"op":"open","path":"{op}"}}"#));
assert_eq!(opened["ok"], true);
let layers = app.automation_op(r#"{"op":"layers"}"#);
assert!(layers["layers"]
.as_array()
.unwrap()
.iter()
.any(|l| l["name"] == "TPL"));
drop(app);
for path in [tpl, out] {
let sidecar = path.with_file_name(format!(
".{}.ocs.lock",
path.file_name().unwrap().to_string_lossy()
));
let _ = std::fs::remove_file(sidecar);
let _ = std::fs::remove_file(&path);
}
}
#[test]
fn entities_copy_to_another_open_document() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#);
let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)
["entities"][0]["handle"]
.as_str()
.unwrap()
.to_owned();
// A second document with its own layer table.
app.tabs
.push(crate::app::document::DocumentTab::new_drawing(99));
let target = app.tabs[1].id;
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"entities_copy_to","request_id":"cp1","document_id":{target},"handles":["{handle}"]}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["count"], 1);
// Source keeps the original; the target holds the fresh copy.
assert_eq!(count_type(&mut app, "Circle"), 1);
app.active_tab = 1;
assert_eq!(count_type(&mut app, "Circle"), 1);
app.active_tab = 0;
// A missing target is refused.
let r = app.automation_op(
r#"{"protocol":1,"op":"entities_copy_to","request_id":"cp2","document_id":4242,"handles":["01"]}"#,
);
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "document_closed");
}
#[test]
fn group_create_and_selection_sets() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,0"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#);
let q = app.automation_op(r#"{"op":"query","detail":"summary"}"#);
let handles: Vec<String> = q["entities"]
.as_array()
.unwrap()
.iter()
.map(|e| format!("\"{}\"", e["handle"].as_str().unwrap()))
.collect();
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"group_create","request_id":"g1","document_id":{doc},"name":"FRAME","handles":[{}]}}"#,
handles.join(",")
));
assert_eq!(r["ok"], true, "{}", r["error"]);
let objects = &app.tabs[app.active_tab].scene.document.objects;
assert!(objects.values().any(|o| matches!(
o,
codec::objects::ObjectType::Group(group) if group.name == "FRAME"
)));
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"selection_set_save","request_id":"s1","document_id":{doc},"name":"frame-set","handles":[{}]}}"#,
handles.join(",")
));
assert_eq!(r["result"]["count"], 2);
// Load recalls (and selects by default).
app.automation_op(r#"{"op":"select","clear":true}"#);
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"selection_set_load","request_id":"s2","document_id":{doc},"name":"frame-set"}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["selected"], 2);
// Unknown set refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"selection_set_load","request_id":"s3","document_id":{doc},"name":"ghost"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "selection_set_missing");
}
#[test]
fn plot_per_page_writes_one_pdf_per_layout() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 100,80"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
app.automation_op(&format!(
r#"{{"protocol":1,"op":"layout_create","request_id":"pp0","document_id":{doc},"name":"PLAN"}}"#
));
let out = std::env::temp_dir().join(format!("ocs_pp_{}.pdf", std::process::id()));
let _ = std::fs::remove_file(&out);
let op = out.to_string_lossy().replace('\\', "\\\\");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"plot","request_id":"pp1","document_id":{doc},"path":"{op}","layout":"all","per_page":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
let files = r["result"]["files"].as_array().unwrap();
let expected = app.tabs[app.active_tab].scene.layout_names().len();
assert_eq!(files.len(), expected, "one PDF per layout");
for file in files {
let bytes = std::fs::read(file["path"].as_str().unwrap()).expect("per-page pdf");
assert!(bytes.starts_with(b"%PDF"));
let _ = std::fs::remove_file(file["path"].as_str().unwrap());
}
}
#[test]
fn entities_create_supports_control_and_fit_point_splines() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let control = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"spline-control","document_id":{doc},"entities":[
{"type":"Spline","degree":2,"knots":[0,0,0,1,1,1],"control_points":[[0,0,0],[5,5,0],[10,0,0]],"weights":[1,2,1],"layer":"CURVES"}
]}"#,
);
assert_eq!(control["ok"], true, "{control}");
let fit = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"spline-fit","document_id":{doc},"entities":[
{"type":"Spline","degree":3,"fit_points":[[20,0],[25,5],[30,0]]}
]}"#,
);
assert_eq!(fit["ok"], true, "{fit}");
let query = app.automation_op(r#"{"op":"query","type":"Spline","detail":"geometry"}"#);
assert_eq!(query["count"], 2, "{query}");
let control_entity = query["entities"]
.as_array()
.unwrap()
.iter()
.find(|entity| entity["layer"] == "CURVES")
.unwrap();
assert_eq!(control_entity["degree"], 2, "{control_entity}");
assert_eq!(control_entity["control_points"].as_array().unwrap().len(), 3);
assert_eq!(control_entity["weights"], json!([1.0, 2.0, 1.0]));
}
#[test]
fn hatch_boundary_is_flattened_in_query() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"h1","document_id":{doc},"entities":[
{"type":"Hatch","boundary":[[0,0],[10,0],[10,10],[0,10]],"solid":true}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
let q = app.automation_op(r#"{"op":"query","type":"Hatch","detail":"full"}"#);
assert_eq!(q["count"], 1);
let boundary = q["entities"][0]["boundary"].as_array().expect("boundary");
assert_eq!(boundary[0].as_array().unwrap().len(), 4);
}
#[test]
fn diagnostic_layer_survives_plain_io_round_trip() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"dg1","document_id":{doc},"entities":[
{"type":"Point","location":[1,1],"layer":"Walls"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["layers_created"], json!(["Walls"]));
// Direct io round trip on a clone of the document.
let doc = app.tabs[app.active_tab].scene.document.clone();
let bytes =
crate::io::save_to_bytes(&doc, "dxf", codec::DxfVersion::AC1032).expect("save to bytes");
let path = std::env::temp_dir().join(format!("ocs_diag_{}.dxf", std::process::id()));
std::fs::write(&path, &bytes).unwrap();
let loaded = crate::io::load_file(&path).expect("load back");
let _ = std::fs::remove_file(&path);
let names: Vec<String> = loaded.layers.iter().map(|l| l.name.clone()).collect();
assert!(
names.iter().any(|n| n == "Walls"),
"Walls lost in plain io round trip; layers = {names:?}"
);
}
#[test]
fn block_delete_and_define_replace_manage_definitions() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,0"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#);
let q = app.automation_op(r#"{"op":"query","detail":"summary"}"#);
let joined = q["entities"]
.as_array()
.unwrap()
.iter()
.map(|e| format!("\"{}\"", e["handle"].as_str().unwrap()))
.collect::<Vec<_>>()
.join(",");
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_define","request_id":"bl0","document_id":{doc},"name":"MARK","base":[0,0,0],"handles":[{joined}]}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(count_type(&mut app, "Insert"), 1);
// Re-import parity (CF-01.3): replace drops the previous definition,
// its children and its insert inside one undoable step.
app.automation_op(r#"{"op":"run","cmd":"POINT 9,9"}"#);
let handle = app.automation_op(r#"{"op":"query","type":"Point","detail":"summary"}"#)
["entities"][0]["handle"]
.as_str()
.unwrap()
.to_owned();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_define","request_id":"bl1","document_id":{doc},"name":"MARK","base":[0,0,0],"handles":["{handle}"],"replace":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(
count_type(&mut app, "Insert"),
1,
"one insert after replace"
);
assert_eq!(count_type(&mut app, "Line"), 0, "old children erased");
assert_eq!(count_type(&mut app, "Circle"), 0, "old children erased");
// Delete removes the insert, the markers, the children and the record.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl2","document_id":{doc},"name":"MARK"}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert!(
r["result"]["erased"].as_u64().unwrap() >= 3,
"insert + markers + child"
);
assert_eq!(count_type(&mut app, "Insert"), 0);
// Unknown names and protected layout records are refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl3","document_id":{doc},"name":"GHOST"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "block_missing");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl4","document_id":{doc},"name":"*Model_Space"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "block_protected");
}
#[test]
fn wblock_normalize_shifts_the_export_to_the_origin() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 100,200 110,210"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let handle = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#)
["entities"][0]["handle"]
.as_str()
.unwrap()
.to_owned();
let out = std::env::temp_dir().join(format!("ocs_wbnorm_{}.dxf", std::process::id()));
let _ = std::fs::remove_file(&out);
let op = out.to_string_lossy().replace('\\', "\\\\");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"wblock","request_id":"wn1","document_id":{doc},"path":"{op}","handles":["{handle}"],"normalize":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["normalized"], true);
assert_eq!(
app.automation_op(&format!(r#"{{"op":"open","path":"{op}"}}"#))["ok"],
true
);
let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"full"}"#);
assert_eq!(q["entities"][0]["start"], json!([0.0, 0.0, 0.0]));
assert_eq!(q["entities"][0]["end"], json!([10.0, 10.0, 0.0]));
let _ = std::fs::remove_file(&out);
}
#[test]
fn file_identity_is_stable_and_survives_a_round_trip() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r1 = mutate(
&mut app,
r#"{"protocol":1,"op":"file_identity","request_id":"fi1","document_id":{doc}}"#,
);
assert_eq!(r1["ok"], true, "{}", r1["error"]);
assert_eq!(r1["result"]["created"], true);
let identity = r1["result"]["identity"].as_str().unwrap().to_owned();
assert_eq!(identity.len(), 36, "RFC 4122 shape: {identity}");
assert_eq!(&identity[8..9], "-");
// Idempotent: the second call returns the same identity.
let r2 = mutate(
&mut app,
r#"{"protocol":1,"op":"file_identity","request_id":"fi2","document_id":{doc}}"#,
);
assert_eq!(r2["result"]["created"], false);
assert_eq!(r2["result"]["identity"], identity.as_str());
// renew mints a fresh one.
let r3 = mutate(
&mut app,
r#"{"protocol":1,"op":"file_identity","request_id":"fi3","document_id":{doc},"renew":true}"#,
);
let renewed = r3["result"]["identity"].as_str().unwrap().to_owned();
assert_ne!(renewed, identity);
// The marker survives a save → open round trip.
let path = std::env::temp_dir().join(format!("ocs_fid_{}.dwg", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = path.to_string_lossy().replace('\\', "\\\\");
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 r4 = mutate(
&mut app,
r#"{"protocol":1,"op":"file_identity","request_id":"fi4","document_id":{doc}}"#,
);
assert_eq!(r4["result"]["identity"], renewed.as_str());
drop(app);
let sidecar = path.with_file_name(format!(
".{}.ocs.lock",
path.file_name().unwrap().to_string_lossy()
));
let _ = std::fs::remove_file(sidecar);
let _ = std::fs::remove_file(&path);
}
#[test]
fn state_reports_the_hand_seed() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let before = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap();
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,1"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let after = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap();
assert!(after > before, "issuing a handle advances the seed");
}
#[test]
fn text_search_op_queries_text_and_attributes() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"ts_c1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"},
{"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;POMPE A EAU}","layer":"0"},
{"type":"Text","position":[20,0,0],"value":"BAROMETRE","layer":"0"},
{"type":"Text","position":[30,0,0],"value":"10 BAR","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Case-insensitive search finds CIRTUITS
let res = app.automation_op(r#"{"op":"text_search","find":"cirtuits"}"#);
assert_eq!(res["ok"], true);
assert_eq!(res["count"], 1);
assert_eq!(res["matches"][0]["match_text"], "cirtuits");
assert_eq!(res["matches"][0]["plain_text"], "CIRTUITS 28-09-2026");
// Case-sensitive search with wrong case finds nothing
let res_cs = app.automation_op(r#"{"op":"text_search","find":"cirtuits","match_case":true}"#);
assert_eq!(res_cs["ok"], true);
assert_eq!(res_cs["count"], 0);
// Whole-word search: "bar" matches "10 BAR" but not "BAROMETRE"
let res_ww = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":true}"#);
assert_eq!(res_ww["ok"], true);
assert_eq!(res_ww["count"], 1);
assert_eq!(res_ww["matches"][0]["plain_text"], "10 BAR");
// Non-whole-word search finds both "BAROMETRE" and "10 BAR"
let res_all = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":false}"#);
assert_eq!(res_all["ok"], true);
assert_eq!(res_all["count"], 2);
}
#[test]
fn text_replace_op_batch_replaces_preserves_formatting_and_undoes() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"tr_c1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"VANNE A ARRET","layer":"0"},
{"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic|b0|i0;V15-CUVE EAU NON TRAITE}","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Execute batch replace with 2 pairs in one atomic step
let rep = mutate(
&mut app,
r#"{"protocol":1,"op":"text_replace","request_id":"tr_r1","document_id":{doc},"pairs":[
{"find":"NON TRAITE","replace":"NON TRAITÉE"},
{"find":"VANNE A ARRET","replace":"VANNE D'ARRÊT"}
]}"#,
);
assert_eq!(rep["ok"], true, "{}", rep["error"]);
assert_eq!(rep["result"]["replaced"], 2);
assert_eq!(rep["result"]["entities_changed"], 2);
// Verify MText formatting codes were preserved
let q = app.automation_op(r#"{"op":"text_search","find":"NON TRAITÉE"}"#);
assert_eq!(q["ok"], true);
assert_eq!(q["count"], 1);
let raw = q["matches"][0]["raw_value"].as_str().unwrap();
assert!(
raw.contains("Century Gothic"),
"raw MText should keep font format code: {raw}"
);
assert!(
raw.contains("NON TRAITÉE"),
"raw MText should contain replaced text: {raw}"
);
// Verify Text was replaced
let q2 = app.automation_op(r#"{"op":"text_search","find":"VANNE D'ARRÊT"}"#);
assert_eq!(q2["ok"], true);
assert_eq!(q2["count"], 1);
// Undo restores the original text in one step
app.automation_op(r#"{"op":"undo"}"#);
let q_orig = app.automation_op(r#"{"op":"text_search","find":"NON TRAITE"}"#);
assert_eq!(q_orig["ok"], true);
assert_eq!(q_orig["count"], 1);
let q_orig2 = app.automation_op(r#"{"op":"text_search","find":"VANNE A ARRET"}"#);
assert_eq!(q_orig2["ok"], true);
assert_eq!(q_orig2["count"], 1);
}
#[test]
fn text_audit_op_simulates_dry_run_checks_terms_and_dictionary() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"ta_c1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"},
{"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;CUVE EAU NON TRAITE}","layer":"0"},
{"type":"Text","position":[20,0,0],"value":"POMPE A EAU","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Test text_audit with pairs, check_terms, and dictionary
let audit_res = app.automation_op(
r#"{
"op":"text_audit",
"pairs":[
{"find":"CIRTUITS","replace":"CIRCUITS"},
{"find":"NON TRAITE","replace":"NON TRAITÉE"},
{"find":"INEXISTANT_PAIR","replace":"REPLACEMENT"}
],
"check_terms":["CIRTUITS", "NON TRAITE", "INEXISTANT_TERM"],
"dictionary":["EAU", "POMPE", "28-09-2026"]
}"#,
);
assert_eq!(audit_res["ok"], true, "{}", audit_res["error"]);
// Check summary
assert_eq!(audit_res["summary"]["pairs_total"], 3);
assert_eq!(audit_res["summary"]["pairs_unmatched"], 1);
assert_eq!(audit_res["summary"]["check_terms_total"], 3);
assert_eq!(audit_res["summary"]["check_terms_found"], 2);
// Unmatched reporting
assert_eq!(audit_res["unmatched_pairs"], json!(["INEXISTANT_PAIR"]));
assert_eq!(
audit_res["unmatched_check_terms"],
json!(["INEXISTANT_TERM"])
);
// Simulated changes has 2 entries (Text and MText)
let changes = audit_res["simulated_changes"].as_array().unwrap();
assert_eq!(changes.len(), 2);
assert_eq!(changes[0]["find"], "CIRTUITS");
assert_eq!(changes[0]["replace"], "CIRCUITS");
assert_eq!(changes[0]["replaced"], 1);
assert!(changes[1]["after"]
.as_str()
.unwrap()
.contains("NON TRAITÉE"));
// Suspect matches
let suspects = audit_res["suspect_matches"].as_array().unwrap();
assert_eq!(suspects.len(), 2);
// Verify document was NOT mutated (pure read op)
let q = app.automation_op(r#"{"op":"text_search","find":"CIRTUITS"}"#);
assert_eq!(q["ok"], true);
assert_eq!(q["count"], 1);
}
#[test]
fn text_replace_op_supports_dry_run_without_mutating_or_undo() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"tr_dry1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"TEST BEFORE REPLACE","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Run text_replace with dry_run = true
let dry_res = mutate(
&mut app,
r#"{"protocol":1,"op":"text_replace","request_id":"tr_sim1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":true}"#,
);
assert_eq!(dry_res["ok"], true, "{}", dry_res["error"]);
assert_eq!(dry_res["result"]["dry_run"], true);
assert_eq!(dry_res["result"]["replaced"], 1);
assert_eq!(dry_res["result"]["entities_changed"], 1);
// Verify the document was NOT modified
let q_before = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#);
assert_eq!(q_before["ok"], true);
assert_eq!(q_before["count"], 1);
let q_after = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#);
assert_eq!(q_after["ok"], true);
assert_eq!(q_after["count"], 0);
// Now run with dry_run = false
let real_res = mutate(
&mut app,
r#"{"protocol":1,"op":"text_replace","request_id":"tr_real1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":false}"#,
);
assert_eq!(real_res["ok"], true, "{}", real_res["error"]);
assert_eq!(real_res["result"]["replaced"], 1);
// Now it was modified
let q_after2 = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#);
assert_eq!(q_after2["ok"], true);
assert_eq!(q_after2["count"], 1);
// Undo restores it
app.automation_op(r#"{"op":"undo"}"#);
let q_undone = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#);
assert_eq!(q_undone["ok"], true);
assert_eq!(q_undone["count"], 1);
}
#[test]
fn text_audit_system_spellcheck_flags_misspelled_words() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"sp_c1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"The cirtuit breaker is conected","layer":"0"},
{"type":"Text","position":[10,0,0],"value":"correct english text here","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Run text_audit with system_spellcheck enabled, English language,
// and an agent dictionary that whitelists "breaker"
let audit = app.automation_op(
r#"{
"op":"text_audit",
"system_spellcheck": true,
"language": "en-US",
"suggest": true,
"dictionary": ["breaker", "the"]
}"#,
);
assert_eq!(audit["ok"], true, "{}", audit["error"]);
// system_speller metadata should be present regardless of availability
let sp = &audit["system_speller"];
assert_eq!(sp["enabled"], true);
if sp["available"].as_bool() == Some(true) {
// Backend should be reported (e.g. "windows" on Windows)
assert!(sp["backend"].as_str().unwrap().len() > 0);
assert!(sp["language"].as_str().is_some());
// "cirtuit" and "conected" should be flagged as unrecognized
let words = audit["unrecognized_words"].as_array().unwrap();
let flagged: Vec<&str> = words.iter().filter_map(|w| w["word"].as_str()).collect();
assert!(
flagged.iter().any(|w| w.eq_ignore_ascii_case("cirtuit")),
"expected 'cirtuit' to be flagged, got: {:?}",
flagged
);
assert!(
flagged.iter().any(|w| w.eq_ignore_ascii_case("conected")),
"expected 'conected' to be flagged, got: {:?}",
flagged
);
// "breaker" should NOT be flagged (it's in the agent dictionary)
assert!(
!flagged.iter().any(|w| w.eq_ignore_ascii_case("breaker")),
"expected 'breaker' to be whitelisted by agent dict, got: {:?}",
flagged
);
// At least one flagged word should have suggestions — except from a
// bare Linux wordlist, which can tell a word is unknown but has no
// way to suggest one without the hunspell CLI.
let has_suggestions = words.iter().any(|w| {
w["suggestions"]
.as_array()
.map(|a| !a.is_empty())
.unwrap_or(false)
});
assert!(
has_suggestions || sp["backend"] == "linux_system",
"expected at least one word to have suggestions"
);
// Summary should reflect unrecognized words
assert!(
audit["summary"]["unrecognized_words_count"]
.as_u64()
.unwrap()
>= 2,
"expected at least 2 unrecognized words in summary"
);
} else {
// System speller not available (e.g. CI without spell-check service)
// Just verify the response structure is well-formed
assert!(audit["unrecognized_words"].is_array());
eprintln!(
"System spellcheck not available (backend: {}), skipping content assertions",
sp["backend"]
);
}
}