Persist diagram routing with atomic edits and grouped movement
This commit is contained in:
@@ -82,6 +82,7 @@ fn diagram(
|
||||
layer,
|
||||
projection,
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
for (index, e) in elements.iter().enumerate() {
|
||||
|
||||
@@ -1241,7 +1241,29 @@ pub struct Diagram {
|
||||
pub projection: ProjectionKind,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub nodes: BTreeMap<Id, NodePlacement>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<EdgeRoute>,
|
||||
}
|
||||
/// Authored presentation for one existing relation or trace in one diagram.
|
||||
/// Computed endpoints and automatic routing elbows are never persisted here.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeRoute {
|
||||
pub edge: Id,
|
||||
#[serde(default)]
|
||||
pub waypoints: Vec<Position>,
|
||||
#[serde(default)]
|
||||
pub label: Option<EdgeLabel>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeLabel {
|
||||
/// Fraction of the computed polyline's total arc length, from source to target.
|
||||
pub fraction: f32,
|
||||
/// Diagram-space displacement relative to the point at `fraction`.
|
||||
pub offset: Position,
|
||||
}
|
||||
pub const MAX_ROUTE_POINTS: usize = 128;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NodePlacement {
|
||||
@@ -1613,6 +1635,7 @@ impl Project {
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
id
|
||||
|
||||
@@ -180,6 +180,59 @@ pub fn descendant_nodes(p: &Project, diagram: Id, node: Id) -> Vec<Id> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Actual endpoint-owner placements before folding promotes a hidden endpoint
|
||||
/// to an ancestor. Ports belong to their placed function/participant; they do not
|
||||
/// acquire independent placement identities. Missing placements are not inferred.
|
||||
pub fn edge_endpoint_placements(p: &Project, d: &Diagram, edge: Id) -> Option<(Id, Id)> {
|
||||
let (source, target) = if let Some(r) = p.relations.get(&edge) {
|
||||
(r.source(), r.target())
|
||||
} else {
|
||||
let trace = p.traces.get(&edge)?;
|
||||
(trace.oa, trace.sa)
|
||||
};
|
||||
let placement = |id| {
|
||||
let element = p.elements.get(&id)?;
|
||||
let owner = if element.kind() == Kind::Port {
|
||||
element.parent?
|
||||
} else {
|
||||
id
|
||||
};
|
||||
d.nodes.values().find(|n| n.element == owner).map(|n| n.id)
|
||||
};
|
||||
Some((placement(source)?, placement(target)?))
|
||||
}
|
||||
|
||||
/// Route updates for a simultaneous placement movement. `deltas` maps placement
|
||||
/// identities to diagram-space displacement. Only routes with two actual owners
|
||||
/// moving by the same nonzero delta translate; one-ended moves keep world pins.
|
||||
/// Returns changed records only, for the caller to apply with its node edits in
|
||||
/// one transaction. This pure helper also drives reflow and drag previews.
|
||||
pub fn translated_routes(
|
||||
p: &Project,
|
||||
d: &Diagram,
|
||||
deltas: &BTreeMap<Id, Position>,
|
||||
) -> Vec<EdgeRoute> {
|
||||
d.routes
|
||||
.iter()
|
||||
.filter_map(|route| {
|
||||
let (source, target) = edge_endpoint_placements(p, d, route.edge)?;
|
||||
let source_delta = deltas.get(&source)?;
|
||||
let target_delta = deltas.get(&target)?;
|
||||
if source_delta != target_delta
|
||||
|| (source_delta.x == 0. && source_delta.y == 0.)
|
||||
|| route.waypoints.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut translated = route.clone();
|
||||
for point in &mut translated.waypoints {
|
||||
point.x += source_delta.x;
|
||||
point.y += source_delta.y;
|
||||
}
|
||||
Some(translated)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// One projection drives drawing, hit testing, Fit, SVG, and grouped movement.
|
||||
pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
||||
let Some(d) = p.diagrams.get(&diagram) else {
|
||||
|
||||
@@ -181,6 +181,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
for route in &mut d.routes {
|
||||
if route.edge == old {
|
||||
route.edge = new;
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
@@ -339,6 +344,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
}
|
||||
let clear = |v: &mut Option<Id>| {
|
||||
if v.is_some_and(|id| removed.contains(&id)) {
|
||||
|
||||
@@ -36,6 +36,14 @@ pub enum Command {
|
||||
original: Id,
|
||||
node: NodePlacement,
|
||||
},
|
||||
SetEdgeRoute {
|
||||
diagram: Id,
|
||||
route: EdgeRoute,
|
||||
},
|
||||
RemoveEdgeRoute {
|
||||
diagram: Id,
|
||||
edge: Id,
|
||||
},
|
||||
RelocateNode {
|
||||
node: Id,
|
||||
diagram: Id,
|
||||
@@ -559,6 +567,28 @@ fn apply_command(
|
||||
.ok_or_else(missing)? = value.clone();
|
||||
Some(*owner)
|
||||
}
|
||||
Command::SetEdgeRoute { diagram, route } => {
|
||||
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
|
||||
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
*existing = route.clone();
|
||||
} else {
|
||||
d.routes.push(route.clone());
|
||||
}
|
||||
Some(route.edge)
|
||||
}
|
||||
Command::RemoveEdgeRoute { diagram, edge } => {
|
||||
if !p.relations.contains_key(edge) && !p.traces.contains_key(edge) {
|
||||
return Err(ModelError(
|
||||
"Routing requires an existing relation or trace.".into(),
|
||||
));
|
||||
}
|
||||
p.diagrams
|
||||
.get_mut(diagram)
|
||||
.ok_or_else(missing)?
|
||||
.routes
|
||||
.retain(|route| route.edge != *edge);
|
||||
Some(*edge)
|
||||
}
|
||||
Command::MoveNode {
|
||||
diagram,
|
||||
node,
|
||||
@@ -570,12 +600,19 @@ fn apply_command(
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
|
||||
let n = d.nodes.get_mut(node).ok_or_else(missing)?;
|
||||
let d = p.diagrams.get(diagram).ok_or_else(missing)?;
|
||||
let n = d.nodes.get(node).ok_or_else(missing)?;
|
||||
let delta = Position {
|
||||
x: position.x - n.position.x,
|
||||
y: position.y - n.position.y,
|
||||
};
|
||||
let deltas = std::iter::once(*node)
|
||||
.chain(descendants.iter().copied())
|
||||
.map(|id| (id, delta))
|
||||
.collect();
|
||||
let routes = crate::projection::translated_routes(p, d, &deltas);
|
||||
let d = p.diagrams.get_mut(diagram).unwrap();
|
||||
let n = d.nodes.get_mut(node).unwrap();
|
||||
n.position = *position;
|
||||
for id in descendants {
|
||||
if let Some(child) = d.nodes.get_mut(&id) {
|
||||
@@ -583,6 +620,11 @@ fn apply_command(
|
||||
child.position.y += delta.y;
|
||||
}
|
||||
}
|
||||
for route in routes {
|
||||
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
*existing = route;
|
||||
}
|
||||
}
|
||||
Some(*node)
|
||||
}
|
||||
Command::Layout(diagram) => {
|
||||
@@ -817,11 +859,30 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
|
||||
x += sizes[id].width + 240.;
|
||||
row_height = row_height.max(sizes[id].height);
|
||||
}
|
||||
let deltas = positions
|
||||
.iter()
|
||||
.map(|(id, position)| {
|
||||
let original = d.nodes[id].position;
|
||||
(
|
||||
*id,
|
||||
Position {
|
||||
x: position.x - original.x,
|
||||
y: position.y - original.y,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let routes = crate::projection::translated_routes(p, d, &deltas);
|
||||
let d = p.diagrams.get_mut(&diagram).unwrap();
|
||||
for (id, position) in positions {
|
||||
let n = d.nodes.get_mut(&id).unwrap();
|
||||
n.position = position;
|
||||
n.size = sizes[&id];
|
||||
}
|
||||
for route in routes {
|
||||
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
*existing = route;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -657,6 +657,78 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, d.id, "name", &d.name);
|
||||
text(&mut out, d.id, "description", &d.description);
|
||||
let mut routed_edges = BTreeSet::new();
|
||||
for (index, route) in d.routes.iter().enumerate() {
|
||||
if !routed_edges.insert(route.edge) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"duplicate_edge_route",
|
||||
"routes",
|
||||
"Each relation or trace has at most one route per diagram.",
|
||||
);
|
||||
}
|
||||
if !p.relations.contains_key(&route.edge) && !p.traces.contains_key(&route.edge) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"route_edge",
|
||||
"routes",
|
||||
"Route edge must reference an existing relation or trace.",
|
||||
);
|
||||
}
|
||||
if route.waypoints.len() > MAX_ROUTE_POINTS {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"route_point_limit",
|
||||
"routes",
|
||||
format!("A route supports at most {MAX_ROUTE_POINTS} pinned points."),
|
||||
);
|
||||
}
|
||||
for (point_index, point) in route.waypoints.iter().enumerate() {
|
||||
for (axis, value) in [("x", point.x), ("y", point.y)] {
|
||||
if !value.is_finite() || value.abs() > MAX_COORDINATE {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_route_bounds",
|
||||
"routes",
|
||||
format!(
|
||||
"Route {} point {} {axis} must be finite and bounded.",
|
||||
index + 1,
|
||||
point_index + 1
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(label) = &route.label {
|
||||
if !label.fraction.is_finite() || !(0. ..=1.).contains(&label.fraction) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_route_fraction",
|
||||
"routes",
|
||||
"Label fraction must be between zero and one.",
|
||||
);
|
||||
}
|
||||
for (axis, value) in [("x", label.offset.x), ("y", label.offset.y)] {
|
||||
if !value.is_finite() || value.abs() > MAX_COORDINATE {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_route_bounds",
|
||||
"routes",
|
||||
format!(
|
||||
"Route {} label offset {axis} must be finite and bounded.",
|
||||
index + 1
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut elements = BTreeSet::new();
|
||||
for (key, n) in &d.nodes {
|
||||
if *key != n.id {
|
||||
|
||||
@@ -57,7 +57,11 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
+ old["artifact_links"].as_object().unwrap().len()
|
||||
+ 1
|
||||
);
|
||||
let current = serde_json::to_value(&decoded.project).unwrap();
|
||||
let mut current = serde_json::to_value(&decoded.project).unwrap();
|
||||
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
assert_eq!(diagram["routes"], json!([]), "V2 adds no authored routing");
|
||||
diagram.as_object_mut().unwrap().remove("routes");
|
||||
}
|
||||
for field in [
|
||||
"id",
|
||||
"alias",
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
use oa_core::{projection, validation, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
second: Id,
|
||||
group: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
edge: Id,
|
||||
external: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let group = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Manage", None)
|
||||
.unwrap();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", Some(group))
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", Some(group))
|
||||
.unwrap();
|
||||
let c = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Outside", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Internal message", a, b);
|
||||
let external = p.relate(RelationKind::FunctionalExchange, "External message", b, c);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
let second = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
second,
|
||||
Diagram {
|
||||
id: second,
|
||||
alias: "alternate_view".into(),
|
||||
name: "Alternate view".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Overview,
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
for d in [diagram, second] {
|
||||
for (element, x, y) in [
|
||||
(group, 100., 100.),
|
||||
(a, 140., 180.),
|
||||
(b, 140., 320.),
|
||||
(c, 900., 180.),
|
||||
] {
|
||||
p.place(d, element, Position { x, y });
|
||||
}
|
||||
}
|
||||
validate_candidate(&p).unwrap();
|
||||
Fixture {
|
||||
project: p,
|
||||
diagram,
|
||||
second,
|
||||
group,
|
||||
a,
|
||||
b,
|
||||
edge,
|
||||
external,
|
||||
}
|
||||
}
|
||||
|
||||
fn route(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: Position { x: 3., y: -16. },
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn node(p: &Project, diagram: Id, element: Id) -> Id {
|
||||
p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == element)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
fn routed(mut f: Fixture) -> Fixture {
|
||||
for d in [f.diagram, f.second] {
|
||||
f.project.diagrams.get_mut(&d).unwrap().routes = vec![route(f.edge), route(f.external)];
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_edits_are_one_history_step_and_identical_edits_preserve_redo() {
|
||||
let f = fixture();
|
||||
let mut session = Session::new(f.project.clone());
|
||||
let command = Command::SetEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
route: route(f.edge),
|
||||
};
|
||||
session.execute(command.clone()).unwrap();
|
||||
let authored = session.project().clone();
|
||||
assert_eq!(authored.identities(), f.project.identities());
|
||||
assert_eq!(authored.elements, f.project.elements);
|
||||
assert_eq!(authored.relations, f.project.relations);
|
||||
assert_eq!(authored.diagrams[&f.second], f.project.diagrams[&f.second]);
|
||||
assert_eq!(session.revision(), 1);
|
||||
session.execute(command.clone()).unwrap();
|
||||
assert_eq!(
|
||||
session.revision(),
|
||||
1,
|
||||
"Identical route has no history entry"
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::RemoveEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
edge: f.edge,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), revision);
|
||||
assert!(session.can_redo(), "No-op reset must retain undone route");
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &authored);
|
||||
let mut moved = route(f.edge);
|
||||
moved.waypoints[0].x += 50.;
|
||||
session
|
||||
.execute(Command::SetEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
route: moved.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.project().diagrams[&f.diagram].routes, vec![moved]);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &authored);
|
||||
session
|
||||
.execute(Command::RemoveEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
edge: f.edge,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &authored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_upsert_preserves_other_entries_and_accepts_an_explicit_empty_default() {
|
||||
let f = routed(fixture());
|
||||
let mut edited = route(f.edge);
|
||||
edited.waypoints.clear();
|
||||
edited.label = None;
|
||||
let result = execute_batch(
|
||||
&f.project,
|
||||
&[Command::SetEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
route: edited.clone(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.diagrams[&f.diagram].routes,
|
||||
vec![edited, route(f.external)]
|
||||
);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&result).unwrap()).unwrap(),
|
||||
result
|
||||
);
|
||||
assert_eq!(result.diagrams[&f.second], f.project.diagrams[&f.second]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_routes_reject_atomically_with_a_located_diagnostic() {
|
||||
let f = fixture();
|
||||
let mut cases = vec![];
|
||||
for edge in [
|
||||
f.a,
|
||||
f.diagram,
|
||||
f.project.id,
|
||||
node(&f.project, f.diagram, f.a),
|
||||
Id::new_v4(),
|
||||
] {
|
||||
cases.push((route(edge), "route_edge"));
|
||||
}
|
||||
for value in [f32::NAN, f32::INFINITY, -f32::INFINITY, MAX_COORDINATE + 1.] {
|
||||
let mut bad = route(f.edge);
|
||||
bad.waypoints[0].x = value;
|
||||
cases.push((bad, "invalid_route_bounds"));
|
||||
let mut bad = route(f.edge);
|
||||
bad.label.as_mut().unwrap().offset.y = value;
|
||||
cases.push((bad, "invalid_route_bounds"));
|
||||
}
|
||||
for fraction in [f32::NAN, f32::INFINITY, -0.1, 1.1] {
|
||||
let mut bad = route(f.edge);
|
||||
bad.label.as_mut().unwrap().fraction = fraction;
|
||||
cases.push((bad, "invalid_route_fraction"));
|
||||
}
|
||||
let mut oversized = route(f.edge);
|
||||
oversized.waypoints = vec![Position { x: 0., y: 0. }; MAX_ROUTE_POINTS + 1];
|
||||
cases.push((oversized, "route_point_limit"));
|
||||
for (bad, expected) in cases {
|
||||
let mut session = Session::new(f.project.clone());
|
||||
let mut candidate = f.project.clone();
|
||||
candidate
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(bad.clone());
|
||||
let issue = validation::validate(&candidate)
|
||||
.into_iter()
|
||||
.find(|i| i.code == expected)
|
||||
.unwrap();
|
||||
assert_eq!(issue.element, Some(f.diagram));
|
||||
assert_eq!(issue.field.as_deref(), Some("routes"));
|
||||
let result = session.execute(Command::Batch(vec![
|
||||
Command::Rename(f.a, "Must roll back".into()),
|
||||
Command::SetEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
route: bad,
|
||||
},
|
||||
]));
|
||||
assert!(result.is_err(), "{expected}");
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert_eq!(session.revision(), 0);
|
||||
assert!(!session.can_undo());
|
||||
}
|
||||
let mut duplicated = f.project.diagrams[&f.diagram].clone();
|
||||
duplicated.routes = vec![route(f.edge), route(f.edge)];
|
||||
let error =
|
||||
execute_batch(&f.project, &[Command::EditDiagram(f.diagram, duplicated)]).unwrap_err();
|
||||
assert!(error.to_string().contains("duplicate_edge_route"));
|
||||
for command in [
|
||||
Command::SetEdgeRoute {
|
||||
diagram: Id::new_v4(),
|
||||
route: route(f.edge),
|
||||
},
|
||||
Command::RemoveEdgeRoute {
|
||||
diagram: Id::new_v4(),
|
||||
edge: f.edge,
|
||||
},
|
||||
Command::RemoveEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
edge: f.a,
|
||||
},
|
||||
] {
|
||||
assert!(execute_batch(&f.project, &[command]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_boundary_values_and_temporarily_hidden_edges_remain_editable() {
|
||||
let f = fixture();
|
||||
for fraction in [0., 1.] {
|
||||
let mut accepted = route(f.edge);
|
||||
accepted.waypoints = vec![
|
||||
Position {
|
||||
x: -MAX_COORDINATE,
|
||||
y: MAX_COORDINATE
|
||||
};
|
||||
MAX_ROUTE_POINTS
|
||||
];
|
||||
accepted.label = Some(EdgeLabel {
|
||||
fraction,
|
||||
offset: Position {
|
||||
x: MAX_COORDINATE,
|
||||
y: -MAX_COORDINATE,
|
||||
},
|
||||
});
|
||||
let mut hidden = f.project.diagrams[&f.diagram].clone();
|
||||
hidden.projection = ProjectionKind::Capabilities;
|
||||
hidden.nodes.clear();
|
||||
hidden.routes = vec![accepted.clone()];
|
||||
let result = execute_batch(&f.project, &[Command::EditDiagram(f.diagram, hidden)]).unwrap();
|
||||
assert_eq!(result.diagrams[&f.diagram].routes, vec![accepted]);
|
||||
assert!(
|
||||
projection::project_view(&result, f.diagram)
|
||||
.edges
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_movement_translates_internal_routes_once_and_keeps_external_pins() {
|
||||
for collapsed in [false, true] {
|
||||
let mut f = routed(fixture());
|
||||
let parent_node = node(&f.project, f.diagram, f.group);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent_node)
|
||||
.unwrap()
|
||||
.collapsed = collapsed;
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session
|
||||
.execute(Command::MoveNode {
|
||||
diagram: f.diagram,
|
||||
node: parent_node,
|
||||
position: Position { x: 130., y: 145. },
|
||||
with_descendants: true,
|
||||
})
|
||||
.unwrap();
|
||||
let moved = session.project();
|
||||
for child in [f.a, f.b] {
|
||||
let placement = node(moved, f.diagram, child);
|
||||
let old = f.project.diagrams[&f.diagram].nodes[&placement].position;
|
||||
assert_eq!(
|
||||
moved.diagrams[&f.diagram].nodes[&placement].position,
|
||||
Position {
|
||||
x: old.x + 30.,
|
||||
y: old.y + 45.
|
||||
}
|
||||
);
|
||||
}
|
||||
let internal = &moved.diagrams[&f.diagram].routes[0];
|
||||
assert_eq!(
|
||||
internal.waypoints,
|
||||
vec![Position { x: 580., y: 285. }, Position { x: 580., y: 405. }]
|
||||
);
|
||||
assert_eq!(internal.label, route(f.edge).label);
|
||||
assert_eq!(moved.diagrams[&f.diagram].routes[1], route(f.external));
|
||||
assert_eq!(moved.diagrams[&f.second], f.project.diagrams[&f.second]);
|
||||
assert_eq!(moved.elements, f.project.elements);
|
||||
assert_eq!(moved.relations, f.project.relations);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
session.redo();
|
||||
assert_eq!(
|
||||
session.project().diagrams[&f.diagram].routes[0].waypoints[0],
|
||||
Position { x: 580., y: 285. }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_endpoint_raw_edits_and_differing_group_deltas_keep_world_pins() {
|
||||
let f = routed(fixture());
|
||||
let a_node = node(&f.project, f.diagram, f.a);
|
||||
let b_node = node(&f.project, f.diagram, f.b);
|
||||
let parent_node = node(&f.project, f.diagram, f.group);
|
||||
for (node, with_descendants) in [(a_node, true), (parent_node, false)] {
|
||||
let moved = execute_batch(
|
||||
&f.project,
|
||||
&[Command::MoveNode {
|
||||
diagram: f.diagram,
|
||||
node,
|
||||
position: Position { x: 260., y: 260. },
|
||||
with_descendants,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
moved.diagrams[&f.diagram].routes,
|
||||
f.project.diagrams[&f.diagram].routes
|
||||
);
|
||||
}
|
||||
let mut changed = f.project.diagrams[&f.diagram].nodes[&a_node].clone();
|
||||
changed.position = Position { x: 270., y: 290. };
|
||||
let edited = execute_batch(
|
||||
&f.project,
|
||||
&[Command::EditNode {
|
||||
diagram: f.diagram,
|
||||
original: a_node,
|
||||
node: changed,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
edited.diagrams[&f.diagram].routes,
|
||||
f.project.diagrams[&f.diagram].routes
|
||||
);
|
||||
let deltas = BTreeMap::from([
|
||||
(a_node, Position { x: 10., y: 20. }),
|
||||
(b_node, Position { x: 10., y: 21. }),
|
||||
]);
|
||||
assert!(
|
||||
projection::translated_routes(&f.project, &f.project.diagrams[&f.diagram], &deltas)
|
||||
.is_empty()
|
||||
);
|
||||
let deltas = BTreeMap::from([
|
||||
(a_node, Position { x: 0., y: 0. }),
|
||||
(b_node, Position { x: 0., y: 0. }),
|
||||
]);
|
||||
assert!(
|
||||
projection::translated_routes(&f.project, &f.project.diagrams[&f.diagram], &deltas)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_ports_use_actual_owners_in_movement_and_folded_lookup() {
|
||||
let mut p = Project::empty();
|
||||
let parent = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let outside = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Partner", None)
|
||||
.unwrap();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
p.relate(RelationKind::Allocation, "Send owner", parent, a);
|
||||
p.relate(RelationKind::Allocation, "Receive owner", parent, b);
|
||||
let mut ports = vec![];
|
||||
for (owner, name) in [(parent, "First interface"), (outside, "Second interface")] {
|
||||
let port = make_port(
|
||||
&p,
|
||||
Id::new_v4(),
|
||||
owner,
|
||||
name,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
ports.push(port.id);
|
||||
p.elements.insert(port.id, port);
|
||||
}
|
||||
let edge = p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Local boundary",
|
||||
ports[0],
|
||||
ports[1],
|
||||
);
|
||||
let fe = p.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Local function message",
|
||||
a,
|
||||
b,
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
|
||||
let pn = p.place(d, parent, Position { x: 100., y: 100. });
|
||||
let on = p.place(d, outside, Position { x: 900., y: 100. });
|
||||
let an = p.place(d, a, Position { x: 130., y: 170. });
|
||||
let bn = p.place(d, b, Position { x: 130., y: 270. });
|
||||
p.diagrams.get_mut(&d).unwrap().routes = vec![route(edge), route(fe)];
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&pn)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
validate_candidate(&p).unwrap();
|
||||
assert_eq!(
|
||||
projection::edge_endpoint_placements(&p, &p.diagrams[&d], edge),
|
||||
Some((pn, on))
|
||||
);
|
||||
assert_eq!(
|
||||
projection::edge_endpoint_placements(&p, &p.diagrams[&d], fe),
|
||||
Some((an, bn))
|
||||
);
|
||||
let moved = execute_batch(
|
||||
&p,
|
||||
&[Command::MoveNode {
|
||||
diagram: d,
|
||||
node: pn,
|
||||
position: Position { x: 120., y: 130. },
|
||||
with_descendants: true,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(moved.diagrams[&d].routes[0], route(edge));
|
||||
assert_eq!(
|
||||
moved.diagrams[&d].routes[1].waypoints[0],
|
||||
Position { x: 570., y: 270. }
|
||||
);
|
||||
assert_eq!(
|
||||
moved.diagrams[&d].routes[1].waypoints[1],
|
||||
Position { x: 570., y: 390. }
|
||||
);
|
||||
let deltas = BTreeMap::from([
|
||||
(pn, Position { x: 20., y: 30. }),
|
||||
(on, Position { x: 20., y: 30. }),
|
||||
]);
|
||||
let translated = projection::translated_routes(&p, &p.diagrams[&d], &deltas);
|
||||
assert_eq!(translated.len(), 1);
|
||||
assert_eq!(translated[0].edge, edge);
|
||||
assert_eq!(translated[0].waypoints[0], Position { x: 570., y: 270. });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentify_and_delete_preserve_or_clean_routes_with_the_semantic_edge() {
|
||||
let f = routed(fixture());
|
||||
let renamed = Id::new_v4();
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session
|
||||
.execute(Command::Reidentify(f.edge, renamed))
|
||||
.unwrap();
|
||||
for d in [f.diagram, f.second] {
|
||||
let r = &session.project().diagrams[&d].routes[0];
|
||||
assert_eq!(r.edge, renamed);
|
||||
assert_eq!(r.waypoints, route(f.edge).waypoints);
|
||||
}
|
||||
session.execute(Command::Delete(renamed)).unwrap();
|
||||
for d in [f.diagram, f.second] {
|
||||
assert_eq!(
|
||||
session.project().diagrams[&d].routes,
|
||||
vec![route(f.external)]
|
||||
);
|
||||
}
|
||||
session.undo();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
session.execute(Command::Delete(f.a)).unwrap();
|
||||
assert!(!session.project().relations.contains_key(&f.edge));
|
||||
for d in [f.diagram, f.second] {
|
||||
assert_eq!(
|
||||
session.project().diagrams[&d].routes,
|
||||
vec![route(f.external)]
|
||||
);
|
||||
}
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
session.execute(Command::Delete(f.diagram)).unwrap();
|
||||
assert_eq!(
|
||||
session.project().diagrams[&f.second],
|
||||
f.project.diagrams[&f.second]
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_or_relocating_a_node_does_not_erase_route_metadata() {
|
||||
let f = routed(fixture());
|
||||
let an = node(&f.project, f.diagram, f.a);
|
||||
let hidden = execute_batch(&f.project, &[Command::Delete(an)]).unwrap();
|
||||
assert_eq!(
|
||||
hidden.diagrams[&f.diagram].routes,
|
||||
f.project.diagrams[&f.diagram].routes
|
||||
);
|
||||
assert_eq!(
|
||||
projection::edge_endpoint_placements(&hidden, &hidden.diagrams[&f.diagram], f.edge),
|
||||
None
|
||||
);
|
||||
let target = Id::new_v4();
|
||||
let mut empty = f.project.diagrams[&f.second].clone();
|
||||
empty.id = target;
|
||||
empty.alias = "empty_view".into();
|
||||
empty.nodes.clear();
|
||||
empty.routes.clear();
|
||||
let relocated = execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::CreateDiagram(empty),
|
||||
Command::RelocateNode {
|
||||
node: an,
|
||||
diagram: target,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
relocated.diagrams[&f.diagram].routes,
|
||||
f.project.diagrams[&f.diagram].routes
|
||||
);
|
||||
assert!(relocated.diagrams[&target].routes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_routes_follow_trace_identity_and_cleanup_without_extra_objects() {
|
||||
let mut f = fixture();
|
||||
let oa = f
|
||||
.project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational send", None)
|
||||
.unwrap();
|
||||
let id = Id::new_v4();
|
||||
f.project.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: "send_trace".into(),
|
||||
name: "Send refinement".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa: f.a,
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let oa_node = f.project.place(f.diagram, oa, Position { x: 0., y: 0. });
|
||||
let a_node = node(&f.project, f.diagram, f.a);
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session
|
||||
.execute(Command::SetEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
route: route(id),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
projection::edge_endpoint_placements(
|
||||
session.project(),
|
||||
&session.project().diagrams[&f.diagram],
|
||||
id
|
||||
),
|
||||
Some((oa_node, a_node))
|
||||
);
|
||||
let original = session.project().clone();
|
||||
let renamed = Id::new_v4();
|
||||
session.execute(Command::Reidentify(id, renamed)).unwrap();
|
||||
assert_eq!(
|
||||
session.project().diagrams[&f.diagram].routes[0].edge,
|
||||
renamed
|
||||
);
|
||||
session.execute(Command::Delete(oa)).unwrap();
|
||||
assert!(session.project().diagrams[&f.diagram].routes.is_empty());
|
||||
session.undo();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_translation_beyond_coordinate_limit_rolls_back_nodes_and_routes() {
|
||||
let mut f = routed(fixture());
|
||||
f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints[0].x = MAX_COORDINATE;
|
||||
let mut session = Session::new(f.project.clone());
|
||||
let group_node = node(&f.project, f.diagram, f.group);
|
||||
let result = session.execute(Command::MoveNode {
|
||||
diagram: f.diagram,
|
||||
node: group_node,
|
||||
position: Position { x: 101., y: 100. },
|
||||
with_descendants: true,
|
||||
});
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("invalid_route_bounds")
|
||||
);
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_preserves_authored_route_count_order_labels_and_undo() {
|
||||
let f = routed(fixture());
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session.execute(Command::Layout(f.diagram)).unwrap();
|
||||
let routes = &session.project().diagrams[&f.diagram].routes;
|
||||
assert_eq!(routes.len(), 2);
|
||||
assert_eq!(routes[0].edge, f.edge);
|
||||
assert_eq!(routes[1].edge, f.external);
|
||||
assert_eq!(routes[0].waypoints.len(), 2);
|
||||
assert_eq!(routes[1].waypoints.len(), 2);
|
||||
assert_eq!(routes[0].label, route(f.edge).label);
|
||||
assert_eq!(routes[1].label, route(f.external).label);
|
||||
assert_eq!(
|
||||
session.project().diagrams[&f.second],
|
||||
f.project.diagrams[&f.second]
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_models_get_empty_routes_and_route_json_is_strict() {
|
||||
for bytes in [
|
||||
include_bytes!("fixtures/v2-coastal-rescue.json").as_slice(),
|
||||
include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json").as_slice(),
|
||||
] {
|
||||
let p = persistence::decode(bytes).unwrap();
|
||||
assert!(p.diagrams.values().all(|d| d.routes.is_empty()));
|
||||
}
|
||||
let f = fixture();
|
||||
let mut old = serde_json::to_value(&f.project).unwrap();
|
||||
for d in old["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
d.as_object_mut().unwrap().remove("routes");
|
||||
}
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
|
||||
f.project
|
||||
);
|
||||
let minimal = serde_json::json!({"edge":f.edge});
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
|
||||
EdgeRoute {
|
||||
edge: f.edge,
|
||||
waypoints: vec![],
|
||||
label: None
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<EdgeRoute>(serde_json::json!({"edge":f.edge,"bendz":[]})).is_err()
|
||||
);
|
||||
assert!(serde_json::from_value::<EdgeLabel>(serde_json::json!({"fraction":0.5})).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<EdgeLabel>(
|
||||
serde_json::json!({"fraction":0.5,"offset":{"x":0,"y":0},"typo":true})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for command in [
|
||||
Command::SetEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
route: route(f.edge),
|
||||
},
|
||||
Command::RemoveEdgeRoute {
|
||||
diagram: f.diagram,
|
||||
edge: f.edge,
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
|
||||
command
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user