386 lines
14 KiB
Rust
386 lines
14 KiB
Rust
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}"
|
|
);
|
|
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"
|
|
);
|
|
}
|
|
}
|