use super::*;
fn reattach_render(screen: &Screen) -> String {
let mut cache = AnsiRenderer::new();
let output = cache.render(screen, true);
String::from_utf8_lossy(&output).into_owned()
}
fn extract_cursor_cup(rendered: &str) -> (u16, u16) {
let mut last_row = 0u16;
let mut last_col = 0u16;
let mut i = 0;
let bytes = rendered.as_bytes();
while i + 2 < bytes.len() {
if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
let start = i + 2;
let mut j = start;
while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b';') {
j += 1;
}
if j < bytes.len() && bytes[j] == b'H' {
let params = &rendered[start..j];
let parts: Vec<&str> = params.split(';').collect();
if parts.len() == 2 {
if let (Ok(r), Ok(c)) = (parts[0].parse::<u16>(), parts[1].parse::<u16>()) {
last_row = r;
last_col = c;
}
}
}
}
i += 1;
}
(last_row, last_col)
}
#[test]
fn reattach_cursor_at_origin() {
let screen = Screen::new(80, 24, 100);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(1, 1),
"reattach: cursor at origin should render as CUP(1,1)"
);
}
#[test]
fn reattach_cursor_after_text_input() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"Hello");
assert_eq!(screen.grid.cursor_x(), 5);
assert_eq!(screen.grid.cursor_y(), 0);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(1, 6),
"reattach: cursor after 'Hello' should be at row 1, col 6 (1-indexed)"
);
}
#[test]
fn reattach_cursor_after_movement() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[15;40H"); let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(15, 40),
"reattach: cursor after CUP(15,40) should be at (15,40)"
);
}
#[test]
fn reattach_cursor_after_newlines() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"line1\r\nline2\r\nline3");
assert_eq!(screen.grid.cursor_y(), 2);
assert_eq!(screen.grid.cursor_x(), 5);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(3, 6),
"reattach: cursor after newlines should be at row 3, col 6 (1-indexed)"
);
}
#[test]
fn reattach_cursor_bottom_right() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[24;80H"); let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(24, 80),
"reattach: cursor at bottom-right should be at (24,80)"
);
}
#[test]
fn reattach_cursor_visibility_hidden() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?25l"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?25l"),
"reattach: hidden cursor should emit DECTCEM hide"
);
assert!(
!rendered.contains("\x1b[?25h"),
"reattach: hidden cursor should NOT emit DECTCEM show"
);
}
#[test]
fn reattach_cursor_visibility_visible() {
let screen = Screen::new(80, 24, 100);
let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?25h"),
"reattach: visible cursor should emit DECTCEM show"
);
}
#[test]
fn reattach_cursor_shape_preserved() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5 q"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[5 q"),
"reattach: cursor shape (blinking bar) should be in render output"
);
}
#[test]
fn reattach_cursor_shape_steady_block() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[2 q"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[2 q"),
"reattach: cursor shape (steady block) should be in render output"
);
}
#[test]
fn reattach_cursor_after_save_restore() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[10;20H"); screen.process(b"\x1b7"); screen.process(b"\x1b[1;1H"); screen.process(b"\x1b8"); assert_eq!(screen.grid.cursor_y(), 9);
assert_eq!(screen.grid.cursor_x(), 19);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(10, 20),
"reattach: cursor position after save/restore should be preserved"
);
}
#[test]
fn reattach_cursor_after_resize_clamp() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[24;80H"); screen.resize(40, 12);
assert_eq!(screen.grid.cursor_x(), 39);
assert_eq!(screen.grid.cursor_y(), 11);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(12, 40),
"reattach: cursor should be clamped to new dimensions after resize"
);
}
#[test]
fn reattach_cursor_after_resize_within_bounds() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;10H"); screen.resize(40, 12);
assert_eq!(screen.grid.cursor_x(), 9);
assert_eq!(screen.grid.cursor_y(), 4);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(5, 10),
"reattach: cursor within bounds should not change after resize"
);
}
#[test]
fn reattach_cursor_after_scroll() {
let mut screen = Screen::new(80, 5, 100);
screen.process(b"row1\r\nrow2\r\nrow3\r\nrow4\r\nrow5\r\nrow6");
assert_eq!(screen.grid.cursor_y(), 4);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
row, 5,
"reattach: cursor row after scroll should be last row (5, 1-indexed)"
);
assert_eq!(
col, 5,
"reattach: cursor col after 'row6' should be 5 (1-indexed)"
);
}
#[test]
fn reattach_cursor_after_alt_screen_exit() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[10;20H"); screen.process(b"\x1b[?1049h"); screen.process(b"\x1b[5;5H"); screen.process(b"\x1b[?1049l"); assert_eq!(screen.grid.cursor_y(), 9);
assert_eq!(screen.grid.cursor_x(), 19);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(10, 20),
"reattach: cursor should be restored after alt screen exit"
);
}
#[test]
fn reattach_bracketed_paste_mode() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?2004h"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?2004h"),
"reattach: bracketed paste mode should be in render output"
);
}
#[test]
fn reattach_mouse_mode_1003() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1003h"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?1003h"),
"reattach: mouse mode 1003 should be in render output"
);
}
#[test]
fn reattach_mouse_sgr_encoding() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1000h"); screen.process(b"\x1b[?1006h"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?1000h"),
"reattach: mouse mode 1000 should be in render output"
);
assert!(
rendered.contains("\x1b[?1006h"),
"reattach: SGR mouse encoding should be in render output"
);
}
#[test]
fn reattach_focus_reporting() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1004h"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?1004h"),
"reattach: focus reporting should be in render output"
);
}
#[test]
fn reattach_cursor_key_mode() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1h"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?1h"),
"reattach: cursor key mode (DECCKM) should be in render output"
);
}
#[test]
fn reattach_keypad_app_mode() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b="); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b="),
"reattach: keypad application mode should be in render output"
);
}
#[test]
fn reattach_title_preserved() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]2;My Session\x07"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b]2;My Session\x07"),
"reattach: window title should be in render output"
);
}
#[test]
fn reattach_cell_content_preserved() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Hello");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("Hello"),
"reattach: cell content should be preserved in render output"
);
}
#[test]
fn reattach_wrap_pending_cursor_at_right_margin() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCDE"); assert!(screen.grid.wrap_pending());
assert_eq!(screen.grid.cursor_x(), 4);
assert_eq!(screen.grid.cursor_y(), 0);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(1, 5),
"reattach: cursor with wrap_pending should be at right margin"
);
}
#[test]
fn reattach_with_scrollback_preserves_cursor() {
let mut screen = Screen::new(80, 5, 100);
for i in 0..10 {
screen.process(format!("line{}\r\n", i).as_bytes());
}
let history = screen.get_history();
assert!(!history.is_empty(), "should have scrollback");
screen.process(b"\x1b[3;15H");
assert_eq!(screen.grid.cursor_y(), 2);
assert_eq!(screen.grid.cursor_x(), 14);
let mut cache = AnsiRenderer::new();
let output = cache.render_with_scrollback(&screen, &history);
let rendered = String::from_utf8_lossy(&output).into_owned();
let pos_clear = rendered.find("\x1b[2J").expect("screen clear missing");
let after_clear = &rendered[pos_clear..];
assert!(
after_clear.contains("\x1b[3;15H"),
"reattach with scrollback: cursor should be at (3,15), rendered: {:?}",
&after_clear[..after_clear.len().min(200)]
);
}
#[test]
fn reattach_full_state_roundtrip() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?2004h"); screen.process(b"\x1b[?1h"); screen.process(b"\x1b[?1003h"); screen.process(b"\x1b[?1006h"); screen.process(b"\x1b[5 q"); screen.process(b"\x1b]2;complex session\x07");
screen.process(b"Hello World");
screen.process(b"\x1b[12;35H");
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(12, 35),
"roundtrip: cursor position should be preserved"
);
assert!(
rendered.contains("\x1b[?2004h"),
"roundtrip: bracketed paste"
);
assert!(rendered.contains("\x1b[?1h"), "roundtrip: DECCKM");
assert!(rendered.contains("\x1b[?1003h"), "roundtrip: mouse mode");
assert!(rendered.contains("\x1b[?1006h"), "roundtrip: SGR encoding");
assert!(rendered.contains("\x1b[5 q"), "roundtrip: cursor shape");
assert!(
rendered.contains("\x1b]2;complex session\x07"),
"roundtrip: title"
);
assert!(rendered.contains("Hello World"), "roundtrip: cell content");
assert!(rendered.contains("\x1b[?25h"), "roundtrip: cursor visible");
assert!(rendered.starts_with("\x1b[?2026h"), "roundtrip: sync begin");
assert!(rendered.ends_with("\x1b[?2026l"), "roundtrip: sync end");
}
#[test]
fn reattach_after_multiple_resizes() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[20;60H");
screen.resize(40, 12);
assert_eq!(screen.grid.cursor_x(), 39); assert_eq!(screen.grid.cursor_y(), 11);
screen.resize(100, 30);
assert_eq!(screen.grid.cursor_x(), 39);
assert_eq!(screen.grid.cursor_y(), 11);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(12, 40),
"reattach: cursor should be at clamped position after multiple resizes"
);
}
#[test]
fn reattach_cursor_after_clear_screen() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"lots of content here");
screen.process(b"\x1b[10;25H"); screen.process(b"\x1b[2J"); assert_eq!(screen.grid.cursor_y(), 9);
assert_eq!(screen.grid.cursor_x(), 24);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(10, 25),
"reattach: cursor position should survive clear screen"
);
}
#[test]
fn reattach_cursor_after_erase_in_display() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[15;30H");
screen.process(b"\x1b[0J"); assert_eq!(screen.grid.cursor_y(), 14);
assert_eq!(screen.grid.cursor_x(), 29);
let rendered = reattach_render(&screen);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(15, 30),
"reattach: cursor should be preserved after erase in display"
);
}
#[test]
fn reattach_second_render_uses_cache() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"Hello");
screen.process(b"\x1b[5;10H");
let mut cache = AnsiRenderer::new();
let render1 = cache.render(&screen, true);
let text1 = String::from_utf8_lossy(&render1);
assert!(
text1.contains("\x1b[5;10H"),
"full render should set cursor position"
);
let render2 = cache.render(&screen, false);
assert!(
render2.is_empty(),
"no-op incremental render should produce empty output"
);
screen.process(b"\x1b[3;5H");
let render3 = cache.render(&screen, false);
let text3 = String::from_utf8_lossy(&render3);
assert!(
text3.contains("\x1b[3;5H"),
"incremental render should emit changed cursor position"
);
}
#[test]
fn reattach_fresh_cache_always_full_render() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"data on row 1");
screen.process(b"\x1b[2;1H");
screen.process(b"data on row 2");
screen.process(b"\x1b[8;20H");
let mut cache = AnsiRenderer::new();
let rendered = String::from_utf8_lossy(&cache.render(&screen, true)).into_owned();
assert!(
rendered.contains("\x1b[2J"),
"reattach with fresh cache should screen clear"
);
assert!(
rendered.contains("data on row 1"),
"reattach should include row 1 content"
);
assert!(
rendered.contains("data on row 2"),
"reattach should include row 2 content"
);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(8, 20),
"reattach with fresh cache should position cursor correctly"
);
}
#[test]
fn reattach_bold_text_preserved() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[1mBOLD\x1b[0m");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("BOLD"),
"bold text content should be present"
);
assert!(
rendered.contains("\x1b[0;1m"),
"reattach: bold SGR should be present in render output"
);
}
#[test]
fn reattach_colored_text_preserved() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[31mRED\x1b[0m \x1b[32mGREEN\x1b[0m");
let rendered = reattach_render(&screen);
assert!(rendered.contains("RED"), "red text should be present");
assert!(rendered.contains("GREEN"), "green text should be present");
assert!(
rendered.contains("31m"),
"reattach: red color SGR should be present"
);
assert!(
rendered.contains("32m"),
"reattach: green color SGR should be present"
);
}
#[test]
fn reattach_rgb_color_preserved() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[38;2;100;200;50mRGB\x1b[0m");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("RGB"),
"RGB-colored text should be present"
);
assert!(
rendered.contains("38;2;100;200;50"),
"reattach: RGB color SGR should be preserved"
);
}
#[test]
fn reattach_256_color_preserved() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[38;5;200mPAL\x1b[0m");
let rendered = reattach_render(&screen);
assert!(rendered.contains("PAL"), "256-color text should be present");
assert!(
rendered.contains("38;5;200"),
"reattach: 256-color SGR should be preserved"
);
}
#[test]
fn reattach_background_color_preserved() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[44m BG \x1b[0m");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("44"),
"reattach: background color SGR should be preserved"
);
}
#[test]
fn reattach_combined_sgr_attributes() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[1;3;4;31;44mSTYLED\x1b[0m");
let rendered = reattach_render(&screen);
assert!(rendered.contains("STYLED"), "styled text should be present");
assert!(rendered.contains(";1;"), "reattach: bold should be in SGR");
assert!(
rendered.contains(";3;"),
"reattach: italic should be in SGR"
);
assert!(
rendered.contains(";4;"),
"reattach: underline should be in SGR"
);
assert!(rendered.contains("31"), "reattach: red fg should be in SGR");
assert!(
rendered.contains("44"),
"reattach: blue bg should be in SGR"
);
}
#[test]
fn reattach_inverse_attribute() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[7mINV\x1b[0m");
let rendered = reattach_render(&screen);
assert!(rendered.contains("INV"), "inverse text should be present");
assert!(
rendered.contains(";7"),
"reattach: inverse (SGR 7) should be preserved"
);
}
#[test]
fn reattach_strikethrough_attribute() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[9mSTRIKE\x1b[0m");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("STRIKE"),
"strikethrough text should be present"
);
assert!(
rendered.contains(";9"),
"reattach: strikethrough (SGR 9) should be preserved"
);
}
#[test]
fn reattach_dim_attribute() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[2mDIM\x1b[0m");
let rendered = reattach_render(&screen);
assert!(rendered.contains("DIM"), "dim text should be present");
assert!(
rendered.contains(";2"),
"reattach: dim (SGR 2) should be preserved"
);
}
#[test]
fn reattach_wide_characters() {
let mut screen = Screen::new(80, 24, 100);
screen.process("你好世界".as_bytes());
let rendered = reattach_render(&screen);
assert!(
rendered.contains("你好世界"),
"reattach: wide CJK characters should be preserved"
);
}
#[test]
fn reattach_combining_marks() {
let mut screen = Screen::new(80, 24, 100);
screen.process("e\u{0301}".as_bytes());
let rendered = reattach_render(&screen);
assert!(
rendered.contains("e\u{0301}"),
"reattach: combining marks should be preserved"
);
}
#[test]
fn reattach_line_drawing_characters() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b(0"); screen.process(b"lqk"); let rendered = reattach_render(&screen);
assert!(
rendered.contains('┌'),
"reattach: line drawing ┌ should be present"
);
assert!(
rendered.contains('─'),
"reattach: line drawing ─ should be present"
);
assert!(
rendered.contains('┐'),
"reattach: line drawing ┐ should be present"
);
}
#[test]
fn reattach_multiple_rows_content() {
let mut screen = Screen::new(40, 10, 100);
screen.process(b"\x1b[1;1HRow One");
screen.process(b"\x1b[2;1HRow Two");
screen.process(b"\x1b[3;1HRow Three");
screen.process(b"\x1b[5;1HRow Five");
let rendered = reattach_render(&screen);
assert!(rendered.contains("Row One"), "reattach: row 1 content");
assert!(rendered.contains("Row Two"), "reattach: row 2 content");
assert!(rendered.contains("Row Three"), "reattach: row 3 content");
assert!(rendered.contains("Row Five"), "reattach: row 5 content");
}
#[test]
fn reattach_row_order_correct() {
let mut screen = Screen::new(40, 5, 100);
screen.process(b"\x1b[1;1HFIRST");
screen.process(b"\x1b[3;1HSECOND");
screen.process(b"\x1b[5;1HTHIRD");
let rendered = reattach_render(&screen);
let pos_first = rendered.find("FIRST").expect("FIRST missing");
let pos_second = rendered.find("SECOND").expect("SECOND missing");
let pos_third = rendered.find("THIRD").expect("THIRD missing");
assert!(pos_first < pos_second, "FIRST should appear before SECOND");
assert!(pos_second < pos_third, "SECOND should appear before THIRD");
}
#[test]
fn reattach_content_after_autowrap() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCDEfgh"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("ABCDE"),
"reattach: first row content after wrap"
);
assert!(
rendered.contains("fgh"),
"reattach: wrapped content on second row"
);
}
#[test]
fn reattach_content_last_column() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"\x1b[1;10H");
screen.process(b"X");
assert_eq!(screen.grid.visible_row(0)[9].c, 'X');
let rendered = reattach_render(&screen);
assert!(
rendered.contains("X"),
"reattach: content at last column should be present"
);
}
#[test]
fn reattach_content_last_row() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[5;1HBottom");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("Bottom"),
"reattach: content on last row should be present"
);
}
#[test]
fn reattach_content_after_insert_lines() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"\x1b[1;1HLine1");
screen.process(b"\x1b[2;1HLine2");
screen.process(b"\x1b[3;1HLine3");
screen.process(b"\x1b[2;1H");
screen.process(b"\x1b[L"); let rendered = reattach_render(&screen);
assert!(rendered.contains("Line1"), "reattach: Line1 should remain");
assert!(
rendered.contains("Line2"),
"reattach: Line2 should be shifted down"
);
assert!(
rendered.contains("Line3"),
"reattach: Line3 should be shifted down"
);
let pos1 = rendered.find("Line1").unwrap();
let pos2 = rendered.find("Line2").unwrap();
let pos3 = rendered.find("Line3").unwrap();
assert!(
pos1 < pos2 && pos2 < pos3,
"reattach: line order should be preserved after insert"
);
}
#[test]
fn reattach_content_after_delete_lines() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"\x1b[1;1HLine1");
screen.process(b"\x1b[2;1HLine2");
screen.process(b"\x1b[3;1HLine3");
screen.process(b"\x1b[2;1H");
screen.process(b"\x1b[M"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("Line1"),
"reattach: Line1 should remain after DL"
);
assert!(
rendered.contains("Line3"),
"reattach: Line3 should be shifted up after DL"
);
}
#[test]
fn reattach_content_after_delete_characters() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"HelloWorld");
screen.process(b"\x1b[1;6H");
screen.process(b"\x1b[5P"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("Hello"),
"reattach: content before DCH should remain"
);
}
#[test]
fn reattach_content_after_insert_characters() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"ABCDE");
screen.process(b"\x1b[1;3H"); screen.process(b"\x1b[2@"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("AB"),
"reattach: content before ICH preserved"
);
assert!(
rendered.contains("CDE"),
"reattach: content after ICH shifted right"
);
}
#[test]
fn reattach_content_after_erase_to_end_of_line() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"Hello World!");
screen.process(b"\x1b[1;6H"); screen.process(b"\x1b[0K"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("Hello"),
"reattach: content before erase should remain"
);
assert!(
!rendered.contains("World"),
"reattach: erased content should not be present"
);
}
#[test]
fn reattach_content_with_scroll_region() {
let mut screen = Screen::new(20, 6, 100);
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[1;1HTop"); screen.process(b"\x1b[2;1HIn2"); screen.process(b"\x1b[3;1HIn3"); screen.process(b"\x1b[6;1HBottom"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("Top"),
"reattach: content above scroll region"
);
assert!(
rendered.contains("In2"),
"reattach: content inside scroll region"
);
assert!(
rendered.contains("In3"),
"reattach: content inside scroll region"
);
assert!(
rendered.contains("Bottom"),
"reattach: content below scroll region"
);
}
#[test]
fn reattach_content_after_scroll_within_region() {
let mut screen = Screen::new(20, 6, 100);
screen.process(b"\x1b[1;1HFixed");
screen.process(b"\x1b[6;1HFooter");
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2;1H");
screen.process(b"R2\r\nR3\r\nR4\r\nR5\r\nR6"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("Fixed"),
"reattach: content outside scroll region should survive scroll"
);
assert!(
rendered.contains("Footer"),
"reattach: content below scroll region should survive scroll"
);
}
#[test]
fn reattach_content_after_reverse_index() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"\x1b[1;1HLine1");
screen.process(b"\x1b[2;1HLine2");
screen.process(b"\x1b[1;1H");
screen.process(b"\x1bM"); let rendered = reattach_render(&screen);
assert!(rendered.contains("Line1"), "reattach: Line1 after RI");
assert!(rendered.contains("Line2"), "reattach: Line2 after RI");
}
#[test]
fn reattach_alt_screen_content() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"Main Screen");
screen.process(b"\x1b[?1049h");
screen.process(b"Alt Content");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("Alt Content"),
"reattach: alt screen content should be rendered when in alt screen"
);
assert!(
!rendered.contains("Main Screen"),
"reattach: main screen content should NOT be visible while in alt screen"
);
}
#[test]
fn reattach_after_alt_screen_roundtrip() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"Original");
screen.process(b"\x1b[?1049h");
screen.process(b"Temporary");
screen.process(b"\x1b[?1049l");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("Original"),
"reattach: main screen content should be restored after alt screen exit"
);
assert!(
!rendered.contains("Temporary"),
"reattach: alt screen content should be gone after exit"
);
}
#[test]
fn reattach_tab_aligned_content() {
let mut screen = Screen::new(40, 3, 100);
screen.process(b"A\tB\tC");
let rendered = reattach_render(&screen);
assert!(rendered.contains("A"), "reattach: content before tab");
assert!(rendered.contains("B"), "reattach: content after first tab");
assert!(rendered.contains("C"), "reattach: content after second tab");
assert_eq!(
screen.grid.visible_row(0)[8].c,
'B',
"reattach: B should be at tab stop column 8"
);
assert_eq!(
screen.grid.visible_row(0)[16].c,
'C',
"reattach: C should be at tab stop column 16"
);
}
#[test]
fn reattach_background_color_erase() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"\x1b[41m"); screen.process(b"\x1b[2K"); assert_eq!(
screen.cell_style(0, 0).bg,
Some(super::style::Color::Indexed(1)),
"BCE: erased cells should have red background"
);
let rendered = reattach_render(&screen);
assert!(
rendered.contains("41"),
"reattach: BCE background color should be in render output"
);
}
#[test]
fn reattach_mixed_styled_unstyled_regions() {
let mut screen = Screen::new(30, 3, 100);
screen.process(b"plain ");
screen.process(b"\x1b[1;31mbold red\x1b[0m");
screen.process(b" plain again");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("plain"),
"reattach: unstyled text present"
);
assert!(
rendered.contains("bold red"),
"reattach: styled text present"
);
assert!(
rendered.contains("plain again"),
"reattach: trailing unstyled text"
);
assert!(
rendered.contains("\x1b[0m"),
"reattach: SGR reset should appear for style transitions"
);
}
#[test]
fn reattach_empty_screen() {
let screen = Screen::new(80, 24, 100);
let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?2026h"),
"reattach: sync begin on empty screen"
);
assert!(
rendered.contains("\x1b[?2026l"),
"reattach: sync end on empty screen"
);
assert!(
rendered.contains("\x1b[2J"),
"reattach: screen clear on empty screen"
);
}
#[test]
fn reattach_render_structure_order() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"\x1b[1;31mContent\x1b[0m");
screen.process(b"\x1b]2;TestTitle\x07");
screen.process(b"\x1b[3;10H"); let rendered = reattach_render(&screen);
let sync_begin = rendered.find("\x1b[?2026h").expect("sync begin");
let hide_cursor = rendered.find("\x1b[?25l").expect("hide cursor");
let pos_clear = rendered.find("\x1b[2J").expect("screen clear");
let content = rendered.find("Content").expect("content");
let title = rendered.find("\x1b]2;TestTitle").expect("title");
let show_cursor = rendered.rfind("\x1b[?25h").expect("show cursor");
let sync_end = rendered.rfind("\x1b[?2026l").expect("sync end");
assert!(sync_begin < hide_cursor, "sync begin before hide cursor");
assert!(hide_cursor < pos_clear, "hide cursor before screen clear");
assert!(pos_clear < content, "screen clear before content");
assert!(content < title, "content before title");
assert!(title < show_cursor, "title before show cursor");
assert!(show_cursor < sync_end, "show cursor before sync end");
}
#[test]
fn reattach_scrollback_not_in_grid_render() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"scroll1\r\nscroll2\r\nscroll3\r\nvisible");
let rendered = reattach_render(&screen);
assert!(
!rendered.contains("scroll1"),
"reattach: scrollback lines should NOT be in grid render"
);
assert!(
rendered.contains("visible"),
"reattach: visible content should be in render"
);
}
#[test]
fn reattach_scrollback_lines_in_history_render() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"scroll1\r\nscroll2\r\nscroll3\r\nvisible");
let history = screen.get_history();
assert!(!history.is_empty(), "should have scrollback history");
let mut cache = AnsiRenderer::new();
let output = cache.render_with_scrollback(&screen, &history);
let rendered = String::from_utf8_lossy(&output);
assert!(
rendered.contains("scroll1"),
"reattach with history: scroll1 should be present"
);
assert!(
rendered.contains("visible"),
"reattach with history: visible content should be present"
);
}
#[test]
fn reattach_multiple_style_changes_per_row() {
let mut screen = Screen::new(40, 3, 100);
screen.process(b"\x1b[31mR\x1b[32mG\x1b[34mB\x1b[0m N");
let rendered = reattach_render(&screen);
assert!(rendered.contains("R"), "red char present");
assert!(rendered.contains("G"), "green char present");
assert!(rendered.contains("B"), "blue char present");
assert!(rendered.contains("N"), "normal char present");
let sgr_count = rendered.matches("\x1b[0;").count();
assert!(
sgr_count >= 3,
"reattach: should have at least 3 SGR style changes, got {}",
sgr_count
);
}
#[test]
fn reattach_content_fills_entire_screen() {
let mut screen = Screen::new(5, 3, 100);
for row in 0..3 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(b"XXXXX");
}
let rendered = reattach_render(&screen);
let x_count = rendered.matches('X').count();
assert_eq!(
x_count, 15,
"reattach: fully filled screen should have 15 X's, got {}",
x_count
);
}
#[test]
fn reattach_cursor_position_independent_of_content() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[1;1HHello");
screen.process(b"\x1b[20;50H"); let rendered = reattach_render(&screen);
assert!(rendered.contains("Hello"), "content should be present");
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(20, 50),
"cursor should be at (20,50), independent of content position"
);
}
#[test]
fn reattach_after_full_reset() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[1;31mColored\x1b[0m");
screen.process(b"\x1b[?2004h"); screen.process(b"\x1b[5 q"); screen.process(b"\x1b[10;20H"); screen.process(b"\x1b]2;Title\x07"); screen.process(b"\x1bc");
let rendered = reattach_render(&screen);
assert!(
!rendered.contains("Colored"),
"reattach after RIS: content should be cleared"
);
let (row, col) = extract_cursor_cup(&rendered);
assert_eq!(
(row, col),
(1, 1),
"reattach after RIS: cursor should be at origin"
);
assert!(
rendered.contains("\x1b[?25h"),
"reattach after RIS: cursor should be visible"
);
assert!(
rendered.contains("\x1b[0 q"),
"reattach after RIS: cursor shape should be default"
);
}
#[test]
fn reattach_overwritten_cell_shows_latest() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"OLD");
screen.process(b"\x1b[1;1H"); screen.process(b"NEW"); let rendered = reattach_render(&screen);
assert!(
rendered.contains("NEW"),
"reattach: overwritten cells should show latest content"
);
}
#[test]
fn reattach_wide_char_at_end_of_row() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"\x1b[1;9H");
screen.process("你".as_bytes()); let rendered = reattach_render(&screen);
assert!(
rendered.contains("你"),
"reattach: wide char at end of row should be preserved"
);
}
#[test]
fn reattach_wide_char_wraps_at_boundary() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCD");
screen.process("你".as_bytes()); let rendered = reattach_render(&screen);
assert!(
rendered.contains("ABCD"),
"narrow chars before wrap boundary"
);
assert!(rendered.contains("你"), "wide char should be on next row");
}
#[test]
fn reattach_hidden_text_attribute() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"\x1b[8mSECRET\x1b[0m");
assert!(screen.cell_style(0, 0).hidden);
let rendered = reattach_render(&screen);
assert!(
rendered.contains("SECRET"),
"reattach: hidden text content should still be in render output"
);
assert!(
rendered.contains(";8"),
"reattach: hidden attribute (SGR 8) should be preserved"
);
}
#[test]
fn reattach_preserves_all_rows_after_partial_scroll() {
let mut screen = Screen::new(20, 5, 100);
for i in 1..=5 {
screen.process(format!("\x1b[{};1HRow{}", i, i).as_bytes());
}
screen.process(b"\x1b[2S");
let rendered = reattach_render(&screen);
assert!(
rendered.contains("Row3"),
"reattach: Row3 should be at top after scroll"
);
assert!(
rendered.contains("Row4"),
"reattach: Row4 should be visible"
);
assert!(
rendered.contains("Row5"),
"reattach: Row5 should be visible"
);
assert!(
!rendered.contains("Row1"),
"reattach: Row1 should be scrolled off"
);
assert!(
!rendered.contains("Row2"),
"reattach: Row2 should be scrolled off"
);
}
#[test]
fn reattach_restores_autowrap_disabled() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?7l"); assert!(!screen.grid.modes().autowrap_mode);
let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?7l"),
"reattach: disabled autowrap (DECAWM) should be emitted in render output"
);
}
#[test]
fn reattach_restores_autowrap_enabled() {
let screen = Screen::new(80, 24, 100);
assert!(screen.grid.modes().autowrap_mode);
let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b[?7h"),
"reattach: enabled autowrap (DECAWM) should be emitted in render output"
);
}
#[test]
fn reattach_restores_g0_line_drawing_charset() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b(0"); assert_eq!(
screen.grid.modes().g0_charset,
super::grid::Charset::LineDrawing
);
let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b(0"),
"reattach: G0 line drawing charset should be restored"
);
}
#[test]
fn reattach_restores_g0_ascii_charset() {
let screen = Screen::new(80, 24, 100);
assert_eq!(screen.grid.modes().g0_charset, super::grid::Charset::Ascii);
let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b(B"),
"reattach: G0 ASCII charset should be explicitly set"
);
}
#[test]
fn reattach_restores_g1_line_drawing_charset() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b)0"); assert_eq!(
screen.grid.modes().g1_charset,
super::grid::Charset::LineDrawing
);
let rendered = reattach_render(&screen);
assert!(
rendered.contains("\x1b)0"),
"reattach: G1 line drawing charset should be restored"
);
}
#[test]
fn reattach_restores_active_charset_g1() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x0e"); assert_eq!(
screen.grid.modes().active_charset,
super::grid::ActiveCharset::G1
);
let rendered = reattach_render(&screen);
assert!(
rendered.as_bytes().contains(&0x0E),
"reattach: active charset G1 (SO) should be restored"
);
}
#[test]
fn reattach_restores_active_charset_g0() {
let screen = Screen::new(80, 24, 100);
assert_eq!(
screen.grid.modes().active_charset,
super::grid::ActiveCharset::G0
);
let rendered = reattach_render(&screen);
assert!(
rendered.as_bytes().contains(&0x0F),
"reattach: active charset G0 (SI) should be explicitly set"
);
}
#[test]
fn mode_delta_detects_autowrap_change() {
let mut grid = super::grid::Grid::new(10, 3, 0);
let mut cache = super::render::RenderCache::new();
let _ = super::render::render_screen(&grid, "", true, &mut cache);
grid.modes_mut().autowrap_mode = false;
let result = super::render::render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b[?7l"),
"mode delta should emit DECAWM disable when autowrap changes to false"
);
}
#[test]
fn mode_delta_detects_charset_change() {
let mut grid = super::grid::Grid::new(10, 3, 0);
let mut cache = super::render::RenderCache::new();
let _ = super::render::render_screen(&grid, "", true, &mut cache);
grid.modes_mut().g0_charset = super::grid::Charset::LineDrawing;
let result = super::render::render_screen(&grid, "", false, &mut cache);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("\x1b(0"),
"mode delta should emit G0 line drawing when charset changes"
);
}
#[test]
fn reattach_htop_multiple_reconnections() {
let mut screen = Screen::new(40, 10, 100);
screen.process(b"\x1b[?1049h"); screen.process(b"\x1b[?25l");
screen.process(b"\x1b[1;1H\x1b[1;37;44m CPU [");
screen.process(b"\x1b[32m|||||||||\x1b[37m..........");
screen.process(b"\x1b[37;44m 45.2%]\x1b[0m");
screen.process(b"\x1b[3;1H\x1b[32m PID USER PRI NI\x1b[0m");
screen.process(b"\x1b[4;1H 1 root 20 0");
let rendered1 = reattach_render(&screen);
assert!(rendered1.contains("CPU"), "first reattach: header content");
assert!(rendered1.contains("PID"), "first reattach: column headers");
assert!(rendered1.contains("root"), "first reattach: process data");
screen.process(b"\x1b[1;1H\x1b[1;37;44m CPU [");
screen.process(b"\x1b[32m|||||||\x1b[37m...........");
screen.process(b"\x1b[37;44m 38.1%]\x1b[0m");
let rendered2 = reattach_render(&screen);
assert!(
rendered2.contains("38.1%"),
"second reattach: updated percentage"
);
assert!(
rendered2.contains("PID"),
"second reattach: column headers still present"
);
screen.process(b"\x1b[1;1H\x1b[1;37;44m CPU [");
screen.process(b"\x1b[32m|||||\x1b[37m.............");
screen.process(b"\x1b[37;44m 27.5%]\x1b[0m");
let rendered3 = reattach_render(&screen);
assert!(
rendered3.contains("27.5%"),
"third reattach: updated percentage"
);
assert!(
rendered3.contains("PID"),
"third reattach: column headers still present"
);
assert!(
rendered3.contains("root"),
"third reattach: process data still present"
);
}
#[test]
fn vte_parser_recovers_from_partial_escape_sequence() {
let mut screen = Screen::new(40, 5, 0);
screen.process(b"\x1b[1;1HBefore");
screen.process(b"\x1b[38;5;");
screen.process(b"\x1b[2;1HAfter");
let row2: String = screen.grid.visible_row(1).iter().map(|c| c.c).collect();
assert!(
row2.starts_with("After"),
"VTE parser should recover from partial escape: row 2 = {:?}",
row2.trim()
);
}
#[test]
fn vte_parser_sgr_correct_after_partial_sequence() {
let mut screen = Screen::new(40, 5, 0);
screen.process(b"\x1b[31mRed\x1b[0m");
screen.process(b"\x1b[1;");
screen.process(b"\x1b[32mGreen\x1b[0m");
let mut found_green = false;
for row in screen.grid.visible_rows() {
for (i, cell) in row.iter().enumerate() {
if cell.c == 'G' {
let word: String = row[i..].iter().take(5).map(|c| c.c).collect();
if word == "Green" {
assert_eq!(
screen.grid.style_table().get(cell.style_id).fg,
Some(style::Color::Indexed(2)),
"Green text should have green foreground after parser recovery"
);
found_green = true;
break;
}
}
}
if found_green {
break;
}
}
assert!(
found_green,
"Should find 'Green' text in grid after parser recovery"
);
}
#[test]
fn reattach_after_simulated_data_loss() {
let mut screen = Screen::new(40, 10, 0);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[1;1H\x1b[32mCPU: 50%\x1b[0m");
screen.process(b"\x1b[2;1H\x1b[33mMem: 2G/8G\x1b[0m");
screen.process(b"\x1b[3;1HPID COMMAND");
screen.process(b"\x1b[4;1H\x1b[38;2;");
let mut cache = AnsiRenderer::new();
let render1 = cache.render(&screen, true);
assert!(
!render1.is_empty(),
"render after partial escape should work"
);
screen.process(b"\x1b[1;1H\x1b[32mCPU: 65%\x1b[0m");
screen.process(b"\x1b[2;1H\x1b[33mMem: 3G/8G\x1b[0m");
screen.process(b"\x1b[3;1HPID COMMAND");
screen.process(b"\x1b[4;1H 42 htop");
cache = AnsiRenderer::new();
let render2 = cache.render(&screen, true);
let text = String::from_utf8_lossy(&render2);
assert!(
text.contains("65%"),
"updated CPU should appear after data loss recovery"
);
assert!(
text.contains("3G/8G"),
"updated Mem should appear after data loss recovery"
);
assert!(
text.contains("htop"),
"process list should appear after data loss recovery"
);
}
#[test]
fn reattach_render_is_self_contained() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?7l"); screen.process(b"\x1b(0"); screen.process(b"\x1b)0"); screen.process(b"\x0e"); screen.process(b"\x1b[?1h"); screen.process(b"\x1b[?2004h"); screen.process(b"\x1b[?1003h"); screen.process(b"\x1b[?1006h"); screen.process(b"\x1b[?1004h"); screen.process(b"\x1b="); screen.process(b"\x1b[5 q"); screen.process(b"\x1b[?25l");
let rendered = reattach_render(&screen);
assert!(rendered.contains("\x1b[?7l"), "self-contained: DECAWM off");
assert!(
rendered.contains("\x1b(0"),
"self-contained: G0 line drawing"
);
assert!(
rendered.contains("\x1b)0"),
"self-contained: G1 line drawing"
);
assert!(
rendered.as_bytes().contains(&0x0E),
"self-contained: SO (activate G1)"
);
assert!(rendered.contains("\x1b[?1h"), "self-contained: DECCKM");
assert!(
rendered.contains("\x1b[?2004h"),
"self-contained: bracketed paste"
);
assert!(
rendered.contains("\x1b[?1003h"),
"self-contained: mouse mode"
);
assert!(
rendered.contains("\x1b[?1006h"),
"self-contained: SGR encoding"
);
assert!(
rendered.contains("\x1b[?1004h"),
"self-contained: focus reporting"
);
assert!(
rendered.contains("\x1b="),
"self-contained: keypad app mode"
);
assert!(
rendered.contains("\x1b[5 q"),
"self-contained: cursor shape"
);
assert!(
!rendered.contains("\x1b[?25h"),
"self-contained: cursor hidden"
);
}
#[test]
fn alt_screen_skips_history_on_reattach() {
let mut screen = Screen::new(20, 5, 100);
for i in 0..10 {
screen.process(format!("line{}\r\n", i).as_bytes());
}
assert!(!screen.get_history().is_empty(), "should have scrollback");
screen.process(b"\x1b[?1049h");
screen.process(b"Alt content");
assert!(screen.in_alt_screen());
assert!(
!screen.get_history().is_empty(),
"scrollback should persist internally in alt screen"
);
assert!(
screen.in_alt_screen(),
"in_alt_screen() must be true so send_initial_state skips history"
);
}
#[test]
fn history_available_after_alt_screen_exit() {
let mut screen = Screen::new(20, 5, 100);
for i in 0..10 {
screen.process(format!("line{}\r\n", i).as_bytes());
}
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[?1049l");
assert!(!screen.in_alt_screen());
assert!(
!screen.get_history().is_empty(),
"scrollback should be available after exiting alt screen"
);
}
#[test]
fn no_flush_newlines_without_history() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"\x1b[?1049h"); screen.process(b"Alt content");
let mut cache = AnsiRenderer::new();
let render = cache.render(&screen, true);
let leading_newlines = render.iter().take_while(|&&b| b == b'\n').count();
assert_eq!(
leading_newlines, 0,
"no flush newlines should be emitted when history is empty (alt screen)"
);
}
fn extract_decstbm(rendered: &str) -> Option<(u16, u16)> {
let bytes = rendered.as_bytes();
let mut i = 0;
while i + 2 < bytes.len() {
if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
let start = i + 2;
let mut j = start;
while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b';') {
j += 1;
}
if j < bytes.len() && bytes[j] == b'r' {
let params = &rendered[start..j];
let parts: Vec<&str> = params.split(';').collect();
if parts.len() == 2 {
if let (Ok(t), Ok(b)) = (parts[0].parse::<u16>(), parts[1].parse::<u16>()) {
return Some((t, b));
}
}
}
}
i += 1;
}
None
}
#[test]
fn reattach_restores_custom_scroll_region() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[2;23r");
assert_eq!(screen.grid.scroll_top(), 1); assert_eq!(screen.grid.scroll_bottom(), 22);
let rendered = reattach_render(&screen);
let decstbm = extract_decstbm(&rendered);
assert_eq!(
decstbm,
Some((2, 23)),
"reattach: DECSTBM should restore scroll region 2;23"
);
}
#[test]
fn reattach_emits_full_screen_scroll_region() {
let screen = Screen::new(80, 24, 100);
let rendered = reattach_render(&screen);
let decstbm = extract_decstbm(&rendered);
assert_eq!(
decstbm,
Some((1, 24)),
"reattach: DECSTBM should emit full-screen scroll region"
);
}
#[test]
fn reattach_scroll_region_before_cursor_position() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[2;23r");
screen.process(b"\x1b[10;5H");
let rendered = reattach_render(&screen);
let decstbm_pos = rendered
.find(";23r")
.expect("DECSTBM should be present in render output");
let after_decstbm = &rendered[decstbm_pos..];
assert!(
after_decstbm.contains("\x1b[10;5H"),
"cursor position must appear AFTER DECSTBM (which resets cursor)"
);
}
#[test]
fn reattach_htop_scroll_region_layout() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[4;22r");
screen.process(b"\x1b[1;1HCPU [|||||||| 50%]");
screen.process(b"\x1b[24;1HF1Help F2Setup F10Quit");
screen.process(b"\x1b[10;1H");
let rendered = reattach_render(&screen);
let decstbm = extract_decstbm(&rendered);
assert_eq!(
decstbm,
Some((4, 22)),
"reattach: htop scroll region (4;22) must be restored"
);
assert!(
rendered.contains("CPU"),
"header content should be preserved"
);
assert!(
rendered.contains("F1Help"),
"footer content should be preserved"
);
}
#[test]
fn mode_delta_detects_scroll_region_change() {
let mut screen = Screen::new(80, 24, 100);
let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
screen.process(b"\x1b[5;20r");
let result = cache.render(&screen, false);
let text = String::from_utf8_lossy(&result);
let decstbm = extract_decstbm(&text);
assert_eq!(
decstbm,
Some((5, 20)),
"incremental render should detect scroll region change"
);
}
#[test]
fn mode_delta_skips_unchanged_scroll_region() {
let screen = Screen::new(80, 24, 100);
let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
let result = cache.render(&screen, false);
let text = String::from_utf8_lossy(&result);
let decstbm = extract_decstbm(&text);
assert!(
decstbm.is_none(),
"unchanged scroll region should not emit DECSTBM on incremental render"
);
}
#[test]
fn data_loss_corrupts_scroll_region() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[4;22r");
screen.process(b"\x1b[1;1HCPU [||||]");
screen.process(b"\x1b[24;1HF1Help");
let mut screen_with_loss = Screen::new(80, 24, 100);
screen_with_loss.process(b"\x1b[?1049h");
screen_with_loss.process(b"\x1b[1;1HCPU [||||]");
screen_with_loss.process(b"\x1b[24;1HF1Help");
assert_eq!(
screen_with_loss.grid.scroll_top(),
0,
"lost DECSTBM leaves scroll region at default"
);
assert_eq!(
screen_with_loss.grid.scroll_bottom(),
23,
"lost DECSTBM leaves scroll region at default"
);
screen_with_loss.process(b"\x1b[22;1H"); screen_with_loss.process(b"\n");
assert_ne!(
screen.grid.scroll_top(),
screen_with_loss.grid.scroll_top(),
"data loss should cause scroll region mismatch"
);
}
#[test]
fn full_data_processing_keeps_grid_in_sync() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[4;22r");
screen.process(b"\x1b[1;1HCPU [|||||||| 50%]");
screen.process(b"\x1b[24;1HF1Help F2Setup F10Quit");
for i in 4..=22 {
screen.process(format!("\x1b[{};1Hprocess_{:02}", i, i).as_bytes());
}
assert_eq!(screen.grid.scroll_top(), 3); assert_eq!(screen.grid.scroll_bottom(), 21); assert_eq!(screen.grid.visible_row(0)[0].c, 'C'); assert_eq!(screen.grid.visible_row(23)[0].c, 'F');
screen.process(b"\x1b[22;1H\n"); assert_eq!(
screen.grid.visible_row(0)[0].c,
'C',
"header must survive scroll"
);
assert_eq!(
screen.grid.visible_row(23)[0].c,
'F',
"footer must survive scroll"
);
assert_eq!(
screen.grid.visible_row(3)[0].c,
'p',
"row 4 content shifted to row 3"
);
}
#[test]
fn notifications_survive_passthrough_drain() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;Title;Body\x1b\\");
screen.process(b"\x07"); screen.process(b"\x1b]9;Hello\x1b\\");
let passthrough = screen.take_passthrough();
assert_eq!(passthrough.len(), 1, "only BEL should be in passthrough");
assert_eq!(passthrough[0], b"\x07");
let notifications = screen.take_queued_notifications();
assert_eq!(notifications.len(), 2);
assert!(String::from_utf8_lossy(¬ifications[0]).contains("777"));
assert!(String::from_utf8_lossy(¬ifications[1]).contains("9"));
}
#[test]
fn notifications_replayed_on_simulated_reconnect() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"hello");
screen.process(b"\x1b]777;notify;Title;Body\x1b\\");
screen.process(b"\x1b]9;Alert\x07");
let _ = screen.take_passthrough();
let _ = screen.take_pending_scrollback();
let notifications = screen.take_queued_notifications();
assert_eq!(notifications.len(), 2);
let mut render_data = Vec::new();
for notif in ¬ifications {
render_data.extend_from_slice(notif);
}
let mut cache = AnsiRenderer::new();
render_data.extend_from_slice(&cache.render(&screen, true));
let output = String::from_utf8_lossy(&render_data);
assert!(
output.starts_with("\x1b]777;"),
"notifications should be prepended to render data"
);
assert!(output.contains("hello"));
}
#[test]
fn consumed_notifications_not_replayed_on_second_reconnect() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"hello");
screen.process(b"\x1b]777;notify;Build;Done\x1b\\");
screen.process(b"\x1b]9;Alert!\x07");
screen.process(b"\x1b]99;kitty notif\x07");
let _ = screen.take_passthrough();
let _ = screen.take_pending_scrollback();
let notifications = screen.take_queued_notifications();
assert_eq!(
notifications.len(),
3,
"first reconnect should get all 3 notifications"
);
let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
let _ = screen.take_pending_scrollback();
let _ = screen.take_passthrough();
let notifications2 = screen.take_queued_notifications();
assert!(
notifications2.is_empty(),
"consumed notifications must not reappear: got {} items",
notifications2.len()
);
let mut cache2 = AnsiRenderer::new();
let render2 = cache2.render(&screen, true);
let output2 = String::from_utf8_lossy(&render2);
assert!(
output2.contains("hello"),
"screen content must survive multiple reconnects"
);
}
#[test]
fn only_new_notifications_on_subsequent_reconnect() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;Title;msgA\x1b\\");
let notifs1 = screen.take_queued_notifications();
assert_eq!(notifs1.len(), 1);
assert!(String::from_utf8_lossy(¬ifs1[0]).contains("msgA"));
let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
let _ = screen.take_pending_scrollback();
let _ = screen.take_passthrough();
screen.process(b"\x1b]777;notify;Title;msgB\x1b\\");
let _ = screen.take_passthrough();
let notifs2 = screen.take_queued_notifications();
assert_eq!(notifs2.len(), 1, "should have exactly 1 new notification");
let content = String::from_utf8_lossy(¬ifs2[0]);
assert!(content.contains("msgB"), "should be msgB, got: {}", content);
assert!(!content.contains("msgA"), "msgA must not reappear");
}
#[test]
fn notifications_during_active_session_consumed_by_relay() {
let mut screen = Screen::new(80, 24, 100);
let _ = screen.take_queued_notifications();
let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
let _ = screen.take_pending_scrollback();
let _ = screen.take_passthrough();
screen.process(b"\x1b]777;notify;Title;active_notif\x1b\\");
screen.process(b"\x1b]9;beep\x07");
let (_, passthrough) = cache.take_and_render(&mut screen);
assert!(
passthrough
.iter()
.any(|p| String::from_utf8_lossy(p).contains("777")),
"notification should be delivered to active client via passthrough"
);
assert!(
passthrough
.iter()
.any(|p| String::from_utf8_lossy(p).contains("9;")),
"OSC 9 should be delivered to active client via passthrough"
);
let notifs = screen.take_queued_notifications();
assert!(
notifs.is_empty(),
"notifications consumed by relay must not reappear, got {} items",
notifs.len()
);
}
#[test]
fn multiple_reconnects_no_stale_notifications() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;Title;once\x1b\\");
let n1 = screen.take_queued_notifications();
assert_eq!(n1.len(), 1);
let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
let _ = screen.take_pending_scrollback();
let _ = screen.take_passthrough();
for i in 0..5 {
let notifs = screen.take_queued_notifications();
assert!(
notifs.is_empty(),
"reconnect #{} should have no notifications, got {}",
i + 2,
notifs.len()
);
let mut c = AnsiRenderer::new();
let _ = c.render(&screen, true);
let _ = screen.take_pending_scrollback();
let _ = screen.take_passthrough();
}
}