diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
| commit | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /tests | |
| parent | 8c0b4c53b130555f040884c1f52b90f16b23e241 (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')
| -rw-r--r-- | tests/editing.rs | 1092 | ||||
| -rw-r--r-- | tests/rendering.rs | 285 |
2 files changed, 1377 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)" + ); +} diff --git a/tests/rendering.rs b/tests/rendering.rs new file mode 100644 index 0000000..79c47f5 --- /dev/null +++ b/tests/rendering.rs @@ -0,0 +1,285 @@ +//! Rendering and routing tests (MJB-HLR-012, MJB-HLR-019). +//! +//! These use ratatui's `TestBackend` to render into an in-memory cell grid and +//! assert on what a user would actually see, rather than declaring the +//! presentation layer "verified manually". + +use mojibake::{ + components::{Component, buffer::BufferComponent}, + config::Config, +}; +use ratatui::{Terminal, backend::TestBackend}; + +fn default_config() -> Config { + toml::from_str(include_str!("../.config/config.toml")).expect("built-in config must parse") +} + +/// Render `text` into a `w`×`h` grid and return the rows as strings. +fn render(text: &str, w: u16, h: u16) -> (Vec<String>, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("f.txt"); + std::fs::write(&path, text).unwrap(); + + let mut component = BufferComponent::new(default_config(), Some(path)).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + terminal + .draw(|frame| component.draw(frame, frame.area()).unwrap()) + .unwrap(); + + let buffer = terminal.backend().buffer().clone(); + let rows = (0..h) + .map(|y| { + (0..w) + .map(|x| buffer.cell((x, y)).map(|c| c.symbol()).unwrap_or(" ")) + .collect::<String>() + .trim_end() + .to_owned() + }) + .collect(); + (rows, dir) +} + +#[test] +fn mjb_llr_200_renders_the_file_contents() { + let (rows, _d) = render("hello world\nsecond line\n", 40, 8); + let joined = rows.join("\n"); + assert!( + joined.contains("hello world"), + "file contents must be on screen, got:\n{joined}" + ); + assert!(joined.contains("second line")); +} + +/// MJB-LLR-200, MJB-HLR-012: only lines intersecting the viewport are drawn. +/// A 1000-line file in an 8-row terminal must not show line 500. +#[test] +fn mjb_llr_200_renders_only_the_visible_window() { + let text: String = (0..1000).map(|i| format!("line{i}\n")).collect(); + let (rows, _d) = render(&text, 40, 8); + let joined = rows.join("\n"); + + assert!(joined.contains("line0"), "the top of the file is visible"); + assert!( + !joined.contains("line500"), + "a line far outside the viewport must not be rendered" + ); + assert!( + !joined.contains("line999"), + "nor the last line of the file" + ); +} + +#[test] +fn mjb_llr_200_line_numbers_are_shown_in_the_gutter() { + let (rows, _d) = render("alpha\nbeta\n", 40, 8); + assert!(rows[0].starts_with('1'), "row 0 gutter, got {:?}", rows[0]); + assert!(rows[1].starts_with('2'), "row 1 gutter, got {:?}", rows[1]); +} + +/// MJB-LLR-201: the block cursor is drawn with a distinct style. +#[test] +fn mjb_llr_201_cursor_is_styled_distinctly() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("f.txt"); + std::fs::write(&path, "abc\n").unwrap(); + + let mut component = BufferComponent::new(default_config(), Some(path)).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + terminal + .draw(|frame| component.draw(frame, frame.area()).unwrap()) + .unwrap(); + + let buffer = terminal.backend().buffer().clone(); + // The gutter is two cells wide for a 1-line file ("1" + space), so the + // first text cell holds the cursor. + let gutter = 2u16; + let cursor_cell = buffer.cell((gutter, 0)).unwrap(); + let plain_cell = buffer.cell((gutter + 1, 0)).unwrap(); + + assert_eq!(cursor_cell.symbol(), "a"); + assert_ne!( + (cursor_cell.fg, cursor_cell.bg), + (plain_cell.fg, plain_cell.bg), + "the cursor cell must be styled differently from ordinary text" + ); +} + +/// MJB-LLR-202: the status line reports mode, file name and position. +#[test] +fn mjb_llr_202_status_line_shows_mode_and_path() { + let (rows, _d) = render("abc\n", 60, 6); + let status = &rows[rows.len() - 2]; + + assert!(status.contains("NOR"), "mode indicator, got {status:?}"); + assert!(status.contains("f.txt"), "file name, got {status:?}"); + assert!(status.contains("1:1"), "cursor position, got {status:?}"); +} + +#[test] +fn mjb_llr_202_status_line_marks_an_unmodified_file() { + let (rows, _d) = render("abc\n", 60, 6); + let status = &rows[rows.len() - 2]; + assert!( + !status.contains("[+]"), + "a freshly opened file is not modified, got {status:?}" + ); +} + +#[test] +fn mjb_llr_202_scratch_buffer_is_labelled() { + let mut component = BufferComponent::new(default_config(), None).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(40, 5)).unwrap(); + terminal + .draw(|frame| component.draw(frame, frame.area()).unwrap()) + .unwrap(); + + let buffer = terminal.backend().buffer().clone(); + let status: String = (0..40) + .map(|x| buffer.cell((x, 3)).map(|c| c.symbol()).unwrap_or(" ")) + .collect(); + assert!( + status.contains("[scratch]"), + "a buffer with no path must say so, got {status:?}" + ); +} + +/// MJB-HLR-019: no trace of the removed template widgets. +#[test] +fn mjb_llr_204_no_fps_counter_or_hello_world_is_rendered() { + let (rows, _d) = render("some content\n", 80, 12); + let joined = rows.join("\n").to_lowercase(); + + assert!(!joined.contains("hello world"), "placeholder widget removed"); + assert!(!joined.contains("fps"), "frame-rate counter removed"); + assert!( + !joined.contains("ticks/sec"), + "frame-rate counter removed" + ); +} + +/// MJB-LLR-203: a key matching the `Global` keymap is consumed by `App` and +/// never reaches the buffer. +/// +/// Verified at the component boundary: `Ctrl-c` is bound globally to `Quit`, +/// and the buffer must not treat it as text even in insert mode. If routing +/// regressed and the key were forwarded, insert mode would type a `c`. +#[test] +fn mjb_llr_203_globally_bound_key_is_not_typed_as_text() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("f.txt"); + std::fs::write(&path, "").unwrap(); + + let mut component = BufferComponent::new(default_config(), Some(path)).unwrap(); + component + .handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::empty())) + .unwrap(); + component + .handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) + .unwrap(); + + assert_eq!( + component.buffer().document.text().to_string(), + "", + "ctrl-c must never insert a literal 'c'" + ); +} + +/// MJB-LLR-205: no per-tick key state remains, so a chord cannot be broken by +/// the passage of time or by intervening frames. +#[test] +fn mjb_llr_205_pending_chord_survives_redraws() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("f.txt"); + std::fs::write(&path, "aaa\nbbb\nccc\n").unwrap(); + + let mut component = BufferComponent::new(default_config(), Some(path)).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap(); + + let k = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty()); + + component.handle_key_event(k('j')).unwrap(); + component.handle_key_event(k('j')).unwrap(); + // Begin the `gg` chord... + component.handle_key_event(k('g')).unwrap(); + + // ...then redraw repeatedly, which is what the tick used to interrupt. + for _ in 0..10 { + terminal + .draw(|frame| component.draw(frame, frame.area()).unwrap()) + .unwrap(); + } + + component.handle_key_event(k('g')).unwrap(); + + let buf = component.buffer(); + assert_eq!( + buf.document.range().cursor(buf.document.slice()), + 0, + "gg must still resolve after many redraws" + ); +} + +/// MJB-LLR-098: a viewport too small to hold the status rows must not panic. +#[test] +fn mjb_llr_098_tiny_viewport_renders_without_panicking() { + for (w, h) in [(1u16, 1u16), (2, 2), (1, 3), (80, 2)] { + let _ = render("content\nmore content\n", w, h); + } +} + +#[test] +fn mjb_llr_200_empty_file_renders_without_panicking() { + let (rows, _d) = render("", 40, 6); + assert!(!rows.is_empty()); +} + +#[test] +fn mjb_llr_200_wide_characters_render() { + let (rows, _d) = render("文字化け\n", 40, 6); + let joined = rows.join("\n"); + // A wide character occupies two terminal cells; ratatui fills the second + // with a continuation space, so the glyphs are not contiguous in the + // reconstructed row. Check for each one. + for c in "文字化け".chars() { + assert!( + joined.contains(c), + "wide character {c:?} must render, got {rows:?}" + ); + } +} + +/// MJB-LLR-025: a wide character must advance the reported column by two, not +/// by one character or by three bytes. +#[test] +fn mjb_llr_025_wide_characters_advance_two_columns() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("f.txt"); + std::fs::write(&path, "文字\n").unwrap(); + + let mut component = BufferComponent::new(default_config(), Some(path)).unwrap(); + // Move right one grapheme, then read the reported column off the status line. + component + .handle_key_event(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('l'), + crossterm::event::KeyModifiers::empty(), + )) + .unwrap(); + + let mut terminal = Terminal::new(TestBackend::new(60, 6)).unwrap(); + terminal + .draw(|frame| component.draw(frame, frame.area()).unwrap()) + .unwrap(); + + let buffer = terminal.backend().buffer().clone(); + let status: String = (0..60) + .map(|x| buffer.cell((x, 4)).map(|c| c.symbol()).unwrap_or(" ")) + .collect(); + assert!( + status.contains("1:3"), + "one wide character past the start is column 3, got {status:?}" + ); +} |
