Files
ARCADIA-Studio/crates/oa-desktop/src/diagram/allocation_group_tests.rs
T

521 lines
19 KiB
Rust

//! Exact Allocation dispatch through real canvas events and App rebuilds.
use super::*;
use crate::app::{
App,
allocation_drag::{Event as WorkEvent, Mode},
multiselect::{Choice, Event as SelectionEvent, context},
};
use crate::diagram_usage::Target;
use iced::{keyboard, widget::canvas::Program};
#[derive(Clone, Copy, Debug)]
enum Control {
Attachment,
Bend,
}
struct Fixture {
app: App,
diagram: Id,
work: Id,
edge: Id,
cover: Option<Id>,
at: Position,
}
fn xy(x: f32, y: f32) -> Position {
Position { x, y }
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
}
fn screen(p: Position) -> Point {
Point::new(bounds().x + p.x, bounds().y + p.y)
}
fn fixture(control: Control, cover: Option<Kind>) -> Fixture {
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "Send", None)
.unwrap();
let b = project
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
.unwrap();
let edge = project.relate(RelationKind::Interaction, "Report", a, b);
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
let work = project.place(diagram, a, xy(100., 150.));
project.place(diagram, b, xy(700., 150.));
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![xy(450., 450.)],
label: None,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.6,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.5,
}),
});
let view = projection::project_view(&project, diagram);
let routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view);
let at = match control {
Control::Attachment => {
let at = *routed.edges[&edge].points.first().unwrap();
// Inside the handle radius but outside its owner's right boundary,
// so the covered-card discriminator does not depend on node order.
xy(at.x + 4., at.y)
}
Control::Bend => routed.edges[&edge].pins[0],
};
let cover = cover.map(|kind| {
let element = project
.add_element(kind, Layer::Oa, "Underlying card", None)
.unwrap();
project.place(diagram, element, xy(at.x - 40., at.y - 35.))
});
validate_candidate(&project).unwrap();
let mut app = App::with_project(project);
app.active_diagram = Some(diagram);
app.zoom = 1.;
app.pan = Vector::ZERO;
let _ = app.update(Message::Zoom(1.));
let _ = app.update(Message::Select(edge));
app.reconcile_multiselect();
Fixture {
app,
diagram,
work,
edge,
cover,
at,
}
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: app.diagram_selection.as_ref(),
allocation: Some(app.allocation_capability()),
project: app.project(),
view_index: Some(app.document.view_index()),
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
.map(|(_, bend)| bend),
selected_attachment: None,
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
}
}
fn input(app: &App, state: &mut State, event: canvas::Event, at: Point) -> Option<Message> {
canvas(app)
.update(state, &event, bounds(), mouse::Cursor::Available(at))
.and_then(|action| action.into_inner().0)
}
fn pointer(app: &App, state: &mut State, event: mouse::Event, at: Point) -> Option<Message> {
input(app, state, canvas::Event::Mouse(event), at)
}
fn mixed(f: &mut Fixture) {
for (target, choice) in [
(Target::Placement(f.work), Choice::Replace),
(Target::Edge(f.edge), Choice::Add),
] {
let current = context(f.app.route_context().unwrap());
let _ = f
.app
.update(Message::DiagramSelection(SelectionEvent::Choose(
current, target, choice,
)));
}
assert_eq!(f.app.multiselect_snapshot().unwrap().targets().len(), 2);
assert_eq!(
f.app.selected, f.edge,
"selected edge exposes its real handles"
);
}
fn assert_control(f: &Fixture, control: Control) {
let c = canvas(&f.app);
let scene = c.scene(&State::default());
let edge = &scene.routed.edges[&f.edge];
assert!(!edge.custom_suspended);
match control {
Control::Attachment => assert!(c.attachment_at(&scene, point(f.at)).is_some()),
Control::Bend => assert!(edge.pins.contains(&f.at)),
}
let local = point(f.at);
if let Some(cover) = f.cover {
assert_eq!(
c.hit(&scene.view, local),
Some(cover),
"fixture actually places a card under the visible control"
);
} else if matches!(control, Control::Bend) {
assert!(
c.hit(&scene.view, local).is_none(),
"isolated bend has no underlying card"
);
}
assert_eq!(
c.allocation_press_target(&scene, local),
Some(Target::Edge(f.edge))
);
}
#[test]
fn allocation_handles_keep_exact_edge_through_shift_and_pending_group_dispatch() {
for control in [Control::Attachment, Control::Bend] {
for cover in [None, Some(Kind::Activity), Some(Kind::Entity)] {
let mut f = fixture(control, cover);
mixed(&mut f);
assert_control(&f, control);
let from = screen(f.at);
let mut state = State::default();
let _ = input(
&f.app,
&mut state,
canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::SHIFT,
)),
from,
);
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
)
.unwrap();
assert!(
matches!(&message, Message::DiagramSelection(SelectionEvent::Choose(_, Target::Edge(id), Choice::Toggle)) if *id == f.edge)
);
let _ = f.app.update(message);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.work)]
);
assert!(state.work.is_none() && state.group.is_none() && state.route_drag.is_none());
}
}
}
#[test]
fn allocation_handle_click_collapses_but_out_and_back_refuses_after_app_rebuilds() {
for control in [Control::Attachment, Control::Bend] {
for cover in [None, Some(Kind::Activity), Some(Kind::Entity)] {
for crossed in [false, true] {
let mut f = fixture(control, cover);
mixed(&mut f);
assert_control(&f, control);
let accepted = f.app.project().clone();
let source = f.app.document.bundle().clone();
let revision = f.app.document.revision();
let selection = f.app.multiselect_snapshot().unwrap();
let from = screen(f.at);
let mut state = State::default();
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
)
.unwrap();
let Message::WorkDrag(WorkEvent::Press(press)) = &message else {
panic!("exact mixed-set Allocation press")
};
assert_eq!(press.target, Target::Edge(f.edge));
assert_eq!(press.selection, selection);
assert_eq!(press.mode, Mode::Assign);
let _ = f.app.update(message);
assert_eq!(
f.app.multiselect_snapshot().unwrap(),
selection,
"press retains primary and membership"
);
if crossed {
for at in [from + Vector::new(4., 0.), from] {
let message = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: at },
at,
)
.unwrap();
assert!(matches!(
&message,
Message::WorkDrag(WorkEvent::Preview(..))
));
let _ = f.app.update(message);
}
}
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
from,
)
.unwrap();
assert!(matches!(
&message,
Message::WorkDrag(WorkEvent::Release(..))
));
let _ = f.app.update(message);
if crossed {
assert_eq!(
f.app.multiselect_snapshot().unwrap(),
selection,
"crossed return cannot collapse or move an underlying card"
);
} else {
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Edge(f.edge)]
);
}
assert_eq!(f.app.project(), &accepted);
assert_eq!(f.app.document.bundle(), &source);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
assert!(!f.app.document.can_redo());
assert!(
state.work.is_none() && state.group.is_none() && state.route_drag.is_none()
);
}
}
}
}
#[test]
fn armed_isolated_bend_is_owned_but_unarmed_controls_still_edit_routes() {
for control in [Control::Attachment, Control::Bend] {
for armed in [false, true] {
let mut f = fixture(control, None);
assert_control(&f, control);
if armed {
let _ = f.app.update(Message::WorkDrag(WorkEvent::ArrangeNext));
}
let from = screen(f.at);
let mut state = State::default();
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
);
if armed {
let Some(Message::WorkDrag(WorkEvent::Press(press))) = message else {
panic!("armed handle requires Allocation ownership")
};
assert_eq!(press.target, Target::Edge(f.edge));
assert_eq!(press.mode, Mode::Arrange);
assert!(state.work.is_some());
assert!(state.route_drag.is_none());
} else {
assert!(state.work.is_none());
let gesture = state
.route_drag
.as_ref()
.expect("ordinary selected handle remains a route editor");
match control {
Control::Attachment => {
assert!(matches!(gesture.handle, Handle::Attachment { .. }))
}
Control::Bend => assert!(matches!(gesture.handle, Handle::Bend(0))),
}
assert!(!matches!(message, Some(Message::WorkDrag(_))));
}
}
}
}
#[test]
fn ordinary_performer_body_uses_one_token_and_retains_existing_movement_policy() {
let mut f = fixture(Control::Bend, None);
let mut project = f.app.project().clone();
let performer = project
.add_element(Kind::Entity, Layer::Oa, "Mover", None)
.unwrap();
let node = project.place(f.diagram, performer, xy(1200., 800.));
f.app = App::with_project(project);
f.app.active_diagram = Some(f.diagram);
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
let _ = f.app.update(Message::Zoom(1.));
f.app.reconcile_multiselect();
let accepted = f.app.project().clone();
let source = f.app.document.bundle().clone();
let from = screen(xy(1240., 835.));
let to = from + Vector::new(30., 20.);
let mut state = State::default();
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
)
.unwrap();
assert!(
matches!(&message, Message::WorkDrag(WorkEvent::Press(p)) if p.target == Target::Placement(node) && p.mode == Mode::Assign)
);
let _ = f.app.update(message);
assert_eq!(
f.app.selected, performer,
"clean unselected press selects immediately"
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(node)]
);
for event in [
mouse::Event::CursorMoved { position: to },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
let message = pointer(&f.app, &mut state, event, to).unwrap();
assert!(matches!(
&message,
Message::WorkDrag(WorkEvent::Preview(..) | WorkEvent::Release(..))
));
let _ = f.app.update(message);
}
let mut expected = accepted;
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position = xy(1230., 820.);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.revision(), 1);
let after = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.document.bundle(), &source);
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.document.bundle(), &after);
}
#[test]
fn dirty_selected_pin_outer_ring_is_shielded_without_starting_pan() {
for zoom in [0.5, 1., 2.] {
let mut f = fixture(Control::Bend, None);
let mut project = f.app.project().clone();
let route = &mut project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
route.source_attachment.as_mut().unwrap().offset = 0.5;
route.target_attachment.as_mut().unwrap().offset = 0.5;
let view = projection::project_view(&project, f.diagram);
let routed = routing::route_diagram(&project, &project.diagrams[&f.diagram], &view);
let y = routed.edges[&f.edge].points.first().unwrap().y;
assert_eq!(routed.edges[&f.edge].points.last().unwrap().y, y);
let pin = xy(450., y);
project.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints = vec![pin];
f.app = App::with_project(project);
f.app.active_diagram = Some(f.diagram);
f.app.zoom = zoom;
f.app.pan = Vector::ZERO;
let _ = f.app.update(Message::Zoom(1.));
let _ = f.app.update(Message::Select(f.edge));
let _ = f.app.update(Message::Field(
"/name".into(),
"Keep this unfinished relation name".into(),
));
assert!(f.app.draft_dirty());
let c = canvas(&f.app);
assert!(!c.editable);
let scene = c.scene(&State::default());
assert!(!scene.routed.edges[&f.edge].custom_suspended);
assert_eq!(scene.routed.edges[&f.edge].pins, vec![pin]);
let local = Point::new(pin.x * zoom, pin.y * zoom + 7.5);
let at = Point::new(bounds().x + local.x, bounds().y + local.y);
assert!(c.hit(&scene.view, local).is_none());
assert!(c.hit_port(&scene.view, local).is_none());
assert!(
c.hit_edge(&scene.routed, local).is_none(),
"outer ring lies beyond the ordinary 7px edge hit"
);
assert!(c.hit_label(&scene.routed, local).is_none());
assert!(c.hit_association_label(&scene.routed, local).is_none());
assert!(
c.work_dirty_point(bounds(), at),
"the root shield must cover the visible 8px pin handle while editable=false"
);
let project = f.app.project().clone();
let source = f.app.document.bundle().clone();
let selection = f.app.multiselect_snapshot().unwrap();
let draft = f.app.draft.as_ref().unwrap().value.clone();
let revision = f.app.document.revision();
let pan = f.app.pan;
let mut state = State::default();
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: at },
at,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
)
.unwrap();
assert!(matches!(
&message,
Message::WorkDrag(WorkEvent::Refused(..))
));
let _ = f.app.update(message);
assert!(state.pan_start.is_none() && state.route_drag.is_none() && state.work.is_none());
let moved = at + Vector::new(40., 20.);
for event in [
mouse::Event::CursorMoved { position: moved },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
let message = pointer(&f.app, &mut state, event, moved);
assert!(
message.is_none(),
"refused handle press must not become background pan or a selector"
);
}
assert_eq!(f.app.project(), &project);
assert_eq!(f.app.document.bundle(), &source);
assert_eq!(f.app.document.revision(), revision);
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert_eq!(f.app.draft.as_ref().unwrap().value, draft);
assert_eq!(f.app.pan, pan);
}
}