From 67655add72365151049c8181f50513d13383c05e Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:02:23 +0200 Subject: [PATCH] ocs_doc_api: Phase 3 attributes + nested block traversal - SetAttribute op + GetAttributes/GetBlockEntities queries (append-only). - backend set_attribute (add/update Insert attribute), attributes (tag,value pairs), block_entities (read-only traversal via BlockRecord.entity_handles -> id+kind+bounds). - facade Entity::set_attribute/attributes, Document::block_entities. - spec Insert methods; api_reference + binding_schema regenerated; mocks + baseline. - Query enum: drop Copy (GetBlockEntities carries String). - Test phase3_attributes_and_block_traversal -> 608 host lib + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 9 +- crates/ocs_doc_api/examples/common/mod.rs | 21 ++++ crates/ocs_doc_api/spec/entities.toml | 13 +++ crates/ocs_doc_api/src/backend.rs | 9 ++ crates/ocs_doc_api/src/executor.rs | 32 +++++-- crates/ocs_doc_api/src/facade.rs | 20 ++++ crates/ocs_doc_api/src/gen/api_reference.md | 5 + .../ocs_doc_api/src/gen/binding_schema.json | 34 ++++++- crates/ocs_doc_api/src/gen/ops_gen.rs | 2 + crates/ocs_doc_api/src/gen/query_gen.rs | 7 +- crates/ocs_doc_api/src/ops.rs | 1 + crates/ocs_doc_api/src/query.rs | 6 ++ crates/ocs_doc_api/tests/mock_backend.rs | 21 ++++ .../tests/spec_wire_consistency.rs | 6 ++ src/app/doc_api.rs | 95 +++++++++++++++++++ 15 files changed, 267 insertions(+), 14 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 4d2fdde13..a330db132 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -79,8 +79,13 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal - **Done:** `create_insert(block_name, point, scale, rotation)` → `Operation::CreateInsert` (validates the BlockRecord exists; unknown block → `Validation`, no mutation). Insert `transform` moves its `insert_point`. Covered by `phase3_*` test. -- **Remaining:** `AttributeDefinition`/`AttributeEntity` typed create + attribute - get/set; read-only nested block-content traversal. +- **Done (attributes + traversal):** `set_attribute(tag, value)` (adds/updates an + attribute on an insert, `SetAttribute` op), `attributes()` → (tag, value) pairs + (`GetAttributes`), and `block_entities(block_name)` → read-only nested traversal + of a block definition's entities (`GetBlockEntities`). Covered by + `phase3_attributes_and_block_traversal` test. +- **Remaining:** typed `AttributeDefinition` (in-block attribute-definition) + create. ### Phase 4 — paper-space & viewports - **Done:** `create_viewport(center, width, height, view_target, view_height)` → diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index fce8ca73e..d6a5c7900 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -27,6 +27,8 @@ pub struct MockBackend { hatch_boundaries: HashMap>>, /// Stored dimension measurements. dimension_measurements: HashMap, + /// Stored insert attributes. + attributes_store: HashMap>, } impl MockBackend { @@ -180,6 +182,25 @@ impl DocApiBackend for MockBackend { fn dimension_measurement(&self, id: ObjectId) -> ApiResult { self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id)) } + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + let attrs = self.attributes_store.entry(id).or_default(); + if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { + a.1 = value.to_string(); + } else { + attrs.push((tag.to_string(), value.to_string())); + } + Ok(()) + } + fn attributes(&self, id: ObjectId) -> ApiResult> { + self.attributes_store.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } + fn block_entities(&self, _block_name: &str) -> ApiResult> { + Ok(Vec::new()) + } +} fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 5dd6be8a6..174f5a253 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -347,6 +347,19 @@ name = "create_insert" args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }] op = "CreateInsert" +[[family.method]] +name = "attributes" +kind = "query" +query = "GetAttributes" +returns = "Vec<(String, String)>" + +[[family.method]] +name = "set_attribute" +kind = "op" +op = "SetAttribute" +args = [{ name = "tag", ty = "&str" }, { name = "value", ty = "&str" }] +returns = "()" + # ============================================================================ # PAPER-SPACE FAMILY (phase 4) — viewports # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 8a591b204..13d09461b 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -66,6 +66,15 @@ pub trait DocApiBackend { /// A dimension's measured value (distance for linear/radius, degrees for angular). fn dimension_measurement(&self, id: ObjectId) -> ApiResult; + /// Set an attribute `value` for `tag` on an insert (adds if absent). Insert-only. + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()>; + + /// An insert's attributes as (tag, value) pairs. Insert-only. + fn attributes(&self, id: ObjectId) -> ApiResult>; + + /// The entities inside a block definition (read-only traversal), as views. + fn block_entities(&self, block_name: &str) -> ApiResult>; + /// Can `id` be modified in place right now (exists, is the expected family, /// not on a locked layer)? Read-only pre-check used before mutations. /// Default: existence + not-locked (backends narrow the family check). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 7ab6b106f..f8cfa8962 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -227,6 +227,16 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.can_modify(*id).map_err(|e| match e { + ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")), + other => other, + })?; + b.push_undo(name); + b.set_attribute(*id, tag, value)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -253,20 +263,22 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes } let mut results = Vec::with_capacity(queries.len()); for q in &queries { - let r = match *q { - Query::GetEntity { id } => QueryResult::Entity(b.get_entity(id)?), - Query::GetBounds { id } => QueryResult::Bounds(b.bounds(id)?), - Query::GetCentroid { id } => QueryResult::Centroid(b.centroid(id)?), - Query::GetVolume { id } => QueryResult::Volume(b.volume(id)?), + let r = match q { + Query::GetEntity { id } => QueryResult::Entity(b.get_entity(*id)?), + Query::GetBounds { id } => QueryResult::Bounds(b.bounds(*id)?), + Query::GetCentroid { id } => QueryResult::Centroid(b.centroid(*id)?), + Query::GetVolume { id } => QueryResult::Volume(b.volume(*id)?), Query::GetIntersects { a, b: bid } => { - let ba = b.bounds(a)?; - let bb = b.bounds(bid)?; + let ba = b.bounds(*a)?; + let bb = b.bounds(*bid)?; QueryResult::Intersects(ba.overlaps(&bb)) } Query::GetGeometryRevision => QueryResult::Revision(b.revision()), - Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(id)?), - Query::GetHatchBoundary { id } => QueryResult::HatchBoundary(b.hatch_boundary(id)?), - Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(b.dimension_measurement(id)?), + Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(*id)?), + Query::GetHatchBoundary { id } => QueryResult::HatchBoundary(b.hatch_boundary(*id)?), + Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(b.dimension_measurement(*id)?), + Query::GetAttributes { id } => QueryResult::Attributes(b.attributes(*id)?), + Query::GetBlockEntities { block_name } => QueryResult::BlockEntities(b.block_entities(block_name)?), }; results.push(r); } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 05689a0ce..e0e7648eb 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -128,6 +128,14 @@ impl Document { } } + /// Read-only traversal of a block definition's entities (ids + kinds + bounds). + pub fn block_entities(&self, block_name: &str) -> ApiResult> { + match self.session.one_query(Query::GetBlockEntities { block_name: block_name.to_string() })? { + QueryResult::BlockEntities(v) => Ok(v), + _ => Err(ApiError::Transport("unexpected block-entities result".into())), + } + } + /// Batch of read-only queries in ONE round-trip (safe: no mutation/undo). /// The closure records queries on a [`QueryBatch`]; the results are returned /// in the same order as a [`QueryResults`] view the caller destructures. @@ -289,6 +297,18 @@ impl Entity { _ => Err(ApiError::Transport("unexpected entity result".into())), } } + /// This insert's attributes as (tag, value) pairs. Insert-only. + pub fn attributes(&self) -> ApiResult> { + match self.session.one_query(Query::GetAttributes { id: self.id })? { + QueryResult::Attributes(v) => Ok(v), + _ => Err(ApiError::Transport("unexpected attributes result".into())), + } + } + /// Set an attribute `value` for `tag` on this insert (adds if absent). One undo step. + pub fn set_attribute(&self, tag: &str, value: &str) -> ApiResult<()> { + self.session.apply_op(Operation::SetAttribute { id: self.id, tag: tag.to_string(), value: value.to_string() })?; + Ok(()) + } pub fn as_solid(&self) -> Option { // Typed downcast is validated by the view's kind. matches!(self.view().ok()?.kind.as_str(), "Solid3D").then(|| Solid::new(self.session.clone(), self.id)) diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index d3366621d..5a0dbdb06 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -145,6 +145,11 @@ Block reference. Place an existing BlockRecord by name; transform moves its inse - **create_insert**(`block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64`) -> `Entity` +### Methods + +- **attributes**(``) -> `Vec<(String, String)>` — query `GetAttributes` +- **set_attribute**(`tag: &str, value: &str`) -> `()` — op `SetAttribute` + ## `Entity` (acadrust `Viewport`, collection `entities`) Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 45646f17c..cb9d1e307 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -300,7 +300,39 @@ "acadrust_variant": "Insert", "collection": "entities", "handle": "Entity", - "methods": [], + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "attributes", + "op": null, + "query": "GetAttributes", + "read_only": true, + "returns": "Vec<(String, String)>" + }, + { + "args": [ + { + "name": "tag", + "ty": "&str" + }, + { + "name": "value", + "ty": "&str" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_attribute", + "op": "SetAttribute", + "query": null, + "read_only": false, + "returns": "()" + } + ], "name": "Insert" }, { diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index fa1f908de..f0ddb86d9 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -40,4 +40,6 @@ pub enum Operation { CreateHatch(HatchSpec), // dimensions CreateDimensionLinear(DimensionSpec), + // attributes + SetAttribute { id: ObjectId, tag: String, value: String }, } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 44bc8af19..531febeed 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -9,7 +9,8 @@ use crate::id::ObjectId; /// A typed read query (plan §5). Read-only: no mutation, no undo, no revision /// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +/// (Not `Copy`: some variants carry owned data like a block name.) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] pub enum Query { GetEntity { id: ObjectId }, @@ -25,4 +26,8 @@ pub enum Query { GetHatchBoundary { id: ObjectId }, /// The measured value of a Dimension (distance or angle). GetDimensionMeasurement { id: ObjectId }, + /// An insert's attributes as (tag, value) pairs. + GetAttributes { id: ObjectId }, + /// The entity ids + kinds inside a block definition (read-only traversal). + GetBlockEntities { block_name: String }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index dd688c88e..eef7025ea 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -169,6 +169,7 @@ impl crate::gen::Operation { SetTextContent { .. } => "SetTextContent", CreateHatch(_) => "CreateHatch", CreateDimensionLinear(_) => "CreateDimensionLinear", + SetAttribute { .. } => "SetAttribute", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index 1e6e1fe52..b2103b025 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -49,6 +49,10 @@ pub enum QueryResult { HatchBoundary(Vec>), /// A dimension's measured value (distance for linear/radius, degrees for angular). DimensionMeasurement(f64), + /// An insert's attributes as (tag, value) pairs. + Attributes(Vec<(String, String)>), + /// The entities inside a block definition (read-only traversal). + BlockEntities(Vec), } /// Convenience: the query name for diagnostics. @@ -65,6 +69,8 @@ impl crate::gen::Query { GetTextContent { .. } => "GetTextContent", GetHatchBoundary { .. } => "GetHatchBoundary", GetDimensionMeasurement { .. } => "GetDimensionMeasurement", + GetAttributes { .. } => "GetAttributes", + GetBlockEntities { .. } => "GetBlockEntities", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 6cfe3485e..743d4e9dd 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -27,6 +27,8 @@ struct MockBackend { hatch_boundaries: HashMap>>, /// Stored dimension measurements. dimension_measurements: HashMap, + /// Stored insert attributes. + attributes_store: HashMap>, } impl MockBackend { @@ -120,6 +122,25 @@ impl DocApiBackend for MockBackend { fn dimension_measurement(&self, id: ObjectId) -> ApiResult { self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id)) } + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + let attrs = self.attributes_store.entry(id).or_default(); + if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { + a.1 = value.to_string(); + } else { + attrs.push((tag.to_string(), value.to_string())); + } + Ok(()) + } + fn attributes(&self, id: ObjectId) -> ApiResult> { + self.attributes_store.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } + fn block_entities(&self, _block_name: &str) -> ApiResult> { + Ok(Vec::new()) + } +} fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index 3e51c937e..2abb5422c 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -58,6 +58,7 @@ fn operation_variants() -> Vec { Operation::SetTextContent { id: ObjectId::from_u64(0), value: String::new() }, Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { boundary: vec![], solid: true }), Operation::CreateDimensionLinear(ocs_doc_api::ops::DimensionSpec { first_point: [0.0; 3], second_point: [0.0; 3], definition_point: [0.0; 3] }), + Operation::SetAttribute { id: ObjectId::from_u64(0), tag: String::new(), value: String::new() }, ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -85,6 +86,8 @@ fn query_variants() -> Vec { Query::GetTextContent { id: ObjectId::from_u64(0) }, Query::GetHatchBoundary { id: ObjectId::from_u64(0) }, Query::GetDimensionMeasurement { id: ObjectId::from_u64(0) }, + Query::GetAttributes { id: ObjectId::from_u64(0) }, + Query::GetBlockEntities { block_name: String::new() }, ]; marker .iter() @@ -140,6 +143,7 @@ const OPERATION_BASELINE: &[&str] = &[ "SetTextContent", "CreateHatch", "CreateDimensionLinear", + "SetAttribute", ]; /// Recorded baseline of the `Query` variant order. @@ -153,6 +157,8 @@ const QUERY_BASELINE: &[&str] = &[ "GetTextContent", "GetHatchBoundary", "GetDimensionMeasurement", + "GetAttributes", + "GetBlockEntities", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 308c4834e..32f7d257b 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -262,6 +262,55 @@ impl DocApiBackend for HostSession<'_> { }) } + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { + let handle = obj_to_handle(id); + let entity = self.document().get_entity(handle).cloned().ok_or(ApiError::UnknownId(id))?; + let EntityType::Insert(mut ins) = entity else { + return Err(ApiError::Unsupported("SetAttribute is only for Insert entities".into())); + }; + if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) { + attr.value = value.to_string(); + } else { + let mut attr = acadrust::entities::AttributeEntity::default(); + attr.tag = tag.to_string(); + attr.value = value.to_string(); + attr.insertion_point = ins.insert_point; + ins.attributes.push(attr); + } + if !self.scene_mut().update_entity(EntityType::Insert(ins)) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) + } + + fn attributes(&self, id: ObjectId) -> ApiResult> { + let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; + let EntityType::Insert(ins) = entity else { + return Err(ApiError::Unsupported("GetAttributes is only for Insert entities".into())); + }; + Ok(ins.attributes.iter().map(|a| (a.tag.clone(), a.value.clone())).collect()) + } + + fn block_entities(&self, block_name: &str) -> ApiResult> { + let br = self + .document() + .block_records + .get(block_name) + .ok_or_else(|| ApiError::validation("GetBlockEntities", format!("unknown block {block_name:?}")))?; + // Read-only traversal of the block definition's entities (handle lookups). + let mut out = Vec::with_capacity(br.entity_handles.len()); + for h in &br.entity_handles { + let id = handle_to_obj(*h); + let (kind, bounds) = match self.document().get_entity(*h) { + Some(e) => (crate::app::doc_api_convert::entity_kind_name(e).to_string(), + crate::app::doc_api_convert::entity_bounds(Some(e), id).ok()), + None => ("Missing".to_string(), None), + }; + out.push(EntityView { id, kind, bounds }); + } + Ok(out) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -929,6 +978,52 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Phase 3 remaining: attributes + nested block traversal ────────────── + + #[test] + fn phase3_attributes_and_block_traversal() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A block "Door" containing a line; insert it, then set/read attributes. + host.document_mut().block_records.add(acadrust::tables::BlockRecord::new("Door")).unwrap(); + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Line { start: [0.0; 3], end: [10.0; 3] }))).unwrap()); + host.document_mut().block_records.get_mut("Door").unwrap().entity_handles.push(obj_to_handle(line)); + + let ins = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateInsert( + ocs_doc_api::ops::InsertSpec { block_name: "Door".into(), insert_point: [0.0; 3], scale: 1.0, rotation: 0.0 }))).unwrap()); + + // set_attribute adds a new attribute; get_attributes reads it back. + dispatch(&mut host, DocApiEnvelope::op(Operation::SetAttribute { id: ins, tag: "HANDLE".into(), value: "A-1".into() })).unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Attributes(attrs) => { + assert_eq!(attrs.len(), 1); + assert_eq!(attrs[0], ("HANDLE".to_string(), "A-1".to_string())); + } + other => panic!("expected attributes, got {other:?}"), + } + // set_attribute on an existing tag updates it. + dispatch(&mut host, DocApiEnvelope::op(Operation::SetAttribute { id: ins, tag: "HANDLE".into(), value: "A-2".into() })).unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Attributes(attrs) => assert_eq!(attrs[0].1, "A-2"), + other => panic!("expected attributes, got {other:?}"), + } + + // Nested traversal: GetBlockEntities("Door") returns the line. + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBlockEntities { block_name: "Door".into() }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::BlockEntities(v) => { + assert_eq!(v.len(), 1); + assert_eq!(v[0].kind, "Line"); + } + other => panic!("expected block entities, got {other:?}"), + } + // Unknown block -> validation error. + assert!(dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBlockEntities { block_name: "Nope".into() }])).is_err()); + } + // ── Phase 2b-c: dimension ───────────────────────────────────────────────── #[test]