use justerm_core::{Engine, SelectionType, Side};
const HTOP_PRE: &[u8] = include_bytes!("fixtures/alt_resize_htop.pre.raw");
const HTOP_POST: &[u8] = include_bytes!("fixtures/alt_resize_htop.post.raw");
const VIM_PRE: &[u8] = include_bytes!("fixtures/alt_resize_vim.pre.raw");
const VIM_POST: &[u8] = include_bytes!("fixtures/alt_resize_vim.post.raw");
fn alt_engine_with_a_full_selection() -> Engine {
let mut e = Engine::new(4, 3);
e.feed(b"\x1b[?1049h"); e.feed(b"abc\r\ndef\r\nghi");
e.selection_begin(0, 0, Side::Left, SelectionType::Char);
e.selection_extend(2, 3, Side::Right); e
}
#[test]
fn an_alt_screen_selection_does_not_survive_the_grid_shrinking_under_it() {
let mut e = alt_engine_with_a_full_selection();
assert!(
!e.selection_range().is_empty(),
"fixture: the selection must exist before the resize for this to assert anything"
);
e.resize(3, 2);
assert!(
e.selection_range().is_empty(),
"a shrink on a pane that does not reflow drops it rather than moving its ends by \
two different rules"
);
assert_eq!(
e.selection_text(),
None,
"and the text extraction agrees — a half-dropped selection is worse than none"
);
}
#[test]
fn frame_still_builds_after_the_alt_screen_shrinks_under_a_selection() {
let mut e = alt_engine_with_a_full_selection();
e.resize(3, 2);
let frame = e.frame();
assert_eq!((frame.cols, frame.rows), (3, 2));
assert!(frame.overlay.selection.is_empty());
}
#[test]
fn a_width_only_shrink_on_the_alt_screen_drops_it_too() {
let mut e = alt_engine_with_a_full_selection();
e.resize(2, 3);
assert!(e.selection_range().is_empty());
}
#[test]
fn a_resize_that_changes_nothing_leaves_an_alt_selection_alone() {
let mut e = alt_engine_with_a_full_selection();
let before = e.selection_range();
e.resize(4, 3);
assert_eq!(
e.selection_range(),
before,
"same geometry, same selection — nothing moved under it"
);
}
#[test]
fn a_primary_screen_selection_still_re_anchors_through_a_resize() {
let mut e = Engine::new(4, 3);
e.feed(b"abc\r\ndef\r\nghi");
e.selection_begin(0, 0, Side::Left, SelectionType::Char);
e.selection_extend(2, 3, Side::Right);
assert!(!e.selection_range().is_empty());
e.resize(3, 2);
assert!(
!e.selection_range().is_empty(),
"a reflowing pane re-anchors the selection instead of dropping it"
);
}
#[test]
fn an_alt_selection_survives_everything_that_is_not_a_resize() {
let mut e = alt_engine_with_a_full_selection();
let before = e.selection_range();
e.feed(b"\x1b[1;3r\x1b[3;1H");
e.feed(b"\n");
assert!(
!e.selection_range().is_empty(),
"content moved under the selection; the anchors rotate, they do not vanish"
);
assert_ne!(
e.selection_range(),
before,
"and they really did move — an unchanged range would mean the rotation never ran"
);
}
fn a_real_app_selection_survives_a_real_sigwinch(pre: &[u8], post: &[u8], label: &str) {
let mut t = Engine::new(80, 24);
t.feed(pre);
assert!(
t.grid().rows() == 24,
"fixture: {label} is on a 24-row screen"
);
t.selection_begin(0, 0, Side::Left, SelectionType::Char);
t.selection_extend(23, 79, Side::Right);
assert!(
!t.selection_range().is_empty(),
"fixture: {label} must have a live selection before the resize"
);
t.resize(40, 18); let frame = t.frame();
assert!(
frame.overlay.selection.is_empty(),
"{label}: the anchor had no re-anchoring path, so it goes"
);
t.feed(post);
assert!(
!t.accessible_text().trim().is_empty(),
"{label}: the application's own repaint still lands after the drop"
);
}
#[test]
fn a_real_htop_selection_survives_a_real_sigwinch() {
a_real_app_selection_survives_a_real_sigwinch(HTOP_PRE, HTOP_POST, "htop");
}
#[test]
fn a_real_vim_selection_survives_a_real_sigwinch() {
a_real_app_selection_survives_a_real_sigwinch(VIM_PRE, VIM_POST, "vim");
}
#[test]
fn a_selection_row_past_the_last_visible_one_is_clamped_at_the_anchor() {
let mut e = Engine::new(4, 3);
e.feed(b"abc\r\ndef\r\nghi");
e.selection_begin(0, 0, Side::Left, SelectionType::Char);
e.selection_extend(3, 3, Side::Right);
let spans = e.selection_range();
assert_eq!(
spans.len(),
3,
"clamped to the last row, so the selection covers the whole visible grid"
);
assert!(
spans.iter().all(|s| s.row < 3),
"and nothing lands off-grid"
);
assert!(e.selection_text().is_some());
let _ = e.frame();
}
#[test]
fn a_wildly_out_of_range_selection_row_is_survivable_on_every_read() {
for ty in [
SelectionType::Char,
SelectionType::Word,
SelectionType::Line,
SelectionType::Block,
] {
let mut e = Engine::new(4, 3);
e.feed(b"abc\r\ndef\r\nghi");
e.selection_begin(0, 0, Side::Left, ty);
e.selection_extend(99, 3, Side::Right);
let spans = e.selection_range();
assert!(
spans.iter().all(|s| s.row < 3),
"{ty:?}: every span is on the grid"
);
let _ = e.selection_text();
let _ = e.accessible_text();
let _ = e.frame();
}
}
#[test]
fn growing_the_alt_screen_drops_it_too_and_not_for_the_obvious_reason() {
let mut e = alt_engine_with_a_full_selection();
assert!(
!e.selection_range().is_empty(),
"fixture: a live selection first"
);
e.resize(4, 6); assert!(
e.selection_range().is_empty(),
"a grow moves the base the anchor is measured from, so it goes too"
);
assert_eq!(e.selection_text(), None);
}
#[test]
fn no_verb_leaves_an_anchor_the_readers_cannot_walk() {
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0 >> 33
}
fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
&xs[(self.next() as usize) % xs.len()]
}
}
const VERBS: &[&[u8]] = &[
b"\x1b[?1049h",
b"\x1b[?1049l",
b"\x1b[?47h",
b"\x1b[?1047l",
b"\x1b[2J",
b"\x1b[3J",
b"\x1b[1J",
b"\x1b[2K",
b"\x1b[1;3r",
b"\x1b[2;4r",
b"\x1b[r",
b"\x1b[S",
b"\x1b[T",
b"\x1b[L",
b"\x1b[M",
b"\x1bM",
b"\x1b[?3h",
b"\x1b[?3l",
b"\x1bc",
b"\x1b[!p",
b"hello\r\n",
b"\n\n\n",
b"\x1b[10;10H",
"wide 中文 text\r\n".as_bytes(),
];
let types = [
SelectionType::Char,
SelectionType::Word,
SelectionType::Line,
SelectionType::Block,
];
let sizes = [(2usize, 2usize), (4, 3), (9, 5), (20, 8), (80, 24)];
for seed in 0..400u64 {
let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
let (c0, r0) = *rng.pick(&sizes);
let mut e = if seed % 3 == 0 {
Engine::with_scrollback(c0, r0, (seed % 7) as usize)
} else {
Engine::new(c0, r0)
};
e.feed(b"one\r\ntwo\r\nthree\r\nfour\r\n");
e.selection_begin(0, 0, Side::Left, *rng.pick(&types));
e.selection_extend(r0 - 1, c0 - 1, Side::Right);
for step in 0..24 {
match rng.next() % 10 {
0 => {
let (c, r) = *rng.pick(&sizes);
e.resize(c, r);
}
1 => e.scroll_up((rng.next() % 5) as usize),
2 => e.scroll_down((rng.next() % 5) as usize),
3 => e.scroll_to_bottom(),
4 => {
e.add_marker(e.grid().rows() - 1);
}
5 => {
let hits = e.search("o");
e.set_search_highlights(hits);
}
6 => {
let r = (rng.next() as usize) % (e.grid().rows() + 3);
let c = (rng.next() as usize) % (e.grid().cols() + 3);
e.selection_begin(0, 0, Side::Left, *rng.pick(&types));
e.selection_extend(r, c, Side::Right);
}
_ => e.feed(rng.pick(VERBS)),
}
let (rows, cols) = (e.grid().rows(), e.grid().cols());
for s in e.selection_range() {
assert!(
s.row < rows && s.right < cols,
"seed {seed} step {step}: span {s:?} outside {cols}x{rows}"
);
}
let _ = e.selection_text();
let _ = e.accessible_text();
let _ = e.frame();
}
}
}