diff --git a/crates/oa-core/src/demo.rs b/crates/oa-core/src/demo.rs index 911bcf92..8e9676b2 100644 --- a/crates/oa-core/src/demo.rs +++ b/crates/oa-core/src/demo.rs @@ -82,6 +82,7 @@ fn diagram( layer, projection, nodes: BTreeMap::new(), + routes: vec![], }, ); for (index, e) in elements.iter().enumerate() { diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs index 4ab6a196..34cedf92 100644 --- a/crates/oa-core/src/model.rs +++ b/crates/oa-core/src/model.rs @@ -1241,7 +1241,29 @@ pub struct Diagram { pub projection: ProjectionKind, #[serde(deserialize_with = "unique_id_map")] pub nodes: BTreeMap, + #[serde(default)] + pub routes: Vec, } +/// 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, + #[serde(default)] + pub label: Option, +} +#[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 diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs index e1917dcf..2d449101 100644 --- a/crates/oa-core/src/projection.rs +++ b/crates/oa-core/src/projection.rs @@ -180,6 +180,59 @@ pub fn descendant_nodes(p: &Project, diagram: Id, node: Id) -> Vec { }) .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, +) -> Vec { + 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 { diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index 20f740f2..38035774 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -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) { 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| { if v.is_some_and(|id| removed.contains(&id)) { diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index 4ff59dbb..b679ac13 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -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(()) } diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index f3063a93..133ac9a3 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -657,6 +657,78 @@ pub fn validate(p: &Project) -> Vec { } 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 { diff --git a/crates/oa-core/tests/port_migration.rs b/crates/oa-core/tests/port_migration.rs index 9796224b..e6271f66 100644 --- a/crates/oa-core/tests/port_migration.rs +++ b/crates/oa-core/tests/port_migration.rs @@ -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", diff --git a/crates/oa-core/tests/release_routing.rs b/crates/oa-core/tests/release_routing.rs new file mode 100644 index 00000000..078438d7 --- /dev/null +++ b/crates/oa-core/tests/release_routing.rs @@ -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::(minimal).unwrap(), + EdgeRoute { + edge: f.edge, + waypoints: vec![], + label: None + } + ); + assert!( + serde_json::from_value::(serde_json::json!({"edge":f.edge,"bendz":[]})).is_err() + ); + assert!(serde_json::from_value::(serde_json::json!({"fraction":0.5})).is_err()); + assert!( + serde_json::from_value::( + 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::(serde_json::to_value(&command).unwrap()).unwrap(), + command + ); + } +}