Complete Tolerance host model coverage

This commit is contained in:
Felix Riestra committed 2026-09-19 07:33:19 +02:00
1 parent 0304596cf8
commit 3387031038
6 files changed
+205 -10

No files matched your search

+3 -1
View File
@@ -583,7 +583,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
("Solid", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "normal" | "thickness") |
("Face3D", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "invisible_edges") |
("Insert", "insert_point" | "x_scale" | "y_scale" | "z_scale" | "rotation" | "normal" |
"column_count" | "row_count" | "column_spacing" | "row_spacing")) {
"column_count" | "row_count" | "column_spacing" | "row_spacing") |
("Tolerance", "insertion_point" | "direction" | "normal" | "text" |
"dimension_style_name" | "text_height" | "dimension_gap")) {
"transaction_geometry"
} else { "type_conversion_only" };
properties.push(PropertyCoverage {
@@ -18,10 +18,12 @@
"XLine": ["layer", "base_point", "direction"],
"Solid": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "normal", "thickness", "is_trace"],
"Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"],
"Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"]
"Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"],
"Tolerance": ["layer", "insertion_point", "direction", "normal", "text", "dimension_style_name", "text_height", "dimension_gap"]
},
"readable": {
"Insert": ["block_name"]
"Insert": ["block_name"],
"Tolerance": ["dimension_style_handle"]
},
"aliases": {
"Polyline": {"closed": "flags"},
+74 -1
View File
@@ -79,6 +79,17 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<()
return Err("Face3D.invisible_edges has unknown bits".into());
}
}
EntityType::Tolerance(value) => {
finite_vector("Tolerance.insertion_point", &value.insertion_point)?;
unit_direction("Tolerance.direction", &value.direction)?;
solid_normal("Tolerance.normal", &value.normal)?;
if value.text.trim().is_empty() { return Err("Tolerance.text is empty".into()); }
if value.dimension_style_name.trim().is_empty() { return Err("Tolerance.dimension_style_name is empty".into()); }
if !value.text_height.is_finite() || value.text_height <= 0.0 {
return Err("Tolerance.text_height must be finite and greater than zero".into());
}
if !value.dimension_gap.is_finite() { return Err("Tolerance.dimension_gap must be finite".into()); }
}
_ => {}
}
Ok(())
@@ -201,11 +212,49 @@ pub fn validate_entity_mutation(
return Err("Insert array counts must be greater than zero".into());
}
}
(EntityType::Tolerance(old), EntityType::Tolerance(new)) => {
changed3("Tolerance.insertion_point", &old.insertion_point, &new.insertion_point)?;
if !same3(&old.direction, &new.direction) { unit_direction("Tolerance.direction", &new.direction)?; }
if !same3(&old.normal, &new.normal) { solid_normal("Tolerance.normal", &new.normal)?; }
if old.text != new.text && new.text.trim().is_empty() { return Err("Tolerance.text is empty".into()); }
if old.dimension_style_name != new.dimension_style_name && new.dimension_style_name.trim().is_empty() {
return Err("Tolerance.dimension_style_name is empty".into());
}
if old.text_height.to_bits() != new.text_height.to_bits()
&& (!new.text_height.is_finite() || new.text_height <= 0.0) {
return Err("Tolerance.text_height must be finite and greater than zero".into());
}
if old.dimension_gap.to_bits() != new.dimension_gap.to_bits() && !new.dimension_gap.is_finite() {
return Err("Tolerance.dimension_gap must be finite".into());
}
if old.dimension_style_handle != new.dimension_style_handle
&& !(old.dimension_style_name != new.dimension_style_name && new.dimension_style_handle.is_none()) {
return Err("Tolerance.dimension_style_handle is read-only".into());
}
}
_ => {}
}
Ok(())
}
/// Validate references which require the surrounding document. Keeping this
/// in the host API gives every scripting adapter the same reference rules.
#[cfg(feature = "host")]
pub fn validate_canvas_entity_references(
document: &crate::host::CadDocument,
entity: &crate::host::EntityType,
) -> Result<(), String> {
use crate::host::EntityType;
if let EntityType::Tolerance(value) = entity {
let found = value.dimension_style_handle.filter(|handle| !handle.is_null()).map_or_else(
|| document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(value.dimension_style_name.trim())),
|handle| document.dim_styles.iter().any(|style| style.handle == handle),
);
if !found { return Err(format!("Tolerance dimension style {:?} does not exist", value.dimension_style_name)); }
}
Ok(())
}
/// Clone a canvas entity with a new layer, preserving its other snapshot
/// fields. Internal records and opaque fallbacks are excluded.
#[cfg(feature = "host")]
@@ -256,7 +305,7 @@ mod tests {
if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(),
"Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" |
"Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" |
"Ray" | "XLine" | "Solid" | "Face3D" | "Insert") {
"Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance") {
assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite
|| (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind);
}
@@ -288,9 +337,33 @@ mod tests {
"Insert.rotation".to_owned(), "Insert.normal".to_owned(),
"Insert.column_count".to_owned(), "Insert.row_count".to_owned(),
"Insert.column_spacing".to_owned(), "Insert.row_spacing".to_owned(),
"Tolerance.insertion_point".to_owned(), "Tolerance.direction".to_owned(),
"Tolerance.normal".to_owned(), "Tolerance.text".to_owned(),
"Tolerance.dimension_style_name".to_owned(), "Tolerance.text_height".to_owned(),
"Tolerance.dimension_gap".to_owned(),
]));
}
#[cfg(feature = "host")]
#[test]
fn tolerance_geometry_and_style_references_are_validated() {
use crate::host::acadrust::{self, types::Vector3};
let document = acadrust::CadDocument::new();
let mut tolerance = acadrust::entities::Tolerance::with_text(
Vector3::new(1.0, 2.0, 0.0), "{\\Fgdt;p}%%v0.1");
let entity = acadrust::EntityType::Tolerance(tolerance.clone());
validate_new_canvas_entity(&entity).unwrap();
validate_canvas_entity_references(&document, &entity).unwrap();
tolerance.direction = Vector3::new(2.0, 0.0, 0.0);
assert!(validate_new_canvas_entity(&acadrust::EntityType::Tolerance(tolerance.clone()))
.unwrap_err().contains("unit vector"));
tolerance.direction = Vector3::UNIT_X;
tolerance.dimension_style_name = "Missing".into();
assert!(validate_canvas_entity_references(
&document, &acadrust::EntityType::Tolerance(tolerance))
.unwrap_err().contains("does not exist"));
}
#[test]
fn layer_write_coverage_matches_canvas_scope() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
@@ -105,3 +105,5 @@ Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate
- The opt-in `staged_python_point_pick_and_cancel_over_real_ipc` test passes with the same runner. It requests point and entity picks through Python, drives the app's active-command point/object-pick callbacks, polls the returned tokens through a later Python run, and verifies Enter cancellation. The callbacks are invoked by the test harness, not by a physical GUI pointer event; live GUI verification remains open.
- The opt-in `staged_python_tabs_isolate_tokens_and_notifications` test passes with two real OCS document tabs and the staged runner. A token created in tab 0 cannot be observed or consumed in tab 1 before or after completion. Host-to-plugin DrawingChanged notifications for both tab ids are delivered over IPC and drained only by their owning tab. A 257-event burst reports overflow, and `DocumentTabClosed` discards queued events for that tab.
- Added `doc.create_entity(kind, **properties)` and `doc.delete_entity(entity_or_handle)` to the high-level Python document model. Unit tests cover forwarding and managed identity fields. The real staged-plugin Line lifecycle now uses these methods rather than low-level `ocs.add`/`remove_entity`.
- Closed the first new-kind increment for `Tolerance`. The host coverage catalog now maps insertion point, direction, normal, text, dimension style name, text height, and gap; validates geometry and document style references; keeps the style handle read-only; and leaves the undocumented DWG short unmapped. The thin adapter adds generated conversion plus stale style-handle clearing when the style name changes.
- `staged_python_tolerance_lifecycle_over_real_ipc` passes the full C/R/E/D/U/I/W/V/P gate with the actual runner: high-level create/read/transaction edit/delete, live canvas selection, undo/redo, invalid direction and missing style rollback, untouched-field preservation, and DWG/DXF reopen. Tolerance is recorded as **Complete** in the ledger. The queue now continues with `Shape`.
+3 -3
View File
@@ -1,13 +1,13 @@
# OCS canvas kind coverage ledger
Use with [the completion plan](plugin-host-model-completion-plan.md). The baseline below is conservative as of 19 September 2026. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`.
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **18 of 43 canvas kinds have explicit geometry writes** (17 creatable, plus update-only `Insert`), **1 kind is complete**, and 25 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`.
Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = portable H7 v5 build. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture.
| Kind | Baseline | Gate evidence / blocker |
|---|---|---|
| Point | Mapped | Host transaction test now checks invalid batch rollback, grouped edit undo, and redo; creation/deletion history, real plugin execution, GUI, and DWG/DXF gates remain pending. |
| Line | Integration-tested | High-level document-model create/delete and transaction edit pass through the actual staged-plugin IPC runner; three undo entries, undo/redo, duplicate-handle atomic rejection, and edited DWG/DXF reopen pass in `staged_python_plugin_line_lifecycle_over_real_ipc`. GUI pick/visible-geometry observation and broader invalid-value/unmapped-field preservation checks remain pending; not complete. |
| Line | Integration-tested | High-level document-model create/delete and transaction edit pass through the actual staged-plugin IPC runner; three undo entries, undo/redo, duplicate-handle and nonfinite-coordinate rejection, full typed-entity preservation, Line object-pick routing, and edited DWG/DXF reopen pass. A second editable property outside defining endpoint geometry still needs integration coverage before this row is complete. |
| Circle | Mapped | Full gate pending. |
| Arc | Mapped | Full gate pending. |
| Ellipse | Mapped | Existing focused DWG conversion test; full gate pending. |
@@ -23,7 +23,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea
| Solid | Mapped | Focused DWG round trip; full gate pending. |
| Face3D | Mapped | Focused DWG round trip; full gate pending. |
| Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. |
| Tolerance | Layer-only | Next new kind after integration harness. |
| Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. |
| Shape | Layer-only | Requires valid shape/style fixture. |
| AttributeDefinition | Layer-only | Requires block-definition fixture. |
| AttributeEntity | Layer-only | Requires linked Insert and attribute-sequence fixture. |
+119 -3
View File
@@ -154,6 +154,9 @@ impl<'a> HostSession<'a> {
}
ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity)
.map_err(|error| format!("entity {handle:?}: {error}"))?;
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(
self.document(), entity,
).map_err(|error| format!("entity {handle:?}: {error}"))?;
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return Err(format!("entity {handle:?} is on a locked layer"));
}
@@ -641,7 +644,7 @@ fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::comm
mod tests {
use super::*;
use crate::app::OpenCADStudio;
use acadrust::entities::Point;
use acadrust::entities::{Line, Point};
use acadrust::xdata::XDataValue;
use ocs_plugin_api::host::DocumentReader;
@@ -742,11 +745,23 @@ mod tests {
assert_eq!(handles.len(), 1, "Python add did not reach the host");
let handle = handles[0];
assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0));
let before_edit = host.document().get_entity(handle).unwrap().clone();
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Move line', [{{'handle':{},'end':{{'x':5.0,'y':0.0,'z':0.0}}}}])",
handle.value()
));
assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0));
let mut expected_after_edit = before_edit;
let EntityType::Line(expected_line) = &mut expected_after_edit else { unreachable!() };
expected_line.end.x = 5.0;
assert_eq!(host.document().get_entity(handle), Some(&expected_after_edit),
"partial Python edit must preserve every unmentioned common and geometry field");
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject NaN', [{{'handle':{},'start':{{'x':float('nan'),'y':0.0,'z':0.0}}}}])",
handle.value()
));
assert_eq!(host.document().get_entity(handle), Some(&expected_after_edit),
"invalid geometry changed the line");
assert!(host.app.command_line.history.last().unwrap().text.contains("finite coordinates"));
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject duplicate', [{{'handle':{},'end':{{'x':8.0,'y':0.0,'z':0.0}}}},{{'handle':{},'end':{{'x':9.0,'y':0.0,'z':0.0}}}}])",
handle.value(), handle.value()
@@ -778,6 +793,104 @@ mod tests {
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_tolerance_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; };
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host,
crate::plugin::v4_support::notification_handler(),
).expect("spawn staged Python plugin");
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
dispatch(&mut host, concat!(
"PY_EVAL ocs.active_document.create_entity('Tolerance',",
"insertion_point={'x':1.0,'y':2.0,'z':0.0},",
"text='POSITION%%v0.1').handle"
));
let handle = host.document().entities().next().expect("Python created a Tolerance")
.common().handle;
let Some(EntityType::Tolerance(created)) = host.document().get_entity(handle) else {
panic!("expected Tolerance");
};
assert_eq!(created.insertion_point, acadrust::types::Vector3::new(1.0, 2.0, 0.0));
assert_eq!(created.dimension_style_name, "Standard");
let created = created.clone();
dispatch(&mut host, &format!(
"PY_EVAL ocs.active_document.entities[{}].text", handle.value()));
assert!(host.app.command_line.history.last().unwrap().text.contains("POSITION%%v0.1"));
let script_path = std::env::temp_dir().join(format!(
"ocs_tolerance_document_model_{}.py", std::process::id()));
std::fs::write(&script_path, format!(concat!(
"doc = ocs.active_document\n",
"tolerance = doc.entities[{}]\n",
"with doc.transaction('Edit tolerance'):\n",
" tolerance.insertion_point = (4.0, 5.0, 0.0)\n",
" tolerance.direction = (0.0, 1.0, 0.0)\n",
" tolerance.text = 'POSITION%%v0.2'\n",
"doc.selection = [tolerance]\n"
), handle.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script_path.display()));
let _ = std::fs::remove_file(&script_path);
let expected = host.document().get_entity(handle).unwrap().clone();
let EntityType::Tolerance(edited) = &expected else { unreachable!() };
assert_eq!(edited.insertion_point, acadrust::types::Vector3::new(4.0, 5.0, 0.0));
assert_eq!(edited.direction, acadrust::types::Vector3::UNIT_Y);
assert_eq!(edited.text, "POSITION%%v0.2");
assert_eq!(edited.common, created.common);
assert_eq!(edited.normal, created.normal);
assert_eq!(edited.dimension_style_name, created.dimension_style_name);
assert_eq!(edited.dimension_style_handle, created.dimension_style_handle);
assert_eq!(edited.text_height, created.text_height);
assert_eq!(edited.dimension_gap, created.dimension_gap);
assert_eq!(edited.dwg_unknown_short, created.dwg_unknown_short);
assert_eq!(host.selection(), vec![handle], "Python selection did not reach the canvas");
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject direction', [{{'handle':{},'direction':{{'x':2.0,'y':0.0,'z':0.0}}}}])",
handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected));
assert!(host.app.command_line.history.last().unwrap().text.contains("unit vector"));
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'dimension_style_name':'Missing'}}])",
handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected));
assert!(host.app.command_line.history.last().unwrap().text.contains("does not exist"));
let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap();
let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes)).read().unwrap();
assert!(matches!(dwg_doc.get_entity(handle), Some(EntityType::Tolerance(value))
if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2"));
let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap();
let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes))
.unwrap().read().unwrap();
assert!(matches!(dxf_doc.get_entity(handle), Some(EntityType::Tolerance(value))
if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2"));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value()));
assert!(host.document().get_entity(handle).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected));
app.undo_steps(1);
assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Tolerance(value))
if value.insertion_point.x == 1.0 && value.text == "POSITION%%v0.1"));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_point_pick_and_cancel_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; };
@@ -823,7 +936,10 @@ mod tests {
let target = {
let mut host = HostSession::new(&mut app, 0);
host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(6.0, 7.0, 0.0))))
host.add_entity(EntityType::Line(Line::from_points(
acadrust::types::Vector3::new(6.0, 7.0, 0.0),
acadrust::types::Vector3::new(9.0, 7.0, 0.0),
)))
};
let entity_token = request(&mut app, "Pick an entity", true);
assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick());