use super::*; 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.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 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 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)); 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 { 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(); symbol( &mut app, nav::SymbolEvent::Choose { stamp: uses.stamp, reference: true, index: 1, }, ); 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 ); symbol( &mut app, nav::SymbolEvent::Choose { stamp: results.stamp, reference: false, index, }, ); 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(); symbol( &mut app, nav::SymbolEvent::Choose { stamp: results.stamp, reference: false, index, }, ); 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_rejects_file_edit_session_cursor_interaction_and_raw_input_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 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(); send(&mut app, Message::SourceSymbolsReady(ready)); assert!( app.source_symbols.in_flight.is_none(), "case {change}: release worker slot" ); assert!( app.source_symbols.results.is_none(), "case {change}: stale result surfaced" ); assert!(app.source_symbols.back.is_empty()); 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())); } } #[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_hides_panel_and_completion_does_not_reopen_it() { let mut app = fixture(); let request = begin(&mut app, query::Action::Definition); let ready = completion(&app, request.clone()); assert!(app.source_symbols.open); symbol(&mut app, nav::SymbolEvent::Close); assert!(!app.source_symbols.open); assert_eq!(app.source_symbols.in_flight, Some(request.job)); assert!(app.source_symbols.request.is_none()); send(&mut app, Message::SourceSymbolsReady(ready)); assert!(!app.source_symbols.open); assert!(app.source_symbols.in_flight.is_none()); assert!(app.source_symbols.results.is_none()); assert_eq!(app.source_workspace.active, USES); let next = begin(&mut app, query::Action::References); assert_ne!(next.job, request.job); assert!(app.source_symbols.open); } #[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_dismisses_results_and_cancels_busy_navigation_without_changing_sources() { 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()); if !pending { send(&mut app, Message::SourceSymbolsReady(ready.clone())); assert!(app.source_symbols.results.is_some()); } app.source_navigation.open = true; send(&mut app, Message::Escape); assert!(!app.source_symbols.open && !app.source_navigation.open); assert!(app.source_symbols.results.is_none()); assert!(app.source_symbols.notice.is_none()); assert!(app.source_symbols.request.is_none()); 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.source_symbols.open); assert!(app.source_symbols.results.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!(app.source_symbols.open); assert!(Arc::ptr_eq( app.source_symbols.results.as_ref().unwrap(), &results )); send(&mut app, Message::Escape); assert!(!app.source_symbols.open); assert!(app.source_symbols.results.is_none()); 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.source_symbols.open && app.source_symbols.results.is_none()); 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!(app.source_symbols.open, 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!(app.source_symbols.open && 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!(!app.source_symbols.open); 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 && !app.source_symbols.open); 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, 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, ) { 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 { 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)); tree.state .downcast_mut::>() .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), ); assert!(shell.is_event_captured()); assert!(input_messages.is_empty()); assert!( !tree .state .downcast_ref::>() .is_focused() ); let event = subscription::Event::Interaction { window: iced::window::Id::unique(), event, status: iced::event::Status::Captured, }; 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(); app.source_navigation.open = true; 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!(app.source_symbols.open && app.source_navigation.open); 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!(!app.source_symbols.open && !app.source_navigation.open); assert!(app.source_symbols.results.is_none() && app.source_symbols.request.is_none()); 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.source_symbols.open && app.source_symbols.results.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.source_symbols.open = true; 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.source_symbols.open = true; 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.source_symbols.open, app.source_symbols.notice.clone()); 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::>(), back ); assert_eq!( (app.source_symbols.open, app.source_symbols.notice.clone()), panel ); 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), app.source_symbol_message(nav::SymbolEvent::Choose { stamp: old_results.stamp, reference: false, index: 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.source_symbols.open && app.source_symbols.results.is_none()); 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::(), 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::()).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 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 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()); assert!(app.source_symbols.results.is_none()); 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); 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()); assert!(app.source_symbols.results.is_none()); 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())); }