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)
}
}
fn grid_row_text(screen: &Screen, row: usize) -> String {
screen
.grid
.visible_row(row)
.iter()
.map(|c| c.c)
.collect::<String>()
.trim_end()
.to_string()
}
fn count_in_history(screen: &Screen, needle: &str) -> usize {
history_texts(screen)
.iter()
.filter(|line| line.contains(needle))
.count()
}
fn count_nonempty_history(screen: &Screen) -> usize {
history_texts(screen)
.iter()
.filter(|line| !line.is_empty())
.count()
}
fn count_blank_history(screen: &Screen) -> usize {
history_texts(screen)
.iter()
.filter(|line| line.is_empty())
.count()
}
#[test]
fn single_line_progress_erased_before_scrolling() {
let mut screen = Screen::new(40, 5, 100);
screen.process(b"user> what is rust?\r\n");
screen.process(b"Thinking...");
screen.process(b"\r\x1b[K");
for i in 1..=10 {
screen.process(format!("Response line {}\r\n", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(
count_in_history(&screen, "Thinking"),
0,
"erased progress bar should not appear in scrollback"
);
assert_eq!(
count_in_history(&screen, "user>"),
1,
"user prompt should be in scrollback"
);
}
#[test]
fn multiline_progress_erased_before_scrolling() {
let mut screen = Screen::new(40, 8, 100);
screen.process(b"user> complex query\r\n");
screen.process(b"Status: thinking\r\n");
screen.process(b"[=====> ] 50%\r\n");
screen.process(b"ETA: 3s");
screen.process(b"\x1b[2A"); screen.process(b"\r\x1b[K"); screen.process(b"\n\r\x1b[K"); screen.process(b"\n\r\x1b[K");
for i in 1..=20 {
screen.process(format!("Answer line {}\r\n", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(count_in_history(&screen, "Status: thinking"), 0);
assert_eq!(count_in_history(&screen, "=====>"), 0);
assert_eq!(count_in_history(&screen, "ETA:"), 0);
assert_eq!(count_in_history(&screen, "user>"), 1);
}
#[test]
fn erased_progress_becomes_blank_line_in_scrollback() {
let mut screen = Screen::new(40, 4, 100);
screen.process(b"prompt line\r\n");
screen.process(b"Thinking...\r\n");
screen.process(b"content A\r\n");
screen.process(b"content B");
screen.process(b"\x1b[2;1H"); screen.process(b"\x1b[K");
screen.process(b"\x1b[4;1H"); screen.process(b"\r\nnew1\r\nnew2\r\nnew3\r\nnew4");
let _ = screen.take_pending_scrollback();
let hist = history_texts(&screen);
assert!(
hist.iter().any(|l| l.contains("prompt")),
"prompt should be in scrollback"
);
let blank_count = count_blank_history(&screen);
assert!(
blank_count >= 1,
"at least one blank line should be in scrollback (from erased progress bar)"
);
assert_eq!(
count_in_history(&screen, "Thinking"),
0,
"erased progress bar text should not appear in scrollback"
);
}
#[test]
fn progress_bar_captured_in_scrollback_before_erase() {
let mut screen = Screen::new(40, 4, 100);
screen.process(b"user> hello\r\n");
screen.process(b"Thinking...");
screen.process(b"\r\n");
screen.process(b"Line 1\r\n");
screen.process(b"Line 2\r\n");
screen.process(b"Line 3\r\n");
screen.process(b"Line 4");
screen.process(b"\x1b[4A"); screen.process(b"\r\x1b[K");
let _ = screen.take_pending_scrollback();
assert_eq!(
count_in_history(&screen, "Thinking"),
1,
"progress bar should be in scrollback (captured before erase could reach it)"
);
assert_eq!(
count_in_history(&screen, "user>"),
1,
"prompt should also be in scrollback"
);
}
#[test]
fn cuu_cannot_reach_scrollback() {
let mut screen = Screen::new(40, 3, 100);
screen.process(b"SCROLL1\r\nSCROLL2\r\nVIS1\r\nVIS2\r\nVIS3");
let _ = screen.take_pending_scrollback();
assert_eq!(screen.get_history().len(), 2);
screen.process(b"\x1b[100A");
assert_eq!(screen.grid.cursor_y(), 0, "CUU should stop at row 0");
screen.process(b"\x1b[2K");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 2);
assert!(hist[0].contains("SCROLL1"));
assert!(hist[1].contains("SCROLL2"));
assert!(
grid_row_text(&screen, 0).is_empty(),
"grid row 0 should be erased"
);
}
#[test]
fn spinner_loop_then_erase_then_large_output() {
let mut screen = Screen::new(60, 6, 200);
screen.process(b"user> explain monads\r\n");
screen.process(b"Thinking...");
screen.process(b"\rThinking... (2s)\x1b[K");
screen.process(b"\rThinking... (4s)\x1b[K");
screen.process(b"\r\x1b[K");
for i in 1..=30 {
screen.process(format!("Response line {:02}\r\n", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(
count_in_history(&screen, "Thinking"),
0,
"spinner text should not appear in scrollback after erasure"
);
assert_eq!(
count_in_history(&screen, "user>"),
1,
"user prompt should be in scrollback"
);
assert!(
count_in_history(&screen, "Response line") > 0,
"response lines should be in scrollback"
);
}
#[test]
fn multiline_spinner_then_erase_then_large_output() {
let mut screen = Screen::new(60, 6, 200);
screen.process(b"user> refactor auth module\r\n");
screen.process(b"Thinking...\r\n");
screen.process(b" effecting changes...");
screen.process(b"\x1b[A"); screen.process(b"\r\x1b[K"); screen.process(b"\n\r\x1b[K"); screen.process(b"\x1b[A");
for i in 1..=30 {
screen.process(format!("Response {:02}\r\n", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(count_in_history(&screen, "Thinking"), 0);
assert_eq!(count_in_history(&screen, "effecting"), 0);
assert_eq!(count_in_history(&screen, "user>"), 1);
}
#[test]
fn progress_bar_visible_in_scrollback_after_reconnect() {
let mut screen = Screen::new(60, 8, 500);
let mut cache = AnsiRenderer::new();
screen.process(b"user> explain async/await\r\n");
screen.process(b"Thinking...");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\r\x1b[K");
for i in 1..=50 {
screen.process(format!("Async/await line {:02}\r\n", i).as_bytes());
}
let _ = do_render_cycle(&mut screen, &mut cache);
let _ = screen.take_pending_scrollback();
let history = screen.get_history();
let hist_texts: Vec<String> = history.iter().map(|b| strip_ansi(b)).collect();
assert!(
!hist_texts.iter().any(|l| l.contains("Thinking")),
"spinner should not be in scrollback history after reconnect"
);
assert!(
hist_texts.iter().any(|l| l.contains("user>")),
"prompt should be in scrollback history"
);
assert!(
hist_texts.iter().any(|l| l.contains("Async/await line")),
"response content should be in scrollback history"
);
}
#[test]
fn erased_region_creates_blank_artifact_in_scrollback() {
let mut screen = Screen::new(40, 5, 100);
screen.process(b"Header\r\n");
screen.process(b"Progress: [===> ]\r\n");
screen.process(b"Status: working\r\n");
screen.process(b"content A\r\n");
screen.process(b"content B");
screen.process(b"\x1b[2;1H\x1b[K"); screen.process(b"\x1b[3;1H\x1b[K");
screen.process(b"\x1b[5;1H"); for i in 1..=10 {
screen.process(format!("\r\nnew line {}", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
let hist = history_texts(&screen);
assert!(hist.iter().any(|l| l.contains("Header")));
assert!(!hist.iter().any(|l| l.contains("Progress:")));
assert!(!hist.iter().any(|l| l.contains("Status: working")));
let blanks = hist.iter().filter(|l| l.is_empty()).count();
assert!(
blanks >= 2,
"expected at least 2 blank lines in scrollback (from erased progress widget), got {}",
blanks
);
}
#[test]
fn ed2_erase_display_does_not_leak_to_scrollback() {
let mut screen = Screen::new(40, 5, 100);
screen.process(b"prompt\r\n");
screen.process(b"Thinking...\r\n");
screen.process(b"more stuff");
screen.process(b"\x1b[2J");
screen.process(b"\x1b[H");
for i in 1..=20 {
screen.process(format!("Result {}\r\n", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(count_in_history(&screen, "Thinking"), 0);
assert_eq!(count_in_history(&screen, "prompt"), 0);
assert!(count_in_history(&screen, "Result") > 0);
}
#[test]
fn ed3_erase_display_with_scrollback_clears_history() {
let mut screen = Screen::new(40, 3, 100);
screen.process(b"old1\r\nold2\r\nold3\r\nold4\r\nold5");
let _ = screen.take_pending_scrollback();
assert!(
screen.get_history().len() > 0,
"should have scrollback before ED 3"
);
screen.process(b"\x1b[3J");
assert_eq!(
screen.get_history().len(),
0,
"ED 3 should clear scrollback history"
);
}
#[test]
fn el_only_affects_current_grid_row() {
let mut screen = Screen::new(40, 3, 100);
screen.process(b"scrolled off\r\nVIS1\r\nVIS2\r\nVIS3");
let _ = screen.take_pending_scrollback();
assert!(history_texts(&screen)
.iter()
.any(|l| l.contains("scrolled off")));
screen.process(b"\x1b[1;1H"); screen.process(b"\x1b[2K");
assert!(
history_texts(&screen)
.iter()
.any(|l| l.contains("scrolled off")),
"EL should not modify scrollback"
);
assert!(
grid_row_text(&screen, 0).is_empty(),
"grid row should be erased"
);
}
#[test]
fn response_with_cursor_home_and_overwrite_no_scrollback_leak() {
let mut screen = Screen::new(40, 5, 100);
screen.process(b"\x1b[1;1HStatus: idle\x1b[K");
screen.process(b"\x1b[2;1Hcontent 1\x1b[K");
screen.process(b"\x1b[3;1Hcontent 2\x1b[K");
screen.process(b"\x1b[4;1Hcontent 3\x1b[K");
screen.process(b"\x1b[1;1HStatus: busy\x1b[K");
screen.process(b"\x1b[5;1H");
for i in 4..=20 {
screen.process(format!("content {}\r\n", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(
count_in_history(&screen, "Status: idle"),
0,
"overwritten status should not appear in scrollback"
);
assert_eq!(
count_in_history(&screen, "Status: busy"),
1,
"current status should appear in scrollback after scrolling"
);
}
#[test]
fn styled_progress_bar_erased_cleanly() {
let mut screen = Screen::new(60, 5, 100);
screen.process(b"user> go\r\n");
screen.process(b"\x1b[1;33m[=====> ] 50%\x1b[0m");
screen.process(b"\r\x1b[K");
for i in 1..=20 {
screen.process(format!("Reply line {}\r\n", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(count_in_history(&screen, "=====>"), 0);
assert_eq!(count_in_history(&screen, "50%"), 0);
assert_eq!(count_in_history(&screen, "user>"), 1);
}
#[test]
fn output_arrives_before_progress_erase_captures_progress_in_scrollback() {
let mut screen = Screen::new(40, 3, 100);
screen.process(b"prompt\r\n");
screen.process(b"Thinking...\r\n");
screen.process(b"Ready");
screen.process(b"\r\nResponse 1\r\nResponse 2\r\nResponse 3");
screen.process(b"\x1b[3A\r\x1b[K");
let _ = screen.take_pending_scrollback();
assert_eq!(
count_in_history(&screen, "Thinking"),
1,
"progress bar should be in scrollback (scrolled off before erase)"
);
}
#[test]
fn save_restore_cursor_around_progress_erase() {
let mut screen = Screen::new(60, 5, 100);
screen.process(b"Header\r\n");
screen.process(b"Line 1\r\n");
screen.process(b"Line 2");
screen.process(b"\x1b7");
screen.process(b"\r\n");
screen.process(b"Spinning...");
screen.process(b"\x1b8"); screen.process(b"\x1b[J");
for i in 3..=20 {
screen.process(format!("\r\nLine {}", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(
count_in_history(&screen, "Spinning"),
0,
"progress written below saved cursor should be erased by ED 0"
);
}
#[test]
fn full_session_prompt_spinner_response_scrollback_consistency() {
let mut screen = Screen::new(80, 10, 1000);
let mut cache = AnsiRenderer::new();
for cycle in 1..=5 {
screen.process(format!("user> question {}\r\n", cycle).as_bytes());
screen.process(format!("Thinking (cycle {})...", cycle).as_bytes());
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\r\x1b[K");
for j in 1..=15 {
screen.process(format!("Answer {} line {:02}\r\n", cycle, j).as_bytes());
}
let _ = do_render_cycle(&mut screen, &mut cache);
}
for cycle in 1..=5 {
assert_eq!(
count_in_history(&screen, &format!("Thinking (cycle {})", cycle)),
0,
"spinner from cycle {} should not be in scrollback",
cycle
);
}
for cycle in 1..=5 {
assert_eq!(
count_in_history(&screen, &format!("question {}", cycle)),
1,
"prompt from cycle {} should appear exactly once in scrollback",
cycle
);
}
}
#[test]
fn progress_in_scroll_region_does_not_leak_to_scrollback() {
let mut screen = Screen::new(40, 6, 100);
screen.process(b"\x1b[1;1HHeader\x1b[K");
screen.process(b"\x1b[6;1HThinking...\x1b[K");
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2;1H");
for i in 1..=20 {
screen.process(format!("scrolling content {}\r\n", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(
screen.get_history().len(),
0,
"scroll region not at top should not generate scrollback"
);
}
#[test]
fn progress_overwritten_by_normal_output_no_scrollback_leak() {
let mut screen = Screen::new(40, 4, 100);
screen.process(b"line A\r\n");
screen.process(b"Thinking...\r\n");
screen.process(b"line C\r\n");
screen.process(b"line D");
screen.process(b"\x1b[2;1H");
screen.process(b"Response 01\x1b[K");
screen.process(b"\x1b[4;1H");
for i in 1..=10 {
screen.process(format!("\r\nmore output {}", i).as_bytes());
}
let _ = screen.take_pending_scrollback();
assert_eq!(count_in_history(&screen, "Thinking"), 0);
assert_eq!(
count_in_history(&screen, "Response 01"),
1,
"overwritten line should be in scrollback with new content"
);
}
#[test]
fn pending_scrollback_correct_during_progress_erase_cycle() {
let mut screen = Screen::new(40, 4, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"A\r\nB\r\nC\r\nD");
let _ = do_render_cycle(&mut screen, &mut cache);
screen.process(b"\r\x1b[K");
screen.process(b"Thinking...");
let pending0_rows = screen.take_pending_scrollback();
let pending0 = cache.render_rows(&screen, &pending0_rows);
assert!(
pending0.is_empty(),
"progress bar in-place should not generate scrollback"
);
screen.process(b"\r\x1b[K");
screen.process(b"\r\nE\r\nF\r\nG\r\nH\r\nI");
let pending1_rows = screen.take_pending_scrollback();
let pending1 = cache.render_rows(&screen, &pending1_rows);
let texts = pending_texts(&pending1);
assert!(
pending1.len() > 0,
"response output should generate scrollback"
);
assert!(
!texts.iter().any(|t| t.contains("Thinking")),
"pending scrollback should not contain erased progress bar"
);
}
#[test]
fn reverse_index_at_top_does_not_affect_scrollback() {
let mut screen = Screen::new(40, 3, 100);
screen.process(b"S1\r\nS2\r\nV1\r\nV2\r\nV3");
let _ = screen.take_pending_scrollback();
let hist_before = screen.get_history().len();
screen.process(b"\x1b[1;1H");
screen.process(b"\x1bM");
let hist_after = screen.get_history().len();
assert_eq!(
hist_before, hist_after,
"reverse index should not modify scrollback"
);
}