1093 lines
41 KiB
Rust
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.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"
|
|
);
|
|
}
|
|
}
|