From c7065db76ecca3faa733ccb5f09b87fd0a0958be Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 1 Oct 2026 22:49:47 -0700 Subject: [PATCH] Preserve canvas context across property edits and contextual drawing --- .../tests/populated_projection_switching.rs | 304 +++++++++++++++ crates/oa-desktop/src/app.rs | 135 ++++++- .../src/app/canvas_selection_tests.rs | 360 +++++++++++++++++ .../src/app/connection_begin_regressions.rs | 366 ++++++++++++++++++ crates/oa-desktop/src/connection_edit.rs | 18 + crates/oa-desktop/src/guide.rs | 3 +- crates/oa-desktop/src/model_menu.rs | 5 +- harness/navigation-draft-contract-20261001.md | 37 ++ 8 files changed, 1205 insertions(+), 23 deletions(-) create mode 100644 crates/oa-core/tests/populated_projection_switching.rs create mode 100644 crates/oa-desktop/src/app/connection_begin_regressions.rs create mode 100644 harness/navigation-draft-contract-20261001.md diff --git a/crates/oa-core/tests/populated_projection_switching.rs b/crates/oa-core/tests/populated_projection_switching.rs new file mode 100644 index 00000000..32597f95 --- /dev/null +++ b/crates/oa-core/tests/populated_projection_switching.rs @@ -0,0 +1,304 @@ +//! Populated presentation survives mixed-layer projection switches. +//! Complements projection_switching.rs without external fixture dependencies. +use oa_core::{ + projection::*, routing::route_diagram, source_project::*, view_options::ViewIndex, *, +}; + +struct Populated { + project: Project, + diagram: Id, + groups: [Id; 2], + children: [[Id; 2]; 2], + edges: [Id; 2], + ports: [Id; 2], + trace: Id, +} + +fn xy(x: f32, y: f32) -> Position { + Position { x, y } +} + +fn apply(project: &mut Project, command: Command) { + *project = prepare_command(project, &command).unwrap().project; +} + +fn fixture(layer: Layer, collapsed: bool) -> Populated { + let mut p = Project::empty(); + let oa_group = p + .add_element(Kind::Activity, Layer::Oa, "Operational response", None) + .unwrap(); + let oa_send = p + .add_element(Kind::Activity, Layer::Oa, "Request repair", Some(oa_group)) + .unwrap(); + let oa_receive = p + .add_element(Kind::Activity, Layer::Oa, "Perform repair", Some(oa_group)) + .unwrap(); + let sa_group = p + .add_element(Kind::Function, Layer::Sa, "System response", None) + .unwrap(); + let sa_send = p + .add_element(Kind::Function, Layer::Sa, "Dispatch repair", Some(sa_group)) + .unwrap(); + let sa_receive = p + .add_element(Kind::Function, Layer::Sa, "Execute repair", Some(sa_group)) + .unwrap(); + let oa_edge = p.relate( + RelationKind::Interaction, + "Repair request", + oa_send, + oa_receive, + ); + let sa_edge = p.relate( + RelationKind::FunctionalExchange, + "Repair dispatch", + sa_send, + sa_receive, + ); + let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = p.relations[&sa_edge].body + else { + unreachable!() + }; + let trace = Id::new_v4(); + let refinement = Trace { + id: trace, + alias: p.fresh_alias("dispatch_refinement", "trace", trace), + name: "Dispatch refines repair request".into(), + description: "Keep this cross-layer trace and its authored route.".into(), + oa: oa_send, + sa: sa_send, + kind: TraceKind::Refinement, + baseline: Some(TraceBaseline { + oa: p.properties(oa_send).unwrap(), + sa: p.properties(sa_send).unwrap(), + semantic: None, + }), + }; + apply(&mut p, Command::CreateTrace(refinement)); + let d = p.ensure_diagram(layer); + p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability; + let oa_group_node = p.place(d, oa_group, xy(0., 0.)); + let oa_send_node = p.place(d, oa_send, xy(40., 120.)); + let oa_receive_node = p.place(d, oa_receive, xy(360., 120.)); + let sa_group_node = p.place(d, sa_group, xy(0., 500.)); + let sa_send_node = p.place(d, sa_send, xy(40., 620.)); + let sa_receive_node = p.place(d, sa_receive, xy(360., 620.)); + for (node, port, side, offset) in [ + (sa_send_node, source_port, PortAnchorSide::Bottom, 0.25), + (sa_receive_node, target_port, PortAnchorSide::Top, 0.75), + ] { + apply( + &mut p, + Command::SetPortAnchor { + diagram: d, + node, + anchor: PortAnchor { port, side, offset }, + }, + ); + } + for (edge, y, port_backed) in [ + (oa_edge, 260., false), + (sa_edge, 860., true), + (trace, 420., false), + ] { + apply( + &mut p, + Command::SetEdgeRoute { + diagram: d, + route: EdgeRoute { + edge, + source_attachment: (!port_backed).then_some(EdgeAttachment { + side: PortAnchorSide::Right, + offset: 0.3, + }), + target_attachment: (!port_backed).then_some(EdgeAttachment { + side: PortAnchorSide::Left, + offset: 0.7, + }), + waypoints: vec![xy(680., y), xy(740., y)], + label: Some(EdgeLabel { + fraction: 0.4, + offset: xy(13., -27.), + }), + }, + }, + ); + } + for node in [oa_group_node, sa_group_node] { + let mut placement = p.diagrams[&d].nodes[&node].clone(); + placement.collapsed = collapsed; + placement.size = Size { + width: 310., + height: 170., + }; + apply( + &mut p, + Command::EditNode { + diagram: d, + original: node, + node: placement, + }, + ); + } + validate_candidate(&p).unwrap(); + Populated { + project: p, + diagram: d, + groups: [oa_group_node, sa_group_node], + children: [ + [oa_send_node, oa_receive_node], + [sa_send_node, sa_receive_node], + ], + edges: [oa_edge, sa_edge], + ports: [source_port, target_port], + trace, + } +} + +fn assert_round_trips(p: &Project) { + assert_eq!(language::parse(&language::format(p)).unwrap(), *p); + assert_eq!( + persistence::decode(&serde_json::to_vec(p).unwrap()).unwrap(), + *p + ); + assert_eq!( + compile_bundle(&format_bundle(p), ProjectLimits::default()) + .unwrap() + .decoded + .project, + *p + ); +} + +fn assert_layer_only(p: &Project, d: Id, view: &ProjectedView) { + let layer = p.diagrams[&d].layer; + for node in &view.nodes { + assert_eq!(p.elements[&node.element].layer(), layer); + if let Some(parent) = node.visual_parent { + let parent = view.nodes.iter().find(|n| n.id == parent).unwrap(); + assert_eq!(p.elements[&parent.element].layer(), layer); + } + } + for port in &view.ports { + assert_eq!(p.elements[&port.element].layer(), layer); + let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap(); + assert_eq!(p.elements[&owner.element].layer(), layer); + } + for edge in &view.edges { + assert!( + !edge.trace, + "cross-layer trace leaked into ordinary projection" + ); + assert_eq!(p.layer_of(edge.id), Some(layer)); + for endpoint in [edge.source, edge.target] { + let owner = view.nodes.iter().find(|n| n.id == endpoint).unwrap(); + assert_eq!(p.elements[&owner.element].layer(), layer); + } + for port in [edge.source_port, edge.target_port].into_iter().flatten() { + assert_eq!(p.elements[&port].layer(), layer); + } + } +} + +#[test] +fn populated_mixed_projection_switch_conserves_authorship_and_restores_geometry() { + for layer in [Layer::Oa, Layer::Sa] { + for collapsed in [false, true] { + let f = fixture(layer, collapsed); + let baseline = &f.project; + let d = f.diagram; + let before_view = project_view(baseline, d); + let before_routes = route_diagram(baseline, &baseline.diagrams[&d], &before_view); + // Guard against a vacuous fixture: Traceability deliberately flattens parents. + assert_eq!(before_view.nodes.len(), 6); + assert!(before_view.nodes.iter().all(|n| n.visual_parent.is_none())); + for id in [f.edges[0], f.edges[1], f.trace] { + assert!(before_view.edges.iter().any(|e| e.id == id)); + let route = baseline.diagrams[&d] + .routes + .iter() + .find(|r| r.edge == id) + .unwrap(); + assert_eq!(route.waypoints.len(), 2); + assert!(route.label.is_some()); + } + for port in f.ports { + assert!(before_view.ports.iter().any(|p| p.element == port)); + } + assert_round_trips(baseline); + for projection in [ + ProjectionKind::Overview, + ProjectionKind::Decomposition, + ProjectionKind::Allocation, + ProjectionKind::FunctionalDataflow, + ProjectionKind::Capabilities, + ] { + let mut expected = baseline.clone(); + expected.diagrams.get_mut(&d).unwrap().projection = projection; + let mut session = Session::new(baseline.clone()); + session + .execute(Command::EditDiagram(d, expected.diagrams[&d].clone())) + .unwrap(); + assert_eq!(session.project(), &expected); + assert_round_trips(session.project()); + let view = project_view(session.project(), d); + assert_eq!( + view, + project_view_with_index(&expected, d, &ViewIndex::new(&expected)) + ); + assert_layer_only(session.project(), d, &view); + if projection == ProjectionKind::Overview { + let own = if layer == Layer::Oa { 0 } else { 1 }; + assert!(view.nodes.iter().any(|n| n.id == f.groups[own])); + for child in f.children[own] { + if collapsed { + assert!(!view.nodes.iter().any(|n| n.id == child)); + } else { + assert_eq!( + view.nodes + .iter() + .find(|n| n.id == child) + .unwrap() + .visual_parent, + Some(f.groups[own]) + ); + } + } + if !collapsed { + assert!(view.edges.iter().any(|e| e.id == f.edges[own])); + if layer == Layer::Sa { + assert_eq!(view.ports.len(), 2); + } + } + } + session.undo(); + assert_eq!(session.project(), baseline); + assert!( + !session.can_undo(), + "projection change must be one model history step" + ); + assert_round_trips(session.project()); + session.redo(); + assert_eq!(session.project(), &expected); + session + .execute(Command::EditDiagram(d, baseline.diagrams[&d].clone())) + .unwrap(); + assert_eq!(session.project(), baseline); + let restored = project_view(session.project(), d); + assert_eq!(restored, before_view); + assert_eq!( + route_diagram( + session.project(), + &session.project().diagrams[&d], + &restored + ), + before_routes + ); + assert_round_trips(session.project()); + } + } + } +} diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index a7ca8cf4..fd7f5197 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -8,6 +8,8 @@ mod behavior_removal_tests; mod canvas_selection_tests; mod checkpoints; #[cfg(test)] +mod connection_begin_regressions; +#[cfg(test)] mod diagram_focus_tests; pub(crate) mod diagram_removal; #[cfg(test)] @@ -950,6 +952,7 @@ impl App { return true; } let original = self.selected; + let canvas_surface = self.architecture_surface(); let result = self .draft .as_ref() @@ -1024,6 +1027,19 @@ impl App { self.selected_node = node; } } + // Properties edit the inspected record; they do not request + // navigation away from the architecture view that owns them. + // Follow an explicitly edited diagram identity/layer, never an + // inferred replacement for an unrelated queued canvas click. + if let Some((diagram, _, _)) = canvas_surface { + let diagram = if diagram == original { id } else { diagram }; + if let Some(view) = self.project().diagrams.get(&diagram) + && view.projection.is_architecture() + { + self.layer = view.layer; + self.active_diagram = Some(diagram); + } + } self.sync(); self.status = "Properties applied".into(); true @@ -1226,15 +1242,50 @@ impl App { } self.refresh_draft(); } - /// Inspect an already validated canvas target without following its model layer. + /// The saved architecture surface, independent of the Inspector's object. + fn architecture_surface(&self) -> Option<(Id, Layer, ProjectionKind)> { + if self.page != Page::Diagram { + return None; + } + let diagram = self.project().diagrams.get(&self.active_diagram?)?; + diagram.projection.is_architecture().then_some(( + diagram.id, + diagram.layer, + diagram.projection, + )) + } + fn canvas_target_visible(&self, diagram: Id, id: Id) -> bool { + let view = project_view(self.project(), diagram); + view.nodes + .iter() + .any(|node| node.id == id || node.element == id) + || view.ports.iter().any(|port| port.element == id) + || view.edges.iter().any(|edge| edge.id == id) + } + /// Inspect a contextual target without following its model layer. An edit + /// may invalidate a previously visible occurrence; accept the edit but + /// refuse that old hit. Invisible Show-in-view targets remain intentional. fn inspect_canvas_target(&mut self, id: Id) -> bool { if self.selected == id { return true; } + let surface = self.architecture_surface(); + let accepted_draft = self.draft_dirty(); + let was_visible = accepted_draft + && surface.is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id)); if !self.project().contains(id) || !self.apply() { return false; } - if !self.project().contains(id) { + if !self.project().contains(id) + || (was_visible + && (self.architecture_surface() != surface + || !surface + .is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id)))) + { + if accepted_draft { + self.status = + "Properties applied. The view changed; select a visible target again.".into(); + } return false; } self.selected = id; @@ -2751,7 +2802,23 @@ impl App { e.kind() != Kind::Port && d.projection.includes(e.kind()) }) }); - if show_in_mixed_view || menu.request.canvas.is_some() { + // Draw acts on the visible current view even when opened from + // Explorer; explicit Select/Open actions still navigate. + let draw_in_current_view = match &*action { + Message::Connection(crate::connection_edit::Event::Begin(tool, source)) => { + *source == menu.request.object + && self.active_diagram.is_some_and(|diagram| { + crate::connection_edit::can_begin( + self.project(), + diagram, + *tool, + *source, + ) + }) + } + _ => false, + }; + if show_in_mixed_view || draw_in_current_view || menu.request.canvas.is_some() { if !self.inspect_canvas_target(menu.request.object) { return Task::none(); } @@ -2957,10 +3024,23 @@ impl App { return Task::none(); } let original = self.selected; + let surface = self.architecture_surface(); + let accepted_draft = self.draft_dirty(); if !self.apply() { return Task::none(); } let id = if id == original { self.selected } else { id }; + if let Some((diagram, _, _)) = surface + && (self.architecture_surface() != surface + || !self.canvas_target_visible(diagram, id)) + { + if accepted_draft { + self.status = + "Properties applied. The view changed; select a visible target again." + .into(); + } + return Task::none(); + } if let Some(d) = self .active_diagram .and_then(|d| self.project().diagrams.get(&d)) @@ -5042,8 +5122,30 @@ impl App { epoch: self.canvas_epoch, }) } + // Admission belongs to the caller: palette choice, contextual source, or + // an already admitted session whose complete context is still current. + fn arm_connection( + &mut self, + context: crate::route_edit::Context, + tool: crate::connection_tool::Tool, + ) -> crate::route_edit::Context { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + let context = crate::route_edit::Context { + epoch: self.canvas_epoch, + ..context + }; + self.connection = Some(crate::connection_edit::Session::new( + self.project(), + &project_view(self.project(), context.diagram), + context, + tool, + )); + self.selected_bend = None; + self.status = tool.hint().into(); + context + } fn edit_connection(&mut self, event: crate::connection_edit::Event) { - use crate::connection_edit::{Event, Session}; + use crate::connection_edit::Event; let Some(context) = self.route_context() else { return; }; @@ -5059,10 +5161,13 @@ impl App { } match event { Event::Begin(tool, source) => { - self.edit_connection(Event::Choose(Some(tool))); - if let Some(context) = self.route_context() { - self.edit_connection(Event::Endpoint(context, source)); + if !crate::connection_edit::can_begin(self.project(), context.diagram, tool, source) + { + self.status = "Choose a visible compatible source in this diagram.".into(); + return; } + let context = self.arm_connection(context, tool); + self.edit_connection(Event::Endpoint(context, source)); } Event::Choose(Some(tool)) => { if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) { @@ -5076,16 +5181,7 @@ impl App { { return; } - self.canvas_epoch = self.canvas_epoch.wrapping_add(1); - let context = self.route_context().unwrap(); - self.connection = Some(Session::new( - self.project(), - &project_view(self.project(), context.diagram), - context, - tool, - )); - self.selected_bend = None; - self.status = tool.hint().into(); + self.arm_connection(context, tool); } Event::Endpoint(captured, id) => { if captured != context { @@ -5164,8 +5260,11 @@ impl App { Event::Restart(captured) => { if captured == context && let Some(session) = &self.connection + && session.context == context { - self.edit_connection(Event::Choose(Some(session.tool))); + // A contextual mixed-layer tool need not be in this layer's + // palette. Its unchanged session context retains admission. + self.arm_connection(context, session.tool); } } Event::Choose(None) => {} diff --git a/crates/oa-desktop/src/app/canvas_selection_tests.rs b/crates/oa-desktop/src/app/canvas_selection_tests.rs index bbbf5cbc..eae9521f 100644 --- a/crates/oa-desktop/src/app/canvas_selection_tests.rs +++ b/crates/oa-desktop/src/app/canvas_selection_tests.rs @@ -542,3 +542,363 @@ fn child_creation_navigates_to_a_parent_layer_preferred_view_only_when_needed() assert_eq!(f.app.document.bundle(), &before.bundle); } } + +// Regression scope: a contextual click may accept a valid property draft, but +// must not inherit the draft object's semantic-layer navigation. If acceptance +// changes the originating diagram identity or hides the clicked target, retain +// that one accepted edit and refuse the old click. Do not infer a new target or +// reinterpret an old surface packet on a replacement diagram. +fn draft_click_fixture() -> (Fixture, Id) { + let mut f = fixture(); + let mut project = f.app.project().clone(); + let capability = project + .add_element(Kind::Capability, Layer::Oa, "Second capability", None) + .unwrap(); + let other_edge = project.relate( + RelationKind::ChainInvolvement, + "Also enables", + f.process, + capability, + ); + project.place(f.diagram, capability, Position { x: 500., y: 300. }); + 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(); + assert!( + project_view(f.app.project(), f.diagram) + .edges + .iter() + .any(|edge| edge.id == other_edge) + ); + (f, other_edge) +} + +fn expected_draft_commit(app: &App, command: Command) -> Document { + let mut expected = Document::from_bundle( + app.document.bundle().clone(), + oa_core::source_project::ProjectLimits::default(), + ) + .unwrap(); + expected.execute(command).unwrap(); + expected +} + +fn assert_accepted_draft(app: &App, expected: &Document, revision: u64) { + assert_eq!(app.project(), expected.project()); + assert_eq!(app.document.bundle(), expected.bundle()); + assert_eq!(app.document.revision(), revision + 1); + assert!(!app.draft_dirty()); + assert!(app.document.can_undo()); + assert!(!app.document.can_redo()); + assert_source_pair(app); +} + +fn assert_one_draft_history( + app: &mut App, + before: &oa_core::source_project::CompiledSourceProject, + expected: &Document, +) { + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!( + !app.document.can_undo(), + "the click must not add a second edit" + ); + assert!(app.document.can_redo()); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), expected.project()); + assert_eq!(app.document.bundle(), expected.bundle()); + assert!(!app.document.can_redo()); +} + +fn pending_edge_name(f: &mut Fixture) { + let context = f.app.route_context().unwrap(); + let _ = f.app.update(Message::CanvasSelect(context, f.edge)); + assert_surface(f); + assert_eq!(f.app.selected, f.edge); + let _ = f + .app + .update(Message::Field("/name".into(), "Reviewed enables".into())); + assert!(f.app.draft_dirty()); + assert!(f.app.draft.as_ref().unwrap().command().is_ok()); +} + +#[test] +fn valid_cross_layer_draft_then_canvas_edge_retains_surface_and_one_edit() { + let (mut f, other_edge) = draft_click_fixture(); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let expected = + expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into())); + pending_edge_name(&mut f); + // Capture after drafting, so this exercises successful draft application, + // not refusal of a packet made stale by the property edit itself. + let context = f.app.route_context().unwrap(); + let _ = f.app.update(Message::CanvasSelect(context, other_edge)); + assert_accepted_draft(&f.app, &expected, revision); + assert_surface(&f); + assert_eq!(f.app.selected, other_edge); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + Some(&Target::Edge(other_edge)) + ); + assert_one_draft_history(&mut f.app, &before, &expected); +} + +#[test] +fn valid_cross_layer_draft_then_node_click_retains_surface_and_one_edit() { + let (mut f, _) = draft_click_fixture(); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let expected = + expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into())); + pending_edge_name(&mut f); + let _ = f.app.update(Message::SelectNode(f.placement)); + assert_accepted_draft(&f.app, &expected, revision); + assert_surface(&f); + assert_eq!(f.app.selected, f.process); + assert_eq!(f.app.selected_node, Some(f.placement)); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + Some(&Target::Placement(f.placement)) + ); + assert_one_draft_history(&mut f.app, &before, &expected); +} + +#[test] +fn diagram_reidentity_draft_commits_but_refuses_the_old_surface_click() { + let (mut f, other_edge) = draft_click_fixture(); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let replacement = Id::new_v4(); + let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement)); + let _ = f + .app + .update(Message::Field("/id".into(), replacement.to_string())); + assert!(f.app.draft_dirty()); + let context = f.app.route_context().unwrap(); + let _ = f.app.update(Message::CanvasSelect(context, other_edge)); + assert_accepted_draft(&f.app, &expected, revision); + assert!(!f.app.project().diagrams.contains_key(&context.diagram)); + assert!( + f.app + .active_diagram + .is_some_and(|id| f.app.project().diagrams.contains_key(&id)), + "never restore a removed diagram identity" + ); + assert_ne!(f.app.selected, other_edge, "refuse the old surface click"); + assert!( + f.app + .multiselect_snapshot() + .is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge))) + ); + assert_eq!( + f.app.status, + "Properties applied. The view changed; select a visible target again." + ); + assert_one_draft_history(&mut f.app, &before, &expected); +} + +#[test] +fn projection_draft_commits_but_refuses_the_now_hidden_canvas_target() { + let (mut f, other_edge) = draft_click_fixture(); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let mut diagram = f.app.project().diagrams[&f.diagram].clone(); + diagram.projection = ProjectionKind::Allocation; + let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram)); + let _ = f.app.update(Message::FieldShape( + "/projection".into(), + serde_json::to_value(ProjectionKind::Allocation).unwrap(), + )); + assert!(f.app.draft_dirty()); + let context = f.app.route_context().unwrap(); + let _ = f.app.update(Message::CanvasSelect(context, other_edge)); + assert_accepted_draft(&f.app, &expected, revision); + assert_surface(&f); + assert!( + !project_view(f.app.project(), f.diagram) + .edges + .iter() + .any(|edge| edge.id == other_edge) + ); + assert_ne!( + f.app.selected, other_edge, + "do not inspect a no-longer-visible hit" + ); + assert!( + f.app + .multiselect_snapshot() + .is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge))) + ); + assert_eq!( + f.app.status, + "Properties applied. The view changed; select a visible target again." + ); + assert_one_draft_history(&mut f.app, &before, &expected); +} + +#[test] +fn invalid_cross_layer_draft_blocks_contextual_clicks_without_changing_history() { + for click in 0..3 { + let (mut f, other_edge) = draft_click_fixture(); + let context = f.app.route_context().unwrap(); + let _ = f.app.update(Message::CanvasSelect(context, f.edge)); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let _ = f + .app + .update(Message::Field("/id".into(), "not-a-uuid".into())); + assert!(f.app.draft_dirty()); + let expected_error = f.app.draft.as_ref().unwrap().command().unwrap_err(); + let draft = + serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + let selection = f.app.multiselect_snapshot().unwrap(); + let message = match click { + 0 => Message::CanvasSelect(f.app.route_context().unwrap(), other_edge), + 1 => Message::SelectNode(f.placement), + _ => Message::DiagramSelection(multiselect::Event::Choose( + f.app.multiselect_context().unwrap(), + Target::Edge(other_edge), + multiselect::Choice::Add, + )), + }; + let _ = f.app.update(message); + if click < 2 { + assert_eq!(f.app.status, expected_error); + } else { + assert_eq!( + f.app.status, + "Finish or cancel the active edit before changing the diagram selection." + ); + } + assert_surface(&f); + assert_eq!(f.app.selected, f.edge); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!(f.app.document.revision(), revision); + assert!(!f.app.document.can_undo()); + assert!(!f.app.document.can_redo()); + assert_eq!( + serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + draft + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selection.targets() + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + selection.primary() + ); + if click == 0 { + // Pre-dispatch stale rejection applies no draft and must not emit + // either success feedback or a new validation message. + let stale = f.app.route_context().unwrap(); + f.app.canvas_epoch += 1; + f.app.status = "Keep previous feedback".into(); + let _ = f.app.update(Message::CanvasSelect(stale, other_edge)); + assert_eq!(f.app.status, "Keep previous feedback"); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!(f.app.document.revision(), revision); + assert_eq!( + serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + draft + ); + } + } +} + +#[test] +fn explicit_select_after_valid_cross_layer_draft_still_navigates_and_commits_once() { + let (mut f, other_edge) = draft_click_fixture(); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let expected = + expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into())); + pending_edge_name(&mut f); + let _ = f.app.update(Message::Select(other_edge)); + assert_accepted_draft(&f.app, &expected, revision); + assert_eq!(f.app.selected, other_edge); + assert_eq!(f.app.layer, Layer::Oa); + assert_ne!(f.app.active_diagram, Some(f.diagram)); + assert_one_draft_history(&mut f.app, &before, &expected); +} + +#[test] +fn properties_apply_on_cross_layer_edge_retains_surface_and_one_edit() { + let (mut f, _) = draft_click_fixture(); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let expected = + expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into())); + pending_edge_name(&mut f); + let _ = f.app.update(Message::Apply); + assert_accepted_draft(&f.app, &expected, revision); + assert_eq!(f.app.status, "Properties applied"); + assert_surface(&f); + assert_eq!(f.app.selected, f.edge); + assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + Some(&Target::Edge(f.edge)) + ); + assert_one_draft_history(&mut f.app, &before, &expected); +} + +#[test] +fn properties_apply_honors_an_intentional_active_diagram_layer_edit() { + let (mut f, _) = draft_click_fixture(); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let mut diagram = f.app.project().diagrams[&f.diagram].clone(); + diagram.layer = Layer::Oa; + let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram)); + let _ = f.app.update(Message::FieldShape( + "/layer".into(), + serde_json::to_value(Layer::Oa).unwrap(), + )); + assert!(f.app.draft_dirty()); + let _ = f.app.update(Message::Apply); + assert_accepted_draft(&f.app, &expected, revision); + assert_eq!(f.app.page, Page::Diagram); + assert_eq!( + f.app.layer, + Layer::Oa, + "honor the edited diagram's own layer" + ); + assert_eq!(f.app.active_diagram, Some(f.diagram)); + assert_eq!(f.app.selected, f.diagram); + assert_eq!(f.app.draft.as_ref().unwrap().original, f.diagram); + assert_one_draft_history(&mut f.app, &before, &expected); +} + +#[test] +fn properties_apply_follows_the_known_active_diagram_reidentity_once() { + let (mut f, _) = draft_click_fixture(); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let replacement = Id::new_v4(); + let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement)); + let _ = f + .app + .update(Message::Field("/id".into(), replacement.to_string())); + assert!(f.app.draft_dirty()); + let _ = f.app.update(Message::Apply); + assert_accepted_draft(&f.app, &expected, revision); + assert_eq!(f.app.page, Page::Diagram); + assert_eq!(f.app.layer, Layer::Sa); + // This identity was explicitly authored in the diagram's own draft. No + // queued click is being remapped or inferred from semantic similarity. + assert_eq!(f.app.active_diagram, Some(replacement)); + assert_eq!(f.app.selected, replacement); + assert_eq!(f.app.draft.as_ref().unwrap().original, replacement); + assert!(!f.app.project().diagrams.contains_key(&f.diagram)); + assert_one_draft_history(&mut f.app, &before, &expected); +} diff --git a/crates/oa-desktop/src/app/connection_begin_regressions.rs b/crates/oa-desktop/src/app/connection_begin_regressions.rs new file mode 100644 index 00000000..06b52dd7 --- /dev/null +++ b/crates/oa-desktop/src/app/connection_begin_regressions.rs @@ -0,0 +1,366 @@ +// Test-only proposal pinned to peer 6520a89dc042dd433454f6cc63cb23b0e6df7911. +// Independent App test module; real popup messages retain menu tokens. +use super::*; +use crate::model_menu::{Event as MenuEvent, Request}; + +struct BeginFixture { + app: App, + diagram: Id, + surface_layer: Layer, + source: Id, + target: Id, + wrong_layer: Id, + hidden: Id, + tool: crate::connection_tool::Tool, +} + +fn begin_fixture(source_layer: Layer, allocation: bool, mixed: bool) -> BeginFixture { + use crate::connection_tool::Tool; + let mut p = Project::empty(); + let (work, performer, exchange) = match source_layer { + Layer::Oa => (Kind::Activity, Kind::Entity, Tool::Interaction), + Layer::Sa => (Kind::Function, Kind::System, Tool::FunctionalExchange), + }; + let opposite = if source_layer == Layer::Oa { + Layer::Sa + } else { + Layer::Oa + }; + let surface_layer = if mixed { opposite } else { source_layer }; + let source = p + .add_element( + if allocation { performer } else { work }, + source_layer, + "Source", + None, + ) + .unwrap(); + let target = p.add_element(work, source_layer, "Target", None).unwrap(); + let hidden = p + .add_element(work, source_layer, "Hidden target", None) + .unwrap(); + let other_work = if opposite == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let wrong_layer = p + .add_element(other_work, opposite, "Wrong layer target", None) + .unwrap(); + p.ensure_diagram(source_layer); + let diagram = p.ensure_diagram(surface_layer); + p.diagrams.get_mut(&diagram).unwrap().projection = if mixed { + ProjectionKind::Traceability + } else { + ProjectionKind::Capabilities + }; + p.place(diagram, source, Position { x: 0., y: 0. }); + p.place(diagram, target, Position { x: 400., y: 0. }); + if mixed { + p.place(diagram, wrong_layer, Position { x: 800., y: 0. }); + } + let mut app = App::with_project(p); + app.page = Page::Diagram; + app.layer = surface_layer; + app.active_diagram = Some(diagram); + app.selected = diagram; + app.refresh_draft(); + app.reconcile_multiselect(); + assert!(!app.document.can_undo()); + BeginFixture { + app, + diagram, + surface_layer, + source, + target, + wrong_layer, + hidden, + tool: if allocation { + Tool::Allocation + } else { + exchange + }, + } +} + +fn begin_surface(f: &BeginFixture) { + assert_eq!(f.app.page, Page::Diagram); + assert_eq!(f.app.layer, f.surface_layer); + assert_eq!(f.app.active_diagram, Some(f.diagram)); +} + +fn begin_popup_action(f: &mut BeginFixture) -> Option { + begin_popup_action_from(f, true) +} + +fn begin_popup_action_from(f: &mut BeginFixture, canvas: bool) -> Option { + let mut request = Request::object(f.source, iced::Point::ORIGIN); + if canvas { + request.canvas = f.app.route_context(); + request.placement = f.app.project().diagrams[&f.diagram] + .nodes + .values() + .find(|node| node.element == f.source) + .map(|node| node.id); + } + let _ = f.app.update(Message::Menu(MenuEvent::Open(request))); + let popup = crate::model_menu::popup(&f.app).expect("real model popup"); + let expected = format!("Draw {}", f.tool.label().to_lowercase()); + for entry in popup.entries { + if let crate::context_menu::Entry::Action { label, message, .. } = entry + && label == expected + { + if let Some(Message::Menu(MenuEvent::Invoke(token, _))) = &message { + assert_eq!(*token, popup.token); + } else { + assert!(message.is_none(), "Draw must use the menu token envelope"); + } + return message; + } + } + None // Omission is also a valid unavailable-action policy. +} + +fn begin_from_popup(f: &mut BeginFixture) -> Message { + begin_from_popup_origin(f, true) +} + +fn begin_from_popup_origin(f: &mut BeginFixture, canvas: bool) -> Message { + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let action = + begin_popup_action_from(f, canvas).expect("visible semantic source offers enabled Draw"); + let _ = f.app.update(action.clone()); + begin_surface(f); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!(f.app.document.revision(), revision); + assert!(!f.app.document.can_undo()); + let session = f + .app + .connection + .as_ref() + .expect("menu Draw must arm its tool"); + assert_eq!(session.tool, f.tool); + assert_eq!(session.source, Some(f.source)); + assert_eq!(session.context, f.app.route_context().unwrap()); + assert!(session.candidates.contains(&f.target)); + assert!(!session.candidates.contains(&f.wrong_layer)); + assert!(!session.candidates.contains(&f.hidden)); + action +} + +fn begin_complete_and_check_history(f: &mut BeginFixture) { + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let context = f.app.route_context().unwrap(); + let _ = f.app.update(Message::Connection( + crate::connection_edit::Event::Endpoint(context, f.target), + )); + begin_surface(f); + assert!(f.app.connection.is_none()); + assert_eq!(f.app.document.revision(), revision + 1); + let relations: Vec<_> = f + .app + .project() + .relations + .values() + .filter(|r| !before.decoded.project.relations.contains_key(&r.id)) + .collect(); + assert_eq!(relations.len(), 1); + let expected = match f.tool { + crate::connection_tool::Tool::Interaction => RelationKind::Interaction, + crate::connection_tool::Tool::FunctionalExchange => RelationKind::FunctionalExchange, + crate::connection_tool::Tool::Allocation => RelationKind::Allocation, + _ => unreachable!(), + }; + assert_eq!(relations[0].kind(), expected); + assert_eq!( + (relations[0].source(), relations[0].target()), + (f.source, f.target) + ); + // Random identities are accepted only for the precise command delta. + // Everything else, including traces, metadata and view options, stays exact. + let mut expected_project = before.decoded.project.clone(); + let created = relations[0]; + assert!( + expected_project + .relations + .insert(created.id, created.clone()) + .is_none() + ); + if let RelationBody::FunctionalExchange { + source_port, + target_port, + items, + .. + } = &created.body + { + assert_eq!(f.tool, crate::connection_tool::Tool::FunctionalExchange); + assert!(items.is_empty()); + assert_ne!(source_port, target_port); + let output = &f.app.project().elements[source_port]; + let input = &f.app.project().elements[target_port]; + assert_eq!( + (output.kind(), output.layer(), output.parent), + (Kind::Port, Layer::Sa, Some(f.source)) + ); + assert_eq!( + (input.kind(), input.layer(), input.parent), + (Kind::Port, Layer::Sa, Some(f.target)) + ); + assert!( + matches!(port_contract(output), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items.is_empty()) + ); + assert!( + matches!(port_contract(input), Some(PortContract::FunctionInput { incoming_items }) if incoming_items.is_empty()) + ); + assert!(!before.decoded.project.elements.contains_key(source_port)); + assert!(!before.decoded.project.elements.contains_key(target_port)); + assert_eq!(output.id, *source_port); + assert_eq!(input.id, *target_port); + expected_project + .elements + .insert(*source_port, output.clone()); + expected_project + .elements + .insert(*target_port, input.clone()); + assert_eq!( + f.app.project().elements.len(), + before.decoded.project.elements.len() + 2 + ); + } else { + assert_ne!(f.tool, crate::connection_tool::Tool::FunctionalExchange); + assert_eq!(f.app.project().elements, before.decoded.project.elements); + } + assert_eq!(f.app.project(), &expected_project); + assert_eq!(f.app.project().diagrams, before.decoded.project.diagrams); + for (id, element) in &before.decoded.project.elements { + assert_eq!(&f.app.project().elements[id], element); + } + begin_assert_source_pair(&f.app); + let after = f.app.document.snapshot(); + 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(), + "creation is a single history step" + ); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.project(), &after.decoded.project); + assert_eq!(f.app.document.bundle(), &after.bundle); + begin_assert_source_pair(&f.app); +} + +#[test] +fn contextual_begin_exchange_uses_actual_menu_in_both_mixed_directions() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = begin_fixture(layer, false, true); + begin_from_popup(&mut f); + begin_complete_and_check_history(&mut f); + } +} + +#[test] +fn contextual_begin_cancel_restart_and_stale_menu_preserve_session_ownership() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = begin_fixture(layer, false, true); + let original = f.app.document.snapshot(); + let stale_menu = begin_from_popup(&mut f); + let first = f.app.route_context().unwrap(); + let _ = f + .app + .update(Message::Connection(crate::connection_edit::Event::Restart( + first, + ))); + begin_surface(&f); + let restarted = f + .app + .connection + .as_ref() + .expect("restart retains contextual tool"); + assert_eq!(restarted.tool, f.tool); + assert_eq!(restarted.source, None); + assert!(restarted.candidates.contains(&f.source)); + let current = restarted.context; + assert_ne!(current, first); + let _ = f.app.update(stale_menu); + let _ = f.app.update(Message::Connection( + crate::connection_edit::Event::Endpoint(first, f.target), + )); + assert_eq!(f.app.connection.as_ref().unwrap().context, current); + assert_eq!(f.app.connection.as_ref().unwrap().source, None); + let _ = f.app.update(Message::Connection( + crate::connection_edit::Event::Endpoint(current, f.source), + )); + assert_eq!(f.app.connection.as_ref().unwrap().source, Some(f.source)); + let _ = f.app.update(Message::Escape); + let _ = f.app.update(Message::Connection( + crate::connection_edit::Event::Endpoint(current, f.target), + )); + assert!(f.app.connection.is_none()); + begin_surface(&f); + assert_eq!(f.app.project(), &original.decoded.project); + assert_eq!(f.app.document.bundle(), &original.bundle); + assert!(!f.app.document.can_undo()); + } +} + +#[test] +fn contextual_begin_allocation_uses_actual_menu_in_both_mixed_directions() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = begin_fixture(layer, true, true); + begin_from_popup(&mut f); + begin_complete_and_check_history(&mut f); + } +} + +#[test] +fn contextual_begin_allocation_is_not_enabled_when_projection_hides_work() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = begin_fixture(layer, true, false); + let view = project_view(f.app.project(), f.diagram); + assert!(view.nodes.iter().any(|n| n.element == f.source)); + assert!(!view.nodes.iter().any(|n| n.element == f.target)); + assert!(!f.tool.available_in(layer, ProjectionKind::Capabilities)); + let before = f.app.document.snapshot(); + assert!( + begin_popup_action(&mut f).is_none(), + "Draw allocation must be disabled or omitted when work is not projected" + ); + let _ = f.app.update(Message::Menu(MenuEvent::Close)); + // Defensive handler refusal complements actual-menu enabledness above. + let _ = f + .app + .update(Message::Connection(crate::connection_edit::Event::Begin( + f.tool, f.source, + ))); + assert!(f.app.connection.is_none()); + begin_surface(&f); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!(!f.app.document.can_undo()); + } +} + +fn begin_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 contextual_begin_explorer_draw_retains_visible_mixed_diagram_in_both_directions() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = begin_fixture(layer, false, true); + // A real Explorer Request::object has no canvas/placement envelope, + // but its generated Draw action still targets the visible current view. + begin_from_popup_origin(&mut f, false); + begin_complete_and_check_history(&mut f); + } +} diff --git a/crates/oa-desktop/src/connection_edit.rs b/crates/oa-desktop/src/connection_edit.rs index 8166be23..93f57682 100644 --- a/crates/oa-desktop/src/connection_edit.rs +++ b/crates/oa-desktop/src/connection_edit.rs @@ -39,6 +39,24 @@ impl Session { } } +/// Contextual drawing follows the visible semantic source, not the palette layer. +/// The caller separately validates the menu/context lifetime and pending drafts. +pub fn can_begin(project: &Project, diagram: Id, tool: Tool, source: Id) -> bool { + let Some(diagram) = project.diagrams.get(&diagram) else { + return false; + }; + let Some(element) = project.elements.get(&source) else { + return false; + }; + diagram.projection.is_architecture() + && (element.layer() == diagram.layer + || diagram.projection == oa_core::ProjectionKind::Traceability) + && Tool::for_layer(element.layer()).contains(&tool) + && tool.can_start(project, source) + && (tool != Tool::Allocation || tool.available_in(element.layer(), diagram.projection)) + && visible(&oa_core::projection::project_view(project, diagram.id)).any(|id| id == source) +} + pub fn visible(view: &ProjectedView) -> impl Iterator + '_ { view.nodes .iter() diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index d049d798..71858a31 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -322,7 +322,8 @@ fn article(topic: Topic) -> Article { "Show a correspondence between OA and SA", &[ "Choose OA ↔ SA traceability in View content. Changing this setting does not create counterparts or add their placements, so an existing single-layer diagram can initially look unchanged.", - "Switching back to Architecture or another single-layer projection shows only that view's layer. The other layer's placements, routing and saved visibility remain stored; return to OA ↔ SA traceability to show them again, subject to the view's filters. Selecting a displayed card, port or line and opening its Properties keeps the current view and perspective.", + "Switching back to Architecture or another single-layer projection shows only that view's layer. The other layer's placements, routing and saved visibility remain stored; return to OA ↔ SA traceability to show them again, subject to the view's filters. Selecting a displayed card, port or line and opening its Properties keeps the current view and perspective. Applying Properties keeps that saved architecture view open. Changes you make to the view's own perspective or identity still take effect. If the app asks you to select again, choose a visible target.", + "In a mixed traceability view, use Draw from a visible source's canvas or Model explorer menu to connect from that source's OA or SA layer without leaving the view. Choose a highlighted compatible visible target; Escape cancels. The palette offers tools for the view's perspective. Opening an endpoint or choosing a record in Model explorer follows that record's perspective. Contextual relationship forms keep the view while opening; applying the reviewed relationship can navigate to its result.", "Use Review layer traces to inspect named Operational and System endpoints and the meaning of their link. This opens the model's layer-trace list with no search restriction; traces between relationships are reviewed here rather than drawn between element cards.", "In the chosen architecture view, use Add existing elements. The chooser offers compatible elements from both layers. Select an existing endpoint and click its position in the canvas; repeat for the other end if needed. This adds a placement, not a new semantic element or trace. For port correspondences, place the ports' owners.", "The view shows correspondence lines alongside ordinary relationships. The shown-line count reflects the current projection and visibility settings, not trace coverage. If a line is hidden, use View visibility and inspect Saved view visibility; placing an endpoint does not remove explicit hides or filters.", diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 054b71b2..d0da51f5 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -557,10 +557,7 @@ pub fn popup(app: &App) -> Option> { &format!("Draw {}", tool.label().to_lowercase()), Message::Connection(crate::connection_edit::Event::Begin(*tool, id)), app.active_diagram.is_some_and(|diagram| { - crate::connection_edit::visible(&projection::project_view( - p, diagram, - )) - .any(|element| element == id) + crate::connection_edit::can_begin(p, diagram, *tool, id) }), )); } diff --git a/harness/navigation-draft-contract-20261001.md b/harness/navigation-draft-contract-20261001.md new file mode 100644 index 00000000..59e8feb4 --- /dev/null +++ b/harness/navigation-draft-contract-20261001.md @@ -0,0 +1,37 @@ +# Saved Architecture inspection and contextual drawing contract + +This bounded desktop contract refines the peer [projection/navigation decision](projection-navigation-20260925.md) for [#43](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/43) and [#42](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/42). It describes intended correction behavior against peer source `6520a89dc042dd433454f6cc63cb23b0e6df7911`, based on `715036e132677dd2bc2fb37ed316d93dc9987aec`. It is not a runtime result, integration claim or live task ledger. Parent owns integration and source/evidence attribution. + +## Inspection and accepted property edits + +Inspection and explicit navigation are distinct intents. Selecting a visible Architecture card, edge, label or port, or opening its Properties, retains the saved canvas and displayed perspective even when the semantic target belongs to the other layer. Explicit Select/Open-owner/Open-endpoint navigation may follow the target's layer. + +Applying an ordinary property draft must not accidentally turn inspection into navigation. Preserve the saved Architecture surface through the accepted transaction and reconciliation. If the user intentionally changes that active diagram's identity or layer, follow the accepted new identity/layer; do not overwrite it with the old surface. Complete model/source changes and their history remain those of the accepted property transaction. + +A pending click captured before Apply is not automatically valid afterward. If the accepted edit changes its target, diagram or projection membership, do not replay a stale selection or gesture against the new document. Retain the accepted edit and require another selection. Invalid drafts remain uncommitted with recoverable draft state and no target change. This contract is scoped to saved Architecture surfaces; typed StateMachine/Scenario representations and their existing navigation/owner guards are not broadened. + +## Contextual Draw + +Draw from a visible semantic source is contextual to the actual current diagram, whether its menu originated on the canvas or in Model explorer. A matching validated menu action must not navigate to a preferred same-layer view before starting. In Traceability, an OA source may start its OA tool in an SA view and vice versa. The source must exist, belong to a tool-supported semantic family/layer and be projected visibly in that current saved Architecture diagram. Opposite-layer latent records in ordinary projections are not actionable sources. + +Targets remain compatible visible endpoints under the existing semantic validators. Allocation still requires a projection supporting performer and work, and same-layer compatible work; mixed visibility does not authorize cross-layer Allocation or bypass leaf/direct-assignment rules. Capabilities may show a performer while excluding work: its Draw allocation action must not be enabled. Overview, Decomposition, Allocation and Traceability support both endpoint categories. + +The active-perspective palette retains its existing tool family. Contextual admission does not expand that palette. Restart of an already admitted, still-current session resets that same tool against the captured view rather than routing it back through palette-layer eligibility. Escape cancels without creating a relation. Old menu tokens, document revisions, diagram/epoch contexts and pre-restart endpoints cannot act on a newer session. Dirty property/source edits retain their existing refusal behavior. + +This does not redefine the separate relationship form workflow: opening a contextual form retains the canvas; successful reviewed Apply keeps its existing explicit post-review navigation. It also does not repair the separately documented opposite-layer performer Add activity/function menu limitation. + +## Rationale and rejected shortcuts + +The recorded defects join unlike intents: ordinary model selection during property application can switch the inspected surface, and routing contextual Begin through palette Choose can reject an enabled Draw action. Repair the intent-specific boundary and reuse semantic admission checks between menu enabledness and Begin. + +Blindly restoring the old layer/diagram after an edit is rejected: it can erase the effect of an intentional diagram-layer/identity edit or revive a stale gesture. Temporarily switching layer to arm a tool risks preferred-view navigation and stale context. Broadening every palette to both layers is a separate UI decision and does not fix stale menus or mismatched enabledness. Injecting a Session in a test bypasses the menu-to-Begin defect. + +## Evaluators and evidence boundaries + +Use actual generated token-wrapped popup actions for both OA-in-SA and SA-in-OA Traceability, from canvas and non-canvas Model explorer requests. Begin must retain surface and full model/source/history; completion may add only its one relation plus the exact two newly referenced SA function ports when FunctionalExchange requires them. Verify complete Project equality against that permitted delta, source recompilation parity and one-step Undo/Redo. Cover Allocation enabledness, visible compatible/hidden/wrong-layer endpoints, cancellation, restart and stale messages. + +For draft inspection, cover a valid property edit applied while clicking another visible opposite-layer object, invalid drafts, accepted edits invalidating the captured target, and deliberate active-diagram layer/identity edits. Compare complete source/model/history; do not accept a preserved screenshot alone. Existing frozen-version admission, reversible projection and typed State/Scenario negative controls retain their original scope. + +Native acceptance must exercise actual pointer selection, menu opening, Apply/reselect feedback, visible target highlights, Escape/restart, Save and fresh reopen on an identified build. Source review and handler tests do not establish native or usability acceptance. No repaired runtime pass, fresh native acceptance, release or issue closure is claimed here. + +The initial correction was reviewed against the frozen source manifest for `repaired-targets`: all 554 input hashes matched the independent archive review. The parent executed 20 canvas-selection tests and five real-menu connection regressions successfully, with formatting passing and inputs unchanged. These are bounded handler/source results, not native acceptance. Complete workspace gates, the saved-source evaluator, actual Windows interaction and persisted history checks remain pending at this source checkpoint. A later validation record must identify the tested commit and publish the retained baseline failures, corrected raw gates, independent reviews, native observations and model/source comparisons. The original peer topic remains authoritative for its earlier evidence and limitations; failed or interrupted commands retain their actual outcomes.