diff --git a/crates/oa-desktop/src/allocation_drag.rs b/crates/oa-desktop/src/allocation_drag.rs index 66cdbb6d..5f6b266d 100644 --- a/crates/oa-desktop/src/allocation_drag.rs +++ b/crates/oa-desktop/src/allocation_drag.rs @@ -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, + deleted: BTreeSet, + changed: BTreeSet, +} + +/// 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, + 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(|| { diff --git a/crates/oa-desktop/src/allocation_drag/arrange.rs b/crates/oa-desktop/src/allocation_drag/arrange.rs index 39d3d444..8a8a7311 100644 --- a/crates/oa-desktop/src/allocation_drag/arrange.rs +++ b/crates/oa-desktop/src/allocation_drag/arrange.rs @@ -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, - 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, diff --git a/crates/oa-desktop/src/allocation_drag/assignment.rs b/crates/oa-desktop/src/allocation_drag/assignment.rs new file mode 100644 index 00000000..64bd47e8 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/assignment.rs @@ -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, + anchor: Id, + roots: Vec, + placements: BTreeMap, + leaves: BTreeMap, + index: ViewIndex, + before_visible: ProjectedView, + before_expanded: ProjectedView, +} + +/// Destination plans share one immutable accepted capture. +#[derive(Debug, Clone)] +pub struct Scope(Arc); + +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 { + &self.0.selected + } + + pub fn affected(&self) -> &BTreeMap { + &self.0.placements + } + + pub fn anchor(&self) -> Id { + self.0.anchor + } + + pub fn leaves(&self) -> &BTreeMap { + &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, + anchor: Id, +) -> Result { + 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, + changed: BTreeSet, + expanded_ids: BTreeSet, + label: String, + warnings: Vec, +} + +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, +) -> Result { + 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 { + 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, 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)) +} diff --git a/crates/oa-desktop/src/allocation_drag/assignment_boundary_tests.rs b/crates/oa-desktop/src/allocation_drag/assignment_boundary_tests.rs new file mode 100644 index 00000000..c08bd859 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/assignment_boundary_tests.rs @@ -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::from([c.f.n, c.child_node]) + ); + assert_eq!( + scope.leaves().keys().copied().collect::>(), + 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::>(), + 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 + ); + } +} diff --git a/crates/oa-desktop/src/allocation_drag/assignment_tests.rs b/crates/oa-desktop/src/allocation_drag/assignment_tests.rs new file mode 100644 index 00000000..a3b71b3f --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/assignment_tests.rs @@ -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) -> 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 { + 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::() + 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 + ); + } +} diff --git a/crates/oa-desktop/src/allocation_drag/geometry.rs b/crates/oa-desktop/src/allocation_drag/geometry.rs new file mode 100644 index 00000000..bd53fecc --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/geometry.rs @@ -0,0 +1,195 @@ +//! Geometry operations over an explicitly captured placement set. +use super::*; + +pub(super) struct Translation { + pub diagram: Diagram, + pub commands: Vec, + 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, + anchor: Id, + requested: Position, +) -> Result { + 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, +) -> Result, 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, +) -> 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, + 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, + 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(()) +} diff --git a/crates/oa-desktop/src/allocation_drag/tests.rs b/crates/oa-desktop/src/allocation_drag/tests.rs index e4401bef..7660ab9c 100644 --- a/crates/oa-desktop/src/allocation_drag/tests.rs +++ b/crates/oa-desktop/src/allocation_drag/tests.rs @@ -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,