From bee1f8240581ed2281833a3d4ac340fcb7b35b10 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 10 May 2026 10:59:43 +0200 Subject: [PATCH] test(evil-keys): add integration and property-based tests 20 integration scenarios (5gg, SPC leader, escape progression, timeout, mode switch, which-key, stress tests) and 8 proptest invariants (roundtrip, never-panic, non-Press ignored, count >= 1, escape clears state). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent) Co-authored-by: Sisyphus --- crates/evil-keys/tests/dispatch_sequences.rs | 403 ++++++++++++++++++ crates/evil-keys/tests/proptest_invariants.rs | 144 +++++++ 2 files changed, 547 insertions(+) create mode 100644 crates/evil-keys/tests/dispatch_sequences.rs create mode 100644 crates/evil-keys/tests/proptest_invariants.rs diff --git a/crates/evil-keys/tests/dispatch_sequences.rs b/crates/evil-keys/tests/dispatch_sequences.rs new file mode 100644 index 0000000..628c64e --- /dev/null +++ b/crates/evil-keys/tests/dispatch_sequences.rs @@ -0,0 +1,403 @@ +use std::thread; +use std::time::Duration; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use evil_keys::{DispatchResult, Dispatcher, KeyTrie}; + +fn press(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn press_char(c: char) -> KeyEvent { + press(KeyCode::Char(c)) +} + +fn press_spc() -> KeyEvent { + press_char(' ') +} + +fn press_esc() -> KeyEvent { + press(KeyCode::Esc) +} + +fn vim_dispatcher() -> Dispatcher<&'static str> { + let mut trie = KeyTrie::new("normal"); + trie.bind("j", "move_down").unwrap(); + trie.bind("k", "move_up").unwrap(); + trie.bind("G", "goto_last").unwrap(); + trie.bind("Esc", "escape").unwrap(); + trie.bind("0", "goto_start").unwrap(); + trie.group("g", "+goto", |g| { + g.bind("g", "goto_first")?; + g.bind("t", "next_tab")?; + g.bind("T", "prev_tab")?; + Ok(()) + }) + .unwrap(); + trie.group("SPC", "+leader", |g| { + g.group("b", "+buffer", |b| { + b.bind_desc("l", "goto_library", "library")?; + b.bind_desc("w", "goto_wanted", "wanted")?; + Ok(()) + })?; + g.bind_desc("h", "show_help", "help")?; + g.bind_desc("q", "quit", "quit")?; + g.bind_desc("n", "notifications", "notifications")?; + Ok(()) + }) + .unwrap(); + + let insert = KeyTrie::new("insert"); + let mut d = Dispatcher::new(); + d.add_mode("normal", trie).unwrap(); + d.add_mode("insert", insert).unwrap(); + d.set_active("normal").unwrap(); + d +} + +#[test] +fn vim_workflow_5gg() { + let mut d = vim_dispatcher(); + + assert_eq!( + d.dispatch(press_char('5')), + DispatchResult::CountAccumulated + ); + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + assert_eq!( + d.dispatch(press_char('g')), + DispatchResult::Matched { + action: "goto_first", + count: 5 + } + ); +} + +#[test] +fn leader_spc_b_l() { + let mut d = vim_dispatcher(); + + assert_eq!(d.dispatch(press_spc()), DispatchResult::Pending); + assert_eq!(d.dispatch(press_char('b')), DispatchResult::Pending); + assert_eq!( + d.dispatch(press_char('l')), + DispatchResult::Matched { + action: "goto_library", + count: 1 + } + ); +} + +#[test] +fn escape_progression() { + let mut d = vim_dispatcher(); + + assert_eq!( + d.dispatch(press_char('5')), + DispatchResult::CountAccumulated + ); + assert_eq!(d.dispatch(press_esc()), DispatchResult::Cancelled); + + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + assert_eq!(d.dispatch(press_esc()), DispatchResult::Cancelled); + + assert_eq!( + d.dispatch(press_char('3')), + DispatchResult::CountAccumulated + ); + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + assert_eq!(d.dispatch(press_esc()), DispatchResult::Cancelled); + + assert_eq!( + d.dispatch(press_char('j')), + DispatchResult::Matched { + action: "move_down", + count: 1 + } + ); +} + +#[test] +fn timeout_resets_sequence() { + let mut d = vim_dispatcher(); + d.set_timeout(Duration::from_millis(50)); + + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + thread::sleep(Duration::from_millis(80)); + assert!(d.check_timeout()); + + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + assert_eq!( + d.dispatch(press_char('g')), + DispatchResult::Matched { + action: "goto_first", + count: 1 + } + ); +} + +#[test] +fn timeout_clears_count_too() { + let mut d = vim_dispatcher(); + d.set_timeout(Duration::from_millis(50)); + + assert_eq!( + d.dispatch(press_char('5')), + DispatchResult::CountAccumulated + ); + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + thread::sleep(Duration::from_millis(80)); + assert!(d.check_timeout()); + + assert_eq!( + d.dispatch(press_char('j')), + DispatchResult::Matched { + action: "move_down", + count: 1 + } + ); +} + +#[test] +fn mode_switch_mid_sequence() { + let mut d = vim_dispatcher(); + + assert_eq!( + d.dispatch(press_char('3')), + DispatchResult::CountAccumulated + ); + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + + d.set_active("insert").unwrap(); + d.set_active("normal").unwrap(); + + assert_eq!( + d.dispatch(press_char('j')), + DispatchResult::Matched { + action: "move_down", + count: 1 + } + ); +} + +#[test] +fn which_key_after_leader() { + let mut d = vim_dispatcher(); + + assert_eq!(d.dispatch(press_spc()), DispatchResult::Pending); + let entries = d.which_key_entries().unwrap(); + assert!(entries.iter().any(|e| e.key == "b" && e.is_group)); + assert!(entries + .iter() + .any(|e| e.key == "h" && e.description == "help")); + assert!(entries + .iter() + .any(|e| e.key == "q" && e.description == "quit")); + assert!(entries + .iter() + .any(|e| e.key == "n" && e.description == "notifications")); + + assert_eq!(d.dispatch(press_char('b')), DispatchResult::Pending); + let entries = d.which_key_entries().unwrap(); + assert!(entries + .iter() + .any(|e| e.key == "l" && e.description == "library")); + assert!(entries + .iter() + .any(|e| e.key == "w" && e.description == "wanted")); + + assert_eq!( + d.dispatch(press_char('l')), + DispatchResult::Matched { + action: "goto_library", + count: 1 + } + ); + assert!(d.which_key_entries().is_none()); +} + +#[test] +fn stress_rapid_fire() { + let mut d = vim_dispatcher(); + for _ in 0..1000 { + assert_eq!( + d.dispatch(press_char('j')), + DispatchResult::Matched { + action: "move_down", + count: 1 + } + ); + } + assert!(d.pending_keys().is_empty()); +} + +#[test] +fn stress_alternating_sequences() { + let mut d = vim_dispatcher(); + for _ in 0..100 { + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + assert_eq!( + d.dispatch(press_char('g')), + DispatchResult::Matched { + action: "goto_first", + count: 1 + } + ); + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + assert_eq!( + d.dispatch(press_char('t')), + DispatchResult::Matched { + action: "next_tab", + count: 1 + } + ); + } +} + +#[test] +fn count_123_j() { + let mut d = vim_dispatcher(); + assert_eq!( + d.dispatch(press_char('1')), + DispatchResult::CountAccumulated + ); + assert_eq!( + d.dispatch(press_char('2')), + DispatchResult::CountAccumulated + ); + assert_eq!( + d.dispatch(press_char('3')), + DispatchResult::CountAccumulated + ); + assert_eq!( + d.dispatch(press_char('j')), + DispatchResult::Matched { + action: "move_down", + count: 123 + } + ); +} + +#[test] +fn zero_as_binding_not_count() { + let mut d = vim_dispatcher(); + assert_eq!( + d.dispatch(press_char('0')), + DispatchResult::Matched { + action: "goto_start", + count: 1 + } + ); +} + +#[test] +fn zero_after_digit_is_count() { + let mut d = vim_dispatcher(); + assert_eq!( + d.dispatch(press_char('1')), + DispatchResult::CountAccumulated + ); + assert_eq!( + d.dispatch(press_char('0')), + DispatchResult::CountAccumulated + ); + assert_eq!( + d.dispatch(press_char('j')), + DispatchResult::Matched { + action: "move_down", + count: 10 + } + ); +} + +#[test] +fn count_overflow_saturates() { + let mut d = vim_dispatcher(); + for _ in 0..20 { + assert_eq!( + d.dispatch(press_char('9')), + DispatchResult::CountAccumulated + ); + } + let result = d.dispatch(press_char('j')); + assert!(matches!( + result, + DispatchResult::Matched { + action: "move_down", + count + } if count == usize::MAX + )); +} + +#[test] +fn pending_display_shows_keys() { + let mut d = vim_dispatcher(); + assert_eq!(d.pending_display(), ""); + + d.dispatch(press_spc()); + assert_eq!(d.pending_display(), "SPC"); + + d.dispatch(press_char('b')); + assert_eq!(d.pending_display(), "SPC b"); +} + +#[test] +fn escape_with_nothing_pending_matches_binding() { + let mut d = vim_dispatcher(); + assert_eq!( + d.dispatch(press_esc()), + DispatchResult::Matched { + action: "escape", + count: 1 + } + ); +} + +#[test] +fn invalid_mode() { + let mut d = vim_dispatcher(); + assert!(d.set_active("nonexistent").is_err()); +} + +#[test] +fn dispatch_with_no_modes() { + let mut d: Dispatcher<&str> = Dispatcher::new(); + assert_eq!(d.dispatch(press_char('j')), DispatchResult::NotFound); +} + +#[test] +fn key_release_ignored() { + let mut d = vim_dispatcher(); + let release = KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Release, + state: KeyEventState::NONE, + }; + assert_eq!(d.dispatch(release), DispatchResult::Ignored); +} + +#[test] +fn key_repeat_ignored() { + let mut d = vim_dispatcher(); + let repeat = KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Repeat, + state: KeyEventState::NONE, + }; + assert_eq!(d.dispatch(repeat), DispatchResult::Ignored); +} + +#[test] +fn wrong_key_mid_sequence_resets() { + let mut d = vim_dispatcher(); + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); + assert_eq!(d.dispatch(press_char('h')), DispatchResult::NotFound); + assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending); +} diff --git a/crates/evil-keys/tests/proptest_invariants.rs b/crates/evil-keys/tests/proptest_invariants.rs new file mode 100644 index 0000000..fb8a61d --- /dev/null +++ b/crates/evil-keys/tests/proptest_invariants.rs @@ -0,0 +1,144 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use evil_keys::key::{parse_key, parse_sequence}; +use evil_keys::{DispatchResult, Dispatcher, Key, KeyTrie}; +use proptest::prelude::*; + +fn valid_key_string() -> impl Strategy { + prop_oneof![ + prop::char::range('a', 'z').prop_map(|c| c.to_string()), + prop::char::range('A', 'Z').prop_map(|c| c.to_string()), + prop::char::range('0', '9').prop_map(|c| c.to_string()), + Just("SPC".to_string()), + Just("Esc".to_string()), + Just("Tab".to_string()), + Just("Enter".to_string()), + Just("-".to_string()), + Just("[".to_string()), + Just("]".to_string()), + Just("/".to_string()), + Just("?".to_string()), + (1u8..=12).prop_map(|n| format!("F{n}")), + prop::char::range('a', 'z').prop_map(|c| format!("C-{c}")), + ] +} + +fn test_dispatcher() -> Dispatcher<&'static str> { + let mut trie = KeyTrie::new("normal"); + trie.bind("j", "down").unwrap(); + trie.bind("k", "up").unwrap(); + trie.bind("Esc", "escape").unwrap(); + trie.group("g", "+goto", |g| { + g.bind("g", "goto_first")?; + g.bind("t", "next_tab")?; + Ok(()) + }) + .unwrap(); + + let mut d = Dispatcher::new(); + d.add_mode("normal", trie).unwrap(); + d.set_active("normal").unwrap(); + d +} + +fn press(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn press_char(c: char) -> KeyEvent { + press(KeyCode::Char(c)) +} + +proptest! { + #[test] + fn key_parse_display_roundtrip(s in valid_key_string()) { + let key = parse_key(&s).unwrap(); + let displayed = key.to_string(); + let reparsed = parse_key(&displayed).unwrap(); + prop_assert_eq!(key, reparsed); + } + + #[test] + fn key_parse_never_panics(s in "\\PC{0,20}") { + let _ = parse_key(&s); + } + + #[test] + fn key_display_never_panics(c in any::().prop_filter("ascii", |c| c.is_ascii() && !c.is_control())) { + let key = Key { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE }; + let _ = key.to_string(); + } + + #[test] + fn non_press_always_ignored(code_char in prop::char::range('a', 'z')) { + let mut d = test_dispatcher(); + let release = KeyEvent { + code: KeyCode::Char(code_char), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Release, + state: KeyEventState::NONE, + }; + prop_assert_eq!(d.dispatch(release), DispatchResult::Ignored); + + let repeat = KeyEvent { + code: KeyCode::Char(code_char), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Repeat, + state: KeyEventState::NONE, + }; + prop_assert_eq!(d.dispatch(repeat), DispatchResult::Ignored); + } + + #[test] + fn count_take_always_at_least_1(digits in prop::collection::vec(prop::char::range('1', '9'), 0..10)) { + let mut count = evil_keys::count::CountState::new(); + for d in digits { + count.push_digit(d); + } + prop_assert!(count.take() >= 1); + } + + #[test] + fn escape_always_clears_pending(keys_before in prop::collection::vec(prop::char::range('a', 'z'), 0..5)) { + let mut d = test_dispatcher(); + for c in keys_before { + let _ = d.dispatch(press_char(c)); + } + let _ = d.dispatch(press(KeyCode::Esc)); + prop_assert!(d.pending_keys().is_empty()); + } + + #[test] + fn trie_bound_keys_always_found(keys in prop::collection::vec(valid_key_string(), 1..10)) { + let mut trie: KeyTrie = KeyTrie::new("test"); + let mut successful = vec![]; + + for (i, key) in keys.iter().enumerate() { + if trie.bind(key, i).is_ok() { + successful.push(key.clone()); + } + } + + for key in &successful { + let seq = parse_sequence(key).unwrap(); + prop_assert!(matches!( + trie.search(&seq), + evil_keys::SearchResult::Found(_) + )); + } + } + + #[test] + fn search_empty_trie_never_panics(keys in prop::collection::vec(valid_key_string(), 0..5)) { + let trie: KeyTrie<()> = KeyTrie::new("test"); + let parsed: Vec = keys + .iter() + .filter_map(|k| parse_key(k).ok()) + .collect(); + let _ = trie.search(&parsed); + } +}