diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 5d1dcdc6..a7f9ba55 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -22,6 +22,8 @@ pub(crate) mod source_rename; mod source_symbols; pub(crate) mod text_workspace; #[cfg(test)] +mod traceability_navigation_tests; +#[cfg(test)] mod transition_context_tests; #[cfg(test)] mod view_copy_tests; @@ -359,6 +361,7 @@ pub enum Message { Page(Page), Guide(crate::guide::Event), Traceability(crate::views::traceability::Event), + ReviewLayerTraces, Behavior(crate::behavior::Event), Layer(Layer), Select(Id), @@ -2310,7 +2313,30 @@ impl App { "Apply or revert pending edits before using modeling actions.".into(); return Task::none(); } - self.select(menu.request.object); + // Show acts on the diagram captured by the validated menu. + // Ordinary cross-layer selection would switch to that element's + // preferred same-layer view before the placement command runs. + let show_in_mixed_view = matches!(&*action, Message::PlaceSelected) + && self + .active_diagram + .and_then(|id| self.project().diagrams.get(&id)) + .is_some_and(|d| { + d.projection == ProjectionKind::Traceability + && self + .project() + .elements + .get(&menu.request.object) + .is_some_and(|e| { + e.kind() != Kind::Port && d.projection.includes(e.kind()) + }) + }); + if show_in_mixed_view { + self.selected = menu.request.object; + self.selected_attachment = None; + self.refresh_draft(); + } else { + self.select(menu.request.object); + } self.selected_node = menu.request.placement; self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i)); return self.update(*action); @@ -2508,6 +2534,23 @@ impl App { iced::widget::scrollable::RelativeOffset::START, ); } + Message::ReviewLayerTraces => { + // Reuse Page navigation so modal, placement and behavior-form + // guards run before changing the transient inventory filter. + let navigation = self.update(Message::Page(Page::Traces)); + if self.page == Page::Traces && !self.behavior.has_pending() { + self.traceability.filter = crate::views::traceability::Filter::Traces; + self.traceability.query.clear(); + return Task::batch([ + navigation, + iced::widget::operation::snap_to( + "traceability-list", + iced::widget::scrollable::RelativeOffset::START, + ), + ]); + } + return navigation; + } Message::Search(value) => { self.search = value; self.explorer_page = 0; diff --git a/crates/oa-desktop/src/app/architecture_authoring.rs b/crates/oa-desktop/src/app/architecture_authoring.rs index 5685c611..0e01bee8 100644 --- a/crates/oa-desktop/src/app/architecture_authoring.rs +++ b/crates/oa-desktop/src/app/architecture_authoring.rs @@ -301,6 +301,17 @@ impl App { _ => return Task::none(), }; let placing = point.is_none() && matches!(intent, Intent::Create { .. }); + let filter = if matches!(intent, Intent::Existing { .. }) + && self + .project() + .diagrams + .get(&diagram) + .is_some_and(|d| d.projection == ProjectionKind::Traceability) + { + Filter::AllCompatible + } else { + Filter::Context + }; let saved = Saved { version: 1, base: oa_core::transitions::project_fingerprint(self.project()), @@ -309,7 +320,7 @@ impl App { point, placing, query: String::new(), - filter: Filter::Context, + filter, show_placed: false, page: 0, }; diff --git a/crates/oa-desktop/src/app/architecture_authoring/tests.rs b/crates/oa-desktop/src/app/architecture_authoring/tests.rs index 18ee63c3..681e1882 100644 --- a/crates/oa-desktop/src/app/architecture_authoring/tests.rs +++ b/crates/oa-desktop/src/app/architecture_authoring/tests.rs @@ -1,4 +1,5 @@ use super::*; +mod traceability; fn setup(layer: Layer) -> App { let mut app = App::with_project(Project::empty()); diff --git a/crates/oa-desktop/src/app/architecture_authoring/tests/traceability.rs b/crates/oa-desktop/src/app/architecture_authoring/tests/traceability.rs new file mode 100644 index 00000000..657078cd --- /dev/null +++ b/crates/oa-desktop/src/app/architecture_authoring/tests/traceability.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn traceability_existing_defaults_show_opposite_layer_and_place_only_one_node() { + let mut project = Project::empty(); + let sa = project + .add_element(Kind::Function, Layer::Sa, "Deliver report", None) + .unwrap(); + let system = project + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let diagram = project.ensure_diagram(Layer::Oa); + project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability; + let mut app = App::with_project(project); + app.page = Page::Diagram; + app.layer = Layer::Oa; + app.active_diagram = Some(diagram); + let before = app.document.snapshot(); + send(&mut app, Event::Existing(None)); + assert_eq!( + app.architecture.as_ref().unwrap().saved.filter, + Filter::AllCompatible + ); + let listed = candidates(&app); + assert!(listed.iter().any(|(id, _, shown)| *id == sa && !shown)); + assert!(listed.iter().any(|(id, _, shown)| *id == system && !shown)); + send(&mut app, Event::Pick(sa)); + assert!(app.architecture.as_ref().unwrap().saved.placing); + assert_eq!(app.project(), &before.decoded.project); + let context = app.route_context().unwrap(); + let point = Position { x: 345., y: 215. }; + send(&mut app, Event::Click(context, point)); + assert!(app.architecture.is_none(), "{}", app.status); + assert_eq!(app.active_diagram, Some(diagram)); + let placed = app.project().diagrams[&diagram] + .nodes + .values() + .find(|n| n.element == sa) + .unwrap() + .clone(); + assert_eq!(placed.position, point); + let mut expected = before.decoded.project.clone(); + expected + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .insert(placed.id, placed); + assert_eq!(app.project(), &expected); + let accepted = app.document.bundle().clone(); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo()); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &accepted); +} + +#[test] +fn traceability_default_does_not_change_creation_or_ordinary_picker_guards() { + let mut app = setup(Layer::Oa); + let diagram = app.active_diagram.unwrap(); + send(&mut app, Event::Existing(None)); + assert_eq!( + app.architecture.as_ref().unwrap().saved.filter, + Filter::Context + ); + send(&mut app, Event::Cancel); + let _ = app.update(Message::Projection(ProjectionKind::Traceability)); + send(&mut app, Event::Start(Kind::Entity, None)); + assert_eq!( + app.architecture.as_ref().unwrap().saved.filter, + Filter::Context + ); + send(&mut app, Event::Cancel); + assert_eq!(app.active_diagram, Some(diagram)); + + let mut project = app.project().clone(); + let sa = project + .add_element(Kind::Function, Layer::Sa, "Other layer", None) + .unwrap(); + let port = Id::new_v4(); + project.elements.insert( + port, + Element { + id: port, + alias: "legacy_port".into(), + name: "Port".into(), + description: String::new(), + parent: Some(sa), + body: ElementBody::shared_in( + Layer::Sa, + SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: None, + }), + }, + ), + }, + ); + oa_core::validate_candidate(&project).unwrap(); + for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] { + let mut p = project.clone(); + p.diagrams.get_mut(&diagram).unwrap().projection = projection; + let mut trial = App::with_project(p); + trial.page = Page::Diagram; + trial.active_diagram = Some(diagram); + trial.layer = Layer::Oa; + let before = trial.document.bundle().clone(); + send(&mut trial, Event::Existing(None)); + send(&mut trial, Event::Filter(Filter::AllCompatible)); + assert_eq!( + candidates(&trial).iter().any(|(id, _, _)| *id == sa), + projection == ProjectionKind::Traceability + ); + assert!(!candidates(&trial).iter().any(|(id, _, _)| *id == port)); + send(&mut trial, Event::Pick(port)); + assert!(!trial.architecture.as_ref().unwrap().saved.placing); + send(&mut trial, Event::Cancel); + assert_eq!(trial.document.bundle(), &before); + } +} diff --git a/crates/oa-desktop/src/app/traceability_navigation_tests.rs b/crates/oa-desktop/src/app/traceability_navigation_tests.rs new file mode 100644 index 00000000..1b5fc681 --- /dev/null +++ b/crates/oa-desktop/src/app/traceability_navigation_tests.rs @@ -0,0 +1,273 @@ +use super::*; +use crate::{context_menu::Entry, model_menu, views::traceability::Filter}; + +fn send(app: &mut App, message: Message) { + let _ = app.update(message); +} + +fn fixture() -> (App, Id, Id, Id, Id) { + let mut p = Project::empty(); + let oa = p + .add_element(Kind::Activity, Layer::Oa, "Observe", None) + .unwrap(); + let sa = p + .add_element(Kind::Function, Layer::Sa, "Detect", None) + .unwrap(); + let mixed = p.ensure_diagram(Layer::Oa); + p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability; + p.place(mixed, oa, Position { x: 300., y: 200. }); + let system = p.ensure_diagram(Layer::Sa); + p.place(system, sa, Position { x: 700., y: 200. }); + oa_core::validate_candidate(&p).unwrap(); + let mut app = App::with_project(p); + app.page = Page::Diagram; + app.layer = Layer::Oa; + app.active_diagram = Some(mixed); + app.selected = oa; + app.refresh_draft(); + app.traceability.filter = Filter::Artifacts; + app.traceability.query = "stale artifact query".into(); + (app, mixed, system, oa, sa) +} + +fn show_action(app: &mut App, element: Id) -> Message { + send(app, model_menu::open(element, iced::Point::ORIGIN)); + model_menu::popup(app) + .unwrap() + .entries + .into_iter() + .find_map(|entry| match entry { + Entry::Action { label, message, .. } if label == "Show in this diagram" => { + Some(message) + } + _ => None, + }) + .expect("eligible real menu action") +} + +#[test] +fn review_layer_traces_resets_inventory_without_applying_property_or_source_drafts() { + for source_draft in [false, true] { + let (mut app, mixed, _, oa, _) = fixture(); + if source_draft { + send(&mut app, Message::Page(Page::Text)); + send( + &mut app, + Message::SourceEdit(iced::widget::text_editor::Action::Edit( + iced::widget::text_editor::Edit::Insert('x'), + )), + ); + assert!(app.source_dirty()); + } else { + send( + &mut app, + Message::Field("/alias".into(), "invalid alias".into()), + ); + assert!(app.draft_dirty()); + } + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + let source = app.source_editor.text(); + let before = app.document.snapshot(); + let camera = (app.zoom, app.pan); + send(&mut app, Message::ReviewLayerTraces); + assert_eq!(app.page, Page::Traces); + assert_eq!(app.traceability.filter, Filter::Traces); + assert!(app.traceability.query.is_empty()); + assert_eq!( + (app.layer, app.active_diagram, app.selected), + (Layer::Oa, Some(mixed), oa) + ); + assert_eq!((app.zoom, app.pan), camera); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + assert_eq!(app.source_editor.text(), source); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert!(!app.document.can_undo()); + } +} + +#[test] +fn review_layer_traces_respects_busy_modal_and_active_placement_guards() { + for already_traces in [false, true] { + for guard in 0..5 { + let (mut app, mixed, _, oa, _) = fixture(); + match guard { + 0 => app.busy = true, + 1 => app.delete_pending = true, + 2 => app.hosted.visible = true, + 3 => send( + &mut app, + Message::Architecture(architecture_authoring::Event::Existing(None)), + ), + 4 => { + app.page = Page::Behavior; + let p = app.project().clone(); + app.behavior + .creation + .update(&p, behavior::creation::Event::Open(Layer::Oa)) + .unwrap(); + assert!(app.behavior.has_pending()); + } + _ => unreachable!(), + } + if already_traces { + app.page = Page::Traces; + } + let original_page = app.page; + let before = app.document.snapshot(); + let behavior_before = app.behavior.recovery_snapshot().unwrap(); + send(&mut app, Message::ReviewLayerTraces); + assert_eq!( + app.page, original_page, + "guard {guard}, already traces {already_traces}" + ); + assert_eq!(app.traceability.filter, Filter::Artifacts); + assert_eq!(app.traceability.query, "stale artifact query"); + assert_eq!( + (app.layer, app.active_diagram, app.selected), + (Layer::Oa, Some(mixed), oa) + ); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), behavior_before); + if guard == 3 { + assert!(app.architecture.is_some()); + } + } + } +} + +#[test] +fn mixed_view_show_keeps_its_context_for_every_diagram_app_and_target_layer() { + for diagram_layer in [Layer::Oa, Layer::Sa] { + for app_layer in [Layer::Oa, Layer::Sa] { + for target_layer in [Layer::Oa, Layer::Sa] { + let mut p = Project::empty(); + let oa = p + .add_element(Kind::Activity, Layer::Oa, "Observe", None) + .unwrap(); + let sa = p + .add_element(Kind::Function, Layer::Sa, "Detect", None) + .unwrap(); + let target = if target_layer == Layer::Oa { oa } else { sa }; + let other = if target_layer == Layer::Oa { sa } else { oa }; + let mixed = p.ensure_diagram(diagram_layer); + p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability; + // An attractive same-layer alternative makes accidental + // semantic-selection navigation observable in both directions. + let mut alternative = p.diagrams[&mixed].clone(); + alternative.id = Id::new_v4(); + alternative.alias = "same_layer_alternative".into(); + alternative.layer = target_layer; + alternative.projection = ProjectionKind::Overview; + let alternative_id = alternative.id; + p.diagrams.insert(alternative_id, alternative); + p.place(alternative_id, target, Position { x: 700., y: 200. }); + oa_core::validate_candidate(&p).unwrap(); + let mut app = App::with_project(p); + app.page = Page::Diagram; + app.layer = app_layer; + app.active_diagram = Some(mixed); + app.selected = other; + app.refresh_draft(); + let before = app.project().clone(); + let action = show_action(&mut app, target); + send(&mut app, action); + assert_eq!( + (app.active_diagram, app.layer, app.selected), + (Some(mixed), app_layer, target) + ); + let added = app.project().diagrams[&mixed] + .nodes + .values() + .find(|n| n.element == target) + .unwrap() + .clone(); + let mut expected = before; + expected + .diagrams + .get_mut(&mixed) + .unwrap() + .nodes + .insert(added.id, added); + assert_eq!(app.project(), &expected); + } + } + } +} + +#[test] +fn mixed_view_show_menu_places_in_captured_view_with_exact_undo_redo() { + let (mut app, mixed, system, _, sa) = fixture(); + let before = app.document.snapshot(); + let action = show_action(&mut app, sa); + send(&mut app, action); + assert_eq!( + (app.active_diagram, app.layer, app.selected), + (Some(mixed), Layer::Oa, sa) + ); + let added = app.project().diagrams[&mixed] + .nodes + .values() + .find(|n| n.element == sa) + .unwrap() + .clone(); + assert_eq!(added.position, Position { x: 60., y: 60. }); + let mut expected = before.decoded.project.clone(); + expected + .diagrams + .get_mut(&mixed) + .unwrap() + .nodes + .insert(added.id, added); + assert_eq!(app.project(), &expected); + assert_eq!( + app.project().diagrams[&system], + before.decoded.project.diagrams[&system] + ); + let after = app.document.snapshot(); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo()); + send(&mut app, Message::Redo); + assert_eq!(app.project(), &after.decoded.project); + assert_eq!(app.document.bundle(), &after.bundle); +} + +#[test] +fn mixed_view_show_menu_rejects_stale_context_and_pending_property_changes() { + for refusal in 0..3 { + let (mut app, mixed, system, oa, sa) = fixture(); + let action = show_action(&mut app, sa); + match refusal { + 0 => app.active_diagram = Some(system), + 1 => app.canvas_epoch = app.canvas_epoch.wrapping_add(1), + 2 => { + // A queued action must not apply a draft that became dirty + // after the menu was rendered, even when its context is valid. + app.draft + .as_mut() + .unwrap() + .input("/name", "Unapplied name".into()); + assert!(app.draft_dirty()); + } + _ => unreachable!(), + } + let before = app.document.snapshot(); + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + send(&mut app, action); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.selected, oa); + assert_eq!( + app.active_diagram, + Some(if refusal == 0 { system } else { mixed }) + ); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + assert!(!app.document.can_undo()); + } +} diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index e3afadf4..56ff4881 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -307,7 +307,7 @@ fn article(topic: Topic) -> Article { "Choose and edit the relationship you mean", &[ "Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.", - "Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships is a broader exploration view; OA ↔ SA traceability displays counterparts.", + "Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships explores one layer; OA ↔ SA traceability can show placed elements from both layers and their correspondence lines alongside ordinary relationships.", "Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.", "Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange next drag or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.", "Select a diagram node to access Placement, Fold / unfold and Hide. With the canvas focused, Delete hides its placement; Shift+Delete asks to remove the model element. Other views retain a hidden element. Undo restores either change.", @@ -317,6 +317,16 @@ fn article(topic: Topic) -> Article { "Keep diagrams readable", "Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.", ), + walkthrough( + "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.", + "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.", + "If the counterpart is already placed, use Include already shown (Locate) in the chooser. Undo restores an added placement; saving preserves each view's placements independently. Use the Traceability page's existing counterpart or manual-trace workflow when the semantic link itself does not yet exist.", + ], + ), walkthrough( "Move work between performers", &[ diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index c0a4cbf8..e8659671 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -458,7 +458,8 @@ pub fn popup(app: &App) -> Option