Files
ARCADIA-Studio/crates/oa-desktop/src/app/source_rename/tests.rs
T

1443 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Message> {
use iced::advanced::{Layout, Shell, layout, subscription, widget};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::{self, key::Named};
use std::task::{Context as PollContext, Poll};
let mut input: iced::Element<'_, (), iced::Theme, ()> =
iced::widget::text_input("Source alias", "retained_alias")
.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::<iced::widget::text_input::State<()>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let mut widget_messages = Vec::new();
let mut shell = Shell::new(&mut widget_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());
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();
let mut keyboard_boundaries = 0;
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
let mut context = PollContext::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
if matches!(
&message,
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
) {
keyboard_boundaries += 1;
} else {
messages.push(message);
}
}
}
assert_eq!(
keyboard_boundaries, 1,
"each key has exactly one passive allocation notification"
);
messages
}
#[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<usize, (String, String)> = 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<widget::Id>,
input: Option<iced::Rectangle>,
preview: Option<iced::Rectangle>,
footer: Option<iced::Rectangle>,
diff: Option<iced::Rectangle>,
}
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::<FontLayout>::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::<FontLayout>::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::<text_editor::State<shaped::highlighter::PlainText>>()
.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::<FontLayout>::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<usize>,
}
impl Clipboard for ClipboardText {
fn read(&self, _: clipboard::Kind) -> Option<String> {
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<iced::widget::text_input::Cursor> {
type InputState =
iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
if tree.tag == widget::tree::Tag::of::<InputState>() {
let state = tree.state.downcast_ref::<InputState>();
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::<FontLayout>::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);
}
}
}