Skip to main content

lattice_ui_tui/
keymap_normal.rs

1//! Crossterm-coupled test harness for the renderer-neutral
2//! `lattice_host::keymap_normal` catalog. Slice 5.4 / slice 3
3//! moved the production code to lattice-host; the tests stay
4//! here because their `ev()` helper builds `KeyChord` values
5//! via `crate::chord::from_event(&KeyEvent { ... })`, and the
6//! crossterm adapter only exists in this crate.
7//!
8//! `pub use` re-exports every public item from the host so
9//! call sites that referenced `lattice_ui_tui::keymap_normal::*`
10//! before the move keep resolving without source changes.
11
12pub use lattice_host::keymap_normal::*;
13
14#[cfg(test)]
15mod tests {
16    #![allow(clippy::unwrap_used, clippy::panic, unused_imports)]
17    use super::*;
18    use crate::actions::ActionIds;
19    use crate::app::{Action, FindKind};
20    use crate::chord::{KeyChord, KeyKind, KeyMods, SpecialKey};
21    use crate::keymap::BindingMode;
22    use crate::keymap_registry::KeymapHandle;
23    use crate::keymap_trie::{BoundCommand, ChordPattern, KeymapLayer, LookupResult};
24    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
25    use lattice_grammar::CommandRegistry;
26    use lattice_grammar::Target;
27    use lattice_grammar::builtins::Builtins;
28    use lattice_grammar::command::CommandInvocation;
29
30    /// Build a canonical `KeyChord` from crossterm pieces. Slice
31    /// 5.4 made `lookup_normal*` take `&KeyChord` directly; the
32    /// test helper keeps its old `(KeyCode, KeyModifiers)` shape
33    /// so test bodies don't churn -- the conversion routes
34    /// through the same `chord::from_event` adapter the runtime
35    /// uses.
36    fn ev(code: KeyCode, mods: KeyModifiers) -> KeyChord {
37        let raw = KeyEvent {
38            code,
39            modifiers: mods,
40            kind: KeyEventKind::Press,
41            state: KeyEventState::NONE,
42        };
43        crate::chord::from_event(&raw).expect("test event converts to a chord")
44    }
45
46    /// D.5.b (2026-05-30): shadow the host-crate `lookup_normal`
47    /// signature change. Production gained
48    /// `active_minor_modes: &[ModeId]` so chord bindings
49    /// registered against `MinorMode(ModeId)` layers gate on
50    /// per-buffer activation (K.1.c). These tests exercise the
51    /// always-on `Builtin`-layer catalog; they pass `&[]`
52    /// (no minor modes active) to keep behaviour identical to
53    /// the pre-D.5.b legacy `lookup` path.
54    fn lookup_normal(handle: &KeymapHandle, chord: &KeyChord) -> Option<Action> {
55        lattice_host::keymap_normal::lookup_normal(handle, chord, &[])
56    }
57
58    fn lookup_normal_with_prefix(
59        handle: &KeymapHandle,
60        prefix: &[KeyChord],
61        chord: &KeyChord,
62    ) -> Action {
63        lattice_host::keymap_normal::lookup_normal_with_prefix(handle, prefix, chord, &[])
64    }
65
66    fn fixture() -> (CommandRegistry, Builtins, ActionIds) {
67        let mut r = CommandRegistry::new();
68        let b = lattice_grammar::builtins::populate(&mut r);
69        let a = crate::actions::populate(&mut r, &b);
70        (r, b, a)
71    }
72
73    fn populated_handle() -> (KeymapHandle, Builtins, ActionIds) {
74        let (mut r, b, a) = fixture();
75        let so = lattice_syntax::register_syntax_text_objects(&mut r);
76        let sm = lattice_syntax::register_syntax_motions(&mut r);
77        let h = KeymapHandle::new();
78        register_normal_bindings(&h, &b, &a, &so, &sm);
79        (h, b, a)
80    }
81
82    #[test]
83    fn motion_h_invokes_char_left() {
84        let (h, b, _) = populated_handle();
85        let r = lookup_normal(&h, &ev(KeyCode::Char('h'), KeyModifiers::NONE));
86        match r {
87            Some(Action::Invoke(inv)) => assert_eq!(inv.command, b.char_left.0),
88            other => panic!("expected Invoke(char_left), got {other:?}"),
89        }
90    }
91
92    #[test]
93    fn arrow_left_aliases_char_left() {
94        let (h, b, _) = populated_handle();
95        let r = lookup_normal(&h, &ev(KeyCode::Left, KeyModifiers::NONE));
96        match r {
97            Some(Action::Invoke(inv)) => assert_eq!(inv.command, b.char_left.0),
98            other => panic!("expected Invoke(char_left), got {other:?}"),
99        }
100    }
101
102    #[test]
103    fn upper_g_invokes_goto_last_line() {
104        let (h, b, _) = populated_handle();
105        let r = lookup_normal(&h, &ev(KeyCode::Char('G'), KeyModifiers::NONE));
106        match r {
107            Some(Action::Invoke(inv)) => assert_eq!(inv.command, b.goto_last_line.0),
108            other => panic!("expected Invoke(goto_last_line), got {other:?}"),
109        }
110    }
111
112    #[test]
113    fn pseudo_operator_x_carries_char_right_target() {
114        let (h, b, _) = populated_handle();
115        let r = lookup_normal(&h, &ev(KeyCode::Char('x'), KeyModifiers::NONE));
116        match r {
117            Some(Action::Invoke(inv)) => {
118                assert_eq!(inv.command, b.delete.0);
119                assert!(matches!(inv.target, Some(Target::Motion(m, _)) if m == b.char_right));
120            }
121            other => panic!("expected Invoke(delete, char_right), got {other:?}"),
122        }
123    }
124
125    #[test]
126    fn pseudo_operator_d_carries_line_end_target() {
127        let (h, b, _) = populated_handle();
128        let r = lookup_normal(&h, &ev(KeyCode::Char('D'), KeyModifiers::NONE));
129        match r {
130            Some(Action::Invoke(inv)) => {
131                assert_eq!(inv.command, b.delete.0);
132                assert!(matches!(inv.target, Some(Target::Motion(m, _)) if m == b.line_end));
133            }
134            other => panic!("expected Invoke(delete, line_end), got {other:?}"),
135        }
136    }
137
138    #[test]
139    fn pseudo_operator_y_capital_uses_current_line_range() {
140        let (h, b, _) = populated_handle();
141        let r = lookup_normal(&h, &ev(KeyCode::Char('Y'), KeyModifiers::NONE));
142        match r {
143            Some(Action::Invoke(inv)) => {
144                assert_eq!(inv.command, b.yank.0);
145                assert!(matches!(
146                    inv.range,
147                    Some(lattice_grammar::Range::CurrentLine)
148                ));
149            }
150            other => panic!("expected Invoke(yank, CurrentLine), got {other:?}"),
151        }
152    }
153
154    #[test]
155    fn viewport_h_resolves_to_the_viewport_top_motion() {
156        // VM.3f: `H` is a motion, so `dH` / `vH` compose.
157        let (h, b, _) = populated_handle();
158        let r = lookup_normal(&h, &ev(KeyCode::Char('H'), KeyModifiers::NONE));
159        match r {
160            Some(Action::Invoke(inv)) => assert_eq!(inv.command, b.viewport_top.0),
161            other => panic!("expected Invoke(viewport_top motion), got {other:?}"),
162        }
163    }
164
165    #[test]
166    fn mode_entry_v_enters_charwise_visual() {
167        let (h, _, a) = populated_handle();
168        let r = lookup_normal(&h, &ev(KeyCode::Char('v'), KeyModifiers::NONE));
169        match r {
170            Some(Action::Invoke(inv)) => assert_eq!(inv.command, a.enter_visual_charwise),
171            other => panic!("expected Invoke(enter_visual_charwise), got {other:?}"),
172        }
173    }
174
175    #[test]
176    fn paste_p_lower_pastes_after() {
177        let (h, _, a) = populated_handle();
178        let r = lookup_normal(&h, &ev(KeyCode::Char('p'), KeyModifiers::NONE));
179        match r {
180            Some(Action::Invoke(inv)) => assert_eq!(inv.command, a.paste_after),
181            other => panic!("expected Invoke(paste_after), got {other:?}"),
182        }
183    }
184
185    #[test]
186    fn search_slash_enters_forward_search() {
187        let (h, _, a) = populated_handle();
188        let r = lookup_normal(&h, &ev(KeyCode::Char('/'), KeyModifiers::NONE));
189        match r {
190            Some(Action::Invoke(inv)) => assert_eq!(inv.command, a.enter_search_forward),
191            other => panic!("expected Invoke(enter_search_forward), got {other:?}"),
192        }
193    }
194
195    #[test]
196    fn tab_jumps_history_forward() {
197        let (h, _, a) = populated_handle();
198        let r = lookup_normal(&h, &ev(KeyCode::Tab, KeyModifiers::NONE));
199        match r {
200            Some(Action::Invoke(inv)) => assert_eq!(inv.command, a.jump_history_forward),
201            other => panic!("expected Invoke(jump_history_forward), got {other:?}"),
202        }
203    }
204
205    #[test]
206    fn page_down_invokes_line_down_with_count_ten() {
207        let (h, b, _) = populated_handle();
208        let r = lookup_normal(&h, &ev(KeyCode::PageDown, KeyModifiers::NONE));
209        match r {
210            Some(Action::Invoke(inv)) => {
211                assert_eq!(inv.command, b.line_down.0);
212                assert_eq!(inv.count, Some(lattice_grammar::command::Count(10)));
213            }
214            other => panic!("expected Invoke(line_down, count=10), got {other:?}"),
215        }
216    }
217
218    /// Slice 8.g.iii: `d` is now a terminal binding that arms
219    /// `Pending::AfterOperator(delete)`. The trie still has
220    /// children (`[d, w]`, `[d, d]`, etc.) for the second-key
221    /// resolution, but lookup of `[d]` alone returns `Bound`
222    /// because the depth-1 node carries a binding.
223    #[test]
224    fn d_invokes_absorb_operator_delete() {
225        // Slice 8.i.4.c: pressing `d` invokes the typed
226        // `absorb_operator_delete` action, which emits
227        // `AppEffect::AbsorbOperatorPrefix(delete)`. App's
228        // handler latches op_count and pushes [d] to
229        // partial_chord.
230        let (h, _, a) = populated_handle();
231        let r = lookup_normal(&h, &ev(KeyCode::Char('d'), KeyModifiers::NONE));
232        match r {
233            Some(Action::Invoke(inv)) => {
234                assert_eq!(inv.command, a.absorb_operator_delete);
235            }
236            other => panic!("expected Invoke(absorb_operator_delete), got {other:?}"),
237        }
238    }
239
240    #[test]
241    fn dw_resolves_to_delete_with_word_forward_target() {
242        let (h, b, _) = populated_handle();
243        let r = lookup_normal_with_prefix(
244            &h,
245            &[KeyChord::char('d')],
246            &ev(KeyCode::Char('w'), KeyModifiers::NONE),
247        );
248        match r {
249            Action::Invoke(inv) => {
250                assert_eq!(inv.command, b.delete.0);
251                assert!(matches!(
252                    inv.target,
253                    Some(Target::Motion(m, _)) if m == b.word_forward
254                ));
255            }
256            other => panic!("expected Invoke(delete, word_forward), got {other:?}"),
257        }
258    }
259
260    /// Motion-table unification: `G` is now in the shared `motion_rows`
261    /// table, so `dG` resolves as an operator target (previously the
262    /// operator-pending list omitted `G`). `G` is linewise, so `dG` deletes
263    /// whole lines to the end (VM.3L).
264    #[test]
265    fn d_uppercase_g_resolves_to_delete_with_goto_last_line_target() {
266        let (h, b, _) = populated_handle();
267        let r = lookup_normal_with_prefix(
268            &h,
269            &[KeyChord::char('d')],
270            &ev(KeyCode::Char('G'), KeyModifiers::NONE),
271        );
272        match r {
273            Action::Invoke(inv) => {
274                assert_eq!(inv.command, b.delete.0);
275                assert!(matches!(
276                    inv.target,
277                    Some(Target::Motion(m, _)) if m == b.goto_last_line
278                ));
279            }
280            other => panic!("expected Invoke(delete, goto_last_line), got {other:?}"),
281        }
282    }
283
284    #[test]
285    fn dd_resolves_to_delete_current_line() {
286        let (h, b, _) = populated_handle();
287        let r = lookup_normal_with_prefix(
288            &h,
289            &[KeyChord::char('d')],
290            &ev(KeyCode::Char('d'), KeyModifiers::NONE),
291        );
292        match r {
293            Action::Invoke(inv) => {
294                assert_eq!(inv.command, b.delete.0);
295                assert!(matches!(
296                    inv.range,
297                    Some(lattice_grammar::Range::CurrentLine)
298                ));
299            }
300            other => panic!("expected Invoke(delete, CurrentLine), got {other:?}"),
301        }
302    }
303
304    #[test]
305    fn yy_resolves_to_yank_current_line() {
306        let (h, b, _) = populated_handle();
307        let r = lookup_normal_with_prefix(
308            &h,
309            &[KeyChord::char('y')],
310            &ev(KeyCode::Char('y'), KeyModifiers::NONE),
311        );
312        match r {
313            Action::Invoke(inv) => {
314                assert_eq!(inv.command, b.yank.0);
315                assert!(matches!(
316                    inv.range,
317                    Some(lattice_grammar::Range::CurrentLine)
318                ));
319            }
320            other => panic!("expected Invoke(yank, CurrentLine), got {other:?}"),
321        }
322    }
323
324    #[test]
325    fn cc_resolves_to_change_current_line() {
326        let (h, b, _) = populated_handle();
327        let r = lookup_normal_with_prefix(
328            &h,
329            &[KeyChord::char('c')],
330            &ev(KeyCode::Char('c'), KeyModifiers::NONE),
331        );
332        match r {
333            Action::Invoke(inv) => {
334                assert_eq!(inv.command, b.change.0);
335                assert!(matches!(
336                    inv.range,
337                    Some(lattice_grammar::Range::CurrentLine)
338                ));
339            }
340            other => panic!("expected Invoke(change, CurrentLine), got {other:?}"),
341        }
342    }
343
344    #[test]
345    fn di_absorbs_partial_chord() {
346        // Slice 8.i.4.c: with prefix [d], pressing `i` absorbs
347        // into partial_chord. The trie returns Partial because
348        // `[d, i, w]` etc. are bound; lookup_normal_with_prefix
349        // emits `AbsorbPartialChord(i)`.
350        let (h, _, _a) = populated_handle();
351        let r = lookup_normal_with_prefix(
352            &h,
353            &[KeyChord::char('d')],
354            &ev(KeyCode::Char('i'), KeyModifiers::NONE),
355        );
356        assert!(matches!(
357            r,
358            Action::AbsorbPartialChord(c) if c == KeyChord::char('i')
359        ));
360    }
361
362    #[test]
363    fn diw_resolves_to_delete_inner_word() {
364        let (h, b, _) = populated_handle();
365        let r = lookup_normal_with_prefix(
366            &h,
367            &[KeyChord::char('d'), KeyChord::char('i')],
368            &ev(KeyCode::Char('w'), KeyModifiers::NONE),
369        );
370        match r {
371            Action::Invoke(inv) => {
372                assert_eq!(inv.command, b.delete.0);
373                assert!(matches!(
374                    inv.target,
375                    Some(Target::TextObject(id, _)) if id == b.inner_word
376                ));
377            }
378            other => panic!("expected Invoke(delete, inner_word), got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn dab_resolves_to_delete_around_paren() {
384        // Alias check: `b` inside `da` resolves to around_paren.
385        let (h, b_, _) = populated_handle();
386        let r = lookup_normal_with_prefix(
387            &h,
388            &[KeyChord::char('d'), KeyChord::char('a')],
389            &ev(KeyCode::Char('b'), KeyModifiers::NONE),
390        );
391        match r {
392            Action::Invoke(inv) => {
393                assert_eq!(inv.command, b_.delete.0);
394                assert!(matches!(
395                    inv.target,
396                    Some(Target::TextObject(id, _)) if id == b_.around_paren
397                ));
398            }
399            other => panic!("expected Invoke(delete, around_paren), got {other:?}"),
400        }
401    }
402
403    #[test]
404    fn df_absorbs_partial_chord() {
405        // Slice 8.i.4.c: with prefix [d], pressing `f` absorbs
406        // into partial_chord. The trie returns Partial because
407        // `[d, f, *]` is bound (find-char wildcard).
408        let (h, _, _a) = populated_handle();
409        let r = lookup_normal_with_prefix(
410            &h,
411            &[KeyChord::char('d')],
412            &ev(KeyCode::Char('f'), KeyModifiers::NONE),
413        );
414        assert!(matches!(
415            r,
416            Action::AbsorbPartialChord(c) if c == KeyChord::char('f')
417        ));
418    }
419
420    #[test]
421    fn d_unrecognised_drops_pending() {
422        let (h, _, _a) = populated_handle();
423        let r = lookup_normal_with_prefix(
424            &h,
425            &[KeyChord::char('d')],
426            &ev(KeyCode::Char('Q'), KeyModifiers::NONE),
427        );
428        assert!(matches!(r, Action::None));
429    }
430
431    /// Doubled-operator under the `g` prefix: `gUU` -> linewise
432    /// upper. The prefix walk is `[g, U, U]`.
433    #[test]
434    fn g_uu_resolves_to_upper_current_line() {
435        let (h, b, _) = populated_handle();
436        let r = lookup_normal_with_prefix(
437            &h,
438            &[KeyChord::char('g'), KeyChord::char('U')],
439            &ev(KeyCode::Char('U'), KeyModifiers::NONE),
440        );
441        match r {
442            Action::Invoke(inv) => {
443                assert_eq!(inv.command, b.upper.0);
444                assert!(matches!(
445                    inv.range,
446                    Some(lattice_grammar::Range::CurrentLine)
447                ));
448            }
449            other => panic!("expected Invoke(upper, CurrentLine), got {other:?}"),
450        }
451    }
452
453    /// `gUw` -- upper applied to the word_forward motion target.
454    #[test]
455    fn g_uw_resolves_to_upper_with_word_forward() {
456        let (h, b, _) = populated_handle();
457        let r = lookup_normal_with_prefix(
458            &h,
459            &[KeyChord::char('g'), KeyChord::char('U')],
460            &ev(KeyCode::Char('w'), KeyModifiers::NONE),
461        );
462        match r {
463            Action::Invoke(inv) => {
464                assert_eq!(inv.command, b.upper.0);
465                assert!(matches!(
466                    inv.target,
467                    Some(Target::Motion(m, _)) if m == b.word_forward
468                ));
469            }
470            other => panic!("expected Invoke(upper, word_forward), got {other:?}"),
471        }
472    }
473
474    /// Slice 8.g.ii: `g` is a partial trie node (children only,
475    /// no terminal binding). `lookup_normal` converts the
476    /// `LookupResult::Partial` into `SetPending(AfterG)` so the
477    /// dispatcher arms the second-key resolver.
478    #[test]
479    fn g_absorbs_partial_chord() {
480        // Slice 8.i.4.a: trie's `Partial` -> `AbsorbPartialChord(g)`.
481        let (h, _, _a) = populated_handle();
482        let r = lookup_normal(&h, &ev(KeyCode::Char('g'), KeyModifiers::NONE));
483        assert!(matches!(
484            r,
485            Some(Action::AbsorbPartialChord(c)) if c == KeyChord::char('g')
486        ));
487    }
488
489    #[test]
490    fn z_absorbs_partial_chord() {
491        // Slice 8.i.4.a: trie's `Partial` -> `AbsorbPartialChord(z)`.
492        let (h, _, _a) = populated_handle();
493        let r = lookup_normal(&h, &ev(KeyCode::Char('z'), KeyModifiers::NONE));
494        assert!(matches!(
495            r,
496            Some(Action::AbsorbPartialChord(c)) if c == KeyChord::char('z')
497        ));
498    }
499
500    #[test]
501    fn gg_resolves_to_goto_first_line() {
502        let (h, b, _) = populated_handle();
503        let r = lookup_normal_with_prefix(
504            &h,
505            &[KeyChord::char('g')],
506            &ev(KeyCode::Char('g'), KeyModifiers::NONE),
507        );
508        match r {
509            Action::Invoke(inv) => assert_eq!(inv.command, b.goto_first_line.0),
510            other => panic!("expected Invoke(goto_first_line), got {other:?}"),
511        }
512    }
513
514    #[test]
515    fn gd_without_lsp_mode_is_unresolved_at_builtin_layer() {
516        // MO.1: gd migrated to LspMode::keymap() (MinorMode layer).
517        // Builtin trie no longer has gd → Action::None without lsp-mode.
518        let (h, _, _) = populated_handle();
519        let r = lookup_normal_with_prefix(
520            &h,
521            &[KeyChord::char('g')],
522            &ev(KeyCode::Char('d'), KeyModifiers::NONE),
523        );
524        assert!(
525            matches!(r, Action::None),
526            "expected Action::None, got {r:?}"
527        );
528    }
529
530    #[test]
531    fn gu_invokes_absorb_operator_lower() {
532        // Slice 8.i.4.c: `gu` (with prefix [g]) resolves to
533        // Invoke(absorb_operator_lower).
534        let (h, _, a) = populated_handle();
535        let r = lookup_normal_with_prefix(
536            &h,
537            &[KeyChord::char('g')],
538            &ev(KeyCode::Char('u'), KeyModifiers::NONE),
539        );
540        match r {
541            Action::Invoke(inv) => assert_eq!(inv.command, a.absorb_operator_lower),
542            other => panic!("expected Invoke(absorb_operator_lower), got {other:?}"),
543        }
544    }
545
546    #[test]
547    fn g_capital_j_resolves_to_join_lines_without_space() {
548        let (h, _, a) = populated_handle();
549        let r = lookup_normal_with_prefix(
550            &h,
551            &[KeyChord::char('g')],
552            &ev(KeyCode::Char('J'), KeyModifiers::NONE),
553        );
554        match r {
555            Action::Invoke(inv) => assert_eq!(inv.command, a.join_lines_bare),
556            other => panic!("expected Invoke(join_lines_bare), got {other:?}"),
557        }
558    }
559
560    #[test]
561    fn zz_centers_cursor() {
562        let (h, _, a) = populated_handle();
563        let r = lookup_normal_with_prefix(
564            &h,
565            &[KeyChord::char('z')],
566            &ev(KeyCode::Char('z'), KeyModifiers::NONE),
567        );
568        match r {
569            Action::Invoke(inv) => assert_eq!(inv.command, a.scroll_cursor_to_center),
570            other => panic!("expected Invoke(scroll_cursor_to_center), got {other:?}"),
571        }
572    }
573
574    #[test]
575    fn z_dot_aliases_zz() {
576        let (h, _, a) = populated_handle();
577        let r = lookup_normal_with_prefix(
578            &h,
579            &[KeyChord::char('z')],
580            &ev(KeyCode::Char('.'), KeyModifiers::NONE),
581        );
582        match r {
583            Action::Invoke(inv) => assert_eq!(inv.command, a.scroll_cursor_to_center),
584            other => panic!("expected Invoke(scroll_cursor_to_center), got {other:?}"),
585        }
586    }
587
588    #[test]
589    fn z_enter_aliases_zt() {
590        let (h, _, a) = populated_handle();
591        let r = lookup_normal_with_prefix(
592            &h,
593            &[KeyChord::char('z')],
594            &ev(KeyCode::Enter, KeyModifiers::NONE),
595        );
596        match r {
597            Action::Invoke(inv) => assert_eq!(inv.command, a.scroll_cursor_to_top),
598            other => panic!("expected Invoke(scroll_cursor_to_top), got {other:?}"),
599        }
600    }
601
602    #[test]
603    fn z_dash_aliases_zb() {
604        let (h, _, a) = populated_handle();
605        let r = lookup_normal_with_prefix(
606            &h,
607            &[KeyChord::char('z')],
608            &ev(KeyCode::Char('-'), KeyModifiers::NONE),
609        );
610        match r {
611            Action::Invoke(inv) => assert_eq!(inv.command, a.scroll_cursor_to_bottom),
612            other => panic!("expected Invoke(scroll_cursor_to_bottom), got {other:?}"),
613        }
614    }
615
616    #[test]
617    fn za_toggles_fold_at_cursor() {
618        let (h, _, a) = populated_handle();
619        let r = lookup_normal_with_prefix(
620            &h,
621            &[KeyChord::char('z')],
622            &ev(KeyCode::Char('a'), KeyModifiers::NONE),
623        );
624        match r {
625            Action::Invoke(inv) => assert_eq!(inv.command, a.toggle_fold_at_cursor),
626            other => panic!("expected Invoke(toggle_fold_at_cursor), got {other:?}"),
627        }
628    }
629
630    #[test]
631    fn z_unrecognized_drops_pending() {
632        let (h, _, _a) = populated_handle();
633        let r = lookup_normal_with_prefix(
634            &h,
635            &[KeyChord::char('z')],
636            &ev(KeyCode::Char('X'), KeyModifiers::NONE),
637        );
638        assert!(matches!(r, Action::None));
639    }
640
641    #[test]
642    fn z_esc_drops_pending() {
643        let (h, _, _a) = populated_handle();
644        let r = lookup_normal_with_prefix(
645            &h,
646            &[KeyChord::char('z')],
647            &ev(KeyCode::Esc, KeyModifiers::NONE),
648        );
649        assert!(matches!(r, Action::None));
650    }
651
652    /// Slice 8.g.v: `q` outside macro recording arms
653    /// `Pending::AfterMacroStart`. The recording-state-dependent
654    /// branch (`StopMacroRecord` when already recording) lives
655    /// in `compute_normal_action` as a short-circuit before the
656    /// trie lookup -- the registry doesn't see App state.
657    #[test]
658    fn q_absorbs_partial_chord() {
659        // Slice 8.i.4.a: trie's `Partial` -> `AbsorbPartialChord(q)`.
660        let (h, _, _a) = populated_handle();
661        let r = lookup_normal(&h, &ev(KeyCode::Char('q'), KeyModifiers::NONE));
662        assert!(matches!(
663            r,
664            Some(Action::AbsorbPartialChord(c)) if c == KeyChord::char('q')
665        ));
666    }
667
668    // ---- Slice 8.g.v: wildcard chord paths ----
669
670    #[test]
671    fn ma_resolves_to_set_mark_a() {
672        let (h, _, a) = populated_handle();
673        let r = lookup_normal_with_prefix(
674            &h,
675            &[KeyChord::char('m')],
676            &ev(KeyCode::Char('a'), KeyModifiers::NONE),
677        );
678        match r {
679            Action::Invoke(inv) => {
680                assert_eq!(inv.command, a.set_mark);
681                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('a')));
682            }
683            other => panic!("expected Invoke(set_mark, Char('a')), got {other:?}"),
684        }
685    }
686
687    #[test]
688    fn m_invalid_passes_char_to_actionspec() {
689        // Slice 8.i.3: validation moved from the dispatcher to
690        // the bound `ActionSpec`. The dispatcher returns
691        // `Invoke(set_mark)` with the captured `'!'` regardless
692        // of validity; the spec returns `Effect::None` for
693        // non-alphanumeric chars and `App::apply` clears the
694        // pending state on every Invoke.
695        let (h, _, a) = populated_handle();
696        let r = lookup_normal_with_prefix(
697            &h,
698            &[KeyChord::char('m')],
699            &ev(KeyCode::Char('!'), KeyModifiers::NONE),
700        );
701        match r {
702            Action::Invoke(inv) => {
703                assert_eq!(inv.command, a.set_mark);
704                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('!')));
705            }
706            other => panic!("expected Invoke(set_mark, Char('!')), got {other:?}"),
707        }
708    }
709
710    #[test]
711    fn apostrophe_a_resolves_to_the_mark_line_motion() {
712        // VM.3e: the motion, so `d'a` / `v'a` compose.
713        let (h, b, _) = populated_handle();
714        let r = lookup_normal_with_prefix(
715            &h,
716            &[KeyChord::char('\'')],
717            &ev(KeyCode::Char('a'), KeyModifiers::NONE),
718        );
719        match r {
720            Action::Invoke(inv) => {
721                assert_eq!(inv.command, b.mark_line.0);
722                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('a')));
723            }
724            other => panic!("expected Invoke(mark_line, Char('a')), got {other:?}"),
725        }
726    }
727
728    #[test]
729    fn backtick_a_resolves_to_the_mark_exact_motion() {
730        // VM.3e: the motion, so `` d`a `` / `` v`a `` compose.
731        let (h, b, _) = populated_handle();
732        let r = lookup_normal_with_prefix(
733            &h,
734            &[KeyChord::char('`')],
735            &ev(KeyCode::Char('a'), KeyModifiers::NONE),
736        );
737        match r {
738            Action::Invoke(inv) => {
739                assert_eq!(inv.command, b.mark_exact.0);
740                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('a')));
741            }
742            other => panic!("expected Invoke(mark_exact, Char('a')), got {other:?}"),
743        }
744    }
745
746    #[test]
747    fn quote_a_selects_named_register_a() {
748        let (h, _, a) = populated_handle();
749        let r = lookup_normal_with_prefix(
750            &h,
751            &[KeyChord::char('"')],
752            &ev(KeyCode::Char('a'), KeyModifiers::NONE),
753        );
754        match r {
755            Action::Invoke(inv) => {
756                assert_eq!(inv.command, a.select_register);
757                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('a')));
758            }
759            other => panic!("expected Invoke(select_register, Char('a')), got {other:?}"),
760        }
761    }
762
763    #[test]
764    fn quote_plus_selects_system_register() {
765        let (h, _, a) = populated_handle();
766        let r = lookup_normal_with_prefix(
767            &h,
768            &[KeyChord::char('"')],
769            &ev(KeyCode::Char('+'), KeyModifiers::NONE),
770        );
771        match r {
772            Action::Invoke(inv) => {
773                assert_eq!(inv.command, a.select_register);
774                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('+')));
775            }
776            other => panic!("expected Invoke(select_register, Char('+')), got {other:?}"),
777        }
778    }
779
780    #[test]
781    fn quote_zero_selects_numbered_register_zero() {
782        let (h, _, a) = populated_handle();
783        let r = lookup_normal_with_prefix(
784            &h,
785            &[KeyChord::char('"')],
786            &ev(KeyCode::Char('0'), KeyModifiers::NONE),
787        );
788        match r {
789            Action::Invoke(inv) => {
790                assert_eq!(inv.command, a.select_register);
791                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('0')));
792            }
793            other => panic!("expected Invoke(select_register, Char('0')), got {other:?}"),
794        }
795    }
796
797    #[test]
798    fn quote_invalid_passes_char_to_actionspec() {
799        // Slice 8.i.3: validation lives in the bound `ActionSpec`,
800        // not the dispatcher. The dispatched `Invoke` carries the
801        // captured `'!'`; the spec returns `Effect::None` for chars
802        // that don't name a register.
803        let (h, _, a) = populated_handle();
804        let r = lookup_normal_with_prefix(
805            &h,
806            &[KeyChord::char('"')],
807            &ev(KeyCode::Char('!'), KeyModifiers::NONE),
808        );
809        match r {
810            Action::Invoke(inv) => {
811                assert_eq!(inv.command, a.select_register);
812                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('!')));
813            }
814            other => panic!("expected Invoke(select_register, Char('!')), got {other:?}"),
815        }
816    }
817
818    #[test]
819    fn qa_starts_macro_record_a() {
820        let (h, _, a) = populated_handle();
821        let r = lookup_normal_with_prefix(
822            &h,
823            &[KeyChord::char('q')],
824            &ev(KeyCode::Char('a'), KeyModifiers::NONE),
825        );
826        match r {
827            Action::Invoke(inv) => {
828                assert_eq!(inv.command, a.start_macro_record);
829                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('a')));
830            }
831            other => panic!("expected Invoke(start_macro_record, Char('a')), got {other:?}"),
832        }
833    }
834
835    #[test]
836    fn at_at_plays_last_macro() {
837        // The dispatcher returns `Invoke(play_macro, Char('@'))`;
838        // the bound `ActionSpec` reads `@` and produces
839        // `AppEffect::PlayLastMacro` rather than `PlayMacro('@')`.
840        // Slice 8.i.3 moved this branching from the dispatcher's
841        // legacy substituter into the spec.
842        let (h, _, a) = populated_handle();
843        let r = lookup_normal_with_prefix(
844            &h,
845            &[KeyChord::char('@')],
846            &ev(KeyCode::Char('@'), KeyModifiers::NONE),
847        );
848        match r {
849            Action::Invoke(inv) => {
850                assert_eq!(inv.command, a.play_macro);
851                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('@')));
852            }
853            other => panic!("expected Invoke(play_macro, Char('@')), got {other:?}"),
854        }
855    }
856
857    #[test]
858    fn at_a_plays_macro_a() {
859        let (h, _, a) = populated_handle();
860        let r = lookup_normal_with_prefix(
861            &h,
862            &[KeyChord::char('@')],
863            &ev(KeyCode::Char('a'), KeyModifiers::NONE),
864        );
865        match r {
866            Action::Invoke(inv) => {
867                assert_eq!(inv.command, a.play_macro);
868                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('a')));
869            }
870            other => panic!("expected Invoke(play_macro, Char('a')), got {other:?}"),
871        }
872    }
873
874    #[test]
875    fn f_x_resolves_to_find_char_forward_with_args() {
876        let (h, b, _) = populated_handle();
877        let r = lookup_normal_with_prefix(
878            &h,
879            &[KeyChord::char('f')],
880            &ev(KeyCode::Char('X'), KeyModifiers::NONE),
881        );
882        match r {
883            Action::Invoke(inv) => {
884                assert_eq!(inv.command, b.find_char_forward.0);
885                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('X')));
886            }
887            other => panic!("expected Invoke(find_char_forward, Char('X')), got {other:?}"),
888        }
889    }
890
891    #[test]
892    fn r_x_resolves_to_replace_char_with_char_arg_on_operator() {
893        // `r<X>` -- the captured char must land on the OPERATOR's
894        // args (the replacement), NOT on the `char_right` target
895        // (which only sizes the range and ignores its args).
896        let (h, b, _) = populated_handle();
897        let r = lookup_normal_with_prefix(
898            &h,
899            &[KeyChord::char('r')],
900            &ev(KeyCode::Char('X'), KeyModifiers::NONE),
901        );
902        match r {
903            Action::Invoke(inv) => {
904                assert_eq!(inv.command, b.replace_char.0);
905                assert!(
906                    matches!(inv.args, lattice_grammar::args::Args::Char('X')),
907                    "replacement char rides on the operator args, got {:?}",
908                    inv.args
909                );
910                assert!(
911                    matches!(inv.target, Some(Target::Motion(m, _)) if m == b.char_right),
912                    "range sized by char_right, got {:?}",
913                    inv.target
914                );
915            }
916            other => panic!("expected Invoke(replace_char, Char('X')), got {other:?}"),
917        }
918    }
919
920    #[test]
921    fn r_alone_absorbs_partial_chord() {
922        // `r` with no following key must wait for the replacement.
923        let (h, _, _) = populated_handle();
924        let r = lookup_normal(&h, &ev(KeyCode::Char('r'), KeyModifiers::NONE));
925        assert!(
926            matches!(r, Some(Action::AbsorbPartialChord(c)) if c == KeyChord::char('r')),
927            "expected AbsorbPartialChord(r), got {r:?}"
928        );
929    }
930
931    #[test]
932    fn dfx_resolves_to_delete_with_find_char_target() {
933        // `df<X>` -- delete forward up to and including 'X'.
934        let (h, b, _) = populated_handle();
935        let r = lookup_normal_with_prefix(
936            &h,
937            &[KeyChord::char('d'), KeyChord::char('f')],
938            &ev(KeyCode::Char('X'), KeyModifiers::NONE),
939        );
940        match r {
941            Action::Invoke(inv) => {
942                assert_eq!(inv.command, b.delete.0);
943                match inv.target {
944                    Some(Target::Motion(m, args)) => {
945                        assert_eq!(m, b.find_char_forward);
946                        assert!(matches!(args, lattice_grammar::args::Args::Char('X')));
947                    }
948                    other => panic!("expected Motion(find_char_forward, Char('X')), got {other:?}"),
949                }
950            }
951            other => panic!("got {other:?}"),
952        }
953    }
954
955    /// Wildcard rejects modifier-bearing chords (per
956    /// `keymap_trie`'s wildcard rule). `f<C-x>` is unbound and
957    /// the dispatcher drops the pending state -- a documented
958    /// drift from legacy `resolve_after_find_char`, which
959    /// accepted any `KeyCode::Char(c)` regardless of modifiers.
960    /// Terminals don't typically emit `f<C-x>` and the
961    /// alternative chord representation is the trie's invariant.
962    #[test]
963    fn f_ctrl_x_falls_through_to_drop_pending() {
964        let (h, _, _a) = populated_handle();
965        let r = lookup_normal_with_prefix(
966            &h,
967            &[KeyChord::char('f')],
968            &ev(KeyCode::Char('x'), KeyModifiers::CONTROL),
969        );
970        assert!(matches!(r, Action::None));
971    }
972
973    /// `<S-h>`'s SHIFT is stripped by `KeyChord::from_event`
974    /// for bare letters (case carries the bit), so the trie
975    /// only needs `(Char('h'), NONE)`. Pin that here.
976    #[test]
977    fn shift_h_resolves_via_lowercase_chord() {
978        let (h, b, _) = populated_handle();
979        let r = lookup_normal(&h, &ev(KeyCode::Char('h'), KeyModifiers::SHIFT));
980        match r {
981            Some(Action::Invoke(inv)) => assert_eq!(inv.command, b.char_left.0),
982            other => panic!("expected Invoke(char_left), got {other:?}"),
983        }
984    }
985
986    /// `<M-h>` falls through to char_left -- legacy
987    /// `translate_normal` matched on `event.code` alone after
988    /// the CTRL guard, so non-CONTROL modifiers are
989    /// transparent. Same modifier-transparency as Replace /
990    /// Visual.
991    #[test]
992    fn alt_h_resolves_to_char_left() {
993        let (h, b, _) = populated_handle();
994        let r = lookup_normal(&h, &ev(KeyCode::Char('h'), KeyModifiers::ALT));
995        match r {
996            Some(Action::Invoke(inv)) => assert_eq!(inv.command, b.char_left.0),
997            other => panic!("expected Invoke(char_left), got {other:?}"),
998        }
999    }
1000
1001    // ---- Slice 8.g.iv: attach_count.
1002
1003    fn invoke_no_count() -> Action {
1004        Action::Invoke(CommandInvocation::of(
1005            lattice_protocol::ids::CommandId::new(42),
1006        ))
1007    }
1008
1009    fn invoke_with_default_count(n: u32) -> Action {
1010        Action::Invoke(
1011            CommandInvocation::of(lattice_protocol::ids::CommandId::new(42))
1012                .with_count(lattice_grammar::command::Count(n)),
1013        )
1014    }
1015
1016    #[test]
1017    fn attach_count_pending_count_only() {
1018        let r = attach_count(invoke_no_count(), 5, 0);
1019        match r {
1020            Action::Invoke(inv) => {
1021                assert_eq!(inv.count, Some(lattice_grammar::command::Count(5)));
1022            }
1023            other => panic!("got {other:?}"),
1024        }
1025    }
1026
1027    #[test]
1028    fn attach_count_op_times_motion() {
1029        let r = attach_count(invoke_no_count(), 3, 2);
1030        match r {
1031            Action::Invoke(inv) => {
1032                assert_eq!(inv.count, Some(lattice_grammar::command::Count(6)));
1033            }
1034            other => panic!("got {other:?}"),
1035        }
1036    }
1037
1038    #[test]
1039    fn attach_count_op_only_uses_default_motion_count_one() {
1040        // pending_count == 0 and inv has no default => motion_count = 1.
1041        // op_count = 4 => final = 4.
1042        let r = attach_count(invoke_no_count(), 0, 4);
1043        match r {
1044            Action::Invoke(inv) => {
1045                assert_eq!(inv.count, Some(lattice_grammar::command::Count(4)));
1046            }
1047            other => panic!("got {other:?}"),
1048        }
1049    }
1050
1051    #[test]
1052    fn attach_count_default_count_baked_in_used_when_pending_zero() {
1053        // PageDown shape: the binding registered with Count(10).
1054        // pending_count == 0 => motion_count falls back to
1055        // inv.count = 10. op_count == 0 => final = 10.
1056        let r = attach_count(invoke_with_default_count(10), 0, 0);
1057        match r {
1058            Action::Invoke(inv) => {
1059                assert_eq!(inv.count, Some(lattice_grammar::command::Count(10)));
1060            }
1061            other => panic!("got {other:?}"),
1062        }
1063    }
1064
1065    #[test]
1066    fn attach_count_pending_overrides_default_count() {
1067        // `5<PageDown>`: pending_count=5 wins over the binding's
1068        // baked-in Count(10).
1069        let r = attach_count(invoke_with_default_count(10), 5, 0);
1070        match r {
1071            Action::Invoke(inv) => {
1072                assert_eq!(inv.count, Some(lattice_grammar::command::Count(5)));
1073            }
1074            other => panic!("got {other:?}"),
1075        }
1076    }
1077
1078    #[test]
1079    fn attach_count_no_attachment_when_final_is_one() {
1080        // `j` with no count: motion_count=1, op_count=0, final=1.
1081        // Don't write `Count(1)` -- keep the invocation's count
1082        // field `None` (legacy semantics).
1083        let r = attach_count(invoke_no_count(), 0, 0);
1084        match r {
1085            Action::Invoke(inv) => assert_eq!(inv.count, None),
1086            other => panic!("got {other:?}"),
1087        }
1088    }
1089
1090    #[test]
1091    fn attach_count_passes_through_non_invoke_actions() {
1092        let r = attach_count(Action::ExitVisual, 5, 0);
1093        assert!(matches!(r, Action::ExitVisual));
1094        let r = attach_count(Action::None, 0, 0);
1095        assert!(matches!(r, Action::None));
1096        // Slice 8.i.4.d: AbsorbPartialChord is the new "non-Invoke
1097        // pass-through" attach_count case (was SetPending(_)).
1098        let r = attach_count(Action::AbsorbPartialChord(KeyChord::char('g')), 5, 0);
1099        assert!(matches!(r, Action::AbsorbPartialChord(_)));
1100    }
1101
1102    #[test]
1103    fn attach_count_idempotent_when_re_applied() {
1104        // App's existing count math runs *after* translate's
1105        // attach_count for the legacy interactive flow. Pin
1106        // idempotence: re-applying with the same pending /
1107        // op_count yields the same Count.
1108        let once = attach_count(invoke_no_count(), 3, 2);
1109        let once_clone = match &once {
1110            Action::Invoke(inv) => Action::Invoke(inv.clone()),
1111            _ => panic!(),
1112        };
1113        let twice = attach_count(once_clone, 3, 2);
1114        match (once, twice) {
1115            (Action::Invoke(a), Action::Invoke(b)) => {
1116                assert_eq!(a.count, b.count);
1117                assert_eq!(a.count, Some(lattice_grammar::command::Count(6)));
1118            }
1119            other => panic!("got {other:?}"),
1120        }
1121    }
1122
1123    // ---- Slice 8.g.vi: CTRL chords + <C-w> sub-tree ----
1124
1125    /// VM.3j-2 (8676912c): `<C-d>` is the half-window scroll, not the `j`
1126    /// motion with a baked `Count(10)` — that moved ten lines whatever the
1127    /// window height, and made `d<C-d>` delete eleven lines. No count is
1128    /// baked in: a typed count now sets `scroll` (VM.3j-3).
1129    #[test]
1130    fn ctrl_d_resolves_to_the_half_page_scroll() {
1131        let (h, _, a) = populated_handle();
1132        let r = lookup_normal(&h, &ev(KeyCode::Char('d'), KeyModifiers::CONTROL));
1133        match r {
1134            Some(Action::Invoke(inv)) => {
1135                assert_eq!(inv.command, a.half_page_down);
1136                assert_eq!(inv.count, None);
1137            }
1138            other => panic!("expected Invoke(half_page_down), got {other:?}"),
1139        }
1140    }
1141
1142    #[test]
1143    fn ctrl_o_resolves_to_jump_history_back() {
1144        let (h, _, a) = populated_handle();
1145        let r = lookup_normal(&h, &ev(KeyCode::Char('o'), KeyModifiers::CONTROL));
1146        match r {
1147            Some(Action::Invoke(inv)) => assert_eq!(inv.command, a.jump_history_back),
1148            other => panic!("expected Invoke(jump_history_back), got {other:?}"),
1149        }
1150    }
1151
1152    #[test]
1153    fn ctrl_v_enters_blockwise_visual() {
1154        let (h, _, a) = populated_handle();
1155        let r = lookup_normal(&h, &ev(KeyCode::Char('v'), KeyModifiers::CONTROL));
1156        match r {
1157            Some(Action::Invoke(inv)) => assert_eq!(inv.command, a.enter_visual_blockwise),
1158            other => panic!("expected Invoke(enter_visual_blockwise), got {other:?}"),
1159        }
1160    }
1161
1162    #[test]
1163    fn ctrl_w_absorbs_partial_chord() {
1164        // Slice 8.i.4.a: trie's `Partial` -> `AbsorbPartialChord(<C-w>)`.
1165        let (h, _, _a) = populated_handle();
1166        let r = lookup_normal(&h, &ev(KeyCode::Char('w'), KeyModifiers::CONTROL));
1167        assert!(matches!(
1168            r,
1169            Some(Action::AbsorbPartialChord(c)) if c == KeyChord::ctrl('w')
1170        ));
1171    }
1172
1173    #[test]
1174    fn ctrl_w_then_l_navigates_pane_right() {
1175        let (h, _, a) = populated_handle();
1176        let r = lookup_normal_with_prefix(
1177            &h,
1178            &[KeyChord::ctrl('w')],
1179            &ev(KeyCode::Char('l'), KeyModifiers::NONE),
1180        );
1181        match r {
1182            Action::Invoke(inv) => assert_eq!(inv.command, a.navigate_pane_right),
1183            other => panic!("expected Invoke(navigate_pane_right), got {other:?}"),
1184        }
1185    }
1186
1187    #[test]
1188    fn ctrl_w_then_ctrl_l_also_navigates_pane_right() {
1189        // Vim accepts ctrl-modified second keys after `<C-w>`
1190        // (sticky-prefix muscle memory).
1191        let (h, _, a) = populated_handle();
1192        let r = lookup_normal_with_prefix(
1193            &h,
1194            &[KeyChord::ctrl('w')],
1195            &ev(KeyCode::Char('l'), KeyModifiers::CONTROL),
1196        );
1197        match r {
1198            Action::Invoke(inv) => assert_eq!(inv.command, a.navigate_pane_right),
1199            other => panic!("expected Invoke(navigate_pane_right), got {other:?}"),
1200        }
1201    }
1202
1203    #[test]
1204    fn ctrl_w_then_arrow_left_navigates_pane_left() {
1205        let (h, _, a) = populated_handle();
1206        let r = lookup_normal_with_prefix(
1207            &h,
1208            &[KeyChord::ctrl('w')],
1209            &ev(KeyCode::Left, KeyModifiers::NONE),
1210        );
1211        match r {
1212            Action::Invoke(inv) => assert_eq!(inv.command, a.navigate_pane_left),
1213            other => panic!("expected Invoke(navigate_pane_left), got {other:?}"),
1214        }
1215    }
1216
1217    #[test]
1218    fn ctrl_w_then_backspace_navigates_pane_left() {
1219        // Many terminals collapse `<C-h>` to Backspace; the
1220        // bare-Backspace path covers that.
1221        let (h, _, a) = populated_handle();
1222        let r = lookup_normal_with_prefix(
1223            &h,
1224            &[KeyChord::ctrl('w')],
1225            &ev(KeyCode::Backspace, KeyModifiers::NONE),
1226        );
1227        match r {
1228            Action::Invoke(inv) => assert_eq!(inv.command, a.navigate_pane_left),
1229            other => panic!("expected Invoke(navigate_pane_left), got {other:?}"),
1230        }
1231    }
1232
1233    #[test]
1234    fn ctrl_w_then_tab_cycles_to_next_pane() {
1235        let (h, _, a) = populated_handle();
1236        let r = lookup_normal_with_prefix(
1237            &h,
1238            &[KeyChord::ctrl('w')],
1239            &ev(KeyCode::Tab, KeyModifiers::NONE),
1240        );
1241        match r {
1242            Action::Invoke(inv) => assert_eq!(inv.command, a.next_pane),
1243            other => panic!("expected Invoke(next_pane), got {other:?}"),
1244        }
1245    }
1246
1247    #[test]
1248    fn ctrl_w_then_back_tab_cycles_to_prev_pane() {
1249        // BackTab normalises to chord `(Tab, SHIFT)` via
1250        // `KeyChord::from_event`; the trie has the explicit
1251        // `<S-Tab>` registration under `[<C-w>]`.
1252        let (h, _, a) = populated_handle();
1253        let r = lookup_normal_with_prefix(
1254            &h,
1255            &[KeyChord::ctrl('w')],
1256            &ev(KeyCode::BackTab, KeyModifiers::NONE),
1257        );
1258        match r {
1259            Action::Invoke(inv) => assert_eq!(inv.command, a.prev_pane),
1260            other => panic!("expected Invoke(prev_pane), got {other:?}"),
1261        }
1262    }
1263
1264    #[test]
1265    fn ctrl_w_then_v_splits_vertical() {
1266        let (h, _, a) = populated_handle();
1267        let r = lookup_normal_with_prefix(
1268            &h,
1269            &[KeyChord::ctrl('w')],
1270            &ev(KeyCode::Char('v'), KeyModifiers::NONE),
1271        );
1272        match r {
1273            Action::Invoke(inv) => assert_eq!(inv.command, a.split_pane_vertical),
1274            other => panic!("expected Invoke(split_pane_vertical), got {other:?}"),
1275        }
1276    }
1277
1278    #[test]
1279    fn ctrl_w_then_capital_s_splits_horizontal() {
1280        // `<C-w>S` is a legacy alias for `<C-w>s`.
1281        let (h, _, a) = populated_handle();
1282        let r = lookup_normal_with_prefix(
1283            &h,
1284            &[KeyChord::ctrl('w')],
1285            &ev(KeyCode::Char('S'), KeyModifiers::NONE),
1286        );
1287        match r {
1288            Action::Invoke(inv) => assert_eq!(inv.command, a.split_pane_horizontal),
1289            other => panic!("expected Invoke(split_pane_horizontal), got {other:?}"),
1290        }
1291    }
1292
1293    #[test]
1294    fn ctrl_w_then_q_closes_pane() {
1295        let (h, _, a) = populated_handle();
1296        let r = lookup_normal_with_prefix(
1297            &h,
1298            &[KeyChord::ctrl('w')],
1299            &ev(KeyCode::Char('q'), KeyModifiers::NONE),
1300        );
1301        match r {
1302            Action::Invoke(inv) => assert_eq!(inv.command, a.close_pane),
1303            other => panic!("expected Invoke(close_pane), got {other:?}"),
1304        }
1305    }
1306
1307    #[test]
1308    fn ctrl_w_then_esc_drops_pending() {
1309        let (h, _, _a) = populated_handle();
1310        let r = lookup_normal_with_prefix(
1311            &h,
1312            &[KeyChord::ctrl('w')],
1313            &ev(KeyCode::Esc, KeyModifiers::NONE),
1314        );
1315        assert!(matches!(r, Action::None));
1316    }
1317}