// Included in state_canvas::tests: exercise the real canvas event path. fn bend_fixture(authored: bool) -> (Fixture, Id) { let mut f = fixture(); let relation = f.project.relate( RelationKind::StateTransition, "Standby to stopped", f.b, f.c, ); if authored { f.project .diagrams .get_mut(&f.diagram) .unwrap() .routes .push(EdgeRoute { edge: relation, waypoints: vec![Position { x: 650.25, y: 340.75, }], source_attachment: Some(EdgeAttachment { side: PortAnchorSide::Bottom, offset: 0.5, }), target_attachment: Some(EdgeAttachment { side: PortAnchorSide::Top, offset: 0.25, }), label: Some(EdgeLabel { fraction: 0.43, offset: Position { x: 28., y: -16. }, }), }); } validate_candidate(&f.project).unwrap(); (f, relation) } fn bend_segment(scene: &Scene, relation: Id) -> (usize, Point) { let edge = &scene.routed.edges[&relation]; edge.points .windows(2) .enumerate() .filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len()) .map(|(i, p)| { ( i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.), (p[1].x - p[0].x).abs() + (p[1].y - p[0].y).abs(), ) }) .filter(|(_, p, length)| { *length > 40. && edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.) }) .max_by(|a, b| a.2.total_cmp(&b.2)) .map(|(i, p, _)| (i, p)) .expect("fixture has an exposed interior route segment") } fn bend_press(canvas: &StateCanvas<'_>, state: &mut State, at: Point) { motion(canvas, state, at, at); let event = input( canvas, state, mouse::Event::ButtonPressed(mouse::Button::Left), at, ); assert!( !matches!(event, Some(Event::SetBends { .. })), "press alone cannot commit" ); } #[test] fn state_bends_segment_drag_inserts_one_pin_using_ordered_pointer_at_three_zooms() { for zoom in [0.5, 1., 2.] { let (f, relation) = bend_fixture(false); let mut canvas = canvas(&f, Tool::Select, zoom); canvas.selected = relation; let mut state = State::default(); let baseline = canvas.scene(&state).unwrap(); let (_, midpoint) = bend_segment(&baseline, relation); let start = screen(&canvas, midpoint); let destination = Position { x: ((midpoint.x + 45.) / 5.).round() * 5., y: ((midpoint.y + 30.) / 5.).round() * 5., }; let release = screen(&canvas, point(destination)); let later_batch_cursor = release + Vector::new(137., 83.); motion(&canvas, &mut state, start, later_batch_cursor); input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), later_batch_cursor, ); assert!(f.project.diagrams[&f.diagram].routes.is_empty()); motion(&canvas, &mut state, release, later_batch_cursor); assert_eq!( state.bend_preview.as_ref().unwrap().waypoints, vec![destination] ); assert_eq!( canvas.scene(&state).unwrap().routed.edges[&relation].pins, vec![destination] ); let event = input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), later_batch_cursor, ) .unwrap(); assert!( matches!(&event, Event::SetBends { context, relation: id, waypoints } if *context == canvas.context && *id == relation && *waypoints == vec![destination]) ); assert!(state.bend_preview.is_none()); assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), later_batch_cursor ) .is_none() ); let after = prepare_command( &f.project, &state_edit::plan(&f.project, canvas.context, &event) .unwrap() .command, ) .unwrap() .project; let stored = &after.diagrams[&f.diagram].routes[0]; assert_eq!(stored.waypoints, vec![destination]); assert!( stored.source_attachment.is_none() && stored.target_attachment.is_none() && stored.label.is_none() ); assert_eq!(after.relations, f.project.relations); } } #[test] fn state_bends_authored_pin_move_preserves_full_project_and_exact_undo_redo() { for zoom in [0.5, 1., 2.] { let (mut f, relation) = bend_fixture(true); let mut other = f.project.diagrams[&f.diagram].clone(); other.id = Id::new_v4(); other.alias = "bend_reference_view".into(); other.nodes = other .nodes .into_values() .map(|mut node| { node.id = Id::new_v4(); node.alias = format!("bend_reference_{}", node.alias); (node.id, node) }) .collect(); let other_id = other.id; f.project.diagrams.insert(other_id, other.clone()); validate_candidate(&f.project).unwrap(); let original = f.project.diagrams[&f.diagram].routes[0].clone(); let mut canvas = canvas(&f, Tool::Select, zoom); canvas.selected = relation; let mut state = State::default(); let start = screen(&canvas, point(original.waypoints[0])); let destination = Position { x: 690., y: 365. }; let release = screen(&canvas, point(destination)); bend_press(&canvas, &mut state, start); motion(&canvas, &mut state, release, release); let preview = state.bend_preview.as_ref().unwrap(); let mut intended = original.clone(); intended.waypoints[0] = destination; assert_eq!(preview, &intended); assert_eq!( f.project.diagrams[&f.diagram].routes[0], original, "preview is transient" ); let preview_scene = canvas.scene(&state).unwrap(); let event = input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), release, ) .unwrap(); assert!( matches!(&event, Event::SetBends { waypoints, .. } if waypoints == &intended.waypoints) ); let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap(); let mut expected = f.project.clone(); expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = intended; let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap(); let before = document.bundle().clone(); document.execute(plan.command).unwrap(); assert_eq!(document.project(), &expected); assert_eq!(document.project().diagrams[&other_id], other); let committed_view = projection::project_view(document.project(), f.diagram); let committed = routing::route_diagram( document.project(), &document.project().diagrams[&f.diagram], &committed_view, ); let preview_edge = &preview_scene.routed.edges[&relation]; let committed_edge = &committed.edges[&relation]; assert_eq!(preview_edge.points, committed_edge.points); assert_eq!(preview_edge.pin_fractions, committed_edge.pin_fractions); assert_eq!(preview_edge.warnings, committed_edge.warnings); let edited = document.bundle().clone(); assert!(document.undo()); assert_eq!(document.project(), &f.project); assert_eq!(document.bundle(), &before); assert!(!document.undo(), "one drag adds one history operation"); assert!(document.redo()); assert_eq!(document.project(), &expected); assert_eq!(document.bundle(), &edited); assert!(!document.redo()); } } #[test] fn state_bends_stationary_and_out_and_back_restore_off_grid_or_automatic_route() { for authored in [false, true] { for zoom in [0.5, 1., 2.] { for travel in [false, true] { let (f, relation) = bend_fixture(authored); let mut canvas = canvas(&f, Tool::Select, zoom); canvas.selected = relation; let mut state = State::default(); let scene = canvas.scene(&state).unwrap(); let world = if authored { point(scene.routed.edges[&relation].pins[0]) } else { bend_segment(&scene, relation).1 }; let start = screen(&canvas, world); bend_press(&canvas, &mut state, start); if travel { let away = start + Vector::new(45., 30.) * zoom; motion(&canvas, &mut state, away, away); assert!(state.bend_preview.is_some()); motion(&canvas, &mut state, start, start); } assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start ) .is_none() ); assert!(state.bend_preview.is_none()); assert_eq!( canvas.scene(&state).unwrap().routed.edges[&relation].points, scene.routed.edges[&relation].points ); assert_eq!( f.project.diagrams[&f.diagram].routes.len(), usize::from(authored) ); if authored { assert_eq!( f.project.diagrams[&f.diagram].routes[0].waypoints, vec![Position { x: 650.25, y: 340.75 }] ); } } } } } #[test] fn state_bends_cancel_on_camera_context_focus_and_pointer_interruptions() { for cancellation in 0..16 { let (f, relation) = bend_fixture(true); let mut canvas = canvas(&f, Tool::Select, 1.); canvas.selected = relation; let mut state = State::default(); let original = f.project.diagrams[&f.diagram].routes[0].clone(); let start = screen(&canvas, point(original.waypoints[0])); let release = start + Vector::new(45., 30.); bend_press(&canvas, &mut state, start); motion(&canvas, &mut state, release, release); assert!(state.bend_preview.is_some(), "case {cancellation}"); let mut release_bounds = bounds(); match cancellation { 0 => canvas.zoom = 2., 1 => canvas.pan.x += 10., 2 => canvas.context.epoch += 1, 3 => canvas.context.revision += 1, 4 => canvas.context.document = Id::new_v4(), 5 => canvas.tool = Tool::Transition, 6 => canvas.editable = false, 7 | 8 => { input( &canvas, &mut state, mouse::Event::ButtonPressed(if cancellation == 7 { mouse::Button::Right } else { mouse::Button::Middle }), release, ); } 9 => { canvas::Program::update( &canvas, &mut state, &canvas::Event::Window(iced::window::Event::Unfocused), bounds(), mouse::Cursor::Available(release), ); } 10 => { input( &canvas, &mut state, mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { x: 0., y: 1. }, }, release, ); } 11 => { input(&canvas, &mut state, mouse::Event::CursorLeft, release); } 12 => { motion(&canvas, &mut state, Point::new(-100., -100.), release); } 13 => release_bounds.x += 25., 15 => canvas.selected = f.b, _ => { let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Escape), modified_key: keyboard::Key::Named(keyboard::key::Named::Escape), physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::empty(), text: None, repeat: false, }); let event = canvas::Program::update( &canvas, &mut state, &escape, bounds(), mouse::Cursor::Available(release), ) .and_then(|action| action.into_inner().0); assert!(matches!(event, Some(Event::Choose(Tool::Select)))); } } let event = input_at_bounds( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), release, release_bounds, ); assert!( !matches!(event, Some(Event::SetBends { .. })), "late release committed case {cancellation}" ); assert!( state.bend_preview.is_none(), "stale preview survived case {cancellation}" ); assert_eq!(f.project.diagrams[&f.diagram].routes[0], original); } } #[test] fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() { let (f, relation) = bend_fixture(true); let mut canvas = canvas(&f, Tool::Select, 1.); canvas.selected = relation; let mut state = State::default(); let scene = canvas.scene(&state).unwrap(); let pin = screen(&canvas, point(scene.routed.edges[&relation].pins[0])); motion(&canvas, &mut state, pin, pin); assert!( matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin), Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None, .. }) if context == canvas.context && object == relation && anchor == pin) ); let (index, midpoint) = bend_segment(&scene, relation); let at = screen(&canvas, midpoint); motion(&canvas, &mut state, at, at); assert!( matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at), Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)), .. }) if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001) ); } #[test] fn state_bends_internal_rows_and_folded_proxies_cannot_acquire_pins() { for internal in [false, true] { let mut f = fixture(); let relation = if internal { let id = f .project .relate(RelationKind::StateTransition, "Internal check", f.a, f.a); if let RelationBody::StateTransition { transition_kind, .. } = &mut f.project.relations.get_mut(&id).unwrap().body { *transition_kind = TransitionKind::Internal; } id } else { let id = f.project.relate( RelationKind::StateTransition, "Hidden child to standby", f.leaf, f.b, ); let parent = placement(&f, f.a); f.project .diagrams .get_mut(&f.diagram) .unwrap() .nodes .get_mut(&parent) .unwrap() .collapsed = true; id }; let mut canvas = canvas(&f, Tool::Select, 1.); canvas.selected = relation; let mut state = State::default(); let scene = canvas.scene(&state).unwrap(); let world = if internal { assert!(!scene.routed.edges.contains_key(&relation)); let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap(); internal_rows(&f.project, node) .into_iter() .find(|(r, _)| r.id == relation) .unwrap() .1 .center() } else { assert!(scene.routed.edges[&relation].custom_suspended); bend_segment(&scene, relation).1 }; let start = screen(&canvas, world); motion(&canvas, &mut state, start, start); assert!(matches!( input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), start ), Some(Event::ContextMenu { bend: None, route_segment: None, .. }) )); bend_press(&canvas, &mut state, start); let away = start + Vector::new(30., 20.); motion(&canvas, &mut state, away, away); assert!(state.bend_preview.is_none()); assert!(!matches!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), away ), Some(Event::SetBends { .. }) )); assert!(f.project.diagrams[&f.diagram].routes.is_empty()); } } #[test] fn state_bends_right_click_discards_provisional_indexes_before_building_menu() { for click_pin in [true, false] { let (mut f, relation) = bend_fixture(true); let route = &mut f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0]; route.waypoints = vec![Position { x: 650., y: 340. }, Position { x: 650., y: 390. }]; validate_candidate(&f.project).unwrap(); let original = f.project.diagrams[&f.diagram].routes[0].clone(); let mut canvas = canvas(&f, Tool::Select, 1.); canvas.selected = relation; let mut state = State::default(); let baseline = canvas.scene(&state).unwrap(); let edge = &baseline.routed.edges[&relation]; let candidates: Vec<_> = edge.points.windows(2).enumerate() .filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len()) .map(|(i, p)| (i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.))) .filter(|(_, p)| edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.)) .filter(|(_, p)| matches!(canvas.hit(&baseline, *p), Some(Hit::Transition(id)) if id == relation)) .filter(|(_, p)| !Rectangle::new(point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height)).contains(*p)) .collect(); let (_, insertion_at) = candidates .iter() .copied() .find(|(i, p)| routing::pin_insertion_index(edge, *i, position(*p)) == 0) .expect("exposed segment before both authored pins"); let menu_segment = candidates .iter() .copied() .find(|(_, p)| p.distance(insertion_at) > 30.) .expect("a distinct committed segment for the menu control"); let start = screen(&canvas, insertion_at); bend_press(&canvas, &mut state, start); let away = start + Vector::new(45., 30.); motion(&canvas, &mut state, away, away); let preview = state.bend_preview.as_ref().unwrap(); assert_eq!(preview.waypoints.len(), 3); assert_eq!( &preview.waypoints[1..], original.waypoints.as_slice(), "provisional insertion shifts both stored indices" ); // With the left gesture still active, moving to the last stored pin also // moves provisional pin0 there. The preview's first hit is therefore // pin0, but a menu built after cancellation must identify stored pin1. let menu_world = if click_pin { point(original.waypoints[1]) } else { menu_segment.1 }; let menu_window = screen(&canvas, menu_world); motion(&canvas, &mut state, menu_window, menu_window); assert!(state.bend_preview.is_some()); let menu = input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), menu_window, ) .unwrap(); let Event::ContextMenu { context, object, bend, route_segment, .. } = menu else { panic!("expected context menu after cancelling drag") }; assert_eq!(context, canvas.context); assert_eq!(object, relation); assert!(state.bend_preview.is_none()); assert_eq!( canvas.scene(&state).unwrap().routed.edges[&relation].points, edge.points ); if click_pin { assert_eq!( bend, Some(1), "menu index must address the stored route, not the provisional route" ); assert!(route_segment.is_none()); let remove = Event::RemoveBend { context, relation, index: bend.unwrap(), }; let plan = state_edit::plan(&f.project, context, &remove).unwrap(); let after = prepare_command(&f.project, &plan.command).unwrap().project; let mut expected = f.project.clone(); expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] .waypoints .remove(1); assert_eq!( after, expected, "remove menu action must delete exactly the addressed original pin" ); } else { assert!( bend.is_none(), "provisional pin under cursor must not leak into menu" ); let (segment, at) = route_segment.expect("committed route segment remains actionable"); assert_eq!(segment, menu_segment.0); assert!(point(at).distance(menu_world) < 0.001); } assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), menu_window ) .is_none() ); assert_eq!(f.project.diagrams[&f.diagram].routes[0], original); } } #[test] fn state_bends_draw_before_update_excludes_preview_after_camera_or_bounds_change() { for change in 0..4 { let (f, relation) = bend_fixture(true); let mut canvas = canvas(&f, Tool::Select, 1.); canvas.selected = relation; let mut state = State::default(); let baseline = canvas.scene_with_bounds(&state, Some(bounds())).unwrap(); let start = screen(&canvas, point(baseline.routed.edges[&relation].pins[0])); let release = start + Vector::new(45., 30.); bend_press(&canvas, &mut state, start); motion(&canvas, &mut state, release, release); let displayed_preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap(); assert_ne!( displayed_preview.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins ); let mut changed_bounds = bounds(); match change { 0 => canvas.zoom = 2., 1 => canvas.pan.y += 25., 2 => changed_bounds.x += 35., _ => changed_bounds.width -= 90., } // No update event between camera/layout change and this draw-time read. let shown = canvas .scene_with_bounds(&state, Some(changed_bounds)) .unwrap(); assert_eq!( shown.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins, "stale draw-time pins for case {change}" ); assert_eq!( shown.routed.edges[&relation].points, baseline.routed.edges[&relation].points, "stale cached preview for case {change}" ); let event = input_at_bounds( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), release, changed_bounds, ); assert!(!matches!(event, Some(Event::SetBends { .. }))); assert!(state.bend_preview.is_none()); } }