Add pure combined Allocation Arrange planner with exact source and geometry checks
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user