//! Persisted State groups through the real App transaction/admission boundary. use super::multiselect::{Choice as SelectionChoice, Event as SelectionEvent}; use super::*; use crate::behavior::{ state_edit::{Event as StateEvent, Tool}, states, }; use crate::diagram_usage::Target; fn point(x: f32, y: f32) -> Position { Position { x, y } } struct Fixture { app: App, diagram: Id, other_view: Id, other_machine: Id, machine: Id, a: Id, b: Id, child: Id, a_element: Id, b_element: Id, internal: Id, action: Id, internal_row: Id, } fn route(edge: Id, pins: Vec) -> EdgeRoute { EdgeRoute { edge, source_attachment: Some(EdgeAttachment { side: PortAnchorSide::Bottom, offset: 0.3, }), target_attachment: Some(EdgeAttachment { side: PortAnchorSide::Top, offset: 0.7, }), waypoints: pins, label: Some(EdgeLabel { fraction: 0.4, offset: point(5., -9.), }), } } fn fixture() -> Fixture { let mut p = Project::empty(); let system = p .add_element(Kind::System, Layer::Sa, "System", None) .unwrap(); let machine = p .add_element( Kind::StateMachine, Layer::Sa, "Operating modes", Some(system), ) .unwrap(); let region = p .add_element(Kind::Region, Layer::Sa, "Main region", Some(machine)) .unwrap(); let a_element = p .add_element(Kind::State, Layer::Sa, "Operating", Some(region)) .unwrap(); let b_element = p .add_element(Kind::State, Layer::Sa, "Standby", Some(region)) .unwrap(); let c_element = p .add_element(Kind::State, Layer::Sa, "Stopped", Some(region)) .unwrap(); let inner = p .add_element(Kind::Region, Layer::Sa, "Nested region", Some(a_element)) .unwrap(); let child_element = p .add_element(Kind::State, Layer::Sa, "Working", Some(inner)) .unwrap(); let work = p .add_element(Kind::Function, Layer::Sa, "Reusable work", None) .unwrap(); let action = p.relate(RelationKind::StateAction, "Entry work", a_element, work); // The row references shared work, not ownership or a placed work target. let other_action = p.relate( RelationKind::StateAction, "Other exit work", b_element, work, ); if let RelationBody::StateAction { phase, .. } = &mut p.relations.get_mut(&other_action).unwrap().body { *phase = StateActionKind::Exit; } let internal_row = p.relate( RelationKind::StateTransition, "Internal check", a_element, a_element, ); if let RelationBody::StateTransition { transition_kind, .. } = &mut p.relations.get_mut(&internal_row).unwrap().body { *transition_kind = TransitionKind::Internal; } let internal = p.relate( RelationKind::StateTransition, "Leave operating", a_element, b_element, ); if let RelationBody::StateTransition { guard, trigger, effect, .. } = &mut p.relations.get_mut(&internal).unwrap().body { *guard = "temperature nominal\nand pressure stable".into(); *trigger = "operator request".into(); *effect = "record transition".into(); } let external = p.relate(RelationKind::StateTransition, "Stop", b_element, c_element); let diagram = p.ensure_diagram(Layer::Sa); p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::StateMachine { machine }; let a = p.place(diagram, a_element, point(10.25, 20.5)); let b = p.place(diagram, b_element, point(510.5, 40.25)); p.place(diagram, c_element, point(900., 400.)); let child = p.place(diagram, child_element, point(80.5, 240.25)); let view = p.diagrams.get_mut(&diagram).unwrap(); view.nodes.get_mut(&a).unwrap().size = oa_core::Size { width: 360., height: 420., }; view.routes = vec![ route(internal, vec![point(450.25, 150.5)]), route(external, vec![point(780.5, 300.25)]), ]; let other_view = p.ensure_diagram(Layer::Sa); p.diagrams.get_mut(&other_view).unwrap().projection = ProjectionKind::StateMachine { machine }; p.place(other_view, a_element, point(-100., -50.)); p.place(other_view, b_element, point(400., -50.)); let other_machine = p .add_element(Kind::StateMachine, Layer::Sa, "Other machine", Some(system)) .unwrap(); let other_region = p .add_element(Kind::Region, Layer::Sa, "Other region", Some(other_machine)) .unwrap(); let other_state = p .add_element(Kind::State, Layer::Sa, "Other state", Some(other_region)) .unwrap(); let other_diagram = p.ensure_diagram(Layer::Sa); p.diagrams.get_mut(&other_diagram).unwrap().projection = ProjectionKind::StateMachine { machine: other_machine, }; p.place(other_diagram, other_state, point(25., 50.)); let opposite = p .add_element(Kind::Activity, Layer::Oa, "Operational context", None) .unwrap(); let architecture = p.ensure_diagram(Layer::Oa); p.place(architecture, opposite, point(50., 75.)); validate_candidate(&p).unwrap(); let mut app = App::with_project(p); app.page = Page::Behavior; app.layer = Layer::Sa; // Deliberately retain a different Architecture diagram. State group edits // must resolve the live State context, never this legacy field. app.active_diagram = Some(architecture); app.behavior.active_owner = Some(machine); app.behavior.diagram = crate::behavior::state_view::State { diagram: Some(diagram), ..Default::default() }; app.selected = diagram; app.selected_node = None; app.refresh_draft(); app.reconcile_multiselect(); assert_eq!(app.state_context().unwrap().diagram, diagram); Fixture { app, diagram, other_view, other_machine, machine, a, b, child, a_element, b_element, internal, action, internal_row, } } fn send(app: &mut App, event: SelectionEvent) { let _ = app.update(Message::DiagramSelection(event)); } fn choose(app: &mut App, target: Target, choice: SelectionChoice) { let context = app.multiselect_context().unwrap(); send(app, SelectionEvent::Choose(context, target, choice)); } fn pair(f: &mut Fixture) { choose(&mut f.app, Target::Placement(f.a), SelectionChoice::Replace); choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Add); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), &[Target::Placement(f.a), Target::Placement(f.b)] ); assert_eq!(f.app.behavior.diagram.placement, Some(f.b)); assert_eq!(f.app.selected_node, None); } fn source_matches(app: &App) { let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap(); assert_eq!(&compiled.decoded.project, app.project()); assert_eq!(&compiled.bundle, app.document.bundle()); assert_eq!( source_workspace::reachable(&app.source_workspace.bundle()).unwrap(), *app.document.bundle() ); } #[test] fn state_fractional_group_move_preserves_full_model_routes_primary_and_one_step_history() { let mut f = fixture(); pair(&mut f); let before = f.app.document.snapshot(); let selection = f.app.multiselect_snapshot().unwrap(); let mut expected = f.app.project().clone(); let view = expected.diagrams.get_mut(&f.diagram).unwrap(); view.nodes.get_mut(&f.a).unwrap().position = point(17.5, 17.); view.nodes.get_mut(&f.b).unwrap().position = point(517.75, 36.75); view.nodes.get_mut(&f.child).unwrap().position = point(87.75, 236.75); view.routes[0].waypoints = vec![point(457.5, 147.)]; send( &mut f.app, SelectionEvent::Move(selection.clone(), point(7.25, -3.5)), ); assert_eq!(f.app.project(), &expected); assert_eq!(f.app.selected, f.b_element); assert_eq!(f.app.behavior.diagram.placement, Some(f.b)); assert_eq!(f.app.selected_node, None); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), selection.targets() ); source_matches(&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()); assert_eq!(f.app.behavior.diagram.placement, Some(f.b)); let _ = f.app.update(Message::Redo); assert_eq!(f.app.project(), &expected); assert_eq!(f.app.document.bundle(), &after.bundle); assert!(!f.app.document.can_redo()); source_matches(&f.app); } #[test] fn state_selected_ancestor_and_child_move_once_then_hide_only_selected_placements() { let mut f = fixture(); choose(&mut f.app, Target::Placement(f.a), SelectionChoice::Replace); choose(&mut f.app, Target::Placement(f.child), SelectionChoice::Add); let before = f.app.document.snapshot(); let mut expected = f.app.project().clone(); let view = expected.diagrams.get_mut(&f.diagram).unwrap(); view.nodes.get_mut(&f.a).unwrap().position = point(12.75, 24.25); view.nodes.get_mut(&f.child).unwrap().position = point(83., 244.); let selection = f.app.multiselect_snapshot().unwrap(); send( &mut f.app, SelectionEvent::Move(selection, point(2.5, 3.75)), ); assert_eq!(f.app.project(), &expected); let _ = f.app.update(Message::Undo); assert_eq!(f.app.document.bundle(), &before.bundle); assert!(!f.app.document.can_undo()); pair(&mut f); let selection = f.app.multiselect_snapshot().unwrap(); let mut expected = f.app.project().clone(); let view = expected.diagrams.get_mut(&f.diagram).unwrap(); view.nodes.remove(&f.a); view.nodes.remove(&f.b); send(&mut f.app, SelectionEvent::Hide(selection)); assert_eq!( f.app.project(), &expected, "Hide preserves the unselected child, routes, semantics and other views" ); assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty()); assert_eq!(f.app.behavior.diagram.placement, None); assert_eq!(f.app.selected_node, None); source_matches(&f.app); let hidden = 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()); let _ = f.app.update(Message::Redo); assert_eq!(f.app.project(), &expected); assert_eq!(f.app.document.bundle(), &hidden.bundle); assert!(!f.app.document.can_redo()); } #[test] fn state_rows_are_exact_relation_primaries_and_mixed_move_or_hide_refuses_whole_set() { for kind in ["action", "internal row", "edge"] { let mut f = fixture(); choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Replace); let target = match kind { "action" => Target::StateRow { placement: f.a, relation: f.action, }, "internal row" => Target::StateRow { placement: f.a, relation: f.internal_row, }, _ => Target::Edge(f.internal), }; assert!(f.app.multiselect_target_visible(&target)); choose(&mut f.app, target.clone(), SelectionChoice::Add); let selection = f.app.multiselect_snapshot().unwrap(); assert_eq!( selection.targets(), &[Target::Placement(f.b), target.clone()] ); assert_eq!(selection.primary(), Some(&target)); assert_eq!(f.app.behavior.diagram.placement, None); assert_eq!(f.app.selected_node, None); let before = f.app.document.snapshot(); for event in [ SelectionEvent::Move(selection.clone(), point(7.25, 8.5)), SelectionEvent::Hide(selection.clone()), ] { send(&mut f.app, event); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); assert!(!f.app.document.can_undo()); } // Even a row selected alone must not use its owner as a placement Hide. choose(&mut f.app, target, SelectionChoice::Replace); let row = f.app.multiselect_snapshot().unwrap(); send(&mut f.app, SelectionEvent::Hide(row)); assert_eq!(f.app.project(), &before.decoded.project); assert!(!f.app.document.can_undo()); } } #[test] fn state_primary_row_reassignment_or_replacement_preserves_exact_survivor_without_retarget() { for row_kind in ["action", "internal"] { for change in ["source", "placement", "collapse"] { let mut f = fixture(); let relation = if row_kind == "action" { f.action } else { f.internal_row }; let old_row = Target::StateRow { placement: f.a, relation, }; choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Replace); choose(&mut f.app, old_row.clone(), SelectionChoice::Add); let stale = f.app.multiselect_snapshot().unwrap(); let before = f.app.document.snapshot(); let mut candidate = f.app.project().clone(); match change { "source" => match &mut candidate.relations.get_mut(&relation).unwrap().body { RelationBody::StateAction { source, .. } => *source = f.b_element, RelationBody::StateTransition { source, target, .. } => { *source = f.b_element; *target = f.b_element; } _ => unreachable!(), }, "placement" => { let view = candidate.diagrams.get_mut(&f.diagram).unwrap(); let mut replacement = view.nodes.remove(&f.a).unwrap(); replacement.id = Id::new_v4(); replacement.alias = "replacement_operating".into(); view.nodes.insert(replacement.id, replacement); } _ => { candidate .diagrams .get_mut(&f.diagram) .unwrap() .nodes .get_mut(&f.a) .unwrap() .collapsed = true } } validate_candidate(&candidate).unwrap(); let _ = f .app .update(Message::Command(Command::Replace(candidate.clone()))); assert_eq!(f.app.project(), &candidate); assert!(!f.app.multiselect_target_visible(&old_row)); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), &[Target::Placement(f.b)] ); assert_eq!(f.app.selected, f.b_element); assert_eq!(f.app.behavior.diagram.placement, Some(f.b)); let live = f.app.multiselect_snapshot().unwrap(); send(&mut f.app, SelectionEvent::Hide(stale)); assert_eq!(f.app.project(), &candidate); assert_eq!(f.app.multiselect_snapshot().unwrap(), live); let _ = f.app.update(Message::Undo); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), &[Target::Placement(f.b)] ); assert!(!f.app.document.can_undo()); } } } #[test] fn state_primary_placement_removal_or_same_semantic_replacement_preserves_other_member() { for replace in [false, true] { let mut f = fixture(); pair(&mut f); let mut candidate = f.app.project().clone(); let view = candidate.diagrams.get_mut(&f.diagram).unwrap(); let mut old = view.nodes.remove(&f.b).unwrap(); if replace { old.id = Id::new_v4(); old.alias = "replacement_standby".into(); view.nodes.insert(old.id, old); } let _ = f .app .update(Message::Command(Command::Replace(candidate.clone()))); assert_eq!(f.app.project(), &candidate); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), &[Target::Placement(f.a)] ); assert_eq!(f.app.selected, f.a_element); assert_eq!(f.app.behavior.diagram.placement, Some(f.a)); assert_eq!(f.app.selected_node, None); let _ = f.app.update(Message::Undo); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), &[Target::Placement(f.a)] ); } } #[test] fn state_stale_group_events_reject_before_status_selection_camera_allocation_or_history_changes() { for nested in [false, true] { for stale_by in [ "document", "revision", "view", "owner", "tool", "generation", ] { let mut f = fixture(); pair(&mut f); let stale = f.app.multiselect_snapshot().unwrap(); match stale_by { "document" => f.app.canvas_document = Id::new_v4(), "revision" => { f.app .document .execute(Command::Describe(f.a_element, "New revision".into())) .unwrap(); } "view" => { let _ = f.app.update(Message::BehaviorDiagram(super::Choice { id: f.other_view, label: "Other saved view".into(), })); } "owner" => { let _ = f.app.update(Message::BehaviorOwner(super::Choice { id: f.other_machine, label: "Other machine".into(), })); } "tool" => { let _ = f .app .update(Message::StateDiagram(StateEvent::Choose(Tool::Transition))); } _ => { choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Toggle); choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Toggle); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), stale.targets() ); } } f.app.status = "Current feedback".into(); f.app.allocation_drag.arrange_next = true; let before = f.app.document.snapshot(); let selection = f.app.multiselect_snapshot(); let selected = ( f.app.selected, f.app.selected_node, f.app.behavior.diagram.placement, ); let epochs = ( f.app.canvas_epoch, f.app.camera_epoch, f.app.allocation_drag.epoch, ); let camera = ( f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan, f.app.behavior.diagram.fit, ); let history = (f.app.document.can_undo(), f.app.document.can_redo()); let draft = f .app .draft .as_ref() .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()); for event in [ SelectionEvent::Move(stale.clone(), point(100., 200.)), SelectionEvent::Hide(stale.clone()), SelectionEvent::Collapse(stale.clone(), Target::Placement(f.a)), SelectionEvent::Refuse(stale.clone(), "Stale refusal".into()), ] { let message = if nested { Message::StateDiagram(StateEvent::Selection(event)) } else { Message::DiagramSelection(event) }; let _ = f.app.update(message); assert_eq!( f.app.project(), &before.decoded.project, "{stale_by}, nested={nested}" ); assert_eq!(f.app.document.bundle(), &before.bundle); assert_eq!(f.app.multiselect_snapshot(), selection); assert_eq!( ( f.app.selected, f.app.selected_node, f.app.behavior.diagram.placement ), selected ); assert_eq!( ( f.app.canvas_epoch, f.app.camera_epoch, f.app.allocation_drag.epoch ), epochs ); assert_eq!( ( f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan, f.app.behavior.diagram.fit ), camera ); assert_eq!( (f.app.document.can_undo(), f.app.document.can_redo()), history ); assert_eq!( f.app .draft .as_ref() .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()), draft ); assert!(f.app.allocation_drag.arrange_next); assert_eq!(f.app.status, "Current feedback"); } } } } #[test] fn state_pending_property_source_and_state_form_inputs_are_preserved_without_implicit_apply() { for pending in [ "valid properties", "invalid properties", "source", "State form", ] { let mut f = fixture(); pair(&mut f); match pending { "valid properties" => { let _ = f .app .update(Message::Field("/name".into(), "Retained name".into())); } "invalid properties" => { let _ = f .app .update(Message::Field("/alias".into(), "invalid alias!".into())); } "source" => { let text = format!( "{}\n// Retained raw source\n", f.app.source_workspace.text() ); let _ = f .app .update(Message::SourceEdit(text_editor::Action::SelectAll)); let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(text)), ))); } _ => { let _ = f.app.update(Message::Behavior(behavior::Event::States( states::Event::Edit(f.internal), ))); let _ = f.app.update(Message::Behavior(behavior::Event::States( states::Event::Guard("Retained raw guard".into()), ))); } } assert!( f.app.draft_dirty() || f.app.source_dirty() || f.app.behavior.has_pending(), "{pending}" ); let snapshot = f.app.multiselect_snapshot().unwrap(); let before = f.app.document.snapshot(); let source = f.app.source_workspace.bundle(); let draft = f .app .draft .as_ref() .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()); let form = f.app.behavior.recovery_snapshot(); let selected = ( f.app.selected, f.app.selected_node, f.app.behavior.diagram.placement, ); for event in [ SelectionEvent::Move(snapshot.clone(), point(7.25, 9.5)), SelectionEvent::Hide(snapshot.clone()), SelectionEvent::Clear(snapshot.context()), SelectionEvent::Choose( snapshot.context(), Target::Placement(f.a), SelectionChoice::Toggle, ), ] { send(&mut f.app, event); assert_eq!(f.app.project(), &before.decoded.project, "{pending}"); assert_eq!(f.app.document.bundle(), &before.bundle); assert_eq!(f.app.source_workspace.bundle(), source); assert_eq!( f.app .draft .as_ref() .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()), draft ); assert_eq!(f.app.behavior.recovery_snapshot(), form); assert_eq!( ( f.app.selected, f.app.selected_node, f.app.behavior.diagram.placement ), selected ); assert_eq!(f.app.multiselect_snapshot().unwrap(), snapshot); assert!(!f.app.document.can_undo()); } } } #[test] fn state_nonselect_tool_denies_current_group_selection_and_commands() { let mut f = fixture(); pair(&mut f); let _ = f .app .update(Message::StateDiagram(StateEvent::Choose(Tool::Transition))); assert_eq!(f.app.behavior.diagram.tool, Tool::Transition); let current = f.app.multiselect_snapshot().unwrap(); let before = f.app.document.snapshot(); for event in [ SelectionEvent::Move(current.clone(), point(7.25, 9.5)), SelectionEvent::Hide(current.clone()), SelectionEvent::Clear(current.context()), SelectionEvent::Choose( current.context(), Target::Placement(f.a), SelectionChoice::Toggle, ), ] { send(&mut f.app, event); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert_eq!(f.app.multiselect_snapshot().unwrap(), current); assert_eq!(f.app.behavior.diagram.tool, Tool::Transition); assert!(!f.app.document.can_undo()); } } #[test] fn state_queued_singleton_move_and_removal_cannot_edit_only_a_member_of_a_group() { use crate::diagram_usage::{self as usage, Surface}; let mut f = fixture(); pair(&mut f); // Use a real, currently valid navigation marker so late shared hooks cannot // explain its retirement as cleanup of an invalid sentinel. f.app.active_diagram = Some(f.diagram); let marker = usage::collect( f.app.project(), f.b_element, &f.app.behavior.topology, &f.app.behavior.scenario, ) .into_iter() .find(|u| u.surface == Surface::Saved(f.diagram) && u.target == Target::Placement(f.b)) .unwrap(); let reveal_context = usage::Context { document: f.app.canvas_document, revision: f.app.document.revision(), subject: f.b_element, }; f.app.pending_diagram_reveal = Some((reveal_context, marker.clone(), f.app.canvas_epoch)); f.app.diagram_navigation_focus = Some(usage::Focus { context: reveal_context, usage: marker, selected: f.app.selected, }); assert!(f.app.diagram_focus(Surface::Saved(f.diagram)).is_some()); f.app.status = "Current group feedback".into(); f.app.allocation_drag.arrange_next = true; let context = f.app.state_context().unwrap(); let selection = f.app.multiselect_snapshot().unwrap(); let before = f.app.document.snapshot(); let pending = f.app.pending_diagram_reveal.clone(); let focus = f .app .diagram_navigation_focus .as_ref() .map(|f| (f.context, f.usage.clone(), f.selected)); let epochs = ( f.app.canvas_epoch, f.app.camera_epoch, f.app.allocation_drag.epoch, ); let camera = ( f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan, f.app.behavior.diagram.fit, ); let selected = ( f.app.selected, f.app.selected_node, f.app.behavior.diagram.placement, ); // These events deliberately have the live document/view/epoch context. // Group admission must reject them before navigation and allocation hooks. for message in [ Message::StateDiagram(StateEvent::Select(context, f.a)), Message::StateDiagram(StateEvent::Move { context, node: f.b, position: point(700., 500.), }), Message::StateDiagram(StateEvent::Remove(diagram_removal::Intent { context, object: f.b_element, placement: Some(f.b), model: false, })), ] { let _ = f.app.update(message); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); assert_eq!(f.app.pending_diagram_reveal, pending); assert_eq!( f.app.diagram_navigation_focus.as_ref().map(|f| ( f.context, f.usage.clone(), f.selected )), focus ); assert_eq!( ( f.app.canvas_epoch, f.app.camera_epoch, f.app.allocation_drag.epoch ), epochs ); assert_eq!( ( f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan, f.app.behavior.diagram.fit ), camera ); assert_eq!( ( f.app.selected, f.app.selected_node, f.app.behavior.diagram.placement ), selected ); assert_eq!(f.app.status, "Current group feedback"); assert!(f.app.allocation_drag.arrange_next); assert!(!f.app.delete_pending); assert!(!f.app.document.can_undo()); assert!(!f.app.document.can_redo()); } // Retain the existing model-level checks for generic singleton removal. for message in [ Message::DiagramRemove(diagram_removal::Intent { context, object: f.b_element, placement: Some(f.b), model: true, }), Message::Delete, ] { let _ = f.app.update(message); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), selection.targets() ); assert!(!f.app.delete_pending); assert!(!f.app.document.can_undo()); } } #[test] fn state_boundary_entry_exit_group_moves_use_authored_positions_and_boundary_owner_pin_rules() { for kind in [Kind::EntryPoint, Kind::ExitPoint] { for boundary in ["fixed", "moved", "omitted"] { let mut f = fixture(); let mut p = f.app.project().clone(); let child_element = p.diagrams[&f.diagram].nodes[&f.child].element; let region = p.elements[&child_element].parent.unwrap(); let symbol = p .add_element(kind, Layer::Sa, "Boundary symbol", Some(region)) .unwrap(); let edge = if kind == Kind::EntryPoint { p.relate( RelationKind::StateTransition, "Enter", symbol, child_element, ) } else { p.relate(RelationKind::StateTransition, "Exit", child_element, symbol) }; let placement = p.place(f.diagram, symbol, point(4000.25, 300.5)); p.diagrams .get_mut(&f.diagram) .unwrap() .routes .push(route(edge, vec![point(140.25, 320.5)])); // Keep the authored symbol inside the existing boundary so the // projection does not independently grow that boundary to enclose it. p.diagrams .get_mut(&f.diagram) .unwrap() .nodes .get_mut(&f.a) .unwrap() .size .width = 5000.; if boundary == "omitted" { p.diagrams.get_mut(&f.diagram).unwrap().nodes.remove(&f.a); } validate_candidate(&p).unwrap(); let architecture = f.app.active_diagram; f.app = App::with_project(p); f.app.page = Page::Behavior; f.app.layer = Layer::Sa; f.app.active_diagram = architecture; f.app.behavior.active_owner = Some(f.machine); f.app.behavior.diagram = crate::behavior::state_view::State { diagram: Some(f.diagram), ..Default::default() }; f.app.selected = f.diagram; f.app.refresh_draft(); f.app.reconcile_multiselect(); choose( &mut f.app, Target::Placement(placement), SelectionChoice::Replace, ); choose(&mut f.app, Target::Placement(f.child), SelectionChoice::Add); if boundary == "moved" { choose(&mut f.app, Target::Placement(f.a), SelectionChoice::Add); } let before = f.app.document.snapshot(); let projected = projection::project_view(f.app.project(), f.diagram); let old_symbol = projected .nodes .iter() .find(|n| n.id == placement) .unwrap() .position; if boundary != "omitted" { assert_ne!( old_symbol.x, 4000.25, "authored anchor differs from projected boundary" ); } let mut expected = f.app.project().clone(); let view = expected.diagrams.get_mut(&f.diagram).unwrap(); view.nodes.get_mut(&placement).unwrap().position = point(4007.5, 305.25); view.nodes.get_mut(&f.child).unwrap().position = point(87.75, 245.); if boundary == "moved" { view.nodes.get_mut(&f.a).unwrap().position = point(17.5, 25.25); } if boundary != "fixed" { view.routes.last_mut().unwrap().waypoints = vec![point(147.5, 325.25)]; } let snapshot = f.app.multiselect_snapshot().unwrap(); send( &mut f.app, SelectionEvent::Move(snapshot, point(7.25, 4.75)), ); assert_eq!(f.app.project(), &expected, "{kind:?}/{boundary}"); let after = projection::project_view(f.app.project(), f.diagram); let new_symbol = after .nodes .iter() .find(|n| n.id == placement) .unwrap() .position; assert_eq!( new_symbol.x, old_symbol.x + if boundary == "fixed" { 0. } else { 7.25 } ); source_matches(&f.app); let _ = f.app.update(Message::Undo); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert!(!f.app.document.can_undo()); } } } #[tokio::test] async fn state_actions_trigger_captures_exact_row_without_owner_placement_and_stale_menu_cannot_retarget() { use crate::{ context_menu::Entry, model_menu::{self, Event as MenuEvent}, }; use iced::advanced::renderer::Headless; use iced::{Event, Point, Size, mouse}; use iced_runtime::user_interface::{Cache, UserInterface}; let mut renderer = ::new(iced::Font::DEFAULT, 16.into(), None) .await .expect("State Actions trigger requires the supported headless renderer"); for internal in [false, true] { let mut f = fixture(); let relation = if internal { f.internal_row } else { f.action }; let row = Target::StateRow { placement: f.a, relation, }; choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Replace); choose(&mut f.app, row.clone(), SelectionChoice::Add); let before = f.app.document.snapshot(); let selection = f.app.multiselect_snapshot().unwrap(); // Exercise the real Actions button closure, not a reconstruction of its // Request: the typed row must survive without the owner's placement. let mut messages = vec![]; { let mut ui = UserInterface::build( model_menu::trigger(&f.app), Size::new(800., 100.), Cache::new(), &mut renderer, ); let _ = ui.update( &[Event::Mouse(mouse::Event::ButtonPressed( mouse::Button::Left, ))], mouse::Cursor::Available(Point::new(5., 5.)), &mut renderer, &mut iced::advanced::clipboard::Null, &mut messages, ); } assert_eq!(messages.len(), 1); let Message::Menu(MenuEvent::Open(request)) = messages.remove(0) else { panic!("Actions opens a menu") }; assert_eq!(request.object, relation); assert_eq!(request.placement, None); assert_eq!(request.selection_target.as_deref(), Some(&row)); assert_eq!(request.canvas, f.app.state_context()); let mut wrong = request.clone(); wrong.placement = Some(f.a); assert!(model_menu::Opened::new(&f.app, wrong, 1).is_none()); let mut wrong = request.clone(); wrong.object = f.a_element; assert!(model_menu::Opened::new(&f.app, wrong, 1).is_none()); let _ = f .app .update(Message::Menu(MenuEvent::Open(request.clone()))); let opened = f.app.model_menu.as_ref().unwrap(); assert_eq!(opened.request.selection_target.as_deref(), Some(&row)); assert_eq!(opened.request.placement, None); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), selection.targets() ); assert_eq!(f.app.multiselect_snapshot().unwrap().primary(), Some(&row)); assert_eq!(f.app.behavior.diagram.placement, None); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); let entries = model_menu::popup(&f.app).unwrap().entries; assert!(entries.iter().any(|entry| matches!(entry, Entry::Action { label, message: None, .. } if label == "Hide selected placements"))); let invoke = entries .into_iter() .find_map(|entry| match entry { Entry::Action { label, message, .. } if label == "Select only this element" => { message } _ => None, }) .expect("row group menu exposes exact selection command"); // Keep the captured menu object alive across a model revision, so this // checks its validation instead of relying on proactive menu dismissal. let mut candidate = f.app.project().clone(); match &mut candidate.relations.get_mut(&relation).unwrap().body { RelationBody::StateAction { source, .. } => *source = f.b_element, RelationBody::StateTransition { source, target, .. } => { *source = f.b_element; *target = f.b_element; } _ => unreachable!(), } f.app .document .execute(Command::Replace(candidate.clone())) .unwrap(); assert!(!f.app.model_menu.as_ref().unwrap().valid(&f.app)); let source = f.app.document.bundle().clone(); let current = f.app.multiselect_snapshot().unwrap(); let _ = f.app.update(invoke); assert_eq!(f.app.project(), &candidate); assert_eq!(f.app.document.bundle(), &source); assert_eq!(f.app.multiselect_snapshot().unwrap(), current); assert_eq!(f.app.selected, relation); assert_eq!(f.app.behavior.diagram.placement, None); assert!(f.app.model_menu.is_none()); let mut stale_row = request; stale_row.canvas = f.app.state_context(); assert!( model_menu::Opened::new(&f.app, stale_row.clone(), 2).is_none(), "a fresh envelope cannot rebind the old row occurrence" ); stale_row.selection_target = Some(Box::new(Target::StateRow { placement: f.b, relation, })); assert!( model_menu::Opened::new(&f.app, stale_row, 2).is_some(), "the replacement occurrence is independently visible" ); } }