Add persisted per-view port anchors and connection-aware border placement
This commit is contained in:
@@ -0,0 +1,816 @@
|
||||
use oa_core::{language, persistence, projection::*, routing::*, source_project::*, *};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, 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, "Information", a, b);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = p.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let na = p.place(d, a, pos(100., 100.));
|
||||
let nb = p.place(d, b, pos(900., 100.));
|
||||
(p, d, na, nb, source_port, target_port, edge)
|
||||
}
|
||||
fn manual(p: &mut Project, d: Id, n: Id, port: Id, side: PortAnchorSide, offset: f32) {
|
||||
let prepared = prepare_command(
|
||||
p,
|
||||
&Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor { port, side, offset },
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(prepared.selected, Some(port));
|
||||
*p = prepared.project;
|
||||
}
|
||||
fn port(view: &ProjectedView, id: Id) -> &ProjectedPort {
|
||||
view.ports.iter().find(|p| p.element == id).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_side_uses_exact_final_border_fraction_and_outward_routing_normal() {
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let (mut p, d, na, _, a, _, edge) = fixture();
|
||||
let before = project_diagram(&p, &p.diagrams[&d]);
|
||||
manual(&mut p, d, na, a, side, 0.3);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(
|
||||
view.nodes, before.nodes,
|
||||
"A port drag must not resize or move its owner"
|
||||
);
|
||||
let node = view.nodes.iter().find(|n| n.id == na).unwrap();
|
||||
let anchor = port(&view, a);
|
||||
assert_eq!(anchor.side, side);
|
||||
assert_eq!(anchor.position, port_anchor_position(node, side, 0.3));
|
||||
assert!((port_anchor_offset(node, side, anchor.position) - 0.3).abs() < 0.00001);
|
||||
let route = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
let points = &route.edges[&edge].points;
|
||||
assert_eq!(points[0], anchor.position);
|
||||
let normal = side.normal();
|
||||
let delta = pos(points[1].x - points[0].x, points[1].y - points[0].y);
|
||||
assert!(
|
||||
delta.x * normal.x + delta.y * normal.y >= PORT_STUB - 0.001,
|
||||
"{side:?}: {points:?}"
|
||||
);
|
||||
assert!(
|
||||
points
|
||||
.windows(2)
|
||||
.all(|w| w[0].x == w[1].x || w[0].y == w[1].y)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_connected_ports_face_peers_and_unconnected_ports_use_four_sides() {
|
||||
for (peer, expected) in [
|
||||
(pos(-700., 100.), PortAnchorSide::Left),
|
||||
(pos(900., 100.), PortAnchorSide::Right),
|
||||
(pos(100., -700.), PortAnchorSide::Top),
|
||||
(pos(100., 900.), PortAnchorSide::Bottom),
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Partner", None)
|
||||
.unwrap();
|
||||
let mut ports = Vec::new();
|
||||
for owner in [a, b] {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Boundary",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
ports.push(id);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Connection",
|
||||
ports[0],
|
||||
ports[1],
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, a, pos(100., 100.));
|
||||
p.place(d, b, peer);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(port(&view, ports[0]).side, expected);
|
||||
let before = view.ports.clone();
|
||||
for element in p.elements.values_mut() {
|
||||
element.alias = format!("renamed_{}", element.id.simple());
|
||||
}
|
||||
assert_eq!(project_diagram(&p, &p.diagrams[&d]).ports, before);
|
||||
p.relations.clear();
|
||||
for i in 0..3 {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
a,
|
||||
&format!("Spare {i}"),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let sides: std::collections::BTreeSet<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|port| p.elements[&port.element].parent == Some(a))
|
||||
.map(|port| port.side)
|
||||
.collect();
|
||||
assert_eq!(sides.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_upsert_reset_and_undo_preserve_unrelated_node_fields_and_list_order() {
|
||||
let (mut p, d, n, _, a, _, _) = fixture();
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let extra = Id::new_v4();
|
||||
let spare = make_port(
|
||||
&p,
|
||||
extra,
|
||||
owner,
|
||||
"Spare",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(extra, spare);
|
||||
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.2);
|
||||
manual(&mut p, d, n, extra, PortAnchorSide::Bottom, 0.7);
|
||||
let before = p.clone();
|
||||
let mut session = Session::new(p);
|
||||
session
|
||||
.execute(Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor {
|
||||
port: a,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.45,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
let changed = session.project().clone();
|
||||
let anchors = &changed.diagrams[&d].nodes[&n].port_anchors;
|
||||
assert_eq!(
|
||||
anchors.iter().map(|a| a.port).collect::<Vec<_>>(),
|
||||
[a, extra]
|
||||
);
|
||||
assert_eq!(anchors[1], before.diagrams[&d].nodes[&n].port_anchors[1]);
|
||||
assert_eq!(
|
||||
changed.diagrams[&d].nodes[&n].position,
|
||||
before.diagrams[&d].nodes[&n].position
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &changed);
|
||||
session
|
||||
.execute(Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: a,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session.project().diagrams[&d].nodes[&n].port_anchors.len(),
|
||||
1
|
||||
);
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: a,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_anchors_and_payloads_fail_atomically_including_duplicate_owner_placements() {
|
||||
let (p, d, n, _, port, other_port, _) = fixture();
|
||||
for (port, offset) in [
|
||||
(port, f32::NAN),
|
||||
(port, f32::INFINITY),
|
||||
(port, -0.1),
|
||||
(port, 1.1),
|
||||
(other_port, 0.5),
|
||||
(Id::new_v4(), 0.5),
|
||||
(p.diagrams[&d].nodes[&n].element, 0.5),
|
||||
] {
|
||||
let mut session = Session::new(p.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Left,
|
||||
offset
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), 0);
|
||||
}
|
||||
let anchor = json!({"port": port, "side": "Top", "offset": 0.5});
|
||||
for bad in [
|
||||
json!({"port":port,"side":"Top"}),
|
||||
json!({"port":port,"side":"Diagonal","offset":0.5}),
|
||||
json!({"port":port,"side":"Top","offset":0.5,"owner":n}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<PortAnchor>(bad).is_err());
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<Command>(
|
||||
json!({"SetPortAnchor":{"diagram":d,"node":n,"anchor":anchor,"unknown":true}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut duplicate = p.clone();
|
||||
let value = PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.5,
|
||||
};
|
||||
duplicate
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&n)
|
||||
.unwrap()
|
||||
.port_anchors = vec![value.clone(), value];
|
||||
assert!(prepare_command(&p, &Command::Replace(duplicate)).is_err());
|
||||
let mut duplicate = p.diagrams[&d].nodes[&n].clone();
|
||||
duplicate.id = Id::new_v4();
|
||||
duplicate.alias = "duplicate_view".into();
|
||||
assert!(
|
||||
prepare_command(
|
||||
&p,
|
||||
&Command::CreateNode {
|
||||
diagram: d,
|
||||
node: duplicate
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentification_deletion_reuse_and_owner_repair_preserve_correct_identity() {
|
||||
let (mut p, d, n, target, _, _, _) = fixture();
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let id = Id::new_v4();
|
||||
let value = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Spare output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, value.clone());
|
||||
manual(&mut p, d, n, id, PortAnchorSide::Top, 0.25);
|
||||
let new_id = Id::new_v4();
|
||||
let renamed = prepare_command(&p, &Command::Reidentify(id, new_id))
|
||||
.unwrap()
|
||||
.project;
|
||||
assert_eq!(renamed.diagrams[&d].nodes[&n].port_anchors[0].port, new_id);
|
||||
let reused = prepare_command(
|
||||
&p,
|
||||
&Command::Batch(vec![Command::Delete(id), Command::CreateElement(value)]),
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(reused.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
let parent = Some(p.diagrams[&d].nodes[&target].element);
|
||||
assert!(
|
||||
prepare_command(
|
||||
&p,
|
||||
&Command::Reparent {
|
||||
element: id,
|
||||
parent
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut edited = p.elements[&id].clone();
|
||||
edited.parent = parent;
|
||||
assert!(prepare_command(&p, &Command::EditElement(id, edited.clone())).is_err());
|
||||
let repaired = prepare_command(
|
||||
&p,
|
||||
&Command::Batch(vec![
|
||||
Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: id,
|
||||
},
|
||||
Command::EditElement(id, edited),
|
||||
]),
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(repaired.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
assert_eq!(repaired.elements[&id].parent, parent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_views_resize_layout_and_hidden_owners_keep_manual_metadata() {
|
||||
let (mut p, d, n, _, a, _, _) = fixture();
|
||||
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.35);
|
||||
let mut second = p.diagrams[&d].clone();
|
||||
second.id = Id::new_v4();
|
||||
second.alias = "review_view".into();
|
||||
second.nodes.clear();
|
||||
let other_d = second.id;
|
||||
p.diagrams.insert(second.id, second);
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let other_n = p.place(other_d, owner, pos(50., 50.));
|
||||
manual(&mut p, other_d, other_n, a, PortAnchorSide::Bottom, 0.8);
|
||||
let original = p.diagrams[&d].nodes[&n].port_anchors.clone();
|
||||
let mut edited = p.diagrams[&d].nodes[&n].clone();
|
||||
edited.size = Size {
|
||||
width: 600.,
|
||||
height: 450.,
|
||||
};
|
||||
edited.position = pos(200., 250.);
|
||||
p = prepare_command(
|
||||
&p,
|
||||
&Command::EditNode {
|
||||
diagram: d,
|
||||
original: n,
|
||||
node: edited,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(port(&view, a).position, pos(410., 250.));
|
||||
p = prepare_command(&p, &Command::Layout(d)).unwrap().project;
|
||||
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
|
||||
assert_eq!(
|
||||
p.diagrams[&other_d].nodes[&other_n].port_anchors[0].side,
|
||||
PortAnchorSide::Bottom
|
||||
);
|
||||
let mut diagram = p.diagrams[&d].clone();
|
||||
diagram.projection = ProjectionKind::Capabilities;
|
||||
p = prepare_command(&p, &Command::EditDiagram(d, diagram))
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(project_diagram(&p, &p.diagrams[&d]).ports.is_empty());
|
||||
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometry() {
|
||||
let (mut p, d, na, nb, a, b, edge) = fixture();
|
||||
let parent = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Group", None)
|
||||
.unwrap();
|
||||
for node in [na, nb] {
|
||||
let element = p.diagrams[&d].nodes[&node].element;
|
||||
p.elements.get_mut(&element).unwrap().parent = Some(parent);
|
||||
}
|
||||
let group = p.place(d, parent, pos(50., 0.));
|
||||
let obstacle = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Sibling obstacle", Some(parent))
|
||||
.unwrap();
|
||||
let obstacle_node = p.place(d, obstacle, pos(440., 160.));
|
||||
let group_port = Id::new_v4();
|
||||
let element = make_port(
|
||||
&p,
|
||||
group_port,
|
||||
parent,
|
||||
"Group input",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(group_port, element);
|
||||
manual(&mut p, d, group, group_port, PortAnchorSide::Top, 0.25);
|
||||
manual(&mut p, d, na, a, PortAnchorSide::Bottom, 0.5);
|
||||
manual(&mut p, d, nb, b, PortAnchorSide::Bottom, 0.5);
|
||||
p = prepare_command(
|
||||
&p,
|
||||
&Command::SetEdgeRoute {
|
||||
diagram: d,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![pos(500., 420.)],
|
||||
label: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let parent_body = view.nodes.iter().find(|n| n.id == group).unwrap();
|
||||
assert!(parent_body.size.width > p.diagrams[&d].nodes[&group].size.width);
|
||||
assert_eq!(
|
||||
port(&view, group_port).position,
|
||||
port_anchor_position(parent_body, PortAnchorSide::Top, 0.25)
|
||||
);
|
||||
let routes = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
assert!(
|
||||
routes.edges[&edge].warnings.is_empty(),
|
||||
"{:?}",
|
||||
routes.edges[&edge]
|
||||
);
|
||||
let obstacle = view.nodes.iter().find(|n| n.id == obstacle_node).unwrap();
|
||||
for pair in routes.edges[&edge].points.windows(2) {
|
||||
let [a, b] = [pair[0], pair[1]];
|
||||
let crosses = if a.x == b.x {
|
||||
a.x > obstacle.position.x
|
||||
&& a.x < obstacle.position.x + obstacle.size.width
|
||||
&& a.y.min(b.y) < obstacle.position.y + obstacle.size.height
|
||||
&& a.y.max(b.y) > obstacle.position.y
|
||||
} else {
|
||||
a.y > obstacle.position.y
|
||||
&& a.y < obstacle.position.y + obstacle.size.height
|
||||
&& a.x.min(b.x) < obstacle.position.x + obstacle.size.width
|
||||
&& a.x.max(b.x) > obstacle.position.x
|
||||
};
|
||||
assert!(!crosses);
|
||||
}
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::MoveNode {
|
||||
diagram: d,
|
||||
node: group,
|
||||
position: pos(190., 80.),
|
||||
with_descendants: true,
|
||||
})
|
||||
.unwrap();
|
||||
let moved = session.project();
|
||||
let after = project_diagram(moved, &moved.diagrams[&d]);
|
||||
for before in &view.ports {
|
||||
assert_eq!(
|
||||
port(&after, before.element).position,
|
||||
pos(before.position.x + 140., before.position.y + 80.)
|
||||
);
|
||||
}
|
||||
for (id, node) in &p.diagrams[&d].nodes {
|
||||
assert_eq!(moved.diagrams[&d].nodes[id].port_anchors, node.port_anchors);
|
||||
}
|
||||
assert_eq!(moved.diagrams[&d].routes[0].waypoints, [pos(640., 500.)]);
|
||||
assert!(
|
||||
route_diagram(moved, &moved.diagrams[&d], &after).edges[&edge]
|
||||
.warnings
|
||||
.is_empty()
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_peer_gap_uses_clear_side_instead_of_a_blocked_facing_stub() {
|
||||
let (mut p, d, a, b, output, input, edge) = fixture();
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&b)
|
||||
.unwrap()
|
||||
.position = pos(100., 260.);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let source = view.nodes.iter().find(|n| n.id == a).unwrap();
|
||||
let target = view.nodes.iter().find(|n| n.id == b).unwrap();
|
||||
assert!(target.position.y - source.position.y - source.size.height < PORT_STUB + CLEARANCE);
|
||||
assert_eq!(port(&view, output).side, PortAnchorSide::Right);
|
||||
assert_eq!(port(&view, input).side, PortAnchorSide::Left);
|
||||
let routed = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
assert!(
|
||||
routed.edges[&edge].warnings.is_empty(),
|
||||
"{:?}",
|
||||
routed.edges[&edge]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_neutral_ports_balance_capacity_without_a_giant_bottom_footer() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::System, Layer::Sa, "Many interfaces", None)
|
||||
.unwrap();
|
||||
for i in 0..100 {
|
||||
let id = Id::from_u128(i + 1);
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
&format!("Interface {i}"),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let node = p.place(d, owner, pos(0., 0.));
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(view.ports.len(), 100);
|
||||
assert!(view.nodes[0].size.width < 1000. && view.nodes[0].size.height < 1000.);
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let mut offsets: Vec<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|p| p.side == side)
|
||||
.map(|p| port_anchor_offset(&view.nodes[0], side, p.position))
|
||||
.collect();
|
||||
assert_eq!(offsets.len(), 25);
|
||||
offsets.sort_by(f32::total_cmp);
|
||||
let length = if matches!(side, PortAnchorSide::Left | PortAnchorSide::Right) {
|
||||
view.nodes[0].size.height
|
||||
} else {
|
||||
view.nodes[0].size.width
|
||||
};
|
||||
assert!(offsets.windows(2).all(|w| (w[1] - w[0]) * length >= 27.99));
|
||||
}
|
||||
let id = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.side == PortAnchorSide::Left)
|
||||
.unwrap()
|
||||
.element;
|
||||
manual(&mut p, d, node, id, PortAnchorSide::Top, 0.5);
|
||||
let after = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(after.nodes, view.nodes);
|
||||
let placed = port(&after, id);
|
||||
for other in after.ports.iter().filter(|p| p.element != id) {
|
||||
assert_eq!(other.side, port(&view, other.element).side);
|
||||
let distance = (other.position.x - placed.position.x).abs()
|
||||
+ (other.position.y - placed.position.y).abs();
|
||||
assert!(
|
||||
distance >= 14.,
|
||||
"An automatic port overlaps the authored anchor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SOURCE: &str = r#"arcadia 3;
|
||||
project ports "Ports" {
|
||||
sa_function owner "Owner" {}
|
||||
sa_port first "First" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
|
||||
sa_port second "Second" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
|
||||
diagram view "View" { layer Sa; }
|
||||
node owner_view { diagram @view; element @owner; port_anchors [
|
||||
// first rationale
|
||||
{ port @first; side Top; offset 0.25; },
|
||||
// second rationale
|
||||
{ port @second; side Bottom; offset 0.75; }
|
||||
]; }
|
||||
}
|
||||
"#;
|
||||
fn find(p: &Project, alias: &str) -> Id {
|
||||
p.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_json_roundtrip_defaults_and_admission_include_anchor_records() {
|
||||
let p = language::parse(SOURCE).unwrap();
|
||||
let formatted = language::format(&p);
|
||||
assert!(formatted.contains("port @first;") && formatted.contains("side Top;"));
|
||||
assert_eq!(language::parse(&formatted).unwrap(), p);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&p).unwrap()).unwrap(),
|
||||
p
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(&p), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
p
|
||||
);
|
||||
// project + function + two ports + diagram + node + two anchors = 8.
|
||||
let limits = language::ParseLimits {
|
||||
max_records: 7,
|
||||
..language::ParseLimits::UNLIMITED
|
||||
};
|
||||
assert!(limits.check_model(&p).is_err());
|
||||
assert!(persistence::decode_limited(&serde_json::to_vec(&p).unwrap(), limits).is_err());
|
||||
assert!(language::parse_limited(SOURCE, limits).is_err());
|
||||
assert!(
|
||||
compile_bundle(
|
||||
&format_bundle(&p),
|
||||
ProjectLimits {
|
||||
model: limits,
|
||||
..ProjectLimits::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut value = serde_json::to_value(&p).unwrap();
|
||||
let d = find(&p, "view");
|
||||
let n = find(&p, "owner_view");
|
||||
value["diagrams"][d.to_string()]["nodes"][n.to_string()]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("port_anchors");
|
||||
let read = persistence::decode(&serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
assert!(read.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_updater_keeps_comment_attachment_through_identity_swap_and_anchor_reordering() {
|
||||
let current = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.replace('\n', "\r\n"))]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = ¤t.decoded.project;
|
||||
let d = find(p, "view");
|
||||
let n = find(p, "owner_view");
|
||||
let first = find(p, "first");
|
||||
let second = find(p, "second");
|
||||
let temp = Id::new_v4();
|
||||
let mut commands = vec![
|
||||
Command::Reidentify(first, temp),
|
||||
Command::Reidentify(second, first),
|
||||
Command::Reidentify(temp, second),
|
||||
Command::SetAlias(second, "revised_first".into()),
|
||||
];
|
||||
let candidate = prepare_command(p, &Command::Batch(commands.clone()))
|
||||
.unwrap()
|
||||
.project;
|
||||
let mut node = candidate.diagrams[&d].nodes[&n].clone();
|
||||
node.port_anchors.reverse();
|
||||
node.port_anchors[1].offset = 0.5;
|
||||
commands.push(Command::EditNode {
|
||||
diagram: d,
|
||||
original: n,
|
||||
node,
|
||||
});
|
||||
let prepared = prepare_command(p, &Command::Batch(commands)).unwrap();
|
||||
let result = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = &result.bundle.files["project.arcadia"];
|
||||
assert!(
|
||||
text.contains("// first rationale\r\n { port @revised_first; side Top; offset 0.5; }"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("// second rationale\r\n { port @second; side Bottom; offset 0.75; }"),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(result.decoded.project, prepared.project);
|
||||
assert_eq!(
|
||||
update_bundle(&result, &prepared.project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_record() {
|
||||
let current = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = ¤t.decoded.project;
|
||||
let diagram = find(p, "view");
|
||||
let node = find(p, "owner_view");
|
||||
let first = find(p, "first");
|
||||
let second = find(p, "second");
|
||||
let prepared = prepare_command(
|
||||
p,
|
||||
&Command::Batch(vec![
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port: first,
|
||||
},
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port: second,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let cleared = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cleared.decoded.project, prepared.project);
|
||||
let text = &cleared.bundle.files["project.arcadia"];
|
||||
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
|
||||
assert!(!text.contains("port_anchors"));
|
||||
let prepared = prepare_command(
|
||||
&cleared.decoded.project,
|
||||
&Command::SetPortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
anchor: PortAnchor {
|
||||
port: second,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.5,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let added = update_bundle_with_identity_map(
|
||||
&cleared,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(added.decoded.project, prepared.project);
|
||||
assert_eq!(
|
||||
added.bundle.files["project.arcadia"]
|
||||
.matches("// second rationale")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_source_anchor_records_have_located_errors() {
|
||||
for bad in [
|
||||
"{ port @first; side Top; }",
|
||||
"{ port @first; side Top; offset -0.1; }",
|
||||
"{ port @first; side Corner; offset 0.5; }",
|
||||
"{ port @owner; side Top; offset 0.5; }",
|
||||
"{ port @first; side Top; offset 0.5; unexpected true; }",
|
||||
"{ port @first; side Top; offset 0.1; }, { port @first; side Right; offset 0.2; }",
|
||||
] {
|
||||
let start = SOURCE.find("port_anchors [").unwrap() + "port_anchors [".len();
|
||||
let end = SOURCE[start..].find("]; }").unwrap() + start;
|
||||
let source = format!("{}\n{}\n{}", &SOURCE[..start], bad, &SOURCE[end..]);
|
||||
let errors = language::parse(&source).expect_err(bad);
|
||||
assert!(errors.iter().any(|e| e.line >= 7), "{bad}: {errors:?}");
|
||||
}
|
||||
}
|
||||
@@ -811,16 +811,19 @@ fn projection_anchors_are_contract_aware_and_unoriented_connections_keep_their_i
|
||||
let edge = view.edges.iter().find(|e| e.id == boundary).unwrap();
|
||||
assert!(!edge.oriented);
|
||||
assert_eq!(edge.source_port, Some(p.relations[&boundary].source()));
|
||||
assert!(
|
||||
view.ports
|
||||
.iter()
|
||||
.filter(|port| [
|
||||
p.relations[&boundary].source(),
|
||||
p.relations[&boundary].target()
|
||||
]
|
||||
.contains(&port.element))
|
||||
.all(|port| port.side == PortAnchorSide::Bottom)
|
||||
);
|
||||
for (element, side) in [
|
||||
(p.relations[&boundary].source(), PortAnchorSide::Right),
|
||||
(p.relations[&boundary].target(), PortAnchorSide::Left),
|
||||
] {
|
||||
assert_eq!(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|port| port.element == element)
|
||||
.unwrap()
|
||||
.side,
|
||||
side
|
||||
);
|
||||
}
|
||||
let (output, input) = endpoints(&p, fe);
|
||||
assert_eq!(
|
||||
view.ports
|
||||
|
||||
@@ -357,9 +357,12 @@ fn workshop_automatic_labels_are_readable_without_mutating_authored_routes() {
|
||||
size
|
||||
}
|
||||
),
|
||||
"{} overlaps {}",
|
||||
"{} overlaps {}: label {:?}, node {:?}, path {:?}",
|
||||
label.text,
|
||||
p.named(node.element)
|
||||
p.named(node.element),
|
||||
label.bounds,
|
||||
node,
|
||||
routed.edges[&edge.id].points
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user