diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /tests/editing.rs | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
Establishes the first working baseline: moji <file> opens a file into a
ropey rope and edits it with Helix selection-first modal editing, under a
DO-178C DAL-C requirements and traceability process.
Prior to this, main tracked four files and src/main.rs was still
println!("Hello, world!") — there was no buildable state to build on.
Verified on a fresh clone of the branch with no untracked files:
cargo build; clippy --all-targets -D warnings clean; 294 tests passing;
scripts/check-trace.sh reports 98/98 requirements traced in both
directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'tests/editing.rs')
| -rw-r--r-- | tests/editing.rs | 1092 |
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)" + ); +} |
