Plan Allocation work-group assignments as one exact transaction
This commit is contained in:
@@ -18,6 +18,14 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
#[path = "allocation_drag/arrange.rs"]
|
#[path = "allocation_drag/arrange.rs"]
|
||||||
pub mod arrange;
|
pub mod arrange;
|
||||||
|
|
||||||
|
#[path = "allocation_drag/geometry.rs"]
|
||||||
|
mod geometry;
|
||||||
|
|
||||||
|
// The combined semantic planner is verified before connecting its App adapter.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[path = "allocation_drag/assignment.rs"]
|
||||||
|
pub mod assignment;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Destination {
|
pub enum Destination {
|
||||||
LayoutOnly,
|
LayoutOnly,
|
||||||
@@ -242,8 +250,93 @@ pub fn plan_semantics(
|
|||||||
let same_performer =
|
let same_performer =
|
||||||
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
|
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
|
||||||
let mut expected = p.clone();
|
let mut expected = p.clone();
|
||||||
let mut commands = Vec::new();
|
let mut edits = IntendedEdits::default();
|
||||||
let mut deleted = BTreeSet::new();
|
append_semantic_edits(
|
||||||
|
p,
|
||||||
|
scope,
|
||||||
|
destination,
|
||||||
|
allocation_ids,
|
||||||
|
&mut expected,
|
||||||
|
&mut edits,
|
||||||
|
)?;
|
||||||
|
// A route cannot survive the intentional deletion of its own edge, even in
|
||||||
|
// another diagram. No other saved-view cleanup is authorized by this drag.
|
||||||
|
for diagram in expected.diagrams.values_mut() {
|
||||||
|
diagram.routes.retain(|r| !edits.deleted.contains(&r.edge));
|
||||||
|
}
|
||||||
|
let actual = prepare_command(p, &Command::Batch(edits.commands.clone()))
|
||||||
|
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
||||||
|
.project;
|
||||||
|
exact_project(&expected, &actual)?;
|
||||||
|
let retained_roles = scope
|
||||||
|
.leaves
|
||||||
|
.values()
|
||||||
|
.any(|l| !l.role_assignments.is_empty());
|
||||||
|
let label = match destination {
|
||||||
|
Destination::LayoutOnly => "Arrange only".into(),
|
||||||
|
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
|
||||||
|
Destination::Performer(id) => format!(
|
||||||
|
"Assign {} leaf {} to {}{}",
|
||||||
|
scope.leaves.len(),
|
||||||
|
match (scope.diagram.layer, scope.leaves.len()) {
|
||||||
|
(Layer::Oa, 1) => "activity",
|
||||||
|
(Layer::Oa, _) => "activities",
|
||||||
|
(Layer::Sa, 1) => "function",
|
||||||
|
(Layer::Sa, _) => "functions",
|
||||||
|
},
|
||||||
|
p.named(id),
|
||||||
|
if retained_roles {
|
||||||
|
" — explicit assignment; role responsibility retained"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Destination::OutsidePerformers if edits.commands.is_empty() => {
|
||||||
|
"Arrange unassigned work — decomposition retained".into()
|
||||||
|
}
|
||||||
|
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
|
||||||
|
};
|
||||||
|
let warnings = validation::validate(&actual)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|i| i.severity == Severity::Warning)
|
||||||
|
.collect();
|
||||||
|
let index = ViewIndex::new(&actual);
|
||||||
|
Ok(SemanticPlan {
|
||||||
|
scope: scope.clone(),
|
||||||
|
destination,
|
||||||
|
label,
|
||||||
|
warnings,
|
||||||
|
accepted: p.clone(),
|
||||||
|
candidate: actual,
|
||||||
|
index,
|
||||||
|
commands: edits.commands,
|
||||||
|
before_visible,
|
||||||
|
before_expanded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct IntendedEdits {
|
||||||
|
commands: Vec<Command>,
|
||||||
|
deleted: BTreeSet<Id>,
|
||||||
|
changed: BTreeSet<Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read each root's accepted responsibility while accumulating one intended
|
||||||
|
/// Project, reserving relation identities and aliases across every root.
|
||||||
|
fn append_semantic_edits(
|
||||||
|
p: &Project,
|
||||||
|
scope: &Scope,
|
||||||
|
destination: Destination,
|
||||||
|
allocation_ids: &BTreeMap<Id, Id>,
|
||||||
|
expected: &mut Project,
|
||||||
|
edits: &mut IntendedEdits,
|
||||||
|
) -> Result<(), Refusal> {
|
||||||
|
let same_performer =
|
||||||
|
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
|
||||||
|
let commands = &mut edits.commands;
|
||||||
|
let deleted = &mut edits.deleted;
|
||||||
|
let changed = &mut edits.changed;
|
||||||
if destination != Destination::LayoutOnly && !same_performer {
|
if destination != Destination::LayoutOnly && !same_performer {
|
||||||
for (work, leaf) in &scope.leaves {
|
for (work, leaf) in &scope.leaves {
|
||||||
match destination {
|
match destination {
|
||||||
@@ -256,6 +349,7 @@ pub fn plan_semantics(
|
|||||||
target: *work,
|
target: *work,
|
||||||
};
|
};
|
||||||
expected.relations.insert(edited.id, edited.clone());
|
expected.relations.insert(edited.id, edited.clone());
|
||||||
|
changed.insert(*work);
|
||||||
commands.push(Command::EditRelation(edited.id, edited));
|
commands.push(Command::EditRelation(edited.id, edited));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -279,6 +373,7 @@ pub fn plan_semantics(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
expected.relations.insert(id, relation.clone());
|
expected.relations.insert(id, relation.clone());
|
||||||
|
changed.insert(*work);
|
||||||
commands.push(Command::CreateRelation(relation));
|
commands.push(Command::CreateRelation(relation));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,6 +432,7 @@ pub fn plan_semantics(
|
|||||||
if let Some(existing) = &leaf.direct {
|
if let Some(existing) = &leaf.direct {
|
||||||
expected.relations.remove(&existing.id);
|
expected.relations.remove(&existing.id);
|
||||||
deleted.insert(existing.id);
|
deleted.insert(existing.id);
|
||||||
|
changed.insert(*work);
|
||||||
commands.push(Command::Delete(existing.id));
|
commands.push(Command::Delete(existing.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,60 +440,7 @@ pub fn plan_semantics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A route cannot survive the intentional deletion of its own edge, even in
|
Ok(())
|
||||||
// another diagram. No other saved-view cleanup is authorized by this drag.
|
|
||||||
for diagram in expected.diagrams.values_mut() {
|
|
||||||
diagram.routes.retain(|r| !deleted.contains(&r.edge));
|
|
||||||
}
|
|
||||||
let actual = prepare_command(p, &Command::Batch(commands.clone()))
|
|
||||||
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
|
||||||
.project;
|
|
||||||
exact_project(&expected, &actual)?;
|
|
||||||
let retained_roles = scope
|
|
||||||
.leaves
|
|
||||||
.values()
|
|
||||||
.any(|l| !l.role_assignments.is_empty());
|
|
||||||
let label = match destination {
|
|
||||||
Destination::LayoutOnly => "Arrange only".into(),
|
|
||||||
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
|
|
||||||
Destination::Performer(id) => format!(
|
|
||||||
"Assign {} leaf {} to {}{}",
|
|
||||||
scope.leaves.len(),
|
|
||||||
match (scope.diagram.layer, scope.leaves.len()) {
|
|
||||||
(Layer::Oa, 1) => "activity",
|
|
||||||
(Layer::Oa, _) => "activities",
|
|
||||||
(Layer::Sa, 1) => "function",
|
|
||||||
(Layer::Sa, _) => "functions",
|
|
||||||
},
|
|
||||||
p.named(id),
|
|
||||||
if retained_roles {
|
|
||||||
" — explicit assignment; role responsibility retained"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Destination::OutsidePerformers if commands.is_empty() => {
|
|
||||||
"Arrange unassigned work — decomposition retained".into()
|
|
||||||
}
|
|
||||||
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
|
|
||||||
};
|
|
||||||
let warnings = validation::validate(&actual)
|
|
||||||
.into_iter()
|
|
||||||
.filter(|i| i.severity == Severity::Warning)
|
|
||||||
.collect();
|
|
||||||
let index = ViewIndex::new(&actual);
|
|
||||||
Ok(SemanticPlan {
|
|
||||||
scope: scope.clone(),
|
|
||||||
destination,
|
|
||||||
label,
|
|
||||||
warnings,
|
|
||||||
accepted: p.clone(),
|
|
||||||
candidate: actual,
|
|
||||||
index,
|
|
||||||
commands,
|
|
||||||
before_visible,
|
|
||||||
before_expanded,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// World-space rectangle in the gesture's frozen transform. The UI resolver
|
/// World-space rectangle in the gesture's frozen transform. The UI resolver
|
||||||
@@ -472,60 +515,18 @@ pub fn place(
|
|||||||
"Make room or adjust the view, then drag again.".into(),
|
"Make room or adjust the view, then drag again.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let root = &plan.scope.placements[&plan.scope.placement];
|
let geometry::Translation {
|
||||||
let delta = Position {
|
mut diagram,
|
||||||
x: (requested.x / 10.).round() * 10. - root.position.x,
|
mut commands,
|
||||||
y: (requested.y / 10.).round() * 10. - root.position.y,
|
delta,
|
||||||
};
|
} = geometry::translate(
|
||||||
let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone();
|
&plan.accepted,
|
||||||
let mut commands = Vec::new();
|
&plan.scope.diagram,
|
||||||
let mut deltas = BTreeMap::new();
|
&plan.candidate.diagrams[&plan.scope.diagram.id],
|
||||||
for (id, original) in &plan.scope.placements {
|
&plan.scope.placements,
|
||||||
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
|
plan.scope.placement,
|
||||||
node.position = Position {
|
requested,
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Resolve endpoints using the captured accepted semantics, not a newly
|
|
||||||
// regrouped descendant walk. Own-edge deletions were removed above already.
|
|
||||||
for route in projection::translated_routes(&plan.accepted, &plan.scope.diagram, &deltas) {
|
|
||||||
if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
|
|
||||||
if route.waypoints.iter().any(|p| {
|
|
||||||
!p.x.is_finite()
|
|
||||||
|| !p.y.is_finite()
|
|
||||||
|| p.x.abs() > MAX_COORDINATE
|
|
||||||
|| p.y.abs() > MAX_COORDINATE
|
|
||||||
}) {
|
|
||||||
return Err(Refusal::InvalidCandidate(
|
|
||||||
"Moved route exceeds the model bounds.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
*existing = route.clone();
|
|
||||||
commands.push(Command::SetEdgeRoute {
|
|
||||||
diagram: diagram.id,
|
|
||||||
route,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut unfolded = None;
|
let mut unfolded = None;
|
||||||
if let Destination::Performer(performer) = plan.destination {
|
if let Destination::Performer(performer) = plan.destination {
|
||||||
let target = presentation.target.ok_or_else(|| {
|
let target = presentation.target.ok_or_else(|| {
|
||||||
|
|||||||
@@ -121,20 +121,10 @@ pub fn capture(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Geometry never changes visual ancestry in Arrange. Recover the complete
|
// Arrange preserves ancestry, so accepted diagnostic identities also apply
|
||||||
// ancestry once, including hidden nodes. This all-unfolded projection is
|
// to every translated candidate.
|
||||||
// used only for identity lookup, never as a collision-admission scene.
|
let expanded_ids = geometry::expansion_ids(p, d, &index, &placements)?;
|
||||||
let mut all_unfolded = d.clone();
|
let before_expanded = geometry::expanded_view(p, d, &index, &expanded_ids);
|
||||||
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 {
|
Ok(Plan {
|
||||||
accepted: p.clone(),
|
accepted: p.clone(),
|
||||||
diagram,
|
diagram,
|
||||||
@@ -148,21 +138,6 @@ pub fn capture(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
/// Snap the captured anchor once; preview the complete union without cloning or
|
||||||
/// validating a Project, rebuilding its semantic index, or expanding scope.
|
/// validating a Project, rebuilding its semantic index, or expanding scope.
|
||||||
pub fn place(
|
pub fn place(
|
||||||
@@ -185,120 +160,35 @@ pub fn place(
|
|||||||
"Make room or adjust the view, then drag again.".into(),
|
"Make room or adjust the view, then drag again.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let anchor = &plan.placements[&plan.anchor];
|
let geometry::Translation {
|
||||||
let delta = Position {
|
diagram,
|
||||||
x: (requested.x / 10.).round() * 10. - anchor.position.x,
|
commands,
|
||||||
y: (requested.y / 10.).round() * 10. - anchor.position.y,
|
delta,
|
||||||
};
|
} = geometry::translate(
|
||||||
let mut diagram = plan.diagram().clone();
|
&plan.accepted,
|
||||||
let mut commands = Vec::new();
|
plan.diagram(),
|
||||||
let mut deltas = BTreeMap::new();
|
plan.diagram(),
|
||||||
for (id, original) in &plan.placements {
|
&plan.placements,
|
||||||
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
|
plan.anchor,
|
||||||
node.position = Position {
|
requested,
|
||||||
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 view = projection::project_diagram_with_index(&plan.accepted, &diagram, &plan.index);
|
||||||
let expanded = diagnostic(&plan.accepted, &diagram, &plan.expanded_ids, &plan.index);
|
let expanded =
|
||||||
if view.nodes.iter().chain(&expanded.nodes).any(|node| {
|
geometry::expanded_view(&plan.accepted, &diagram, &plan.index, &plan.expanded_ids);
|
||||||
!(Rect {
|
// Preserve Arrange's refusal precedence: projected bounds, then rigidity,
|
||||||
position: node.position,
|
// then overlap growth. The shared scene check combines bounds and overlaps.
|
||||||
size: node.size,
|
let scenes = match geometry::check_scenes(
|
||||||
})
|
&plan.before_visible,
|
||||||
.valid()
|
&plan.before_expanded,
|
||||||
}) {
|
&view,
|
||||||
return Err(Refusal::InvalidCandidate(
|
&expanded,
|
||||||
"The projected enclosure exceeds the model bounds.".into(),
|
) {
|
||||||
));
|
Err(error @ Refusal::InvalidCandidate(_)) => return Err(error),
|
||||||
}
|
result => result,
|
||||||
let before: BTreeMap<_, _> = plan
|
};
|
||||||
.before_expanded
|
geometry::check_rigid(&plan.before_expanded, &expanded, &plan.placements, delta)?;
|
||||||
.nodes
|
scenes?;
|
||||||
.iter()
|
geometry::check_headers(&view, &plan.selected, presentation.viewport)?;
|
||||||
.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 {
|
Ok(PlacementPlan {
|
||||||
diagram,
|
diagram,
|
||||||
view,
|
view,
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
//! Ordinary work-only group assignment, with one combined semantic/geometry plan.
|
||||||
|
//! App retains authority over the complete typed selection and gesture lifetime.
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Captured {
|
||||||
|
accepted: Project,
|
||||||
|
diagram: Id,
|
||||||
|
selected: BTreeMap<Id, Id>,
|
||||||
|
anchor: Id,
|
||||||
|
roots: Vec<super::Scope>,
|
||||||
|
placements: BTreeMap<Id, NodePlacement>,
|
||||||
|
leaves: BTreeMap<Id, Leaf>,
|
||||||
|
index: ViewIndex,
|
||||||
|
before_visible: ProjectedView,
|
||||||
|
before_expanded: ProjectedView,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destination plans share one immutable accepted capture.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Scope(Arc<Captured>);
|
||||||
|
|
||||||
|
impl Scope {
|
||||||
|
pub fn project(&self) -> &Project {
|
||||||
|
&self.0.accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn index(&self) -> &ViewIndex {
|
||||||
|
&self.0.index
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diagram(&self) -> &Diagram {
|
||||||
|
&self.0.accepted.diagrams[&self.0.diagram]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> &BTreeMap<Id, Id> {
|
||||||
|
&self.0.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn affected(&self) -> &BTreeMap<Id, NodePlacement> {
|
||||||
|
&self.0.placements
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn anchor(&self) -> Id {
|
||||||
|
self.0.anchor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leaves(&self) -> &BTreeMap<Id, Leaf> {
|
||||||
|
&self.0.leaves
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App must reject non-placement members before constructing this complete map.
|
||||||
|
/// Every original identity is checked before redundant work roots are removed.
|
||||||
|
pub fn capture(
|
||||||
|
p: &Project,
|
||||||
|
diagram: Id,
|
||||||
|
selected: &BTreeMap<Id, Id>,
|
||||||
|
anchor: Id,
|
||||||
|
) -> Result<Scope, 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(
|
||||||
|
"Assignment requires selected work 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(|node| (node.id, node.element))
|
||||||
|
.collect();
|
||||||
|
let mut work = BTreeMap::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.kind().is_work() || record.layer() != d.layer {
|
||||||
|
return Err(Refusal::Ineligible(
|
||||||
|
"Assignment requires only activities or functions in this layer. Use Arrange only for a supported mixed selection."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
work.insert(*element, *placement);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
let mut placements = BTreeMap::new();
|
||||||
|
let mut leaves = BTreeMap::new();
|
||||||
|
for (element, placement) in &work {
|
||||||
|
let record = &p.elements[element];
|
||||||
|
let mut ancestor = record.parent;
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
let mut covered = false;
|
||||||
|
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() != record.kind() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if work.contains_key(&id) {
|
||||||
|
covered = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ancestor = parent.parent;
|
||||||
|
}
|
||||||
|
if covered {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let root = capture_validated(p, diagram, *element, *placement)?;
|
||||||
|
if root.work_records.len() > 1 && root.uniform == Uniform::MixedOrUnresolved {
|
||||||
|
return Err(Refusal::MixedLayoutRequired);
|
||||||
|
}
|
||||||
|
for (id, leaf) in &root.leaves {
|
||||||
|
if leaves.insert(*id, leaf.clone()).is_some() {
|
||||||
|
return Err(Refusal::StaleScope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placements.extend(root.placements.clone());
|
||||||
|
roots.push(root);
|
||||||
|
}
|
||||||
|
let expansion = geometry::expansion_ids(p, d, &index, &placements)?;
|
||||||
|
let before_expanded = geometry::expanded_view(p, d, &index, &expansion);
|
||||||
|
if roots
|
||||||
|
.iter()
|
||||||
|
.any(|root| root.work_records.len() > 1 && !coherent(root, &before_expanded))
|
||||||
|
{
|
||||||
|
return Err(Refusal::MixedLayoutRequired);
|
||||||
|
}
|
||||||
|
Ok(Scope(Arc::new(Captured {
|
||||||
|
accepted: p.clone(),
|
||||||
|
diagram,
|
||||||
|
selected: selected.clone(),
|
||||||
|
anchor,
|
||||||
|
roots,
|
||||||
|
placements,
|
||||||
|
leaves,
|
||||||
|
index,
|
||||||
|
before_visible,
|
||||||
|
before_expanded,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SemanticPlan {
|
||||||
|
scope: Scope,
|
||||||
|
destination: Destination,
|
||||||
|
candidate: Project,
|
||||||
|
index: ViewIndex,
|
||||||
|
commands: Vec<Command>,
|
||||||
|
changed: BTreeSet<Id>,
|
||||||
|
expanded_ids: BTreeSet<Id>,
|
||||||
|
label: String,
|
||||||
|
warnings: Vec<Issue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SemanticPlan {
|
||||||
|
pub fn scope(&self) -> &Scope {
|
||||||
|
&self.scope
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project(&self) -> &Project {
|
||||||
|
&self.candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn index(&self) -> &ViewIndex {
|
||||||
|
&self.index
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn destination(&self) -> Destination {
|
||||||
|
self.destination
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
&self.label
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warnings(&self) -> &[Issue] {
|
||||||
|
&self.warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn changed_count(&self) -> usize {
|
||||||
|
self.changed.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Already effective at the performer, or already unassigned for Outside.
|
||||||
|
pub fn already_correct_count(&self) -> usize {
|
||||||
|
self.scope.leaves().len() - self.changed.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one destination candidate. Per-root decisions read accepted facts;
|
||||||
|
/// aliases and new identities are reserved in the combined expected Project.
|
||||||
|
pub fn plan_semantics(
|
||||||
|
scope: &Scope,
|
||||||
|
destination: Destination,
|
||||||
|
allocation_ids: &BTreeMap<Id, Id>,
|
||||||
|
) -> Result<SemanticPlan, Refusal> {
|
||||||
|
let accepted = scope.project();
|
||||||
|
match destination {
|
||||||
|
Destination::LayoutOnly => {
|
||||||
|
return Err(Refusal::Ineligible(
|
||||||
|
"Use the explicit Arrange only planner for presentation-only movement.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Destination::Performer(id) => {
|
||||||
|
if !accepted.elements.get(&id).is_some_and(|element| {
|
||||||
|
element.kind().is_performer() && element.layer() == scope.diagram().layer
|
||||||
|
}) {
|
||||||
|
return Err(Refusal::Ineligible(
|
||||||
|
"Choose a performer in this layer.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Destination::OutsidePerformers => {}
|
||||||
|
}
|
||||||
|
let mut expected = accepted.clone();
|
||||||
|
let mut edits = IntendedEdits::default();
|
||||||
|
for root in &scope.0.roots {
|
||||||
|
append_semantic_edits(
|
||||||
|
accepted,
|
||||||
|
root,
|
||||||
|
destination,
|
||||||
|
allocation_ids,
|
||||||
|
&mut expected,
|
||||||
|
&mut edits,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for diagram in expected.diagrams.values_mut() {
|
||||||
|
diagram
|
||||||
|
.routes
|
||||||
|
.retain(|route| !edits.deleted.contains(&route.edge));
|
||||||
|
}
|
||||||
|
let candidate = prepare_command(accepted, &Command::Batch(edits.commands.clone()))
|
||||||
|
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
||||||
|
.project;
|
||||||
|
exact_project(&expected, &candidate)?;
|
||||||
|
let warnings = validation::validate(&candidate)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|issue| issue.severity == Severity::Warning)
|
||||||
|
.collect();
|
||||||
|
let index = ViewIndex::new(&candidate);
|
||||||
|
// Assignment may change visual ancestry: never reuse accepted expansion IDs.
|
||||||
|
let expanded_ids = geometry::expansion_ids(
|
||||||
|
&candidate,
|
||||||
|
&candidate.diagrams[&scope.0.diagram],
|
||||||
|
&index,
|
||||||
|
scope.affected(),
|
||||||
|
)?;
|
||||||
|
let total = scope.leaves().len();
|
||||||
|
let changed = edits.changed.len();
|
||||||
|
let already_correct = total - changed;
|
||||||
|
let noun = match (scope.diagram().layer, total) {
|
||||||
|
(Layer::Oa, 1) => "activity",
|
||||||
|
(Layer::Oa, _) => "activities",
|
||||||
|
(Layer::Sa, 1) => "function",
|
||||||
|
(Layer::Sa, _) => "functions",
|
||||||
|
};
|
||||||
|
let retained_roles = scope
|
||||||
|
.leaves()
|
||||||
|
.values()
|
||||||
|
.any(|leaf| !leaf.role_assignments.is_empty());
|
||||||
|
let label = match destination {
|
||||||
|
Destination::Performer(id) if changed == 0 => {
|
||||||
|
format!(
|
||||||
|
"Arrange within {} — all {total} leaf {noun} already assigned",
|
||||||
|
accepted.named(id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Destination::Performer(id) => format!(
|
||||||
|
"Assign {changed} of {total} leaf {noun} to {}{}{}",
|
||||||
|
accepted.named(id),
|
||||||
|
if already_correct > 0 {
|
||||||
|
format!(" · {already_correct} already assigned")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
},
|
||||||
|
if retained_roles {
|
||||||
|
" — role responsibility retained"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Destination::OutsidePerformers if changed == 0 => {
|
||||||
|
"Arrange unassigned work — decomposition retained".into()
|
||||||
|
}
|
||||||
|
Destination::OutsidePerformers => format!(
|
||||||
|
"Remove assignments from {changed} of {total} leaf {noun} — decomposition retained"
|
||||||
|
),
|
||||||
|
Destination::LayoutOnly => unreachable!(),
|
||||||
|
};
|
||||||
|
Ok(SemanticPlan {
|
||||||
|
scope: scope.clone(),
|
||||||
|
destination,
|
||||||
|
candidate,
|
||||||
|
index,
|
||||||
|
commands: edits.commands,
|
||||||
|
changed: edits.changed,
|
||||||
|
expanded_ids,
|
||||||
|
label,
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate the entire captured Q once, then evaluate the combined candidate.
|
||||||
|
/// This path does not clone/validate a Project or rebuild semantic indexes.
|
||||||
|
pub fn place(
|
||||||
|
plan: &SemanticPlan,
|
||||||
|
requested: Position,
|
||||||
|
presentation: PresentationContext,
|
||||||
|
) -> Result<PlacementPlan, Refusal> {
|
||||||
|
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 scope = &plan.scope;
|
||||||
|
let geometry::Translation {
|
||||||
|
mut diagram,
|
||||||
|
mut commands,
|
||||||
|
delta,
|
||||||
|
} = geometry::translate(
|
||||||
|
scope.project(),
|
||||||
|
scope.diagram(),
|
||||||
|
&plan.candidate.diagrams[&scope.0.diagram],
|
||||||
|
scope.affected(),
|
||||||
|
scope.anchor(),
|
||||||
|
requested,
|
||||||
|
)?;
|
||||||
|
let mut unfolded = None;
|
||||||
|
if let Destination::Performer(performer) = plan.destination {
|
||||||
|
let target = presentation.target.ok_or_else(|| {
|
||||||
|
Refusal::LayoutRequired("A visible accepted performer target is required.".into())
|
||||||
|
})?;
|
||||||
|
if !scope
|
||||||
|
.0
|
||||||
|
.before_visible
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.any(|node| node.id == target.placement && node.element == performer)
|
||||||
|
{
|
||||||
|
return Err(Refusal::LayoutRequired(
|
||||||
|
"Make room or adjust the view, then drag again.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let target_node = diagram
|
||||||
|
.nodes
|
||||||
|
.get_mut(&target.placement)
|
||||||
|
.ok_or(Refusal::StaleScope)?;
|
||||||
|
if target_node.element != performer {
|
||||||
|
return Err(Refusal::StaleScope);
|
||||||
|
}
|
||||||
|
if !plan.changed.is_empty() && target_node.collapsed {
|
||||||
|
target_node.collapsed = false;
|
||||||
|
unfolded = Some(target_node.id);
|
||||||
|
commands.push(Command::EditNode {
|
||||||
|
diagram: diagram.id,
|
||||||
|
original: target_node.id,
|
||||||
|
node: target_node.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if presentation.target.is_some() {
|
||||||
|
return Err(Refusal::Ineligible(
|
||||||
|
"An outside destination has no performer target.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index);
|
||||||
|
let expanded =
|
||||||
|
geometry::expanded_view(&plan.candidate, &diagram, &plan.index, &plan.expanded_ids);
|
||||||
|
if scope
|
||||||
|
.0
|
||||||
|
.roots
|
||||||
|
.iter()
|
||||||
|
.any(|root| root.work_records.len() > 1 && !coherent(root, &expanded))
|
||||||
|
{
|
||||||
|
return Err(Refusal::MixedLayoutRequired);
|
||||||
|
}
|
||||||
|
geometry::check_scenes(
|
||||||
|
&scope.0.before_visible,
|
||||||
|
&scope.0.before_expanded,
|
||||||
|
&view,
|
||||||
|
&expanded,
|
||||||
|
)?;
|
||||||
|
geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?;
|
||||||
|
geometry::check_headers(&view, scope.selected(), presentation.viewport)?;
|
||||||
|
Ok(PlacementPlan {
|
||||||
|
diagram,
|
||||||
|
view,
|
||||||
|
delta,
|
||||||
|
unfolded,
|
||||||
|
requested,
|
||||||
|
presentation,
|
||||||
|
commands,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recheck immutable facts and recompute geometry before one final source batch.
|
||||||
|
/// Zero delta remains a transaction when assignment semantics changed.
|
||||||
|
pub fn finish(
|
||||||
|
plan: &SemanticPlan,
|
||||||
|
live: &Project,
|
||||||
|
placement: &PlacementPlan,
|
||||||
|
) -> Result<Option<Command>, Refusal> {
|
||||||
|
if live != plan.scope.project() {
|
||||||
|
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.candidate.clone();
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.insert(checked.diagram.id, checked.diagram);
|
||||||
|
let mut commands = plan.commands.clone();
|
||||||
|
commands.extend(checked.commands);
|
||||||
|
let command = Command::Batch(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,497 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct Composite {
|
||||||
|
f: Fixture,
|
||||||
|
child: Id,
|
||||||
|
child_node: Id,
|
||||||
|
unplaced: Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn composite(layer: Layer) -> Composite {
|
||||||
|
let mut f = fixture(layer);
|
||||||
|
let kind = f.p.elements[&f.w].kind();
|
||||||
|
let child =
|
||||||
|
f.p.add_element(kind, layer, "Placed child", Some(f.w))
|
||||||
|
.unwrap();
|
||||||
|
let unplaced =
|
||||||
|
f.p.add_element(kind, layer, "Unplaced leaf", Some(f.w))
|
||||||
|
.unwrap();
|
||||||
|
let child_node = f.p.place(f.d, child, xy(140., 600.));
|
||||||
|
validate_candidate(&f.p).unwrap();
|
||||||
|
Composite {
|
||||||
|
f,
|
||||||
|
child,
|
||||||
|
child_node,
|
||||||
|
unplaced,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_allocation(id: Id, alias: &str, name: &str, performer: Id, work: Id) -> Relation {
|
||||||
|
Relation {
|
||||||
|
id,
|
||||||
|
alias: alias.into(),
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
body: RelationBody::Allocation {
|
||||||
|
source: performer,
|
||||||
|
target: work,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_checks_original_identity_and_visibility_before_normalization() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let c = composite(layer);
|
||||||
|
let valid = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]);
|
||||||
|
let admitted = assignment::capture(&c.f.p, c.f.d, &valid, c.f.n).unwrap();
|
||||||
|
assert_eq!(admitted.selected(), &valid);
|
||||||
|
let foreign = c.f.p.diagrams[&c.f.second]
|
||||||
|
.nodes
|
||||||
|
.values()
|
||||||
|
.find(|node| node.element == c.f.w)
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
// Each extra member is semantically covered by the valid selected parent.
|
||||||
|
// Normalizing first would incorrectly hide its invalid original identity.
|
||||||
|
for invalid in [
|
||||||
|
BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.unplaced)]),
|
||||||
|
BTreeMap::from([(c.f.n, c.f.w), (Id::new_v4(), c.child)]),
|
||||||
|
BTreeMap::from([(c.f.n, c.f.w), (foreign, c.f.w)]),
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
assignment::capture(&c.f.p, c.f.d, &invalid, c.f.n),
|
||||||
|
Err(Refusal::StaleScope)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut folded = c.f.p.clone();
|
||||||
|
folded
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&c.f.d)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&c.f.n)
|
||||||
|
.unwrap()
|
||||||
|
.collapsed = true;
|
||||||
|
validate_candidate(&folded).unwrap();
|
||||||
|
assert!(
|
||||||
|
!projection::project_view(&folded, c.f.d)
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.any(|node| node.id == c.child_node)
|
||||||
|
);
|
||||||
|
// Hidden descendants remain in Q when only the visible parent is selected.
|
||||||
|
let parent_only =
|
||||||
|
assignment::capture(&folded, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n).unwrap();
|
||||||
|
assert!(parent_only.affected().contains_key(&c.child_node));
|
||||||
|
assert!(matches!(
|
||||||
|
assignment::capture(&folded, c.f.d, &valid, c.f.n),
|
||||||
|
Err(Refusal::StaleScope)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_normalizes_parent_child_and_assigns_unplaced_leaf_once() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let c = composite(layer);
|
||||||
|
let selected = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]);
|
||||||
|
let scope = assignment::capture(&c.f.p, c.f.d, &selected, c.f.n).unwrap();
|
||||||
|
assert_eq!(scope.selected(), &selected);
|
||||||
|
assert_eq!(
|
||||||
|
scope.affected().keys().copied().collect::<BTreeSet<_>>(),
|
||||||
|
BTreeSet::from([c.f.n, c.child_node])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scope.leaves().keys().copied().collect::<BTreeSet<_>>(),
|
||||||
|
BTreeSet::from([c.child, c.unplaced])
|
||||||
|
);
|
||||||
|
let child_allocation = Id::new_v4();
|
||||||
|
let unplaced_allocation = Id::new_v4();
|
||||||
|
let ids = BTreeMap::from([
|
||||||
|
(c.child, child_allocation),
|
||||||
|
(c.unplaced, unplaced_allocation),
|
||||||
|
]);
|
||||||
|
let plan = assignment::plan_semantics(&scope, Destination::Performer(c.f.a), &ids).unwrap();
|
||||||
|
assert_eq!(plan.changed_count(), 2);
|
||||||
|
let mut expected = c.f.p.clone();
|
||||||
|
expected.relations.insert(
|
||||||
|
child_allocation,
|
||||||
|
expected_allocation(
|
||||||
|
child_allocation,
|
||||||
|
"a_performs_placed_child",
|
||||||
|
"A performs Placed child",
|
||||||
|
c.f.a,
|
||||||
|
c.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expected.relations.insert(
|
||||||
|
unplaced_allocation,
|
||||||
|
expected_allocation(
|
||||||
|
unplaced_allocation,
|
||||||
|
"a_performs_unplaced_leaf",
|
||||||
|
"A performs Unplaced leaf",
|
||||||
|
c.f.a,
|
||||||
|
c.unplaced,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(plan.project(), &expected);
|
||||||
|
let placed = assignment::place(
|
||||||
|
&plan,
|
||||||
|
xy(140., 200.),
|
||||||
|
presentation(&c.f.p, c.f.d, Some(c.f.a)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut clipped = presentation(&c.f.p, c.f.d, Some(c.f.a));
|
||||||
|
clipped.viewport.size.height = 450.; // bottom350: parent header fits; selected child ends362.
|
||||||
|
for (id, should_fit) in [(c.f.n, true), (c.child_node, false)] {
|
||||||
|
let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap();
|
||||||
|
let header = Rect {
|
||||||
|
position: node.position,
|
||||||
|
size: Size {
|
||||||
|
width: node.size.width,
|
||||||
|
height: 62.,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(clipped.viewport.contains(header), should_fit);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
assignment::place(&plan, xy(140., 200.), clipped),
|
||||||
|
Err(Refusal::LayoutRequired(_))
|
||||||
|
),
|
||||||
|
"normalized-away selected child still requires its header"
|
||||||
|
);
|
||||||
|
let d = expected.diagrams.get_mut(&c.f.d).unwrap();
|
||||||
|
d.nodes.get_mut(&c.f.n).unwrap().position = xy(140., 200.);
|
||||||
|
d.nodes.get_mut(&c.child_node).unwrap().position = xy(180., 300.);
|
||||||
|
assert_eq!(placed.delta, xy(40., -300.));
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip(
|
||||||
|
&c.f.p,
|
||||||
|
assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap()
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
expected.diagrams[&c.f.d].nodes.len(),
|
||||||
|
c.f.p.diagrams[&c.f.d].nodes.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!expected.diagrams[&c.f.d]
|
||||||
|
.nodes
|
||||||
|
.values()
|
||||||
|
.any(|node| node.element == c.unplaced)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!expected
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.any(|relation| relation.kind() == RelationKind::Allocation
|
||||||
|
&& relation.target() == c.f.w)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_distributed_parent_refuses_but_its_independent_leaves_can_reassign() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let mut c = composite(layer);
|
||||||
|
let second_node = c.f.p.place(c.f.d, c.unplaced, xy(940., 200.));
|
||||||
|
let performer =
|
||||||
|
c.f.p
|
||||||
|
.add_element(
|
||||||
|
if layer == Layer::Oa {
|
||||||
|
Kind::Actor
|
||||||
|
} else {
|
||||||
|
Kind::SystemActor
|
||||||
|
},
|
||||||
|
layer,
|
||||||
|
"C",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.f.p.place(c.f.d, performer, xy(1700., 100.));
|
||||||
|
let d = c.f.p.diagrams.get_mut(&c.f.d).unwrap();
|
||||||
|
d.nodes.get_mut(&c.f.n).unwrap().position = xy(1700., 800.);
|
||||||
|
d.nodes.get_mut(&c.child_node).unwrap().position = xy(140., 200.);
|
||||||
|
let ax =
|
||||||
|
c.f.p
|
||||||
|
.relate(RelationKind::Allocation, "A child", c.f.a, c.child);
|
||||||
|
let by =
|
||||||
|
c.f.p
|
||||||
|
.relate(RelationKind::Allocation, "B child", c.f.b, c.unplaced);
|
||||||
|
validate_candidate(&c.f.p).unwrap();
|
||||||
|
let visible = projection::project_view(&c.f.p, c.f.d);
|
||||||
|
for id in [c.f.n, c.child_node, second_node] {
|
||||||
|
assert!(visible.nodes.iter().any(|node| node.id == id));
|
||||||
|
}
|
||||||
|
assert_eq!(scope(&c.f).uniform, Uniform::MixedOrUnresolved);
|
||||||
|
assert!(matches!(
|
||||||
|
assignment::capture(&c.f.p, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n,),
|
||||||
|
Err(Refusal::MixedLayoutRequired)
|
||||||
|
));
|
||||||
|
let selected = BTreeMap::from([(c.child_node, c.child), (second_node, c.unplaced)]);
|
||||||
|
let captured = assignment::capture(&c.f.p, c.f.d, &selected, c.child_node).unwrap();
|
||||||
|
let plan = assignment::plan_semantics(
|
||||||
|
&captured,
|
||||||
|
Destination::Performer(performer),
|
||||||
|
&BTreeMap::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut expected = c.f.p.clone();
|
||||||
|
expected.relations.get_mut(&ax).unwrap().body = RelationBody::Allocation {
|
||||||
|
source: performer,
|
||||||
|
target: c.child,
|
||||||
|
};
|
||||||
|
expected.relations.get_mut(&by).unwrap().body = RelationBody::Allocation {
|
||||||
|
source: performer,
|
||||||
|
target: c.unplaced,
|
||||||
|
};
|
||||||
|
assert_eq!(plan.project(), &expected);
|
||||||
|
// One delta moves both leaves. The unselected semantic parent remains
|
||||||
|
// authored at (1700,800), while its derived enclosure may change.
|
||||||
|
let placed = assignment::place(
|
||||||
|
&plan,
|
||||||
|
xy(1740., 120.),
|
||||||
|
presentation(&c.f.p, c.f.d, Some(performer)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let d = expected.diagrams.get_mut(&c.f.d).unwrap();
|
||||||
|
d.nodes.get_mut(&c.child_node).unwrap().position = xy(1740., 120.);
|
||||||
|
d.nodes.get_mut(&second_node).unwrap().position = xy(2540., 120.);
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip(
|
||||||
|
&c.f.p,
|
||||||
|
assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap()
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn separate_unassigned(layer: Layer) -> (Fixture, Id, Id) {
|
||||||
|
let mut f = fixture(layer);
|
||||||
|
let companion =
|
||||||
|
f.p.add_element(f.p.elements[&f.w].kind(), layer, "Companion", None)
|
||||||
|
.unwrap();
|
||||||
|
let companion_node = f.p.place(f.d, companion, xy(100., 1000.));
|
||||||
|
validate_candidate(&f.p).unwrap();
|
||||||
|
(f, companion, companion_node)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_finish_rejects_stale_project_and_each_tampered_result() {
|
||||||
|
let (f, companion, companion_node) = separate_unassigned(Layer::Oa);
|
||||||
|
let selected = BTreeMap::from([(f.n, f.w), (companion_node, companion)]);
|
||||||
|
let captured = assignment::capture(&f.p, f.d, &selected, f.n).unwrap();
|
||||||
|
let plan =
|
||||||
|
assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
|
||||||
|
.unwrap();
|
||||||
|
let context = presentation(&f.p, f.d, None);
|
||||||
|
let valid = assignment::place(&plan, xy(500., 500.), context).unwrap();
|
||||||
|
let mut expected = f.p.clone();
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&f.d)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&f.n)
|
||||||
|
.unwrap()
|
||||||
|
.position = xy(500., 500.);
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&f.d)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&companion_node)
|
||||||
|
.unwrap()
|
||||||
|
.position = xy(500., 1000.);
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip(
|
||||||
|
&f.p,
|
||||||
|
assignment::finish(&plan, &f.p, &valid).unwrap().unwrap()
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
let mut stale = f.p.clone();
|
||||||
|
stale.elements.get_mut(&f.b).unwrap().description = "Changed unrelated accepted record".into();
|
||||||
|
validate_candidate(&stale).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
assignment::finish(&plan, &stale, &valid),
|
||||||
|
Err(Refusal::StaleScope)
|
||||||
|
));
|
||||||
|
for field in 0..6 {
|
||||||
|
let mut altered = assignment::place(&plan, xy(500., 500.), context).unwrap();
|
||||||
|
match field {
|
||||||
|
0 => altered.diagram.nodes.get_mut(&f.n).unwrap().position.x += 20.,
|
||||||
|
1 => {
|
||||||
|
altered
|
||||||
|
.view
|
||||||
|
.nodes
|
||||||
|
.iter_mut()
|
||||||
|
.find(|node| node.id == f.n)
|
||||||
|
.unwrap()
|
||||||
|
.position
|
||||||
|
.x += 20.
|
||||||
|
}
|
||||||
|
2 => altered.delta.x += 20.,
|
||||||
|
3 => altered.unfolded = Some(f.n),
|
||||||
|
4 => altered.commands.clear(),
|
||||||
|
5 => altered.requested.x += 10.,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
assignment::finish(&plan, &f.p, &altered),
|
||||||
|
Err(Refusal::StaleScope)
|
||||||
|
),
|
||||||
|
"tampered result field {field}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_requires_non_anchor_selected_header_inside_viewport() {
|
||||||
|
let (f, companion, companion_node) = separate_unassigned(Layer::Oa);
|
||||||
|
let captured = assignment::capture(
|
||||||
|
&f.p,
|
||||||
|
f.d,
|
||||||
|
&BTreeMap::from([(f.n, f.w), (companion_node, companion)]),
|
||||||
|
f.n,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let plan =
|
||||||
|
assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
|
||||||
|
.unwrap();
|
||||||
|
let wide = presentation(&f.p, f.d, None);
|
||||||
|
let placed = assignment::place(&plan, xy(500., 500.), wide).unwrap();
|
||||||
|
let mut narrow = wide;
|
||||||
|
narrow.viewport.size.height = 800.; // world bottom700: anchor header fits, companion does not.
|
||||||
|
let header = |id| {
|
||||||
|
let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap();
|
||||||
|
Rect {
|
||||||
|
position: node.position,
|
||||||
|
size: Size {
|
||||||
|
width: node.size.width,
|
||||||
|
height: 62.,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(narrow.viewport.contains(header(f.n)));
|
||||||
|
assert!(!narrow.viewport.contains(header(companion_node)));
|
||||||
|
// Only the viewport differs from the already admitted identical geometry.
|
||||||
|
assert!(matches!(
|
||||||
|
assignment::place(&plan, xy(500., 500.), narrow),
|
||||||
|
Err(Refusal::LayoutRequired(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_unresolved_oa_role_leaf_accepts_explicit_target_but_not_outside() {
|
||||||
|
for ambiguous in [false, true] {
|
||||||
|
let (mut f, companion, companion_node) = separate_unassigned(Layer::Oa);
|
||||||
|
let (role_id, role_assignment) = role(&mut f, None);
|
||||||
|
if ambiguous {
|
||||||
|
f.p.relate(
|
||||||
|
RelationKind::RoleRealization,
|
||||||
|
"A realizes responsibility",
|
||||||
|
f.a,
|
||||||
|
role_id,
|
||||||
|
);
|
||||||
|
f.p.relate(
|
||||||
|
RelationKind::RoleRealization,
|
||||||
|
"B realizes responsibility",
|
||||||
|
f.b,
|
||||||
|
role_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validate_candidate(&f.p).unwrap();
|
||||||
|
assert_eq!(validation::effective_performer(&f.p, f.w), None);
|
||||||
|
let captured = assignment::capture(
|
||||||
|
&f.p,
|
||||||
|
f.d,
|
||||||
|
&BTreeMap::from([(f.n, f.w), (companion_node, companion)]),
|
||||||
|
f.n,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let Refusal::RoleExitRequired {
|
||||||
|
work,
|
||||||
|
roles,
|
||||||
|
performers,
|
||||||
|
..
|
||||||
|
} = assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
|
||||||
|
.unwrap_err()
|
||||||
|
else {
|
||||||
|
panic!("unresolved role must produce its specific outward refusal");
|
||||||
|
};
|
||||||
|
assert_eq!(work, f.w);
|
||||||
|
assert_eq!(roles, vec![role_id]);
|
||||||
|
assert_eq!(
|
||||||
|
performers.into_iter().collect::<BTreeSet<_>>(),
|
||||||
|
if ambiguous {
|
||||||
|
BTreeSet::from([f.a, f.b])
|
||||||
|
} else {
|
||||||
|
BTreeSet::new()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let work_allocation = Id::new_v4();
|
||||||
|
let companion_allocation = Id::new_v4();
|
||||||
|
let ids = BTreeMap::from([(f.w, work_allocation), (companion, companion_allocation)]);
|
||||||
|
let plan =
|
||||||
|
assignment::plan_semantics(&captured, Destination::Performer(f.b), &ids).unwrap();
|
||||||
|
assert_eq!(plan.changed_count(), 2);
|
||||||
|
assert!(plan.label().contains("role responsibility retained"));
|
||||||
|
let mut expected = f.p.clone();
|
||||||
|
expected.relations.insert(
|
||||||
|
work_allocation,
|
||||||
|
expected_allocation(
|
||||||
|
work_allocation,
|
||||||
|
"b_performs_work",
|
||||||
|
"B performs Work",
|
||||||
|
f.b,
|
||||||
|
f.w,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expected.relations.insert(
|
||||||
|
companion_allocation,
|
||||||
|
expected_allocation(
|
||||||
|
companion_allocation,
|
||||||
|
"b_performs_companion",
|
||||||
|
"B performs Companion",
|
||||||
|
f.b,
|
||||||
|
companion,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(plan.project(), &expected);
|
||||||
|
assert_eq!(
|
||||||
|
plan.project().relations[&role_assignment],
|
||||||
|
f.p.relations[&role_assignment]
|
||||||
|
);
|
||||||
|
let placed =
|
||||||
|
assignment::place(&plan, xy(940., 200.), presentation(&f.p, f.d, Some(f.b))).unwrap();
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&f.d)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&f.n)
|
||||||
|
.unwrap()
|
||||||
|
.position = xy(940., 200.);
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&f.d)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&companion_node)
|
||||||
|
.unwrap()
|
||||||
|
.position = xy(940., 700.);
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip(
|
||||||
|
&f.p,
|
||||||
|
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct Roots {
|
||||||
|
f: Fixture,
|
||||||
|
c: Id,
|
||||||
|
cn: Id,
|
||||||
|
y: Id,
|
||||||
|
yn: Id,
|
||||||
|
z: Id,
|
||||||
|
zn: Id,
|
||||||
|
ax: Id,
|
||||||
|
by: Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roots(layer: Layer) -> Roots {
|
||||||
|
let mut f = fixture(layer);
|
||||||
|
let kind = f.p.elements[&f.w].kind();
|
||||||
|
let c =
|
||||||
|
f.p.add_element(
|
||||||
|
if layer == Layer::Oa {
|
||||||
|
Kind::Actor
|
||||||
|
} else {
|
||||||
|
Kind::SystemActor
|
||||||
|
},
|
||||||
|
layer,
|
||||||
|
"C",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let y = f.p.add_element(kind, layer, "Y", None).unwrap();
|
||||||
|
let z = f.p.add_element(kind, layer, "Z", None).unwrap();
|
||||||
|
let cn = f.p.place(f.d, c, xy(1500., 100.));
|
||||||
|
let yn = f.p.place(f.d, y, xy(140., 800.));
|
||||||
|
let zn = f.p.place(f.d, z, xy(1540., 1400.));
|
||||||
|
let d = f.p.diagrams.get_mut(&f.d).unwrap();
|
||||||
|
d.nodes.get_mut(&f.n).unwrap().position = xy(140., 200.);
|
||||||
|
d.nodes
|
||||||
|
.values_mut()
|
||||||
|
.find(|n| n.element == f.b)
|
||||||
|
.unwrap()
|
||||||
|
.position = xy(100., 700.);
|
||||||
|
let ax = f.p.relate(
|
||||||
|
RelationKind::Allocation,
|
||||||
|
"Original X responsibility",
|
||||||
|
f.a,
|
||||||
|
f.w,
|
||||||
|
);
|
||||||
|
let by = f.p.relate(
|
||||||
|
RelationKind::Allocation,
|
||||||
|
"Original Y responsibility",
|
||||||
|
f.b,
|
||||||
|
y,
|
||||||
|
);
|
||||||
|
f.p.relations.get_mut(&ax).unwrap().description = "Reviewed X identity must survive".into();
|
||||||
|
f.p.relations.get_mut(&by).unwrap().description = "Reviewed Y identity must survive".into();
|
||||||
|
if layer == Layer::Oa {
|
||||||
|
let r =
|
||||||
|
f.p.add_element(Kind::Role, layer, "Responsibility", None)
|
||||||
|
.unwrap();
|
||||||
|
f.p.relate(RelationKind::RoleAssignment, "Z role", r, z);
|
||||||
|
f.p.relate(RelationKind::RoleRealization, "C role", c, r);
|
||||||
|
} else {
|
||||||
|
f.p.relate(RelationKind::Allocation, "Z already assigned", c, z);
|
||||||
|
}
|
||||||
|
validate_candidate(&f.p).unwrap();
|
||||||
|
Roots {
|
||||||
|
f,
|
||||||
|
c,
|
||||||
|
cn,
|
||||||
|
y,
|
||||||
|
yn,
|
||||||
|
z,
|
||||||
|
zn,
|
||||||
|
ax,
|
||||||
|
by,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide(p: &Project, d: Id, target: Option<Id>) -> PresentationContext {
|
||||||
|
let mut context = presentation(p, d, target);
|
||||||
|
context.viewport.size = Size {
|
||||||
|
width: 5000.,
|
||||||
|
height: 3000.,
|
||||||
|
};
|
||||||
|
context
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected(r: &Roots, with_z: bool) -> BTreeMap<Id, Id> {
|
||||||
|
let mut ids = BTreeMap::from([(r.f.n, r.f.w), (r.yn, r.y)]);
|
||||||
|
if with_z {
|
||||||
|
ids.insert(r.zn, r.z);
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retarget(p: &mut Project, relation: Id, target: Id) {
|
||||||
|
let r = p.relations.get_mut(&relation).unwrap();
|
||||||
|
r.body = RelationBody::Allocation {
|
||||||
|
source: target,
|
||||||
|
target: r.target(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_independent_performers_preserves_already_correct_provenance() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let r = roots(layer);
|
||||||
|
let selected = selected(&r, true);
|
||||||
|
let scope = assignment::capture(&r.f.p, r.f.d, &selected, r.f.n).unwrap();
|
||||||
|
let plan =
|
||||||
|
assignment::plan_semantics(&scope, Destination::Performer(r.c), &BTreeMap::new())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(plan.changed_count(), 2);
|
||||||
|
assert_eq!(plan.already_correct_count(), 1);
|
||||||
|
let placed =
|
||||||
|
assignment::place(&plan, xy(1540., 200.), wide(&r.f.p, r.f.d, Some(r.c))).unwrap();
|
||||||
|
let mut expected = r.f.p.clone();
|
||||||
|
retarget(&mut expected, r.ax, r.c);
|
||||||
|
retarget(&mut expected, r.by, r.c);
|
||||||
|
let d = expected.diagrams.get_mut(&r.f.d).unwrap();
|
||||||
|
d.nodes.get_mut(&r.f.n).unwrap().position = xy(1540., 200.);
|
||||||
|
d.nodes.get_mut(&r.yn).unwrap().position = xy(1540., 800.);
|
||||||
|
d.nodes.get_mut(&r.zn).unwrap().position = xy(2940., 1400.);
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip(
|
||||||
|
&r.f.p,
|
||||||
|
assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap()
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
if layer == Layer::Oa {
|
||||||
|
assert!(
|
||||||
|
!plan
|
||||||
|
.project()
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.any(|relation| relation.kind() == RelationKind::Allocation
|
||||||
|
&& relation.target() == r.z)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
assignment::plan_semantics(
|
||||||
|
&scope,
|
||||||
|
Destination::OutsidePerformers,
|
||||||
|
&BTreeMap::new()
|
||||||
|
),
|
||||||
|
Err(Refusal::RoleExitRequired { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
assignment::plan_semantics(&scope, Destination::LayoutOnly, &BTreeMap::new()).is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_zero_delta_changes_semantics_once_and_then_is_noop() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let mut f = fixture(layer);
|
||||||
|
let d = f.p.diagrams.get_mut(&f.d).unwrap();
|
||||||
|
d.nodes
|
||||||
|
.values_mut()
|
||||||
|
.find(|n| n.element == f.a)
|
||||||
|
.unwrap()
|
||||||
|
.size = Size {
|
||||||
|
width: 600.,
|
||||||
|
height: 400.,
|
||||||
|
};
|
||||||
|
d.nodes.get_mut(&f.n).unwrap().position = xy(140., 200.);
|
||||||
|
let scope = assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let plan = assignment::plan_semantics(
|
||||||
|
&scope,
|
||||||
|
Destination::Performer(f.a),
|
||||||
|
&BTreeMap::from([(f.w, id)]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let placed = assignment::place(&plan, xy(140., 200.), wide(&f.p, f.d, Some(f.a))).unwrap();
|
||||||
|
assert_eq!(placed.delta, xy(0., 0.));
|
||||||
|
let mut expected = f.p.clone();
|
||||||
|
expected.relations.insert(
|
||||||
|
id,
|
||||||
|
Relation {
|
||||||
|
id,
|
||||||
|
alias: "a_performs_work".into(),
|
||||||
|
name: "A performs Work".into(),
|
||||||
|
description: String::new(),
|
||||||
|
body: RelationBody::Allocation {
|
||||||
|
source: f.a,
|
||||||
|
target: f.w,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip(
|
||||||
|
&f.p,
|
||||||
|
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
let scope =
|
||||||
|
assignment::capture(&expected, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
|
||||||
|
let noop =
|
||||||
|
assignment::plan_semantics(&scope, Destination::Performer(f.a), &BTreeMap::new())
|
||||||
|
.unwrap();
|
||||||
|
let placed =
|
||||||
|
assignment::place(&noop, xy(140., 200.), wide(&expected, f.d, Some(f.a))).unwrap();
|
||||||
|
assert!(
|
||||||
|
assignment::finish(&noop, &expected, &placed)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_unfolds_only_changed_destination_and_refuses_newly_exposed_overlap() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
for obstacle in [false, true] {
|
||||||
|
let mut r = roots(layer);
|
||||||
|
let d = r.f.p.diagrams.get_mut(&r.f.d).unwrap();
|
||||||
|
d.nodes
|
||||||
|
.values_mut()
|
||||||
|
.find(|n| n.element == r.f.b)
|
||||||
|
.unwrap()
|
||||||
|
.position = xy(100., 400.);
|
||||||
|
d.nodes.get_mut(&r.yn).unwrap().position = xy(140., 500.);
|
||||||
|
d.nodes.get_mut(&r.zn).unwrap().position = xy(1540., 800.);
|
||||||
|
d.nodes.get_mut(&r.cn).unwrap().collapsed = true;
|
||||||
|
if obstacle {
|
||||||
|
let k =
|
||||||
|
r.f.p
|
||||||
|
.add_element(r.f.p.elements[&r.f.w].kind(), layer, "Hidden overlap", None)
|
||||||
|
.unwrap();
|
||||||
|
r.f.p
|
||||||
|
.relate(RelationKind::Allocation, "C also owns K", r.c, k);
|
||||||
|
r.f.p.place(r.f.d, k, xy(1540., 800.));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!projection::project_view(&r.f.p, r.f.d)
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.any(|n| n.id == r.zn)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
assignment::capture(&r.f.p, r.f.d, &selected(&r, true), r.f.n).is_err(),
|
||||||
|
"hidden selected member is not normalized away"
|
||||||
|
);
|
||||||
|
let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap();
|
||||||
|
let plan =
|
||||||
|
assignment::plan_semantics(&scope, Destination::Performer(r.c), &BTreeMap::new())
|
||||||
|
.unwrap();
|
||||||
|
let placed = assignment::place(&plan, xy(1540., 120.), wide(&r.f.p, r.f.d, Some(r.c)));
|
||||||
|
if obstacle {
|
||||||
|
assert!(matches!(placed, Err(Refusal::LayoutRequired(_))));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let placed = placed.unwrap();
|
||||||
|
assert_eq!(placed.unfolded, Some(r.cn));
|
||||||
|
let mut expected = r.f.p.clone();
|
||||||
|
retarget(&mut expected, r.ax, r.c);
|
||||||
|
retarget(&mut expected, r.by, r.c);
|
||||||
|
let d = expected.diagrams.get_mut(&r.f.d).unwrap();
|
||||||
|
d.nodes.get_mut(&r.cn).unwrap().collapsed = false;
|
||||||
|
d.nodes.get_mut(&r.f.n).unwrap().position = xy(1540., 120.);
|
||||||
|
d.nodes.get_mut(&r.yn).unwrap().position = xy(1540., 420.);
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip(
|
||||||
|
&r.f.p,
|
||||||
|
assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap()
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_accumulates_stable_ids_and_aliases_without_partial_reuse() {
|
||||||
|
let mut f = fixture(Layer::Oa);
|
||||||
|
let y =
|
||||||
|
f.p.add_element(Kind::Activity, Layer::Oa, "Work", None)
|
||||||
|
.unwrap();
|
||||||
|
let yn = f.p.place(f.d, y, xy(400., 500.));
|
||||||
|
let selected = BTreeMap::from([(f.n, f.w), (yn, y)]);
|
||||||
|
let scope = assignment::capture(&f.p, f.d, &selected, f.n).unwrap();
|
||||||
|
let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]);
|
||||||
|
let first = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
|
||||||
|
let other = assignment::plan_semantics(&scope, Destination::Performer(f.b), &ids).unwrap();
|
||||||
|
let repeated = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
|
||||||
|
assert_eq!(first.project(), repeated.project());
|
||||||
|
assert_eq!(first.changed_count(), 2);
|
||||||
|
let mut expected = f.p.clone();
|
||||||
|
let aliases: BTreeSet<_> = ids
|
||||||
|
.values()
|
||||||
|
.map(|id| first.project().relations[id].alias.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(aliases.len(), 2);
|
||||||
|
assert!(aliases.contains("a_performs_work"));
|
||||||
|
for (ordinal, (work, id)) in ids.iter().enumerate() {
|
||||||
|
let relation = &first.project().relations[id];
|
||||||
|
let expected_relation = Relation {
|
||||||
|
id: *id,
|
||||||
|
alias: if ordinal == 0 {
|
||||||
|
"a_performs_work".into()
|
||||||
|
} else {
|
||||||
|
format!("a_performs_work_{}", &id.simple().to_string()[..8])
|
||||||
|
},
|
||||||
|
name: "A performs Work".into(),
|
||||||
|
description: String::new(),
|
||||||
|
body: RelationBody::Allocation {
|
||||||
|
source: f.a,
|
||||||
|
target: *work,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(relation, &expected_relation);
|
||||||
|
assert_eq!(
|
||||||
|
other.project().relations[id].body,
|
||||||
|
RelationBody::Allocation {
|
||||||
|
source: f.b,
|
||||||
|
target: *work
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expected.relations.insert(*id, expected_relation);
|
||||||
|
}
|
||||||
|
assert_eq!(first.project(), &expected);
|
||||||
|
let duplicate = Id::new_v4();
|
||||||
|
for bad in [
|
||||||
|
BTreeMap::from([(f.w, duplicate), (y, duplicate)]),
|
||||||
|
BTreeMap::from([(f.w, Id::nil()), (y, ids[&y])]),
|
||||||
|
BTreeMap::from([(f.w, f.a), (y, ids[&y])]),
|
||||||
|
] {
|
||||||
|
assert!(assignment::plan_semantics(&scope, Destination::Performer(f.a), &bad).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_source_failure_preserves_existing_history_and_retry_ids() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let mut f = fixture(layer);
|
||||||
|
let y =
|
||||||
|
f.p.add_element(f.p.elements[&f.w].kind(), layer, "Y", None)
|
||||||
|
.unwrap();
|
||||||
|
let yn = f.p.place(f.d, y, xy(400., 800.));
|
||||||
|
let mut bundle = format_bundle(&f.p);
|
||||||
|
bundle
|
||||||
|
.files
|
||||||
|
.get_mut(&bundle.entry.clone())
|
||||||
|
.unwrap()
|
||||||
|
.insert_str(
|
||||||
|
0,
|
||||||
|
"// Keep reviewed source through a refused group assignment.\n",
|
||||||
|
);
|
||||||
|
let mut limits = ProjectLimits::default();
|
||||||
|
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 128;
|
||||||
|
let mut document = Document::from_bundle(bundle, limits).unwrap();
|
||||||
|
document
|
||||||
|
.execute(Command::Describe(f.w, "Reviewed".into()))
|
||||||
|
.unwrap();
|
||||||
|
document
|
||||||
|
.execute(Command::Rename(y, "Future Y".into()))
|
||||||
|
.unwrap();
|
||||||
|
let redo_project = document.project().clone();
|
||||||
|
let redo_source = document.bundle().clone();
|
||||||
|
assert!(document.undo());
|
||||||
|
assert!(document.can_undo() && document.can_redo());
|
||||||
|
let accepted = document.project().clone();
|
||||||
|
let source = document.bundle().clone();
|
||||||
|
let revision = document.revision();
|
||||||
|
let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]);
|
||||||
|
let scope =
|
||||||
|
assignment::capture(&accepted, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n)
|
||||||
|
.unwrap();
|
||||||
|
let plan = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
|
||||||
|
let placed =
|
||||||
|
assignment::place(&plan, xy(150., 200.), wide(&accepted, f.d, Some(f.a))).unwrap();
|
||||||
|
let command = assignment::finish(&plan, &accepted, &placed)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
document.execute(command.clone()),
|
||||||
|
Err(crate::document::DocumentError::Source(_))
|
||||||
|
));
|
||||||
|
assert_eq!(document.project(), &accepted);
|
||||||
|
assert_eq!(document.bundle(), &source);
|
||||||
|
assert_eq!(document.revision(), revision);
|
||||||
|
assert!(document.can_undo() && document.can_redo());
|
||||||
|
assert!(document.redo());
|
||||||
|
assert_eq!(document.project(), &redo_project);
|
||||||
|
assert_eq!(document.bundle(), &redo_source);
|
||||||
|
assert!(document.undo());
|
||||||
|
assert_eq!(document.project(), &accepted);
|
||||||
|
assert_eq!(document.bundle(), &source);
|
||||||
|
let mut expected = accepted.clone();
|
||||||
|
for (work, name, alias) in [
|
||||||
|
(f.w, "A performs Work", "a_performs_work"),
|
||||||
|
(y, "A performs Y", "a_performs_y"),
|
||||||
|
] {
|
||||||
|
expected.relations.insert(
|
||||||
|
ids[&work],
|
||||||
|
Relation {
|
||||||
|
id: ids[&work],
|
||||||
|
alias: alias.into(),
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
body: RelationBody::Allocation {
|
||||||
|
source: f.a,
|
||||||
|
target: work,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let d = expected.diagrams.get_mut(&f.d).unwrap();
|
||||||
|
d.nodes.get_mut(&f.n).unwrap().position = xy(150., 200.);
|
||||||
|
d.nodes.get_mut(&yn).unwrap().position = xy(450., 500.);
|
||||||
|
assert_eq!(roundtrip(&accepted, command), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_collateral_surviving_constraint_refuses_whole_semantic_candidate() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let mut r = roots(layer);
|
||||||
|
let k =
|
||||||
|
r.f.p
|
||||||
|
.add_element(Kind::Constraint, layer, "Keep reviewed assignment", None)
|
||||||
|
.unwrap();
|
||||||
|
*r.f.p.elements.get_mut(&k).unwrap().shared_mut().unwrap() = SharedBody::Constraint {
|
||||||
|
expression: "reviewed assignment".into(),
|
||||||
|
constrained: vec![r.ax, r.f.w],
|
||||||
|
};
|
||||||
|
let core = prepare_command(&r.f.p, &Command::Delete(r.ax))
|
||||||
|
.unwrap()
|
||||||
|
.project;
|
||||||
|
validate_candidate(&core).unwrap();
|
||||||
|
assert!(core.elements.contains_key(&k));
|
||||||
|
assert_ne!(core.elements[&k], r.f.p.elements[&k]);
|
||||||
|
let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap();
|
||||||
|
let Err(Refusal::CollateralChangeRequired(changed)) =
|
||||||
|
assignment::plan_semantics(&scope, Destination::OutsidePerformers, &BTreeMap::new())
|
||||||
|
else {
|
||||||
|
panic!("surviving constraint edit must refuse the entire plan");
|
||||||
|
};
|
||||||
|
assert!(changed.iter().any(|record| record.id == k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_uses_vacated_space_and_one_complete_route_owner_map() {
|
||||||
|
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, "Y", None).unwrap();
|
||||||
|
let outside = f.p.add_element(kind, layer, "Outside", None).unwrap();
|
||||||
|
let yn = f.p.place(f.d, y, xy(400., 500.));
|
||||||
|
f.p.place(f.d, outside, xy(1600., 500.));
|
||||||
|
let d = f.p.diagrams.get_mut(&f.d).unwrap();
|
||||||
|
let a = d.nodes.values_mut().find(|n| n.element == f.a).unwrap();
|
||||||
|
a.position = xy(350., 350.);
|
||||||
|
a.size = Size {
|
||||||
|
width: 1000.,
|
||||||
|
height: 600.,
|
||||||
|
};
|
||||||
|
let edge_kind = if layer == Layer::Oa {
|
||||||
|
RelationKind::Interaction
|
||||||
|
} else {
|
||||||
|
RelationKind::FunctionalExchange
|
||||||
|
};
|
||||||
|
let internal = f.p.relate(edge_kind, "Internal", f.w, y);
|
||||||
|
let external = f.p.relate(edge_kind, "External", y, outside);
|
||||||
|
let mut internal_route = route(internal);
|
||||||
|
internal_route.waypoints = vec![xy(370., 550.), xy(370., 650.)];
|
||||||
|
internal_route.source_attachment = Some(EdgeAttachment {
|
||||||
|
side: PortAnchorSide::Bottom,
|
||||||
|
offset: 0.3,
|
||||||
|
});
|
||||||
|
internal_route.target_attachment = Some(EdgeAttachment {
|
||||||
|
side: PortAnchorSide::Top,
|
||||||
|
offset: 0.7,
|
||||||
|
});
|
||||||
|
let mut external_route = internal_route.clone();
|
||||||
|
external_route.edge = external;
|
||||||
|
external_route.waypoints = vec![xy(1200., 550.), xy(1300., 650.)];
|
||||||
|
for diagram in [f.d, f.second] {
|
||||||
|
f.p.diagrams.get_mut(&diagram).unwrap().routes =
|
||||||
|
vec![internal_route.clone(), external_route.clone()];
|
||||||
|
}
|
||||||
|
if let RelationBody::FunctionalExchange { source_port, .. } = f.p.relations[&internal].body
|
||||||
|
{
|
||||||
|
f.p.diagrams
|
||||||
|
.get_mut(&f.d)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&f.n)
|
||||||
|
.unwrap()
|
||||||
|
.port_anchors
|
||||||
|
.push(PortAnchor {
|
||||||
|
port: source_port,
|
||||||
|
side: PortAnchorSide::Bottom,
|
||||||
|
offset: 0.4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
validate_candidate(&f.p).unwrap();
|
||||||
|
let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]);
|
||||||
|
// The singleton candidate really occupies stationary Y's card. The
|
||||||
|
// combined candidate must not inherit that sequential-planning veto.
|
||||||
|
let mut singleton = f.p.clone();
|
||||||
|
singleton
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&f.d)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&f.n)
|
||||||
|
.unwrap()
|
||||||
|
.position = xy(400., 500.);
|
||||||
|
let projected = projection::project_view(&singleton, f.d);
|
||||||
|
let x_card = projected.nodes.iter().find(|n| n.id == f.n).unwrap();
|
||||||
|
let y_card = projected.nodes.iter().find(|n| n.id == yn).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
overlap(x_card, y_card),
|
||||||
|
if layer == Layer::Oa { 21056. } else { 29568. }
|
||||||
|
);
|
||||||
|
let single = assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
|
||||||
|
let single =
|
||||||
|
assignment::plan_semantics(&single, Destination::Performer(f.a), &ids).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
assignment::place(&single, xy(400., 500.), wide(&f.p, f.d, Some(f.a))),
|
||||||
|
Err(Refusal::LayoutRequired(_))
|
||||||
|
));
|
||||||
|
let scope =
|
||||||
|
assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap();
|
||||||
|
let plan = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
|
||||||
|
let placed = assignment::place(&plan, xy(400., 500.), wide(&f.p, f.d, Some(f.a))).unwrap();
|
||||||
|
let mut expected = f.p.clone();
|
||||||
|
for (work, name, alias) in [
|
||||||
|
(f.w, "A performs Work", "a_performs_work"),
|
||||||
|
(y, "A performs Y", "a_performs_y"),
|
||||||
|
] {
|
||||||
|
expected.relations.insert(
|
||||||
|
ids[&work],
|
||||||
|
Relation {
|
||||||
|
id: ids[&work],
|
||||||
|
alias: alias.into(),
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
body: RelationBody::Allocation {
|
||||||
|
source: f.a,
|
||||||
|
target: work,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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(670., 550.), xy(670., 650.)];
|
||||||
|
assert_eq!(placed.diagram.routes[1], external_route);
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip(
|
||||||
|
&f.p,
|
||||||
|
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_assignment_deletes_only_own_routes_across_views_and_keeps_internal_flow() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let mut r = roots(layer);
|
||||||
|
let kind = if layer == Layer::Oa {
|
||||||
|
RelationKind::Interaction
|
||||||
|
} else {
|
||||||
|
RelationKind::FunctionalExchange
|
||||||
|
};
|
||||||
|
let internal = r.f.p.relate(kind, "Keep internal flow", r.f.w, r.y);
|
||||||
|
for diagram in [r.f.d, r.f.second] {
|
||||||
|
r.f.p.diagrams.get_mut(&diagram).unwrap().routes =
|
||||||
|
vec![route(r.ax), route(internal), route(r.by)];
|
||||||
|
}
|
||||||
|
validate_candidate(&r.f.p).unwrap();
|
||||||
|
let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap();
|
||||||
|
let plan =
|
||||||
|
assignment::plan_semantics(&scope, Destination::OutsidePerformers, &BTreeMap::new())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(plan.changed_count(), 2);
|
||||||
|
let placed = assignment::place(&plan, xy(640., 200.), wide(&r.f.p, r.f.d, None)).unwrap();
|
||||||
|
let mut expected = r.f.p.clone();
|
||||||
|
expected.relations.remove(&r.ax);
|
||||||
|
expected.relations.remove(&r.by);
|
||||||
|
for diagram in [r.f.d, r.f.second] {
|
||||||
|
expected.diagrams.get_mut(&diagram).unwrap().routes = vec![route(internal)];
|
||||||
|
}
|
||||||
|
let d = expected.diagrams.get_mut(&r.f.d).unwrap();
|
||||||
|
d.nodes.get_mut(&r.f.n).unwrap().position = xy(640., 200.);
|
||||||
|
d.nodes.get_mut(&r.yn).unwrap().position = xy(640., 800.);
|
||||||
|
d.routes[0].waypoints = vec![xy(920., 400.), xy(920., 550.)];
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip(
|
||||||
|
&r.f.p,
|
||||||
|
assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap()
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! Geometry operations over an explicitly captured placement set.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) struct Translation {
|
||||||
|
pub diagram: Diagram,
|
||||||
|
pub commands: Vec<Command>,
|
||||||
|
pub delta: Position,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate exactly `placements` using one snapped anchor delta. `base` must
|
||||||
|
/// come from an exact, validated semantic candidate; absent routes are permitted
|
||||||
|
/// only because that candidate has already admitted their semantic deletion.
|
||||||
|
pub(super) fn translate(
|
||||||
|
accepted: &Project,
|
||||||
|
original: &Diagram,
|
||||||
|
base: &Diagram,
|
||||||
|
placements: &BTreeMap<Id, NodePlacement>,
|
||||||
|
anchor: Id,
|
||||||
|
requested: Position,
|
||||||
|
) -> Result<Translation, Refusal> {
|
||||||
|
let anchor = placements.get(&anchor).ok_or(Refusal::StaleScope)?;
|
||||||
|
let delta = Position {
|
||||||
|
x: (requested.x / 10.).round() * 10. - anchor.position.x,
|
||||||
|
y: (requested.y / 10.).round() * 10. - anchor.position.y,
|
||||||
|
};
|
||||||
|
let mut diagram = base.clone();
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
let mut deltas = BTreeMap::new();
|
||||||
|
for (id, captured) in placements {
|
||||||
|
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
|
||||||
|
node.position = Position {
|
||||||
|
x: captured.position.x + delta.x,
|
||||||
|
y: captured.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 != captured {
|
||||||
|
commands.push(Command::EditNode {
|
||||||
|
diagram: diagram.id,
|
||||||
|
original: *id,
|
||||||
|
node: node.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resolve actual endpoint owners once against accepted semantics and the
|
||||||
|
// complete delta map. Never reinsert a semantically deleted saved route.
|
||||||
|
for route in projection::translated_routes(accepted, original, &deltas) {
|
||||||
|
if let Some(stored) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
*stored = route.clone();
|
||||||
|
commands.push(Command::SetEdgeRoute {
|
||||||
|
diagram: diagram.id,
|
||||||
|
route,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Translation {
|
||||||
|
diagram,
|
||||||
|
commands,
|
||||||
|
delta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover hidden ancestry for identity lookup only. The all-unfolded scene is
|
||||||
|
/// never used for collision admission; only affected nodes and their ancestors
|
||||||
|
/// enter the required diagnostic expansion.
|
||||||
|
pub(super) fn expansion_ids(
|
||||||
|
p: &Project,
|
||||||
|
d: &Diagram,
|
||||||
|
index: &ViewIndex,
|
||||||
|
placements: &BTreeMap<Id, NodePlacement>,
|
||||||
|
) -> Result<BTreeSet<Id>, Refusal> {
|
||||||
|
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 ids: BTreeSet<_> = placements.keys().copied().collect();
|
||||||
|
for id in placements.keys() {
|
||||||
|
ids.extend(ancestry.get(id).ok_or(Refusal::StaleScope)?.iter().copied());
|
||||||
|
}
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn expanded_view(
|
||||||
|
p: &Project,
|
||||||
|
d: &Diagram,
|
||||||
|
index: &ViewIndex,
|
||||||
|
ids: &BTreeSet<Id>,
|
||||||
|
) -> ProjectedView {
|
||||||
|
let mut expanded = d.clone();
|
||||||
|
for id in ids {
|
||||||
|
if let Some(node) = expanded.nodes.get_mut(id) {
|
||||||
|
node.collapsed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projection::project_diagram_with_index(p, &expanded, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn check_scenes(
|
||||||
|
before_visible: &ProjectedView,
|
||||||
|
before_expanded: &ProjectedView,
|
||||||
|
visible: &ProjectedView,
|
||||||
|
expanded: &ProjectedView,
|
||||||
|
) -> Result<(), Refusal> {
|
||||||
|
if visible.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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if overlaps(before_visible, visible) || overlaps(before_expanded, expanded) {
|
||||||
|
return Err(Refusal::LayoutRequired(
|
||||||
|
"Make room before arranging: the candidate would introduce or enlarge overlapping cards."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn check_rigid(
|
||||||
|
before: &ProjectedView,
|
||||||
|
after: &ProjectedView,
|
||||||
|
placements: &BTreeMap<Id, NodePlacement>,
|
||||||
|
delta: Position,
|
||||||
|
) -> Result<(), Refusal> {
|
||||||
|
let before: BTreeMap<_, _> = before.nodes.iter().map(|n| (n.id, n)).collect();
|
||||||
|
let after: BTreeMap<_, _> = after.nodes.iter().map(|n| (n.id, n)).collect();
|
||||||
|
for id in 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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn check_headers(
|
||||||
|
view: &ProjectedView,
|
||||||
|
selected: &BTreeMap<Id, Id>,
|
||||||
|
viewport: Rect,
|
||||||
|
) -> Result<(), Refusal> {
|
||||||
|
for (id, element) in selected {
|
||||||
|
let node = 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: node.position,
|
||||||
|
size: Size {
|
||||||
|
width: node.size.width,
|
||||||
|
height: node.size.height.min(62.),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if !viewport.contains(header) {
|
||||||
|
return Err(Refusal::LayoutRequired(
|
||||||
|
"Make room or adjust the view, then drag again.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ mod group_arrange_experiment;
|
|||||||
#[path = "arrange_tests.rs"]
|
#[path = "arrange_tests.rs"]
|
||||||
mod arrange_tests;
|
mod arrange_tests;
|
||||||
|
|
||||||
|
#[path = "assignment_tests.rs"]
|
||||||
|
mod assignment_tests;
|
||||||
|
|
||||||
|
#[path = "assignment_boundary_tests.rs"]
|
||||||
|
mod assignment_boundary_tests;
|
||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
p: Project,
|
p: Project,
|
||||||
d: Id,
|
d: Id,
|
||||||
|
|||||||
Reference in New Issue
Block a user