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 history_texts(screen: &Screen) -> Vec<String> {
screen
.get_history()
.iter()
.map(|b| strip_ansi(b))
.collect()
}
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 RenderCache) -> Vec<u8> {
let pending = screen.take_pending_scrollback();
if !pending.is_empty() {
screen.render_with_scrollback(&pending, cache)
} else {
screen.render(false, cache)
}
}
#[test]
fn incremental_render_after_scrollback_injection_redraws_all_rows() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = RenderCache::new();
screen.process(b"AAAA\r\nBBBB\r\nCCCC\r\nDDDD");
let _ = screen.render(false, &mut cache);
screen.process(b"\r\nEEEE\r\nFFFF\r\nGGGG");
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 3, "3 lines should have scrolled off");
let output = screen.render_with_scrollback(&pending, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[2J"), "should have screen clear");
let incr = screen.render(false, &mut cache);
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 = RenderCache::new();
screen.process(b"A\r\nB\r\nC\r\nD\r\nE\r\nF");
let pending = screen.take_pending_scrollback();
let _ = screen.render_with_scrollback(&pending, &mut cache);
screen.process(b"\rMODIFIED");
let r1 = screen.render(false, &mut cache);
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 = screen.render(false, &mut cache);
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 = RenderCache::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 = screen.take_pending_scrollback();
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 = screen.render_with_scrollback(&pending1, &mut cache);
let t1 = String::from_utf8_lossy(&out1);
let clear1 = t1.find("\x1b[2J").unwrap();
assert!(t1[..clear1].contains("R01"), "batch1: R01 in scrollback");
assert!(t1[clear1..].contains("R04"), "batch1: R04 on screen");
screen.process(b"\r\nR07\r\nR08");
let pending2 = screen.take_pending_scrollback();
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 = screen.render_with_scrollback(&pending2, &mut cache);
let t2 = String::from_utf8_lossy(&out2);
let clear2 = t2.find("\x1b[2J").unwrap();
assert!(t2[..clear2].contains("R04"), "batch2: R04 in scrollback");
assert!(t2[..clear2].contains("R05"), "batch2: R05 in scrollback");
assert!(
!t2[..clear2].contains("R01"),
"batch2: R01 should NOT be in this scrollback (already sent)"
);
assert!(t2[clear2..].contains("R06"), "batch2: R06 on screen");
assert!(t2[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 = RenderCache::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 = screen.take_pending_scrollback();
assert!(
!pending.is_empty(),
"batch {} should have pending scrollback",
batch
);
total_pending_sent += pending.len();
let _ = screen.render_with_scrollback(&pending, &mut cache);
}
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 = RenderCache::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 = screen.take_pending_scrollback();
assert!(pending.is_empty(), "cursor overwrite should not generate scrollback");
let output = screen.render(false, &mut cache);
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 = RenderCache::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 clear screen");
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 clear screen 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 = RenderCache::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 = screen.render(false, &mut cache);
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 = screen.render(false, &mut cache);
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 = RenderCache::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 = screen.render(false, &mut cache);
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 = screen.render(false, &mut cache);
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 = RenderCache::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 = screen.render(false, &mut cache);
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 = screen.render(false, &mut cache);
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 = RenderCache::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 = screen.render(false, &mut cache);
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 = screen.render(false, &mut cache);
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 = RenderCache::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 = screen.take_pending_scrollback();
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::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 = screen.take_pending_scrollback();
assert_eq!(
pending.len(), 500,
"all 500 scrolled lines should be in pending"
);
let output = screen.render_with_scrollback(&pending, &mut cache);
let text = String::from_utf8_lossy(&output);
let clear_pos = text.find("\x1b[2J").expect("should have screen clear");
let scrollback_portion = &text[..clear_pos];
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[clear_pos..];
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 = RenderCache::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 = screen.take_pending_scrollback();
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 = RenderCache::new();
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let pending = screen.take_pending_scrollback();
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::new();
screen.process(b"SCRLL1\r\nSCRLL2\r\nCC\r\nDD\r\nEE\r\nFF\r\nGG");
let pending = screen.take_pending_scrollback();
assert!(!pending.is_empty());
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::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 = screen.take_pending_scrollback();
let texts = pending_texts(&pending);
assert_eq!(texts.len(), 5, "5 lines should be pending");
let output = screen.render_with_scrollback(&pending, &mut cache);
let text = String::from_utf8_lossy(&output);
let clear_pos = text.find("\x1b[2J").unwrap();
let before_clear = &text[..clear_pos];
let after_clear = &text[clear_pos..];
for i in 1..=5 {
let label = format!("LINE{:02}", i);
assert!(
before_clear.contains(&label),
"{} should be in scrollback (before clear)",
label
);
assert!(
!after_clear.contains(&label),
"{} should NOT be in screen portion (after clear)",
label
);
}
for i in 6..=8 {
let label = format!("LINE{:02}", i);
assert!(
after_clear.contains(&label),
"{} should be on screen (after clear)",
label
);
}
}
#[test]
fn full_relay_cycle_scrollback_then_incremental_then_scrollback() {
let mut screen = Screen::new(20, 4, 100);
let mut cache = RenderCache::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 clear (scrollback)");
let c1 = t1.find("\x1b[2J").unwrap();
assert!(t1[..c1].contains("A001"), "cycle 1: A001 in scrollback");
assert!(t1[c1..].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 clear (scrollback)");
let c3 = t3.find("\x1b[2J").unwrap();
assert!(
!t3[..c3].contains("A001"),
"cycle 3: A001 already sent, should not be in this batch"
);
assert!(t3[c3..].contains("C009"), "cycle 3: C009 on screen");
}
#[test]
fn single_line_scrollback_injection() {
let mut screen = Screen::new(20, 3, 100);
let mut cache = RenderCache::new();
screen.process(b"line1\r\nline2\r\nline3");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\r\nline4");
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 1, "exactly one line should scroll off");
let output = screen.render_with_scrollback(&pending, &mut cache);
let text = String::from_utf8_lossy(&output);
assert!(text.contains("\x1b[2J"), "should have screen clear");
let clear_pos = text.find("\x1b[2J").unwrap();
assert!(
text[..clear_pos].contains("line1"),
"single scrollback line should be present"
);
assert!(text[clear_pos..].contains("line4"), "line4 on screen");
}
#[test]
fn styled_scrollback_lines_preserve_formatting() {
let mut screen = Screen::new(30, 3, 100);
let mut cache = RenderCache::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 = screen.take_pending_scrollback();
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 = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::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 = screen.render(false, &mut cache);
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 = screen.render(false, &mut cache);
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 = RenderCache::new();
screen.process(b"\x1b[?25l");
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let pending = screen.take_pending_scrollback();
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = screen.take_pending_scrollback();
assert_eq!(first.len(), 3, "first drain should have 3 lines");
let second = screen.take_pending_scrollback();
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 = RenderCache::new();
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let pending1 = screen.take_pending_scrollback();
assert_eq!(pending1.len(), 2);
screen.process(b"\r\nF\r\nG");
let _ = screen.render_with_scrollback(&pending1, &mut cache);
let pending2 = screen.take_pending_scrollback();
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 = RenderCache::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 = screen.take_pending_scrollback();
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 = screen.take_pending_scrollback();
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 = screen.take_pending_scrollback();
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 = screen.take_pending_scrollback();
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 = RenderCache::new();
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = screen.take_pending_scrollback();
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 = RenderCache::new();
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::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 = screen.render(false, &mut cache);
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 = RenderCache::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 = screen.take_pending_scrollback();
assert!(!pending.is_empty());
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::new();
screen.process(b"AA\r\nBB\r\nCC\r\nDD\r\nEE\r\nFF\r\nGG");
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 2, "2 lines should scroll off");
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::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 = screen.take_pending_scrollback();
assert_eq!(pending.len(), 8, "8 lines should scroll off");
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::new();
screen.process(b"S1\r\nS2\r\nS3\r\nS4\r\nS5\r\nV1\r\nV2\r\nV3\r\nV4");
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 5);
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::new();
screen.process(b"E1\r\nE2\r\nE3\r\nE4\r\nV1\r\nV2\r\nV3\r\nV4");
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 4);
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::new();
screen.process(b"ONLY\r\nV1\r\nV2\r\nV3");
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 1);
let output = screen.render_with_scrollback(&pending, &mut cache);
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 = RenderCache::new();
screen.process(b"X1\r\nX2\r\nX3\r\nX4\r\nVIS");
let pending = screen.take_pending_scrollback();
let output = screen.render_with_scrollback(&pending, &mut cache);
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 clear_pos = text.find("\x1b[2J").expect("clear missing");
assert!(sync_begin < clear_pos, "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 = RenderCache::new();
for i in 1..=54 {
screen.process(format!("LINE{:03}\r\n", i).as_bytes());
}
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 50);
let output = screen.render_with_scrollback(&pending, &mut cache);
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");
}