diff --git a/crates/oa-desktop/src/app/multiselect_tests.rs b/crates/oa-desktop/src/app/multiselect_tests.rs index d0d06012..0c71dec4 100644 --- a/crates/oa-desktop/src/app/multiselect_tests.rs +++ b/crates/oa-desktop/src/app/multiselect_tests.rs @@ -145,6 +145,202 @@ fn assert_source_matches_model(app: &App) { assert_eq!(&compiled.bundle, app.document.bundle()); } +fn text_apply_group_fixture(state: bool) -> (App, Id, Id, Id) { + let ordinary = fixture(); + if !state { + return (ordinary.app, ordinary.diagram, ordinary.a, ordinary.b); + } + // Keep the ordinary diagram, routes and opposite-layer view in the same + // Project so the complete-model oracle also protects non-active surfaces. + let mut project = ordinary.app.project().clone(); + let ordinary_view = project.diagrams[&ordinary.diagram].clone(); + let system = project + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let machine = project + .add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system)) + .unwrap(); + let region = project + .add_element(Kind::Region, Layer::Sa, "Region", Some(machine)) + .unwrap(); + let first = project + .add_element(Kind::State, Layer::Sa, "Before text", Some(region)) + .unwrap(); + let second = project + .add_element(Kind::State, Layer::Sa, "Other state", Some(region)) + .unwrap(); + let diagram = project.ensure_diagram(Layer::Sa); + assert_ne!(diagram, ordinary.diagram); + project.diagrams.get_mut(&diagram).unwrap().projection = + ProjectionKind::StateMachine { machine }; + let a = project.place(diagram, first, point(20., 30.)); + let b = project.place(diagram, second, point(420., 30.)); + assert_eq!(project.diagrams[&ordinary.diagram], ordinary_view); + let mut app = App::with_project(project); + app.page = Page::Behavior; + app.layer = Layer::Sa; + app.active_diagram = Some(ordinary.diagram); + 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); + (app, diagram, a, b) +} + +#[test] +fn completed_text_apply_rejects_captured_ordinary_and_state_group_actions() { + for state in [false, true] { + let (mut app, diagram, a, b) = text_apply_group_fixture(state); + choose(&mut app, Target::Placement(a), GroupChoice::Replace); + choose(&mut app, Target::Placement(b), GroupChoice::Add); + let stale = app.multiselect_snapshot().unwrap(); + let before = app.document.snapshot(); + assert!(!app.document.can_undo()); + let element = app.project().diagrams[&diagram].nodes[&a].element; + let location = app.document.compiled().index.declarations[&element].clone(); + let old_name = if state { "Before text" } else { "Send" }; + assert_eq!(app.project().elements[&element].name, old_name); + let quoted_name = format!("\"{old_name}\""); + let original = &before.bundle.files[&location.path]; + assert_eq!(original.matches(quoted_name.as_str()).count(), 1); + let edited = original.replace(quoted_name.as_str(), "\"Changed through Text\""); + let mut expected_model = before.decoded.project.clone(); + expected_model.elements.get_mut(&element).unwrap().name = "Changed through Text".into(); + let mut expected_bundle = before.bundle.clone(); + expected_bundle + .files + .insert(location.path.clone(), edited.clone()); + + let _ = app.update(Message::Page(Page::Text)); + let _ = app.update(Message::SourceFile(location.path)); + let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(Arc::new(edited.clone())), + ))); + assert!(app.source_dirty()); + assert_eq!(app.source_editor.text(), edited); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + let _ = app.update(Message::ApplySource); + assert!(!app.source_dirty(), "{}", app.status); + assert_eq!(app.project(), &expected_model); + assert_eq!(app.document.bundle(), &expected_bundle); + assert!(app.document.can_undo()); + assert!(!app.document.can_redo()); + assert_source_matches_model(&app); + + let _ = app.update(Message::Page(if state { + Page::Behavior + } else { + Page::Diagram + })); + choose(&mut app, Target::Placement(a), GroupChoice::Replace); + choose(&mut app, Target::Placement(b), GroupChoice::Add); + let current = app.multiselect_snapshot().unwrap(); + assert_eq!( + current.targets(), + &[Target::Placement(a), Target::Placement(b)] + ); + assert_eq!(current.primary(), Some(&Target::Placement(b))); + assert_ne!(current.context(), stale.context()); + assert!(app.multiselect_envelope(&GroupEvent::Hide(current.clone()))); + app.status = "Feedback after completed Text Apply".into(); + app.allocation_drag.arrange_next = true; + let revision = app.document.revision(); + let epochs = ( + app.canvas_epoch, + app.camera_epoch, + app.allocation_drag.epoch, + ); + let legacy = ( + app.selected, + app.selected_node, + app.behavior.diagram.placement, + ); + let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size); + let state_camera = ( + app.behavior.diagram.zoom, + app.behavior.diagram.pan, + app.behavior.diagram.fit, + app.behavior.diagram.viewport, + ); + let draft = app + .draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()); + for event in [ + GroupEvent::Move(stale.clone(), point(900., 800.)), + GroupEvent::Hide(stale.clone()), + GroupEvent::Collapse(stale.clone(), Target::Placement(a)), + GroupEvent::Refuse(stale.clone(), "Obsolete refusal".into()), + ] { + dispatch(&mut app, event); + assert_eq!(app.project(), &expected_model); + assert_eq!(app.document.bundle(), &expected_bundle); + assert_eq!(app.document.revision(), revision); + assert_eq!(app.multiselect_snapshot(), Some(current.clone())); + assert_eq!( + ( + app.selected, + app.selected_node, + app.behavior.diagram.placement + ), + legacy + ); + assert_eq!( + ( + app.canvas_epoch, + app.camera_epoch, + app.allocation_drag.epoch + ), + epochs + ); + assert_eq!( + (app.zoom, app.pan, app.fit_to_view, app.canvas_size), + camera + ); + assert_eq!( + ( + app.behavior.diagram.zoom, + app.behavior.diagram.pan, + app.behavior.diagram.fit, + app.behavior.diagram.viewport + ), + state_camera + ); + assert_eq!(app.status, "Feedback after completed Text Apply"); + assert!(app.allocation_drag.arrange_next); + assert!(!app.source_dirty()); + assert_eq!( + app.draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()), + draft + ); + assert!(app.document.can_undo()); + assert!(!app.document.can_redo()); + } + // Actual Text Apply is the sole history entry; no stale group action + // can create a hidden second edit or damage either round-trip snapshot. + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo()); + assert!(app.document.can_redo()); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected_model); + assert_eq!(app.document.bundle(), &expected_bundle); + assert!(app.document.can_undo()); + assert!(!app.document.can_redo()); + } +} + #[test] fn toggled_group_moves_model_and_source_once_and_keeps_primary_through_history() { let mut f = fixture();