use super::test_helpers::*;
use super::*;
use render::AnsiRenderer;
fn pending_texts(pending: &[Vec<u8>]) -> Vec<String> {
pending.iter().map(|b| strip_ansi(b)).collect()
}
fn do_render_cycle(screen: &mut Screen, cache: &mut AnsiRenderer) -> Vec<u8> {
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(screen, &pending_rows);
if !pending.is_empty() {
cache.render_with_scrollback(screen, &pending)
} else {
cache.render(screen, false)
}
}
#[test]
fn take_pending_scrollback_returns_rows_and_advances_pending() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"aaa\r\nbbb\r\nccc\r\nddd\r\neee");
assert_eq!(screen.grid.scrollback_len(), 2);
let rows = screen.take_pending_scrollback();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0].c, 'a');
assert_eq!(rows[1][0].c, 'b');
assert!(screen.take_pending_scrollback().is_empty());
screen.process(b"\r\nfff");
let rows = screen.take_pending_scrollback();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0][0].c, 'c');
let mut small = Screen::new(10, 3, 2);
small.process(b"ggg\r\nhhh\r\niii\r\njjj\r\nkkk\r\nlll\r\nmmm\r\nnnn\r\nooo");
assert_eq!(small.grid.scrollback_len(), 2);
let rows = small.take_pending_scrollback();
assert_eq!(rows.len(), 2, "pending capped at scrollback limit");
assert_eq!(rows[0][0].c, 'k', "oldest surviving pending line");
assert_eq!(rows[1][0].c, 'l', "most recent scrolled-off line");
assert!(small.take_pending_scrollback().is_empty());
}
#[test]
fn incremental_render_after_scrollback_injection_redraws_all_rows() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"AAAA\r\nBBBB\r\nCCCC\r\nDDDD");
let _ = cache.render(&screen, false);
screen.process(b"\r\nEEEE\r\nFFFF\r\nGGGG");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 3, "3 lines should have scrolled off");
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[2J"), "should have screen clear");
let incr = cache.render(&screen, false);
let incr_text = String::from_utf8_lossy(&incr);
assert!(
!incr_text.contains("\x1b[1;1H"),
"no row redraws when nothing changed after scrollback injection"
);
assert!(
!incr_text.contains("\x1b[2;1H"),
"no row redraws when nothing changed after scrollback injection"
);
}
#[test]
fn two_incremental_renders_after_scrollback_only_first_redraws() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"A\r\nB\r\nC\r\nD\r\nE\r\nF");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
let _ = cache.render_with_scrollback(&screen, &pending);
screen.process(b"\rMODIFIED");
let r1 = cache.render(&screen, false);
let t1 = String::from_utf8_lossy(&r1);
assert!(
t1.contains("MODIFIED"),
"modified row should be in first incremental"
);
assert!(t1.contains("\x1b[3;1H"), "bottom row should be redrawn");
let r2 = cache.render(&screen, false);
let t2 = String::from_utf8_lossy(&r2);
assert!(
!t2.contains("\x1b[1;1H") && !t2.contains("\x1b[2;1H") && !t2.contains("\x1b[3;1H"),
"no row redraws on second unchanged incremental"
);
}
#[test]
fn sequential_scrollback_batches_no_duplication() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"R01\r\nR02\r\nR03");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\r\nR04\r\nR05\r\nR06");
let pending1_rows = screen.take_pending_scrollback();
let pending1 = cache.render_rows(&screen, &pending1_rows);
let texts1 = pending_texts(&pending1);
assert_eq!(texts1.len(), 3, "batch 1: 3 lines scrolled off");
assert!(texts1[0].contains("R01"));
assert!(texts1[2].contains("R03"));
let out1 = cache.render_with_scrollback(&screen, &pending1);
let t1 = String::from_utf8_lossy(&out1);
let pos_clear1 = t1.find("\x1b[2J").unwrap();
assert!(
t1[..pos_clear1].contains("R01"),
"batch1: R01 in scrollback"
);
assert!(t1[pos_clear1..].contains("R04"), "batch1: R04 on screen");
screen.process(b"\r\nR07\r\nR08");
let pending2_rows = screen.take_pending_scrollback();
let pending2 = cache.render_rows(&screen, &pending2_rows);
let texts2 = pending_texts(&pending2);
assert_eq!(texts2.len(), 2, "batch 2: 2 lines scrolled off");
assert!(
texts2[0].contains("R04"),
"batch2 pending should start from R04"
);
assert!(texts2[1].contains("R05"), "batch2 pending should have R05");
let out2 = cache.render_with_scrollback(&screen, &pending2);
let t2 = String::from_utf8_lossy(&out2);
let pos_clear2 = t2.find("\x1b[2J").unwrap();
assert!(
t2[..pos_clear2].contains("R04"),
"batch2: R04 in scrollback"
);
assert!(
t2[..pos_clear2].contains("R05"),
"batch2: R05 in scrollback"
);
assert!(
!t2[..pos_clear2].contains("R01"),
"batch2: R01 should NOT be in this scrollback (already sent)"
);
assert!(t2[pos_clear2..].contains("R06"), "batch2: R06 on screen");
assert!(t2[pos_clear2..].contains("R08"), "batch2: R08 on screen");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 5, "total history should be 5 lines");
for i in 1..=5 {
assert!(
hist[i - 1].contains(&format!("R{:02}", i)),
"history[{}] should be R{:02}, got: '{}'",
i - 1,
i,
hist[i - 1]
);
}
}
#[test]
fn many_sequential_batches_accumulate_correctly() {
let mut screen = Screen::new(20, 3, 1000);
let mut cache = AnsiRenderer::new();
let mut total_pending_sent = 0;
screen.process(b"init1\r\ninit2\r\ninit3");
let _ = do_render_cycle(&mut screen, &mut cache);
for batch in 0..10 {
for j in 0..5 {
let n = 4 + batch * 5 + j; screen.process(format!("\r\nB{:03}", n).as_bytes());
}
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert!(
!pending.is_empty(),
"batch {} should have pending scrollback",
batch
);
total_pending_sent += pending.len();
let _ = cache.render_with_scrollback(&screen, &pending);
}
let hist = history_texts(&screen);
assert_eq!(
hist.len(),
total_pending_sent,
"total history should match total pending sent across all batches"
);
}
#[test]
fn screen_only_change_after_scrollback_renders_incrementally() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"A\r\nB\r\nC\r\nD\r\nE\r\nF\r\nG\r\nH");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\rCHANGED");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert!(
pending.is_empty(),
"cursor overwrite should not generate scrollback"
);
let output = cache.render(&screen, false);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("CHANGED"), "changed content should appear");
assert!(
!text.contains("\x1b[2J"),
"screen-only change should not clear screen"
);
}
#[test]
fn alternating_scrollback_and_no_scrollback_cycles() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"line1\r\nline2\r\nline3");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\r\nnew1\r\nnew2");
let out1 = do_render_cycle(&mut screen, &mut cache);
let t1 = String::from_utf8_lossy(&out1);
assert!(
t1.contains("\x1b[2J"),
"scrollback cycle should screen clear"
);
screen.process(b"\x1b[1;1H"); screen.process(b"OVER");
let out2 = do_render_cycle(&mut screen, &mut cache);
let t2 = String::from_utf8_lossy(&out2);
assert!(
!t2.contains("\x1b[2J"),
"screen-only cycle should not clear"
);
assert!(t2.contains("OVER"), "overwrite should appear");
screen.process(b"\x1b[3;1H"); screen.process(b"\r\nnew3\r\nnew4\r\nnew5");
let out3 = do_render_cycle(&mut screen, &mut cache);
let t3 = String::from_utf8_lossy(&out3);
assert!(
t3.contains("\x1b[2J"),
"scrollback cycle should screen clear again"
);
let out4 = do_render_cycle(&mut screen, &mut cache);
let t4 = String::from_utf8_lossy(&out4);
assert!(!t4.contains("\x1b[2J"), "no-change cycle should not clear");
assert!(
!t4.contains("\x1b[1;1H") && !t4.contains("\x1b[2;1H") && !t4.contains("\x1b[3;1H"),
"no-change cycle should not redraw rows"
);
}
#[test]
fn cursor_shape_delta_correct_after_scrollback_then_change() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"\x1b[5 q"); screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\x1b[2 q");
let incr = cache.render(&screen, false);
let incr_text = String::from_utf8_lossy(&incr);
assert!(
incr_text.contains("\x1b[2 q"),
"incremental should emit new cursor shape (block)"
);
assert!(
!incr_text.contains("\x1b[5 q"),
"incremental should NOT re-emit old cursor shape (bar)"
);
let incr2 = cache.render(&screen, false);
let incr2_text = String::from_utf8_lossy(&incr2);
assert!(
!incr2_text.contains(" q"),
"no cursor shape emission when nothing changed"
);
}
#[test]
fn bracketed_paste_delta_after_scrollback_cycle() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"\x1b[?2004h");
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let _ = do_render_cycle(&mut screen, &mut cache);
let incr1 = cache.render(&screen, false);
let incr1_text = String::from_utf8_lossy(&incr1);
assert!(
!incr1_text.contains("?2004"),
"no mode change → should not re-emit bracketed paste"
);
screen.process(b"\x1b[?2004l");
let incr2 = cache.render(&screen, false);
let incr2_text = String::from_utf8_lossy(&incr2);
assert!(
incr2_text.contains("\x1b[?2004l"),
"disabling bracketed paste should emit ?2004l in delta"
);
}
#[test]
fn autowrap_delta_after_scrollback_cycle() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"\x1b[?7l");
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\x1b[?7h");
let incr = cache.render(&screen, false);
let incr_text = String::from_utf8_lossy(&incr);
assert!(
incr_text.contains("\x1b[?7h"),
"re-enabling autowrap should appear in incremental delta after scrollback"
);
let incr2 = cache.render(&screen, false);
let incr2_text = String::from_utf8_lossy(&incr2);
assert!(
!incr2_text.contains("?7"),
"no autowrap change → should not re-emit"
);
}
#[test]
fn scroll_region_cached_after_scrollback_not_re_emitted() {
let mut screen = Screen::new(20, 5, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[H");
for i in 1..=10 {
screen.process(format!("L{:02}\r\n", i).as_bytes());
}
let _ = do_render_cycle(&mut screen, &mut cache);
let incr = cache.render(&screen, false);
let incr_text = String::from_utf8_lossy(&incr);
assert!(
!incr_text.contains("\x1b[2;4r"),
"unchanged scroll region should NOT be re-emitted on incremental render"
);
screen.process(b"\x1b[1;3r");
let incr2 = cache.render(&screen, false);
let incr2_text = String::from_utf8_lossy(&incr2);
assert!(
incr2_text.contains("\x1b[1;3r"),
"changed scroll region should be emitted on incremental render"
);
}
#[test]
fn cursor_position_correct_after_scrollback_injection() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"row1\r\nrow2\r\nrow3\r\nrow4");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\r\nrow5\r\nrow6");
assert_eq!(screen.grid.cursor_y(), 3);
assert_eq!(screen.grid.cursor_x(), 4);
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("\x1b[4;5H"),
"cursor should be at row 4, col 5 (1-indexed) after scrollback injection, got: {}",
text.chars().collect::<String>().replace('\x1b', "ESC")
);
}
#[test]
fn large_pending_scrollback_renders_all_lines() {
let mut screen = Screen::new(20, 3, 5000);
let mut cache = AnsiRenderer::new();
screen.process(b"init1\r\ninit2\r\ninit3");
let _ = do_render_cycle(&mut screen, &mut cache);
for i in 1..=500 {
screen.process(format!("\r\nR{:04}", i).as_bytes());
}
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(
pending.len(),
500,
"all 500 scrolled lines should be in pending"
);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
let pos_clear = text.find("\x1b[2J").expect("should have screen clear");
let scrollback_portion = &text[..pos_clear];
assert!(
scrollback_portion.contains("R0001"),
"first line should be in scrollback"
);
assert!(
scrollback_portion.contains("R0250"),
"middle line should be in scrollback"
);
assert!(
scrollback_portion.contains("R0497"),
"last scrolled-off line should be in scrollback"
);
let screen_portion = &text[pos_clear..];
assert!(screen_portion.contains("R0498"), "R0498 on screen");
assert!(screen_portion.contains("R0500"), "R0500 on screen");
}
#[test]
fn pending_scrollback_limit_enforced_during_rapid_output() {
let limit = 50;
let mut screen = Screen::new(20, 3, limit);
let mut cache = AnsiRenderer::new();
screen.process(b"A\r\nB\r\nC");
let _ = do_render_cycle(&mut screen, &mut cache);
for i in 1..=200 {
screen.process(format!("\r\nL{:04}", i).as_bytes());
}
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(
pending.len(),
limit,
"pending should be capped at scrollback limit"
);
let texts = pending_texts(&pending);
assert!(
texts.first().unwrap().contains("L0148"),
"first pending should be L0148 (oldest kept), got: '{}'",
texts.first().unwrap()
);
assert!(
texts.last().unwrap().contains("L0197"),
"last pending should be L0197 (last scrolled-off line), got: '{}'",
texts.last().unwrap()
);
}
#[test]
fn scrollback_injection_outside_synchronized_output() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
let sync_begin = text.find("\x1b[?2026h").expect("sync begin missing");
let pos_a = text.find("A").unwrap();
assert!(
pos_a < sync_begin,
"scrollback content should appear before sync block"
);
assert!(
text.ends_with("\x1b[?2026l"),
"output should end with synchronized output end"
);
}
#[test]
fn scrollback_injection_starts_at_bottom_row() {
let mut screen = Screen::new(20, 5, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"SCRLL1\r\nSCRLL2\r\nCC\r\nDD\r\nEE\r\nFF\r\nGG");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert!(!pending.is_empty());
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
let pos_first_scrollback = text.find("SCRLL1").expect("SCRLL1 missing");
let sync_begin = text.find("\x1b[?2026h").expect("sync begin missing");
assert!(
pos_first_scrollback < sync_begin,
"scrollback content should precede sync block"
);
assert!(
text.contains("\x1b[5;1H"),
"should position cursor at bottom row for scrolling"
);
}
#[test]
fn scrollback_content_before_clear_screen_content_after() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
for i in 1..=8 {
if i < 8 {
screen.process(format!("LINE{:02}\r\n", i).as_bytes());
} else {
screen.process(format!("LINE{:02}", i).as_bytes());
}
}
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
let texts = pending_texts(&pending);
assert_eq!(texts.len(), 5, "5 lines should be pending");
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
let pos_clear = text.find("\x1b[2J").unwrap();
let before_screen = &text[..pos_clear];
let after_screen = &text[pos_clear..];
for i in 1..=5 {
let label = format!("LINE{:02}", i);
assert!(
before_screen.contains(&label),
"{} should be in scrollback (before screen clear)",
label
);
assert!(
!after_screen.contains(&label),
"{} should NOT be in screen portion (after screen clear)",
label
);
}
for i in 6..=8 {
let label = format!("LINE{:02}", i);
assert!(
after_screen.contains(&label),
"{} should be on screen (after screen clear)",
label
);
}
}
#[test]
fn full_relay_cycle_scrollback_then_incremental_then_scrollback() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"A001\r\nA002\r\nA003\r\nA004");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\r\nB005\r\nB006\r\nB007");
let out1 = do_render_cycle(&mut screen, &mut cache);
let t1 = String::from_utf8_lossy(&out1);
assert!(
t1.contains("\x1b[2J"),
"cycle 1 should screen clear (scrollback)"
);
let pos_clear1 = t1.find("\x1b[2J").unwrap();
assert!(
t1[..pos_clear1].contains("A001"),
"cycle 1: A001 in scrollback"
);
assert!(t1[pos_clear1..].contains("B007"), "cycle 1: B007 on screen");
screen.process(b"\x1b[1;1H"); let out2 = do_render_cycle(&mut screen, &mut cache);
let t2 = String::from_utf8_lossy(&out2);
assert!(
!t2.contains("\x1b[2J"),
"cycle 2 should not clear (no scrollback)"
);
assert!(t2.contains("\x1b[1;1H"), "cycle 2: cursor at top-left");
screen.process(b"\x1b[4;1H"); screen.process(b"\r\nC008\r\nC009");
let out3 = do_render_cycle(&mut screen, &mut cache);
let t3 = String::from_utf8_lossy(&out3);
assert!(
t3.contains("\x1b[2J"),
"cycle 3 should screen clear (scrollback)"
);
let pos_clear3 = t3.find("\x1b[2J").unwrap();
assert!(
!t3[..pos_clear3].contains("A001"),
"cycle 3: A001 already sent, should not be in this batch"
);
assert!(t3[pos_clear3..].contains("C009"), "cycle 3: C009 on screen");
}
#[test]
fn single_line_scrollback_injection() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"line1\r\nline2\r\nline3");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\r\nline4");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 1, "exactly one line should scroll off");
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[2J"), "should have screen clear");
let pos_clear = text.find("\x1b[2J").unwrap();
assert!(
text[..pos_clear].contains("line1"),
"single scrollback line should be present"
);
assert!(text[pos_clear..].contains("line4"), "line4 on screen");
}
#[test]
fn styled_scrollback_lines_preserve_formatting() {
let mut screen = Screen::new(30, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"\x1b[1;31mBOLD_RED\x1b[0m\r\n");
screen.process(b"\x1b[4;32mUNDERLINE_GREEN\x1b[0m\r\n");
screen.process(b"plain\r\n");
screen.process(b"visible1\r\n");
screen.process(b"visible2");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 2, "2 styled lines should scroll off");
let line0 = String::from_utf8_lossy(&pending[0]);
assert!(line0.contains("BOLD_RED"), "content should be preserved");
assert!(
line0.contains(";1;") && line0.contains(";31m"),
"bold+red SGR codes should be preserved in scrollback, got: '{}'",
line0
);
let line1 = String::from_utf8_lossy(&pending[1]);
assert!(
line1.contains("UNDERLINE_GREEN"),
"content should be preserved"
);
assert!(
line1.contains(";4;") && line1.contains(";32m"),
"underline+green SGR codes should be preserved in scrollback, got: '{}'",
line1
);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("BOLD_RED"), "styled content in render output");
assert!(
text.contains("UNDERLINE_GREEN"),
"styled content in render output"
);
}
#[test]
fn title_cached_after_scrollback_not_re_emitted() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"\x1b]2;My Terminal\x07");
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let _ = do_render_cycle(&mut screen, &mut cache);
let incr = cache.render(&screen, false);
let incr_text = String::from_utf8_lossy(&incr);
assert!(
!incr_text.contains("My Terminal"),
"unchanged title should NOT be re-emitted on incremental render"
);
screen.process(b"\x1b]2;New Title\x07");
let incr2 = cache.render(&screen, false);
let incr2_text = String::from_utf8_lossy(&incr2);
assert!(
incr2_text.contains("\x1b]2;New Title\x07"),
"changed title should be emitted on incremental render"
);
assert!(
!incr2_text.contains("My Terminal"),
"old title should not appear"
);
}
#[test]
fn cursor_hidden_state_preserved_after_scrollback_injection() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"\x1b[?25l");
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
let show_count = text.matches("\x1b[?25h").count();
assert_eq!(
show_count, 0,
"cursor show (?25h) should not appear when cursor is hidden"
);
assert!(
text.contains("\x1b[?25l"),
"cursor hide should be present in render"
);
}
#[test]
fn pending_empty_after_drain_no_double_send() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"A\r\nB\r\nC\r\nD\r\nE\r\nF");
let first_rows = screen.take_pending_scrollback();
let first = AnsiRenderer::new().render_rows(&screen, &first_rows);
assert_eq!(first.len(), 3, "first drain should have 3 lines");
let second_rows = screen.take_pending_scrollback();
let second = AnsiRenderer::new().render_rows(&screen, &second_rows);
assert!(second.is_empty(), "second drain should be empty");
assert_eq!(
screen.get_history().len(),
3,
"history should still have 3 lines after drain"
);
}
#[test]
fn output_between_take_and_render_captured_in_next_cycle() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let pending1_rows = screen.take_pending_scrollback();
let pending1 = cache.render_rows(&screen, &pending1_rows);
assert_eq!(pending1.len(), 2);
screen.process(b"\r\nF\r\nG");
let _ = cache.render_with_scrollback(&screen, &pending1);
let pending2_rows = screen.take_pending_scrollback();
let pending2 = cache.render_rows(&screen, &pending2_rows);
assert_eq!(
pending2.len(),
2,
"new lines should be in next pending batch"
);
let texts2 = pending_texts(&pending2);
assert!(texts2[0].contains("C"), "pending2 should have C");
assert!(texts2[1].contains("D"), "pending2 should have D");
}
#[test]
fn alt_screen_does_not_generate_scrollback() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"main1\r\nmain2\r\nmain3");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\x1b[?1049h");
for i in 1..=20 {
screen.process(format!("alt{:02}\r\n", i).as_bytes());
}
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert!(
pending.is_empty(),
"alt screen output should NOT generate pending scrollback, got {} lines",
pending.len()
);
let hist = screen.get_history();
assert!(
hist.is_empty(),
"alt screen output should NOT generate history, got {} lines",
hist.len()
);
}
#[test]
fn pending_scrollback_preserved_across_alt_screen_excursion() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"L01\r\nL02\r\nL03\r\nL04\r\nL05");
screen.process(b"\x1b[?1049h");
screen.process(b"alt content\r\nalt line 2\r\nalt line 3\r\nalt line 4");
screen.process(b"\x1b[?1049l");
let pending_rows = screen.take_pending_scrollback();
let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
assert_eq!(
pending.len(),
2,
"pending from before alt screen should survive the excursion"
);
let texts = pending_texts(&pending);
assert!(texts[0].contains("L01"), "first pending should be L01");
assert!(texts[1].contains("L02"), "second pending should be L02");
}
#[test]
fn scroll_region_not_at_top_produces_no_scrollback() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[2;1H");
for i in 1..=20 {
if i < 20 {
screen.process(format!("SR{:02}\r\n", i).as_bytes());
} else {
screen.process(format!("SR{:02}", i).as_bytes());
}
}
let pending_rows = screen.take_pending_scrollback();
let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
assert!(
pending.is_empty(),
"scrolling within non-top scroll region should NOT generate scrollback, got {} lines",
pending.len()
);
let hist = screen.get_history();
assert!(
hist.is_empty(),
"scrolling within non-top scroll region should NOT generate history"
);
assert_eq!(
screen.grid.visible_row(0)[0].c,
' ',
"row above scroll region should be blank"
);
}
#[test]
fn wide_chars_in_scrollback_render_correctly() {
let mut screen = Screen::new(20, 3, 100);
screen.process("你好世界\r\n".as_bytes());
screen.process("テスト\r\n".as_bytes());
screen.process("plain\r\n".as_bytes());
screen.process("visible1\r\n".as_bytes());
screen.process("visible2".as_bytes());
let pending_rows = screen.take_pending_scrollback();
let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 2, "2 lines should scroll off");
let line0 = String::from_utf8_lossy(&pending[0]);
assert!(
line0.contains("你好世界"),
"wide chars should be preserved in scrollback render, got: '{}'",
line0
);
let line1 = String::from_utf8_lossy(&pending[1]);
assert!(
line1.contains("テスト"),
"wide chars should be preserved in scrollback render, got: '{}'",
line1
);
let mut cache = AnsiRenderer::new();
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("你好世界"),
"wide chars in scrollback render output"
);
assert!(
text.contains("テスト"),
"wide chars in scrollback render output"
);
}
#[test]
fn blank_lines_in_scrollback_produce_empty_entries() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"\r\n\r\n\r\nvisible1\r\nvisible2\r\nvisible3");
let pending_rows = screen.take_pending_scrollback();
let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 3, "3 lines should scroll off");
assert!(
pending[0].is_empty(),
"blank scrollback line should render as empty, got {} bytes",
pending[0].len()
);
assert!(
pending[1].is_empty(),
"blank scrollback line should render as empty, got {} bytes",
pending[1].len()
);
let mut cache = AnsiRenderer::new();
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
let sync_pos = text.find("\x1b[?2026h").expect("should have sync begin");
let scrollback_portion = &text[..sync_pos];
let newline_count = scrollback_portion.matches('\n').count();
assert!(
newline_count >= pending.len(),
"each scrollback entry (even blank) should produce a scroll \\n, got {} for {} entries",
newline_count,
pending.len()
);
}
#[test]
fn mode_change_between_scrollback_batches_reflected_correctly() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"\x1b[?2004h");
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let out1 = do_render_cycle(&mut screen, &mut cache);
let t1 = String::from_utf8_lossy(&out1);
assert!(
t1.contains("\x1b[?2004h"),
"batch 1: bracketed paste should be ON"
);
screen.process(b"\x1b[?2004l");
screen.process(b"\r\nF\r\nG");
let out2 = do_render_cycle(&mut screen, &mut cache);
let t2 = String::from_utf8_lossy(&out2);
assert!(
t2.contains("\x1b[?2004l"),
"batch 2: bracketed paste should be OFF"
);
assert!(
!t2.contains("\x1b[?2004h"),
"batch 2: bracketed paste ON should NOT appear"
);
let incr = cache.render(&screen, false);
let incr_text = String::from_utf8_lossy(&incr);
assert!(
!incr_text.contains("?2004"),
"incremental after batch 2: no mode change → no emission"
);
}
#[test]
fn scrollback_injection_resets_scroll_region() {
let mut screen = Screen::new(20, 5, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[r"); screen.process(b"A\r\nB\r\nC\r\nD\r\nE\r\nF\r\nG");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert!(!pending.is_empty());
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
let reset_pos = text.find("\x1b[r").expect("scroll region reset missing");
let first_content = text.find("A").unwrap_or(text.len());
assert!(
reset_pos < first_content,
"scroll region reset (pos {}) must precede scrollback content (pos {})",
reset_pos,
first_content
);
}
#[test]
fn scrollback_single_chunk_overwrites_rows() {
let mut screen = Screen::new(20, 5, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"AA\r\nBB\r\nCC\r\nDD\r\nEE\r\nFF\r\nGG");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 2, "2 lines should scroll off");
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("\x1b[1;1H"),
"row 1 positioning for first scrollback line"
);
assert!(
text.contains("\x1b[2;1H"),
"row 2 positioning for second scrollback line"
);
let pos_aa = text.find("AA").expect("AA missing");
let pos_bb = text.find("BB").expect("BB missing");
assert!(pos_aa < pos_bb, "AA should appear before BB");
let raw = output;
let aa_pos = raw.windows(2).position(|w| w == b"AA").unwrap();
assert_eq!(
&raw[aa_pos + 2..aa_pos + 5],
b"\x1b[K",
"EL should follow scrollback line content"
);
}
#[test]
fn scrollback_multi_chunk_processes_correctly() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"L01\r\nL02\r\nL03\r\nL04\r\nL05\r\nL06\r\nL07\r\nL08\r\nV01\r\nV02\r\nV03");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 8, "8 lines should scroll off");
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
for i in 1..=8 {
let label = format!("L{:02}", i);
assert!(
text.contains(&label),
"{} should be in scrollback output",
label
);
}
let bottom_count = text.matches("\x1b[3;1H").count();
assert!(
bottom_count >= 3,
"expected at least 3 bottom-row positionings for 3 chunks, got {}",
bottom_count
);
let sync_begin = text.find("\x1b[?2026h").expect("sync begin missing");
let last_scrollback = text.rfind("L08").expect("L08 missing");
assert!(
last_scrollback < sync_begin,
"all scrollback content must precede sync block"
);
let pos_clear = text.find("\x1b[2J").expect("screen clear missing");
let after_clear = &text[pos_clear..];
assert!(
after_clear.contains("V01"),
"V01 should be in screen portion"
);
assert!(
after_clear.contains("V03"),
"V03 should be in screen portion"
);
}
#[test]
fn scrollback_partial_chunk_erases_remaining_rows() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"S1\r\nS2\r\nS3\r\nS4\r\nS5\r\nV1\r\nV2\r\nV3\r\nV4");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 5);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
let el2_count = text.matches("\x1b[2K").count();
assert!(
el2_count >= 3,
"partial chunk should erase at least 3 remaining rows, got {} EL2 sequences",
el2_count
);
}
#[test]
fn scrollback_exactly_rows_lines_single_chunk() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"E1\r\nE2\r\nE3\r\nE4\r\nV1\r\nV2\r\nV3\r\nV4");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 4);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
for i in 1..=4 {
let label = format!("E{}", i);
assert!(text.contains(&label), "{} missing from output", label);
}
let el2_count = text.matches("\x1b[2K").count();
assert_eq!(
el2_count, 0,
"full chunk should not erase any rows, got {} EL2 sequences",
el2_count
);
let bottom_positions = text.matches("\x1b[4;1H").count();
assert!(
bottom_positions >= 1,
"should have bottom-row positioning for the single chunk"
);
}
#[test]
fn scrollback_single_line_overwrites_and_scrolls() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"ONLY\r\nV1\r\nV2\r\nV3");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 1);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[1;1H"), "should position at row 1");
assert!(text.contains("ONLY"), "scrollback content missing");
let el2_count = text.matches("\x1b[2K").count();
assert!(
el2_count >= 2,
"should erase rows 2-3, got {} EL2 sequences",
el2_count
);
assert!(
text.contains("\x1b[3;1H"),
"should position at bottom row for scroll"
);
let sync_begin = text.find("\x1b[?2026h").unwrap();
let content_pos = text.find("ONLY").unwrap();
assert!(
content_pos < sync_begin,
"scrollback should precede sync block"
);
}
#[test]
fn scrollback_byte_ordering_end_to_end() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"X1\r\nX2\r\nX3\r\nX4\r\nVIS");
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
assert!(
text.starts_with("\x1b[?25l"),
"should start with cursor hide"
);
let reset_pos = text.find("\x1b[r").expect("scroll region reset missing");
let content_pos = text.find("X1").expect("X1 missing");
assert!(reset_pos < content_pos, "reset before content");
let sync_begin = text.find("\x1b[?2026h").expect("sync begin missing");
let last_content = text.rfind("X2").expect("X2 missing");
assert!(last_content < sync_begin, "content before sync");
let pos_clear = text.find("\x1b[2J").expect("screen clear missing");
assert!(sync_begin < pos_clear, "screen clear inside sync block");
assert!(text.ends_with("\x1b[?2026l"), "should end with sync end");
}
#[test]
fn scrollback_large_burst_chunked_correctly() {
let mut screen = Screen::new(40, 5, 500);
let mut cache = AnsiRenderer::new();
for i in 1..=54 {
screen.process(format!("LINE{:03}\r\n", i).as_bytes());
}
let pending_rows = screen.take_pending_scrollback();
let pending = cache.render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 50);
let output = cache.render_with_scrollback(&screen, &pending);
let text = String::from_utf8_lossy(&output);
for i in 1..=50 {
let label = format!("LINE{:03}", i);
assert!(
text.contains(&label),
"{} missing from scrollback output",
label
);
}
let bottom_count = text.matches("\x1b[5;1H").count();
assert!(
bottom_count >= 10,
"expected at least 10 bottom-row positionings for 10 chunks, got {}",
bottom_count
);
let el2_count = text.matches("\x1b[2K").count();
assert_eq!(el2_count, 0, "full chunks should not erase rows");
let after_clear = &text[text.find("\x1b[2J").unwrap()..];
assert!(after_clear.contains("LINE051"), "LINE051 should be visible");
assert!(after_clear.contains("LINE054"), "LINE054 should be visible");
}