aboutsummaryrefslogtreecommitdiff
path: root/tests/editing.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
commitea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /tests/editing.rs
parent8c0b4c53b130555f040884c1f52b90f16b23e241 (diff)
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor code, JSON5 config, and placeholder widgets. This establishes the first working baseline — `moji <file>` opens a file into a ropey rope and edits it with Helix selection-first semantics. Requirements, implementation and tests land together because they must: the traceability check rejects requirements with no implementation and tests naming requirements that do not exist, so neither half is a valid commit on its own. Package renamed to mojibake-editor (mojibake was taken on crates.io); binary is moji, library target stays mojibake. Class: New behaviour Requirements: MJB-HLR-001..019, MJB-LLR-001..205 Derived: MJB-DR-001..007 (DR-001 resolved, six open for review) Verified: cargo build; clippy --all-targets -D warnings clean; cargo test 294 passing; ./scripts/check-trace.sh 98/98/98; cargo package clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'tests/editing.rs')
-rw-r--r--tests/editing.rs1092
1 files changed, 1092 insertions, 0 deletions
diff --git a/tests/editing.rs b/tests/editing.rs
new file mode 100644
index 0000000..cacd232
--- /dev/null
+++ b/tests/editing.rs
@@ -0,0 +1,1092 @@
+//! Requirements-based integration tests for the buffer as a whole.
+//!
+//! The unit tests inside each module verify components in isolation; these
+//! drive the editor the way a user does — through key events resolved against
+//! the real built-in keymap — and check the behaviour the HLRs promise.
+//!
+//! Test names carry the LLR they exercise, so the trace matrix can be checked
+//! mechanically.
+
+use std::path::PathBuf;
+
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use mojibake::{
+ buffer::{Buffer, Outcome},
+ config::{Config, Mode},
+};
+
+/// A buffer over `text`, using the compiled-in default keymap.
+fn buffer_with(text: &str) -> (Buffer, tempfile::TempDir) {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("scratch.txt");
+ std::fs::write(&path, text).unwrap();
+
+ let config = default_config();
+ let mut buf = Buffer::new(config, Some(path)).unwrap();
+ // A viewport must exist before paging commands mean anything.
+ buf.update_view(80, 24);
+ (buf, dir)
+}
+
+/// The built-in defaults, without consulting the user's real config directory.
+fn default_config() -> Config {
+ let toml = include_str!("../.config/config.toml");
+ toml::from_str(toml).expect("built-in config must parse")
+}
+
+fn key(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty())
+}
+
+fn ctrl(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
+}
+
+fn esc() -> KeyEvent {
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
+}
+
+fn enter() -> KeyEvent {
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
+}
+
+/// Type a run of plain characters.
+fn typed(buf: &mut Buffer, s: &str) {
+ for c in s.chars() {
+ buf.handle_key(key(c));
+ }
+}
+
+/// Press a run of plain keys, returning the last outcome.
+fn press(buf: &mut Buffer, s: &str) -> Outcome {
+ let mut out = Outcome::Consumed;
+ for c in s.chars() {
+ out = buf.handle_key(key(c));
+ }
+ out
+}
+
+fn text(buf: &Buffer) -> String {
+ buf.document.text().to_string()
+}
+
+fn cursor(buf: &Buffer) -> usize {
+ buf.document.range().cursor(buf.document.slice())
+}
+
+// ---------------------------------------------------------------------------
+// Motion (MJB-HLR-006)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_062_h_and_l_move_by_one_grapheme() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "ll");
+ assert_eq!(cursor(&buf), 2);
+ press(&mut buf, "h");
+ assert_eq!(cursor(&buf), 1);
+}
+
+#[test]
+fn mjb_llr_062_h_at_start_of_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "hhhhh");
+ assert_eq!(cursor(&buf), 0, "must not move past the start");
+}
+
+#[test]
+fn mjb_llr_063_l_at_end_of_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("ab");
+ press(&mut buf, "llllll");
+ assert!(cursor(&buf) <= 2, "must not move past the end");
+}
+
+#[test]
+fn mjb_llr_064_j_and_k_move_between_lines() {
+ let (mut buf, _d) = buffer_with("abc\ndef\nghi\n");
+ press(&mut buf, "jj");
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 2);
+ press(&mut buf, "k");
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 1);
+}
+
+#[test]
+fn mjb_llr_155_count_repeats_a_motion() {
+ let (mut buf, _d) = buffer_with("abcdefghij\n");
+ press(&mut buf, "5l");
+ assert_eq!(cursor(&buf), 5, "5l must move five graphemes");
+}
+
+// ---------------------------------------------------------------------------
+// Word motion selects (MJB-HLR-007) — the defining Helix behaviour
+// ---------------------------------------------------------------------------
+
+/// `w` must leave a *selection*, not a bare cursor. This is what lets `d`
+/// delete a word with no operator-pending state.
+#[test]
+fn mjb_llr_065_w_leaves_a_selection() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "w");
+ let r = buf.document.range();
+ assert!(!r.is_empty(), "w must select, not collapse");
+ assert_eq!(r.from(), 0);
+ assert_eq!(r.to(), 6);
+}
+
+/// The pay-off: `wd` deletes a word without any operator machinery.
+#[test]
+fn mjb_llr_065_w_then_d_deletes_the_word() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wd");
+ assert_eq!(text(&buf), "world\n");
+}
+
+#[test]
+fn mjb_llr_067_e_selects_to_the_word_end() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "e");
+ let r = buf.document.range();
+ assert_eq!(r.from(), 0);
+ assert_eq!(r.to(), 5, "inclusive of the last character");
+}
+
+#[test]
+fn mjb_llr_066_b_selects_backward() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "ww");
+ let before = cursor(&buf);
+ press(&mut buf, "b");
+ assert!(cursor(&buf) < before, "b must move backward");
+}
+
+#[test]
+fn mjb_llr_069_w_at_end_of_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("ab");
+ press(&mut buf, "wwwww");
+ assert_eq!(text(&buf), "ab", "must not corrupt the buffer");
+}
+
+// ---------------------------------------------------------------------------
+// Goto (MJB-HLR-008)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_070_gg_goes_to_file_start() {
+ let (mut buf, _d) = buffer_with("abc\ndef\nghi\n");
+ press(&mut buf, "jj");
+ press(&mut buf, "gg");
+ assert_eq!(cursor(&buf), 0);
+}
+
+/// MJB-LLR-154: the old resolver cleared pending keys on every tick, so `gg`
+/// failed when typed slowly. Nothing time-based remains; interleaving other
+/// work between the two keys must not matter.
+#[test]
+fn mjb_llr_154_gg_resolves_regardless_of_intervening_time() {
+ let (mut buf, _d) = buffer_with("abc\ndef\nghi\n");
+ press(&mut buf, "jj");
+
+ buf.handle_key(key('g'));
+ // Simulate an arbitrary delay and unrelated frame work.
+ std::thread::sleep(std::time::Duration::from_millis(300));
+ buf.update_view(80, 24);
+ buf.handle_key(key('g'));
+
+ assert_eq!(cursor(&buf), 0, "gg must still resolve after a long pause");
+}
+
+#[test]
+fn mjb_llr_071_ge_goes_to_the_last_line() {
+ let (mut buf, _d) = buffer_with("abc\ndef\nghi\n");
+ press(&mut buf, "ge");
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 2);
+}
+
+#[test]
+fn mjb_llr_073_gl_goes_to_the_line_end() {
+ let (mut buf, _d) = buffer_with("abcdef\nxy\n");
+ press(&mut buf, "gl");
+ assert_eq!(cursor(&buf), 6, "excludes the terminator");
+}
+
+#[test]
+fn mjb_llr_072_gh_goes_to_the_line_start() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "lll");
+ press(&mut buf, "gh");
+ assert_eq!(cursor(&buf), 0);
+}
+
+#[test]
+fn mjb_llr_153_unknown_g_sequence_does_not_corrupt_state() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "gz");
+ assert_eq!(text(&buf), "abc\n");
+ assert!(buf.pending_keys().is_empty(), "pending must be cleared");
+}
+
+// ---------------------------------------------------------------------------
+// Insert mode (MJB-HLR-009, MJB-HLR-010)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_156_i_then_typing_inserts_text() {
+ let (mut buf, _d) = buffer_with("world\n");
+ buf.handle_key(key('i'));
+ assert_eq!(buf.mode, Mode::Insert);
+ typed(&mut buf, "hello ");
+ assert_eq!(text(&buf), "hello world\n");
+}
+
+#[test]
+fn mjb_llr_156_escape_returns_to_normal_mode() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(esc());
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+/// In insert mode a printable key must type, not run a normal-mode command.
+#[test]
+fn mjb_llr_156_command_keys_type_literally_in_insert_mode() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "dujw");
+ assert_eq!(text(&buf), "dujw\n", "d/u/j/w must not act as commands");
+}
+
+#[test]
+fn mjb_llr_009_a_appends_after_the_selection() {
+ let (mut buf, _d) = buffer_with("ac\n");
+ buf.handle_key(key('a'));
+ typed(&mut buf, "b");
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_009_capital_a_inserts_at_line_end() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT));
+ typed(&mut buf, "!");
+ assert_eq!(text(&buf), "abc!\n");
+}
+
+#[test]
+fn mjb_llr_009_capital_i_inserts_at_first_non_whitespace() {
+ let (mut buf, _d) = buffer_with(" abc\n");
+ press(&mut buf, "gl");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT));
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), " Xabc\n");
+}
+
+#[test]
+fn mjb_llr_009_o_opens_a_line_below() {
+ let (mut buf, _d) = buffer_with("abc\ndef\n");
+ buf.handle_key(key('o'));
+ assert_eq!(buf.mode, Mode::Insert);
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), "abc\nX\ndef\n");
+}
+
+/// Robustness: the last line has no trailing newline, so there is no "next
+/// line" for `o` to anchor to.
+#[test]
+fn mjb_llr_009_o_on_a_final_line_without_trailing_newline() {
+ let (mut buf, _d) = buffer_with("abc");
+ buf.handle_key(key('o'));
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), "abc\nX");
+}
+
+#[test]
+fn mjb_llr_009_capital_o_opens_a_line_above() {
+ let (mut buf, _d) = buffer_with("abc\ndef\n");
+ press(&mut buf, "j");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('O'), KeyModifiers::SHIFT));
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), "abc\nX\ndef\n");
+}
+
+#[test]
+fn mjb_llr_010_backspace_deletes_backward() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "abc");
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "ab\n");
+}
+
+#[test]
+fn mjb_llr_010_backspace_at_buffer_start_is_a_noop() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ for _ in 0..5 {
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ }
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_010_enter_inserts_a_newline() {
+ let (mut buf, _d) = buffer_with("ab\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(enter());
+ assert_eq!(text(&buf), "\nab\n");
+}
+
+#[test]
+fn mjb_llr_010_multibyte_text_inserts_and_deletes_whole_characters() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "文字化け");
+ assert_eq!(text(&buf), "文字化け\n");
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "文字化\n", "one character, not one byte");
+}
+
+// ---------------------------------------------------------------------------
+// Delete and change (MJB-HLR-010)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_010_d_deletes_the_selection() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wd");
+ assert_eq!(text(&buf), "world\n");
+}
+
+#[test]
+fn mjb_llr_010_c_deletes_and_enters_insert_mode() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wc");
+ assert_eq!(buf.mode, Mode::Insert);
+ typed(&mut buf, "goodbye ");
+ assert_eq!(text(&buf), "goodbye world\n");
+}
+
+#[test]
+fn mjb_llr_050_d_with_an_empty_selection_deletes_one_grapheme() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "d");
+ assert_eq!(text(&buf), "bc\n");
+}
+
+#[test]
+fn mjb_llr_050_d_on_an_empty_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("");
+ press(&mut buf, "ddd");
+ assert_eq!(text(&buf), "");
+}
+
+/// Helix's `x`: select the line, then extend a line at a time.
+#[test]
+fn mjb_llr_010_x_selects_a_line_then_extends() {
+ let (mut buf, _d) = buffer_with("aaa\nbbb\nccc\n");
+ press(&mut buf, "x");
+ let r = buf.document.range();
+ assert_eq!((r.from(), r.to()), (0, 4), "the whole first line");
+
+ press(&mut buf, "x");
+ let r = buf.document.range();
+ assert_eq!((r.from(), r.to()), (0, 8), "extended to the second");
+}
+
+#[test]
+fn mjb_llr_010_x_then_d_deletes_whole_lines() {
+ let (mut buf, _d) = buffer_with("aaa\nbbb\nccc\n");
+ press(&mut buf, "xxd");
+ assert_eq!(text(&buf), "ccc\n");
+}
+
+// ---------------------------------------------------------------------------
+// Undo and redo (MJB-HLR-011)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_052_u_undoes_a_deletion() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wd");
+ assert_eq!(text(&buf), "world\n");
+ press(&mut buf, "u");
+ assert_eq!(text(&buf), "hello world\n");
+}
+
+#[test]
+fn mjb_llr_053_capital_u_redoes() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wd");
+ press(&mut buf, "u");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT));
+ assert_eq!(text(&buf), "world\n");
+}
+
+/// MJB-LLR-052: undoing with no history is a no-op, and must not corrupt the
+/// buffer or panic however many times it is pressed.
+#[test]
+fn mjb_llr_052_undo_past_history_start_is_safe() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "uuuuu");
+ assert_eq!(text(&buf), "abc\n");
+ assert!(buf.status.is_some(), "should report the boundary");
+}
+
+#[test]
+fn mjb_llr_053_redo_past_end_is_safe() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ for _ in 0..5 {
+ buf.handle_key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT));
+ }
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_051_undo_restores_typed_text() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "abc");
+ buf.handle_key(esc());
+ assert_eq!(text(&buf), "abc\n");
+
+ // Each typed character is its own undo step.
+ press(&mut buf, "uuu");
+ assert_eq!(text(&buf), "\n");
+}
+
+// ---------------------------------------------------------------------------
+// Paging (MJB-HLR-013)
+// ---------------------------------------------------------------------------
+
+fn many_lines(n: usize) -> String {
+ (0..n).map(|i| format!("line{i}\n")).collect()
+}
+
+#[test]
+fn mjb_llr_100_ctrl_d_pages_half_a_screen_down() {
+ let (mut buf, _d) = buffer_with(&many_lines(200));
+ buf.update_view(80, 20);
+ buf.handle_key(ctrl('d'));
+ assert_eq!(
+ buf.document.range().cursor_line(buf.document.slice()),
+ 10,
+ "half of a 20-row viewport"
+ );
+}
+
+#[test]
+fn mjb_llr_101_ctrl_f_pages_a_full_screen_down() {
+ let (mut buf, _d) = buffer_with(&many_lines(200));
+ buf.update_view(80, 20);
+ buf.handle_key(ctrl('f'));
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 20);
+}
+
+#[test]
+fn mjb_llr_100_ctrl_u_pages_back_up() {
+ let (mut buf, _d) = buffer_with(&many_lines(200));
+ buf.update_view(80, 20);
+ buf.handle_key(ctrl('f'));
+ buf.handle_key(ctrl('u'));
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 10);
+}
+
+#[test]
+fn mjb_llr_102_paging_saturates_at_both_ends() {
+ let (mut buf, _d) = buffer_with(&many_lines(5));
+ buf.update_view(80, 20);
+ for _ in 0..10 {
+ buf.handle_key(ctrl('f'));
+ }
+ for _ in 0..10 {
+ buf.handle_key(ctrl('u'));
+ }
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 0);
+}
+
+// ---------------------------------------------------------------------------
+// Command mode and saving (MJB-HLR-016, MJB-HLR-017)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_159_write_saves_to_disk() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "original\n").unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.update_view(80, 24);
+ buf.handle_key(key('i'));
+ typed(&mut buf, "new ");
+ buf.handle_key(esc());
+
+ buf.handle_key(key(':'));
+ assert_eq!(buf.mode, Mode::Command);
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "new original\n");
+ assert!(!buf.document.is_modified());
+}
+
+#[test]
+fn mjb_llr_159_quit_returns_the_quit_outcome() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ typed(&mut buf, "q");
+ assert_eq!(buf.handle_key(enter()), Outcome::Quit);
+}
+
+/// MJB-LLR-160: `:q` with unsaved changes must refuse and say why.
+#[test]
+fn mjb_llr_160_quit_with_unsaved_changes_is_refused() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+
+ buf.handle_key(key(':'));
+ typed(&mut buf, "q");
+ assert_eq!(buf.handle_key(enter()), Outcome::Consumed, "must not quit");
+ assert!(buf.status.is_some(), "must explain the refusal");
+}
+
+#[test]
+fn mjb_llr_160_force_quit_discards_changes() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+
+ buf.handle_key(key(':'));
+ typed(&mut buf, "q!");
+ assert_eq!(buf.handle_key(enter()), Outcome::Quit);
+}
+
+#[test]
+fn mjb_llr_159_wq_writes_then_quits() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "abc\n").unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+
+ buf.handle_key(key(':'));
+ typed(&mut buf, "wq");
+ assert_eq!(buf.handle_key(enter()), Outcome::Quit);
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "Xabc\n");
+}
+
+#[test]
+fn mjb_llr_159_x_is_an_alias_for_wq() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "abc\n").unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key(':'));
+ typed(&mut buf, "x");
+ assert_eq!(buf.handle_key(enter()), Outcome::Quit);
+}
+
+/// MJB-LLR-159: an unrecognised command reports and keeps going.
+#[test]
+fn mjb_llr_159_unknown_command_is_reported_not_fatal() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ typed(&mut buf, "nonsense");
+ assert_eq!(buf.handle_key(enter()), Outcome::Consumed);
+ assert!(
+ buf.status.as_deref().unwrap_or("").contains("nonsense"),
+ "must name the unknown command, got {:?}",
+ buf.status
+ );
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+#[test]
+fn mjb_llr_158_escape_cancels_the_command_line() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ typed(&mut buf, "q");
+ buf.handle_key(esc());
+ assert_eq!(buf.mode, Mode::Normal);
+ assert!(buf.command_line.is_empty());
+}
+
+#[test]
+fn mjb_llr_158_command_line_accepts_backspace() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ typed(&mut buf, "qx");
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ assert_eq!(buf.command_line, "q");
+}
+
+#[test]
+fn mjb_llr_112_writing_a_new_file_creates_it() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("brand-new.txt");
+ assert!(!path.exists());
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "hello");
+ buf.handle_key(esc());
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello\n");
+}
+
+// ---------------------------------------------------------------------------
+// Robustness (MJB-HLR-018)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_112_empty_file_is_editable() {
+ let (mut buf, _d) = buffer_with("");
+ press(&mut buf, "hjklwbe");
+ press(&mut buf, "gg");
+ press(&mut buf, "ge");
+ assert_eq!(text(&buf), "", "no motion may corrupt an empty buffer");
+
+ buf.handle_key(key('i'));
+ typed(&mut buf, "x");
+ assert_eq!(text(&buf), "x");
+}
+
+#[test]
+fn mjb_llr_112_file_without_trailing_newline_round_trips() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "no trailing newline").unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ // insert_final_newline defaults to true, so one is appended on write.
+ assert_eq!(
+ std::fs::read_to_string(&path).unwrap(),
+ "no trailing newline\n"
+ );
+}
+
+/// MJB-LLR-118: the UTF-8 exception, end to end. A binary file is refused
+/// with a diagnostic rather than opened full of replacement characters.
+#[test]
+fn mjb_llr_118_binary_file_is_refused_not_opened() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("bad.bin");
+ let original: Vec<u8> = (0u8..=255).collect();
+ std::fs::write(&path, &original).unwrap();
+
+ let err = Buffer::new(default_config(), Some(path.clone()))
+ .err()
+ .expect("a binary file must not open");
+ assert!(
+ err.to_string().contains("UTF-8"),
+ "must say why, got: {err}"
+ );
+ assert_eq!(
+ std::fs::read(&path).unwrap(),
+ original,
+ "refusing to open must not modify the file"
+ );
+}
+
+/// MJB-LLR-113: a BOM-declared non-UTF-8 file still opens and is editable —
+/// the strictness applies to UTF-8 only.
+#[test]
+fn mjb_llr_113_declared_utf16_file_opens_and_edits() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("u16.txt");
+ // UTF-16LE BOM + "hi", including an unpaired surrogate.
+ std::fs::write(&path, [0xFF, 0xFE_u8, 0x00, 0xD8, b'h', 0x00, b'i', 0x00]).unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path)).expect("declared encoding must open");
+ buf.update_view(80, 24);
+ press(&mut buf, "jjllww");
+ // Reaching here without a panic is the requirement.
+}
+
+#[test]
+fn mjb_llr_098_zero_height_viewport_does_not_panic() {
+ let (mut buf, _d) = buffer_with(&many_lines(50));
+ buf.update_view(0, 0);
+ buf.handle_key(ctrl('d'));
+ buf.handle_key(ctrl('f'));
+ press(&mut buf, "jjkk");
+}
+
+#[test]
+fn mjb_llr_011_motions_at_boundaries_never_leave_the_buffer() {
+ let (mut buf, _d) = buffer_with("ab\ncd\n");
+ // Hammer every motion from every position.
+ for _ in 0..40 {
+ press(&mut buf, "hjklwbe");
+ press(&mut buf, "gg");
+ press(&mut buf, "gl");
+ press(&mut buf, "gh");
+ press(&mut buf, "ge");
+ }
+ let len = buf.document.text().len();
+ assert!(cursor(&buf) <= len);
+ assert_eq!(text(&buf), "ab\ncd\n", "motions must not modify text");
+}
+
+#[test]
+fn mjb_llr_112_missing_file_opens_as_an_empty_buffer() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("nope.txt");
+ let buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ assert_eq!(buf.document.text().len(), 0);
+ assert_eq!(buf.document.path(), Some(path.as_path()));
+}
+
+#[test]
+fn mjb_llr_111_no_path_yields_a_scratch_buffer() {
+ let buf = Buffer::new(default_config(), None::<PathBuf>).unwrap();
+ assert_eq!(buf.document.path(), None);
+ assert_eq!(buf.document.text().len(), 0);
+}
+
+// ---------------------------------------------------------------------------
+// Select mode and selection manipulation
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_184_v_toggles_select_mode() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "v");
+ assert_eq!(buf.mode, Mode::Select);
+ press(&mut buf, "v");
+ assert_eq!(buf.mode, Mode::Normal, "v toggles back");
+}
+
+/// In select mode a motion extends the selection instead of replacing it.
+#[test]
+fn mjb_llr_007_select_mode_motions_extend() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "vlll");
+ let r = buf.document.range();
+ assert!(!r.is_empty(), "must have extended a selection");
+ assert_eq!(r.from(), 0);
+ assert!(r.to() >= 3, "selection grew with each motion");
+}
+
+#[test]
+fn mjb_llr_007_select_mode_then_delete() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "vlll");
+ press(&mut buf, "d");
+ assert!(text(&buf).len() < "abcdef\n".len(), "selection was deleted");
+}
+
+#[test]
+fn mjb_llr_007_escape_leaves_select_mode() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "v");
+ buf.handle_key(esc());
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+#[test]
+fn mjb_llr_010_semicolon_collapses_the_selection() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "w");
+ assert!(!buf.document.range().is_empty());
+ press(&mut buf, ";");
+ assert!(buf.document.range().is_empty(), "collapsed to a cursor");
+}
+
+#[test]
+fn mjb_llr_010_percent_selects_the_whole_buffer() {
+ let (mut buf, _d) = buffer_with("abc\ndef\n");
+ press(&mut buf, "%");
+ let r = buf.document.range();
+ assert_eq!((r.from(), r.to()), (0, 8));
+}
+
+#[test]
+fn mjb_llr_010_percent_then_d_empties_the_buffer() {
+ let (mut buf, _d) = buffer_with("abc\ndef\n");
+ press(&mut buf, "%d");
+ assert_eq!(text(&buf), "");
+}
+
+#[test]
+fn mjb_llr_010_alt_semicolon_flips_the_selection() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "w");
+ let before = buf.document.range();
+ buf.handle_key(KeyEvent::new(KeyCode::Char(';'), KeyModifiers::ALT));
+ let after = buf.document.range();
+ assert_eq!(after.anchor, before.head);
+ assert_eq!(after.head, before.anchor);
+}
+
+// ---------------------------------------------------------------------------
+// Long-word motions and goto first non-whitespace
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_068_capital_w_treats_punctuation_as_word_characters() {
+ let (mut buf, _d) = buffer_with("foo.bar baz\n");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT));
+ assert_eq!(
+ buf.document.range().to(),
+ 8,
+ "W must step over foo.bar as one word"
+ );
+}
+
+#[test]
+fn mjb_llr_068_capital_e_and_b_are_bound() {
+ let (mut buf, _d) = buffer_with("foo.bar baz\n");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT));
+ assert!(!buf.document.range().is_empty());
+ buf.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::SHIFT));
+ assert_eq!(text(&buf), "foo.bar baz\n", "motions must not modify text");
+}
+
+#[test]
+fn mjb_llr_072_gs_goes_to_first_non_whitespace() {
+ let (mut buf, _d) = buffer_with(" indented\n");
+ press(&mut buf, "gl");
+ press(&mut buf, "gs");
+ assert_eq!(cursor(&buf), 4);
+}
+
+// ---------------------------------------------------------------------------
+// Insert-mode editing commands
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_010_ctrl_w_deletes_the_previous_word() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "hello world");
+ buf.handle_key(ctrl('w'));
+ assert!(
+ !text(&buf).contains("world"),
+ "ctrl-w must remove the last word, got {:?}",
+ text(&buf)
+ );
+}
+
+#[test]
+fn mjb_llr_010_ctrl_w_at_buffer_start_is_a_noop() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(ctrl('w'));
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_010_ctrl_u_kills_to_line_start() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "some text");
+ buf.handle_key(ctrl('u'));
+ assert_eq!(text(&buf), "\n");
+}
+
+#[test]
+fn mjb_llr_010_ctrl_u_at_line_start_is_a_noop() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(ctrl('u'));
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_010_delete_removes_the_character_forward() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "bc\n");
+}
+
+#[test]
+fn mjb_llr_010_delete_at_end_of_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("ab");
+ press(&mut buf, "gl");
+ buf.handle_key(key('a'));
+ buf.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "ab");
+}
+
+#[test]
+fn mjb_llr_010_tab_inserts_a_tab() {
+ let (mut buf, _d) = buffer_with("x\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "\tx\n");
+}
+
+#[test]
+fn mjb_llr_156_arrow_keys_move_in_insert_mode() {
+ let (mut buf, _d) = buffer_with("abcd\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::empty()));
+ buf.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::empty()));
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), "abXcd\n");
+}
+
+#[test]
+fn mjb_llr_156_unbound_control_key_is_discarded_in_insert_mode() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(ctrl('q'));
+ assert_eq!(text(&buf), "abc\n", "ctrl-q must not type a q");
+}
+
+// ---------------------------------------------------------------------------
+// Global bindings and force-write (MJB-HLR-017)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_159_force_write_creates_missing_directories() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("deep").join("nested").join("f.txt");
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "content");
+ buf.handle_key(esc());
+
+ // Plain :w must refuse, naming the remedy.
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+ assert!(!path.exists(), "plain :w must not create directories");
+ assert!(buf.status.as_deref().unwrap_or("").contains(":w!"));
+
+ // :w! creates them.
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w!");
+ buf.handle_key(enter());
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "content\n");
+}
+
+#[test]
+fn mjb_llr_159_empty_command_line_does_nothing() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ assert_eq!(buf.handle_key(enter()), Outcome::Consumed);
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+#[test]
+fn mjb_llr_158_backspacing_an_empty_command_line_leaves_command_mode() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+/// MJB-LLR-017: writing over a symlink must update the target, not replace
+/// the link. Exercised end-to-end through `:w`.
+#[cfg(unix)]
+#[test]
+fn mjb_llr_130_write_through_a_symlink_end_to_end() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("real.txt");
+ let link = dir.path().join("link.txt");
+ std::fs::write(&target, "before\n").unwrap();
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(link.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ assert!(
+ std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(),
+ "the link must survive the write"
+ );
+ assert_eq!(std::fs::read_to_string(&target).unwrap(), "Xbefore\n");
+}
+
+/// MJB-LLR-131: a read-only file must be refused without truncating it.
+#[cfg(unix)]
+#[test]
+fn mjb_llr_131_readonly_file_reports_and_preserves_contents() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("ro.txt");
+ std::fs::write(&path, "protected\n").unwrap();
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ assert_eq!(
+ std::fs::read_to_string(&path).unwrap(),
+ "protected\n",
+ "a refused write must leave the file intact"
+ );
+ assert!(buf.status.is_some(), "must report the failure");
+}
+
+// ---------------------------------------------------------------------------
+// Pagination is structural (MJB-HLR-012)
+// ---------------------------------------------------------------------------
+
+/// MJB-LLR-092, MJB-LLR-200: rendering cost must not scale with file size.
+///
+/// A 200k-line buffer and a 10-line buffer must yield the same number of
+/// visible lines for the same viewport, and the visible set must come from the
+/// anchored window rather than a scan.
+#[test]
+fn mjb_llr_092_visible_line_count_is_independent_of_file_size() {
+ for n in [10usize, 200_000] {
+ let (mut buf, _d) = buffer_with(&many_lines(n));
+ buf.update_view(80, 24);
+ press(&mut buf, "ge");
+ buf.update_view(80, 24);
+
+ let text = buf.document.slice();
+ let count = buf.view.visible_lines(text, 24).count();
+ assert!(
+ count <= 24,
+ "viewport must cap at its height, got {count} for {n} lines"
+ );
+ }
+}
+
+#[test]
+fn mjb_llr_092_scrolling_a_large_file_stays_responsive() {
+ let (mut buf, _d) = buffer_with(&many_lines(200_000));
+ buf.update_view(80, 24);
+
+ let start = std::time::Instant::now();
+ for _ in 0..500 {
+ buf.handle_key(ctrl('d'));
+ buf.update_view(80, 24);
+ }
+ let elapsed = start.elapsed();
+
+ // A per-frame full scan of 200k lines would take far longer than this.
+ assert!(
+ elapsed < std::time::Duration::from_secs(5),
+ "500 half-page scrolls over 200k lines took {elapsed:?}; \
+ rendering is probably not O(viewport)"
+ );
+}