diff --git a/crates/oa-desktop/src/allocation_drag.rs b/crates/oa-desktop/src/allocation_drag.rs index a903c682..66cdbb6d 100644 --- a/crates/oa-desktop/src/allocation_drag.rs +++ b/crates/oa-desktop/src/allocation_drag.rs @@ -13,6 +13,11 @@ use oa_core::{ }; use std::collections::{BTreeMap, BTreeSet}; +// The pure planner is verified before its App gesture adapter is connected. +#[allow(dead_code)] +#[path = "allocation_drag/arrange.rs"] +pub mod arrange; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Destination { LayoutOnly, @@ -76,6 +81,11 @@ pub enum Refusal { pub fn capture(p: &Project, diagram: Id, work: Id, placement: Id) -> Result { validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?; + capture_validated(p, diagram, work, placement) +} + +/// Collect work facts after the caller's single complete Project validation. +fn capture_validated(p: &Project, diagram: Id, work: Id, placement: Id) -> Result { let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?; let root = p.elements.get(&work).ok_or(Refusal::StaleScope)?; if !root.kind().is_work() diff --git a/crates/oa-desktop/src/allocation_drag/arrange.rs b/crates/oa-desktop/src/allocation_drag/arrange.rs new file mode 100644 index 00000000..39d3d444 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/arrange.rs @@ -0,0 +1,342 @@ +//! Explicit, semantics-preserving arrangement of one or more Allocation cards. +//! +//! The App must admit the entire original typed selection before converting it +//! to placement/element identities. These values do not grant input authority. +use super::*; + +#[derive(Debug)] +pub struct Plan { + accepted: Project, + diagram: Id, + selected: BTreeMap, + anchor: Id, + placements: BTreeMap, + expanded_ids: BTreeSet, + index: ViewIndex, + before_visible: ProjectedView, + before_expanded: ProjectedView, +} + +impl Plan { + pub fn project(&self) -> &Project { + &self.accepted + } + + pub fn index(&self) -> &ViewIndex { + &self.index + } + + pub fn affected(&self) -> &BTreeMap { + &self.placements + } + + pub fn selected(&self) -> &BTreeMap { + &self.selected + } + + pub fn anchor(&self) -> Id { + self.anchor + } + + pub fn diagram(&self) -> &Diagram { + &self.accepted.diagrams[&self.diagram] + } +} + +/// Capture every original placement identity before normalizing semantic roots. +/// Document/revision/selection-generation and clean-owner guards belong to App. +pub fn capture( + p: &Project, + diagram: Id, + selected: &BTreeMap, + anchor: Id, +) -> Result { + validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?; + let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?; + if d.projection != ProjectionKind::Allocation + || selected.is_empty() + || !selected.contains_key(&anchor) + { + return Err(Refusal::Ineligible( + "Arrange requires selected placements and a selected anchor in an Allocation view." + .into(), + )); + } + let index = ViewIndex::new(p); + let before_visible = projection::project_diagram_with_index(p, d, &index); + let visible: BTreeMap<_, _> = before_visible + .nodes + .iter() + .map(|n| (n.id, n.element)) + .collect(); + let mut work = BTreeMap::new(); + let mut performers = Vec::new(); + for (placement, element) in selected { + let node = d.nodes.get(placement).ok_or(Refusal::StaleScope)?; + let record = p.elements.get(element).ok_or(Refusal::StaleScope)?; + if node.element != *element || visible.get(placement) != Some(element) { + return Err(Refusal::StaleScope); + } + if record.layer() != d.layer || !(record.kind().is_work() || record.kind().is_performer()) { + return Err(Refusal::Ineligible( + "Arrange requires only activities, functions or performers in this layer.".into(), + )); + } + if record.kind().is_work() { + work.insert(*element, *placement); + } else { + performers.push(*placement); + } + } + + let mut placements = BTreeMap::new(); + for (element, placement) in &work { + let root = &p.elements[element]; + let mut ancestor = root.parent; + let mut covered = false; + let mut seen = BTreeSet::new(); + while let Some(id) = ancestor { + if !seen.insert(id) { + return Err(Refusal::StaleScope); + } + let parent = p.elements.get(&id).ok_or(Refusal::StaleScope)?; + if parent.kind() != root.kind() { + break; + } + if work.contains_key(&id) { + covered = true; + break; + } + ancestor = parent.parent; + } + if !covered { + placements.extend(capture_validated(p, diagram, *element, *placement)?.placements); + } + } + for performer in performers { + for id in + std::iter::once(performer).chain(projection::descendant_nodes(p, diagram, performer)) + { + placements.insert(id, d.nodes.get(&id).ok_or(Refusal::StaleScope)?.clone()); + } + } + + // Geometry never changes visual ancestry in Arrange. Recover the complete + // ancestry once, including hidden nodes. This all-unfolded projection is + // used only for identity lookup, never as a collision-admission scene. + let mut all_unfolded = d.clone(); + for node in all_unfolded.nodes.values_mut() { + node.collapsed = false; + } + let all_view = projection::project_diagram_with_index(p, &all_unfolded, &index); + let ancestry = ancestors(&all_view); + let mut expanded_ids: BTreeSet<_> = placements.keys().copied().collect(); + for id in placements.keys() { + expanded_ids.extend(ancestry.get(id).ok_or(Refusal::StaleScope)?.iter().copied()); + } + let before_expanded = diagnostic(p, d, &expanded_ids, &index); + Ok(Plan { + accepted: p.clone(), + diagram, + selected: selected.clone(), + anchor, + placements, + expanded_ids, + index, + before_visible, + before_expanded, + }) +} + +fn diagnostic( + project: &Project, + diagram: &Diagram, + expanded_ids: &BTreeSet, + index: &ViewIndex, +) -> ProjectedView { + let mut expanded = diagram.clone(); + for id in expanded_ids { + if let Some(node) = expanded.nodes.get_mut(id) { + node.collapsed = false; + } + } + projection::project_diagram_with_index(project, &expanded, index) +} + +/// Snap the captured anchor once; preview the complete union without cloning or +/// validating a Project, rebuilding its semantic index, or expanding scope. +pub fn place( + plan: &Plan, + requested: Position, + presentation: PresentationContext, +) -> Result { + if presentation.target.is_some() { + return Err(Refusal::Ineligible( + "Arrange only has no performer destination.".into(), + )); + } + if !requested.x.is_finite() + || !requested.y.is_finite() + || !presentation.viewport.valid() + || !presentation.zoom.is_finite() + || presentation.zoom <= 0. + { + return Err(Refusal::LayoutRequired( + "Make room or adjust the view, then drag again.".into(), + )); + } + let anchor = &plan.placements[&plan.anchor]; + let delta = Position { + x: (requested.x / 10.).round() * 10. - anchor.position.x, + y: (requested.y / 10.).round() * 10. - anchor.position.y, + }; + let mut diagram = plan.diagram().clone(); + let mut commands = Vec::new(); + let mut deltas = BTreeMap::new(); + for (id, original) in &plan.placements { + let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?; + node.position = Position { + x: original.position.x + delta.x, + y: original.position.y + delta.y, + }; + if !(Rect { + position: node.position, + size: node.size, + }) + .valid() + { + return Err(Refusal::InvalidCandidate( + "Moved coordinates exceed the model bounds.".into(), + )); + } + deltas.insert(*id, delta); + if node != original { + commands.push(Command::EditNode { + diagram: diagram.id, + original: *id, + node: node.clone(), + }); + } + } + for route in projection::translated_routes(&plan.accepted, plan.diagram(), &deltas) { + if route.waypoints.iter().any(|point| { + !point.x.is_finite() + || !point.y.is_finite() + || point.x.abs() > MAX_COORDINATE + || point.y.abs() > MAX_COORDINATE + }) { + return Err(Refusal::InvalidCandidate( + "Moved route exceeds the model bounds.".into(), + )); + } + let stored = diagram + .routes + .iter_mut() + .find(|stored| stored.edge == route.edge) + .ok_or(Refusal::StaleScope)?; + *stored = route.clone(); + commands.push(Command::SetEdgeRoute { + diagram: diagram.id, + route, + }); + } + let view = projection::project_diagram_with_index(&plan.accepted, &diagram, &plan.index); + let expanded = diagnostic(&plan.accepted, &diagram, &plan.expanded_ids, &plan.index); + if view.nodes.iter().chain(&expanded.nodes).any(|node| { + !(Rect { + position: node.position, + size: node.size, + }) + .valid() + }) { + return Err(Refusal::InvalidCandidate( + "The projected enclosure exceeds the model bounds.".into(), + )); + } + let before: BTreeMap<_, _> = plan + .before_expanded + .nodes + .iter() + .map(|n| (n.id, n)) + .collect(); + let after: BTreeMap<_, _> = expanded.nodes.iter().map(|n| (n.id, n)).collect(); + for id in plan.placements.keys() { + let old = before.get(id).ok_or(Refusal::StaleScope)?; + let new = after.get(id).ok_or(Refusal::StaleScope)?; + if (new.position.x - old.position.x - delta.x).abs() > 0.01 + || (new.position.y - old.position.y - delta.y).abs() > 0.01 + || (new.size.width - old.size.width).abs() > 0.01 + || (new.size.height - old.size.height).abs() > 0.01 + { + return Err(Refusal::LayoutRequired( + "This selection requires an explicit layout; its footprint cannot be translated unchanged." + .into(), + )); + } + } + if overlaps(&plan.before_visible, &view) || overlaps(&plan.before_expanded, &expanded) { + return Err(Refusal::LayoutRequired( + "Make room before arranging: the candidate would introduce or enlarge overlapping cards." + .into(), + )); + } + for (id, element) in &plan.selected { + let selected = view + .nodes + .iter() + .find(|node| node.id == *id && node.element == *element) + .ok_or_else(|| Refusal::LayoutRequired("A selected header would be hidden.".into()))?; + let header = Rect { + position: selected.position, + size: Size { + width: selected.size.width, + height: selected.size.height.min(62.), + }, + }; + if !presentation.viewport.contains(header) { + return Err(Refusal::LayoutRequired( + "Make room or adjust the view, then drag again.".into(), + )); + } + } + Ok(PlacementPlan { + diagram, + view, + delta, + unfolded: None, + requested, + presentation, + commands, + }) +} + +/// Recompute the candidate and compare the complete expected Project before the +/// caller performs one source-admitted transaction. None means no history entry. +pub fn finish( + plan: &Plan, + live: &Project, + placement: &PlacementPlan, +) -> Result, Refusal> { + if live != &plan.accepted { + return Err(Refusal::StaleScope); + } + let checked = place(plan, placement.requested, placement.presentation)?; + if checked.diagram != placement.diagram + || checked.view != placement.view + || checked.delta != placement.delta + || checked.unfolded != placement.unfolded + || checked.commands != placement.commands + { + return Err(Refusal::StaleScope); + } + let mut expected = plan.accepted.clone(); + expected + .diagrams + .insert(checked.diagram.id, checked.diagram); + let command = Command::Batch(checked.commands); + let actual = prepare_command(live, &command) + .map_err(|e| Refusal::InvalidCandidate(e.to_string()))? + .project; + exact_project(&expected, &actual)?; + Ok((actual != *live).then_some(command)) +} diff --git a/crates/oa-desktop/src/allocation_drag/arrange_tests.rs b/crates/oa-desktop/src/allocation_drag/arrange_tests.rs new file mode 100644 index 00000000..f3c86973 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/arrange_tests.rs @@ -0,0 +1,443 @@ +use super::*; + +struct Distributed { + f: Fixture, + a: Id, + b: Id, + x: Id, + y: Id, + internal: Id, +} + +fn distributed(layer: Layer, folded: bool) -> Distributed { + let mut f = fixture(layer); + let kind = f.p.elements[&f.w].kind(); + let x = f.p.add_element(kind, layer, "X", Some(f.w)).unwrap(); + let y = f.p.add_element(kind, layer, "Y", Some(f.w)).unwrap(); + let outside = f.p.add_element(kind, layer, "Outside", None).unwrap(); + let xn = f.p.place(f.d, x, xy(140., 200.)); + let yn = f.p.place(f.d, y, xy(940., 200.)); + f.p.place(f.d, outside, xy(1800., 800.)); + f.p.relate(RelationKind::Allocation, "A owns X", f.a, x); + f.p.relate(RelationKind::Allocation, "B owns Y", f.b, y); + let exchange = match layer { + Layer::Oa => RelationKind::Interaction, + Layer::Sa => RelationKind::FunctionalExchange, + }; + let internal = f.p.relate(exchange, "X to Y", x, y); + let external = f.p.relate(exchange, "Y to Outside", y, outside); + let d = f.p.diagrams.get_mut(&f.d).unwrap(); + let a = d.nodes.values().find(|n| n.element == f.a).unwrap().id; + let b = d.nodes.values().find(|n| n.element == f.b).unwrap().id; + d.nodes.get_mut(&a).unwrap().collapsed = folded; + d.nodes.get_mut(&f.n).unwrap().position = xy(100., 800.); + d.routes = vec![route(internal), route(external)]; + validate_candidate(&f.p).unwrap(); + Distributed { + f, + a, + b, + x: xn, + y: yn, + internal, + } +} + +fn identities(m: &Distributed, ids: &[Id]) -> BTreeMap { + ids.iter() + .map(|id| (*id, m.f.p.diagrams[&m.f.d].nodes[id].element)) + .collect() +} + +fn expected(m: &Distributed, include_a: bool) -> Project { + let mut p = m.f.p.clone(); + let d = p.diagrams.get_mut(&m.f.d).unwrap(); + for (id, position) in [ + (m.f.n, xy(500., 800.)), + (m.x, xy(540., 200.)), + (m.y, xy(1340., 200.)), + ] { + d.nodes.get_mut(&id).unwrap().position = position; + } + if include_a { + d.nodes.get_mut(&m.a).unwrap().position = xy(500., 100.); + } + d.routes + .iter_mut() + .find(|r| r.edge == m.internal) + .unwrap() + .waypoints = vec![xy(820., 400.), xy(820., 550.)]; + p +} + +#[test] +fn explicit_arrange_distributed_oa_sa_exact_scope_routes_source_history_and_anchor() { + for layer in [Layer::Oa, Layer::Sa] { + for folded in [false, true] { + for include_a in [false, true] { + let m = distributed(layer, folded); + let members = if include_a { + vec![m.f.n, m.a] + } else { + vec![m.f.n] + }; + let selected = identities(&m, &members); + for anchor in selected.keys().copied() { + let plan = arrange::capture(&m.f.p, m.f.d, &selected, anchor).unwrap(); + assert_eq!(plan.selected(), &selected); + let mut affected = BTreeSet::from([m.f.n, m.x, m.y]); + if include_a { + affected.insert(m.a); + } + assert_eq!( + plan.affected().keys().copied().collect::>(), + affected + ); + let requested = if anchor == m.a { + xy(503., 102.) + } else { + xy(503., 802.) + }; + let placed = + arrange::place(&plan, requested, presentation(&m.f.p, m.f.d, None)) + .unwrap(); + assert_eq!(placed.delta, xy(400., 0.)); + assert_eq!(placed.unfolded, None); + let command = arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap(); + assert_eq!(roundtrip(&m.f.p, command), expected(&m, include_a)); + // Explicit Arrange does not widen ordinary distributed assignment. + assert!(matches!( + plan_semantics( + &m.f.p, + &scope(&m.f), + Destination::Performer(m.f.a), + &BTreeMap::new() + ), + Err(Refusal::MixedLayoutRequired) + )); + } + } + } + } +} + +#[test] +fn explicit_arrange_performer_closure_does_not_acquire_incidental_semantic_root() { + for layer in [Layer::Oa, Layer::Sa] { + let m = distributed(layer, true); + let selected = identities(&m, &[m.a]); + let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.a).unwrap(); + assert_eq!( + plan.affected().keys().copied().collect::>(), + BTreeSet::from([m.a, m.x]) + ); + let placed = + arrange::place(&plan, xy(500., 100.), presentation(&m.f.p, m.f.d, None)).unwrap(); + let mut expected = m.f.p.clone(); + let d = expected.diagrams.get_mut(&m.f.d).unwrap(); + d.nodes.get_mut(&m.a).unwrap().position = xy(500., 100.); + d.nodes.get_mut(&m.x).unwrap().position = xy(540., 200.); + // X-to-Y is now one-ended; all saved pins remain byte-for-byte unchanged. + assert_eq!( + roundtrip( + &m.f.p, + arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap() + ), + expected + ); + } +} + +#[test] +fn explicit_arrange_validates_all_original_members_before_normalizing() { + let mut m = distributed(Layer::Oa, false); + let selected = identities(&m, &[m.f.n, m.x]); + let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.x).unwrap(); + assert_eq!(plan.selected(), &selected); + assert_eq!(plan.affected().len(), 3); + let placed = arrange::place(&plan, xy(540., 200.), presentation(&m.f.p, m.f.d, None)).unwrap(); + assert_eq!( + roundtrip( + &m.f.p, + arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap() + ), + expected(&m, false) + ); + let mut stale = selected.clone(); + stale.insert(m.x, m.f.w); + assert!(arrange::capture(&m.f.p, m.f.d, &stale, m.f.n).is_err()); + m.f.p + .diagrams + .get_mut(&m.f.d) + .unwrap() + .nodes + .get_mut(&m.a) + .unwrap() + .collapsed = true; + assert!( + arrange::capture(&m.f.p, m.f.d, &selected, m.f.n).is_err(), + "covered selected X is hidden and cannot disappear in normalization" + ); + assert!(arrange::capture(&m.f.p, m.f.d, &BTreeMap::new(), m.f.n).is_err()); + assert!(arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.a).is_err()); + assert!(arrange::capture(&m.f.p, m.f.second, &identities(&m, &[m.f.n]), m.f.n).is_err()); + let foreign = Id::new_v4(); + assert!( + arrange::capture( + &m.f.p, + m.f.d, + &BTreeMap::from([(m.f.n, m.f.w), (foreign, m.f.w)]), + m.f.n + ) + .is_err() + ); + m.f.p.diagrams.get_mut(&m.f.d).unwrap().projection = ProjectionKind::Overview; + assert!(arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.f.n).is_err()); +} + +#[test] +fn explicit_arrange_simultaneous_roots_may_enter_each_others_vacated_bounds() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer); + let kind = f.p.elements[&f.w].kind(); + let y = f.p.add_element(kind, layer, "Second root", None).unwrap(); + let yn = f.p.place(f.d, y, xy(400., 500.)); + let relation = f.p.relate( + if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }, + "Between roots", + f.w, + y, + ); + f.p.diagrams.get_mut(&f.d).unwrap().routes = vec![route(relation)]; + let context = presentation(&f.p, f.d, None); + let single = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap(); + assert!( + arrange::place(&single, xy(400., 500.), context).is_err(), + "singleton would collide with stationary Y" + ); + let plan = + arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap(); + let placed = arrange::place(&plan, xy(400., 500.), context).unwrap(); + let mut expected = f.p.clone(); + let d = expected.diagrams.get_mut(&f.d).unwrap(); + d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.); + d.nodes.get_mut(&yn).unwrap().position = xy(700., 500.); + d.routes[0].waypoints = vec![xy(720., 400.), xy(720., 550.)]; + assert_eq!( + roundtrip( + &f.p, + arrange::finish(&plan, &f.p, &placed).unwrap().unwrap() + ), + expected + ); + } +} + +#[test] +fn explicit_arrange_preserves_roles_and_does_not_invent_unplaced_descendants() { + let mut f = fixture(Layer::Oa); + let a = f.a; + role(&mut f, Some(a)); + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&f.n) + .unwrap() + .position = xy(150., 200.); + let plan = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap(); + let placed = arrange::place(&plan, xy(160., 210.), presentation(&f.p, f.d, None)).unwrap(); + let mut role_expected = f.p.clone(); + role_expected + .diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&f.n) + .unwrap() + .position = xy(160., 210.); + assert_eq!( + roundtrip( + &f.p, + arrange::finish(&plan, &f.p, &placed).unwrap().unwrap() + ), + role_expected + ); + for layer in [Layer::Oa, Layer::Sa] { + let mut m = distributed(layer, true); + let kind = m.f.p.elements[&m.f.w].kind(); + m.f.p + .add_element(kind, layer, "Unplaced leaf", Some(m.f.w)) + .unwrap(); + let plan = arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.f.n).unwrap(); + assert_eq!(plan.affected().len(), 3); + let placed = + arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)).unwrap(); + assert_eq!( + roundtrip( + &m.f.p, + arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap() + ), + expected(&m, false) + ); + } +} + +#[test] +fn explicit_arrange_detects_hidden_moved_child_and_stationary_enclosure_growth() { + for layer in [Layer::Oa, Layer::Sa] { + for hidden in [false, true] { + let mut m = distributed(layer, hidden); + let kind = m.f.p.elements[&m.f.w].kind(); + let z = m.f.p.add_element(kind, layer, "Obstacle", None).unwrap(); + let zn = m.f.p.place( + m.f.d, + z, + if hidden { + xy(550., 230.) + } else { + xy(1200., 100.) + }, + ); + // The independent candidate differs only by literal admitted edits. + // Establish which geometric case the real planner must reject. + let candidate = expected(&m, true); + let before = projection::project_view(&m.f.p, m.f.d); + let after = projection::project_view(&candidate, m.f.d); + let find = + |view: &ProjectedView, id| view.nodes.iter().find(|n| n.id == id).unwrap().clone(); + if hidden { + assert!(!overlaps(&before, &after)); + assert!(!after.nodes.iter().any(|n| n.id == m.x)); + let mut diagnostic = candidate.diagrams[&m.f.d].clone(); + diagnostic.nodes.get_mut(&m.a).unwrap().collapsed = false; + let expanded = projection::project_diagram(&candidate, &diagnostic); + assert_eq!( + overlap(&find(&expanded, m.x), &find(&expanded, zn)), + if layer == Layer::Oa { 13696. } else { 20116. } + ); + } else { + assert_eq!(overlap(&find(&before, m.b), &find(&before, zn)), 0.); + assert_eq!(overlap(&find(&after, m.b), &find(&after, zn)), 21056.); + for id in [m.a, m.f.n, m.x, m.y] { + assert_eq!(overlap(&find(&after, id), &find(&after, zn)), 0.); + } + assert_eq!( + candidate.diagrams[&m.f.d].nodes[&m.b], + m.f.p.diagrams[&m.f.d].nodes[&m.b] + ); + } + let plan = + arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.a, m.f.n]), m.f.n).unwrap(); + assert!(matches!( + arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)), + Err(Refusal::LayoutRequired(_)) + )); + } + } +} + +#[test] +fn explicit_arrange_preserves_existing_overlap_but_refuses_its_growth() { + let mut f = fixture(Layer::Oa); + let y = + f.p.add_element(Kind::Activity, Layer::Oa, "Existing overlap", None) + .unwrap(); + let yn = f.p.place(f.d, y, xy(250., 500.)); + let context = presentation(&f.p, f.d, None); + let single = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap(); + assert!(arrange::place(&single, xy(110., 500.), context).is_err()); + let plan = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap(); + let placed = arrange::place(&plan, xy(400., 500.), context).unwrap(); + let mut expected = f.p.clone(); + let d = expected.diagrams.get_mut(&f.d).unwrap(); + d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.); + d.nodes.get_mut(&yn).unwrap().position = xy(550., 500.); + assert_eq!( + roundtrip( + &f.p, + arrange::finish(&plan, &f.p, &placed).unwrap().unwrap() + ), + expected + ); +} + +#[test] +fn explicit_arrange_preserves_unrelated_folded_interior_diagnostic_boundary() { + for layer in [Layer::Oa, Layer::Sa] { + let mut m = distributed(layer, true); + let performer = if layer == Layer::Oa { + Kind::Actor + } else { + Kind::SystemActor + }; + let d = m.f.p.add_element(performer, layer, "D", None).unwrap(); + let z = + m.f.p + .add_element(m.f.p.elements[&m.f.w].kind(), layer, "Hidden Z", None) + .unwrap(); + m.f.p.relate(RelationKind::Allocation, "D owns Z", d, z); + let dn = m.f.p.place(m.f.d, d, xy(2500., 100.)); + m.f.p.place(m.f.d, z, xy(550., 230.)); + m.f.p + .diagrams + .get_mut(&m.f.d) + .unwrap() + .nodes + .get_mut(&dn) + .unwrap() + .collapsed = true; + let plan = arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.a, m.f.n]), m.f.n).unwrap(); + let placed = + arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)).unwrap(); + assert_eq!( + roundtrip( + &m.f.p, + arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap() + ), + expected(&m, true) + ); + } +} + +#[test] +fn explicit_arrange_noop_stale_tamper_target_and_all_selected_headers() { + let m = distributed(Layer::Sa, false); + let selected = identities(&m, &[m.a, m.f.n]); + let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.f.n).unwrap(); + let context = presentation(&m.f.p, m.f.d, None); + let noop = arrange::place(&plan, xy(103., 802.), context).unwrap(); + assert!(arrange::finish(&plan, &m.f.p, &noop).unwrap().is_none()); + let mut placed = arrange::place(&plan, xy(500., 800.), context).unwrap(); + let mut stale = m.f.p.clone(); + stale.name.push_str(" changed"); + assert!(matches!( + arrange::finish(&plan, &stale, &placed), + Err(Refusal::StaleScope) + )); + placed.diagram.nodes.get_mut(&m.b).unwrap().position.x += 1.; + assert!(matches!( + arrange::finish(&plan, &m.f.p, &placed), + Err(Refusal::StaleScope) + )); + assert!( + arrange::place( + &plan, + xy(500., 800.), + presentation(&m.f.p, m.f.d, Some(m.f.b)) + ) + .is_err() + ); + let mut clipped = context; + clipped.viewport.position = xy(400., 700.); + assert!( + arrange::place(&plan, xy(500., 800.), clipped).is_err(), + "anchor C visible, other selected A header clipped" + ); + assert!(arrange::place(&plan, xy(f32::NAN, 800.), context).is_err()); + assert!(arrange::place(&plan, xy(MAX_COORDINATE, 800.), context).is_err()); +} diff --git a/crates/oa-desktop/src/allocation_drag/tests.rs b/crates/oa-desktop/src/allocation_drag/tests.rs index 643a9517..e4401bef 100644 --- a/crates/oa-desktop/src/allocation_drag/tests.rs +++ b/crates/oa-desktop/src/allocation_drag/tests.rs @@ -5,6 +5,9 @@ use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle}; #[path = "group_arrange_experiment.rs"] mod group_arrange_experiment; +#[path = "arrange_tests.rs"] +mod arrange_tests; + struct Fixture { p: Project, d: Id,