use super::*;
use render::RenderCache;
fn strip_ansi(bytes: &[u8]) -> String {
let s = String::from_utf8_lossy(bytes);
let mut out = String::new();
let mut in_esc = false;
for ch in s.chars() {
if in_esc {
if ch.is_ascii_alphabetic() || ch == 'm' {
in_esc = false;
}
continue;
}
if ch == '\x1b' {
in_esc = true;
continue;
}
if ch >= ' ' {
out.push(ch);
}
}
out.trim_end().to_string()
}
fn screen_lines(screen: &Screen) -> Vec<String> {
screen
.grid
.visible_rows()
.map(|row| {
let s: String = row.iter().map(|c| c.c).collect();
s.trim_end().to_string()
})
.collect()
}
fn history_texts(screen: &Screen) -> Vec<String> {
screen
.get_history()
.iter()
.map(|b| strip_ansi(b))
.collect()
}
fn write_many_lines(screen: &mut Screen, count: usize) {
for i in 1..=count {
if i < count {
screen.process(format!("L{:03}\r\n", i).as_bytes());
} else {
screen.process(format!("L{:03}", i).as_bytes());
}
}
}
#[test]
fn bulk_output_scrollback_count() {
let mut screen = Screen::new(20, 5, 1000);
write_many_lines(&mut screen, 100);
let _ = screen.take_pending_scrollback();
let hist = history_texts(&screen);
assert_eq!(hist.len(), 95, "expected 95 scrollback lines, got {}", hist.len());
let visible = screen_lines(&screen);
assert!(visible[0].contains("L096"), "row 0 should be L096, got: '{}'", visible[0]);
assert!(visible[4].contains("L100"), "row 4 should be L100, got: '{}'", visible[4]);
}
#[test]
fn bulk_output_scrollback_ordering() {
let mut screen = Screen::new(20, 3, 5000);
write_many_lines(&mut screen, 500);
let _ = screen.take_pending_scrollback();
let hist = history_texts(&screen);
assert_eq!(hist.len(), 497);
for (i, line) in hist.iter().enumerate() {
let expected = format!("L{:03}", i + 1);
assert!(
line.contains(&expected),
"history line {} should contain '{}', got: '{}'",
i, expected, line
);
}
}
#[test]
fn bulk_output_pending_scrollback_matches_total() {
let mut screen = Screen::new(20, 4, 1000);
write_many_lines(&mut screen, 50);
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 46, "50 lines - 4 visible = 46 pending");
let pending2 = screen.take_pending_scrollback();
assert!(pending2.is_empty());
assert_eq!(screen.get_history().len(), 46);
}
#[test]
fn scrollback_limit_caps_history() {
let limit = 20;
let mut screen = Screen::new(20, 5, limit);
write_many_lines(&mut screen, 100);
let _ = screen.take_pending_scrollback();
let hist = history_texts(&screen);
assert_eq!(
hist.len(), limit,
"scrollback should be capped at limit {}, got {}",
limit, hist.len()
);
assert!(
hist[0].contains("L076"),
"first history line should be L076 (oldest kept), got: '{}'",
hist[0]
);
assert!(
hist[limit - 1].contains("L095"),
"last history line should be L095, got: '{}'",
hist[limit - 1]
);
}
#[test]
fn scrollback_limit_pending_also_capped() {
let limit = 10;
let mut screen = Screen::new(20, 3, limit);
write_many_lines(&mut screen, 50);
let pending = screen.take_pending_scrollback();
assert_eq!(
pending.len(), limit,
"pending scrollback should be capped at limit {}, got {}",
limit, pending.len()
);
}
#[test]
fn scrollback_limit_zero_no_history() {
let mut screen = Screen::new(20, 5, 0);
write_many_lines(&mut screen, 50);
let hist = screen.get_history();
assert!(hist.is_empty(), "zero scrollback limit should produce no history");
let pending = screen.take_pending_scrollback();
assert!(pending.is_empty(), "zero scrollback limit should produce no pending");
}
#[test]
fn full_render_after_bulk_scroll_shows_last_rows() {
let mut screen = Screen::new(20, 5, 100);
write_many_lines(&mut screen, 50);
let mut cache = RenderCache::new();
let output = screen.render(true, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("L046"), "render should contain L046");
assert!(text.contains("L050"), "render should contain L050");
assert!(!text.contains("L001"), "render should not contain scrolled-off L001");
assert!(!text.contains("L045"), "render should not contain scrolled-off L045");
}
#[test]
fn incremental_render_after_bulk_scroll() {
let mut screen = Screen::new(20, 5, 100);
let mut cache = RenderCache::new();
screen.process(b"AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\nEEEE");
let _ = screen.render(false, &mut cache);
write_many_lines(&mut screen, 50);
let output = screen.render(false, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[1;1H"), "row 1 should be redrawn");
assert!(text.contains("\x1b[2;1H"), "row 2 should be redrawn");
assert!(text.contains("\x1b[3;1H"), "row 3 should be redrawn");
assert!(text.contains("\x1b[4;1H"), "row 4 should be redrawn");
assert!(text.contains("\x1b[5;1H"), "row 5 should be redrawn");
}
#[test]
fn incremental_render_no_redraw_when_content_unchanged_after_scroll() {
let mut screen = Screen::new(10, 3, 100);
let mut cache = RenderCache::new();
write_many_lines(&mut screen, 6);
let _ = screen.render(false, &mut cache);
let output = screen.render(false, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(!text.contains("\x1b[1;1H"), "no row redraws when unchanged");
assert!(!text.contains("\x1b[2;1H"), "no row redraws when unchanged");
}
#[test]
fn render_with_large_pending_scrollback() {
let mut screen = Screen::new(20, 5, 200);
write_many_lines(&mut screen, 100);
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 95);
let mut cache = RenderCache::new();
let output = screen.render_with_scrollback(&pending, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.ends_with("\x1b[?2026l"));
let pos_l001 = text.find("L001").expect("L001 should be in scrollback");
let sync_begin = text.find("\x1b[?2026h").expect("sync begin should be present");
assert!(pos_l001 < sync_begin, "scrollback should precede sync block");
let pos_clear = text.find("\x1b[2J").expect("screen clear should be present");
assert!(pos_l001 < pos_clear, "scrollback should precede screen clear");
let after_clear = &text[pos_clear..];
assert!(after_clear.contains("L096"), "visible L096 should be after clear");
assert!(after_clear.contains("L100"), "visible L100 should be after clear");
assert!(!after_clear.contains("L001"), "L001 should not be in screen portion");
assert!(!after_clear.contains("L050"), "L050 should not be in screen portion");
}
#[test]
fn multiple_bulk_updates_dirty_tracking() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = RenderCache::new();
write_many_lines(&mut screen, 20);
let r1 = screen.render(false, &mut cache);
let t1 = String::from_utf8_lossy(&r1);
assert!(t1.contains("\x1b[1;1H"));
assert!(t1.contains("\x1b[4;1H"));
for i in 21..=40 {
screen.process(format!("M{:03}\r\n", i).as_bytes());
}
screen.process(b"M041");
let r2 = screen.render(false, &mut cache);
let t2 = String::from_utf8_lossy(&r2);
assert!(t2.contains("\x1b[1;1H"), "all rows should redraw after second bulk");
assert!(t2.contains("M038") || t2.contains("M039") || t2.contains("M040") || t2.contains("M041"),
"new content should appear in render");
let r3 = screen.render(false, &mut cache);
let t3 = String::from_utf8_lossy(&r3);
assert!(!t3.contains("\x1b[1;1H"), "no redraws on third render without changes");
}
#[test]
fn alternating_bulk_and_single_line_updates() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = RenderCache::new();
write_many_lines(&mut screen, 10);
let _ = screen.render(false, &mut cache);
screen.process(b"\r\nSINGLE");
let r = screen.render(false, &mut cache);
let t = String::from_utf8_lossy(&r);
assert!(t.contains("\x1b[1;1H"), "row 1 should redraw after scroll");
assert!(t.contains("SINGLE"), "new content should be visible");
}
#[test]
fn cursor_position_after_bulk_output() {
let mut screen = Screen::new(20, 5, 100);
write_many_lines(&mut screen, 100);
assert_eq!(screen.grid.cursor_y, 4, "cursor_y should be at bottom row");
assert_eq!(screen.grid.cursor_x, 4, "cursor_x should be after 'L100'");
let mut cache = RenderCache::new();
let output = screen.render(true, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[5;5H"), "cursor should be at row 5, col 5 (1-indexed)");
}
#[test]
fn cursor_stays_on_bottom_row_during_continuous_scroll() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"a\r\nb\r\nc");
assert_eq!(screen.grid.cursor_y, 2);
for i in 1..=50 {
screen.process(format!("\r\nline{}", i).as_bytes());
assert_eq!(
screen.grid.cursor_y, 2,
"cursor_y should stay at bottom row (2) after scroll, iteration {}",
i
);
}
}
#[test]
fn reattach_after_1000_lines() {
let mut screen = Screen::new(20, 5, 500);
write_many_lines(&mut screen, 1000);
let _ = screen.take_pending_scrollback();
let hist = screen.get_history();
assert_eq!(hist.len(), 500, "history should be capped at 500");
let mut cache = RenderCache::new();
let output = screen.render_with_scrollback(&hist, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[2J"));
let clear_pos = text.find("\x1b[2J").unwrap();
let after_clear = &text[clear_pos..];
assert!(after_clear.contains("L996"), "screen should show L996");
assert!(after_clear.contains("L1000"), "screen should show L1000");
}
#[test]
fn reattach_render_no_standalone_bell_after_bulk() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"\x1b]2;Bulk Test Title\x07");
write_many_lines(&mut screen, 200);
let _ = screen.take_pending_scrollback();
let hist = screen.get_history();
let mut cache = RenderCache::new();
let output = screen.render_with_scrollback(&hist, &mut cache);
let bell_count = output.iter().filter(|&&b| b == 0x07).count();
assert!(bell_count > 0, "title should produce at least one BEL byte in render");
for (i, &byte) in output.iter().enumerate() {
if byte == 0x07 {
let prefix = &output[..i];
let osc_start = prefix.windows(2).rposition(|w| w == b"\x1b]");
assert!(osc_start.is_some(),
"BEL at offset {} is standalone after bulk output reattach", i);
}
}
}
#[test]
fn output_exactly_fills_screen_no_scroll() {
let mut screen = Screen::new(20, 5, 100);
write_many_lines(&mut screen, 5);
let hist = screen.get_history();
assert!(hist.is_empty(), "no scrollback when output exactly fills screen");
let visible = screen_lines(&screen);
assert!(visible[0].contains("L001"), "row 0 should be L001");
assert!(visible[4].contains("L005"), "row 4 should be L005");
}
#[test]
fn output_one_more_than_screen_scrolls_once() {
let mut screen = Screen::new(20, 5, 100);
write_many_lines(&mut screen, 6);
let hist = history_texts(&screen);
assert_eq!(hist.len(), 1, "one line should scroll off");
assert!(hist[0].contains("L001"), "scrolled line should be L001");
let visible = screen_lines(&screen);
assert!(visible[0].contains("L002"), "row 0 should be L002");
assert!(visible[4].contains("L006"), "row 4 should be L006");
}
#[test]
fn rapid_output_then_partial_overwrite() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = RenderCache::new();
write_many_lines(&mut screen, 30);
let _ = screen.render(false, &mut cache);
screen.process(b"\rOVERWRITTEN");
let output = screen.render(false, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[3;1H"), "bottom row should be redrawn");
assert!(text.contains("OVERWRITTEN"), "overwritten content should appear");
assert!(!text.contains("\x1b[1;1H"), "row 1 should not be redrawn");
assert!(!text.contains("\x1b[2;1H"), "row 2 should not be redrawn");
}
#[test]
fn bulk_output_with_scroll_region() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"\x1b[2;4r"); screen.process(b"\x1b[2;1H");
for i in 1..=10 {
if i < 10 {
screen.process(format!("R{:02}\r\n", i).as_bytes());
} else {
screen.process(format!("R{:02}", i).as_bytes());
}
}
assert_eq!(screen.grid.visible_row(0)[0].c, ' ', "row 0 should be blank (outside scroll region)");
assert_eq!(screen.grid.visible_row(4)[0].c, ' ', "row 4 should be blank (outside scroll region)");
let visible = screen_lines(&screen);
assert!(visible[1].contains("R08"),
"scroll region row 1 should be R08, got: '{}'", visible[1]);
assert!(visible[2].contains("R09"),
"scroll region row 2 should be R09, got: '{}'", visible[2]);
assert!(visible[3].contains("R10"),
"scroll region row 3 should be R10, got: '{}'", visible[3]);
let hist = screen.get_history();
assert!(hist.is_empty(),
"scroll region not starting at top should not generate scrollback, got {} lines",
hist.len());
}
#[test]
fn bulk_output_with_styles_renders_correctly() {
let mut screen = Screen::new(30, 3, 100);
let mut cache = RenderCache::new();
for i in 1..=10 {
let color = 31 + (i % 7); screen.process(format!("\x1b[{}mLine{:02}\x1b[0m\r\n", color, i).as_bytes());
}
screen.process(b"\x1b[1;33mLastLine\x1b[0m");
let output = screen.render(true, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("LastLine"), "last line should be visible");
let hist = screen.get_history();
assert!(!hist.is_empty(), "styled lines should be in scrollback");
let first_hist = String::from_utf8_lossy(&hist[0]);
assert!(first_hist.contains("\x1b["), "scrollback should preserve SGR codes");
}
#[test]
fn cache_invalidate_mid_bulk_produces_correct_render() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = RenderCache::new();
write_many_lines(&mut screen, 20);
let _ = screen.render(false, &mut cache);
cache.invalidate();
let output = screen.render(false, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[1;1H"), "row 1 redrawn after invalidate");
assert!(text.contains("\x1b[2;1H"), "row 2 redrawn after invalidate");
assert!(text.contains("\x1b[3;1H"), "row 3 redrawn after invalidate");
assert!(text.contains("\x1b[4;1H"), "row 4 redrawn after invalidate");
}
#[test]
fn sync_block_wraps_large_render() {
let mut screen = Screen::new(20, 5, 100);
write_many_lines(&mut screen, 200);
let mut cache = RenderCache::new();
let output = screen.render(true, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.starts_with("\x1b[?2026h"), "should start with sync begin");
assert!(text.ends_with("\x1b[?2026l"), "should end with sync end");
}