//! Saved-view group operations through the real App message boundary. use super::multiselect::{Choice as GroupChoice, Event as GroupEvent}; use super::*; use crate::diagram_usage::Target; fn point(x: f32, y: f32) -> Position { Position { x, y } } struct Fixture { app: App, diagram: Id, a: Id, b: Id, child: Id, edge: Id, } fn fixture() -> Fixture { let mut p = Project::empty(); let a = p .add_element(Kind::Function, Layer::Sa, "Send", None) .unwrap(); let b = p .add_element(Kind::Function, Layer::Sa, "Receive", None) .unwrap(); let child = p .add_element(Kind::Function, Layer::Sa, "Prepare", Some(a)) .unwrap(); let edge = p.relate(RelationKind::FunctionalExchange, "Signal", a, b); let diagram = p.ensure_diagram(Layer::Sa); p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow; let a = p.place(diagram, a, point(10., 20.)); let b = p.place(diagram, b, point(500., 20.)); let child = p.place(diagram, child, point(40., 100.)); p.diagrams .get_mut(&diagram) .unwrap() .routes .push(EdgeRoute { edge, source_attachment: Some(EdgeAttachment { side: PortAnchorSide::Bottom, offset: 0.3, }), target_attachment: Some(EdgeAttachment { side: PortAnchorSide::Top, offset: 0.7, }), waypoints: vec![point(370., 150.)], label: Some(EdgeLabel { fraction: 0.4, offset: point(5., -9.), }), }); let other = p .add_element(Kind::Activity, Layer::Oa, "Operational work", None) .unwrap(); let other_diagram = p.ensure_diagram(Layer::Oa); p.place(other_diagram, other, point(-50., -100.)); let mut app = App::with_project(p); app.page = Page::Diagram; app.layer = Layer::Sa; app.active_diagram = Some(diagram); app.selected = diagram; app.selected_node = None; app.refresh_draft(); app.reconcile_multiselect(); Fixture { app, diagram, a, b, child, edge, } } fn dispatch(app: &mut App, event: GroupEvent) { let _ = app.update(Message::DiagramSelection(event)); } fn choose(app: &mut App, target: Target, choice: GroupChoice) { let context = app.multiselect_context().unwrap(); dispatch(app, GroupEvent::Choose(context, target, choice)); } fn select_pair(f: &mut Fixture) { choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace); choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), &[Target::Placement(f.a), Target::Placement(f.b)] ); } #[test] fn queued_singleton_move_or_delete_cannot_partially_edit_a_new_group() { for model in [false, true] { let mut f = fixture(); select_pair(&mut f); let before = f.app.document.snapshot(); let selected = f.app.multiselect_snapshot().unwrap(); let object = f.app.project().diagrams[&f.diagram].nodes[&f.b].element; for message in [ Message::MoveNode(f.b, point(700., 600.)), Message::DiagramRemove(super::diagram_removal::Intent { context: f.app.route_context().unwrap(), object, placement: Some(f.b), model, }), 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(), selected.targets() ); assert!(!f.app.delete_pending); assert!(!f.app.document.can_undo()); } } } #[test] fn legacy_hide_action_applies_to_the_complete_visible_group() { let mut f = fixture(); select_pair(&mut f); let before = f.app.document.snapshot(); 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); let _ = f.app.update(Message::HideSelected); assert_eq!(f.app.project(), &expected); assert_source_matches_model(&f.app); let _ = f.app.update(Message::Undo); assert_eq!(f.app.document.bundle(), &before.bundle); assert!(!f.app.document.can_undo()); } fn assert_source_matches_model(app: &App) { let compiled = oa_core::source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap(); assert_eq!(&compiled.decoded.project, app.project()); assert_eq!(&compiled.bundle, app.document.bundle()); } #[test] fn toggled_group_moves_model_and_source_once_and_keeps_primary_through_history() { let mut f = fixture(); select_pair(&mut f); let before = f.app.document.snapshot(); let snapshot = f.app.multiselect_snapshot().unwrap(); assert_eq!(snapshot.primary(), Some(&Target::Placement(f.b))); 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(50., 80.); view.nodes.get_mut(&f.b).unwrap().position = point(540., 80.); view.nodes.get_mut(&f.child).unwrap().position = point(80., 160.); view.routes[0].waypoints = vec![point(410., 210.)]; dispatch( &mut f.app, GroupEvent::Move(snapshot.clone(), point(40., 60.)), ); assert_eq!(f.app.project(), &expected); assert_source_matches_model(&f.app); assert_eq!(f.app.selected_node, Some(f.b)); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), snapshot.targets() ); 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()); 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()); assert_eq!(f.app.selected_node, Some(f.b)); } #[test] fn ancestor_and_child_selection_moves_child_exactly_once() { let mut f = fixture(); choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace); choose(&mut f.app, Target::Placement(f.child), GroupChoice::Toggle); let snapshot = f.app.multiselect_snapshot().unwrap(); assert_eq!(snapshot.targets().len(), 2); 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(0., 45.); view.nodes.get_mut(&f.child).unwrap().position = point(30., 125.); dispatch(&mut f.app, GroupEvent::Move(snapshot, point(-10., 25.))); assert_eq!(f.app.project(), &expected); assert_eq!(f.app.selected_node, Some(f.child)); assert_source_matches_model(&f.app); } #[test] fn mixed_edge_or_port_selection_refuses_the_entire_move() { for include_port in [false, true] { let mut f = fixture(); choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace); let extra = if include_port { let view = projection::project_view(f.app.project(), f.diagram); let port = view .ports .iter() .find(|p| p.owner == f.a) .expect("functional exchange exposes its typed owner port"); Target::Port { port: port.element, placement: port.owner, } } else { Target::Edge(f.edge) }; choose(&mut f.app, extra.clone(), GroupChoice::Toggle); let selection = f.app.multiselect_snapshot().unwrap(); assert_eq!(selection.targets(), &[Target::Placement(f.a), extra]); let before = f.app.document.snapshot(); let revision = f.app.document.revision(); dispatch( &mut f.app, GroupEvent::Move(selection.clone(), point(40., 60.)), ); 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_eq!( f.app.multiselect_snapshot().unwrap().targets(), selection.targets() ); assert!(f.app.status.contains("Move requires only placed elements")); } } #[test] fn stale_operation_envelopes_have_no_selection_history_status_or_allocation_side_effects() { // Isolate each lifetime component, including the same visible set recreated // by a remove/re-add cycle. Accepted setup changes are outside the assertion. for stale_by in ["epoch", "revision", "document", "selection generation"] { for action in ["move", "hide", "refuse", "collapse"] { let mut f = fixture(); select_pair(&mut f); let stale = f.app.multiselect_snapshot().unwrap(); match stale_by { "epoch" => f.app.canvas_epoch += 1, "revision" => { let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.a].element; // Advance only the document revision, retaining this canvas // and selection epoch to independently test revision checking. f.app .document .execute(Command::Describe(semantic, "New revision".into())) .unwrap(); } "document" => f.app.canvas_document = Id::new_v4(), _ => { choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle); choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), stale.targets() ); } } f.app.status = "Current feedback must survive".into(); f.app.allocation_drag.arrange_next = true; let before = f.app.document.snapshot(); let selection = f.app.multiselect_snapshot(); let legacy = (f.app.selected, f.app.selected_node); let revision = f.app.document.revision(); let epochs = ( f.app.canvas_epoch, f.app.camera_epoch, f.app.allocation_drag.epoch, ); 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()); let event = match action { "move" => GroupEvent::Move(stale, point(99., 100.)), "hide" => GroupEvent::Hide(stale), "collapse" => GroupEvent::Collapse(stale, Target::Placement(f.a)), _ => GroupEvent::Refuse(stale, "Obsolete refusal".into()), }; dispatch(&mut f.app, event); assert_eq!( f.app.project(), &before.decoded.project, "{stale_by}/{action}" ); assert_eq!(f.app.document.bundle(), &before.bundle); assert_eq!(f.app.document.revision(), revision); assert_eq!(f.app.multiselect_snapshot(), selection); assert_eq!((f.app.selected, f.app.selected_node), legacy); assert_eq!( ( f.app.canvas_epoch, f.app.camera_epoch, f.app.allocation_drag.epoch ), epochs ); assert_eq!( (f.app.document.can_undo(), f.app.document.can_redo()), history ); assert_eq!(f.app.status, "Current feedback must survive"); assert!(f.app.allocation_drag.arrange_next); assert_eq!( f.app .draft .as_ref() .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()), draft ); } } } #[test] fn pending_valid_and_invalid_property_drafts_refuse_group_edits_without_applying_them() { for (path, value) in [("/name", "Unapplied name"), ("/id", "-")] { for hide in [false, true] { let mut f = fixture(); select_pair(&mut f); let _ = f.app.update(Message::Field(path.into(), value.into())); assert!(f.app.draft_dirty()); let snapshot = f.app.multiselect_snapshot().unwrap(); let before = f.app.document.snapshot(); let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); let event = if hide { GroupEvent::Hide(snapshot.clone()) } else { GroupEvent::Move(snapshot.clone(), point(20., 30.)) }; dispatch(&mut f.app, event); 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.multiselect_snapshot().unwrap().targets(), snapshot.targets() ); assert_eq!( serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), draft ); assert!(f.app.status.contains("Finish or cancel")); } } } #[test] fn hiding_a_group_removes_only_exact_placements_and_roundtrips_in_one_history_step() { let mut f = fixture(); select_pair(&mut f); let snapshot = f.app.multiselect_snapshot().unwrap(); let before = f.app.document.snapshot(); 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); dispatch(&mut f.app, GroupEvent::Hide(snapshot)); assert_eq!( f.app.project(), &expected, "engineering objects, descendant placement, authored route and opposite-layer view survive" ); assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty()); assert_source_matches_model(&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()); 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()); } fn assert_surviving_primary(f: &Fixture) { let selection = f.app.multiselect_snapshot().unwrap(); assert_eq!(selection.targets(), &[Target::Placement(f.a)]); assert_eq!(selection.primary(), Some(&Target::Placement(f.a))); assert_eq!(f.app.selected_node, Some(f.a)); assert_eq!( f.app.selected, f.app.project().diagrams[&f.diagram].nodes[&f.a].element ); } #[test] fn removing_primary_placement_from_model_preserves_exact_survivor_after_undo() { let mut f = fixture(); select_pair(&mut f); let before = f.app.document.snapshot(); let _ = f.app.update(Message::Command(Command::Delete(f.b))); assert!( !f.app.project().diagrams[&f.diagram] .nodes .contains_key(&f.b) ); assert_surviving_primary(&f); let _ = f.app.update(Message::Undo); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert_surviving_primary(&f); let _ = f.app.update(Message::Redo); assert_surviving_primary(&f); } #[test] fn deleting_primary_model_object_then_undo_does_not_restore_removed_selection_membership() { let mut f = fixture(); select_pair(&mut f); let before = f.app.document.snapshot(); let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.b].element; let _ = f.app.update(Message::Command(Command::Delete(semantic))); assert!(!f.app.project().elements.contains_key(&semantic)); assert_surviving_primary(&f); let _ = f.app.update(Message::Undo); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert_surviving_primary(&f); let _ = f.app.update(Message::Redo); assert_surviving_primary(&f); } #[test] fn replacing_primary_occurrence_with_same_semantic_placement_does_not_retarget_selection() { let mut f = fixture(); select_pair(&mut f); let mut candidate = f.app.project().clone(); let view = candidate.diagrams.get_mut(&f.diagram).unwrap(); let mut replacement = view.nodes.remove(&f.b).unwrap(); let replacement_id = Id::new_v4(); replacement.id = replacement_id; replacement.alias = "replacement_receive".into(); replacement.position = point(800., 300.); view.nodes.insert(replacement_id, replacement); // Replace preserves the semantic inspector identity. Its normal sync used // to rediscover the new occurrence through that identity and steal the set. let _ = f .app .update(Message::Command(Command::Replace(candidate.clone()))); assert_eq!(f.app.project(), &candidate); assert_surviving_primary(&f); assert!( !f.app .multiselect_snapshot() .unwrap() .targets() .contains(&Target::Placement(replacement_id)) ); assert_source_matches_model(&f.app); let _ = f.app.update(Message::Undo); assert!( f.app.project().diagrams[&f.diagram] .nodes .contains_key(&f.b) ); assert_surviving_primary(&f); } #[test] fn explicit_surface_navigation_and_return_never_resurrect_the_group() { for destination in [Page::Catalog, Page::Behavior] { let mut f = fixture(); select_pair(&mut f); let before = f.app.document.snapshot(); let stale = f.app.multiselect_snapshot().unwrap(); let _ = f.app.update(Message::Page(destination)); assert!(f.app.multiselect_snapshot().is_none()); let _ = f.app.update(Message::Page(Page::Diagram)); let live = f.app.multiselect_snapshot().unwrap(); assert!( live.targets().len() <= 1, "navigation may inspect one exact member but must not restore the group" ); dispatch(&mut f.app, GroupEvent::Move(stale, point(400., 500.))); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert!(!f.app.document.can_undo()); } } fn open_placement_menu(f: &mut Fixture, placement: Id) { let semantic = f.app.project().diagrams[&f.diagram].nodes[&placement].element; let mut request = crate::model_menu::Request::object(semantic, iced::Point::new(100., 100.)); request.placement = Some(placement); request.canvas = f.app.route_context(); let _ = f .app .update(Message::Menu(crate::model_menu::Event::Open(request))); assert!(f.app.model_menu.is_some()); } fn menu_action(app: &App, caption: &str) -> Message { crate::model_menu::popup(app) .unwrap() .entries .into_iter() .find_map(|entry| match entry { crate::context_menu::Entry::Action { label, message, .. } if label == caption => { message } _ => None, }) .unwrap_or_else(|| panic!("enabled menu action {caption:?} must exist")) } #[test] fn context_menu_dismissal_preserves_group_and_add_keeps_pre_menu_members() { let mut f = fixture(); select_pair(&mut f); let before = f.app.document.snapshot(); let selected = f.app.multiselect_snapshot().unwrap(); let child = f.child; open_placement_menu(&mut f, child); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), selected.targets() ); assert_eq!( f.app.multiselect_snapshot().unwrap().primary(), selected.primary() ); let _ = f.app.update(Message::Menu(crate::model_menu::Event::Close)); assert_eq!( f.app.multiselect_snapshot().unwrap().targets(), selected.targets() ); assert_eq!( f.app.multiselect_snapshot().unwrap().primary(), selected.primary() ); open_placement_menu(&mut f, child); let add = menu_action(&f.app, "Add to selection"); let _ = f.app.update(add); let selected = f.app.multiselect_snapshot().unwrap(); assert_eq!( selected.targets(), &[ Target::Placement(f.a), Target::Placement(f.b), Target::Placement(child) ] ); assert_eq!(selected.primary(), Some(&Target::Placement(child))); assert_eq!(f.app.project(), &before.decoded.project); assert_eq!(f.app.document.bundle(), &before.bundle); assert!(!f.app.document.can_undo()); } #[test] fn selected_member_context_menu_exposes_and_executes_group_hide_not_singleton_deletion() { let mut f = fixture(); select_pair(&mut f); let before = f.app.document.snapshot(); let member = f.a; open_placement_menu(&mut f, member); let popup = crate::model_menu::popup(&f.app).unwrap(); for entry in &popup.entries { if let crate::context_menu::Entry::Action { message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, action))), .. } = entry { assert!( !matches!( action.as_ref(), Message::HideSelected | Message::Delete | Message::DiagramRemove(_) ), "a selected-member group menu must not expose singleton removal" ); } } let hide = menu_action(&f.app, "Hide selected placements"); let _ = f.app.update(hide); let mut expected = before.decoded.project.clone(); let view = expected.diagrams.get_mut(&f.diagram).unwrap(); view.nodes.remove(&f.a); view.nodes.remove(&f.b); assert_eq!(f.app.project(), &expected); assert!(!f.app.delete_pending); assert!(f.app.model_menu.is_none()); assert_source_matches_model(&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()); }