From 0b61771d079cae4a513958fad04e6c3e785d458f Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Wed, 23 Sep 2026 00:37:07 -0700 Subject: [PATCH] Reject stale Scenario actions before shared reveal boundaries --- crates/oa-desktop/src/app.rs | 11 ++++ .../owner_navigation_tests.rs | 2 +- .../src/app/scenario_spacing/tests.rs | 58 +++++++++++++++++++ harness/scenario-text-union-20260923.md | 8 +++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 84ee81d8..9b568f01 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -1386,9 +1386,20 @@ impl App { || current.visit != captured.visit || (!matches!(event, behavior::scenario::CanvasEvent::Restored(..)) && current.revision != captured.revision) + || (!matches!( + event, + behavior::scenario::CanvasEvent::FormShown(..) + | behavior::scenario::CanvasEvent::Viewport(..) + | behavior::scenario::CanvasEvent::Restored(..) + | behavior::scenario::CanvasEvent::Scrolled(..) + | behavior::scenario::CanvasEvent::Camera(..) + ) && current.epoch != captured.epoch) }) { return Task::none(); } + // Passive layout/form reports retain their existing epoch rules; + // camera input uses its separate lifetime below. All other actions + // must match the exact context before shared reveal reconciliation. if let behavior::scenario::CanvasEvent::Camera(_, lifetime, _) = event && *lifetime != self.camera_epoch { diff --git a/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs b/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs index 70211569..a5ccecf8 100644 --- a/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs +++ b/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs @@ -86,7 +86,7 @@ fn exercise(via_guide: bool) { } assert_destination(&app, target); let camera = app.camera_boundary(); - // The prior saved canvas cannot regain focus after either owner route. + // The prior saved canvas cannot open its outline after either owner route. let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Shelf( held, crate::behavior::scenario::Shelf::Selection, diff --git a/crates/oa-desktop/src/app/scenario_spacing/tests.rs b/crates/oa-desktop/src/app/scenario_spacing/tests.rs index 1e399b01..74dc01b3 100644 --- a/crates/oa-desktop/src/app/scenario_spacing/tests.rs +++ b/crates/oa-desktop/src/app/scenario_spacing/tests.rs @@ -50,6 +50,7 @@ fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture() context, Size::new(900., 600.), ))); + let context = app.scenario_context().unwrap(); let (event, start, _) = begin(&mut app, Target::Lane(lane)); move_to(&mut app, event, start + Vector::new(24., 0.)); let accepted = app.document.snapshot(); @@ -314,6 +315,63 @@ fn stale_finish_cannot_remove_new_same_view_deferred_reveal() { assert!(!app.document.can_undo()); } +#[test] +fn stale_active_actions_cannot_remove_new_same_visit_deferred_reveal() { + let (mut app, lane, message) = setup(); + let old = app.scenario_context().unwrap(); + let behavior::scenario_layout::Representation::Saved { diagram } = old.representation else { + unreachable!() + }; + let accepted = app.document.snapshot(); + let source = app.source_workspace.bundle(); + let _ = app.update(Message::RevealInDiagram(diagram, message)); + let current = app.scenario_context().unwrap(); + assert_eq!(current.visit, old.visit); + assert_eq!(current.representation, old.representation); + assert_eq!(current.revision, old.revision); + assert_ne!(current.epoch, old.epoch); + let pending = app.pending_diagram_reveal.clone(); + assert!(pending.is_some()); + let focus = app.diagram_navigation_focus.as_ref().unwrap(); + let focus = (focus.context, focus.usage.clone(), focus.selected); + let camera = app.camera_boundary(); + for event in [ + CanvasEvent::Representation(old, old.representation), + CanvasEvent::Shelf(old, behavior::scenario::Shelf::Selection), + CanvasEvent::Select(old, lane), + CanvasEvent::Zoom(old, Some(1.)), + ] { + let _ = app.update(Message::ScenarioCanvas(event)); + assert_eq!(app.scenario_context(), Some(current)); + assert_eq!(app.pending_diagram_reveal, pending); + assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit)); + let actual = app.diagram_navigation_focus.as_ref().unwrap(); + assert_eq!( + (actual.context, actual.usage.clone(), actual.selected), + focus + ); + assert!(app.camera_boundary() == camera); + assert_eq!(app.selected, message); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert_eq!(app.project(), &accepted.decoded.project); + assert_eq!(app.source_workspace.bundle(), source); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + current, + Size::new(800., 500.), + ))); + assert!(app.pending_diagram_reveal.is_none()); + assert!(app.pending_scenario_reveal_visit.is_none()); + assert!( + app.diagram_focus(crate::diagram_usage::Surface::Saved(diagram)) + .is_some() + ); + assert_eq!(app.selected, message); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + #[test] fn modal_open_cancels_preview_and_captured_begin_stays_retired_after_close() { let (mut app, lane, _) = setup(); diff --git a/harness/scenario-text-union-20260923.md b/harness/scenario-text-union-20260923.md index da29dd7b..54128822 100644 --- a/harness/scenario-text-union-20260923.md +++ b/harness/scenario-text-union-20260923.md @@ -29,6 +29,14 @@ representation/visit checks also run before those effects. Scope input-owner equality now includes Scenario representation and visit. Current source Tab and selection behavior are retained, as are passive WorkDrag reveal exceptions. +Independent review found a same-visit, newer-epoch stale action could retire a +newer deferred reveal before its late rejection. Active Scenario actions now +require the exact context before shared boundaries. FormShown, Viewport, +Restored and Scrolled retain their existing passive admission, while Camera +retains its separate lifetime rule. A regression captures arrangement/outline/ +selection/zoom actions, starts a same-view reveal, verifies all stale actions +preserve its full receipt/focus/source/history, and completes the live viewport. + Root intentionally removed the obsolete panel-focus mode and Canvas button. This integration retains that always-active canvas policy. Saved Scenario Details opens the current inspector and dismisses its outline without replacing