use super::*;
#[test]
fn alt_screen_save_restore() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Hello");
assert_eq!(screen.grid.visible_row(0)[0].c, 'H');
assert_eq!(screen.grid.visible_row(0)[4].c, 'o');
screen.process(b"\x1b[?1049h");
assert!(screen.in_alt_screen());
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
screen.process(b"Alt");
assert_eq!(screen.grid.visible_row(0)[0].c, 'A');
screen.process(b"\x1b[?1049l");
assert!(!screen.in_alt_screen());
assert_eq!(screen.grid.visible_row(0)[0].c, 'H');
assert_eq!(screen.grid.visible_row(0)[4].c, 'o');
}
#[test]
fn scrollback_on_scroll() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Line1\r\nLine2\r\nLine3\r\nLine4");
let scrollback_rows = screen.take_pending_scrollback();
let scrollback = AnsiRenderer::new().render_rows(&screen, &scrollback_rows);
assert!(!scrollback.is_empty());
let first = String::from_utf8_lossy(&scrollback[0]);
assert!(
first.contains("Line1"),
"scrollback should contain Line1, got: {}",
first
);
}
#[test]
fn no_scrollback_in_alt_screen() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"\x1b[?1049h"); screen.process(b"A\r\nB\r\nC\r\nD"); let scrollback_rows = screen.take_pending_scrollback();
let scrollback = AnsiRenderer::new().render_rows(&screen, &scrollback_rows);
assert!(
scrollback.is_empty(),
"alt screen should not generate scrollback"
);
}
#[test]
fn history_preserved_across_sessions() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let _ = screen.take_pending_scrollback();
let history = screen.get_history();
assert!(!history.is_empty());
}
#[test]
fn deferred_wrap_cr_stays_on_same_line() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"% ");
assert!(screen.grid.wrap_pending());
assert_eq!(screen.grid.cursor_y(), 0);
screen.process(b"\r");
assert!(!screen.grid.wrap_pending());
assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(screen.grid.cursor_y(), 0);
screen.process(b" ");
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
}
#[test]
fn deferred_wrap_next_print_wraps() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCDE");
assert!(screen.grid.wrap_pending());
assert_eq!(screen.grid.cursor_y(), 0);
screen.process(b"F");
assert_eq!(screen.grid.cursor_y(), 1);
assert_eq!(screen.grid.visible_row(1)[0].c, 'F');
}
#[test]
fn dsr_cursor_position_report() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;10H"); screen.process(b"\x1b[6n"); let responses = screen.take_responses();
assert_eq!(responses.len(), 1);
assert_eq!(responses[0], b"\x1b[5;10R");
}
#[test]
fn da1_primary_device_attributes() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[c");
let responses = screen.take_responses();
assert_eq!(responses.len(), 1);
assert_eq!(responses[0], b"\x1b[?62;22c");
}
#[test]
fn da2_secondary_device_attributes() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[>c");
let responses = screen.take_responses();
assert_eq!(responses.len(), 1);
assert_eq!(responses[0], b"\x1b[>0;10;1c");
}
#[test]
fn dec_line_drawing_charset() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b(0"); screen.process(b"lqk"); assert_eq!(screen.grid.visible_row(0)[0].c, '\u{250C}'); assert_eq!(screen.grid.visible_row(0)[1].c, '\u{2500}'); assert_eq!(screen.grid.visible_row(0)[2].c, '\u{2510}'); screen.process(b"\x1b(B");
screen.process(b"l");
assert_eq!(screen.grid.visible_row(0)[3].c, 'l'); }
#[test]
fn rep_repeats_last_char() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"A\x1b[3b"); assert_eq!(screen.grid.visible_row(0)[0].c, 'A');
assert_eq!(screen.grid.visible_row(0)[1].c, 'A');
assert_eq!(screen.grid.visible_row(0)[2].c, 'A');
assert_eq!(screen.grid.visible_row(0)[3].c, 'A');
}
#[test]
fn wide_character_occupies_two_cells() {
let mut screen = Screen::new(80, 24, 100);
screen.process("你".as_bytes());
assert_eq!(screen.grid.visible_row(0)[0].c, '你');
assert_eq!(screen.grid.visible_row(0)[0].width, 2);
assert_eq!(screen.grid.visible_row(0)[1].width, 0);
assert_eq!(screen.grid.cursor_x(), 2);
}
#[test]
fn wide_char_wraps_at_end_of_line() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCD"); screen.process("你".as_bytes()); assert_eq!(screen.grid.visible_row(0)[4].c, ' ');
assert_eq!(screen.grid.visible_row(1)[0].c, '你');
assert_eq!(screen.grid.visible_row(1)[0].width, 2);
assert_eq!(screen.grid.visible_row(1)[1].width, 0);
}
#[test]
fn esc_c_full_reset() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?2004h"); screen.process(b"\x1b[5;10H"); screen.process(b"Hello");
screen.process(b"\x1b[2 q"); screen.process(b"\x1bc"); assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(screen.grid.cursor_y(), 0);
assert!(!screen.grid.modes().bracketed_paste);
assert_eq!(screen.grid.modes().cursor_shape, grid::CursorShape::Default);
assert!(screen.grid.cursor_visible());
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert!(screen.title().is_empty());
}
#[test]
fn osc_sets_title() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]0;My Terminal\x07");
assert_eq!(screen.title(), "My Terminal");
screen.process(b"\x1b]2;New Title\x07");
assert_eq!(screen.title(), "New Title");
}
#[test]
fn osc_passthrough_non_title() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]52;c;SGVsbG8=\x07");
let pt = screen.take_passthrough();
assert_eq!(pt.len(), 1, "should have one passthrough sequence");
assert_eq!(pt[0], b"\x1b]52;c;SGVsbG8=\x07");
assert_eq!(screen.title(), "");
}
#[test]
fn osc_title_not_passedthrough() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]0;My Title\x07");
let pt = screen.take_passthrough();
assert!(pt.is_empty(), "OSC 0 should not be passedthrough");
assert_eq!(screen.title(), "My Title");
}
#[test]
fn bell_forwarded_as_passthrough() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x07");
let pt = screen.take_passthrough();
assert_eq!(pt.len(), 1, "standalone BEL should produce one passthrough");
assert_eq!(pt[0], b"\x07");
}
#[test]
fn bell_does_not_affect_screen_state() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"Hello");
let (cx, cy) = (screen.grid.cursor_x(), screen.grid.cursor_y());
screen.process(b"\x07");
assert_eq!(screen.grid.cursor_x(), cx, "BEL should not move cursor x");
assert_eq!(screen.grid.cursor_y(), cy, "BEL should not move cursor y");
assert_eq!(
screen.grid.visible_row(0)[0].c,
'H',
"BEL should not alter cell content"
);
}
#[test]
fn bell_drained_after_take() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x07");
let pt1 = screen.take_passthrough();
assert_eq!(pt1.len(), 1);
let pt2 = screen.take_passthrough();
assert!(
pt2.is_empty(),
"BEL should not persist after take_passthrough()"
);
}
#[test]
fn bell_not_resent_on_render() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x07");
let _ = screen.take_passthrough(); let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
let pt = screen.take_passthrough();
assert!(pt.is_empty(), "BEL must not be re-sent on full redraw");
}
#[test]
fn bell_not_resent_on_incremental_render() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x07");
let _ = screen.take_passthrough(); let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, false);
let pt = screen.take_passthrough();
assert!(
pt.is_empty(),
"BEL must not be re-sent on incremental render"
);
}
#[test]
fn bell_not_resent_on_resize() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x07");
let _ = screen.take_passthrough(); screen.resize(120, 40);
let pt = screen.take_passthrough();
assert!(pt.is_empty(), "BEL must not be re-sent after resize");
}
#[test]
fn osc_777_drained_after_take() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;title;body\x07");
let pt = screen.take_passthrough();
assert!(pt.is_empty(), "OSC 777 should not be in passthrough");
let n1 = screen.take_queued_notifications();
assert_eq!(n1.len(), 1);
let n2 = screen.take_queued_notifications();
assert!(
n2.is_empty(),
"OSC 777 should not persist after take_queued_notifications()"
);
}
#[test]
fn osc_777_not_resent_on_render() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;title;body\x07");
let _ = screen.take_queued_notifications(); let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
let n = screen.take_queued_notifications();
assert!(n.is_empty(), "OSC 777 must not be re-sent on full redraw");
}
#[test]
fn osc_777_not_resent_on_resize() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;title;body\x07");
let _ = screen.take_queued_notifications(); screen.resize(120, 40);
let n = screen.take_queued_notifications();
assert!(n.is_empty(), "OSC 777 must not be re-sent after resize");
}
#[test]
fn osc_777_not_resent_on_resize_then_render() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;title;body\x07");
let _ = screen.take_queued_notifications(); screen.resize(40, 10);
let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
let n = screen.take_queued_notifications();
assert!(
n.is_empty(),
"OSC 777 must not re-appear after resize + full redraw"
);
}
#[test]
fn multiple_bells_all_forwarded() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x07\x07\x07");
let pt = screen.take_passthrough();
assert_eq!(
pt.len(),
3,
"three BELs should produce three passthrough entries"
);
for entry in &pt {
assert_eq!(entry, &vec![0x07u8]);
}
}
#[test]
fn bell_and_osc_777_interleaved() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x07\x1b]777;notify;t;b\x07\x07");
let pt = screen.take_passthrough();
assert_eq!(pt.len(), 2, "BEL + BEL = 2 passthrough entries");
assert_eq!(pt[0], b"\x07", "first should be standalone BEL");
assert_eq!(pt[1], b"\x07", "second should be standalone BEL");
let notifs = screen.take_queued_notifications();
assert_eq!(notifs.len(), 1);
assert_eq!(
notifs[0], b"\x1b]777;notify;t;b\x07",
"OSC 777 should be in notification queue"
);
}
#[test]
fn bell_in_osc_is_terminator_not_separate_bell() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;title;body\x07");
let pt = screen.take_passthrough();
assert!(pt.is_empty(), "OSC 777 should not be in passthrough");
let notifs = screen.take_queued_notifications();
assert_eq!(
notifs.len(),
1,
"OSC terminated by BEL should produce exactly 1 notification"
);
assert!(
notifs[0].starts_with(b"\x1b]"),
"notification should be the OSC sequence"
);
}
#[test]
fn bell_not_resent_on_render_with_scrollback() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A\r\nB\r\nC\r\nD");
let scrollback_rows = screen.take_pending_scrollback();
let scrollback = AnsiRenderer::new().render_rows(&screen, &scrollback_rows);
screen.process(b"\x07");
let _ = screen.take_passthrough(); let mut cache = AnsiRenderer::new();
let _ = cache.render_with_scrollback(&screen, &scrollback);
let pt = screen.take_passthrough();
assert!(
pt.is_empty(),
"BEL must not re-appear after render_with_scrollback"
);
}
#[test]
fn osc_777_not_resent_on_render_with_scrollback() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A\r\nB\r\nC\r\nD");
let scrollback_rows = screen.take_pending_scrollback();
let scrollback = AnsiRenderer::new().render_rows(&screen, &scrollback_rows);
screen.process(b"\x1b]777;notify;title;body\x07");
let _ = screen.take_queued_notifications(); let mut cache = AnsiRenderer::new();
let _ = cache.render_with_scrollback(&screen, &scrollback);
let n = screen.take_queued_notifications();
assert!(
n.is_empty(),
"OSC 777 must not re-appear after render_with_scrollback"
);
}
#[test]
fn ed3_clears_scrollback_and_forwards_passthrough() {
let mut screen = Screen::new(80, 24, 100);
for i in 0..30 {
screen.process(format!("Line{}\r\n", i).as_bytes());
}
assert!(screen.grid.scrollback_len() > 0, "should have scrollback");
let _ = screen.take_passthrough();
screen.process(b"\x1b[3J");
assert_eq!(
screen.grid.scrollback_len(),
0,
"scrollback should be cleared"
);
let pt = screen.take_passthrough();
assert_eq!(
pt.len(),
1,
"ED mode 3 should produce one passthrough entry"
);
assert_eq!(pt[0], b"\x1b[3J", "passthrough should be \\e[3J");
}
#[test]
fn ed3_passthrough_even_without_scrollback() {
let mut screen = Screen::new(80, 24, 0); screen.process(b"\x1b[3J");
let pt = screen.take_passthrough();
assert_eq!(
pt.len(),
1,
"ED mode 3 should forward even with empty scrollback"
);
assert_eq!(pt[0], b"\x1b[3J");
}
#[test]
fn ed3_passthrough_drained_after_take() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[3J");
let pt1 = screen.take_passthrough();
assert_eq!(pt1.len(), 1);
let pt2 = screen.take_passthrough();
assert!(
pt2.is_empty(),
"ED mode 3 should not persist after take_passthrough()"
);
}
#[test]
fn ed3_passthrough_not_resent_on_render() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[3J");
let _ = screen.take_passthrough(); let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
let pt = screen.take_passthrough();
assert!(
pt.is_empty(),
"ED mode 3 must not be re-sent on full redraw"
);
}
#[test]
fn ed2_does_not_produce_passthrough() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[2J");
let pt = screen.take_passthrough();
assert!(pt.is_empty(), "ED mode 2 should not produce passthrough");
}
#[test]
fn mode_flags_bracketed_paste() {
let mut screen = Screen::new(80, 24, 100);
assert!(!screen.grid.modes().bracketed_paste);
screen.process(b"\x1b[?2004h");
assert!(screen.grid.modes().bracketed_paste);
screen.process(b"\x1b[?2004l");
assert!(!screen.grid.modes().bracketed_paste);
}
#[test]
fn mode_flags_cursor_key_mode() {
let mut screen = Screen::new(80, 24, 100);
assert!(!screen.grid.modes().cursor_key_mode);
screen.process(b"\x1b[?1h");
assert!(screen.grid.modes().cursor_key_mode);
screen.process(b"\x1b[?1l");
assert!(!screen.grid.modes().cursor_key_mode);
}
#[test]
fn mode_flags_mouse() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1000h");
assert!(screen.grid.modes().mouse_modes.click);
assert_eq!(
screen.grid.modes().mouse_modes.effective(),
super::grid::MouseMode::Click
);
screen.process(b"\x1b[?1006h");
assert_eq!(
screen.grid.modes().mouse_encoding,
super::grid::MouseEncoding::Sgr
);
screen.process(b"\x1b[?1000l");
assert!(!screen.grid.modes().mouse_modes.click);
}
#[test]
fn keypad_app_mode() {
let mut screen = Screen::new(80, 24, 100);
assert!(!screen.grid.modes().keypad_app_mode);
screen.process(b"\x1b=");
assert!(screen.grid.modes().keypad_app_mode);
screen.process(b"\x1b>");
assert!(!screen.grid.modes().keypad_app_mode);
}
#[test]
fn cursor_shape_decscusr() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[2 q"); assert_eq!(
screen.grid.modes().cursor_shape,
grid::CursorShape::SteadyBlock
);
screen.process(b"\x1b[5 q"); assert_eq!(
screen.grid.modes().cursor_shape,
grid::CursorShape::BlinkBar
);
screen.process(b"\x1b[0 q"); assert_eq!(screen.grid.modes().cursor_shape, grid::CursorShape::Default);
}
#[test]
fn autowrap_mode_disable_prevents_wrap() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"\x1b[?7l"); screen.process(b"ABCDEF"); assert_eq!(screen.grid.cursor_y(), 0);
assert_eq!(screen.grid.visible_row(0)[4].c, 'F');
assert!(!screen.grid.wrap_pending());
}
#[test]
fn sgr_hidden_attribute() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[8m"); screen.process(b"secret");
assert!(screen.cell_style(0, 0).hidden);
screen.process(b"\x1b[28m"); screen.process(b"visible");
assert!(!screen.cell_style(0, 6).hidden);
}
#[test]
fn cursor_save_restore() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;10H"); screen.process(b"\x1b7"); screen.process(b"\x1b[1;1H"); assert_eq!(screen.grid.cursor_y(), 0);
screen.process(b"\x1b8"); assert_eq!(screen.grid.cursor_y(), 4);
assert_eq!(screen.grid.cursor_x(), 9);
}
#[test]
fn so_si_charset_switching() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b)0"); screen.process(b"\x0E"); screen.process(b"q"); assert_eq!(screen.grid.visible_row(0)[0].c, '\u{2500}');
screen.process(b"\x0F"); screen.process(b"q");
assert_eq!(screen.grid.visible_row(0)[1].c, 'q');
}
#[test]
fn cuu_cud_respects_scroll_region() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;15r");
screen.process(b"\x1b[10;1H"); screen.process(b"\x1b[20A"); assert_eq!(screen.grid.cursor_y(), 4); screen.process(b"\x1b[20B"); assert_eq!(screen.grid.cursor_y(), 14); }
#[test]
fn vt_ff_treated_as_lf() {
let mut screen = Screen::new(80, 3, 100);
screen.process(b"A");
screen.process(&[0x0B]); assert_eq!(screen.grid.cursor_y(), 1);
screen.process(&[0x0C]); assert_eq!(screen.grid.cursor_y(), 2);
}
#[test]
fn dl_large_count_clamped() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[2;1H"); screen.process(b"\x1b[99999M"); assert_eq!(screen.grid.visible_row_count(), 5);
}
#[test]
fn il_large_count_clamped() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[2;1H");
screen.process(b"\x1b[99999L"); assert_eq!(screen.grid.visible_row_count(), 5);
}
#[test]
fn alt_screen_mode_47_no_cursor_save() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[3;4H");
assert_eq!(screen.grid.cursor_y(), 2);
assert_eq!(screen.grid.cursor_x(), 3);
screen.process(b"\x1b7");
screen.process(b"\x1b[?47h");
screen.process(b"\x1b[1;1H");
assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(screen.grid.cursor_y(), 0);
screen.process(b"\x1b[?47l");
assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(screen.grid.cursor_y(), 0);
screen.process(b"\x1b8");
assert_eq!(screen.grid.cursor_x(), 3);
assert_eq!(screen.grid.cursor_y(), 2);
}
#[test]
fn mode_1048_save_restore_cursor() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;10H"); screen.process(b"\x1b[?1048h"); screen.process(b"\x1b[1;1H"); screen.process(b"\x1b[?1048l"); assert_eq!(screen.grid.cursor_y(), 4);
assert_eq!(screen.grid.cursor_x(), 9);
}
#[test]
fn dch_through_wide_char_no_orphan() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A");
screen.process("你".as_bytes());
screen.process(b"B");
screen.process(b"\x1b[1;2H"); screen.process(b"\x1b[P"); assert_ne!(
screen.grid.visible_row(0)[1].width,
0,
"orphaned continuation cell after DCH"
);
}
#[test]
fn ich_pushes_wide_char_off_right_edge() {
let mut screen = Screen::new(6, 3, 100);
screen.process(b"\x1b[1;5H"); screen.process("你".as_bytes());
assert_eq!(screen.grid.visible_row(0)[4].c, '你');
assert_eq!(screen.grid.visible_row(0)[4].width, 2);
assert_eq!(screen.grid.visible_row(0)[5].width, 0);
screen.process(b"\x1b[1;1H");
screen.process(b"\x1b[@"); assert_ne!(
screen.grid.visible_row(0)[5].width,
2,
"orphaned wide char at right edge after ICH"
);
}
#[test]
fn scrollback_captured_with_partial_scroll_region() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[1;3r");
screen.process(b"\x1b[1;1H");
screen.process(b"Line1\r\n");
screen.process(b"Line2\r\n");
screen.process(b"Line3\r\n"); let scrollback_rows = screen.take_pending_scrollback();
let scrollback = AnsiRenderer::new().render_rows(&screen, &scrollback_rows);
assert!(
!scrollback.is_empty(),
"scrollback should be captured even with partial scroll region (scroll_top==0)"
);
}
#[test]
fn csi_s_u_save_restore_cursor() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;10H"); screen.process(b"\x1b[s"); screen.process(b"\x1b[1;1H"); assert_eq!(screen.grid.cursor_y(), 0);
assert_eq!(screen.grid.cursor_x(), 0);
screen.process(b"\x1b[u"); assert_eq!(screen.grid.cursor_y(), 4); assert_eq!(screen.grid.cursor_x(), 9); }
#[test]
fn cursor_movement_cuf_cub() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5C"); assert_eq!(screen.grid.cursor_x(), 5);
screen.process(b"\x1b[2D"); assert_eq!(screen.grid.cursor_x(), 3);
screen.process(b"\x1b[100D");
assert_eq!(screen.grid.cursor_x(), 0);
screen.process(b"\x1b[200C");
assert_eq!(screen.grid.cursor_x(), 79);
}
#[test]
fn cursor_movement_cnl_cpl() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[10;15H"); assert_eq!(screen.grid.cursor_y(), 9);
assert_eq!(screen.grid.cursor_x(), 14);
screen.process(b"\x1b[3E");
assert_eq!(screen.grid.cursor_y(), 12);
assert_eq!(screen.grid.cursor_x(), 0);
screen.process(b"\x1b[5;20H"); screen.process(b"\x1b[2F");
assert_eq!(screen.grid.cursor_y(), 2); assert_eq!(screen.grid.cursor_x(), 0);
screen.process(b"\x1b[100E");
assert_eq!(screen.grid.cursor_y(), 23);
assert_eq!(screen.grid.cursor_x(), 0);
screen.process(b"\x1b[100F");
assert_eq!(screen.grid.cursor_y(), 0);
assert_eq!(screen.grid.cursor_x(), 0);
}
#[test]
fn cursor_horizontal_absolute() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[20G");
assert_eq!(screen.grid.cursor_x(), 19); screen.process(b"\x1b[1G");
assert_eq!(screen.grid.cursor_x(), 0);
screen.process(b"\x1b[200G");
assert_eq!(screen.grid.cursor_x(), 79);
screen.process(b"\x1b[G");
assert_eq!(screen.grid.cursor_x(), 0);
}
#[test]
fn cursor_position_cup() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[12;40H");
assert_eq!(screen.grid.cursor_y(), 11); assert_eq!(screen.grid.cursor_x(), 39); screen.process(b"\x1b[H");
assert_eq!(screen.grid.cursor_y(), 0);
assert_eq!(screen.grid.cursor_x(), 0);
screen.process(b"\x1b[100;200H");
assert_eq!(screen.grid.cursor_y(), 23);
assert_eq!(screen.grid.cursor_x(), 79);
}
#[test]
fn vpa_line_position_absolute() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;10H"); screen.process(b"\x1b[15d");
assert_eq!(screen.grid.cursor_y(), 14); assert_eq!(screen.grid.cursor_x(), 9); screen.process(b"\x1b[100d");
assert_eq!(screen.grid.cursor_y(), 23);
screen.process(b"\x1b[d");
assert_eq!(screen.grid.cursor_y(), 0);
}
#[test]
fn erase_in_display_j0() {
let mut screen = Screen::new(10, 5, 100);
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(b"XXXXXXXXXX");
}
screen.process(b"\x1b[3;5H");
screen.process(b"\x1b[0J");
assert_eq!(screen.grid.visible_row(2)[0].c, 'X');
assert_eq!(screen.grid.visible_row(2)[3].c, 'X');
assert_eq!(screen.grid.visible_row(2)[4].c, ' ');
assert_eq!(screen.grid.visible_row(2)[9].c, ' ');
assert_eq!(screen.grid.visible_row(3)[0].c, ' ');
assert_eq!(screen.grid.visible_row(4)[5].c, ' ');
assert_eq!(screen.grid.visible_row(0)[0].c, 'X');
assert_eq!(screen.grid.visible_row(1)[9].c, 'X');
}
#[test]
fn erase_in_display_j1() {
let mut screen = Screen::new(10, 5, 100);
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(b"XXXXXXXXXX");
}
screen.process(b"\x1b[3;5H");
screen.process(b"\x1b[1J");
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(1)[9].c, ' ');
assert_eq!(screen.grid.visible_row(2)[0].c, ' ');
assert_eq!(screen.grid.visible_row(2)[4].c, ' ');
assert_eq!(screen.grid.visible_row(2)[5].c, 'X');
assert_eq!(screen.grid.visible_row(2)[9].c, 'X');
assert_eq!(screen.grid.visible_row(3)[0].c, 'X');
assert_eq!(screen.grid.visible_row(4)[5].c, 'X');
}
#[test]
fn erase_in_display_j2() {
let mut screen = Screen::new(10, 5, 100);
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(b"XXXXXXXXXX");
}
screen.process(b"\x1b[3;5H");
screen.process(b"\x1b[2J");
for row in 0..5 {
for col in 0..10 {
assert_eq!(
screen.grid.visible_row(row)[col].c,
' ',
"cell [{row}][{col}] should be blank after CSI 2J"
);
}
}
}
#[test]
fn erase_in_line_k0() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ"); screen.process(b"\x1b[1;4H"); screen.process(b"\x1b[0K");
assert_eq!(screen.grid.visible_row(0)[0].c, 'A');
assert_eq!(screen.grid.visible_row(0)[2].c, 'C');
assert_eq!(screen.grid.visible_row(0)[3].c, ' '); assert_eq!(screen.grid.visible_row(0)[9].c, ' '); }
#[test]
fn erase_in_line_k1() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ"); screen.process(b"\x1b[1;4H"); screen.process(b"\x1b[1K");
assert_eq!(screen.grid.visible_row(0)[0].c, ' '); assert_eq!(screen.grid.visible_row(0)[3].c, ' '); assert_eq!(screen.grid.visible_row(0)[4].c, 'E'); assert_eq!(screen.grid.visible_row(0)[9].c, 'J'); }
#[test]
fn erase_in_line_k2() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ"); screen.process(b"\x1b[1;4H"); screen.process(b"\x1b[2K");
for col in 0..10 {
assert_eq!(
screen.grid.visible_row(0)[col].c,
' ',
"col {col} should be blank after CSI 2K"
);
}
}
#[test]
fn erase_character_ech() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ"); screen.process(b"\x1b[1;3H"); screen.process(b"\x1b[4X");
assert_eq!(screen.grid.visible_row(0)[0].c, 'A');
assert_eq!(screen.grid.visible_row(0)[1].c, 'B');
assert_eq!(screen.grid.visible_row(0)[2].c, ' '); assert_eq!(screen.grid.visible_row(0)[3].c, ' '); assert_eq!(screen.grid.visible_row(0)[4].c, ' '); assert_eq!(screen.grid.visible_row(0)[5].c, ' '); assert_eq!(screen.grid.visible_row(0)[6].c, 'G'); assert_eq!(screen.grid.visible_row(0)[9].c, 'J'); assert_eq!(screen.grid.cursor_x(), 2);
}
#[test]
fn delete_character_dch() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ"); screen.process(b"\x1b[1;3H"); screen.process(b"\x1b[2P");
assert_eq!(screen.grid.visible_row(0)[0].c, 'A');
assert_eq!(screen.grid.visible_row(0)[1].c, 'B');
assert_eq!(screen.grid.visible_row(0)[2].c, 'E');
assert_eq!(screen.grid.visible_row(0)[3].c, 'F');
assert_eq!(screen.grid.visible_row(0)[7].c, 'J');
assert_eq!(screen.grid.visible_row(0)[8].c, ' '); assert_eq!(screen.grid.visible_row(0)[9].c, ' '); }
#[test]
fn insert_character_ich() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ"); screen.process(b"\x1b[1;3H"); screen.process(b"\x1b[2@");
assert_eq!(screen.grid.visible_row(0)[0].c, 'A');
assert_eq!(screen.grid.visible_row(0)[1].c, 'B');
assert_eq!(screen.grid.visible_row(0)[2].c, ' '); assert_eq!(screen.grid.visible_row(0)[3].c, ' '); assert_eq!(screen.grid.visible_row(0)[4].c, 'C'); assert_eq!(screen.grid.visible_row(0)[5].c, 'D'); assert_eq!(screen.grid.visible_row(0)[9].c, 'H');
}
#[test]
fn scroll_up_su() {
let mut screen = Screen::new(10, 5, 100);
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("Row{}", row).as_bytes());
}
screen.process(b"\x1b[2S");
assert_eq!(screen.grid.visible_row(0)[0].c, 'R');
assert_eq!(screen.grid.visible_row(0)[3].c, '2');
assert_eq!(screen.grid.visible_row(3)[0].c, ' ');
assert_eq!(screen.grid.visible_row(4)[0].c, ' ');
}
#[test]
fn scroll_down_sd() {
let mut screen = Screen::new(10, 5, 100);
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("Row{}", row).as_bytes());
}
screen.process(b"\x1b[2T");
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(1)[0].c, ' ');
assert_eq!(screen.grid.visible_row(2)[0].c, 'R');
assert_eq!(screen.grid.visible_row(2)[3].c, '0');
}
#[test]
fn delete_lines_dl() {
let mut screen = Screen::new(10, 5, 100);
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("Line{}", row).as_bytes());
}
screen.process(b"\x1b[2;1H");
screen.process(b"\x1b[2M");
assert_eq!(screen.grid.visible_row(1)[4].c, '3');
assert_eq!(screen.grid.visible_row(2)[4].c, '4');
assert_eq!(screen.grid.visible_row(3)[0].c, ' ');
assert_eq!(screen.grid.visible_row(4)[0].c, ' ');
}
#[test]
fn insert_lines_il() {
let mut screen = Screen::new(10, 5, 100);
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("Line{}", row).as_bytes());
}
screen.process(b"\x1b[2;1H");
screen.process(b"\x1b[2L");
assert_eq!(screen.grid.visible_row(0)[4].c, '0');
assert_eq!(screen.grid.visible_row(1)[0].c, ' ');
assert_eq!(screen.grid.visible_row(2)[0].c, ' ');
assert_eq!(screen.grid.visible_row(3)[4].c, '1');
}
#[test]
fn decstbm_set_scroll_region() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[10;20H");
assert_eq!(screen.grid.cursor_y(), 9);
assert_eq!(screen.grid.cursor_x(), 19);
screen.process(b"\x1b[5;15r");
assert_eq!(screen.grid.scroll_top(), 4);
assert_eq!(screen.grid.scroll_bottom(), 14);
assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(screen.grid.cursor_y(), 0);
assert!(!screen.grid.wrap_pending());
}
#[test]
fn reverse_index_ri() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[2;1H");
screen.process(b"LineA");
screen.process(b"\x1b[3;1H");
screen.process(b"LineB");
screen.process(b"\x1b[4;1H");
screen.process(b"LineC");
screen.process(b"\x1b[2;1H");
assert_eq!(screen.grid.cursor_y(), 1);
screen.process(b"\x1bM");
assert_eq!(screen.grid.cursor_y(), 1);
assert_eq!(screen.grid.visible_row(1)[0].c, ' ');
assert_eq!(screen.grid.visible_row(2)[0].c, 'L');
assert_eq!(screen.grid.visible_row(2)[4].c, 'A');
}
#[test]
fn reverse_index_ri_not_at_top() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[3;1H"); screen.process(b"\x1bM"); assert_eq!(screen.grid.cursor_y(), 1); }
#[test]
fn focus_reporting_mode() {
let mut screen = Screen::new(80, 24, 100);
assert!(!screen.grid.modes().focus_reporting);
screen.process(b"\x1b[?1004h");
assert!(screen.grid.modes().focus_reporting);
screen.process(b"\x1b[?1004l");
assert!(!screen.grid.modes().focus_reporting);
}
#[test]
fn autowrap_mode_re_enable() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"\x1b[?7l");
assert!(!screen.grid.modes().autowrap_mode);
screen.process(b"ABCDEF");
assert_eq!(screen.grid.cursor_y(), 0);
assert_eq!(screen.grid.visible_row(0)[4].c, 'F');
screen.process(b"\x1b[?7h");
assert!(screen.grid.modes().autowrap_mode);
screen.process(b"\x1b[1;1H");
screen.process(b"12345");
assert!(screen.grid.wrap_pending());
screen.process(b"6");
assert_eq!(screen.grid.cursor_y(), 1);
assert_eq!(screen.grid.visible_row(1)[0].c, '6');
}
#[test]
fn bce_erase_uses_bg_color() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ"); screen.process(b"\x1b[41m");
screen.process(b"\x1b[1;4H");
screen.process(b"\x1b[0K");
let expected_bg = Some(style::Color::Indexed(1)); assert_eq!(
screen.cell_style(0, 3).bg,
expected_bg,
"erased cell at col 3 should have red background (BCE)"
);
assert_eq!(
screen.cell_style(0, 9).bg,
expected_bg,
"erased cell at col 9 should have red background (BCE)"
);
assert_eq!(
screen.cell_style(0, 0).bg,
None,
"cell at col 0 should have default background"
);
screen.process(b"\x1b[2J");
assert_eq!(
screen.cell_style(1, 5).bg,
expected_bg,
"CSI 2J erased cell should have red background (BCE)"
);
screen.process(b"\x1b[1;1H");
screen.process(b"XYZ");
screen.process(b"\x1b[1;1H");
screen.process(b"\x1b[2X"); assert_eq!(
screen.cell_style(0, 0).bg,
expected_bg,
"ECH erased cell should have red background (BCE)"
);
assert_eq!(
screen.cell_style(0, 1).bg,
expected_bg,
"ECH erased cell at col 1 should have red background (BCE)"
);
}
#[test]
fn tab_advances_to_next_tab_stop() {
let mut screen = Screen::new(80, 3, 100);
screen.process(b"AB"); screen.process(b"\t"); assert_eq!(screen.grid.cursor_x(), 8);
screen.process(b"\t"); assert_eq!(screen.grid.cursor_x(), 16);
}
#[test]
fn tab_at_end_of_line_clamps() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGH"); screen.process(b"\t"); assert_eq!(screen.grid.cursor_x(), 9);
}
#[test]
fn backspace_at_column_zero() {
let mut screen = Screen::new(80, 3, 100);
assert_eq!(screen.grid.cursor_x(), 0);
screen.process(b"\x08"); assert_eq!(screen.grid.cursor_x(), 0, "BS at column 0 should stay at 0");
}
#[test]
fn backspace_clears_wrap_pending() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCDE"); assert!(screen.grid.wrap_pending());
screen.process(b"\x08"); assert!(!screen.grid.wrap_pending(), "BS should clear wrap_pending");
assert_eq!(screen.grid.cursor_x(), 3);
}
#[test]
fn erase_scrollback_j3() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Line1\r\nLine2\r\nLine3\r\nLine4\r\nLine5");
let history = screen.get_history();
assert!(!history.is_empty(), "should have scrollback before J3");
screen.process(b"\x1b[3J");
let history_after = screen.get_history();
assert!(
history_after.is_empty(),
"CSI 3J should clear all scrollback, got {} lines",
history_after.len()
);
}
#[test]
fn alt_screen_clears_wrap_pending() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCDE"); assert!(screen.grid.wrap_pending());
screen.process(b"\x1b[?1049h");
assert!(
!screen.grid.wrap_pending(),
"wrap_pending should be cleared on alt screen enter"
);
assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(screen.grid.cursor_y(), 0);
}
#[test]
fn alt_screen_mode_1047() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Hello");
assert_eq!(screen.grid.visible_row(0)[0].c, 'H');
screen.process(b"\x1b[?1047h");
assert!(screen.in_alt_screen());
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
screen.process(b"Alt");
assert_eq!(screen.grid.visible_row(0)[0].c, 'A');
screen.process(b"\x1b[?1047l");
assert!(!screen.in_alt_screen());
assert_eq!(screen.grid.visible_row(0)[0].c, 'H'); }
#[test]
fn alt_screen_mode_47() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Main");
assert_eq!(screen.grid.visible_row(0)[0].c, 'M');
screen.process(b"\x1b[?47h");
assert!(screen.in_alt_screen());
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
screen.process(b"\x1b[?47l");
assert!(!screen.in_alt_screen());
assert_eq!(screen.grid.visible_row(0)[0].c, 'M');
}
#[test]
fn alt_screen_restores_modes() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"\x1b[?2004h"); screen.process(b"\x1b[?1h"); assert!(screen.grid.modes().bracketed_paste);
assert!(screen.grid.modes().cursor_key_mode);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[?2004l");
screen.process(b"\x1b[?1l");
assert!(!screen.grid.modes().bracketed_paste);
assert!(!screen.grid.modes().cursor_key_mode);
screen.process(b"\x1b[?1049l");
assert!(
screen.grid.modes().bracketed_paste,
"bracketed paste should be restored on alt screen exit"
);
assert!(
screen.grid.modes().cursor_key_mode,
"cursor key mode should be restored on alt screen exit"
);
}
#[test]
fn cursor_visibility_mode_25() {
let mut screen = Screen::new(80, 24, 100);
assert!(screen.grid.cursor_visible());
screen.process(b"\x1b[?25l");
assert!(
!screen.grid.cursor_visible(),
"cursor should be hidden after ?25l"
);
screen.process(b"\x1b[?25h");
assert!(
screen.grid.cursor_visible(),
"cursor should be visible after ?25h"
);
}
#[test]
fn render_with_hidden_cursor() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"\x1b[?25l"); let mut cache = AnsiRenderer::new();
let result = cache.render(&screen, false);
let text = String::from_utf8_lossy(&result);
assert!(
!text.contains("\x1b[?25h"),
"hidden cursor should not emit ?25h in render output"
);
assert!(
text.contains("\x1b[?25l"),
"render should always hide cursor during redraw"
);
}
#[test]
fn render_full_reattach_redraws_all() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Hello");
let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, false);
let result = cache.render(&screen, true);
let text = String::from_utf8_lossy(&result);
assert!(text.contains("\x1b[2J"), "full render should screen clear");
assert!(
text.contains("Hello"),
"full render should include screen content"
);
}
#[test]
fn pending_scrollback_drained_separately() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A\r\nB\r\nC\r\nD");
let pending_rows = screen.take_pending_scrollback();
let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
assert!(!pending.is_empty(), "should have pending scrollback");
let pending2_rows = screen.take_pending_scrollback();
let pending2 = AnsiRenderer::new().render_rows(&screen, &pending2_rows);
assert!(pending2.is_empty(), "second drain should be empty");
let history = screen.get_history();
assert!(
!history.is_empty(),
"history should be preserved after drain"
);
}
#[test]
fn stale_pending_scrollback_after_reattach_simulation() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Line1\r\nLine2\r\nLine3\r\nLine4");
let _ = screen.take_pending_scrollback();
screen.process(b"\r\nLine5\r\nLine6");
let history = screen.get_history();
let history_count = history.len();
let stale_rows = screen.take_pending_scrollback();
let stale = AnsiRenderer::new().render_rows(&screen, &stale_rows);
assert!(
!stale.is_empty(),
"there should be stale pending scrollback from the disconnect"
);
screen.process(b"\r\nLine7");
let new_pending_rows = screen.take_pending_scrollback();
let new_pending = AnsiRenderer::new().render_rows(&screen, &new_pending_rows);
let new_history = screen.get_history();
assert_eq!(
new_history.len(),
history_count + new_pending.len(),
"new scrollback should only contain lines added after reattach drain"
);
}
#[test]
fn window_ops_ignored() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[14t"); screen.process(b"\x1b[22;0t"); let responses = screen.take_responses();
assert!(
responses.is_empty(),
"window ops should not generate responses"
);
}
#[test]
fn scroll_region_il_dl_interaction() {
let mut screen = Screen::new(10, 6, 100);
screen.process(b"\x1b[2;5r");
for row in 0..6 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("R{}", row).as_bytes());
}
screen.process(b"\x1b[3;1H"); screen.process(b"\x1b[L");
assert_eq!(
screen.grid.visible_row(2)[0].c,
' ',
"inserted line should be blank"
);
assert_eq!(
screen.grid.visible_row(0)[0].c,
'R',
"row above scroll region should be untouched"
);
assert_eq!(
screen.grid.visible_row(5)[0].c,
'R',
"row below scroll region should be untouched"
);
}
#[test]
fn render_bce_erase_output() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ"); screen.process(b"\x1b[41m"); screen.process(b"\x1b[1;4H"); screen.process(b"\x1b[0K");
let mut cache = AnsiRenderer::new();
let result = cache.render(&screen, true);
let text = String::from_utf8_lossy(&result);
assert!(
text.contains("41"),
"rendered output should include red bg (41) after BCE erase"
);
}
#[test]
fn wide_char_scrollback_rendering() {
let mut screen = Screen::new(10, 3, 100);
screen.process("\u{4e16}\u{754c}".as_bytes()); screen.process(b"\r\nLine2\r\nLine3\r\nLine4");
let history = screen.get_history();
assert!(!history.is_empty(), "should have scrollback");
let first_line = String::from_utf8_lossy(&history[0]);
assert!(
first_line.contains('\u{4e16}'),
"scrollback should contain wide char 世"
);
assert!(
first_line.contains('\u{754c}'),
"scrollback should contain wide char 界"
);
}
#[test]
fn combining_mark_attaches_to_previous_cell() {
let mut screen = Screen::new(80, 24, 100);
screen.process("e\u{0301}".as_bytes());
assert_eq!(screen.grid.visible_row(0)[0].c, 'e');
assert_eq!(screen.grid.visible_row(0).combining(0), &['\u{0301}']);
}
#[test]
fn combining_mark_with_wrap_pending() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCDE");
assert!(
screen.grid.wrap_pending(),
"wrap should be pending after filling line"
);
screen.process("\u{0308}".as_bytes()); assert_eq!(screen.grid.visible_row(0)[4].c, 'E');
assert_eq!(screen.grid.visible_row(0).combining(4), &['\u{0308}']);
}
#[test]
fn combining_mark_on_wide_char() {
let mut screen = Screen::new(80, 24, 100);
screen.process("\u{4e16}\u{0301}".as_bytes()); assert_eq!(screen.grid.visible_row(0)[0].c, '\u{4e16}');
assert_eq!(screen.grid.visible_row(0).combining(0), &['\u{0301}']);
assert_eq!(screen.grid.visible_row(0)[1].width, 0); }
#[test]
fn combining_mark_renders_in_output() {
let mut screen = Screen::new(80, 24, 100);
screen.process("e\u{0301}".as_bytes());
let mut cache = AnsiRenderer::new();
let output = cache.render(&screen, true);
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("e\u{0301}"),
"rendered output should contain base char + combining mark"
);
}
#[test]
fn delete_lines_preserves_cursor_x() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[6;11H"); assert_eq!(screen.grid.cursor_x(), 10);
assert_eq!(screen.grid.cursor_y(), 5);
screen.process(b"\x1b[M");
assert_eq!(screen.grid.cursor_x(), 10, "DL must not change cursor_x");
assert_eq!(screen.grid.cursor_y(), 5, "DL must not change cursor_y");
}
#[test]
fn insert_lines_preserves_cursor_x() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[6;11H");
assert_eq!(screen.grid.cursor_x(), 10);
screen.process(b"\x1b[L");
assert_eq!(screen.grid.cursor_x(), 10, "IL must not change cursor_x");
assert_eq!(screen.grid.cursor_y(), 5, "IL must not change cursor_y");
}
fn screen_lines(screen: &Screen) -> Vec<String> {
screen
.grid
.visible_rows()
.map(|row| {
let s: String = row.iter().map(|c| c.c).collect();
s.trim_end().to_string()
})
.collect()
}
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()
}
#[test]
fn lf_at_bottom_scrolls_content_up() {
let mut screen = Screen::new(10, 4, 100);
screen.process(b"Row0\r\nRow1\r\nRow2\r\nRow3");
assert_eq!(screen_lines(&screen), vec!["Row0", "Row1", "Row2", "Row3"]);
screen.process(b"\r\nRow4");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Row1", "first visible row after scroll");
assert_eq!(lines[1], "Row2");
assert_eq!(lines[2], "Row3");
assert_eq!(lines[3], "Row4", "new content at bottom");
}
#[test]
fn lf_scroll_captures_scrollback() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"AAA\r\nBBB\r\nCCC\r\nDDD");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 1, "exactly one line scrolled off");
assert_eq!(hist[0], "AAA");
}
#[test]
fn su_does_not_capture_scrollback() {
let mut screen = Screen::new(10, 4, 100);
screen.process(b"Row0\r\nRow1\r\nRow2\r\nRow3");
assert_eq!(screen.grid.scrollback_len(), 0, "no scrollback yet");
screen.process(b"\x1b[2S");
assert_eq!(
screen.grid.scrollback_len(),
0,
"SU must not capture rows into scrollback"
);
assert!(history_texts(&screen).is_empty(), "history stays empty");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Row2");
assert_eq!(lines[1], "Row3");
assert_eq!(lines[2], "");
assert_eq!(lines[3], "");
}
#[test]
fn lf_still_captures_scrollback_after_su() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"AAA\r\nBBB\r\nCCC");
assert_eq!(screen.grid.scrollback_len(), 0);
screen.process(b"\r\nDDD");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 1, "LF scroll still captures one line");
assert_eq!(hist[0], "AAA");
}
#[test]
fn many_lines_overflow_screen() {
let mut screen = Screen::new(10, 5, 100);
for i in 0..20 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("L{:02}", i).as_bytes());
}
let lines = screen_lines(&screen);
assert_eq!(lines[0], "L15");
assert_eq!(lines[1], "L16");
assert_eq!(lines[2], "L17");
assert_eq!(lines[3], "L18");
assert_eq!(lines[4], "L19");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 15);
assert_eq!(hist[0], "L00", "first scrollback line");
assert_eq!(hist[14], "L14", "last scrollback line");
}
#[test]
fn scrollback_order_preserved() {
let mut screen = Screen::new(10, 3, 100);
for i in 0..10 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("Line{}", i).as_bytes());
}
let hist = history_texts(&screen);
assert_eq!(hist.len(), 7);
for (idx, h) in hist.iter().enumerate() {
assert_eq!(
*h,
format!("Line{}", idx),
"scrollback line {} should be Line{}",
idx,
idx
);
}
}
#[test]
fn lf_within_scroll_region_only_scrolls_region() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("Row{}", row).as_bytes());
}
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[5;1H");
screen.process(b"\r\n");
screen.process(b"New");
let lines = screen_lines(&screen);
assert_eq!(
lines[0], "Row0",
"row above scroll region must be untouched"
);
assert_eq!(
lines[5], "Row5",
"row below scroll region must be untouched"
);
assert_eq!(lines[1], "Row2", "top of region should have what was row 2");
assert_eq!(lines[2], "Row3");
assert_eq!(lines[3], "Row4");
}
#[test]
fn scroll_region_preserves_outer_content_on_multiple_scrolls() {
let mut screen = Screen::new(10, 6, 100);
screen.process(b"\x1b[1;1HHeader");
screen.process(b"\x1b[6;1HFooter");
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2;1H");
for i in 0..12 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("Msg{:02}", i).as_bytes());
}
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Header", "header must survive region scrolling");
assert_eq!(lines[5], "Footer", "footer must survive region scrolling");
}
#[test]
fn csi_s_within_scroll_region() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("R{}", row).as_bytes());
}
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2S");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "R0", "row above region untouched after CSI S");
assert_eq!(lines[5], "R5", "row below region untouched after CSI S");
assert_eq!(lines[1], "R3", "region top after scroll by 2");
assert_eq!(lines[2], "R4", "region second row after scroll by 2");
assert_eq!(lines[3], "", "blank line after scroll");
assert_eq!(lines[4], "", "blank line after scroll");
}
#[test]
fn csi_t_within_scroll_region() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("R{}", row).as_bytes());
}
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2T");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "R0", "row above region untouched after CSI T");
assert_eq!(lines[5], "R5", "row below region untouched after CSI T");
assert_eq!(lines[1], "", "blank line after scroll down");
assert_eq!(lines[2], "", "blank line after scroll down");
assert_eq!(lines[3], "R1", "shifted content after scroll down");
assert_eq!(lines[4], "R2", "shifted content after scroll down");
}
#[test]
fn scroll_down_does_not_generate_scrollback() {
let mut screen = Screen::new(10, 5, 100);
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("Row{}", row).as_bytes());
}
screen.process(b"\x1b[3T");
let scrollback_rows = screen.take_pending_scrollback();
let scrollback = AnsiRenderer::new().render_rows(&screen, &scrollback_rows);
assert!(
scrollback.is_empty(),
"CSI T (scroll down) should not generate scrollback"
);
}
#[test]
fn cursor_stays_in_place_during_lf_scroll() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"Row0\r\nRow1\r\nRow2");
assert_eq!(screen.grid.cursor_y(), 2);
assert_eq!(screen.grid.cursor_x(), 4);
screen.process(b"\x1b[1;11H"); screen.process(b"\x1b[3;11H");
screen.process(b"\r\n");
assert_eq!(screen.grid.cursor_y(), 2, "cursor_y stays at scroll_bottom");
assert_eq!(screen.grid.cursor_x(), 0, "cursor_x reset by CR");
}
#[test]
fn cursor_column_preserved_on_bare_lf_scroll() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"Row0\r\nRow1\r\n");
screen.process(b"\x1b[3;8H"); assert_eq!(screen.grid.cursor_y(), 2);
assert_eq!(screen.grid.cursor_x(), 7);
screen.process(b"\n");
assert_eq!(screen.grid.cursor_y(), 2, "cursor_y stays at scroll_bottom");
assert_eq!(screen.grid.cursor_x(), 7, "bare LF preserves cursor_x");
}
#[test]
fn reverse_index_at_top_of_full_screen_scrolls_down() {
let mut screen = Screen::new(10, 4, 100);
screen.process(b"Row0\r\nRow1\r\nRow2\r\nRow3");
screen.process(b"\x1b[1;1H");
screen.process(b"\x1bM");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "", "new blank line at top");
assert_eq!(lines[1], "Row0", "original row 0 shifted down");
assert_eq!(lines[2], "Row1", "original row 1 shifted down");
assert_eq!(lines[3], "Row2", "original row 2 shifted down");
}
#[test]
fn rapid_scroll_up_down_content_integrity() {
let mut screen = Screen::new(10, 5, 100);
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("Row{}", row).as_bytes());
}
screen.process(b"\x1b[2S"); screen.process(b"\x1b[2T");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "", "blank at top after scroll down");
assert_eq!(lines[1], "", "blank at top after scroll down");
assert_eq!(lines[2], "Row2", "surviving content");
assert_eq!(lines[3], "Row3", "surviving content");
assert_eq!(lines[4], "Row4", "surviving content shifted back");
}
#[test]
fn lf_scroll_with_styled_content() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Row0\r\nRow1\r\nRow2");
screen.process(b"\x1b[41m");
screen.process(b"\r\n");
assert_eq!(screen.cell_char(2, 0), ' ', "new line should be blank");
assert_eq!(
screen.cell_style(2, 0).bg,
Some(style::Color::Indexed(1)),
"blank line should inherit current bg (BCE)"
);
}
#[test]
fn scroll_region_lf_no_scrollback_when_top_nonzero() {
let mut screen = Screen::new(10, 6, 100);
screen.process(b"\x1b[3;6r");
screen.process(b"\x1b[3;1H");
for i in 0..10 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("Msg{}", i).as_bytes());
}
let scrollback_rows = screen.take_pending_scrollback();
let scrollback = AnsiRenderer::new().render_rows(&screen, &scrollback_rows);
assert!(
scrollback.is_empty(),
"scroll_top > 0: no scrollback should be captured"
);
}
#[test]
fn tui_app_alt_screen_scroll_region_lf() {
let mut screen = Screen::new(20, 6, 100);
screen.process(b"MainContent");
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[6;1HStatus: OK");
screen.process(b"\x1b[1;5r");
screen.process(b"\x1b[1;1H");
for i in 0..8 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("Msg{:02}", i).as_bytes());
}
let lines = screen_lines(&screen);
assert_eq!(lines[5], "Status: OK", "status bar must survive scroll");
assert_eq!(lines[0], "Msg03");
assert_eq!(lines[1], "Msg04");
assert_eq!(lines[2], "Msg05");
assert_eq!(lines[3], "Msg06");
assert_eq!(lines[4], "Msg07");
}
#[test]
fn tui_app_alt_screen_explicit_scroll_up() {
let mut screen = Screen::new(15, 5, 100);
screen.process(b"\x1b[?1049h");
for row in 0..5 {
screen.process(format!("\x1b[{};1H", row + 1).as_bytes());
screen.process(format!("Line{}", row).as_bytes());
}
screen.process(b"\x1b[2S");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Line2", "content shifted up by 2");
assert_eq!(lines[1], "Line3");
assert_eq!(lines[2], "Line4");
assert_eq!(lines[3], "", "blank line at bottom");
assert_eq!(lines[4], "", "blank line at bottom");
let scrollback_rows = screen.take_pending_scrollback();
let scrollback = AnsiRenderer::new().render_rows(&screen, &scrollback_rows);
assert!(
scrollback.is_empty(),
"alt screen CSI S should not generate scrollback"
);
}
#[test]
fn tui_app_alt_screen_scroll_then_exit_restores_main() {
let mut screen = Screen::new(15, 4, 100);
screen.process(b"Original0\r\nOriginal1\r\nOriginal2\r\nOriginal3");
screen.process(b"\x1b[?1049h");
for i in 0..20 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("Alt{}", i).as_bytes());
}
screen.process(b"\x1b[?1049l");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Original0");
assert_eq!(lines[1], "Original1");
assert_eq!(lines[2], "Original2");
assert_eq!(lines[3], "Original3");
}
#[test]
fn tui_app_scroll_region_with_header_and_footer() {
let mut screen = Screen::new(20, 5, 100);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[1;1H== My App ==");
screen.process(b"\x1b[5;1H[Ctrl+C quit]");
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[2;1H");
for i in 0..10 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("item{}", i).as_bytes());
}
let lines = screen_lines(&screen);
assert_eq!(lines[0], "== My App ==", "header must be preserved");
assert_eq!(lines[4], "[Ctrl+C quit]", "footer must be preserved");
assert_eq!(lines[1], "item7");
assert_eq!(lines[2], "item8");
assert_eq!(lines[3], "item9");
}
#[test]
fn tui_app_delete_lines_within_scroll_region() {
let mut screen = Screen::new(15, 6, 100);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[1;1HTitle");
screen.process(b"\x1b[6;1HStatus");
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2;1HItem-A");
screen.process(b"\x1b[3;1HItem-B");
screen.process(b"\x1b[4;1HItem-C");
screen.process(b"\x1b[5;1HItem-D");
screen.process(b"\x1b[3;1H");
screen.process(b"\x1b[M");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Title", "title preserved");
assert_eq!(lines[5], "Status", "status preserved");
assert_eq!(lines[1], "Item-A", "Item-A stays");
assert_eq!(
lines[2], "Item-C",
"Item-C shifted up into Item-B's position"
);
assert_eq!(lines[3], "Item-D", "Item-D shifted up");
assert_eq!(lines[4], "", "blank line at region bottom");
}
#[test]
fn tui_app_insert_lines_within_scroll_region() {
let mut screen = Screen::new(15, 6, 100);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[1;1HTitle");
screen.process(b"\x1b[6;1HStatus");
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2;1HItem-A");
screen.process(b"\x1b[3;1HItem-B");
screen.process(b"\x1b[4;1HItem-C");
screen.process(b"\x1b[5;1HItem-D");
screen.process(b"\x1b[3;1H");
screen.process(b"\x1b[L"); screen.process(b"NEW ITEM");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Title", "title preserved");
assert_eq!(lines[5], "Status", "status preserved");
assert_eq!(lines[1], "Item-A", "Item-A stays");
assert_eq!(lines[2], "NEW ITEM", "new item inserted");
assert_eq!(lines[3], "Item-B", "Item-B shifted down");
assert_eq!(lines[4], "Item-C", "Item-C shifted down");
}
#[test]
fn tui_app_reverse_index_in_scroll_region() {
let mut screen = Screen::new(15, 6, 100);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[1;1HHeader");
screen.process(b"\x1b[6;1HFooter");
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2;1HMsg-A");
screen.process(b"\x1b[3;1HMsg-B");
screen.process(b"\x1b[4;1HMsg-C");
screen.process(b"\x1b[5;1HMsg-D");
screen.process(b"\x1b[2;1H");
screen.process(b"\x1bM");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Header", "header preserved");
assert_eq!(lines[5], "Footer", "footer preserved");
assert_eq!(lines[1], "", "new blank line at region top");
assert_eq!(lines[2], "Msg-A", "Msg-A shifted down");
assert_eq!(lines[3], "Msg-B", "Msg-B shifted down");
assert_eq!(lines[4], "Msg-C", "Msg-C shifted down");
}
#[test]
fn tui_app_scroll_region_change_mid_session() {
let mut screen = Screen::new(20, 8, 100);
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[1;1HTop Bar");
screen.process(b"\x1b[8;1HBottom Bar");
screen.process(b"\x1b[2;7r");
screen.process(b"\x1b[2;1H");
for i in 0..6 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("V1_{}", i).as_bytes());
}
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Top Bar");
assert_eq!(lines[7], "Bottom Bar");
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[5;1HPanel-A");
screen.process(b"\x1b[6;1HPanel-B");
screen.process(b"\x1b[7;1HPanel-C");
screen.process(b"\x1b[2;1H");
for i in 0..6 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("V2_{}", i).as_bytes());
}
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Top Bar", "top bar survives region change");
assert_eq!(lines[7], "Bottom Bar", "bottom bar survives region change");
assert_eq!(
lines[4], "Panel-A",
"panel row survives scroll in smaller region"
);
assert_eq!(
lines[5], "Panel-B",
"panel row survives scroll in smaller region"
);
assert_eq!(
lines[6], "Panel-C",
"panel row survives scroll in smaller region"
);
}
#[test]
fn scroll_up_count_exceeds_region_size() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1HR{}", row + 1, row).as_bytes());
}
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[100S");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "R0", "row above region untouched");
assert_eq!(lines[5], "R5", "row below region untouched");
assert_eq!(lines[1], "", "region blanked");
assert_eq!(lines[2], "", "region blanked");
assert_eq!(lines[3], "", "region blanked");
}
#[test]
fn scroll_down_count_exceeds_region_size() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1HR{}", row + 1, row).as_bytes());
}
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[100T");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "R0", "row above region untouched");
assert_eq!(lines[5], "R5", "row below region untouched");
assert_eq!(lines[1], "", "region blanked");
assert_eq!(lines[2], "", "region blanked");
assert_eq!(lines[3], "", "region blanked");
}
#[test]
fn lf_at_last_row_outside_scroll_region() {
let mut screen = Screen::new(10, 6, 100);
screen.process(b"\x1b[6;1HBottom");
screen.process(b"\x1b[1;4r");
screen.process(b"\x1b[6;1H");
assert_eq!(screen.grid.cursor_y(), 5);
screen.process(b"\n");
assert_eq!(
screen.grid.cursor_y(),
5,
"cursor stuck at last row outside region"
);
let lines = screen_lines(&screen);
assert_eq!(lines[5], "Bottom", "content at last row unchanged");
}
#[test]
fn lf_between_scroll_top_and_bottom_just_moves_cursor() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1HR{}", row + 1, row).as_bytes());
}
screen.process(b"\x1b[2;5r"); screen.process(b"\x1b[3;1H");
screen.process(b"\n");
assert_eq!(screen.grid.cursor_y(), 3, "cursor moved down by 1");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "R0");
assert_eq!(lines[1], "R1");
assert_eq!(lines[2], "R2");
assert_eq!(lines[3], "R3");
assert_eq!(lines[4], "R4");
assert_eq!(lines[5], "R5");
}
#[test]
fn autowrap_at_scroll_bottom_triggers_scroll() {
let mut screen = Screen::new(4, 4, 100);
screen.process(b"AAA0\r\nBBB1\r\nCCC2\r\nDDD3");
assert!(
screen.grid.wrap_pending(),
"wrap_pending after filling last cell"
);
screen.process(b"X");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "BBB1", "scroll happened: AAA0 gone");
assert_eq!(lines[1], "CCC2");
assert_eq!(lines[2], "DDD3");
assert_eq!(lines[3], "X", "new char on fresh line");
}
#[test]
fn autowrap_at_scroll_region_bottom_triggers_region_scroll() {
let mut screen = Screen::new(5, 6, 100);
screen.process(b"\x1b[1;1HHead");
screen.process(b"\x1b[6;1HFoot");
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2;1HAAAAA"); screen.process(b"\x1b[3;1HBBBBB"); screen.process(b"\x1b[4;1HCCCCC"); screen.process(b"\x1b[5;1HDDDDD");
screen.process(b"Z");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Head", "header survives autowrap scroll");
assert_eq!(lines[5], "Foot", "footer survives autowrap scroll");
assert_eq!(
lines[1], "BBBBB",
"AAAAA scrolled off, BBBBB now at region top"
);
assert_eq!(lines[4], "Z", "Z on new line at region bottom");
}
#[test]
fn wide_char_wrap_at_scroll_bottom_triggers_scroll() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"Row0\r\nRow1\r\n");
screen.process(b"ABCD");
assert_eq!(screen.grid.cursor_x(), 4);
screen.process("你".as_bytes());
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Row1", "Row0 scrolled off");
assert_eq!(lines[1], "ABCD", "original row with blank at col 4");
assert_eq!(
screen.grid.visible_row(2)[0].c,
'你',
"wide char on scrolled-in line"
);
assert_eq!(screen.grid.visible_row(2)[0].width, 2);
}
#[test]
fn csi_r_reset_restores_full_screen_scroll() {
let mut screen = Screen::new(10, 6, 100);
screen.process(b"\x1b[2;5r");
assert_eq!(screen.grid.scroll_top(), 1);
assert_eq!(screen.grid.scroll_bottom(), 4);
screen.process(b"\x1b[r");
assert_eq!(screen.grid.scroll_top(), 0, "scroll_top reset to 0");
assert_eq!(
screen.grid.scroll_bottom(),
5,
"scroll_bottom reset to rows-1"
);
}
#[test]
fn scroll_single_row_region() {
let mut screen = Screen::new(10, 6, 100);
screen.process(b"\x1b[3;3r");
assert_eq!(screen.grid.scroll_top(), 2, "single-row region applied");
assert_eq!(screen.grid.scroll_bottom(), 2, "single-row region applied");
assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(screen.grid.cursor_y(), 0);
}
#[test]
fn scroll_region_reversed_params_ignored() {
let mut screen = Screen::new(10, 20, 100);
let old_top = screen.grid.scroll_top();
let old_bottom = screen.grid.scroll_bottom();
screen.process(b"\x1b[15;5r");
assert_eq!(screen.grid.scroll_top(), old_top);
assert_eq!(screen.grid.scroll_bottom(), old_bottom);
}
#[test]
fn scrollback_limit_enforced_during_scroll() {
let limit = 5;
let mut screen = Screen::new(10, 3, limit);
for i in 0..20 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("L{:02}", i).as_bytes());
}
let hist = history_texts(&screen);
assert_eq!(
hist.len(),
limit,
"scrollback should be exactly at limit {}, got {}",
limit,
hist.len()
);
assert_eq!(hist[0], "L12", "oldest scrollback should be evicted");
}
#[test]
fn csi_s_does_not_move_cursor() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[3;5H"); assert_eq!(screen.grid.cursor_y(), 2);
assert_eq!(screen.grid.cursor_x(), 4);
screen.process(b"\x1b[2S");
assert_eq!(screen.grid.cursor_y(), 2, "CSI S must not change cursor_y");
assert_eq!(screen.grid.cursor_x(), 4, "CSI S must not change cursor_x");
}
#[test]
fn csi_t_does_not_move_cursor() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[3;5H");
assert_eq!(screen.grid.cursor_y(), 2);
assert_eq!(screen.grid.cursor_x(), 4);
screen.process(b"\x1b[2T");
assert_eq!(screen.grid.cursor_y(), 2, "CSI T must not change cursor_y");
assert_eq!(screen.grid.cursor_x(), 4, "CSI T must not change cursor_x");
}
#[test]
fn scroll_then_overwrite_last_line() {
let mut screen = Screen::new(20, 4, 100);
screen.process(b"compile a.c\r\n");
screen.process(b"compile b.c\r\n");
screen.process(b"compile c.c\r\n");
screen.process(b"compile d.c");
screen.process(b"\r\ncompile e.c");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "compile b.c");
assert_eq!(lines[1], "compile c.c");
assert_eq!(lines[2], "compile d.c");
assert_eq!(lines[3], "compile e.c");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 1);
assert_eq!(hist[0], "compile a.c");
}
#[test]
fn scroll_then_cup_overwrite_middle() {
let mut screen = Screen::new(20, 5, 100);
for i in 0..5 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("Line{}", i).as_bytes());
}
screen.process(b"\r\nLine5\r\nLine6");
screen.process(b"\x1b[1;1H");
screen.process(b"[=====> ] 50%");
let lines = screen_lines(&screen);
assert_eq!(
lines[0], "[=====> ] 50%",
"row 0 overwritten after scroll"
);
assert_eq!(lines[1], "Line3", "row 1 unchanged");
assert_eq!(lines[2], "Line4");
assert_eq!(lines[3], "Line5");
assert_eq!(lines[4], "Line6");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 2);
assert_eq!(hist[0], "Line0");
assert_eq!(hist[1], "Line1");
}
#[test]
fn continuous_scroll_with_bottom_status_update() {
let mut screen = Screen::new(30, 5, 100);
screen.process(b"\x1b[1;4r");
screen.process(b"\x1b[5;1HProgress: 0%");
screen.process(b"\x1b[1;1H");
for i in 0..10 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("Installing pkg-{:02}...", i).as_bytes());
let pct = (i + 1) * 10;
screen.process(format!("\x1b7\x1b[5;1H\x1b[2KProgress: {}%\x1b8", pct).as_bytes());
}
let lines = screen_lines(&screen);
assert_eq!(lines[4], "Progress: 100%", "status line updated");
assert_eq!(lines[0], "Installing pkg-06...");
assert_eq!(lines[1], "Installing pkg-07...");
assert_eq!(lines[2], "Installing pkg-08...");
assert_eq!(lines[3], "Installing pkg-09...");
}
#[test]
fn scroll_with_erase_and_rewrite() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"Step 1: done\r\n");
screen.process(b"Step 2: done\r\n");
screen.process(b"Step 3: running...");
screen.process(b"\r\nStep 4: running...");
screen.process(b"\x1b[2;9H"); screen.process(b"\x1b[0K"); screen.process(b"done");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Step 2: done");
assert_eq!(lines[1], "Step 3: done", "rewritten after scroll");
assert_eq!(lines[2], "Step 4: running...");
}
#[test]
fn scroll_region_scroll_then_overwrite_fixed_rows() {
let mut screen = Screen::new(25, 6, 100);
screen.process(b"\x1b[1;1HTitle: My App v1.0");
screen.process(b"\x1b[6;1HItems: 0");
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2;1H");
for i in 0..8 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("Item #{}", i + 1).as_bytes());
screen.process(format!("\x1b7\x1b[6;1H\x1b[2KItems: {}\x1b8", i + 1).as_bytes());
}
screen.process(b"\x1b7\x1b[1;1H\x1b[2KTitle: My App v2.0\x1b8");
let lines = screen_lines(&screen);
assert_eq!(
lines[0], "Title: My App v2.0",
"header updated after scrolling"
);
assert_eq!(lines[5], "Items: 8", "footer updated with count");
assert_eq!(lines[1], "Item #5");
assert_eq!(lines[2], "Item #6");
assert_eq!(lines[3], "Item #7");
assert_eq!(lines[4], "Item #8");
}
#[test]
fn scroll_then_overwrite_scrolled_line_content_integrity() {
let mut screen = Screen::new(10, 5, 100);
for i in 0..8 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("L{:02}", i).as_bytes());
}
screen.process(b"\x1b[3;1H\x1b[2K");
screen.process(b"REPLACED");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "L03", "line above overwrite unchanged");
assert_eq!(lines[1], "L04", "line above overwrite unchanged");
assert_eq!(lines[2], "REPLACED", "middle line replaced");
assert_eq!(lines[3], "L06", "line below overwrite unchanged");
assert_eq!(lines[4], "L07", "line below overwrite unchanged");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 3);
assert_eq!(hist[0], "L00");
assert_eq!(hist[1], "L01");
assert_eq!(hist[2], "L02");
}
#[test]
fn interleaved_scroll_and_cup_writes() {
let mut screen = Screen::new(15, 4, 100);
screen.process(b"A\r\nB\r\nC\r\nD");
screen.process(b"\r\nE");
screen.process(b"\x1b[1;1H\x1b[2KHeader");
screen.process(b"\x1b[4;1H"); screen.process(b"\r\nF");
screen.process(b"\x1b[1;1H\x1b[2KNewHdr");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "NewHdr");
assert_eq!(lines[1], "D");
assert_eq!(lines[2], "E");
assert_eq!(lines[3], "F");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 2);
assert_eq!(hist[0], "A", "first scrolled-off line");
assert_eq!(hist[1], "Header", "overwritten row scrolled off");
}
#[test]
fn scroll_and_partial_line_overwrite() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"AAAAAAAAAAAAAAAAAAAA\r\n"); screen.process(b"BBBBBBBBBBBBBBBBBBBB\r\n"); screen.process(b"CCCCCCCCCCCCCCCCCCCC");
screen.process(b"\r\nDDDDDDDDDDDDDDDDDDDD");
screen.process(b"\x1b[1;1H");
screen.process(b"XYZ");
let lines = screen_lines(&screen);
assert_eq!(
lines[0], "XYZBBBBBBBBBBBBBBBBB",
"partial overwrite: first 3 replaced, rest intact"
);
assert_eq!(lines[1], "CCCCCCCCCCCCCCCCCCCC", "row 1 untouched");
assert_eq!(lines[2], "DDDDDDDDDDDDDDDDDDDD", "row 2 untouched");
}
#[test]
fn ind_esc_d_at_scroll_bottom_scrolls() {
let mut screen = Screen::new(10, 4, 100);
screen.process(b"Row0\r\nRow1\r\nRow2\r\nRow3");
screen.process(b"\x1bD");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Row1", "ESC D at bottom should scroll: Row0 gone");
assert_eq!(lines[1], "Row2");
assert_eq!(lines[2], "Row3");
assert_eq!(lines[3], "", "new blank line at bottom after IND scroll");
}
#[test]
fn ind_esc_d_mid_screen_just_moves_cursor() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[2;1H"); screen.process(b"\x1bD");
assert_eq!(
screen.grid.cursor_y(),
2,
"ESC D should move cursor down by 1"
);
}
#[test]
fn save_cursor_scroll_restore_cursor_writes_at_shifted_content() {
let mut screen = Screen::new(10, 4, 100);
screen.process(b"Row0\r\nRow1\r\nRow2\r\nRow3");
screen.process(b"\x1b[2;6H"); screen.process(b"\x1b7");
screen.process(b"\x1b[2S");
screen.process(b"\x1b8");
assert_eq!(screen.grid.cursor_y(), 1, "restored cursor_y");
assert_eq!(screen.grid.cursor_x(), 5, "restored cursor_x");
screen.process(b"!");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "Row2", "row 0 after scroll");
assert_eq!(
lines[1], "Row3 !",
"cursor writes at restored position in shifted content"
);
}
#[test]
fn erase_display_ignores_scroll_region() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1HR{}", row + 1, row).as_bytes());
}
screen.process(b"\x1b[2;5r");
screen.process(b"\x1b[2J");
let lines = screen_lines(&screen);
for (i, line) in lines.iter().enumerate() {
assert_eq!(
line, &"",
"row {} should be erased by CSI 2J (regardless of scroll region)",
i
);
}
}
#[test]
fn erase_display_0_from_cursor_ignores_scroll_region() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1HR{}", row + 1, row).as_bytes());
}
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[3;1H");
screen.process(b"\x1b[0J");
let lines = screen_lines(&screen);
assert_eq!(lines[0], "R0", "row above cursor preserved");
assert_eq!(lines[1], "R1", "row above cursor preserved");
assert_eq!(lines[2], "", "cursor row erased");
assert_eq!(lines[3], "", "row below cursor erased");
assert_eq!(lines[4], "", "row below region erased (ED ignores region)");
assert_eq!(lines[5], "", "last row erased (ED ignores region)");
}
#[test]
fn reattach_render_after_scroll_and_cup_overwrite() {
let mut screen = Screen::new(15, 4, 100);
for i in 0..7 {
if i > 0 {
screen.process(b"\r\n");
}
screen.process(format!("Line{}", i).as_bytes());
}
screen.process(b"\x1b[2;1H\x1b[2KModified");
let mut cache = render::AnsiRenderer::new();
let output = cache.render(&screen, true);
let rendered = String::from_utf8_lossy(&output);
assert!(rendered.contains("Line3"), "reattach should show Line3");
assert!(
rendered.contains("Modified"),
"reattach should show Modified (not Line4)"
);
assert!(rendered.contains("Line5"), "reattach should show Line5");
assert!(rendered.contains("Line6"), "reattach should show Line6");
assert!(
!rendered.contains("Line4"),
"Line4 was overwritten, should not appear"
);
}
#[test]
fn scrollback_history_after_scroll_and_cup_overwrite() {
let mut screen = Screen::new(15, 3, 100);
screen.process(b"Original\r\nRow1\r\nRow2");
screen.process(b"\x1b[1;1H\x1b[2KChanged");
screen.process(b"\x1b[3;1H\r\nRow3");
let hist = history_texts(&screen);
assert_eq!(hist.len(), 1);
assert_eq!(
hist[0], "Changed",
"scrollback should have the modified content, not 'Original'"
);
}
#[test]
fn scroll_region_lf_cursor_outside_region_between_bottom_and_last_row() {
let mut screen = Screen::new(10, 8, 100);
for row in 0..8 {
screen.process(format!("\x1b[{};1HR{}", row + 1, row).as_bytes());
}
screen.process(b"\x1b[1;4r");
screen.process(b"\x1b[6;1H");
assert_eq!(screen.grid.cursor_y(), 5);
screen.process(b"\n");
assert_eq!(screen.grid.cursor_y(), 6, "cursor moves down, no scroll");
let lines = screen_lines(&screen);
for row in 0..8 {
assert_eq!(lines[row], format!("R{}", row), "row {} unchanged", row);
}
}
#[test]
fn combining_mark_at_column_zero_no_previous_cell() {
let mut screen = Screen::new(10, 3, 100);
screen.process("\u{0301}".as_bytes()); assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(0).combining_len(0), 0);
}
#[test]
fn multiple_combining_marks_on_single_cell() {
let mut screen = Screen::new(80, 3, 100);
screen.process("e\u{0301}\u{0308}".as_bytes());
assert_eq!(screen.grid.visible_row(0)[0].c, 'e');
assert_eq!(screen.grid.visible_row(0).combining_len(0), 2);
assert_eq!(screen.grid.visible_row(0).combining(0)[0], '\u{0301}');
assert_eq!(screen.grid.visible_row(0).combining(0)[1], '\u{0308}');
}
#[test]
fn wide_char_exactly_fills_line() {
let mut screen = Screen::new(4, 3, 100);
screen.process(b"AB"); screen.process("你".as_bytes()); assert_eq!(screen.grid.visible_row(0)[2].c, '你');
assert_eq!(screen.grid.visible_row(0)[2].width, 2);
assert_eq!(screen.grid.visible_row(0)[3].width, 0);
assert_eq!(screen.grid.cursor_y(), 0, "should NOT have wrapped");
assert!(
screen.grid.wrap_pending(),
"wrap should be pending after filling line"
);
}
#[test]
fn wide_char_on_2_column_terminal() {
let mut screen = Screen::new(2, 3, 100);
screen.process("你".as_bytes());
assert_eq!(screen.grid.visible_row(0)[0].c, '你');
assert_eq!(screen.grid.visible_row(0)[0].width, 2);
assert_eq!(screen.grid.visible_row(0)[1].width, 0);
assert_eq!(screen.grid.cursor_y(), 0);
assert!(screen.grid.wrap_pending());
}
#[test]
fn wide_char_on_1_column_terminal() {
let mut screen = Screen::new(1, 3, 100);
screen.process("你".as_bytes());
assert_eq!(
screen.grid.cursor_y(),
0,
"wide char should be dropped on 1-col terminal"
);
assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(
screen.grid.visible_row(0)[0].c,
' ',
"cell should remain blank"
);
}
#[test]
fn wide_char_no_autowrap_at_last_col() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"\x1b[?7l"); screen.process(b"ABCD"); screen.process("你".as_bytes()); assert_eq!(screen.grid.visible_row(0)[3].c, 'D');
assert_eq!(
screen.grid.visible_row(0)[4].c,
' ',
"wide char dropped, col 4 stays blank"
);
assert_eq!(screen.grid.cursor_y(), 0, "no wrap");
}
#[test]
fn rep_with_no_prior_print() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"\x1b[3b"); assert_eq!(screen.grid.cursor_x(), 3);
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(0)[1].c, ' ');
assert_eq!(screen.grid.visible_row(0)[2].c, ' ');
}
#[test]
fn restore_cursor_with_no_saved_state() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;10H"); screen.process(b"\x1b8"); assert_eq!(screen.grid.cursor_y(), 4);
assert_eq!(screen.grid.cursor_x(), 9);
}
#[test]
fn restore_cursor_csi_u_with_no_saved_state() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[3;5H");
screen.process(b"\x1b[u"); assert_eq!(screen.grid.cursor_y(), 2);
assert_eq!(screen.grid.cursor_x(), 4);
}
#[test]
fn save_cursor_resize_then_restore_clamps() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[20;70H"); screen.process(b"\x1b7"); screen.resize(40, 10); screen.process(b"\x1b8"); assert_eq!(
screen.grid.cursor_x(),
39,
"restored x should clamp to cols-1"
);
assert_eq!(
screen.grid.cursor_y(),
9,
"restored y should clamp to rows-1"
);
}
#[test]
fn double_enter_alt_screen() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Main");
screen.process(b"\x1b[?1049h"); assert!(screen.in_alt_screen());
screen.process(b"Alt1");
screen.process(b"\x1b[?1049h"); assert!(screen.in_alt_screen());
assert_eq!(screen.grid.visible_row(0)[0].c, 'A');
screen.process(b"\x1b[?1049l"); assert!(!screen.in_alt_screen());
assert_eq!(screen.grid.visible_row(0)[0].c, 'M');
assert_eq!(screen.grid.visible_row(0)[3].c, 'n');
}
#[test]
fn exit_alt_screen_when_not_in_alt() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"Hello");
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[3;5H"); let sr_top = screen.grid.scroll_top();
let sr_bot = screen.grid.scroll_bottom();
let cx = screen.grid.cursor_x();
let cy = screen.grid.cursor_y();
screen.process(b"\x1b[?1049l");
assert!(!screen.in_alt_screen());
assert_eq!(
screen.grid.scroll_top(),
sr_top,
"scroll region top must not change"
);
assert_eq!(
screen.grid.scroll_bottom(),
sr_bot,
"scroll region bottom must not change"
);
assert_eq!(screen.grid.cursor_x(), cx, "cursor x must not change");
assert_eq!(screen.grid.cursor_y(), cy, "cursor y must not change");
}
#[test]
fn hts_sets_tab_stop() {
let mut screen = Screen::new(80, 3, 100);
screen.process(b"\x1b[1;5H"); screen.process(b"\x1bH"); screen.process(b"\x1b[1;1H"); screen.process(b"\t"); assert_eq!(screen.grid.cursor_x(), 4);
}
#[test]
fn tbc_clear_current_tab_stop() {
let mut screen = Screen::new(80, 3, 100);
screen.process(b"\x1b[1;9H"); screen.process(b"\x1b[0g"); screen.process(b"\x1b[1;1H"); screen.process(b"\t"); assert_eq!(screen.grid.cursor_x(), 16);
}
#[test]
fn tbc_clear_all_tab_stops() {
let mut screen = Screen::new(80, 3, 100);
screen.process(b"\x1b[3g"); screen.process(b"\t"); assert_eq!(screen.grid.cursor_x(), 79);
}
#[test]
fn scroll_region_top_equals_bottom_accepted() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;5r"); assert_eq!(screen.grid.scroll_top(), 4);
assert_eq!(screen.grid.scroll_bottom(), 4);
}
#[test]
fn scroll_region_reversed_params_rejected() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[15;5r"); assert_eq!(screen.grid.scroll_top(), 0);
assert_eq!(screen.grid.scroll_bottom(), 23);
}
#[test]
fn ech_count_exceeds_remaining_columns() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ");
screen.process(b"\x1b[1;6H"); screen.process(b"\x1b[100X"); for i in 0..5 {
assert_ne!(screen.grid.visible_row(0)[i].c, ' ');
}
for i in 5..10 {
assert_eq!(
screen.grid.visible_row(0)[i].c,
' ',
"col {} should be erased",
i
);
}
}
#[test]
fn dch_at_end_of_line() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ");
screen.process(b"\x1b[1;10H"); screen.process(b"\x1b[P"); assert_eq!(screen.grid.visible_row(0)[8].c, 'I');
assert_eq!(screen.grid.visible_row(0)[9].c, ' ');
}
#[test]
fn dch_count_exceeds_remaining_columns() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ");
screen.process(b"\x1b[1;6H"); screen.process(b"\x1b[100P"); for (i, ch) in "ABCDE".chars().enumerate() {
assert_eq!(screen.grid.visible_row(0)[i].c, ch);
}
for i in 5..10 {
assert_eq!(
screen.grid.visible_row(0)[i].c,
' ',
"col {} should be blank after large DCH",
i
);
}
}
#[test]
fn ich_count_exceeds_remaining_columns() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ");
screen.process(b"\x1b[1;1H"); screen.process(b"\x1b[100@"); for i in 0..10 {
assert_eq!(
screen.grid.visible_row(0)[i].c,
' ',
"col {} should be blank after large ICH",
i
);
}
}
#[test]
fn ind_esc_d_within_scroll_region_nonzero_top() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1HR{}", row + 1, row).as_bytes());
}
screen.process(b"\x1b[2;4r");
screen.process(b"\x1b[4;1H");
assert_eq!(screen.grid.cursor_y(), 3);
screen.process(b"\x1bD");
assert_eq!(screen.grid.visible_row(0)[0].c, 'R');
assert_eq!(screen.grid.visible_row(0)[1].c, '0');
assert_eq!(screen.grid.visible_row(5)[0].c, 'R');
assert_eq!(screen.grid.visible_row(5)[1].c, '5');
assert_eq!(screen.grid.visible_row(1)[1].c, '2');
}
#[test]
fn ed_0_cursor_on_wide_char_continuation() {
let mut screen = Screen::new(10, 3, 100);
screen.process("AB你CD".as_bytes());
screen.process(b"\x1b[1;4H"); screen.process(b"\x1b[J"); assert_eq!(
screen.grid.visible_row(0)[2].c,
' ',
"first half of wide char should be blanked"
);
assert_eq!(
screen.grid.visible_row(0)[3].c,
' ',
"continuation cell should be blanked"
);
}
#[test]
fn el_1_cursor_on_wide_char_continuation() {
let mut screen = Screen::new(10, 3, 100);
screen.process("你BCDE".as_bytes());
screen.process(b"\x1b[1;2H"); screen.process(b"\x1b[1K"); assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(0)[1].c, ' ');
assert_eq!(screen.grid.visible_row(0)[2].c, 'B');
}
#[test]
fn ris_clears_scrollback() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let _ = screen.take_pending_scrollback();
let hist_before = screen.get_history().len();
assert!(hist_before > 0);
screen.process(b"\x1bc"); assert_eq!(
screen.get_history().len(),
0,
"ESC c should clear scrollback history"
);
assert_eq!(screen.grid.scrollback_len(), 0);
assert_eq!(screen.grid.pending_start(), 0);
}
#[test]
fn ris_forwards_clear_scrollback_passthrough() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let _ = screen.take_passthrough(); screen.process(b"\x1bc"); let pt = screen.take_passthrough();
assert_eq!(pt.len(), 1, "RIS should produce one passthrough entry");
assert_eq!(pt[0], b"\x1b[3J", "RIS should forward \\e[3J, not \\ec");
}
#[test]
fn ris_during_alt_screen_restores_scrollback_limit() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let _ = screen.take_pending_scrollback();
assert!(
screen.grid.scrollback_len() > 0,
"should have scrollback before alt screen"
);
screen.process(b"\x1b[?1049h");
assert_eq!(
screen.grid.scrollback_limit(),
0,
"alt screen should disable scrollback"
);
screen.process(b"\x1bc");
assert!(!screen.in_alt_screen(), "RIS should exit alt screen");
assert_eq!(
screen.grid.scrollback_limit(),
100,
"RIS should restore scrollback_limit from saved grid"
);
screen.process(b"X\r\nY\r\nZ\r\nW");
let _ = screen.take_pending_scrollback();
assert!(
screen.grid.scrollback_len() > 0,
"scrollback should work after RIS during alt screen"
);
}
#[test]
fn scrollback_limit_zero() {
let mut screen = Screen::new(10, 3, 0);
screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
let hist = screen.get_history();
assert_eq!(
hist.len(),
0,
"scrollback with limit=0 should store nothing, got {}",
hist.len()
);
let pending_rows = screen.take_pending_scrollback();
let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
assert!(
pending.is_empty(),
"pending scrollback with limit=0 should be empty"
);
}
#[test]
fn csi_f_is_alias_for_cup() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;10f"); assert_eq!(screen.grid.cursor_y(), 4);
assert_eq!(screen.grid.cursor_x(), 9);
}
#[test]
fn csi_cup_zero_params_default_to_home() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;10H"); screen.process(b"\x1b[H"); assert_eq!(screen.grid.cursor_y(), 0);
assert_eq!(screen.grid.cursor_x(), 0);
}
#[test]
fn csi_cup_params_beyond_screen_clamp() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[999;999H");
assert_eq!(screen.grid.cursor_y(), 23);
assert_eq!(screen.grid.cursor_x(), 79);
}
#[test]
fn dsr_at_boundary_positions() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[1;1H");
screen.process(b"\x1b[6n");
let r = screen.take_responses();
assert_eq!(r[0], b"\x1b[1;1R");
screen.process(b"\x1b[24;80H");
screen.process(b"\x1b[6n");
let r = screen.take_responses();
assert_eq!(r[0], b"\x1b[24;80R");
}
#[test]
fn responses_drained_after_take() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[6n");
let r1 = screen.take_responses();
assert_eq!(r1.len(), 1);
let r2 = screen.take_responses();
assert!(r2.is_empty(), "responses should be empty after drain");
}
#[test]
fn cursor_movement_with_zero_params_defaults() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[10;10H"); screen.process(b"\x1b[A"); assert_eq!(screen.grid.cursor_y(), 8);
screen.process(b"\x1b[B"); assert_eq!(screen.grid.cursor_y(), 9);
screen.process(b"\x1b[C"); assert_eq!(screen.grid.cursor_x(), 10);
screen.process(b"\x1b[D"); assert_eq!(screen.grid.cursor_x(), 9);
}
#[test]
fn cursor_movement_beyond_bounds_clamps() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[3;5H"); screen.process(b"\x1b[999A"); assert_eq!(screen.grid.cursor_y(), 0);
screen.process(b"\x1b[999B"); assert_eq!(screen.grid.cursor_y(), 4);
screen.process(b"\x1b[999D"); assert_eq!(screen.grid.cursor_x(), 0);
screen.process(b"\x1b[999C"); assert_eq!(screen.grid.cursor_x(), 9);
}
#[test]
fn overwrite_wide_char_first_half() {
let mut screen = Screen::new(10, 3, 100);
screen.process("你好".as_bytes()); screen.process(b"\x1b[1;1H"); screen.process(b"X"); assert_eq!(screen.grid.visible_row(0)[0].c, 'X');
assert_eq!(screen.grid.visible_row(0)[0].width, 1);
assert_eq!(
screen.grid.visible_row(0)[1].c,
' ',
"continuation should be blanked"
);
assert_eq!(screen.grid.visible_row(0)[1].width, 1);
}
#[test]
fn overwrite_wide_char_second_half() {
let mut screen = Screen::new(10, 3, 100);
screen.process("你好".as_bytes());
screen.process(b"\x1b[1;2H"); screen.process(b"X"); assert_eq!(
screen.grid.visible_row(0)[0].c,
' ',
"first half should be blanked"
);
assert_eq!(screen.grid.visible_row(0)[0].width, 1);
assert_eq!(screen.grid.visible_row(0)[1].c, 'X');
assert_eq!(screen.grid.visible_row(0)[1].width, 1);
}
#[test]
fn lf_at_last_row_without_scroll_region() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"\x1b[3;1H"); screen.process(b"X");
screen.process(b"\n"); assert_eq!(screen.grid.cursor_y(), 2);
}
#[test]
fn cr_does_not_scroll() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"ABCDEFGHIJ"); screen.process(b"\r"); assert_eq!(screen.grid.cursor_x(), 0);
assert_eq!(screen.grid.cursor_y(), 0, "CR should stay on same row");
}
#[test]
fn sgr_color_reset_39_49() {
let mut screen = Screen::new(80, 3, 100);
screen.process(b"\x1b[31;42m"); screen.process(b"A");
assert!(screen.cell_style(0, 0).fg.is_some());
assert!(screen.cell_style(0, 0).bg.is_some());
screen.process(b"\x1b[39m"); screen.process(b"B");
assert!(screen.cell_style(0, 1).fg.is_none(), "fg should be reset");
assert!(screen.cell_style(0, 1).bg.is_some(), "bg should remain");
screen.process(b"\x1b[49m"); screen.process(b"C");
assert!(screen.cell_style(0, 2).fg.is_none());
assert!(screen.cell_style(0, 2).bg.is_none(), "bg should be reset");
}
#[test]
fn scroll_down_within_region_does_not_produce_scrollback() {
let mut screen = Screen::new(10, 5, 100);
screen.process(b"\x1b[2;4r"); screen.process(b"\x1b[2;1H"); screen.process(b"\x1b[3T"); let pending_rows = screen.take_pending_scrollback();
let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
assert!(
pending.is_empty(),
"scroll down should never produce scrollback"
);
}
#[test]
fn mouse_mode_per_mode_toggle() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1000h");
assert!(screen.grid.modes().mouse_modes.click);
assert!(!screen.grid.modes().mouse_modes.button);
screen.process(b"\x1b[?1000l");
assert!(!screen.grid.modes().mouse_modes.click);
}
#[test]
fn mouse_mode_multiple_simultaneous() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1000h");
screen.process(b"\x1b[?1003h");
assert!(screen.grid.modes().mouse_modes.click);
assert!(screen.grid.modes().mouse_modes.any);
assert_eq!(
screen.grid.modes().mouse_modes.effective(),
super::grid::MouseMode::Any
);
}
#[test]
fn mouse_mode_disable_own_mode_only() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1000h");
screen.process(b"\x1b[?1002h");
screen.process(b"\x1b[?1000l"); assert!(!screen.grid.modes().mouse_modes.click);
assert!(screen.grid.modes().mouse_modes.button);
assert_eq!(
screen.grid.modes().mouse_modes.effective(),
super::grid::MouseMode::Button
);
}
#[test]
fn mouse_mode_priority_resolution() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1000h");
screen.process(b"\x1b[?1002h");
screen.process(b"\x1b[?1003h");
assert_eq!(
screen.grid.modes().mouse_modes.effective(),
super::grid::MouseMode::Any
);
screen.process(b"\x1b[?1003l");
assert_eq!(
screen.grid.modes().mouse_modes.effective(),
super::grid::MouseMode::Button
);
screen.process(b"\x1b[?1002l");
assert_eq!(
screen.grid.modes().mouse_modes.effective(),
super::grid::MouseMode::Click
);
screen.process(b"\x1b[?1000l");
assert_eq!(
screen.grid.modes().mouse_modes.effective(),
super::grid::MouseMode::Off
);
}
#[test]
fn mouse_encoding_disable_resets_to_x10() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[?1006h"); assert_eq!(
screen.grid.modes().mouse_encoding,
super::grid::MouseEncoding::Sgr
);
screen.process(b"\x1b[?1006l");
assert_eq!(
screen.grid.modes().mouse_encoding,
super::grid::MouseEncoding::X10
);
}
#[test]
fn cursor_visibility_toggle() {
let mut screen = Screen::new(80, 24, 100);
assert!(screen.grid.cursor_visible());
screen.process(b"\x1b[?25l"); assert!(!screen.grid.cursor_visible());
screen.process(b"\x1b[?25h"); assert!(screen.grid.cursor_visible());
}
#[test]
fn dl_il_outside_scroll_region_no_op() {
let mut screen = Screen::new(10, 6, 100);
for row in 0..6 {
screen.process(format!("\x1b[{};1HR{}", row + 1, row).as_bytes());
}
screen.process(b"\x1b[2;4r"); screen.process(b"\x1b[6;1H"); screen.process(b"\x1b[M"); screen.process(b"\x1b[L"); let lines = screen_lines(&screen);
for row in 0..6 {
assert_eq!(
lines[row],
format!("R{}", row),
"row {} should be unchanged after DL/IL outside region",
row
);
}
}
#[test]
fn dl_clears_wrap_pending() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCDE"); assert!(screen.grid.wrap_pending());
screen.process(b"\x1b[M"); assert!(!screen.grid.wrap_pending());
}
#[test]
fn il_clears_wrap_pending() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCDE");
assert!(screen.grid.wrap_pending());
screen.process(b"\x1b[L"); assert!(!screen.grid.wrap_pending());
}
#[test]
fn erase_display_preserves_cursor_position() {
for mode in [0u8, 1, 2] {
let mut screen = Screen::new(20, 10, 100);
screen.process(b"ABCDEFGHIJ\r\nXYZ");
screen.process(b"\x1b[3;5H"); let (cx, cy) = (screen.grid.cursor_x(), screen.grid.cursor_y());
let seq = format!("\x1b[{}J", mode);
screen.process(seq.as_bytes()); assert_eq!(
screen.grid.cursor_x(),
cx,
"ED {} should preserve cursor_x",
mode
);
assert_eq!(
screen.grid.cursor_y(),
cy,
"ED {} should preserve cursor_y",
mode
);
}
}
#[test]
fn erase_line_preserves_cursor_position() {
for mode in [0u8, 1, 2] {
let mut screen = Screen::new(20, 10, 100);
screen.process(b"ABCDEFGHIJ\r\nXYZ");
screen.process(b"\x1b[2;7H"); let (cx, cy) = (screen.grid.cursor_x(), screen.grid.cursor_y());
let seq = format!("\x1b[{}K", mode);
screen.process(seq.as_bytes()); assert_eq!(
screen.grid.cursor_x(),
cx,
"EL {} should preserve cursor_x",
mode
);
assert_eq!(
screen.grid.cursor_y(),
cy,
"EL {} should preserve cursor_y",
mode
);
}
}
#[test]
fn rep_repeats_wide_char() {
let mut screen = Screen::new(20, 3, 100);
screen.process("世".as_bytes());
assert_eq!(screen.grid.visible_row(0)[0].c, '世');
assert_eq!(screen.grid.visible_row(0)[0].width, 2);
screen.process(b"\x1b[2b");
assert_eq!(screen.grid.visible_row(0)[0].c, '世');
assert_eq!(screen.grid.visible_row(0)[0].width, 2);
assert_eq!(screen.grid.visible_row(0)[2].c, '世');
assert_eq!(screen.grid.visible_row(0)[2].width, 2);
assert_eq!(screen.grid.visible_row(0)[4].c, '世');
assert_eq!(screen.grid.visible_row(0)[4].width, 2);
assert_eq!(screen.grid.visible_row(0)[1].width, 0);
assert_eq!(screen.grid.visible_row(0)[3].width, 0);
assert_eq!(screen.grid.visible_row(0)[5].width, 0);
}
#[test]
fn compact_styles_reclaims_unused() {
let mut screen = Screen::new(10, 3, 0);
let s1 = style::Style {
bold: true,
..style::Style::default()
};
let s2 = style::Style {
italic: true,
..style::Style::default()
};
let id1 = screen.grid.style_table_mut().intern(s1);
let id2 = screen.grid.style_table_mut().intern(s2);
screen.grid.visible_row_mut(0)[0].style_id = id1;
screen.compact_styles();
assert_eq!(screen.grid.style_table().get(id1), s1);
let s3 = style::Style {
dim: true,
..style::Style::default()
};
let id3 = screen.grid.style_table_mut().intern(s3);
assert_eq!(id3, id2, "should reuse freed slot");
assert_eq!(screen.grid.style_table().get(id3), s3);
}
#[test]
fn compact_styles_preserves_scrollback_styles() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"\x1b[1mBold\x1b[0m");
let scrollback_style_id = screen.grid.visible_row(0)[0].style_id;
assert!(
!scrollback_style_id.is_default(),
"styled cell should have non-default style_id"
);
screen.process(b"\n\n\n\n");
screen.compact_styles();
let style = screen.grid.style_table().get(scrollback_style_id);
assert!(style.bold, "scrollback style should still be bold");
}
#[test]
fn compact_styles_preserves_saved_grid_styles() {
let mut screen = Screen::new(10, 3, 0);
screen.process(b"\x1b[1mHello\x1b[0m");
let main_style_id = screen.grid.visible_row(0)[0].style_id;
screen.process(b"\x1b[?1049h");
let alt_style = style::Style {
italic: true,
..style::Style::default()
};
let alt_id = screen.grid.style_table_mut().intern(alt_style);
screen.compact_styles();
let style = screen.grid.style_table().get(main_style_id);
assert!(style.bold, "saved grid style should survive compaction");
let new_style = style::Style {
dim: true,
..style::Style::default()
};
let new_id = screen.grid.style_table_mut().intern(new_style);
assert_eq!(
new_id, alt_id,
"unreferenced alt style slot should be reused"
);
}
#[test]
fn alt_screen_exit_gc_trigger() {
let mut screen = Screen::new(10, 3, 0);
screen.process(b"\x1b[?1049h");
for i in 0..50u16 {
let style = style::Style {
fg: Some(style::Color::Rgb(i as u8, 0, 0)),
..style::Style::default()
};
screen.grid.style_table_mut().intern(style);
}
let pre_exit_len = screen.grid.style_table().len();
screen.process(b"\x1b[?1049l");
assert!(
screen.grid.style_table().len() < pre_exit_len,
"GC should have reclaimed styles: {} should be < {}",
screen.grid.style_table().len(),
pre_exit_len
);
}
#[test]
fn osc_777_queued_as_notification() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;Title;Body\x1b\\");
let notifications = screen.take_queued_notifications();
assert_eq!(notifications.len(), 1);
assert!(notifications[0].starts_with(b"\x1b]777;"));
}
#[test]
fn osc_9_queued_as_notification() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]9;Hello\x1b\\");
let notifications = screen.take_queued_notifications();
assert_eq!(notifications.len(), 1);
assert!(notifications[0].starts_with(b"\x1b]9;"));
}
#[test]
fn osc_99_queued_as_notification() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]99;Body text\x07");
let notifications = screen.take_queued_notifications();
assert_eq!(notifications.len(), 1);
assert!(notifications[0].starts_with(b"\x1b]99;"));
}
#[test]
fn bell_not_queued_as_notification() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x07");
let notifications = screen.take_queued_notifications();
assert!(
notifications.is_empty(),
"BEL should not be queued as notification"
);
}
#[test]
fn ed3_not_queued_as_notification() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[3J");
let notifications = screen.take_queued_notifications();
assert!(
notifications.is_empty(),
"ED3 should not be queued as notification"
);
}
#[test]
fn osc_52_not_queued_as_notification() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]52;c;SGVsbG8=\x1b\\");
let notifications = screen.take_queued_notifications();
assert!(
notifications.is_empty(),
"OSC 52 (clipboard) should not be queued"
);
}
#[test]
fn notification_queue_respects_limit() {
let mut screen = Screen::new(80, 24, 100);
for i in 0..55u32 {
let osc = format!("\x1b]777;notify;Title;msg{}\x1b\\", i);
screen.process(osc.as_bytes());
}
let notifications = screen.take_queued_notifications();
assert_eq!(notifications.len(), 50);
let first = String::from_utf8_lossy(¬ifications[0]);
assert!(
first.contains("msg5"),
"oldest should be msg5, got: {}",
first
);
let last = String::from_utf8_lossy(¬ifications[49]);
assert!(
last.contains("msg54"),
"newest should be msg54, got: {}",
last
);
}
#[test]
fn take_queued_notifications_drains() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b]777;notify;Title;Body\x1b\\");
let first = screen.take_queued_notifications();
assert_eq!(first.len(), 1);
let second = screen.take_queued_notifications();
assert!(second.is_empty(), "second take should be empty after drain");
}
#[test]
fn combining_survives_scroll_into_scrollback() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
screen.process(b"\n\n\n");
assert_eq!(screen.grid.scrollback_len(), 1);
let row = screen.grid.scrollback_row(0);
assert_eq!(row[0].c, 'e');
assert_eq!(row.combining(0), &['\u{0301}']);
}
#[test]
fn combining_in_scrollback_renders_correctly() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
screen.process(b"\n\n\n");
let pending_rows = screen.take_pending_scrollback();
let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
assert_eq!(pending.len(), 1);
let text = String::from_utf8_lossy(&pending[0]);
assert!(
text.contains("e\u{0301}"),
"scrollback render must include combining mark, got: {}",
text
);
}
#[test]
fn combining_in_reattach_history() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
screen.process(b"\n\n\n");
let _ = screen.take_pending_scrollback();
let history = screen.get_history();
assert!(!history.is_empty());
let text = String::from_utf8_lossy(&history[0]);
assert!(
text.contains("e\u{0301}"),
"history render must include combining mark, got: {}",
text
);
}
#[test]
fn combining_erased_by_erase_in_line() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
assert_eq!(screen.grid.visible_row(0).combining(0), &['\u{0301}']);
screen.process(b"\x1b[1G"); screen.process(b"\x1b[K"); assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(0).combining(0), &[] as &[char]);
}
#[test]
fn combining_erased_by_erase_character() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
screen.process(b"\x1b[1G");
screen.process(b"\x1b[X");
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(0).combining(0), &[] as &[char]);
}
#[test]
fn combining_erased_by_erase_display() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
screen.process(b"\x1b[2J");
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(0).combining(0), &[] as &[char]);
}
#[test]
fn combining_shifted_by_insert_character() {
let mut screen = Screen::new(10, 3, 100);
screen.process("Ae\u{0301}".as_bytes());
assert_eq!(screen.grid.visible_row(0)[1].c, 'e');
assert_eq!(screen.grid.visible_row(0).combining(1), &['\u{0301}']);
screen.process(b"\x1b[1G");
screen.process(b"\x1b[@");
assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(0)[1].c, 'A');
assert_eq!(screen.grid.visible_row(0)[2].c, 'e');
assert_eq!(
screen.grid.visible_row(0).combining(2),
&['\u{0301}'],
"combining mark should shift with its cell during ICH"
);
}
#[test]
fn combining_shifted_by_delete_character() {
let mut screen = Screen::new(10, 3, 100);
screen.process("Ae\u{0301}".as_bytes());
screen.process(b"\x1b[1G");
screen.process(b"\x1b[P");
assert_eq!(screen.grid.visible_row(0)[0].c, 'e');
assert_eq!(
screen.grid.visible_row(0).combining(0),
&['\u{0301}'],
"combining mark should shift with its cell during DCH"
);
}
#[test]
fn combining_overwritten_by_new_char() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
assert_eq!(screen.grid.visible_row(0).combining(0), &['\u{0301}']);
screen.process(b"\x1b[1G");
screen.process(b"X");
assert_eq!(screen.grid.visible_row(0)[0].c, 'X');
assert_eq!(
screen.grid.visible_row(0).combining(0),
&[] as &[char],
"overwriting a cell must clear its combining marks"
);
}
#[test]
fn combining_survives_alt_screen_round_trip() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
screen.process(b"\x1b[?1049h");
screen.process(b"\x1b[?1049l");
assert_eq!(screen.grid.visible_row(0)[0].c, 'e');
assert_eq!(
screen.grid.visible_row(0).combining(0),
&['\u{0301}'],
"combining marks must survive alt screen round trip"
);
}
#[test]
fn combining_in_delete_lines() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"\x1b[2;1H"); screen.process("e\u{0301}".as_bytes());
assert_eq!(screen.grid.visible_row(1).combining(0), &['\u{0301}']);
screen.process(b"\x1b[1;1H");
screen.process(b"\x1b[M"); assert_eq!(screen.grid.visible_row(0)[0].c, 'e');
assert_eq!(
screen.grid.visible_row(0).combining(0),
&['\u{0301}'],
"combining marks should move with row during DL"
);
}
#[test]
fn combining_in_insert_lines() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
screen.process(b"\x1b[1;1H");
screen.process(b"\x1b[L"); assert_eq!(screen.grid.visible_row(0)[0].c, ' ');
assert_eq!(screen.grid.visible_row(1)[0].c, 'e');
assert_eq!(
screen.grid.visible_row(1).combining(0),
&['\u{0301}'],
"combining marks should move with row during IL"
);
}
#[test]
fn combining_in_full_render_output() {
let mut screen = Screen::new(10, 3, 100);
screen.process("e\u{0301}".as_bytes());
let mut cache = AnsiRenderer::new();
let output = cache.render(&screen, true);
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("e\u{0301}"),
"full render must include combining mark"
);
}
#[test]
fn combining_in_incremental_render_output() {
let mut screen = Screen::new(10, 3, 100);
let mut cache = AnsiRenderer::new();
let _ = cache.render(&screen, true);
screen.process("e\u{0301}".as_bytes());
let output = cache.render(&screen, false);
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("e\u{0301}"),
"incremental render must include combining mark"
);
}
#[test]
fn combining_dirty_tracking_detects_change() {
let mut screen = Screen::new(10, 3, 100);
let mut cache = AnsiRenderer::new();
screen.process(b"e");
let _ = cache.render(&screen, true);
screen.process(b"\x1b[1G");
screen.process("e\u{0301}".as_bytes()); let output = cache.render(&screen, false);
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("e\u{0301}"),
"dirty tracking must detect combining mark addition"
);
}
#[test]
fn nel_next_line() {
let mut screen = Screen::new(10, 3, 0);
screen.process(b"AB\x1bECD");
assert_eq!(screen.grid.visible_row(0)[0].c, 'A');
assert_eq!(screen.grid.visible_row(0)[1].c, 'B');
assert_eq!(screen.grid.visible_row(1)[0].c, 'C');
assert_eq!(screen.grid.visible_row(1)[1].c, 'D');
}
#[test]
fn nel_at_scroll_bottom_scrolls() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Line1\r\nLine2\r\nLine3");
screen.process(b"\x1bE");
assert_eq!(screen.grid.visible_row(0)[0].c, 'L'); assert_eq!(screen.grid.visible_row(2)[0].c, ' '); }
#[test]
fn decaln_fills_screen_with_e() {
let mut screen = Screen::new(5, 3, 0);
screen.process(b"Hello\r\nWorld");
screen.process(b"\x1b#8");
for y in 0..3 {
for x in 0..5 {
assert_eq!(
screen.grid.visible_row(y)[x].c,
'E',
"expected 'E' at ({}, {})",
x,
y
);
}
}
assert_eq!(screen.grid.cursor_pos(), (0, 0));
}
#[test]
fn rep_with_line_drawing_charset() {
let mut screen = Screen::new(10, 1, 0);
screen.process(b"\x1b(0q\x1b[3b");
for x in 0..4 {
assert_eq!(
screen.grid.visible_row(0)[x].c,
'\u{2500}',
"expected '\u{2500}' at col {}, got '{}'",
x,
screen.grid.visible_row(0)[x].c
);
}
}
#[test]
fn title_push_pop() {
let mut screen = Screen::new(10, 3, 0);
screen.process(b"\x1b]2;First\x07");
assert_eq!(screen.title(), "First");
screen.process(b"\x1b[22;0t");
screen.process(b"\x1b]2;Second\x07");
assert_eq!(screen.title(), "Second");
screen.process(b"\x1b[23;0t");
assert_eq!(screen.title(), "First");
}
#[test]
fn title_pop_empty_stack_noop() {
let mut screen = Screen::new(10, 3, 0);
screen.process(b"\x1b]2;Title\x07");
screen.process(b"\x1b[23;0t");
assert_eq!(screen.title(), "Title");
}
#[test]
fn decom_cursor_position_relative_to_scroll_region() {
let mut screen = Screen::new(10, 10, 0);
screen.process(b"\x1b[3;7r"); screen.process(b"\x1b[?6h"); screen.process(b"\x1b[1;1H"); assert_eq!(screen.grid.cursor_pos(), (0, 2)); }
#[test]
fn decom_cursor_clamped_to_scroll_region() {
let mut screen = Screen::new(10, 10, 0);
screen.process(b"\x1b[3;7r");
screen.process(b"\x1b[?6h");
screen.process(b"\x1b[99;1H"); assert_eq!(screen.grid.cursor_pos(), (0, 6)); }
#[test]
fn decom_off_cursor_absolute() {
let mut screen = Screen::new(10, 10, 0);
screen.process(b"\x1b[3;7r");
screen.process(b"\x1b[1;1H"); assert_eq!(screen.grid.cursor_pos(), (0, 0)); }
#[test]
fn decom_set_scrolling_region_homes_to_origin() {
let mut screen = Screen::new(10, 10, 0);
screen.process(b"\x1b[?6h");
screen.process(b"\x1b[5;5H");
screen.process(b"\x1b[3;7r"); assert_eq!(screen.grid.cursor_pos(), (0, 2)); }
#[test]
fn decom_saved_and_restored() {
let mut screen = Screen::new(10, 10, 0);
screen.process(b"\x1b[3;7r");
screen.process(b"\x1b[?6h");
screen.process(b"\x1b[2;3H");
screen.process(b"\x1b7"); screen.process(b"\x1b[?6l"); screen.process(b"\x1b[1;1H");
assert_eq!(screen.grid.cursor_pos(), (0, 0));
screen.process(b"\x1b8"); assert!(screen.grid.modes().origin_mode);
}
#[test]
fn render_cup_appears_after_decom_on_full_render() {
let mut screen = Screen::new(80, 24, 1000);
for _ in 0..24 {
screen.process(b"Line\r\n");
}
screen.process(b"$ ");
let mut cache = AnsiRenderer::new();
let scrollback_rows = screen.take_pending_scrollback();
let scrollback = cache.render_rows(&screen, &scrollback_rows);
assert!(!scrollback.is_empty(), "should have pending scrollback");
let output = cache.render_with_scrollback(&screen, &scrollback);
let text = String::from_utf8_lossy(&output);
let cup_seq = "\x1b[24;3H";
let decom_seq = "\x1b[?6l";
let cup_pos = text
.find(cup_seq)
.unwrap_or_else(|| panic!("cursor CUP {cup_seq:?} not found in render output:\n{text}"));
let decom_pos = text
.find(decom_seq)
.unwrap_or_else(|| panic!("DECOM reset {decom_seq:?} not found in render output:\n{text}"));
assert!(
cup_pos > decom_pos,
"CUP({cup_seq:?} at byte {cup_pos}) must appear after DECOM reset \
({decom_seq:?} at byte {decom_pos}), otherwise DECOM homes cursor to (1,1)"
);
}
#[test]
fn dsr_reports_relative_position_in_origin_mode() {
let mut screen = Screen::new(80, 24, 100);
screen.process(b"\x1b[5;15r");
screen.process(b"\x1b[?6h");
screen.process(b"\x1b[3;10H");
screen.process(b"\x1b[6n");
let responses = screen.take_responses();
assert_eq!(responses.len(), 1);
assert_eq!(
responses[0],
b"\x1b[3;10R",
"DSR in origin mode should report position relative to scroll region, \
got: {:?}",
String::from_utf8_lossy(&responses[0])
);
}
#[test]
fn insert_character_blanks_orphaned_wide_char_base_at_right_margin() {
let mut screen = Screen::new(6, 3, 100);
screen.process(b"\x1b[1;4H"); screen.process("你".as_bytes());
assert_eq!(screen.grid.visible_row(0)[3].c, '你');
assert_eq!(screen.grid.visible_row(0)[3].width, 2);
assert_eq!(screen.grid.visible_row(0)[4].width, 0);
screen.process(b"\x1b[1;1H");
screen.process(b"\x1b[1@");
let last = 5usize; assert_eq!(
screen.grid.visible_row(0)[last].c,
' ',
"orphaned continuation at last column should be blanked"
);
assert_eq!(
screen.grid.visible_row(0)[last - 1].c,
' ',
"orphaned wide char base at last-1 should be blanked"
);
assert_ne!(
screen.grid.visible_row(0)[last - 1].width,
2,
"orphaned wide char base should not remain width==2"
);
}
#[test]
fn ind_clears_wrap_pending() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"ABCDE");
assert!(
screen.grid.wrap_pending(),
"wrap should be pending after filling line"
);
assert_eq!(screen.grid.cursor_y(), 0);
screen.process(b"\x1bD");
assert!(!screen.grid.wrap_pending(), "IND should clear wrap_pending");
assert_eq!(
screen.grid.cursor_y(),
1,
"IND should move cursor down one row"
);
assert_eq!(
screen.grid.cursor_x(),
4,
"IND should not change cursor x position"
);
screen.process(b"F");
assert_eq!(
screen.grid.cursor_y(),
1,
"after IND cleared wrap_pending, print should stay on row 1"
);
assert_eq!(
screen.grid.visible_row(1)[4].c,
'F',
"F should be at col 4 of row 1 (IND moved cursor down, no wrap)"
);
}
#[test]
fn csi_gt_m_does_not_set_underline() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"\x1b[>4;2m");
screen.process(b"hello");
let style = screen
.grid
.style_table()
.get(screen.grid.visible_row(0)[0].style_id);
assert_eq!(
style.underline,
style::UnderlineStyle::None,
"CSI > 4 ; 2 m should not set underline"
);
assert!(!style.dim, "CSI > 4 ; 2 m should not set dim");
}
#[test]
fn irm_insert_mode_shifts_text_right() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"world");
screen.process(b"\r");
screen.process(b"\x1b[4h");
screen.process(b"hi ");
let row: String = (0..8).map(|x| screen.grid.visible_row(0)[x].c).collect();
assert_eq!(
row, "hi world",
"IRM insert mode should shift existing text right"
);
}
#[test]
fn irm_reset_returns_to_replace_mode() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"world");
screen.process(b"\r");
screen.process(b"\x1b[4h"); screen.process(b"\x1b[4l"); screen.process(b"HI");
let row: String = (0..5).map(|x| screen.grid.visible_row(0)[x].c).collect();
assert_eq!(
row, "HIrld",
"after IRM reset, printing overwrites in place"
);
}
#[test]
fn ri_clears_pending_wrap() {
let mut screen = Screen::new(5, 3, 100);
screen.process(b"\r\n");
screen.process(b"ABCDE");
assert!(screen.grid.wrap_pending());
assert_eq!(screen.grid.cursor_y(), 1);
screen.process(b"\x1bM");
assert!(!screen.grid.wrap_pending());
assert_eq!(screen.grid.cursor_y(), 0);
screen.process(b"X");
assert_eq!(screen.grid.cursor_y(), 0);
assert_eq!(screen.grid.visible_row(0)[4].c, 'X');
}
#[test]
fn dsr_5_reports_terminal_ok() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"\x1b[5n");
let responses = screen.take_responses();
assert_eq!(responses.len(), 1, "DSR 5 should produce one response");
assert_eq!(responses[0], b"\x1b[0n");
}
#[test]
fn dch_clears_combining_on_orphan_blank() {
let mut screen = Screen::new(6, 3, 100);
screen.process("世".as_bytes());
screen.process("\u{0301}".as_bytes()); assert!(!screen.grid.visible_row(0).combining(0).is_empty());
screen.process(b"\r");
screen.process(b"\x1b[P");
assert!(
screen.grid.visible_row(0).combining(0).is_empty(),
"DCH orphan-blank must clear stale combining marks"
);
assert!(screen.grid.visible_row(0).combining(1).is_empty());
}
#[test]
fn ich_clears_combining_on_orphan_blank() {
let mut screen = Screen::new(4, 3, 100);
screen.process(b"a");
screen.process("世".as_bytes());
screen.process("\u{0301}".as_bytes());
assert!(!screen.grid.visible_row(0).combining(1).is_empty());
screen.process(b"\r");
screen.process(b"\x1b[@");
let last = 3u16;
assert!(
screen.grid.visible_row(0).combining(last).is_empty(),
"ICH orphan-blank must clear stale combining marks at last col"
);
assert!(
screen.grid.visible_row(0).combining(2).is_empty(),
"ICH must clear combining marks for blanked base cell"
);
}