215 lines
6.7 KiB
Rust
215 lines
6.7 KiB
Rust
#![cfg(feature = "vt100-tests")]
|
|
#![expect(clippy::expect_used)]
|
|
|
|
use ratatui::backend::TestBackend;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Color;
|
|
use ratatui::style::Style;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
|
|
// Small helper macro to assert a collection contains an item with a clearer
|
|
// failure message.
|
|
macro_rules! assert_contains {
|
|
($collection:expr, $item:expr $(,)?) => {
|
|
assert!(
|
|
$collection.contains(&$item),
|
|
"Expected {:?} to contain {:?}",
|
|
$collection,
|
|
$item
|
|
);
|
|
};
|
|
($collection:expr, $item:expr, $($arg:tt)+) => {
|
|
assert!($collection.contains(&$item), $($arg)+);
|
|
};
|
|
}
|
|
|
|
struct TestScenario {
|
|
width: u16,
|
|
height: u16,
|
|
term: codex_tui::custom_terminal::Terminal<TestBackend>,
|
|
}
|
|
|
|
impl TestScenario {
|
|
fn new(width: u16, height: u16, viewport: Rect) -> Self {
|
|
let backend = TestBackend::new(width, height);
|
|
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
|
|
.expect("failed to construct terminal");
|
|
term.set_viewport_area(viewport);
|
|
Self {
|
|
width,
|
|
height,
|
|
term,
|
|
}
|
|
}
|
|
|
|
fn run_insert(&mut self, lines: Vec<Line<'static>>) -> Vec<u8> {
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
codex_tui::insert_history::insert_history_lines_to_writer(&mut self.term, &mut buf, lines);
|
|
buf
|
|
}
|
|
|
|
fn screen_rows_from_bytes(&self, bytes: &[u8]) -> Vec<String> {
|
|
let mut parser = vt100::Parser::new(self.height, self.width, 0);
|
|
parser.process(bytes);
|
|
let screen = parser.screen();
|
|
|
|
let mut rows: Vec<String> = Vec::with_capacity(self.height as usize);
|
|
for row in 0..self.height {
|
|
let mut s = String::with_capacity(self.width as usize);
|
|
for col in 0..self.width {
|
|
if let Some(cell) = screen.cell(row, col) {
|
|
if let Some(ch) = cell.contents().chars().next() {
|
|
s.push(ch);
|
|
} else {
|
|
s.push(' ');
|
|
}
|
|
} else {
|
|
s.push(' ');
|
|
}
|
|
}
|
|
rows.push(s.trim_end().to_string());
|
|
}
|
|
rows
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hist_001_basic_insertion_no_wrap() {
|
|
// Screen of 20x6; viewport is the last row (height=1 at y=5)
|
|
let area = Rect::new(0, 5, 20, 1);
|
|
let mut scenario = TestScenario::new(20, 6, area);
|
|
|
|
let lines = vec![Line::from("first"), Line::from("second")];
|
|
let buf = scenario.run_insert(lines);
|
|
let rows = scenario.screen_rows_from_bytes(&buf);
|
|
assert_contains!(rows, String::from("first"));
|
|
assert_contains!(rows, String::from("second"));
|
|
let first_idx = rows
|
|
.iter()
|
|
.position(|r| r == "first")
|
|
.expect("expected 'first' row to be present");
|
|
let second_idx = rows
|
|
.iter()
|
|
.position(|r| r == "second")
|
|
.expect("expected 'second' row to be present");
|
|
assert_eq!(second_idx, first_idx + 1, "rows should be adjacent");
|
|
}
|
|
|
|
#[test]
|
|
fn hist_002_long_token_wraps() {
|
|
let area = Rect::new(0, 5, 20, 1);
|
|
let mut scenario = TestScenario::new(20, 6, area);
|
|
|
|
let long = "A".repeat(45); // > 2 lines at width 20
|
|
let lines = vec![Line::from(long.clone())];
|
|
let buf = scenario.run_insert(lines);
|
|
let mut parser = vt100::Parser::new(6, 20, 0);
|
|
parser.process(&buf);
|
|
let screen = parser.screen();
|
|
|
|
// Count total A's on the screen
|
|
let mut count_a = 0usize;
|
|
for row in 0..6 {
|
|
for col in 0..20 {
|
|
if let Some(cell) = screen.cell(row, col) {
|
|
if let Some(ch) = cell.contents().chars().next() {
|
|
if ch == 'A' {
|
|
count_a += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
assert_eq!(
|
|
count_a,
|
|
long.len(),
|
|
"wrapped content did not preserve all characters"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hist_003_emoji_and_cjk() {
|
|
let area = Rect::new(0, 5, 20, 1);
|
|
let mut scenario = TestScenario::new(20, 6, area);
|
|
|
|
let text = String::from("😀😀😀😀😀 你好世界");
|
|
let lines = vec![Line::from(text.clone())];
|
|
let buf = scenario.run_insert(lines);
|
|
let rows = scenario.screen_rows_from_bytes(&buf);
|
|
let reconstructed: String = rows.join("").chars().filter(|c| *c != ' ').collect();
|
|
for ch in text.chars().filter(|c| !c.is_whitespace()) {
|
|
assert!(
|
|
reconstructed.contains(ch),
|
|
"missing character {ch:?} in reconstructed screen"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hist_004_mixed_ansi_spans() {
|
|
let area = Rect::new(0, 5, 20, 1);
|
|
let mut scenario = TestScenario::new(20, 6, area);
|
|
|
|
let line = Line::from(vec![
|
|
Span::styled("red", Style::default().fg(Color::Red)),
|
|
Span::raw("+plain"),
|
|
]);
|
|
let buf = scenario.run_insert(vec![line]);
|
|
let rows = scenario.screen_rows_from_bytes(&buf);
|
|
assert_contains!(rows, String::from("red+plain"));
|
|
}
|
|
|
|
#[test]
|
|
fn hist_006_cursor_restoration() {
|
|
let area = Rect::new(0, 5, 20, 1);
|
|
let mut scenario = TestScenario::new(20, 6, area);
|
|
|
|
let lines = vec![Line::from("x")];
|
|
let buf = scenario.run_insert(lines);
|
|
let s = String::from_utf8_lossy(&buf);
|
|
// CUP to 1;1 (ANSI: ESC[1;1H)
|
|
assert!(
|
|
s.contains("\u{1b}[1;1H"),
|
|
"expected final CUP to 1;1 in output, got: {s:?}"
|
|
);
|
|
// Reset scroll region
|
|
assert!(
|
|
s.contains("\u{1b}[r"),
|
|
"expected reset scroll region in output, got: {s:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hist_005_pre_scroll_region_down() {
|
|
// Viewport not at bottom: y=3 (0-based), height=1
|
|
let area = Rect::new(0, 3, 20, 1);
|
|
let mut scenario = TestScenario::new(20, 6, area);
|
|
|
|
let lines = vec![Line::from("first"), Line::from("second")];
|
|
let buf = scenario.run_insert(lines);
|
|
let s = String::from_utf8_lossy(&buf);
|
|
// Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based)
|
|
assert!(
|
|
s.contains("\u{1b}[4;6r"),
|
|
"expected pre-scroll SetScrollRegion 4..6, got: {s:?}"
|
|
);
|
|
// Expect we moved cursor to top of that region: row 3 (0-based) => CUP 4;1H
|
|
assert!(
|
|
s.contains("\u{1b}[4;1H"),
|
|
"expected cursor at top of pre-scroll region, got: {s:?}"
|
|
);
|
|
// Expect at least two Reverse Index commands (ESC M) for two inserted lines
|
|
let ri_count = s.matches("\u{1b}M").count();
|
|
assert!(
|
|
ri_count >= 1,
|
|
"expected at least one RI (ESC M), got: {s:?}"
|
|
);
|
|
// After pre-scroll, we set insertion scroll region to [1 .. new_top] => [1 .. 5]
|
|
assert!(
|
|
s.contains("\u{1b}[1;5r"),
|
|
"expected insertion SetScrollRegion 1..5, got: {s:?}"
|
|
);
|
|
}
|