use super::test_helpers::*;
use super::*;
use render::AnsiRenderer;
fn all_content(screen: &Screen) -> Vec<String> {
let mut lines = history_texts(screen);
lines.extend(screen_lines(screen).into_iter().filter(|s| !s.is_empty()));
lines
}
fn write_labeled_lines(screen: &mut Screen, count: usize) {
for i in 1..=count {
if i < count {
screen.process(format!("L{:02}\r\n", i).as_bytes());
} else {
screen.process(format!("L{:02}", i).as_bytes());
}
}
}
fn simulate_reattach(screen: &Screen) -> (Vec<Vec<u8>>, Vec<u8>) {
let hist = screen.get_history();
let mut render_data = Vec::new();
if !hist.is_empty() {
render_data.extend_from_slice(b"\x1b[");
style::write_u16(&mut render_data, screen.grid.rows());
render_data.extend_from_slice(b";1H");
render_data.extend(std::iter::repeat_n(
b'\n',
screen.grid.rows().saturating_sub(1) as usize,
));
}
let mut cache = AnsiRenderer::new();
render_data.extend_from_slice(&cache.render(screen, true));
(hist, render_data)
}
fn client_write_history(history: &[Vec<u8>]) -> Vec<u8> {
let mut out = Vec::new();
for line in history {
out.extend_from_slice(line);
out.extend_from_slice(b"\r\n");
}
out
}
#[test]
fn history_and_screen_no_overlap() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 8);
let _ = screen.take_pending_scrollback();
let hist = history_texts(&screen);
let visible = screen_lines(&screen);
assert_eq!(hist.len(), 5, "expected 5 history lines, got {:?}", hist);
assert!(
visible[0].contains("L06"),
"screen row 0 should be L06, got: '{}'",
visible[0]
);
assert!(
visible[2].contains("L08"),
"screen row 2 should be L08, got: '{}'",
visible[2]
);
for h in &hist {
for v in &visible {
if !v.is_empty() {
assert_ne!(h, v, "line '{}' appears in both history and screen", h);
}
}
}
}
#[test]
fn pending_scrollback_drained_before_reattach() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 6);
let first_rows = screen.take_pending_scrollback();
let first = AnsiRenderer::new().render_rows(&screen, &first_rows);
assert!(
!first.is_empty(),
"first take should have pending scrollback"
);
let second_rows = screen.take_pending_scrollback();
let second = AnsiRenderer::new().render_rows(&screen, &second_rows);
assert!(second.is_empty(), "second take should be empty after drain");
let hist = screen.get_history();
assert!(
!hist.is_empty(),
"get_history should still return scrollback after pending drain"
);
}
#[test]
fn history_ordering_preserved_with_many_lines() {
let mut screen = Screen::new(20, 3, 5000);
for i in 1..=200 {
if i < 200 {
screen.process(format!("LINE{:04}\r\n", i).as_bytes());
} else {
screen.process(format!("LINE{:04}", i).as_bytes());
}
}
let _ = screen.take_pending_scrollback();
let hist = history_texts(&screen);
assert_eq!(hist.len(), 197);
for (i, line) in hist.iter().enumerate() {
let expected = format!("LINE{:04}", i + 1);
assert!(
line.contains(&expected),
"history line {} should contain '{}', got: '{}'",
i,
expected,
line
);
}
}
#[test]
fn resize_expand_moves_scrollback_to_screen_no_duplication() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 10);
let _ = screen.take_pending_scrollback();
assert_eq!(screen.get_history().len(), 7);
screen.resize(10, 6);
let all = all_content(&screen);
assert_eq!(all.len(), 10, "total lines after expand: {:?}", all);
for (i, line) in all.iter().enumerate() {
let expected = format!("L{:02}", i + 1);
assert!(
line.contains(&expected),
"line {} should be '{}', got: '{}'",
i,
expected,
line
);
}
assert_eq!(
screen.get_history().len(),
4,
"scrollback should have 4 lines after restoring 3"
);
}
#[test]
fn resize_shrink_then_expand_roundtrip_no_duplication() {
let mut screen = Screen::new(10, 5, 100);
write_labeled_lines(&mut screen, 8);
let _ = screen.take_pending_scrollback();
let content_before = all_content(&screen);
assert_eq!(content_before.len(), 8);
screen.resize(10, 3);
screen.resize(10, 5);
let content_after = all_content(&screen);
let unique: std::collections::HashSet<&String> = content_after.iter().collect();
assert_eq!(
unique.len(),
content_after.len(),
"no duplicates allowed after shrink/expand: {:?}",
content_after
);
}
#[test]
fn stale_pending_scrollback_only_new_lines() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A1\r\nA2\r\nA3\r\nA4\r\nA5");
let batch1_rows = screen.take_pending_scrollback();
let batch1 = AnsiRenderer::new().render_rows(&screen, &batch1_rows);
let batch1_texts: Vec<String> = batch1.iter().map(|b| strip_ansi(b)).collect();
assert_eq!(batch1_texts.len(), 2, "first batch: {:?}", batch1_texts);
assert!(batch1_texts[0].contains("A1"));
assert!(batch1_texts[1].contains("A2"));
screen.process(b"\r\nA6");
let batch2_rows = screen.take_pending_scrollback();
let batch2 = AnsiRenderer::new().render_rows(&screen, &batch2_rows);
let batch2_texts: Vec<String> = batch2.iter().map(|b| strip_ansi(b)).collect();
assert_eq!(batch2_texts.len(), 1, "second batch: {:?}", batch2_texts);
assert!(
batch2_texts[0].contains("A3"),
"second batch should only have A3, got: '{}'",
batch2_texts[0]
);
}
#[test]
fn history_render_flush_newlines_match_rows() {
for rows in [3u16, 5, 10, 24] {
let mut screen = Screen::new(80, rows, 100);
for i in 1..=(rows as usize + 5) {
if i < (rows as usize + 5) {
screen.process(format!("line{}\r\n", i).as_bytes());
} else {
screen.process(format!("line{}", i).as_bytes());
}
}
let _ = screen.take_pending_scrollback();
let (hist, screen_update) = simulate_reattach(&screen);
assert!(!hist.is_empty(), "should have history for rows={}", rows);
let expected_prefix = format!("\x1b[{};1H", rows);
let prefix_bytes = expected_prefix.as_bytes();
assert!(
screen_update.starts_with(prefix_bytes),
"rows={}: update should start with cursor-to-bottom '{}', got: {:?}",
rows,
expected_prefix,
String::from_utf8_lossy(&screen_update[..20.min(screen_update.len())])
);
let after_cursor = &screen_update[prefix_bytes.len()..];
let newline_count = after_cursor.iter().take_while(|&&b| b == b'\n').count();
assert_eq!(
newline_count,
(rows - 1) as usize,
"rows={}: expected {} flush newlines, got {}",
rows,
rows - 1,
newline_count
);
}
}
#[test]
fn resize_between_reattach_preserves_content() {
let mut screen = Screen::new(10, 5, 100);
write_labeled_lines(&mut screen, 12);
let _ = screen.take_pending_scrollback();
let content_before = all_content(&screen);
assert_eq!(content_before.len(), 12);
let (hist_before, _) = simulate_reattach(&screen);
assert_eq!(hist_before.len(), 7);
screen.resize(10, 3);
let _ = screen.take_pending_scrollback(); let (hist_after, screen_update_after) = simulate_reattach(&screen);
let content_after = all_content(&screen);
let unique: std::collections::HashSet<&String> = content_after.iter().collect();
assert_eq!(
unique.len(),
content_after.len(),
"no duplicates after resize between reattach: {:?}",
content_after
);
assert!(
!hist_after.is_empty(),
"history should not be empty after resize"
);
let update_text = String::from_utf8_lossy(&screen_update_after);
assert!(
update_text.contains("\x1b[3;1H"),
"flush should position cursor at new bottom row (3)"
);
}
#[test]
fn resize_expand_between_reattach_restores_scrollback() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 8);
let _ = screen.take_pending_scrollback();
assert_eq!(screen.get_history().len(), 5);
screen.resize(10, 8);
assert_eq!(
screen.get_history().len(),
0,
"all scrollback should be restored after expanding to 8 rows"
);
let visible = screen_lines(&screen);
for i in 1..=8 {
let expected = format!("L{:02}", i);
assert!(
visible.iter().any(|v| v.contains(&expected)),
"L{:02} should be visible after expand, screen: {:?}",
i,
visible
);
}
let (hist, screen_update) = simulate_reattach(&screen);
assert!(hist.is_empty(), "no history after full restore");
assert!(
screen_update.starts_with(b"\x1b[?2026h"),
"no-history reattach should start with sync begin"
);
}
#[test]
fn e2e_reattach_history_then_screen() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 8);
let _ = screen.take_pending_scrollback();
let (hist, screen_update) = simulate_reattach(&screen);
assert_eq!(hist.len(), 5, "should have 5 history lines");
let mut stdout = Vec::new();
for line in &hist {
stdout.extend_from_slice(line);
stdout.extend_from_slice(b"\r\n");
}
stdout.extend_from_slice(&screen_update);
let stdout_text = String::from_utf8_lossy(&stdout);
let pos_l01 = stdout_text.find("L01").expect("L01 should be in output");
let pos_l05 = stdout_text.find("L05").expect("L05 should be in output");
let pos_clear = stdout_text
.find("\x1b[2J")
.expect("screen clear should be in output");
assert!(pos_l01 < pos_l05, "history lines should be in order");
assert!(
pos_l05 < pos_clear,
"history should appear before screen clear"
);
let after_clear = &stdout_text[pos_clear..];
assert!(
after_clear.contains("L06"),
"screen should contain L06 after clear"
);
assert!(
after_clear.contains("L08"),
"screen should contain L08 after clear"
);
for label in &["L01", "L02", "L03", "L04", "L05"] {
assert!(
!after_clear.contains(label),
"'{}' should not appear in screen portion (after clear)",
label
);
}
}
#[test]
fn e2e_reattach_no_history_no_flush() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Hello\r\nWorld");
let (hist, screen_update) = simulate_reattach(&screen);
assert!(hist.is_empty(), "should have no history");
assert!(
screen_update.starts_with(b"\x1b[?2026h"),
"no-history reattach should start with sync begin, got: {:?}",
String::from_utf8_lossy(&screen_update[..20.min(screen_update.len())])
);
}
#[test]
fn e2e_reattach_with_styled_history() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"\x1b[1;31mRED BOLD\x1b[0m normal\r\n");
screen.process(b"\x1b[32mGREEN\x1b[0m\r\n");
screen.process(b"plain1\r\n");
screen.process(b"plain2\r\n");
screen.process(b"visible");
let _ = screen.take_pending_scrollback();
let (hist, _) = simulate_reattach(&screen);
assert_eq!(hist.len(), 2, "2 lines should be in history");
let line0 = &hist[0];
let line0_text = String::from_utf8_lossy(line0);
assert!(
line0_text.contains("RED BOLD"),
"history should preserve text content"
);
assert!(
line0_text.contains("\x1b["),
"history should preserve SGR escape codes"
);
}
#[test]
fn e2e_resize_between_reattach_cycles() {
let mut screen = Screen::new(10, 5, 100);
write_labeled_lines(&mut screen, 10);
let _ = screen.take_pending_scrollback();
let (hist1, update1) = simulate_reattach(&screen);
assert_eq!(hist1.len(), 5, "first reattach: 5 history lines");
let mut stdout1 = client_write_history(&hist1);
stdout1.extend_from_slice(&update1);
let text1 = String::from_utf8_lossy(&stdout1);
assert!(
text1.contains("L01"),
"first reattach should have L01 in history"
);
screen.resize(10, 3);
let _ = screen.take_pending_scrollback();
let (hist2, update2) = simulate_reattach(&screen);
let mut stdout2 = client_write_history(&hist2);
stdout2.extend_from_slice(&update2);
let text2 = String::from_utf8_lossy(&stdout2);
assert!(
hist2.len() >= hist1.len(),
"shrink should not reduce scrollback, before={}, after={}",
hist1.len(),
hist2.len()
);
let clear_pos2 = text2
.find("\x1b[2J")
.expect("screen clear in second reattach");
let history_portion = &text2[..clear_pos2];
let screen_portion = &text2[clear_pos2..];
for i in 1..=10 {
let label = format!("L{:02}", i);
let in_hist = history_portion.contains(&label);
let in_screen = screen_portion.contains(&label);
assert!(
!(in_hist && in_screen),
"'{}' appears in both history and screen portions",
label
);
}
screen.resize(10, 8);
let _ = screen.take_pending_scrollback();
let (hist3, update3) = simulate_reattach(&screen);
let mut stdout3 = client_write_history(&hist3);
stdout3.extend_from_slice(&update3);
let _text3 = String::from_utf8_lossy(&stdout3);
assert!(
hist3.len() < hist2.len(),
"expand should reduce scrollback, before={}, after={}",
hist2.len(),
hist3.len()
);
if !hist3.is_empty() {
let cursor_prefix = format!("\x1b[{};1H", 8);
assert!(
String::from_utf8_lossy(&update3).contains(&cursor_prefix),
"flush should use new row count (8)"
);
}
let all = all_content(&screen);
let unique: std::collections::HashSet<&String> = all.iter().collect();
assert_eq!(
unique.len(),
all.len(),
"no duplicates after multiple resize+reattach cycles: {:?}",
all
);
}
#[test]
fn e2e_scrollback_during_session_then_reattach() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 5);
let pending_rows = screen.take_pending_scrollback();
let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 2, "2 lines scrolled off");
let mut cache = AnsiRenderer::new();
let atomic_update = cache.render_with_scrollback(&screen, &pending);
let atomic_text = String::from_utf8_lossy(&atomic_update);
assert!(atomic_text.contains("L01"), "scrollback should contain L01");
let pos_clear = atomic_text
.find("\x1b[2J")
.expect("atomic update should have screen clear");
let l01_pos = atomic_text.find("L01").expect("L01 should be in output");
assert!(
l01_pos < pos_clear,
"scrollback content should precede screen clear"
);
let (hist, screen_update) = simulate_reattach(&screen);
let hist_texts: Vec<String> = hist.iter().map(|b| strip_ansi(b)).collect();
assert!(
hist_texts.iter().any(|t| t.contains("L01")),
"L01 should be in reattach history: {:?}",
hist_texts
);
let mut stdout = client_write_history(&hist);
stdout.extend_from_slice(&screen_update);
let text = String::from_utf8_lossy(&stdout);
assert!(text.contains("L01"), "reattach should include L01");
assert!(text.contains("L05"), "reattach should include L05");
}
#[test]
fn e2e_reattach_wide_terminal_to_narrow() {
let mut screen = Screen::new(40, 5, 100);
for i in 1..=8 {
let line = format!("LINE{:02}--padding-to-fill-wide-terminal---", i);
if i < 8 {
screen.process(format!("{}\r\n", line).as_bytes());
} else {
screen.process(line.as_bytes());
}
}
let _ = screen.take_pending_scrollback();
let (hist_wide, _) = simulate_reattach(&screen);
let hist_wide_texts: Vec<String> = hist_wide.iter().map(|b| strip_ansi(b)).collect();
screen.resize(10, 5);
let _ = screen.take_pending_scrollback();
let (hist_narrow, screen_update) = simulate_reattach(&screen);
let hist_narrow_texts: Vec<String> = hist_narrow.iter().map(|b| strip_ansi(b)).collect();
for line in &hist_narrow_texts[..hist_wide_texts.len().min(hist_narrow_texts.len())] {
assert!(
line.contains("LINE"),
"old scrollback line should still have content: '{}'",
line
);
}
let update_text = String::from_utf8_lossy(&screen_update);
assert!(
update_text.contains("\x1b[?2026h"),
"screen update should have sync begin"
);
assert!(
update_text.contains("\x1b[?2026l"),
"screen update should have sync end"
);
}