Skip to main content

lattice_ui_tui/
input.rs

1//! Crossterm → KeyChord adapter shim for the host-side dispatch.
2//!
3//! Phase 5.4 / slice 5 moved the renderer-neutral dispatch logic
4//! (`TranslateContext`, `translate`, every per-mode translator)
5//! into [`lattice_host::input`]. This module now contains only:
6//!
7//! - A re-export of [`TranslateContext`] so the runtime / tests
8//!   keep their `crate::input::TranslateContext` path working.
9//! - A thin `translate(ctx, event: KeyEvent) -> Action` that
10//!   converts the crossterm event into a canonical [`KeyChord`]
11//!   via [`crate::chord::from_event`] and forwards to
12//!   [`lattice_host::input::translate`]. Events that have no
13//!   chord representation (release events on terminals that emit
14//!   them, modifier-only presses) return [`Action::None`].
15//!
16//! The future `lattice-ui-gpui` ships its own analogous shim
17//! over GPUI key events feeding the same host-side dispatch.
18
19use crossterm::event::KeyEvent;
20
21use crate::app::Action;
22
23pub use lattice_host::input::TranslateContext;
24
25pub fn translate(ctx: TranslateContext<'_>, event: KeyEvent) -> Action {
26    let Some(chord) = crate::chord::from_event(&event) else {
27        return Action::None;
28    };
29    lattice_host::input::translate(ctx, chord)
30}
31
32#[cfg(test)]
33mod tests {
34    #![allow(clippy::unwrap_used, clippy::panic, unused_imports)]
35    use super::*;
36    use crate::buffers::BufferKind;
37    use crate::chord::{KeyChord, KeyKind, KeyMods, SpecialKey};
38    use crate::keymap_registry::KeymapHandle;
39    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
40    use lattice_grammar::CommandRegistry;
41    use lattice_grammar::ModalState;
42    use lattice_grammar::SearchDirection;
43    use lattice_grammar::Target;
44    use lattice_grammar::VisualKind;
45    use lattice_grammar::builtins::{Builtins, populate};
46
47    fn key(code: KeyCode) -> KeyEvent {
48        KeyEvent::new(code, KeyModifiers::NONE)
49    }
50
51    fn ctrl(code: KeyCode) -> KeyEvent {
52        KeyEvent::new(code, KeyModifiers::CONTROL)
53    }
54
55    /// Process-wide shared [`Builtins`] + [`crate::actions::ActionIds`].
56    /// Built once on first access from a single shared registry;
57    /// returned as a static reference so every test fixture in
58    /// this module sees the same id space. `fixture()` returns a
59    /// copy; the scenario-specific `shared_keymap_*` helpers below
60    /// register against the same ids so trie-bound
61    /// `CommandInvocation` ids stay in lockstep with what each
62    /// test compares against.
63    fn shared_init() -> &'static (
64        CommandRegistry,
65        Builtins,
66        crate::actions::ActionIds,
67        lattice_syntax::SyntaxTextObjectIds,
68        lattice_syntax::SyntaxMotionIds,
69    ) {
70        use std::sync::OnceLock;
71        static INIT: OnceLock<(
72            CommandRegistry,
73            Builtins,
74            crate::actions::ActionIds,
75            lattice_syntax::SyntaxTextObjectIds,
76            lattice_syntax::SyntaxMotionIds,
77        )> = OnceLock::new();
78        INIT.get_or_init(|| {
79            let mut r = CommandRegistry::new();
80            let b = populate(&mut r);
81            let _ex = lattice_grammar::ex_commands::populate(&mut r);
82            let a = crate::actions::populate(&mut r, &b);
83            let so = lattice_syntax::register_syntax_text_objects(&mut r);
84            let sm = lattice_syntax::register_syntax_motions(&mut r);
85            // Keep `r`: the snippet mode-keymap layer must translate
86            // against the SAME registry that minted these ids
87            // (`CommandId`s are stable only within one registry).
88            (r, b, a, so, sm)
89        })
90    }
91
92    fn shared_registry() -> &'static CommandRegistry {
93        &shared_init().0
94    }
95
96    fn shared_builtins() -> &'static Builtins {
97        &shared_init().1
98    }
99
100    fn shared_actions() -> &'static crate::actions::ActionIds {
101        &shared_init().2
102    }
103
104    fn shared_syntax_textobjects() -> &'static lattice_syntax::SyntaxTextObjectIds {
105        &shared_init().3
106    }
107
108    fn shared_syntax_motions() -> &'static lattice_syntax::SyntaxMotionIds {
109        &shared_init().4
110    }
111
112    /// Build a fresh `KeymapHandle` populated with every catalog
113    /// the per-mode dispatchers consult: Replace, Visual, Insert,
114    /// Normal. Each scenario-specific helper below starts from
115    /// this and pushes the relevant minor-mode overlays.
116    fn build_base_keymap() -> KeymapHandle {
117        let h = KeymapHandle::new();
118        // VM.4: as boot does. Once the keymap can ask the registry for a
119        // command's kind, it mirrors each motion into Visual and Select at the
120        // write. The handle wants an owned, swappable registry, so this clones
121        // the shared one once per base keymap.
122        h.set_command_registry(std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
123            shared_registry().clone(),
124        )));
125        let b = shared_builtins();
126        let a = shared_actions();
127        let so = shared_syntax_textobjects();
128        let sm = shared_syntax_motions();
129        crate::keymap_replace::register_replace_bindings(&h, a);
130        crate::keymap_visual::register_visual_bindings(&h, b, a, so);
131        crate::keymap_insert::register_insert_bindings(&h, a);
132        crate::keymap_normal::register_normal_bindings(&h, b, a, so, sm);
133        // VM.1: operator-pending rows (`d]]`, `y%`, ...). Visual and Select
134        // motion rows come from the keymap itself as of VM.4.
135        crate::keymap_normal::expand_grammar_rows(
136            &h,
137            shared_registry(),
138            b,
139            lattice_host::keymap_trie::KeymapLayer::Builtin,
140        );
141        h
142    }
143
144    /// Base keymap -- no minor-mode overlays pushed. Default
145    /// scenario for the bulk of `ctx*` test builders.
146    fn shared_keymap_base() -> &'static KeymapHandle {
147        use std::sync::OnceLock;
148        static H: OnceLock<KeymapHandle> = OnceLock::new();
149        H.get_or_init(build_base_keymap)
150    }
151
152    /// SN.3c.1: base keymap + the *Global* `snippet-mode` layer only
153    /// (NOT `active-snippet-mode`). `snippet-mode` is active on every
154    /// document buffer (`ActivationPolicy::Global`), so a base Insert
155    /// keymap in a real document carries its single contributed chord
156    /// — the `<C-x><C-s>` expand prefix. Pushing only `snippet-mode`
157    /// (not the session-scoped `active-snippet-mode`) keeps `<Tab>` /
158    /// `<S-Tab>` resolving to their base Insert meanings, so this is
159    /// the faithful model for the catalog drift check's non-popup,
160    /// non-active-session Insert descriptors (notably `<C-x>`).
161    fn shared_keymap_base_with_snippet_mode() -> &'static KeymapHandle {
162        use std::sync::OnceLock;
163        static H: OnceLock<KeymapHandle> = OnceLock::new();
164        H.get_or_init(|| {
165            let h = build_base_keymap();
166            let mut mr = lattice_mode::ModeRegistry::new();
167            mr.register(lattice_snippet::modes::SnippetMode::new())
168                .expect("register snippet-mode");
169            lattice_host::keymap_mode_contributions::translate_mode_keymaps(
170                &h,
171                &mr,
172                shared_registry(),
173            );
174            h
175        })
176    }
177
178    /// Base keymap + `command-line-mode`'s Insert-layer keymap (submit /
179    /// cancel / history / completion chords). MB.1: the `:` line is a
180    /// buffer whose major mode contributes those chords, so Command-mode
181    /// descriptors resolve only with this layer present — mirroring boot's
182    /// `translate_mode_keymaps` pass over the focused `*command-line*`
183    /// buffer.
184    fn shared_keymap_with_command_line_mode() -> &'static KeymapHandle {
185        use std::sync::OnceLock;
186        static H: OnceLock<KeymapHandle> = OnceLock::new();
187        H.get_or_init(|| {
188            let h = build_base_keymap();
189            let mut mr = lattice_mode::ModeRegistry::new();
190            mr.register(lattice_host::command_line_mode::CommandLineMode)
191                .expect("register command-line-mode");
192            lattice_host::keymap_mode_contributions::translate_mode_keymaps(
193                &h,
194                &mr,
195                shared_registry(),
196            );
197            h
198        })
199    }
200    /// `command-line-mode` active — paired with
201    /// [`shared_keymap_with_command_line_mode`] so the K.1.c gate lets its
202    /// chords through in the drift check.
203    fn command_line_mode_active() -> &'static [lattice_mode::ModeId] {
204        use std::sync::OnceLock;
205        static M: OnceLock<Vec<lattice_mode::ModeId>> = OnceLock::new();
206        M.get_or_init(|| vec![lattice_host::command_line_mode::CommandLineMode::mode_id()])
207    }
208
209    // SN.3c.2a: per-fixture active-minor-mode sets, paired with the
210    // `shared_keymap_*` handles above. Insert dispatch is now K.1.c-gated
211    // (`dispatch_insert` threads `active_minor_modes`), so a test that
212    // pushes a minor layer must also name that mode as active — exactly
213    // the per-buffer gate production applies. `&'static` so they satisfy
214    // `TranslateContext.active_minor_modes: &'a [ModeId]`.
215    fn popup_minor_modes() -> &'static [lattice_mode::ModeId] {
216        use std::sync::OnceLock;
217        static M: OnceLock<Vec<lattice_mode::ModeId>> = OnceLock::new();
218        M.get_or_init(|| vec![crate::keymap_insert::completion_popup_mode_id()])
219    }
220    /// The Global `snippet-mode` only — for base Insert in a document
221    /// buffer (carries the `<C-x><C-s>` expand prefix), no live session.
222    fn snippet_mode_only() -> &'static [lattice_mode::ModeId] {
223        use std::sync::OnceLock;
224        static M: OnceLock<Vec<lattice_mode::ModeId>> = OnceLock::new();
225        M.get_or_init(|| vec![lattice_snippet::modes::SnippetMode::mode_id()])
226    }
227    /// `snippet-mode` + `active-snippet-mode` — a live snippet session.
228    fn snippet_minor_modes() -> &'static [lattice_mode::ModeId] {
229        use std::sync::OnceLock;
230        static M: OnceLock<Vec<lattice_mode::ModeId>> = OnceLock::new();
231        M.get_or_init(|| {
232            vec![
233                lattice_snippet::modes::SnippetMode::mode_id(),
234                lattice_snippet::modes::SnippetActiveMode::mode_id(),
235            ]
236        })
237    }
238    /// Both overlays active: snippet session + completion popup.
239    fn both_minor_modes() -> &'static [lattice_mode::ModeId] {
240        use std::sync::OnceLock;
241        static M: OnceLock<Vec<lattice_mode::ModeId>> = OnceLock::new();
242        M.get_or_init(|| {
243            vec![
244                lattice_snippet::modes::SnippetMode::mode_id(),
245                lattice_snippet::modes::SnippetActiveMode::mode_id(),
246                crate::keymap_insert::completion_popup_mode_id(),
247            ]
248        })
249    }
250
251    /// Base keymap + completion-popup minor-mode layer.
252    fn shared_keymap_with_popup() -> &'static KeymapHandle {
253        use std::sync::OnceLock;
254        static H: OnceLock<KeymapHandle> = OnceLock::new();
255        H.get_or_init(|| {
256            let h = build_base_keymap();
257            h.push_layer(
258                crate::keymap_registry::PushLayerKind::MinorMode(
259                    crate::keymap_insert::completion_popup_mode_id(),
260                ),
261                "completion-popup",
262                crate::keymap_insert::completion_popup_layer_bindings(shared_actions()),
263            );
264            h
265        })
266    }
267
268    /// Push the `active-snippet-mode` layer via the K.2.4 translation path.
269    fn push_snippet_layer_via_k24(h: &KeymapHandle) {
270        // Translate against the SHARED registry so snippet bindings
271        // resolve `action:snippet-*` to the same `CommandId`s
272        // `shared_actions()` exposes (ids are per-registry-instance).
273        let mut mr = lattice_mode::ModeRegistry::new();
274        mr.register(lattice_snippet::modes::SnippetActiveMode)
275            .expect("register active-snippet-mode");
276        // SN.3c.1: `snippet-mode` owns the `<C-x><C-s>` expand chord
277        // (Insert). Register it so the translated layer carries the
278        // chord — `<C-x><C-s>` is no longer a Builtin binding.
279        mr.register(lattice_snippet::modes::SnippetMode::new())
280            .expect("register snippet-mode");
281        lattice_host::keymap_mode_contributions::translate_mode_keymaps(h, &mr, shared_registry());
282    }
283
284    /// Base keymap + active-snippet minor-mode layer.
285    fn shared_keymap_with_snippet() -> &'static KeymapHandle {
286        use std::sync::OnceLock;
287        static H: OnceLock<KeymapHandle> = OnceLock::new();
288        H.get_or_init(|| {
289            let h = build_base_keymap();
290            push_snippet_layer_via_k24(&h);
291            h
292        })
293    }
294
295    /// Base keymap + both overlays. Push order matches
296    /// `App::sync_keymap_overlays`: snippet first, popup
297    /// second, so popup wins on overlapping chords.
298    fn shared_keymap_with_both_overlays() -> &'static KeymapHandle {
299        use std::sync::OnceLock;
300        static H: OnceLock<KeymapHandle> = OnceLock::new();
301        H.get_or_init(|| {
302            let h = build_base_keymap();
303            push_snippet_layer_via_k24(&h);
304            h.push_layer(
305                crate::keymap_registry::PushLayerKind::MinorMode(
306                    crate::keymap_insert::completion_popup_mode_id(),
307                ),
308                "completion-popup",
309                crate::keymap_insert::completion_popup_layer_bindings(shared_actions()),
310            );
311            h
312        })
313    }
314
315    fn fixture() -> (CommandRegistry, Builtins) {
316        // Tests discard the registry (every caller binds `_`);
317        // we still return one for signature compat. The shared
318        // `Builtins` carries the canonical ids the keymap
319        // registry references.
320        let r = CommandRegistry::new();
321        (r, *shared_builtins())
322    }
323
324    fn test_keymap() -> &'static KeymapHandle {
325        shared_keymap_base()
326    }
327
328    fn ctx<'a>(modal: ModalState, b: &'a Builtins) -> TranslateContext<'a> {
329        TranslateContext {
330            modal,
331            builtins: b,
332            pending_count: 0,
333            op_count: 0,
334            recording_macro: false,
335            active_buffer: BufferKind::Document,
336            completion_open: false,
337            chord_capture: false,
338            picker_open: false,
339            insert_completion_open: false,
340            snippet_active: false,
341            keymap: test_keymap(),
342            terminal_insert_active: false,
343            terminal_esc_exits: false,
344            terminal_app_cursor_keys: false,
345            terminal_insert_exit_pending: false,
346            terminal_visual_active: false,
347            partial_chord: &[],
348            active_minor_modes: &[],
349        }
350    }
351
352    /// Slice 8.i.4.a: build a `TranslateContext` simulating
353    /// "the user has just pressed `partial_chord` and the trie
354    /// returned `Partial`, so `App::partial_chord` is now this
355    /// slice." Replaces `ctx(modal, Pending::AfterG, b)` and
356    /// siblings for the 9 migrated simple-prefix Pending
357    /// variants. Tests using parameterised pendings
358    /// (`AfterOperator(_)`, `AfterTextObject{_}`,
359    /// `AfterFindChar{_}`, `AfterCtrlX`) keep using `ctx` until
360    /// 8.i.4.b retires those.
361    fn ctx_partial<'a>(
362        modal: ModalState,
363        partial: &'a [crate::chord::KeyChord],
364        b: &'a Builtins,
365    ) -> TranslateContext<'a> {
366        TranslateContext {
367            modal,
368            builtins: b,
369            pending_count: 0,
370            op_count: 0,
371            recording_macro: false,
372            active_buffer: BufferKind::Document,
373            completion_open: false,
374            chord_capture: false,
375            picker_open: false,
376            insert_completion_open: false,
377            snippet_active: false,
378            keymap: test_keymap(),
379            terminal_insert_active: false,
380            terminal_esc_exits: false,
381            terminal_app_cursor_keys: false,
382            terminal_insert_exit_pending: false,
383            terminal_visual_active: false,
384            partial_chord: partial,
385            active_minor_modes: &[],
386        }
387    }
388
389    fn ctx_with_count<'a>(
390        modal: ModalState,
391        b: &'a Builtins,
392        pending_count: u32,
393    ) -> TranslateContext<'a> {
394        TranslateContext {
395            modal,
396            builtins: b,
397            pending_count,
398            op_count: 0,
399            recording_macro: false,
400            active_buffer: BufferKind::Document,
401            completion_open: false,
402            chord_capture: false,
403            picker_open: false,
404            insert_completion_open: false,
405            snippet_active: false,
406            keymap: test_keymap(),
407            terminal_insert_active: false,
408            terminal_esc_exits: false,
409            terminal_app_cursor_keys: false,
410            terminal_insert_exit_pending: false,
411            terminal_visual_active: false,
412            partial_chord: &[],
413            active_minor_modes: &[],
414        }
415    }
416
417    /// Test-fixture: a `TranslateContext` with explicit
418    /// pending + op counts. Currently unused (the migrated
419    /// operator-flow tests build the context inline) but kept
420    /// alongside the other fixture builders for symmetry; the
421    /// next op-count regression test will reach for it.
422    #[allow(dead_code)]
423    fn ctx_with_op_count<'a>(
424        modal: ModalState,
425        b: &'a Builtins,
426        pending_count: u32,
427        op_count: u32,
428    ) -> TranslateContext<'a> {
429        TranslateContext {
430            modal,
431            builtins: b,
432            pending_count,
433            op_count,
434            recording_macro: false,
435            active_buffer: BufferKind::Document,
436            completion_open: false,
437            chord_capture: false,
438            picker_open: false,
439            insert_completion_open: false,
440            snippet_active: false,
441            keymap: test_keymap(),
442            terminal_insert_active: false,
443            terminal_esc_exits: false,
444            terminal_app_cursor_keys: false,
445            terminal_insert_exit_pending: false,
446            terminal_visual_active: false,
447            partial_chord: &[],
448            active_minor_modes: &[],
449        }
450    }
451
452    fn ctx_recording<'a>(modal: ModalState, b: &'a Builtins) -> TranslateContext<'a> {
453        TranslateContext {
454            modal,
455            builtins: b,
456            pending_count: 0,
457            op_count: 0,
458            recording_macro: true,
459            active_buffer: BufferKind::Document,
460            completion_open: false,
461            chord_capture: false,
462            picker_open: false,
463            insert_completion_open: false,
464            snippet_active: false,
465            keymap: test_keymap(),
466            terminal_insert_active: false,
467            terminal_esc_exits: false,
468            terminal_app_cursor_keys: false,
469            terminal_insert_exit_pending: false,
470            terminal_visual_active: false,
471            partial_chord: &[],
472            active_minor_modes: &[],
473        }
474    }
475
476    /// Slice 8.i.4.c: helper for tests that simulate "after
477    /// operator was pressed": pass the operator's chord prefix
478    /// as `partial` and the latched op_count as `op_count`.
479    /// Replaces `ctx_with_op_count(_, Pending::AfterOperator(_),
480    /// _, _, _)` for the migrated AfterOperator flow.
481    fn ctx_partial_with_op_count<'a>(
482        modal: ModalState,
483        partial: &'a [crate::chord::KeyChord],
484        b: &'a Builtins,
485        pending_count: u32,
486        op_count: u32,
487    ) -> TranslateContext<'a> {
488        TranslateContext {
489            modal,
490            builtins: b,
491            pending_count,
492            op_count,
493            recording_macro: false,
494            active_buffer: BufferKind::Document,
495            completion_open: false,
496            chord_capture: false,
497            picker_open: false,
498            insert_completion_open: false,
499            snippet_active: false,
500            keymap: test_keymap(),
501            terminal_insert_active: false,
502            terminal_esc_exits: false,
503            terminal_app_cursor_keys: false,
504            terminal_insert_exit_pending: false,
505            terminal_visual_active: false,
506            partial_chord: partial,
507            active_minor_modes: &[],
508        }
509    }
510
511    fn ctx_chord_capture<'a>(b: &'a Builtins) -> TranslateContext<'a> {
512        TranslateContext {
513            modal: ModalState::Command,
514            builtins: b,
515            pending_count: 0,
516            op_count: 0,
517            recording_macro: false,
518            active_buffer: BufferKind::Document,
519            completion_open: false,
520            chord_capture: true,
521            picker_open: false,
522            insert_completion_open: false,
523            snippet_active: false,
524            keymap: test_keymap(),
525            terminal_insert_active: false,
526            terminal_esc_exits: false,
527            terminal_app_cursor_keys: false,
528            terminal_insert_exit_pending: false,
529            terminal_visual_active: false,
530            partial_chord: &[],
531            active_minor_modes: &[],
532        }
533    }
534
535    fn invocation_command(action: &Action) -> Option<lattice_protocol::ids::CommandId> {
536        if let Action::Invoke(inv) = action {
537            Some(inv.command)
538        } else {
539            None
540        }
541    }
542
543    // ---- Universal ----
544
545    #[test]
546    fn ctrl_c_in_normal_mode_is_unbound() {
547        // <C-c> has no Builtin-layer binding — it's left for modes
548        // (compilation-mode, ACP conversation-mode, etc.) to bind.
549        let (_, b) = fixture();
550        let action = translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('c')));
551        assert!(
552            matches!(action, Action::None),
553            "Normal-mode <C-c> must be unbound at Builtin layer; got {action:?}"
554        );
555    }
556
557    #[test]
558    fn ctrl_c_in_insert_mode_does_not_quit() {
559        let (_, b) = fixture();
560        let action = translate(ctx(ModalState::Insert, &b), ctrl(KeyCode::Char('c')));
561        assert!(
562            !matches!(action, Action::Quit),
563            "Insert-mode <C-c> must not quit — no universal hatch"
564        );
565    }
566
567    #[test]
568    fn ctrl_c_in_command_mode_does_not_quit() {
569        // <C-c> is no longer a universal quit hatch; it flows
570        // through the command-line input as a regular key.
571        let (_, b) = fixture();
572        let action = translate(ctx(ModalState::Command, &b), ctrl(KeyCode::Char('c')));
573        assert!(
574            !matches!(action, Action::Quit),
575            "Command-mode <C-c> must not quit — no universal hatch"
576        );
577    }
578
579    // ---- Normal mode motions ----
580
581    #[test]
582    fn hjkl_invoke_corresponding_motions() {
583        let (_, b) = fixture();
584        let cases = [
585            (KeyCode::Char('h'), b.char_left.0),
586            (KeyCode::Char('j'), b.line_down.0),
587            (KeyCode::Char('k'), b.line_up.0),
588            (KeyCode::Char('l'), b.char_right.0),
589        ];
590        for (code, expected) in cases {
591            let action = translate(ctx(ModalState::Normal, &b), key(code));
592            assert_eq!(invocation_command(&action), Some(expected));
593        }
594    }
595
596    #[test]
597    fn arrows_alias_hjkl() {
598        let (_, b) = fixture();
599        let cases = [
600            (KeyCode::Left, b.char_left.0),
601            (KeyCode::Down, b.line_down.0),
602            (KeyCode::Up, b.line_up.0),
603            (KeyCode::Right, b.char_right.0),
604        ];
605        for (code, expected) in cases {
606            let action = translate(ctx(ModalState::Normal, &b), key(code));
607            assert_eq!(invocation_command(&action), Some(expected));
608        }
609    }
610
611    #[test]
612    fn zero_and_dollar_invoke_line_start_and_end() {
613        let (_, b) = fixture();
614        assert_eq!(
615            invocation_command(&translate(
616                ctx(ModalState::Normal, &b),
617                key(KeyCode::Char('0'))
618            )),
619            Some(b.line_start.0)
620        );
621        assert_eq!(
622            invocation_command(&translate(
623                ctx(ModalState::Normal, &b),
624                key(KeyCode::Char('$'))
625            )),
626            Some(b.line_end.0)
627        );
628    }
629
630    #[test]
631    fn capital_g_invokes_goto_last_line() {
632        let (_, b) = fixture();
633        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('G')));
634        assert_eq!(invocation_command(&action), Some(b.goto_last_line.0));
635    }
636
637    #[test]
638    fn first_g_absorbs_partial_chord() {
639        // Slice 8.i.4.a: pressing `g` returns
640        // `Action::AbsorbPartialChord(g_chord)` instead of
641        // `Action::SetPending(Pending::AfterG)`. The trie's
642        // `Partial` result drives the App's `partial_chord`
643        // stack directly.
644        let (_, b) = fixture();
645        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('g')));
646        assert!(matches!(
647            action,
648            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('g')
649        ));
650    }
651
652    #[test]
653    fn second_g_with_pending_resolves_to_goto_first_line() {
654        let (_, b) = fixture();
655        let action = translate(
656            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
657            key(KeyCode::Char('g')),
658        );
659        assert_eq!(invocation_command(&action), Some(b.goto_first_line.0));
660    }
661
662    #[test]
663    fn unrelated_key_after_pending_g_clears_pending() {
664        let (_, b) = fixture();
665        assert!(matches!(
666            translate(
667                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
668                key(KeyCode::Char('z'))
669            ),
670            Action::None
671        ));
672    }
673
674    // ---- Mode entry ----
675
676    #[test]
677    fn i_enters_insert_mode() {
678        let (_, b) = fixture();
679        let a = shared_actions();
680        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('i'))) {
681            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_mode_insert),
682            other => panic!("expected Invoke(enter_mode_insert), got {other:?}"),
683        }
684    }
685
686    #[test]
687    fn a_enters_append_mode() {
688        let (_, b) = fixture();
689        let a = shared_actions();
690        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('a'))) {
691            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_append),
692            other => panic!("expected Invoke(enter_append), got {other:?}"),
693        }
694    }
695
696    #[test]
697    fn o_opens_line_below() {
698        let (_, b) = fixture();
699        let a = shared_actions();
700        // Slice 8.i.1.a: `o` / `O` are now `CommandKind::Action`
701        // dispatch (`Effect::AppAction(AppEffect::OpenLine{Below,Above})`)
702        // routed through `run_invocation`, surfaced at the
703        // input layer as `Action::Invoke`.
704        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('o'))) {
705            Action::Invoke(inv) => assert_eq!(inv.command, a.open_line_below),
706            other => panic!("expected Invoke(open_line_below), got {other:?}"),
707        }
708        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('O'))) {
709            Action::Invoke(inv) => assert_eq!(inv.command, a.open_line_above),
710            other => panic!("expected Invoke(open_line_above), got {other:?}"),
711        }
712    }
713
714    // ---- Operator-pending state ----
715
716    #[test]
717    fn d_invokes_absorb_operator_delete() {
718        // Slice 8.i.4.c: pressing `d` returns
719        // `Action::Invoke(absorb_operator_delete)` instead of
720        // `Action::SetPending(Pending::AfterOperator(delete))`.
721        // The bound `ActionSpec` returns
722        // `Effect::AppAction(AppEffect::AbsorbOperatorPrefix(delete))`,
723        // which `App::apply_app_effect` translates into
724        // `partial_chord = [d]` + `op_count` latching.
725        let (_, b) = fixture();
726        let a = shared_actions();
727        let _ = b;
728        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('d')));
729        match action {
730            Action::Invoke(inv) => assert_eq!(inv.command, a.absorb_operator_delete),
731            _ => panic!("expected Invoke(absorb_operator_delete)"),
732        }
733    }
734
735    #[test]
736    fn dw_resolves_to_delete_with_word_forward_target() {
737        let (_, b) = fixture();
738        let action = translate(
739            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
740            key(KeyCode::Char('w')),
741        );
742        match action {
743            Action::Invoke(inv) => {
744                assert_eq!(inv.command, b.delete.0);
745                match inv.target {
746                    Some(Target::Motion(id, _)) => assert_eq!(id, b.word_forward),
747                    other => panic!("expected motion target, got {other:?}"),
748                }
749            }
750            _ => panic!("expected Invoke"),
751        }
752    }
753
754    #[test]
755    fn dd_resolves_to_delete_current_line() {
756        let (_, b) = fixture();
757        let action = translate(
758            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
759            key(KeyCode::Char('d')),
760        );
761        match action {
762            Action::Invoke(inv) => {
763                assert_eq!(inv.command, b.delete.0);
764                assert_eq!(inv.range, Some(lattice_grammar::Range::CurrentLine));
765            }
766            _ => panic!("expected Invoke"),
767        }
768    }
769
770    #[test]
771    fn esc_after_operator_cancels_pending() {
772        let (_, b) = fixture();
773        assert!(matches!(
774            translate(
775                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
776                key(KeyCode::Esc)
777            ),
778            Action::None
779        ));
780    }
781
782    #[test]
783    fn x_resolves_directly_to_delete_char_right() {
784        let (_, b) = fixture();
785        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('x')));
786        match action {
787            Action::Invoke(inv) => {
788                assert_eq!(inv.command, b.delete.0);
789                match inv.target {
790                    Some(Target::Motion(id, _)) => assert_eq!(id, b.char_right),
791                    other => panic!("expected motion target, got {other:?}"),
792                }
793            }
794            _ => panic!("expected Invoke"),
795        }
796    }
797
798    // ---- Insert mode ----
799
800    #[test]
801    fn esc_in_insert_returns_to_normal() {
802        let (_, b) = fixture();
803        let a = shared_actions();
804        match translate(ctx(ModalState::Insert, &b), key(KeyCode::Esc)) {
805            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_mode_normal),
806            other => panic!("expected Invoke(enter_mode_normal), got {other:?}"),
807        }
808    }
809
810    #[test]
811    fn printable_char_in_insert_inserts_text() {
812        let (_, b) = fixture();
813        match translate(ctx(ModalState::Insert, &b), key(KeyCode::Char('h'))) {
814            Action::Insert(s) => assert_eq!(s, "h"),
815            _ => panic!("expected Insert"),
816        }
817    }
818
819    #[test]
820    fn enter_in_insert_inserts_newline() {
821        let (_, b) = fixture();
822        let a = shared_actions();
823        match translate(ctx(ModalState::Insert, &b), key(KeyCode::Enter)) {
824            Action::Invoke(inv) => assert_eq!(inv.command, a.insert_newline),
825            other => panic!("expected Invoke(insert_newline), got {other:?}"),
826        }
827    }
828
829    #[test]
830    fn backspace_in_insert_deletes_char_backward() {
831        let (_, b) = fixture();
832        let a = shared_actions();
833        match translate(ctx(ModalState::Insert, &b), key(KeyCode::Backspace)) {
834            Action::Invoke(inv) => assert_eq!(inv.command, a.delete_char_backward),
835            other => panic!("expected Invoke(delete_char_backward), got {other:?}"),
836        }
837    }
838
839    // ---- Undo / Redo ----
840
841    #[test]
842    fn u_in_normal_undoes() {
843        let (_, b) = fixture();
844        let a = shared_actions();
845        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('u'))) {
846            Action::Invoke(inv) => assert_eq!(inv.command, a.undo),
847            other => panic!("expected Invoke(undo), got {other:?}"),
848        }
849    }
850
851    #[test]
852    fn ctrl_r_in_normal_redoes() {
853        let (_, b) = fixture();
854        let a = shared_actions();
855        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('r'))) {
856            Action::Invoke(inv) => assert_eq!(inv.command, a.redo),
857            other => panic!("expected Invoke(redo), got {other:?}"),
858        }
859    }
860
861    // ---- Command modal ----
862
863    #[test]
864    fn colon_in_normal_enters_command_line() {
865        let (_, b) = fixture();
866        let a = shared_actions();
867        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char(':'))) {
868            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_command_line),
869            other => panic!("expected Invoke(enter_command_line), got {other:?}"),
870        }
871    }
872
873    #[test]
874    fn printable_char_in_command_routes_through_insert_dispatcher() {
875        // MB.1: the `:` line is a buffer-backed readline surface — a
876        // printable key in Command modal routes through the universal
877        // Insert dispatcher (editing the focused `*command-line*` buffer),
878        // not the retired `translate_command` `CommandLineAppend` path.
879        let (_, b) = fixture();
880        match translate(ctx(ModalState::Command, &b), key(KeyCode::Char('w'))) {
881            Action::Insert(s) => assert_eq!(s, "w"),
882            other => panic!("expected Insert(\"w\"), got {other:?}"),
883        }
884    }
885
886    // MB.1: `<CR>` submit / `<Esc>` + `<C-c>` cancel / `<C-p>` / `<C-n>` /
887    // `<Up>` / `<Down>` history are now owned by `command-line-mode`'s
888    // Insert-layer keymap (they need the `*command-line*` buffer's
889    // active-mode set, absent in this pure-translate fixture). Their
890    // end-to-end behavior is covered by the host-level MB.1 tests
891    // (`command_line_cr_submits_and_pushes_history`,
892    // `command_line_esc_cancels_and_restores_document_cursor_modal`,
893    // `command_line_history_walk_restores_pending_text`).
894
895    #[test]
896    fn ctrl_c_in_command_mode_is_regular_key() {
897        // <C-c> is no longer a universal quit hatch in command mode.
898        let (_, b) = fixture();
899        let action = translate(ctx(ModalState::Command, &b), ctrl(KeyCode::Char('c')));
900        assert!(!matches!(action, Action::Quit));
901    }
902
903    // ---- Search modal ----
904
905    #[test]
906    fn slash_in_normal_enters_forward_search() {
907        let (_, b) = fixture();
908        let a = shared_actions();
909        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('/'))) {
910            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_search_forward),
911            other => panic!("expected Invoke(enter_search_forward), got {other:?}"),
912        }
913    }
914
915    #[test]
916    fn question_in_normal_enters_backward_search() {
917        let (_, b) = fixture();
918        let a = shared_actions();
919        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('?'))) {
920            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_search_backward),
921            other => panic!("expected Invoke(enter_search_backward), got {other:?}"),
922        }
923    }
924
925    #[test]
926    fn n_in_normal_repeats_search_forward() {
927        // VM.3d-2: a motion, so `dn` / `vn` compose.
928        let (_, b) = fixture();
929        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('n'))) {
930            Action::Invoke(inv) => assert_eq!(inv.command, b.search_next.0),
931            other => panic!("expected Invoke(search_next motion), got {other:?}"),
932        }
933    }
934
935    #[test]
936    fn capital_n_in_normal_repeats_search_reverse() {
937        // VM.3d-2: a motion, so `dn` / `vn` compose.
938        let (_, b) = fixture();
939        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('N'))) {
940            Action::Invoke(inv) => assert_eq!(inv.command, b.search_prev.0),
941            other => panic!("expected Invoke(search_prev motion), got {other:?}"),
942        }
943    }
944
945    #[test]
946    fn printable_char_in_search_appends_to_pattern() {
947        let (_, b) = fixture();
948        let modal = ModalState::Search(SearchDirection::Forward);
949        // MB.5a: typing in the search line is an ordinary Insert onto
950        // the focused `*search-line*` buffer; `Action::SearchAppend` is
951        // retired (its dispatcher arm is a no-op).
952        match translate(ctx(modal, &b), key(KeyCode::Char('f'))) {
953            Action::Insert(s) => assert_eq!(s, "f"),
954            other => panic!("expected Insert(\"f\"), got {other:?}"),
955        }
956    }
957
958    #[test]
959    fn enter_in_search_submits() {
960        let (_, b) = fixture();
961        let modal = ModalState::Search(SearchDirection::Forward);
962        // MB.5a: `<CR>` in Search mode is no longer a hardcoded
963        // `Action::SearchSubmit` — the search line is a buffer-backed
964        // readline surface, so keys route through the Insert dispatcher
965        // and `search-line-mode`'s keymap layer supplies submit/cancel.
966        // This fixture builds only the BASE keymap (no mode layers), so
967        // all this layer can honestly assert is that the key is
968        // keymap-resolved rather than intercepted. Submit semantics are
969        // covered by `app::search::tests`.
970        assert!(matches!(
971            translate(ctx(modal, &b), key(KeyCode::Enter)),
972            Action::Invoke(_)
973        ));
974    }
975
976    #[test]
977    fn esc_in_search_cancels() {
978        let (_, b) = fixture();
979        let modal = ModalState::Search(SearchDirection::Backward);
980        // See `enter_in_search_submits`: cancel now lives in
981        // `search-line-mode`'s keymap, not in `translate`.
982        assert!(matches!(
983            translate(ctx(modal, &b), key(KeyCode::Esc)),
984            Action::Invoke(_)
985        ));
986    }
987
988    #[test]
989    fn backspace_in_search_pops_pattern() {
990        let (_, b) = fixture();
991        let modal = ModalState::Search(SearchDirection::Forward);
992        // See `enter_in_search_submits`: `<BS>` resolves through the
993        // keymap (base Insert's delete-backward here; `search-line-mode`
994        // overrides it with `action:search-line-backspace`, which also
995        // cancels on an empty pattern).
996        assert!(matches!(
997            translate(ctx(modal, &b), key(KeyCode::Backspace)),
998            Action::Invoke(_)
999        ));
1000    }
1001
1002    #[test]
1003    fn ctrl_c_in_search_does_not_quit() {
1004        // <C-c> is no longer a universal quit hatch in search mode.
1005        let (_, b) = fixture();
1006        let modal = ModalState::Search(SearchDirection::Forward);
1007        let action = translate(ctx(modal, &b), ctrl(KeyCode::Char('c')));
1008        assert!(!matches!(action, Action::Quit));
1009    }
1010
1011    // ---- WORD motions / D/C/S / J / ;/, ----
1012
1013    #[test]
1014    fn capital_w_invokes_big_word_forward() {
1015        let (_, b) = fixture();
1016        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('W')));
1017        assert_eq!(invocation_command(&action), Some(b.big_word_forward.0));
1018    }
1019
1020    #[test]
1021    fn capital_b_invokes_big_word_backward() {
1022        let (_, b) = fixture();
1023        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('B')));
1024        assert_eq!(invocation_command(&action), Some(b.big_word_backward.0));
1025    }
1026
1027    #[test]
1028    fn capital_e_invokes_big_word_end() {
1029        let (_, b) = fixture();
1030        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('E')));
1031        assert_eq!(invocation_command(&action), Some(b.big_word_end.0));
1032    }
1033
1034    #[test]
1035    fn capital_d_invokes_delete_to_line_end() {
1036        let (_, b) = fixture();
1037        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('D')));
1038        match action {
1039            Action::Invoke(inv) => {
1040                assert_eq!(inv.command, b.delete.0);
1041                match inv.target {
1042                    Some(Target::Motion(id, _)) => assert_eq!(id, b.line_end),
1043                    other => panic!("expected line_end target, got {other:?}"),
1044                }
1045            }
1046            other => panic!("expected Invoke, got {other:?}"),
1047        }
1048    }
1049
1050    #[test]
1051    fn capital_c_invokes_change_to_line_end() {
1052        let (_, b) = fixture();
1053        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('C')));
1054        match action {
1055            Action::Invoke(inv) => {
1056                assert_eq!(inv.command, b.change.0);
1057                match inv.target {
1058                    Some(Target::Motion(id, _)) => assert_eq!(id, b.line_end),
1059                    other => panic!("expected line_end target, got {other:?}"),
1060                }
1061            }
1062            other => panic!("expected Invoke, got {other:?}"),
1063        }
1064    }
1065
1066    #[test]
1067    fn capital_s_invokes_change_current_line() {
1068        let (_, b) = fixture();
1069        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('S')));
1070        match action {
1071            Action::Invoke(inv) => {
1072                assert_eq!(inv.command, b.change.0);
1073                assert_eq!(inv.range, Some(lattice_grammar::Range::CurrentLine));
1074            }
1075            other => panic!("expected Invoke, got {other:?}"),
1076        }
1077    }
1078
1079    #[test]
1080    fn capital_j_emits_join_with_space() {
1081        let (_, b) = fixture();
1082        let a = shared_actions();
1083        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('J'))) {
1084            Action::Invoke(inv) => assert_eq!(inv.command, a.join_lines_with_space),
1085            other => panic!("expected Invoke(join_lines_with_space), got {other:?}"),
1086        }
1087    }
1088
1089    #[test]
1090    fn gj_after_g_emits_join_without_space() {
1091        let (_, b) = fixture();
1092        let a = shared_actions();
1093        match translate(
1094            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
1095            key(KeyCode::Char('J')),
1096        ) {
1097            Action::Invoke(inv) => assert_eq!(inv.command, a.join_lines_bare),
1098            other => panic!("expected Invoke(join_lines_bare), got {other:?}"),
1099        }
1100    }
1101
1102    /// VM.3c: `;` resolves to the MOTION, not `action:find-repeat-forward`.
1103    ///
1104    /// The change is the point of the test. As an action, `;` could take no
1105    /// operator (`d;`) and could not be mirrored into Visual, so both were
1106    /// unbound. Vim treats `;` as a motion.
1107    #[test]
1108    fn semicolon_emits_the_find_repeat_motion() {
1109        let (_, b) = fixture();
1110        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char(';'))) {
1111            Action::Invoke(inv) => assert_eq!(inv.command, b.find_repeat.0),
1112            other => panic!("expected Invoke(find_repeat), got {other:?}"),
1113        }
1114    }
1115
1116    /// VM.3c: `,` resolves to the reverse find-repeat MOTION, for the same
1117    /// reason `;` does.
1118    #[test]
1119    fn comma_emits_the_find_repeat_reverse_motion() {
1120        let (_, b) = fixture();
1121        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char(','))) {
1122            Action::Invoke(inv) => assert_eq!(inv.command, b.find_repeat_reverse.0),
1123            other => panic!("expected Invoke(find_repeat_reverse), got {other:?}"),
1124        }
1125    }
1126
1127    #[test]
1128    fn d_capital_w_resolves_to_delete_big_word_forward() {
1129        let (_, b) = fixture();
1130        let action = translate(
1131            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
1132            key(KeyCode::Char('W')),
1133        );
1134        match action {
1135            Action::Invoke(inv) => match inv.target {
1136                Some(Target::Motion(id, _)) => assert_eq!(id, b.big_word_forward),
1137                other => panic!("expected motion target, got {other:?}"),
1138            },
1139            _ => panic!("expected Invoke"),
1140        }
1141    }
1142
1143    // ---- Macros: q, @ ----
1144
1145    #[test]
1146    fn q_in_normal_when_not_recording_absorbs_partial_chord() {
1147        // Slice 8.i.4.a: `q` migrated to partial_chord.
1148        let (_, b) = fixture();
1149        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('q')));
1150        assert!(matches!(
1151            action,
1152            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('q')
1153        ));
1154    }
1155
1156    #[test]
1157    fn q_in_normal_while_recording_stops() {
1158        let (_, b) = fixture();
1159        assert!(matches!(
1160            translate(
1161                ctx_recording(ModalState::Normal, &b),
1162                key(KeyCode::Char('q'))
1163            ),
1164            Action::StopMacroRecord
1165        ));
1166    }
1167
1168    #[test]
1169    fn at_in_normal_absorbs_partial_chord() {
1170        // Slice 8.i.4.a: `@` migrated to partial_chord.
1171        let (_, b) = fixture();
1172        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('@')));
1173        assert!(matches!(
1174            action,
1175            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('@')
1176        ));
1177    }
1178
1179    #[test]
1180    fn letter_after_q_starts_recording() {
1181        let (_, b) = fixture();
1182        let a = shared_actions();
1183        match translate(
1184            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('q')], &b),
1185            key(KeyCode::Char('a')),
1186        ) {
1187            Action::Invoke(inv) => {
1188                assert_eq!(inv.command, a.start_macro_record);
1189                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('a')));
1190            }
1191            other => panic!("expected Invoke(start_macro_record, Char('a')), got {other:?}"),
1192        }
1193    }
1194
1195    #[test]
1196    fn letter_after_at_plays_macro() {
1197        let (_, b) = fixture();
1198        let a = shared_actions();
1199        match translate(
1200            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('@')], &b),
1201            key(KeyCode::Char('q')),
1202        ) {
1203            Action::Invoke(inv) => {
1204                assert_eq!(inv.command, a.play_macro);
1205                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('q')));
1206            }
1207            other => panic!("expected Invoke(play_macro, Char('q')), got {other:?}"),
1208        }
1209    }
1210
1211    #[test]
1212    fn at_at_plays_last_macro() {
1213        // Slice 8.i.3: dispatcher returns Invoke(play_macro,
1214        // Char('@')); ActionSpec maps `@` to AppEffect::PlayLastMacro.
1215        let (_, b) = fixture();
1216        let a = shared_actions();
1217        match translate(
1218            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('@')], &b),
1219            key(KeyCode::Char('@')),
1220        ) {
1221            Action::Invoke(inv) => {
1222                assert_eq!(inv.command, a.play_macro);
1223                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('@')));
1224            }
1225            other => panic!("expected Invoke(play_macro, Char('@')), got {other:?}"),
1226        }
1227    }
1228
1229    #[test]
1230    fn esc_after_macro_pending_clears() {
1231        let (_, b) = fixture();
1232        assert!(matches!(
1233            translate(
1234                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('q')], &b),
1235                key(KeyCode::Esc)
1236            ),
1237            Action::None
1238        ));
1239        assert!(matches!(
1240            translate(
1241                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('@')], &b),
1242                key(KeyCode::Esc)
1243            ),
1244            Action::None
1245        ));
1246    }
1247
1248    // ---- Folds: zf zo zc za zR zM zd ----
1249
1250    /// VM.3h: `zf` is vim's fold OPERATOR, so after `z` the `f` is absorbed as
1251    /// a pending prefix, and a motion completes it. It used to resolve straight
1252    /// to `action:create-fold-from-visual`, which required Visual but was bound
1253    /// in Normal only and so always errored.
1254    #[test]
1255    fn zf_after_z_is_the_fold_operator_prefix() {
1256        let (_, b) = fixture();
1257        match translate(
1258            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
1259            key(KeyCode::Char('f')),
1260        ) {
1261            Action::AbsorbPartialChord(c) => assert_eq!(c, crate::chord::KeyChord::char('f')),
1262            other => panic!("expected `f` absorbed as the `zf` operator prefix, got {other:?}"),
1263        }
1264        match translate(
1265            ctx_partial(
1266                ModalState::Normal,
1267                &[
1268                    crate::chord::KeyChord::char('z'),
1269                    crate::chord::KeyChord::char('f'),
1270                ],
1271                &b,
1272            ),
1273            key(KeyCode::Char('j')),
1274        ) {
1275            Action::Invoke(inv) => {
1276                assert_eq!(
1277                    inv.command, b.create_fold.0,
1278                    "`zfj` invokes the fold operator"
1279                );
1280                assert!(inv.target.is_some(), "with `j` as its motion target");
1281            }
1282            other => panic!("expected Invoke(operator:create-fold), got {other:?}"),
1283        }
1284    }
1285
1286    #[test]
1287    fn zo_after_z_emits_open_fold() {
1288        let (_, b) = fixture();
1289        let a = shared_actions();
1290        match translate(
1291            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
1292            key(KeyCode::Char('o')),
1293        ) {
1294            Action::Invoke(inv) => assert_eq!(inv.command, a.open_fold_at_cursor),
1295            other => panic!("expected Invoke(open_fold_at_cursor), got {other:?}"),
1296        }
1297    }
1298
1299    #[test]
1300    fn zc_after_z_emits_close_fold() {
1301        let (_, b) = fixture();
1302        let a = shared_actions();
1303        match translate(
1304            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
1305            key(KeyCode::Char('c')),
1306        ) {
1307            Action::Invoke(inv) => assert_eq!(inv.command, a.close_fold_at_cursor),
1308            other => panic!("expected Invoke(close_fold_at_cursor), got {other:?}"),
1309        }
1310    }
1311
1312    #[test]
1313    fn za_after_z_emits_toggle_fold() {
1314        let (_, b) = fixture();
1315        let a = shared_actions();
1316        match translate(
1317            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
1318            key(KeyCode::Char('a')),
1319        ) {
1320            Action::Invoke(inv) => assert_eq!(inv.command, a.toggle_fold_at_cursor),
1321            other => panic!("expected Invoke(toggle_fold_at_cursor), got {other:?}"),
1322        }
1323    }
1324
1325    #[test]
1326    fn capital_z_r_after_z_opens_all() {
1327        let (_, b) = fixture();
1328        let a = shared_actions();
1329        match translate(
1330            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
1331            key(KeyCode::Char('R')),
1332        ) {
1333            Action::Invoke(inv) => assert_eq!(inv.command, a.open_all_folds),
1334            other => panic!("expected Invoke(open_all_folds), got {other:?}"),
1335        }
1336    }
1337
1338    #[test]
1339    fn capital_z_m_after_z_closes_all() {
1340        let (_, b) = fixture();
1341        let a = shared_actions();
1342        match translate(
1343            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
1344            key(KeyCode::Char('M')),
1345        ) {
1346            Action::Invoke(inv) => assert_eq!(inv.command, a.close_all_folds),
1347            other => panic!("expected Invoke(close_all_folds), got {other:?}"),
1348        }
1349    }
1350
1351    #[test]
1352    fn zd_after_z_deletes_fold() {
1353        let (_, b) = fixture();
1354        let a = shared_actions();
1355        match translate(
1356            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
1357            key(KeyCode::Char('d')),
1358        ) {
1359            Action::Invoke(inv) => assert_eq!(inv.command, a.delete_fold_at_cursor),
1360            other => panic!("expected Invoke(delete_fold_at_cursor), got {other:?}"),
1361        }
1362    }
1363
1364    // ---- Blockwise visual ----
1365
1366    #[test]
1367    fn ctrl_v_enters_blockwise_visual() {
1368        let (_, b) = fixture();
1369        let a = shared_actions();
1370        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('v'))) {
1371            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_visual_blockwise),
1372            other => panic!("expected Invoke(enter_visual_blockwise), got {other:?}"),
1373        }
1374    }
1375
1376    #[test]
1377    fn ctrl_q_is_alternate_blockwise_visual() {
1378        // Many terminals (Konsole, Windows Terminal, tmux paste-key)
1379        // intercept Ctrl+V for clipboard paste before it reaches us.
1380        // Vim binds Ctrl+Q as the alternate enter-block-visual key for
1381        // exactly this reason.
1382        let (_, b) = fixture();
1383        let a = shared_actions();
1384        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('q'))) {
1385            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_visual_blockwise),
1386            other => panic!("expected Invoke(enter_visual_blockwise), got {other:?}"),
1387        }
1388    }
1389
1390    #[test]
1391    fn lowercase_q_without_ctrl_still_absorbs_macro_record_prefix() {
1392        // Slice 8.i.4.a: `q` migrated to partial_chord. Guard
1393        // against the Ctrl+Q binding accidentally swallowing
1394        // the bare `q` that starts macro recording.
1395        let (_, b) = fixture();
1396        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('q'))) {
1397            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('q') => {}
1398            other => panic!("expected AbsorbPartialChord(q), got {other:?}"),
1399        }
1400    }
1401
1402    // ---- Help buffer (DESIGN.md §5.11, §5.9) ----
1403    //
1404    // Help is a regular buffer routed through `translate_normal` via
1405    // `App::active_buffer`. Only three buffer-local bindings differ
1406    // from the document path: `Esc` / `q` dismiss, `<CR>` follows
1407    // the link under the cursor. Everything else (motions, page
1408    // motions, `<C-o>` / `<C-i>`, `gg` / `G`) flows through the same
1409    // chord grammar -- the apply layer decides which cursor moves.
1410
1411    fn ctx_help_active<'a>(modal: ModalState, b: &'a Builtins) -> TranslateContext<'a> {
1412        TranslateContext {
1413            modal,
1414            builtins: b,
1415            pending_count: 0,
1416            op_count: 0,
1417            recording_macro: false,
1418            active_buffer: BufferKind::Help,
1419            completion_open: false,
1420            chord_capture: false,
1421            picker_open: false,
1422            insert_completion_open: false,
1423            snippet_active: false,
1424            keymap: test_keymap(),
1425            terminal_insert_active: false,
1426            terminal_esc_exits: false,
1427            terminal_app_cursor_keys: false,
1428            terminal_insert_exit_pending: false,
1429            terminal_visual_active: false,
1430            partial_chord: &[],
1431            active_minor_modes: &[],
1432        }
1433    }
1434
1435    /// Slice 8.i.4.a: help-active variant of `ctx_partial`.
1436    fn ctx_help_active_partial<'a>(
1437        modal: ModalState,
1438        partial: &'a [crate::chord::KeyChord],
1439        b: &'a Builtins,
1440    ) -> TranslateContext<'a> {
1441        TranslateContext {
1442            modal,
1443            builtins: b,
1444            pending_count: 0,
1445            op_count: 0,
1446            recording_macro: false,
1447            active_buffer: BufferKind::Help,
1448            completion_open: false,
1449            chord_capture: false,
1450            picker_open: false,
1451            insert_completion_open: false,
1452            snippet_active: false,
1453            keymap: test_keymap(),
1454            terminal_insert_active: false,
1455            terminal_esc_exits: false,
1456            terminal_app_cursor_keys: false,
1457            terminal_insert_exit_pending: false,
1458            terminal_visual_active: false,
1459            partial_chord: partial,
1460            active_minor_modes: &[],
1461        }
1462    }
1463
1464    #[test]
1465    fn help_active_does_not_intercept_q_to_dismiss() {
1466        let (_, b) = fixture();
1467        // Reverted contract: `q` no longer auto-dismisses help /
1468        // log buffers. They should behave like other buffers --
1469        // only Esc and `:bd` close them. Pressing `q` while in a
1470        // log buffer would otherwise destroy the user's view
1471        // unexpectedly. With the early-return for `q` removed,
1472        // `q` falls through to its normal Normal-mode meaning
1473        // (macro-record start). Macros in a help buffer are a
1474        // no-op since the buffer is read-only; harmless.
1475        assert!(!matches!(
1476            translate(
1477                ctx_help_active(ModalState::Normal, &b),
1478                key(KeyCode::Char('q'))
1479            ),
1480            Action::HelpDismiss
1481        ));
1482    }
1483
1484    #[test]
1485    fn help_esc_is_mode_owned_not_intercepted_by_translate() {
1486        // `<Esc>` for help is now owned by `help-mode`'s keymap
1487        // (`action:help-dismiss` → `Effect::DismissPopup`), the LM.4 file-tree
1488        // pattern — so the host input gate must NOT special-case it into
1489        // `HelpDismiss` any more. The close behavior itself (split pane /
1490        // popup / active-pane) is covered end-to-end where the mode keymap is
1491        // wired; here we only assert the host stopped intercepting Esc.
1492        let (_, b) = fixture();
1493        assert!(!matches!(
1494            translate(ctx_help_active(ModalState::Normal, &b), key(KeyCode::Esc)),
1495            Action::HelpDismiss
1496        ));
1497    }
1498
1499    #[test]
1500    fn help_active_routes_enter_to_follow_link() {
1501        let (_, b) = fixture();
1502        assert!(matches!(
1503            translate(ctx_help_active(ModalState::Normal, &b), key(KeyCode::Enter)),
1504            Action::FollowLink
1505        ));
1506    }
1507
1508    fn ctx_dashboard_active<'a>(modal: ModalState, b: &'a Builtins) -> TranslateContext<'a> {
1509        TranslateContext {
1510            active_buffer: BufferKind::Dashboard,
1511            ..ctx_help_active(modal, b)
1512        }
1513    }
1514
1515    #[test]
1516    fn dashboard_active_routes_enter_to_follow_link() {
1517        // The launch dashboard's clickable links: `<CR>` on a link follows
1518        // it (dashboard.md §9.2). Without this gate the dashboard links are
1519        // dead — Enter falls through to plain Normal-mode.
1520        let (_, b) = fixture();
1521        assert!(matches!(
1522            translate(
1523                ctx_dashboard_active(ModalState::Normal, &b),
1524                key(KeyCode::Enter)
1525            ),
1526            Action::FollowLink
1527        ));
1528    }
1529
1530    #[test]
1531    fn dashboard_active_does_not_map_dash_to_oil_navigate_up() {
1532        // Deliberately NOT folded into the Help/FileTree gate: that gate maps
1533        // `-` → OilNavigateUp, which on the dashboard would open the oil file
1534        // browser. `-` must keep its plain Normal-mode meaning here.
1535        let (_, b) = fixture();
1536        assert!(!matches!(
1537            translate(
1538                ctx_dashboard_active(ModalState::Normal, &b),
1539                key(KeyCode::Char('-'))
1540            ),
1541            Action::OilNavigateUp
1542        ));
1543    }
1544
1545    #[test]
1546    fn help_active_routes_jk_through_normal_motions() {
1547        // `j` in help is the *same* line_down motion as in Normal --
1548        // active_buffer routing in the apply layer redirects which
1549        // cursor moves; the chord grammar is unchanged.
1550        let (_, b) = fixture();
1551        let action = translate(
1552            ctx_help_active(ModalState::Normal, &b),
1553            key(KeyCode::Char('j')),
1554        );
1555        assert_eq!(invocation_command(&action), Some(b.line_down.0));
1556    }
1557
1558    #[test]
1559    fn help_active_routes_gg_through_chord_grammar() {
1560        // First `g` absorbs into partial_chord (same as Normal);
1561        // second resolves to goto_first_line. The buffer-local
1562        // handler must NOT collapse a bare `g` into `gg` -- that
1563        // was the bug fc872ec papered over with a help-specific
1564        // chord engine. Slice 8.i.4.a: the AfterG path is now
1565        // partial_chord-driven.
1566        let (_, b) = fixture();
1567        let first = translate(
1568            ctx_help_active(ModalState::Normal, &b),
1569            key(KeyCode::Char('g')),
1570        );
1571        assert!(matches!(
1572            first,
1573            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('g')
1574        ));
1575        let second = translate(
1576            ctx_help_active_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
1577            key(KeyCode::Char('g')),
1578        );
1579        assert_eq!(invocation_command(&second), Some(b.goto_first_line.0));
1580    }
1581
1582    #[test]
1583    fn help_active_routes_capital_g_to_goto_last_line() {
1584        let (_, b) = fixture();
1585        let action = translate(
1586            ctx_help_active(ModalState::Normal, &b),
1587            key(KeyCode::Char('G')),
1588        );
1589        assert_eq!(invocation_command(&action), Some(b.goto_last_line.0));
1590    }
1591
1592    #[test]
1593    fn help_active_routes_ctrl_o_to_jump_history_back() {
1594        // `<C-o>` and `<C-i>` walk the unified position history --
1595        // crossing the document <-> help boundary is what
1596        // active_buffer routing makes possible.
1597        let (_, b) = fixture();
1598        let a = shared_actions();
1599        match translate(
1600            ctx_help_active(ModalState::Normal, &b),
1601            ctrl(KeyCode::Char('o')),
1602        ) {
1603            Action::Invoke(inv) => assert_eq!(inv.command, a.jump_history_back),
1604            other => panic!("expected Invoke(jump_history_back), got {other:?}"),
1605        }
1606    }
1607
1608    // ---- Pane navigation (DESIGN.md §5.9, B.1.b) ----
1609
1610    #[test]
1611    fn ctrl_w_absorbs_partial_chord() {
1612        // Slice 8.i.4.a: `<C-w>` migrated to partial_chord.
1613        let (_, b) = fixture();
1614        let action = translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('w')));
1615        assert!(matches!(
1616            action,
1617            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::ctrl('w')
1618        ));
1619    }
1620
1621    #[test]
1622    fn ctrl_w_l_navigates_right() {
1623        let (_, b) = fixture();
1624        let a = shared_actions();
1625        match translate(
1626            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::ctrl('w')], &b),
1627            key(KeyCode::Char('l')),
1628        ) {
1629            Action::Invoke(inv) => assert_eq!(inv.command, a.navigate_pane_right),
1630            other => panic!("expected Invoke(navigate_pane_right), got {other:?}"),
1631        }
1632    }
1633
1634    #[test]
1635    fn ctrl_w_ctrl_l_also_navigates_right() {
1636        // Vim accepts the "Ctrl held throughout" form (`<C-w><C-l>`)
1637        // as well as the "release then press" form (`<C-w>l`).
1638        let (_, b) = fixture();
1639        let a = shared_actions();
1640        match translate(
1641            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::ctrl('w')], &b),
1642            ctrl(KeyCode::Char('l')),
1643        ) {
1644            Action::Invoke(inv) => assert_eq!(inv.command, a.navigate_pane_right),
1645            other => panic!("expected Invoke(navigate_pane_right), got {other:?}"),
1646        }
1647    }
1648
1649    #[test]
1650    fn ctrl_w_ctrl_j_navigates_down() {
1651        let (_, b) = fixture();
1652        let a = shared_actions();
1653        match translate(
1654            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::ctrl('w')], &b),
1655            ctrl(KeyCode::Char('j')),
1656        ) {
1657            Action::Invoke(inv) => assert_eq!(inv.command, a.navigate_pane_down),
1658            other => panic!("expected Invoke(navigate_pane_down), got {other:?}"),
1659        }
1660    }
1661
1662    #[test]
1663    fn ctrl_w_w_cycles_to_next_pane() {
1664        let (_, b) = fixture();
1665        let a = shared_actions();
1666        match translate(
1667            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::ctrl('w')], &b),
1668            key(KeyCode::Char('w')),
1669        ) {
1670            Action::Invoke(inv) => assert_eq!(inv.command, a.next_pane),
1671            other => panic!("expected Invoke(next_pane), got {other:?}"),
1672        }
1673    }
1674
1675    #[test]
1676    fn ctrl_w_capital_w_cycles_to_prev_pane() {
1677        let (_, b) = fixture();
1678        let a = shared_actions();
1679        match translate(
1680            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::ctrl('w')], &b),
1681            key(KeyCode::Char('W')),
1682        ) {
1683            Action::Invoke(inv) => assert_eq!(inv.command, a.prev_pane),
1684            other => panic!("expected Invoke(prev_pane), got {other:?}"),
1685        }
1686    }
1687
1688    #[test]
1689    fn ctrl_c_in_normal_mode_under_help_is_unbound() {
1690        // <C-c> has no Builtin-layer binding regardless of buffer.
1691        let (_, b) = fixture();
1692        let action = translate(
1693            ctx_help_active(ModalState::Normal, &b),
1694            ctrl(KeyCode::Char('c')),
1695        );
1696        assert!(matches!(action, Action::None));
1697    }
1698
1699    // ---- Keymap drift detection (DESIGN.md §5.2.3, §5.11) ----
1700
1701    /// Parse a chord-notation string from `keymap::default_keymap()` into
1702    /// a sequence of `KeyEvent`s. Recognises:
1703    /// - bare chars: `j` / `dw` / `gg`
1704    /// - special keys: `<Esc>`, `<CR>`, `<Tab>`, `<BS>`,
1705    ///   `<Up>`/`<Down>`/`<Left>`/`<Right>`, `<Home>`/`<End>`,
1706    ///   `<PageUp>`/`<PageDown>`
1707    /// - control chords: `<C-d>`, `<C-v>`, `<C-r>`, ...
1708    fn parse_chord_for_test(chord: &str) -> Vec<KeyEvent> {
1709        // `<` and `>` are valid bare chords (indent-left / indent-right
1710        // operators). Treat a single-char chord as a literal character
1711        // so the escape parser doesn't try to interpret `<` as the
1712        // start of a `<Special>` token.
1713        if chord.chars().count() == 1 {
1714            let c = chord.chars().next().expect("len == 1");
1715            return vec![KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)];
1716        }
1717        let mut out = Vec::new();
1718        let mut chars = chord.chars().peekable();
1719        while let Some(c) = chars.next() {
1720            if c != '<' {
1721                out.push(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
1722                continue;
1723            }
1724            let mut body = String::new();
1725            for n in chars.by_ref() {
1726                if n == '>' {
1727                    break;
1728                }
1729                body.push(n);
1730            }
1731            let evt = match body.as_str() {
1732                "Esc" => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
1733                "CR" | "Enter" => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
1734                "Tab" => KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
1735                "BS" => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
1736                "Up" => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
1737                "Down" => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
1738                "Left" => KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
1739                "Right" => KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
1740                "Home" => KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
1741                "End" => KeyEvent::new(KeyCode::End, KeyModifiers::NONE),
1742                "PageUp" => KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
1743                "PageDown" => KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
1744                other => {
1745                    if let Some(rest) = other.strip_prefix("S-") {
1746                        // Shift-modified specials: `<S-Tab>` is the
1747                        // primary user; crossterm reports it as
1748                        // `BackTab` (no SHIFT modifier on the event).
1749                        let evt = match rest {
1750                            "Tab" => KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
1751                            _ => match rest.chars().next() {
1752                                Some(c) => KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT),
1753                                None => continue,
1754                            },
1755                        };
1756                        out.push(evt);
1757                        continue;
1758                    }
1759                    if let Some(rest) = other.strip_prefix("C-") {
1760                        // Recognise `Space` as a token before falling
1761                        // back to the single-char path so `<C-Space>`
1762                        // parses to `Char(' ') + CONTROL`. Same shape
1763                        // crossterm reports.
1764
1765                        match rest {
1766                            "Space" => KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL),
1767                            // Issue #29 slice 3 (2026-05-22): Ctrl-modified
1768                            // PageUp / PageDown for tab navigation.
1769                            "PageUp" => KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL),
1770                            "PageDown" => KeyEvent::new(KeyCode::PageDown, KeyModifiers::CONTROL),
1771                            _ => match rest.chars().next() {
1772                                Some(c) => KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL),
1773                                None => continue,
1774                            },
1775                        }
1776                    } else {
1777                        // Unrecognised special-key notation -- skip
1778                        // rather than panic; the drift test will fail
1779                        // with a clearer message about the descriptor.
1780                        continue;
1781                    }
1782                }
1783            };
1784            out.push(evt);
1785        }
1786        out
1787    }
1788
1789    /// Walk a chord through `translate()` from the descriptor's
1790    /// starting mode, updating pending state across multi-key
1791    /// sequences. Returns the final Action.
1792    fn simulate_chord(
1793        chord: &str,
1794        mode: crate::keymap::BindingMode,
1795        builtins: &Builtins,
1796    ) -> Action {
1797        use crate::keymap::BindingMode;
1798        let modal = match mode {
1799            BindingMode::Visual => ModalState::Visual(lattice_grammar::VisualKind::Charwise),
1800            // CompletionPopup minor mode rides on top of Insert.
1801            BindingMode::Insert | BindingMode::CompletionPopup | BindingMode::AfterCtrlX => {
1802                ModalState::Insert
1803            }
1804            BindingMode::Replace => ModalState::Replace,
1805            BindingMode::Command => ModalState::Command,
1806            BindingMode::Search => ModalState::Search(lattice_grammar::SearchDirection::Forward),
1807            // After-* modes are pending substates of Normal: their
1808            // chords include the prefix (`gg`, `gU`, `zz`, ...) so we
1809            // start the walk from Normal pending=None and let
1810            // translate() set the pending state mid-sequence.
1811            _ => ModalState::Normal,
1812        };
1813        let active_buffer = if matches!(mode, BindingMode::Help) {
1814            BufferKind::Help
1815        } else {
1816            BufferKind::Document
1817        };
1818        // After-* modes whose chord doesn't start with the prefix
1819        // (e.g. `AfterCtrlX` whose chord is `<C-x><C-o>` -- which
1820        // *does* start with `<C-x>`) need the prefix in the chord.
1821        // The completion-popup minor mode is signalled host-side
1822        // by `App.insert_completion.is_some()`; in this harness we
1823        // toggle the equivalent context flag.
1824        let insert_completion_open = matches!(mode, BindingMode::CompletionPopup);
1825        let snippet_active = matches!(mode, BindingMode::Snippet);
1826        // Snippet minor mode rides on Insert.
1827        let modal = if snippet_active {
1828            ModalState::Insert
1829        } else {
1830            modal
1831        };
1832        // Slice 8.f: the minor-mode overlays no longer ride on
1833        // the legacy `insert_completion_open` / `snippet_active`
1834        // flags; they're `KeymapLayer::MinorMode` layers pushed
1835        // onto the registry. Pick the scenario-matched shared
1836        // keymap so the descriptor's chord resolves through the
1837        // intended layer.
1838        // SN.3c.2a: the keymap AND its active-minor set move together —
1839        // Insert dispatch is K.1.c-gated now, so pushing a layer without
1840        // naming its mode active would leave the chord unresolved.
1841        let (keymap_for_mode, active_for_mode): (
1842            &'static KeymapHandle,
1843            &'static [lattice_mode::ModeId],
1844        ) = if insert_completion_open {
1845            (shared_keymap_with_popup(), popup_minor_modes())
1846        } else if snippet_active {
1847            (shared_keymap_with_snippet(), snippet_minor_modes())
1848        } else if matches!(mode, BindingMode::Command) {
1849            (
1850                shared_keymap_with_command_line_mode(),
1851                command_line_mode_active(),
1852            )
1853        } else {
1854            // SN.3c.1: snippet-mode is Global, so a document buffer's
1855            // base Insert keymap carries its `<C-x><C-s>` expand prefix.
1856            // Use the base + snippet-mode layer (mode named active) so
1857            // descriptors like `<C-x>` resolve (the partial node moved
1858            // off Builtin).
1859            (shared_keymap_base_with_snippet_mode(), snippet_mode_only())
1860        };
1861        let mut partial_chord: Vec<crate::chord::KeyChord> = Vec::new();
1862        let mut last = Action::None;
1863        for event in parse_chord_for_test(chord) {
1864            let ctx = TranslateContext {
1865                modal,
1866                builtins,
1867                pending_count: 0,
1868                op_count: 0,
1869                recording_macro: false,
1870                active_buffer,
1871                completion_open: false,
1872                chord_capture: false,
1873                picker_open: false,
1874                insert_completion_open,
1875                snippet_active,
1876                keymap: keymap_for_mode,
1877                terminal_insert_active: false,
1878                terminal_esc_exits: false,
1879                terminal_app_cursor_keys: false,
1880                terminal_insert_exit_pending: false,
1881                terminal_visual_active: false,
1882                partial_chord: &partial_chord,
1883                active_minor_modes: active_for_mode,
1884            };
1885            last = translate(ctx, event);
1886            // Mirror `App::apply`'s partial_chord lifecycle
1887            // (slice 8.i.4): AbsorbPartialChord appends to the
1888            // chord stack; any other action resolves it (clear).
1889            match &last {
1890                Action::AbsorbPartialChord(c) => partial_chord.push(*c),
1891                _ => partial_chord.clear(),
1892            }
1893        }
1894        last
1895    }
1896
1897    #[test]
1898    fn keymap_descriptors_dont_drift_from_translate() {
1899        // One of two complementary catalog drift checks (see also
1900        // `every_catalog_command_resolves_in_registry` below). This
1901        // one runs each descriptor's chord through `translate()` and
1902        // asserts the chord still resolves to a non-`None` Action --
1903        // i.e. the catalog's chord notation matches what the
1904        // dispatcher accepts. Catches:
1905        //   - removed bindings (descriptor still in table)
1906        //   - moved bindings (descriptor in wrong mode)
1907        //   - typo'd chord notation
1908        // The companion test catches the orthogonal failure: a
1909        // descriptor that names a command which doesn't exist in the
1910        // registry. Both stay in place until `default_keymap()`
1911        // becomes the trie's source-of-truth (post-1.0); at that
1912        // point the chord side becomes tautological and this test
1913        // retires.
1914        let (_, b) = fixture();
1915        for entry in crate::keymap::default_keymap() {
1916            // B-field: an entry may be live in several modes; check each.
1917            for &mode in entry.modes {
1918                let action = simulate_chord(entry.chord, mode, &b);
1919                assert!(
1920                    !matches!(action, Action::None),
1921                    "keymap descriptor `{}` ({}) doc=`{}` produced Action::None -- \
1922                     binding may have been removed or moved",
1923                    entry.chord,
1924                    mode.label(),
1925                    entry.doc,
1926                );
1927            }
1928        }
1929    }
1930
1931    #[test]
1932    fn every_catalog_command_resolves_in_registry() {
1933        // Companion to `keymap_descriptors_dont_drift_from_translate`.
1934        // Every catalog entry that names a canonical command via
1935        // `command: Some(name)` must resolve to a real registry entry.
1936        // Catches the orthogonal drift the chord-side check misses:
1937        //   - descriptor names a command that was renamed at the
1938        //     registry side without updating the catalog,
1939        //   - descriptor names a command that doesn't exist at all
1940        //     (typo, copy-paste from a sibling entry),
1941        //   - a registry refactor dropped a command but the catalog
1942        //     still claims it.
1943        // Synthetic-action descriptors (`PushDigit`, `SetPending`,
1944        // mode-entry primitives, ...) carry `command: None` and are
1945        // skipped -- they don't have a registry-resolvable name.
1946        let mut r = CommandRegistry::new();
1947        let b = populate(&mut r);
1948        let _ex = lattice_grammar::ex_commands::populate(&mut r);
1949        let _a = crate::actions::populate(&mut r, &b);
1950        for entry in crate::keymap::default_keymap() {
1951            let Some(name) = entry.command else {
1952                continue;
1953            };
1954            assert!(
1955                r.id_by_name(name).is_some(),
1956                "keymap descriptor `{}` ({}) names command `{}` -- \
1957                 not found in CommandRegistry. Possible rename or \
1958                 typo; catalog is out of sync with the registry.",
1959                entry.chord,
1960                entry.modes_label(),
1961                name,
1962            );
1963        }
1964    }
1965
1966    // ---- Mark history (g; / g,) ----
1967
1968    #[test]
1969    fn g_semicolon_after_g_walks_mark_history_back() {
1970        let (_, b) = fixture();
1971        let a = shared_actions();
1972        match translate(
1973            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
1974            key(KeyCode::Char(';')),
1975        ) {
1976            Action::Invoke(inv) => assert_eq!(inv.command, a.walk_mark_history_back),
1977            other => panic!("expected Invoke(walk_mark_history_back), got {other:?}"),
1978        }
1979    }
1980
1981    #[test]
1982    fn g_comma_after_g_walks_mark_history_forward() {
1983        let (_, b) = fixture();
1984        let a = shared_actions();
1985        match translate(
1986            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
1987            key(KeyCode::Char(',')),
1988        ) {
1989            Action::Invoke(inv) => assert_eq!(inv.command, a.walk_mark_history_forward),
1990            other => panic!("expected Invoke(walk_mark_history_forward), got {other:?}"),
1991        }
1992    }
1993
1994    // ---- LSP navigation (gd / gD / gy / gI / gr) ----
1995    // MO.1: these bindings moved to LspMode::keymap() (MinorMode layer).
1996    // Without lsp-mode active the Builtin layer has no entry → Action::None.
1997
1998    #[test]
1999    fn gd_without_lsp_mode_is_unresolved() {
2000        let (_, b) = fixture();
2001        assert!(matches!(
2002            translate(
2003                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
2004                key(KeyCode::Char('d')),
2005            ),
2006            Action::None
2007        ));
2008    }
2009
2010    #[test]
2011    fn g_d_without_lsp_mode_is_unresolved() {
2012        let (_, b) = fixture();
2013        assert!(matches!(
2014            translate(
2015                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
2016                key(KeyCode::Char('D')),
2017            ),
2018            Action::None
2019        ));
2020    }
2021
2022    #[test]
2023    fn gy_without_lsp_mode_is_unresolved() {
2024        let (_, b) = fixture();
2025        assert!(matches!(
2026            translate(
2027                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
2028                key(KeyCode::Char('y')),
2029            ),
2030            Action::None
2031        ));
2032    }
2033
2034    #[test]
2035    fn g_i_without_lsp_mode_is_unresolved() {
2036        let (_, b) = fixture();
2037        assert!(matches!(
2038            translate(
2039                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
2040                key(KeyCode::Char('I')),
2041            ),
2042            Action::None
2043        ));
2044    }
2045
2046    #[test]
2047    fn gr_without_lsp_mode_is_unresolved() {
2048        let (_, b) = fixture();
2049        assert!(matches!(
2050            translate(
2051                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
2052                key(KeyCode::Char('r')),
2053            ),
2054            Action::None
2055        ));
2056    }
2057
2058    // ---- Insert-mode completion (Phase 4.2.g.1) ----
2059
2060    fn ctx_insert_completion<'a>(b: &'a Builtins) -> TranslateContext<'a> {
2061        TranslateContext {
2062            modal: ModalState::Insert,
2063            builtins: b,
2064            pending_count: 0,
2065            op_count: 0,
2066            recording_macro: false,
2067            active_buffer: BufferKind::Document,
2068            completion_open: false,
2069            chord_capture: false,
2070            picker_open: false,
2071            insert_completion_open: true,
2072            snippet_active: false,
2073            // Slice 8.f: the popup overlay rides as a
2074            // `KeymapLayer::MinorMode` layer pushed on the
2075            // shared base handle; the legacy
2076            // `insert_completion_open` flag stays for
2077            // back-compat but no longer affects dispatch.
2078            keymap: shared_keymap_with_popup(),
2079            terminal_insert_active: false,
2080            terminal_esc_exits: false,
2081            terminal_app_cursor_keys: false,
2082            terminal_insert_exit_pending: false,
2083            terminal_visual_active: false,
2084            partial_chord: &[],
2085            // SN.3c.2a: popup layer is gated on this mode being active.
2086            active_minor_modes: popup_minor_modes(),
2087        }
2088    }
2089
2090    #[test]
2091    fn ctrl_space_in_insert_triggers_completion() {
2092        let (_, b) = fixture();
2093        let a = shared_actions();
2094        match translate(ctx(ModalState::Insert, &b), ctrl(KeyCode::Char(' '))) {
2095            Action::Invoke(inv) => assert_eq!(inv.command, a.completion_trigger),
2096            other => panic!("expected Invoke(completion_trigger), got {other:?}"),
2097        }
2098    }
2099
2100    #[test]
2101    fn ctrl_x_in_insert_absorbs_partial_chord() {
2102        // Slice 8.i.4.b: `<C-x>` migrated to partial_chord.
2103        // SN.3c.1: `<C-x>` is a partial prefix only because
2104        // `snippet-mode`'s `<C-x><C-s>` layer provides the terminal
2105        // (no longer Builtin) — dispatch against the snippet-layer
2106        // keymap, matching boot where the layer is pushed.
2107        let (_, b) = fixture();
2108        let mut cx = ctx(ModalState::Insert, &b);
2109        cx.keymap = shared_keymap_with_snippet();
2110        cx.active_minor_modes = snippet_minor_modes();
2111        let action = translate(cx, ctrl(KeyCode::Char('x')));
2112        assert!(matches!(
2113            action,
2114            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::ctrl('x')
2115        ));
2116    }
2117
2118    #[test]
2119    fn ctrl_x_ctrl_o_no_longer_invokes() {
2120        // CSM.K1: `<C-x><C-o>` (vim omni-completion alias)
2121        // retired. `<C-Space>` is the sole popup trigger; the
2122        // chord is unbound now (the dispatcher returns
2123        // something other than `Invoke`).
2124        let (_, b) = fixture();
2125        let r = translate(
2126            ctx_partial(ModalState::Insert, &[crate::chord::KeyChord::ctrl('x')], &b),
2127            ctrl(KeyCode::Char('o')),
2128        );
2129        assert!(
2130            !matches!(r, Action::Invoke(_)),
2131            "<C-x><C-o> should no longer resolve to an Invoke; got {r:?}",
2132        );
2133    }
2134
2135    #[test]
2136    fn ctrl_x_followed_by_unrecognised_clears_partial_chord() {
2137        // Slice 8.i.4: with `partial_chord = [<C-x>]` and an
2138        // unrecognised second key, dispatch_insert returns
2139        // `Action::None`. App::apply's
2140        // non-`AbsorbPartialChord(_)` rule clears partial_chord.
2141        let (_, b) = fixture();
2142        assert!(matches!(
2143            translate(
2144                ctx_partial(ModalState::Insert, &[crate::chord::KeyChord::ctrl('x')], &b,),
2145                ctrl(KeyCode::Char('z'))
2146            ),
2147            Action::None
2148        ));
2149    }
2150
2151    #[test]
2152    fn popup_open_ctrl_n_navigates_next() {
2153        let (_, b) = fixture();
2154        let a = shared_actions();
2155        let r = translate(ctx_insert_completion(&b), ctrl(KeyCode::Char('n')));
2156        match r {
2157            Action::Invoke(inv) => assert_eq!(inv.command, a.completion_next),
2158            other => panic!("expected Invoke(completion_next), got {other:?}"),
2159        }
2160    }
2161
2162    #[test]
2163    fn popup_open_ctrl_p_navigates_prev() {
2164        let (_, b) = fixture();
2165        let a = shared_actions();
2166        let r = translate(ctx_insert_completion(&b), ctrl(KeyCode::Char('p')));
2167        match r {
2168            Action::Invoke(inv) => assert_eq!(inv.command, a.completion_prev),
2169            other => panic!("expected Invoke(completion_prev), got {other:?}"),
2170        }
2171    }
2172
2173    #[test]
2174    fn popup_open_tab_accepts() {
2175        let (_, b) = fixture();
2176        let a = shared_actions();
2177        let r = translate(ctx_insert_completion(&b), key(KeyCode::Tab));
2178        match r {
2179            Action::Invoke(inv) => assert_eq!(inv.command, a.completion_accept),
2180            other => panic!("expected Invoke(completion_accept), got {other:?}"),
2181        }
2182    }
2183
2184    #[test]
2185    fn popup_open_ctrl_y_accepts() {
2186        let (_, b) = fixture();
2187        let a = shared_actions();
2188        let r = translate(ctx_insert_completion(&b), ctrl(KeyCode::Char('y')));
2189        match r {
2190            Action::Invoke(inv) => assert_eq!(inv.command, a.completion_accept),
2191            other => panic!("expected Invoke(completion_accept), got {other:?}"),
2192        }
2193    }
2194
2195    #[test]
2196    fn popup_open_enter_accepts() {
2197        let (_, b) = fixture();
2198        let a = shared_actions();
2199        let r = translate(ctx_insert_completion(&b), key(KeyCode::Enter));
2200        match r {
2201            Action::Invoke(inv) => assert_eq!(inv.command, a.completion_accept),
2202            other => panic!("expected Invoke(completion_accept), got {other:?}"),
2203        }
2204    }
2205
2206    #[test]
2207    fn popup_open_ctrl_e_cancels_keeps_insert() {
2208        let (_, b) = fixture();
2209        let a = shared_actions();
2210        let r = translate(ctx_insert_completion(&b), ctrl(KeyCode::Char('e')));
2211        match r {
2212            Action::Invoke(inv) => assert_eq!(inv.command, a.completion_cancel),
2213            other => panic!("expected Invoke(completion_cancel), got {other:?}"),
2214        }
2215    }
2216
2217    #[test]
2218    fn popup_open_esc_cancels_and_exits_insert() {
2219        let (_, b) = fixture();
2220        let a = shared_actions();
2221        let r = translate(ctx_insert_completion(&b), key(KeyCode::Esc));
2222        match r {
2223            Action::Invoke(inv) => {
2224                assert_eq!(inv.command, a.completion_cancel_and_exit_insert)
2225            }
2226            other => panic!("expected Invoke(completion_cancel_and_exit_insert), got {other:?}"),
2227        }
2228    }
2229
2230    #[test]
2231    fn popup_open_ctrl_d_toggles_docs_only_inside_minor_mode() {
2232        let (_, b) = fixture();
2233        let a = shared_actions();
2234        // Inside the popup minor mode -- claim it.
2235        let r = translate(ctx_insert_completion(&b), ctrl(KeyCode::Char('d')));
2236        match r {
2237            Action::Invoke(inv) => {
2238                assert_eq!(inv.command, a.completion_toggle_docs)
2239            }
2240            other => panic!("expected Invoke(completion_toggle_docs), got {other:?}"),
2241        }
2242        // OUTSIDE the minor mode (Normal mode) -- the popup
2243        // layer doesn't fire; falls through to Normal-mode
2244        // half-page-down. This verifies the layer's
2245        // confinement.
2246        let half_down = translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('d')));
2247        if let Action::Invoke(inv) = half_down {
2248            assert_ne!(inv.command, a.completion_toggle_docs);
2249        }
2250    }
2251
2252    /// CSM.K2: inside the popup `<C-f>` is the path filter
2253    /// chord (was docs-scroll-down before; that moved to
2254    /// `PageDown`).
2255    #[test]
2256    fn popup_open_ctrl_f_filters_to_path() {
2257        use lattice_grammar::args::Args;
2258        let (_, b) = fixture();
2259        let a = shared_actions();
2260        let r = translate(ctx_insert_completion(&b), ctrl(KeyCode::Char('f')));
2261        match r {
2262            Action::Invoke(inv) => {
2263                assert_eq!(inv.command, a.completion_filter_to_source);
2264                match inv.args {
2265                    Args::String(s) => {
2266                        assert_eq!(s, lattice_completion::insert::PATH_SOURCE_ID)
2267                    }
2268                    other => panic!("expected Args::String, got {other:?}"),
2269                }
2270            }
2271            other => {
2272                panic!("expected Invoke(completion_filter_to_source, \"gen:path\"), got {other:?}")
2273            }
2274        }
2275    }
2276
2277    /// CSM.K2: inside the popup `<C-b>` is the buffer-words
2278    /// filter chord (was docs-scroll-up; moved to `PageUp`).
2279    #[test]
2280    fn popup_open_ctrl_b_filters_to_buffer_words() {
2281        use lattice_grammar::args::Args;
2282        let (_, b) = fixture();
2283        let a = shared_actions();
2284        let r = translate(ctx_insert_completion(&b), ctrl(KeyCode::Char('b')));
2285        match r {
2286            Action::Invoke(inv) => {
2287                assert_eq!(inv.command, a.completion_filter_to_source);
2288                match inv.args {
2289                    Args::String(s) => {
2290                        assert_eq!(s, lattice_completion::insert::BufferWordsSource::ID)
2291                    }
2292                    other => panic!("expected Args::String, got {other:?}"),
2293                }
2294            }
2295            other => panic!(
2296                "expected Invoke(completion_filter_to_source, \"gen:buffer-words\"), got {other:?}"
2297            ),
2298        }
2299    }
2300
2301    /// CSM.K2: inside the popup `<C-Space>` clears the active
2302    /// source filter (was re-trigger before; the re-trigger
2303    /// binding lives one layer down on base Insert).
2304    #[test]
2305    fn popup_open_ctrl_space_clears_filter() {
2306        let (_, b) = fixture();
2307        let a = shared_actions();
2308        let r = translate(ctx_insert_completion(&b), ctrl(KeyCode::Char(' ')));
2309        match r {
2310            Action::Invoke(inv) => assert_eq!(inv.command, a.completion_filter_clear),
2311            other => panic!("expected Invoke(completion_filter_clear), got {other:?}"),
2312        }
2313    }
2314
2315    // ---- Active-snippet minor mode (Phase 4.2.g.4) ----
2316
2317    fn ctx_snippet_active<'a>(b: &'a Builtins) -> TranslateContext<'a> {
2318        TranslateContext {
2319            modal: ModalState::Insert,
2320            builtins: b,
2321            pending_count: 0,
2322            op_count: 0,
2323            recording_macro: false,
2324            active_buffer: BufferKind::Document,
2325            completion_open: false,
2326            chord_capture: false,
2327            picker_open: false,
2328            insert_completion_open: false,
2329            snippet_active: true,
2330            // Slice 8.f: snippet overlay rides as a
2331            // `KeymapLayer::MinorMode` layer pushed on the
2332            // shared base handle.
2333            keymap: shared_keymap_with_snippet(),
2334            terminal_insert_active: false,
2335            terminal_esc_exits: false,
2336            terminal_app_cursor_keys: false,
2337            terminal_insert_exit_pending: false,
2338            terminal_visual_active: false,
2339            partial_chord: &[],
2340            // SN.3c.2a: snippet layers gated on these modes being active.
2341            active_minor_modes: snippet_minor_modes(),
2342        }
2343    }
2344
2345    #[test]
2346    fn snippet_active_tab_jumps_to_next_placeholder() {
2347        let (_, b) = fixture();
2348        let a = shared_actions();
2349        let r = translate(ctx_snippet_active(&b), key(KeyCode::Tab));
2350        match r {
2351            Action::Invoke(inv) => {
2352                assert_eq!(inv.command, a.snippet_next_placeholder)
2353            }
2354            other => panic!("expected Invoke(snippet_next_placeholder), got {other:?}"),
2355        }
2356    }
2357
2358    #[test]
2359    fn snippet_active_back_tab_jumps_to_prev_placeholder() {
2360        let (_, b) = fixture();
2361        let a = shared_actions();
2362        let r = translate(ctx_snippet_active(&b), key(KeyCode::BackTab));
2363        match r {
2364            Action::Invoke(inv) => {
2365                assert_eq!(inv.command, a.snippet_prev_placeholder)
2366            }
2367            other => panic!("expected Invoke(snippet_prev_placeholder), got {other:?}"),
2368        }
2369    }
2370
2371    #[test]
2372    fn snippet_active_esc_leaves_snippet_then_falls_through() {
2373        // SN.3c.2b: `<Esc>` is `fall_through` — clear the snippet, then
2374        // continue to the native `<Esc>` (enter-normal). The full
2375        // `translate` path resolves it to a Chain.
2376        let (_, b) = fixture();
2377        let a = shared_actions();
2378        let r = translate(ctx_snippet_active(&b), key(KeyCode::Esc));
2379        match r {
2380            Action::Chain(v) => match (v.first(), v.get(1)) {
2381                (Some(Action::Invoke(leave)), Some(Action::Invoke(native))) => {
2382                    assert_eq!(leave.command, a.snippet_leave);
2383                    assert_eq!(native.command, a.enter_mode_normal);
2384                    assert_eq!(v.len(), 2);
2385                }
2386                _ => panic!("expected Chain([snippet_leave, enter_mode_normal]), got {v:?}"),
2387            },
2388            other => panic!("expected Chain, got {other:?}"),
2389        }
2390    }
2391
2392    #[test]
2393    fn snippet_active_other_keys_fall_through_to_insert() {
2394        let (_, b) = fixture();
2395        // A regular printable char inside a placeholder should
2396        // still hit the Insert-mode handler so the user can
2397        // overtype the default.
2398        let action = translate(ctx_snippet_active(&b), key(KeyCode::Char('x')));
2399        assert!(matches!(action, Action::Insert(s) if s == "x"));
2400    }
2401
2402    #[test]
2403    fn snippet_active_yields_to_completion_popup() {
2404        // When both layers are active the popup wins for
2405        // `<Tab>` (popup uses Tab to accept, snippet uses Tab
2406        // to step). Otherwise navigating snippet placeholders
2407        // through the popup would be impossible. Slice 8.f:
2408        // the gating used to live in `translate`; now the
2409        // layer-stack push order in
2410        // `App::sync_keymap_overlays` (snippet first, popup
2411        // second) ensures popup wins. The shared
2412        // `with_both_overlays` keymap mirrors that order.
2413        let (_, b) = fixture();
2414        let a = shared_actions();
2415        let mut ctx = ctx_snippet_active(&b);
2416        ctx.insert_completion_open = true;
2417        ctx.keymap = shared_keymap_with_both_overlays();
2418        ctx.active_minor_modes = both_minor_modes();
2419        let action = translate(ctx, key(KeyCode::Tab));
2420        match action {
2421            Action::Invoke(inv) => assert_eq!(inv.command, a.completion_accept),
2422            other => panic!("expected Invoke(completion_accept), got {other:?}"),
2423        }
2424    }
2425
2426    #[test]
2427    fn snippet_active_only_in_insert_mode() {
2428        // Active-snippet layer must never claim keys outside
2429        // Insert mode; otherwise a stuck snippet could swallow
2430        // Normal-mode `<Tab>` (which is `<C-i>` -- jump-list
2431        // forward).
2432        let (_, b) = fixture();
2433        let a = shared_actions();
2434        let mut ctx = ctx_snippet_active(&b);
2435        ctx.modal = ModalState::Normal;
2436        let action = translate(ctx, key(KeyCode::Tab));
2437        // Normal-mode `<Tab>` is `<C-i>` -- jump history forward,
2438        // not the snippet placeholder action.
2439        if let Action::Invoke(inv) = action {
2440            assert_ne!(inv.command, a.snippet_next_placeholder);
2441        }
2442    }
2443
2444    #[test]
2445    fn ctrl_x_ctrl_s_resolves_to_snippet_expand() {
2446        // SN.3c.1: `<C-x><C-s>` is contributed by `snippet-mode`'s
2447        // layer (not Builtin) — dispatch against the snippet-layer
2448        // keymap the editor sees once boot pushes the layer.
2449        let (_, b) = fixture();
2450        let a = shared_actions();
2451        let partial = [crate::chord::KeyChord::ctrl('x')];
2452        let mut cx = ctx_partial(ModalState::Insert, &partial, &b);
2453        cx.keymap = shared_keymap_with_snippet();
2454        cx.active_minor_modes = snippet_minor_modes();
2455        match translate(cx, ctrl(KeyCode::Char('s'))) {
2456            Action::Invoke(inv) => assert_eq!(inv.command, a.snippet_expand),
2457            other => panic!("expected Invoke(snippet_expand), got {other:?}"),
2458        }
2459    }
2460
2461    // ---- Position history ----
2462
2463    #[test]
2464    fn ctrl_o_emits_jump_history_back() {
2465        let (_, b) = fixture();
2466        let a = shared_actions();
2467        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('o'))) {
2468            Action::Invoke(inv) => assert_eq!(inv.command, a.jump_history_back),
2469            other => panic!("expected Invoke(jump_history_back), got {other:?}"),
2470        }
2471    }
2472
2473    #[test]
2474    fn ctrl_i_emits_jump_history_forward() {
2475        let (_, b) = fixture();
2476        let a = shared_actions();
2477        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('i'))) {
2478            Action::Invoke(inv) => assert_eq!(inv.command, a.jump_history_forward),
2479            other => panic!("expected Invoke(jump_history_forward), got {other:?}"),
2480        }
2481    }
2482
2483    #[test]
2484    fn ctrl_l_emits_redraw_screen() {
2485        let (_, b) = fixture();
2486        let a = shared_actions();
2487        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('l'))) {
2488            Action::Invoke(inv) => assert_eq!(inv.command, a.redraw_screen),
2489            other => panic!("expected Invoke(redraw_screen), got {other:?}"),
2490        }
2491    }
2492
2493    #[test]
2494    fn tab_in_normal_emits_jump_history_forward() {
2495        let (_, b) = fixture();
2496        let a = shared_actions();
2497        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Tab)) {
2498            Action::Invoke(inv) => assert_eq!(inv.command, a.jump_history_forward),
2499            other => panic!("expected Invoke(jump_history_forward), got {other:?}"),
2500        }
2501    }
2502
2503    // ---- Register prefix ----
2504
2505    #[test]
2506    fn quote_in_normal_absorbs_partial_chord() {
2507        // Slice 8.i.4.a: `"` migrated to partial_chord.
2508        let (_, b) = fixture();
2509        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('"')));
2510        assert!(matches!(
2511            action,
2512            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('"')
2513        ));
2514    }
2515
2516    #[test]
2517    fn lowercase_letter_after_quote_selects_named_register() {
2518        let (_, b) = fixture();
2519        let a = shared_actions();
2520        let action = translate(
2521            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('"')], &b),
2522            key(KeyCode::Char('a')),
2523        );
2524        match action {
2525            Action::Invoke(inv) => {
2526                assert_eq!(inv.command, a.select_register);
2527                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('a')));
2528            }
2529            other => panic!("expected Invoke(select_register, Char('a')), got {other:?}"),
2530        }
2531    }
2532
2533    #[test]
2534    fn digit_after_quote_selects_numbered_register() {
2535        let (_, b) = fixture();
2536        let a = shared_actions();
2537        let action = translate(
2538            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('"')], &b),
2539            key(KeyCode::Char('0')),
2540        );
2541        match action {
2542            Action::Invoke(inv) => {
2543                assert_eq!(inv.command, a.select_register);
2544                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('0')));
2545            }
2546            other => panic!("expected Invoke(select_register, Char('0')), got {other:?}"),
2547        }
2548    }
2549
2550    #[test]
2551    fn underscore_after_quote_selects_black_hole() {
2552        let (_, b) = fixture();
2553        let a = shared_actions();
2554        let action = translate(
2555            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('"')], &b),
2556            key(KeyCode::Char('_')),
2557        );
2558        match action {
2559            Action::Invoke(inv) => {
2560                assert_eq!(inv.command, a.select_register);
2561                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('_')));
2562            }
2563            other => panic!("expected Invoke(select_register, Char('_')), got {other:?}"),
2564        }
2565    }
2566
2567    #[test]
2568    fn plus_after_quote_selects_system() {
2569        let (_, b) = fixture();
2570        let a = shared_actions();
2571        let action = translate(
2572            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('"')], &b),
2573            key(KeyCode::Char('+')),
2574        );
2575        match action {
2576            Action::Invoke(inv) => {
2577                assert_eq!(inv.command, a.select_register);
2578                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('+')));
2579            }
2580            other => panic!("expected Invoke(select_register, Char('+')), got {other:?}"),
2581        }
2582    }
2583
2584    #[test]
2585    fn invalid_char_after_quote_passes_to_actionspec() {
2586        // Slice 8.i.3: validation lives in the bound `ActionSpec`,
2587        // which calls `Register::from_input_char(c)`. The dispatcher
2588        // returns `Invoke(select_register, Char(c))` regardless;
2589        // the spec returns `Effect::None` when the char doesn't
2590        // name a register.
2591        let (_, b) = fixture();
2592        let a = shared_actions();
2593        match translate(
2594            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('"')], &b),
2595            key(KeyCode::Char('@')),
2596        ) {
2597            Action::Invoke(inv) => {
2598                assert_eq!(inv.command, a.select_register);
2599                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('@')));
2600            }
2601            other => panic!("expected Invoke(select_register, Char('@')), got {other:?}"),
2602        }
2603    }
2604
2605    // ---- ~ toggle case at cursor ----
2606
2607    #[test]
2608    fn tilde_emits_toggle_case_at_cursor() {
2609        let (_, b) = fixture();
2610        let a = shared_actions();
2611        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('~'))) {
2612            Action::Invoke(inv) => assert_eq!(inv.command, a.toggle_case_at_cursor),
2613            other => panic!("expected Invoke(toggle_case_at_cursor), got {other:?}"),
2614        }
2615    }
2616
2617    // ---- Word-search and matching-bracket ----
2618
2619    #[test]
2620    fn star_emits_search_word_forward() {
2621        // VM.3d-2: a motion, so `d*` / `v*` compose.
2622        let (_, b) = fixture();
2623        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('*'))) {
2624            Action::Invoke(inv) => assert_eq!(inv.command, b.search_word_forward.0),
2625            other => panic!("expected Invoke(search_word_forward motion), got {other:?}"),
2626        }
2627    }
2628
2629    #[test]
2630    fn hash_emits_search_word_backward() {
2631        // VM.3d-2: a motion, so `d*` / `v*` compose.
2632        let (_, b) = fixture();
2633        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('#'))) {
2634            Action::Invoke(inv) => assert_eq!(inv.command, b.search_word_backward.0),
2635            other => panic!("expected Invoke(search_word_backward motion), got {other:?}"),
2636        }
2637    }
2638
2639    /// VM.3b: `%` resolves to the MOTION, not `action:match-bracket`.
2640    ///
2641    /// The change is the point of the test, not incidental to it. As an
2642    /// action `%` could not take an operator and could not be mirrored into
2643    /// Visual, so `d%` / `y%` / `v%` were all unbound — vim calls `%` a
2644    /// motion and composes all three.
2645    #[test]
2646    fn percent_emits_the_match_pair_motion() {
2647        let (_, b) = fixture();
2648        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('%'))) {
2649            Action::Invoke(inv) => assert_eq!(inv.command, b.match_pair.0),
2650            other => panic!("expected Invoke(match_pair), got {other:?}"),
2651        }
2652    }
2653
2654    // ---- Viewport motions: H, M, L, z*, Ctrl-F/B/Y/E ----
2655
2656    #[test]
2657    fn capital_h_emits_the_viewport_top_motion() {
2658        // VM.3f: a motion, so `d{key}` / `v{key}` compose.
2659        let (_, b) = fixture();
2660        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('H')));
2661        match action {
2662            Action::Invoke(inv) => assert_eq!(inv.command, b.viewport_top.0),
2663            other => panic!("expected Invoke(viewport_top motion), got {other:?}"),
2664        }
2665    }
2666
2667    #[test]
2668    fn capital_m_emits_the_viewport_middle_motion() {
2669        // VM.3f: a motion, so `d{key}` / `v{key}` compose.
2670        let (_, b) = fixture();
2671        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('M')));
2672        match action {
2673            Action::Invoke(inv) => assert_eq!(inv.command, b.viewport_middle.0),
2674            other => panic!("expected Invoke(viewport_middle motion), got {other:?}"),
2675        }
2676    }
2677
2678    #[test]
2679    fn capital_l_emits_the_viewport_bottom_motion() {
2680        // VM.3f: a motion, so `d{key}` / `v{key}` compose.
2681        let (_, b) = fixture();
2682        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('L')));
2683        match action {
2684            Action::Invoke(inv) => assert_eq!(inv.command, b.viewport_bottom.0),
2685            other => panic!("expected Invoke(viewport_bottom motion), got {other:?}"),
2686        }
2687    }
2688
2689    #[test]
2690    fn z_absorbs_partial_chord() {
2691        // Slice 8.i.4.a: `z` migrated to partial_chord.
2692        let (_, b) = fixture();
2693        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('z')));
2694        assert!(matches!(
2695            action,
2696            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('z')
2697        ));
2698    }
2699
2700    #[test]
2701    fn zz_emits_scroll_cursor_center() {
2702        let (_, b) = fixture();
2703        let a = shared_actions();
2704        let action = translate(
2705            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
2706            key(KeyCode::Char('z')),
2707        );
2708        match action {
2709            Action::Invoke(inv) => assert_eq!(inv.command, a.scroll_cursor_to_center),
2710            other => panic!("expected Invoke(scroll_cursor_to_center), got {other:?}"),
2711        }
2712    }
2713
2714    #[test]
2715    fn zt_emits_scroll_cursor_top() {
2716        let (_, b) = fixture();
2717        let a = shared_actions();
2718        let action = translate(
2719            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
2720            key(KeyCode::Char('t')),
2721        );
2722        match action {
2723            Action::Invoke(inv) => assert_eq!(inv.command, a.scroll_cursor_to_top),
2724            other => panic!("expected Invoke(scroll_cursor_to_top), got {other:?}"),
2725        }
2726    }
2727
2728    #[test]
2729    fn zb_emits_scroll_cursor_bottom() {
2730        let (_, b) = fixture();
2731        let a = shared_actions();
2732        let action = translate(
2733            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
2734            key(KeyCode::Char('b')),
2735        );
2736        match action {
2737            Action::Invoke(inv) => assert_eq!(inv.command, a.scroll_cursor_to_bottom),
2738            other => panic!("expected Invoke(scroll_cursor_to_bottom), got {other:?}"),
2739        }
2740    }
2741
2742    #[test]
2743    fn ctrl_f_emits_page_down() {
2744        let (_, b) = fixture();
2745        let a = shared_actions();
2746        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('f'))) {
2747            Action::Invoke(inv) => assert_eq!(inv.command, a.page_down),
2748            other => panic!("expected Invoke(page_down), got {other:?}"),
2749        }
2750    }
2751
2752    #[test]
2753    fn ctrl_b_emits_page_up() {
2754        let (_, b) = fixture();
2755        let a = shared_actions();
2756        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('b'))) {
2757            Action::Invoke(inv) => assert_eq!(inv.command, a.page_up),
2758            other => panic!("expected Invoke(page_up), got {other:?}"),
2759        }
2760    }
2761
2762    #[test]
2763    fn ctrl_e_emits_scroll_line_down() {
2764        let (_, b) = fixture();
2765        let a = shared_actions();
2766        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('e'))) {
2767            Action::Invoke(inv) => assert_eq!(inv.command, a.scroll_line_down),
2768            other => panic!("expected Invoke(scroll_line_down), got {other:?}"),
2769        }
2770    }
2771
2772    #[test]
2773    fn ctrl_y_emits_scroll_line_up() {
2774        let (_, b) = fixture();
2775        let a = shared_actions();
2776        match translate(ctx(ModalState::Normal, &b), ctrl(KeyCode::Char('y'))) {
2777            Action::Invoke(inv) => assert_eq!(inv.command, a.scroll_line_up),
2778            other => panic!("expected Invoke(scroll_line_up), got {other:?}"),
2779        }
2780    }
2781
2782    #[test]
2783    fn esc_after_z_pending_clears() {
2784        let (_, b) = fixture();
2785        assert!(matches!(
2786            translate(
2787                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('z')], &b),
2788                key(KeyCode::Esc)
2789            ),
2790            Action::None
2791        ));
2792    }
2793
2794    // ---- Replace mode ----
2795
2796    #[test]
2797    fn capital_r_enters_replace_mode() {
2798        let (_, b) = fixture();
2799        let a = shared_actions();
2800        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('R'))) {
2801            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_mode_replace),
2802            other => panic!("expected Invoke(enter_mode_replace), got {other:?}"),
2803        }
2804    }
2805
2806    #[test]
2807    fn char_in_replace_emits_overwrite() {
2808        let (_, b) = fixture();
2809        let a = shared_actions();
2810        match translate(ctx(ModalState::Replace, &b), key(KeyCode::Char('z'))) {
2811            Action::Invoke(inv) => {
2812                assert_eq!(inv.command, a.overwrite_char);
2813                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('z')));
2814            }
2815            other => panic!("expected Invoke(overwrite_char, Char('z')), got {other:?}"),
2816        }
2817    }
2818
2819    #[test]
2820    fn esc_in_replace_returns_to_normal() {
2821        let (_, b) = fixture();
2822        let a = shared_actions();
2823        match translate(ctx(ModalState::Replace, &b), key(KeyCode::Esc)) {
2824            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_mode_normal),
2825            other => panic!("expected Invoke(enter_mode_normal), got {other:?}"),
2826        }
2827    }
2828
2829    #[test]
2830    fn backspace_in_replace_emits_replace_undo_last() {
2831        let (_, b) = fixture();
2832        let a = shared_actions();
2833        match translate(ctx(ModalState::Replace, &b), key(KeyCode::Backspace)) {
2834            Action::Invoke(inv) => assert_eq!(inv.command, a.replace_undo_last),
2835            other => panic!("expected Invoke(replace_undo_last), got {other:?}"),
2836        }
2837    }
2838
2839    #[test]
2840    fn enter_in_replace_inserts_newline() {
2841        let (_, b) = fixture();
2842        let a = shared_actions();
2843        match translate(ctx(ModalState::Replace, &b), key(KeyCode::Enter)) {
2844            Action::Invoke(inv) => assert_eq!(inv.command, a.insert_newline),
2845            other => panic!("expected Invoke(insert_newline), got {other:?}"),
2846        }
2847    }
2848
2849    /// Slice 8.d wiring: an empty `KeymapHandle` (no Replace
2850    /// catalog registered) routes every Replace key event to
2851    /// `Action::None`. The `ctx_*` builders use `test_keymap()`
2852    /// (populated); this test pins that the dispatcher genuinely
2853    /// reads from the handle by overriding it with an empty one.
2854    #[test]
2855    fn replace_dispatch_reads_from_handle_not_baked_in() {
2856        let (_, b) = fixture();
2857        let empty = KeymapHandle::new();
2858        let mut c = ctx(ModalState::Replace, &b);
2859        c.keymap = &empty;
2860        match translate(c, key(KeyCode::Char('z'))) {
2861            Action::None => {}
2862            other => panic!("empty handle must yield None for Replace dispatch, got {other:?}"),
2863        }
2864    }
2865
2866    /// Slice 8.d also tightens the Replace mode's "modifier
2867    /// transparency" semantic at trie level: `<C-x>` is the only
2868    /// hard guard; `<M-x>` falls through to OverwriteChar('x') just
2869    /// like the legacy `translate_replace` did. Pinned end-to-end
2870    /// through the `translate` boundary so a future refactor can't
2871    /// regress it without tripping this test.
2872    #[test]
2873    fn alt_x_in_replace_overwrites_with_x() {
2874        let (_, b) = fixture();
2875        let a = shared_actions();
2876        let mut event = key(KeyCode::Char('x'));
2877        event.modifiers = KeyModifiers::ALT;
2878        match translate(ctx(ModalState::Replace, &b), event) {
2879            Action::Invoke(inv) => {
2880                assert_eq!(inv.command, a.overwrite_char);
2881                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('x')));
2882            }
2883            other => panic!("expected Invoke(overwrite_char, Char('x')), got {other:?}"),
2884        }
2885    }
2886
2887    // ---- Marks ----
2888
2889    #[test]
2890    fn m_in_normal_absorbs_partial_chord() {
2891        // Slice 8.i.4.a: `m` migrated to partial_chord.
2892        let (_, b) = fixture();
2893        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('m')));
2894        assert!(matches!(
2895            action,
2896            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('m')
2897        ));
2898    }
2899
2900    #[test]
2901    fn apostrophe_in_normal_absorbs_partial_chord() {
2902        // Slice 8.i.4.a: `'` migrated to partial_chord.
2903        let (_, b) = fixture();
2904        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('\'')));
2905        assert!(matches!(
2906            action,
2907            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('\'')
2908        ));
2909    }
2910
2911    #[test]
2912    fn backtick_in_normal_absorbs_partial_chord() {
2913        // Slice 8.i.4.a: `` ` `` migrated to partial_chord.
2914        let (_, b) = fixture();
2915        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('`')));
2916        assert!(matches!(
2917            action,
2918            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('`')
2919        ));
2920    }
2921
2922    #[test]
2923    fn ma_after_m_emits_set_mark() {
2924        let (_, b) = fixture();
2925        let a = shared_actions();
2926        let action = translate(
2927            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('m')], &b),
2928            key(KeyCode::Char('a')),
2929        );
2930        match action {
2931            Action::Invoke(inv) => {
2932                assert_eq!(inv.command, a.set_mark);
2933                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('a')));
2934            }
2935            other => panic!("expected Invoke(set_mark, Char('a')), got {other:?}"),
2936        }
2937    }
2938
2939    #[test]
2940    fn jump_mark_line_routes_correctly() {
2941        // VM.3e: the motion, so `d'a` / `v'a` compose.
2942        let (_, b) = fixture();
2943        let action = translate(
2944            ctx_partial(
2945                ModalState::Normal,
2946                &[crate::chord::KeyChord::char('\'')],
2947                &b,
2948            ),
2949            key(KeyCode::Char('z')),
2950        );
2951        match action {
2952            Action::Invoke(inv) => {
2953                assert_eq!(inv.command, b.mark_line.0);
2954                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('z')));
2955            }
2956            other => panic!("expected Invoke(mark_line, Char('z')), got {other:?}"),
2957        }
2958    }
2959
2960    #[test]
2961    fn jump_mark_exact_routes_correctly() {
2962        // VM.3e: the motion, so `` d`a `` / `` v`a `` compose.
2963        let (_, b) = fixture();
2964        let action = translate(
2965            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('`')], &b),
2966            key(KeyCode::Char('A')),
2967        );
2968        match action {
2969            Action::Invoke(inv) => {
2970                assert_eq!(inv.command, b.mark_exact.0);
2971                assert!(matches!(inv.args, lattice_grammar::args::Args::Char('A')));
2972            }
2973            other => panic!("expected Invoke(mark_exact, Char('A')), got {other:?}"),
2974        }
2975    }
2976
2977    #[test]
2978    fn esc_cancels_set_mark_pending() {
2979        let (_, b) = fixture();
2980        assert!(matches!(
2981            translate(
2982                ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('m')], &b),
2983                key(KeyCode::Esc)
2984            ),
2985            Action::None
2986        ));
2987    }
2988
2989    #[test]
2990    fn non_alpha_after_set_mark_passes_char_to_actionspec() {
2991        // Slice 8.i.3: dispatcher returns Invoke(set_mark) with
2992        // the captured char regardless of validity; the bound
2993        // ActionSpec returns Effect::None for non-alphanumeric
2994        // chars, and App::apply clears the pending state on
2995        // every Invoke.
2996        let (_, b) = fixture();
2997        let a = shared_actions();
2998        match translate(
2999            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('m')], &b),
3000            key(KeyCode::Char(' ')),
3001        ) {
3002            Action::Invoke(inv) => {
3003                assert_eq!(inv.command, a.set_mark);
3004                assert!(matches!(inv.args, lattice_grammar::args::Args::Char(' ')));
3005            }
3006            other => panic!("expected Invoke(set_mark, Char(' ')), got {other:?}"),
3007        }
3008    }
3009
3010    // ---- gv reselect ----
3011
3012    #[test]
3013    fn gv_after_g_emits_reselect_visual() {
3014        let (_, b) = fixture();
3015        let a = shared_actions();
3016        let action = translate(
3017            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
3018            key(KeyCode::Char('v')),
3019        );
3020        match action {
3021            Action::Invoke(inv) => assert_eq!(inv.command, a.reselect_last_visual),
3022            other => panic!("expected Invoke(reselect_last_visual), got {other:?}"),
3023        }
3024    }
3025
3026    // ---- Indent and case operators ----
3027
3028    #[test]
3029    fn gt_invokes_absorb_operator_indent_right() {
3030        // Slice 8.i.4.c: `>` -> Invoke(absorb_operator_indent_right).
3031        let (_, b) = fixture();
3032        let a = shared_actions();
3033        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('>')));
3034        match action {
3035            Action::Invoke(inv) => assert_eq!(inv.command, a.absorb_operator_indent_right),
3036            other => panic!("expected Invoke(absorb_operator_indent_right), got {other:?}"),
3037        }
3038    }
3039
3040    #[test]
3041    fn lt_invokes_absorb_operator_indent_left() {
3042        let (_, b) = fixture();
3043        let a = shared_actions();
3044        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('<')));
3045        match action {
3046            Action::Invoke(inv) => assert_eq!(inv.command, a.absorb_operator_indent_left),
3047            other => panic!("expected Invoke(absorb_operator_indent_left), got {other:?}"),
3048        }
3049    }
3050
3051    #[test]
3052    fn double_gt_resolves_to_indent_right_current_line() {
3053        let (_, b) = fixture();
3054        let action = translate(
3055            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('>')], &b),
3056            key(KeyCode::Char('>')),
3057        );
3058        match action {
3059            Action::Invoke(inv) => {
3060                assert_eq!(inv.command, b.indent_right.0);
3061                assert_eq!(inv.range, Some(lattice_grammar::Range::CurrentLine));
3062            }
3063            _ => panic!("expected Invoke"),
3064        }
3065    }
3066
3067    #[test]
3068    fn gu_after_g_invokes_absorb_operator_lower() {
3069        let (_, b) = fixture();
3070        let a = shared_actions();
3071        let action = translate(
3072            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
3073            key(KeyCode::Char('u')),
3074        );
3075        match action {
3076            Action::Invoke(inv) => assert_eq!(inv.command, a.absorb_operator_lower),
3077            other => panic!("expected Invoke(absorb_operator_lower), got {other:?}"),
3078        }
3079    }
3080
3081    #[test]
3082    fn capital_g_then_capital_u_invokes_absorb_operator_upper() {
3083        let (_, b) = fixture();
3084        let a = shared_actions();
3085        let action = translate(
3086            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
3087            key(KeyCode::Char('U')),
3088        );
3089        match action {
3090            Action::Invoke(inv) => assert_eq!(inv.command, a.absorb_operator_upper),
3091            other => panic!("expected Invoke(absorb_operator_upper), got {other:?}"),
3092        }
3093    }
3094
3095    #[test]
3096    fn g_tilde_after_g_invokes_absorb_operator_toggle_case() {
3097        let (_, b) = fixture();
3098        let a = shared_actions();
3099        let action = translate(
3100            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('g')], &b),
3101            key(KeyCode::Char('~')),
3102        );
3103        match action {
3104            Action::Invoke(inv) => assert_eq!(inv.command, a.absorb_operator_toggle_case),
3105            other => panic!("expected Invoke(absorb_operator_toggle_case), got {other:?}"),
3106        }
3107    }
3108
3109    #[test]
3110    fn guu_resolves_to_lower_current_line() {
3111        let (_, b) = fixture();
3112        // After `gu`, pending = AfterOperator(lower). Pressing `u` doubles.
3113        let action = translate(
3114            ctx_partial(
3115                ModalState::Normal,
3116                &[
3117                    crate::chord::KeyChord::char('g'),
3118                    crate::chord::KeyChord::char('u'),
3119                ],
3120                &b,
3121            ),
3122            key(KeyCode::Char('u')),
3123        );
3124        match action {
3125            Action::Invoke(inv) => {
3126                assert_eq!(inv.command, b.lower.0);
3127                assert_eq!(inv.range, Some(lattice_grammar::Range::CurrentLine));
3128            }
3129            _ => panic!("expected Invoke"),
3130        }
3131    }
3132
3133    #[test]
3134    fn g_capital_u_w_resolves_to_upper_word_forward() {
3135        let (_, b) = fixture();
3136        // After `gU`, pending = AfterOperator(upper). Pressing `w` is the motion.
3137        let action = translate(
3138            ctx_partial(
3139                ModalState::Normal,
3140                &[
3141                    crate::chord::KeyChord::char('g'),
3142                    crate::chord::KeyChord::char('U'),
3143                ],
3144                &b,
3145            ),
3146            key(KeyCode::Char('w')),
3147        );
3148        match action {
3149            Action::Invoke(inv) => {
3150                assert_eq!(inv.command, b.upper.0);
3151                match inv.target {
3152                    Some(Target::Motion(id, _)) => assert_eq!(id, b.word_forward),
3153                    other => panic!("expected motion target, got {other:?}"),
3154                }
3155            }
3156            _ => panic!("expected Invoke"),
3157        }
3158    }
3159
3160    // ---- Text object chord routing ----
3161
3162    #[test]
3163    fn i_in_operator_pending_absorbs_partial_chord() {
3164        // Slice 8.i.4.c: pressing `i` after `d` returns
3165        // `AbsorbPartialChord(i)`. The trie returns `Partial`
3166        // for `[d, i]` because `[d, i, w]` etc. are bound; the
3167        // next key resolves the full text-object path.
3168        let (_, b) = fixture();
3169        let action = translate(
3170            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
3171            key(KeyCode::Char('i')),
3172        );
3173        assert!(matches!(
3174            action,
3175            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('i')
3176        ));
3177    }
3178
3179    #[test]
3180    fn a_in_operator_pending_absorbs_partial_chord() {
3181        let (_, b) = fixture();
3182        let action = translate(
3183            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
3184            key(KeyCode::Char('a')),
3185        );
3186        assert!(matches!(
3187            action,
3188            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('a')
3189        ));
3190    }
3191
3192    #[test]
3193    fn diw_resolves_to_delete_inner_word() {
3194        let (_, b) = fixture();
3195        let action = translate(
3196            ctx_partial(
3197                ModalState::Normal,
3198                &[
3199                    crate::chord::KeyChord::char('d'),
3200                    crate::chord::KeyChord::char('i'),
3201                ],
3202                &b,
3203            ),
3204            key(KeyCode::Char('w')),
3205        );
3206        match action {
3207            Action::Invoke(inv) => {
3208                assert_eq!(inv.command, b.delete.0);
3209                match inv.target {
3210                    Some(Target::TextObject(id, _)) => assert_eq!(id, b.inner_word),
3211                    other => panic!("expected text-object target, got {other:?}"),
3212                }
3213            }
3214            _ => panic!("expected Invoke"),
3215        }
3216    }
3217
3218    #[test]
3219    fn da_quote_resolves_to_delete_around_double_quote() {
3220        let (_, b) = fixture();
3221        let action = translate(
3222            ctx_partial(
3223                ModalState::Normal,
3224                &[
3225                    crate::chord::KeyChord::char('d'),
3226                    crate::chord::KeyChord::char('a'),
3227                ],
3228                &b,
3229            ),
3230            key(KeyCode::Char('"')),
3231        );
3232        match action {
3233            Action::Invoke(inv) => match inv.target {
3234                Some(Target::TextObject(id, _)) => assert_eq!(id, b.around_quote_double),
3235                other => panic!("expected text-object target, got {other:?}"),
3236            },
3237            _ => panic!("expected Invoke"),
3238        }
3239    }
3240
3241    #[test]
3242    fn ci_paren_resolves_to_change_inner_paren() {
3243        let (_, b) = fixture();
3244        let action = translate(
3245            ctx_partial(
3246                ModalState::Normal,
3247                &[
3248                    crate::chord::KeyChord::char('c'),
3249                    crate::chord::KeyChord::char('i'),
3250                ],
3251                &b,
3252            ),
3253            key(KeyCode::Char('(')),
3254        );
3255        match action {
3256            Action::Invoke(inv) => {
3257                assert_eq!(inv.command, b.change.0);
3258                match inv.target {
3259                    Some(Target::TextObject(id, _)) => assert_eq!(id, b.inner_paren),
3260                    other => panic!("expected text-object target, got {other:?}"),
3261                }
3262            }
3263            _ => panic!("expected Invoke"),
3264        }
3265    }
3266
3267    #[test]
3268    #[allow(non_snake_case)]
3269    fn diW_resolves_to_delete_inner_big_word() {
3270        let (_, b) = fixture();
3271        let action = translate(
3272            ctx_partial(
3273                ModalState::Normal,
3274                &[
3275                    crate::chord::KeyChord::char('d'),
3276                    crate::chord::KeyChord::char('i'),
3277                ],
3278                &b,
3279            ),
3280            key(KeyCode::Char('W')),
3281        );
3282        match action {
3283            Action::Invoke(inv) => {
3284                assert_eq!(inv.command, b.delete.0);
3285                match inv.target {
3286                    Some(Target::TextObject(id, _)) => assert_eq!(id, b.inner_big_word),
3287                    other => panic!("expected text-object target, got {other:?}"),
3288                }
3289            }
3290            _ => panic!("expected Invoke"),
3291        }
3292    }
3293
3294    #[test]
3295    #[allow(non_snake_case)]
3296    fn daW_resolves_to_delete_around_big_word() {
3297        let (_, b) = fixture();
3298        let action = translate(
3299            ctx_partial(
3300                ModalState::Normal,
3301                &[
3302                    crate::chord::KeyChord::char('d'),
3303                    crate::chord::KeyChord::char('a'),
3304                ],
3305                &b,
3306            ),
3307            key(KeyCode::Char('W')),
3308        );
3309        match action {
3310            Action::Invoke(inv) => match inv.target {
3311                Some(Target::TextObject(id, _)) => assert_eq!(id, b.around_big_word),
3312                other => panic!("expected text-object target, got {other:?}"),
3313            },
3314            _ => panic!("expected Invoke"),
3315        }
3316    }
3317
3318    #[test]
3319    fn ci_angle_resolves_to_change_inner_angle() {
3320        let (_, b) = fixture();
3321        let action = translate(
3322            ctx_partial(
3323                ModalState::Normal,
3324                &[
3325                    crate::chord::KeyChord::char('c'),
3326                    crate::chord::KeyChord::char('i'),
3327                ],
3328                &b,
3329            ),
3330            key(KeyCode::Char('<')),
3331        );
3332        match action {
3333            Action::Invoke(inv) => match inv.target {
3334                Some(Target::TextObject(id, _)) => assert_eq!(id, b.inner_angle),
3335                other => panic!("expected text-object target, got {other:?}"),
3336            },
3337            _ => panic!("expected Invoke"),
3338        }
3339    }
3340
3341    #[test]
3342    fn da_angle_via_closer_resolves_to_delete_around_angle() {
3343        // Both `<` and `>` should resolve to the angle text object.
3344        let (_, b) = fixture();
3345        let action = translate(
3346            ctx_partial(
3347                ModalState::Normal,
3348                &[
3349                    crate::chord::KeyChord::char('d'),
3350                    crate::chord::KeyChord::char('a'),
3351                ],
3352                &b,
3353            ),
3354            key(KeyCode::Char('>')),
3355        );
3356        match action {
3357            Action::Invoke(inv) => match inv.target {
3358                Some(Target::TextObject(id, _)) => assert_eq!(id, b.around_angle),
3359                other => panic!("expected text-object target, got {other:?}"),
3360            },
3361            _ => panic!("expected Invoke"),
3362        }
3363    }
3364
3365    #[test]
3366    fn esc_after_text_object_partial_chord_clears() {
3367        // Slice 8.i.4.c: with `partial_chord = [d, i]`, pressing
3368        // <Esc> dispatches to an unbound `[d, i, Esc]` path.
3369        // Returns `SetPending(None)` from
3370        // `lookup_normal_with_prefix`, which `App::apply` turns
3371        // into a `partial_chord.clear()` (the
3372        // non-`AbsorbPartialChord(_)` clear-rule).
3373        let (_, b) = fixture();
3374        assert!(matches!(
3375            translate(
3376                ctx_partial(
3377                    ModalState::Normal,
3378                    &[
3379                        crate::chord::KeyChord::char('d'),
3380                        crate::chord::KeyChord::char('i'),
3381                    ],
3382                    &b,
3383                ),
3384                key(KeyCode::Esc),
3385            ),
3386            Action::None
3387        ));
3388    }
3389
3390    // ---- Dot-repeat ----
3391
3392    #[test]
3393    fn dot_in_normal_emits_repeat_last_change() {
3394        let (_, b) = fixture();
3395        let a = shared_actions();
3396        match translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('.'))) {
3397            Action::Invoke(inv) => assert_eq!(inv.command, a.repeat_last_change),
3398            other => panic!("expected Invoke(repeat_last_change), got {other:?}"),
3399        }
3400    }
3401
3402    // ---- Visual mode entry / exit ----
3403
3404    #[test]
3405    fn v_in_normal_enters_charwise_visual() {
3406        let (_, b) = fixture();
3407        let a = shared_actions();
3408        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('v')));
3409        match action {
3410            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_visual_charwise),
3411            other => panic!("expected Invoke(enter_visual_charwise), got {other:?}"),
3412        }
3413    }
3414
3415    #[test]
3416    fn capital_v_in_normal_enters_linewise_visual() {
3417        let (_, b) = fixture();
3418        let a = shared_actions();
3419        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('V')));
3420        match action {
3421            Action::Invoke(inv) => assert_eq!(inv.command, a.enter_visual_linewise),
3422            other => panic!("expected Invoke(enter_visual_linewise), got {other:?}"),
3423        }
3424    }
3425
3426    #[test]
3427    fn esc_in_visual_exits_to_normal() {
3428        let (_, b) = fixture();
3429        let a = shared_actions();
3430        let action = translate(
3431            ctx(ModalState::Visual(VisualKind::Charwise), &b),
3432            key(KeyCode::Esc),
3433        );
3434        match action {
3435            Action::Invoke(inv) => assert_eq!(inv.command, a.exit_visual),
3436            other => panic!("expected Invoke(exit_visual), got {other:?}"),
3437        }
3438    }
3439
3440    #[test]
3441    fn v_in_visual_toggles_off() {
3442        let (_, b) = fixture();
3443        let a = shared_actions();
3444        let action = translate(
3445            ctx(ModalState::Visual(VisualKind::Charwise), &b),
3446            key(KeyCode::Char('v')),
3447        );
3448        match action {
3449            Action::Invoke(inv) => assert_eq!(inv.command, a.exit_visual),
3450            other => panic!("expected Invoke(exit_visual), got {other:?}"),
3451        }
3452    }
3453
3454    #[test]
3455    fn motion_in_visual_returns_invocation() {
3456        let (_, b) = fixture();
3457        let action = translate(
3458            ctx(ModalState::Visual(VisualKind::Charwise), &b),
3459            key(KeyCode::Char('w')),
3460        );
3461        assert_eq!(invocation_command(&action), Some(b.word_forward.0));
3462    }
3463
3464    #[test]
3465    fn d_in_visual_invokes_delete_with_selection_range() {
3466        let (_, b) = fixture();
3467        let action = translate(
3468            ctx(ModalState::Visual(VisualKind::Charwise), &b),
3469            key(KeyCode::Char('d')),
3470        );
3471        match action {
3472            Action::Invoke(inv) => {
3473                assert_eq!(inv.command, b.delete.0);
3474                assert_eq!(inv.range, Some(lattice_grammar::Range::Selection));
3475            }
3476            other => panic!("expected Invoke(delete, Selection), got {other:?}"),
3477        }
3478    }
3479
3480    #[test]
3481    fn y_in_visual_invokes_yank_with_selection_range() {
3482        let (_, b) = fixture();
3483        let action = translate(
3484            ctx(ModalState::Visual(VisualKind::Charwise), &b),
3485            key(KeyCode::Char('y')),
3486        );
3487        match action {
3488            Action::Invoke(inv) => {
3489                assert_eq!(inv.command, b.yank.0);
3490                assert_eq!(inv.range, Some(lattice_grammar::Range::Selection));
3491            }
3492            other => panic!("expected Invoke(yank, Selection), got {other:?}"),
3493        }
3494    }
3495
3496    #[test]
3497    fn c_in_visual_invokes_change_with_selection_range() {
3498        let (_, b) = fixture();
3499        let action = translate(
3500            ctx(ModalState::Visual(VisualKind::Charwise), &b),
3501            key(KeyCode::Char('c')),
3502        );
3503        match action {
3504            Action::Invoke(inv) => {
3505                assert_eq!(inv.command, b.change.0);
3506                assert_eq!(inv.range, Some(lattice_grammar::Range::Selection));
3507            }
3508            other => panic!("expected Invoke(change, Selection), got {other:?}"),
3509        }
3510    }
3511
3512    #[test]
3513    fn gt_in_visual_invokes_indent_right_with_selection_range() {
3514        let (_, b) = fixture();
3515        for kind in [
3516            VisualKind::Charwise,
3517            VisualKind::Linewise,
3518            VisualKind::Blockwise,
3519        ] {
3520            let action = translate(ctx(ModalState::Visual(kind), &b), key(KeyCode::Char('>')));
3521            match action {
3522                Action::Invoke(inv) => {
3523                    assert_eq!(inv.command, b.indent_right.0, "kind = {kind:?}");
3524                    assert_eq!(inv.range, Some(lattice_grammar::Range::Selection));
3525                }
3526                other => panic!(
3527                    "kind = {kind:?}, expected Invoke(indent_right, Selection), got {other:?}"
3528                ),
3529            }
3530        }
3531    }
3532
3533    #[test]
3534    fn lt_in_visual_invokes_indent_left_with_selection_range() {
3535        let (_, b) = fixture();
3536        for kind in [
3537            VisualKind::Charwise,
3538            VisualKind::Linewise,
3539            VisualKind::Blockwise,
3540        ] {
3541            let action = translate(ctx(ModalState::Visual(kind), &b), key(KeyCode::Char('<')));
3542            match action {
3543                Action::Invoke(inv) => {
3544                    assert_eq!(inv.command, b.indent_left.0, "kind = {kind:?}");
3545                    assert_eq!(inv.range, Some(lattice_grammar::Range::Selection));
3546                }
3547                other => panic!(
3548                    "kind = {kind:?}, expected Invoke(indent_left, Selection), got {other:?}"
3549                ),
3550            }
3551        }
3552    }
3553
3554    // ---- Count prefix (1-9, 0 with count in progress) ----
3555
3556    #[test]
3557    fn digit_1_to_9_emits_push_digit_in_normal_mode() {
3558        let (_, b) = fixture();
3559        for digit in 1u8..=9 {
3560            let c = char::from_digit(digit as u32, 10).unwrap();
3561            let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char(c)));
3562            assert!(matches!(action, Action::PushDigit(d) if d == digit));
3563        }
3564    }
3565
3566    /// PBH.3 regression: a CTRL-bearing digit is a chord, not a count.
3567    ///
3568    /// The count-prefix check used to test `chord.key` alone, so
3569    /// `Char('6') + CTRL` matched `to_digit` and was consumed as a count
3570    /// before the trie was consulted — making every `<C-digit>` binding
3571    /// in Normal mode silently unreachable. `<C-6>` / `<C-7>` (the pane
3572    /// buffer history walk) were the first bindings to notice.
3573    ///
3574    /// Asserting "not PushDigit" rather than a specific action keeps
3575    /// this a test of the guard, not of what happens to be bound.
3576    #[test]
3577    fn ctrl_digit_is_a_chord_not_a_count() {
3578        let (_, b) = fixture();
3579        for digit in 0u8..=9 {
3580            let c = char::from_digit(digit as u32, 10).unwrap();
3581            let action = translate(
3582                ctx(ModalState::Normal, &b),
3583                crossterm::event::KeyEvent::new(
3584                    KeyCode::Char(c),
3585                    crossterm::event::KeyModifiers::CONTROL,
3586                ),
3587            );
3588            assert!(
3589                !matches!(action, Action::PushDigit(_)),
3590                "<C-{c}> must not be swallowed as a count digit, got {action:?}",
3591            );
3592        }
3593    }
3594
3595    /// The same guard must not break counts: an UNMODIFIED digit still
3596    /// accumulates. Paired with the test above so neither direction can
3597    /// regress alone.
3598    #[test]
3599    fn plain_digit_is_still_a_count() {
3600        let (_, b) = fixture();
3601        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('6')));
3602        assert!(matches!(action, Action::PushDigit(6)));
3603    }
3604
3605    #[test]
3606    fn zero_with_no_count_invokes_line_start() {
3607        let (_, b) = fixture();
3608        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('0')));
3609        assert_eq!(invocation_command(&action), Some(b.line_start.0));
3610    }
3611
3612    #[test]
3613    fn zero_with_count_in_progress_extends_count() {
3614        let (_, b) = fixture();
3615        // pending_count == 1 -> '0' becomes a digit, not line_start.
3616        let action = translate(
3617            ctx_with_count(ModalState::Normal, &b, 1),
3618            key(KeyCode::Char('0')),
3619        );
3620        assert!(matches!(action, Action::PushDigit(0)));
3621    }
3622
3623    #[test]
3624    fn digit_after_count_extends_count() {
3625        let (_, b) = fixture();
3626        let action = translate(
3627            ctx_with_count(ModalState::Normal, &b, 12),
3628            key(KeyCode::Char('3')),
3629        );
3630        // Translate just emits the digit; App accumulates 12 -> 123.
3631        assert!(matches!(action, Action::PushDigit(3)));
3632    }
3633
3634    #[test]
3635    fn motion_after_count_dispatches_motion() {
3636        let (_, b) = fixture();
3637        let action = translate(
3638            ctx_with_count(ModalState::Normal, &b, 3),
3639            key(KeyCode::Char('w')),
3640        );
3641        // Slice 8.g.iv: translate attaches the in-progress count
3642        // (`pending_count`) to the resolved invocation before
3643        // returning. App's dispatcher reads `inv.count` directly.
3644        match action {
3645            Action::Invoke(inv) => {
3646                assert_eq!(inv.command, b.word_forward.0);
3647                assert_eq!(inv.count, Some(lattice_grammar::command::Count(3)));
3648            }
3649            other => panic!("expected Invoke(word_forward, count=3), got {other:?}"),
3650        }
3651    }
3652
3653    /// Slice 8.g.iv / 8.i.4.c end-to-end: `2d3w` walks
3654    /// operator-pending resolution. `op_count=2 * motion_count=3
3655    /// = 6` should be attached at translate time, with
3656    /// `Range::None` and the correct `Target::Motion(word_forward)`.
3657    /// Slice 8.i.4.c migrated the operator-pending state from
3658    /// `Pending::AfterOperator(_)` to `App::partial_chord`; this
3659    /// test now passes the operator's chord prefix via
3660    /// `ctx_partial_with_op_count`.
3661    #[test]
3662    fn op_count_times_motion_count_attaches_at_translate() {
3663        let (_, b) = fixture();
3664        let action = translate(
3665            ctx_partial_with_op_count(
3666                ModalState::Normal,
3667                &[crate::chord::KeyChord::char('d')],
3668                &b,
3669                3,
3670                2,
3671            ),
3672            key(KeyCode::Char('w')),
3673        );
3674        match action {
3675            Action::Invoke(inv) => {
3676                assert_eq!(inv.command, b.delete.0);
3677                assert!(matches!(
3678                    inv.target,
3679                    Some(Target::Motion(m, _)) if m == b.word_forward
3680                ));
3681                assert_eq!(inv.count, Some(lattice_grammar::command::Count(6)));
3682            }
3683            other => panic!("expected Invoke(delete, word_forward, count=6), got {other:?}"),
3684        }
3685    }
3686
3687    // ---- Find-char / till-char (f, F, t, T) ----
3688
3689    #[test]
3690    fn f_absorbs_partial_chord() {
3691        // Slice 8.i.4.c: `f` is a Partial trie node (because
3692        // `[f, *]` is bound as a CharLiteral wildcard).
3693        // Pressing `f` returns `AbsorbPartialChord(f)` instead
3694        // of `SetPending(AfterFindChar { kind: Forward, ... })`.
3695        let (_, b) = fixture();
3696        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('f')));
3697        assert!(matches!(
3698            action,
3699            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('f')
3700        ));
3701    }
3702
3703    #[test]
3704    fn capital_f_absorbs_partial_chord() {
3705        let (_, b) = fixture();
3706        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('F')));
3707        assert!(matches!(
3708            action,
3709            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('F')
3710        ));
3711    }
3712
3713    #[test]
3714    fn t_absorbs_partial_chord() {
3715        let (_, b) = fixture();
3716        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('t')));
3717        assert!(matches!(
3718            action,
3719            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('t')
3720        ));
3721    }
3722
3723    #[test]
3724    fn capital_t_absorbs_partial_chord() {
3725        let (_, b) = fixture();
3726        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('T')));
3727        assert!(matches!(
3728            action,
3729            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('T')
3730        ));
3731    }
3732
3733    #[test]
3734    fn f_then_char_resolves_to_motion_with_args_char() {
3735        // Slice 8.i.4.c: with `partial_chord = [f]`, pressing
3736        // `z` resolves `[f, z]` -> Invoke(find_char_forward,
3737        // args=Char('z')).
3738        let (_, b) = fixture();
3739        let action = translate(
3740            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('f')], &b),
3741            key(KeyCode::Char('z')),
3742        );
3743        match action {
3744            Action::Invoke(inv) => {
3745                assert_eq!(inv.command, b.find_char_forward.0);
3746                assert_eq!(inv.args, lattice_grammar::Args::Char('z'));
3747            }
3748            other => panic!("expected Invoke(find_char_forward), got {other:?}"),
3749        }
3750    }
3751
3752    #[test]
3753    fn df_then_char_composes_delete_with_find_target() {
3754        // Slice 8.i.4.c: end-to-end `dfx` walks
3755        // partial_chord absorption rather than
3756        // SetPending(AfterOperator) -> SetPending(AfterFindChar)
3757        // chains.
3758        let (_, b) = fixture();
3759        // First press: `d` -> Invoke(absorb_operator_delete);
3760        // App's apply_app_effect pushes [d] to partial_chord
3761        // and latches op_count.
3762        let after_d = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('d')));
3763        match after_d {
3764            Action::Invoke(_) => {}
3765            other => panic!("expected Invoke(absorb_operator_delete), got {other:?}"),
3766        };
3767        // Second press: `f` in partial_chord = [d] -> AbsorbPartialChord(f).
3768        let after_df = translate(
3769            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
3770            key(KeyCode::Char('f')),
3771        );
3772        assert!(matches!(
3773            after_df,
3774            Action::AbsorbPartialChord(c) if c == crate::chord::KeyChord::char('f')
3775        ));
3776        // Third press: `x` in partial_chord = [d, f] -> Invoke
3777        // delete with find_char_forward target.
3778        let after_dfx = translate(
3779            ctx_partial(
3780                ModalState::Normal,
3781                &[
3782                    crate::chord::KeyChord::char('d'),
3783                    crate::chord::KeyChord::char('f'),
3784                ],
3785                &b,
3786            ),
3787            key(KeyCode::Char('x')),
3788        );
3789        match after_dfx {
3790            Action::Invoke(inv) => {
3791                assert_eq!(inv.command, b.delete.0);
3792                match inv.target {
3793                    Some(Target::Motion(id, args)) => {
3794                        assert_eq!(id, b.find_char_forward);
3795                        assert_eq!(args, lattice_grammar::Args::Char('x'));
3796                    }
3797                    other => panic!("expected motion target, got {other:?}"),
3798                }
3799            }
3800            other => panic!("expected Invoke(delete, find_target), got {other:?}"),
3801        }
3802    }
3803
3804    #[test]
3805    fn esc_after_find_partial_clears_partial_chord() {
3806        // Slice 8.i.4: with partial_chord = [f] and Esc as the
3807        // second key, `[f, Esc]` is unbound; lookup returns
3808        // `Action::None`. App::apply clears partial_chord via
3809        // the non-`AbsorbPartialChord(_)` rule.
3810        let (_, b) = fixture();
3811        let action = translate(
3812            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('f')], &b),
3813            key(KeyCode::Esc),
3814        );
3815        assert!(matches!(action, Action::None));
3816    }
3817
3818    // ---- New motions: b, e, ^ ----
3819
3820    #[test]
3821    fn b_invokes_word_backward() {
3822        let (_, b) = fixture();
3823        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('b')));
3824        assert_eq!(invocation_command(&action), Some(b.word_backward.0));
3825    }
3826
3827    #[test]
3828    fn e_invokes_word_end() {
3829        let (_, b) = fixture();
3830        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('e')));
3831        assert_eq!(invocation_command(&action), Some(b.word_end.0));
3832    }
3833
3834    #[test]
3835    fn caret_invokes_first_non_blank() {
3836        let (_, b) = fixture();
3837        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('^')));
3838        assert_eq!(invocation_command(&action), Some(b.first_non_blank.0));
3839    }
3840
3841    #[test]
3842    fn db_resolves_to_delete_word_backward() {
3843        let (_, b) = fixture();
3844        let action = translate(
3845            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
3846            key(KeyCode::Char('b')),
3847        );
3848        match action {
3849            Action::Invoke(inv) => {
3850                assert_eq!(inv.command, b.delete.0);
3851                match inv.target {
3852                    Some(Target::Motion(id, _)) => assert_eq!(id, b.word_backward),
3853                    other => panic!("expected motion target, got {other:?}"),
3854                }
3855            }
3856            _ => panic!("expected Invoke"),
3857        }
3858    }
3859
3860    #[test]
3861    fn de_resolves_to_delete_word_end() {
3862        let (_, b) = fixture();
3863        let action = translate(
3864            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
3865            key(KeyCode::Char('e')),
3866        );
3867        match action {
3868            Action::Invoke(inv) => match inv.target {
3869                Some(Target::Motion(id, _)) => assert_eq!(id, b.word_end),
3870                other => panic!("expected motion target, got {other:?}"),
3871            },
3872            _ => panic!("expected Invoke"),
3873        }
3874    }
3875
3876    // ---- change operator: c, cw, cc ----
3877
3878    #[test]
3879    fn c_invokes_absorb_operator_change() {
3880        let (_, b) = fixture();
3881        let a = shared_actions();
3882        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('c')));
3883        match action {
3884            Action::Invoke(inv) => assert_eq!(inv.command, a.absorb_operator_change),
3885            other => panic!("expected Invoke(absorb_operator_change), got {other:?}"),
3886        }
3887    }
3888
3889    #[test]
3890    fn cw_resolves_to_change_with_word_forward_target() {
3891        let (_, b) = fixture();
3892        let action = translate(
3893            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('c')], &b),
3894            key(KeyCode::Char('w')),
3895        );
3896        match action {
3897            Action::Invoke(inv) => {
3898                assert_eq!(inv.command, b.change.0);
3899                match inv.target {
3900                    Some(Target::Motion(id, _)) => assert_eq!(id, b.word_forward),
3901                    other => panic!("expected motion target, got {other:?}"),
3902                }
3903            }
3904            _ => panic!("expected Invoke"),
3905        }
3906    }
3907
3908    #[test]
3909    fn cc_resolves_to_change_with_current_line_range() {
3910        let (_, b) = fixture();
3911        let action = translate(
3912            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('c')], &b),
3913            key(KeyCode::Char('c')),
3914        );
3915        match action {
3916            Action::Invoke(inv) => {
3917                assert_eq!(inv.command, b.change.0);
3918                assert_eq!(inv.range, Some(lattice_grammar::Range::CurrentLine));
3919            }
3920            _ => panic!("expected Invoke"),
3921        }
3922    }
3923
3924    // ---- yank operator + paste ----
3925
3926    #[test]
3927    fn y_invokes_absorb_operator_yank() {
3928        let (_, b) = fixture();
3929        let a = shared_actions();
3930        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('y')));
3931        match action {
3932            Action::Invoke(inv) => assert_eq!(inv.command, a.absorb_operator_yank),
3933            other => panic!("expected Invoke(absorb_operator_yank), got {other:?}"),
3934        }
3935    }
3936
3937    #[test]
3938    fn yw_resolves_to_yank_word_forward() {
3939        let (_, b) = fixture();
3940        let action = translate(
3941            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('y')], &b),
3942            key(KeyCode::Char('w')),
3943        );
3944        match action {
3945            Action::Invoke(inv) => {
3946                assert_eq!(inv.command, b.yank.0);
3947                match inv.target {
3948                    Some(Target::Motion(id, _)) => assert_eq!(id, b.word_forward),
3949                    other => panic!("expected motion target, got {other:?}"),
3950                }
3951            }
3952            _ => panic!("expected Invoke"),
3953        }
3954    }
3955
3956    #[test]
3957    fn yy_resolves_to_yank_current_line() {
3958        let (_, b) = fixture();
3959        let action = translate(
3960            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('y')], &b),
3961            key(KeyCode::Char('y')),
3962        );
3963        match action {
3964            Action::Invoke(inv) => {
3965                assert_eq!(inv.command, b.yank.0);
3966                assert_eq!(inv.range, Some(lattice_grammar::Range::CurrentLine));
3967            }
3968            _ => panic!("expected Invoke"),
3969        }
3970    }
3971
3972    #[test]
3973    fn capital_y_aliases_to_yank_current_line() {
3974        let (_, b) = fixture();
3975        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('Y')));
3976        match action {
3977            Action::Invoke(inv) => {
3978                assert_eq!(inv.command, b.yank.0);
3979                assert_eq!(inv.range, Some(lattice_grammar::Range::CurrentLine));
3980            }
3981            _ => panic!("expected Invoke"),
3982        }
3983    }
3984
3985    #[test]
3986    fn p_lowercase_is_paste_after() {
3987        let (_, b) = fixture();
3988        let a = shared_actions();
3989        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('p')));
3990        match action {
3991            Action::Invoke(inv) => assert_eq!(inv.command, a.paste_after),
3992            other => panic!("expected Invoke(paste_after), got {other:?}"),
3993        }
3994    }
3995
3996    #[test]
3997    fn p_uppercase_is_paste_before() {
3998        let (_, b) = fixture();
3999        let a = shared_actions();
4000        let action = translate(ctx(ModalState::Normal, &b), key(KeyCode::Char('P')));
4001        match action {
4002            Action::Invoke(inv) => assert_eq!(inv.command, a.paste_before),
4003            other => panic!("expected Invoke(paste_before), got {other:?}"),
4004        }
4005    }
4006
4007    #[test]
4008    fn dd_is_not_treated_as_change_current_line() {
4009        // Regression check: the `cc` arm should only fire for op == change.
4010        let (_, b) = fixture();
4011        let action = translate(
4012            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
4013            key(KeyCode::Char('c')),
4014        );
4015        // Delete operator + 'c' key: no specific motion, fallback clears pending.
4016        assert!(matches!(action, Action::None));
4017    }
4018
4019    #[test]
4020    fn d_caret_resolves_to_delete_first_non_blank() {
4021        let (_, b) = fixture();
4022        let action = translate(
4023            ctx_partial(ModalState::Normal, &[crate::chord::KeyChord::char('d')], &b),
4024            key(KeyCode::Char('^')),
4025        );
4026        match action {
4027            Action::Invoke(inv) => match inv.target {
4028                Some(Target::Motion(id, _)) => assert_eq!(id, b.first_non_blank),
4029                other => panic!("expected motion target, got {other:?}"),
4030            },
4031            _ => panic!("expected Invoke"),
4032        }
4033    }
4034
4035    // ---- Chord-capture (DESIGN.md §B.1, ArgKind::Chord) ----
4036
4037    #[test]
4038    fn chord_capture_translates_ctrl_letter_to_chord_token() {
4039        let (_, b) = fixture();
4040        let action = translate(ctx_chord_capture(&b), ctrl(KeyCode::Char('c')));
4041        match action {
4042            Action::CommandLineAppendChord(s) => assert_eq!(s, "<C-c>"),
4043            other => panic!("expected CommandLineAppendChord, got {other:?}"),
4044        }
4045    }
4046
4047    #[test]
4048    fn chord_capture_translates_plain_letter_unwrapped() {
4049        let (_, b) = fixture();
4050        let action = translate(ctx_chord_capture(&b), key(KeyCode::Char('g')));
4051        match action {
4052            Action::CommandLineAppendChord(s) => assert_eq!(s, "g"),
4053            other => panic!("expected CommandLineAppendChord, got {other:?}"),
4054        }
4055    }
4056
4057    /// DK.2: capture reserves NOTHING. `<CR>` / `<Esc>` / `<BS>` used to mean
4058    /// submit / cancel / delete-token, which is exactly why they were the
4059    /// three keys `:describe-key` could never answer for. The keymap trie ends
4060    /// the sequence now, so each of them appends its own token like any other
4061    /// key.
4062    #[test]
4063    fn chord_capture_reserves_nothing() {
4064        let (_, b) = fixture();
4065        for (code, expected) in [
4066            (KeyCode::Enter, "<CR>"),
4067            (KeyCode::Esc, "<Esc>"),
4068            (KeyCode::Backspace, "<BS>"),
4069        ] {
4070            match translate(ctx_chord_capture(&b), key(code)) {
4071                Action::CommandLineAppendChord(s) => assert_eq!(
4072                    s, expected,
4073                    "{code:?} must be captured as a chord, not obeyed as a control"
4074                ),
4075                other => panic!("{code:?} was reserved: {other:?}"),
4076            }
4077        }
4078    }
4079
4080    #[test]
4081    fn chord_capture_translates_special_keys_with_angles() {
4082        let (_, b) = fixture();
4083        // Up arrow -- the canonical chord is `<Up>`, not Esc.
4084        let action = translate(ctx_chord_capture(&b), key(KeyCode::Up));
4085        match action {
4086            Action::CommandLineAppendChord(s) => assert_eq!(s, "<Up>"),
4087            other => panic!("expected CommandLineAppendChord, got {other:?}"),
4088        }
4089    }
4090
4091    /// Terminal-mode T2.b: `a` / `I` / `A` in Normal-in-terminal
4092    /// all funnel into `EnterTerminalInsert`. The four vim insert-
4093    /// entry chords collapse to one action because the terminal
4094    /// grid has no before-cursor / after-cursor or BOL / EOL
4095    /// distinction — the shell owns the cursor.
4096    #[test]
4097    fn terminal_normal_a_upper_i_upper_a_also_enter_terminal_insert() {
4098        let (_, b) = fixture();
4099        for c in ['a', 'I', 'A'] {
4100            let ctx = TranslateContext {
4101                modal: ModalState::Normal,
4102                builtins: &b,
4103                pending_count: 0,
4104                op_count: 0,
4105                recording_macro: false,
4106                active_buffer: BufferKind::Terminal,
4107                completion_open: false,
4108                chord_capture: false,
4109                picker_open: false,
4110                insert_completion_open: false,
4111                snippet_active: false,
4112                terminal_insert_active: false,
4113                terminal_esc_exits: false,
4114                terminal_app_cursor_keys: false,
4115                terminal_insert_exit_pending: false,
4116                terminal_visual_active: false,
4117                keymap: test_keymap(),
4118                partial_chord: &[],
4119                active_minor_modes: &[],
4120            };
4121            let action = translate(ctx, key(KeyCode::Char(c)));
4122            assert!(
4123                matches!(action, Action::EnterTerminalInsert),
4124                "expected EnterTerminalInsert for `{c}`, got {action:?}",
4125            );
4126        }
4127    }
4128
4129    /// Terminal-mode T2.a: when active_buffer is Terminal and
4130    /// `terminal-insert-mode` is OFF, pressing `i` enters
4131    /// Terminal-Insert (analogous to vim's `i` entering Insert).
4132    #[test]
4133    fn terminal_normal_i_enters_terminal_insert() {
4134        let (_, b) = fixture();
4135        let ctx = TranslateContext {
4136            modal: ModalState::Normal,
4137            builtins: &b,
4138            pending_count: 0,
4139            op_count: 0,
4140            recording_macro: false,
4141            active_buffer: BufferKind::Terminal,
4142            completion_open: false,
4143            chord_capture: false,
4144            picker_open: false,
4145            insert_completion_open: false,
4146            snippet_active: false,
4147            terminal_insert_active: false,
4148            terminal_esc_exits: false,
4149            terminal_app_cursor_keys: false,
4150            terminal_insert_exit_pending: false,
4151            terminal_visual_active: false,
4152            keymap: test_keymap(),
4153            partial_chord: &[],
4154            active_minor_modes: &[],
4155        };
4156        assert!(matches!(
4157            translate(ctx, key(KeyCode::Char('i'))),
4158            Action::EnterTerminalInsert,
4159        ));
4160    }
4161
4162    /// Terminal-mode T2.a: with `terminal-insert-mode` active,
4163    /// printable chars + control chars encode to ANSI bytes
4164    /// wrapped in `Action::TerminalInput`, bypassing the modal
4165    /// dispatchers entirely. This is what makes `ls<CR>` route
4166    /// to the shell instead of moving the cursor.
4167    #[test]
4168    fn terminal_insert_encodes_chars_to_terminal_input() {
4169        let (_, b) = fixture();
4170        let mk_ctx = || TranslateContext {
4171            modal: ModalState::Normal,
4172            builtins: &b,
4173            pending_count: 0,
4174            op_count: 0,
4175            recording_macro: false,
4176            active_buffer: BufferKind::Terminal,
4177            completion_open: false,
4178            chord_capture: false,
4179            picker_open: false,
4180            insert_completion_open: false,
4181            snippet_active: false,
4182            terminal_insert_active: true,
4183            terminal_esc_exits: false,
4184            terminal_app_cursor_keys: false,
4185            terminal_insert_exit_pending: false,
4186            terminal_visual_active: false,
4187            keymap: test_keymap(),
4188            partial_chord: &[],
4189            active_minor_modes: &[],
4190        };
4191        match translate(mk_ctx(), key(KeyCode::Char('l'))) {
4192            Action::TerminalInput(bytes) => assert_eq!(bytes, b"l".to_vec()),
4193            other => panic!("expected TerminalInput, got {other:?}"),
4194        }
4195        match translate(mk_ctx(), key(KeyCode::Enter)) {
4196            Action::TerminalInput(bytes) => assert_eq!(bytes, vec![b'\r']),
4197            other => panic!("expected TerminalInput, got {other:?}"),
4198        }
4199    }
4200
4201    /// Terminal-mode T2.a — critical override: `<C-c>` in
4202    /// Terminal-Insert sends SIGINT to the child (`\x03`); it
4203    /// must NOT trigger the universal `Action::Quit` hatch. A
4204    /// vim user reflex of `<C-c>` to interrupt a long shell
4205    /// command would otherwise close the editor.
4206    #[test]
4207    fn terminal_insert_ctrl_c_sends_sigint_byte_not_quit() {
4208        let (_, b) = fixture();
4209        let ctx = TranslateContext {
4210            modal: ModalState::Normal,
4211            builtins: &b,
4212            pending_count: 0,
4213            op_count: 0,
4214            recording_macro: false,
4215            active_buffer: BufferKind::Terminal,
4216            completion_open: false,
4217            chord_capture: false,
4218            picker_open: false,
4219            insert_completion_open: false,
4220            snippet_active: false,
4221            terminal_insert_active: true,
4222            terminal_esc_exits: false,
4223            terminal_app_cursor_keys: false,
4224            terminal_insert_exit_pending: false,
4225            terminal_visual_active: false,
4226            keymap: test_keymap(),
4227            partial_chord: &[],
4228            active_minor_modes: &[],
4229        };
4230        match translate(ctx, ctrl(KeyCode::Char('c'))) {
4231            Action::TerminalInput(bytes) => assert_eq!(bytes, vec![0x03]),
4232            other => panic!("expected TerminalInput([0x03]), got {other:?}"),
4233        }
4234    }
4235
4236    /// Terminal-mode T2.c — `<C-\>` in Terminal-Insert ARMS
4237    /// the two-key exit chord; the next key resolves it.
4238    /// Single `<C-\>` no longer exits on its own (vim parity).
4239    #[test]
4240    fn terminal_insert_ctrl_backslash_arms_exit_chord() {
4241        let (_, b) = fixture();
4242        let ctx = TranslateContext {
4243            modal: ModalState::Normal,
4244            builtins: &b,
4245            pending_count: 0,
4246            op_count: 0,
4247            recording_macro: false,
4248            active_buffer: BufferKind::Terminal,
4249            completion_open: false,
4250            chord_capture: false,
4251            picker_open: false,
4252            insert_completion_open: false,
4253            snippet_active: false,
4254            terminal_insert_active: true,
4255            terminal_esc_exits: false,
4256            terminal_app_cursor_keys: false,
4257            terminal_insert_exit_pending: false,
4258            terminal_visual_active: false,
4259            keymap: test_keymap(),
4260            partial_chord: &[],
4261            active_minor_modes: &[],
4262        };
4263        assert!(matches!(
4264            translate(ctx, ctrl(KeyCode::Char('\\'))),
4265            Action::TerminalArmExitChord,
4266        ));
4267    }
4268
4269    /// Terminal-mode T2.c — once `<C-\>` has armed the chord,
4270    /// pressing `<C-n>` confirms the exit.
4271    #[test]
4272    fn terminal_insert_armed_then_ctrl_n_exits() {
4273        let (_, b) = fixture();
4274        let ctx = TranslateContext {
4275            modal: ModalState::Normal,
4276            builtins: &b,
4277            pending_count: 0,
4278            op_count: 0,
4279            recording_macro: false,
4280            active_buffer: BufferKind::Terminal,
4281            completion_open: false,
4282            chord_capture: false,
4283            picker_open: false,
4284            insert_completion_open: false,
4285            snippet_active: false,
4286            terminal_insert_active: true,
4287            terminal_esc_exits: false,
4288            terminal_app_cursor_keys: false,
4289            terminal_insert_exit_pending: true,
4290            terminal_visual_active: false,
4291            keymap: test_keymap(),
4292            partial_chord: &[],
4293            active_minor_modes: &[],
4294        };
4295        assert!(matches!(
4296            translate(ctx, ctrl(KeyCode::Char('n'))),
4297            Action::ExitTerminalInsert,
4298        ));
4299    }
4300
4301    /// Terminal-mode T2.c — armed then ANY other key sends
4302    /// `\x1c` (lost `<C-\>`) plus the other key's PTY bytes.
4303    #[test]
4304    fn terminal_insert_armed_then_other_key_emits_lost_prefix_plus_byte() {
4305        let (_, b) = fixture();
4306        let ctx = TranslateContext {
4307            modal: ModalState::Normal,
4308            builtins: &b,
4309            pending_count: 0,
4310            op_count: 0,
4311            recording_macro: false,
4312            active_buffer: BufferKind::Terminal,
4313            completion_open: false,
4314            chord_capture: false,
4315            picker_open: false,
4316            insert_completion_open: false,
4317            snippet_active: false,
4318            terminal_insert_active: true,
4319            terminal_esc_exits: false,
4320            terminal_app_cursor_keys: false,
4321            terminal_insert_exit_pending: true,
4322            terminal_visual_active: false,
4323            keymap: test_keymap(),
4324            partial_chord: &[],
4325            active_minor_modes: &[],
4326        };
4327        match translate(ctx, key(KeyCode::Char('a'))) {
4328            Action::TerminalInput(bytes) => assert_eq!(bytes, vec![0x1c, b'a']),
4329            other => panic!("expected TerminalInput([0x1c, 'a']), got {other:?}"),
4330        }
4331    }
4332
4333    /// Terminal-mode T2.c — DECCKM flips arrow keys to SS3.
4334    #[test]
4335    fn terminal_insert_arrow_uses_ss3_when_decckm_set() {
4336        let (_, b) = fixture();
4337        let ctx = TranslateContext {
4338            modal: ModalState::Normal,
4339            builtins: &b,
4340            pending_count: 0,
4341            op_count: 0,
4342            recording_macro: false,
4343            active_buffer: BufferKind::Terminal,
4344            completion_open: false,
4345            chord_capture: false,
4346            picker_open: false,
4347            insert_completion_open: false,
4348            snippet_active: false,
4349            terminal_insert_active: true,
4350            terminal_esc_exits: false,
4351            terminal_app_cursor_keys: true,
4352            terminal_insert_exit_pending: false,
4353            terminal_visual_active: false,
4354            keymap: test_keymap(),
4355            partial_chord: &[],
4356            active_minor_modes: &[],
4357        };
4358        match translate(ctx, key(KeyCode::Up)) {
4359            Action::TerminalInput(bytes) => assert_eq!(bytes, b"\x1bOA".to_vec()),
4360            other => panic!("expected SS3 ESC O A, got {other:?}"),
4361        }
4362    }
4363
4364    /// Terminal-mode T2.b.0 — `<Esc>` exits Terminal-Insert when
4365    /// `terminal.esc-exits` is on (the default). Matches the
4366    /// table-stakes behaviour users expect from `:terminal` →
4367    /// `i` → type → `<Esc>` → `:q`.
4368    #[test]
4369    fn terminal_insert_esc_exits_when_option_set() {
4370        let (_, b) = fixture();
4371        let ctx = TranslateContext {
4372            modal: ModalState::Normal,
4373            builtins: &b,
4374            pending_count: 0,
4375            op_count: 0,
4376            recording_macro: false,
4377            active_buffer: BufferKind::Terminal,
4378            completion_open: false,
4379            chord_capture: false,
4380            picker_open: false,
4381            insert_completion_open: false,
4382            snippet_active: false,
4383            terminal_insert_active: true,
4384            terminal_esc_exits: true,
4385            terminal_app_cursor_keys: false,
4386            terminal_insert_exit_pending: false,
4387            terminal_visual_active: false,
4388            keymap: test_keymap(),
4389            partial_chord: &[],
4390            active_minor_modes: &[],
4391        };
4392        assert!(matches!(
4393            translate(ctx, key(KeyCode::Esc)),
4394            Action::ExitTerminalInsert,
4395        ));
4396    }
4397
4398    /// Terminal-mode T2.b.0 — `<Esc>` encodes to `\x1b` and goes
4399    /// to the PTY when `terminal.esc-exits` is off. Nested vim /
4400    /// htop / less inside the terminal keep their own Esc
4401    /// semantics; user exits via `<C-\>` (T2.a) or `<C-\><C-n>`
4402    /// (T2.c).
4403    #[test]
4404    fn terminal_insert_esc_encodes_when_option_unset() {
4405        let (_, b) = fixture();
4406        let ctx = TranslateContext {
4407            modal: ModalState::Normal,
4408            builtins: &b,
4409            pending_count: 0,
4410            op_count: 0,
4411            recording_macro: false,
4412            active_buffer: BufferKind::Terminal,
4413            completion_open: false,
4414            chord_capture: false,
4415            picker_open: false,
4416            insert_completion_open: false,
4417            snippet_active: false,
4418            terminal_insert_active: true,
4419            terminal_esc_exits: false,
4420            terminal_app_cursor_keys: false,
4421            terminal_insert_exit_pending: false,
4422            terminal_visual_active: false,
4423            keymap: test_keymap(),
4424            partial_chord: &[],
4425            active_minor_modes: &[],
4426        };
4427        match translate(ctx, key(KeyCode::Esc)) {
4428            Action::TerminalInput(bytes) => assert_eq!(bytes, vec![0x1b]),
4429            other => panic!("expected TerminalInput([0x1b]), got {other:?}"),
4430        }
4431    }
4432}