Add pure combined Allocation Arrange planner with exact source and geometry checks

This commit is contained in:
2026-09-23 07:51:43 -07:00
parent 8dbbaf262e
commit 9b83f7ca0d
4 changed files with 798 additions and 0 deletions
+10
View File
@@ -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<Scope, Refusal> {
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<Scope, Refusal> {
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()
@@ -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<Id, Id>,
anchor: Id,
placements: BTreeMap<Id, NodePlacement>,
expanded_ids: BTreeSet<Id>,
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<Id, NodePlacement> {
&self.placements
}
pub fn selected(&self) -> &BTreeMap<Id, Id> {
&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<Id, Id>,
anchor: Id,
) -> Result<Plan, Refusal> {
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<Id>,
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<PlacementPlan, Refusal> {
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<Option<Command>, 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))
}
@@ -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<Id, Id> {
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::<BTreeSet<_>>(),
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<_>>(),
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());
}
@@ -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,