521 lines
19 KiB
Rust
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);
|
|
}
|
|
}
|