diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-30 13:01:32 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-30 13:01:52 +0300 |
| commit | 741fbbe8b30774247f8a68831d8df469672e1947 (patch) | |
| tree | 608afe0a8415ea1a3ee2035f809795dc09809dfc /src/input/tests.rs | |
| parent | ca14500717864b1c9b1885cbbc6a451a19b9caf8 (diff) | |
refactor: split terminal keyboard protocol module
Diffstat (limited to 'src/input/tests.rs')
| -rw-r--r-- | src/input/tests.rs | 634 |
1 files changed, 634 insertions, 0 deletions
diff --git a/src/input/tests.rs b/src/input/tests.rs new file mode 100644 index 00000000..ec8fdf63 --- /dev/null +++ b/src/input/tests.rs @@ -0,0 +1,634 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::{ + encode_key, encode_terminal_key, parse_terminal_key_sequence, KeyboardProtocol, TerminalKey, +}; + +fn assert_terminal_key_eq( + actual: TerminalKey, + code: KeyCode, + modifiers: KeyModifiers, + kind: crossterm::event::KeyEventKind, + shifted_codepoint: Option<u32>, +) { + assert_eq!(actual.code, code); + assert_eq!(actual.modifiers, modifiers); + assert_eq!(actual.kind, kind); + assert_eq!(actual.shifted_codepoint, shifted_codepoint); +} + +fn decode_hex(hex: &str) -> Vec<u8> { + let hex = hex.trim(); + assert_eq!(hex.len() % 2, 0, "hex string must have even length"); + (0..hex.len()) + .step_by(2) + .map(|idx| u8::from_str_radix(&hex[idx..idx + 2], 16).unwrap()) + .collect() +} + +fn parse_fixture_key_code(value: &str) -> KeyCode { + match value { + "enter" => KeyCode::Enter, + "tab" => KeyCode::Tab, + "backspace" => KeyCode::Backspace, + "esc" => KeyCode::Esc, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "insert" => KeyCode::Insert, + "delete" => KeyCode::Delete, + value if value.starts_with("char:") => { + KeyCode::Char(value.trim_start_matches("char:").chars().next().unwrap()) + } + other => panic!("unsupported fixture key code: {other}"), + } +} + +fn parse_fixture_modifiers(value: &str) -> KeyModifiers { + if value == "-" || value.is_empty() { + return KeyModifiers::empty(); + } + + let mut modifiers = KeyModifiers::empty(); + for part in value.split('+') { + match part { + "shift" => modifiers |= KeyModifiers::SHIFT, + "alt" => modifiers |= KeyModifiers::ALT, + "control" => modifiers |= KeyModifiers::CONTROL, + "super" => modifiers |= KeyModifiers::SUPER, + "hyper" => modifiers |= KeyModifiers::HYPER, + "meta" => modifiers |= KeyModifiers::META, + other => panic!("unsupported fixture modifier: {other}"), + } + } + modifiers +} + +fn parse_fixture_kind(value: &str) -> crossterm::event::KeyEventKind { + match value { + "press" => crossterm::event::KeyEventKind::Press, + "repeat" => crossterm::event::KeyEventKind::Repeat, + "release" => crossterm::event::KeyEventKind::Release, + other => panic!("unsupported fixture kind: {other}"), + } +} + +#[test] +fn legacy_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); +} + +#[test] +fn legacy_ctrl_c() { + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![3]); +} + +#[test] +fn legacy_shift_enter_is_just_cr() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + // Enter/Tab/Backspace/Esc aren't special keys with xterm modifier encoding, + // so Shift+Enter falls through to legacy which just sends CR + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); +} + +#[test] +fn legacy_alt_up() { + let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT); + // xterm modified key format: CSI 1;3A (3 = 1 + Alt) + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;3A"); +} + +#[test] +fn legacy_shift_right() { + let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;2C"); +} + +#[test] +fn legacy_ctrl_left() { + let key = KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;5D"); +} + +#[test] +fn legacy_ctrl_shift_end() { + let key = KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;6F"); +} + +#[test] +fn legacy_alt_delete() { + let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[3;3~"); +} + +#[test] +fn legacy_shift_f5() { + let key = KeyEvent::new(KeyCode::F(5), KeyModifiers::SHIFT); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[15;2~"); +} + +#[test] +fn legacy_alt_char_still_esc_prefix() { + // Alt+a on character keys still uses ESC prefix (not xterm modified) + let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1ba"); +} + +#[test] +fn kitty_shift_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + b"\x1b[13;2u" + ); +} + +#[test] +fn kitty_ctrl_shift_a() { + let key = KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + b"\x1b[97;6u" + ); +} + +#[test] +fn kitty_shift_uppercase_letter_uses_base_codepoint() { + let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + b"\x1b[108;2u" + ); +} + +#[test] +fn kitty_shift_uppercase_letter_with_alternate_keys_reports_shifted_codepoint() { + let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), + b"\x1b[108:76;2:1u" + ); +} + +#[test] +fn kitty_shift_lowercase_letter_normalizes_to_base_codepoint() { + let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::SHIFT); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + b"\x1b[108;2u" + ); +} + +#[test] +fn kitty_alt_shift_uppercase_letter_uses_base_codepoint() { + let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::ALT | KeyModifiers::SHIFT); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + b"\x1b[108;4u" + ); +} + +#[test] +fn kitty_ctrl_shift_uppercase_letter_uses_base_codepoint() { + let key = KeyEvent::new( + KeyCode::Char('L'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + b"\x1b[108;6u" + ); +} + +#[test] +fn legacy_shift_uppercase_letter_stays_uppercase() { + let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"L"); +} + +#[test] +fn kitty_alt_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + b"\x1b[13;3u" + ); +} + +#[test] +fn kitty_plain_ctrl_c_uses_legacy() { + // Plain Ctrl+letter is well-represented in legacy + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + vec![3] + ); +} + +#[test] +fn kitty_unmodified_uses_legacy() { + let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"a"); +} + +#[test] +fn kitty_shift_tab() { + let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + b"\x1b[9;2u" + ); +} + +#[test] +fn kitty_ctrl_shift_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), + b"\x1b[13;6u" + ); +} + +#[test] +fn kitty_repeat_event_type_is_encoded_when_requested() { + let key = KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::SHIFT, + crossterm::event::KeyEventKind::Repeat, + ); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 3 }), + b"\x1b[13;2:2u" + ); +} + +#[test] +fn kitty_release_event_type_and_alternate_keys_are_encoded_together() { + let key = KeyEvent::new_with_kind( + KeyCode::Char('L'), + KeyModifiers::SHIFT, + crossterm::event::KeyEventKind::Release, + ); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), + b"\x1b[108:76;2:3u" + ); +} + +#[test] +fn kitty_rich_key_data_can_preserve_shifted_symbol_pairs() { + let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) + .with_shifted_codepoint('!' as u32); + assert_eq!( + encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), + b"\x1b[49:33;2:1u" + ); +} + +#[test] +fn parse_kitty_sequence_preserves_shifted_symbol_pair() { + let key = parse_terminal_key_sequence("\x1b[49:33;2:1u").unwrap(); + assert_eq!(key.code, KeyCode::Char('1')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); + assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); + assert_eq!(key.shifted_codepoint, Some('!' as u32)); +} + +#[test] +fn parse_kitty_sequence_preserves_shifted_letter_pair_and_release() { + let key = parse_terminal_key_sequence("\x1b[108:76;2:3u").unwrap(); + assert_eq!(key.code, KeyCode::Char('l')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); + assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); + assert_eq!(key.shifted_codepoint, Some('L' as u32)); +} + +#[test] +fn parse_modify_other_keys_sequence() { + let key = parse_terminal_key_sequence("\x1b[27;6;108~").unwrap(); + assert_eq!(key.code, KeyCode::Char('l')); + assert_eq!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT); + assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); + assert_eq!(key.shifted_codepoint, None); +} + +#[test] +fn parse_legacy_uppercase_letter_as_shifted_char() { + let key = parse_terminal_key_sequence("L").unwrap(); + assert_eq!(key.code, KeyCode::Char('L')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); +} + +#[test] +fn parse_legacy_up_arrow_sequence() { + let key = parse_terminal_key_sequence("\x1b[A").unwrap(); + assert_eq!(key.code, KeyCode::Up); + assert_eq!(key.modifiers, KeyModifiers::empty()); +} + +#[test] +fn parse_xterm_alt_up_arrow_sequence() { + let key = parse_terminal_key_sequence("\x1b[1;3A").unwrap(); + assert_eq!(key.code, KeyCode::Up); + assert_eq!(key.modifiers, KeyModifiers::ALT); +} + +#[test] +fn parse_xterm_alt_down_arrow_sequence() { + let key = parse_terminal_key_sequence("\x1b[1;3B").unwrap(); + assert_eq!(key.code, KeyCode::Down); + assert_eq!(key.modifiers, KeyModifiers::ALT); +} + +#[test] +fn parse_kitty_functional_up_arrow_sequence() { + let key = parse_terminal_key_sequence("\x1b[57419;1u").unwrap(); + assert_eq!(key.code, KeyCode::Up); + assert_eq!(key.modifiers, KeyModifiers::empty()); +} + +#[test] +fn parse_legacy_ctrl_b_sequence() { + let key = parse_terminal_key_sequence("\x02").unwrap(); + assert_eq!(key.code, KeyCode::Char('b')); + assert_eq!(key.modifiers, KeyModifiers::CONTROL); +} + +#[test] +fn parse_legacy_ctrl_c_sequence() { + let key = parse_terminal_key_sequence("\x03").unwrap(); + assert_eq!(key.code, KeyCode::Char('c')); + assert_eq!(key.modifiers, KeyModifiers::CONTROL); +} + +#[test] +fn legacy_ctrl_byte_matrix_is_covered() { + for (byte, expected) in [ + (b'\x01', 'a'), + (b'\x02', 'b'), + (b'\x03', 'c'), + (b'\x1a', 'z'), + ] { + let key = parse_terminal_key_sequence(std::str::from_utf8(&[byte]).unwrap()).unwrap(); + assert_terminal_key_eq( + key, + KeyCode::Char(expected), + KeyModifiers::CONTROL, + crossterm::event::KeyEventKind::Press, + None, + ); + } + + // Ctrl+[ is byte-identical to Escape in legacy terminals, so the parser + // intentionally treats 0x1b as Escape and only disambiguates the other + // legacy control-symbol bytes here. + for (byte, expected) in [ + (b'\x1c', '\\'), + (b'\x1d', ']'), + (b'\x1e', '^'), + (b'\x1f', '-'), + ] { + let key = parse_terminal_key_sequence(std::str::from_utf8(&[byte]).unwrap()).unwrap(); + assert_terminal_key_eq( + key, + KeyCode::Char(expected), + KeyModifiers::CONTROL, + crossterm::event::KeyEventKind::Press, + None, + ); + } +} + +#[test] +fn legacy_modified_special_roundtrip_matrix() { + let cases = [ + KeyEvent::new(KeyCode::Up, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Down, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::PageUp, KeyModifiers::ALT), + KeyEvent::new(KeyCode::PageDown, KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT), + ]; + + for key in cases { + let encoded = encode_key(key, KeyboardProtocol::Legacy); + let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); + assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None); + } +} + +#[test] +fn kitty_shifted_symbol_roundtrip_preserves_alternate_key() { + let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) + .with_shifted_codepoint('!' as u32); + let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); + let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); + assert_terminal_key_eq( + parsed, + KeyCode::Char('1'), + KeyModifiers::SHIFT, + crossterm::event::KeyEventKind::Press, + Some('!' as u32), + ); +} + +#[test] +fn legacy_basic_special_roundtrip_matrix() { + let cases = [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Up, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Home, KeyModifiers::empty()), + KeyEvent::new(KeyCode::End, KeyModifiers::empty()), + KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()), + KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Insert, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()), + ]; + + for key in cases { + let encoded = encode_key(key, KeyboardProtocol::Legacy); + let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); + assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None); + } +} + +#[test] +fn kitty_functional_key_matrix_is_covered() { + let cases = [ + ("\x1b[57417;1u", KeyCode::Left), + ("\x1b[57418;1u", KeyCode::Right), + ("\x1b[57419;1u", KeyCode::Up), + ("\x1b[57420;1u", KeyCode::Down), + ("\x1b[57421;1u", KeyCode::PageUp), + ("\x1b[57422;1u", KeyCode::PageDown), + ("\x1b[57423;1u", KeyCode::Home), + ("\x1b[57424;1u", KeyCode::End), + ("\x1b[57425;1u", KeyCode::Insert), + ("\x1b[57426;1u", KeyCode::Delete), + ]; + + for (sequence, code) in cases { + let parsed = parse_terminal_key_sequence(sequence).unwrap(); + assert_terminal_key_eq( + parsed, + code, + KeyModifiers::empty(), + crossterm::event::KeyEventKind::Press, + None, + ); + } +} + +#[test] +fn kitty_shifted_symbol_pair_matrix_is_covered() { + let cases = [('1', '!'), ('/', '?'), ('[', '{')]; + + for (base, shifted) in cases { + let key = TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT) + .with_shifted_codepoint(shifted as u32); + let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); + let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); + assert_terminal_key_eq( + parsed, + KeyCode::Char(base), + KeyModifiers::SHIFT, + crossterm::event::KeyEventKind::Press, + Some(shifted as u32), + ); + } +} + +fn assert_fixture_corpus_parses(corpus: &str) { + for line in corpus.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut columns: Vec<_> = line.split('\t').collect(); + if columns.len() == 5 { + columns.push(""); + } + + let (family, bytes_hex, code, modifiers, kind, shifted) = match columns.len() { + 6 => { + if columns[1].chars().all(|ch| ch.is_ascii_hexdigit()) { + ( + columns[0], columns[1], columns[2], columns[3], columns[4], columns[5], + ) + } else { + ( + columns[0], columns[2], columns[3], columns[4], columns[5], "", + ) + } + } + 7 => ( + columns[0], columns[2], columns[3], columns[4], columns[5], columns[6], + ), + _ => panic!("fixture row must have 6 or 7 columns: {line}"), + }; + + assert!( + bytes_hex.chars().all(|ch| ch.is_ascii_hexdigit()), + "non-hex fixture bytes for {family}: {bytes_hex}" + ); + let bytes = decode_hex(bytes_hex); + let text = std::str::from_utf8(&bytes).unwrap(); + let parsed = parse_terminal_key_sequence(text) + .unwrap_or_else(|| panic!("fixture failed to parse: {family}")); + + assert_terminal_key_eq( + parsed, + parse_fixture_key_code(code), + parse_fixture_modifiers(modifiers), + parse_fixture_kind(kind), + if shifted.is_empty() { + None + } else { + Some(shifted.parse::<u32>().unwrap()) + }, + ); + } +} + +#[test] +fn keyboard_protocol_corpus_fixture_parses() { + let corpus = include_str!("../../tests/fixtures/keyboard_protocol_corpus.tsv"); + assert_fixture_corpus_parses(corpus); +} + +#[test] +fn macos_terminal_variants_fixture_parses() { + let corpus = include_str!("../../tests/fixtures/macos_terminal_variants.tsv"); + for line in corpus.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut columns: Vec<_> = line.split('\t').collect(); + if columns.len() == 6 { + columns.push(""); + } + assert_eq!( + columns.len(), + 7, + "macOS fixture row must have 7 columns: {line}" + ); + + let source = format!("{}:{}", columns[0], columns[1]); + let transformed = [ + source.as_str(), + columns[2], + columns[3], + columns[4], + columns[5], + columns[6], + ] + .join("\t"); + assert_fixture_corpus_parses(&transformed); + } +} + +#[test] +fn linux_terminal_variants_fixture_parses() { + let corpus = include_str!("../../tests/fixtures/linux_terminal_variants.tsv"); + assert_fixture_corpus_parses(corpus); +} + +#[test] +fn protocol_from_zero_flags_is_legacy() { + assert_eq!( + KeyboardProtocol::from_kitty_flags(0), + KeyboardProtocol::Legacy + ); +} + +#[test] +fn protocol_from_nonzero_flags_is_kitty() { + assert_eq!( + KeyboardProtocol::from_kitty_flags(7), + KeyboardProtocol::Kitty { flags: 7 } + ); +} |
