Files
ARCADIA-Studio/crates/oa-desktop/src/app/state_multiselect_tests.rs
T
sorokin.oleg.edu 2a3b9ba822 Add exact State multiselection, atomic movement and placement Hide
Preserve the desktop-2 captured source (1286 passed, 6 existing ignored). Three menu payload blobs are staged from that verified capture without replacing working files; the strict-lint size correction follows in a separate commit. Native acceptance remains pending.
2026-09-23 03:37:01 -07:00

1093 lines
41 KiB
Rust

//! 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<Position>) -> 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 = <iced::Renderer as Headless>::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, Some(row.clone()));
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, Some(row.clone()));
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(Target::StateRow {
placement: f.b,
relation,
});
assert!(
model_menu::Opened::new(&f.app, stale_row, 2).is_some(),
"the replacement occurrence is independently visible"
);
}
}