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"]
|
||||
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(|| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user