Files
Alexander bee1f82405 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 <clio-agent@sisyphuslabs.ai>
2026-05-10 10:59:43 +02:00

404 lines
9.9 KiB
Rust

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);
}