From 603a59c1785dbcca324dae063ecc3405d9519a30 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Thu, 24 Sep 2026 23:00:05 -0700 Subject: [PATCH] Verify projection API and preserve same-object explicit navigation --- .../oa-core/tests/legacy_diagram_admission.rs | 16 +- crates/oa-desktop/src/app.rs | 8 +- .../src/app/canvas_selection_tests.rs | 19 +++ crates/oa-server/tests/http/source6_views.rs | 147 ++++++++++++++++++ 4 files changed, 182 insertions(+), 8 deletions(-) diff --git a/crates/oa-core/tests/legacy_diagram_admission.rs b/crates/oa-core/tests/legacy_diagram_admission.rs index 19b94d7e..0e938ba9 100644 --- a/crates/oa-core/tests/legacy_diagram_admission.rs +++ b/crates/oa-core/tests/legacy_diagram_admission.rs @@ -23,12 +23,13 @@ fn model_issue(error: persistence::FileError) -> validation::Issue { #[test] fn frozen_3_through_6_reject_cross_layer_nodes_but_keep_mixed_traceability() { + let node_id = Id::parse_str("00000000-0000-4000-8000-000000000004").unwrap(); for version in 3..=6 { let source = format!( - "arcadia {version}; project compat \"Compatibility\" {{\n\ - sa_function work \"Counterpart\" {{}}\n\ - diagram view \"OA concern\" {{ layer Oa; projection Traceability; }}\n\ - node occurrence {{ diagram @view; element @work; }}\n}}" + "arcadia {version}; project compat \"Compatibility\" id \"00000000-0000-4000-8000-000000000001\" {{\n\ + sa_function work \"Counterpart\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n\ + diagram view \"OA concern\" id \"00000000-0000-4000-8000-000000000003\" {{ layer Oa; projection Traceability; }}\n\ + node occurrence id \"00000000-0000-4000-8000-000000000004\" {{ diagram @view; element @work; }}\n}}" ); let mixed = language::parse(&source).unwrap(); assert_eq!( @@ -61,12 +62,13 @@ fn frozen_3_through_6_reject_cross_layer_nodes_but_keep_mixed_traceability() { let diagram = latent.diagrams.values_mut().next().unwrap(); diagram.projection = ProjectionKind::Overview; let node = diagram.nodes.values().next().unwrap().id; + assert_eq!(node, node_id); if version >= 4 { let diagnostic = errors .iter() .find(|error| error.code == "diagram_layer") .unwrap(); - assert_eq!(diagnostic.object_id, Some(node)); + assert_eq!(diagnostic.object_id, Some(node_id)); assert_eq!(diagnostic.field.as_deref(), Some("element")); let bundle = source_project::SourceBundle { entry: "model.arcadia".into(), @@ -80,7 +82,7 @@ fn frozen_3_through_6_reject_cross_layer_nodes_but_keep_mixed_traceability() { .find(|error| error.diagnostic.code == "diagram_layer") .unwrap() .diagnostic; - assert_eq!(diagnostic.object_id, Some(node)); + assert_eq!(diagnostic.object_id, Some(node_id)); assert_eq!(diagnostic.field.as_deref(), Some("element")); } let issue = model_issue(persistence::decode(&legacy_json(&latent, version)).unwrap_err()); @@ -103,7 +105,7 @@ fn frozen_3_through_6_reject_cross_layer_nodes_but_keep_mixed_traceability() { .values_mut() .next() .unwrap() - .element = Id::new_v4(); + .element = Id::parse_str("00000000-0000-4000-8000-000000000005").unwrap(); let issue = model_issue(persistence::decode(&legacy_json(&dangling, version)).unwrap_err()); assert_eq!(issue.code, "diagram_layer"); assert_eq!(issue.element, Some(node)); diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index ed0cf391..a94df1df 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -1174,7 +1174,13 @@ impl App { if id != self.selected { self.selected_attachment = None; } - if self.selected == id && !process_preview { + if self.selected == id + && !process_preview + && self + .project() + .layer_of(id) + .is_none_or(|layer| layer == self.layer) + { return; } if !self.apply() { diff --git a/crates/oa-desktop/src/app/canvas_selection_tests.rs b/crates/oa-desktop/src/app/canvas_selection_tests.rs index fd2cd6ad..63b1687c 100644 --- a/crates/oa-desktop/src/app/canvas_selection_tests.rs +++ b/crates/oa-desktop/src/app/canvas_selection_tests.rs @@ -181,6 +181,25 @@ fn mixed_canvas_explicit_navigation_still_follows_the_target_layer() { assert_ne!(f.app.active_diagram, Some(f.diagram)); } +#[test] +fn explicit_navigation_to_the_inspected_cross_layer_object_follows_its_layer() { + let mut f = fixture(); + let _ = f.app.update(Message::CanvasSelect( + f.app.route_context().unwrap(), + f.edge, + )); + assert_surface(&f); + assert_eq!(f.app.selected, f.edge); + let before = f.app.document.snapshot(); + let _ = f.app.update(Message::Select(f.edge)); + assert_eq!(f.app.selected, f.edge); + assert_eq!(f.app.layer, Layer::Oa); + assert_ne!(f.app.active_diagram, Some(f.diagram)); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!(!f.app.document.can_undo()); +} + #[test] fn mixed_canvas_invalid_draft_and_stale_packets_preserve_selection() { let mut f = fixture(); diff --git a/crates/oa-server/tests/http/source6_views.rs b/crates/oa-server/tests/http/source6_views.rs index 5ece6f56..ac0c8f69 100644 --- a/crates/oa-server/tests/http/source6_views.rs +++ b/crates/oa-server/tests/http/source6_views.rs @@ -372,3 +372,150 @@ async fn source6_http_explicit_communication_ports_preview_apply_and_restart_pre assert_pair(&reopened); server.stop().await; } + +#[tokio::test] +async fn mixed_traceability_projection_roundtrip_preserves_dormant_http_state() { + let f = fixture(); + let mut model = compile_bundle(&f.bundle, ProjectLimits::default()) + .unwrap() + .decoded + .project; + let view = model.diagrams.get_mut(&f.diagram).unwrap(); + view.layer = Layer::Sa; + view.projection = ProjectionKind::Traceability; + // The OA placements and category are intentionally opposite the view's layer. + view.view_options.hidden_edges = vec![f.mean]; + for (index, element) in f.sa.into_iter().enumerate() { + model.place( + f.diagram, + element, + Position { + x: index as f32 * 400., + y: 300., + }, + ); + } + let mut bundle = f.bundle.clone(); + bundle.files.insert( + bundle.entry.clone(), + format!( + "// Authored source-6 engineering rationale\n{}", + oa_core::language::format(&model).replacen( + "{\n", + "{\n include \"notes.arcadia\";\n", + 1 + ) + ), + ); + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let original = import(&server, &bundle).await; + assert_pair(&original); + let resource = original.resource_id; + let mixed = oa_core::projection::project_view(&original.model, f.diagram); + assert!( + f.oa.iter() + .chain(&f.sa) + .all(|id| mixed.nodes.iter().any(|n| n.element == *id)) + ); + + let mut ordinary = original.model.diagrams[&f.diagram].clone(); + ordinary.projection = ProjectionKind::Allocation; + let response = transact( + &server, + &original, + vec![Command::EditDiagram(f.diagram, ordinary.clone())], + ) + .await; + let status = response.status(); + let etag = response + .headers() + .get("etag") + .map(|v| v.to_str().unwrap().to_owned()); + let result: Value = response.json().await.unwrap(); + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["changed"], true); + assert_eq!(result["dry_run"], false); + assert_eq!(result["revision"], original.revision + 1); + let changed = server.get(resource).await; + assert_eq!(etag.as_deref(), Some(changed.etag().as_str())); + let mut expected = original.model.clone(); + expected.diagrams.insert(f.diagram, ordinary.clone()); + assert_eq!( + changed.model, expected, + "only the projection changes; dormant state is retained" + ); + assert_pair(&changed); + assert_eq!( + changed.source_project.files["notes.arcadia"], + bundle.files["notes.arcadia"] + ); + assert!( + changed.source_project.files[&bundle.entry] + .starts_with("// Authored source-6 engineering rationale\n") + ); + let projected = oa_core::projection::project_view(&changed.model, f.diagram); + assert!(projected.nodes.iter().all(|n| !f.oa.contains(&n.element))); + assert!( + f.sa.iter() + .all(|id| projected.nodes.iter().any(|n| n.element == *id)) + ); + + // A dormant option still must name an existing category. Rejection must not + // advance the ETag or rewrite either side of the persisted model/source pair. + let disk = directory.path().join(format!("{resource}.json")); + let accepted_bytes = std::fs::read(&disk).unwrap(); + ordinary.view_options.participant_categories = CategorySelection::Any(vec![Id::new_v4()]); + let rejected = transact( + &server, + &changed, + vec![Command::EditDiagram(f.diagram, ordinary)], + ) + .await; + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + let retained = server.get(resource).await; + assert_eq!(retained.etag(), changed.etag()); + assert_eq!( + serde_json::to_value(&retained).unwrap(), + serde_json::to_value(&changed).unwrap() + ); + assert_eq!(std::fs::read(&disk).unwrap(), accepted_bytes); + server.stop().await; + + let server = TestServer::start(directory.path(), None).await; + let reopened = server.get(resource).await; + assert_eq!( + serde_json::to_value(&reopened).unwrap(), + serde_json::to_value(&changed).unwrap() + ); + assert_pair(&reopened); + let response = transact( + &server, + &reopened, + vec![Command::EditDiagram( + f.diagram, + original.model.diagrams[&f.diagram].clone(), + )], + ) + .await; + let status = response.status(); + let result: Value = response.json().await.unwrap(); + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["changed"], true); + assert_eq!(result["revision"], changed.revision + 1); + let restored = server.get(resource).await; + assert_eq!(restored.model, original.model); + assert_pair(&restored); + assert_eq!( + restored.source_project.files["notes.arcadia"], + bundle.files["notes.arcadia"] + ); + let projected = oa_core::projection::project_view(&restored.model, f.diagram); + assert!( + f.oa.iter() + .chain(&f.sa) + .all(|id| projected.nodes.iter().any(|n| n.element == *id)) + ); + assert!(!projected.edges.iter().any(|edge| edge.id == f.mean)); + server.stop().await; +}