use justerm_core::{Engine, MarkerKind, TermEvent};
fn four_commands() -> Engine {
let mut e = Engine::with_scrollback(16, 6, 40);
for i in 0..4 {
e.feed(
format!("\x1b]133;A\x07$ \x1b]133;B\x07c{i}\x1b]133;C\x07o\r\n\x1b]133;D;0\x07")
.as_bytes(),
);
}
e.drain_events();
e
}
fn lines(e: &Engine) -> Vec<(usize, String, Option<i32>)> {
e.command_lines()
.into_iter()
.map(|c| (c.line, c.command, c.exit))
.collect()
}
fn disposed(e: &mut Engine) -> usize {
e.drain_events()
.into_iter()
.filter(|ev| matches!(ev, TermEvent::MarkerDisposed(_)))
.count()
}
#[test]
fn the_commands_are_reported_before_anything_is_erased() {
let e = four_commands();
assert_eq!(
lines(&e),
vec![
(0, "c0".into(), Some(0)),
(1, "c1".into(), Some(0)),
(2, "c2".into(), Some(0)),
(3, "c3".into(), Some(0)),
]
);
}
#[test]
fn ed_2_disposes_the_marks_on_every_row_it_blanks() {
let mut e = four_commands();
e.feed(b"\x1b[H\x1b[2J");
assert_eq!(lines(&e), vec![], "no command survives a cleared screen");
assert!(e.command_marks().is_empty(), "and no mark does either");
assert_eq!(disposed(&mut e), 16, "each of the four groups' four marks");
}
#[test]
fn a_command_run_after_a_clear_is_reported_exactly_once() {
let mut e = four_commands();
e.feed(b"\x1b[H\x1b[2J");
e.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07new\x1b]133;C\x07n\r\n\x1b]133;D;0\x07");
assert_eq!(lines(&e), vec![(0, "new".into(), Some(0))]);
}
#[test]
fn ed_0_disposes_the_rows_below_and_keeps_the_cursor_row_marks() {
let mut e = four_commands();
e.feed(b"\x1b[2;1H\x1b[0J");
assert_eq!(
lines(&e),
vec![(0, "c0".into(), Some(0)), (1, "c1".into(), Some(0))],
"rows 2..5 are blanked whole and retire; row 0 is untouched and row 1 is the \
cursor row"
);
}
#[test]
fn ed_1_disposes_the_rows_above_and_keeps_the_cursor_row_marks() {
let mut e = four_commands();
e.feed(b"\x1b[3;1H\x1b[1J");
assert_eq!(
lines(&e),
vec![(2, "c2".into(), Some(0)), (3, "c3".into(), Some(0))],
"rows 0 and 1 retire; row 2 is the cursor row and keeps c2. The document lines \
do NOT renumber — a blanked row is still a hard-ended row, so `doc_line_of` \
still counts it. `line` stays derived rather than frozen precisely because it \
is the half the anchor fixups do maintain"
);
}
#[test]
fn ed_1_at_the_last_column_blanks_the_row_and_retires_nothing() {
let mut e = four_commands();
e.feed(b"\x1b[1;16H\x1b[1J");
assert_eq!(e.command_marks().len(), 16, "no whole row is above row 0");
assert_eq!(disposed(&mut e), 0);
assert_eq!(
lines(&e),
vec![
(0, "c0".into(), Some(0)),
(1, "c1".into(), Some(0)),
(2, "c2".into(), Some(0)),
(3, "c3".into(), Some(0)),
],
"all four still reported, and c0's row is blank — the widest form of the \
cursor-row residue"
);
assert!(
e.accessible_text().starts_with('\n'),
"row 0 really is blank: {:?}",
e.accessible_text()
);
}
#[test]
fn el_2_does_not_dispose_the_mark_on_the_row_it_blanks() {
let mut e = four_commands();
e.feed(b"\x1b[1;1H\x1b[2K");
assert_eq!(e.command_marks().len(), 16, "EL retires nothing");
assert_eq!(disposed(&mut e), 0);
assert_eq!(
lines(&e)[0],
(0, "c0".into(), Some(0)),
"and because the text is captured, the surviving mark still answers the \
command that ran rather than the blanks now under it"
);
}
#[test]
fn ed_2_leaves_the_marks_that_have_scrolled_into_scrollback() {
let mut e = Engine::with_scrollback(16, 3, 40);
for i in 0..4 {
e.feed(
format!("\x1b]133;A\x07$ \x1b]133;B\x07c{i}\x1b]133;C\x07o\r\n\x1b]133;D;0\x07")
.as_bytes(),
);
}
e.drain_events();
let before = lines(&e);
assert!(before.len() >= 2, "fixture must push some rows off-screen");
e.feed(b"\x1b[H\x1b[2J");
let after = lines(&e);
assert!(
!after.is_empty() && after.len() < before.len(),
"the scrolled-off commands survive and the on-screen ones do not: \
before {before:?}, after {after:?}"
);
assert_eq!(after, before[..after.len()].to_vec());
}
#[test]
fn an_erase_on_the_alt_screen_does_not_dispose_primary_command_marks() {
let mut e = four_commands();
let before = lines(&e);
e.feed(b"\x1b[?1049h"); e.feed(b"\x1b[H\x1b[2J");
assert_eq!(lines(&e), before, "while on alt");
e.feed(b"\x1b[?1049l"); assert_eq!(lines(&e), before, "and after leaving");
}
fn three_coded_commands() -> Engine {
let mut e = Engine::with_scrollback(16, 6, 40);
for (i, code) in [1, 2, 3].iter().enumerate() {
e.feed(
format!("\x1b]133;A\x07$ \x1b]133;B\x07a{i}\x1b]133;C\x07o\r\n\x1b]133;D;{code}\x07")
.as_bytes(),
);
}
e.drain_events();
e
}
#[test]
fn a_command_keeps_its_exit_when_an_erase_takes_only_its_finished_mark() {
let mut e = three_coded_commands();
e.feed(b"\x1b[2;1H\x1b[0J");
assert_eq!(
lines(&e),
vec![(0, "a0".into(), Some(1)), (1, "a1".into(), Some(2))],
"a1 survives whole and keeps its own code, though the mark that carried it is \
gone"
);
}
#[test]
fn a_hole_in_the_marks_does_not_move_an_exit_code_onto_another_command() {
let mut e = three_coded_commands();
let victims: Vec<_> = e
.command_marks()
.into_iter()
.filter(|(_, line, _)| *line == 1)
.map(|(id, _, _)| id)
.collect();
assert_eq!(victims.len(), 4, "a0's D plus the whole of a1");
for id in victims {
e.remove_marker(id);
}
assert_eq!(
lines(&e),
vec![(0, "a0".into(), Some(1)), (2, "a2".into(), Some(3))],
"a0 keeps its own code and a1 is simply absent — a2 stays at its own document line, since disposing a mark moves no content"
);
}
#[test]
fn a_surviving_command_keeps_its_exit_when_only_its_finished_mark_is_disposed() {
let mut e = four_commands();
let d_ids: Vec<_> = e
.command_marks()
.into_iter()
.filter(|(_, _, k)| matches!(k, MarkerKind::CommandFinished(_)))
.map(|(id, _, _)| id)
.collect();
assert_eq!(d_ids.len(), 4);
for id in d_ids {
e.remove_marker(id);
}
assert_eq!(
lines(&e),
vec![
(0, "c0".into(), Some(0)),
(1, "c1".into(), Some(0)),
(2, "c2".into(), Some(0)),
(3, "c3".into(), Some(0)),
]
);
}
#[test]
fn an_overwrite_of_the_command_row_does_not_re_borrow_its_cells() {
let mut e = four_commands();
e.feed(b"\x1b[1;3HZZ");
assert_eq!(
lines(&e)[0],
(0, "c0".into(), Some(0)),
"the command that ran, not the cells now standing where it was"
);
}
#[test]
fn an_in_line_shift_does_not_re_borrow_the_command_row() {
let mut e = four_commands();
e.feed(b"\x1b[1;3H\x1b[1P"); assert_eq!(lines(&e)[0].1, "c0");
let mut e = four_commands();
e.feed(b"\x1b[1;3H\x1b[3@"); assert_eq!(lines(&e)[0].1, "c0");
}
#[test]
fn a_wrapped_command_is_captured_across_its_rows() {
let mut e = Engine::with_scrollback(8, 6, 40);
e.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07abcdefghij\x1b]133;C\x07o\r\n\x1b]133;D;0\x07");
e.drain_events();
assert_eq!(lines(&e)[0].1, "abcdefghij");
e.feed(b"\x1b[1;1H\x1b[2K");
assert_eq!(lines(&e)[0].1, "abcdefghij", "still the captured text");
}
#[test]
fn a_command_with_no_output_start_is_still_omitted() {
let mut e = four_commands();
e.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07typing");
assert_eq!(
lines(&e).len(),
4,
"the four finished ones, and not the fifth"
);
}
#[test]
fn a_capture_is_bounded_by_max_command_text() {
let mut e = Engine::with_scrollback(64, 40, 4000);
e.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07");
for _ in 0..400 {
e.feed(b"0123456789012345678901234567890123456789012345678901234567890123");
}
e.feed(b"\x1b]133;C\x07o\r\n\x1b]133;D;0\x07");
let captured = &lines(&e)[0].1;
assert!(
captured.chars().count() <= justerm_core::MAX_COMMAND_TEXT,
"captured {} chars, cap is {}",
captured.chars().count(),
justerm_core::MAX_COMMAND_TEXT
);
assert!(
!captured.is_empty(),
"truncation keeps the announceable prefix rather than dropping the answer"
);
}