Files
ARCADIA-Studio/crates/oa-desktop/src/app/source_symbols/tests.rs
T
sorokin.oleg.edu 2df7f580aa Make the Text workspace compact and preserve editing context
Replace the oversized header and reference rail with stable editor slots and a retained supporting drawer. Bind diagnostic, result and menu actions to their original publication and visit; preserve effective selections on navigation and dismiss Actions, Find and the drawer one surface per Escape.

Retain exact Windows source captures, eight final gates (871 desktop passes), independent reviews, native selection/rename/recovery trials and the original failures. All 526 tested inputs and staged evidence were byte-checked before commit. Combined preview integration and packaging remain with issue 27; Linux remains paused.
2026-09-12 22:00:36 -07:00

2115 lines
83 KiB
Rust

use super::*;
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
use crate::source_workspace::Workspace;
use oa_core::source_project::SourceBundle;
use std::collections::BTreeMap;
use std::time::Instant;
const DEFINITIONS: &str = "defs/活動.arcadia";
const USES: &str = "uses.arcadia";
fn fixture_bundle() -> SourceBundle {
SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), concat!(
"arcadia 7;\r\nproject model \"Navigation\" id \"00000000-0000-0000-0000-000000000001\" {\r\n",
" include \"defs/活動.arcadia\";\r\n include \"uses.arcadia\";\r\n}\r\n"
).into()),
(DEFINITIONS.into(), concat!(
"// 船 and exact CRLF\r\n",
"oa_activity pump \"送水 🚀\" id \"00000000-0000-0000-0000-000000000002\" {}\r\n",
"oa_activity backup \"送水 🚀\" id \"00000000-0000-0000-0000-000000000004\" {}\r\n"
).into()),
(USES.into(), concat!(
"// Reference names remain lexical\r\n",
"interaction first \"先\" id \"00000000-0000-0000-0000-000000000003\" {\r\n",
" description \"船 @pump\"; source @pump; target @backup;\r\n}\r\n",
"interaction second \"後\" id \"00000000-0000-0000-0000-000000000005\" {\r\n",
" source @backup; target @ // @pump is a comment\r\n pump;\r\n}\r\n"
).into()),
]),
}
}
fn fixture() -> App {
let mut app = App::with_project(Project::empty());
app.document = Document::from_bundle(fixture_bundle(), LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
app.selected = app.project().id;
app.refresh_draft();
app.page = Page::Text;
app.show_source_file(false);
show(&mut app, USES);
let text = app.source_workspace.text();
let offset = text.find("source @pump").unwrap() + "source @".len();
app.source_editor
.move_to(selection(text, offset, offset + 4, true));
assert!(app.source_navigation_available());
app
}
fn select_effective_pump(app: &mut App, line: bool) -> String {
let source = app.source_workspace.text();
let offset = source.find("source @pump").unwrap() + "source @pu".len();
app.source_editor.move_to(text_editor::Cursor {
position: position(source, offset),
selection: None,
});
send(
app,
Message::SourceEdit(if line {
text_editor::Action::SelectLine
} else {
text_editor::Action::SelectWord
}),
);
let selected = app.source_editor.selection().unwrap();
assert!(selected.contains("pump"));
let raw = app.source_editor.cursor();
assert_eq!(raw.selection, Some(raw.position));
selected
}
#[test]
fn effective_selection_survives_file_roundtrip_and_definition_back() {
for line in [false, true] {
for definition in [false, true] {
let mut app = fixture();
let selected = select_effective_pump(&mut app, line);
let accepted = app.document.snapshot();
let bundle = app.source_workspace.bundle();
let revision = app.source_workspace.revision();
if definition {
let results = finish(&mut app, query::Action::Definition);
assert_eq!(results.disposition, query::Disposition::Direct);
reveal(&mut app);
assert_eq!(app.source_workspace.active, DEFINITIONS);
symbol(&mut app, nav::SymbolEvent::Back);
reveal(&mut app);
assert!(app.source_symbols.back.is_empty());
} else {
show(&mut app, DEFINITIONS);
show(&mut app, USES);
}
assert_eq!(app.source_workspace.active, USES);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
assert_eq!(app.source_workspace.bundle(), bundle);
assert_eq!(app.source_workspace.revision(), revision);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.source_workspace.undo(false) && !app.source_workspace.undo(true));
assert!(!app.document.can_undo() && !app.document.can_redo());
let expected = if line {
bundle.files[USES].replacen(&selected, "x", 1)
} else {
bundle.files[USES].replacen("source @pump", "source @x", 1)
};
send(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
);
assert_eq!(app.source_workspace.text(), expected);
assert_eq!(app.source_editor.text(), expected);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
}
#[test]
fn effective_selection_is_untouched_by_noop_missing_invalid_or_cancelled_navigation() {
for variant in 0..4 {
let mut app = fixture();
let selected = select_effective_pump(&mut app, false);
let raw_cursor = app.source_editor.cursor();
let bundle = app.source_workspace.bundle();
let revision = app.source_workspace.revision();
let accepted = app.document.snapshot();
match variant {
0 => send(&mut app, Message::SourceFile(USES.into())),
1 => send(&mut app, Message::SourceFile("missing.arcadia".into())),
2 => app.source_symbol_jump(
DEFINITIONS,
query::Position {
line: 999,
byte_column: 0,
},
query::Position {
line: 999,
byte_column: 1,
},
),
_ => {
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
send(&mut app, Message::SourceFile(USES.into()));
send(&mut app, Message::SourceSymbolsReady(ready));
reveal(&mut app);
}
}
assert_eq!(app.source_workspace.active, USES);
assert_eq!(
app.source_editor.cursor(),
raw_cursor,
"live selection mode must not normalize"
);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
assert_eq!(app.source_workspace.bundle(), bundle);
assert_eq!(app.source_workspace.revision(), revision);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn changed_active_origin_back_keeps_current_effective_selection() {
let mut app = fixture();
let old_generation = app.source_workspace.files[USES].generation();
let old_cursor = app.source_editor.cursor();
let original = app.source_workspace.text().to_owned();
app.source_editor
.perform(text_editor::Action::Move(text_editor::Motion::Left));
app.source_editor.move_to(text_editor::Cursor {
position: text_editor::Position { line: 0, column: 3 },
selection: None,
});
send(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
);
let mut expected_edit = original;
expected_edit.insert(3, 'x');
assert_eq!(app.source_workspace.text(), expected_edit);
let selected = select_effective_pump(&mut app, true);
let bundle = app.source_workspace.bundle();
let revision = app.source_workspace.revision();
app.source_symbols.back.push(nav::HistoryEntry {
session: app.source_workspace.session(),
path: USES.into(),
generation: old_generation,
cursor: old_cursor,
});
symbol(&mut app, nav::SymbolEvent::Back);
reveal(&mut app);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
assert!(
app.source_symbols
.notice
.as_deref()
.unwrap()
.contains("draft changed")
);
assert_eq!(app.source_workspace.bundle(), bundle);
assert_eq!(app.source_workspace.revision(), revision);
}
#[test]
fn source_creation_preserves_outgoing_selection_and_undisplayed_entry_cursor() {
let mut app = fixture();
let selected = select_effective_pump(&mut app, true);
let bundle = app.source_workspace.bundle();
let entry_cursor = app.source_workspace.files[&bundle.entry].cursor;
let accepted = app.document.snapshot();
send(&mut app, Message::SourceNewPath("new.arcadia".into()));
send(&mut app, Message::SourceCreateFile);
assert_eq!(app.source_workspace.active, "new.arcadia");
assert_eq!(
app.source_workspace.files[&bundle.entry].cursor,
entry_cursor
);
assert_eq!(app.source_workspace.files[USES].text, bundle.files[USES]);
assert!(
app.source_workspace.files[&bundle.entry]
.text
.contains("include \"new.arcadia\";")
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
show(&mut app, USES);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
select_effective_pump(&mut app, true);
let raw = app.source_editor.cursor();
send(
&mut app,
Message::SourceNewPath("../invalid.arcadia".into()),
);
send(&mut app, Message::SourceCreateFile);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), raw);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
}
fn send(app: &mut App, message: Message) {
// Dropping the returned Task does not poll its worker/lint future. Completion
// below is deterministic and still traverses the real App message handler.
let _ = app.update(message);
}
fn symbol(app: &mut App, event: nav::SymbolEvent) {
let message = app.source_symbol_message(event);
send(app, message);
}
fn choice(app: &App, stamp: query::QueryStamp, reference: bool, index: usize) -> Message {
app.source_symbol_message(nav::SymbolEvent::Choose {
stamp,
visit: app.text_workspace.drawer_visit(),
reference,
index,
})
}
fn references_visible(app: &App) -> bool {
app.text_workspace.drawer() == Some(DrawerTab::References)
}
fn show(app: &mut App, path: &str) {
send(app, Message::SourceFile(path.into()));
reveal(app);
}
fn reveal(app: &mut App) {
if let Some(ticket) = app.source_reveal_ticket() {
send(app, Message::SourceViewport(Some(ticket)));
}
}
fn position(text: &str, offset: usize) -> text_editor::Position {
assert!(text.is_char_boundary(offset));
let prefix = &text[..offset];
text_editor::Position {
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
column: prefix
.rsplit_once('\n')
.map_or(prefix.len(), |(_, tail)| tail.len()),
}
}
fn selection(text: &str, start: usize, end: usize, reversed: bool) -> text_editor::Cursor {
let (start, end) = (position(text, start), position(text, end));
text_editor::Cursor {
position: if reversed { end } else { start },
selection: Some(if reversed { start } else { end }),
}
}
fn begin(app: &mut App, action: query::Action) -> nav::Request {
symbol(app, nav::SymbolEvent::Query(action));
let request = app
.source_symbols
.request
.clone()
.expect("request admitted");
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp));
assert!(references_visible(app));
request
}
fn completion(app: &App, request: nav::Request) -> nav::Completion {
let snapshot = Arc::new(app.with_source_input(|input| {
query::admit_owned(input, query::Limits::default())
.unwrap()
.capture()
.unwrap()
}));
let result = Arc::new(
snapshot
.query(query::Query {
stamp: request.stamp,
path: &request.path,
byte_offset: request.offset,
action: request.action,
})
.unwrap(),
);
nav::Completion {
request,
result: Ok((snapshot, result)),
}
}
fn finish(app: &mut App, action: query::Action) -> Arc<query::Results> {
let request = begin(app, action);
let ready = completion(app, request);
let results = ready.result.as_ref().unwrap().1.clone();
send(app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
results
}
#[test]
fn valid_definition_and_reference_choice_keep_whole_model_and_source_unchanged() {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let results = finish(&mut app, query::Action::Definition);
assert_eq!(results.disposition, query::Disposition::Direct);
assert_eq!(results.total_definitions, 1);
assert_eq!(app.source_workspace.active, DEFINITIONS);
assert_eq!(app.source_symbols.back.len(), 1);
let row = &results.definitions[0];
assert_eq!(row.declaration.alias, "pump");
assert_eq!(row.declaration.name.as_deref(), Some("送水 🚀"));
let expected = selection(&raw.files[DEFINITIONS], row.span.start, row.span.end, false);
assert_eq!(
app.source_jump,
Some(expected),
"jump waits for the matching viewport"
);
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), expected);
let uses = finish(&mut app, query::Action::References);
assert_eq!(
uses.total_references, 2,
"comment/string references must not leak into App results"
);
assert_eq!(
app.source_workspace.active, DEFINITIONS,
"references require a choice"
);
let second = uses.references[1].clone();
let message = choice(&app, uses.stamp, true, 1);
send(&mut app, message);
reveal(&mut app);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(
app.source_editor.cursor(),
selection(&raw.files[USES], second.span.start, second.span.end, false)
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), raw);
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn detached_duplicate_requires_an_explicit_choice_even_when_included_source_is_validated() {
let mut app = fixture();
let mut loaded = app.source_workspace.bundle();
loaded.files.insert(
"detached.arcadia".into(),
"oa_activity pump \"Different source\" id \"00000000-0000-0000-0000-000000000099\" {}\r\n"
.into(),
);
let cursor = app.source_editor.cursor();
app.source_workspace = Workspace::new(&loaded, true);
app.source_workspace.active = USES.into();
app.show_source_file(false);
app.source_editor.move_to(cursor);
let results = finish(&mut app, query::Action::Definition);
assert_eq!(results.total_definitions, 2);
assert_eq!(results.disposition, query::Disposition::Choices);
assert_eq!(app.source_workspace.active, USES);
assert!(app.source_symbols.back.is_empty());
let index = results
.definitions
.iter()
.position(|row| row.path == "detached.arcadia")
.unwrap();
assert_eq!(
results.definitions[index].membership,
query::Membership::Detached
);
let message = choice(&app, results.stamp, false, index);
send(&mut app, message);
reveal(&mut app);
assert_eq!(app.source_workspace.active, "detached.arcadia");
assert_eq!(app.source_workspace.bundle(), loaded);
assert_eq!(
app.document.bundle().files.len(),
3,
"choice must not adopt the detached source"
);
}
#[test]
fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() {
let mut app = fixture();
let base = app.document.snapshot();
let id = app.project().id;
app.document
.execute(Command::Rename(id, "Accepted one".into()))
.unwrap();
let one = app.document.snapshot();
app.document
.execute(Command::Rename(id, "Accepted two".into()))
.unwrap();
let two = app.document.snapshot();
assert!(app.document.undo());
app.sync_source();
show(&mut app, USES);
let prior_raw = app.source_workspace.text().to_owned();
let invalid = format!("{prior_raw}\r\n? unfinished raw edit 雨\r\n");
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(invalid)),
)));
app.source_workspace.applied = false;
let text = app.source_workspace.text();
let alias = text.find("source @pump").unwrap() + "source @".len();
app.source_editor
.move_to(selection(text, alias, alias + 4, true));
let raw = app.source_workspace.bundle();
let revision = app.document.revision();
let results = finish(&mut app, query::Action::Definition);
assert_eq!(results.disposition, query::Disposition::Choices);
assert!(results.scope.partial() && !results.scope.validated);
let index = results
.definitions
.iter()
.position(|row| row.declaration.alias == "pump")
.unwrap();
let message = choice(&app, results.stamp, false, index);
send(&mut app, message);
reveal(&mut app);
symbol(&mut app, nav::SymbolEvent::Back);
reveal(&mut app);
assert!(Arc::ptr_eq(&one, &app.document.snapshot()));
assert_eq!(app.project(), &one.decoded.project);
assert_eq!(app.document.bundle(), &one.bundle);
assert_eq!(app.document.revision(), revision);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(!app.source_workspace.applied);
assert!(app.document.can_undo() && app.document.can_redo());
// Actual round trips, not only Boolean history flags.
assert!(app.document.undo());
assert_eq!(app.document.bundle(), &base.bundle);
assert!(app.document.redo());
assert_eq!(app.document.bundle(), &one.bundle);
assert!(app.document.redo());
assert_eq!(app.document.bundle(), &two.bundle);
assert!(app.source_workspace.undo(false));
assert_eq!(app.source_workspace.text(), prior_raw);
assert!(app.source_workspace.undo(true));
assert_eq!(app.source_workspace.bundle(), raw);
}
#[test]
fn completion_retains_current_display_but_rejects_navigation_after_context_changes() {
for change in 0..7 {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
let accepted = app.document.snapshot();
match change {
0 => show(&mut app, "project.arcadia"),
1 => send(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
),
2 => app.source_workspace.reset_navigation_session(),
3 => app.source_editor.move_to(text_editor::Cursor {
position: text_editor::Position { line: 0, column: 0 },
selection: None,
}),
4 => app.source_interaction(),
5 => {
app.source_symbols
.raw_interaction
.fetch_add(1, Ordering::SeqCst);
}
_ => app.page = Page::Diagram,
}
let after = app.source_workspace.bundle();
let active = app.source_workspace.active.clone();
let cursor = app.source_editor.cursor();
let drawer = app.text_workspace.drawer();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(
app.source_symbols.in_flight.is_none(),
"case {change}: release worker slot"
);
if matches!(change, 1 | 2) {
assert!(
app.source_symbols.results.is_none(),
"case {change}: changed source/session cannot own display"
);
} else {
assert!(
Arc::ptr_eq(app.source_symbols.results.as_ref().unwrap(), &expected),
"case {change}: canceled navigation still owns current-source display"
);
}
assert_eq!(
app.text_workspace.drawer(),
drawer,
"completion cannot select a tab"
);
assert!(app.source_symbols.back.is_empty());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.active, active);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), after);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_new_results() {
let mut app = fixture();
let request = begin(&mut app, query::Action::References);
let ready = completion(&app, request.clone());
let mut unrelated = ready.clone();
unrelated.request.job = Id::new_v4();
send(&mut app, Message::SourceSymbolsReady(unrelated));
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.results.is_none());
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
let newer = begin(&mut app, query::Action::References);
let newer_ready = completion(&app, newer.clone());
send(&mut app, Message::SourceSymbolsReady(ready));
assert_eq!(app.source_symbols.in_flight, Some(newer.job));
send(&mut app, Message::SourceSymbolsReady(newer_ready));
assert_eq!(
app.source_symbols.results.as_ref().unwrap().stamp,
newer.stamp
);
}
#[test]
fn close_during_busy_retains_hidden_completion_without_reopening_or_navigation() {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
assert!(references_visible(&app));
symbol(&mut app, nav::SymbolEvent::Close);
assert!(app.text_workspace.drawer().is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.text_workspace.drawer().is_none());
assert!(app.source_symbols.in_flight.is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.back.is_empty());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let next = begin(&mut app, query::Action::References);
assert_ne!(next.job, request.job);
assert!(references_visible(&app));
assert!(
app.source_symbols.results.is_none(),
"explicit new query replaces prior display ownership"
);
}
#[test]
fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal() {
let mut app = fixture();
finish(&mut app, query::Action::Definition);
let ticket = app.source_reveal_ticket().unwrap();
let destination = app.source_jump.unwrap();
let before = app.source_editor.cursor();
assert_ne!(before, destination);
for wrong in [None, Some(Id::new_v4())] {
send(&mut app, Message::SourceViewport(wrong));
assert_eq!(app.source_editor.cursor(), before);
assert_eq!(app.source_reveal_ticket(), Some(ticket));
}
app.source_symbols
.raw_interaction
.fetch_add(1, Ordering::SeqCst);
send(&mut app, Message::SourceViewport(Some(ticket)));
assert_eq!(
app.source_editor.cursor(),
before,
"raw input arrived before App event dispatch"
);
app.queue_source_reveal(destination, false);
let fresh = app.source_reveal_ticket().unwrap();
assert_ne!(ticket, fresh);
send(&mut app, Message::SourceViewport(Some(ticket)));
assert_eq!(app.source_editor.cursor(), before);
send(&mut app, Message::SourceViewport(Some(fresh)));
assert_eq!(app.source_editor.cursor(), destination);
assert!(app.source_reveal_ticket().is_none() && app.source_jump.is_none());
}
#[test]
fn escape_hides_results_and_cancels_navigation_but_retains_latest_display() {
for pending in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let request = begin(&mut app, query::Action::References);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
if !pending {
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
}
app.source_navigation.open = true;
let visit = app.text_workspace.drawer_visit();
send(&mut app, Message::Escape);
assert!(references_visible(&app) && !app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(app.source_symbols.request.is_none());
reveal(&mut app);
send(&mut app, Message::Escape);
assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open);
assert_eq!(app.source_symbols.results.is_some(), !pending);
assert!(app.source_symbols.request.is_none());
// Explicit Escape may restore editor focus; consume that ticket before
// delivering completion so any later focus request is discriminated.
reveal(&mut app);
if pending {
assert_eq!(app.source_symbols.in_flight, Some(request.job));
send(&mut app, Message::SourceSymbolsReady(ready));
}
assert!(app.source_symbols.in_flight.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(app.source_symbols.back.is_empty());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn escape_dismisses_modal_before_underlying_source_results() {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let results = finish(&mut app, query::Action::References);
app.hosted.visible = true;
send(&mut app, Message::Escape);
assert!(!app.hosted.visible);
assert!(
app.source_reveal_ticket().is_none(),
"modal dismissal must not focus the editor"
);
assert!(references_visible(&app));
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
send(&mut app, Message::Escape);
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice() {
for changed in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
let origin = app.source_editor.cursor();
finish(&mut app, query::Action::Definition);
reveal(&mut app);
symbol(&mut app, nav::SymbolEvent::Close);
assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some());
if changed {
// Use the workspace edit API to give the origin a new generation.
show(&mut app, USES);
let text = app.source_workspace.text().to_owned();
send(
&mut app,
Message::SourceEdit(text_editor::Action::SelectAll),
);
send(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(format!("{text}// current cursor\r\n")),
))),
);
show(&mut app, DEFINITIONS);
}
let raw = app.source_workspace.bundle();
symbol(&mut app, nav::SymbolEvent::Back);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(references_visible(&app), changed);
assert_eq!(app.source_symbols.notice.is_some(), changed);
reveal(&mut app);
if !changed {
assert_eq!(app.source_editor.cursor(), origin);
}
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
let mut app = fixture();
symbol(&mut app, nav::SymbolEvent::Back);
assert!(references_visible(&app) && app.source_symbols.notice.is_some());
}
#[test]
fn find_only_captured_escape_closes_once_and_preserves_raw_query_and_selection() {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
app.source_navigation.open = true;
app.source_navigation.query = "送水 🚀".into();
assert!(!references_visible(&app));
let mut messages = captured_input_subscription_messages(&app);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
assert!(!app.source_navigation.open && !references_visible(&app));
assert_eq!(app.source_navigation.query, "送水 🚀");
assert!(app.source_reveal_ticket().is_some());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
assert!(captured_input_subscription_messages(&app).is_empty());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn old_find_close_cannot_cancel_a_new_file_session_or_reopened_find_intent() {
for replacement in 0..3 {
let mut app = fixture();
if replacement == 0 {
show(&mut app, "project.arcadia");
}
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
let old_context = app.source_symbol_context();
let old_instance = app.source_find_instance;
let old_close = app.source_find_close_message();
match replacement {
0 => {
let project = app.project().clone();
app.reset(project, None);
app.page = Page::Text;
assert_eq!(app.source_workspace.active, old_context.path);
assert_ne!(app.source_workspace.session(), old_context.session);
}
1 => {
show(&mut app, DEFINITIONS);
assert_eq!(app.source_workspace.session(), old_context.session);
assert_ne!(app.source_workspace.active, old_context.path);
}
2 => {
send(&mut app, old_close.clone());
assert!(!app.source_navigation.open);
assert!(app.source_reveal_ticket().is_some());
assert_eq!(app.source_symbol_context(), old_context);
}
_ => unreachable!(),
}
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
assert_ne!(app.source_find_instance, old_instance);
send(
&mut app,
Message::SourceNavigation(nav::Event::Query("Current Find 雨".into())),
);
let text = app.source_workspace.text();
let (offset, len) = match replacement {
0 => (text.find("project model").unwrap() + "project ".len(), 5),
1 => (
text.find("oa_activity pump").unwrap() + "oa_activity ".len(),
4,
),
_ => (text.find("source @pump").unwrap() + "source @".len(), 4),
};
let cursor = selection(text, offset, offset + len, true);
app.source_editor.move_to(cursor);
let request = begin(&mut app, query::Action::References);
let ready = completion(&app, request);
app.queue_source_reveal(cursor, false);
let find_instance = app.source_find_instance;
let mut stale = vec![old_close, Message::SourceNavigation(nav::Event::Close)];
if replacement != 2 {
// Independently require the session/path guard, even with a current
// invocation token. A token check alone is not the location contract.
stale.push(Message::SourceFindClose(old_context, find_instance));
}
assert_stale_envelopes_preserve_current(&mut app, &stale);
assert!(app.source_navigation.open);
assert_eq!(app.source_navigation.query, "Current Find 雨");
assert_eq!(app.source_find_instance, find_instance);
send(&mut app, Message::SourceSymbolsReady(ready));
assert_stale_envelopes_preserve_current(&mut app, &stale);
assert!(app.source_navigation.open);
assert_eq!(app.source_navigation.query, "Current Find 雨");
let current_close = app.source_find_close_message();
send(&mut app, current_close);
assert!(!app.source_navigation.open);
assert_eq!(app.source_navigation.query, "Current Find 雨");
assert!(app.source_reveal_ticket().is_some());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
}
}
#[tokio::test]
async fn find_close_releases_removed_input_and_guarded_focus_respects_later_input() {
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
use iced::widget::{column, text_input};
#[derive(Default)]
struct Focus(Vec<(Option<widget::Id>, bool)>);
impl widget::Operation for Focus {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
_: iced::Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
self.0.push((id.cloned(), state.is_focused()));
}
}
fn view(app: &App, later: bool) -> iced::Element<'static, Message, iced::Theme, ()> {
let mut content = column![
text_input("Editor", "unchanged source")
.id(app.source_editor_id())
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s)))
];
if app.source_navigation.open {
content = content.push(
text_input("Find", "送水")
.id("source-find")
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
);
}
if later {
content = content.push(
text_input("Other", "preserve focus")
.id("later-input")
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
);
}
content.into()
}
fn run(
ui: &mut iced::Element<'_, Message, iced::Theme, ()>,
tree: &mut widget::Tree,
node: &layout::Node,
mut op: Box<dyn widget::Operation>,
) {
loop {
ui.as_widget_mut()
.operate(tree, Layout::new(node), &(), op.as_mut());
match op.finish() {
widget::operation::Outcome::Chain(next) => op = next,
widget::operation::Outcome::None => break,
widget::operation::Outcome::Some(()) => panic!("focus must not produce a message"),
}
}
}
for escape in [false, true] {
for later in 0..3 {
let mut app = fixture();
app.source_navigation.open = true;
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let mut ui = view(&app, false);
let mut tree = widget::Tree::new(ui.as_widget());
let limits = layout::Limits::new(iced::Size::ZERO, iced::Size::new(500., 250.));
let node = ui.as_widget_mut().layout(&mut tree, &(), &limits);
run(
&mut ui,
&mut tree,
&node,
Box::new(widget::operation::focusable::focus(widget::Id::new(
"source-find",
))),
);
let mut focus = Focus::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &(), &mut focus);
assert!(
focus
.0
.contains(&(Some(widget::Id::new("source-find")), true))
);
let close = if escape {
Message::Escape
} else {
app.source_find_close_message()
};
send(&mut app, close);
assert!(!app.source_navigation.open);
let ticket = app
.source_reveal_ticket()
.expect("closing an open Find queues focus");
ui = view(&app, later != 0);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(&mut tree, &(), &limits);
let task = app.update(Message::SourceViewport(Some(ticket)));
if later != 0 {
run(
&mut ui,
&mut tree,
&node,
Box::new(widget::operation::focusable::focus(widget::Id::new(
"later-input",
))),
);
}
if later == 2 {
app.source_symbols
.raw_interaction
.fetch_add(1, Ordering::SeqCst);
}
let mut actions =
iced_runtime::task::into_stream(task).expect("guarded widget operation");
while let Some(action) = actions.next().await {
let iced_runtime::Action::Widget(op) = action else {
panic!("focus-only task");
};
run(&mut ui, &mut tree, &node, op);
}
let mut focus = Focus::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &(), &mut focus);
assert!(
!focus
.0
.iter()
.any(|(id, _)| id.as_ref() == Some(&widget::Id::new("source-find")))
);
assert!(
focus
.0
.contains(&(Some(app.source_editor_id()), later == 0))
);
if later != 0 {
assert!(
focus
.0
.contains(&(Some(widget::Id::new("later-input")), true))
);
}
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
}
/// Feed the real App subscription recipes the status produced by a mounted
/// TextInput. Poll only immediately available output: no timer wait, worker,
/// file dialog or App task is executed by this helper.
fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
input_subscription_messages(app, true)
}
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
use iced::advanced::{Layout, Shell, layout, subscription, widget};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::{self, key::Named};
use std::task::{Context, Poll};
let mut input: iced::Element<'_, (), iced::Theme, ()> =
iced::widget::text_input("Find / modal input", "Retain exact text 雨")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
if focused {
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
}
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let mut input_messages = Vec::new();
let mut shell = Shell::new(&mut input_messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
let captured = shell.is_event_captured();
assert_eq!(captured, focused);
assert!(input_messages.is_empty());
assert!(
!tree
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
);
let event = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event,
status: if captured {
iced::event::Status::Captured
} else {
iced::event::Status::Ignored
},
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
let mut messages = Vec::new();
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
let mut context = Context::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
assert!(
!matches!(message, Message::CheckpointTick),
"timer must remain pending"
);
messages.push(message);
}
}
messages
}
#[test]
fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pending_work() {
for pending in [false, true] {
for modal in 0..4 {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let request = begin(&mut app, query::Action::References);
let ready = completion(&app, request.clone());
if !pending {
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
}
let results = app.source_symbols.results.clone();
let expected = ready.result.as_ref().unwrap().1.clone();
app.source_navigation.open = true;
let visit = app.text_workspace.drawer_visit();
match modal {
0 => {}
1 => app.hosted.visible = true,
2 => {
app.file_prompt = Some(FilePrompt {
operation: FileOperation::Save,
path: "unchanged-destination.arcadia".into(),
overwrite: false,
})
}
3 => app.pending = Some(Pending::Walkthrough),
_ => unreachable!(),
}
let mut messages = captured_input_subscription_messages(&app);
assert_eq!(messages.len(), 1, "pending={pending}, modal={modal}");
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
if modal != 0 {
assert!(!app.hosted.visible && app.file_prompt.is_none() && app.pending.is_none());
assert!(references_visible(&app) && app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(
app.source_reveal_ticket().is_none(),
"modal Escape cannot steal editor focus"
);
match (&results, &app.source_symbols.results) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
_ => panic!("modal Escape must retain the underlying results"),
}
let mut messages = captured_input_subscription_messages(&app);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
}
assert!(references_visible(&app) && !app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert_eq!(app.source_symbols.results.is_some(), !pending);
assert!(app.source_reveal_ticket().is_some());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
// One more actual Escape hides the remaining drawer. Both status
// routes emit exactly one App message; no second bridge is needed.
let mut messages = input_subscription_messages(&app, false);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
assert!(!references_visible(&app) && !app.source_navigation.open);
assert!(app.source_symbols.request.is_none());
reveal(&mut app);
if pending {
assert_eq!(app.source_symbols.in_flight, Some(request.job));
send(&mut app, Message::SourceSymbolsReady(ready));
}
assert!(app.source_symbols.in_flight.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
assert!(
captured_input_subscription_messages(&app).is_empty(),
"closed panel opts out"
);
}
}
}
#[test]
fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() {
let mut app = fixture();
app.text_workspace.show_drawer(Some(DrawerTab::References));
let project = app.project().clone();
assert!(
app.behavior
.creation
.update(&project, crate::behavior::creation::Event::Open(Layer::Oa))
.unwrap()
.is_none()
);
assert!(app.behavior.creation.has_pending());
let messages = captured_input_subscription_messages(&app);
assert_eq!(
messages.len(),
1,
"behavior and symbols share the same bridge"
);
assert!(matches!(messages[0], Message::Escape));
let mut app = App::with_project(Project::empty());
send(&mut app, Message::AddDiagram);
send(
&mut app,
Message::Architecture(crate::app::architecture_authoring::Event::Start(
Kind::Actor,
Some(oa_core::Position { x: 120., y: 160. }),
)),
);
assert!(app.architecture.as_ref().unwrap().inline());
// A retained Text panel must not add a second route to inline architecture.
app.page = Page::Text;
app.text_workspace.show_drawer(Some(DrawerTab::References));
let messages = captured_input_subscription_messages(&app);
assert_eq!(messages.len(), 1);
assert!(matches!(
messages[0],
Message::Architecture(crate::app::architecture_authoring::Event::CapturedEscape)
));
app.architecture = None;
app.page = Page::Behavior;
assert!(
captured_input_subscription_messages(&app).is_empty(),
"off-page symbols opt out"
);
}
#[test]
fn saturated_raw_clock_never_authorizes_completion_or_reveal() {
let mut app = fixture();
app.source_symbols
.raw_interaction
.store(u64::MAX, Ordering::SeqCst);
symbol(&mut app, nav::SymbolEvent::Query(query::Action::Definition));
if let Some(request) = app.source_symbols.request.clone() {
let ready = completion(&app, request);
send(&mut app, Message::SourceSymbolsReady(ready));
}
assert!(app.source_symbols.results.is_none());
assert_eq!(app.source_workspace.active, USES);
let before = app.source_editor.cursor();
let cursor = text_editor::Cursor {
position: text_editor::Position { line: 0, column: 0 },
selection: None,
};
app.queue_source_reveal(cursor, false);
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), before);
}
#[test]
fn back_restores_exact_reversed_utf8_selection_and_crlf_source() {
let mut app = fixture();
let origin = app.source_editor.cursor();
assert!(
origin.position.column > origin.selection.unwrap().column,
"reverse selection fixture"
);
let raw = app.source_workspace.bundle();
assert!(raw.files[USES].contains("\r\n"));
finish(&mut app, query::Action::Definition);
reveal(&mut app);
symbol(&mut app, nav::SymbolEvent::Back);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_jump, Some(origin));
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), origin);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(app.source_symbols.back.is_empty());
}
#[test]
fn back_after_origin_edit_uses_its_current_cursor_instead_of_the_old_range() {
let mut app = fixture();
let old_origin = app.source_editor.cursor();
finish(&mut app, query::Action::Definition);
reveal(&mut app);
show(&mut app, USES);
send(
&mut app,
Message::SourceEdit(text_editor::Action::SelectAll),
);
send(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new("// changed\n@pump\n".into()),
))),
);
let current = text_editor::Cursor {
position: text_editor::Position { line: 1, column: 5 },
selection: Some(text_editor::Position { line: 1, column: 1 }),
};
app.source_editor.move_to(current);
show(&mut app, DEFINITIONS);
assert_ne!(old_origin, current);
let changed = app.source_workspace.bundle();
symbol(&mut app, nav::SymbolEvent::Back);
assert_eq!(app.source_jump, Some(current));
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), current);
assert_eq!(app.source_workspace.bundle(), changed);
assert!(
app.source_symbols
.notice
.as_deref()
.is_some_and(|notice| notice.contains("changed"))
);
}
#[test]
fn reset_retains_worker_slot_until_old_completion_then_admits_new_session_query() {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let old_session = app.source_workspace.session();
let project = app.project().clone();
app.reset(project, None);
assert_ne!(app.source_workspace.session(), old_session);
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.request.is_none());
app.page = Page::Text;
let text = app.source_workspace.text();
let alias = text.find("project model").unwrap() + "project ".len();
app.source_editor
.move_to(selection(text, alias, alias + 5, false));
symbol(&mut app, nav::SymbolEvent::Query(query::Action::Definition));
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(
app.source_symbols.request.is_none(),
"reset must not create a second active worker"
);
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(app.source_symbols.results.is_none());
let fresh = begin(&mut app, query::Action::Definition);
assert_eq!(
fresh.stamp.snapshot.workspace,
app.source_workspace.session()
);
assert_ne!(fresh.stamp.snapshot.workspace, old_session);
let ready = completion(&app, fresh);
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.results.is_some());
}
#[test]
fn back_keeps_the_latest_64_actual_locations_without_reusing_dropped_history() {
let mut app = fixture();
let text: String = (0..80).map(|line| format!("// 行 {line}\r\n")).collect();
let loaded = SourceBundle {
entry: "trail.arcadia".into(),
files: BTreeMap::from([("trail.arcadia".into(), text)]),
};
app.source_workspace = Workspace::new(&loaded, false);
app.show_source_file(false);
let cursor = |line| text_editor::Cursor {
position: text_editor::Position { line, column: 0 },
selection: Some(text_editor::Position { line, column: 1 }),
};
app.source_editor.move_to(cursor(0));
for line in 1..=66 {
app.source_symbol_jump(
"trail.arcadia",
query::Position {
line,
byte_column: 0,
},
query::Position {
line,
byte_column: 1,
},
);
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor(line));
}
assert_eq!(app.source_symbols.back.len(), 64);
assert_eq!(app.source_symbols.back.first().unwrap().cursor, cursor(2));
for line in (2..=65).rev() {
symbol(&mut app, nav::SymbolEvent::Back);
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor(line));
}
assert!(app.source_symbols.back.is_empty());
symbol(&mut app, nav::SymbolEvent::Back);
assert_eq!(app.source_editor.cursor(), cursor(2));
assert_eq!(app.source_workspace.bundle(), loaded);
}
/// Replay frozen widget messages without rebuilding their context. In a real
/// Iced batch these can follow a reset or file switch before the next view diff.
fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message]) {
let interaction = app.source_symbols.interaction;
let raw_interaction = app.source_symbols.raw_interaction.load(Ordering::SeqCst);
let request = app.source_symbols.request.as_ref().map(|request| {
(
request.job,
request.interaction,
request.raw_interaction,
request.stamp,
request.path.clone(),
request.offset,
request.cursor,
request.action,
)
});
let in_flight = app.source_symbols.in_flight;
let serial = app.source_symbols.serial;
let reveal = app.source_symbols.reveal.as_ref().map(|reveal| {
(
reveal.ticket,
reveal.interaction,
reveal.raw_interaction,
reveal.snapshot,
reveal.path.clone(),
)
});
let jump = (app.source_jump, app.source_select_line);
let back: Vec<_> = app
.source_symbols
.back
.iter()
.map(|entry| {
(
entry.session,
entry.path.clone(),
entry.generation,
entry.cursor,
)
})
.collect();
let results = app.source_symbols.results.clone();
let cache = app.source_symbols.cache.clone();
let panel = (
app.text_workspace.drawer(),
app.source_symbols.notice.clone(),
);
let display_request = app.source_symbols.display_request.clone();
let reference_presentation = (
app.text_workspace.references().stamp(),
app.text_workspace.drawer_visit(),
app.text_workspace.references().scroll_y(),
);
let key = app.source_key();
let active = app.source_workspace.active.clone();
let cursor = app.source_editor.cursor();
let editor = app.source_editor.text();
let raw = app.source_workspace.bundle();
let accepted = app.document.snapshot();
let history = (
app.document.revision(),
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
for message in messages.iter().cloned() {
send(app, message);
assert_eq!(
app.source_symbols.interaction, interaction,
"reject stale context before invalidating current intent"
);
assert_eq!(
app.source_symbols.raw_interaction.load(Ordering::SeqCst),
raw_interaction
);
assert_eq!(
app.source_symbols.request.as_ref().map(|request| (
request.job,
request.interaction,
request.raw_interaction,
request.stamp,
request.path.clone(),
request.offset,
request.cursor,
request.action,
)),
request
);
assert_eq!(
(app.source_symbols.in_flight, app.source_symbols.serial),
(in_flight, serial)
);
assert_eq!(
app.source_symbols.reveal.as_ref().map(|reveal| (
reveal.ticket,
reveal.interaction,
reveal.raw_interaction,
reveal.snapshot,
reveal.path.clone(),
)),
reveal
);
assert_eq!((app.source_jump, app.source_select_line), jump);
assert_eq!(
app.source_symbols
.back
.iter()
.map(|entry| (
entry.session,
entry.path.clone(),
entry.generation,
entry.cursor,
))
.collect::<Vec<_>>(),
back
);
assert_eq!(
(
app.text_workspace.drawer(),
app.source_symbols.notice.clone()
),
panel
);
assert_eq!(app.source_symbols.display_request, display_request);
assert_eq!(
(
app.text_workspace.references().stamp(),
app.text_workspace.drawer_visit(),
app.text_workspace.references().scroll_y()
),
reference_presentation
);
match (&results, &app.source_symbols.results) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
_ => panic!("stale envelope replaced or cleared current results"),
}
match (&cache, &app.source_symbols.cache) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
_ => panic!("stale envelope replaced or cleared current snapshot"),
}
assert_eq!(app.source_key(), key);
assert_eq!(app.source_workspace.active, active);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_editor.text(), editor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(
(
app.document.revision(),
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
}
}
fn stale_envelope_trial(reset: bool) {
let mut app = fixture();
if reset {
show(&mut app, "project.arcadia");
let text = app.source_workspace.text();
let alias = text.find("project model").unwrap() + "project ".len();
app.source_editor
.move_to(selection(text, alias, alias + 5, false));
}
let old_results = finish(&mut app, query::Action::References);
let old_context = app.source_symbol_context();
let old_messages = [
app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)),
app.source_symbol_message(nav::SymbolEvent::Back),
app.source_symbol_message(nav::SymbolEvent::Close),
choice(&app, old_results.stamp, false, 0),
];
if reset {
let project = app.project().clone();
app.reset(project, None);
app.page = Page::Text;
let new_context = app.source_symbol_context();
assert_eq!(
new_context.path, old_context.path,
"same path isolates the session guard"
);
assert_ne!(new_context.session, old_context.session);
} else {
show(&mut app, DEFINITIONS);
let new_context = app.source_symbol_context();
assert_ne!(new_context.path, old_context.path);
assert_eq!(
new_context.session, old_context.session,
"file switch isolates the path guard"
);
assert_eq!(app.source_key(), old_results.stamp.snapshot);
}
let text = app.source_workspace.text();
let (alias, length) = if reset {
(text.find("project model").unwrap() + "project ".len(), 5)
} else {
(
text.find("oa_activity pump").unwrap() + "oa_activity ".len(),
4,
)
};
let cursor = selection(text, alias, alias + length, false);
app.source_editor.move_to(cursor);
// A real jump creates useful current Back history before the next request.
let path = app.source_workspace.active.clone();
app.source_symbol_jump(
&path,
query::Position {
line: cursor.position.line,
byte_column: cursor.position.column,
},
query::Position {
line: cursor.selection.unwrap().line,
byte_column: cursor.selection.unwrap().column,
},
);
reveal(&mut app);
let current = begin(&mut app, query::Action::References);
let ready = completion(&app, current.clone());
app.queue_source_reveal(cursor, false);
assert_stale_envelopes_preserve_current(&mut app, &old_messages);
send(&mut app, Message::SourceSymbolsReady(ready));
assert_eq!(
app.source_symbols.results.as_ref().unwrap().stamp,
current.stamp
);
assert!(app.source_symbols.in_flight.is_none());
// Replay once more with no busy-slot fallback and visible current results.
assert_stale_envelopes_preserve_current(&mut app, &old_messages);
let interaction = app.source_symbols.interaction;
symbol(&mut app, nav::SymbolEvent::Close);
assert_ne!(app.source_symbols.interaction, interaction);
assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some());
let next = begin(&mut app, query::Action::References);
assert_ne!(
next.job, current.job,
"a freshly wrapped current-context Query remains usable"
);
let ready = completion(&app, next);
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.results.is_some());
}
#[test]
fn old_symbol_widget_envelopes_after_reset_preserve_new_same_path_intent() {
stale_envelope_trial(true);
}
#[test]
fn old_symbol_widget_envelopes_after_file_switch_preserve_same_session_intent() {
stale_envelope_trial(false);
}
#[test]
#[ignore = "manual 100-module latency/process-memory trial; run alone with --ignored --nocapture --test-threads=1"]
fn source_symbols_100_modules_1000_definitions_10000_uses() {
let mut files = BTreeMap::new();
let mut entry = "arcadia 7; project performance \"Current text trial\" {\n".to_owned();
for module in 0..100 {
let path = format!("modules/m{module:03}.arcadia");
entry.push_str(&format!(" include \"{path}\";\n"));
let mut text = String::new();
for declaration in 0..10 {
text.push_str(&format!("oa_process p_{module}_{declaration} \"Process {module}/{declaration} 雨\" {{ steps ["));
for reference in 0..10 {
if reference != 0 {
text.push_str(", ");
}
text.push_str("@p_0_0");
}
text.push_str("]; description \"@p_0_0 is literal documentation\"; }\n");
}
files.insert(path, text);
}
entry.push_str("}\n");
files.insert("project.arcadia".into(), entry);
let original = files.clone();
let input_bytes: usize = files.values().map(String::len).sum();
let drafts: Vec<_> = files
.iter()
.map(|(path, text)| query::DraftFile { path, text })
.collect();
let key = query::SnapshotKey {
workspace: Id::new_v4(),
revision: 1,
};
let memory_before = process_memory();
let started = Instant::now();
let owned = query::admit_owned(
query::Input {
key,
entry: "project.arcadia",
files: &drafts,
applied: false,
validation: None,
},
query::Limits::default(),
)
.unwrap();
let admitted = started.elapsed();
let started = Instant::now();
let snapshot = owned.capture().unwrap();
let captured = started.elapsed();
let path = "modules/m000.arcadia";
let offset = files[path].find("p_0_0").unwrap();
let started = Instant::now();
let result = snapshot
.query(query::Query {
stamp: query::QueryStamp {
snapshot: key,
serial: 1,
},
path,
byte_offset: offset,
action: query::Action::References,
})
.unwrap();
let queried = started.elapsed();
let memory_after = process_memory();
assert_eq!(files.len(), 101, "100 modules plus entry");
assert_eq!(
files
.values()
.map(|text| text.matches("oa_process ").count())
.sum::<usize>(),
1000
);
assert_eq!(result.total_definitions, 1);
assert_eq!(result.total_references, 10_000);
assert_eq!(result.references.len() + result.omitted_references, 10_000);
assert!(
result.definitions.len() + result.references.len() <= query::Limits::default().result_rows
);
assert!(result.scope.closure_complete && !result.scope.validated);
assert_eq!(files, original);
eprintln!(
"100-module current-draft trial: bytes={input_bytes}, admission={admitted:?}, capture={captured:?}, references={queried:?}, rows={}, omitted={}, process_memory_before={memory_before:?}, process_memory_after={memory_after:?}",
result.references.len(),
result.omitted_references
);
// This fixture intentionally has process-to-process placeholder steps. It
// measures lexical draft navigation, not engineering validity or GUI latency.
}
#[cfg(not(windows))]
fn process_memory() -> Option<(usize, usize)> {
None
}
#[cfg(windows)]
fn process_memory() -> Option<(usize, usize)> {
#[repr(C)]
#[derive(Default)]
struct Counters {
cb: u32,
page_fault_count: u32,
peak_working_set: usize,
working_set: usize,
quota_peak_paged_pool: usize,
quota_paged_pool: usize,
quota_peak_non_paged_pool: usize,
quota_non_paged_pool: usize,
pagefile_usage: usize,
peak_pagefile_usage: usize,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetCurrentProcess() -> *mut std::ffi::c_void;
fn K32GetProcessMemoryInfo(
process: *mut std::ffi::c_void,
counters: *mut Counters,
size: u32,
) -> i32;
}
let size = u32::try_from(std::mem::size_of::<Counters>()).unwrap();
let mut counters = Counters {
cb: size,
..Counters::default()
};
// Windows writes one correctly sized PROCESS_MEMORY_COUNTERS for this
// process. These are process-wide resident/peak bytes, not exact allocations.
let ok = unsafe { K32GetProcessMemoryInfo(GetCurrentProcess(), &mut counters, size) };
(ok != 0).then_some((counters.working_set, counters.peak_working_set))
}
#[test]
fn delayed_definition_after_hide_or_tab_change_retains_data_without_reopening_or_focus() {
for destination in [None, Some(DrawerTab::Problems), Some(DrawerTab::Status)] {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
assert_eq!(expected.disposition, query::Disposition::Direct);
let event = destination.map_or(TextEvent::HideDrawer, TextEvent::ShowDrawer);
send(&mut app, Message::TextWorkspace(event));
assert_eq!(app.text_workspace.drawer(), destination);
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
assert_eq!(app.source_symbols.in_flight, Some(request.job));
let visit = app.text_workspace.drawer_visit();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert_eq!(app.text_workspace.drawer(), destination);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
send(
&mut app,
Message::TextWorkspace(TextEvent::ShowDrawer(DrawerTab::References)),
);
assert!(references_visible(&app));
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.in_flight.is_none() && app.source_symbols.request.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_editor.cursor(), cursor);
}
}
#[test]
fn same_worker_with_changed_request_envelope_cannot_publish_or_navigate() {
for changed in 0..7 {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let mut ready = completion(&app, request.clone());
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
match changed {
0 => ready.request.stamp.serial += 1,
1 => ready.request.path = DEFINITIONS.into(),
2 => ready.request.offset += 1,
3 => ready.request.cursor.selection = None,
4 => ready.request.interaction = Id::new_v4(),
5 => ready.request.raw_interaction += 1,
_ => ready.request.action = query::Action::References,
}
assert_eq!(ready.request.job, request.job);
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(
app.source_symbols.in_flight.is_none(),
"worker completed in case {changed}"
);
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn matching_old_worker_cannot_replace_a_newer_display_owner() {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let old = begin(&mut app, query::Action::Definition);
let ready = completion(&app, old.clone());
let mut latest = old.clone();
latest.stamp.serial += 1;
latest.job = Id::new_v4();
// Isolate display ownership from both worker ownership and navigation intent.
app.source_interaction();
app.source_symbols.display_request = Some(latest.clone());
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&latest));
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn hidden_completion_checks_exact_bytes_even_when_snapshot_key_was_not_advanced() {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let accepted = app.document.snapshot();
send(&mut app, Message::TextWorkspace(TextEvent::HideDrawer));
// Deliberately violate the normal edit API's key advancement: the exact
// source witness must still reject stale bytes instead of trusting the key.
app.source_workspace
.files
.get_mut(USES)
.unwrap()
.text
.push_str("\r\n// changed bytes");
assert_eq!(app.source_key(), request.stamp.snapshot);
let changed = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(app.source_symbols.results.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.bundle(), changed);
assert_eq!(app.source_editor.cursor(), cursor);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn popup_escape_has_no_duplicate_bridge_and_dismissal_preserves_underlying_find_and_drawer() {
for line in [false, true] {
for captured_find_escape in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let results = finish(&mut app, query::Action::References);
let selected = select_effective_pump(&mut app, line);
let cursor = app.source_editor.cursor();
let group = TextEvent::ToggleFile {
stamp: results.stamp,
visit: app.text_workspace.drawer_visit(),
path: USES.into(),
};
send(&mut app, Message::TextWorkspace(group));
let visit = app.text_workspace.drawer_visit();
send(
&mut app,
Message::TextWorkspace(TextEvent::ReferenceScrolled {
stamp: results.stamp,
visit,
y: 96.,
}),
);
assert!(app.text_workspace.references().collapsed(USES));
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
app.source_navigation.query = "Retain Find 雨".into();
let open = app.text_menu_message(iced::Point::new(30., 30.));
send(&mut app, open);
let menu = app.text_workspace_menu().expect("Text popup opened");
assert!(
captured_input_subscription_messages(&app).is_empty(),
"Popup owns captured Escape; a bridge Escape would also close its underlying UI"
);
// The popup itself publishes this tokened dismissal on Escape.
send(&mut app, menu.on_close);
assert!(app.text_workspace.menu().is_none());
assert!(references_visible(&app) && app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
let mut next = input_subscription_messages(&app, captured_find_escape);
assert_eq!(
next.len(),
1,
"exactly one message for either Escape status"
);
assert!(matches!(next[0], Message::Escape));
send(&mut app, next.remove(0));
assert!(references_visible(&app) && !app.source_navigation.open);
assert_eq!(
app.text_workspace.drawer_visit(),
visit,
"closing Find does not replace the drawer visit"
);
assert_eq!(app.source_navigation.query, "Retain Find 雨");
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert!(app.text_workspace.references().collapsed(USES));
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
assert!(app.source_reveal_ticket().is_some() && app.source_jump.is_none());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
let mut last = input_subscription_messages(&app, false);
assert_eq!(last.len(), 1);
assert!(matches!(last[0], Message::Escape));
send(&mut app, last.remove(0));
assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open);
assert_ne!(app.text_workspace.drawer_visit(), visit);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert!(app.text_workspace.references().collapsed(USES));
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
assert_eq!(app.source_navigation.query, "Retain Find 雨");
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
assert!(captured_input_subscription_messages(&app).is_empty());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(app.source_symbols.back.is_empty());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
#[test]
fn retained_choices_cannot_replay_after_hide_or_references_problems_references() {
for reference in [false, true] {
for hide in [false, true] {
let mut app = fixture();
let results = finish(&mut app, query::Action::References);
let index = usize::from(reference);
let old = choice(&app, results.stamp, reference, index);
let old_visit = app.text_workspace.drawer_visit();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let event = if hide {
TextEvent::HideDrawer
} else {
TextEvent::ShowDrawer(DrawerTab::Problems)
};
send(&mut app, Message::TextWorkspace(event));
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert_stale_envelopes_preserve_current(&mut app, std::slice::from_ref(&old));
send(
&mut app,
Message::TextWorkspace(TextEvent::ShowDrawer(DrawerTab::References)),
);
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
assert_eq!(
app.source_key(),
results.stamp.snapshot,
"same source isolates the visit guard"
);
assert_stale_envelopes_preserve_current(&mut app, &[old]);
let fresh = choice(&app, results.stamp, reference, index);
send(&mut app, fresh);
assert!(
app.source_symbols.reveal.is_some(),
"freshly captured reopened choice remains usable"
);
assert_eq!(app.source_symbols.back.len(), 1);
reveal(&mut app);
let (path, span) = if reference {
let row = &results.references[index];
(&row.path, row.span)
} else {
let row = &results.definitions[index];
(&row.path, row.span)
};
assert_eq!(&app.source_workspace.active, path);
assert_eq!(
app.source_editor.cursor(),
selection(&raw.files[path], span.start, span.end, false)
);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}