diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index a94df1df..a7ca8cf4 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -2766,7 +2766,15 @@ impl App { } self.reconcile_multiselect(); self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i)); - return self.update(*action); + let action = match *action { + Message::EditProperties(id) + if menu.request.canvas.is_some() && id == menu.request.object => + { + Message::InspectorTab(crate::navigation::InspectorTab::Properties) + } + action => action, + }; + return self.update(action); } Message::CreateContextElement(kind, child) => { self.create_kind = kind; @@ -3377,7 +3385,7 @@ impl App { self.workflow = Some(crate::workflow::Workflow::relationship_kind( self.project(), self.selected, - self.layer, + self.project().layer_of(self.selected).unwrap_or(self.layer), kind, incoming, )); @@ -4403,13 +4411,18 @@ impl App { self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into(); return; } + let creation_layer = if child { + self.project().layer_of(self.selected).unwrap_or(self.layer) + } else { + self.layer + }; let allocated = child && self.create_kind.is_work() && self .project() .elements .get(&self.selected) - .is_some_and(|e| e.layer() == self.layer && e.kind().is_performer()); + .is_some_and(|e| e.layer() == creation_layer && e.kind().is_performer()); let parent = if child && !allocated { Some(self.selected) } else { @@ -4417,7 +4430,7 @@ impl App { }; let Some(e) = self.project().make_element( self.create_kind, - self.layer, + creation_layer, &format!("New {}", self.create_kind.label().to_lowercase()), parent, ) else { @@ -4446,16 +4459,16 @@ impl App { .project() .diagrams .values() - .find(|d| d.layer == self.layer && d.projection == preferred) + .find(|d| d.layer == creation_layer && d.projection == preferred) .map(|d| d.id); if diagram_target.is_none() { let view = Id::new_v4(); commands.push(Command::CreateDiagram(Diagram { id: view, alias: self.project().fresh_alias("engineering_view", "view", view), - name: format!("{} {preferred}", self.layer), + name: format!("{} {preferred}", creation_layer), description: String::new(), - layer: self.layer, + layer: creation_layer, projection: preferred, layout: None, routes: Vec::new(), @@ -4588,7 +4601,13 @@ impl App { )); } if self.execute(Command::Batch(commands)) { + let changed_view = self.active_diagram != diagram_target; self.active_diagram = diagram_target; + if changed_view + && let Some(view) = diagram_target.and_then(|id| self.project().diagrams.get(&id)) + { + self.layer = view.layer; + } self.selected = id; self.inspector_tab = crate::navigation::InspectorTab::Properties; self.refresh_draft(); @@ -4602,7 +4621,7 @@ impl App { self.workflow = Some(crate::workflow::Workflow::relationship( self.project(), self.selected, - self.layer, + self.project().layer_of(self.selected).unwrap_or(self.layer), )); } fn add_trace(&mut self) { diff --git a/crates/oa-desktop/src/app/canvas_selection_tests.rs b/crates/oa-desktop/src/app/canvas_selection_tests.rs index 63b1687c..6746f904 100644 --- a/crates/oa-desktop/src/app/canvas_selection_tests.rs +++ b/crates/oa-desktop/src/app/canvas_selection_tests.rs @@ -295,3 +295,251 @@ fn cross_layer_port_inspection_and_connection_start_retain_the_view() { assert_eq!(app.connection.as_ref().unwrap().source, Some(port)); assert!(!app.document.can_undo()); } + +fn authoring_fixture(performer: bool) -> (Fixture, Id) { + let mut f = fixture(); + let mut project = f.app.project().clone(); + let owner = project + .add_element( + if performer { + Kind::Entity + } else { + Kind::Activity + }, + Layer::Oa, + "Operational owner", + None, + ) + .unwrap(); + let target = project + .add_element(Kind::Activity, Layer::Oa, "Operational target", None) + .unwrap(); + let placement = project.place(f.diagram, owner, Position { x: 0., y: 400. }); + project.place(f.diagram, target, Position { x: 500., y: 400. }); + f.app = App::with_project(project); + f.app.page = Page::Diagram; + f.app.layer = Layer::Sa; + f.app.active_diagram = Some(f.diagram); + f.app.selected = f.diagram; + f.app.refresh_draft(); + f.app.reconcile_multiselect(); + f.process = owner; + f.placement = placement; + (f, target) +} + +fn assert_source_pair(app: &App) { + let compiled = oa_core::source_project::compile_bundle( + app.document.bundle(), + oa_core::source_project::ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(&compiled.decoded.project, app.project()); + assert_eq!(&compiled.bundle, app.document.bundle()); +} + +#[test] +fn mixed_canvas_relationship_actions_keep_operational_source_and_commit_one_step() { + for context_action in [false, true] { + let (mut f, target) = authoring_fixture(false); + let before = f.app.document.snapshot(); + let owner = f.process; + let placement = f.placement; + if context_action { + invoke( + &mut f, + owner, + Some(placement), + Message::AddRelationKind(RelationKind::Interaction, false), + ); + } else { + let _ = f.app.update(Message::SelectNode(placement)); + let _ = f.app.update(Message::AddRelation); + } + assert_surface(&f); + let Some(crate::workflow::Workflow::Relation(relation)) = &f.app.workflow else { + panic!("relationship form did not open: {}", f.app.status); + }; + assert_eq!( + relation.source.as_ref().map(|source| source.id), + Some(owner) + ); + assert_eq!(relation.kind, RelationKind::Interaction); + let saved = f + .app + .workflow + .as_ref() + .unwrap() + .recovery_snapshot() + .unwrap(); + assert_eq!(saved["Relation"]["layer"], "Oa"); + let _ = f + .app + .update(Message::Workflow(crate::workflow::Event::Target(Choice { + id: target, + label: "Operational target".into(), + }))); + let _ = f + .app + .update(Message::Workflow(crate::workflow::Event::Apply)); + assert!(f.app.workflow.is_none(), "{}", f.app.status); + let created: Vec<_> = f + .app + .project() + .relations + .values() + .filter(|relation| !before.decoded.project.relations.contains_key(&relation.id)) + .collect(); + assert_eq!(created.len(), 1); + assert_eq!( + (created[0].kind(), created[0].source(), created[0].target()), + (RelationKind::Interaction, owner, target) + ); + assert_source_pair(&f.app); + let _ = f.app.update(Message::Undo); + 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_add_child_command_uses_parent_layer_for_containment_and_allocation() { + for performer in [false, true] { + let (mut f, _) = authoring_fixture(performer); + let before = f.app.document.snapshot(); + let owner = f.process; + let placement = f.placement; + invoke( + &mut f, + owner, + Some(placement), + Message::CreateContextElement(Kind::Activity, true), + ); + assert_surface(&f); + let created: Vec<_> = f + .app + .project() + .elements + .values() + .filter(|element| !before.decoded.project.elements.contains_key(&element.id)) + .collect(); + assert_eq!(created.len(), 1, "{}", f.app.status); + let child = created[0].id; + assert_eq!(created[0].kind(), Kind::Activity); + assert_eq!(created[0].layer(), Layer::Oa); + assert_eq!( + created[0].parent, + if performer { None } else { Some(owner) } + ); + let allocations: Vec<_> = f + .app + .project() + .relations + .values() + .filter(|relation| !before.decoded.project.relations.contains_key(&relation.id)) + .collect(); + assert_eq!(allocations.len(), usize::from(performer)); + if performer { + assert_eq!( + ( + allocations[0].kind(), + allocations[0].source(), + allocations[0].target() + ), + (RelationKind::Allocation, owner, child) + ); + } + assert!( + f.app.project().diagrams[&f.diagram] + .nodes + .values() + .any(|node| node.element == child) + ); + assert_source_pair(&f.app); + let _ = f.app.update(Message::Undo); + 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_root_creation_still_uses_the_active_perspective() { + let (mut f, _) = authoring_fixture(false); + let _ = f.app.update(Message::SelectNode(f.placement)); + let before = f.app.document.snapshot(); + let _ = f + .app + .update(Message::CreateContextElement(Kind::Function, false)); + assert_surface(&f); + let created: Vec<_> = f + .app + .project() + .elements + .values() + .filter(|element| !before.decoded.project.elements.contains_key(&element.id)) + .collect(); + assert_eq!(created.len(), 1, "{}", f.app.status); + assert_eq!(created[0].kind(), Kind::Function); + assert_eq!(created[0].layer(), Layer::Sa); + assert_eq!(created[0].parent, None); + assert_source_pair(&f.app); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); +} + +#[test] +fn child_creation_navigates_to_a_parent_layer_preferred_view_only_when_needed() { + for existing_preferred in [false, true] { + let (mut f, _) = authoring_fixture(false); + let mut project = f.app.project().clone(); + for diagram in project + .diagrams + .values_mut() + .filter(|d| d.layer == Layer::Oa) + { + diagram.projection = if existing_preferred { + ProjectionKind::Allocation + } else { + ProjectionKind::Overview + }; + } + f.app = App::with_project(project); + f.app.page = Page::Diagram; + f.app.layer = Layer::Sa; + f.app.active_diagram = Some(f.diagram); + f.app.selected = f.diagram; + f.app.refresh_draft(); + let _ = f.app.update(Message::SelectNode(f.placement)); + let _ = f + .app + .update(Message::Projection(ProjectionKind::Capabilities)); + assert_eq!(f.app.selected, f.process); + let before = f.app.document.snapshot(); + let _ = f + .app + .update(Message::CreateContextElement(Kind::Activity, true)); + assert_eq!(f.app.layer, Layer::Oa, "{}", f.app.status); + let diagram = f.app.active_diagram.unwrap(); + assert_ne!(diagram, f.diagram); + assert_eq!(f.app.project().diagrams[&diagram].layer, Layer::Oa); + assert_eq!( + f.app.project().diagrams[&diagram].projection, + ProjectionKind::Allocation + ); + assert_eq!( + f.app.project().diagrams.len(), + before.decoded.project.diagrams.len() + usize::from(!existing_preferred) + ); + assert_eq!( + f.app.project().elements[&f.app.selected].parent, + Some(f.process) + ); + assert_source_pair(&f.app); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + } +} diff --git a/harness/projection-navigation-20260925.md b/harness/projection-navigation-20260925.md index db12c024..81b8cab3 100644 --- a/harness/projection-navigation-20260925.md +++ b/harness/projection-navigation-20260925.md @@ -29,8 +29,10 @@ Canvas inspection carries the originating route context and preserves view/layer Explicit model navigation continues to follow the target layer. Draft validation and stale-context rejection apply before inspection. Existing production group selection already retained the canvas; fallback edge/port selection and canvas -context actions require the correction. Actual native reproduction must establish -which path accounts for the user's reported click; source diagnosis alone does not. +context actions require the correction. The published Windows baseline reproduces both reported failure states; +source diagnosis and those baseline results do not establish patched acceptance. +Contextual child/relationship creation must derive the semantic layer from the +selected object, while root creation continues to use the active perspective. ## Acceptance @@ -49,4 +51,12 @@ which path accounts for the user's reported click; source diagnosis alone does n Linux remains excluded by the existing user pause. Independent design critique identified saved visibility selectors and frozen migration as additional admission -paths; both are included. Automated and native outcomes are not yet recorded. +paths; both are included. At published candidate `603a59c1785dbcca324dae063ecc3405d9519a30`, 41 targeted core checks pass: legacy admission (2), projection switching (3), review regressions (15), source-6 view contract (8), and state diagrams (13). Tracked inputs were unchanged across the run. Whole-workspace formatting also passed at that commit. The first `0341bca` core attempt failed a generated-UUID test oracle; explicit fixture identities corrected the evaluator, without relaxing the admission assertion. + +The desktop attempt at `603a59c` was deliberately interrupted before test execution after independent source review found canvas Properties redispatch and contextual relationship/child semantic-layer defects. Its source inputs remained unchanged; no desktop passing result is claimed. The Properties issue arose from fixing explicit navigation to an already-inspected opposite-layer object. Keep both regressions: contextual Properties stays in the view, explicit navigation follows the object's layer. + +Published-preview executable SHA256 `2c03bd6cc3f1110c219e4f57ab94874a121590f83779239a93289b7170664399` reproduces edge selection changing SA to OA, and Architecture switching refused with `diagram_layer`. The isolated valid fixture remained byte-for-byte unchanged after the trial. An earlier fixture used reserved alias `capability` and was rejected; it was corrected before either reproduction. Initial native-dialog capture/input failures also remain separate from application acceptance. Independent evidence review confirmed the resulting screenshot states and persisted byte equality; screenshots alone do not capture the complete action sequence or transient state history. + +Candidate Windows GUI save/history/reopen, HTTP regression, broader affected suites and actual rendering acceptance remain outstanding. Raw local evidence is not yet a peer-consumable handoff; publish the consolidated evidence before integration. + +Scope boundary: contextual relationship forms retain the canvas while opening; successful Apply retains the existing explicit post-review navigation. The generic child-creation handler is tested for containment and allocation, but the separate architecture palette/performer workflow still offers creation kinds for the diagram layer. Thus an OA performer displayed in an SA Traceability view has no Add activity menu entry; this inherited limitation is not claimed repaired here.