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/rendering.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/rendering.rs')
| -rw-r--r-- | tests/rendering.rs | 285 |
1 files changed, 285 insertions, 0 deletions
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:?}" + ); +} |
