forked from 1b-agent/opencadstudio-upstream
Add host model integration compatibility tests
This commit is contained in:
1 parent
db61379bfe
commit
4dda2dace9
4 files changed
+446
No files matched your search
@@ -444,16 +444,21 @@ mod tests {
|
||||
|
||||
struct DummyHost {
|
||||
push_info_messages: StdMutex<Vec<String>>,
|
||||
transactions: StdMutex<Vec<(String, Vec<acadrust::EntityType>)>>,
|
||||
}
|
||||
impl DummyHost {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
push_info_messages: StdMutex::new(Vec::new()),
|
||||
transactions: StdMutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn take_push_info(&self) -> Vec<String> {
|
||||
std::mem::take(&mut *self.push_info_messages.lock().unwrap())
|
||||
}
|
||||
fn take_transactions(&self) -> Vec<(String, Vec<acadrust::EntityType>)> {
|
||||
std::mem::take(&mut *self.transactions.lock().unwrap())
|
||||
}
|
||||
}
|
||||
impl HostApi for DummyHost {
|
||||
fn tab_index(&self) -> usize {
|
||||
@@ -490,6 +495,14 @@ mod tests {
|
||||
false
|
||||
}
|
||||
fn push_undo(&mut self, _label: &str) {}
|
||||
fn update_entities_transaction(
|
||||
&mut self,
|
||||
label: &str,
|
||||
entities: Vec<acadrust::EntityType>,
|
||||
) -> Result<(), String> {
|
||||
self.transactions.lock().unwrap().push((label.to_owned(), entities));
|
||||
Ok(())
|
||||
}
|
||||
fn set_dirty(&mut self) {}
|
||||
fn push_info(&mut self, msg: &str) {
|
||||
self.push_info_messages.lock().unwrap().push(msg.to_string());
|
||||
@@ -661,6 +674,49 @@ mod tests {
|
||||
restore_test_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_connection_routes_entity_transaction_over_nested_ipc() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
set_test_env();
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||
Arc::new(|_, _| {});
|
||||
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||
let mut point = acadrust::entities::Point::new();
|
||||
point.common.handle = Handle::new(42);
|
||||
point.location.x = 7.0;
|
||||
let expected = acadrust::EntityType::Point(point);
|
||||
let sent = expected.clone();
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
let req = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||
let HostToPluginV4::Request { id, payload: HostRequest::Dispatch { .. } } = req else {
|
||||
panic!("unexpected host request: {req:?}");
|
||||
};
|
||||
send(&mut runner_stream, &PluginToHostV4::Request {
|
||||
id: 99,
|
||||
tab_id: Some(0),
|
||||
payload: PluginRequest::UpdateEntitiesTransaction {
|
||||
label: "Move point".into(),
|
||||
entities: vec![sent],
|
||||
},
|
||||
}).unwrap();
|
||||
assert!(matches!(recv::<HostToPluginV4>(&mut runner_stream).unwrap(),
|
||||
HostToPluginV4::Response { id: 99, payload: PluginResponse::EntityTransactionResult(Ok(())) }));
|
||||
send(&mut runner_stream, &PluginToHostV4::Response {
|
||||
id,
|
||||
payload: HostResponse::Bool(true),
|
||||
}).unwrap();
|
||||
});
|
||||
|
||||
let mut host = DummyHost::new();
|
||||
assert!(matches!(conn.call(&mut host, HostRequest::Dispatch { cmd: "MOVE".into() }, &mut |_| {}).unwrap(),
|
||||
HostResponse::Bool(true)));
|
||||
assert_eq!(host.take_transactions(), vec![("Move point".into(), vec![expected])]);
|
||||
runner.join().unwrap();
|
||||
restore_test_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_connection_notification_invokes_handler() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# OCS canvas entity completion plan
|
||||
|
||||
## Goal and current state
|
||||
|
||||
Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work.
|
||||
|
||||
As of 19 September 2026, the OCS fork branch `plugin/host-model-api` is pushed at `db61379b` in `felixriestra/OpenCADStudio`; the PandoraBox adapter on `main` is pushed at `d0cc85e` in `felixriestra/PandoraBox`. Host API is v7. The Python feature has mapped writes for 16 initial geometry kinds plus **update-only** `Insert`; this is **not** 17 completed kinds. The other 26 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds.
|
||||
|
||||
Repositories:
|
||||
|
||||
- OCS host: `/Users/felix/Documents/MacApps/OpenCADStudio`
|
||||
- Python adapter: `/Users/felix/Documents/MacApps/opencad-python/plugins/opencad-python`
|
||||
- Host coverage policy: `crates/ocs_plugin_api/entity_coverage_policy.json`
|
||||
- Host coverage/validation: `crates/ocs_plugin_api/src/entity_coverage.rs` and `build.rs`
|
||||
- Adapter mapping: `entity_manifest.json`, `build/generate.rs`, `src/ocs_module.rs`, `src/document_model.py`
|
||||
- Host design notes: `docs/plugin-host-model.md`
|
||||
|
||||
Preserve unrelated untracked files in both repositories. Push OCS changes only to the `felixriestra/OpenCADStudio` fork, not H7 upstream; do not open a PR without asking. The prior user approved pushing work to the existing OCS and PandoraBox repositories, but verify remote branch tips before each push.
|
||||
|
||||
## First: complete the originally agreed Phase 8
|
||||
|
||||
The earlier Phase 8 was **integration and compatibility**, not a new entity kind. `Insert` was added afterward as a separate coverage increment. Before marking any kind complete, establish an executable integration harness that exercises the actual plugin runner and IPC, not only direct Rust conversion helpers:
|
||||
|
||||
1. Stage the development v7 plugin with `bash plugins/opencad-python/tools/stage-host-model.sh <OCS checkout> <stage directory> [--debug]`. Verify the staged `plugin.toml` says API v7 and its acadrust source matches OCS. Keep checked-in PandoraBox `plugin.toml` and `Cargo.lock` on H7 API v5; keep `Cargo.toml` free of machine-local paths.
|
||||
2. Through real IPC, create, inspect, edit, and delete a representative entity. Verify an invalid multi-entity transaction changes nothing, a valid transaction produces one undo entry, and undo **and redo** restore the expected geometry and references.
|
||||
3. In the running GUI, exercise a real point pick, entity pick, cancellation/Escape, and plugin command cancellation. Verify drawing/selection/command notifications, including overflow recovery.
|
||||
4. Repeat an input request and event poll across two tabs; verify token and notification isolation and cleanup when a tab closes.
|
||||
5. Save and reopen representative edits as **DWG and DXF**, verifying the actual geometry and preserved unmapped fields. Use a real viewport/canvas check where the entity has visible geometry; a converter round trip alone does not satisfy this.
|
||||
6. Record platform, OCS/acadrust revisions, fixture files, commands, results, and any engine limitation. Keep repeatable tests in the relevant repositories.
|
||||
|
||||
Phase 8 is complete only when these checks pass without weakening the portable v5 build. If a test is blocked by OCS or acadrust behavior, record the specific failure and leave Phase 8 open.
|
||||
|
||||
## Per-kind completion gate
|
||||
|
||||
Use one checklist row per kind in a coverage ledger. Status values: `unstarted`, `mapped`, `integration-tested`, `complete`, or `blocked`; record the blocking dependency. **Do not use “complete” for read-only, update-only, or layer-only support.** For every kind:
|
||||
|
||||
1. **Declare coverage:** Every traced field has an accurate catalog status and validation rule. Distinguish ordinary properties from handles, owners, linked records, external assets, and opaque payloads. Snapshot-only fields remain explicitly unmapped. Assert adapter keys match the host catalog.
|
||||
2. **Create:** A Python script creates a valid entity through the document model in a real document with required tables, styles, definitions, files, and references. Missing or invalid required inputs fail before mutation. Add document-model creation/deletion methods if needed; current `doc.entities` only indexes and iterates, while low-level `ocs.add`/remove APIs exist.
|
||||
3. **Read and edit:** After creation, read the entity by handle through `doc.entities`; edit at least one defining geometry or placement field and one other meaningful writable field inside `doc.transaction`. Verify that all unmentioned fields, handles, ownership, attached records, and XDATA remain unchanged. Check invalid values and forbidden reference edits fail atomically.
|
||||
4. **Delete and history:** Delete the created entity through the Python interface. Prove create, edit, and delete have defined undo/redo behavior in the host, including grouped writes and failure rollback. Record any transaction API extension and bump the host API version if its ABI changes; keep older plugin compatibility.
|
||||
5. **Real IPC and canvas:** Run the above through the staged plugin and actual OCS IPC. Verify visibility/geometry in the canvas and selection or picking in the GUI. For nonvisual entities, verify their observable document or layout behavior and explain the oracle.
|
||||
6. **Persistence:** Save/reopen DWG **and** DXF fixtures. Compare kind, geometry, important metadata, handles/references, and preserved fields after reopen; edit again after reopen. Test at least one valid edge case for that kind. Do not count a JSON or in-memory round trip as file persistence.
|
||||
7. **Portable build:** Run feature tests with a command-line Cargo patch to the OCS fork, restore PandoraBox `Cargo.lock`, then run `cargo check --locked` against the checked-in H7 v5 dependency. Never check in a local Cargo path or v7 portable manifest.
|
||||
|
||||
If OCS/acadrust cannot create or serialize a kind reliably, mark it **blocked**, add a regression test or fixture demonstrating the failure, and move to the next independent kind. An explicit unsupported status is honest progress, not completion.
|
||||
|
||||
## Existing mapped kinds: audit before claiming completion
|
||||
|
||||
Audit these against the same gate: `Point`, `Line`, `Circle`, `Arc`, `Ellipse`, `Polyline`, `Polyline2D`, `Polyline3D`, `LwPolyline`, `Spline`, `Text`, `MText`, `Ray`, `XLine`, `Solid`, `Face3D`, `Insert`. `Insert` currently has transform edits but **no Python creation**; its block name is read-only and attached attributes are snapshot-only. Earlier tests include some direct DWG round trips, but there is no evidence that every mapped kind passes real IPC, GUI, deletion, undo/redo, and both file formats. Mark each independently after testing.
|
||||
|
||||
## Order for the 26 canvas kinds without mapped geometry writes
|
||||
|
||||
The order below groups shared implementation work. Complete a kind only through the gate above. Within a group, move past a blocked kind after recording the exact dependency. “Minimum exercise” identifies a real entity-specific create/edit/delete case; it does not make other fields automatically writable.
|
||||
|
||||
| # | Kind | Minimum entity-specific exercise and dependency |
|
||||
|---:|---|---|
|
||||
| 1 | Tolerance | Create a feature-control frame with text/style; move and edit its text/direction; check style reference and display. |
|
||||
| 2 | Shape | Create with an installed SHX shape/style; change insertion, size, rotation; verify the named glyph and style survive reopen. |
|
||||
| 3 | AttributeDefinition | Create in a block definition; edit tag, default text, and placement; verify the block’s definition and linked inserts. |
|
||||
| 4 | AttributeEntity | Create as a block reference attribute; edit value and placement; verify parent `Insert`, handles, and sequence linkage. |
|
||||
| 5 | Hatch | Create a closed-boundary solid hatch and a patterned hatch; edit boundary/pattern; verify fill and boundary validity. |
|
||||
| 6 | Leader | Create with vertices and an annotation; edit path and annotation placement; preserve annotation handle. |
|
||||
| 7 | MLine | Create against a valid MLine style; edit vertices and scale; verify style element offsets and joins. |
|
||||
| 8 | Dimension | Create at least one supported dimension subtype with style and reference points; edit defining geometry and displayed measurement. Track subtype coverage separately. |
|
||||
| 9 | MultiLeader | Create text and leader paths using a valid style; edit landing/vertices and text; preserve nested context and annotation links. |
|
||||
| 10 | Table | Create rows, columns, style, and cells; edit size and one cell value; preserve merged ranges and block/style links. |
|
||||
| 11 | PolygonMesh | Create a grid with defined M/N counts and vertices; edit a vertex and density; reject inconsistent counts. |
|
||||
| 12 | PolyfaceMesh | Create vertices/faces; edit a vertex and face indices; reject invalid indices and preserve sequence records. |
|
||||
| 13 | Mesh | Create vertices/faces/edges; edit one vertex and topology; reject out-of-range indices and verify rendered faces. |
|
||||
| 14 | Helix | Create with axis, radius, turns, and height; edit a defining parameter and verify the derived curve plus embedded spline data. |
|
||||
| 15 | RasterImage | Create with a resolvable image definition/file; edit placement, vectors, and clipping; verify display and definition handles. |
|
||||
| 16 | Wipeout | Create with a valid clip polygon; edit boundary and placement; verify masking, clip mode, and image-definition links. |
|
||||
| 17 | Underlay | Create with a valid PDF/DWF/DGN definition; edit transform and clipping; verify external resource and definition handle. |
|
||||
| 18 | Viewport | Create in the appropriate layout; edit center/size/view target; verify visible viewport and frozen-layer/clip references. |
|
||||
| 19 | ViewBorder | Create with its required view/scale references; edit border geometry; verify linked viewport/view records. |
|
||||
| 20 | SectionSymbol | Create with view style and point records; edit endpoints/label; preserve view-representation links. |
|
||||
| 21 | Light | Create a light with position/target; edit intensity and direction; verify color/photometric state and visible glyph. |
|
||||
| 22 | Region | Create/import a valid region payload; transform/edit supported geometry, then verify ACIS payload after both file formats. |
|
||||
| 23 | Body | Create/import a valid body payload; apply a supported transform/edit; verify ACIS and history/reference preservation. |
|
||||
| 24 | Solid3D | Create/import a valid solid; edit a supported geometric operation; verify ACIS, topology, and history after reopen. |
|
||||
| 25 | Surface | Create/import a valid surface; edit a supported parameter/transform; verify surface payload, isolines, and history. |
|
||||
| 26 | Ole2Frame | Create with a valid embedded object payload; edit frame geometry; verify storage, aspect behavior, and persistence. |
|
||||
|
||||
The last five may require new engine capabilities rather than only Python converters. Do not deserialize and rewrite their opaque payloads unless lossless behavior is proven. If a listed kind has no meaningful safe Python creation/edit path, keep it marked blocked or partial and report why; do not relabel it complete to reach 43/43.
|
||||
|
||||
## Work pattern for each coding increment
|
||||
|
||||
1. Inspect the acadrust entity struct, constructors, private fields, serializer, drawing path, and linked tables/objects. Record the intended editable/read-only/unmapped properties first.
|
||||
2. Put generic validation and transaction behavior in the OCS fork. Keep the Python adapter thin; add a manifest override only when the generator cannot safely express a field.
|
||||
3. Add focused host validation, adapter conversion, document-model, real transaction/undo/redo, IPC, GUI, and DWG/DXF fixture checks. Record test evidence in the ledger, including which checks were manual.
|
||||
4. Run `cargo test -p ocs_plugin_api --features host --lib` and relevant app host tests in OCS. For the adapter, use `cargo test --features experimental-host-model --config 'patch."https://github.com/HakanSeven12/OpenCADStudio".ocs_plugin_api.path="/Users/felix/Documents/MacApps/OpenCADStudio/crates/ocs_plugin_api"' --lib`, run its Python tests, restore `Cargo.lock`, then run `cargo check --locked`.
|
||||
5. Check both `git diff --check` and repository status. Stage only task files, preserve unrelated untracked files, commit coherent host/adapter changes, and push only to the two established `felixriestra` remotes if still authorized. Record the resulting commits and next unfinished gate in the ledger.
|
||||
|
||||
## Handoff start point
|
||||
|
||||
Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate the existing 17 mapped kinds against the strict completion gate. Then take `Tolerance` as the first new kind, unless its required style or persistence path is blocked; record that and proceed to `Shape`. Keep the exact remaining-kind table above as the queue and update statuses with evidence after each increment.
|
||||
|
||||
## Phase 8 progress log
|
||||
|
||||
- Added a V4 local-socket nested-request test for `UpdateEntitiesTransaction` using a simulated runner peer and a recording host. This proves the request and response cross the V4 IPC framing and reach `HostApi`; it does **not** prove the actual Python plugin runs through IPC.
|
||||
- Extended the OCS app-host Point batch test to assert redo after undo. This proves the real app history path for those edits; creation/deletion history remains untested by this case.
|
||||
- Staged the actual debug Python cdylib with `stage-host-model.sh`. Its generated manifest declares API v7 and the OCS acadrust source; the checked-in PandoraBox manifest and lockfile remain on H7 v5, and `cargo check --locked` passes.
|
||||
- The opt-in `staged_python_plugin_line_lifecycle_over_real_ipc` app test passed with the staged v7 Python plugin and built OCS executable. It creates, edits, and deletes a Line through Python over the actual runner IPC; confirms three undo entries and undo/redo; and writes/reopens the edited Line as DWG and DXF. It does not exercise GUI picking, multiple tabs, or cancellation. Re-run with `OCS_TEST_PYTHON_PLUGIN=<staged dylib> OCS_PLUGIN_RUNNER_EXE=<built OpenCADStudio executable> cargo test app::plugin_host::tests::staged_python_plugin_line_lifecycle_over_real_ipc --lib -- --test-threads=1 --nocapture` from the OCS checkout.
|
||||
- The same Line test now also sends a duplicate-handle batch through Python and confirms the host rejects it without changing geometry or creating an undo entry.
|
||||
- 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.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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`.
|
||||
|
||||
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 | Mapped | Actual staged-plugin IPC Python create/edit/delete, three undo entries and undo/redo, and edited DWG/DXF reopen pass in `staged_python_plugin_line_lifecycle_over_real_ipc`. GUI pick/visibility, invalid-input/reference preservation, and document-model create/delete API still pending; not complete. |
|
||||
| Circle | Mapped | Full gate pending. |
|
||||
| Arc | Mapped | Full gate pending. |
|
||||
| Ellipse | Mapped | Existing focused DWG conversion test; full gate pending. |
|
||||
| Polyline | Mapped | Full gate pending. |
|
||||
| Polyline2D | Mapped | Full gate pending. |
|
||||
| Polyline3D | Mapped | Full gate pending. |
|
||||
| LwPolyline | Mapped | Full gate pending. |
|
||||
| Spline | Mapped | Full gate pending. |
|
||||
| Text | Mapped | Full gate pending. |
|
||||
| MText | Mapped | Full gate pending. |
|
||||
| Ray | Mapped | Focused DWG round trip; full gate pending. |
|
||||
| XLine | Mapped | Focused DWG round trip; full gate pending. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Hatch | Layer-only | Requires valid boundary and pattern fixtures. |
|
||||
| Leader | Layer-only | Requires annotation-linked fixture. |
|
||||
| MLine | Layer-only | Requires valid style and vertex fixture. |
|
||||
| Dimension | Layer-only | Track individual dimension subtypes. |
|
||||
| MultiLeader | Layer-only | Requires nested context and style fixture. |
|
||||
| Table | Layer-only | Requires table style and cell fixture. |
|
||||
| PolygonMesh | Layer-only | Requires consistent grid/count fixture. |
|
||||
| PolyfaceMesh | Layer-only | Requires valid face-index fixture. |
|
||||
| Mesh | Layer-only | Requires valid topology fixture. |
|
||||
| Helix | Layer-only | Requires valid derived-spline fixture. |
|
||||
| RasterImage | Layer-only | Requires image definition and file fixture. |
|
||||
| Wipeout | Layer-only | Requires clip and image-definition fixture. |
|
||||
| Underlay | Layer-only | Requires external definition/file fixture. |
|
||||
| Viewport | Layer-only | Requires layout and view-state fixture. |
|
||||
| ViewBorder | Layer-only | Requires linked view records. |
|
||||
| SectionSymbol | Layer-only | Requires section-view style and point records. |
|
||||
| Light | Layer-only | Requires position/target and photometric-state fixture. |
|
||||
| Region | Layer-only | Requires valid ACIS region payload. |
|
||||
| Body | Layer-only | Requires valid ACIS body payload. |
|
||||
| Solid3D | Layer-only | Requires valid ACIS solid payload. |
|
||||
| Surface | Layer-only | Requires valid surface payload. |
|
||||
| Ole2Frame | Layer-only | Requires valid embedded-object storage. |
|
||||
|
||||
After each increment, update the row with test names, fixture paths, exact passing commands, OCS and PandoraBox commit hashes, and any manual GUI result. If a gate is untested, leave it pending. Do not raise the headline completion count from converter coverage alone.
|
||||
@@ -705,6 +705,237 @@ mod tests {
|
||||
app.undo_steps(1);
|
||||
assert!(matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0));
|
||||
assert!(matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 2.0));
|
||||
app.redo_steps(1);
|
||||
assert!(matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0));
|
||||
assert!(matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 20.0));
|
||||
}
|
||||
|
||||
/// Opt-in end-to-end check using the staged Python cdylib and the actual
|
||||
/// OCS executable as its out-of-process runner. Run with OCS_TEST_PYTHON_PLUGIN
|
||||
/// and OCS_PLUGIN_RUNNER_EXE set; unlike the local-socket protocol test,
|
||||
/// this executes Python and calls the app's HostSession over real IPC.
|
||||
#[test]
|
||||
fn staged_python_plugin_line_lifecycle_over_real_ipc() {
|
||||
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; };
|
||||
let plugin_path = std::path::PathBuf::from(plugin_path);
|
||||
assert!(plugin_path.is_file(), "missing staged Python plugin: {}", plugin_path.display());
|
||||
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(
|
||||
&plugin_path, &mut host, crate::plugin::v4_support::notification_handler(),
|
||||
).expect("spawn staged Python plugin");
|
||||
assert_eq!(process.id(), "opencad.python");
|
||||
let dispatch = |host: &mut HostSession<'_>, command: &str| {
|
||||
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
|
||||
};
|
||||
dispatch(&mut host, concat!(
|
||||
"PY_EVAL ocs.add({'kind':'Line',",
|
||||
"'start':{'x':0.0,'y':0.0,'z':0.0},",
|
||||
"'end':{'x':1.0,'y':0.0,'z':0.0}})"
|
||||
));
|
||||
let handles: Vec<_> = host.document().entities().map(|entity| entity.common().handle).collect();
|
||||
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));
|
||||
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));
|
||||
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()
|
||||
));
|
||||
assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0),
|
||||
"rejected batch changed the line");
|
||||
let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).expect("write edited DWG");
|
||||
let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes))
|
||||
.read().expect("reopen edited DWG");
|
||||
assert!(matches!(dwg_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0));
|
||||
let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().expect("write edited DXF");
|
||||
let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes))
|
||||
.expect("open edited DXF").read().expect("reopen edited DXF");
|
||||
assert!(matches!(dxf_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0));
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.remove_entity({})", handle.value()));
|
||||
assert!(host.document().get_entity(handle).is_none(), "Python removal did not reach the host");
|
||||
drop(process);
|
||||
drop(host);
|
||||
app.finish_pending_history(0);
|
||||
assert_eq!(app.tabs[0].history.undo_stack.len(), 3,
|
||||
"create, edit, and delete should each form one undo entry");
|
||||
app.undo_steps(1);
|
||||
assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0));
|
||||
app.undo_steps(1);
|
||||
assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0));
|
||||
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; };
|
||||
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 process = {
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
std::sync::Arc::new(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 request = |app: &mut OpenCADStudio, prompt: &str, entity: bool| {
|
||||
let mut started = None;
|
||||
{
|
||||
let mut host = HostSession::new(app, 0);
|
||||
let method = if entity { "request_entity" } else { "request_point" };
|
||||
let cmd = format!("PY_EVAL ocs.active_document.{method}('{prompt}')");
|
||||
assert!(process.dispatch(&mut host, &cmd, &mut |id| started = Some(id)).unwrap());
|
||||
}
|
||||
let output = app.command_line.history.last().unwrap().text.clone();
|
||||
let token = output.split_whitespace().find_map(|part| part.parse::<u64>().ok())
|
||||
.unwrap_or_else(|| panic!("Python pick token missing from {output:?}"));
|
||||
let id = started.expect("Python request started an interactive command");
|
||||
app.set_active_command(0, Box::new(PluginProcessInteractiveAdapter::new(
|
||||
std::sync::Arc::clone(&process), id,
|
||||
)));
|
||||
token
|
||||
};
|
||||
|
||||
let picked_token = request(&mut app, "Pick a point", false);
|
||||
let result = app.tabs[0].active_cmd.as_mut().unwrap().on_point(glam::DVec3::new(3.0, 4.0, 0.0));
|
||||
let _ = app.apply_cmd_result(result);
|
||||
assert!(app.tabs[0].active_cmd.is_none());
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({picked_token})"), &mut |_| {}).unwrap());
|
||||
}
|
||||
assert!(app.command_line.history.last().unwrap().text.contains("point"));
|
||||
|
||||
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))))
|
||||
};
|
||||
let entity_token = request(&mut app, "Pick an entity", true);
|
||||
assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick());
|
||||
let result = app.tabs[0].active_cmd.as_mut().unwrap()
|
||||
.on_entity_pick(target, glam::DVec3::new(6.0, 7.0, 0.0));
|
||||
let _ = app.apply_cmd_result(result);
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({entity_token})"), &mut |_| {}).unwrap());
|
||||
}
|
||||
assert!(app.command_line.history.last().unwrap().text.contains("entity"));
|
||||
assert!(app.command_line.history.last().unwrap().text.contains(&target.value().to_string()));
|
||||
|
||||
let cancelled_token = request(&mut app, "Cancel a point", false);
|
||||
let result = app.tabs[0].active_cmd.as_mut().unwrap().on_enter();
|
||||
let _ = app.apply_cmd_result(result);
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({cancelled_token})"), &mut |_| {}).unwrap());
|
||||
}
|
||||
assert!(app.command_line.history.last().unwrap().text.contains("cancelled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_python_tabs_isolate_tokens_and_notifications() {
|
||||
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;
|
||||
app.tabs.push(crate::app::document::DocumentTab::new_drawing(2));
|
||||
app.tabs[1].is_start = false;
|
||||
let tab0 = app.tabs[0].id;
|
||||
let tab1 = app.tabs[1].id;
|
||||
assert_ne!(tab0, tab1);
|
||||
let process = {
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
std::sync::Arc::new(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 mut started = None;
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.request_point('Tab zero')",
|
||||
&mut |id| started = Some(id)).unwrap());
|
||||
}
|
||||
let token = app.command_line.history.last().unwrap().text.split_whitespace()
|
||||
.find_map(|part| part.parse::<u64>().ok()).expect("pick token");
|
||||
app.set_active_command(0, Box::new(PluginProcessInteractiveAdapter::new(
|
||||
std::sync::Arc::clone(&process), started.unwrap(),
|
||||
)));
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 1);
|
||||
assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({token})"), &mut |_| {}).unwrap());
|
||||
}
|
||||
assert!(app.command_line.history.last().unwrap().text.contains("None"),
|
||||
"other tab must not consume the pending token");
|
||||
let result = app.tabs[0].active_cmd.as_mut().unwrap().on_point(glam::DVec3::new(2.0, 3.0, 0.0));
|
||||
let _ = app.apply_cmd_result(result);
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 1);
|
||||
assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({token})"), &mut |_| {}).unwrap());
|
||||
}
|
||||
assert!(app.command_line.history.last().unwrap().text.contains("None"));
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({token})"), &mut |_| {}).unwrap());
|
||||
}
|
||||
assert!(app.command_line.history.last().unwrap().text.contains("point"));
|
||||
|
||||
use ocs_plugin_api::host::HostNotification;
|
||||
process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab0, epoch: 101 }).unwrap();
|
||||
process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab1, epoch: 202 }).unwrap();
|
||||
// Notifications are best-effort and consumed by the runner's reader
|
||||
// thread before its next Dispatch. Give that thread a bounded handoff.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 1);
|
||||
assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap());
|
||||
}
|
||||
let other_events = &app.command_line.history.last().unwrap().text;
|
||||
assert!(other_events.contains("202") && !other_events.contains("101"), "{other_events}");
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap());
|
||||
}
|
||||
let first_events = &app.command_line.history.last().unwrap().text;
|
||||
assert!(first_events.contains("101") && !first_events.contains("202"), "{first_events}");
|
||||
|
||||
for epoch in 0..257 {
|
||||
process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab0, epoch }).unwrap();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap());
|
||||
}
|
||||
let overflow = &app.command_line.history.last().unwrap().text;
|
||||
assert!(overflow.contains("overflow") && overflow.contains("dropped"), "{overflow}");
|
||||
|
||||
process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab0, epoch: 303 }).unwrap();
|
||||
process.notify_plugin(None, HostNotification::DocumentTabClosed { tab_id: tab0 }).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap());
|
||||
}
|
||||
assert!(app.command_line.history.last().unwrap().text.contains("[]"),
|
||||
"closing a tab must discard its queued events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user