use super::*;
use crate::screen::cell::{Cell, Row};
use crate::screen::grid::{CursorShape, Grid, MouseEncoding};
use crate::screen::style::{Color, Style, StyleId, StyleTable};
fn set_style(cell: &mut Cell, style: Style, st: &mut StyleTable) {
cell.style_id = st.intern(style);
}
#[test]
fn render_line_blank() {
let row = Row::new(80);
let st = StyleTable::new();
let result = render_line(&row, &st);
assert!(result.is_empty(), "blank line should produce empty vec");
}
#[test]
fn render_line_with_text() {
let mut row = Row::new(10);
row[0].c = 'H';
row[1].c = 'i';
let st = StyleTable::new();
let result = render_line(&row, &st);
assert_eq!(result, b"Hi");
}
#[test]
fn render_line_with_style() {
let mut st = StyleTable::new();
let mut row = Row::new(10);
row[0].c = 'R';
set_style(
&mut row[0],
Style {
fg: Some(Color::Indexed(1)),
..Style::default()
},
&mut st,
);
let result = render_line(&row, &st);
assert!(
result.starts_with(b"\x1b[0;31mR"),
"expected combined reset+set, got: {:?}",
String::from_utf8_lossy(&result)
);
assert!(result.ends_with(b"\x1b[0m"));
}
#[test]
fn render_line_with_matches_render_line() {
let mut st = StyleTable::new();
let mut row = Row::new(12);
row[0].c = 'A';
set_style(
&mut row[0],
Style {
fg: Some(Color::Indexed(2)),
bold: true,
..Style::default()
},
&mut st,
);
row[1].c = '界';
row[1].width = 2;
row[2].width = 0; row[3].c = 'x';
row.push_combining(3, '\u{0301}');
let old = render_line(&row, &st);
let new = render_line_with(&row, |id| st.get(id));
assert_eq!(
old, new,
"render_line_with must produce byte-identical output"
);
}
#[test]
fn render_screen_full() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[2J\x1b[H"),
"full render must emit screen clear"
);
assert!(text.contains("\x1b[1;1H"));
}
#[test]
fn render_screen_incremental() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(!text.contains("\x1b[2J"));
}
#[test]
fn render_line_skips_wide_char_continuation() {
let mut row = Row::new(10);
row[0] = Cell::new('你', StyleId::default(), 2);
row[1] = Cell::new('\0', StyleId::default(), 0);
row[2].c = 'A';
let st = StyleTable::new();
let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(text.contains('你'));
assert!(text.contains('A'));
assert!(!text.contains('\0'));
}
#[test]
fn render_screen_includes_title() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "My Title", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("\x1b]2;My Title\x07"));
}
#[test]
fn render_screen_no_title_when_empty() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(!text.contains("\x1b]2;"));
}
#[test]
fn render_screen_hidden_cursor() {
let mut grid = Grid::new(10, 3, 0);
grid.set_cursor_visible(false);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("\x1b[?25l"));
assert!(!text.contains("\x1b[?25h"));
}
#[test]
fn render_screen_incremental_dirty_tracking() {
let mut grid = Grid::new(10, 5, 0);
grid.set_cursor_x_unclamped(3);
grid.set_cursor_y_unclamped(4);
let mut cache = RenderCache::new();
let result1 = render_screen(&grid, "", false, &mut cache);
let text1 = String::from_utf8_lossy(&result1);
assert!(
text1.contains("\x1b[1;1H"),
"row 1 should be drawn on first render"
);
assert!(
text1.contains("\x1b[2;1H"),
"row 2 should be drawn on first render"
);
assert!(
text1.contains("\x1b[3;1H"),
"row 3 should be drawn on first render"
);
let result2 = render_screen(&grid, "", false, &mut cache);
let text2 = String::from_utf8_lossy(&result2);
assert!(
!text2.contains("\x1b[1;1H"),
"unchanged rows should be skipped in incremental render"
);
assert!(
!text2.contains("\x1b[2;1H"),
"unchanged rows should be skipped in incremental render"
);
grid.visible_row_mut(1)[0].c = 'X';
let result3 = render_screen(&grid, "", false, &mut cache);
let text3 = String::from_utf8_lossy(&result3);
assert!(
text3.contains("\x1b[2;1H"),
"changed row should be redrawn in incremental render"
);
assert!(
!text3.contains("\x1b[1;1H"),
"unchanged row 1 should be skipped"
);
assert!(
!text3.contains("\x1b[3;1H"),
"unchanged row 3 should be skipped"
);
}
#[test]
fn render_screen_synchronized_output() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.starts_with("\x1b[?2026h"),
"render should start with synchronized output begin"
);
assert!(
text.ends_with("\x1b[?2026l"),
"render should end with synchronized output end"
);
}
#[test]
fn render_screen_cursor_position() {
let mut grid = Grid::new(10, 5, 0);
grid.set_cursor_x_unclamped(4);
grid.set_cursor_y_unclamped(2);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[3;5H"),
"cursor should be at row 3, col 5 (1-indexed), got: {:?}",
text.matches("\x1b[").collect::<Vec<_>>()
);
}
#[test]
fn render_screen_title_cached() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let result1 = render_screen(&grid, "Title1", false, &mut cache);
assert!(String::from_utf8_lossy(&result1).contains("\x1b]2;Title1\x07"));
let result2 = render_screen(&grid, "Title1", false, &mut cache);
assert!(
!String::from_utf8_lossy(&result2).contains("\x1b]2;"),
"same title should not be re-emitted"
);
let result3 = render_screen(&grid, "Title2", false, &mut cache);
assert!(
String::from_utf8_lossy(&result3).contains("\x1b]2;Title2\x07"),
"changed title should be emitted"
);
}
#[test]
fn render_screen_title_sanitized() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let evil_title = "bad\x07title";
let result = render_screen(&grid, evil_title, false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b]2;badtitle\x07"),
"control chars should be stripped from title"
);
}
#[test]
fn render_line_multiple_style_changes() {
let mut st = StyleTable::new();
let mut row = Row::new(10);
row[0].c = 'R';
set_style(
&mut row[0],
Style {
fg: Some(Color::Indexed(1)),
..Style::default()
},
&mut st,
);
row[1].c = 'G';
set_style(
&mut row[1],
Style {
fg: Some(Color::Indexed(2)),
..Style::default()
},
&mut st,
);
row[2].c = 'N'; let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("R"), "should contain 'R'");
assert!(text.contains("G"), "should contain 'G'");
assert!(text.contains("N"), "should contain 'N'");
let combined_sgr_count = text.matches("\x1b[0;").count() + text.matches("\x1b[0m").count();
assert!(
combined_sgr_count >= 2,
"expected at least 2 combined reset+set SGR sequences, got {}",
combined_sgr_count
);
}
#[test]
fn render_screen_full_mode_emits_mouse_modes() {
let mut grid = Grid::new(10, 3, 0);
grid.modes_mut().mouse_modes.any = true;
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?1000l"),
"full render should disable mouse mode 1000"
);
assert!(
text.contains("\x1b[?1002l"),
"full render should disable mouse mode 1002"
);
assert!(
text.contains("\x1b[?1003h"),
"full render should enable active mouse mode 1003"
);
}
#[test]
fn render_screen_mode_delta_mouse_switch() {
let mut grid = Grid::new(10, 3, 0);
grid.modes_mut().mouse_modes.click = true;
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", true, &mut cache);
grid.modes_mut().mouse_modes.click = false;
grid.modes_mut().mouse_modes.any = true;
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?1000l"),
"delta should disable old mouse mode 1000"
);
assert!(
text.contains("\x1b[?1003h"),
"delta should enable new mouse mode 1003"
);
}
#[test]
fn render_screen_mode_delta_bracketed_paste() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", true, &mut cache);
grid.modes_mut().bracketed_paste = true;
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?2004h"),
"delta should emit bracketed paste enable"
);
}
#[test]
fn render_cache_invalidate() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "test", false, &mut cache);
assert!(!cache.rows.is_empty());
assert!(cache.last_modes.is_some());
cache.invalidate();
assert!(cache.rows.is_empty());
assert!(cache.last_modes.is_none());
assert!(cache.last_title.is_empty());
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[1;1H"),
"after invalidate, all rows should be redrawn"
);
assert!(
text.contains("\x1b[2;1H"),
"after invalidate, all rows should be redrawn"
);
assert!(
text.contains("\x1b[3;1H"),
"after invalidate, all rows should be redrawn"
);
}
#[test]
fn render_line_styled_spaces_not_blank() {
let mut st = StyleTable::new();
let mut row = Row::new(10);
let red_bg = st.intern(Style {
bg: Some(Color::Indexed(1)),
..Style::default()
});
for cell in row.iter_mut() {
cell.c = ' ';
cell.style_id = red_bg;
}
let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b["),
"styled spaces should produce SGR sequences"
);
assert!(text.contains("41m"), "red bg should produce code 41");
}
#[test]
fn render_line_styled_trailing_space() {
let mut st = StyleTable::new();
let mut row = Row::new(5);
row[0].c = 'A';
row[4].c = ' ';
set_style(
&mut row[4],
Style {
bg: Some(Color::Indexed(4)),
..Style::default()
},
&mut st,
);
let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("44m"),
"trailing styled space should include blue bg SGR"
);
}
#[test]
fn render_line_wide_char_at_end() {
let mut row = Row::new(10);
row[8] = Cell::new('\u{4e16}', StyleId::default(), 2); row[9] = Cell::new('\0', StyleId::default(), 0); let st = StyleTable::new();
let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains('\u{4e16}'),
"wide char at end should be rendered"
);
assert!(
!text.contains('\0'),
"continuation cell should not produce output"
);
}
#[test]
fn render_line_rgb_color() {
let mut st = StyleTable::new();
let mut row = Row::new(5);
row[0].c = 'X';
set_style(
&mut row[0],
Style {
fg: Some(Color::Rgb(100, 150, 200)),
..Style::default()
},
&mut st,
);
let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("38;2;100;150;200m"),
"RGB color should produce 38;2;R;G;B"
);
}
#[test]
fn render_line_combined_attributes() {
let mut st = StyleTable::new();
let mut row = Row::new(5);
row[0].c = 'Z';
set_style(
&mut row[0],
Style {
bold: true,
italic: true,
underline: super::super::style::UnderlineStyle::Single,
fg: Some(Color::Indexed(3)),
bg: Some(Color::Indexed(4)),
..Style::default()
},
&mut st,
);
let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("1;"), "bold should be present");
assert!(text.contains("3;"), "italic should be present");
assert!(text.contains(";4;"), "underline should be present");
assert!(text.contains("33"), "yellow fg should be present");
assert!(text.contains("44"), "blue bg should be present");
}
#[test]
fn render_line_256_color() {
let mut st = StyleTable::new();
let mut row = Row::new(5);
row[0].c = 'P';
set_style(
&mut row[0],
Style {
fg: Some(Color::Indexed(200)),
..Style::default()
},
&mut st,
);
let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("38;5;200m"),
"palette index 200 should produce 38;5;200"
);
}
#[test]
fn render_screen_title_cleared() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "Hello", false, &mut cache);
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b]2;\x07"),
"clearing title should emit empty OSC, got: {:?}",
text
);
}
#[test]
fn render_screen_after_resize() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
let grid2 = Grid::new(10, 5, 0);
let result = render_screen(&grid2, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
!text.contains("\x1b[1;1H"),
"unchanged row 1 should be skipped after resize"
);
assert!(
text.contains("\x1b[4;1H"),
"new row 4 should be redrawn after resize"
);
assert!(
text.contains("\x1b[5;1H"),
"new row 5 should be redrawn after resize"
);
}
#[test]
fn render_screen_style_only_change_detected() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
let sid = grid.style_table_mut().intern(Style {
fg: Some(Color::Indexed(1)),
..Style::default()
});
grid.visible_row_mut(1)[0].style_id = sid;
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[2;1H"),
"row with style-only change should be redrawn"
);
}
#[test]
fn render_screen_1x1_grid() {
let grid = Grid::new(1, 1, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[1;1H"),
"1x1 grid should position at 1,1"
);
}
#[test]
fn render_screen_cursor_bottom_right() {
let mut grid = Grid::new(80, 24, 0);
grid.set_cursor_x_unclamped(79);
grid.set_cursor_y_unclamped(23);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[24;80H"),
"cursor at bottom-right should position at row 24, col 80"
);
}
#[test]
fn render_screen_mouse_encoding_1006() {
let mut grid = Grid::new(10, 3, 0);
grid.modes_mut().mouse_encoding = MouseEncoding::Sgr;
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?1006h"),
"SGR mouse encoding should be enabled"
);
}
#[test]
fn render_screen_mouse_encoding_1005() {
let mut grid = Grid::new(10, 3, 0);
grid.modes_mut().mouse_encoding = MouseEncoding::Utf8;
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?1005h"),
"UTF-8 mouse encoding should be enabled"
);
}
#[test]
fn render_screen_cursor_shape_delta() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", true, &mut cache);
grid.modes_mut().cursor_shape = CursorShape::BlinkBar;
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[5 q"),
"cursor shape change should emit DECSCUSR"
);
}
#[test]
fn render_screen_keypad_mode_delta() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", true, &mut cache);
grid.modes_mut().keypad_app_mode = true;
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("\x1b="), "keypad app mode should emit ESC =");
grid.modes_mut().keypad_app_mode = false;
let result2 = render_screen(&grid, "", false, &mut cache);
let text2 = String::from_utf8_lossy(&result2);
assert!(
text2.contains("\x1b>"),
"keypad normal mode should emit ESC >"
);
}
#[test]
fn render_line_combining_mark() {
let mut row = Row::new(10);
row[0].c = 'e';
row.push_combining(0, '\u{0301}'); let st = StyleTable::new();
let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("e\u{0301}"),
"combining mark should be rendered after base char"
);
}
#[test]
fn render_line_combining_on_wide_char() {
let mut row = Row::new(10);
row[0] = Cell::new('\u{4e16}', StyleId::default(), 2);
row.push_combining(0, '\u{0308}');
row[1] = Cell::new('\0', StyleId::default(), 0);
let st = StyleTable::new();
let result = render_line(&row, &st);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\u{4e16}\u{0308}"),
"combining mark on wide char should render"
);
}
#[test]
fn scrollback_positions_cursor_at_bottom() {
let grid = Grid::new(80, 24, 0);
let mut cache = RenderCache::new();
let scrollback = vec![b"line one".to_vec()];
let result = render_screen_with_scrollback(&grid, "", &scrollback, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[24;1H"),
"scrollback should position cursor at bottom row"
);
}
#[test]
fn scrollback_lines_appear_before_screen_clear() {
let grid = Grid::new(80, 24, 0);
let mut cache = RenderCache::new();
let scrollback = vec![b"old prompt".to_vec(), b"ls output".to_vec()];
let result = render_screen_with_scrollback(&grid, "", &scrollback, &mut cache);
let text = String::from_utf8_lossy(&result);
let pos_line1 = text.find("old prompt").expect("scrollback line 1 missing");
let pos_line2 = text.find("ls output").expect("scrollback line 2 missing");
let pos_clear = text.find("\x1b[2J").expect("screen clear missing");
assert!(pos_line1 < pos_line2, "scrollback lines must be in order");
assert!(
pos_line2 < pos_clear,
"scrollback must precede screen clear"
);
}
#[test]
fn scrollback_lines_use_cursor_positioning() {
let grid = Grid::new(80, 24, 0);
let mut cache = RenderCache::new();
let scrollback = vec![b"AAA".to_vec(), b"BBB".to_vec()];
let result = render_screen_with_scrollback(&grid, "", &scrollback, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("AAA"), "AAA should be present");
assert!(text.contains("BBB"), "BBB should be present");
let raw = &result;
let pos_a = raw
.windows(3)
.position(|w| w == b"AAA")
.expect("AAA missing");
assert_eq!(
&raw[pos_a + 3..pos_a + 6],
b"\x1b[K",
"scrollback line should end with EL"
);
}
#[test]
fn scrollback_outside_sync_block() {
let grid = Grid::new(80, 24, 0);
let mut cache = RenderCache::new();
let scrollback = vec![b"scroll line".to_vec()];
let result = render_screen_with_scrollback(&grid, "", &scrollback, &mut cache);
let text = String::from_utf8_lossy(&result);
let sync_begin = text.find("\x1b[?2026h").expect("sync begin missing");
let pos_scroll = text
.find("scroll line")
.expect("scrollback content missing");
let sync_end = text.rfind("\x1b[?2026l").expect("sync end missing");
assert!(
pos_scroll < sync_begin,
"scrollback must be before sync begin (scrollback at {}, sync at {})",
pos_scroll,
sync_begin
);
assert!(sync_begin < sync_end, "sync begin must precede sync end");
}
#[test]
fn scrollback_forces_full_redraw() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
assert!(!cache.rows.is_empty());
grid.visible_row_mut(1)[0].c = 'X';
let scrollback = vec![b"old".to_vec()];
let result = render_screen_with_scrollback(&grid, "", &scrollback, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[1;1H"),
"row 1 must be redrawn after scrollback"
);
assert!(
text.contains("\x1b[2;1H"),
"row 2 must be redrawn after scrollback"
);
assert!(
text.contains("\x1b[3;1H"),
"row 3 must be redrawn after scrollback"
);
}
#[test]
fn no_scrollback_no_crlf_in_output() {
let grid = Grid::new(80, 24, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", false, &mut cache);
assert!(
!result.windows(2).any(|w| w == b"\r\n"),
"render without scrollback must not contain \\r\\n"
);
}
#[test]
fn reattach_history_flush_count() {
let rows: u16 = 5;
let grid = Grid::new(80, rows, 0);
let mut cache = RenderCache::new();
let render = render_screen(&grid, "", true, &mut cache);
let mut reattach_data = Vec::new();
let flush_count = rows.saturating_sub(1) as usize;
reattach_data.extend(std::iter::repeat(b'\n').take(flush_count));
reattach_data.extend_from_slice(&render);
let leading_newlines = reattach_data.iter().take_while(|&&b| b == b'\n').count();
assert_eq!(
leading_newlines,
(rows - 1) as usize,
"reattach should prepend exactly rows-1 newlines, got {}",
leading_newlines
);
assert_eq!(
&reattach_data[flush_count..flush_count + 8],
b"\x1b[?2026h",
"render must start with synchronized output after flush newlines"
);
}
#[test]
fn reattach_no_flush_without_history() {
let grid = Grid::new(80, 5, 0);
let mut cache = RenderCache::new();
let render = render_screen(&grid, "", true, &mut cache);
assert_eq!(
render[0], b'\x1b',
"render without history must start directly with escape, not newline"
);
}
#[test]
fn render_no_standalone_bell() {
let grid = Grid::new(80, 24, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "My Title", true, &mut cache);
for (i, &byte) in result.iter().enumerate() {
if byte == 0x07 {
let prefix = &result[..i];
let osc_start = prefix.windows(2).rposition(|w| w == b"\x1b]");
assert!(
osc_start.is_some(),
"BEL at byte offset {} is standalone (not inside an OSC sequence)",
i
);
}
}
}
#[test]
fn render_full_redraw_no_standalone_bell() {
let grid = Grid::new(80, 24, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "Title1", false, &mut cache);
cache.invalidate();
let result = render_screen(&grid, "Title2", true, &mut cache);
for (i, &byte) in result.iter().enumerate() {
if byte == 0x07 {
let prefix = &result[..i];
let osc_start = prefix.windows(2).rposition(|w| w == b"\x1b]");
assert!(
osc_start.is_some(),
"BEL at byte offset {} is standalone after cache invalidate",
i
);
}
}
}
#[test]
fn render_no_title_no_bell_bytes() {
let grid = Grid::new(80, 24, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", false, &mut cache);
let bell_count = result.iter().filter(|&&b| b == 0x07).count();
assert_eq!(
bell_count, 0,
"render with empty title should produce zero BEL bytes, got {}",
bell_count
);
}
#[test]
fn render_cached_title_no_bell() {
let grid = Grid::new(80, 24, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "Hello", false, &mut cache);
let result = render_screen(&grid, "Hello", false, &mut cache);
let bell_count = result.iter().filter(|&&b| b == 0x07).count();
assert_eq!(
bell_count, 0,
"cached title should produce zero BEL bytes, got {}",
bell_count
);
}
#[test]
fn trait_render_incremental_skips_unchanged_rows() {
use super::super::Screen;
let mut screen = Screen::new(10, 5, 0);
screen.process(b"Hello");
screen.process(b"\x1b[4;4H");
let mut renderer = AnsiRenderer::new();
let result1 = renderer.render(&screen, false);
let text1 = String::from_utf8_lossy(&result1);
assert!(
text1.contains("\x1b[1;1H"),
"first render should draw row 1"
);
assert!(
text1.contains("\x1b[2;1H"),
"first render should draw row 2"
);
assert!(
text1.contains("\x1b[3;1H"),
"first render should draw row 3"
);
let result2 = renderer.render(&screen, false);
let text2 = String::from_utf8_lossy(&result2);
assert!(
!text2.contains("\x1b[1;1H"),
"unchanged row 1 should be skipped in trait incremental render"
);
assert!(
!text2.contains("\x1b[2;1H"),
"unchanged row 2 should be skipped in trait incremental render"
);
}
#[test]
fn trait_render_incremental_redraws_changed_row() {
use super::super::Screen;
let mut screen = Screen::new(10, 5, 0);
screen.process(b"Hello");
screen.process(b"\x1b[4;4H");
let mut renderer = AnsiRenderer::new();
let _ = renderer.render(&screen, false);
screen.process(b"\x1b[1;1HWorld");
screen.process(b"\x1b[4;4H");
let result = renderer.render(&screen, false);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[1;1H"),
"changed row should be redrawn via trait path"
);
assert!(
!text.contains("\x1b[3;1H"),
"unchanged row 3 should be skipped via trait path"
);
}
#[test]
fn trait_render_full_redraws_all_rows() {
use super::super::Screen;
let mut screen = Screen::new(10, 3, 0);
screen.process(b"Hi");
let mut renderer = AnsiRenderer::new();
let _ = renderer.render(&screen, false);
let result = renderer.render(&screen, true);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("\x1b[1;1H"), "full render should draw row 1");
assert!(text.contains("\x1b[2;1H"), "full render should draw row 2");
assert!(text.contains("\x1b[3;1H"), "full render should draw row 3");
}
fn count_pattern(haystack: &[u8], needle: &[u8]) -> usize {
haystack
.windows(needle.len())
.filter(|w| *w == needle)
.count()
}
#[test]
fn noop_render_returns_empty() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
let result = render_screen(&grid, "", false, &mut cache);
assert!(
result.is_empty(),
"no-op render should return empty, got {} bytes: {:?}",
result.len(),
String::from_utf8_lossy(&result)
);
}
#[test]
fn noop_render_trait_returns_empty() {
use super::super::Screen;
let mut screen = Screen::new(10, 3, 0);
screen.process(b"Test");
screen.process(b"\x1b[2;3H");
let mut renderer = AnsiRenderer::new();
let _ = renderer.render(&screen, false);
let result = renderer.render(&screen, false);
assert!(
result.is_empty(),
"trait path no-op render should return empty, got {} bytes",
result.len()
);
}
#[test]
fn noop_render_after_cursor_only_change() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
grid.set_cursor_x_unclamped(5);
let result = render_screen(&grid, "", false, &mut cache);
assert!(!result.is_empty(), "cursor move should produce output");
let result2 = render_screen(&grid, "", false, &mut cache);
assert!(
result2.is_empty(),
"second render after cursor-only change should be no-op"
);
}
#[test]
fn cursor_position_cached_across_renders() {
let mut grid = Grid::new(10, 5, 0);
grid.set_cursor_x_unclamped(3);
grid.set_cursor_y_unclamped(2);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
let result = render_screen(&grid, "", false, &mut cache);
assert!(
result.is_empty(),
"same cursor position should not produce output"
);
grid.set_cursor_x_unclamped(7);
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[3;8H"),
"new cursor position should emit CUP, got: {:?}",
text
);
}
#[test]
fn cursor_position_always_emitted_on_full() {
let mut grid = Grid::new(10, 3, 0);
grid.set_cursor_x_unclamped(2);
grid.set_cursor_y_unclamped(1);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[2;3H"),
"full render must always emit CUP even if cached"
);
}
#[test]
fn cursor_visibility_cached() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?25h"),
"first render should emit cursor show"
);
let result2 = render_screen(&grid, "", false, &mut cache);
assert!(
!String::from_utf8_lossy(&result2).contains("\x1b[?25h"),
"cached cursor visibility should not be re-emitted"
);
}
#[test]
fn cursor_visibility_change_detected() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
grid.set_cursor_visible(false);
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?25l"),
"cursor hide should be emitted when visibility changes"
);
let result2 = render_screen(&grid, "", false, &mut cache);
assert!(
result2.is_empty(),
"same cursor visibility should produce no-op render"
);
}
#[test]
fn scroll_region_cached() {
let grid = Grid::new(10, 5, 0);
let mut cache = RenderCache::new();
let result1 = render_screen(&grid, "", false, &mut cache);
let text1 = String::from_utf8_lossy(&result1);
assert!(text1.contains("r"), "first render should emit DECSTBM");
let result2 = render_screen(&grid, "", false, &mut cache);
assert!(
result2.is_empty(),
"same scroll region should produce no-op render"
);
}
#[test]
fn scroll_region_change_emitted() {
let mut grid = Grid::new(10, 10, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
grid.set_scroll_region(1, 7);
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[2;8r"),
"changed scroll region should emit DECSTBM"
);
}
#[test]
fn origin_mode_cursor_is_origin_relative() {
let mut grid = Grid::new(10, 10, 0);
grid.set_scroll_region(2, 7);
grid.modes_mut().origin_mode = true;
grid.set_cursor_y_unclamped(5);
grid.set_cursor_x_unclamped(0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("\x1b[?6h"), "origin mode should be emitted");
assert!(
text.contains("\x1b[3;8r"),
"scroll region should be emitted"
);
let after_modes = &text[text.find("\x1b[?6h").unwrap()..];
assert!(
after_modes.contains("\x1b[4;1H"),
"origin-mode cursor CUP must be relative to scroll_top, got: {:?}",
text
);
assert!(
!after_modes.contains("\x1b[6;1H"),
"absolute cursor CUP must not be emitted under origin mode, got: {:?}",
text
);
}
#[test]
fn trait_origin_mode_cursor_is_origin_relative() {
use super::super::Screen;
let mut screen = Screen::new(10, 10, 0);
screen.process(b"\x1b[3;8r");
screen.process(b"\x1b[?6h");
screen.process(b"\x1b[4;1H");
let mut renderer = AnsiRenderer::new();
let result = renderer.render(&screen, true);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("\x1b[?6h"), "origin mode should be emitted");
let after_modes = &text[text.find("\x1b[?6h").unwrap()..];
assert!(
after_modes.contains("\x1b[4;1H"),
"trait-path origin-mode cursor CUP must be origin-relative, got: {:?}",
text
);
assert!(
!after_modes.contains("\x1b[6;1H"),
"trait path must not emit absolute cursor CUP under origin mode, got: {:?}",
text
);
}
#[test]
fn no_origin_mode_cursor_is_absolute() {
let mut grid = Grid::new(10, 10, 0);
grid.set_scroll_region(2, 7);
grid.set_cursor_y_unclamped(5);
grid.set_cursor_x_unclamped(0);
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
let after_modes = &text[text.find("\x1b[?6l").unwrap()..];
assert!(
after_modes.contains("\x1b[6;1H"),
"without origin mode cursor CUP must be absolute, got: {:?}",
text
);
}
#[test]
fn modes_delta_skips_unchanged() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
grid.modes_mut().bracketed_paste = true;
let result = render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?2004h"),
"changed mode should be emitted"
);
assert!(
!text.contains(" q"),
"unchanged cursor shape should not be re-emitted in delta"
);
assert!(
!text.contains("\x1b[?7"),
"unchanged autowrap should not be re-emitted in delta"
);
}
#[test]
fn modes_full_always_emitted() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
let result = render_screen(&grid, "", true, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(text.contains(" q"), "full render must emit cursor shape");
assert!(text.contains("\x1b[?7"), "full render must emit autowrap");
}
#[test]
fn title_not_reemitted_when_unchanged() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "MyApp", false, &mut cache);
let result = render_screen(&grid, "MyApp", false, &mut cache);
assert_eq!(
count_pattern(&result, b"\x1b]2;"),
0,
"unchanged title should not produce OSC"
);
}
#[test]
fn title_emitted_when_changed() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "OldTitle", false, &mut cache);
let result = render_screen(&grid, "NewTitle", false, &mut cache);
assert_eq!(
count_pattern(&result, b"\x1b]2;"),
1,
"changed title should emit exactly one OSC"
);
assert!(
result.windows(8).any(|w| w == b"NewTitle"),
"new title should be in output"
);
}
#[test]
fn incremental_render_smaller_than_full() {
let mut grid = Grid::new(80, 24, 0);
for i in 0..24 {
grid.visible_row_mut(i)[0].c = 'A';
}
let mut cache = RenderCache::new();
let full = render_screen(&grid, "Title", true, &mut cache);
grid.visible_row_mut(5)[1].c = 'B';
let incr = render_screen(&grid, "Title", false, &mut cache);
assert!(
incr.len() < full.len(),
"incremental single-row change ({} bytes) should be smaller than full ({} bytes)",
incr.len(),
full.len()
);
}
#[test]
fn incremental_no_screen_clear() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
let mut grid2 = grid;
grid2.visible_row_mut(0)[0].c = 'X';
let result = render_screen(&grid2, "", false, &mut cache);
assert_eq!(
count_pattern(&result, b"\x1b[2J"),
0,
"incremental render must never emit screen clear"
);
}
#[test]
fn noop_render_no_sync_block() {
let grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
let result = render_screen(&grid, "", false, &mut cache);
assert_eq!(
count_pattern(&result, b"\x1b[?2026h"),
0,
"no-op render should not emit sync begin"
);
assert_eq!(
count_pattern(&result, b"\x1b[?2026l"),
0,
"no-op render should not emit sync end"
);
}
#[test]
fn non_noop_render_has_sync_block() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
grid.visible_row_mut(0)[0].c = 'X';
let result = render_screen(&grid, "", false, &mut cache);
assert_eq!(
count_pattern(&result, b"\x1b[?2026h"),
1,
"non-noop render must have exactly one sync begin"
);
assert_eq!(
count_pattern(&result, b"\x1b[?2026l"),
1,
"non-noop render must have exactly one sync end"
);
}
#[test]
fn full_render_no_erase_line() {
let mut grid = Grid::new(10, 3, 0);
grid.visible_row_mut(0)[0].c = 'A';
let mut cache = RenderCache::new();
let result = render_screen(&grid, "", true, &mut cache);
assert_eq!(
count_pattern(&result, b"\x1b[K"),
0,
"full render should not emit per-row erase (screen already cleared)"
);
}
#[test]
fn incremental_render_uses_erase_line() {
let mut grid = Grid::new(10, 3, 0);
let mut cache = RenderCache::new();
let _ = render_screen(&grid, "", false, &mut cache);
grid.visible_row_mut(0)[0].c = 'A';
let result = render_screen(&grid, "", false, &mut cache);
assert!(
count_pattern(&result, b"\x1b[K") >= 1,
"incremental render should use erase-to-EOL for changed rows"
);
}
#[test]
fn trait_noop_render_no_sync_block() {
use super::super::Screen;
let mut screen = Screen::new(10, 3, 0);
screen.process(b"Hi");
let mut renderer = AnsiRenderer::new();
let _ = renderer.render(&screen, false);
let result = renderer.render(&screen, false);
assert!(result.is_empty(), "trait no-op should be empty");
assert_eq!(
count_pattern(&result, b"\x1b[?2026h"),
0,
"trait no-op should not emit sync begin"
);
}
#[test]
fn trait_cursor_position_cached() {
use super::super::Screen;
let mut screen = Screen::new(10, 5, 0);
screen.process(b"\x1b[3;4H");
let mut renderer = AnsiRenderer::new();
let result1 = renderer.render(&screen, false);
let text1 = String::from_utf8_lossy(&result1);
assert!(text1.contains("\x1b[3;4H"), "first render should emit CUP");
let result2 = renderer.render(&screen, false);
assert!(
result2.is_empty(),
"trait path: same cursor position should produce no-op"
);
screen.process(b"\x1b[1;1H");
let result3 = renderer.render(&screen, false);
let text3 = String::from_utf8_lossy(&result3);
assert!(
text3.contains("\x1b[1;1H"),
"trait path: new cursor position should emit CUP"
);
}
#[test]
fn trait_mode_delta_only_changed() {
use super::super::Screen;
let mut screen = Screen::new(10, 3, 0);
let mut renderer = AnsiRenderer::new();
let _ = renderer.render(&screen, false);
screen.process(b"\x1b[?2004h");
let result = renderer.render(&screen, false);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?2004h"),
"changed mode should be emitted"
);
assert!(
!text.contains(" q"),
"unchanged cursor shape should be skipped"
);
}
#[test]
fn trait_title_cached() {
use super::super::Screen;
let mut screen = Screen::new(10, 3, 0);
screen.process(b"\x1b]2;AppTitle\x07");
let mut renderer = AnsiRenderer::new();
let result1 = renderer.render(&screen, false);
assert!(
count_pattern(&result1, b"\x1b]2;") == 1,
"first render emits title"
);
let result2 = renderer.render(&screen, false);
assert!(result2.is_empty(), "same title should produce no-op");
screen.process(b"\x1b]2;NewTitle\x07");
let result3 = renderer.render(&screen, false);
assert!(
count_pattern(&result3, b"\x1b]2;") == 1,
"changed title emitted"
);
}
#[test]
fn trait_incremental_smaller_than_full() {
use super::super::Screen;
let mut screen = Screen::new(80, 24, 0);
for i in 0..24u8 {
screen.process(format!("\x1b[{};1H{}", i + 1, (b'A' + i) as char).as_bytes());
}
let mut renderer = AnsiRenderer::new();
let full = renderer.render(&screen, true);
screen.process(b"\x1b[5;1HCHANGED");
let incr = renderer.render(&screen, false);
assert!(
incr.len() < full.len(),
"trait incremental ({} bytes) should be smaller than full ({} bytes)",
incr.len(),
full.len()
);
}
#[test]
fn grid_and_trait_assembly_paths_are_byte_identical() {
let mut screen = crate::screen::Screen::new(20, 6, 50);
screen.process(b"\x1b[2;5r\x1b[?6h\x1b]2;t\x07\x1b[31mhi\x1b[0m\r\nworld");
let mut c1 = RenderCache::new();
let mut c2 = RenderCache::new();
let via_grid = render_screen(&screen.grid, screen.title(), true, &mut c1);
let via_trait = super::core::render_emulator_impl(&screen, &[], true, &mut c2);
assert_eq!(via_grid, via_trait);
}
#[test]
fn grid_and_trait_assembly_paths_byte_identical_with_scrollback() {
let mut screen = crate::screen::Screen::new(20, 6, 50);
screen.process(b"\x1b[2;5r\x1b[?6h\x1b]2;t\x07\x1b[31mhi\x1b[0m\r\nworld");
let lines: Vec<Vec<u8>> = vec![b"old line 1".to_vec(), b"old line 2".to_vec()];
let mut c1 = RenderCache::new();
let mut c2 = RenderCache::new();
let via_grid = render_screen_with_scrollback(&screen.grid, screen.title(), &lines, &mut c1);
let via_trait = super::core::render_emulator_impl(&screen, &lines, true, &mut c2);
assert_eq!(via_grid, via_trait);
}