942 lines
31 KiB
Rust
942 lines
31 KiB
Rust
use oa_core::{projection::*, routing::*, *};
|
|
use std::time::Instant;
|
|
|
|
fn point(x: f32, y: f32) -> Position {
|
|
Position { x, y }
|
|
}
|
|
fn fixture() -> (Project, Id, Id, Id, Id) {
|
|
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, "Status report", a, b);
|
|
let d = p.ensure_diagram(Layer::Sa);
|
|
p.place(d, a, point(0., 100.));
|
|
p.place(d, b, point(800., 100.));
|
|
(p, d, a, b, edge)
|
|
}
|
|
fn projected(p: &Project, d: Id) -> ProjectedView {
|
|
project_diagram(p, &p.diagrams[&d])
|
|
}
|
|
fn route(p: &Project, d: Id) -> RoutedView {
|
|
route_diagram(p, &p.diagrams[&d], &projected(p, d))
|
|
}
|
|
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
|
|
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
|
|
edge,
|
|
source_attachment: None,
|
|
target_attachment: None,
|
|
waypoints: pins,
|
|
label,
|
|
});
|
|
}
|
|
fn orthogonal(points: &[Position]) {
|
|
assert!(points.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
|
assert!(
|
|
points
|
|
.windows(2)
|
|
.all(|p| p[0].x == p[1].x || p[0].y == p[1].y),
|
|
"{points:?}"
|
|
);
|
|
}
|
|
fn avoids(points: &[Position], position: Position, size: Size, clearance: f32) {
|
|
let (left, right) = (position.x - clearance, position.x + size.width + clearance);
|
|
let (top, bottom) = (position.y - clearance, position.y + size.height + clearance);
|
|
for segment in points.windows(2) {
|
|
let (a, b) = (segment[0], segment[1]);
|
|
let crosses = if a.x == b.x {
|
|
a.x > left + 0.001
|
|
&& a.x < right - 0.001
|
|
&& a.y.min(b.y) < bottom - 0.001
|
|
&& a.y.max(b.y) > top + 0.001
|
|
} else {
|
|
a.y > top + 0.001
|
|
&& a.y < bottom - 0.001
|
|
&& a.x.min(b.x) < right - 0.001
|
|
&& a.x.max(b.x) > left + 0.001
|
|
};
|
|
assert!(
|
|
!crosses,
|
|
"Segment {a:?} -> {b:?} intersects rectangle {position:?} {size:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn functional_endpoints_preserve_real_ports_and_normals_while_detouring() {
|
|
let (mut p, d, _, _, edge) = fixture();
|
|
let obstacle = p
|
|
.add_element(Kind::Function, Layer::Sa, "Unrelated computation", None)
|
|
.unwrap();
|
|
p.place(d, obstacle, point(380., 110.));
|
|
let view = projected(&p, d);
|
|
let result = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let path = &result.edges[&edge];
|
|
assert!(path.warnings.is_empty(), "{:?}", path.warnings);
|
|
orthogonal(&path.points);
|
|
let projected_edge = view.edges.iter().find(|e| e.id == edge).unwrap();
|
|
let source = view
|
|
.ports
|
|
.iter()
|
|
.find(|p| Some(p.element) == projected_edge.source_port)
|
|
.unwrap();
|
|
let target = view
|
|
.ports
|
|
.iter()
|
|
.find(|p| Some(p.element) == projected_edge.target_port)
|
|
.unwrap();
|
|
assert_eq!(path.points[0], source.position);
|
|
assert_eq!(
|
|
path.points[1],
|
|
point(source.position.x + PORT_STUB, source.position.y)
|
|
);
|
|
assert_eq!(*path.points.last().unwrap(), target.position);
|
|
assert_eq!(
|
|
path.points[path.points.len() - 2],
|
|
point(target.position.x - PORT_STUB, target.position.y)
|
|
);
|
|
let obstacle = view.nodes.iter().find(|n| n.element == obstacle).unwrap();
|
|
avoids(&path.points, obstacle.position, obstacle.size, CLEARANCE);
|
|
}
|
|
|
|
#[test]
|
|
fn bottom_standard_ports_are_approached_from_below() {
|
|
let (p, d, _, _, edge) = fixture();
|
|
let mut view = projected(&p, d);
|
|
for port in &mut view.ports {
|
|
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
|
port.side = PortAnchorSide::Bottom;
|
|
port.position = point(
|
|
owner.position.x + owner.size.width / 2.,
|
|
owner.position.y + owner.size.height,
|
|
);
|
|
}
|
|
let result = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let points = &result.edges[&edge].points;
|
|
assert_eq!(points[1], point(points[0].x, points[0].y + PORT_STUB));
|
|
let n = points.len();
|
|
assert_eq!(
|
|
points[n - 2],
|
|
point(points[n - 1].x, points[n - 1].y + PORT_STUB)
|
|
);
|
|
assert!(result.edges[&edge].warnings.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn nested_endpoint_enclosure_is_traversable_but_its_header_and_siblings_are_not() {
|
|
let (mut p, d, a, _, edge) = fixture();
|
|
let container = p
|
|
.add_element(Kind::Function, Layer::Sa, "Supervise", None)
|
|
.unwrap();
|
|
p.elements.get_mut(&a).unwrap().parent = Some(container);
|
|
let parent = p.place(d, container, point(-60., 0.));
|
|
p.diagrams
|
|
.get_mut(&d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&parent)
|
|
.unwrap()
|
|
.size = Size {
|
|
width: 680.,
|
|
height: 430.,
|
|
};
|
|
let sibling = p
|
|
.add_element(Kind::Function, Layer::Sa, "Other work", Some(container))
|
|
.unwrap();
|
|
p.place(d, sibling, point(350., 110.));
|
|
let view = projected(&p, d);
|
|
let result = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let routed = &result.edges[&edge];
|
|
assert!(routed.warnings.is_empty(), "{:?}", routed.warnings);
|
|
let parent = view.nodes.iter().find(|n| n.element == container).unwrap();
|
|
avoids(
|
|
&routed.points,
|
|
parent.position,
|
|
Size {
|
|
width: parent.size.width,
|
|
height: 64.,
|
|
},
|
|
CLEARANCE,
|
|
);
|
|
let sibling = view.nodes.iter().find(|n| n.element == sibling).unwrap();
|
|
avoids(&routed.points, sibling.position, sibling.size, CLEARANCE);
|
|
assert!(routed.points.iter().any(|p| p.x > parent.position.x
|
|
&& p.x < parent.position.x + parent.size.width
|
|
&& p.y > parent.position.y + 64.));
|
|
}
|
|
|
|
#[test]
|
|
fn authored_pins_keep_traversal_order_even_when_reversing_and_repeated() {
|
|
let (mut p, d, _, _, edge) = fixture();
|
|
let pins = vec![point(650., 400.), point(300., 400.), point(650., 400.)];
|
|
set_route(&mut p, d, edge, pins.clone(), None);
|
|
let result = route(&p, d);
|
|
let r = &result.edges[&edge];
|
|
assert_eq!(r.pins, pins);
|
|
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
|
assert!(r.pin_fractions.windows(2).all(|p| p[0] < p[1]));
|
|
for (pin, fraction) in r.pins.iter().zip(&r.pin_fractions) {
|
|
assert!(distance(*pin, point_along_polyline(&r.points, *fraction).unwrap()) < 0.01);
|
|
}
|
|
let first_reverse = r
|
|
.points
|
|
.windows(2)
|
|
.position(|p| p[0] == pins[0] && p[1] == pins[1])
|
|
.unwrap();
|
|
assert_eq!(pin_insertion_index(r, first_reverse, point(500., 400.)), 1);
|
|
assert_eq!(
|
|
pin_insertion_index(r, first_reverse + 1, point(500., 400.)),
|
|
2
|
|
);
|
|
orthogonal(&r.points);
|
|
}
|
|
|
|
#[test]
|
|
fn single_pin_above_an_oa_interaction_makes_a_detour_without_a_retraced_spur() {
|
|
let mut p = Project::empty();
|
|
let a = p
|
|
.add_element(Kind::Activity, Layer::Oa, "Observe", None)
|
|
.unwrap();
|
|
let b = p
|
|
.add_element(Kind::Activity, Layer::Oa, "Decide", None)
|
|
.unwrap();
|
|
let d = p.ensure_diagram(Layer::Oa);
|
|
p.place(d, a, point(0., 300.));
|
|
p.place(d, b, point(800., 300.));
|
|
let edge = p.relate(RelationKind::Interaction, "Observation", a, b);
|
|
let pin = point(500., 100.);
|
|
set_route(&mut p, d, edge, vec![pin], None);
|
|
let result = route(&p, d);
|
|
let r = &result.edges[&edge];
|
|
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
|
assert!(r.points.contains(&pin));
|
|
for turn in r.points.windows(3) {
|
|
let dot = (turn[1].x - turn[0].x) * (turn[2].x - turn[1].x)
|
|
+ (turn[1].y - turn[0].y) * (turn[2].y - turn[1].y);
|
|
assert!(
|
|
dot >= 0.,
|
|
"Adjacent reversal creates an unhelpful pin spur: {:?}",
|
|
r.points
|
|
);
|
|
}
|
|
for (i, a) in r.points.windows(2).enumerate() {
|
|
for b in r.points.windows(2).skip(i + 1) {
|
|
let overlap = if a[0].y == a[1].y && b[0].y == b[1].y && a[0].y == b[0].y {
|
|
a[0].x.max(a[1].x).min(b[0].x.max(b[1].x))
|
|
- a[0].x.min(a[1].x).max(b[0].x.min(b[1].x))
|
|
} else if a[0].x == a[1].x && b[0].x == b[1].x && a[0].x == b[0].x {
|
|
a[0].y.max(a[1].y).min(b[0].y.max(b[1].y))
|
|
- a[0].y.min(a[1].y).max(b[0].y.min(b[1].y))
|
|
} else {
|
|
0.0
|
|
};
|
|
assert!(overlap <= 0., "Retraced span: {:?}", r.points);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn automatic_label_fraction_survives_its_first_authored_drag_without_a_jump() {
|
|
let (mut p, d, _, _, edge) = fixture();
|
|
let view = projected(&p, d);
|
|
let source_port = view
|
|
.edges
|
|
.iter()
|
|
.find(|e| e.id == edge)
|
|
.unwrap()
|
|
.source_port;
|
|
let y = view
|
|
.ports
|
|
.iter()
|
|
.find(|p| Some(p.element) == source_port)
|
|
.unwrap()
|
|
.position
|
|
.y;
|
|
let obstacle = p
|
|
.add_element(Kind::Function, Layer::Sa, "Above the line", None)
|
|
.unwrap();
|
|
p.place(d, obstacle, point(450., y - 117.));
|
|
set_route(&mut p, d, edge, vec![point(350., y)], None);
|
|
let result = route(&p, d);
|
|
let label = result.edges[&edge].label.clone();
|
|
assert_ne!(
|
|
label.fraction, 0.5,
|
|
"Fixture must exercise automatic collision-avoidance relocation"
|
|
);
|
|
assert_eq!(
|
|
label.anchor,
|
|
point_along_polyline(&result.edges[&edge].points, label.fraction).unwrap()
|
|
);
|
|
let offset = point(
|
|
label.center.x - label.anchor.x + 15.,
|
|
label.center.y - label.anchor.y - 10.,
|
|
);
|
|
p.diagrams.get_mut(&d).unwrap().routes[0].label = Some(EdgeLabel {
|
|
fraction: label.fraction,
|
|
offset,
|
|
});
|
|
let moved = route(&p, d).edges[&edge].label.clone();
|
|
assert_eq!(
|
|
moved.center,
|
|
point(label.center.x + 15., label.center.y - 10.)
|
|
);
|
|
}
|
|
|
|
fn label_overlap(a: RoutingBounds, b: RoutingBounds) -> bool {
|
|
a.position.x < b.position.x + b.size.width
|
|
&& a.position.x + a.size.width > b.position.x
|
|
&& a.position.y < b.position.y + b.size.height
|
|
&& a.position.y + a.size.height > b.position.y
|
|
}
|
|
|
|
#[test]
|
|
fn workshop_automatic_labels_are_readable_without_mutating_authored_routes() {
|
|
let p = language::parse(include_str!("../../../examples/routing-workshop.arcadia")).unwrap();
|
|
let d = p
|
|
.diagrams
|
|
.values()
|
|
.find(|d| d.alias == "sa_system_boundary_and_functions")
|
|
.unwrap();
|
|
let view = project_diagram(&p, d);
|
|
let routed = route_diagram(&p, d, &view);
|
|
assert_eq!(routed.edges.len(), 12);
|
|
let labels: Vec<_> = routed
|
|
.edges
|
|
.values()
|
|
.filter(|e| !e.label.text.is_empty())
|
|
.collect();
|
|
let mut overlaps = Vec::new();
|
|
for (i, a) in labels.iter().enumerate() {
|
|
for b in labels.iter().skip(i + 1) {
|
|
if label_overlap(a.label.bounds, b.label.bounds) {
|
|
overlaps.push(format!(
|
|
"{} at {:?} / {} at {:?}",
|
|
a.label.text, a.label.center, b.label.text, b.label.center
|
|
));
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
overlaps.is_empty(),
|
|
"Overlapping automatic workshop labels: {overlaps:#?}"
|
|
);
|
|
for edge in &view.edges {
|
|
let label = &routed.edges[&edge.id].label;
|
|
let mut ancestors = std::collections::BTreeSet::new();
|
|
for endpoint in [edge.source, edge.target] {
|
|
let mut parent = view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.id == endpoint)
|
|
.unwrap()
|
|
.visual_parent;
|
|
while let Some(id) = parent {
|
|
ancestors.insert(id);
|
|
parent = view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.id == id)
|
|
.and_then(|n| n.visual_parent);
|
|
}
|
|
}
|
|
for node in &view.nodes {
|
|
let size = if ancestors.contains(&node.id) {
|
|
Size {
|
|
height: node.size.height.min(64.),
|
|
..node.size
|
|
}
|
|
} else {
|
|
node.size
|
|
};
|
|
assert!(
|
|
!label_overlap(
|
|
label.bounds,
|
|
RoutingBounds {
|
|
position: node.position,
|
|
size
|
|
}
|
|
),
|
|
"{} overlaps {}: label {:?}, node {:?}, path {:?}",
|
|
label.text,
|
|
p.named(node.element),
|
|
label.bounds,
|
|
node,
|
|
routed.edges[&edge.id].points
|
|
);
|
|
}
|
|
}
|
|
let offset_count = labels
|
|
.iter()
|
|
.filter(|e| e.label.center != point(e.label.anchor.x, e.label.anchor.y - 18.))
|
|
.count();
|
|
eprintln!("Workshop labels: 12 clear labels; {offset_count} bounded offset placements");
|
|
|
|
// Explicit placement stays exact even when deliberately overlapping another
|
|
// label. Preview updates only the selected label; its path cannot jump.
|
|
let selected = *routed.edges.keys().last().unwrap();
|
|
let baseline = &routed.edges[&selected];
|
|
let destination = labels
|
|
.iter()
|
|
.find(|e| e.id != selected)
|
|
.unwrap()
|
|
.label
|
|
.center;
|
|
let mut edited = p.clone();
|
|
set_route(
|
|
&mut edited,
|
|
d.id,
|
|
selected,
|
|
vec![],
|
|
Some(EdgeLabel {
|
|
fraction: baseline.label.fraction,
|
|
offset: point(
|
|
destination.x - baseline.label.anchor.x,
|
|
destination.y - baseline.label.anchor.y,
|
|
),
|
|
}),
|
|
);
|
|
let preview = route_edge_preview(&edited, &edited.diagrams[&d.id], &view, &routed, selected);
|
|
assert_eq!(preview.edges[&selected].label.center, destination);
|
|
assert_eq!(preview.edges[&selected].points, baseline.points);
|
|
assert_eq!(
|
|
preview.edges[&selected],
|
|
route_diagram(&edited, &edited.diagrams[&d.id], &view).edges[&selected]
|
|
);
|
|
for (&id, edge) in &routed.edges {
|
|
if id != selected {
|
|
assert_eq!(preview.edges[&id], *edge);
|
|
}
|
|
}
|
|
// Resetting a label uses the same earlier-label occupancy as a settled solve.
|
|
edited.diagrams.get_mut(&d.id).unwrap().routes[0].label = None;
|
|
let reset = route_edge_preview(&edited, &edited.diagrams[&d.id], &view, &preview, selected);
|
|
assert_eq!(reset.edges[&selected], *baseline);
|
|
// An actual bend edit also places the automatic label against that prefix.
|
|
edited.diagrams.get_mut(&d.id).unwrap().routes[0]
|
|
.waypoints
|
|
.push(point(1100., 720.));
|
|
let pin_preview =
|
|
route_edge_preview(&edited, &edited.diagrams[&d.id], &view, &routed, selected);
|
|
assert_eq!(
|
|
pin_preview.edges[&selected],
|
|
route_diagram(&edited, &edited.diagrams[&d.id], &view).edges[&selected]
|
|
);
|
|
}
|
|
fn distance(a: Position, b: Position) -> f32 {
|
|
(a.x - b.x).hypot(a.y - b.y)
|
|
}
|
|
|
|
#[test]
|
|
fn blocked_pin_is_retained_and_warned_without_nan_or_diagonal_fallback() {
|
|
let (mut p, d, _, _, edge) = fixture();
|
|
let pin = point(50., 140.);
|
|
set_route(&mut p, d, edge, vec![pin], None);
|
|
let result = route(&p, d);
|
|
let r = &result.edges[&edge];
|
|
assert_eq!(r.pins, vec![pin]);
|
|
assert!(r.points.contains(&pin));
|
|
assert!(
|
|
r.warnings
|
|
.iter()
|
|
.any(|w| matches!(w, RouteWarning::PinInsideObstacle { pin: 0, .. }))
|
|
);
|
|
assert!(r.warnings.contains(&RouteWarning::NoClearPath));
|
|
orthogonal(&r.points);
|
|
}
|
|
|
|
#[test]
|
|
fn collapse_suspends_only_promoted_endpoints_and_never_changes_stored_route() {
|
|
let (mut p, d, a, _, edge) = fixture();
|
|
let group = p
|
|
.add_element(Kind::Function, Layer::Sa, "Group", None)
|
|
.unwrap();
|
|
p.elements.get_mut(&a).unwrap().parent = Some(group);
|
|
let parent = p.place(d, group, point(-60., 0.));
|
|
set_route(
|
|
&mut p,
|
|
d,
|
|
edge,
|
|
vec![point(500., 400.)],
|
|
Some(EdgeLabel {
|
|
fraction: 0.4,
|
|
offset: point(20., 40.),
|
|
}),
|
|
);
|
|
let stored = p.diagrams[&d].routes.clone();
|
|
assert!(!route(&p, d).edges[&edge].custom_suspended);
|
|
p.diagrams
|
|
.get_mut(&d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&parent)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
let folded = route(&p, d);
|
|
assert!(folded.edges[&edge].custom_suspended);
|
|
assert!(folded.edges[&edge].pins.is_empty());
|
|
assert!(
|
|
folded.edges[&edge]
|
|
.warnings
|
|
.contains(&RouteWarning::PromotedEndpoints)
|
|
);
|
|
assert_eq!(p.diagrams[&d].routes, stored);
|
|
p.diagrams.get_mut(&d).unwrap().routes.clear();
|
|
assert!(
|
|
route(&p, d).edges[&edge].custom_suspended,
|
|
"A promoted endpoint cannot accept its first bend either"
|
|
);
|
|
p.diagrams.get_mut(&d).unwrap().routes = stored.clone();
|
|
p.diagrams
|
|
.get_mut(&d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&parent)
|
|
.unwrap()
|
|
.collapsed = false;
|
|
assert_eq!(route(&p, d).edges[&edge].pins, stored[0].waypoints);
|
|
let unrelated = p
|
|
.add_element(Kind::Function, Layer::Sa, "Unrelated group", None)
|
|
.unwrap();
|
|
let node = p.place(d, unrelated, point(1100., 100.));
|
|
p.diagrams
|
|
.get_mut(&d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&node)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
assert!(!route(&p, d).edges[&edge].custom_suspended);
|
|
}
|
|
|
|
fn maze() -> (Project, Id, Id) {
|
|
let (mut p, d, _, _, edge) = fixture();
|
|
let initial = projected(&p, d);
|
|
let source_port = initial
|
|
.edges
|
|
.iter()
|
|
.find(|e| e.id == edge)
|
|
.unwrap()
|
|
.source_port;
|
|
let y = initial
|
|
.ports
|
|
.iter()
|
|
.find(|p| Some(p.element) == source_port)
|
|
.unwrap()
|
|
.position
|
|
.y;
|
|
for (index, (position, size)) in [
|
|
(
|
|
point(230., y - 143.),
|
|
Size {
|
|
width: 100.,
|
|
height: 120.,
|
|
},
|
|
),
|
|
(
|
|
point(230., y + 27.),
|
|
Size {
|
|
width: 100.,
|
|
height: 160.,
|
|
},
|
|
),
|
|
(
|
|
point(400., y - 73.),
|
|
Size {
|
|
width: 100.,
|
|
height: 130.,
|
|
},
|
|
),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let id = p
|
|
.add_element(Kind::Function, Layer::Sa, &format!("Wall {index}"), None)
|
|
.unwrap();
|
|
let node = p.place(d, id, position);
|
|
p.diagrams
|
|
.get_mut(&d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&node)
|
|
.unwrap()
|
|
.size = size;
|
|
}
|
|
(p, d, edge)
|
|
}
|
|
|
|
#[test]
|
|
fn compressed_grid_search_finds_multi_turn_exit_from_a_narrow_corridor() {
|
|
let (p, d, edge) = maze();
|
|
let view = projected(&p, d);
|
|
let result = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let r = &result.edges[&edge];
|
|
assert!(r.warnings.is_empty(), "{:?}: {:?}", r.warnings, r.points);
|
|
orthogonal(&r.points);
|
|
assert!(
|
|
r.points.len() >= 7,
|
|
"The blocked up/down and forward exits require several turns: {:?}",
|
|
r.points
|
|
);
|
|
for wall in view
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| p.named(n.element).starts_with("Wall"))
|
|
{
|
|
avoids(&r.points, wall.position, wall.size, CLEARANCE);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn excessive_search_grid_falls_back_with_an_explicit_warning_and_preserved_endpoints() {
|
|
let (p, d, edge) = maze();
|
|
let mut view = projected(&p, d);
|
|
// Distant cards do not alter the local maze, but their distinct coordinates
|
|
// deliberately exceed the compressed-grid admission limit.
|
|
for i in 0..150 {
|
|
view.nodes.push(ProjectedNode {
|
|
id: Id::from_u128(i + 1),
|
|
element: Id::from_u128(i + 1000),
|
|
position: point(10_000. + i as f32 * 333., 10_000. + i as f32 * 377.),
|
|
size: Size {
|
|
width: 100.,
|
|
height: 94.,
|
|
},
|
|
visual_parent: None,
|
|
depth: 0,
|
|
collapsed: false,
|
|
allocation_summary: false,
|
|
});
|
|
}
|
|
let result = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let r = &result.edges[&edge];
|
|
assert!(
|
|
r.warnings.contains(&RouteWarning::SearchBudgetExhausted),
|
|
"{:?}",
|
|
r.warnings
|
|
);
|
|
orthogonal(&r.points);
|
|
let endpoint = view.edges.iter().find(|e| e.id == edge).unwrap();
|
|
assert_eq!(
|
|
r.points[0],
|
|
view.ports
|
|
.iter()
|
|
.find(|p| Some(p.element) == endpoint.source_port)
|
|
.unwrap()
|
|
.position
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nonport_relations_try_clear_alternate_sides_and_label_only_supported_roles() {
|
|
let (mut p, d, a, b, functional) = fixture();
|
|
let include = p.relate(RelationKind::CapabilityInclude, "Hidden custom name", a, b);
|
|
let mut view = projected(&p, d);
|
|
// The projection is the geometry seam; model validation separately restricts
|
|
// the semantic types permitted for each relationship role.
|
|
view.edges.retain(|e| e.id == include);
|
|
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
|
|
let owner = source.id;
|
|
let right_midpoint = point(
|
|
source.position.x + source.size.width,
|
|
source.position.y + source.size.height / 2.,
|
|
);
|
|
view.nodes.push(ProjectedNode {
|
|
id: Id::from_u128(1),
|
|
element: Id::from_u128(2),
|
|
position: point(right_midpoint.x + 2., right_midpoint.y - 40.),
|
|
size: Size {
|
|
width: 90.,
|
|
height: 94.,
|
|
},
|
|
visual_parent: None,
|
|
depth: 0,
|
|
collapsed: false,
|
|
allocation_summary: false,
|
|
});
|
|
let result = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let r = &result.edges[&include];
|
|
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
|
assert_ne!(r.points[0], right_midpoint);
|
|
assert_eq!(r.label.text, "«include»");
|
|
let mut ordinary = view;
|
|
ordinary.edges[0].id = Id::from_u128(3);
|
|
ordinary.edges[0].source = owner;
|
|
let result = route_diagram(&p, &p.diagrams[&d], &ordinary);
|
|
assert!(result.edges[&Id::from_u128(3)].label.text.is_empty());
|
|
assert!(!result.edges.contains_key(&functional));
|
|
}
|
|
|
|
#[test]
|
|
fn label_geometry_and_bounds_are_shared_exact_and_unicode_safe() {
|
|
let (mut p, d, _, _, edge) = fixture();
|
|
p.relations.get_mut(&edge).unwrap().name =
|
|
"温度と圧力の状態を定期的に送信する長い機能交換名のテストデータ表示です".into();
|
|
let authored = EdgeLabel {
|
|
fraction: 0.2,
|
|
offset: point(-700., -500.),
|
|
};
|
|
set_route(
|
|
&mut p,
|
|
d,
|
|
edge,
|
|
vec![point(500., 400.)],
|
|
Some(authored.clone()),
|
|
);
|
|
let result = route(&p, d);
|
|
let r = &result.edges[&edge];
|
|
assert_eq!(r.label.text.chars().count(), 32);
|
|
assert!(r.label.text.ends_with('…'));
|
|
assert_eq!(
|
|
r.label.bounds.size,
|
|
Size {
|
|
width: 240.,
|
|
height: 24.
|
|
}
|
|
);
|
|
assert_eq!(
|
|
r.label.anchor,
|
|
point_along_polyline(&r.points, authored.fraction).unwrap()
|
|
);
|
|
assert_eq!(
|
|
r.label.center,
|
|
point(
|
|
r.label.anchor.x + authored.offset.x,
|
|
r.label.anchor.y + authored.offset.y
|
|
)
|
|
);
|
|
assert!(result.bounds.position.x <= r.label.bounds.position.x);
|
|
assert!(result.bounds.position.y <= r.label.bounds.position.y);
|
|
assert!(result.bounds.position.x + result.bounds.size.width >= 1024.);
|
|
assert!(result.bounds.position.y + result.bounds.size.height >= 400.);
|
|
}
|
|
|
|
#[test]
|
|
fn deterministic_under_input_and_route_list_permutations() {
|
|
let (mut p, d, a, b, edge) = fixture();
|
|
let second = p.relate(RelationKind::FunctionalExchange, "Acknowledgement", b, a);
|
|
set_route(&mut p, d, edge, vec![point(450., 300.)], None);
|
|
set_route(&mut p, d, second, vec![point(550., 400.)], None);
|
|
let view = projected(&p, d);
|
|
let original = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let mut shuffled = view;
|
|
shuffled.edges.reverse();
|
|
shuffled.nodes.reverse();
|
|
shuffled.ports.reverse();
|
|
p.diagrams.get_mut(&d).unwrap().routes.reverse();
|
|
assert_eq!(route_diagram(&p, &p.diagrams[&d], &shuffled), original);
|
|
}
|
|
|
|
#[test]
|
|
fn single_edge_preview_matches_settled_geometry_and_preserves_other_routes() {
|
|
let (mut p, d, a, b, first) = fixture();
|
|
let second = p.relate(RelationKind::FunctionalExchange, "Return", b, a);
|
|
let view = projected(&p, d);
|
|
let baseline = route_diagram(&p, &p.diagrams[&d], &view);
|
|
// Choose the later ID to exercise baseline crossing costs; earlier paths
|
|
// are unchanged by this edit, exactly as in the complete stable-ID solve.
|
|
let changed = first.max(second);
|
|
let unchanged = first.min(second);
|
|
set_route(
|
|
&mut p,
|
|
d,
|
|
changed,
|
|
vec![point(450., 400.)],
|
|
Some(EdgeLabel {
|
|
fraction: 0.2,
|
|
offset: point(-800., -500.),
|
|
}),
|
|
);
|
|
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, changed);
|
|
let complete = route_diagram(&p, &p.diagrams[&d], &view);
|
|
assert_eq!(preview.edges[&changed], complete.edges[&changed]);
|
|
assert_eq!(preview.edges[&unchanged], baseline.edges[&unchanged]);
|
|
assert_eq!(preview.bounds, complete.bounds);
|
|
}
|
|
|
|
#[test]
|
|
fn exhausted_search_budget_survives_label_and_pin_previews() {
|
|
let (mut p, d, original) = maze();
|
|
// Valid reuse of one typed port pair: many distinct exchanges through the
|
|
// same multi-turn corridor exhaust the shared per-view search budget.
|
|
let template = p.relations.remove(&original).unwrap();
|
|
for i in 0..1500 {
|
|
let mut edge = template.clone();
|
|
edge.id = Id::from_u128(100 + i);
|
|
edge.alias = format!("budget_flow_{i}");
|
|
p.relations.insert(edge.id, edge);
|
|
}
|
|
validate_candidate(&p).unwrap();
|
|
let view = projected(&p, d);
|
|
let baseline = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let exhausted = baseline
|
|
.edges
|
|
.values()
|
|
.find(|edge| edge.warnings.contains(&RouteWarning::SearchBudgetExhausted))
|
|
.expect("Fixture must exhaust the cumulative budget");
|
|
let id = exhausted.id;
|
|
set_route(
|
|
&mut p,
|
|
d,
|
|
id,
|
|
vec![],
|
|
Some(EdgeLabel {
|
|
fraction: exhausted.label.fraction,
|
|
offset: point(0., -40.),
|
|
}),
|
|
);
|
|
let label_preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, id);
|
|
assert_eq!(label_preview.edges[&id].points, exhausted.points);
|
|
assert_eq!(
|
|
label_preview.edges[&id].pin_fractions,
|
|
exhausted.pin_fractions
|
|
);
|
|
assert_eq!(label_preview.edges[&id].warnings, exhausted.warnings);
|
|
assert_ne!(
|
|
label_preview.edges[&id].label.center,
|
|
exhausted.label.center
|
|
);
|
|
assert_eq!(
|
|
label_preview.edges[&id],
|
|
route_diagram(&p, &p.diagrams[&d], &view).edges[&id]
|
|
);
|
|
// Adding a pin requires actual rerouting. Its inherited pre-edge budget
|
|
// must agree with the full solve, rather than obtaining a fresh view budget.
|
|
p.diagrams.get_mut(&d).unwrap().routes[0]
|
|
.waypoints
|
|
.push(point(650., 400.));
|
|
let pin_preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, id);
|
|
let full = route_diagram(&p, &p.diagrams[&d], &view);
|
|
assert_eq!(pin_preview.edges[&id], full.edges[&id]);
|
|
assert!(pin_preview.edges[&id].points.contains(&point(650., 400.)));
|
|
}
|
|
|
|
#[test]
|
|
fn interpolation_projection_empty_and_repeated_points_are_well_defined() {
|
|
assert_eq!(point_along_polyline(&[], 0.5), None);
|
|
assert_eq!(project_onto_polyline(&[], point(1., 2.)), None);
|
|
let p = [
|
|
point(0., 0.),
|
|
point(0., 0.),
|
|
point(100., 0.),
|
|
point(100., 100.),
|
|
];
|
|
assert_eq!(point_along_polyline(&p, 0.75), Some(point(100., 50.)));
|
|
assert_eq!(point_along_polyline(&p, -1.), Some(point(0., 0.)));
|
|
assert_eq!(point_along_polyline(&p, 2.), Some(point(100., 100.)));
|
|
let projection = project_onto_polyline(&p, point(110., 50.)).unwrap();
|
|
assert_eq!(
|
|
projection,
|
|
PolylineProjection {
|
|
point: point(100., 50.),
|
|
fraction: 0.75,
|
|
distance: 10.
|
|
}
|
|
);
|
|
assert_eq!(
|
|
point_along_polyline(&[point(7., 8.); 3], 0.5),
|
|
Some(point(7., 8.))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn routing_100_nodes_150_edges_has_bounded_work_and_reports_actual_time() {
|
|
let mut p = Project::empty();
|
|
let d = p.ensure_diagram(Layer::Sa);
|
|
let mut elements = Vec::new();
|
|
for i in 0..100 {
|
|
let id = p
|
|
.add_element(Kind::Function, Layer::Sa, &format!("Function {i}"), None)
|
|
.unwrap();
|
|
p.place(d, id, point((i % 10) as f32 * 400., (i / 10) as f32 * 280.));
|
|
elements.push(id);
|
|
}
|
|
for i in 0..150 {
|
|
let source = i % 100;
|
|
let target = if i < 90 {
|
|
source + 10
|
|
} else {
|
|
(source / 10) * 10 + (source + 1) % 10
|
|
};
|
|
p.relate(
|
|
RelationKind::FunctionalExchange,
|
|
&format!("Flow {i}"),
|
|
elements[source],
|
|
elements[target],
|
|
);
|
|
}
|
|
let view = projected(&p, d);
|
|
assert_eq!(view.nodes.len(), 100);
|
|
assert_eq!(view.edges.len(), 150);
|
|
let start = Instant::now();
|
|
let routed = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let elapsed = start.elapsed();
|
|
eprintln!(
|
|
"100-node/150-edge settled routing: {elapsed:?}; {} warnings",
|
|
routed
|
|
.edges
|
|
.values()
|
|
.map(|r| r.warnings.len())
|
|
.sum::<usize>()
|
|
);
|
|
assert_eq!(routed.edges.len(), 150);
|
|
for edge in routed.edges.values() {
|
|
orthogonal(&edge.points);
|
|
}
|
|
// A deliberately loose debug regression guard, not the interactive target.
|
|
assert!(
|
|
elapsed.as_secs() < 10,
|
|
"Unexpected multi-second routing regression: {elapsed:?}"
|
|
);
|
|
// A harder admitted model: every edge has eight coincident detour pins.
|
|
// Report settled work separately from the interactive single-edge preview.
|
|
let edge_ids: Vec<_> = p.relations.keys().copied().collect();
|
|
for &edge in &edge_ids {
|
|
set_route(
|
|
&mut p,
|
|
d,
|
|
edge,
|
|
(0..8)
|
|
.map(|i| point(-100. - (i % 2) as f32 * 100., -100. - i as f32 * 80.))
|
|
.collect(),
|
|
None,
|
|
);
|
|
}
|
|
let started = Instant::now();
|
|
let baseline = route_diagram(&p, &p.diagrams[&d], &view);
|
|
let settled = started.elapsed();
|
|
let selected = *edge_ids.last().unwrap();
|
|
let changed = p
|
|
.diagrams
|
|
.get_mut(&d)
|
|
.unwrap()
|
|
.routes
|
|
.iter_mut()
|
|
.find(|r| r.edge == selected)
|
|
.unwrap();
|
|
changed.waypoints[7].x -= 35.;
|
|
let moved = changed.waypoints[7];
|
|
let started = Instant::now();
|
|
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, selected);
|
|
let preview_time = started.elapsed();
|
|
eprintln!(
|
|
"100-node/150-edge/eight-pins settled: {settled:?}; single-edge preview: {preview_time:?}; {} warnings",
|
|
baseline
|
|
.edges
|
|
.values()
|
|
.map(|r| r.warnings.len())
|
|
.sum::<usize>()
|
|
);
|
|
assert!(preview.edges[&selected].points.contains(&moved));
|
|
for &edge in &edge_ids[..edge_ids.len() - 1] {
|
|
assert_eq!(preview.edges[&edge], baseline.edges[&edge]);
|
|
}
|
|
assert!(
|
|
preview_time.as_secs() < 1,
|
|
"Single route preview must not stall for seconds: {preview_time:?}"
|
|
);
|
|
}
|