use core::num::NonZeroU32;
use justerm_core::{
Cell, CellFlags, Color, Engine, Frame, FrameKind, ScrollOp, Span, decode, encode,
};
use std::collections::BTreeMap;
#[test]
fn round_trip_empty_partial_frame() {
let frame = Frame {
cols: 80,
rows: 24,
kind: FrameKind::Partial,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
};
let bytes = encode(&frame);
assert_eq!(decode(&bytes).expect("decode"), frame);
}
#[test]
fn round_trip_cursor_position_and_visibility() {
let frame = Frame {
cols: 80,
rows: 24,
kind: FrameKind::Partial,
cursor_row: 9,
cursor_col: 19,
cursor_visible: false,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
};
assert_eq!(decode(&encode(&frame)).expect("decode"), frame);
}
#[test]
fn round_trip_span_of_plain_cells() {
let cells: Vec<Cell> = "hi!"
.chars()
.map(|c| Cell::from_parts(c, Color::Default, Color::Default, CellFlags::empty()))
.collect();
let frame = Frame {
cols: 80,
rows: 24,
kind: FrameKind::Partial,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![Span {
line: 3,
left: 10,
right: 12,
cells,
combining: BTreeMap::new(),
links: BTreeMap::new(),
}],
side_table: vec![],
link_table: vec![],
};
assert_eq!(decode(&encode(&frame)).expect("decode"), frame);
}
#[test]
fn round_trip_distinct_colour_references() {
let mk = |fg, bg| Cell::from_parts('x', fg, bg, CellFlags::empty());
let cells = vec![
mk(Color::Default, Color::Default),
mk(Color::Indexed(0), Color::Indexed(255)),
mk(Color::Rgb(0, 0, 0), Color::Rgb(1, 2, 3)),
];
let frame = Frame {
cols: 80,
rows: 24,
kind: FrameKind::Partial,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![Span {
line: 0,
left: 0,
right: 2,
cells,
combining: BTreeMap::new(),
links: BTreeMap::new(),
}],
side_table: vec![],
link_table: vec![],
};
let d = decode(&encode(&frame)).expect("decode");
assert_eq!(d, frame);
let row = &d.spans[0].cells;
assert_ne!(
row[0].fg(),
row[1].fg(),
"Default must differ from Indexed(0)"
);
assert_ne!(
row[1].fg(),
row[2].fg(),
"Indexed(0) must differ from Rgb(0,0,0)"
);
}
#[test]
fn round_trip_cell_flags_incl_layout_markers() {
let lead = Cell::from_parts(
'한',
Color::Default,
Color::Default,
CellFlags::BOLD | CellFlags::WIDE_CHAR,
);
let spacer = Cell::from_parts(
' ',
Color::Default,
Color::Default,
CellFlags::WIDE_CHAR_SPACER,
);
let frame = Frame {
cols: 80,
rows: 24,
kind: FrameKind::Partial,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![Span {
line: 5,
left: 0,
right: 1,
cells: vec![lead, spacer],
combining: BTreeMap::new(),
links: BTreeMap::new(),
}],
side_table: vec![],
link_table: vec![],
};
assert_eq!(decode(&encode(&frame)).expect("decode"), frame);
}
#[test]
fn decode_rejects_malformed_input() {
assert!(decode(&[]).is_err(), "empty");
assert!(decode(b"XXxxxxxxx").is_err(), "bad magic");
assert!(decode(b"JT").is_err(), "truncated mid-header");
}
#[test]
fn decode_rejects_superseded_version() {
let frame = Frame {
cols: 1,
rows: 1,
kind: FrameKind::Partial,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
};
let mut bytes = encode(&frame);
bytes[2] = 2; assert!(matches!(
decode(&bytes),
Err(justerm_core::DecodeError::BadVersion(2))
));
}
#[test]
fn round_trip_grapheme_side_table() {
let mut accented = Cell::from_parts('e', Color::Default, Color::Default, CellFlags::empty());
accented.set_combined(true);
let plain = Cell::from_parts('x', Color::Default, Color::Default, CellFlags::empty());
let frame = Frame {
cols: 80,
rows: 24,
kind: FrameKind::Partial,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![Span {
line: 0,
left: 0,
right: 1,
cells: vec![accented, plain],
combining: BTreeMap::from([(0, NonZeroU32::new(1).unwrap())]),
links: BTreeMap::new(),
}],
side_table: vec![vec!['\u{0301}']], link_table: vec![],
};
assert_eq!(decode(&encode(&frame)).expect("decode"), frame);
}
#[test]
fn cell_record_is_fixed_18_bytes() {
let span_of = |n: usize| Frame {
cols: 1,
rows: 1,
kind: FrameKind::Partial,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![Span {
line: 0,
left: 0,
right: (n - 1) as u16,
cells: vec![Cell::default(); n],
combining: BTreeMap::new(),
links: BTreeMap::new(),
}],
side_table: vec![],
link_table: vec![],
};
let one = encode(&span_of(1)).len();
let two = encode(&span_of(2)).len();
assert_eq!(two - one, 18, "each added cell must cost exactly 18 bytes");
}
#[test]
fn round_trip_scroll_op() {
let frame = Frame {
cols: 80,
rows: 24,
kind: FrameKind::Partial,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: Some(ScrollOp {
top: 0,
bottom: 23,
count: 3,
}),
spans: vec![],
side_table: vec![],
link_table: vec![],
};
assert_eq!(decode(&encode(&frame)).expect("decode"), frame);
}
#[test]
fn round_trip_full_frame_kind() {
let frame = Frame {
cols: 40,
rows: 12,
kind: FrameKind::Full,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
};
assert_eq!(decode(&encode(&frame)).expect("decode"), frame);
}
#[test]
fn engine_frame_captures_written_cells() {
let mut term = Engine::new(5, 2);
term.feed(b"hi");
let f = term.frame();
assert_eq!((f.cols, f.rows), (5, 2));
assert_eq!(f.kind, FrameKind::Partial);
let chars: String = f
.spans
.iter()
.filter(|s| s.line == 0)
.flat_map(|s| s.cells.iter().map(|c| c.c()))
.collect();
assert!(chars.contains('h') && chars.contains('i'), "got {chars:?}");
}
#[test]
fn engine_frame_reports_cursor_position() {
let mut term = Engine::new(80, 24);
term.feed(b"\x1b[10;20H"); let f = term.frame();
assert_eq!((f.cursor_row, f.cursor_col), (9, 19));
assert!(f.cursor_visible, "cursor is visible by default");
}
#[test]
fn engine_frame_reports_cursor_visibility_via_dectcem() {
let mut term = Engine::new(80, 24);
term.feed(b"\x1b[?25l"); assert!(!term.frame().cursor_visible, "hidden after ?25l");
term.feed(b"\x1b[?25h"); assert!(term.frame().cursor_visible, "visible again after ?25h");
}
#[test]
fn engine_cursor_visibility_is_independent_of_alt_screen() {
let mut term = Engine::new(80, 24);
term.feed(b"\x1b[?1049h\x1b[?25l\x1b[?1049l");
assert!(
!term.frame().cursor_visible,
"?25l on alt must persist after ?1049l"
);
let mut term = Engine::new(80, 24);
term.feed(b"\x1b[?25l\x1b[?1049h\x1b[?25h\x1b[?1049l");
assert!(
term.frame().cursor_visible,
"?25h on alt must persist after ?1049l"
);
}
#[test]
fn engine_frame_cursor_survives_resize_shrink() {
let mut term = Engine::new(80, 24);
term.feed(b"\x1b[20;70H"); term.reset_damage(); term.resize(10, 5); let f = term.frame(); assert!(
f.cursor_row < 5 && f.cursor_col < 10,
"cursor clamped to new bounds, got ({}, {})",
f.cursor_row,
f.cursor_col
);
}
#[test]
fn engine_frame_carries_scroll_op() {
let mut term = Engine::new(5, 2);
term.feed(b"x\r\ny\r\nz"); assert!(term.frame().scroll.is_some());
}
#[test]
fn engine_frame_is_full_after_resize() {
let mut term = Engine::new(5, 2);
term.feed(b"hi");
term.resize(6, 3);
let f = term.frame();
assert_eq!(f.kind, FrameKind::Full);
assert_eq!((f.cols, f.rows), (6, 3));
assert_eq!(f.spans.len(), 3, "Full ships every row");
}
#[test]
fn engine_frame_ships_only_live_combining_clusters() {
let mut term = Engine::new(5, 1);
term.feed("e\u{0301}".as_bytes()); term.feed(b"\rx"); term.feed("o\u{0308}".as_bytes()); let f = term.frame();
assert_eq!(
f.side_table,
vec![vec!['\u{0308}']],
"only the live cluster ships"
);
let span = f
.spans
.iter()
.find(|s| !s.combining.is_empty())
.expect("a span with combining");
let (&col, idx) = span.combining.iter().next().unwrap();
assert_eq!(idx.get(), 1, "the live cluster is frame-local index 1");
assert_eq!(span.cells[col].c(), 'o');
assert!(span.cells[col].is_combined());
}
#[test]
fn engine_frame_round_trips_through_bytes() {
let mut term = Engine::new(8, 1);
term.feed("\x1b[31m한e\u{0301}".as_bytes());
let f = term.frame();
assert_eq!(decode(&encode(&f)).expect("decode"), f);
}
#[test]
fn engine_frame_round_trips_real_captures() {
for raw in [
include_bytes!("fixtures/vim_redraw.raw").as_slice(),
include_bytes!("fixtures/top.raw").as_slice(),
include_bytes!("fixtures/htop.raw").as_slice(),
] {
let mut term = Engine::new(80, 24);
term.feed(raw);
let f = term.frame();
assert_eq!(
decode(&encode(&f)).expect("decode"),
f,
"real-capture round-trip"
);
}
}
#[test]
fn decode_rejects_span_with_left_past_right() {
let mut b = Vec::new();
b.extend_from_slice(b"JT"); b.push(1); b.push(0); b.push(1); b.extend_from_slice(&80u16.to_le_bytes()); b.extend_from_slice(&24u16.to_le_bytes()); b.extend_from_slice(&1u16.to_le_bytes()); b.extend_from_slice(&0u16.to_le_bytes()); b.extend_from_slice(&5u16.to_le_bytes()); b.extend_from_slice(&0u16.to_le_bytes()); assert!(decode(&b).is_err(), "left>right must error, not panic");
}
#[test]
fn decode_rejects_span_length_u16_overflow() {
let mut b = Vec::new();
b.extend_from_slice(b"JT"); b.push(1); b.push(0); b.push(1); b.extend_from_slice(&80u16.to_le_bytes()); b.extend_from_slice(&24u16.to_le_bytes()); b.extend_from_slice(&1u16.to_le_bytes()); b.extend_from_slice(&0u16.to_le_bytes()); b.extend_from_slice(&0u16.to_le_bytes()); b.extend_from_slice(&65535u16.to_le_bytes()); assert!(
decode(&b).is_err(),
"u16-overflowing span length must error, not panic"
);
}
#[test]
fn round_trip_full_frame_with_cells() {
let row = |line| Span {
line,
left: 0,
right: 2,
cells: "abc"
.chars()
.map(|c| Cell::from_parts(c, Color::Default, Color::Default, CellFlags::empty()))
.collect(),
combining: BTreeMap::new(),
links: BTreeMap::new(),
};
let frame = Frame {
cols: 3,
rows: 2,
kind: FrameKind::Full,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: None,
spans: vec![row(0), row(1)],
side_table: vec![],
link_table: vec![],
};
assert_eq!(decode(&encode(&frame)).expect("decode"), frame);
}
#[test]
fn round_trip_negative_scroll_count() {
let frame = Frame {
cols: 80,
rows: 24,
kind: FrameKind::Partial,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
cursor_shape: justerm_core::CursorShape::Block,
cursor_blink: false,
scroll: Some(ScrollOp {
top: 2,
bottom: 23,
count: -4,
}),
spans: vec![],
side_table: vec![],
link_table: vec![],
};
assert_eq!(decode(&encode(&frame)).expect("decode"), frame);
}
#[test]
fn engine_frame_undamaged_is_empty_partial_not_full() {
let mut term = Engine::new(5, 2);
term.feed(b"hi");
term.reset_damage(); let f = term.frame();
assert_eq!(f.kind, FrameKind::Partial);
assert!(f.spans.is_empty(), "no damage since ack -> no spans");
assert!(f.scroll.is_none());
}