forked from 1b-agent/opencadstudio-upstream
Create and serialize splines through automation # Conflicts: # src/app/control/entities.rs
972 lines
39 KiB
Rust
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"]
|
|
);
|
|
}
|
|
}
|