use oa_core::{projection::*, routing::*, *}; fn pos(x: f32, y: f32) -> Position { Position { x, y } } fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment { EdgeAttachment { side, offset } } fn fixture() -> (Project, Id, Id, Id, Id) { let mut p = Project::empty(); let a = p .add_element(Kind::Activity, Layer::Oa, "Report", None) .unwrap(); let b = p .add_element(Kind::Activity, Layer::Oa, "Receive", None) .unwrap(); let edge = p.relate(RelationKind::Interaction, "Report status", a, b); let d = p.ensure_diagram(Layer::Oa); p.place(d, a, pos(50., 50.)); p.place(d, b, pos(750., 200.)); (p, d, a, b, edge) } fn stored(edge: Id) -> EdgeRoute { EdgeRoute { edge, source_attachment: None, target_attachment: None, waypoints: vec![], label: None, } } fn routed(p: &Project, d: Id) -> RoutedView { let diagram = &p.diagrams[&d]; route_diagram(p, diagram, &project_diagram(p, diagram)) } fn near(a: f32, b: f32) { assert!((a - b).abs() < 0.002, "{a} != {b}"); } fn orthogonal(points: &[Position]) { assert!(points.len() >= 2); for span in points.windows(2) { assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}"); } } fn length(points: &[Position]) -> f32 { points .windows(2) .map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs()) .sum() } #[test] fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() { let (mut p, d, a, _, edge) = fixture(); let initial = routed(&p, d); let mut route = stored(edge); route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25)); route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75)); route.waypoints = vec![pos(460., 350.)]; p.diagrams.get_mut(&d).unwrap().routes.push(route.clone()); let view = project_diagram(&p, &p.diagrams[&d]); let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge); let full = routed(&p, d); assert_eq!( preview, full, "attachment-only changes must invalidate the label-only path cache" ); let n = view.nodes.iter().find(|n| n.element == a).unwrap(); assert_eq!( full.edges[&edge].points[0], pos( n.position.x + n.size.width * 0.25, n.position.y + n.size.height ) ); assert_eq!(full.edges[&edge].pins, route.waypoints); orthogonal(&full.edges[&edge].points); let node = p .diagrams .get_mut(&d) .unwrap() .nodes .values_mut() .find(|n| n.element == a) .unwrap(); node.position.x += 75.; node.size.width += 100.; node.size.height += 50.; let moved = routed(&p, d); near( moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x, 100., ); near( moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y, 50., ); assert_eq!(p.diagrams[&d].routes[0], route); } #[test] fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() { let (mut p, d, _, _, edge) = fixture(); let baseline = routed(&p, d); let mut route = stored(edge); route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2)); p.diagrams.get_mut(&d).unwrap().routes.push(route); let view = project_diagram(&p, &p.diagrams[&d]); let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge); assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points); assert_eq!(preview, routed(&p, d)); } #[test] fn ports_remain_authoritative_even_when_projection_filters_the_port() { 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 edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b); let d = p.ensure_diagram(Layer::Sa); p.place(d, a, pos(0., 0.)); p.place(d, b, pos(600., 0.)); let diagram = p.diagrams[&d].clone(); let view = project_diagram(&p, &diagram); let expected = route_diagram(&p, &diagram, &view); assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some()); let mut route = stored(edge); route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1)); route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9)); p.diagrams.get_mut(&d).unwrap().routes.push(route); assert_eq!(routed(&p, d), expected); let mut hidden = view.clone(); hidden.ports.clear(); for e in &mut hidden.edges { e.source_port = None; e.target_port = None; } let absent = route_diagram(&p, &diagram, &hidden); assert_eq!( route_diagram(&p, &p.diagrams[&d], &hidden), absent, "hidden ports must not activate a dormant node preference" ); } fn state(kind: Kind) -> (Project, Id, Id, Id) { let mut p = Project::empty(); let owner = p .add_element(Kind::Entity, Layer::Oa, "Crew", None) .unwrap(); let machine = p .add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner)) .unwrap(); let region = p .add_element(Kind::Region, Layer::Oa, "Main", Some(machine)) .unwrap(); let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap(); let b = p .add_element(Kind::State, Layer::Oa, "B", Some(region)) .unwrap(); let edge = p.relate(RelationKind::StateTransition, "Next", a, b); let d = p.ensure_diagram(Layer::Oa); p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine }; p.place(d, a, pos(100., 100.)); p.place(d, b, pos(600., 100.)); (p, d, a, edge) } #[test] fn nonrectangular_state_attachments_intersect_the_painted_shape() { for kind in [ Kind::Initial, Kind::Junction, Kind::Final, Kind::Choice, Kind::ShallowHistory, Kind::DeepHistory, Kind::EntryPoint, Kind::ExitPoint, Kind::Terminate, Kind::Fork, Kind::Join, Kind::State, ] { let (p, d, a, _) = state(kind); let diagram = &p.diagrams[&d]; let view = project_diagram(&p, diagram); let n = view.nodes.iter().find(|n| n.element == a).unwrap(); for side in [ PortAnchorSide::Left, PortAnchorSide::Right, PortAnchorSide::Top, PortAnchorSide::Bottom, ] { for offset in [0., 0.2, 0.5, 0.8, 1.] { let at = attachment_point(&p, diagram, n, &anchor(side, offset)); let x = (at.x - n.position.x) / n.size.width; let y = (at.y - n.position.y) / n.size.height; match kind { Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.), Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.), Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.), Kind::State => { assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y)); if offset == 0. || offset == 1. { assert!( x != 0. && x != 1. || y != 0. && y != 1., "rounded corner cannot use bounding-box corner" ); } } _ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.), } } } } } #[test] fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() { let (mut p, d, _, edge) = state(Kind::Choice); let mut route = stored(edge); route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.)); route.waypoints = vec![pos(380., 40.)]; route.label = Some(EdgeLabel { fraction: 0.3, offset: pos(0., 0.), }); p.diagrams.get_mut(&d).unwrap().routes.push(route); let result = routed(&p, d); let r = &result.edges[&edge]; orthogonal(&r.points); assert!(r.warnings.is_empty(), "{:?}", r.warnings); let pin = r.pins[0]; let pin_index = r.points.iter().position(|p| *p == pin).unwrap(); near( r.pin_fractions[0], length(&r.points[..=pin_index]) / length(&r.points), ); assert_eq!( r.label.anchor, point_along_polyline(&r.points, 0.3).unwrap() ); } #[test] fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() { for kind in [ Kind::Initial, Kind::Choice, Kind::Terminate, Kind::Fork, Kind::State, ] { let (p, d, a, _) = state(kind); let view = project_diagram(&p, &p.diagrams[&d]); let n = view.nodes.iter().find(|n| n.element == a).unwrap(); for zoom in [0.5, 1., 2.] { for overshoot in [0., 20.] { let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot); let chosen = attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom); assert_eq!( chosen, anchor(PortAnchorSide::Top, 0.5), "{kind:?}, zoom {zoom}, overshoot {overshoot}" ); let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen); near(painted.x, n.position.x + n.size.width / 2.); near( painted.y, n.position.y + if kind == Kind::Terminate { n.size.height / 2. } else { 0. }, ); } let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.); let stable = attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom); assert_eq!( stable.side, PortAnchorSide::Right, "corner hysteresis stays stable" ); } } } #[test] fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() { let (mut p, d, a, edge) = state(Kind::State); if let RelationBody::StateTransition { target, .. } = &mut p.relations.get_mut(&edge).unwrap().body { *target = a; } for pins in [vec![], vec![pos(400., 30.)]] { let mut route = stored(edge); route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5)); route.target_attachment = route.source_attachment; route.waypoints = pins.clone(); p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()]; let result = routed(&p, d); let r = &result.edges[&edge]; orthogonal(&r.points); assert_eq!(r.points.first(), r.points.last()); assert!(length(&r.points) > 2. * PORT_STUB + 70.); assert_eq!(r.pins, pins); assert_eq!(r.pin_fractions.len(), pins.len()); assert!( r.warnings.contains(&RouteWarning::CoincidentAttachments), "{:?}", r.warnings ); assert_eq!(routed(&p, d), result, "deterministic fallback"); assert_eq!(p.diagrams[&d].routes[0], route); } } #[test] fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() { for blocker_side in [-1., 1.] { let (mut p, d, a, edge) = state(Kind::State); if let RelationBody::StateTransition { target, .. } = &mut p.relations.get_mut(&edge).unwrap().body { *target = a; } let mut route = stored(edge); route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5)); route.target_attachment = route.source_attachment; p.diagrams.get_mut(&d).unwrap().routes.push(route); let view = project_diagram(&p, &p.diagrams[&d]); let source = view.nodes.iter().find(|n| n.element == a).unwrap(); let tip = attachment_point( &p, &p.diagrams[&d], source, &anchor(PortAnchorSide::Top, 0.5), ); let region = p.elements[&a].parent; let blocker = p .add_element(Kind::State, Layer::Oa, "Detour obstruction", region) .unwrap(); let left = if blocker_side > 0. { tip.x + 30. } else { tip.x - 30. - 224. }; p.place(d, blocker, pos(left, tip.y - 144.)); let result = routed(&p, d); let r = &result.edges[&edge]; assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]); orthogonal(&r.points); let view = project_diagram(&p, &p.diagrams[&d]); let n = view.nodes.iter().find(|n| n.element == blocker).unwrap(); for segment in r.points.windows(2) { for step in 0..=100 { let t = step as f32 / 100.; let q = pos( segment[0].x + (segment[1].x - segment[0].x) * t, segment[0].y + (segment[1].y - segment[0].y) * t, ); assert!( !(q.x > n.position.x - CLEARANCE && q.x < n.position.x + n.size.width + CLEARANCE && q.y > n.position.y - CLEARANCE && q.y < n.position.y + n.size.height + CLEARANCE), "route crosses blocker: {q:?}" ); } } assert!( r.pins.is_empty(), "generated detours must never become authored pins" ); } }