//! Contextual actions reuse the ordinary guarded application commands. use crate::{ app::{App, Message, Page}, context_menu::{Entry, Menu}, port_workflow, route_edit, }; use iced::Point; use oa_core::*; #[derive(Debug, Clone)] pub struct Request { pub anchor: Point, pub object: Id, pub placement: Option, pub selection_target: Option>, pub bend: Option, pub route_point: Option, pub route_segment: Option<(usize, Position)>, pub creation_point: Option, pub canvas: Option, pub process: Option>, pub source_file: Option, } impl Request { pub fn object(object: Id, anchor: Point) -> Self { Self { anchor, object, placement: None, selection_target: None, bend: None, route_point: None, route_segment: None, creation_point: None, canvas: None, process: None, source_file: None, } } } #[derive(Debug, Clone)] pub enum Event { Open(Request), Invoke(u64, Box), Close, } pub struct Opened { pub token: u64, pub request: Request, document: Id, revision: u64, epoch: u64, page: Page, layer: Layer, diagram: Option, } impl Opened { pub fn new(app: &App, request: Request, token: u64) -> Option { if !app.project().contains(request.object) || !target_matches(app, &request) || request .canvas .is_some_and(|c| Some(c) != current_canvas(app)) || request .process .as_deref() .is_some_and(|c| Some(*c) != app.topology_context()) { return None; } Some(Self { token, request, document: app.canvas_document, revision: app.document.revision(), epoch: app.canvas_epoch, page: app.page, layer: app.layer, diagram: current_diagram(app), }) } pub fn valid(&self, app: &App) -> bool { self.document == app.canvas_document && self.revision == app.document.revision() && self.epoch == app.canvas_epoch && self.page == app.page && self.layer == app.layer && self.diagram == current_diagram(app) && app.project().contains(self.request.object) && target_matches(app, &self.request) && self .request .canvas .is_none_or(|c| Some(c) == current_canvas(app)) && self .request .process .as_deref() .is_none_or(|c| Some(*c) == app.topology_context()) && self .request .source_file .as_ref() .is_none_or(|path| app.source_workspace.files.contains_key(path)) } } fn target_matches(app: &App, request: &Request) -> bool { use crate::diagram_usage::Target; let Some(target) = request.selection_target.as_deref() else { return true; }; if (request.canvas.is_none() && request.process.is_none()) || !app.multiselect_target_visible(target) { return false; } match target { Target::ProcessOccurrence(endpoint) => { request.process.is_some() && request.canvas.is_none() && request.placement.is_none() && endpoint.occurrence == request.object } Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => { request.process.is_some() && request.canvas.is_none() && request.placement.is_none() && *id == request.object } Target::Placement(id) => { request.placement == Some(*id) && request .canvas .and_then(|context| app.project().diagrams.get(&context.diagram)) .and_then(|diagram| diagram.nodes.get(id)) .is_some_and(|node| node.element == request.object || node.id == request.object) } Target::Port { port, .. } => request.placement.is_none() && *port == request.object, Target::Edge(id) => request.placement.is_none() && *id == request.object, Target::StateRow { relation, .. } => { request.placement.is_none() && *relation == request.object } _ => false, } } fn current_canvas(app: &App) -> Option { if app.page == Page::Behavior { app.state_context() } else { app.route_context() } } fn current_diagram(app: &App) -> Option { if app.page == Page::Behavior { app.topology_context() .map(|context| context.owner) .or_else(|| app.state_context().map(|context| context.diagram)) } else { app.active_diagram } } pub fn open(object: Id, anchor: Point) -> Message { Message::Menu(Event::Open(Request::object(object, anchor))) } pub fn trigger(app: &App) -> iced::Element<'_, Message> { crate::context_menu::Area::left( crate::views::action("Actions ▾", Message::Menu(Event::Close)), move |anchor| { let mut request = Request::object(app.selected, anchor); if app.page == Page::Diagram { request.placement = app.selected_node; request.bend = app .selected_bend .filter(|(id, _)| *id == app.selected) .map(|(_, i)| i); request.canvas = app.route_context(); } else if app.page == Page::Behavior { request.canvas = app.state_context(); request.process = app.topology_context().map(Box::new); if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) { request.selection_target = Some(Box::new(target.clone())); request.placement = match target { crate::diagram_usage::Target::Placement(id) => { if let Some(node) = request .canvas .and_then(|c| app.project().diagrams.get(&c.diagram)) .and_then(|d| d.nodes.get(id)) { request.object = node.element; } Some(*id) } _ => None, }; } else if let Some(context) = request.canvas && let Some(node) = app.project().diagrams[&context.diagram] .nodes .values() .find(|node| node.element == app.selected || node.id == app.selected) { request.object = node.element; request.placement = Some(node.id); } } Message::Menu(Event::Open(request)) }, ) .into() } pub fn popup(app: &App) -> Option> { let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?; let request = &opened.request; let p = app.project(); let id = request.object; let clean = !app.busy && (request.source_file.is_some() || (!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending())); let item = |caption: &str, message: Message, enabled: bool| { if clean && enabled { Entry::action( caption, Message::Menu(Event::Invoke(opened.token, Box::new(message))), ) } else { Entry::disabled(caption) } }; if let Some(path) = &request.source_file { let buffer = app.source_workspace.files.get(path)?; let entries = vec![ Entry::title(path), item("Open file", Message::SourceFile(path.clone()), true), item("Reveal active file", Message::SourceTreeLocate, true), Entry::separator(), item( "Close detached buffer", Message::SourceCloseFile(path.clone()), !app.source_workspace.reachable.contains(path) && !buffer.dirty(), ), Entry::title(if app.source_workspace.reachable.contains(path) { "Included files remain part of the project" } else if buffer.dirty() { "Apply or revert this file before closing" } else { "Closing leaves the file on disk" }), ]; return Some(Menu { token: opened.token, anchor: request.anchor, entries, on_close: Message::Menu(Event::Close), }); } let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))]; if (request.canvas.is_some() || request.process.is_some()) && let Some(context) = app.multiselect_context() { use crate::app::multiselect::{Choice, Event as SelectionEvent}; use crate::diagram_usage::Target; let view = request .canvas .map(|context| context.diagram) .map(|d| projection::project_view_with_index(p, d, app.document.view_index())); let target = request .selection_target .as_deref() .cloned() .or_else(|| request.placement.map(Target::Placement)) .or_else(|| { view.as_ref()? .ports .iter() .find(|port| port.element == id) .map(|port| Target::Port { port: port.element, placement: port.owner, }) }) .or_else(|| { view.as_ref()? .edges .iter() .any(|edge| edge.id == id) .then_some(Target::Edge(id)) }); if let Some(target) = target { let selected = app .diagram_selection .as_ref() .is_some_and(|s| s.contains(&target)); entries.push(item( if selected { "Remove from selection" } else { "Add to selection" }, Message::DiagramSelection(SelectionEvent::Choose( context, target.clone(), if selected { Choice::Remove } else { Choice::Add }, )), true, )); if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1) && selected { entries.push(Entry::title(format!( "{} selected", snapshot.targets().len() ))); entries.push(item( "Inspect this element", Message::DiagramSelection(SelectionEvent::Choose( context, target.clone(), Choice::Inspect, )), true, )); entries.push(item( "Select only this element", Message::DiagramSelection(SelectionEvent::Choose( context, target, Choice::Replace, )), true, )); if request.process.is_some() { entries.push(Entry::title( "Drag contributions within one column to reorder", )); entries.push(Entry::disabled( "Hide unavailable · cards are model contributions", )); } else { entries.push(item( "Hide selected placements", Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())), crate::app::multiselect::placements(&snapshot).is_some(), )); } entries.push(Entry::disabled( "Delete selected from model (not yet available)", )); return Some(Menu { token: opened.token, anchor: request.anchor, entries, on_close: Message::Menu(Event::Close), }); } entries.push(Entry::separator()); if let Some(context) = request.process.as_deref().copied() { let endpoint = match request.selection_target.as_deref() { Some(Target::ProcessOccurrence(endpoint)) if !endpoint.references.is_empty() => { Some(endpoint.clone()) } Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()), _ => None, }; if let Some(endpoint) = endpoint { entries.push(Entry::title("Reused contents · read-only in this process")); entries.push(item( "Open referenced definition", Message::TopologyCanvas( crate::behavior::topology::CanvasEvent::OpenQualified( context, endpoint, ), ), true, )); return Some(Menu { token: opened.token, anchor: request.anchor, entries, on_close: Message::Menu(Event::Close), }); } } } } if app.page == Page::Behavior && let Some(mut menu) = crate::behavior::state_menu::popup(app, opened, clean) { if entries.len() > 1 { entries.extend(menu.entries.into_iter().skip(1)); menu.entries = entries; } return Some(menu); } if !clean { entries.push(Entry::title("Apply or revert pending edits first")); } if request.canvas.is_some() && app.active_diagram == Some(id) { entries.extend([ item("View properties", Message::EditProperties(id), true), item("Duplicate view", Message::DuplicateDiagram(id), true), item("Fit diagram", Message::Fit, true), item( "Arrange elements", Message::Command(Command::Layout(id)), true, ), item("Export SVG…", Message::Export, true), Entry::separator(), ]); for kind in crate::app::architecture_authoring::kinds(app) { entries.push(item( &format!("Create {} here…", kind.label()), Message::Architecture(crate::app::architecture_authoring::Event::Start( kind, request.creation_point, )), true, )); } entries.push(item( "Add existing here…", Message::Architecture(crate::app::architecture_authoring::Event::Existing( request.creation_point, )), true, )); } else { if request.route_point.is_some() || request.bend.is_some() || (request.canvas.is_some() && p.relations.contains_key(&id)) { let diagram = app.active_diagram.and_then(|d| p.diagrams.get(&d)); let view = diagram.map(|d| projection::project_view(p, d.id)); let projected = view .as_ref() .and_then(|v| v.edges.iter().find(|e| e.id == id)); let editable = diagram.zip(projected).is_some_and(|(d, e)| { projection::edge_endpoint_placements(p, d, id) .is_none_or(|(s, t)| s == e.source && t == e.target) }); let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id)); if let Some((diagram, view)) = diagram.zip(view.as_ref()) { for (end, choose, reset) in [ ( EdgeEnd::Source, "Move source attachment", "Reset source attachment", ), ( EdgeEnd::Target, "Move target attachment", "Reset target attachment", ), ] { let eligible = editable && route_edit::attachment_node(p, diagram, view, id, end).is_some(); entries.push(item( choose, Message::Route(route_edit::Event::ChooseAttachment(end)), eligible, )); entries.push(item( reset, Message::Route(route_edit::Event::ResetAttachment(end)), eligible && route.is_some_and(|route| route.attachment(end).is_some()), )); } } if let Some(point) = request.route_point { entries.push( item( "Add bend here", Message::Route(route_edit::Event::AddBendAt(point)), editable && route.is_none_or(|r| r.waypoints.len() < 128), ) .shortcut("Insert"), ); } else { entries.push( item( "Add bend", Message::Route(route_edit::Event::AddBend), editable && route.is_none_or(|r| r.waypoints.len() < 128), ) .shortcut("Insert"), ); } if request.bend.is_some() { entries.push( item( "Remove bend", Message::Route(route_edit::Event::RemoveBend), editable, ) .shortcut("Delete"), ); } entries.push(item( "Reset routing", Message::Route(route_edit::Event::Reset), editable && route.is_some(), )); if !editable { entries.push(Entry::title("Expand endpoint containers to edit routing")); } entries.push(Entry::separator()); } entries.push(item("Properties", Message::EditProperties(id), true)); entries.push(item("Open source", Message::SourceForSelection, true)); entries.push(item( "Show in diagrams…", app.diagram_usage_message(id), true, )); entries.push(item( "Connections & references", Message::InspectorTab(crate::navigation::InspectorTab::Connections), true, )); if let Some(e) = p.elements.get(&id) { if e.kind() == Kind::Port && app.page == Page::Diagram && let Some(context) = app.route_context() && let Some(port) = projection::project_view(p, context.diagram) .ports .iter() .find(|port| port.element == id) { let manual = p.diagrams[&context.diagram].nodes[&port.owner] .port_anchors .iter() .any(|anchor| anchor.port == id); entries.push(item( "Reset automatic position", Message::PortAnchor(crate::port_edit::Event::Reset(context, port.owner, id)), manual, )); } if app.page == Page::Diagram { for tool in crate::connection_tool::Tool::for_layer(e.layer()) { if tool.can_start(p, id) { entries.push(item( &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) }), )); } } } if let Some(owner) = e.parent { entries.push(item("Open owner", Message::Select(owner), true)); } entries.push(Entry::separator()); for action in port_workflow::actions(p, id) { if matches!( action, port_workflow::Action::Flow | port_workflow::Action::Standard ) { continue; } let caption = match action { port_workflow::Action::Component => "Add port…", port_workflow::Action::Input => "Add input port…", port_workflow::Action::Output => "Add output port…", _ => action.label(), }; entries.push(item(caption, Message::PortAction(action), true)); } if let Some(PortContract::Component { kind, orientation }) = port_contract(e) { entries.push(item( "Configure boundary exchange…", Message::AddRelationKind( RelationKind::BoundaryExchange, *kind == ComponentPortKind::Flow && *orientation == ComponentPortOrientation::In, ), true, )); } let work = if e.layer() == Layer::Oa { Kind::Activity } else { Kind::Function }; if e.kind().is_performer() && request.canvas.is_some() { if crate::app::architecture_authoring::kinds(app).contains(&work) { entries.push(item( if e.layer() == Layer::Oa { "Add activity…" } else { "Add function…" }, Message::Architecture(crate::app::architecture_authoring::Event::Allocate( work, id, request.creation_point, )), true, )); } } else if e.kind().is_performer() || e.kind().is_work() { entries.push(item( if e.kind().is_performer() { "Create allocated work" } else { "Create child work" }, Message::CreateContextElement(work, true), true, )); } else if validation::can_parent(p, app.create_kind, e.layer(), Some(id)) && app.create_kind != Kind::Port { entries.push(item( &format!("Create child {}", app.create_kind.label()), Message::CreateContextElement(app.create_kind, true), true, )); } for (caption, kind, incoming) in relation_actions(e.kind()) { entries.push(item( caption, Message::AddRelationKind(*kind, *incoming), true, )); } if let Some(node) = request.placement.and_then(|node| { app.active_diagram .and_then(|d| p.diagrams.get(&d))? .nodes .get(&node) }) { entries.push(Entry::separator()); entries.push(item( if node.collapsed { "Unfold contents" } else { "Fold contents" }, Message::ToggleCollapse, true, )); entries.push(item("Placement properties", Message::Select(node.id), true)); entries.push( item("Hide from this diagram", Message::HideSelected, true).shortcut("Delete"), ); } else if e.kind() != Kind::Port && let Some(d) = app.active_diagram.and_then(|d| p.diagrams.get(&d)) { entries.push(item( "Show in this diagram", Message::PlaceSelected, (d.layer == e.layer() || d.projection == ProjectionKind::Traceability) && d.projection.includes(e.kind()), )); } } if let Some(r) = p.relations.get(&id) { for action in port_workflow::actions(p, id) { entries.push(item(action.label(), Message::PortAction(action), true)); } entries.push(item( "Open source endpoint", Message::Select(r.source()), true, )); entries.push(item( "Open target endpoint", Message::Select(r.target()), true, )); } entries.push(Entry::separator()); if p.layer_of(id).is_some() { entries.push(item("Create relationship…", Message::AddRelation, true)); entries.push(item( if p.layer_of(id) == Some(Layer::Oa) { "Create System counterpart…" } else { "Create Operational counterpart…" }, Message::StartTransition, true, )); entries.push(item( "Review counterpart changes…", Message::StartSemanticReview(id), p.traces.values().any(|t| t.oa == id || t.sa == id), )); } entries.push(item( "Attach artifact reference…", Message::AddArtifact, true, )); if id != p.id { entries.push( item("Delete from model…", Message::Delete, true).shortcut( if request.canvas.is_some_and(|context| context.diagram != id) && (request.placement.is_some() || p.relations.contains_key(&id) || p.elements .get(&id) .is_some_and(|element| element.kind() == Kind::Port)) { if request.placement.is_some() { "Shift+Delete" } else { "Delete / Shift+Delete" } } else if app.selected == id && (app.topology_context().is_some_and(|context| { app.behavior.topology.selecting() && app.behavior.topology.removal_target(p, context.owner, id) }) || app.scenario_context().is_some_and(|context| { app.behavior.scenario.canvas_tool(context.scenario) == crate::behavior::scenario::CanvasTool::Select && crate::behavior::scenario::removal_target( p, context.scenario, id, ) })) { "Delete / Shift+Delete" } else { "" }, ), ); } } Some(Menu { token: opened.token, anchor: request.anchor, entries, on_close: Message::Menu(Event::Close), }) } fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)] { match kind { Kind::Entity | Kind::Actor => &[( "Configure communication means…", RelationKind::CommunicationMean, false, )], Kind::OperationalMission | Kind::Mission => &[ ( "Exploit capability…", RelationKind::MissionCapability, false, ), ( "Involve participant…", RelationKind::MissionInvolvement, true, ), ], Kind::Capability | Kind::SystemCapability => &[ ( "Include capability…", RelationKind::CapabilityInclude, false, ), ("Extend capability…", RelationKind::CapabilityExtend, false), ( "Specialize capability…", RelationKind::CapabilityGeneralization, false, ), ( "Involve participant or work…", RelationKind::CapabilityInvolvement, true, ), ], _ => &[], } } #[cfg(test)] mod tests { use super::*; include!("model_menu_traceability_tests.rs"); fn send(app: &mut App, message: Message) { let _ = app.update(message); } fn choose(app: &App, caption: &str) -> Message { popup(app) .unwrap() .entries .into_iter() .find_map(|entry| match entry { Entry::Action { label, message, .. } if label == caption => message, _ => None, }) .unwrap_or_else(|| panic!("Expected enabled menu action {caption}")) } #[test] fn state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views() { use crate::behavior::state_edit; let mut project = Project::empty(); let machine = project .add_element(Kind::StateMachine, Layer::Oa, "Modes", None) .unwrap(); let region = project .add_element(Kind::Region, Layer::Oa, "Region", Some(machine)) .unwrap(); let state = project .add_element(Kind::State, Layer::Oa, "Ready", Some(region)) .unwrap(); let architecture = project.ensure_diagram(Layer::Oa); project.place(architecture, state, Position { x: 80., y: 90. }); let first = state_edit::create_view(&project, machine).unwrap(); let first_id = first.diagram; project = execute_batch(&project, &[first.command]).unwrap(); let second = state_edit::create_view(&project, machine).unwrap(); let second_id = second.diagram; project = execute_batch(&project, &[second.command]).unwrap(); let placement = project.diagrams[&first_id] .nodes .values() .find(|node| node.element == state) .unwrap() .id; let mut app = App::with_project(project.clone()); send(&mut app, Message::Select(machine)); send(&mut app, Message::Page(Page::Behavior)); app.behavior.diagram.diagram = Some(first_id); app.active_diagram = Some(architecture); let open_state = |app: &mut App| { let mut request = Request::object(state, Point::ORIGIN); request.canvas = app.state_context(); request.placement = Some(placement); send(app, Message::Menu(Event::Open(request))); }; // A canvas menu from Architecture is stale on the state page even when // the architecture diagram itself remains the active architecture view. let mut wrong = Request::object(state, Point::ORIGIN); wrong.canvas = app.route_context(); assert!(Opened::new(&app, wrong, 1).is_none()); open_state(&mut app); let fold = choose(&app, "Fold contents"); send(&mut app, fold); assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed); assert_eq!(app.selected, state, "Folding retains semantic selection"); assert_eq!(app.page, Page::Behavior); assert_eq!(app.state_context().unwrap().diagram, first_id); assert_eq!(app.active_diagram, Some(architecture)); assert_eq!( app.project().diagrams[&architecture], project.diagrams[&architecture] ); assert_eq!( app.project().diagrams[&second_id], project.diagrams[&second_id] ); assert_eq!(app.project().elements, project.elements); open_state(&mut app); let unfold = choose(&app, "Unfold contents"); send(&mut app, unfold); assert_eq!(app.selected, state, "Unfolding retains semantic selection"); assert_eq!(app.project(), &project); assert_eq!(app.active_diagram, Some(architecture)); send(&mut app, Message::Undo); assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed); send(&mut app, Message::Undo); assert_eq!(app.project(), &project); open_state(&mut app); let stale = choose(&app, "Fold contents"); app.behavior.diagram.diagram = Some(second_id); send(&mut app, stale); assert_eq!(app.project(), &project); app.behavior.diagram.diagram = Some(first_id); open_state(&mut app); let hide = choose(&app, "Hide from this diagram"); send(&mut app, hide); assert!( !app.project().diagrams[&first_id] .nodes .contains_key(&placement) ); assert_eq!( app.project().diagrams[&architecture], project.diagrams[&architecture] ); assert_eq!( app.project().diagrams[&second_id], project.diagrams[&second_id] ); assert_eq!(app.project().elements, project.elements); send(&mut app, Message::Undo); assert_eq!(app.project(), &project); // Placement properties deliberately select a placement record, but an // accepted numeric edit must keep the Behavior view's own context. let source_before = app.document.bundle().clone(); open_state(&mut app); let properties = choose(&app, "Placement properties"); send(&mut app, properties); assert_eq!(app.selected, placement); assert_eq!( app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Node(first_id) ); assert_eq!(app.page, Page::Behavior); assert_eq!(app.active_diagram, Some(architecture)); let x = project.diagrams[&first_id].nodes[&placement].position.x + 37.25; send( &mut app, Message::Field("/position/x".into(), x.to_string()), ); assert!(app.draft_dirty()); send(&mut app, Message::Apply); assert!(!app.draft_dirty()); assert_eq!(app.page, Page::Behavior); assert_eq!(app.behavior.active_owner, Some(machine)); assert_eq!(app.state_context().unwrap().diagram, first_id); assert_eq!( app.active_diagram, Some(architecture), "Placement Apply must not replace the Architecture view" ); let mut expected = project.clone(); expected .diagrams .get_mut(&first_id) .unwrap() .nodes .get_mut(&placement) .unwrap() .position .x = x; assert_eq!(app.project(), &expected); send(&mut app, Message::Undo); assert_eq!(app.project(), &project); assert_eq!(app.document.bundle(), &source_before); assert_eq!(app.page, Page::Behavior); assert_eq!(app.state_context().unwrap().diagram, first_id); assert_eq!(app.active_diagram, Some(architecture)); } #[test] fn state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties() { let mut project = Project::empty(); let machine = project .add_element(Kind::StateMachine, Layer::Oa, "Modes", None) .unwrap(); let region = project .add_element(Kind::Region, Layer::Oa, "Region", Some(machine)) .unwrap(); let entry = project .add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(region)) .unwrap(); let mut app = App::with_project(project); send(&mut app, Message::Select(entry)); send(&mut app, Message::Page(Page::Behavior)); send(&mut app, open(entry, Point::ORIGIN)); let start = choose(&app, "New transition with details…"); send(&mut app, start); assert_eq!(app.behavior.pending_owner(), Some(machine)); send( &mut app, Message::Behavior(crate::behavior::Event::States( crate::behavior::states::Event::Cancel, )), ); send( &mut app, Message::Field("/name".into(), "Unapplied entry".into()), ); let before = app.draft.as_ref().unwrap().materialize().unwrap(); send(&mut app, open(entry, Point::ORIGIN)); assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!( entry, Entry::Action { message: Some(_), .. } ))); assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), before); } #[test] fn right_click_targets_actor_without_changing_selection_until_action() { let mut app = App::with_project(Project::demo()); let actor = app .project() .elements .values() .find(|e| e.kind() == Kind::SystemActor) .unwrap() .id; let selected = app.selected; let model = app.project().clone(); send(&mut app, open(actor, Point::new(240., 180.))); assert_eq!(app.selected, selected); assert_eq!(app.project(), &model); let action = choose(&app, "Add port…"); send(&mut app, action); assert_eq!(app.selected, actor); assert!(matches!( &app.workflow, Some(crate::workflow::Workflow::Ports(_)) )); assert!(app.model_menu.is_none()); assert_eq!( app.project(), &model, "Opening the existing creation workflow does not create a port" ); send(&mut app, Message::Workflow(crate::workflow::Event::Apply)); assert_eq!(app.project().elements.len(), model.elements.len() + 1); assert_eq!(app.project().elements[&app.selected].parent, Some(actor)); assert_eq!(app.project().elements[&app.selected].kind(), Kind::Port); } #[test] fn menu_preserves_pending_properties_and_rejects_delayed_mutations() { let mut app = App::with_project(Project::demo()); let target = app.project().elements.keys().next().copied().unwrap(); let selected = app.selected; let model = app.project().clone(); send( &mut app, Message::Field("/name".into(), "Unapplied project title".into()), ); assert!(app.draft_dirty()); let draft = app.draft.as_ref().unwrap().materialize().unwrap(); send(&mut app, open(target, Point::ORIGIN)); assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!( entry, Entry::Action { message: Some(_), .. } ))); let token = app.model_menu.as_ref().unwrap().token; send( &mut app, Message::Menu(Event::Invoke(token, Box::new(Message::Delete))), ); assert_eq!(app.selected, selected); assert_eq!(app.project(), &model); assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), draft); assert!(!app.delete_pending); } #[test] fn late_menu_actions_do_not_dismiss_new_menu_or_cross_view_context() { let mut app = App::with_project(Project::demo()); let id = app.selected; send(&mut app, open(id, Point::ORIGIN)); let old = choose(&app, "Open source"); send(&mut app, open(id, Point::new(200., 80.))); let current = app.model_menu.as_ref().unwrap().token; send(&mut app, old); assert_eq!(app.model_menu.as_ref().unwrap().token, current); let stale = choose(&app, "Open source"); app.canvas_epoch += 1; send(&mut app, stale); assert_eq!(app.page, Page::Diagram); assert!(app.model_menu.is_none()); } #[test] fn pending_source_allows_file_navigation_but_disables_model_actions() { let mut app = App::with_project(Project::demo()); 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()); let original = app.source_editor.text(); let file = app.source_workspace.active.clone(); let mut request = Request::object(app.project().id, Point::ORIGIN); request.source_file = Some(file.clone()); send(&mut app, Message::Menu(Event::Open(request))); let action = choose(&app, "Open file"); send(&mut app, action); assert_eq!(app.source_editor.text(), original); assert_eq!(app.source_workspace.active, file); let id = app.project().id; send(&mut app, open(id, Point::ORIGIN)); assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!( entry, Entry::Action { message: Some(_), .. } ))); } #[test] fn add_bend_here_uses_context_point_and_one_source_aware_history_change() { let mut p = Project::empty(); let a = p.add(Kind::Activity, "Send", 0., 0.); let b = p.add(Kind::Activity, "Receive", 650., 0.); let edge = p.relate(RelationKind::Interaction, "Report", a, b); let diagram = p.ensure_diagram(Layer::Oa); let mut app = App::with_project(p); let original = app.document.bundle().clone(); let point = Position { x: 420., y: -80. }; let mut request = Request::object(edge, Point::new(540., 360.)); request.canvas = app.route_context(); request.route_point = Some(point); send(&mut app, Message::Menu(Event::Open(request))); let action = choose(&app, "Add bend here"); send(&mut app, action); assert_eq!(app.selected, edge); assert_eq!(app.selected_bend, Some((edge, 0))); assert_eq!( app.project().diagrams[&diagram].routes[0].waypoints, vec![point] ); let changed = app.document.bundle().clone(); assert_ne!(original, changed); send(&mut app, Message::Undo); assert_eq!(app.document.bundle(), &original); send(&mut app, Message::Redo); assert_eq!(app.document.bundle(), &changed); } #[test] fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() { let mut project = Project::empty(); let source = project.add(Kind::Activity, "Send", 0., 0.); let target = project.add(Kind::Activity, "Receive", 650., 0.); let edge = project.relate(RelationKind::Interaction, "Report", source, target); let mut app = App::with_project(project); let before = app.document.bundle().clone(); let mut request = Request::object(edge, Point::ORIGIN); request.canvas = app.route_context(); send(&mut app, Message::Menu(Event::Open(request.clone()))); let action = choose(&app, "Move target attachment"); send(&mut app, action); assert_eq!( app.selected_attachment, Some((app.route_context().unwrap(), edge, EdgeEnd::Target)) ); assert_eq!(app.document.bundle(), &before); request.canvas = app.route_context(); send(&mut app, Message::Menu(Event::Open(request))); let stale = choose(&app, "Move source attachment"); send(&mut app, Message::Escape); send(&mut app, stale); assert!( app.selected_attachment .is_none_or(|(context, _, _)| Some(context) != app.route_context()) ); assert_eq!(app.document.bundle(), &before); } #[test] fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() { let mut p = Project::empty(); let parent = p.add(Kind::Activity, "Respond", 0., 0.); let child = p .add_element(Kind::Activity, Layer::Oa, "Assess", Some(parent)) .unwrap(); let diagram = p.ensure_diagram(Layer::Oa); let parent_node = p.diagrams[&diagram] .nodes .values() .find(|n| n.element == parent) .unwrap() .id; p.diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&parent_node) .unwrap() .collapsed = true; let mut app = App::with_project(p); let original = app.document.bundle().clone(); send(&mut app, Message::Explore(Some(child))); send(&mut app, open(child, Point::new(90., 300.))); let action = choose(&app, "Show in this diagram"); send(&mut app, action); assert_eq!(app.page, Page::Diagram); assert_eq!(app.selected, child); assert!( app.selected_node .is_some_and(|node| app.project().diagrams[&diagram].nodes[&node].element == child) ); assert!(!app.project().diagrams[&diagram].nodes[&parent_node].collapsed); assert!( projection::project_view(app.project(), diagram) .nodes .iter() .any(|n| n.element == child) ); send(&mut app, Message::Undo); assert_eq!( app.document.bundle(), &original, "One Undo restores both placement and folded ancestors" ); } }