Files
ARCADIA-Studio/crates/oa-desktop/src/app/state_diagram_tests.rs
T

1134 lines
39 KiB
Rust

//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
states,
};
struct Fixture {
app: App,
machine: Id,
region: Id,
a: Id,
b: Id,
leaf: Id,
members: BTreeSet<Id>,
}
fn send(app: &mut App, message: Message) {
// Exercise the real App boundary without polling asynchronous I/O tasks.
let _ = app.update(message);
}
fn event(app: &mut App, event: Event) {
send(app, Message::StateDiagram(event));
}
fn form(app: &mut App, event: states::Event) {
send(app, Message::Behavior(behavior::Event::States(event)));
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let mut add = |kind, name, parent| project.add_element(kind, Layer::Sa, name, parent).unwrap();
let owner = add(Kind::System, "System", None);
let machine = add(Kind::StateMachine, "Operating modes", Some(owner));
let region = add(Kind::Region, "Main", Some(machine));
let initial = add(Kind::Initial, "Start", Some(region));
let a = add(Kind::State, "Operating", Some(region));
let b = add(Kind::State, "Standby", Some(region));
let nested = add(Kind::Region, "Work", Some(a));
let leaf = add(Kind::State, "Working", Some(nested));
let other_machine = add(Kind::StateMachine, "Other machine", None);
let other_region = add(Kind::Region, "Other region", Some(other_machine));
add(Kind::State, "Foreign state", Some(other_region));
let mut app = App::with_project(project);
send(&mut app, Message::Select(a));
send(&mut app, Message::Page(Page::Behavior));
assert_eq!(behavior::selected_owner(&app), Some(machine));
Fixture {
app,
machine,
region,
a,
b,
leaf,
members: BTreeSet::from([machine, region, initial, a, b, nested, leaf]),
}
}
fn create_view(f: &mut Fixture) -> Context {
event(&mut f.app, Event::CreateView(f.machine));
f.app
.state_context()
.expect("CreateView must activate the new persisted view")
}
fn node(app: &App, diagram: Id, element: Id) -> Id {
app.project().diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == element)
.unwrap()
.id
}
fn assert_source_model_agree(app: &App) {
assert_eq!(
source_project::compile_bundle(app.document.bundle(), LIMITS)
.unwrap()
.decoded
.project,
*app.project()
);
assert_eq!(
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
*app.document.bundle()
);
assert!(!app.source_dirty());
}
#[test]
fn state_diagram_routes_constructor_and_source_open_to_the_existing_workspace_without_edits() {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let project = f.app.project().clone();
let app = App::with_project(project.clone());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, diagram);
assert_eq!(behavior::selected_owner(&app), Some(f.machine));
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert_eq!(app.selected, project.id);
assert_eq!(app.project(), &project);
assert!(!app.document.can_undo());
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("state-view.arcadia");
let text = format!(
"// Exact authored source must remain unchanged.\n{}",
language::format(&project)
);
std::fs::write(&path, &text).unwrap();
let mut reopened = App::with_project(Project::empty());
send(
&mut reopened,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
);
assert_eq!(reopened.page, Page::Behavior);
assert_eq!(reopened.state_context().unwrap().diagram, diagram);
assert_eq!(reopened.project(), &project);
assert_eq!(
reopened.document.bundle().files[&reopened.document.bundle().entry],
text
);
assert_eq!(std::fs::read_to_string(path).unwrap(), text);
assert!(!reopened.document.can_undo());
assert_source_model_agree(&reopened);
}
#[test]
fn state_diagram_routes_picker_and_reveal_but_explicit_architecture_does_not_bounce() {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let second = state_edit::create_view(&project, f.machine).unwrap();
project = prepare_command(&project, &second.command).unwrap().project;
let transition = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
let mut app = App::with_project(project.clone());
let before = app.document.snapshot();
send(&mut app, Message::Page(Page::Diagram));
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, None);
send(
&mut app,
Message::Diagram(Choice {
id: second.diagram,
label: "Second state view".into(),
}),
);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, second.diagram);
send(&mut app, Message::RevealInDiagram(diagram, transition));
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, diagram);
assert_eq!(app.selected, transition);
assert_eq!(app.draft.as_ref().unwrap().original, transition);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_source_model_agree(&app);
let architecture = project.ensure_diagram(Layer::Sa);
let mut app = App::with_project(project);
send(
&mut app,
Message::Diagram(Choice {
id: diagram,
label: "State view".into(),
}),
);
assert_eq!(app.page, Page::Behavior);
send(&mut app, Message::Page(Page::Diagram));
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, Some(architecture));
assert!(app.state_context().is_none());
assert!(!app.document.can_undo());
}
#[test]
fn state_diagram_routes_preserve_selected_raw_properties_and_pending_behavior_guards() {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut app = App::with_project(f.app.project().clone());
send(&mut app, Message::Select(f.a));
send(
&mut app,
Message::Field("/alias".into(), "invalid alias!".into()),
);
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert!(!app.apply());
let before = app.document.snapshot();
// Exercise normalization itself with an existing raw draft, including callers
// that have just adopted a view without a successful property Apply.
app.page = Page::Diagram;
app.route_state_diagram_workspace();
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, f.a);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
send(
&mut app,
Message::Diagram(Choice {
id: diagram,
label: "State view".into(),
}),
);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let mut app = App::with_project(f.app.project().clone());
form(
&mut app,
states::Event::Start(f.machine, states::Tool::Transition, f.a),
);
form(
&mut app,
states::Event::Name("Pending behavior stays".into()),
);
let pending = app.behavior.recovery_snapshot().unwrap();
let context = app.state_context().unwrap();
send(&mut app, Message::Page(Page::Diagram));
send(
&mut app,
Message::Diagram(Choice {
id: diagram,
label: "State view".into(),
}),
);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, context.diagram);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), pending);
assert!(!app.document.can_undo());
}
struct NavigationFixture {
app: App,
machine_a: Id,
diagram_a: Id,
state_a: Id,
transition_a: Id,
root_event: Id,
machine_b: Id,
diagram_b: Id,
state_b: Id,
}
fn navigation_fixture(layer_b: Layer) -> NavigationFixture {
let mut project = Project::empty();
let mut machine = |layer, name| {
let machine = project
.add_element(Kind::StateMachine, layer, name, None)
.unwrap();
let region = project
.add_element(Kind::Region, layer, "Main", Some(machine))
.unwrap();
let state = project
.add_element(Kind::State, layer, "Ready", Some(region))
.unwrap();
(machine, state)
};
let (machine_a, state_a) = machine(Layer::Sa, "Machine A");
let (machine_b, state_b) = machine(layer_b, "Machine B");
let root_event = project
.add_element(Kind::Event, Layer::Sa, "External event", None)
.unwrap();
let transition_a = project.relate(RelationKind::StateTransition, "Repeat", state_a, state_a);
let plan_a = state_edit::create_view(&project, machine_a).unwrap();
project = prepare_command(&project, &plan_a.command).unwrap().project;
let plan_b = state_edit::create_view(&project, machine_b).unwrap();
project = prepare_command(&project, &plan_b.command).unwrap().project;
let mut app = App::with_project(project);
send(&mut app, Message::Select(state_a));
send(&mut app, Message::Page(Page::Behavior));
send(
&mut app,
Message::BehaviorDiagram(Choice {
id: plan_a.diagram,
label: "A view".into(),
}),
);
let context = app.state_context().unwrap();
event(
&mut app,
Event::Viewport(context, iced::Size::new(820., 540.)),
);
event(&mut app, Event::Zoom(context, 0.65));
event(
&mut app,
Event::Pan(context, iced::Vector::new(-1_400., -800.)),
);
event(
&mut app,
Event::Choose(Tool::Reconnect {
relation: transition_a,
endpoint: Endpoint::Target,
}),
);
assert_eq!(app.state_context().unwrap().diagram, plan_a.diagram);
assert!(!app.behavior.diagram.fit);
NavigationFixture {
app,
machine_a,
diagram_a: plan_a.diagram,
state_a,
transition_a,
root_event,
machine_b,
diagram_b: plan_b.diagram,
state_b,
}
}
fn assert_switched_state_view(f: &mut NavigationFixture) {
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_b));
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_b);
let defaults = behavior::state_view::State::default();
let state = &f.app.behavior.diagram;
assert_eq!(state.diagram, Some(f.diagram_b));
assert_eq!(
state.tool,
Tool::Select,
"Reconnect from A must not be armed in B"
);
assert_eq!(state.pan, defaults.pan);
assert_eq!(state.zoom, defaults.zoom);
assert!(state.fit);
assert!(
state.viewport.is_none(),
"B needs its own first viewport measurement"
);
assert!(state.placement.is_none());
let context = f.app.state_context().unwrap();
event(
&mut f.app,
Event::Viewport(context, iced::Size::new(820., 540.)),
);
assert!(
f.app.behavior.diagram.pan != defaults.pan || f.app.behavior.diagram.zoom != defaults.zoom,
"The unchanged widget size still fits a newly selected view"
);
}
fn catalog_selects_another_machine(layer_b: Layer) {
let mut f = navigation_fixture(layer_b);
let before = f.app.document.snapshot();
send(&mut f.app, Message::Page(Page::Catalog));
send(&mut f.app, Message::Select(f.state_b));
assert_eq!(f.app.layer, layer_b);
send(&mut f.app, Message::Page(Page::Behavior));
assert_switched_state_view(&mut f);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_source_model_agree(&f.app);
}
#[test]
fn state_navigation_catalog_selection_of_another_machine_resets_camera_and_tool() {
catalog_selects_another_machine(Layer::Sa);
}
#[test]
fn state_navigation_catalog_selection_across_layers_resets_camera_and_tool() {
catalog_selects_another_machine(Layer::Oa);
}
#[test]
fn state_navigation_layer_change_resets_the_new_machine_at_the_same_viewport_size() {
let mut f = navigation_fixture(Layer::Oa);
let before = f.app.document.snapshot();
send(&mut f.app, Message::Layer(Layer::Oa));
assert_switched_state_view(&mut f);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_source_model_agree(&f.app);
}
#[test]
fn state_navigation_return_to_the_same_view_preserves_camera_tool_and_fit_mode() {
for fit in [false, true] {
let mut f = navigation_fixture(Layer::Sa);
if fit {
let context = f.app.state_context().unwrap();
event(&mut f.app, Event::Fit(context));
}
let state = &f.app.behavior.diagram;
let retained = (
state.diagram,
state.pan,
state.zoom,
state.tool,
state.fit,
state.viewport,
);
let before = f.app.document.snapshot();
send(&mut f.app, Message::Page(Page::Catalog));
send(&mut f.app, Message::Select(f.state_a));
send(&mut f.app, Message::Page(Page::Behavior));
let context = f.app.state_context().unwrap();
event(
&mut f.app,
Event::Viewport(context, iced::Size::new(820., 540.)),
);
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
assert_eq!(context.diagram, f.diagram_a);
let state = &f.app.behavior.diagram;
assert_eq!(
(
state.diagram,
state.pan,
state.zoom,
state.tool,
state.fit,
state.viewport
),
retained
);
assert_eq!(
state.tool,
Tool::Reconnect {
relation: f.transition_a,
endpoint: Endpoint::Target
}
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
}
}
#[test]
fn state_navigation_root_event_and_same_layer_keep_the_current_camera_and_tool() {
let mut f = navigation_fixture(Layer::Sa);
let state = &f.app.behavior.diagram;
let retained = (
state.diagram,
state.pan,
state.zoom,
state.tool,
state.fit,
state.viewport,
);
let before = f.app.document.snapshot();
send(&mut f.app, Message::Select(f.root_event));
assert_eq!(f.app.selected, f.root_event);
send(&mut f.app, Message::Page(Page::Catalog));
send(&mut f.app, Message::Page(Page::Behavior));
send(&mut f.app, Message::Layer(Layer::Sa));
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_a);
let state = &f.app.behavior.diagram;
assert_eq!(
(
state.diagram,
state.pan,
state.zoom,
state.tool,
state.fit,
state.viewport
),
retained
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
}
#[test]
fn create_view_places_only_its_machine_and_restores_exact_source_in_one_undo() {
let mut f = fixture();
let before = f.app.document.snapshot();
let context = create_view(&mut f);
let app = &mut f.app;
let diagram = &app.project().diagrams[&context.diagram];
assert_eq!(
diagram.projection,
ProjectionKind::StateMachine { machine: f.machine }
);
assert_eq!(
diagram
.nodes
.values()
.map(|n| n.element)
.collect::<BTreeSet<_>>(),
f.members
);
assert_eq!(app.project().elements, before.decoded.project.elements);
assert_eq!(app.project().relations, before.decoded.project.relations);
assert_eq!(app.selected, f.machine);
assert!(app.selected_node.is_none());
assert_eq!(app.document.revision(), 1);
assert_source_model_agree(app);
let accepted = app.document.snapshot();
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.dirty());
assert_source_model_agree(app);
send(app, Message::Redo);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_source_model_agree(app);
}
#[test]
fn direct_create_connect_select_and_move_keep_semantic_identity_and_atomic_history() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let before_create = app.document.snapshot();
event(
app,
Event::CreateAt {
context,
owner: f.region,
kind: Kind::State,
position: Position { x: 900., y: 700. },
},
);
let created = app.selected;
assert_eq!(app.project().elements[&created].parent, Some(f.region));
assert_eq!(app.project().elements[&created].kind(), Kind::State);
assert_eq!(
app.project().elements.len(),
before_create.decoded.project.elements.len() + 1
);
let created_node = node(app, context.diagram, created);
assert_ne!(created_node, created);
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&created_node].position,
Position { x: 900., y: 700. }
);
assert_source_model_agree(app);
let before_connect = app.document.snapshot();
let context = app.state_context().unwrap();
event(
app,
Event::Connect {
context,
source: f.a,
target: created,
},
);
let relation = app.selected;
assert!(matches!(app.project().relations[&relation].body,
RelationBody::StateTransition { source, target, transition_kind: TransitionKind::External, .. }
if source == f.a && target == created));
assert!(app.selected_node.is_none());
assert_eq!(behavior::selected_owner(app), Some(f.machine));
assert_source_model_agree(app);
let context = app.state_context().unwrap();
let before_select = app.document.snapshot();
event(app, Event::Select(context, f.a));
assert_eq!(app.selected, f.a);
assert_eq!(
app.state_context(),
Some(context),
"The press selection must not invalidate its following drag release"
);
assert!(Arc::ptr_eq(&before_select, &app.document.snapshot()));
let placement = node(app, context.diagram, f.a);
let leaf = node(app, context.diagram, f.leaf);
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
let leaf_original = app.project().diagrams[&context.diagram].nodes[&leaf].position;
event(
app,
Event::Move {
context,
node: placement,
position: Position {
x: original.x + 31.,
y: original.y + 43.,
},
},
);
assert_eq!(app.selected, f.a);
assert!(app.selected_node.is_none());
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&leaf].position,
Position {
x: leaf_original.x + 31.,
y: leaf_original.y + 43.
}
);
assert_eq!(
app.document.revision(),
4,
"View, vertex, edge, and drag each commit once"
);
assert_source_model_agree(app);
for expected in [before_select, before_connect, before_create] {
send(app, Message::Undo);
assert_eq!(app.project(), &expected.decoded.project);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_source_model_agree(app);
}
assert!(app.project().diagrams.contains_key(&context.diagram));
assert!(!app.project().contains(created));
assert!(!app.project().contains(relation));
}
#[test]
fn all_context_dimensions_reject_stale_selection_camera_and_model_messages() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let before = app.document.snapshot();
let selected = app.selected;
let pan = app.behavior.diagram.pan;
let zoom = app.behavior.diagram.zoom;
let viewport = app.behavior.diagram.viewport;
let placement = node(app, context.diagram, f.a);
for changed in 0..4 {
let mut captured = context;
match changed {
0 => captured.document = Id::new_v4(),
1 => captured.diagram = Id::new_v4(),
2 => captured.revision += 1,
_ => captured.epoch += 1,
}
for stale in [
Event::Select(captured, f.b),
Event::Viewport(captured, iced::Size::new(999., 777.)),
Event::Pan(captured, iced::Vector::new(55., 66.)),
Event::Zoom(captured, 2.7),
Event::Move {
context: captured,
node: placement,
position: Position { x: 88., y: 99. },
},
Event::Connect {
context: captured,
source: f.a,
target: f.b,
},
Event::CreateAt {
context: captured,
owner: f.region,
kind: Kind::State,
position: Position { x: 800., y: 800. },
},
] {
event(app, stale);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.selected, selected);
assert_eq!(app.behavior.diagram.pan, pan);
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.viewport, viewport);
assert_eq!(app.state_context(), Some(context));
assert!(!app.behavior.has_pending());
}
}
assert_source_model_agree(app);
}
#[test]
fn real_view_tool_and_history_changes_invalidate_old_release_events() {
let mut f = fixture();
let first = create_view(&mut f);
let second = create_view(&mut f);
let app = &mut f.app;
assert_ne!(first.diagram, second.diagram);
let first_choice = Choice {
id: first.diagram,
label: app.project().named(first.diagram).into(),
};
send(app, Message::BehaviorDiagram(first_choice));
let restored_view = app.state_context().unwrap();
assert_eq!(restored_view.diagram, first.diagram);
let before = app.document.snapshot();
for captured in [first, second] {
event(
app,
Event::Connect {
context: captured,
source: f.a,
target: f.b,
},
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
event(app, Event::Choose(Tool::Transition));
let armed = app.state_context().unwrap();
assert_ne!(restored_view.epoch, armed.epoch);
event(
app,
Event::Connect {
context: restored_view,
source: f.a,
target: f.b,
},
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
event(
app,
Event::Connect {
context: armed,
source: f.a,
target: f.b,
},
);
assert_eq!(
app.project().relations.len(),
before.decoded.project.relations.len() + 1
);
let committed = app.state_context().unwrap();
send(app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
let undone = app.document.snapshot();
event(
app,
Event::Connect {
context: armed,
source: f.a,
target: f.b,
},
);
event(
app,
Event::Connect {
context: committed,
source: f.a,
target: f.b,
},
);
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
send(app, Message::Page(Page::Diagram));
assert!(app.state_context().is_none());
let diagram_count = app.project().diagrams.len();
event(app, Event::CreateView(f.machine));
assert_eq!(app.project().diagrams.len(), diagram_count);
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
}
#[test]
fn rejected_creation_keeps_source_and_selection_then_valid_owner_can_retry() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let before = app.document.snapshot();
let selected = app.selected;
event(
app,
Event::CreateAt {
context,
owner: f.a,
kind: Kind::State,
position: Position { x: 600., y: 700. },
},
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.selected, selected);
assert_eq!(app.state_context(), Some(context));
event(
app,
Event::CreateAt {
context,
owner: f.region,
kind: Kind::State,
position: Position { x: 600., y: 700. },
},
);
assert_eq!(app.project().elements[&app.selected].parent, Some(f.region));
assert_eq!(app.document.revision(), context.revision + 1);
assert_source_model_agree(app);
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn pending_drafts_block_every_direct_mutation_and_passive_camera_preserves_inputs() {
for pending in ["properties", "source", "behavior"] {
let mut f = fixture();
let context = create_view(&mut f);
event(
&mut f.app,
Event::Connect {
context,
source: f.a,
target: f.b,
},
);
let relation = f.app.selected;
// A valid Place request must have a genuinely absent placement.
let missing = f
.app
.project()
.make_element(Kind::State, Layer::Sa, "Not yet displayed", Some(f.region))
.unwrap();
let missing_id = missing.id;
send(
&mut f.app,
Message::Command(Command::CreateElement(missing)),
);
send(&mut f.app, Message::Select(f.a));
let app = &mut f.app;
let context = app.state_context().unwrap();
let placement = node(app, context.diagram, f.a);
let at = app.project().diagrams[&context.diagram].nodes[&placement].position;
let mutations = [
Event::CreateAt {
context,
owner: f.region,
kind: Kind::State,
position: Position { x: 700., y: 800. },
},
Event::Place {
context,
element: missing_id,
position: Position { x: 800., y: 900. },
},
Event::Move {
context,
node: placement,
position: Position {
x: at.x + 50.,
y: at.y + 70.,
},
},
Event::Connect {
context,
source: f.b,
target: f.a,
},
Event::Reconnect {
context,
relation,
endpoint: Endpoint::Target,
vertex: f.leaf,
},
];
for mutation in &mutations {
let plan = state_edit::plan(app.project(), context, mutation).unwrap();
assert_ne!(
&prepare_command(app.project(), &plan.command)
.unwrap()
.project,
app.project(),
"Each blocked probe must otherwise be a valid, substantive model change"
);
}
match pending {
"properties" => send(app, Message::Field("/name".into(), "Unapplied name".into())),
"source" => {
let draft = format!(
"{}\n// Unapplied source note.\n",
app.source_workspace.text()
);
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
send(
app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(draft),
))),
);
}
_ => {
form(app, states::Event::Edit(relation));
form(app, states::Event::Name("Retained form name".into()));
form(app, states::Event::Guard("temperature nominal".into()));
form(app, states::Event::TriggerNotes("Retained trigger".into()));
}
}
assert_eq!(app.draft_dirty(), pending == "properties");
assert_eq!(app.source_dirty(), pending == "source");
assert_eq!(app.behavior.has_pending(), pending == "behavior");
let before = app.document.snapshot();
let source = app.source_workspace.bundle();
let properties = app.draft.as_ref().unwrap().materialize().unwrap();
let pending_candidate = app
.behavior
.prepare_pending(app.project())
.unwrap()
.map(|command| prepare_command(app.project(), &command).unwrap().project);
let selected = app.selected;
for mutation in mutations {
event(app, mutation);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
assert_eq!(app.selected, selected);
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
properties
);
}
for passive in [
Event::Viewport(context, iced::Size::new(960., 640.)),
Event::Pan(context, iced::Vector::new(12., 18.)),
Event::Zoom(context, 0.7),
Event::Fit(context),
] {
event(app, passive);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
properties
);
}
assert_eq!(
app.behavior
.prepare_pending(app.project())
.unwrap()
.map(|command| prepare_command(app.project(), &command).unwrap().project),
pending_candidate
);
match pending {
"properties" => send(app, Message::RevertProperties),
"source" => send(app, Message::RevertSource),
_ => form(app, states::Event::Cancel),
}
assert!(!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending());
let context = app.state_context().unwrap();
event(
app,
Event::Move {
context,
node: placement,
position: Position {
x: at.x + 50.,
y: at.y + 70.,
},
},
);
assert_eq!(app.selected, f.a);
assert_ne!(app.document.bundle(), &before.bundle);
assert_source_model_agree(app);
send(app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
}
}
#[test]
fn fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move() {
let f = fixture();
let mut project = f.app.project().clone();
// One eligible machine makes the fallback deterministic. Start from a loaded
// architecture view with no selected behavior element or chosen owner. A
// state-only project now opens its saved view with an explicit owner.
project
.elements
.retain(|id, element| f.members.contains(id) || element.kind() == Kind::System);
let plan = state_edit::create_view(&project, f.machine).unwrap();
let mut project = prepare_command(&project, &plan.command).unwrap().project;
let architecture = project.ensure_diagram(Layer::Sa);
project.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
let mut app = App::with_project(project);
assert_eq!(app.selected, app.project().id);
assert!(app.behavior.active_owner.is_none());
send(&mut app, Message::Page(Page::Behavior));
let context = app.state_context().unwrap();
assert_eq!(context.diagram, plan.diagram);
assert_eq!(behavior::selected_owner(&app), Some(f.machine));
let before = app.document.snapshot();
let placement = node(&app, context.diagram, f.a);
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
event(&mut app, Event::Select(context, f.a));
assert_eq!(app.selected, f.a);
assert_eq!(
app.state_context(),
Some(context),
"Pinning the already-visible fallback owner must not advance the gesture epoch"
);
assert_eq!(app.behavior.diagram.placement, Some(placement));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let destination = Position {
x: original.x + 37.,
y: original.y + 29.,
};
event(
&mut app,
Event::Move {
context,
node: placement,
position: destination,
},
);
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&placement].position,
destination
);
assert_eq!(app.selected, f.a);
assert_eq!(
app.document.revision(),
1,
"The first release must commit without another press"
);
assert_source_model_agree(&app);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn same_size_viewport_after_revision_or_tool_epoch_does_not_refit_the_camera() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let size = iced::Size::new(960., 640.);
event(app, Event::Viewport(context, size));
assert!(app.behavior.diagram.fit);
let camera = (app.behavior.diagram.pan, app.behavior.diagram.zoom);
let placement = node(app, context.diagram, f.a);
// Move far enough to change projected bounds. Refitting now would visibly
// jump the camera even though the canvas has not changed size.
event(
app,
Event::Move {
context,
node: placement,
position: Position { x: 4000., y: 3000. },
},
);
let changed = app.state_context().unwrap();
assert_ne!(changed.revision, context.revision);
assert_eq!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera
);
assert!(app.behavior.diagram.fit);
let after_move = app.document.snapshot();
event(app, Event::Viewport(changed, size));
assert_eq!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera,
"Context initialization is not a canvas resize"
);
event(app, Event::Choose(Tool::Transition));
let changed_tool = app.state_context().unwrap();
assert_ne!(changed_tool.epoch, changed.epoch);
event(app, Event::Viewport(changed_tool, size));
assert_eq!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera
);
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
// A real resize still uses automatic fitting while fit mode is active.
event(
app,
Event::Viewport(changed_tool, iced::Size::new(1200., 720.)),
);
assert_ne!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera
);
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
assert_source_model_agree(app);
}
#[test]
fn shared_semantic_states_keep_architecture_and_behavior_placements_independent() {
let f = fixture();
let mut project = f.app.project().clone();
let architecture = project.ensure_diagram(Layer::Sa);
let arch_a = project.place(architecture, f.a, Position { x: 110., y: 90. });
let arch_b = project.place(architecture, f.b, Position { x: 780., y: 240. });
project.place(architecture, f.leaf, Position { x: 190., y: 310. });
let state_view = state_edit::create_view(&project, f.machine).unwrap();
let project = prepare_command(&project, &state_view.command)
.unwrap()
.project;
let architecture_before = project.diagrams[&architecture].clone();
let mut app = App::with_project(project);
let architecture_choice = Choice {
id: architecture,
label: app.project().named(architecture).into(),
};
// The initial UUID-ordered view may be a state view, which correctly opens
// Behavior. Enter Architecture explicitly before testing its selection.
send(&mut app, Message::Page(Page::Diagram));
send(&mut app, Message::Diagram(architecture_choice));
assert_eq!(app.page, Page::Diagram);
send(&mut app, Message::Select(f.b));
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected_node, Some(arch_b));
send(&mut app, Message::Page(Page::Behavior));
let context = app.state_context().unwrap();
assert_eq!(context.diagram, state_view.diagram);
let state_a = node(&app, context.diagram, f.a);
assert_ne!(state_a, arch_a);
event(&mut app, Event::Select(context, f.a));
assert_eq!(app.selected, f.a);
assert!(
app.selected_node.is_none(),
"Behavior must not borrow the architecture placement"
);
assert_eq!(app.behavior.diagram.placement, Some(state_a));
assert_eq!(app.active_diagram, Some(architecture));
let before_move = app.document.snapshot();
let original = app.project().diagrams[&context.diagram].nodes[&state_a].position;
let destination = Position {
x: original.x + 41.,
y: original.y + 63.,
};
event(
&mut app,
Event::Move {
context,
node: state_a,
position: destination,
},
);
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&state_a].position,
destination
);
assert_eq!(app.project().diagrams[&architecture], architecture_before);
assert_eq!(app.selected, f.a);
assert!(app.selected_node.is_none());
assert_eq!(app.behavior.diagram.placement, Some(state_a));
assert_source_model_agree(&app);
let moved = app.document.snapshot();
send(&mut app, Message::Page(Page::Diagram));
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected, f.a);
assert_eq!(
app.selected_node,
Some(arch_a),
"Returning to architecture resolves that view's placement of the same state"
);
assert!(Arc::ptr_eq(&moved, &app.document.snapshot()));
send(&mut app, Message::Page(Page::Behavior));
assert!(app.selected_node.is_none());
assert_eq!(app.behavior.diagram.placement, Some(state_a));
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before_move.decoded.project);
assert_eq!(app.document.bundle(), &before_move.bundle);
assert_eq!(app.project().diagrams[&architecture], architecture_before);
assert_eq!(app.behavior.diagram.placement, Some(state_a));
assert!(!app.document.can_undo());
}