2036 lines
80 KiB
Rust
2036 lines
80 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;
|
|
|
|
mod departure_selection;
|
|
|
|
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
|
|
}
|
|
|
|
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 close_drawer(app: &mut App) {
|
|
let message = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
|
|
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));
|
|
close_drawer(&mut app);
|
|
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()));
|
|
}
|
|
let results = app.source_symbols.results.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);
|
|
match (&results, &app.source_symbols.results) {
|
|
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
|
(None, None) => {}
|
|
_ => panic!("Find Escape must retain underlying results"),
|
|
}
|
|
|
|
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);
|
|
close_drawer(&mut app);
|
|
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. Require the canvas keyboard
|
|
/// notification separately, then return every command routed to an Escape owner.
|
|
fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
|
input_subscription_messages(app, true)
|
|
}
|
|
|
|
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
|
|
crate::app::escape::tests::input_messages(app, iced::keyboard::key::Named::Escape, focused)
|
|
}
|
|
|
|
#[test]
|
|
fn captured_find_and_modal_escape_use_one_actual_delivery_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);
|
|
match (&results, &app.source_symbols.results) {
|
|
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
|
(None, None) => {}
|
|
_ => panic!("Find Escape must retain underlying results"),
|
|
}
|
|
|
|
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_delivery_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),
|
|
Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())),
|
|
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;
|
|
close_drawer(&mut app);
|
|
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(app.text_drawer_context()), |tab| {
|
|
TextEvent::ShowDrawer(app.text_drawer_context(), tab)
|
|
});
|
|
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());
|
|
{
|
|
let captured = Message::TextWorkspace(TextEvent::ShowDrawer(
|
|
app.text_drawer_context(),
|
|
DrawerTab::References,
|
|
));
|
|
send(&mut app, captured);
|
|
};
|
|
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();
|
|
{
|
|
let captured = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
|
|
send(&mut app, captured);
|
|
};
|
|
// 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(app.text_drawer_context())
|
|
} else {
|
|
TextEvent::ShowDrawer(app.text_drawer_context(), 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));
|
|
{
|
|
let captured = Message::TextWorkspace(TextEvent::ShowDrawer(
|
|
app.text_drawer_context(),
|
|
DrawerTab::References,
|
|
));
|
|
send(&mut app, captured);
|
|
};
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn combined_modal_completion_releases_worker_without_navigation_or_model_changes() {
|
|
for modal in 0..5 {
|
|
let mut app = fixture();
|
|
let request = begin(&mut app, query::Action::Definition);
|
|
let ready = completion(&app, request);
|
|
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();
|
|
match modal {
|
|
0 => app.busy = true,
|
|
1 => app.hosted.visible = true,
|
|
2 => app.pending = Some(Pending::Walkthrough),
|
|
3 => {
|
|
app.file_prompt = Some(FilePrompt {
|
|
operation: FileOperation::Save,
|
|
path: "unchanged.arcadia".into(),
|
|
overwrite: false,
|
|
});
|
|
}
|
|
4 => {
|
|
let project = app.project().clone();
|
|
assert!(
|
|
app.behavior
|
|
.creation
|
|
.update(&project, crate::behavior::creation::Event::Open(Layer::Oa))
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
assert!(app.behavior.has_pending());
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
assert!(!app.source_navigation_available());
|
|
send(&mut app, Message::SourceSymbolsReady(ready));
|
|
assert!(app.source_symbols.in_flight.is_none(), "modal {modal}");
|
|
assert!(app.source_symbols.request.is_none());
|
|
// Current-source display data may finish behind a modal; it grants no navigation.
|
|
assert!(Arc::ptr_eq(
|
|
app.source_symbols.results.as_ref().unwrap(),
|
|
&expected
|
|
));
|
|
assert!(app.source_symbols.back.is_empty());
|
|
assert!(app.source_reveal_ticket().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()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconciliation() {
|
|
let mut project = Project::empty();
|
|
let record = project
|
|
.add_element(Kind::DataType, Layer::Oa, "Record", None)
|
|
.unwrap();
|
|
let Some(SharedBody::DataType { definition, .. }) =
|
|
project.elements.get_mut(&record).unwrap().shared_mut()
|
|
else {
|
|
panic!("record datatype fixture");
|
|
};
|
|
*definition = DataTypeDefinition::Record { supertype: None };
|
|
let mut app = App::with_project(project);
|
|
send(&mut app, Message::Select(record));
|
|
let path = "/body/Oa/Shared/DataType/definition/Record/supertype";
|
|
let open = app.draft.as_ref().unwrap().reference_open(
|
|
app.project(),
|
|
path,
|
|
iced::Rectangle {
|
|
x: 100.,
|
|
y: 100.,
|
|
width: 240.,
|
|
height: 32.,
|
|
},
|
|
);
|
|
let alias = app.project().elements[&record].alias.clone();
|
|
let source_path = app
|
|
.source_workspace
|
|
.files
|
|
.iter()
|
|
.find(|(_, buffer)| buffer.text.contains(&format!(" {alias} ")))
|
|
.map(|(path, _)| path.clone())
|
|
.unwrap();
|
|
app.page = Page::Text;
|
|
show(&mut app, &source_path);
|
|
let text = app.source_workspace.text();
|
|
let offset = text.find(&format!(" {alias} ")).unwrap() + 1;
|
|
app.source_editor
|
|
.move_to(selection(text, offset, offset + alias.len(), false));
|
|
let request = begin(&mut app, query::Action::Definition);
|
|
let ready = completion(&app, request);
|
|
let expected = ready.result.as_ref().unwrap().1.clone();
|
|
app.page = Page::Diagram;
|
|
send(&mut app, Message::FieldReferenceOpen(open));
|
|
assert!(app.reference_input_active(), "{}", app.status);
|
|
// Reproduce a queued completion observing a retained chooser before normal
|
|
// reconciliation catches the changed page. Neither may authorize navigation.
|
|
app.page = Page::Text;
|
|
assert!(!app.source_navigation_available());
|
|
let accepted = app.document.snapshot();
|
|
let raw = app.source_workspace.bundle();
|
|
let cursor = app.source_editor.cursor();
|
|
send(&mut app, Message::SourceSymbolsReady(ready));
|
|
assert!(app.source_symbols.in_flight.is_none());
|
|
// Current-source display data may finish behind a modal; it grants no navigation.
|
|
assert!(Arc::ptr_eq(
|
|
app.source_symbols.results.as_ref().unwrap(),
|
|
&expected
|
|
));
|
|
assert!(app.source_reveal_ticket().is_none());
|
|
assert!(
|
|
app.reference_input_active(),
|
|
"internal completion must precede chooser reconciliation and modal dispatch"
|
|
);
|
|
assert_eq!(app.source_editor.cursor(), cursor);
|
|
assert_eq!(app.source_workspace.bundle(), raw);
|
|
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
|
}
|
|
|
|
#[test]
|
|
fn same_key_changed_bytes_mark_retained_rows_stale_before_choice_boundaries() {
|
|
for path in [USES, DEFINITIONS] {
|
|
let mut app = fixture();
|
|
let results = finish(&mut app, query::Action::References);
|
|
assert!(app.source_results_current());
|
|
#[cfg(debug_assertions)]
|
|
assert!(!crate::views::captured_source_reference_stale_summary(&app));
|
|
let captured = choice(&app, results.stamp, true, 0);
|
|
let key = app.source_key();
|
|
// Isolate byte authority from revision authority in both the displayed
|
|
// and an inactive buffer. Views share this exact currentness decision.
|
|
app.source_workspace
|
|
.files
|
|
.get_mut(path)
|
|
.unwrap()
|
|
.text
|
|
.push_str("\r\n// exact bytes changed");
|
|
assert_eq!(app.source_key(), key);
|
|
assert!(!app.source_results_current());
|
|
#[cfg(debug_assertions)]
|
|
assert!(crate::views::captured_source_reference_stale_summary(&app));
|
|
app.queue_source_reveal(app.source_editor.cursor(), false);
|
|
assert!(app.source_reveal_ticket().is_some());
|
|
assert_stale_envelopes_preserve_current(&mut app, &[captured]);
|
|
}
|
|
}
|