Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
2 changes: 2 additions & 0 deletions src/painting/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down
3 changes: 3 additions & 0 deletions src/painting/painter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,9 @@ impl Painter {
}
}

#[cfg(test)]
mod screen_tests;

#[cfg(test)]
mod tests {
use super::*;
Expand Down
240 changes: 240 additions & 0 deletions src/painting/painter/screen_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
//! 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.mark_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 lines = Self::lines(prompt, before, after);
self.painter
.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<usize> {
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]
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));
}

#[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. The row math
// counts rows with `str::lines()`, which yields no row after a trailing
// 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 = "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);

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));
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
// 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, "");
let scrolled_by_first_paint = session.screen.scrolled();
session.paint(&prompt, &buffer, "");

// 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));
}
Loading
Loading