//! 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 = (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::).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)" ); }