aboutsummaryrefslogtreecommitdiff
path: root/tests/rendering.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
commitea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /tests/rendering.rs
parent8c0b4c53b130555f040884c1f52b90f16b23e241 (diff)
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor code, JSON5 config, and placeholder widgets. This establishes the first working baseline — `moji <file>` opens a file into a ropey rope and edits it with Helix selection-first semantics. Requirements, implementation and tests land together because they must: the traceability check rejects requirements with no implementation and tests naming requirements that do not exist, so neither half is a valid commit on its own. Package renamed to mojibake-editor (mojibake was taken on crates.io); binary is moji, library target stays mojibake. Class: New behaviour Requirements: MJB-HLR-001..019, MJB-LLR-001..205 Derived: MJB-DR-001..007 (DR-001 resolved, six open for review) Verified: cargo build; clippy --all-targets -D warnings clean; cargo test 294 passing; ./scripts/check-trace.sh 98/98/98; cargo package clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'tests/rendering.rs')
-rw-r--r--tests/rendering.rs285
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:?}"
+ );
+}