use std::time::{Duration, Instant};
use abstracttui::base::Size;
use abstracttui::input::{Event, EventReader, KeyCode, KeyEvent, Mods, Parser};
use abstracttui::term::{Capabilities, EnterOptions, KittyFlags, Terminal};
use abstracttui::testing::fuzzish::{self, Rng};
use abstracttui::testing::{hostile_corpus, CaptureTerm};
fn parse_all(chunks: &[Vec<u8>]) -> Vec<Event> {
let mut p = Parser::new();
let mut out = Vec::new();
for c in chunks {
p.feed(c, &mut out);
}
p.finish(&mut out);
out
}
#[test]
fn ss3_keypad_events_carry_flag_and_identity() {
let cases: &[(&[u8], KeyCode, bool)] = &[
(b"\x1bOM", KeyCode::Enter, true), (b"\x1bOp", KeyCode::Char('0'), true), (b"\x1bOy", KeyCode::Char('9'), true), (b"\x1bOk", KeyCode::Char('+'), true), (b"\x1bOA", KeyCode::Up, false), (b"\x1bOP", KeyCode::F(1), false), ];
for (bytes, code, keypad) in cases {
let mut p = Parser::new();
let mut out = Vec::new();
p.feed(bytes, &mut out);
p.finish(&mut out);
let key = out.iter().find_map(|e| match e {
Event::Key(k) => Some(k),
_ => None,
});
let key = key.unwrap_or_else(|| panic!("no key event for {bytes:?}"));
assert_eq!(key.code, *code, "wrong identity for {bytes:?}");
assert_eq!(key.keypad, *keypad, "wrong keypad flag for {bytes:?}");
}
let seq = b"\x1bOp\x1bOM\x1bOk";
let whole = {
let mut p = Parser::new();
let mut o = Vec::new();
p.feed(seq, &mut o);
p.finish(&mut o);
o
};
for split in 1..seq.len() {
let mut p = Parser::new();
let mut o = Vec::new();
p.feed(&seq[..split], &mut o);
p.feed(&seq[split..], &mut o);
p.finish(&mut o);
assert_eq!(o, whole, "keypad split at {split} diverged");
}
}
#[test]
fn keyevent_constructors_default_keypad_false() {
assert_eq!(KeyEvent::char('a').code, KeyCode::Char('a'));
assert!(!KeyEvent::char('a').keypad);
assert_eq!(KeyEvent::plain(KeyCode::Enter).code, KeyCode::Enter);
assert!(!KeyEvent::plain(KeyCode::Enter).keypad);
assert!(!KeyEvent::new(KeyCode::Char('z'), Mods::CTRL).keypad);
assert_eq!(
KeyEvent::new(KeyCode::Char('z'), Mods::CTRL).mods,
Mods::CTRL
);
}
#[test]
fn parser_survives_hostile_corpus_and_finish() {
let corpus = hostile_corpus(0x6b_39c2, 1200);
let mut streaming = Parser::new();
let mut sink = Vec::new();
for chunk in &corpus {
let mut fresh = Parser::new();
fresh.feed(chunk, &mut sink);
fresh.finish(&mut sink);
streaming.feed(chunk, &mut sink);
sink.clear();
}
streaming.finish(&mut sink);
}
#[test]
fn split_feed_equals_one_shot_feed() {
let mut rng = Rng::new(0x51ee7);
let mut streams: Vec<Vec<u8>> = vec![
b"hello\x1b[A\x1b[1;5C\x1bOP\x1b[Z".to_vec(),
b"\x1b[<0;10;5M\x1b[<0;10;5m\x1b[<64;3;3M".to_vec(),
b"\x1b[200~pasted \x1b[201 text\x1b[201~tail".to_vec(),
b"\x1b[?1;2;4c\x1b[?2026;2$y\x1bP>|kitty 0.38\x1b\\".to_vec(),
"héllo 日本 🎉\u{1b}[Iworld\u{1b}[O".as_bytes().to_vec(),
b"\x1b[97;5u\x1b[13;2u\x1b[57441;1u".to_vec(),
];
for _ in 0..60 {
let mut soup = Vec::new();
for _ in 0..rng.range(1, 5) {
soup.extend(fuzzish::sequence_shaped(&mut rng));
soup.extend(fuzzish::truncated_utf8(&mut rng));
soup.extend(fuzzish::random_chunk(&mut rng, 24));
}
streams.push(soup);
}
for (i, stream) in streams.iter().enumerate() {
let one_shot = parse_all(std::slice::from_ref(stream));
for max in [1usize, 2, 3, 5, 9] {
let parts = fuzzish::random_splits(&mut rng, stream, max);
let split = parse_all(&parts);
assert_eq!(
one_shot, split,
"stream {i}: split(max {max}) diverged from one-shot"
);
}
}
}
#[test]
fn all_single_bytes_and_esc_pairs_are_safe() {
let mut sink = Vec::new();
for b in 0..=255u8 {
let mut p = Parser::new();
p.feed(&[b], &mut sink);
p.finish(&mut sink);
sink.clear();
}
for b in 0..=255u8 {
let mut p = Parser::new();
p.feed(&[0x1b, b], &mut sink);
p.feed(&[0x1b, b'[', b], &mut sink);
p.finish(&mut sink);
sink.clear();
}
}
#[test]
fn unterminated_csi_is_capped_and_resyncs() {
let mut p = Parser::new();
let mut out = Vec::new();
let mut stream = vec![0x1b, b'['];
stream.extend(std::iter::repeat_n(b'1', 5000));
stream.push(b'H');
p.feed(&stream, &mut out);
assert!(
out.iter().any(|e| matches!(e, Event::Unknown(_))),
"capped CSI must surface as Unknown, got {out:?}"
);
for e in &out {
match e {
Event::Unknown(bytes) => {
assert!(
bytes.len() <= 64,
"Unknown payload must be capped at 64 bytes"
)
}
Event::Key(k) => panic!("capped-sequence bytes leaked as key {k:?}"),
_ => {}
}
}
out.clear();
p.feed(b"x\x1b[A", &mut out);
assert!(
out.iter()
.any(|e| matches!(e, Event::Key(k) if k.code == KeyCode::Char('x'))),
"text after a terminated capped sequence must parse, got {out:?}"
);
assert!(out
.iter()
.any(|e| matches!(e, Event::Key(k) if k.code == KeyCode::Up)));
}
#[test]
fn giant_paste_flushes_in_bounded_chunks() {
let mut p = Parser::new();
let mut out = Vec::new();
p.feed(b"\x1b[200~", &mut out);
let slab = vec![b'p'; 8192];
for _ in 0..25 {
p.feed(&slab, &mut out);
}
p.feed(b"\x1b[201~", &mut out);
let pastes: Vec<&String> = out
.iter()
.filter_map(|e| match e {
Event::Paste(s) => Some(s),
_ => None,
})
.collect();
assert!(
pastes.len() >= 2,
"200 KiB must flush as multiple Paste events"
);
let total: usize = pastes.iter().map(|s| s.len()).sum();
assert_eq!(total, 25 * 8192, "no pasted byte may be lost or invented");
let max = pastes.iter().map(|s| s.len()).max().unwrap();
assert!(max <= 100 * 1024, "single Paste event too large: {max}");
}
#[test]
fn esc_inside_paste_and_torn_terminator_survive() {
let stream = b"\x1b[200~abc\x1b[201Xdef\x1bghi\x1b[201~z";
let one = parse_all(std::slice::from_ref(&stream.to_vec()));
let content: String = one
.iter()
.filter_map(|e| match e {
Event::Paste(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert_eq!(content, "abc\x1b[201Xdef\x1bghi");
let bytes: Vec<Vec<u8>> = stream.iter().map(|b| vec![*b]).collect();
assert_eq!(one, parse_all(&bytes));
}
#[test]
fn swallowed_sequences_never_leak_text() {
let hostile: &[&[u8]] = &[
b"\x1b[M ab", b"\x1b]0;fake title\x07", b"\x1bP+q544e\x1b\\", b"\x1b_Gi=1;OK\x1b\\", b"\x1b[38;2;300;300;300m", b"\x1b[999999999999999H",
];
for stream in hostile {
let events = parse_all(std::slice::from_ref(&stream.to_vec()));
for e in &events {
assert!(
!matches!(
e,
Event::Key(KeyEvent {
code: KeyCode::Char(_),
..
})
),
"sequence {:?} leaked text: {events:?}",
String::from_utf8_lossy(stream)
);
}
}
let events = parse_all(&[b"\x1b[M abc".to_vec()]);
assert!(
events
.iter()
.any(|e| matches!(e, Event::Key(k) if k.code == KeyCode::Char('c'))),
"the byte after the 3-byte X10 payload is real input: {events:?}"
);
}
#[test]
fn kitty_and_legacy_keys_decode_exactly() {
let events = parse_all(&[b"\x1b[97;5u".to_vec()]);
assert_eq!(events.len(), 1);
match &events[0] {
Event::Key(k) => {
assert_eq!(k.code, KeyCode::Char('a'));
assert!(k.mods.contains(Mods::CTRL));
}
other => panic!("expected Ctrl+A, got {other:?}"),
}
let events = parse_all(&[b"\x1b[Z\x1b[15~\x1b\x7f".to_vec()]);
assert!(
matches!(&events[0], Event::Key(k) if k.code == KeyCode::Tab && k.mods.contains(Mods::SHIFT))
);
assert!(matches!(&events[1], Event::Key(k) if k.code == KeyCode::F(5)));
assert!(
matches!(&events[2], Event::Key(k) if k.code == KeyCode::Backspace && k.mods.contains(Mods::ALT))
);
}
#[test]
fn csi_1_5_r_ambiguity_pinned_as_f3() {
let events = parse_all(&[b"\x1b[1;5R".to_vec()]);
assert_eq!(events.len(), 1, "exactly one event from the ambiguous form");
match &events[0] {
Event::Key(k) => {
assert_eq!(k.code, KeyCode::F(3), "documented resolution: F3 side");
assert!(k.mods.contains(Mods::CTRL));
}
Event::CapsReply(r) => {
panic!("resolution CHANGED to CPR side: {r:?} — update finding RT2-K1")
}
other => panic!("unexpected decode {other:?}"),
}
let events = parse_all(&[b"\x1b[24;80R".to_vec()]);
assert!(
matches!(&events[0], Event::CapsReply(_)),
"CSI 24;80R must decode as a cursor-position report, got {events:?}"
);
}
#[test]
fn reader_bare_esc_resolves_after_deadline() {
let mut term = CaptureTerm::new(Size::new(10, 2));
term.push_input(b"\x1b");
let mut reader = EventReader::new();
reader.esc_timeout = Duration::ZERO;
let deadline = Instant::now(); let ev = reader.poll_event(&mut term, Some(deadline)).unwrap();
assert_eq!(
ev,
Some(Event::Key(KeyEvent::plain(KeyCode::Esc))),
"bare ESC + expired deadline must deliver the Esc key"
);
}
#[test]
fn reader_split_sequence_survives_when_deadline_generous() {
let mut term = CaptureTerm::new(Size::new(10, 2));
term.push_input(b"\x1b");
term.push_input(b"[A"); let mut reader = EventReader::new(); let ev = reader.poll_event(&mut term, Some(Instant::now())).unwrap();
assert_eq!(
ev,
Some(Event::Key(KeyEvent::plain(KeyCode::Up))),
"split ESC-[A with time to spare must decode as Up, not Esc+[+A"
);
}
#[test]
fn reader_torn_sequence_flushes_as_unknown() {
let mut term = CaptureTerm::new(Size::new(10, 2));
term.push_input(b"\x1b[12;3");
let mut reader = EventReader::new();
reader.seq_timeout = Duration::ZERO;
let ev = reader.poll_event(&mut term, Some(Instant::now())).unwrap();
assert!(
matches!(ev, Some(Event::Unknown(_))),
"torn sequence past its deadline must flush as Unknown, got {ev:?}"
);
}
#[test]
fn reader_deadline_expiry_returns_none() {
let mut term = CaptureTerm::new(Size::new(10, 2));
let mut reader = EventReader::new();
let ev = reader.poll_event(&mut term, Some(Instant::now())).unwrap();
assert_eq!(
ev, None,
"empty script + expired deadline = None, never hangs"
);
}
#[test]
fn reader_resize_passthrough_orders_with_input() {
let mut term = CaptureTerm::new(Size::new(10, 2));
term.push_input(b"a");
term.push_resize(Size::new(20, 5));
term.push_input(b"b");
let mut reader = EventReader::new();
let d = Some(Instant::now());
assert!(
matches!(reader.poll_event(&mut term, d).unwrap(), Some(Event::Key(k)) if k.code == KeyCode::Char('a'))
);
assert!(
matches!(reader.poll_event(&mut term, d).unwrap(), Some(Event::Resize(s)) if s == Size::new(20, 5))
);
assert!(
matches!(reader.poll_event(&mut term, d).unwrap(), Some(Event::Key(k)) if k.code == KeyCode::Char('b'))
);
}
#[test]
fn probe_on_mute_terminal_ends_at_deadline() {
let mut term = CaptureTerm::new(Size::new(80, 24));
let mut reader = EventReader::new();
let mut caps = Capabilities::default();
let passthrough =
abstracttui::input::probe_active(&mut term, &mut reader, &mut caps, Duration::ZERO)
.unwrap();
assert!(passthrough.is_empty());
assert!(!caps.kitty_graphics && !caps.sixel);
assert!(
!term.bytes().is_empty(),
"probe must have written its queries"
);
}
#[test]
fn probe_on_dumb_terminal_writes_nothing() {
let mut term = CaptureTerm::new(Size::new(80, 24));
let mut reader = EventReader::new();
let mut caps = Capabilities::with(|c| c.dumb = true);
let passthrough =
abstracttui::input::probe_active(&mut term, &mut reader, &mut caps, Duration::ZERO)
.unwrap();
assert!(passthrough.is_empty());
assert!(
term.bytes().is_empty(),
"a dumb terminal must never be interrogated with escapes; wrote {:?}",
String::from_utf8_lossy(term.bytes())
);
}
#[test]
fn probe_folds_replies_and_returns_user_input() {
let mut term = CaptureTerm::new(Size::new(80, 24));
term.push_input(b"\x1b[?1u");
term.push_input(b"x");
term.push_input(b"\x1b[?2026;2$y");
term.push_input(b"\x1b[?62;4;22c");
let mut reader = EventReader::new();
let mut caps = Capabilities::default();
let passthrough =
abstracttui::input::probe_active(&mut term, &mut reader, &mut caps, Duration::ZERO)
.unwrap();
assert!(caps.kitty_keyboard && caps.sync_output_2026 && caps.sixel);
assert_eq!(passthrough.len(), 1, "the keystroke must survive the probe");
assert!(matches!(&passthrough[0], Event::Key(k) if k.code == KeyCode::Char('x')));
}
#[test]
fn late_probe_reply_is_a_caps_event_not_text() {
let mut term = CaptureTerm::new(Size::new(80, 24));
term.push_input(b"\x1b[?62c"); term.push_input(b"\x1b_Gi=4242;OK\x1b\\");
term.push_input(b"\x1bP>|kitty 0.40\x1b\\");
term.push_input(b"k");
let mut reader = EventReader::new();
let mut caps = Capabilities::default();
let _ = abstracttui::input::probe_active(&mut term, &mut reader, &mut caps, Duration::ZERO)
.unwrap();
let d = Some(Instant::now());
let mut after = Vec::new();
while let Some(ev) = reader.poll_event(&mut term, d).unwrap() {
after.push(ev);
}
assert!(
after.iter().any(|e| matches!(e, Event::CapsReply(_))),
"late replies must surface as CapsReply events: {after:?}"
);
assert!(
after.iter().all(
|e| !matches!(e, Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) if *c != 'k')
),
"late reply bytes leaked into text: {after:?}"
);
assert!(
after
.iter()
.any(|e| matches!(e, Event::Key(k) if k.code == KeyCode::Char('k'))),
"the real keystroke after the late replies must still arrive"
);
}
#[test]
fn enter_leave_balance_across_option_shapes() {
use abstracttui::term::MouseMode;
let shapes = [
EnterOptions::default(),
EnterOptions {
kitty_keyboard: KittyFlags::standard(),
..EnterOptions::default()
},
EnterOptions {
mouse: MouseMode::AnyMotion,
..EnterOptions::default()
},
EnterOptions {
mouse: MouseMode::Off,
alternate_screen: false,
..EnterOptions::default()
},
EnterOptions {
alternate_screen: false,
hide_cursor: false,
mouse: MouseMode::Off,
bracketed_paste: false,
focus_events: false,
kitty_keyboard: KittyFlags(0),
},
];
for (i, opts) in shapes.iter().enumerate() {
let mut term = CaptureTerm::new(Size::new(10, 4));
term.enter(opts).unwrap();
term.leave().unwrap();
let screen = term.screen();
let modes = screen.modes().all_set();
assert!(
modes.iter().all(|m| *m == 25 || *m == 7),
"shape {i}: modes still set after leave: {modes:?}"
);
assert!(
screen.modes().cursor_visible(),
"shape {i}: cursor must be restored"
);
assert_eq!(
screen.counters().kitty_push_depth,
0,
"shape {i}: kitty pop missing"
);
assert_eq!(
screen.unknown_seq_count(),
0,
"shape {i}: unmodeled bytes in enter/leave"
);
}
}