diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index b3741927..2e65a5be 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -1363,6 +1363,15 @@ impl App { { return Task::none(); } + // Selection can change without advancing the captured canvas context. + if let Message::Route( + crate::route_edit::Event::Commit(captured, changed, ..) + | crate::route_edit::Event::CommitAttachment(captured, changed, ..), + ) = &message + && (self.route_context() != Some(*captured) || changed.edge != self.selected) + { + return Task::none(); + } if let Message::DiagramSelection(event) | Message::StateDiagram(crate::behavior::state_edit::Event::Selection(event)) = &message && !self.multiselect_envelope(event) diff --git a/crates/oa-desktop/src/app/allocation_drag_tests/group.rs b/crates/oa-desktop/src/app/allocation_drag_tests/group.rs index 107b60aa..f3f07091 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests/group.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests/group.rs @@ -4,6 +4,8 @@ use crate::diagram_usage::Target as SelectionTarget; #[path = "group/connection_lifetime.rs"] mod connection_lifetime; +#[path = "group/route_lifetime.rs"] +mod route_lifetime; struct Group { f: Fixture, diff --git a/crates/oa-desktop/src/app/allocation_drag_tests/group/route_lifetime.rs b/crates/oa-desktop/src/app/allocation_drag_tests/group/route_lifetime.rs new file mode 100644 index 00000000..1f5dda58 --- /dev/null +++ b/crates/oa-desktop/src/app/allocation_drag_tests/group/route_lifetime.rs @@ -0,0 +1,274 @@ +//! Captured route commits cannot retire a newer Allocation input owner. +use super::*; +use crate::route_edit::{Context, Event as RouteEvent}; + +fn routed_group() -> (Group, Id) { + let mut g = group(Layer::Oa); + let mut project = g.f.app.project().clone(); + let edge = project.relate( + RelationKind::Interaction, + "Reviewed exchange", + g.f.work, + g.y, + ); + project + .diagrams + .get_mut(&g.f.diagram) + .unwrap() + .routes + .push(EdgeRoute { + edge, + waypoints: vec![xy(700., 400.)], + label: Some(EdgeLabel { + fraction: 0.4, + offset: xy(5., 10.), + }), + source_attachment: None, + target_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Left, + offset: 0.6, + }), + }); + validate_candidate(&project).unwrap(); + g.f.app = App::with_project(project); + g.f.app.active_diagram = Some(g.f.diagram); + g.f.app.zoom = 1.; + g.f.app.pan = Vector::ZERO; + g.f.app.fit_to_view = false; + choose(&mut g.f.app, SelectionTarget::Edge(edge), Choice::Replace); + assert_eq!(g.f.app.selected, edge); + let view = project_view(g.f.app.project(), g.f.diagram); + assert!(view.edges.iter().any(|e| e.id == edge)); + assert_eq!( + crate::route_edit::attachment_node( + g.f.app.project(), + &g.f.app.project().diagrams[&g.f.diagram], + &view, + edge, + EdgeEnd::Source, + ) + .unwrap() + .id, + g.f.node + ); + (g, edge) +} + +fn packet(g: &Group, edge: Id, captured: Context, attachment: bool) -> RouteEvent { + let mut route = + crate::route_edit::stored_route(&g.f.app.project().diagrams[&g.f.diagram], edge); + if attachment { + route.source_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.25, + }); + RouteEvent::CommitAttachment(captured, route, EdgeEnd::Source, g.f.node) + } else { + route.waypoints[0] = xy(750., 450.); + RouteEvent::Commit(captured, route, Some(0)) + } +} + +fn assert_route_noop(app: &mut App, event: RouteEvent) { + let document = app.document.snapshot(); + let selected = selection(app); + let context = app.route_context(); + let camera = (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch); + let ownership = (app.allocation_drag.epoch, app.allocation_drag.arrange_next); + let press = app.allocation_drag.active.as_ref().map(|a| a.press.clone()); + let preview = app.allocation_drag.preview.clone(); + let feedback = app.allocation_drag.feedback.as_ref().map(|f| { + ( + f.press.token, + f.text.clone(), + f.refused, + f.targets.clone(), + f.inspection_targets.clone(), + ) + }); + let handles = (app.selected_bend, app.selected_attachment); + let interaction = app.source_symbols.interaction; + let status = app.status.clone(); + let _ = app.update(Message::Route(event)); + assert_eq!(selection(app), selected); + assert_eq!(app.route_context(), context); + assert_eq!( + (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch), + camera + ); + assert_eq!( + (app.allocation_drag.epoch, app.allocation_drag.arrange_next), + ownership + ); + match (press, app.allocation_drag.active.as_ref()) { + (Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, &after.press)), + (None, None) => {} + _ => panic!("stale route packet changed the active Allocation token"), + } + match (preview, app.allocation_drag.preview.as_ref()) { + (Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, after)), + (None, None) => {} + _ => panic!("stale route packet changed the Allocation preview"), + } + assert_eq!( + app.allocation_drag.feedback.as_ref().map(|f| ( + f.press.token, + f.text.clone(), + f.refused, + f.targets.clone(), + f.inspection_targets.clone(), + )), + feedback + ); + assert_eq!((app.selected_bend, app.selected_attachment), handles); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(app.status, status); + assert!(Arc::ptr_eq(&document, &app.document.snapshot())); + assert_eq!(app.source_workspace.bundle(), document.bundle); +} + +fn stale_route_preserves_new_group(attachment: bool, escape: bool) { + let (mut g, edge) = routed_group(); + let old = g.f.app.route_context().unwrap(); + let delayed = packet(&g, edge, old, attachment); + if escape { + let _ = g.f.app.update(Message::Escape); + assert_ne!(g.f.app.route_context(), Some(old)); + } + select(&mut g.f.app, &[g.an, g.yn]); + let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext)); + assert!(g.f.app.allocation_drag.arrange_next); + if !escape { + assert_eq!(g.f.app.route_context(), Some(old)); + assert_ne!(g.f.app.selected, edge); + } + assert_route_noop(&mut g.f.app, delayed.clone()); + let before = g.f.app.project().clone(); + let source = g.f.app.document.bundle().clone(); + let selected = selection(&g.f.app); + let from = node_point(&g.f.app, g.an); + let to = from + Vector::new(400., 0.); + let mut state = CanvasState::default(); + let press = begin(&mut g.f.app, &mut state, from); + assert_eq!(press.mode, Mode::Arrange); + move_pointer(&mut g.f.app, &mut state, to); + assert!(g.f.app.allocation_drag.preview.is_some()); + if !escape { + assert_eq!(g.f.app.route_context(), Some(old)); + assert_ne!(g.f.app.selected, edge); + } + assert_route_noop(&mut g.f.app, delayed); + release(&mut g.f.app, &mut state, to); + let mut expected = before.clone(); + let diagram = expected.diagrams.get_mut(&g.f.diagram).unwrap(); + diagram.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.); + diagram.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.); + diagram.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.); + // Both endpoint placements move by (400, 0); the saved pin follows once, + // while label offset and endpoint attachment settings stay unchanged. + diagram + .routes + .iter_mut() + .find(|r| r.edge == edge) + .unwrap() + .waypoints = vec![xy(1100., 400.)]; + assert_eq!(selection(&g.f.app), selected); + exact_history(&mut g.f.app, &before, &source, &expected); +} + +#[test] +fn stale_route_commit_preserves_arrange_arm_and_live_group() { + stale_route_preserves_new_group(false, true); +} + +#[test] +fn stale_route_attachment_preserves_arrange_arm_and_live_group() { + stale_route_preserves_new_group(true, true); +} + +#[test] +fn same_context_route_commit_after_selection_preserves_arrange_arm_and_live_group() { + stale_route_preserves_new_group(false, false); +} + +#[test] +fn same_context_route_attachment_after_selection_preserves_arrange_arm_and_live_group() { + stale_route_preserves_new_group(true, false); +} + +#[test] +fn captured_route_context_fields_independently_reject_before_retirement() { + for attachment in [false, true] { + let (mut g, edge) = routed_group(); + let current = g.f.app.route_context().unwrap(); + // The selected edge and route payload are valid. Each capture mismatch + // is isolated; no nonexistent route session can mask admission. + for captured in [ + Context { + document: Id::new_v4(), + ..current + }, + Context { + diagram: Id::new_v4(), + ..current + }, + Context { + revision: current.revision + 1, + ..current + }, + Context { + epoch: current.epoch + 1, + ..current + }, + ] { + let event = packet(&g, edge, captured, attachment); + assert_route_noop(&mut g.f.app, event); + } + // Hold the captured packet fixed and advance the live context instead. + let event = packet(&g, edge, current, attachment); + g.f.app.canvas_epoch += 1; + assert_route_noop(&mut g.f.app, event.clone()); + g.f.app.canvas_epoch = current.epoch; + g.f.app.active_diagram = None; + assert_route_noop(&mut g.f.app, event); + } +} + +#[test] +fn current_route_commits_without_session_preserve_exact_route_history() { + for attachment in [false, true] { + let (mut g, edge) = routed_group(); + let current = g.f.app.route_context().unwrap(); + let event = packet(&g, edge, current, attachment); + let before = g.f.app.project().clone(); + let source = g.f.app.document.bundle().clone(); + let revision = g.f.app.document.revision(); + assert!(g.f.app.connection.is_none()); + assert!(g.f.app.selected_attachment.is_none()); + let _ = g.f.app.update(Message::Route(event)); + assert_eq!(g.f.app.document.revision(), revision + 1); + assert_eq!(g.f.app.selected, edge); + assert_eq!( + g.f.app.selected_bend, + if attachment { None } else { Some((edge, 0)) } + ); + let mut expected = before.clone(); + let route = expected + .diagrams + .get_mut(&g.f.diagram) + .unwrap() + .routes + .iter_mut() + .find(|r| r.edge == edge) + .unwrap(); + if attachment { + route.source_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.25, + }); + } else { + route.waypoints = vec![xy(750., 450.)]; + } + exact_history(&mut g.f.app, &before, &source, &expected); + } +}