//! 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, 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) -> 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 { 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 { 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); } }