test: reject captured group actions after real Text Apply

This commit is contained in:
2026-09-23 12:12:10 -07:00
parent a2a326f183
commit 1006b496ae
@@ -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();