use super::*; use oa_core::source_project::SourceBundle; use std::collections::BTreeMap; #[path = "navigation_union_tests.rs"] mod navigation_union_tests; #[cfg(debug_assertions)] #[path = "preview_glyph_tests.rs"] mod preview_glyph_tests; #[cfg(debug_assertions)] #[path = "preview_scroll_tests.rs"] mod preview_scroll_tests; fn bundle() -> SourceBundle { SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ ("project.arcadia".into(), concat!("arcadia 7;\r\nproject model \"ๆ•‘ๆด\" id \"00000000-0000-0000-0000-000000000001\" {\r\n", " include \"defs.arcadia\"; include \"uses.arcadia\";\r\n}\r\n").into()), ("defs.arcadia".into(), concat!("oa_activity pump \"้€ๆฐด ๐Ÿš€\" id \"00000000-0000-0000-0000-000000000002\" {}\r\n", "oa_activity backup \"้€ๆฐด ๐Ÿš€\" id \"00000000-0000-0000-0000-000000000004\" {}\r\n").into()), ("uses.arcadia".into(), concat!("// keep @pump and Unicode ้›จ\r\n", "interaction first \"ๅ…ˆ\" id \"00000000-0000-0000-0000-000000000003\" { description \"literal @pump\"; source @pump; target @backup; } ", "interaction second \"ๅพŒ\" id \"00000000-0000-0000-0000-000000000005\" { source @ // keep @pump\r\n pump; target @backup; }\r\n").into()), ]) } } fn fixture() -> App { let mut app = App::with_project(Project::empty()); app.document = Document::from_bundle(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.source_workspace.active = "uses.arcadia".into(); app.show_source_file(false); let start = app.source_workspace.text().rfind("pump;").unwrap(); let cursor = cursor(app.source_workspace.text(), start, start + 4); app.source_editor.move_to(cursor); app } fn cursor(text: &str, start: usize, end: usize) -> text_editor::Cursor { let pos = |offset| { let prefix = &text[..offset]; text_editor::Position { line: prefix.bytes().filter(|b| *b == b'\n').count(), column: prefix .rsplit_once('\n') .map_or(prefix.len(), |(_, tail)| tail.len()), } }; text_editor::Cursor { position: pos(start), selection: Some(pos(end)), } } fn send(app: &mut App, message: Message) { // Drop unpolled Tasks; deterministic completion still travels through the // real update route. These tests never launch an application or file worker. let _ = app.update(message); } fn event(app: &mut App, event: Event) { send(app, Message::SourceRename(event)); } fn current_job(app: &App) -> Job { let form = app.source_rename.form.as_ref().unwrap(); Job { id: form.pending.unwrap(), invocation: form.invocation, generation: form.generation, context: form.context.clone(), } } fn target_completion(app: &App, job: Job) -> Completion { let snapshot = app .with_source_input(|input| query::capture(input, query::Limits::default())) .unwrap(); let offset = nav::cursor_offset( &job.context.accepted.bundle.files[&job.context.begin.path], job.context.begin.cursor, ) .unwrap(); let result = resolve_target(&snapshot, &job.context, offset).map(Arc::new); Completion { job, payload: Payload::Target(result), } } fn begin(app: &mut App) { let message = app.source_rename_message(); send(app, message); let ready = target_completion(app, current_job(app)); send(app, Message::SourceRenameReady(ready)); assert!(app.source_rename.form.as_ref().unwrap().target.is_some()); } fn input(app: &mut App, replacement: &str) { let id = app.source_rename.form.as_ref().unwrap().invocation; event(app, Event::Input(id, replacement.into())); } fn preview_completion(app: &App, job: Job) -> Completion { let form = app.source_rename.form.as_ref().unwrap(); let target = form.target.as_ref().unwrap(); let result = app .document .alias_rename_request(target.id, form.replacement.clone()) .unwrap() .prepare() .map_err(|error| error.to_string()) .and_then(|prepared| prepare_approval(prepared, target, &form.context.begin.path)) .map(Arc::new); Completion { job, payload: Payload::Preview(result), } } fn start_preview(app: &mut App) -> Job { let form = app.source_rename.form.as_ref().unwrap(); let message = Event::Preview(form.invocation, form.generation); event(app, message); current_job(app) } fn preview(app: &mut App) -> Message { let job = start_preview(app); let completion = preview_completion(app, job); send(app, Message::SourceRenameReady(completion)); let form = app.source_rename.form.as_ref().unwrap(); Message::SourceRename(Event::Confirm( form.invocation, form.generation, form.approval.as_ref().unwrap().id, )) } #[derive(Debug, PartialEq)] struct Evidence { project: Project, source: SourceBundle, drafts: SourceBundle, revision: u64, workspace: query::SnapshotKey, cursor: text_editor::Cursor, undo: bool, redo: bool, dirty: bool, } fn evidence(app: &App) -> Evidence { Evidence { project: app.project().clone(), source: app.document.bundle().clone(), drafts: app.source_workspace.bundle(), revision: app.document.revision(), workspace: app.source_key(), cursor: app.source_editor.cursor(), undo: app.document.can_undo(), redo: app.document.can_redo(), dirty: app.document.dirty(), } } fn assert_accepted_display(app: &App, accepted: &SourceBundle) { let active = &accepted.files[&app.source_workspace.active]; assert_eq!( app.source_editor.text(), *active, "displayed accepted source" ); assert_eq!( app.source_snapshot.as_ref(), active.as_str(), "snapshot for highlighting" ); assert_eq!(app.source_workspace.text(), active, "active workspace text"); assert_eq!( app.source_workspace.bundle(), *accepted, "all accepted workspace files" ); assert!( !app.source_workspace.dirty(), "display synchronization creates no source draft" ); } #[test] fn complete_alias_change_preserves_literals_and_last_comment_separated_origin_and_undo() { let mut app = fixture(); let original = evidence(&app); let mut expected_model = original.project.clone(); let id = Id::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); expected_model.elements.get_mut(&id).unwrap().alias = "send_water".into(); let mut expected_source = original.source.clone(); expected_source .files .get_mut("defs.arcadia") .unwrap() .replace_range(12..16, "send_water"); let uses = expected_source.files.get_mut("uses.arcadia").unwrap(); *uses = uses .replace("source @pump;", "source @send_water;") .replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;"); begin(&mut app); input(&mut app, "send_water"); let confirm = preview(&mut app); assert_eq!(evidence(&app), original, "preview is not Apply"); send(&mut app, confirm.clone()); assert!(!app.source_rename.open()); assert_eq!(app.project(), &expected_model); assert_eq!(app.document.bundle(), &expected_source); assert_accepted_display(&app, &expected_source); let text = &expected_source.files["uses.arcadia"]; let start = text.rfind("send_water;").unwrap(); assert_eq!( app.source_editor.cursor(), cursor(text, start, start + "send_water".len()) ); let accepted = evidence(&app); send(&mut app, confirm); assert_eq!(evidence(&app), accepted, "repeated approval has no effect"); // A plain caret movement is not a newer model operation. send( &mut app, Message::SourceEdit(text_editor::Action::Move(text_editor::Motion::Home)), ); let undo = app .source_rename_undo_message() .expect("caret move retains Undo rename"); send(&mut app, undo); assert_eq!(app.document.bundle(), &original.source); assert_accepted_display(&app, &original.source); assert_eq!(app.project(), &original.project); send(&mut app, Message::Redo); assert_eq!(app.document.bundle(), &expected_source); assert_accepted_display(&app, &expected_source); assert_eq!(app.project(), &expected_model); } #[test] fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_history() { for mismatch in ["none", "path", "before", "after"] { let mut app = fixture(); let original_project = app.project().clone(); let original_source = app.document.bundle().clone(); begin(&mut app); input(&mut app, "send_water"); let confirm = preview(&mut app); send(&mut app, confirm); let renamed_project = app.project().clone(); let renamed_source = app.document.bundle().clone(); assert_accepted_display(&app, &renamed_source); // Undo the actual accepted transaction at the Document boundary, leaving // the display/workspace on the newer source until the common sync runs. assert!(app.document.undo()); assert_eq!(app.project(), &original_project); assert_eq!(app.document.bundle(), &original_source); assert!(!app.document.can_undo()); assert!(app.document.can_redo()); let revision = app.document.revision(); let dirty = app.document.dirty(); let workspace_key = app.source_key(); let path = app.source_workspace.active.clone(); let before = app.source_editor.text(); let after = original_source.files[&path].clone(); assert_ne!(before, after, "fallback must visibly replace stale text"); // Observable stale derived state makes a skipped sync fail independently // of the editor byte oracle. None of this changes Document authority. let selected = Id::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); app.selected = selected; app.draft = None; app.source_snapshot = Arc::from("stale snapshot"); app.source_select_line = true; app.source_jump = Some(cursor(&before, 0, 1)); app.source_navigation.query = "send_water".into(); app.source_navigation.refresh(&before); assert_ne!(app.source_navigation.label(), "0 matches"); let mut invalid = original_source.clone(); invalid.files.insert(path.clone(), "?".into()); app.publish_source_diagnostics(source_workspace::lint(&invalid)); assert!(!app.source_diagnostics.is_empty()); let version = app.source_version; app.lint_requested = Some(version); app.model_issues = vec![oa_core::validation::Issue { severity: oa_core::validation::Severity::Error, element: None, code: "stale-test-issue".into(), field: None, message: "Must be replaced by accepted-model validation".into(), }]; let hint = match mismatch { "none" => None, "path" => Some(EditorRefresh { path: "different.arcadia", before: &before, after: &after, }), "before" => Some(EditorRefresh { path: &path, before: "not the displayed source", after: &after, }), "after" => Some(EditorRefresh { path: &path, before: &before, after: &before, }), _ => unreachable!(), }; if mismatch == "none" { app.sync(); } else { app.sync_with_alias_editor(hint); } assert_accepted_display(&app, &original_source); assert_eq!(app.project(), &original_project, "{mismatch}"); assert_eq!(app.document.bundle(), &original_source); assert_eq!( app.document.revision(), revision, "display sync adds no model operation" ); assert_eq!(app.document.dirty(), dirty); assert!(!app.document.can_undo()); assert!(app.document.can_redo()); assert_ne!( app.source_key(), workspace_key, "workspace adopts accepted source" ); assert_eq!(app.source_version, version + 1); assert_eq!(app.lint_requested, None); assert_eq!(app.source_diagnostics, app.document.compiled().diagnostics); assert_eq!(app.source_navigation.label(), "0 matches"); assert!(!app.source_select_line); assert_eq!(app.source_jump, None); let draft = app .draft .as_ref() .expect("accepted property draft refreshed"); assert_eq!(draft.original, selected); assert_eq!(draft.value["alias"], "pump"); assert!(!draft.dirty()); assert_eq!( serde_json::to_value(&app.model_issues).unwrap(), serde_json::to_value(validate(&original_project)).unwrap(), ); // A display fallback must not consume, replace or append model history. send(&mut app, Message::Redo); assert_eq!(app.project(), &renamed_project); assert_eq!(app.document.bundle(), &renamed_source); assert_accepted_display(&app, &renamed_source); assert!(!app.document.can_redo()); send(&mut app, Message::Undo); assert_eq!(app.project(), &original_project); assert_eq!(app.document.bundle(), &original_source); assert_accepted_display(&app, &original_source); assert!(!app.document.can_undo()); } } #[test] fn stale_dialog_envelopes_do_not_cancel_new_same_path_form_or_its_approval() { let mut app = fixture(); begin(&mut app); input(&mut app, "send_water"); let old_confirm = preview(&mut app); let old_id = app.source_rename.form.as_ref().unwrap().invocation; event(&mut app, Event::Close(old_id)); begin(&mut app); input(&mut app, "current_water"); let _ = preview(&mut app); let form = app.source_rename.form.as_ref().unwrap(); let current_id = form.invocation; let approval_id = form.approval.as_ref().unwrap().id; let interaction = app.source_symbols.interaction; let before = evidence(&app); for stale in [ old_confirm, Message::SourceRename(Event::Close(old_id)), Message::SourceRename(Event::Input(old_id, "stale".into())), ] { send(&mut app, stale); let form = app.source_rename.form.as_ref().unwrap(); assert_eq!(form.invocation, current_id); assert_eq!(form.replacement, "current_water"); assert_eq!(form.approval.as_ref().unwrap().id, approval_id); assert_eq!(app.source_symbols.interaction, interaction); assert_eq!(evidence(&app), before); } } #[test] fn close_and_reset_retain_worker_slot_until_exact_completion_without_reopening() { let mut app = fixture(); begin(&mut app); input(&mut app, "send_water"); let job = start_preview(&mut app); let ready = preview_completion(&app, job.clone()); event(&mut app, Event::Close(job.invocation)); assert_eq!(app.source_rename.in_flight, Some(job.id)); let project = app.project().clone(); app.reset(project, None); assert_eq!(app.source_rename.in_flight, Some(job.id)); let mut impostor = ready.clone(); impostor.job.id = Id::new_v4(); send(&mut app, Message::SourceRenameReady(impostor)); assert_eq!(app.source_rename.in_flight, Some(job.id)); send(&mut app, Message::SourceRenameReady(ready)); assert!(app.source_rename.in_flight.is_none()); assert!(!app.source_rename.open()); } #[test] fn replacement_generation_aba_and_modal_shortcuts_cannot_commit_stale_preview() { let mut app = fixture(); begin(&mut app); input(&mut app, "send_water"); let stale = preview(&mut app); let before = evidence(&app); input(&mut app, "other_water"); input(&mut app, "send_water"); send(&mut app, stale); assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); for message in [ Message::Save(false), Message::Undo, Message::Page(Page::Guide), Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), Message::SourceNavigation(nav::Event::Open(false)), ] { send(&mut app, message); assert_eq!(evidence(&app), before); assert!(app.source_rename.open()); } send(&mut app, Message::Escape); assert!(!app.source_rename.open()); assert_eq!(evidence(&app), before); } #[test] fn property_draft_and_detached_buffer_refuse_without_hidden_apply() { let mut app = fixture(); send( &mut app, Message::Field("/name".into(), "Retain property draft".into()), ); assert!(app.draft_dirty()); let before = evidence(&app); let message = app.source_rename_message(); send(&mut app, message); assert!(!app.source_rename.open()); assert!(app.draft_dirty()); assert_eq!( app.draft.as_ref().unwrap().inputs["/name"].raw, "Retain property draft" ); assert_eq!(evidence(&app), before); let mut app = fixture(); let mut detached = app.document.bundle().clone(); detached .files .insert("orphan.arcadia".into(), "// retained orphan\n".into()); app.source_workspace = Workspace::new(&detached, true); app.source_workspace .set_reachable(app.document.bundle().files.keys().cloned().collect()); app.show_source_file(false); let before = evidence(&app); let message = app.source_rename_message(); send(&mut app, message); assert!(!app.source_rename.open()); assert_eq!(evidence(&app), before); } #[test] fn same_alias_invalid_and_cancel_keep_existing_redo_and_source_selection() { let mut app = fixture(); begin(&mut app); input(&mut app, "send_water"); let confirm = preview(&mut app); send(&mut app, confirm); send(&mut app, Message::Undo); let start = app.source_workspace.text().rfind("pump;").unwrap(); app.source_editor .move_to(cursor(app.source_workspace.text(), start, start + 4)); assert!(app.document.can_redo()); let before = evidence(&app); begin(&mut app); for replacement in ["pump", " bad alias", "true"] { input(&mut app, replacement); let form = app.source_rename.form.as_ref().unwrap(); let message = Event::Preview(form.invocation, form.generation); event(&mut app, message); assert!(app.source_rename.in_flight.is_none()); assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); assert_eq!(evidence(&app), before); } let invocation = app.source_rename.form.as_ref().unwrap().invocation; event(&mut app, Event::Close(invocation)); assert_eq!(evidence(&app), before); send(&mut app, Message::Redo); assert!(app.document.bundle().files["defs.arcadia"].contains("oa_activity send_water ")); } #[test] fn source_edit_undo_and_recovery_invalidation_do_not_revive_approval() { let mut app = fixture(); begin(&mut app); input(&mut app, "send_water"); let old = preview(&mut app); let before = app.document.bundle().clone(); let text = app.source_workspace.text().to_owned(); let cursor = app.source_editor.cursor(); app.source_workspace .edit(format!("{text}// temporary\n"), cursor); app.source_workspace.undo(false); assert_eq!(app.source_workspace.text(), text); send(&mut app, old); assert!(app.source_rename.form.as_ref().unwrap().stale); assert_eq!(app.document.bundle(), &before); let invocation = app.source_rename.form.as_ref().unwrap().invocation; event(&mut app, Event::Close(invocation)); begin(&mut app); input(&mut app, "send_water"); let old = preview(&mut app); let target = PathBuf::from("recovery/project.arcadia"); app.pending_recovery = Some(target.clone()); send( &mut app, Message::SourceRecovered(Ok(RecoverySnapshot { target, disk: Err("readback unavailable".into()), })), ); send(&mut app, old); assert!(app.source_rename.form.as_ref().unwrap().stale); assert_eq!(app.document.bundle(), &before); assert_eq!( app.source_rename.form.as_ref().unwrap().replacement, "send_water" ); } #[test] fn full_details_selection_survives_readonly_and_passive_messages_and_tracks_input() { let mut app = fixture(); begin(&mut app); let invocation = app.source_rename.form.as_ref().unwrap().invocation; event(&mut app, Event::Details(invocation, true)); event( &mut app, Event::ReadOnly(invocation, text_editor::Action::SelectAll), ); let selected = app .source_rename .form .as_ref() .unwrap() .detail_editor .cursor(); assert!(selected.selection.is_some()); let size = app.window_size; send(&mut app, Message::WindowResized(size)); let version = app.source_version; send(&mut app, Message::Linted(version, Ok(vec![]))); assert_eq!( app.source_rename .form .as_ref() .unwrap() .detail_editor .cursor(), selected ); input(&mut app, "send_water"); let form = app.source_rename.form.as_ref().unwrap(); assert_eq!( form.detail_editor.text(), preview::text_segment(&details(form), form.detail_segment).unwrap() ); assert!(details(form).contains("Replacement: send_water")); event( &mut app, Event::ReadOnly( invocation, text_editor::Action::Edit(text_editor::Edit::Insert('x')), ), ); let form = app.source_rename.form.as_ref().unwrap(); assert_eq!( form.detail_editor.text(), preview::text_segment(&details(form), form.detail_segment).unwrap() ); } fn many_uses(count: usize) -> App { uses_fixture(count, false) } fn uses_fixture(count: usize, multiline: bool) -> App { let mut source = bundle(); let mut uses = String::new(); for i in 0..count { uses.push_str(&format!( "interaction r{i} \"ๅŒๅ\" id \"{}\" {{ source @pump; target @backup; }} ", Id::from_u128(100 + i as u128) )); if multiline { uses.push_str("\r\n"); if i + 1 == count / 2 { uses.push_str(&"// unchanged context ้›จ\r\n".repeat(10)); } } } uses.push_str("\r\n"); source.files.insert("uses.arcadia".into(), uses); let mut app = fixture(); app.document = Document::from_bundle(source, LIMITS).unwrap(); app.source_workspace = Workspace::new(app.document.bundle(), true); app.selected = app.project().id; app.refresh_draft(); app.source_workspace.active = "uses.arcadia".into(); app.show_source_file(false); let start = app.source_workspace.text().rfind("@pump").unwrap() + 1; app.source_editor .move_to(cursor(app.source_workspace.text(), start, start + 4)); app } #[test] fn last_same_line_use_beyond_reference_row_limit_and_selection_boundaries() { for count in [2, 220] { for selection_kind in 0..3 { let mut app = many_uses(count); let original = evidence(&app); let mut selection = app.source_editor.cursor(); if selection_kind == 1 { std::mem::swap( &mut selection.position, selection.selection.as_mut().unwrap(), ); } else if selection_kind == 2 { selection.selection.as_mut().unwrap().column += 1; } app.source_editor.move_to(selection); let before = evidence(&app); let message = app.source_rename_message(); send(&mut app, message); let ready = target_completion(&app, current_job(&app)); send(&mut app, Message::SourceRenameReady(ready)); if selection_kind == 2 { assert!(app.source_rename.form.as_ref().unwrap().target.is_none()); assert_eq!(evidence(&app), before); continue; } input(&mut app, "send_water"); let confirm = preview(&mut app); send(&mut app, confirm); let expected = original.source.files["uses.arcadia"] .replace("source @pump;", "source @send_water;"); assert_eq!(app.document.bundle().files["uses.arcadia"], expected); let start = expected.rfind("@send_water").unwrap() + 1; assert_eq!( app.source_editor.cursor(), cursor(&expected, start, start + 10) ); } } } #[test] fn genuine_save_receipts_preserve_same_destination_preview_but_invalidate_save_as() { for relocated in [false, true] { let mut app = fixture(); let directory = tempfile::tempdir().unwrap(); let original_path = directory.path().join("original").join("project.arcadia"); let destination = if relocated { directory.path().join("relocated").join("project.arcadia") } else { original_path.clone() }; std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); app.path = Some(original_path); app.document.mark_recovered(); let receipt = write_snapshot( app.canvas_document, app.document.revision(), app.document.snapshot(), destination, None, ) .unwrap(); begin(&mut app); input(&mut app, "send_water"); let confirm = preview(&mut app); let original = app.document.bundle().clone(); send(&mut app, Message::Saved(Ok(Some(receipt)))); assert!( !app.document.dirty(), "actual saved snapshot became the live baseline" ); send(&mut app, confirm); if relocated { assert_eq!(app.document.bundle(), &original); assert!(app.source_rename.form.as_ref().unwrap().stale); } else { assert!(!app.source_rename.open()); assert!(app.document.dirty()); let undo = app.source_rename_undo_message().unwrap(); send(&mut app, undo); assert_eq!(app.document.bundle(), &original); assert!( !app.document.dirty(), "Undo uses the latest saved marker, not the preview-time marker" ); } } } #[test] fn invalid_open_retains_accepted_document_but_refuses_alias_rename() { let mut app = fixture(); let original = evidence(&app); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("project.arcadia"); let invalid = "arcadia 7;\nproject broken { ? invalid source }\n"; std::fs::write(&path, invalid).unwrap(); let disk = source_files::load_project(&path, LIMITS).unwrap(); send( &mut app, Message::Loaded(Ok(Some(LoadedSnapshot::Source(disk)))), ); assert_eq!(app.document.bundle(), &original.source); assert_eq!(app.project(), &original.project); assert!(!app.source_workspace.applied); assert_eq!(app.source_workspace.text(), invalid); let before = evidence(&app); let message = app.source_rename_message(); send(&mut app, message); assert!(!app.source_rename.open()); assert_eq!(evidence(&app), before); } fn captured_escape(app: &App) -> Vec { crate::app::escape::tests::focused_input_messages(app) } #[test] fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() { for pending in [false, true] { for other_modal in 0..4 { let mut app = fixture(); begin(&mut app); input(&mut app, "send_water"); let job = start_preview(&mut app); let ready = preview_completion(&app, job.clone()); if !pending { send(&mut app, Message::SourceRenameReady(ready.clone())); } app.source_navigation.open = true; app.text_workspace .show_drawer(Some(crate::app::text_workspace::DrawerTab::References)); match other_modal { 1 => app.hosted.visible = true, 2 => { app.file_prompt = Some(FilePrompt { operation: FileOperation::Save, path: "retained.arcadia".into(), overwrite: false, }) } 3 => app.pending = Some(Pending::Walkthrough), _ => {} } let before = evidence(&app); let mut messages = captured_escape(&app); assert_eq!(messages.len(), 1); assert!(matches!(messages[0], Message::Escape)); send(&mut app, messages.remove(0)); if other_modal != 0 { assert!(app.source_rename.open(), "existing modal has priority"); let mut messages = captured_escape(&app); assert_eq!(messages.len(), 1); assert!(matches!(messages[0], Message::Escape)); send(&mut app, messages.remove(0)); } assert!(!app.source_rename.open()); assert!( app.text_workspace.drawer().is_some() && app.source_navigation.open, "rename Escape does not fall through" ); if pending { assert_eq!(app.source_rename.in_flight, Some(job.id)); send(&mut app, Message::SourceRenameReady(ready)); assert!(!app.source_rename.open()); } assert_eq!(evidence(&app), before); app.text_workspace.show_drawer(None); app.source_navigation.open = false; assert!(captured_escape(&app).is_empty()); } } } #[test] fn paging_all_utf8_fragments_preserves_exact_preview_and_approval() { for multiline in [false, true] { check_all_preview_pages( if multiline { uses_fixture(24, true) } else { many_uses(220) }, multiline, ); } } fn check_all_preview_pages(mut app: App, multiline: bool) { begin(&mut app); input(&mut app, &"water".repeat(40)); let confirm = preview(&mut app); let original = evidence(&app); let ready = app .source_rename .form .as_ref() .unwrap() .approval .as_ref() .unwrap() .clone(); let invocation = app.source_rename.form.as_ref().unwrap().invocation; let mut row_transitions = 0; let mut hunk_transitions = 0; for file_index in 0..ready.preview.files().len() { let file = &ready.preview.files()[file_index]; for hunk_index in 0..file.hunks().len() { let hunk = &file.hunks()[hunk_index]; for page in 0..hunk.lines().len().div_ceil(LINES_PER_PAGE) { let mut reconstructed: BTreeMap = BTreeMap::new(); let segments = segment_total(app.source_rename.form.as_ref().unwrap()); for segment in 0..segments { let form = app.source_rename.form.as_ref().unwrap(); assert_eq!( (form.file, form.hunk, form.line, form.segment), (file_index, hunk_index, page * LINES_PER_PAGE, segment) ); let mut mounted = 0; for line in hunk.lines().iter().skip(form.line).take(LINES_PER_PAGE) { let before = line .before_text(&ready.prepared.before().bundle.files[file.path()]) .unwrap(); let after = line .after_text(&ready.prepared.after().bundle.files[file.path()]) .unwrap(); let a = preview::text_segment(before, segment).unwrap_or(""); let b = preview::text_segment(after, segment).unwrap_or(""); mounted += a.len() + b.len(); let pair = reconstructed.entry(line.number()).or_default(); pair.0.push_str(a); pair.1.push_str(b); } assert!(mounted <= LINES_PER_PAGE * 2 * 255); if segment + 1 < segments { event(&mut app, Event::Segment(invocation, ready.id, true)); } } for line in hunk .lines() .iter() .skip(page * LINES_PER_PAGE) .take(LINES_PER_PAGE) { let pair = &reconstructed[&line.number()]; assert_eq!( pair.0, line.before_text(&ready.prepared.before().bundle.files[file.path()]) .unwrap() ); assert_eq!( pair.1, line.after_text(&ready.prepared.after().bundle.files[file.path()]) .unwrap() ); } if page + 1 < hunk.lines().len().div_ceil(LINES_PER_PAGE) { event(&mut app, Event::Line(invocation, ready.id, true)); row_transitions += 1; } } if hunk_index + 1 < file.hunks().len() { event(&mut app, Event::Hunk(invocation, ready.id, true)); hunk_transitions += 1; } } if file_index + 1 < ready.preview.files().len() { event(&mut app, Event::File(invocation, ready.id, true)); } } if multiline { assert!( row_transitions >= 2, "both multiline hunks require row paging" ); assert!(hunk_transitions >= 1, "unchanged gap must separate hunks"); } assert_eq!(evidence(&app), original); send(&mut app, confirm); assert_eq!(app.document.bundle(), &ready.prepared.after().bundle); } #[cfg(debug_assertions)] #[test] fn actual_dialog_real_paragraph_geometry_keeps_input_preview_and_footer_reachable() { use crate::views::source_tests::FontLayout; use iced::advanced::{Layout, layout, widget}; #[derive(Default)] struct Bounds { diff_id: Option, input: Option, preview: Option, footer: Option, diff: Option, } impl widget::Operation for Bounds { fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { visit(self); } fn container(&mut self, id: Option<&widget::Id>, bounds: iced::Rectangle) { if id == Some(&widget::Id::new("source-rename-input")) { self.input = Some(bounds); } if id == Some(&widget::Id::new("source-rename-preview")) { self.preview = Some(bounds); } if id == Some(&widget::Id::new("source-rename-footer")) { self.footer = Some(bounds); } } fn scrollable( &mut self, id: Option<&widget::Id>, bounds: iced::Rectangle, _: iced::Rectangle, _: iced::Vector, _: &mut dyn widget::operation::Scrollable, ) { if id == self.diff_id.as_ref() { self.diff = Some(bounds); } } } let mut app = many_uses(12); let id = Id::from_u128(2); app.document .execute(Command::Rename(id, "้•ทใ„ๆ•‘ๆดใฎๅๅ‰ ๐Ÿš€ ".repeat(100))) .unwrap(); app.sync(); let start = app.source_workspace.text().rfind("@pump").unwrap() + 1; app.source_editor .move_to(cursor(app.source_workspace.text(), start, start + 4)); begin(&mut app); input(&mut app, &"water".repeat(100)); let _ = preview(&mut app); for (width, height) in [(1100., 700.), (580., 700.), (1440., 900.)] { app.window_size = iced::Size::new(width, height); for details in [false, true] { let invocation = app.source_rename.form.as_ref().unwrap().invocation; event(&mut app, Event::Details(invocation, details)); let exact = app .source_rename .form .as_ref() .unwrap() .detail_editor .text(); let editor = text_editor::Content::::with_text(&exact); let mut view = dialog_content(&app, &editor); let mut tree = widget::Tree::new(view.as_widget()); let host = iced::Size::new((width - 84.).min(708.), height - 120.); let node = view.as_widget_mut().layout( &mut tree, &FontLayout, &layout::Limits::new(iced::Size::ZERO, host), ); let form = app.source_rename.form.as_ref().unwrap(); let mut bounds = Bounds { diff_id: Some(preview_scroll_id(form, form.approval.as_ref().unwrap().id)), ..Bounds::default() }; view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &FontLayout, &mut bounds); let input = bounds.input.unwrap(); let body = bounds.preview.unwrap(); let footer = bounds.footer.unwrap(); assert!(input.height >= 28. && input.y >= 0.); assert!(body.height >= 80., "{width}ร—{height} body={body:?}"); assert!( footer.y >= body.y + body.height && footer.y + footer.height <= host.height + 1. ); if !details { assert!( bounds.diff.unwrap().height >= 40., "{width}ร—{height} diff={:?}", bounds.diff ); } } } } #[cfg(debug_assertions)] #[test] fn mounted_editor_f2_requires_focus_and_no_modifiers() { use crate::views::{source_binding, source_tests::FontLayout}; use iced::advanced::widget::operation::Focusable; use iced::advanced::{Layout, Shell, layout, text as shaped, widget}; use iced::keyboard::{ self, Key, Modifiers, key::{Code, Named, Physical}, }; let app = fixture(); let begin = app.source_rename_begin(); let context = app.source_symbol_context(); for focused in [false, true] { for modifiers in [ Modifiers::empty(), Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::LOGO, ] { let content = text_editor::Content::::with_text(app.source_workspace.text()); let mut editor: iced::Element<'_, Message, iced::Theme, FontLayout> = iced::widget::text_editor(&content) .on_action(Message::SourceEdit) .key_binding(|press| source_binding(press, &context, Some(&begin))) .height(200.) .into(); let mut tree = widget::Tree::new(editor.as_widget()); let size = iced::Size::new(500., 200.); let node = editor.as_widget_mut().layout( &mut tree, &FontLayout, &layout::Limits::new(iced::Size::ZERO, size), ); if focused { tree.state .downcast_mut::>() .focus(); } let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { key: Key::Named(Named::F2), modified_key: Key::Named(Named::F2), physical_key: Physical::Code(Code::F2), location: keyboard::Location::Standard, modifiers, text: None, repeat: false, }); let mut messages = Vec::new(); let mut shell = Shell::new(&mut messages); editor.as_widget_mut().update( &mut tree, &event, Layout::new(&node), iced::mouse::Cursor::Unavailable, &FontLayout, &mut iced::advanced::clipboard::Null, &mut shell, &iced::Rectangle::new(iced::Point::ORIGIN, size), ); let renames: Vec<_> = messages .iter() .filter_map(|message| match message { Message::SourceRename(Event::Begin(context)) => Some(context), _ => None, }) .collect(); assert_eq!(renames.len(), usize::from(focused && modifiers.is_empty())); if let Some(context) = renames.first() { assert_eq!(*context, &begin); } } } } #[cfg(debug_assertions)] #[test] fn actual_dialog_enter_requests_preview_even_when_confirm_is_available() { use crate::views::source_tests::FontLayout; use iced::advanced::{Layout, Shell, layout, widget}; use iced::keyboard::{ self, Key, key::{Code, Named, Physical}, }; struct Focus(iced::widget::Id); 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, ) { if id == Some(&self.0) { state.focus(); } else { state.unfocus(); } } } let mut app = fixture(); begin(&mut app); input(&mut app, "send_water"); let _ = preview(&mut app); let before = evidence(&app); let form = app.source_rename.form.as_ref().unwrap(); let invocation = form.invocation; let generation = form.generation; let mut messages = Vec::new(); { let editor = text_editor::Content::::new(); let mut view = dialog_content(&app, &editor); let mut tree = widget::Tree::new(view.as_widget()); let size = iced::Size::new(708., 580.); let node = view.as_widget_mut().layout( &mut tree, &FontLayout, &layout::Limits::new(iced::Size::ZERO, size), ); view.as_widget_mut().operate( &mut tree, Layout::new(&node), &FontLayout, &mut Focus(input_id(invocation)), ); let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { key: Key::Named(Named::Enter), modified_key: Key::Named(Named::Enter), physical_key: Physical::Code(Code::Enter), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::empty(), text: None, repeat: false, }); let mut shell = Shell::new(&mut messages); view.as_widget_mut().update( &mut tree, &event, Layout::new(&node), iced::mouse::Cursor::Unavailable, &FontLayout, &mut iced::advanced::clipboard::Null, &mut shell, &iced::Rectangle::new(iced::Point::ORIGIN, size), ); } assert_eq!(messages.len(), 1); assert!( matches!(messages[0], Message::SourceRename(Event::Preview(i,g)) if i == invocation && g == generation) ); send(&mut app, messages.remove(0)); assert_eq!(evidence(&app), before); assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); assert!(app.source_rename.in_flight.is_some()); } #[cfg(debug_assertions)] #[test] fn actual_alias_clipboard_rejects_controls_without_normalizing_or_moving_selection() { use crate::views::source_tests::FontLayout; use iced::advanced::{Clipboard, Layout, Shell, clipboard, layout, text, widget}; use iced::keyboard::{ self, Key, Modifiers, key::{Code, Physical}, }; use std::cell::Cell; struct ClipboardText { value: String, reads: Cell, } impl Clipboard for ClipboardText { fn read(&self, _: clipboard::Kind) -> Option { self.reads.set(self.reads.get() + 1); Some(self.value.clone()) } fn write(&mut self, _: clipboard::Kind, _: String) {} } struct Select(iced::widget::Id); impl widget::Operation for Select { 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, ) { if id == Some(&self.0) { state.focus(); } else { state.unfocus(); } } } fn focused_cursor(tree: &widget::Tree) -> Option { type InputState = iced::widget::text_input::State<::Paragraph>; if tree.tag == widget::tree::Tag::of::() { let state = tree.state.downcast_ref::(); if state.is_focused() { return Some(state.cursor()); } } tree.children.iter().find_map(focused_cursor) } for (pasted, pending) in [ ("foo\nbar", false), ("foo\tbar", false), ("foo\r\nbar", false), ("foo\0bar", false), ("water_flow", false), ("foo\nbar", true), ] { let rejected = pasted.chars().any(char::is_control); let mut app = fixture(); begin(&mut app); input(&mut app, "send_water"); let stale_confirm = preview(&mut app); let pending_completion = pending.then(|| { let job = start_preview(&mut app); preview_completion(&app, job) }); let before = evidence(&app); let form = app.source_rename.form.as_ref().unwrap(); let invocation = form.invocation; let generation = form.generation; let mut messages = Vec::new(); let editor = text_editor::Content::::new(); let mut tree; let selected; { let mut view = dialog_content(&app, &editor); tree = widget::Tree::new(view.as_widget()); let size = iced::Size::new(708., 580.); let node = view.as_widget_mut().layout( &mut tree, &FontLayout, &layout::Limits::new(iced::Size::ZERO, size), ); view.as_widget_mut().operate( &mut tree, Layout::new(&node), &FontLayout, &mut Select(input_id(invocation)), ); // Iced visits text_input before focusable. A separate traversal // selects after focus(), which otherwise moves the caret to the end. view.as_widget_mut().operate( &mut tree, Layout::new(&node), &FontLayout, &mut widget::operation::text_input::select_range(input_id(invocation), 0, 10), ); selected = focused_cursor(&tree).unwrap(); assert_eq!( selected.state(&iced::widget::text_input::Value::new("send_water")), iced::widget::text_input::cursor::State::Selection { start: 0, end: 10 } ); let mut clipboard = ClipboardText { value: pasted.into(), reads: Cell::new(0), }; for event in [ iced::Event::Keyboard(keyboard::Event::ModifiersChanged(Modifiers::COMMAND)), iced::Event::Keyboard(keyboard::Event::KeyPressed { key: Key::Character("v".into()), modified_key: Key::Character("v".into()), physical_key: Physical::Code(Code::KeyV), location: keyboard::Location::Standard, // Iced's paste branch deliberately uses the stored modifiers. modifiers: Modifiers::empty(), text: None, repeat: false, }), ] { let mut shell = Shell::new(&mut messages); view.as_widget_mut().update( &mut tree, &event, Layout::new(&node), iced::mouse::Cursor::Unavailable, &FontLayout, &mut clipboard, &mut shell, &iced::Rectangle::new(iced::Point::ORIGIN, size), ); if matches!( event, iced::Event::Keyboard(keyboard::Event::KeyPressed { .. }) ) { assert!(shell.is_event_captured()); } } assert_eq!( clipboard.reads.get(), 1, "child uses the checked clipboard snapshot" ); if rejected { assert_eq!(focused_cursor(&tree), Some(selected)); } } assert_eq!(messages.len(), 1); if rejected { assert!( matches!(messages[0], Message::SourceRename(Event::PasteRejected(id)) if id == invocation) ); } else { assert!( matches!(&messages[0], Message::SourceRename(Event::Input(id, value)) if *id == invocation && value == pasted), "paste must replace the entire active selection: {:?}", messages[0] ); } send(&mut app, messages.remove(0)); let form = app.source_rename.form.as_ref().unwrap(); assert_ne!(form.generation, generation); assert!(form.approval.is_none()); assert_eq!( form.replacement, if rejected { "send_water" } else { pasted } ); if rejected { assert!(form.notice.contains("Paste rejected")); let view = dialog_content(&app, &editor); tree.diff(view.as_widget()); assert_eq!( focused_cursor(&tree), Some(selected), "refusal feedback retains mounted selection" ); } send(&mut app, stale_confirm); assert_eq!(evidence(&app), before); if let Some(completion) = pending_completion { assert_eq!(app.source_rename.in_flight, Some(completion.job.id)); assert!(app.source_rename.form.as_ref().unwrap().pending.is_none()); send(&mut app, Message::SourceRenameReady(completion)); assert!(app.source_rename.in_flight.is_none()); assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); assert_eq!(evidence(&app), before); } } }