Reject stale navigation envelopes before shared boundaries

This commit is contained in:
2026-09-12 19:49:27 -07:00
parent a17ca5d066
commit 659b7a655a
11 changed files with 422 additions and 51 deletions
@@ -65,6 +65,7 @@ impl Root<'_> {
view_index: Some(self.app.document.view_index()), view_index: Some(self.app.document.view_index()),
diagram, diagram,
selected: self.app.selected, selected: self.app.selected,
navigation_focus: None,
zoom: self.app.zoom, zoom: self.app.zoom,
pan: self.app.pan, pan: self.app.pan,
context: self.app.route_context().unwrap(), context: self.app.route_context().unwrap(),
+18 -16
View File
@@ -1249,6 +1249,21 @@ impl App {
]) ])
} }
pub fn update(&mut self, message: Message) -> Task<Message> { pub fn update(&mut self, message: Message) -> Task<Message> {
// These handlers reject old owners/tokens without changing state. Apply
// the same admission before shared camera and allocation boundaries.
if !self.source_input_envelope(&message)
|| matches!(&message, Message::DiagramUsage(event) if !self.diagram_usage_envelope(event))
|| matches!(&message, Message::WorkDrag(event) if !self.allocation_event_envelope(event))
{
return Task::none();
}
if let Message::WorkDrag(allocation_drag::Event::Press(press)) = &message
&& !self.allocation_press_owns_current_view(press)
{
// Keep the existing stale-press explanation, without treating the
// rejected press as a new navigation or allocation boundary.
return self.allocation_event(allocation_drag::Event::Press(press.clone()));
}
// A queued toolbar from a previous input owner cannot retire a newer // A queued toolbar from a previous input owner cannot retire a newer
// navigation destination or allocation capability before dispatch. // navigation destination or allocation capability before dispatch.
if let Message::BehaviorControl(intent) = &message if let Message::BehaviorControl(intent) = &message
@@ -1381,29 +1396,16 @@ impl App {
) )
} }
fn update_inner(&mut self, message: Message) -> Task<Message> { fn update_inner(&mut self, message: Message) -> Task<Message> {
let message = match message { if !self.source_input_envelope(&message) {
Message::SourceFindClose(context, instance) => {
if context != self.source_symbol_context()
|| instance != self.source_find_instance
|| !self.source_navigation.open
|| !self.source_navigation_available()
{
return Task::none(); return Task::none();
} }
let message = match message {
Message::SourceFindClose(..) => {
// Only the checked envelope may enter the legacy Close handler. // Only the checked envelope may enter the legacy Close handler.
Message::SourceNavigation(crate::source_navigation::Event::Close) Message::SourceNavigation(crate::source_navigation::Event::Close)
} }
Message::SourceNavigation(crate::source_navigation::Event::Close) => {
return Task::none();
}
message => message, message => message,
}; };
if let Message::SourceSymbols(context, _) = &message
&& (context.session != self.source_workspace.session()
|| context.path != self.source_workspace.active)
{
return Task::none();
}
// Internal results and geometry do not constitute a new user intent. // Internal results and geometry do not constitute a new user intent.
// Process query completion even behind a modal to release its worker slot; // Process query completion even behind a modal to release its worker slot;
// its handler independently refuses navigation outside the live editor. // its handler independently refuses navigation outside the live editor.
+23 -3
View File
@@ -437,7 +437,29 @@ impl App {
self.refresh_draft(); self.refresh_draft();
true true
} }
pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool {
self.allocation_drag.active.is_none()
&& self.allocation_stamp_valid(press.stamp)
&& press.arrange == self.allocation_drag.arrange_next
}
pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool {
match event {
Event::Cancel(token) | Event::Preview(_, token, _) | Event::Release(_, token, _) => {
self.allocation_drag
.active
.as_ref()
.is_some_and(|active| active.press.token == *token)
}
Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch,
// Invalid Press packets deliberately report why the view or draft
// changed. Leave their existing validation and feedback intact.
_ => true,
}
}
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> { pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
if !self.allocation_event_envelope(&event) {
return Task::none();
}
match event { match event {
Event::ArrangeNext => { Event::ArrangeNext => {
self.allocation_drag.retire(); self.allocation_drag.retire();
@@ -475,9 +497,7 @@ impl App {
if self.allocation_drag.active.is_some() { if self.allocation_drag.active.is_some() {
return Task::none(); return Task::none();
} }
if !self.allocation_stamp_valid(press.stamp) if !self.allocation_press_owns_current_view(&press) {
|| press.arrange != self.allocation_drag.arrange_next
{
self.status = if self.allocation_drag_clean() { self.status = if self.allocation_drag_clean() {
"The view changed. Start this drag again.".into() "The view changed. Start this drag again.".into()
} else { } else {
@@ -8,6 +8,8 @@ use iced::{
#[path = "allocation_drag_full_tree_tests.rs"] #[path = "allocation_drag_full_tree_tests.rs"]
mod full_tree; mod full_tree;
mod navigation_union;
struct Fixture { struct Fixture {
app: App, app: App,
a: Id, a: Id,
@@ -61,6 +63,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
view_index: Some(app.document.view_index()), view_index: Some(app.document.view_index()),
diagram: app.active_diagram.unwrap(), diagram: app.active_diagram.unwrap(),
selected: app.selected, selected: app.selected,
navigation_focus: None,
zoom: app.zoom, zoom: app.zoom,
pan: app.pan, pan: app.pan,
context: app.route_context().unwrap(), context: app.route_context().unwrap(),
@@ -0,0 +1,203 @@
//! Captured real canvas packets against a later navigation or live gesture.
use super::*;
use crate::diagram_usage::{self as usage, Target};
fn navigate_work(f: &mut Fixture) -> usage::Usage {
let message = f.app.diagram_usage_message(f.work);
let _ = f.app.update(message);
let panel = f.app.diagram_usages.as_ref().unwrap();
let token = panel.token;
let context = panel.context;
let process = f.app.behavior.topology.usage_snapshot().into_query_state();
let completion = usage::Completion {
token,
context,
result: Ok(usage::collect(
f.app.project(),
f.work,
&process,
&crate::behavior::scenario::State::default(),
)),
};
let _ = f.app.update(Message::DiagramUsagesReady(completion));
let found = f
.app
.diagram_usages
.as_ref()
.unwrap()
.usages
.iter()
.find(|usage| usage.target == Target::Placement(f.node))
.unwrap()
.clone();
let _ = f.app.update(Message::DiagramUsage(usage::Event::Choose(
token,
found.clone(),
)));
assert!(f.app.pending_diagram_reveal.is_some());
assert!(f.app.diagram_focus(found.surface).is_some());
found
}
fn assert_stale_noop(app: &mut App, message: Message) {
let before = app.document.snapshot();
let selection = (app.selected, app.selected_node, app.active_diagram);
let camera = (app.zoom, app.pan, app.canvas_epoch, app.camera_epoch);
let pending = app.pending_diagram_reveal.clone();
let focus = app
.diagram_navigation_focus
.as_ref()
.map(|focus| (focus.context, focus.usage.clone(), focus.selected));
let allocation = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
let active = app
.allocation_drag
.active
.as_ref()
.map(|active| (active.press.token, active.cache.len()));
let interaction = app.source_symbols.interaction;
let status = app.status.clone();
let _ = app.update(message);
assert_eq!(
(app.selected, app.selected_node, app.active_diagram),
selection
);
assert_eq!(
(app.zoom, app.pan, app.canvas_epoch, app.camera_epoch),
camera
);
assert_eq!(app.pending_diagram_reveal, pending);
assert_eq!(
app.diagram_navigation_focus.as_ref().map(|focus| (
focus.context,
focus.usage.clone(),
focus.selected
)),
focus
);
assert_eq!(
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
allocation
);
assert_eq!(
app.allocation_drag
.active
.as_ref()
.map(|active| (active.press.token, active.cache.len())),
active
);
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.status, status);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), before.bundle);
}
fn stale_packets(press: &Press) -> Vec<Message> {
vec![
Message::WorkDrag(Event::Cancel(press.token)),
Message::WorkDrag(Event::Preview(press.stamp, press.token, Some(press.start))),
Message::WorkDrag(Event::Release(press.stamp, press.token, Some(press.start))),
Message::WorkDrag(Event::Refused(press.stamp.epoch, "old refusal".into())),
]
}
#[test]
fn stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works() {
let mut f = fixture(Layer::Oa);
let before = f.app.document.snapshot();
let old = press(&f);
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
assert_eq!(
f.app.allocation_drag.active.as_ref().unwrap().press.token,
old.token
);
let found = navigate_work(&mut f);
assert!(f.app.allocation_drag.active.is_none());
assert_ne!(f.app.allocation_drag.epoch, old.stamp.epoch);
for message in stale_packets(&old) {
assert_stale_noop(&mut f.app, message);
}
// A rejected press still explains the invalid view, but cannot consume the
// new destination's pending first measurement.
let pending = f.app.pending_diagram_reveal.clone();
let epoch = f.app.allocation_drag.epoch;
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
assert_eq!(f.app.pending_diagram_reveal, pending);
assert_eq!(f.app.allocation_drag.epoch, epoch);
assert!(f.app.diagram_focus(found.surface).is_some());
assert_eq!(f.app.status, "The view changed. Start this drag again.");
let (context, _, epoch) = f.app.pending_diagram_reveal.clone().unwrap();
let _ = f.app.update(Message::DiagramRevealViewport(
context,
epoch,
bounds().size(),
));
assert!(f.app.pending_diagram_reveal.is_none());
assert!(f.app.diagram_focus(found.surface).is_some());
// Restore the fixture's camera through current App actions, then capture a
// fresh packet through the actual DiagramCanvas rather than seeding active.
let factor = 1. / f.app.zoom;
let _ = f.app.update(Message::Zoom(factor));
let delta = -f.app.pan;
let _ = f.app.update(Message::Pan(delta));
let current = press(&f);
let _ = f
.app
.update(Message::WorkDrag(Event::Press(current.clone())));
assert_eq!(
f.app.allocation_drag.active.as_ref().unwrap().press.token,
current.token
);
assert!(
f.app.diagram_navigation_focus.is_none(),
"live press retires navigation"
);
for message in stale_packets(&old) {
assert_stale_noop(&mut f.app, message);
}
let epoch = f.app.allocation_drag.epoch;
let _ = f
.app
.update(Message::WorkDrag(Event::Cancel(current.token)));
assert!(f.app.allocation_drag.active.is_none());
assert!(f.app.allocation_drag.epoch > epoch);
assert_eq!(f.app.status, "Work drag canceled");
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
}
#[test]
fn old_chooser_envelopes_preserve_a_current_canvas_gesture() {
let mut previous = fixture(Layer::Oa);
let old_open = previous.app.diagram_usage_message(previous.work);
let _ = previous.app.update(old_open.clone());
let old_token = previous.app.diagram_usages.as_ref().unwrap().token;
let mut f = fixture(Layer::Oa);
let current = press(&f);
let _ = f
.app
.update(Message::WorkDrag(Event::Press(current.clone())));
assert_stale_noop(&mut f.app, old_open);
assert_stale_noop(
&mut f.app,
Message::DiagramUsage(usage::Event::Close(old_token)),
);
assert_stale_noop(
&mut f.app,
Message::DiagramUsage(usage::Event::Search(old_token, "old".into())),
);
assert_eq!(
f.app.allocation_drag.active.as_ref().unwrap().press.token,
current.token
);
let open = f.app.diagram_usage_message(f.work);
let _ = f.app.update(open);
assert!(
f.app.diagram_usages.is_some(),
"current chooser remains live"
);
assert!(
f.app.allocation_drag.active.is_none(),
"current chooser retires gesture"
);
}
+26 -21
View File
@@ -144,14 +144,31 @@ impl App {
} }
} }
pub(super) fn diagram_usage_envelope(&self, event: &Event) -> bool {
match event {
Event::Open(context) => {
context.document == self.canvas_document
&& context.revision == self.document.revision()
&& self.project().contains(context.subject)
}
Event::Close(token)
| Event::Search(token, _)
| Event::Page(token, _)
| Event::Move(token, _)
| Event::Activate(token)
| Event::Destination(token, _)
| Event::Choose(token, _) => self
.diagram_usages
.as_ref()
.is_some_and(|panel| panel.token == *token),
}
}
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> { pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
if let Event::Open(context) = event { if !self.diagram_usage_envelope(&event) {
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.project().contains(context.subject)
{
return Task::none(); return Task::none();
} }
if let Event::Open(context) = event {
if !self.usage_navigation_available() { if !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into(); self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
return Task::none(); return Task::none();
@@ -171,22 +188,10 @@ impl App {
iced::widget::operation::focus(filter_id), iced::widget::operation::focus(filter_id),
]); ]);
} }
let token = match &event { let panel = self
Event::Open(_) => unreachable!(), .diagram_usages
Event::Close(t) .as_ref()
| Event::Search(t, _) .expect("admitted chooser token");
| Event::Page(t, _)
| Event::Move(t, _)
| Event::Activate(t)
| Event::Destination(t, _)
| Event::Choose(t, _) => *t,
};
let Some(panel) = &self.diagram_usages else {
return Task::none();
};
if panel.token != token {
return Task::none();
}
if matches!(event, Event::Close(_)) { if matches!(event, Event::Close(_)) {
self.diagram_usages = None; self.diagram_usages = None;
if self.page == Page::Text { if self.page == Page::Text {
@@ -1029,11 +1029,10 @@ fn repeated_preview_arrows_select_the_relation_and_highlight_the_exact_caller_li
let camera = app.behavior.topology.camera_boundary(); let camera = app.behavior.topology.camera_boundary();
if call == calls[1] if call == calls[1]
&& let Some((previous_call, previous)) = &previous_camera && let Some((previous_call, previous)) = &previous_camera
&& *previous_call != call
{ {
if *previous_call != call {
assert!(&camera != previous); assert!(&camera != previous);
} }
}
if let Some(epoch) = previous_epoch { if let Some(epoch) = previous_epoch {
assert_ne!(app.canvas_epoch, epoch); assert_ne!(app.canvas_epoch, epoch);
} }
@@ -1375,7 +1374,7 @@ fn whole_surface_camera_fits_huge_extent_while_target_camera_remains_readable()
}; };
let viewport = iced::Size::new(800., 500.); let viewport = iced::Size::new(800., 500.);
let (zoom, pan) = usage::camera_for(&Target::Whole, bounds, viewport); let (zoom, pan) = usage::camera_for(&Target::Whole, bounds, viewport);
assert!(zoom < 0.05 && zoom >= 0.0001); assert!((0.0001..0.05).contains(&zoom));
assert!(bounds.width * zoom <= viewport.width - 64. + 0.01); assert!(bounds.width * zoom <= viewport.width - 64. + 0.01);
assert!(bounds.height * zoom <= viewport.height - 64. + 0.01); assert!(bounds.height * zoom <= viewport.height - 64. + 0.01);
assert!((bounds.center_x() * zoom + pan.x - viewport.width / 2.).abs() < 0.01); assert!((bounds.center_x() * zoom + pan.x - viewport.width / 2.).abs() < 0.01);
@@ -1,6 +1,100 @@
//! Interactions absent from the independently published navigation branch. //! Interactions absent from the independently published navigation branch.
use super::*; use super::*;
fn assert_stale_navigation_message(message: Message) {
let mut app = navigation_fixture();
let found = navigate(&mut app);
let destination = navigation(&app);
let allocation_epoch = app.allocation_drag.epoch;
let accepted = app.document.snapshot();
let status = app.status.clone();
send(&mut app, message);
assert_eq!(navigation(&app), destination);
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
assert_eq!(app.status, status);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
first_measurement(&mut app, &found);
}
#[test]
fn captured_old_source_query_and_find_close_preserve_the_new_destination() {
let mut previous = navigation_fixture();
let query = previous.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(
&mut previous,
Message::SourceNavigation(nav::Event::Open(false)),
);
assert!(previous.source_navigation.open);
let close = previous.source_find_close_message();
assert!(previous.source_input_envelope(&close));
assert_stale_navigation_message(query);
assert_stale_navigation_message(close);
}
#[test]
fn current_source_query_and_checked_find_close_remain_live() {
let mut app = navigation_fixture();
let before = app.document.snapshot();
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
assert!(app.source_navigation.open);
let close = app.source_find_close_message();
send(&mut app, close.clone());
assert!(!app.source_navigation.open);
let epoch = app.allocation_drag.epoch;
send(&mut app, close);
assert_eq!(app.allocation_drag.epoch, epoch, "retired close is inert");
let message = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(&mut app, message);
assert!(app.source_symbols.in_flight.is_some());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), before.bundle);
}
#[test]
fn captured_old_chooser_context_and_every_token_variant_are_inert() {
let mut previous = navigation_fixture();
let open = previous.diagram_usage_message(model_id(2));
send(&mut previous, open.clone());
let (token, found) = finish_lookup(&mut previous);
assert_stale_navigation_message(open);
for event in [
usage::Event::Close(token),
usage::Event::Search(token, "old filter".into()),
usage::Event::Page(token, 1),
usage::Event::Move(token, 1),
usage::Event::Activate(token),
usage::Event::Destination(token, Some(found.surface)),
usage::Event::Choose(token, found),
] {
assert_stale_navigation_message(Message::DiagramUsage(event));
}
}
#[test]
fn current_chooser_open_and_close_keep_their_boundary_and_worker_semantics() {
let mut app = navigation_fixture();
navigate(&mut app);
let epoch = app.allocation_drag.epoch;
let message = app.diagram_usage_message(model_id(2));
send(&mut app, message);
assert!(app.diagram_usages.is_some());
assert!(app.pending_diagram_reveal.is_none());
assert!(app.diagram_navigation_focus.is_none());
assert!(app.allocation_drag.epoch > epoch);
let completion = diagram_completion(&app);
let token = completion.token;
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
assert!(app.diagram_usages.is_none());
assert_eq!(app.diagram_lookup_in_flight, Some(token));
send(&mut app, Message::DiagramUsagesReady(completion));
assert!(app.diagram_lookup_in_flight.is_none());
assert!(
app.diagram_usages.is_none(),
"completion does not reopen a closed chooser"
);
}
#[test] #[test]
fn allocation_keyboard_notification_preserves_navigation_and_first_measurement() { fn allocation_keyboard_notification_preserves_navigation_and_first_measurement() {
let mut app = navigation_fixture(); let mut app = navigation_fixture();
@@ -31,6 +31,24 @@ fn admission_notice(error: query::AdmissionError) -> String {
} }
impl App { impl App {
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
match message {
Message::SourceSymbols(context, _) => {
context.session == self.source_workspace.session()
&& context.path == self.source_workspace.active
}
Message::SourceFindClose(context, instance) => {
context == &self.source_symbol_context()
&& *instance == self.source_find_instance
&& self.source_navigation.open
&& self.source_navigation_available()
}
// The checked Find-close envelope is mapped inside update_inner.
Message::SourceNavigation(nav::Event::Close) => false,
_ => true,
}
}
pub fn source_symbol_context(&self) -> nav::SymbolContext { pub fn source_symbol_context(&self) -> nav::SymbolContext {
nav::SymbolContext { nav::SymbolContext {
session: self.source_workspace.session(), session: self.source_workspace.session(),
@@ -10,11 +10,11 @@ fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody)
precondition: String::new(), precondition: String::new(),
postcondition: String::new(), postcondition: String::new(),
}; };
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() { if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut()
if !steps.contains(&id) { && !steps.contains(&id)
{
steps.push(id); steps.push(id);
} }
}
id id
} }
@@ -56,3 +56,29 @@ Only source inspection, bounded formatting and whitespace checks are performed h
No `cargo`, `rustc`, test binary or GUI was run. Compile/lint correctness, new test outcomes, timing and actual native usability remain open. The integration owner should run focused new filters first, retain existing grouped/full/strict gates and build the matching executable. The parent's e44 results do not validate this union. No `cargo`, `rustc`, test binary or GUI was run. Compile/lint correctness, new test outcomes, timing and actual native usability remain open. The integration owner should run focused new filters first, retain existing grouped/full/strict gates and build the matching executable. The parent's e44 results do not validate this union.
Native acceptance retains the peer's six-file `examples/source-project` oracle: rename the second `@assess_report` reference to `assess_report_union`, exactly six alias-token changes in two files, no disk change before Save, immediate editor selection/typing and text Undo, navigation without model history, complete model Undo/Redo, then Save/reopen equality. Also exercise actual overflowing preview/Details return, modal exclusivity, source syntax selection, canvas focus/Delete after navigation, exact port/state-row and repeated-placement targets, nested activation shoulders/lifecycle boundaries, and passive event timing. Use deterministic race tests for held operations; screenshots alone do not establish ordering. Linux remains deferred. Native acceptance retains the peer's six-file `examples/source-project` oracle: rename the second `@assess_report` reference to `assess_report_union`, exactly six alias-token changes in two files, no disk change before Save, immediate editor selection/typing and text Undo, navigation without model history, complete model Undo/Redo, then Save/reopen equality. Also exercise actual overflowing preview/Details return, modal exclusivity, source syntax selection, canvas focus/Delete after navigation, exact port/state-row and repeated-placement targets, nested activation shoulders/lifecycle boundaries, and passive event timing. Use deterministic race tests for held operations; screenshots alone do not establish ordering. Linux remains deferred.
## Independent-review correction after 4e51, source only
The independent review of `4e51d68973da52cf057fab4983b9f112400b802c` identified an actual dispatch-order defect: old source, chooser and allocation envelopes reached shared reveal/allocation boundaries before their handlers rejected them. The follow-up shares those existing admission checks with the outer App boundary. SourceSymbols retains session/path checks; Find-close retains context/instance/open/availability checks and maps to the legacy Close only inside the checked handler. DiagramUsage retains exact Open document/revision/subject checks and all seven panel-token checks. Cancel, Preview and Release retain the active gesture token check; Refused retains its epoch check. Worker completion handlers and their exact occupied-slot release remain unchanged.
Rejected Press packets still run their existing feedback handler, before shared boundaries, so an old stamp can explain the changed view without consuming a newer destination. Current gestures, current chooser actions and current Find-close remain active. Current-token chooser model-refresh recovery is retained; this change does not require a matching panel revision before its existing refresh handler can run. No selection rewrite or broader event classification is introduced.
The exact test-only peer correction `a49b0ec7ac4538a372139175386a881645c1a59e` was inspected and applied: two collapsed conditions and one range containment expression in `app/diagram_usages/tests.rs` and `behavior/scenario/usage/tests.rs`. A source sweep also supplies the newly required `navigation_focus: None` in the root allocation test canvas and the production dirty-point-only canvas in `allocation_input.rs`. The latter is used only for the dirty-work hit check, not painted.
Four further tests under `app::source_rename::tests::navigation_union_tests::palette_union::`, all unrun:
- `captured_old_source_query_and_find_close_preserve_the_new_destination`
- `current_source_query_and_checked_find_close_remain_live`
- `captured_old_chooser_context_and_every_token_variant_are_inert`
- `current_chooser_open_and_close_keep_their_boundary_and_worker_semantics`
They capture actual prior owners/tokens and check complete navigation, allocation epoch, source interaction, status and accepted snapshot retention; each rejected navigation case then completes the valid pending viewport. Positive controls exercise current source query/Find-close and current chooser boundaries, including close while its worker slot remains occupied and exact completion release.
Two further tests under `app::allocation_drag::tests::navigation_union::`, all unrun:
- `stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works`
- `old_chooser_envelopes_preserve_a_current_canvas_gesture`
They capture Press through the actual DiagramCanvas event path, navigate through the actual chooser, preserve camera/selection/focus/pending measurement and the newer active gesture across stale packets, retain invalid-Press feedback, and exercise current Cancel and current chooser retirement. They do not seed private active gesture state. The resulting `palette_union::` filter contains seven tests, its enclosing `navigation_union_tests::` contains thirteen, the separate removal union still contains two, and the new allocation union contains two. The four existing preview-scroll tests are retained.
Only bounded rustfmt and whitespace/source checks are performed for this follow-up; the ignored formatting receipt is `.local/navigation-import/stale-envelope-format.json`. All new tests, compilation, strict lint and native acceptance remain unrun here. Root readonly, typed activation/lifecycle and paint implementations are untouched by this follow-up. The integration owner must execute the combined source gates; neither source review nor peer-only passes establishes union acceptance.