Plan Allocation work-group assignments as one exact transaction

This commit is contained in:
2026-09-23 11:20:58 -07:00
parent eed80ffd6e
commit 33e880103c
7 changed files with 1891 additions and 252 deletions
+111 -110
View File
@@ -18,6 +18,14 @@ use std::collections::{BTreeMap, BTreeSet};
#[path = "allocation_drag/arrange.rs"]
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)]
pub enum Destination {
LayoutOnly,
@@ -242,8 +250,93 @@ pub fn plan_semantics(
let same_performer =
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
let mut expected = p.clone();
let mut commands = Vec::new();
let mut deleted = BTreeSet::new();
let mut edits = IntendedEdits::default();
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 {
for (work, leaf) in &scope.leaves {
match destination {
@@ -256,6 +349,7 @@ pub fn plan_semantics(
target: *work,
};
expected.relations.insert(edited.id, edited.clone());
changed.insert(*work);
commands.push(Command::EditRelation(edited.id, edited));
}
} else {
@@ -279,6 +373,7 @@ pub fn plan_semantics(
},
};
expected.relations.insert(id, relation.clone());
changed.insert(*work);
commands.push(Command::CreateRelation(relation));
}
}
@@ -337,6 +432,7 @@ pub fn plan_semantics(
if let Some(existing) = &leaf.direct {
expected.relations.remove(&existing.id);
deleted.insert(existing.id);
changed.insert(*work);
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
// 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,
})
Ok(())
}
/// 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(),
));
}
let root = &plan.scope.placements[&plan.scope.placement];
let delta = Position {
x: (requested.x / 10.).round() * 10. - root.position.x,
y: (requested.y / 10.).round() * 10. - root.position.y,
};
let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone();
let mut commands = Vec::new();
let mut deltas = BTreeMap::new();
for (id, original) in &plan.scope.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(),
});
}
}
// 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 geometry::Translation {
mut diagram,
mut commands,
delta,
} = geometry::translate(
&plan.accepted,
&plan.scope.diagram,
&plan.candidate.diagrams[&plan.scope.diagram.id],
&plan.scope.placements,
plan.scope.placement,
requested,
)?;
let mut unfolded = None;
if let Destination::Performer(performer) = plan.destination {
let target = presentation.target.ok_or_else(|| {
+32 -142
View File
@@ -121,20 +121,10 @@ pub fn capture(
}
}
// 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);
// Arrange preserves ancestry, so accepted diagnostic identities also apply
// to every translated candidate.
let expanded_ids = geometry::expansion_ids(p, d, &index, &placements)?;
let before_expanded = geometry::expanded_view(p, d, &index, &expanded_ids);
Ok(Plan {
accepted: p.clone(),
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
/// validating a Project, rebuilding its semantic index, or expanding scope.
pub fn place(
@@ -185,120 +160,35 @@ pub fn place(
"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 geometry::Translation {
diagram,
commands,
delta,
} = geometry::translate(
&plan.accepted,
plan.diagram(),
plan.diagram(),
&plan.placements,
plan.anchor,
requested,
)?;
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(),
));
}
}
let expanded =
geometry::expanded_view(&plan.accepted, &diagram, &plan.index, &plan.expanded_ids);
// Preserve Arrange's refusal precedence: projected bounds, then rigidity,
// then overlap growth. The shared scene check combines bounds and overlaps.
let scenes = match geometry::check_scenes(
&plan.before_visible,
&plan.before_expanded,
&view,
&expanded,
) {
Err(error @ Refusal::InvalidCandidate(_)) => return Err(error),
result => result,
};
geometry::check_rigid(&plan.before_expanded, &expanded, &plan.placements, delta)?;
scenes?;
geometry::check_headers(&view, &plan.selected, presentation.viewport)?;
Ok(PlacementPlan {
diagram,
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"]
mod arrange_tests;
#[path = "assignment_tests.rs"]
mod assignment_tests;
#[path = "assignment_boundary_tests.rs"]
mod assignment_boundary_tests;
struct Fixture {
p: Project,
d: Id,