1374 lines
51 KiB
Rust
1374 lines
51 KiB
Rust
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> {
|
||
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<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);
|
||
}
|
||
}
|
||
}
|