diff --git a/crates/oa-desktop/src/app/allocation_drag_tests.rs b/crates/oa-desktop/src/app/allocation_drag_tests.rs index 58b5a391..490429f4 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests.rs @@ -1310,6 +1310,250 @@ fn outgoing_boundary_drop_clears_source_without_changing_other_placements() { } } +#[test] +fn outgoing_clearance_collision_refuses_release_without_source_or_history_mutation() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = outgoing_fixture(layer); + let mut project = f.app.project().clone(); + let obstacle = project + .add_element( + project.elements[&f.work].kind(), + layer, + "Unrelated work", + None, + ) + .unwrap(); + let obstacle_node = project.place(f.diagram, obstacle, xy(240., 190.)); + replace(&mut f, project); + // A refused release must preserve an existing redo branch, too. + let _ = f.app.update(Message::Command(Command::Rename( + f.work, + "Temporary".into(), + ))); + let _ = f.app.update(Message::Undo); + let before = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let snapshot = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let from = screen(xy(660., 225.)); + let to = screen(xy(490., 225.)); + let mut events = batch(&f.app, from, to); + let release = events.pop().unwrap(); + send(&mut f.app, events); + + let active = f.app.allocation_drag.active.as_ref().unwrap(); + let p = &active.press; + let frozen = active.frozen.as_ref().unwrap(); + let owner = frozen.targets.iter().find(|t| t.element == f.a).unwrap(); + assert_eq!(p.start, from); + assert!(contains(owner.region, p.stamp.world(from))); + assert!(p.stamp.world(to).x < owner.region.x); + assert_eq!( + frozen.resolve(p.stamp, Some(to), false), + Hit::Destination(plan::Destination::OutsidePerformers, None), + "The pointer must reach empty canvas, not be occluded by the obstacle" + ); + let candidate = active + .cache + .iter() + .find(|(destination, _)| *destination == plan::Destination::OutsidePerformers) + .unwrap() + .1 + .as_ref() + .unwrap(); + let affected = match &active.prepared { + Prepared::Singleton(scope) => &scope.placements, + Prepared::Assignment(scope) => scope.affected(), + _ => panic!("expected assignment gesture"), + }; + let requested = xy(370., 190.); + let presentation = plan::PresentationContext { + viewport: p.stamp.viewport(), + zoom: p.stamp.zoom, + target: None, + }; + assert!(matches!( + candidate.place(requested, presentation), + Err(plan::Refusal::LayoutRequired(_)) + )); + let proposed = clearance::requests( + (f.app.project(), f.app.document.view_index()), + (candidate.project(), candidate.index()), + f.diagram, + affected, + f.node, + requested, + (p.stamp.world(from), p.stamp.world(to)), + ); + assert_eq!( + proposed.len(), + 1, + "a genuine left crossing must propose clearance" + ); + assert!(proposed[0].x < requested.x); + let work = &before.diagrams[&f.diagram].nodes[&f.node]; + let obstacle = &before.diagrams[&f.diagram].nodes[&obstacle_node]; + assert!(obstacle.position.x + obstacle.size.width < 490.); + assert!(proposed[0].x + work.size.width <= owner.region.x - 10.); + assert!(proposed[0].x < obstacle.position.x + obstacle.size.width); + assert!(obstacle.position.x < proposed[0].x + work.size.width); + assert_eq!(proposed[0].y, obstacle.position.y); + assert!(matches!( + candidate.place(proposed[0], presentation), + Err(plan::Refusal::LayoutRequired(message)) if message.contains("overlapping cards") + )); + let feedback = f.app.allocation_drag.feedback.as_ref().unwrap(); + assert!(feedback.refused, "{}", feedback.text); + assert!(feedback.ghost_delta.is_some()); + assert!(f.app.allocation_drag.preview.is_none()); + assert_eq!(f.app.project(), &before); + assert_eq!(f.app.document.bundle(), &source); + assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot())); + let _ = f.app.update(release); + assert!( + f.app.status.contains("overlapping cards"), + "{}", + f.app.status + ); + assert!(f.app.allocation_drag.active.is_none()); + assert!(f.app.allocation_drag.preview.is_none()); + assert_eq!(f.app.project(), &before); + assert_eq!(f.app.document.bundle(), &source); + assert_eq!(f.app.document.revision(), revision); + assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot())); + assert!(!f.app.document.can_undo()); + assert!(f.app.document.can_redo()); + } +} + +#[test] +fn outgoing_clearance_preserves_source_enclosure_and_allocated_sibling() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = outgoing_fixture(layer); + let mut project = f.app.project().clone(); + let sibling = project + .add_element( + project.elements[&f.work].kind(), + layer, + "Remaining work", + None, + ) + .unwrap(); + let sibling_node = project.place(f.diagram, sibling, xy(540., 450.)); + let allocation = project.relate( + RelationKind::Allocation, + "Keep responsibility", + f.a, + sibling, + ); + replace(&mut f, project); + let before = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let view = projection::project_view(&before, f.diagram); + let owner = view.nodes.iter().find(|n| n.element == f.a).unwrap(); + let bottom = owner.position.y + owner.size.height; + let stored_owner = &before.diagrams[&f.diagram].nodes[&owner.id]; + assert!( + bottom > stored_owner.position.y + stored_owner.size.height, + "The retained sibling must enlarge the projected source enclosure" + ); + let from = screen(xy(660., 225.)); + let to = screen(xy(660., bottom + 1.)); + let mut events = batch(&f.app, from, to); + let release = events.pop().unwrap(); + send(&mut f.app, events); + let active = f.app.allocation_drag.active.as_ref().unwrap(); + let p = &active.press; + let frozen = active.frozen.as_ref().unwrap(); + let target = frozen.targets.iter().find(|t| t.element == f.a).unwrap(); + assert_eq!(p.start, from); + assert!(contains(target.region, p.stamp.world(from))); + assert!(p.stamp.world(to).y >= target.region.y + target.region.height); + assert_eq!( + frozen.resolve(p.stamp, Some(to), false), + Hit::Destination(plan::Destination::OutsidePerformers, None) + ); + let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status); + assert!(!f.app.allocation_drag.feedback.as_ref().unwrap().refused); + let requested = xy(540., 190. + to.y - from.y); + assert!(matches!( + preview.candidate.place( + requested, + plan::PresentationContext { + viewport: p.stamp.viewport(), + zoom: p.stamp.zoom, + target: None, + } + ), + Err(plan::Refusal::LayoutRequired(_)) + )); + let moved = &preview.placement.diagram.nodes[&f.node]; + assert_eq!(moved.position.x, 540.); + assert!(moved.position.y >= bottom + 10.); + assert!(moved.position.y > requested.y); + assert_eq!( + preview + .placement + .view + .nodes + .iter() + .find(|n| n.id == owner.id) + .unwrap(), + owner + ); + let remaining = preview + .placement + .view + .nodes + .iter() + .find(|n| n.id == sibling_node) + .unwrap(); + assert_eq!(remaining.visual_parent, Some(owner.id)); + assert_eq!( + remaining.position, + before.diagrams[&f.diagram].nodes[&sibling_node].position + ); + assert_eq!( + &preview.project().relations[&allocation], + &before.relations[&allocation] + ); + for (id, node) in &before.diagrams[&f.diagram].nodes { + if *id != f.node { + assert_eq!(&preview.placement.diagram.nodes[id], node); + } + } + let mut expected = preview.project().clone(); + expected + .diagrams + .insert(f.diagram, preview.placement.diagram.clone()); + assert_eq!(f.app.project(), &before); + assert_eq!(f.app.document.bundle(), &source); + let _ = f.app.update(release); + assert_eq!(f.app.project(), &expected, "{}", f.app.status); + assert_eq!(f.app.document.revision(), 1); + assert_eq!( + validation::effective_performer(f.app.project(), f.work), + None + ); + assert_eq!( + validation::effective_performer(f.app.project(), sibling), + Some(f.a) + ); + assert_eq!( + &f.app.project().relations[&allocation], + &before.relations[&allocation] + ); + let after = f.app.document.bundle().clone(); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo()); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &after); + } +} + #[test] fn refused_motion_retains_last_scene_but_release_never_commits_it() { let mut f = outgoing_fixture(Layer::Sa);