From d256fa1c62e582426eb3dcfb014aa3090ea06643 Mon Sep 17 00:00:00 2001 From: kronberger-droid Date: Mon, 12 Oct 2026 11:15:45 +0200 Subject: [PATCH 1/2] test(painter): assert on a vt100 screen instead of emitted bytes --- Cargo.lock | 30 +++- Cargo.toml | 1 + src/painting/mod.rs | 2 + src/painting/painter.rs | 3 + src/painting/painter/screen_tests.rs | 218 +++++++++++++++++++++++++++ src/painting/test_screen.rs | 88 +++++++++++ 6 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 src/painting/painter/screen_tests.rs create mode 100644 src/painting/test_screen.rs diff --git a/Cargo.lock b/Cargo.lock index 4ac3e7906..fb1e2383d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" @@ -714,6 +720,7 @@ dependencies = [ "tempfile", "unicode-segmentation", "unicode-width", + "vt100", ] [[package]] @@ -952,7 +959,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" dependencies = [ - "vte", + "vte 0.14.1", ] [[package]] @@ -1066,6 +1073,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte 0.15.0", +] + [[package]] name = "vte" version = "0.14.1" @@ -1075,6 +1093,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index d2f0af18b..3fa4207bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ pretty_assertions = "1.4.0" rstest = { version = "0.26.1", default-features = false } serde_json = "1.0" tempfile = "3.3.0" +vt100 = "0.16.2" [features] default = [] diff --git a/src/painting/mod.rs b/src/painting/mod.rs index 18f5d9776..5c3b1d477 100644 --- a/src/painting/mod.rs +++ b/src/painting/mod.rs @@ -1,6 +1,8 @@ mod painter; mod prompt_lines; mod styled_text; +#[cfg(test)] +mod test_screen; mod utils; pub use painter::{Painter, PainterSuspendedState, RenderSnapshot, W}; diff --git a/src/painting/painter.rs b/src/painting/painter.rs index 29af46f37..4b3a9cff1 100644 --- a/src/painting/painter.rs +++ b/src/painting/painter.rs @@ -1572,6 +1572,9 @@ impl Painter { } } +#[cfg(test)] +mod screen_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/src/painting/painter/screen_tests.rs b/src/painting/painter/screen_tests.rs new file mode 100644 index 000000000..0374fc770 --- /dev/null +++ b/src/painting/painter/screen_tests.rs @@ -0,0 +1,218 @@ +//! Painter tests asserting on what reaches the screen, through +//! [`TestScreen`], rather than on the bytes the painter emits. + +use super::*; +use crate::painting::test_screen::TestScreen; +use crate::PromptHistorySearch; +use pretty_assertions::assert_eq; +use std::borrow::Cow; + +/// A prompt drawing fixed strings. Lines are built through +/// [`PromptLines::new`] from it, as the engine builds them, so a new field on +/// `PromptLines` does not reach these tests. +#[derive(Default)] +struct FixedPrompt { + left: &'static str, + indicator: &'static str, + right: &'static str, + right_on_last_line: bool, +} + +impl FixedPrompt { + fn new(left: &'static str, indicator: &'static str) -> Self { + Self { + left, + indicator, + ..Self::default() + } + } + + fn with_right(mut self, right: &'static str, on_last_line: bool) -> Self { + self.right = right; + self.right_on_last_line = on_last_line; + self + } +} + +impl Prompt for FixedPrompt { + fn render_prompt_left(&self) -> Cow<'_, str> { + self.left.into() + } + + fn render_prompt_right(&self) -> Cow<'_, str> { + self.right.into() + } + + fn render_prompt_indicator(&self, _prompt_mode: PromptEditMode) -> Cow<'_, str> { + self.indicator.into() + } + + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { + "".into() + } + + fn render_prompt_history_search_indicator( + &self, + _history_search: PromptHistorySearch, + ) -> Cow<'_, str> { + "".into() + } + + fn right_prompt_on_last_line(&self) -> bool { + self.right_on_last_line + } +} + +/// A painter wired to a [`TestScreen`] of the same size, with its prompt +/// anchored on `anchor_row` as if `initialize_prompt_position` had measured it. +struct Session { + painter: Painter, + screen: TestScreen, +} + +impl Session { + fn new(width: u16, height: u16, anchor_row: u16) -> Self { + let mut painter = Painter::new(W::capture()); + painter.terminal_size = (width, height); + // Pinned rather than read from `TERM`, which differs between a + // developer's shell and CI. + painter.term_is_dumb = false; + painter.prompt_start_row = PromptStartRow::Verified(anchor_row); + Self { + painter, + screen: TestScreen::new(width, height), + } + } + + /// As [`Session::new`], with `output` already on screen above the anchor, + /// left there by the host before the prompt. + fn after_output(width: u16, height: u16, output: &[&str]) -> Self { + let rows = u16::try_from(output.len()).expect("a test prints a few rows"); + let mut session = Self::new(width, height, rows); + for line in output { + session.screen.feed(line.as_bytes()); + session.screen.feed(b"\r\n"); + } + session + } + + /// Paint `prompt` with the cursor between `before` and `after`. + fn paint(&mut self, prompt: &FixedPrompt, before: &str, after: &str) { + let mode = PromptEditMode::Default; + let lines = PromptLines::new(prompt, mode.clone(), None, before, after, ""); + self.painter + .repaint_buffer(prompt, &lines, mode, None, false, &None) + .expect("repaint_buffer failed"); + self.screen.sync(self.painter.stdout.captured()); + } +} + +#[test] +fn paints_below_the_host_output_without_touching_it() { + let mut session = Session::after_output(20, 6, &["out1", "out2"]); + + session.paint(&FixedPrompt::new("> ", ""), "hello", ""); + + assert_eq!(session.screen.row(0), "out1"); + assert_eq!(session.screen.row(1), "out2"); + assert_eq!(session.screen.row(2), "> hello"); + assert_eq!(session.screen.cursor(), (7, 2)); + assert!(session.screen.cursor_visible()); + assert_eq!(session.screen.scrolled(), 0); +} + +#[test] +fn cursor_sits_between_the_text_before_and_after_it() { + let mut session = Session::new(20, 6, 1); + + session.paint(&FixedPrompt::new("> ", ""), "hel", "lo"); + + assert_eq!(session.screen.row(1), "> hello"); + assert_eq!(session.screen.cursor(), (5, 1)); +} + +#[test] +fn repaint_draws_over_the_previous_frame() { + let mut session = Session::new(20, 6, 1); + let prompt = FixedPrompt::new("> ", ""); + + session.paint(&prompt, "a longer line", ""); + session.paint(&prompt, "short", ""); + + assert_eq!(session.screen.row(1), "> short"); + assert_eq!(session.screen.row(2), ""); + assert_eq!(session.screen.scrolled(), 0); +} + +// #1141: text ending exactly on the margin leaves the terminal's wrap pending, +// and terminals disagree about restoring that state. The cursor belongs on the +// next row's home column, placed there absolutely. +#[test] +fn text_filling_the_width_puts_the_cursor_on_the_next_row() { + let mut session = Session::new(10, 6, 1); + + session.paint(&FixedPrompt::new("> ", ""), "abcdefgh", ""); + + assert_eq!(session.screen.row(1), "> abcdefgh"); + assert_eq!(session.screen.cursor(), (0, 2)); +} + +// A wide grapheme with one column left wraps whole, leaving that column blank. +#[test] +fn wide_grapheme_without_room_wraps_to_the_next_row() { + let mut session = Session::new(5, 6, 1); + + session.paint(&FixedPrompt::new("> ", ""), "ab中", ""); + + assert_eq!(session.screen.row(1), "> ab"); + assert_eq!(session.screen.row(2), "中"); + assert_eq!(session.screen.cursor(), (2, 2)); +} + +#[test] +fn right_prompt_on_last_line_of_a_multiline_prompt() { + let mut session = Session::new(20, 6, 1); + let prompt = FixedPrompt::new("abc\n", "> ").with_right("R", true); + + session.paint(&prompt, "hi", ""); + + assert_eq!(session.screen.row(1), "abc"); + assert_eq!(session.screen.row(2), format!("> hi{}R", " ".repeat(15))); + assert_eq!(session.screen.cursor(), (4, 2)); +} + +// As above with the indicator folded into the left prompt. `prompt_height` +// counts rows with `str::lines()`, which yields no row after a trailing +// newline, so the prompt reads as one row and the right prompt lands a row +// too high. +#[test] +#[ignore = "prompt_height reads a trailing newline as no row"] +fn right_prompt_on_last_line_after_a_prompt_ending_in_newline() { + let mut session = Session::new(20, 6, 1); + let prompt = FixedPrompt::new("abc\n", "").with_right("R", true); + + session.paint(&prompt, "hi", ""); + + assert_eq!(session.screen.row(1), "abc"); + assert_eq!(session.screen.row(2), format!("hi{}R", " ".repeat(17))); + assert_eq!(session.screen.cursor(), (2, 2)); +} + +// A buffer taller than the screen is drawn as a viewport onto its tail. Which +// rows scroll off is decided by `compute_layout` counting `str::lines()`, so a +// single line wrapping past the bottom reads as fitting: the paint emits one +// row too many and the terminal scrolls, on every paint. +#[test] +#[ignore = "compute_layout counts logical lines, not wrapped rows"] +fn large_buffer_of_one_wrapped_line_stays_on_screen() { + let mut session = Session::new(80, 5, 0); + let prompt = FixedPrompt::new("> ", ""); + let buffer = "x".repeat(400); + + session.paint(&prompt, &buffer, ""); + session.paint(&prompt, &buffer, ""); + + assert_eq!(session.screen.scrolled(), 0); + assert_eq!(session.screen.row(4), "xx"); + assert_eq!(session.screen.cursor(), (2, 4)); +} diff --git a/src/painting/test_screen.rs b/src/painting/test_screen.rs new file mode 100644 index 000000000..1cc0532b2 --- /dev/null +++ b/src/painting/test_screen.rs @@ -0,0 +1,88 @@ +//! A virtual terminal for painting tests. +//! +//! [`W::capture`](super::W::capture) records the bytes the painter emits, but +//! asserting on escape sequences says nothing about what reaches the screen, +//! and the painter's own layout math cannot serve as the oracle for itself. +//! This feeds those bytes through an independent terminal emulator, so a test +//! can assert on rows, the cursor and scrolling instead. +//! +//! Where real terminals disagree, `vt100` is just one opinion: it keeps a +//! deferred wrap across DECSC/DECRC, and it wraps a wide grapheme early when +//! one column is left (tmux's behavior). A test should assert that the painter +//! stays out of such a case, not on how this emulator resolves it. +//! +//! Tests talk to [`TestScreen`] rather than to `vt100` directly, so the +//! emulator can be swapped by changing this file alone. + +/// Rows kept above the screen; far more than any test scrolls, so +/// [`TestScreen::scrolled`] never undercounts. +const SCROLLBACK: usize = 1000; + +pub(crate) struct TestScreen { + parser: vt100::Parser, + /// How much of the painter's capture has been fed already, so + /// [`TestScreen::sync`] can be called after every paint. + consumed: usize, +} + +impl TestScreen { + pub(crate) fn new(width: u16, height: u16) -> Self { + Self { + parser: vt100::Parser::new(height, width, SCROLLBACK), + consumed: 0, + } + } + + /// Feed bytes that did not come from the painter, such as output a host + /// printed before the prompt. + pub(crate) fn feed(&mut self, bytes: &[u8]) { + self.parser.process(bytes); + } + + /// Feed whatever `captured` holds beyond what was fed last time. Pass the + /// whole of [`W::captured`](super::W::captured) after each paint. + pub(crate) fn sync(&mut self, captured: &[u8]) { + self.parser.process(&captured[self.consumed..]); + self.consumed = captured.len(); + } + + /// The text on `row`, trailing blanks trimmed. + pub(crate) fn row(&self, row: u16) -> String { + self.rows() + .into_iter() + .nth(row.into()) + .unwrap_or_else(|| panic!("row {row} is off the screen")) + } + + /// Every visible row, trailing blanks trimmed. + pub(crate) fn rows(&self) -> Vec { + let (_, width) = self.parser.screen().size(); + self.parser + .screen() + .rows(0, width) + .map(|row| row.trim_end().to_owned()) + .collect() + } + + /// Where the cursor is, as `(column, row)` like the painter and crossterm + /// count it. The column equals the screen width while a wrap is pending. + pub(crate) fn cursor(&self) -> (u16, u16) { + let (row, column) = self.parser.screen().cursor_position(); + (column, row) + } + + pub(crate) fn cursor_visible(&self) -> bool { + !self.parser.screen().hide_cursor() + } + + /// Rows pushed off the top into scrollback since the screen was created. + pub(crate) fn scrolled(&mut self) -> usize { + let screen = self.parser.screen_mut(); + // `vt100` only exposes the scrollback length through the view offset, + // which it clamps to the rows it holds. + screen.set_scrollback(usize::MAX); + let rows = screen.scrollback(); + screen.set_scrollback(0); + rows + } +} From 49e686736a1e42646ac99ef9fa20d80108a65705 Mon Sep 17 00:00:00 2001 From: kronberger-droid Date: Sun, 18 Oct 2026 14:04:06 +0200 Subject: [PATCH 2/2] test(painter): assert only what the painter decides in screen tests The wide grapheme test asserted where `vt100` wraps a grapheme that does not fit. The painter prints it raw and restores the position the terminal reached, so both the row and the cursor were the emulator's choice; the painter's own model of that wrap is covered by `deferred_wrap_row_on_wide_graphemes`. The two ignored repros now go green only on a full fix: the large buffer one tolerates a scroll on the first paint, and the trailing newline one also checks the cursor column and click mapping against the screen, so fixing `prompt_height` alone keeps it red. --- src/painting/painter/screen_tests.rs | 64 +++++++++++++++++++--------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/src/painting/painter/screen_tests.rs b/src/painting/painter/screen_tests.rs index 0374fc770..0357e6242 100644 --- a/src/painting/painter/screen_tests.rs +++ b/src/painting/painter/screen_tests.rs @@ -77,7 +77,7 @@ impl Session { // Pinned rather than read from `TERM`, which differs between a // developer's shell and CI. painter.term_is_dumb = false; - painter.prompt_start_row = PromptStartRow::Verified(anchor_row); + painter.prompt_start_row.mark_verified(anchor_row); Self { painter, screen: TestScreen::new(width, height), @@ -98,13 +98,35 @@ impl Session { /// Paint `prompt` with the cursor between `before` and `after`. fn paint(&mut self, prompt: &FixedPrompt, before: &str, after: &str) { - let mode = PromptEditMode::Default; - let lines = PromptLines::new(prompt, mode.clone(), None, before, after, ""); + let lines = Self::lines(prompt, before, after); self.painter - .repaint_buffer(prompt, &lines, mode, None, false, &None) + .repaint_buffer(prompt, &lines, PromptEditMode::Default, None, false, &None) .expect("repaint_buffer failed"); self.screen.sync(self.painter.stdout.captured()); } + + /// The lines [`Session::paint`] paints for the same arguments. + fn lines<'p>(prompt: &'p FixedPrompt, before: &'p str, after: &'p str) -> PromptLines<'p> { + PromptLines::new(prompt, PromptEditMode::Default, None, before, after, "") + } + + /// The buffer offset the painter maps a click on `(column, row)` to, from + /// the layout of the last paint, which must have had the same arguments. + fn click( + &self, + prompt: &FixedPrompt, + before: &str, + after: &str, + column: u16, + row: u16, + ) -> Option { + let lines = Self::lines(prompt, before, after); + let layout = self.painter.last_layout.as_ref().expect("painted before"); + let snapshot = self + .painter + .render_snapshot(&lines, None, before, after, layout); + self.painter.screen_to_buffer_offset(&snapshot, column, row) + } } #[test] @@ -157,18 +179,6 @@ fn text_filling_the_width_puts_the_cursor_on_the_next_row() { assert_eq!(session.screen.cursor(), (0, 2)); } -// A wide grapheme with one column left wraps whole, leaving that column blank. -#[test] -fn wide_grapheme_without_room_wraps_to_the_next_row() { - let mut session = Session::new(5, 6, 1); - - session.paint(&FixedPrompt::new("> ", ""), "ab中", ""); - - assert_eq!(session.screen.row(1), "> ab"); - assert_eq!(session.screen.row(2), "中"); - assert_eq!(session.screen.cursor(), (2, 2)); -} - #[test] fn right_prompt_on_last_line_of_a_multiline_prompt() { let mut session = Session::new(20, 6, 1); @@ -181,12 +191,14 @@ fn right_prompt_on_last_line_of_a_multiline_prompt() { assert_eq!(session.screen.cursor(), (4, 2)); } -// As above with the indicator folded into the left prompt. `prompt_height` +// As above with the indicator folded into the left prompt. The row math // counts rows with `str::lines()`, which yields no row after a trailing -// newline, so the prompt reads as one row and the right prompt lands a row -// too high. +// newline, so the prompt reads as one row: the right prompt lands a row too +// high, and the cursor column and buffer start the painter derives (used for +// menu placement and click mapping) are measured from `abc`, not the empty +// row the buffer is on. #[test] -#[ignore = "prompt_height reads a trailing newline as no row"] +#[ignore = "row math reads a trailing newline as no row"] fn right_prompt_on_last_line_after_a_prompt_ending_in_newline() { let mut session = Session::new(20, 6, 1); let prompt = FixedPrompt::new("abc\n", "").with_right("R", true); @@ -196,6 +208,10 @@ fn right_prompt_on_last_line_after_a_prompt_ending_in_newline() { assert_eq!(session.screen.row(1), "abc"); assert_eq!(session.screen.row(2), format!("hi{}R", " ".repeat(17))); assert_eq!(session.screen.cursor(), (2, 2)); + let painter_column = Session::lines(&prompt, "hi", "").cursor_pos(20).0; + assert_eq!(painter_column, session.screen.cursor().0); + // The `i` on screen. + assert_eq!(session.click(&prompt, "hi", "", 1, 2), Some(1)); } // A buffer taller than the screen is drawn as a viewport onto its tail. Which @@ -210,9 +226,15 @@ fn large_buffer_of_one_wrapped_line_stays_on_screen() { let buffer = "x".repeat(400); session.paint(&prompt, &buffer, ""); + let scrolled_by_first_paint = session.screen.scrolled(); session.paint(&prompt, &buffer, ""); - assert_eq!(session.screen.scrolled(), 0); + // The first paint may scroll to make room; a repaint of the same buffer + // has nothing new to make room for. + assert_eq!(session.screen.scrolled(), scrolled_by_first_paint); + for row in 0..4 { + assert_eq!(session.screen.row(row), "x".repeat(80)); + } assert_eq!(session.screen.row(4), "xx"); assert_eq!(session.screen.cursor(), (2, 4)); }