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, view_options: Default::default(), nodes: BTreeMap::new(), layout: None, 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, source_attachment: None, target_attachment: None, 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, source_attachment: None, target_attachment: None, 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 ); } }