use vterm::Term;
fn rich_stream() -> Vec<u8> {
let mut s = Vec::new();
for i in 0..30 {
s.extend_from_slice(format!("line {i} plain text\r\n").as_bytes());
}
s.extend_from_slice(
b"\x1b[1;31mbold red\x1b[0m \x1b[38;5;208m256\x1b[48;2;10;20;30mrgb\x1b[0m\r\n",
);
s.extend_from_slice(b"\x1b[3;4;7;9mital-under-rev-strike\x1b[0m\x1b[2mdim\x1b[22m\r\n");
s.extend_from_slice(b"\x1b[5;10Hmoved\x1b7saved\x1b[20;1Helsewhere\x1b8back\r\n");
s.extend_from_slice(b"\x1b[3g\x1b[1;5H\x1bH\x1b[1;13H\x1bH\r\ta\tb\tc\r\n");
s.extend_from_slice(b"\x1b[4;12r");
for i in 0..20 {
s.extend_from_slice(format!("\x1b[12;1Hscroll region {i}\n").as_bytes());
}
s.extend_from_slice(b"\x1b[r\x1b[?1049halternate screen\x1b[2J\x1b[Hon alt\x1b[?1049l");
s.extend_from_slice("wide: 中文字 and é\r\n".as_bytes());
s.extend_from_slice(b"\x1b[24;75Hedge-wraps-here and on\r\n");
s.extend_from_slice(b"\x1b[?7l\x1b[23;70Hno autowrap past the edge\x1b[?7h\r\n");
s.extend_from_slice(b"\x1b[?25l\x1b[?2026hsync part one\x1b[1;1Hdrawn\x1b[?2026l\x1b[?25h");
s.extend_from_slice(b"\x1b]0;a window title\x07\x1bPq#0;2;0;0;0\x1b\\");
s.extend_from_slice(b"\x1b[2;2H\x1b[K\x1b[1J\x1b[3;3H\x1b[J done\r\n");
s.extend_from_slice(b"\x1b[0m");
s
}
fn fed(bytes: &[u8]) -> Term {
let mut t = Term::new(24, 80);
t.feed(bytes);
t
}
#[test]
fn every_checkpointable_split_restores_exactly() {
let stream = rich_stream();
let whole = fed(&stream);
let final_state = whole
.checkpoint()
.expect("the stream ends between sequences");
let mut checked = 0;
for split in 0..=stream.len() {
let mut original = fed(&stream[..split]);
let Some(ck) = original.checkpoint() else {
continue; };
let mut restored = Term::restore(&ck).expect("a fresh checkpoint restores");
assert_eq!(
restored.checkpoint().as_deref(),
Some(&ck[..]),
"round trip at {split}"
);
original.feed(&stream[split..]);
restored.feed(&stream[split..]);
assert_eq!(
restored.screen(),
original.screen(),
"screen after resuming at {split}"
);
assert_eq!(
restored.checkpoint(),
Some(final_state.clone()),
"full state after resuming at {split}"
);
checked += 1;
}
assert!(
checked > stream.len() / 2,
"only {checked} of {} splits checked",
stream.len()
);
}
#[test]
fn chunked_feeding_matches_one_shot_feeding_after_restore() {
let stream = rich_stream();
let expected = fed(&stream).checkpoint().unwrap();
for chunk in [1usize, 3, 7, 64, 1000] {
let mut t = Term::new(24, 80);
for piece in stream.chunks(chunk) {
t.feed(piece);
if let Some(ck) = t.checkpoint() {
t = Term::restore(&ck).unwrap();
}
}
assert_eq!(t.checkpoint().unwrap(), expected, "chunk size {chunk}");
}
}
#[test]
fn no_checkpoint_mid_sequence_or_mid_character() {
for partial in [
&b"\x1b"[..],
b"\x1b[1;3",
b"\x1b]0;tit",
b"\x1bPdata",
"é".as_bytes().split_at(1).0,
] {
let t = fed(partial);
assert!(!t.can_checkpoint(), "{partial:?}");
assert_eq!(t.checkpoint(), None, "{partial:?}");
}
assert!(fed(b"plain").can_checkpoint());
}
#[test]
fn damaged_checkpoints_are_rejected_never_panic() {
let ck = fed(&rich_stream()).checkpoint().unwrap();
for len in 0..ck.len() {
assert!(Term::restore(&ck[..len]).is_err(), "truncated to {len}");
}
let mut longer = ck.clone();
longer.push(0);
assert!(Term::restore(&longer).is_err(), "trailing byte");
for i in 0..ck.len() {
let mut bad = ck.clone();
bad[i] ^= 0xFF;
let _ = Term::restore(&bad);
}
assert!(Term::restore(b"not a checkpoint at all").is_err());
}
#[test]
fn checkpoints_are_small_for_ordinary_screens() {
let ck = fed(&rich_stream()).checkpoint().unwrap();
assert!(ck.len() < 32 * 1024, "checkpoint is {} bytes", ck.len());
}
#[test]
#[ignore]
fn measure_checkpoint_cost() {
use std::time::Instant;
let mut agent = Vec::new();
for i in 0..5000 {
agent.extend_from_slice(
format!(
"\x1b[32m Compiling\x1b[0m crate-{i} v0.{}.{} (build step {})\r\n",
i % 9,
i % 13,
i * 7
)
.as_bytes(),
);
}
for (rows, cols) in [(24usize, 80usize), (50, 200)] {
let mut t = Term::new(rows, cols);
t.feed(&agent);
let n = 2000;
let start = Instant::now();
let mut ck = Vec::new();
for _ in 0..n {
ck = t.checkpoint().unwrap();
}
let save = start.elapsed() / n;
let start = Instant::now();
for _ in 0..n {
std::hint::black_box(Term::restore(&ck).unwrap());
}
let load = start.elapsed() / n;
println!(
"{rows}x{cols}: checkpoint {} bytes, save {:.1} us, restore {:.1} us",
ck.len(),
save.as_secs_f64() * 1e6,
load.as_secs_f64() * 1e6
);
}
}
#[test]
fn new_line_mode_survives_a_checkpoint() {
let mut t = Term::new(4, 10);
t.feed(b"\x1b[20hab");
let ck = t.checkpoint().expect("at ground");
let mut r = Term::restore(&ck).expect("restore");
r.feed(b"\ncd");
assert_eq!(r.screen().cell(1, 0).ch, 'c', "LNM still on after restore");
}
#[test]
fn bracketed_paste_and_cursor_visibility_are_tracked_and_survive_a_checkpoint() {
let mut t = Term::new(4, 10);
assert!(!t.bracketed_paste() && t.cursor_visible(), "defaults");
t.feed(b"\x1b[?2004h\x1b[?25l");
assert!(t.bracketed_paste() && !t.cursor_visible());
let ck = t.checkpoint().expect("at ground");
let mut r = Term::restore(&ck).expect("restore");
assert!(r.bracketed_paste() && !r.cursor_visible(), "restored");
r.feed(b"\x1b[?2004l\x1b[?25h");
assert!(!r.bracketed_paste() && r.cursor_visible());
}
#[test]
fn checkpoints_from_older_versions_still_restore() {
for (bytes, version, bracketed) in [
(
&include_bytes!("fixtures/checkpoint-v1.bin")[..],
1u16,
false,
),
(&include_bytes!("fixtures/checkpoint-v2.bin")[..], 2, true),
] {
assert_eq!(&bytes[4..6], &version.to_le_bytes());
let r = Term::restore(bytes).expect("an older checkpoint restores");
assert_eq!(r.bracketed_paste(), bracketed, "version {version}");
assert_eq!(r.screen().cell(0, 0).ch, 'a');
assert_eq!(r.screen().cell(1, 0).ch, 'i');
assert!(!r.screen().row_wrapped(0), "no wrap marks before version 3");
}
let mut ck = include_bytes!("fixtures/checkpoint-v2.bin").to_vec();
ck[4..6].copy_from_slice(&9u16.to_le_bytes());
assert!(Term::restore(&ck).is_err());
}
#[test]
fn an_autowrapped_row_is_marked_and_the_mark_survives_a_checkpoint() {
let mut t = Term::new(3, 8);
t.feed(b"abcdefghij\r\nok");
let s = t.screen();
assert!(s.row_wrapped(0), "abcdefgh ran on into ij");
assert!(!s.row_wrapped(1), "ij ended in a line break");
let ck = t.checkpoint().expect("at ground");
let r = Term::restore(&ck).expect("restore");
assert!(r.screen().row_wrapped(0) && !r.screen().row_wrapped(1));
let mut t = r;
t.feed(b"\x1b[1;3H\x1b[K");
assert!(!t.screen().row_wrapped(0));
}
#[test]
fn a_wrapped_line_keeps_its_mark_in_the_history() {
let mut t = Term::with_history(2, 4, 10);
t.feed(b"abcdefgh\r\n\r\n");
let h = t.scrollback();
assert_eq!(h.history_len(), 2);
assert!(h.history_wrapped(1), "abcd, older, ran on");
assert!(!h.history_wrapped(0), "efgh ended with a line break");
}
#[test]
fn a_restored_terminal_keeps_its_scrollback_capacity() {
let mut t = Term::with_history(4, 10, 100);
t.set_history_bytes(1 << 20);
for i in 0..50 {
t.feed(format!("line{i}\r\n").as_bytes());
}
assert!(t.screen().history_len() > 0);
let ck = t.checkpoint().expect("at rest");
let mut r = Term::restore(&ck).expect("restore");
assert_eq!(
r.screen().history_capacity(),
100,
"the capacity is carried"
);
assert_eq!(
r.screen().history_len(),
0,
"the lines are not: they live on disk"
);
for i in 0..30 {
r.feed(format!("more{i}\r\n").as_bytes());
}
let recorded = r.screen().history_len();
assert!(recorded > 0 && recorded <= 100, "records again: {recorded}");
r.resize(5, 12);
assert_eq!(r.screen().history_capacity(), 100, "and through a resize");
assert_eq!(r.screen().history_len(), recorded);
let cap = 64 * 1024;
let mut t = Term::with_history(4, 10, 20_000);
t.set_history_bytes(cap);
let ck = t.checkpoint().unwrap();
let mut r = Term::restore(&ck).unwrap();
let mut fresh = Term::with_history(4, 10, 20_000);
fresh.set_history_bytes(cap);
for i in 0..10_000 {
let line = format!("line{i}\r\n");
r.feed(line.as_bytes());
fresh.feed(line.as_bytes());
}
let (h, f) = (r.screen(), fresh.screen());
assert!(
h.history_len() < 10_000,
"the oldest went: {}",
h.history_len()
);
assert_eq!(
(h.history_len(), h.history_bytes()),
(f.history_len(), f.history_bytes())
);
let r = Term::restore(&Term::new(4, 10).checkpoint().unwrap()).unwrap();
assert_eq!(r.screen().history_capacity(), 0);
}