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

603 lines
22 KiB
Rust

//! Saved-view group operations through the real App message boundary.
use super::multiselect::{Choice as GroupChoice, Event as GroupEvent};
use super::*;
use crate::diagram_usage::Target;
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
struct Fixture {
app: App,
diagram: Id,
a: Id,
b: Id,
child: Id,
edge: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let child = p
.add_element(Kind::Function, Layer::Sa, "Prepare", Some(a))
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Signal", a, b);
let diagram = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
let a = p.place(diagram, a, point(10., 20.));
let b = p.place(diagram, b, point(500., 20.));
let child = p.place(diagram, child, point(40., 100.));
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.3,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.7,
}),
waypoints: vec![point(370., 150.)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: point(5., -9.),
}),
});
let other = p
.add_element(Kind::Activity, Layer::Oa, "Operational work", None)
.unwrap();
let other_diagram = p.ensure_diagram(Layer::Oa);
p.place(other_diagram, other, point(-50., -100.));
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Sa;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.selected_node = None;
app.refresh_draft();
app.reconcile_multiselect();
Fixture {
app,
diagram,
a,
b,
child,
edge,
}
}
fn dispatch(app: &mut App, event: GroupEvent) {
let _ = app.update(Message::DiagramSelection(event));
}
fn choose(app: &mut App, target: Target, choice: GroupChoice) {
let context = app.multiselect_context().unwrap();
dispatch(app, GroupEvent::Choose(context, target, choice));
}
fn select_pair(f: &mut Fixture) {
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a), Target::Placement(f.b)]
);
}
#[test]
fn queued_singleton_move_or_delete_cannot_partially_edit_a_new_group() {
for model in [false, true] {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let selected = f.app.multiselect_snapshot().unwrap();
let object = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
for message in [
Message::MoveNode(f.b, point(700., 600.)),
Message::DiagramRemove(super::diagram_removal::Intent {
context: f.app.route_context().unwrap(),
object,
placement: Some(f.b),
model,
}),
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(),
selected.targets()
);
assert!(!f.app.delete_pending);
assert!(!f.app.document.can_undo());
}
}
}
#[test]
fn legacy_hide_action_applies_to_the_complete_visible_group() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
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);
let _ = f.app.update(Message::HideSelected);
assert_eq!(f.app.project(), &expected);
assert_source_matches_model(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
fn assert_source_matches_model(app: &App) {
let compiled = oa_core::source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
assert_eq!(&compiled.decoded.project, app.project());
assert_eq!(&compiled.bundle, app.document.bundle());
}
#[test]
fn toggled_group_moves_model_and_source_once_and_keeps_primary_through_history() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let snapshot = f.app.multiselect_snapshot().unwrap();
assert_eq!(snapshot.primary(), Some(&Target::Placement(f.b)));
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(50., 80.);
view.nodes.get_mut(&f.b).unwrap().position = point(540., 80.);
view.nodes.get_mut(&f.child).unwrap().position = point(80., 160.);
view.routes[0].waypoints = vec![point(410., 210.)];
dispatch(
&mut f.app,
GroupEvent::Move(snapshot.clone(), point(40., 60.)),
);
assert_eq!(f.app.project(), &expected);
assert_source_matches_model(&f.app);
assert_eq!(f.app.selected_node, Some(f.b));
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
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());
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());
assert_eq!(f.app.selected_node, Some(f.b));
}
#[test]
fn ancestor_and_child_selection_moves_child_exactly_once() {
let mut f = fixture();
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
choose(&mut f.app, Target::Placement(f.child), GroupChoice::Toggle);
let snapshot = f.app.multiselect_snapshot().unwrap();
assert_eq!(snapshot.targets().len(), 2);
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(0., 45.);
view.nodes.get_mut(&f.child).unwrap().position = point(30., 125.);
dispatch(&mut f.app, GroupEvent::Move(snapshot, point(-10., 25.)));
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.selected_node, Some(f.child));
assert_source_matches_model(&f.app);
}
#[test]
fn mixed_edge_or_port_selection_refuses_the_entire_move() {
for include_port in [false, true] {
let mut f = fixture();
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
let extra = if include_port {
let view = projection::project_view(f.app.project(), f.diagram);
let port = view
.ports
.iter()
.find(|p| p.owner == f.a)
.expect("functional exchange exposes its typed owner port");
Target::Port {
port: port.element,
placement: port.owner,
}
} else {
Target::Edge(f.edge)
};
choose(&mut f.app, extra.clone(), GroupChoice::Toggle);
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.targets(), &[Target::Placement(f.a), extra]);
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
dispatch(
&mut f.app,
GroupEvent::Move(selection.clone(), point(40., 60.)),
);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert!(f.app.status.contains("Move requires only placed elements"));
}
}
#[test]
fn stale_operation_envelopes_have_no_selection_history_status_or_allocation_side_effects() {
// Isolate each lifetime component, including the same visible set recreated
// by a remove/re-add cycle. Accepted setup changes are outside the assertion.
for stale_by in ["epoch", "revision", "document", "selection generation"] {
for action in ["move", "hide", "refuse", "collapse"] {
let mut f = fixture();
select_pair(&mut f);
let stale = f.app.multiselect_snapshot().unwrap();
match stale_by {
"epoch" => f.app.canvas_epoch += 1,
"revision" => {
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.a].element;
// Advance only the document revision, retaining this canvas
// and selection epoch to independently test revision checking.
f.app
.document
.execute(Command::Describe(semantic, "New revision".into()))
.unwrap();
}
"document" => f.app.canvas_document = Id::new_v4(),
_ => {
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
stale.targets()
);
}
}
f.app.status = "Current feedback must survive".into();
f.app.allocation_drag.arrange_next = true;
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot();
let legacy = (f.app.selected, f.app.selected_node);
let revision = f.app.document.revision();
let epochs = (
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch,
);
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());
let event = match action {
"move" => GroupEvent::Move(stale, point(99., 100.)),
"hide" => GroupEvent::Hide(stale),
"collapse" => GroupEvent::Collapse(stale, Target::Placement(f.a)),
_ => GroupEvent::Refuse(stale, "Obsolete refusal".into()),
};
dispatch(&mut f.app, event);
assert_eq!(
f.app.project(),
&before.decoded.project,
"{stale_by}/{action}"
);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert_eq!(f.app.multiselect_snapshot(), selection);
assert_eq!((f.app.selected, f.app.selected_node), legacy);
assert_eq!(
(
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch
),
epochs
);
assert_eq!(
(f.app.document.can_undo(), f.app.document.can_redo()),
history
);
assert_eq!(f.app.status, "Current feedback must survive");
assert!(f.app.allocation_drag.arrange_next);
assert_eq!(
f.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
}
}
}
#[test]
fn pending_valid_and_invalid_property_drafts_refuse_group_edits_without_applying_them() {
for (path, value) in [("/name", "Unapplied name"), ("/id", "-")] {
for hide in [false, true] {
let mut f = fixture();
select_pair(&mut f);
let _ = f.app.update(Message::Field(path.into(), value.into()));
assert!(f.app.draft_dirty());
let snapshot = f.app.multiselect_snapshot().unwrap();
let before = f.app.document.snapshot();
let draft =
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let event = if hide {
GroupEvent::Hide(snapshot.clone())
} else {
GroupEvent::Move(snapshot.clone(), point(20., 30.))
};
dispatch(&mut f.app, event);
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.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
assert!(f.app.status.contains("Finish or cancel"));
}
}
}
#[test]
fn hiding_a_group_removes_only_exact_placements_and_roundtrips_in_one_history_step() {
let mut f = fixture();
select_pair(&mut f);
let snapshot = f.app.multiselect_snapshot().unwrap();
let before = f.app.document.snapshot();
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);
dispatch(&mut f.app, GroupEvent::Hide(snapshot));
assert_eq!(
f.app.project(),
&expected,
"engineering objects, descendant placement, authored route and opposite-layer view survive"
);
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
assert_source_matches_model(&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());
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());
}
fn assert_surviving_primary(f: &Fixture) {
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.targets(), &[Target::Placement(f.a)]);
assert_eq!(selection.primary(), Some(&Target::Placement(f.a)));
assert_eq!(f.app.selected_node, Some(f.a));
assert_eq!(
f.app.selected,
f.app.project().diagrams[&f.diagram].nodes[&f.a].element
);
}
#[test]
fn removing_primary_placement_from_model_preserves_exact_survivor_after_undo() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let _ = f.app.update(Message::Command(Command::Delete(f.b)));
assert!(
!f.app.project().diagrams[&f.diagram]
.nodes
.contains_key(&f.b)
);
assert_surviving_primary(&f);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_surviving_primary(&f);
let _ = f.app.update(Message::Redo);
assert_surviving_primary(&f);
}
#[test]
fn deleting_primary_model_object_then_undo_does_not_restore_removed_selection_membership() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
let _ = f.app.update(Message::Command(Command::Delete(semantic)));
assert!(!f.app.project().elements.contains_key(&semantic));
assert_surviving_primary(&f);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_surviving_primary(&f);
let _ = f.app.update(Message::Redo);
assert_surviving_primary(&f);
}
#[test]
fn replacing_primary_occurrence_with_same_semantic_placement_does_not_retarget_selection() {
let mut f = fixture();
select_pair(&mut f);
let mut candidate = f.app.project().clone();
let view = candidate.diagrams.get_mut(&f.diagram).unwrap();
let mut replacement = view.nodes.remove(&f.b).unwrap();
let replacement_id = Id::new_v4();
replacement.id = replacement_id;
replacement.alias = "replacement_receive".into();
replacement.position = point(800., 300.);
view.nodes.insert(replacement_id, replacement);
// Replace preserves the semantic inspector identity. Its normal sync used
// to rediscover the new occurrence through that identity and steal the set.
let _ = f
.app
.update(Message::Command(Command::Replace(candidate.clone())));
assert_eq!(f.app.project(), &candidate);
assert_surviving_primary(&f);
assert!(
!f.app
.multiselect_snapshot()
.unwrap()
.targets()
.contains(&Target::Placement(replacement_id))
);
assert_source_matches_model(&f.app);
let _ = f.app.update(Message::Undo);
assert!(
f.app.project().diagrams[&f.diagram]
.nodes
.contains_key(&f.b)
);
assert_surviving_primary(&f);
}
#[test]
fn explicit_surface_navigation_and_return_never_resurrect_the_group() {
for destination in [Page::Catalog, Page::Behavior] {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let stale = f.app.multiselect_snapshot().unwrap();
let _ = f.app.update(Message::Page(destination));
assert!(f.app.multiselect_snapshot().is_none());
let _ = f.app.update(Message::Page(Page::Diagram));
let live = f.app.multiselect_snapshot().unwrap();
assert!(
live.targets().len() <= 1,
"navigation may inspect one exact member but must not restore the group"
);
dispatch(&mut f.app, GroupEvent::Move(stale, point(400., 500.)));
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
}
fn open_placement_menu(f: &mut Fixture, placement: Id) {
let semantic = f.app.project().diagrams[&f.diagram].nodes[&placement].element;
let mut request = crate::model_menu::Request::object(semantic, iced::Point::new(100., 100.));
request.placement = Some(placement);
request.canvas = f.app.route_context();
let _ = f
.app
.update(Message::Menu(crate::model_menu::Event::Open(request)));
assert!(f.app.model_menu.is_some());
}
fn menu_action(app: &App, caption: &str) -> Message {
crate::model_menu::popup(app)
.unwrap()
.entries
.into_iter()
.find_map(|entry| match entry {
crate::context_menu::Entry::Action { label, message, .. } if label == caption => {
message
}
_ => None,
})
.unwrap_or_else(|| panic!("enabled menu action {caption:?} must exist"))
}
#[test]
fn context_menu_dismissal_preserves_group_and_add_keeps_pre_menu_members() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let selected = f.app.multiselect_snapshot().unwrap();
let child = f.child;
open_placement_menu(&mut f, child);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Close));
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
open_placement_menu(&mut f, child);
let add = menu_action(&f.app, "Add to selection");
let _ = f.app.update(add);
let selected = f.app.multiselect_snapshot().unwrap();
assert_eq!(
selected.targets(),
&[
Target::Placement(f.a),
Target::Placement(f.b),
Target::Placement(child)
]
);
assert_eq!(selected.primary(), Some(&Target::Placement(child)));
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
#[test]
fn selected_member_context_menu_exposes_and_executes_group_hide_not_singleton_deletion() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let member = f.a;
open_placement_menu(&mut f, member);
let popup = crate::model_menu::popup(&f.app).unwrap();
for entry in &popup.entries {
if let crate::context_menu::Entry::Action {
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, action))),
..
} = entry
{
assert!(
!matches!(
action.as_ref(),
Message::HideSelected | Message::Delete | Message::DiagramRemove(_)
),
"a selected-member group menu must not expose singleton removal"
);
}
}
let hide = menu_action(&f.app, "Hide selected placements");
let _ = f.app.update(hide);
let mut expected = before.decoded.project.clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.remove(&f.a);
view.nodes.remove(&f.b);
assert_eq!(f.app.project(), &expected);
assert!(!f.app.delete_pending);
assert!(f.app.model_menu.is_none());
assert_source_matches_model(&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());
}