use core::num::NonZeroU32;
use justerm_core::{
Cell, CellFlags, Color, Engine, Frame, FrameKind, MarkerId, MarkerKind, MarkerLine,
MarkerPosition, MouseEvents, Overlay, ScrollOp, SelectionSpan, SelectionType, Side, 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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Default::default(),
};
let bytes = encode(&frame);
assert_eq!(decode(&bytes).expect("decode"), frame);
}
#[test]
fn round_trip_overlay_selection_and_match_spans() {
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Overlay {
selection: vec![
SelectionSpan {
row: 0,
left: 2,
right: 7,
},
SelectionSpan {
row: 1,
left: 0,
right: 4,
},
],
matches: vec![SelectionSpan {
row: 3,
left: 10,
right: 12,
}],
markers: vec![],
marker_lines: vec![],
},
};
let bytes = encode(&frame);
assert_eq!(decode(&bytes).expect("decode"), frame);
}
#[test]
fn round_trip_full_frame_all_fields_present() {
let mut term = Engine::with_scrollback(20, 3, 100);
term.feed(b"L0\r\nL1\r\nL2\r\nL3\r\nL4"); term.feed(b"\x1b[?1002h"); term.selection_begin(0, 0, Side::Left, SelectionType::Char);
term.selection_extend(0, 2, Side::Right);
term.add_marker(1);
let frame = term.frame();
assert!(frame.scroll.is_some() && !frame.mouse_events.is_empty());
assert!(!frame.overlay.selection.is_empty() && !frame.overlay.markers.is_empty());
let bytes = encode(&frame);
assert_eq!(decode(&bytes).expect("decode"), frame);
}
#[test]
fn round_trip_mouse_events_mask() {
let mut 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,
display_offset: 0,
scrollback_len: 0,
mouse_events: MouseEvents::empty(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Overlay::default(),
};
frame.mouse_events = MouseEvents::DOWN | MouseEvents::UP | MouseEvents::WHEEL;
let bytes = encode(&frame);
assert_eq!(decode(&bytes).expect("decode"), frame);
}
#[test]
fn round_trip_alt_screen_flag() {
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: MouseEvents::empty(),
alt_screen: true,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Overlay::default(),
};
let decoded = decode(&encode(&frame)).expect("decode");
assert!(decoded.alt_screen);
assert_eq!(decoded, frame);
}
#[test]
fn round_trip_overlay_marker_positions() {
let mut 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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Overlay::default(),
};
frame.overlay.markers = vec![
MarkerPosition {
id: MarkerId(5),
row: 3,
kind: MarkerKind::Plain,
},
MarkerPosition {
id: MarkerId(99),
row: 0,
kind: MarkerKind::Plain,
},
];
let bytes = encode(&frame);
assert_eq!(decode(&bytes).expect("decode"), frame);
}
#[test]
fn round_trip_overlay_marker_lines() {
let mut 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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Overlay::default(),
};
frame.overlay.marker_lines = vec![
MarkerLine {
id: MarkerId(5),
line: 3,
},
MarkerLine {
id: MarkerId(99),
line: 100_000, },
];
let bytes = encode(&frame);
assert_eq!(decode(&bytes).expect("decode"), frame);
}
#[test]
fn frame_reports_off_viewport_marker_in_marker_lines() {
let mut t = Engine::new(10, 3); let id = t.add_marker(0); t.feed(b"a\r\nb\r\nc\r\nd\r\ne\r\nf\r\n");
let frame = t.frame();
assert!(
frame.overlay.markers.iter().all(|m| m.id != id),
"off-viewport marker is absent from the viewport group"
);
assert_eq!(
frame.overlay.marker_lines,
vec![MarkerLine { id, line: 0 }],
"but present in marker_lines at its absolute line"
);
}
#[test]
fn round_trip_overlay_marker_kinds() {
let mut 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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Overlay::default(),
};
frame.overlay.markers = vec![
MarkerPosition {
id: MarkerId(1),
row: 0,
kind: MarkerKind::Plain,
},
MarkerPosition {
id: MarkerId(2),
row: 1,
kind: MarkerKind::PromptStart,
},
MarkerPosition {
id: MarkerId(3),
row: 2,
kind: MarkerKind::CommandStart,
},
MarkerPosition {
id: MarkerId(4),
row: 3,
kind: MarkerKind::OutputStart,
},
MarkerPosition {
id: MarkerId(5),
row: 4,
kind: MarkerKind::CommandFinished(Some(130)),
},
MarkerPosition {
id: MarkerId(6),
row: 5,
kind: MarkerKind::CommandFinished(None),
},
];
let bytes = encode(&frame);
assert_eq!(decode(&bytes).expect("decode"), frame);
}
#[test]
fn decode_rejects_bad_marker_kind_and_truncated_exit() {
let mut 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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Overlay::default(),
};
frame.overlay.markers = vec![MarkerPosition {
id: MarkerId(1),
row: 0,
kind: MarkerKind::CommandFinished(Some(7)),
}];
let bytes = encode(&frame);
let mut bad_kind = bytes.clone();
let k = bad_kind.len() - 8;
bad_kind[k] = 5; assert!(
decode(&bad_kind).is_err(),
"an unknown marker kind must be rejected, not misread"
);
let mut truncated = bytes.clone();
truncated.truncate(truncated.len() - 4); assert!(
decode(&truncated).is_err(),
"a truncated exit must error, not panic"
);
}
#[test]
fn round_trip_scroll_position() {
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,
display_offset: 7,
scrollback_len: 250,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Default::default(),
};
let decoded = decode(&encode(&frame)).expect("decode");
assert_eq!(decoded.display_offset, 7);
assert_eq!(decoded.scrollback_len, 250);
assert_eq!(decoded, 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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Default::default(),
};
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: 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![],
overlay: Default::default(),
};
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: 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![],
overlay: Default::default(),
};
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: 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![],
overlay: Default::default(),
};
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Default::default(),
};
let mut bytes = encode(&frame);
bytes[2] = 2; assert!(matches!(
decode(&bytes),
Err(justerm_core::DecodeError::BadVersion(2))
));
}
#[test]
fn wire_version_is_eleven() {
assert_eq!(justerm_core::WIRE_VERSION, 11);
let mut term = Engine::new(1, 1);
term.feed(b"x");
let bytes = encode(&term.frame());
assert_eq!(bytes[2], justerm_core::WIRE_VERSION); }
#[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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: 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![],
overlay: Default::default(),
};
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: 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![],
overlay: Default::default(),
};
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: Some(ScrollOp {
top: 0,
bottom: 23,
count: 3,
}),
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Default::default(),
};
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Default::default(),
};
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: None,
spans: vec![row(0), row(1)],
side_table: vec![],
link_table: vec![],
overlay: Default::default(),
};
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,
display_offset: 0,
scrollback_len: 0,
mouse_events: Default::default(),
alt_screen: false,
scroll: Some(ScrollOp {
top: 2,
bottom: 23,
count: -4,
}),
spans: vec![],
side_table: vec![],
link_table: vec![],
overlay: Default::default(),
};
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());
}