1134 lines
39 KiB
Rust
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());
|
|
}
|