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