Support per-view connection boundary attachments in ordinary and state diagrams

This commit is contained in:
2026-09-22 17:51:13 -07:00
parent 3f9e6a18af
commit 79cc9d5551
49 changed files with 3534 additions and 52 deletions
+385
View File
@@ -0,0 +1,385 @@
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"
);
}
}
@@ -0,0 +1,272 @@
use oa_core::{language, projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn authored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![pos(520., 420.)],
label: Some(EdgeLabel {
fraction: 0.35,
offset: pos(17., -23.),
}),
}
}
fn routed(project: &Project, diagram: Id) -> RoutedView {
let d = &project.diagrams[&diagram];
route_diagram(project, d, &project_diagram(project, d))
}
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
let mut p = language::parse(
r#"arcadia 7;
project lifecycle "Attachment lifecycle" {
oa_activity group "Group" { oa_activity first "First" {} }
oa_activity second "Second" {}
oa_activity replacement "Replacement" {}
interaction signal "Signal" { source @first; target @second; }
diagram view "View" {}
diagram other "Other" {}
}"#,
)
.unwrap();
let (d, other, group, first, second, replacement, edge) = (
id(&p, "view"),
id(&p, "other"),
id(&p, "group"),
id(&p, "first"),
id(&p, "second"),
id(&p, "replacement"),
id(&p, "signal"),
);
for diagram in [d, other] {
p.place(diagram, group, pos(20., 20.));
p.place(diagram, first, pos(100., 130.));
p.place(diagram, second, pos(750., 150.));
p.place(diagram, replacement, pos(750., 570.));
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(authored(edge));
}
(p, d, other, group, first, replacement, edge)
}
#[test]
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
let (mut p, d, other, group, _, _, edge) = fixture();
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
let before = p.clone();
let expanded = routed(&p, d);
let other_geometry = routed(&p, other);
let parent = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == group)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
let folded = routed(&p, d);
assert!(folded.edges[&edge].custom_suspended);
assert_eq!(folded.edges[&edge].pins, []);
assert!(
folded.edges[&edge]
.warnings
.contains(&RouteWarning::PromotedEndpoints)
);
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
let mut automatic = p.clone();
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
assert_eq!(
folded,
routed(&automatic, d),
"attachments, pins and label must all be dormant on promotion"
);
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
assert_eq!(routed(&p, other), other_geometry);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = false;
assert_eq!(p, before);
assert_eq!(routed(&p, d), expanded);
}
#[test]
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
let (p, d, other, _, _, replacement, edge) = fixture();
let before = routed(&p, d);
let mut relation = p.relations[&edge].clone();
let RelationBody::Interaction { target, .. } = &mut relation.body else {
unreachable!()
};
*target = replacement;
let mut expected = p.clone();
expected.relations.insert(edge, relation.clone());
let mut session = Session::new(p.clone());
session
.execute(Command::EditRelation(edge, relation))
.unwrap();
assert_eq!(
session.project(),
&expected,
"reconnect changes only the semantic endpoint; all per-view records remain exact"
);
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
for diagram in [d, other] {
let current = session.project();
let view = project_diagram(current, &current.diagrams[&diagram]);
let node = view
.nodes
.iter()
.find(|n| n.element == replacement)
.unwrap();
let preference = current.diagrams[&diagram].routes[0]
.target_attachment
.unwrap();
let expected_tip =
attachment_point(current, &current.diagrams[&diagram], node, &preference);
let result = routed(current, diagram);
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
assert_eq!(
result.edges[&edge].pins,
p.diagrams[&diagram].routes[0].waypoints
);
}
assert_ne!(
routed(session.project(), d).edges[&edge].points.last(),
before.edges[&edge].points.last()
);
session.undo();
assert_eq!(session.project(), &p);
assert_eq!(routed(session.project(), d), before);
session.redo();
assert_eq!(session.project(), &expected);
}
#[test]
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let mut ports = Vec::new();
for (owner, name, contract) in [
(
system,
"Output",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
),
(
actor,
"Input",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
},
),
(
function,
"Function output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
let port = Id::new_v4();
p.elements
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
ports.push(port);
}
p.relate(
RelationKind::Allocation,
"Function allocation",
system,
function,
);
let boundary = p.relate(
RelationKind::BoundaryExchange,
"Boundary",
ports[0],
ports[1],
);
let allocation = p.relate(
RelationKind::PortAllocation,
"Port allocation",
ports[0],
ports[2],
);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, system, pos(0., 0.));
p.place(d, actor, pos(700., 0.));
p.place(d, function, pos(0., 450.));
let original = p.clone();
let baseline = routed(&p, d);
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
assert!(baseline.edges.contains_key(&edge));
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
Some(ports[0])
);
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
Some(target)
);
let mut route = authored(edge);
route.waypoints.clear();
route.label = None;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
}
assert_eq!(routed(&p, d), baseline);
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
hidden.ports.clear();
for edge in &mut hidden.edges {
edge.source_port = None;
edge.target_port = None;
}
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
route_diagram(&original, &original.diagrams[&d], &hidden),
"filtered direct Port endpoints must not activate their stored preferences"
);
}
+290
View File
@@ -0,0 +1,290 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::{Value, json};
use std::collections::BTreeMap;
const SOURCE: &str = r#"arcadia 7;
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
label { fraction 0.5; offset { x 3; y -7; }; };
source_attachment { side Left; offset 0.25; };
target_attachment { side Bottom; offset 1; };
}];
}
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
}
"#;
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn original() -> Project {
language::parse(SOURCE).unwrap()
}
fn set(diagram: Id, route: EdgeRoute) -> Command {
Command::SetEdgeRoute { diagram, route }
}
#[test]
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
let project = original();
let view = id(&project, "view");
let route = &project.diagrams[&view].routes[0];
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25
})
);
assert_eq!(
route.target_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0
})
);
for text in [SOURCE.to_owned(), language::format(&project)] {
assert_eq!(language::parse(&text).unwrap(), project);
}
assert_eq!(
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
project
);
assert_eq!(
compile_bundle(&format_bundle(&project), ProjectLimits::default())
.unwrap()
.decoded
.project,
project
);
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
assert_eq!(automatic.source_attachment, None);
assert_eq!(automatic.target_attachment, None);
let encoded = serde_json::to_value(automatic).unwrap();
assert!(encoded.get("source_attachment").is_none());
assert!(encoded.get("target_attachment").is_none());
let mut with_null = encoded.clone();
with_null["source_attachment"] = Value::Null;
with_null["target_attachment"] = Value::Null;
assert_eq!(
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
*automatic
);
assert_eq!(
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
*automatic
);
// Old source without either new field still means automatic presentation.
let old = SOURCE
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
let old = language::parse(&old).unwrap();
assert!(
old.diagrams
.values()
.flat_map(|d| &d.routes)
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
);
assert!(!language::format(&old).contains("_attachment"));
assert_eq!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
old
);
}
#[test]
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
let project = original();
let edge = id(&project, "signal");
for bad in [
json!({}),
json!({"side":"Top"}),
json!({"offset":0.5}),
json!({"side":"Diagonal","offset":0.5}),
json!({"side":"Top","offset":null}),
json!({"side":"Top","offset":0.5,"unknown":true}),
json!({"side":"Top","offset":"NaN"}),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route = json!({"edge":edge});
route[field] = bad.clone();
assert!(
serde_json::from_value::<EdgeRoute>(route).is_err(),
"{field}: {bad}"
);
}
}
for bad in [
"{}",
"{ side Top; }",
"{ offset 0.5; }",
"{ side Diagonal; offset 0.5; }",
"{ side Top; offset null; }",
"{ side Top; offset 0.5; unknown true; }",
"{ side Top; offset -0.01; }",
"{ side Top; offset 1.01; }",
"{ side Top; offset NaN; }",
"{ side Top; offset 1e999; }",
] {
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
assert!(language::parse(&source).is_err(), "{bad}");
let bundle = SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
"{bad}"
);
}
for version in [1, 2] {
let legacy =
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
assert!(
language::parse(&legacy).is_err(),
"legacy route extension must not be silently admitted"
);
}
}
#[test]
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
let project = original();
let view = id(&project, "view");
let first = id(&project, "first");
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut route = project.diagrams[&view].routes[0].clone();
route.set_attachment(
end,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset,
}),
);
let mut session = Session::new(project.clone());
assert!(
session
.execute(Command::Batch(vec![
Command::Rename(first, "Must roll back".into()),
set(view, route)
]))
.is_err()
);
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && !session.can_redo());
}
}
for offset in [-0.01, 1.01] {
let mut value = serde_json::to_value(&project).unwrap();
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
json!(offset);
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
}
}
#[test]
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
let project = original();
let view = id(&project, "view");
let edge = id(&project, "signal");
let mut session = Session::new(project.clone());
let mut route = project.diagrams[&view].routes[0].clone();
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.75,
});
let command = set(view, route.clone());
assert_eq!(
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
command
);
session.execute(command).unwrap();
let mut expected = project.clone();
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
assert_eq!(
session.project(),
&expected,
"only this view/end changes; semantic fields and other view stay exact"
);
session.undo();
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && session.can_redo());
session.redo();
assert_eq!(session.project(), &expected);
route.source_attachment = None;
session.execute(set(view, route.clone())).unwrap();
let mut reset = expected.clone();
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
assert_eq!(
session.project(),
&reset,
"opposite attachment, pins and label survive endpoint reset"
);
session.undo();
assert_eq!(session.project(), &expected);
session
.execute(Command::RemoveEdgeRoute {
diagram: view,
edge,
})
.unwrap();
let mut removed = expected.clone();
removed.diagrams.get_mut(&view).unwrap().routes.clear();
assert_eq!(session.project(), &removed);
session.undo();
assert_eq!(session.project(), &expected);
}
#[test]
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
let current = compile_bundle(&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
]),
}, ProjectLimits::default()).unwrap();
let mut project = current.decoded.project.clone();
let view = id(&project, "view");
project.diagrams.get_mut(&view).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let changed = update_bundle(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(changed.decoded.project, project);
for path in ["p.arcadia", "model.arcadia"] {
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
}
assert_eq!(
changed.bundle.files["views.arcadia"],
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
);
assert_eq!(
compile_bundle(&changed.bundle, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
assert_eq!(
update_bundle(&changed, &project, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
}
+2
View File
@@ -441,6 +441,8 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
diagram: d,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pos(500., 420.)],
label: None,
},
+4
View File
@@ -70,6 +70,8 @@ fn fixture() -> Fixture {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -689,6 +691,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
EdgeRoute {
edge: f.edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None
}
+2
View File
@@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView {
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,
});
+2
View File
@@ -117,6 +117,8 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
.routes
.push(EdgeRoute {
edge: fe,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(authored.clone()),
});
+16
View File
@@ -301,6 +301,8 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
let pins = vec![point(690., 480.), point(690., 230.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: out,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -362,11 +364,15 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
let pins = vec![point(570., 460.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(label.clone()),
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.external,
source_attachment: None,
target_attachment: None,
waypoints: vec![point(690., 230.)],
label: None,
});
@@ -422,6 +428,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
let placement = f.p.place(f.d, orphan, point(30., 750.));
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
@@ -456,6 +464,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
diagram: other,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
},
@@ -583,6 +593,8 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
let pins = vec![point(730., 130.), point(1060., 130.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(EdgeLabel {
fraction: 0.25,
@@ -652,6 +664,8 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
let pins = vec![point(280., 390.), point(280., 470.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -730,6 +744,8 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
);
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(EdgeLabel {
fraction: 0.3,