test(editor): split tests.rs into a src/tests submodule
The 2,708-line tests.rs is now one file per theme under src/tests/, with every shared helper in mod.rs. These tests drive the whole Editor and assert on private state, so they stay in-crate under #[cfg(test)] rather than moving to a top-level tests/ integration dir. No test logic changed; 224 tests still pass.
This commit is contained in:
2708
editor/src/tests.rs
2708
editor/src/tests.rs
File diff suppressed because it is too large
Load Diff
281
editor/src/tests/buffers.rs
Normal file
281
editor/src/tests/buffers.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
//! Multi-file buffers and `:enew` / `:delete`.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrap_text_packs_words_and_splits_overlong_tokens() {
|
||||
// Short message: one line.
|
||||
assert_eq!(wrap_text("saved", 15), vec!["saved"]);
|
||||
// Word-wraps on the space, keeping the actionable tail.
|
||||
assert_eq!(
|
||||
wrap_text("save FAILED - retry :w", 15),
|
||||
vec!["save FAILED -", "retry :w"]
|
||||
);
|
||||
// A token longer than the width is hard-split rather than truncated.
|
||||
assert_eq!(
|
||||
wrap_text("supercalifragilistic", 8),
|
||||
vec!["supercal", "ifragili", "stic"]
|
||||
);
|
||||
assert!(wrap_text("", 15).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_maps_prefixes_and_bare_names() {
|
||||
assert_eq!(
|
||||
resolve_path("/sd/local/j.md", Scope::Tracked),
|
||||
("/sd/local/j.md".to_string(), Scope::Local)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_path("/sd/repo/n.md", Scope::Local),
|
||||
("/sd/repo/n.md".to_string(), Scope::Tracked)
|
||||
);
|
||||
// A leading `local/` or `repo/` segment selects scope (the palette label
|
||||
// form), independent of the current buffer's scope.
|
||||
assert_eq!(
|
||||
resolve_path("local/j.md", Scope::Tracked),
|
||||
("/sd/local/j.md".to_string(), Scope::Local)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_path("repo/n.md", Scope::Local),
|
||||
("/sd/repo/n.md".to_string(), Scope::Tracked)
|
||||
);
|
||||
// The `/sd` prefix is optional: `/repo/x` and `/local/x` (leading slash,
|
||||
// no `/sd`) resolve into the same scopes as their `/sd/…` spellings.
|
||||
assert_eq!(
|
||||
resolve_path("/repo/n.md", Scope::Local),
|
||||
("/sd/repo/n.md".to_string(), Scope::Tracked)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_path("/local/j.md", Scope::Tracked),
|
||||
("/sd/local/j.md".to_string(), Scope::Local)
|
||||
);
|
||||
// A bare name lands in the current buffer's scope directory.
|
||||
assert_eq!(
|
||||
resolve_path("draft.md", Scope::Local),
|
||||
("/sd/local/draft.md".to_string(), Scope::Local)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_path("draft.md", Scope::Tracked),
|
||||
("/sd/repo/draft.md".to_string(), Scope::Tracked)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_marks_dirty_and_mark_saved_clears_it() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "hi".into());
|
||||
assert!(!e.dirty()); // a freshly loaded buffer is clean
|
||||
e.handle(Key::Char('x')); // delete a char
|
||||
assert!(e.dirty());
|
||||
e.mark_saved("/sd/repo/a.md");
|
||||
assert!(!e.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_nonresident_file_queues_a_load() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
|
||||
edit(&mut e, "/sd/local/j.md");
|
||||
assert_eq!(
|
||||
e.take_effects(),
|
||||
vec![Effect::Load {
|
||||
path: "/sd/local/j.md".into(),
|
||||
scope: Scope::Local,
|
||||
}]
|
||||
);
|
||||
// The active buffer does not change until the host loads and installs it.
|
||||
assert_eq!(e.path(), "/sd/repo/a.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_loaded_parks_current_and_activates_the_target() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "hello B".into());
|
||||
assert_eq!(e.path(), "/sd/repo/b.md");
|
||||
assert_eq!(e.text(), "hello B");
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_back_to_a_resident_buffer_needs_no_load() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "AAA".into());
|
||||
assert_eq!(e.caret, 2); // caret on A's last char
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "BBBBB".into());
|
||||
// A is parked (resident) — switching back reads memory, not disk.
|
||||
edit(&mut e, "/sd/repo/a.md");
|
||||
assert!(e.take_effects().is_empty());
|
||||
assert_eq!(e.path(), "/sd/repo/a.md");
|
||||
assert_eq!(e.text(), "AAA");
|
||||
assert_eq!(e.caret, 2); // its caret came back with it
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_register_is_global_across_buffers() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "word".into());
|
||||
e.handle(Key::Char('y')); // yy — yank the line
|
||||
e.handle(Key::Char('y'));
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, String::new());
|
||||
e.handle(Key::Char('p')); // paste it into the other buffer
|
||||
assert!(e.text().contains("word"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dirty_parked_buffer_is_saved_when_evicted() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
|
||||
// Dirty the active buffer, then push it out of the ≤3 resident window.
|
||||
e.handle(Key::Char('i'));
|
||||
e.handle(Key::Char('!'));
|
||||
e.handle(Key::Escape);
|
||||
assert!(e.dirty());
|
||||
e.take_effects(); // discard anything queued so far
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "B".into()); // parks A(dirty)
|
||||
e.install_loaded("/sd/repo/c.md".into(), Scope::Tracked, "C".into()); // parked: [A,B]
|
||||
assert!(e.take_effects().is_empty()); // nothing evicted yet
|
||||
e.install_loaded("/sd/repo/d.md".into(), Scope::Tracked, "D".into()); // evicts A
|
||||
let effs = e.take_effects();
|
||||
assert_eq!(effs.len(), 1, "the evicted dirty buffer must be saved");
|
||||
match &effs[0] {
|
||||
Effect::Save { path, .. } => assert_eq!(path, "/sd/repo/a.md"),
|
||||
other => panic!("expected a Save of A, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_parked_buffer_is_dropped_silently_on_eviction() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
|
||||
// A is never edited (clean); filling past ≤3 must evict it without a Save.
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "B".into());
|
||||
e.install_loaded("/sd/repo/c.md".into(), Scope::Tracked, "C".into());
|
||||
e.take_effects();
|
||||
e.install_loaded("/sd/repo/d.md".into(), Scope::Tracked, "D".into());
|
||||
assert!(e.take_effects().is_empty()); // clean buffer: no save on evict
|
||||
}
|
||||
|
||||
// ---- :enew / :delete (v0.5 slice 3) ----
|
||||
|
||||
#[test]
|
||||
fn enew_creates_a_dirty_empty_buffer_and_asks_the_host_for_nothing() {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "A".into());
|
||||
ex(&mut e, "enew draft.md");
|
||||
assert_eq!(e.path(), "/sd/repo/draft.md"); // bare name → current (Tracked) scope
|
||||
assert_eq!(e.scope(), Scope::Tracked);
|
||||
assert_eq!(e.text(), "");
|
||||
assert!(e.dirty()); // fresh + unsaved, so eviction/`:w` will persist it
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
// `:enew` allocates no card IO — it neither loads nor saves.
|
||||
assert!(e.take_effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enew_derives_local_scope_from_the_path() {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "A".into());
|
||||
ex(&mut e, "enew local/journal.md");
|
||||
assert_eq!(e.path(), "/sd/local/journal.md");
|
||||
assert_eq!(e.scope(), Scope::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enew_adds_the_new_file_to_the_palette_list() {
|
||||
let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/repo/todo.md"]);
|
||||
ex(&mut e, "enew draft.md");
|
||||
assert!(files_vec(&e).contains(&"/sd/repo/draft.md".to_string()));
|
||||
// and it is findable in the palette without a disk re-enumeration
|
||||
e.handle(Key::Palette);
|
||||
for c in "draft".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
assert_eq!(palette_labels(&e), vec!["repo/draft.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enew_of_an_already_open_file_switches_without_clobbering() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "AAA".into());
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "BBB".into()); // parks A
|
||||
e.take_effects();
|
||||
ex(&mut e, "enew /sd/repo/a.md"); // A is parked (resident) — switch, don't empty it
|
||||
assert_eq!(e.path(), "/sd/repo/a.md");
|
||||
assert_eq!(e.text(), "AAA"); // contents preserved, not clobbered to empty
|
||||
assert!(e.take_effects().is_empty()); // resident: no Load
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enew_without_a_name_is_a_usage_noop() {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "A".into());
|
||||
ex(&mut e, "enew");
|
||||
assert_eq!(e.path(), "/sd/repo/notes.md"); // unchanged
|
||||
assert!(e.take_effects().is_empty());
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_queues_a_delete_of_the_current_file() {
|
||||
let (mut e, effs) = command("delete");
|
||||
assert_eq!(
|
||||
effs,
|
||||
vec![Effect::Delete {
|
||||
path: "/sd/repo/notes.md".into(),
|
||||
scope: Scope::Tracked,
|
||||
}]
|
||||
);
|
||||
// No file remains active (nothing else was resident): a scratch buffer.
|
||||
assert_eq!(e.path(), "");
|
||||
assert_eq!(e.text(), "");
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert!(e.take_effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_never_saves_the_discarded_buffer_even_when_dirty() {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "A".into());
|
||||
e.handle(Key::Char('x')); // dirty it
|
||||
assert!(e.dirty());
|
||||
ex(&mut e, "delete");
|
||||
// The buffer is being deleted, so it is discarded, not saved: Delete only.
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::Delete]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_switches_to_the_most_recently_parked_buffer() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "AAA".into());
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "BBB".into()); // active B, A parked
|
||||
e.take_effects();
|
||||
ex(&mut e, "delete"); // deletes B, restores A
|
||||
assert_eq!(e.path(), "/sd/repo/a.md");
|
||||
assert_eq!(e.text(), "AAA"); // A came back from RAM, caret/undo with it
|
||||
match &e.take_effects()[..] {
|
||||
[Effect::Delete { path, .. }] => assert_eq!(path, "/sd/repo/b.md"),
|
||||
other => panic!("expected a single Delete of B, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_drops_the_file_from_the_palette_list() {
|
||||
let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/repo/todo.md"]);
|
||||
ex(&mut e, "delete"); // notes.md is active
|
||||
e.take_effects();
|
||||
assert!(!files_vec(&e).contains(&"/sd/repo/notes.md".to_string()));
|
||||
e.handle(Key::Palette);
|
||||
for c in "md".chars() {
|
||||
e.handle(Key::Char(c)); // reach the search threshold
|
||||
}
|
||||
assert_eq!(palette_labels(&e), vec!["repo/todo.md"]); // only the survivor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_of_a_local_file_carries_local_scope() {
|
||||
let mut e = Editor::with_file("/sd/local/j.md".into(), Scope::Local, "diary".into());
|
||||
ex(&mut e, "delete");
|
||||
match &e.take_effects()[..] {
|
||||
[Effect::Delete { path, scope }] => {
|
||||
assert_eq!(path, "/sd/local/j.md");
|
||||
assert_eq!(*scope, Scope::Local);
|
||||
}
|
||||
other => panic!("expected a Local Delete, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_on_an_unnamed_buffer_is_a_noop() {
|
||||
let mut e = Editor::new(); // scratch, empty path — nothing on disk to delete
|
||||
ex(&mut e, "delete");
|
||||
assert!(e.take_effects().is_empty());
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
187
editor/src/tests/commands.rs
Normal file
187
editor/src/tests/commands.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! Ex commands (`:w`, `:gp`, `:gl`, format-on-save, aliases) and command-line editing.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn w_command_signals_save_and_returns_to_normal() {
|
||||
let (e, effs) = command("w");
|
||||
assert_eq!(
|
||||
effs,
|
||||
vec![Effect::Save {
|
||||
path: "/sd/repo/notes.md".into(),
|
||||
scope: Scope::Tracked,
|
||||
contents: String::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gp_command_saves_then_publishes() {
|
||||
// `:gp` queues a save of the current buffer, then the git publish.
|
||||
assert_eq!(kinds(&command("gp").1), vec![Kind::Save, Kind::Publish]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gl_command_signals_pull() {
|
||||
assert_eq!(kinds(&command("gl").1), vec![Kind::Pull]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gp_formats_the_buffer_before_publishing() {
|
||||
// fmt → save → commit → push: `:gp` runs :fmt in-core first (default on).
|
||||
let mut e = Editor::with_file(
|
||||
"/sd/repo/notes.md".into(),
|
||||
Scope::Tracked,
|
||||
"hello \nworld".to_string(), // trailing spaces
|
||||
);
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "gp".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::Save, Kind::Publish]);
|
||||
assert_eq!(e.text(), "hello\nworld"); // :fmt stripped the trailing whitespace
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gp_is_refused_in_a_local_buffer() {
|
||||
// Publish is Tracked-only; `:gp` in Local queues nothing and warns.
|
||||
let mut e = Editor::with_file(
|
||||
"/sd/local/journal.md".into(),
|
||||
Scope::Local,
|
||||
"dear diary".to_string(),
|
||||
);
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "gp".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
assert!(e.take_effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_on_save_off_leaves_the_buffer_untouched() {
|
||||
let mut e = Editor::with_file(
|
||||
"/sd/repo/notes.md".into(),
|
||||
Scope::Tracked,
|
||||
"hello \nworld".to_string(),
|
||||
);
|
||||
e.prefs.format_on_save = false;
|
||||
e.handle(Key::Char(':'));
|
||||
e.handle(Key::Char('w'));
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::Save]);
|
||||
assert_eq!(e.text(), "hello \nworld"); // unchanged when the pref is off
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_keeps_at_most_one_trailing_blank_line() {
|
||||
// The writer's trailing blank line (pressed Enter to open the next line) is
|
||||
// kept; a run of them collapses to one; a note with none gains none.
|
||||
assert_eq!(format_markdown("hello\n"), "hello\n"); // one blank kept
|
||||
assert_eq!(format_markdown("hello\n\n\n"), "hello\n"); // extras collapsed to one
|
||||
assert_eq!(format_markdown("hello"), "hello"); // none added
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_on_save_keeps_the_caret_on_a_trailing_blank_line() {
|
||||
// Regression: `:w` used to drop the trailing blank line and yank the caret
|
||||
// up onto the last non-empty line. The blank line — and the caret — stay.
|
||||
let mut e = Editor::with_file(
|
||||
"/sd/repo/notes.md".into(),
|
||||
Scope::Tracked,
|
||||
"hello\n".to_string(), // row 0 "hello", row 1 "" (a fresh empty line)
|
||||
);
|
||||
e.caret = e.text().len(); // caret at the very end = on the trailing blank row
|
||||
let lay = e.layout();
|
||||
assert_eq!(e.caret_rc(&lay).0, 1, "precondition: caret on the blank row");
|
||||
|
||||
e.handle(Key::Char(':'));
|
||||
e.handle(Key::Char('w'));
|
||||
e.handle(Key::Enter);
|
||||
|
||||
assert_eq!(e.text(), "hello\n", "trailing blank line survived format-on-save");
|
||||
let lay = e.layout();
|
||||
assert_eq!(e.caret_rc(&lay).0, 1, "caret stayed on the blank row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wq_and_x_alias_save_dropping_the_quit() {
|
||||
assert_eq!(kinds(&command("wq").1), vec![Kind::Save]);
|
||||
assert_eq!(kinds(&command("x").1), vec![Kind::Save]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_stays_in_core_and_asks_the_host_for_nothing() {
|
||||
assert!(command("fmt").1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_is_ignored() {
|
||||
let (e, effs) = command("q"); // quit is deliberately unimplemented
|
||||
assert!(effs.is_empty());
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn w_on_an_unnamed_buffer_posts_no_file_name() {
|
||||
// A scratch buffer (empty path) has nowhere to save to.
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char(':'));
|
||||
e.handle(Key::Char('w'));
|
||||
e.handle(Key::Enter);
|
||||
assert!(e.take_effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_text_boots_normal_with_caret_on_last_char() {
|
||||
let e = Editor::with_text("resumed draft".to_string());
|
||||
assert_eq!(e.text(), "resumed draft");
|
||||
assert_eq!(e.caret, 12); // on the last char ('t'), the resume point
|
||||
assert_eq!(e.mode(), Mode::Normal); // vim-style: open a file in Normal
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_text_empty_matches_new() {
|
||||
let e = Editor::with_text(String::new());
|
||||
assert_eq!(e.text(), "");
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
// ---- Command-line editing (Ctrl-W / Cmd-Backspace while typing `:`) ----
|
||||
|
||||
#[test]
|
||||
fn ctrl_w_deletes_the_last_word_of_the_command_line() {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "sync now".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::DeleteWord);
|
||||
assert_eq!(e.cmdline, "sync ");
|
||||
assert_eq!(e.mode(), Mode::Command); // stays on the command line
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_w_on_a_one_word_command_does_not_cancel() {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char(':'));
|
||||
e.handle(Key::Char('w'));
|
||||
e.handle(Key::DeleteWord);
|
||||
assert_eq!(e.cmdline, "");
|
||||
assert_eq!(e.mode(), Mode::Command); // unlike Backspace, does not exit
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_backspace_clears_the_command_line() {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "fmt".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::DeleteLine);
|
||||
assert_eq!(e.cmdline, "");
|
||||
assert_eq!(e.mode(), Mode::Command);
|
||||
}
|
||||
109
editor/src/tests/dot_repeat.rs
Normal file
109
editor/src/tests/dot_repeat.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! The `.` repeat command.
|
||||
|
||||
use super::*;
|
||||
|
||||
// ---- `.` repeat (v0.3) ----
|
||||
|
||||
#[test]
|
||||
fn dot_repeats_x() {
|
||||
let mut e = Editor::with_text("abcde".to_string());
|
||||
e.handle(Key::Char('0'));
|
||||
e.handle(Key::Char('x')); // "bcde"
|
||||
e.handle(Key::Char('.')); // "cde"
|
||||
e.handle(Key::Char('.')); // "de"
|
||||
assert_eq!(e.text(), "de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_repeats_dd() {
|
||||
let mut e = Editor::with_text("a\nb\nc\nd".to_string());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('d'));
|
||||
e.handle(Key::Char('d')); // delete "a"
|
||||
e.handle(Key::Char('.')); // delete "b"
|
||||
assert_eq!(e.text(), "c\nd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_repeats_dw() {
|
||||
let mut e = Editor::with_text("foo bar baz".to_string());
|
||||
e.handle(Key::Char('0'));
|
||||
e.handle(Key::Char('d'));
|
||||
e.handle(Key::Char('w')); // "bar baz"
|
||||
e.handle(Key::Char('.')); // "baz"
|
||||
assert_eq!(e.text(), "baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_repeats_a_change_operator_with_its_inserted_text() {
|
||||
// The reason `.` records keystrokes: it must replay `ciw` *and* the text
|
||||
// typed in the insert session that followed.
|
||||
let mut e = Editor::with_text("foo bar".to_string());
|
||||
e.handle(Key::Char('0'));
|
||||
e.handle(Key::Char('c'));
|
||||
e.handle(Key::Char('i'));
|
||||
e.handle(Key::Char('w'));
|
||||
e.handle(Key::Char('X'));
|
||||
e.handle(Key::Escape); // "X bar"
|
||||
assert_eq!(e.text(), "X bar");
|
||||
e.handle(Key::Char('w')); // caret onto "bar"
|
||||
e.handle(Key::Char('.')); // repeat: change that word to "X" too
|
||||
assert_eq!(e.text(), "X X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_repeats_a_paste() {
|
||||
let mut e = Editor::with_text("x\na\nb".to_string());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('y'));
|
||||
e.handle(Key::Char('y')); // yank line "x"
|
||||
e.handle(Key::Char('p')); // "x\nx\na\nb"
|
||||
e.handle(Key::Char('.')); // paste again below
|
||||
assert_eq!(e.text(), "x\nx\nx\na\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_ignores_pure_motions() {
|
||||
let mut e = Editor::with_text("abc".to_string());
|
||||
e.handle(Key::Char('0'));
|
||||
e.handle(Key::Char('l')); // motions only — nothing to repeat
|
||||
e.handle(Key::Char('.'));
|
||||
assert_eq!(e.text(), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_yank_does_not_become_the_dot_change() {
|
||||
// `y` is not a `.`-repeatable change; the prior `x` must remain the dot.
|
||||
let mut e = Editor::with_text("abcdef".to_string());
|
||||
e.handle(Key::Char('0'));
|
||||
e.handle(Key::Char('x')); // dot = x; "bcdef"
|
||||
e.handle(Key::Char('y'));
|
||||
e.handle(Key::Char('w')); // yank — must not overwrite the dot
|
||||
e.handle(Key::Char('.')); // repeat the x, not the yank
|
||||
assert_eq!(e.text(), "cdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_in_insert_mode_is_a_literal_character() {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char('i'));
|
||||
e.handle(Key::Char('.'));
|
||||
assert_eq!(e.text(), "."); // '.' only repeats from Normal
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_getter_reflects_edits() {
|
||||
let e = typed("hello");
|
||||
assert_eq!(e.text(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notice_shows_until_the_next_key_dismisses_it() {
|
||||
let mut e = Editor::new();
|
||||
e.set_notice("saved");
|
||||
assert_eq!(e.notice.as_deref(), Some("saved"));
|
||||
e.handle(Key::Char('j')); // any key dismisses the snackbar
|
||||
assert_eq!(e.notice, None);
|
||||
}
|
||||
207
editor/src/tests/editing.rs
Normal file
207
editor/src/tests/editing.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! Insert/Normal editing basics and the register + yank/paste commands.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn insert_builds_buffer_and_advances_caret() {
|
||||
let e = typed("hello");
|
||||
assert_eq!(e.text, "hello");
|
||||
assert_eq!(e.caret, 5);
|
||||
assert_eq!(e.mode(), Mode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_previous_char() {
|
||||
let mut e = typed("hello");
|
||||
e.handle(Key::Backspace);
|
||||
assert_eq!(e.text, "hell");
|
||||
assert_eq!(e.caret, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_splits_the_line() {
|
||||
let mut e = typed("ab");
|
||||
e.handle(Key::Enter);
|
||||
e.handle(Key::Char('c'));
|
||||
assert_eq!(e.text, "ab\nc");
|
||||
assert_eq!(e.caret, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_a_blockquote_continues_the_marker() {
|
||||
let mut e = typed("> quote");
|
||||
e.handle(Key::Enter);
|
||||
e.handle(Key::Char('m'));
|
||||
assert_eq!(e.text, "> quote\n> m");
|
||||
assert_eq!(e.caret, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_an_empty_blockquote_exits_the_quote() {
|
||||
let mut e = typed("> quote");
|
||||
e.handle(Key::Enter); // -> "> quote\n> "
|
||||
e.handle(Key::Enter); // empty quote: drop the "> ", leaving a blank line
|
||||
assert_eq!(e.text, "> quote\n");
|
||||
assert_eq!(e.caret, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_a_nested_blockquote_keeps_the_depth() {
|
||||
let mut e = typed("> > deep");
|
||||
e.handle(Key::Enter);
|
||||
e.handle(Key::Char('x'));
|
||||
assert_eq!(e.text, "> > deep\n> > x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_enters_normal_and_steps_onto_last_char() {
|
||||
let mut e = typed("abc");
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert_eq!(e.caret, 2); // vim: caret drops onto the last inserted char
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_h_and_l_step_one_char() {
|
||||
let mut e = typed("abc");
|
||||
e.handle(Key::Escape); // Normal, caret = 2
|
||||
e.handle(Key::Char('h'));
|
||||
assert_eq!(e.caret, 1);
|
||||
e.handle(Key::Char('h'));
|
||||
assert_eq!(e.caret, 0);
|
||||
e.handle(Key::Char('l'));
|
||||
assert_eq!(e.caret, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_x_deletes_char_under_caret() {
|
||||
let mut e = typed("abc");
|
||||
e.handle(Key::Escape); // caret on 'c'
|
||||
e.handle(Key::Char('h')); // caret on 'b'
|
||||
e.handle(Key::Char('x'));
|
||||
assert_eq!(e.text, "ac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_forward_lands_on_next_word_start() {
|
||||
let mut e = typed("foo bar");
|
||||
e.handle(Key::Escape); // Normal
|
||||
e.handle(Key::Char('0')); // line start
|
||||
e.handle(Key::Char('w'));
|
||||
assert_eq!(e.caret, 4); // 'b' of "bar"
|
||||
}
|
||||
|
||||
/// The buffer round-trips and `draw()` runs for a plain-ASCII buffer — the
|
||||
/// current, byte==char world. UTF-8 (accented-input) correctness is the next
|
||||
/// change; when it lands, add the accented-motion cases here.
|
||||
#[test]
|
||||
fn draw_produces_a_full_frame_for_ascii() {
|
||||
let mut e = typed("hello world");
|
||||
let frame = e.draw(true);
|
||||
assert_eq!(frame.bytes().len(), display::FB_BYTES);
|
||||
}
|
||||
|
||||
// ---- Register + yank / paste (v0.3) ----
|
||||
|
||||
#[test]
|
||||
fn yy_then_p_opens_a_copy_of_the_line_below() {
|
||||
let mut e = Editor::with_text("foo\nbar".to_string());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g')); // gg -> caret on line "foo"
|
||||
e.handle(Key::Char('y'));
|
||||
e.handle(Key::Char('y')); // yank the line, linewise
|
||||
e.handle(Key::Char('p')); // paste it after the current line
|
||||
assert_eq!(e.text(), "foo\nfoo\nbar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yy_then_capital_p_pastes_the_line_above() {
|
||||
let mut e = Editor::with_text("foo\nbar".to_string()); // caret on line "bar"
|
||||
e.handle(Key::Char('y'));
|
||||
e.handle(Key::Char('y'));
|
||||
e.handle(Key::Char('P'));
|
||||
assert_eq!(e.text(), "foo\nbar\nbar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dd_then_p_moves_a_line_down() {
|
||||
let mut e = Editor::with_text("one\ntwo\nthree".to_string());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g')); // caret on "one"
|
||||
e.handle(Key::Char('d'));
|
||||
e.handle(Key::Char('d')); // cut "one" into the register
|
||||
e.handle(Key::Char('p')); // paste after "two"
|
||||
assert_eq!(e.text(), "two\none\nthree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_dd_captures_all_lines_for_paste() {
|
||||
let mut e = Editor::with_text("a\nb\nc\nd".to_string());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('3'));
|
||||
e.handle(Key::Char('d'));
|
||||
e.handle(Key::Char('d')); // 3dd — cut three lines
|
||||
assert_eq!(e.text(), "d");
|
||||
e.handle(Key::Char('p')); // paste all three back below "d"
|
||||
assert_eq!(e.text(), "d\na\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x_then_p_replays_the_deleted_char_after_the_caret() {
|
||||
let mut e = Editor::with_text("abc".to_string());
|
||||
e.handle(Key::Char('0')); // caret on 'a'
|
||||
e.handle(Key::Char('x')); // delete 'a' -> "bc", register = "a" (charwise)
|
||||
e.handle(Key::Char('p')); // paste after 'b'
|
||||
assert_eq!(e.text(), "bac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yw_yanks_charwise_and_p_inserts_after_the_caret() {
|
||||
let mut e = Editor::with_text("foo bar".to_string());
|
||||
e.handle(Key::Char('0'));
|
||||
e.handle(Key::Char('y'));
|
||||
e.handle(Key::Char('w')); // yank "foo " (word + trailing space), caret stays put
|
||||
e.handle(Key::Char('p'));
|
||||
assert_eq!(e.text(), "ffoo oo bar"); // charwise paste after the cursor char
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capital_p_pastes_a_char_before_the_caret() {
|
||||
let mut e = Editor::with_text("abc".to_string());
|
||||
e.handle(Key::Char('0'));
|
||||
e.handle(Key::Char('x')); // register = "a", text "bc", caret on 'b'
|
||||
e.handle(Key::Char('l')); // caret on 'c'
|
||||
e.handle(Key::Char('P')); // paste "a" before 'c'
|
||||
assert_eq!(e.text(), "bac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_with_an_empty_register_is_a_noop() {
|
||||
let mut e = Editor::with_text("abc".to_string());
|
||||
e.handle(Key::Char('p'));
|
||||
e.handle(Key::Char('P'));
|
||||
assert_eq!(e.text(), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_paste_at_the_bottom_reveals_the_whole_block() {
|
||||
// A screenful+ of lines, caret on the last line; paste two lines after
|
||||
// it. Both pasted lines must be visible without a manual scroll — the
|
||||
// caret stays on the first pasted line, but the viewport reveals the end.
|
||||
let mut e = Editor::with_text(vec!["x"; 20].join("\n")); // 20 display rows
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('2'));
|
||||
e.handle(Key::Char('y'));
|
||||
e.handle(Key::Char('y')); // yank two lines
|
||||
e.handle(Key::Char('G')); // to the last line
|
||||
e.handle(Key::Char('p')); // paste two lines below it (22 rows total)
|
||||
e.draw(true); // adjust_scroll runs; reveal already applied by paste
|
||||
let last_row = e.layout().len() - 1; // the second pasted line
|
||||
assert!(
|
||||
last_row >= e.scroll_top() && last_row < e.scroll_top() + ROWS,
|
||||
"pasted block end (row {last_row}) off-screen at scroll_top {}",
|
||||
e.scroll_top()
|
||||
);
|
||||
}
|
||||
185
editor/src/tests/mod.rs
Normal file
185
editor/src/tests/mod.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
//! Test support: shared helpers plus the per-theme test module
|
||||
//! declarations.
|
||||
//!
|
||||
//! The editor's tests are behavioural — each builds a whole [`Editor`],
|
||||
//! drives it through [`Key`] events, and asserts on private state — so they
|
||||
//! live in this `#[cfg(test)] mod tests` (declared in `lib.rs`) to keep
|
||||
//! crate-private access. Every helper lives here; each theme's `#[test]`
|
||||
//! fns live in the submodule named below.
|
||||
|
||||
mod editing;
|
||||
mod utf8;
|
||||
mod commands;
|
||||
mod render;
|
||||
mod undo;
|
||||
mod dot_repeat;
|
||||
mod visual;
|
||||
mod buffers;
|
||||
mod palette;
|
||||
mod prefs;
|
||||
mod snippets;
|
||||
mod search;
|
||||
mod pull;
|
||||
|
||||
pub(crate) use crate::*;
|
||||
|
||||
const TWO_SNIPPETS: &str = r##"{
|
||||
"Markdown link": { "prefix": "link", "body": "[$1]($2)$0", "description": "Inline link" },
|
||||
"Book notes": { "prefix": "booknotes", "body": "# $1", "description": "Reading fiche" }
|
||||
}"##;
|
||||
|
||||
/// Type a run of characters in Insert mode, entered with `i` from the
|
||||
/// power-on Normal mode.
|
||||
fn typed(s: &str) -> Editor {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char('i')); // Normal -> Insert
|
||||
for c in s.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// From a fresh editor over a named Tracked file, run `:{cmd}<Enter>`,
|
||||
/// returning the editor and the drained [`Effect`]s the command queued.
|
||||
fn command(cmd: &str) -> (Editor, Vec<Effect>) {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, String::new());
|
||||
e.handle(Key::Char(':')); // Normal -> Command
|
||||
for c in cmd.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
let effects = e.take_effects();
|
||||
(e, effects)
|
||||
}
|
||||
|
||||
/// Coarse kind of an [`Effect`], ignoring `Save`/`Load` payloads, so the
|
||||
/// command tests can assert intent without pinning path/scope/contents.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Save,
|
||||
Load,
|
||||
Publish,
|
||||
Pull,
|
||||
Delete,
|
||||
SavePrefs,
|
||||
}
|
||||
|
||||
fn kinds(effects: &[Effect]) -> Vec<Kind> {
|
||||
effects
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Effect::Save { .. } => Kind::Save,
|
||||
Effect::Load { .. } => Kind::Load,
|
||||
Effect::Publish => Kind::Publish,
|
||||
Effect::Pull => Kind::Pull,
|
||||
Effect::Delete { .. } => Kind::Delete,
|
||||
Effect::SavePrefs { .. } => Kind::SavePrefs,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 1 = white paper, 0 = black ink (SSD16xx convention). Reads one pixel.
|
||||
fn ink_at(frame: &Frame, x: usize, y: usize) -> bool {
|
||||
frame.bytes()[y * display::FB_BYTES_W + x / 8] & (0x80 >> (x % 8)) == 0
|
||||
}
|
||||
|
||||
/// Feed a run of characters as Normal-mode keys.
|
||||
fn send(e: &mut Editor, s: &str) {
|
||||
for c in s.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `arg` the way a user now does — via the file palette. (`:e` was
|
||||
/// retired in v0.6; bare `Cmd-P` opens files.) Lists the target, opens the
|
||||
/// palette, and types its exact label so the fuzzy matcher ranks it first,
|
||||
/// then Enter selects it — routing through the same `open_path` `:e` used.
|
||||
fn edit(e: &mut Editor, arg: &str) {
|
||||
let (path, _) = resolve_path(arg, e.scope);
|
||||
e.add_to_file_list(&path);
|
||||
e.open_palette();
|
||||
for c in palette_label(&path).chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
}
|
||||
|
||||
/// Drive an arbitrary `:{cmd}<Enter>` from Normal.
|
||||
fn ex(e: &mut Editor, cmd: &str) {
|
||||
e.handle(Key::Char(':'));
|
||||
for c in cmd.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
}
|
||||
|
||||
/// A fresh editor over `/sd/repo/notes.md` with a palette file list.
|
||||
fn palette_editor(files: &[&str]) -> Editor {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, String::new());
|
||||
e.set_file_list(files.iter().map(|s| s.to_string()).collect());
|
||||
e
|
||||
}
|
||||
|
||||
/// The palette's current result as display labels, in ranked order.
|
||||
fn palette_labels(e: &Editor) -> Vec<&str> {
|
||||
e.palette_matches().iter().map(|&i| palette_label(e.file_at(i))).collect()
|
||||
}
|
||||
|
||||
/// The interned file list back as owned strings, in base (sorted) order.
|
||||
fn files_vec(e: &Editor) -> Vec<String> {
|
||||
(0..e.file_count()).map(|i| e.file_at(i).to_string()).collect()
|
||||
}
|
||||
|
||||
/// Open the palette and type `query` (so `>...` enters command mode).
|
||||
fn palette_type(files: &[&str], query: &str) -> Editor {
|
||||
let mut e = palette_editor(files);
|
||||
e.handle(Key::Palette);
|
||||
for c in query.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
fn with_snippets(json: &str) -> Editor {
|
||||
let mut e = Editor::new();
|
||||
e.set_snippets(Snippets::parse(json).unwrap());
|
||||
e
|
||||
}
|
||||
|
||||
/// Open the palette on an editor with `json`'s snippets loaded, then type `q`.
|
||||
fn snippet_palette(json: &str, q: &str) -> Editor {
|
||||
let mut e = with_snippets(json);
|
||||
e.handle(Key::Palette);
|
||||
for c in q.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// Type `word` into a fresh Insert-mode buffer with the two snippets loaded.
|
||||
fn typed_in_insert(word: &str) -> Editor {
|
||||
let mut e = with_snippets(TWO_SNIPPETS);
|
||||
e.handle(Key::Char('i'));
|
||||
for c in word.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// A fresh Normal-mode editor over `text`, caret normalized to 0 with `gg`
|
||||
/// (a loaded file resumes at its end).
|
||||
fn over(text: &str) -> Editor {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, text.into());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
e
|
||||
}
|
||||
|
||||
/// Run `/{pat}<Enter>` on `e`.
|
||||
fn search(e: &mut Editor, pat: &str) {
|
||||
e.handle(Key::Char('/'));
|
||||
for c in pat.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
}
|
||||
647
editor/src/tests/palette.rs
Normal file
647
editor/src/tests/palette.rs
Normal file
@@ -0,0 +1,647 @@
|
||||
//! The file palette (Ctrl-P) and the `>` command palette.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_matches_subsequence_case_insensitively() {
|
||||
assert!(fuzzy_score("notes", "repo/notes.md").is_some());
|
||||
assert!(fuzzy_score("NOTES", "repo/notes.md").is_some()); // case-insensitive
|
||||
assert!(fuzzy_score("rpnm", "repo/notes.md").is_some()); // scattered subsequence
|
||||
assert!(fuzzy_score("xyz", "repo/notes.md").is_none()); // not a subsequence
|
||||
assert_eq!(fuzzy_score("", "anything"), Some(0)); // empty query matches all
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_space_matches_any_separator() {
|
||||
assert!(fuzzy_score("la conv", "repo/la-convergence.md").is_some()); // space finds '-'
|
||||
assert!(fuzzy_score("notes md", "repo/notes.md").is_some()); // space finds '.'
|
||||
assert!(fuzzy_score("repo notes", "repo/notes.md").is_some()); // space finds '/'
|
||||
assert!(fuzzy_score("la conv", "repo/laconvergence.md").is_none()); // still needs a separator
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_ranks_word_boundaries_above_midword() {
|
||||
// "no" after the "/" boundary in repo/notes beats a mid-word hit.
|
||||
let boundary = fuzzy_score("no", "repo/notes.md").unwrap();
|
||||
let midword = fuzzy_score("no", "cannotes.md").unwrap();
|
||||
assert!(boundary > midword, "{boundary} !> {midword}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_file_list_sorts_and_dedups() {
|
||||
let mut e = Editor::new();
|
||||
e.set_file_list(vec![
|
||||
"/sd/repo/b.md".into(),
|
||||
"/sd/repo/a.md".into(),
|
||||
"/sd/repo/b.md".into(),
|
||||
]);
|
||||
assert_eq!(files_vec(&e), vec!["/sd/repo/a.md", "/sd/repo/b.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_p_opens_the_palette_and_esc_closes_it() {
|
||||
let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/repo/todo.md"]);
|
||||
e.handle(Key::Palette);
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_p_toggles_the_palette_closed() {
|
||||
let mut e = palette_editor(&["/sd/repo/notes.md"]);
|
||||
e.handle(Key::Palette);
|
||||
e.handle(Key::Palette); // same chord closes
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_p_opens_the_palette_from_insert_ending_the_session_like_esc() {
|
||||
let mut e = typed("hi");
|
||||
e.handle(Key::Palette);
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
assert_eq!(e.caret, 1); // caret dropped onto the last inserted char, as Esc does
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal); // closing lands in Normal, not back in Insert
|
||||
assert_eq!(e.text, "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_p_opens_the_palette_from_visual_dropping_the_selection() {
|
||||
let mut e = typed("hello");
|
||||
e.handle(Key::Escape);
|
||||
e.handle(Key::Char('v')); // charwise Visual, anchor set
|
||||
assert_eq!(e.mode(), Mode::Visual);
|
||||
e.handle(Key::Palette);
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
assert_eq!(e.visual_anchor, None); // selection gone, as Esc would leave it
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_p_opens_the_palette_from_visual_line() {
|
||||
let mut e = typed("hello");
|
||||
e.handle(Key::Escape);
|
||||
e.handle(Key::Char('V'));
|
||||
assert_eq!(e.mode(), Mode::VisualLine);
|
||||
e.handle(Key::Palette);
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
assert_eq!(e.visual_anchor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_p_opens_the_palette_from_view_mode() {
|
||||
let mut e = typed("hello");
|
||||
e.handle(Key::Escape);
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('r')); // gr — go-read (View)
|
||||
assert_eq!(e.mode(), Mode::View);
|
||||
e.handle(Key::Palette);
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_p_opens_the_palette_from_command_abandoning_the_line() {
|
||||
let mut e = palette_editor(&["/sd/repo/notes.md"]);
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "del".chars() {
|
||||
e.handle(Key::Char(c)); // half-typed command
|
||||
}
|
||||
e.handle(Key::Palette);
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
assert_eq!(e.cmdline, ""); // the abandoned `:del` is gone
|
||||
assert_eq!(e.palette_query, ""); // and didn't leak into the palette query
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert!(e.take_effects().is_empty()); // `:del` never ran
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_p_mid_insert_aborts_the_dot_recording() {
|
||||
let mut e = typed("ab"); // `i a b` — a dot recording in progress
|
||||
e.handle(Key::Palette); // palette trip aborts it
|
||||
e.handle(Key::Escape); // back to Normal — would normally complete a recording
|
||||
e.handle(Key::Char('.'));
|
||||
assert_eq!(e.text, "ab"); // nothing to repeat: no re-insert, no reopened palette
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completed_dot_survives_a_palette_round_trip() {
|
||||
let mut e = typed("abc");
|
||||
e.handle(Key::Escape); // caret on 'c'
|
||||
e.handle(Key::Char('x')); // dot = [x], text "ab"
|
||||
e.handle(Key::Palette);
|
||||
e.handle(Key::Escape); // palette round trip
|
||||
e.handle(Key::Char('.')); // still repeats the x
|
||||
assert_eq!(e.text, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_filters_and_enter_opens_the_picked_file() {
|
||||
let mut e = palette_editor(&[
|
||||
"/sd/repo/notes.md",
|
||||
"/sd/repo/todo.md",
|
||||
"/sd/local/journal.md",
|
||||
]);
|
||||
e.handle(Key::Palette);
|
||||
for c in "todo".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.mode(), Mode::Normal); // palette closed
|
||||
let effs = e.take_effects();
|
||||
assert_eq!(kinds(&effs), vec![Kind::Load]);
|
||||
match &effs[0] {
|
||||
Effect::Load { path, scope } => {
|
||||
assert_eq!(path, "/sd/repo/todo.md");
|
||||
assert_eq!(*scope, Scope::Tracked);
|
||||
}
|
||||
other => panic!("expected a Load, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_local_file_from_the_palette_carries_local_scope() {
|
||||
let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/local/journal.md"]);
|
||||
e.handle(Key::Palette);
|
||||
for c in "journal".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
match &e.take_effects()[0] {
|
||||
Effect::Load { path, scope } => {
|
||||
assert_eq!(path, "/sd/local/journal.md");
|
||||
assert_eq!(*scope, Scope::Local); // scope derived from the /sd/local path
|
||||
}
|
||||
other => panic!("expected a Local Load, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_the_active_file_from_the_palette_is_a_noop() {
|
||||
let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/repo/todo.md"]);
|
||||
e.handle(Key::Palette);
|
||||
for c in "notes".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
// notes.md is already active: no Load, just a closed palette.
|
||||
assert!(e.take_effects().is_empty());
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_page_keys_move_the_selection_wrapping() {
|
||||
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md", "/sd/repo/c.md"]);
|
||||
e.handle(Key::Palette);
|
||||
for ch in "md".chars() {
|
||||
e.handle(Key::Char(ch)); // reach the search threshold: all three match
|
||||
}
|
||||
assert_eq!(e.palette_sel, 0);
|
||||
e.handle(Key::HalfPageDown);
|
||||
assert_eq!(e.palette_sel, 1);
|
||||
e.handle(Key::HalfPageDown);
|
||||
e.handle(Key::HalfPageDown); // wraps past the last row to the top
|
||||
assert_eq!(e.palette_sel, 0);
|
||||
e.handle(Key::HalfPageUp); // wraps back to the last row
|
||||
assert_eq!(e.palette_sel, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_n_p_navigate_the_palette() {
|
||||
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md", "/sd/repo/c.md"]);
|
||||
e.handle(Key::Palette);
|
||||
for ch in "md".chars() {
|
||||
e.handle(Key::Char(ch)); // reach the search threshold: all three match
|
||||
}
|
||||
e.handle(Key::Down); // Ctrl-n
|
||||
assert_eq!(e.palette_sel, 1);
|
||||
e.handle(Key::Down);
|
||||
e.handle(Key::Down); // wraps past the last row to the top
|
||||
assert_eq!(e.palette_sel, 0);
|
||||
e.handle(Key::Up); // Ctrl-p wraps back to the last row
|
||||
assert_eq!(e.palette_sel, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_n_p_move_by_a_line_in_normal_mode() {
|
||||
// Three lines; caret starts on the last char of line 3 (with_file posture).
|
||||
let mut e = Editor::with_file("/sd/repo/n.md".into(), Scope::Tracked, "aa\nbb\ncc".into());
|
||||
e.handle(Key::Char('g')); // gg → top (line 1)
|
||||
e.handle(Key::Char('g'));
|
||||
assert_eq!(e.caret, 0);
|
||||
e.handle(Key::Down); // Ctrl-n → line 2
|
||||
assert_eq!(e.caret, 3); // start of "bb"
|
||||
e.handle(Key::Down); // → line 3
|
||||
assert_eq!(e.caret, 6); // start of "cc"
|
||||
e.handle(Key::Up); // Ctrl-p → line 2
|
||||
assert_eq!(e.caret, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_n_takes_a_count_in_normal_mode() {
|
||||
let mut e = Editor::with_file("/sd/repo/n.md".into(), Scope::Tracked, "aa\nbb\ncc".into());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g')); // top
|
||||
e.handle(Key::Char('2'));
|
||||
e.handle(Key::Down); // 2<C-n> → down two lines
|
||||
assert_eq!(e.caret, 6); // start of "cc"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_n_p_scroll_in_view_mode() {
|
||||
let mut e = Editor::with_file("/sd/repo/n.md".into(), Scope::Tracked, "a\nb\nc\nd".into());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('r')); // gr → View
|
||||
assert_eq!(e.mode(), Mode::View);
|
||||
let top = e.scroll_top();
|
||||
e.handle(Key::Down); // Ctrl-n scrolls like j
|
||||
assert_eq!(e.scroll_top(), top + 1);
|
||||
e.handle(Key::Up); // Ctrl-p scrolls like k
|
||||
assert_eq!(e.scroll_top(), top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_query_resets_the_selection_to_the_top() {
|
||||
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md"]);
|
||||
e.handle(Key::Palette);
|
||||
for ch in "md".chars() {
|
||||
e.handle(Key::Char(ch)); // reach the search threshold: both match
|
||||
}
|
||||
e.handle(Key::HalfPageDown);
|
||||
assert_eq!(e.palette_sel, 1);
|
||||
e.handle(Key::Char('a')); // a query edit resets the selection
|
||||
assert_eq!(e.palette_sel, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_on_an_empty_query_closes_the_palette() {
|
||||
let mut e = palette_editor(&["/sd/repo/a.md"]);
|
||||
e.handle(Key::Palette);
|
||||
e.handle(Key::Backspace);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_query_lists_recents_only() {
|
||||
let mut e = palette_editor(&["/sd/repo/b.md", "/sd/repo/a.md", "/sd/repo/c.md"]);
|
||||
// No opens yet: below the search threshold there is nothing to show.
|
||||
assert!(palette_labels(&e).is_empty());
|
||||
// Open c.md through the palette; it becomes the recents-only result.
|
||||
e.handle(Key::Palette);
|
||||
for ch in "c.md".chars() {
|
||||
e.handle(Key::Char(ch));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
e.take_effects(); // drop the queued Load; we only care about the MRU
|
||||
assert_eq!(palette_labels(&e), vec!["repo/c.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_char_query_reveals_the_full_file_list() {
|
||||
let mut e = palette_editor(&["/sd/repo/b.md", "/sd/repo/a.md", "/sd/repo/c.md"]);
|
||||
e.handle(Key::Palette);
|
||||
e.handle(Key::Char('m')); // one char: still recents-only (none yet)
|
||||
assert!(e.palette_matches().is_empty());
|
||||
e.handle(Key::Char('d')); // "md": the full list, fuzzy-ranked
|
||||
assert_eq!(palette_labels(&e), vec!["repo/a.md", "repo/b.md", "repo/c.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recents_float_above_the_full_list_on_a_matching_query() {
|
||||
let mut e = palette_editor(&["/sd/repo/b.md", "/sd/repo/a.md", "/sd/repo/c.md"]);
|
||||
// Open c.md so it is the MRU head.
|
||||
e.handle(Key::Palette);
|
||||
for ch in "c.md".chars() {
|
||||
e.handle(Key::Char(ch));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
e.take_effects();
|
||||
// "md" scores the three labels equally; the stable sort keeps the
|
||||
// recently-opened c.md in front of the sorted rest.
|
||||
e.handle(Key::Palette);
|
||||
for ch in "md".chars() {
|
||||
e.handle(Key::Char(ch));
|
||||
}
|
||||
assert_eq!(palette_labels(&e), vec!["repo/c.md", "repo/a.md", "repo/b.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_in_palette_mode_does_not_panic() {
|
||||
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/local/j.md"]);
|
||||
e.handle(Key::Palette);
|
||||
let _ = e.draw(true); // empty query, no recents: "(type to search)"
|
||||
e.handle(Key::Char('j')); // one char, still below the threshold
|
||||
let _ = e.draw(true);
|
||||
e.handle(Key::Char('m')); // at the threshold: the ranked list
|
||||
let _ = e.draw(true);
|
||||
// Empty file list: the "(no files on card)" path must also be safe.
|
||||
let mut empty = Editor::new();
|
||||
empty.handle(Key::Palette);
|
||||
let _ = empty.draw(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_gt_switches_the_palette_to_command_mode() {
|
||||
let e = palette_type(&["/sd/repo/notes.md"], ">");
|
||||
assert!(e.palette_command_mode());
|
||||
// Every prefs command is offered on a bare `>`.
|
||||
assert_eq!(e.palette_command_matches().len(), PALETTE_CMDS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspacing_the_gt_returns_to_file_mode() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">");
|
||||
assert!(e.palette_command_mode());
|
||||
e.handle(Key::Backspace);
|
||||
assert!(!e.palette_command_mode());
|
||||
assert_eq!(e.mode(), Mode::Palette); // still open, just file mode again
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_filter_fuzzy_matches_the_label() {
|
||||
let e = palette_type(&["/sd/repo/notes.md"], ">line");
|
||||
let matches = e.palette_command_matches();
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert_eq!(PALETTE_CMDS[matches[0]], PaletteCmd::LineNumbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_label_reflects_current_pref_state() {
|
||||
let e = palette_editor(&["/sd/repo/notes.md"]);
|
||||
assert_eq!(e.command_label(PaletteCmd::LineNumbers), "line numbers: on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_a_command_toggles_the_pref_live_and_queues_a_save_prefs() {
|
||||
// >line<Enter> flips line_numbers off, in-core, and asks the host to persist.
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">line");
|
||||
assert!(e.prefs().line_numbers);
|
||||
e.handle(Key::Enter);
|
||||
assert!(!e.prefs().line_numbers); // applied live
|
||||
assert_eq!(e.mode(), Mode::Palette); // stays open for more toggles
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::SavePrefs]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_mode_stays_open_across_multiple_toggles() {
|
||||
// Flip line numbers, then retype the query for save-on-idle and flip that,
|
||||
// without the palette closing between them; each toggle persists. Navigate
|
||||
// by fuzzy filter, not registry position.
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">line");
|
||||
let before = e.prefs().line_numbers;
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.prefs().line_numbers, !before); // applied live
|
||||
assert_eq!(e.mode(), Mode::Palette); // stays open
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::SavePrefs]);
|
||||
// Still open: retype the query for a different pref (">idle" is unique to
|
||||
// "save on idle").
|
||||
for _ in 0.."line".len() {
|
||||
e.handle(Key::Backspace);
|
||||
}
|
||||
for c in "idle".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
let before = e.prefs().save_on_idle;
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.prefs().save_on_idle, !before);
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::SavePrefs]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_prefs_carries_the_serialized_prefs() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">line");
|
||||
e.handle(Key::Enter);
|
||||
let effects = e.take_effects();
|
||||
let Effect::SavePrefs { contents } = &effects[0] else {
|
||||
panic!("expected SavePrefs, got {effects:?}");
|
||||
};
|
||||
// The written TOML reflects the toggled state and round-trips.
|
||||
assert!(!Prefs::parse(contents).line_numbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_a_command_confirms_the_new_state_on_the_snackbar() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">save");
|
||||
e.handle(Key::Enter);
|
||||
// save_on_idle default true -> off; the notice names the new state.
|
||||
assert_eq!(e.notice.as_deref(), Some("save on idle: off - saved"));
|
||||
}
|
||||
|
||||
// ---- Preset (non-boolean) prefs: rotate through options on Enter ----
|
||||
|
||||
#[test]
|
||||
fn next_option_rotates_and_wraps() {
|
||||
assert_eq!(next_option("light", &THEME_OPTIONS), "dark");
|
||||
assert_eq!(next_option("dark", &THEME_OPTIONS), "light"); // wraps
|
||||
assert_eq!(next_option("10m", &AUTO_SYNC_OPTIONS), "15m");
|
||||
assert_eq!(next_option("30m", &AUTO_SYNC_OPTIONS), "2m"); // wraps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_option_snaps_an_unknown_value_to_the_head() {
|
||||
// A hand-typed value outside the preset list lands on the first option.
|
||||
assert_eq!(next_option("sepia", &THEME_OPTIONS), "light");
|
||||
assert_eq!(next_option("7m", &AUTO_SYNC_OPTIONS), "2m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_command_label_reflects_the_current_value() {
|
||||
let e = palette_editor(&["/sd/repo/notes.md"]);
|
||||
assert_eq!(e.command_label(PaletteCmd::Theme), "theme: light");
|
||||
assert_eq!(e.command_label(PaletteCmd::AutoSync), "auto sync: 10m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_the_theme_command_rotates_the_preset_and_queues_a_save_prefs() {
|
||||
// >theme<Enter> flips light -> dark, in-core, and asks the host to persist.
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">theme");
|
||||
assert_eq!(e.prefs().theme, "light");
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.prefs().theme, "dark"); // applied live
|
||||
assert_eq!(e.mode(), Mode::Palette); // stays open for more changes
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::SavePrefs]);
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.prefs().theme, "light"); // rotates back, wrapping
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_the_auto_sync_command_walks_the_interval_presets() {
|
||||
// Default 10m; Enter rotates 10m -> 15m -> 30m -> 2m (wrap).
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">auto");
|
||||
assert_eq!(e.prefs().auto_sync, "10m");
|
||||
for expected in ["15m", "30m", "2m"] {
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.prefs().auto_sync, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_theme_inverts_the_whole_frame() {
|
||||
// The dark frame is the exact bitwise inverse of the light one.
|
||||
let mut e = Editor::with_text("hello world".into());
|
||||
e.caret = 0;
|
||||
let light = e.draw(true).bytes().to_vec();
|
||||
e.prefs.theme = "dark".into();
|
||||
let dark = e.draw(true).bytes().to_vec();
|
||||
assert_eq!(light.len(), dark.len());
|
||||
assert!(light.iter().zip(&dark).all(|(l, d)| *l == !*d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_match_command_query_runs_nothing() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">zzzzz");
|
||||
assert!(e.palette_command_matches().is_empty());
|
||||
let before = e.prefs().clone();
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.prefs(), &before); // nothing toggled
|
||||
assert!(e.take_effects().is_empty()); // nothing queued
|
||||
assert_eq!(e.mode(), Mode::Palette); // stays open so the query can be fixed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_n_moves_the_command_selection_wrapping() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">");
|
||||
for _ in 0..PALETTE_CMDS.len() - 1 {
|
||||
e.handle(Key::Down); // Ctrl-N down to the last command
|
||||
}
|
||||
assert_eq!(e.palette_sel, PALETTE_CMDS.len() - 1);
|
||||
e.handle(Key::Down); // wraps back to the top
|
||||
assert_eq!(e.palette_sel, 0);
|
||||
e.handle(Key::Up); // and Ctrl-P wraps back to the bottom
|
||||
assert_eq!(e.palette_sel, PALETTE_CMDS.len() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_command_opens_the_palette_in_command_mode() {
|
||||
let (e, _) = command("settings");
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
assert!(e.palette_command_mode()); // dropped straight into `>` mode
|
||||
assert_eq!(e.palette_command_matches().len(), PALETTE_CMDS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_in_command_mode_does_not_panic() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">");
|
||||
let _ = e.draw(true);
|
||||
let mut none = palette_type(&["/sd/repo/notes.md"], ">zzzzz"); // "(no command)"
|
||||
let _ = none.draw(true);
|
||||
}
|
||||
|
||||
// ---- `>` command palette generalisation (v0.6) ----
|
||||
|
||||
#[test]
|
||||
fn command_labels_for_the_new_actions() {
|
||||
let e = Editor::new();
|
||||
assert_eq!(e.command_label(PaletteCmd::NewFile), "new file...");
|
||||
assert_eq!(e.command_label(PaletteCmd::Format), "format");
|
||||
assert_eq!(e.command_label(PaletteCmd::Publish), "publish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_command_runs_and_closes() {
|
||||
// A one-shot: it formats the buffer (extra trailing blanks collapse) and
|
||||
// closes the palette — and, being an action not a toggle, queues no
|
||||
// SavePrefs. `>format` ranks the action above `format on save` (registry
|
||||
// order breaks the tie).
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "a\n\n\n".into());
|
||||
e.handle(Key::Palette);
|
||||
for c in ">format".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.text(), "a\n"); // formatted → not the FormatOnSave toggle
|
||||
assert_eq!(e.mode(), Mode::Normal); // one-shot closes
|
||||
assert!(!kinds(&e.take_effects()).contains(&Kind::SavePrefs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_command_saves_and_pushes_then_closes() {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "hi".into());
|
||||
e.handle(Key::Palette);
|
||||
for c in ">publish".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::Save, Kind::Publish]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_command_is_unavailable_in_a_local_buffer() {
|
||||
let mut e = Editor::with_file("/sd/local/j.md".into(), Scope::Local, "hi".into());
|
||||
e.handle(Key::Palette);
|
||||
for c in ">publish".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert!(e.take_effects().is_empty()); // Local never reaches the remote
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_file_command_opens_the_filename_input_step() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">new");
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.mode(), Mode::Palette); // still open — now the input step
|
||||
assert_eq!(e.palette_step, PaletteStep::NewFile);
|
||||
assert!(e.palette_query.is_empty()); // the list filter gave way to the name
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_file_step_creates_the_typed_file_and_switches() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">new");
|
||||
e.handle(Key::Enter); // → input step
|
||||
for c in "draft.md".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.mode(), Mode::Normal); // committed and closed
|
||||
assert_eq!(e.path(), "/sd/repo/draft.md"); // scope inherited from notes.md
|
||||
assert!(e.dirty()); // a fresh, unsaved file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_file_step_backspace_returns_to_the_command_list() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">new");
|
||||
e.handle(Key::Enter); // input step, empty name
|
||||
e.handle(Key::Backspace); // nothing to erase → step back to the `>` list
|
||||
assert_eq!(e.palette_step, PaletteStep::List);
|
||||
assert!(e.palette_command_mode()); // query restored to ">"
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_file_step_empty_enter_stays_in_the_step() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">new");
|
||||
e.handle(Key::Enter); // input step
|
||||
e.handle(Key::Enter); // empty name → no-op, still awaiting one
|
||||
assert_eq!(e.palette_step, PaletteStep::NewFile);
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
assert_eq!(e.path(), "/sd/repo/notes.md"); // unchanged
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_in_new_file_step_does_not_panic() {
|
||||
let mut e = palette_type(&["/sd/repo/notes.md"], ">new");
|
||||
e.handle(Key::Enter);
|
||||
let _ = e.draw(true); // empty-name placeholder path
|
||||
for c in "draft.md".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
let _ = e.draw(true); // with a typed name
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_command_is_retired() {
|
||||
// `:e` no longer opens files — bare Cmd-P does. `:e foo` is now a no-op.
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
|
||||
ex(&mut e, "e /sd/repo/b.md");
|
||||
assert_eq!(e.path(), "/sd/repo/a.md"); // unchanged
|
||||
assert!(e.take_effects().is_empty()); // nothing queued
|
||||
}
|
||||
96
editor/src/tests/prefs.rs
Normal file
96
editor/src/tests/prefs.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! Preferences (`.typoena.toml`) and the live gutter toggle.
|
||||
|
||||
use super::*;
|
||||
|
||||
// ---- Preferences (.typoena.toml) ----
|
||||
|
||||
#[test]
|
||||
fn prefs_default_matches_the_documented_defaults() {
|
||||
let p = Prefs::default();
|
||||
assert!(p.save_on_idle);
|
||||
assert!(p.format_on_save);
|
||||
assert!(p.line_numbers);
|
||||
assert!(p.open_last_on_boot);
|
||||
assert_eq!(p.theme, "light");
|
||||
assert_eq!(p.auto_sync, "10m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_parse_falls_back_to_defaults_for_missing_keys() {
|
||||
// Only one key present; the rest stay at their defaults.
|
||||
let p = Prefs::parse("line_numbers = false\n");
|
||||
assert!(!p.line_numbers);
|
||||
assert!(p.save_on_idle); // untouched -> default
|
||||
assert!(p.format_on_save);
|
||||
assert_eq!(p.auto_sync, "10m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_parse_reads_all_keys_and_ignores_comments_and_junk() {
|
||||
let src = "\
|
||||
# a header comment\n\
|
||||
save_on_idle = false # trailing comment\n\
|
||||
format_on_save = false\n\
|
||||
line_numbers = false\n\
|
||||
open_last_on_boot = false\n\
|
||||
auto_sync = \"2m\"\n\
|
||||
bogus_key = whatever\n\
|
||||
not a pair\n";
|
||||
let p = Prefs::parse(src);
|
||||
assert!(!p.save_on_idle);
|
||||
assert!(!p.format_on_save);
|
||||
assert!(!p.line_numbers);
|
||||
assert!(!p.open_last_on_boot);
|
||||
assert_eq!(p.auto_sync, "2m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_parse_keeps_default_on_an_unparseable_bool() {
|
||||
// A typo in a bool value leaves that key at its default, not `false`.
|
||||
let p = Prefs::parse("save_on_idle = yes\n");
|
||||
assert!(p.save_on_idle); // "yes" isn't a TOML bool -> default (true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_to_toml_round_trips_through_parse() {
|
||||
let p = Prefs {
|
||||
save_on_idle: false,
|
||||
format_on_save: true,
|
||||
line_numbers: false,
|
||||
open_last_on_boot: false,
|
||||
theme: "dark".into(),
|
||||
auto_sync: "5m".into(),
|
||||
};
|
||||
assert_eq!(Prefs::parse(&p.to_toml()), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_parse_reads_theme_and_auto_sync_strings() {
|
||||
let p = Prefs::parse("theme = \"dark\"\nauto_sync = \"15m\"\n");
|
||||
assert_eq!(p.theme, "dark");
|
||||
assert_eq!(p.auto_sync, "15m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prefs_file_yields_defaults() {
|
||||
assert_eq!(Prefs::parse(""), Prefs::default());
|
||||
}
|
||||
|
||||
// ---- line_numbers pref (live gutter toggle) ----
|
||||
|
||||
#[test]
|
||||
fn line_numbers_off_reclaims_the_gutter_columns() {
|
||||
let mut e = Editor::with_text("one\ntwo\nthree".into());
|
||||
assert!(e.text_cols() < WRITE_COLS); // gutter present by default
|
||||
e.prefs.line_numbers = false;
|
||||
assert_eq!(e.gutter_cols(), 0);
|
||||
assert_eq!(e.text_cols(), WRITE_COLS); // full width reclaimed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_with_line_numbers_off_does_not_panic() {
|
||||
// The `gutter - 1` field width would underflow if unguarded.
|
||||
let mut e = Editor::with_text("alpha\nbeta\ngamma".into());
|
||||
e.prefs.line_numbers = false;
|
||||
let _ = e.draw(true);
|
||||
}
|
||||
52
editor/src/tests/pull.rs
Normal file
52
editor/src/tests/pull.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! `:gl` pull support.
|
||||
|
||||
use super::*;
|
||||
|
||||
// --- `:gl` pull support (v0.7) ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn refresh_active_replaces_text_and_resets_state() {
|
||||
let mut e = over("old text");
|
||||
e.handle(Key::Char('v')); // some transient state to reset
|
||||
e.refresh_active("pulled text".into());
|
||||
assert_eq!(e.text, "pulled text");
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert!(!e.dirty());
|
||||
assert!(e.undo.is_empty()); // old snapshots reference the old text
|
||||
assert_eq!(e.path(), "/sd/repo/notes.md"); // same file, new contents
|
||||
assert_eq!(e.caret, 10); // boot posture: caret on the last char
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joined_file_list_sorts_dedups_and_survives_blank_lines() {
|
||||
let mut e = Editor::new();
|
||||
e.set_file_list_joined("/sd/repo/b.md\n\n/sd/repo/a.md\n/sd/repo/b.md\n".into());
|
||||
assert_eq!(files_vec(&e), vec!["/sd/repo/a.md", "/sd/repo/b.md"]);
|
||||
e.add_to_file_list("/sd/repo/ab.md"); // lands between, sorted
|
||||
e.add_to_file_list("/sd/repo/a.md"); // already known — no dup
|
||||
assert_eq!(
|
||||
files_vec(&e),
|
||||
vec!["/sd/repo/a.md", "/sd/repo/ab.md", "/sd/repo/b.md"]
|
||||
);
|
||||
e.remove_from_file_list("/sd/repo/b.md");
|
||||
assert_eq!(files_vec(&e), vec!["/sd/repo/a.md", "/sd/repo/ab.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_clean_parked_keeps_only_dirty_buffers() {
|
||||
let mut e = over("one"); // active: notes.md, clean
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "enew /sd/repo/b.md".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter); // notes.md parked (clean); b.md active (dirty by design)
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "enew /sd/repo/c.md".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter); // b.md parked (dirty); c.md active
|
||||
assert_eq!(e.parked.len(), 2);
|
||||
e.drop_clean_parked();
|
||||
let kept: Vec<&str> = e.parked.iter().map(|b| b.path.as_str()).collect();
|
||||
assert_eq!(kept, ["/sd/repo/b.md"]); // clean notes.md dropped, dirty b.md kept
|
||||
}
|
||||
125
editor/src/tests/render.rs
Normal file
125
editor/src/tests/render.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! Viewport rendering: half-page scroll and the absolute line-number gutter.
|
||||
|
||||
use super::*;
|
||||
|
||||
// ---- Ctrl-d / Ctrl-u half-page scroll (v0.2) ----
|
||||
|
||||
/// The core reason this isn't `HALF_PAGE × move_down`: on one long paragraph
|
||||
/// that soft-wraps, half-page-down steps *display* rows, advancing the caret
|
||||
/// half a window into the wrap — whereas `j` (logical-line) can't move
|
||||
/// within the single line at all.
|
||||
#[test]
|
||||
fn half_page_down_steps_display_rows_within_a_wrapped_line() {
|
||||
let mut e = Editor::with_text("a".repeat(WRITE_COLS * 10)); // one long wrapped line
|
||||
let cols = e.text_cols(); // wrap width shrinks by the gutter
|
||||
e.caret = 0;
|
||||
e.handle(Key::HalfPageDown);
|
||||
assert_eq!(e.caret, cols * HALF_PAGE); // down HALF_PAGE *display* rows
|
||||
|
||||
// Contrast: `j` on the same single logical line is a no-op.
|
||||
let mut j = Editor::with_text("a".repeat(WRITE_COLS * 10));
|
||||
j.caret = 0;
|
||||
j.handle(Key::Char('j'));
|
||||
assert_eq!(j.caret, 0);
|
||||
}
|
||||
|
||||
/// Up is the inverse of down within a wrapped line.
|
||||
#[test]
|
||||
fn half_page_up_is_the_inverse_within_a_wrapped_line() {
|
||||
let mut e = Editor::with_text("a".repeat(WRITE_COLS * 10));
|
||||
e.caret = e.text_cols() * HALF_PAGE; // start on a display-row boundary
|
||||
e.handle(Key::HalfPageUp);
|
||||
assert_eq!(e.caret, 0);
|
||||
}
|
||||
|
||||
/// Clamps at both ends: up from the top stays; down past the bottom lands on
|
||||
/// the last row on a character boundary, never out of range.
|
||||
#[test]
|
||||
fn half_page_clamps_at_both_ends() {
|
||||
let mut e = Editor::with_text("a".repeat(WRITE_COLS * 3)); // 3 rows
|
||||
e.caret = 0;
|
||||
e.handle(Key::HalfPageUp);
|
||||
assert_eq!(e.caret, 0);
|
||||
e.handle(Key::HalfPageDown);
|
||||
e.handle(Key::HalfPageDown);
|
||||
assert!(e.caret <= e.text.len());
|
||||
assert!(e.text.is_char_boundary(e.caret));
|
||||
}
|
||||
|
||||
/// The viewport follows the caret past the window: after enough half-pages,
|
||||
/// `scroll_top` advances (in draw) and the caret stays visible.
|
||||
#[test]
|
||||
fn half_page_down_scrolls_the_viewport() {
|
||||
let text = vec!["a"; 40].join("\n"); // 40 one-char lines = 40 display rows
|
||||
let mut e = Editor::with_text(text);
|
||||
e.caret = 0;
|
||||
for _ in 0..4 {
|
||||
e.handle(Key::HalfPageDown);
|
||||
}
|
||||
e.draw(true); // adjust_scroll runs here
|
||||
assert!(e.scroll_top() > 0, "viewport should have scrolled");
|
||||
let lay = e.layout();
|
||||
let (row, _) = e.caret_rc(&lay);
|
||||
assert!(row >= e.scroll_top() && row < e.scroll_top() + ROWS);
|
||||
}
|
||||
|
||||
/// In View mode (read-only) half-page moves the viewport directly and leaves
|
||||
/// the caret alone.
|
||||
#[test]
|
||||
fn half_page_scrolls_viewport_in_view_mode() {
|
||||
let mut e = Editor::with_text(vec!["a"; 40].join("\n"));
|
||||
let caret_before = e.caret;
|
||||
e.handle(Key::Char('g')); // `gr` -> View (v/V are now Visual)
|
||||
e.handle(Key::Char('r'));
|
||||
assert_eq!(e.mode(), Mode::View);
|
||||
e.handle(Key::HalfPageDown);
|
||||
assert_eq!(e.scroll_top(), HALF_PAGE);
|
||||
assert_eq!(e.caret, caret_before); // caret untouched in View
|
||||
e.handle(Key::HalfPageUp);
|
||||
assert_eq!(e.scroll_top(), 0);
|
||||
}
|
||||
|
||||
/// Inert in Insert mode — it must not yank the caret off the text you're
|
||||
/// typing.
|
||||
#[test]
|
||||
fn half_page_is_a_noop_in_insert_mode() {
|
||||
let mut e = Editor::with_text(vec!["a"; 40].join("\n"));
|
||||
e.caret = 0;
|
||||
e.handle(Key::Char('i')); // Normal -> Insert
|
||||
e.handle(Key::HalfPageDown);
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.mode(), Mode::Insert);
|
||||
}
|
||||
|
||||
// ---- Absolute line-number gutter (v0.2) ----
|
||||
|
||||
#[test]
|
||||
fn gutter_is_two_digits_plus_separator_for_small_files() {
|
||||
let e = Editor::with_text("one\ntwo\nthree".to_string()); // 3 logical lines
|
||||
assert_eq!(e.logical_lines(), 3);
|
||||
assert_eq!(e.gutter_cols(), 3); // 2 digit cols + 1 separator
|
||||
assert_eq!(e.text_cols(), WRITE_COLS - 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gutter_widens_past_ninety_nine_lines() {
|
||||
let e = Editor::with_text("x\n".repeat(120)); // 121 logical lines
|
||||
assert_eq!(e.gutter_cols(), 4); // 3 digit cols + 1 separator
|
||||
assert_eq!(e.text_cols(), WRITE_COLS - 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gutter_narrows_the_soft_wrap_width() {
|
||||
let e = Editor::with_text("a".repeat(WRITE_COLS)); // 60 chars, one logical line
|
||||
let cols = e.text_cols();
|
||||
assert!(cols < WRITE_COLS); // the gutter stole columns
|
||||
let lay = e.layout();
|
||||
assert_eq!(lay[0].text.chars().count(), cols); // first row fills the text width
|
||||
assert!(lay.len() >= 2); // 60 chars no longer fit one row
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_with_gutter_produces_a_full_frame() {
|
||||
let mut e = Editor::with_text("line one\nline two\nline three".to_string());
|
||||
assert_eq!(e.draw(true).bytes().len(), display::FB_BYTES);
|
||||
}
|
||||
182
editor/src/tests/search.rs
Normal file
182
editor/src/tests/search.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
//! `/` search and n/N repeat.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slash_opens_the_search_prompt_and_esc_cancels() {
|
||||
let mut e = over("alpha beta");
|
||||
e.handle(Key::Char('/'));
|
||||
assert_eq!(e.mode(), Mode::Command); // command-line mode, `/` prompt
|
||||
e.handle(Key::Char('b'));
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert_eq!(e.caret, 0); // cancelled search never moves the caret
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_jumps_past_the_caret_to_the_next_match() {
|
||||
let mut e = over("alpha beta alpha");
|
||||
search(&mut e, "alpha");
|
||||
// The caret sits on the first "alpha"; search starts *after* it.
|
||||
assert_eq!(e.caret, 11);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_wraps_to_the_top_with_a_notice() {
|
||||
let mut e = over("alpha beta");
|
||||
search(&mut e, "beta"); // caret → 6
|
||||
search(&mut e, "alpha"); // no match after 6 → wraps to 0
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.notice.as_deref(), Some("wrapped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_not_found_keeps_the_caret_and_says_so() {
|
||||
let mut e = over("alpha beta");
|
||||
search(&mut e, "gamma");
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.notice.as_deref(), Some("not found: gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_repeats_forward_and_wraps() {
|
||||
let mut e = over("ab x ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
assert_eq!(e.caret, 5);
|
||||
e.handle(Key::Char('n')); // → 10
|
||||
assert_eq!(e.caret, 10);
|
||||
e.handle(Key::Char('n')); // wraps → 0
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.notice.as_deref(), Some("wrapped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capital_n_repeats_backward_and_wraps() {
|
||||
let mut e = over("ab x ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
e.handle(Key::Char('N')); // back → 0
|
||||
assert_eq!(e.caret, 0);
|
||||
e.handle(Key::Char('N')); // wraps to the last match → 10
|
||||
assert_eq!(e.caret, 10);
|
||||
assert_eq!(e.notice.as_deref(), Some("wrapped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_applies_to_n() {
|
||||
let mut e = over("ab ab ab ab");
|
||||
search(&mut e, "ab"); // → 3
|
||||
e.handle(Key::Char('2'));
|
||||
e.handle(Key::Char('n')); // 2 matches forward → 9
|
||||
assert_eq!(e.caret, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_without_a_previous_search_says_so() {
|
||||
let mut e = over("alpha");
|
||||
e.handle(Key::Char('n'));
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.notice.as_deref(), Some("no previous search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_slash_repeats_the_last_search() {
|
||||
let mut e = over("ab x ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
search(&mut e, ""); // bare `/` Enter reuses "ab" → 10
|
||||
assert_eq!(e.caret, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowercase_search_is_case_insensitive() {
|
||||
let mut e = over("x Alpha alpha");
|
||||
search(&mut e, "alpha");
|
||||
assert_eq!(e.caret, 2); // "Alpha" matches "alpha"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smartcase_a_capital_makes_the_search_exact() {
|
||||
let mut e = over("x paris Paris");
|
||||
search(&mut e, "Paris"); // capital → case-sensitive
|
||||
assert_eq!(e.caret, 8); // skips the lowercase "paris"
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
search(&mut e, "paris"); // all-lowercase → insensitive again
|
||||
assert_eq!(e.caret, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_folds_accents_both_ways() {
|
||||
let mut e = over("x Été bien"); // 'É' (2 bytes) folds to 'e'
|
||||
search(&mut e, "été");
|
||||
assert_eq!(e.caret, 2);
|
||||
assert_eq!(&e.text[e.caret..e.caret + 5], "Été");
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
search(&mut e, "ete"); // bare ascii finds the accented word too
|
||||
assert_eq!(e.caret, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smartcase_still_folds_accents() {
|
||||
let mut e = over("x ete Ete");
|
||||
search(&mut e, "Été"); // capital É → case-sensitive, but é still = e
|
||||
assert_eq!(e.caret, 6); // matches "Ete", not "ete"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_search_is_case_insensitive() {
|
||||
let mut e = over("Alpha x alpha");
|
||||
search(&mut e, "alpha"); // → 8 (past the caret on 'A')
|
||||
assert_eq!(e.caret, 8);
|
||||
e.handle(Key::Char('N')); // back → the capitalized one at 0
|
||||
assert_eq!(e.caret, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_lands_on_char_boundaries_in_multibyte_text() {
|
||||
let mut e = over("héé ém"); // 'é' is 2 bytes
|
||||
search(&mut e, "ém");
|
||||
assert_eq!(e.caret, 6); // byte offset of the standalone "ém"
|
||||
assert_eq!(&e.text[e.caret..e.caret + 3], "ém");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_extends_a_visual_selection() {
|
||||
let mut e = over("ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
e.handle(Key::Char('N')); // back → 0
|
||||
e.handle(Key::Char('v')); // Visual, anchor at 0
|
||||
e.handle(Key::Char('n')); // extend to the next match
|
||||
assert_eq!(e.mode(), Mode::Visual);
|
||||
assert_eq!(e.caret, 5);
|
||||
e.handle(Key::Char('y')); // yank the span (inclusive of the caret char)
|
||||
assert_eq!(e.register, "ab x a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_search_survives_a_buffer_switch() {
|
||||
let mut e = over("ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "enew /sd/repo/other.md".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
e.handle(Key::Char('i'));
|
||||
for c in "ab cd ab".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Escape);
|
||||
e.handle(Key::Char('0'));
|
||||
e.handle(Key::Char('n')); // the pattern is editor-global, like vim
|
||||
assert_eq!(e.caret, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_prompt_draws_without_panic() {
|
||||
let mut e = over("alpha");
|
||||
e.handle(Key::Char('/'));
|
||||
e.handle(Key::Char('a'));
|
||||
let _ = e.draw(true);
|
||||
}
|
||||
284
editor/src/tests/snippets.rs
Normal file
284
editor/src/tests/snippets.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! Snippets, the `$` snippet palette, and hint-on-pause.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_labels_reduces_placeholders_to_bare_stops() {
|
||||
assert_eq!(strip_stop_labels("# ${1:Titre}"), "# $1");
|
||||
assert_eq!(strip_stop_labels("${2}"), "$2");
|
||||
assert_eq!(strip_stop_labels("[$1]($2)$0"), "[$1]($2)$0"); // plain stops untouched
|
||||
assert_eq!(strip_stop_labels("price: $ and ${3:x}"), "price: $ and $3"); // lone $ kept
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_body_extracts_literal_and_visit_order() {
|
||||
let (lit, stops) = parse_snippet_body("[$1]($2)$0");
|
||||
assert_eq!(lit, "[]()");
|
||||
assert_eq!(stops, vec![1, 3, 4]); // $1, $2, then $0 (end) last
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_body_appends_implicit_final_stop_when_no_zero() {
|
||||
let (lit, stops) = parse_snippet_body("# $1\n## $2");
|
||||
assert_eq!(lit, "# \n## ");
|
||||
assert_eq!(stops, vec![2, 6, 6]); // $1, $2, implicit rest at the end
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_body_no_stops_has_empty_visit_list() {
|
||||
let (lit, stops) = parse_snippet_body("- [ ] ");
|
||||
assert_eq!(lit, "- [ ] ");
|
||||
assert!(stops.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_snippets_reads_zed_json_string_and_array_bodies() {
|
||||
// r###"…"### so the `"#`/`"##` in the heading bodies don't close the string.
|
||||
let json = r###"{
|
||||
"Link": { "prefix": "link", "body": "[$1]($2)$0", "description": "Inline link" },
|
||||
"Book notes": { "prefix": "booknotes", "body": ["# ${1:Titre}", "## $2"] }
|
||||
}"###;
|
||||
let s = Snippets::parse(json).unwrap().0;
|
||||
assert_eq!(s.len(), 2);
|
||||
// BTreeMap parse → sorted by display name ("Book notes" < "Link").
|
||||
assert_eq!(s[0].name, "Book notes");
|
||||
assert_eq!(s[0].prefix, "booknotes");
|
||||
assert_eq!(s[0].body, "# $1\n## $2"); // array joined with \n, label stripped
|
||||
assert_eq!(s[0].description, ""); // omitted → empty
|
||||
assert_eq!(s[1].name, "Link");
|
||||
assert_eq!(s[1].body, "[$1]($2)$0");
|
||||
assert_eq!(s[1].description, "Inline link");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_snippets_empty_and_malformed() {
|
||||
assert!(Snippets::parse("{}").unwrap().0.is_empty());
|
||||
assert!(Snippets::parse("").unwrap().0.is_empty()); // empty file = no snippets
|
||||
assert!(Snippets::parse(" \n\t").unwrap().0.is_empty()); // whitespace-only too
|
||||
assert!(Snippets::parse("{ not json").is_err()); // host logs, boots with none
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_expands_prefix_and_lands_on_first_stop() {
|
||||
let mut e = with_snippets(r#"{ "Link": { "prefix": "link", "body": "[$1]($2)$0" } }"#);
|
||||
e.handle(Key::Char('i'));
|
||||
for c in "link".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Char('\t')); // expand
|
||||
assert_eq!(e.text, "[]()"); // trigger word replaced by the expansion
|
||||
assert_eq!(e.caret, 1); // caret on $1
|
||||
assert_eq!(e.mode(), Mode::Insert);
|
||||
assert_eq!(e.snippet_stops, vec![3, 4]); // $2 then $0 pending
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_advances_stops_and_typing_shifts_pending_ones() {
|
||||
let mut e = with_snippets(r#"{ "Link": { "prefix": "link", "body": "[$1]($2)$0" } }"#);
|
||||
e.handle(Key::Char('i'));
|
||||
for c in "link".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Char('\t'));
|
||||
for c in "url".chars() {
|
||||
e.handle(Key::Char(c)); // type at $1
|
||||
}
|
||||
assert_eq!(e.text, "[url]()");
|
||||
assert_eq!(e.snippet_stops, vec![6, 7]); // pending shifted by +3
|
||||
e.handle(Key::Char('\t')); // → $2
|
||||
assert_eq!(e.caret, 6);
|
||||
assert_eq!(e.snippet_stops, vec![7]);
|
||||
for c in "http".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
assert_eq!(e.text, "[url](http)");
|
||||
e.handle(Key::Char('\t')); // → $0 (end); session ends
|
||||
assert_eq!(e.caret, 11);
|
||||
assert!(e.snippet_stops.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_ends_the_snippet_session() {
|
||||
let mut e = with_snippets(r#"{ "Link": { "prefix": "link", "body": "[$1]($2)$0" } }"#);
|
||||
e.handle(Key::Char('i'));
|
||||
for c in "link".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Char('\t'));
|
||||
assert!(!e.snippet_stops.is_empty());
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert!(e.snippet_stops.is_empty(), "leaving Insert ends the session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_without_matching_prefix_inserts_spaces() {
|
||||
let mut e = with_snippets(r#"{ "Link": { "prefix": "link", "body": "[$1]($2)$0" } }"#);
|
||||
e.handle(Key::Char('i'));
|
||||
for c in "zzz".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Char('\t'));
|
||||
assert!(e.text.starts_with("zzz"));
|
||||
assert!(e.text.len() > 3, "tab inserted whitespace, not an expansion");
|
||||
assert!(e.snippet_stops.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_stop_snippet_expands_without_a_session() {
|
||||
let mut e = with_snippets(r#"{ "Todo": { "prefix": "todo", "body": "- [ ] " } }"#);
|
||||
e.handle(Key::Char('i'));
|
||||
for c in "todo".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Char('\t'));
|
||||
assert_eq!(e.text, "- [ ] ");
|
||||
assert_eq!(e.caret, 6); // caret at the end, no session
|
||||
assert!(e.snippet_stops.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_after_expansion_restores_the_trigger_word() {
|
||||
let mut e = with_snippets(r#"{ "Link": { "prefix": "link", "body": "[$1]($2)$0" } }"#);
|
||||
e.handle(Key::Char('i'));
|
||||
for c in "link".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Char('\t'));
|
||||
assert_eq!(e.text, "[]()");
|
||||
e.handle(Key::Escape);
|
||||
e.handle(Key::Char('u')); // undo the whole expansion
|
||||
assert_eq!(e.text, "link");
|
||||
}
|
||||
|
||||
// ---- `$` snippet palette ----
|
||||
|
||||
// r##"…"## so the `"#` in the `"# $1"` heading body doesn't close the string.
|
||||
|
||||
#[test]
|
||||
fn dollar_switches_the_palette_to_snippet_mode() {
|
||||
let e = snippet_palette(TWO_SNIPPETS, "$");
|
||||
assert!(e.palette_snippet_mode());
|
||||
assert!(!e.palette_command_mode());
|
||||
// A bare `$` lists every snippet.
|
||||
assert_eq!(e.palette_snippet_matches().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspacing_the_dollar_returns_to_file_mode() {
|
||||
let mut e = snippet_palette(TWO_SNIPPETS, "$");
|
||||
assert!(e.palette_snippet_mode());
|
||||
e.handle(Key::Backspace);
|
||||
assert!(!e.palette_snippet_mode());
|
||||
assert_eq!(e.mode(), Mode::Palette); // still open, file mode again
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_filter_fuzzy_matches_name_prefix_and_description() {
|
||||
// Query the prefix.
|
||||
let e = snippet_palette(TWO_SNIPPETS, "$link");
|
||||
let m = e.palette_snippet_matches();
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(e.snippets[m[0]].name, "Markdown link");
|
||||
// Query a word only in the *description* ("fiche") finds the other one.
|
||||
let e = snippet_palette(TWO_SNIPPETS, "$fiche");
|
||||
let m = e.palette_snippet_matches();
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(e.snippets[m[0]].name, "Book notes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_snippet_mode_inserts_and_starts_the_session() {
|
||||
let mut e = snippet_palette(TWO_SNIPPETS, "$link");
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(e.mode(), Mode::Insert); // dropped into the buffer at $1
|
||||
assert_eq!(e.text, "[]()");
|
||||
assert_eq!(e.caret, 1); // on $1
|
||||
assert_eq!(e.snippet_stops, vec![3, 4]); // $2 then $0 pending
|
||||
// Inserting content closes the palette (unlike a `>` toggle, which stays).
|
||||
// A follow-up Esc must leave Insert, not reopen anything.
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_palette_insertion_is_one_undo_group() {
|
||||
let mut e = snippet_palette(TWO_SNIPPETS, "$link");
|
||||
e.handle(Key::Enter);
|
||||
e.handle(Key::Escape); // end the session, back to Normal
|
||||
e.handle(Key::Char('u')); // undo the whole insertion
|
||||
assert_eq!(e.text, ""); // buffer restored to its pre-insert state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_n_wraps_around_the_snippet_result_list() {
|
||||
let mut e = snippet_palette(TWO_SNIPPETS, "$");
|
||||
e.handle(Key::Down);
|
||||
assert_eq!(e.palette_sel, 1); // two snippets → last index is 1
|
||||
e.handle(Key::Down); // past the end — wraps to the top
|
||||
assert_eq!(e.palette_sel, 0);
|
||||
e.handle(Key::Up); // before the top — wraps to the bottom
|
||||
assert_eq!(e.palette_sel, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_snippet_library_matches_nothing() {
|
||||
let mut e = Editor::new(); // no snippets set
|
||||
e.handle(Key::Palette);
|
||||
e.handle(Key::Char('$'));
|
||||
assert!(e.palette_snippet_mode());
|
||||
assert!(e.palette_snippet_matches().is_empty());
|
||||
e.handle(Key::Enter); // no-op, stays open
|
||||
assert_eq!(e.mode(), Mode::Palette);
|
||||
let _ = e.draw(true); // "(no snippets)" path must not panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_in_snippet_mode_does_not_panic() {
|
||||
let mut e = snippet_palette(TWO_SNIPPETS, "$");
|
||||
let _ = e.draw(true);
|
||||
let mut filtered = snippet_palette(TWO_SNIPPETS, "$link");
|
||||
let _ = filtered.draw(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_hint_names_the_snippet_a_prefix_would_expand() {
|
||||
let mut e = typed_in_insert("link");
|
||||
assert_eq!(e.snippet_hint, None); // not computed per keystroke
|
||||
e.refresh_stats(); // the typing-pause throttle
|
||||
assert_eq!(e.snippet_hint.as_deref(), Some("Markdown link"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_hint_is_absent_without_a_matching_prefix() {
|
||||
let mut e = typed_in_insert("zz");
|
||||
e.refresh_stats();
|
||||
assert_eq!(e.snippet_hint, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_hint_clears_when_leaving_insert() {
|
||||
let mut e = typed_in_insert("link");
|
||||
e.refresh_stats();
|
||||
assert!(e.snippet_hint.is_some());
|
||||
e.handle(Key::Escape); // → Normal
|
||||
e.refresh_stats(); // the main loop refreshes on non-Insert actions
|
||||
assert_eq!(e.snippet_hint, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_hint_is_absent_during_a_live_session() {
|
||||
let mut e = typed_in_insert("link");
|
||||
e.handle(Key::Char('\t')); // expand → session live, caret on $1
|
||||
assert!(!e.snippet_stops.is_empty());
|
||||
e.refresh_stats();
|
||||
assert_eq!(e.snippet_hint, None, "mid-session Tab advances, not expands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_with_a_pause_hint_does_not_panic() {
|
||||
let mut e = typed_in_insert("link");
|
||||
e.refresh_stats();
|
||||
assert!(e.snippet_hint.is_some());
|
||||
let _ = e.draw(true); // the `» name` panel row must render cleanly
|
||||
}
|
||||
104
editor/src/tests/undo.rs
Normal file
104
editor/src/tests/undo.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! Undo / redo history.
|
||||
|
||||
use super::*;
|
||||
|
||||
// ---- Undo / redo (v0.3) ----
|
||||
|
||||
#[test]
|
||||
fn undo_reverts_a_whole_insert_session_at_once() {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char('i'));
|
||||
for c in "hello".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.text(), "hello");
|
||||
e.handle(Key::Char('u'));
|
||||
assert_eq!(e.text(), ""); // the entire typed run, not one char
|
||||
assert_eq!(e.mode(), Mode::Normal); // undo always lands in Normal
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redo_reapplies_an_undone_change() {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char('i'));
|
||||
e.handle(Key::Char('x'));
|
||||
e.handle(Key::Escape); // "x"
|
||||
e.handle(Key::Char('u'));
|
||||
assert_eq!(e.text(), "");
|
||||
e.handle(Key::Redo); // Ctrl-r
|
||||
assert_eq!(e.text(), "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_reverts_dd() {
|
||||
let mut e = Editor::with_text("one\ntwo".to_string());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('d'));
|
||||
e.handle(Key::Char('d'));
|
||||
assert_eq!(e.text(), "two");
|
||||
e.handle(Key::Char('u'));
|
||||
assert_eq!(e.text(), "one\ntwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_reverts_x_and_restores_the_caret() {
|
||||
let mut e = Editor::with_text("abc".to_string()); // caret on 'c'
|
||||
e.handle(Key::Char('x'));
|
||||
assert_eq!(e.text(), "ab");
|
||||
e.handle(Key::Char('u'));
|
||||
assert_eq!(e.text(), "abc");
|
||||
assert_eq!(e.caret, 2); // caret came back to where the change began
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_reverts_a_paste() {
|
||||
let mut e = Editor::with_text("foo\nbar".to_string());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('y'));
|
||||
e.handle(Key::Char('y'));
|
||||
e.handle(Key::Char('p'));
|
||||
assert_eq!(e.text(), "foo\nfoo\nbar");
|
||||
e.handle(Key::Char('u'));
|
||||
assert_eq!(e.text(), "foo\nbar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_edit_after_undo_clears_the_redo_history() {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char('i'));
|
||||
e.handle(Key::Char('a'));
|
||||
e.handle(Key::Escape); // "a"
|
||||
e.handle(Key::Char('u')); // -> ""
|
||||
e.handle(Key::Char('i'));
|
||||
e.handle(Key::Char('b'));
|
||||
e.handle(Key::Escape); // new branch: "b"
|
||||
e.handle(Key::Redo); // nothing to redo — the "a" branch is gone
|
||||
assert_eq!(e.text(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successive_undos_walk_the_history_back() {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char('i'));
|
||||
e.handle(Key::Char('a'));
|
||||
e.handle(Key::Escape); // "a"
|
||||
e.handle(Key::Char('A'));
|
||||
e.handle(Key::Char('b'));
|
||||
e.handle(Key::Escape); // "ab"
|
||||
e.handle(Key::Char('u'));
|
||||
assert_eq!(e.text(), "a");
|
||||
e.handle(Key::Char('u'));
|
||||
assert_eq!(e.text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_with_empty_history_is_a_noop() {
|
||||
let mut e = Editor::with_text("x".to_string());
|
||||
e.handle(Key::Char('u'));
|
||||
assert_eq!(e.text(), "x");
|
||||
e.handle(Key::Redo);
|
||||
assert_eq!(e.text(), "x");
|
||||
}
|
||||
126
editor/src/tests/utf8.rs
Normal file
126
editor/src/tests/utf8.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
//! UTF-8 correctness for accented (Latin-9) input, plus extra-glyph overlay drawing.
|
||||
|
||||
use super::*;
|
||||
|
||||
// ---- UTF-8 correctness: accented (Latin-9) input the composer feeds ----
|
||||
|
||||
#[test]
|
||||
fn insert_accented_char_advances_by_utf8_len() {
|
||||
let e = typed("é");
|
||||
assert_eq!(e.text, "é");
|
||||
assert_eq!(e.caret, 2); // 'é' is two bytes; caret is a byte offset
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_whole_multibyte_char() {
|
||||
let mut e = typed("café");
|
||||
e.handle(Key::Backspace);
|
||||
assert_eq!(e.text, "caf");
|
||||
assert_eq!(e.caret, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_hl_step_over_multibyte_chars() {
|
||||
let mut e = typed("aéb"); // bytes: a(1) é(2) b(1)
|
||||
e.handle(Key::Escape); // Normal, caret onto 'b' at byte 3
|
||||
assert_eq!(e.caret, 3);
|
||||
e.handle(Key::Char('h')); // onto 'é'
|
||||
assert_eq!(e.caret, 1);
|
||||
e.handle(Key::Char('h')); // onto 'a'
|
||||
assert_eq!(e.caret, 0);
|
||||
e.handle(Key::Char('l')); // back onto 'é'
|
||||
assert_eq!(e.caret, 1);
|
||||
e.handle(Key::Char('l')); // onto 'b'
|
||||
assert_eq!(e.caret, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_char_under_caret_removes_whole_multibyte() {
|
||||
let mut e = typed("aéb");
|
||||
e.handle(Key::Escape); // caret on 'b'
|
||||
e.handle(Key::Char('h')); // caret on 'é'
|
||||
e.handle(Key::Char('x'));
|
||||
assert_eq!(e.text, "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn de_deletes_through_end_of_accented_word() {
|
||||
let mut e = typed("café bar");
|
||||
e.handle(Key::Escape);
|
||||
e.handle(Key::Char('0')); // line start, on 'c'
|
||||
e.handle(Key::Char('d'));
|
||||
e.handle(Key::Char('e')); // delete to the end of "café"
|
||||
assert_eq!(e.text, " bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_move_keeps_char_column_across_accents() {
|
||||
let mut e = typed("éé"); // line 0: two 2-byte chars
|
||||
e.handle(Key::Enter);
|
||||
for c in "xxx".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Escape); // Normal, on last 'x'
|
||||
e.handle(Key::Char('k')); // up to line 0 at the same character column
|
||||
assert!(e.text.is_char_boundary(e.caret)); // never lands mid-character
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_runs_for_accented_buffer() {
|
||||
// Every glyph here is in ISO-8859-15, which the composer is limited to.
|
||||
let mut e = typed("café naïve garçon çÿ");
|
||||
let frame = e.draw(true);
|
||||
assert_eq!(frame.bytes().len(), display::FB_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_glyph_covers_snippet_and_prose_symbols() {
|
||||
// The curated non-Latin-9 set the overlay draws (→ ≠ Σ … – — ' ' " " •).
|
||||
let targets = [
|
||||
'\u{2192}', '\u{2260}', '\u{03A3}', '\u{2026}', '\u{2013}', '\u{2014}', '\u{2018}',
|
||||
'\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}',
|
||||
];
|
||||
for c in targets {
|
||||
assert!(extra_glyph(c).is_some(), "missing glyph for U+{:04X}", c as u32);
|
||||
}
|
||||
// The base font already draws these — the overlay must defer to it,
|
||||
// including œ/€ (which *are* in ISO-8859-15, at 0xBD/0xA4).
|
||||
for c in ['a', 'é', 'œ', '€', ' ', '#', '-'] {
|
||||
assert!(extra_glyph(c).is_none(), "should defer to base font: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_runs_for_symbol_buffer() {
|
||||
// Insert the whole extra set and render with a caret — no panic, right size.
|
||||
let mut e = typed("\u{2192} \u{2260} \u{03A3} \u{2026} \u{2013} \u{2014} \u{2018}x\u{2019} \u{201C}y\u{201D} \u{2022}");
|
||||
let frame = e.draw(true);
|
||||
assert_eq!(frame.bytes().len(), display::FB_BYTES);
|
||||
assert!(e.text.is_char_boundary(e.caret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_paints_extra_glyph_over_fallback_box() {
|
||||
// The em dash is two solid mid-height bars and nothing else; a fallback
|
||||
// box would ink the cell's top row. Gutter off so it lands in column 0.
|
||||
let mut e = Editor::with_text("\u{2014}".into()); // —
|
||||
e.prefs.line_numbers = false;
|
||||
let f = e.draw(false); // no caret
|
||||
assert!((0..10).all(|x| !ink_at(&f, x, 0)), "cell top row must be blank");
|
||||
assert!((0..10).all(|x| ink_at(&f, x, 9)), "row 9 must be solid ink");
|
||||
assert!((0..10).all(|x| ink_at(&f, x, 10)), "row 10 must be solid ink");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_inverts_extra_glyph_under_selection() {
|
||||
// Same em dash, but selected: reverse-video flips the cell — the fill
|
||||
// goes black and the dash bars punch back to white paper.
|
||||
let mut e = Editor::with_text("\u{2014}x".into());
|
||||
e.prefs.line_numbers = false;
|
||||
e.handle(Key::Char('0')); // to column 0 (the em dash)
|
||||
e.handle(Key::Char('v')); // charwise Visual selects the char under the caret
|
||||
let f = e.draw(false); // no active-end caret punch, just the selection
|
||||
assert!((0..10).all(|x| ink_at(&f, x, 0)), "selected cell top row must be inked");
|
||||
assert!((0..10).all(|x| !ink_at(&f, x, 9)), "row 9 dash must punch to white");
|
||||
assert!((0..10).all(|x| !ink_at(&f, x, 10)), "row 10 dash must punch to white");
|
||||
}
|
||||
172
editor/src/tests/visual.rs
Normal file
172
editor/src/tests/visual.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! Charwise and linewise Visual mode.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn v_enters_charwise_visual_and_anchors_at_the_caret() {
|
||||
let mut e = Editor::with_text("hello".into());
|
||||
e.caret = 2;
|
||||
e.handle(Key::Char('v'));
|
||||
assert_eq!(e.mode(), Mode::Visual);
|
||||
assert_eq!(e.visual_anchor, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capital_v_enters_linewise_visual() {
|
||||
let mut e = Editor::with_text("hello".into());
|
||||
e.handle(Key::Char('V'));
|
||||
assert_eq!(e.mode(), Mode::VisualLine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charwise_yank_is_inclusive_and_lands_the_caret_at_the_start() {
|
||||
let mut e = Editor::with_text("hello world".into());
|
||||
e.caret = 0;
|
||||
send(&mut e, "vey"); // select "hello" (e -> last char of the word), yank
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.register, "hello");
|
||||
assert!(!e.register_linewise);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vy_yanks_the_single_char_under_the_caret() {
|
||||
let mut e = Editor::with_text("hello".into());
|
||||
e.caret = 1;
|
||||
send(&mut e, "vy");
|
||||
assert_eq!(e.register, "e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charwise_delete_removes_the_span_and_fills_the_register() {
|
||||
let mut e = Editor::with_text("hello world".into());
|
||||
e.caret = 0;
|
||||
send(&mut e, "ved"); // select "hello", delete
|
||||
assert_eq!(e.text(), " world");
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.register, "hello");
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charwise_change_deletes_the_span_and_enters_insert() {
|
||||
let mut e = Editor::with_text("hello".into());
|
||||
e.caret = 0;
|
||||
send(&mut e, "v$c"); // select the whole line, change
|
||||
assert_eq!(e.mode(), Mode::Insert);
|
||||
assert_eq!(e.text(), "");
|
||||
send(&mut e, "bye");
|
||||
assert_eq!(e.text(), "bye");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_in_visual_extends_the_selection() {
|
||||
let mut e = Editor::with_text("abcdef".into());
|
||||
e.caret = 0;
|
||||
send(&mut e, "v2ld"); // select a,b,c (2l from a), delete
|
||||
assert_eq!(e.text(), "def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linewise_delete_removes_the_whole_line_like_dd() {
|
||||
let mut e = Editor::with_text("one\ntwo\nthree".into());
|
||||
e.caret = e.text().find("two").unwrap();
|
||||
send(&mut e, "Vd");
|
||||
assert_eq!(e.text(), "one\nthree");
|
||||
assert!(e.register_linewise);
|
||||
assert_eq!(e.register, "two\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linewise_selection_spans_multiple_lines_with_j() {
|
||||
let mut e = Editor::with_text("a\nb\nc\nd".into());
|
||||
e.caret = 0;
|
||||
send(&mut e, "Vjd"); // select lines a and b, delete both
|
||||
assert_eq!(e.text(), "c\nd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linewise_yank_then_paste_copies_the_line_below() {
|
||||
let mut e = Editor::with_text("one\ntwo".into());
|
||||
e.caret = 0;
|
||||
send(&mut e, "Vy"); // yank line "one" linewise
|
||||
assert_eq!(e.register, "one\n");
|
||||
send(&mut e, "p");
|
||||
assert_eq!(e.text(), "one\none\ntwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linewise_change_clears_the_line_but_keeps_one_to_type_on() {
|
||||
let mut e = Editor::with_text("one\ntwo\nthree".into());
|
||||
e.caret = e.text().find("two").unwrap();
|
||||
send(&mut e, "Vc");
|
||||
assert_eq!(e.mode(), Mode::Insert);
|
||||
assert_eq!(e.text(), "one\n\nthree"); // the line's text is gone, the row remains
|
||||
send(&mut e, "X");
|
||||
assert_eq!(e.text(), "one\nX\nthree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_leaves_visual_without_touching_the_buffer() {
|
||||
let mut e = Editor::with_text("hello".into());
|
||||
e.caret = 2;
|
||||
send(&mut e, "vll");
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert_eq!(e.text(), "hello");
|
||||
assert_eq!(e.visual_anchor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v_toggles_charwise_visual_off() {
|
||||
let mut e = Editor::with_text("hello".into());
|
||||
send(&mut e, "vv");
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capital_v_then_v_switches_to_charwise() {
|
||||
let mut e = Editor::with_text("hello".into());
|
||||
send(&mut e, "Vv");
|
||||
assert_eq!(e.mode(), Mode::Visual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gr_enters_view_and_v_no_longer_does() {
|
||||
let mut e = Editor::with_text("hello".into());
|
||||
send(&mut e, "gr");
|
||||
assert_eq!(e.mode(), Mode::View);
|
||||
e.handle(Key::Escape);
|
||||
e.handle(Key::Char('v'));
|
||||
assert_eq!(e.mode(), Mode::Visual); // v is Visual now, not View
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_ops_do_not_clobber_the_dot_register() {
|
||||
let mut e = Editor::with_text("abcdef".into());
|
||||
e.caret = 0;
|
||||
e.handle(Key::Char('x')); // dot = x ; "bcdef"
|
||||
send(&mut e, "vld"); // a visual delete must not become the new dot
|
||||
e.handle(Key::Char('.')); // repeats the x
|
||||
// buffer after x -> "bcdef"; vld deletes "bc" -> "def"; . deletes 'd' -> "ef"
|
||||
assert_eq!(e.text(), "ef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_inverts_the_selected_cells() {
|
||||
let mut e = Editor::with_text("hello world".into());
|
||||
e.caret = 0;
|
||||
let normal = e.draw(true).bytes().to_vec();
|
||||
send(&mut e, "ve"); // select "hello"
|
||||
let visual = e.draw(true).bytes().to_vec();
|
||||
assert_ne!(normal, visual); // the selection changed pixels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_runs_for_a_linewise_selection_over_a_blank_line() {
|
||||
let mut e = Editor::with_text("a\n\nb".into());
|
||||
e.caret = 0;
|
||||
send(&mut e, "Vjj"); // select all three rows, including the blank one
|
||||
let _ = e.draw(true); // must not panic on the empty-row highlight path
|
||||
assert_eq!(e.mode(), Mode::VisualLine);
|
||||
}
|
||||
Reference in New Issue
Block a user