diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs index befb027..2ee820e 100644 --- a/crates/oa-core/src/projection.rs +++ b/crates/oa-core/src/projection.rs @@ -4,6 +4,15 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; mod port_layout; pub use port_layout::{port_anchor_offset, port_anchor_position}; + +/// Isolated experiment only: no persisted setting or production default change. +#[doc(hidden)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExperimentalPortPolicy { + Current, + SameSidesAdaptSpacing, + CapacityAware, +} #[path = "projection/state.rs"] mod state; pub(crate) use state::node_size as state_node_size; @@ -249,7 +258,17 @@ pub fn project_diagram_with_index( d: &Diagram, index: &crate::view_options::ViewIndex, ) -> ProjectedView { - project_diagram_impl(p, d, None, Some(index)) + project_diagram_impl(p, d, None, Some(index), ExperimentalPortPolicy::Current) +} +/// Compare port policies over the actual projector; not a document/API setting. +#[doc(hidden)] +pub fn project_diagram_with_experimental_port_policy( + p: &Project, + d: &Diagram, + policy: ExperimentalPortPolicy, +) -> ProjectedView { + let index = crate::view_options::ViewIndex::new(p); + project_diagram_impl(p, d, None, Some(&index), policy) } /// Unfiltered eligibility from the same projection/anchor/fold decisions used /// for drawing. Compute once for a view-options editor, then combine with the @@ -269,7 +288,13 @@ pub fn edge_availability( ) }) .collect(); - project_diagram_impl(p, d, Some(&mut availability), None); + project_diagram_impl( + p, + d, + Some(&mut availability), + None, + ExperimentalPortPolicy::Current, + ); availability } fn project_diagram_impl( @@ -277,6 +302,7 @@ fn project_diagram_impl( d: &Diagram, mut availability: Option<&mut BTreeMap>, index: Option<&crate::view_options::ViewIndex>, + port_policy: ExperimentalPortPolicy, ) -> ProjectedView { let parents = parents(p, d); let mut nodes = Vec::new(); @@ -516,7 +542,7 @@ fn project_diagram_impl( let ports = if d.projection.state_machine().is_some() { Vec::new() } else { - port_layout::layout(p, d, &mut nodes, &edges) + port_layout::layout(p, d, &mut nodes, &edges, port_policy) }; // Port geometry uses every eligible edge. Presentation filters must never // move visible ports when another line is hidden or a category is selected. diff --git a/crates/oa-core/src/projection/port_capacity_experiment.rs b/crates/oa-core/src/projection/port_capacity_experiment.rs new file mode 100644 index 0000000..25bdab0 --- /dev/null +++ b/crates/oa-core/src/projection/port_capacity_experiment.rs @@ -0,0 +1,221 @@ +//! Experimental four-side capacity assignment; no model fields or router feedback. +use super::*; + +#[derive(Clone)] +pub(super) struct Demand { + pub preferred: PortAnchorSide, + pub semantic: Option, + pub clear: [bool; 4], +} + +fn index(side: PortAnchorSide) -> usize { + SIDES + .iter() + .position(|candidate| *candidate == side) + .unwrap() +} + +fn capacity(length: f32) -> usize { + if length < 2. * CORNER { + 0 + } else { + ((length - 2. * CORNER) / GAP).floor() as usize + 1 + } +} + +fn counts(assigned: &[Option]) -> [usize; 4] { + let mut result = [0; 4]; + for side in assigned.iter().flatten() { + result[index(*side)] += 1; + } + result +} + +fn ranked(demand: &Demand, counts: [usize; 4]) -> Vec { + let mut result = Vec::new(); + for side in [Some(demand.preferred), demand.semantic] + .into_iter() + .flatten() + { + if demand.clear[index(side)] && !result.contains(&side) { + result.push(side); + } + } + let mut remaining = SIDES; + remaining.sort_by_key(|side| (counts[index(*side)], index(*side))); + for side in remaining { + if demand.clear[index(side)] && !result.contains(&side) { + result.push(side); + } + } + result +} + +// An alternating reassignment visits each of four side bins at most once. +// It is attempted only after every directly free clear side has been considered. +fn try_assign( + port: usize, + demands: &[Demand], + capacities: [usize; 4], + assigned: &mut [Option], + visited: &mut [bool; 4], +) -> bool { + let counts = counts(assigned); + let sides = ranked(&demands[port], counts); + for side in &sides { + let slot = index(*side); + if !visited[slot] && counts[slot] < capacities[slot] { + assigned[port] = Some(*side); + return true; + } + } + for side in sides { + let slot = index(side); + if visited[slot] || capacities[slot] == 0 { + continue; + } + visited[slot] = true; + let occupants: Vec<_> = assigned + .iter() + .enumerate() + .filter_map(|(port, current)| (*current == Some(side)).then_some(port)) + .collect(); + for other in occupants { + if try_assign(other, demands, capacities, assigned, visited) { + assigned[port] = Some(side); + return true; + } + } + } + false +} + +fn assign_with_capacities(demands: &[Demand], capacities: [usize; 4]) -> Vec { + let mut assigned = vec![None; demands.len()]; + // Demands are in the core's stable semantic port-ID order. Manual anchors + // are deliberately absent: editing one must not resize owners or flip peers. + for port in 0..demands.len() { + try_assign(port, demands, capacities, &mut assigned, &mut [false; 4]); + } + for (port, demand) in demands.iter().enumerate() { + if assigned[port].is_some() { + continue; + } + let counts = counts(&assigned); + // Prefer a crowded clear exit to an available blocked one. The existing + // position allocator adapts spacing; no port or owner geometry is invented. + let side = ranked(demand, counts) + .into_iter() + .next() + .unwrap_or_else(|| { + *SIDES + .iter() + .min_by_key(|side| counts[index(**side)]) + .unwrap() + }); + assigned[port] = Some(side); + } + assigned.into_iter().map(Option::unwrap).collect() +} + +pub(super) fn assign(node: &ProjectedNode, demands: &[Demand]) -> Vec { + assign_with_capacities( + demands, + [ + capacity(node.size.height), + capacity(node.size.height), + capacity(node.size.width), + capacity(node.size.width), + ], + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn demand(preferred: PortAnchorSide, sides: &[PortAnchorSide]) -> Demand { + Demand { + preferred, + semantic: None, + clear: SIDES.map(|side| sides.contains(&side)), + } + } + + #[test] + fn reassignment_recovers_scarce_clear_capacity() { + use PortAnchorSide::{Left, Right, Top}; + // Assignment-level falsifier only. Its clear masks are not yet produced + // by a model fixture with actual peer geometry. + let mut demands = vec![demand(Left, &[Left, Top]); 8]; + demands.extend(vec![demand(Left, &[Left, Right]); 9]); + demands.extend(vec![demand(Right, &[Right, Top]); 3]); + let capacities = [8, 8, 6, 0]; + let result = assign_with_capacities(&demands, capacities); + let totals = counts(&result.iter().copied().map(Some).collect::>()); + for (actual, capacity) in totals.into_iter().zip(capacities) { + assert!(actual <= capacity); + } + for (side, demand) in result.iter().zip(&demands) { + assert!(demand.clear[index(*side)]); + } + assert_eq!(assign_with_capacities(&demands, capacities), result); + } + + #[test] + fn clear_overflow_does_not_choose_a_blocked_empty_side() { + let demands = vec![demand(PortAnchorSide::Right, &[PortAnchorSide::Right]); 5]; + let result = assign_with_capacities(&demands, [10, 1, 10, 10]); + assert!(result.iter().all(|side| *side == PortAnchorSide::Right)); + } + + #[test] + fn small_clear_masks_match_an_exhaustive_assignment_oracle() { + let capacities = [1, 1, 1, 0]; + // All343 triples of nonempty masks on three sides. This validates the + // alternating assignment itself, not geometric reachability of masks. + for a in 1..8_u8 { + for b in 1..8_u8 { + for c in 1..8_u8 { + let demands: Vec<_> = [a, b, c] + .into_iter() + .map(|mask| { + let sides: Vec<_> = SIDES + .iter() + .copied() + .filter(|side| mask & (1 << index(*side)) != 0) + .collect(); + demand(sides[0], &sides) + }) + .collect(); + let feasible = (0..3).any(|x| { + (0..3).any(|y| { + (0..3).any(|z| { + x != y + && x != z + && y != z + && demands[0].clear[x] + && demands[1].clear[y] + && demands[2].clear[z] + }) + }) + }); + let result = assign_with_capacities(&demands, capacities); + for (side, demand) in result.iter().zip(&demands) { + assert!(demand.clear[index(*side)]); + } + if feasible { + let totals = counts(&result.into_iter().map(Some).collect::>()); + assert!( + totals + .into_iter() + .zip(capacities) + .all(|(n, capacity)| n <= capacity), + "missed feasible masks {a}/{b}/{c}" + ); + } + } + } + } + } +} diff --git a/crates/oa-core/src/projection/port_layout.rs b/crates/oa-core/src/projection/port_layout.rs index c8ec61d..ba0d868 100644 --- a/crates/oa-core/src/projection/port_layout.rs +++ b/crates/oa-core/src/projection/port_layout.rs @@ -1,6 +1,8 @@ //! Presentation-only port anchors. Automatic choices depend on owner geometry, //! never on routed elbows, so routing cannot feed back into layout. use super::*; +#[path = "port_capacity_experiment.rs"] +mod capacity_experiment; const GAP: f32 = 28.; const CORNER: f32 = 18.; @@ -50,6 +52,7 @@ struct Choice { automatic_side: PortAnchorSide, manual: Option, peer: Option, + capacity: Option, } fn facing(node: &ProjectedNode, peer: Position) -> Option { @@ -158,6 +161,7 @@ pub(super) fn layout( d: &Diagram, nodes: &mut [ProjectedNode], edges: &[ProjectedEdge], + policy: ExperimentalPortPolicy, ) -> Vec { let by_id: BTreeMap<_, _> = nodes.iter().map(|n| (n.id, n)).collect(); let mut peers: BTreeMap<(Id, Id), Peers> = BTreeMap::new(); @@ -228,34 +232,62 @@ pub(super) fn layout( automatic_side: side, manual: anchor.map(|a| a.offset), peer, + capacity: (policy == ExperimentalPortPolicy::CapacityAware).then(|| { + capacity_experiment::Demand { + preferred: side, + semantic: port_anchor_side(port), + clear: SIDES.map(clear), + } + }), }); } } - // One capacity pass followed by semantic enclosure. Side choices stay frozen - // throughout this pass; expanded parents cannot cause layout oscillation. - for node in nodes.iter_mut() { - for side in SIDES { - let count = choices - .get(&node.id) - .into_iter() - .flatten() - .filter(|c| c.automatic_side == side) - .count(); - if count == 0 { + if policy == ExperimentalPortPolicy::CapacityAware { + for node in nodes.iter() { + let Some(choices) = choices.get_mut(&node.id) else { continue; + }; + let demands: Vec<_> = choices + .iter() + .map(|choice| choice.capacity.clone().unwrap()) + .collect(); + let sides = capacity_experiment::assign(node, &demands); + for (choice, side) in choices.iter_mut().zip(sides) { + choice.automatic_side = side; + if choice.manual.is_none() { + choice.side = side; + } } - let minimum = 2. * CORNER + GAP * count.saturating_sub(1) as f32; - match side { - PortAnchorSide::Left | PortAnchorSide::Right => { - node.size.height = node.size.height.max(minimum) + } + } + // One capacity pass followed by semantic enclosure. Side choices stay frozen + // throughout this pass; expanded parents cannot cause layout oscillation. + // Experimental alternatives finalize nodes before port-side assignment. + if policy == ExperimentalPortPolicy::Current { + for node in nodes.iter_mut() { + for side in SIDES { + let count = choices + .get(&node.id) + .into_iter() + .flatten() + .filter(|c| c.automatic_side == side) + .count(); + if count == 0 { + continue; } - PortAnchorSide::Top | PortAnchorSide::Bottom => { - node.size.width = node.size.width.max(minimum) + let minimum = 2. * CORNER + GAP * count.saturating_sub(1) as f32; + match side { + PortAnchorSide::Left | PortAnchorSide::Right => { + node.size.height = node.size.height.max(minimum) + } + PortAnchorSide::Top | PortAnchorSide::Bottom => { + node.size.width = node.size.width.max(minimum) + } } } } + super::enclose(p, nodes); } - super::enclose(p, nodes); let mut result = Vec::new(); for node in nodes.iter() { let Some(choices) = choices.get(&node.id) else { diff --git a/crates/oa-core/tests/experimental_port_policy.rs b/crates/oa-core/tests/experimental_port_policy.rs new file mode 100644 index 0000000..06e3dc3 --- /dev/null +++ b/crates/oa-core/tests/experimental_port_policy.rs @@ -0,0 +1,458 @@ +//! Isolated policy comparisons. Current remains the ordinary projector default. +use oa_core::{projection::*, routing::*, *}; + +const ALTERNATIVES: [ExperimentalPortPolicy; 2] = [ + ExperimentalPortPolicy::SameSidesAdaptSpacing, + ExperimentalPortPolicy::CapacityAware, +]; +const POLICIES: [ExperimentalPortPolicy; 3] = [ + ExperimentalPortPolicy::Current, + ExperimentalPortPolicy::SameSidesAdaptSpacing, + ExperimentalPortPolicy::CapacityAware, +]; + +fn pos(x: f32, y: f32) -> Position { + Position { x, y } +} +fn size(width: f32, height: f32) -> Size { + Size { width, height } +} +fn standard(p: &mut Project, owner: Id, name: &str) -> Id { + let id = Id::new_v4(); + let port = make_port( + p, + id, + owner, + name, + PortContract::Component { + kind: ComponentPortKind::Standard, + orientation: ComponentPortOrientation::Unset, + }, + ) + .unwrap(); + p.elements.insert(id, port); + id +} +fn place(p: &mut Project, d: Id, e: Id, position: Position, dimensions: Size) -> Id { + let n = p.place(d, e, position); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&n) + .unwrap() + .size = dimensions; + n +} +struct Fixture { + p: Project, + d: Id, + owner: Id, + ports: Vec, +} +fn eight_ports() -> Fixture { + let mut p = Project::empty(); + let a = p + .add_element(Kind::System, Layer::Sa, "Owner", None) + .unwrap(); + let b = p + .add_element(Kind::SystemActor, Layer::Sa, "Above", None) + .unwrap(); + let c = p + .add_element(Kind::SystemActor, Layer::Sa, "Below", None) + .unwrap(); + let pb = standard(&mut p, b, "Above interface"); + let pc = standard(&mut p, c, "Below interface"); + let mut ports = Vec::new(); + for i in 0..8 { + let pa = standard(&mut p, a, &format!("Interface {i}")); + p.relate( + RelationKind::BoundaryExchange, + &format!("Above {i}"), + pa, + pb, + ); + p.relate( + RelationKind::BoundaryExchange, + &format!("Below {i}"), + pa, + pc, + ); + ports.push(pa); + } + let d = p.ensure_diagram(Layer::Sa); + let owner = place(&mut p, d, a, pos(0., 0.), size(224., 160.)); + place(&mut p, d, b, pos(38., -996.), size(184., 132.)); + place(&mut p, d, c, pos(38., 1004.), size(184., 132.)); + validate_candidate(&p).unwrap(); + Fixture { p, d, owner, ports } +} +fn hundred_ports() -> Fixture { + let mut p = Project::empty(); + let a = p + .add_element(Kind::System, Layer::Sa, "Owner", None) + .unwrap(); + let b = p + .add_element(Kind::SystemActor, Layer::Sa, "Peer", None) + .unwrap(); + let pb = standard(&mut p, b, "Peer interface"); + let mut ports = Vec::new(); + for i in 0..100 { + let pa = standard(&mut p, a, &format!("Interface {i}")); + p.relate( + RelationKind::BoundaryExchange, + &format!("Exchange {i}"), + pa, + pb, + ); + ports.push(pa); + } + let d = p.ensure_diagram(Layer::Sa); + // Semantic port minima grow this to728x804 before any side choice. + let owner = place(&mut p, d, a, pos(0., 0.), size(224., 160.)); + place(&mut p, d, b, pos(1908., 336.), size(184., 132.)); + validate_candidate(&p).unwrap(); + Fixture { p, d, owner, ports } +} +fn project(f: &Fixture, policy: ExperimentalPortPolicy) -> ProjectedView { + project_diagram_with_experimental_port_policy(&f.p, &f.p.diagrams[&f.d], policy) +} +fn node(view: &ProjectedView, id: Id) -> &ProjectedNode { + view.nodes.iter().find(|node| node.id == id).unwrap() +} +fn port(view: &ProjectedView, id: Id) -> &ProjectedPort { + view.ports.iter().find(|port| port.element == id).unwrap() +} +fn minimum_gap(view: &ProjectedView, owner: Id) -> f32 { + let mut result = f32::INFINITY; + for side in [ + PortAnchorSide::Left, + PortAnchorSide::Right, + PortAnchorSide::Top, + PortAnchorSide::Bottom, + ] { + let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom); + let mut positions: Vec<_> = view + .ports + .iter() + .filter(|p| p.owner == owner && p.side == side) + .map(|p| { + if horizontal { + p.position.x + } else { + p.position.y + } + }) + .collect(); + positions.sort_by(f32::total_cmp); + for pair in positions.windows(2) { + result = result.min(pair[1] - pair[0]); + } + } + result +} + +#[test] +fn experimental_current_is_the_unchanged_default_projector() { + for p in [Project::demo(), eight_ports().p, hundred_ports().p] { + for diagram in p.diagrams.values() { + let expected = project_diagram(&p, diagram); + let actual = project_diagram_with_experimental_port_policy( + &p, + diagram, + ExperimentalPortPolicy::Current, + ); + assert_eq!(actual, expected); + } + } +} + +#[test] +fn alternatives_realize_the_three_previously_forbidden_rectangles() { + for policy in POLICIES { + for (position, dimensions) in [ + (pos(0., -10.), size(224., 242.)), + (pos(10., 0.), size(214., 232.)), + (pos(0., 0.), size(224., 222.)), + ] { + let mut f = eight_ports(); + let original = f.p.diagrams[&f.d].nodes[&f.owner].clone(); + let mut edited = original.clone(); + edited.position = position; + edited.size = dimensions; + let before = f.p.clone(); + f.p = prepare_command( + &f.p, + &Command::EditNode { + diagram: f.d, + original: f.owner, + node: edited, + }, + ) + .unwrap() + .project; + let view = project(&f, policy); + let actual = node(&view, f.owner); + if policy == ExperimentalPortPolicy::Current { + assert_ne!((actual.position, actual.size), (position, dimensions)); + } else { + assert_eq!((actual.position, actual.size), (position, dimensions)); + } + let mut restored = f.p.clone(); + restored + .diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .insert(f.owner, original); + assert_eq!(restored, before, "Only the selected record may change"); + } + } +} + +#[test] +fn all_eight_outward_handles_have_exact_fixed_edges_without_authored_inactive_changes() { + for policy in ALTERNATIVES { + for (left, top, right, bottom) in [ + (false, true, false, false), + (false, true, true, false), + (false, false, true, false), + (false, false, true, true), + (false, false, false, true), + (true, false, false, true), + (true, false, false, false), + (true, true, false, false), + ] { + let mut f = eight_ports(); + let baseline = project(&f, policy); + let old = node(&baseline, f.owner); + assert_eq!(old.size, size(224., 160.)); + let mut edited = f.p.diagrams[&f.d].nodes[&f.owner].clone(); + let x = old.position.x - if left { 20. } else { 0. }; + let y = old.position.y - if top { 20. } else { 0. }; + let r = old.position.x + old.size.width + if right { 20. } else { 0. }; + let b = old.position.y + old.size.height + if bottom { 20. } else { 0. }; + edited.position = pos(x, y); + edited.size = size(r - x, b - y); + f.p = prepare_command( + &f.p, + &Command::EditNode { + diagram: f.d, + original: f.owner, + node: edited.clone(), + }, + ) + .unwrap() + .project; + let view = project(&f, policy); + let actual = node(&view, f.owner); + assert_eq!( + (actual.position, actual.size), + (edited.position, edited.size) + ); + if !left && !right { + assert_eq!(edited.position.x, old.position.x); + assert_eq!(edited.size.width, old.size.width); + } + if !top && !bottom { + assert_eq!(edited.position.y, old.position.y); + assert_eq!(edited.size.height, old.size.height); + } + } + } +} + +#[test] +fn capacity_uses_available_sides_where_spacing_only_overlaps_fourteen_unit_glyphs() { + let f = hundred_ports(); + let before = f.p.clone(); + let current = project(&f, ExperimentalPortPolicy::Current); + let spacing = project(&f, ExperimentalPortPolicy::SameSidesAdaptSpacing); + let capacity = project(&f, ExperimentalPortPolicy::CapacityAware); + assert_eq!(node(¤t, f.owner).size, size(728., 2808.)); + assert_eq!(node(&spacing, f.owner).size, size(728., 804.)); + assert_eq!(capacity.nodes, spacing.nodes); + assert!(minimum_gap(¤t, f.owner) >= 27.99); + assert!(minimum_gap(&spacing, f.owner) < 14.); + assert!(minimum_gap(&capacity, f.owner) >= 27.99); + assert_eq!( + capacity.ports.iter().filter(|p| p.owner == f.owner).count(), + 100 + ); + assert_eq!( + f.p, before, + "Projection must not author anchors or new dimensions" + ); + println!( + "100 ports: current={:?} spacing={:?} capacity={:?}; gaps={}/{}/{}", + node(¤t, f.owner).size, + node(&spacing, f.owner).size, + node(&capacity, f.owner).size, + minimum_gap(¤t, f.owner), + minimum_gap(&spacing, f.owner), + minimum_gap(&capacity, f.owner) + ); +} + +#[test] +fn manual_anchor_edits_preserve_owner_geometry_and_unrelated_automatic_sides() { + let mut f = hundred_ports(); + let before = project(&f, ExperimentalPortPolicy::CapacityAware); + let id = before + .ports + .iter() + .find(|p| p.owner == f.owner && p.side == PortAnchorSide::Right) + .unwrap() + .element; + f.p = prepare_command( + &f.p, + &Command::SetPortAnchor { + diagram: f.d, + node: f.owner, + anchor: PortAnchor { + port: id, + side: PortAnchorSide::Top, + offset: 0.5, + }, + }, + ) + .unwrap() + .project; + let after = project(&f, ExperimentalPortPolicy::CapacityAware); + assert_eq!(after.nodes, before.nodes); + assert_eq!( + port(&after, id).position, + port_anchor_position(node(&after, f.owner), PortAnchorSide::Top, 0.5) + ); + for other in after.ports.iter().filter(|p| p.element != id) { + assert_eq!(other.side, port(&before, other.element).side); + } + assert_eq!(language::parse(&language::format(&f.p)).unwrap(), f.p); +} + +#[test] +fn exact_crowded_manual_fractions_survive_every_alternative_and_reopen() { + for policy in ALTERNATIVES { + let mut f = eight_ports(); + let fractions = [0., 0.25, 0.25, 0.5, 0.75, 1., 0.01, 0.99]; + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&f.owner) + .unwrap() + .port_anchors = f + .ports + .iter() + .zip(fractions) + .map(|(port, offset)| PortAnchor { + port: *port, + side: PortAnchorSide::Right, + offset, + }) + .collect(); + validate_candidate(&f.p).unwrap(); + let original_anchors = f.p.diagrams[&f.d].nodes[&f.owner].port_anchors.clone(); + let view = project(&f, policy); + assert_eq!(node(&view, f.owner).size, size(224., 160.)); + for anchor in &original_anchors { + let actual = port(&view, anchor.port); + assert_eq!(actual.side, anchor.side); + assert_eq!( + actual.position, + port_anchor_position(node(&view, f.owner), anchor.side, anchor.offset) + ); + } + let reopened = language::parse(&language::format(&f.p)).unwrap(); + assert_eq!(reopened, f.p); + assert_eq!( + project_diagram_with_experimental_port_policy( + &reopened, + &reopened.diagrams[&f.d], + policy + ), + view + ); + assert_eq!( + f.p.diagrams[&f.d].nodes[&f.owner].port_anchors, + original_anchors + ); + } +} + +#[test] +fn narrow_peer_gap_keeps_clear_sides_and_actual_routed_port_endpoints() { + 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); + p.place(d, a, pos(100., 100.)); + p.place(d, b, pos(100., 260.)); + validate_candidate(&p).unwrap(); + for policy in POLICIES { + let view = project_diagram_with_experimental_port_policy(&p, &p.diagrams[&d], policy); + assert_eq!(port(&view, source_port).side, PortAnchorSide::Right); + assert_eq!(port(&view, target_port).side, PortAnchorSide::Left); + let routed = route_diagram(&p, &p.diagrams[&d], &view); + let route = &routed.edges[&edge]; + assert!( + route.warnings.is_empty(), + "{policy:?}: {:?}", + route.warnings + ); + assert_eq!( + route.points.first(), + Some(&port(&view, source_port).position) + ); + assert_eq!( + route.points.last(), + Some(&port(&view, target_port).position) + ); + assert!( + route + .points + .windows(2) + .all(|pair| pair[0].x == pair[1].x || pair[0].y == pair[1].y) + ); + } +} + +#[test] +fn candidate_geometry_is_deterministic_and_independent_of_peer_motion_and_names() { + let mut f = eight_ports(); + let before = project(&f, ExperimentalPortPolicy::CapacityAware); + assert_eq!(project(&f, ExperimentalPortPolicy::CapacityAware), before); + for element in f.p.elements.values_mut() { + element.alias = format!("renamed_{}", element.id.simple()); + } + assert_eq!(project(&f, ExperimentalPortPolicy::CapacityAware), before); + for node in + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .values_mut() + .filter(|n| n.id != f.owner) + { + node.position.x += 500.; + } + let moved = project(&f, ExperimentalPortPolicy::CapacityAware); + assert_eq!( + node(&moved, f.owner), + node(&before, f.owner), + "Peer motion must not change owner geometry" + ); +}