Skip to main content

hjkl_vim/
pending.rs

1/// Pending-state machine for second-key chords. The umbrella stores
2/// `Option<PendingState>`; when `Some`, it routes keys through `step`
3/// instead of the keymap trie.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5#[non_exhaustive]
6pub enum PendingState {
7    Replace {
8        count: usize,
9    },
10    /// `f<x>` / `F<x>` / `t<x>` / `T<x>` — find single char on current line.
11    /// `forward` = direction (true for f/t, false for F/T).
12    /// `till` = stop one char before target (true for t/T, false for f/F).
13    Find {
14        count: usize,
15        forward: bool,
16        till: bool,
17    },
18    /// `g<x>` — bare g-prefix chord in Normal / Visual mode. The app sets this
19    /// after intercepting `g`; `step` routes the next `Key::Char(ch)` to
20    /// `EngineCmd::AfterGChord { ch, count }`. `Key::Esc` cancels; any
21    /// non-char key also cancels (mirrors the `Find` arm).
22    AfterG {
23        count: usize,
24    },
25    /// `z<x>` — bare z-prefix chord in Normal / Visual mode. The app sets this
26    /// after intercepting `z`; `step` routes the next `Key::Char(ch)` to
27    /// `EngineCmd::AfterZChord { ch, count }`. `Key::Esc` cancels; any
28    /// non-char key also cancels (mirrors the `AfterG` arm).
29    AfterZ {
30        count: usize,
31    },
32    /// `d<x>` / `y<x>` / `c<x>` / `><x>` / `<<x>` — bare op-pending entered
33    /// from Normal mode after the operator key. `count1` is the count pressed
34    /// before the operator; `inner_count` accumulates digits pressed after the
35    /// operator (e.g. `d3w` → count1=1, inner_count=3, total=3). The reducer
36    /// is authoritative for both counts; `total = count1.max(1) *
37    /// inner_count.max(1)` is passed to the engine on completion.
38    ///
39    /// Vim quirk: a bare `0` when `inner_count == 0` is the line-start motion
40    /// (`LineStart`), not a digit. Any other digit, or `0` when `inner_count >
41    /// 0`, accumulates.
42    AfterOp {
43        op: crate::operator::OperatorKind,
44        count1: usize,
45        inner_count: usize,
46    },
47    /// `df<x>` / `dF<x>` / `dt<x>` / `dT<x>` and same for y/c/>/<. Reached
48    /// from `AfterOp` when the next key after the operator is `f`/`F`/`t`/`T`.
49    /// `total_count = count1.max(1) * inner_count.max(1)` already folded at
50    /// transition time; neither component is independently meaningful after
51    /// this point.
52    ///
53    /// The next char is the find target. `Key::Esc` or any non-char cancels
54    /// (vim's `f<Esc>` cancel semantics apply here too).
55    ///
56    /// `cf<x>` stays as Change + Find — the cw→ce quirk in `apply_op_with_motion`
57    /// only rewrites `Motion::WordFwd`/`BigWordFwd`, not `Motion::Find`.
58    OpFind {
59        op: crate::operator::OperatorKind,
60        total_count: usize,
61        forward: bool,
62        till: bool,
63    },
64    /// `di<x>` / `da<x>` etc. — reached from `AfterOp` when next key after
65    /// operator is `i` or `a`. `total_count = count1 * inner_count` already
66    /// folded; engine ignores it for text-object motions but it's passed
67    /// through for future-proofing / consistency with `OpFind` shape.
68    OpTextObj {
69        op: crate::operator::OperatorKind,
70        total_count: usize,
71        inner: bool,
72    },
73    /// `dgg` / `dge` / `dgE` / `dgj` / `dgk` etc. — reached from `AfterOp`
74    /// when next key after operator is `g`. For case-ops (gu/gU/g~) the
75    /// doubled form (gUgU = gUU linewise) is dispatched here too — engine
76    /// detects via op-matching second char.
77    OpG {
78        op: crate::operator::OperatorKind,
79        total_count: usize,
80    },
81    /// `d'<x>` / ``d`<x>`` — reached from `AfterOp` when the next key after
82    /// the operator is `'` (`linewise`) or `` ` ``. The next char names the
83    /// mark; vim ignores a count on mark motions, so none is carried.
84    OpMark {
85        op: crate::operator::OperatorKind,
86        linewise: bool,
87    },
88    /// `"<reg>` — register-prefix chord in Normal mode. The next char names
89    /// a register that the next y/d/c/p operation will use. Engine validates
90    /// the char; invalid chars silently no-op.
91    SelectRegister,
92    /// `m<x>` — set mark `x` at current cursor position. Any char cancels on
93    /// Esc or non-char key; only alphanumeric and special marks are accepted by
94    /// the engine, invalid chars silently no-op (engine validates).
95    SetMark,
96    /// `'<x>` — go to mark `x`, linewise (row only, col = first non-blank).
97    /// Esc or non-char key cancels; engine validates the char and no-ops on
98    /// unset or invalid marks.
99    GotoMarkLine,
100    /// `` `<x> `` — go to mark `x`, charwise (row + col). Esc or non-char key
101    /// cancels; engine validates the char and no-ops on unset or invalid marks.
102    GotoMarkChar,
103    /// `q` pressed in Normal mode while NOT already recording — waits for the
104    /// register char. Esc or non-char key cancels (no recording started). Any
105    /// alphabetic or digit char commits `StartMacroRecord { reg: ch }`. The
106    /// stop-on-bare-`q` path is handled in `AppAction::QChord` BEFORE this
107    /// pending state is entered.
108    RecordMacroTarget,
109    /// `@` pressed in Normal mode — waits for the register char. Esc or
110    /// non-char key cancels. `'@'` commits `PlayMacro { reg: '@', count }` for
111    /// `@@` repeat-last semantics (host resolves actual register). `':'`
112    /// commits `PlayMacro { reg: ':', count }` for `@:` last-ex-repeat
113    /// (host handles app-side storage — Phase 5d). Any other alphabetic or
114    /// digit char commits `PlayMacro { reg: ch, count }`.
115    PlayMacroTarget {
116        count: usize,
117    },
118}
119
120/// One step of the reducer.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Outcome {
123    /// Need more keys — keep accumulating with new state.
124    Wait(PendingState),
125    /// Run this engine command, then clear pending.
126    Commit(crate::cmd::EngineCmd),
127    /// Cancel pending (Esc, invalid char, etc.). No engine call.
128    Cancel,
129    /// Pending state didn't consume this key — host should route it
130    /// normally (e.g. modifier-only key). Pending state stays alive.
131    Forward,
132}
133
134/// `Key` is intentionally minimal — hjkl-vim should not depend on
135/// crossterm. Hosts translate their native keys into this shape.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum Key {
138    Char(char),
139    Esc,
140    Enter,
141    Backspace,
142    Tab,
143    // Add more variants only as later chunks require them.
144}
145
146pub fn step(state: PendingState, key: Key) -> Outcome {
147    match state {
148        PendingState::Replace { count } => match key {
149            Key::Esc => Outcome::Cancel,
150            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch, count }),
151            Key::Enter => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch: '\n', count }),
152            _ => Outcome::Cancel,
153        },
154        PendingState::Find {
155            count,
156            forward,
157            till,
158        } => match key {
159            Key::Esc => Outcome::Cancel,
160            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::FindChar {
161                ch,
162                forward,
163                till,
164                count,
165            }),
166            // Any non-char key cancels (vim cancels f<non-char>).
167            _ => Outcome::Cancel,
168        },
169        PendingState::AfterG { count } => match key {
170            Key::Esc => Outcome::Cancel,
171            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterGChord { ch, count }),
172            // Any non-char key cancels (mirrors Find arm).
173            _ => Outcome::Cancel,
174        },
175        PendingState::AfterZ { count } => match key {
176            Key::Esc => Outcome::Cancel,
177            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterZChord { ch, count }),
178            // Any non-char key cancels (mirrors AfterG arm).
179            _ => Outcome::Cancel,
180        },
181        PendingState::AfterOp {
182            op,
183            count1,
184            inner_count,
185        } => match key {
186            Key::Esc => Outcome::Cancel,
187            Key::Char(d @ '0'..='9') => {
188                // Vim quirk: bare `0` with inner_count==0 is LineStart motion.
189                if d == '0' && inner_count == 0 {
190                    // Treat as motion key — engine will parse '0' as LineStart.
191                    let total = count1.max(1);
192                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpMotion {
193                        op,
194                        motion_key: '0',
195                        total_count: total,
196                    })
197                } else {
198                    let new_inner = inner_count
199                        .saturating_mul(10)
200                        .saturating_add(d as usize - '0' as usize);
201                    Outcome::Wait(PendingState::AfterOp {
202                        op,
203                        count1,
204                        inner_count: new_inner,
205                    })
206                }
207            }
208            Key::Char(ch) => {
209                let total = count1.max(1).saturating_mul(inner_count.max(1));
210                // Doubled letter → line op (dd/yy/cc/>>/<<).
211                if ch == op.double_char() {
212                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpDouble {
213                        op,
214                        total_count: total,
215                    })
216                // Text object: `i` → inner, `a` → outer. Transition to
217                // `OpTextObj` so the reducer owns the next char instead of
218                // delegating to the engine FSM (mirrors OpFind pattern).
219                } else if ch == 'i' {
220                    Outcome::Wait(PendingState::OpTextObj {
221                        op,
222                        total_count: total,
223                        inner: true,
224                    })
225                } else if ch == 'a' {
226                    Outcome::Wait(PendingState::OpTextObj {
227                        op,
228                        total_count: total,
229                        inner: false,
230                    })
231                // g-chord sub-pending (dgg, dge, etc.): transition to OpG so
232                // the reducer owns the second char instead of delegating to the
233                // engine FSM. `total_count` collapses both counts at transition
234                // time (mirrors OpFind / OpTextObj pattern).
235                } else if ch == 'g' {
236                    Outcome::Wait(PendingState::OpG {
237                        op,
238                        total_count: total,
239                    })
240                // Find sub-pending (df/dF/dt/dT): transition to OpFind instead
241                // of setting engine Pending::OpFind. `total_count` collapses
242                // both counts at transition time.
243                } else if ch == 'f' {
244                    Outcome::Wait(PendingState::OpFind {
245                        op,
246                        total_count: total,
247                        forward: true,
248                        till: false,
249                    })
250                } else if ch == 'F' {
251                    Outcome::Wait(PendingState::OpFind {
252                        op,
253                        total_count: total,
254                        forward: false,
255                        till: false,
256                    })
257                } else if ch == 't' {
258                    Outcome::Wait(PendingState::OpFind {
259                        op,
260                        total_count: total,
261                        forward: true,
262                        till: true,
263                    })
264                } else if ch == 'T' {
265                    Outcome::Wait(PendingState::OpFind {
266                        op,
267                        total_count: total,
268                        forward: false,
269                        till: true,
270                    })
271                } else if ch == '\'' || ch == '`' {
272                    Outcome::Wait(PendingState::OpMark {
273                        op,
274                        linewise: ch == '\'',
275                    })
276                } else {
277                    // All other chars: treat as motion key and let the engine
278                    // parse it via parse_motion. Unknown keys no-op in the engine.
279                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpMotion {
280                        op,
281                        motion_key: ch,
282                        total_count: total,
283                    })
284                }
285            }
286            // Non-char, non-Esc → cancel (mirrors Find/AfterG arms).
287            _ => Outcome::Cancel,
288        },
289        PendingState::OpFind {
290            op,
291            total_count,
292            forward,
293            till,
294        } => match key {
295            Key::Esc => Outcome::Cancel,
296            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ApplyOpFind {
297                op,
298                ch,
299                forward,
300                till,
301                total_count,
302            }),
303            // Any non-char key cancels (vim's f<non-char> cancel semantics apply).
304            _ => Outcome::Cancel,
305        },
306        PendingState::OpTextObj {
307            op,
308            total_count,
309            inner,
310        } => match key {
311            Key::Esc => Outcome::Cancel,
312            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ApplyOpTextObj {
313                op,
314                ch,
315                inner,
316                total_count,
317            }),
318            // Any non-char key cancels; engine handles invalid chars as no-ops.
319            _ => Outcome::Cancel,
320        },
321        PendingState::OpG { op, total_count } => match key {
322            Key::Esc => Outcome::Cancel,
323            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ApplyOpG {
324                op,
325                ch,
326                total_count,
327            }),
328            // Any non-char key cancels; engine apply_op_g handles unknown chars
329            // as a no-op (mirrors OpTextObj arm).
330            _ => Outcome::Cancel,
331        },
332        PendingState::OpMark { op, linewise } => match key {
333            Key::Esc => Outcome::Cancel,
334            Key::Char(ch) => {
335                Outcome::Commit(crate::cmd::EngineCmd::ApplyOpMark { op, ch, linewise })
336            }
337            // Any non-char key cancels (mirrors the GotoMark arms).
338            _ => Outcome::Cancel,
339        },
340        PendingState::SelectRegister => match key {
341            Key::Esc => Outcome::Cancel,
342            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::SetPendingRegister { reg: ch }),
343            // Any non-char key cancels (mirrors AfterG / Find arms).
344            _ => Outcome::Cancel,
345        },
346        PendingState::SetMark => match key {
347            Key::Esc => Outcome::Cancel,
348            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::SetMark { ch }),
349            // Any non-char key cancels (mirrors SelectRegister / AfterG arms).
350            _ => Outcome::Cancel,
351        },
352        PendingState::GotoMarkLine => match key {
353            Key::Esc => Outcome::Cancel,
354            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::GotoMarkLine { ch }),
355            // Any non-char key cancels (mirrors SetMark arm).
356            _ => Outcome::Cancel,
357        },
358        PendingState::GotoMarkChar => match key {
359            Key::Esc => Outcome::Cancel,
360            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::GotoMarkChar { ch }),
361            // Any non-char key cancels (mirrors GotoMarkLine arm).
362            _ => Outcome::Cancel,
363        },
364        PendingState::RecordMacroTarget => match key {
365            Key::Esc => Outcome::Cancel,
366            Key::Char(ch) if ch.is_ascii_alphabetic() || ch.is_ascii_digit() => {
367                Outcome::Commit(crate::cmd::EngineCmd::StartMacroRecord { reg: ch })
368            }
369            // Non-alphabetic/digit char or non-char key cancels (no recording started).
370            _ => Outcome::Cancel,
371        },
372        PendingState::PlayMacroTarget { count } => match key {
373            Key::Esc => Outcome::Cancel,
374            // `@@` — repeat-last semantics; pass literal '@' and let the host resolve.
375            Key::Char('@') => Outcome::Commit(crate::cmd::EngineCmd::PlayMacro { reg: '@', count }),
376            // `@:` — last-ex-repeat; host handles app-side storage (Phase 5d).
377            Key::Char(':') => Outcome::Commit(crate::cmd::EngineCmd::PlayMacro { reg: ':', count }),
378            Key::Char(ch) if ch.is_ascii_alphabetic() || ch.is_ascii_digit() => {
379                Outcome::Commit(crate::cmd::EngineCmd::PlayMacro { reg: ch, count })
380            }
381            // Any other char or non-char key cancels.
382            _ => Outcome::Cancel,
383        },
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::cmd::EngineCmd;
391    use crate::operator::OperatorKind;
392
393    // ── AfterG reducer unit tests ────────────────────────────────────────────
394
395    #[test]
396    fn after_g_gg_commits() {
397        let state = PendingState::AfterG { count: 1 };
398        assert_eq!(
399            step(state, Key::Char('g')),
400            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 1 })
401        );
402    }
403
404    #[test]
405    fn after_g_gv_commits() {
406        let state = PendingState::AfterG { count: 1 };
407        assert_eq!(
408            step(state, Key::Char('v')),
409            Outcome::Commit(EngineCmd::AfterGChord { ch: 'v', count: 1 })
410        );
411    }
412
413    #[test]
414    fn after_g_gu_operator_commits() {
415        // gU still produces AfterGChord; the engine handles the Pending::Op transition.
416        let state = PendingState::AfterG { count: 1 };
417        assert_eq!(
418            step(state, Key::Char('U')),
419            Outcome::Commit(EngineCmd::AfterGChord { ch: 'U', count: 1 })
420        );
421    }
422
423    #[test]
424    fn after_g_gi_commits() {
425        let state = PendingState::AfterG { count: 1 };
426        assert_eq!(
427            step(state, Key::Char('i')),
428            Outcome::Commit(EngineCmd::AfterGChord { ch: 'i', count: 1 })
429        );
430    }
431
432    #[test]
433    fn after_g_esc_cancels() {
434        let state = PendingState::AfterG { count: 1 };
435        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
436    }
437
438    #[test]
439    fn after_g_count_carry_through() {
440        // 5gg enters with count=5 — AfterGChord carries it through.
441        let state = PendingState::AfterG { count: 5 };
442        assert_eq!(
443            step(state, Key::Char('g')),
444            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 5 })
445        );
446    }
447
448    #[test]
449    fn after_g_non_char_cancels() {
450        // Non-char, non-Esc key (e.g. Enter) cancels.
451        let state = PendingState::AfterG { count: 1 };
452        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
453    }
454
455    #[test]
456    fn g_ampersand_dispatches_via_g_chord() {
457        // `g&` must emit AfterGChord { ch: '&', count: 1 }, not be treated as
458        // the standalone `&` substitute-char.
459        let state = PendingState::AfterG { count: 1 };
460        assert_eq!(
461            step(state, Key::Char('&')),
462            Outcome::Commit(EngineCmd::AfterGChord { ch: '&', count: 1 })
463        );
464    }
465
466    // ── AfterZ reducer unit tests ────────────────────────────────────────────
467
468    #[test]
469    fn after_z_zz_commits() {
470        let state = PendingState::AfterZ { count: 1 };
471        assert_eq!(
472            step(state, Key::Char('z')),
473            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 1 })
474        );
475    }
476
477    #[test]
478    fn after_z_zf_commits() {
479        let state = PendingState::AfterZ { count: 1 };
480        assert_eq!(
481            step(state, Key::Char('f')),
482            Outcome::Commit(EngineCmd::AfterZChord { ch: 'f', count: 1 })
483        );
484    }
485
486    #[test]
487    fn after_z_esc_cancels() {
488        let state = PendingState::AfterZ { count: 1 };
489        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
490    }
491
492    #[test]
493    fn after_z_count_carry_through() {
494        // 3zz enters with count=3 — AfterZChord carries it through.
495        let state = PendingState::AfterZ { count: 3 };
496        assert_eq!(
497            step(state, Key::Char('z')),
498            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 3 })
499        );
500    }
501
502    #[test]
503    fn after_z_non_char_cancels() {
504        // Non-char, non-Esc key (e.g. Enter) cancels.
505        let state = PendingState::AfterZ { count: 1 };
506        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
507    }
508
509    // ── AfterOp reducer unit tests ───────────────────────────────────────────
510
511    fn after_op(op: OperatorKind, count1: usize) -> PendingState {
512        PendingState::AfterOp {
513            op,
514            count1,
515            inner_count: 0,
516        }
517    }
518
519    #[test]
520    fn op_d_then_w_commits_motion() {
521        let state = after_op(OperatorKind::Delete, 1);
522        assert_eq!(
523            step(state, Key::Char('w')),
524            Outcome::Commit(EngineCmd::ApplyOpMotion {
525                op: OperatorKind::Delete,
526                motion_key: 'w',
527                total_count: 1,
528            })
529        );
530    }
531
532    #[test]
533    fn op_d_then_d_commits_double() {
534        let state = after_op(OperatorKind::Delete, 1);
535        assert_eq!(
536            step(state, Key::Char('d')),
537            Outcome::Commit(EngineCmd::ApplyOpDouble {
538                op: OperatorKind::Delete,
539                total_count: 1,
540            })
541        );
542    }
543
544    #[test]
545    fn op_d_inner_count_d3w_commits_motion_with_count_3() {
546        // d3w: count1=1, inner_count accumulates to 3, total=3.
547        let state = after_op(OperatorKind::Delete, 1);
548        // Type '3'.
549        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
550            panic!("expected Wait");
551        };
552        assert_eq!(
553            state2,
554            PendingState::AfterOp {
555                op: OperatorKind::Delete,
556                count1: 1,
557                inner_count: 3
558            }
559        );
560        // Type 'w'.
561        assert_eq!(
562            step(state2, Key::Char('w')),
563            Outcome::Commit(EngineCmd::ApplyOpMotion {
564                op: OperatorKind::Delete,
565                motion_key: 'w',
566                total_count: 3,
567            })
568        );
569    }
570
571    #[test]
572    fn op_2d_d_commits_double_with_count_2() {
573        // 2dd: count1=2, inner_count=0, doubled → total=2.
574        let state = after_op(OperatorKind::Delete, 2);
575        assert_eq!(
576            step(state, Key::Char('d')),
577            Outcome::Commit(EngineCmd::ApplyOpDouble {
578                op: OperatorKind::Delete,
579                total_count: 2,
580            })
581        );
582    }
583
584    #[test]
585    fn op_2d_3w_commits_motion_with_total_6() {
586        // 2d3w: count1=2, inner=3, total=6.
587        let state = after_op(OperatorKind::Delete, 2);
588        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
589            panic!("expected Wait");
590        };
591        assert_eq!(
592            step(state2, Key::Char('w')),
593            Outcome::Commit(EngineCmd::ApplyOpMotion {
594                op: OperatorKind::Delete,
595                motion_key: 'w',
596                total_count: 6,
597            })
598        );
599    }
600
601    #[test]
602    fn op_d_then_i_transitions_to_op_text_obj_inner() {
603        // `di` → Wait(OpTextObj { inner:true, total_count:1 })
604        let state = after_op(OperatorKind::Delete, 1);
605        assert_eq!(
606            step(state, Key::Char('i')),
607            Outcome::Wait(PendingState::OpTextObj {
608                op: OperatorKind::Delete,
609                total_count: 1,
610                inner: true,
611            })
612        );
613    }
614
615    #[test]
616    fn op_d_then_a_transitions_to_op_text_obj_around() {
617        // `da` → Wait(OpTextObj { inner:false, total_count:1 })
618        let state = after_op(OperatorKind::Delete, 1);
619        assert_eq!(
620            step(state, Key::Char('a')),
621            Outcome::Wait(PendingState::OpTextObj {
622                op: OperatorKind::Delete,
623                total_count: 1,
624                inner: false,
625            })
626        );
627    }
628
629    #[test]
630    fn op_d_then_g_transitions_to_op_g() {
631        let state = after_op(OperatorKind::Delete, 1);
632        assert_eq!(
633            step(state, Key::Char('g')),
634            Outcome::Wait(PendingState::OpG {
635                op: OperatorKind::Delete,
636                total_count: 1,
637            })
638        );
639    }
640
641    #[test]
642    fn op_d_then_f_transitions_to_op_find_forward_not_till() {
643        // `df` → Wait(OpFind { forward:true, till:false, total_count:1 })
644        let state = after_op(OperatorKind::Delete, 1);
645        assert_eq!(
646            step(state, Key::Char('f')),
647            Outcome::Wait(PendingState::OpFind {
648                op: OperatorKind::Delete,
649                total_count: 1,
650                forward: true,
651                till: false,
652            })
653        );
654    }
655
656    #[test]
657    fn op_d_then_cap_f_transitions_to_op_find_backward_not_till() {
658        // `dF` → Wait(OpFind { forward:false, till:false, total_count:1 })
659        let state = after_op(OperatorKind::Delete, 1);
660        assert_eq!(
661            step(state, Key::Char('F')),
662            Outcome::Wait(PendingState::OpFind {
663                op: OperatorKind::Delete,
664                total_count: 1,
665                forward: false,
666                till: false,
667            })
668        );
669    }
670
671    #[test]
672    fn op_d_then_t_transitions_to_op_find_forward_till() {
673        // `dt` → Wait(OpFind { forward:true, till:true, total_count:1 })
674        let state = after_op(OperatorKind::Delete, 1);
675        assert_eq!(
676            step(state, Key::Char('t')),
677            Outcome::Wait(PendingState::OpFind {
678                op: OperatorKind::Delete,
679                total_count: 1,
680                forward: true,
681                till: true,
682            })
683        );
684    }
685
686    #[test]
687    fn op_d_then_cap_t_transitions_to_op_find_backward_till() {
688        // `dT` → Wait(OpFind { forward:false, till:true, total_count:1 })
689        let state = after_op(OperatorKind::Delete, 1);
690        assert_eq!(
691            step(state, Key::Char('T')),
692            Outcome::Wait(PendingState::OpFind {
693                op: OperatorKind::Delete,
694                total_count: 1,
695                forward: false,
696                till: true,
697            })
698        );
699    }
700
701    // ── OpFind reducer unit tests ────────────────────────────────────────────
702
703    fn op_find(op: OperatorKind, total_count: usize, forward: bool, till: bool) -> PendingState {
704        PendingState::OpFind {
705            op,
706            total_count,
707            forward,
708            till,
709        }
710    }
711
712    #[test]
713    fn op_then_quote_or_backtick_then_mark_commits_apply_op_mark() {
714        let after_d = PendingState::AfterOp {
715            op: OperatorKind::Delete,
716            count1: 1,
717            inner_count: 0,
718        };
719        for (key, linewise) in [('\'', true), ('`', false)] {
720            let Outcome::Wait(state) = step(after_d, Key::Char(key)) else {
721                panic!("`d{key}` should wait for the mark name");
722            };
723            assert_eq!(
724                state,
725                PendingState::OpMark {
726                    op: OperatorKind::Delete,
727                    linewise,
728                }
729            );
730            assert_eq!(
731                step(state, Key::Char('a')),
732                Outcome::Commit(EngineCmd::ApplyOpMark {
733                    op: OperatorKind::Delete,
734                    ch: 'a',
735                    linewise,
736                })
737            );
738            assert_eq!(step(state, Key::Esc), Outcome::Cancel);
739        }
740    }
741
742    #[test]
743    fn op_d_then_f_then_x_commits_apply_op_find() {
744        // `dfx` → ApplyOpFind { Delete, 'x', forward:true, till:false, total:1 }
745        let state = op_find(OperatorKind::Delete, 1, true, false);
746        assert_eq!(
747            step(state, Key::Char('x')),
748            Outcome::Commit(EngineCmd::ApplyOpFind {
749                op: OperatorKind::Delete,
750                ch: 'x',
751                forward: true,
752                till: false,
753                total_count: 1,
754            })
755        );
756    }
757
758    #[test]
759    fn op_d_then_cap_f_then_x_commits_apply_op_find_backward() {
760        // `dFx` → ApplyOpFind { Delete, 'x', forward:false, till:false, total:1 }
761        let state = op_find(OperatorKind::Delete, 1, false, false);
762        assert_eq!(
763            step(state, Key::Char('x')),
764            Outcome::Commit(EngineCmd::ApplyOpFind {
765                op: OperatorKind::Delete,
766                ch: 'x',
767                forward: false,
768                till: false,
769                total_count: 1,
770            })
771        );
772    }
773
774    #[test]
775    fn op_d_then_t_then_x_commits_apply_op_find_till() {
776        // `dtx` → ApplyOpFind { Delete, 'x', forward:true, till:true, total:1 }
777        let state = op_find(OperatorKind::Delete, 1, true, true);
778        assert_eq!(
779            step(state, Key::Char('x')),
780            Outcome::Commit(EngineCmd::ApplyOpFind {
781                op: OperatorKind::Delete,
782                ch: 'x',
783                forward: true,
784                till: true,
785                total_count: 1,
786            })
787        );
788    }
789
790    #[test]
791    fn op_d_then_cap_t_then_x_commits_apply_op_find_backward_till() {
792        // `dTx` → ApplyOpFind { Delete, 'x', forward:false, till:true, total:1 }
793        let state = op_find(OperatorKind::Delete, 1, false, true);
794        assert_eq!(
795            step(state, Key::Char('x')),
796            Outcome::Commit(EngineCmd::ApplyOpFind {
797                op: OperatorKind::Delete,
798                ch: 'x',
799                forward: false,
800                till: true,
801                total_count: 1,
802            })
803        );
804    }
805
806    #[test]
807    fn op_2d_3f_x_commits_total_count_6() {
808        // `2d3fx`: count1=2, inner_count=3 → total=6 folded at AfterOp→OpFind.
809        // Simulate via AfterOp(count1=2, inner_count=3) then 'f', then 'x'.
810        let state = PendingState::AfterOp {
811            op: OperatorKind::Delete,
812            count1: 2,
813            inner_count: 3,
814        };
815        let Outcome::Wait(op_find_state) = step(state, Key::Char('f')) else {
816            panic!("expected Wait(OpFind)");
817        };
818        assert_eq!(
819            op_find_state,
820            PendingState::OpFind {
821                op: OperatorKind::Delete,
822                total_count: 6,
823                forward: true,
824                till: false,
825            }
826        );
827        assert_eq!(
828            step(op_find_state, Key::Char('x')),
829            Outcome::Commit(EngineCmd::ApplyOpFind {
830                op: OperatorKind::Delete,
831                ch: 'x',
832                forward: true,
833                till: false,
834                total_count: 6,
835            })
836        );
837    }
838
839    #[test]
840    fn op_d_f_then_esc_cancels() {
841        // `df<Esc>` — vim cancels f<Esc>, so OpFind on Esc → Cancel.
842        let state = op_find(OperatorKind::Delete, 1, true, false);
843        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
844    }
845
846    #[test]
847    fn op_d_f_then_enter_cancels() {
848        // Non-char key after `df` cancels (mirrors Find arm).
849        let state = op_find(OperatorKind::Delete, 1, true, false);
850        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
851    }
852
853    #[test]
854    fn op_d_then_esc_cancels() {
855        let state = after_op(OperatorKind::Delete, 1);
856        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
857    }
858
859    #[test]
860    fn op_d_non_char_cancels() {
861        let state = after_op(OperatorKind::Delete, 1);
862        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
863    }
864
865    // ── OpTextObj reducer unit tests ─────────────────────────────────────────
866
867    fn op_text_obj(op: OperatorKind, total_count: usize, inner: bool) -> PendingState {
868        PendingState::OpTextObj {
869            op,
870            total_count,
871            inner,
872        }
873    }
874
875    #[test]
876    fn op_d_then_i_then_w_commits_apply_op_text_obj_inner() {
877        // `diw` → ApplyOpTextObj { Delete, 'w', inner:true, total_count:1 }
878        let state = op_text_obj(OperatorKind::Delete, 1, true);
879        assert_eq!(
880            step(state, Key::Char('w')),
881            Outcome::Commit(EngineCmd::ApplyOpTextObj {
882                op: OperatorKind::Delete,
883                ch: 'w',
884                inner: true,
885                total_count: 1,
886            })
887        );
888    }
889
890    #[test]
891    fn op_d_then_a_then_w_commits_apply_op_text_obj_around() {
892        // `daw` → ApplyOpTextObj { Delete, 'w', inner:false, total_count:1 }
893        let state = op_text_obj(OperatorKind::Delete, 1, false);
894        assert_eq!(
895            step(state, Key::Char('w')),
896            Outcome::Commit(EngineCmd::ApplyOpTextObj {
897                op: OperatorKind::Delete,
898                ch: 'w',
899                inner: false,
900                total_count: 1,
901            })
902        );
903    }
904
905    #[test]
906    fn op_d_then_i_then_quote_commits_with_quote_char() {
907        // `di"` → ApplyOpTextObj { Delete, '"', inner:true, total_count:1 }
908        let state = op_text_obj(OperatorKind::Delete, 1, true);
909        assert_eq!(
910            step(state, Key::Char('"')),
911            Outcome::Commit(EngineCmd::ApplyOpTextObj {
912                op: OperatorKind::Delete,
913                ch: '"',
914                inner: true,
915                total_count: 1,
916            })
917        );
918    }
919
920    #[test]
921    fn op_d_then_i_then_paren_commits_with_paren() {
922        // `di(` → ApplyOpTextObj { Delete, '(', inner:true, total_count:1 }
923        let state = op_text_obj(OperatorKind::Delete, 1, true);
924        assert_eq!(
925            step(state, Key::Char('(')),
926            Outcome::Commit(EngineCmd::ApplyOpTextObj {
927                op: OperatorKind::Delete,
928                ch: '(',
929                inner: true,
930                total_count: 1,
931            })
932        );
933    }
934
935    #[test]
936    fn op_c_then_i_then_p_commits_change_paragraph_inner() {
937        // `cip` → ApplyOpTextObj { Change, 'p', inner:true, total_count:1 }
938        let state = op_text_obj(OperatorKind::Change, 1, true);
939        assert_eq!(
940            step(state, Key::Char('p')),
941            Outcome::Commit(EngineCmd::ApplyOpTextObj {
942                op: OperatorKind::Change,
943                ch: 'p',
944                inner: true,
945                total_count: 1,
946            })
947        );
948    }
949
950    #[test]
951    fn op_d_i_then_esc_cancels() {
952        // `di<Esc>` — Esc after OpTextObj transition cancels.
953        let state = op_text_obj(OperatorKind::Delete, 1, true);
954        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
955    }
956
957    #[test]
958    fn op_d_i_then_enter_cancels() {
959        // Non-char key after `di` cancels (mirrors OpFind arm).
960        let state = op_text_obj(OperatorKind::Delete, 1, true);
961        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
962    }
963
964    #[test]
965    fn op_2d_i_w_total_count_2_preserved() {
966        // `2diw`: count1=2, inner_count=0 → total=2. Check count carry-through.
967        // Simulate via AfterOp(count1=2, inner_count=0) then 'i', then 'w'.
968        let state = PendingState::AfterOp {
969            op: OperatorKind::Delete,
970            count1: 2,
971            inner_count: 0,
972        };
973        let Outcome::Wait(obj_state) = step(state, Key::Char('i')) else {
974            panic!("expected Wait(OpTextObj)");
975        };
976        assert_eq!(
977            obj_state,
978            PendingState::OpTextObj {
979                op: OperatorKind::Delete,
980                total_count: 2,
981                inner: true,
982            }
983        );
984        assert_eq!(
985            step(obj_state, Key::Char('w')),
986            Outcome::Commit(EngineCmd::ApplyOpTextObj {
987                op: OperatorKind::Delete,
988                ch: 'w',
989                inner: true,
990                total_count: 2,
991            })
992        );
993    }
994
995    #[test]
996    fn op_d_bare_zero_is_line_start_motion() {
997        // Bare '0' with inner_count=0 → LineStart motion (total=1).
998        let state = after_op(OperatorKind::Delete, 1);
999        assert_eq!(
1000            step(state, Key::Char('0')),
1001            Outcome::Commit(EngineCmd::ApplyOpMotion {
1002                op: OperatorKind::Delete,
1003                motion_key: '0',
1004                total_count: 1,
1005            })
1006        );
1007    }
1008
1009    #[test]
1010    fn op_d_zero_accumulates_when_inner_count_nonzero() {
1011        // d10w: '1' accumulates to inner=1, then '0' accumulates (inner>0) to inner=10.
1012        let state = after_op(OperatorKind::Delete, 1);
1013        let Outcome::Wait(s2) = step(state, Key::Char('1')) else {
1014            panic!("expected Wait");
1015        };
1016        let Outcome::Wait(s3) = step(s2, Key::Char('0')) else {
1017            panic!("expected Wait");
1018        };
1019        assert_eq!(
1020            s3,
1021            PendingState::AfterOp {
1022                op: OperatorKind::Delete,
1023                count1: 1,
1024                inner_count: 10,
1025            }
1026        );
1027        assert_eq!(
1028            step(s3, Key::Char('w')),
1029            Outcome::Commit(EngineCmd::ApplyOpMotion {
1030                op: OperatorKind::Delete,
1031                motion_key: 'w',
1032                total_count: 10,
1033            })
1034        );
1035    }
1036
1037    #[test]
1038    fn op_total_count_saturates_instead_of_overflowing() {
1039        // Pathological counts: count1 * inner_count must saturate, not
1040        // overflow (overflow panics in debug builds).
1041        let state = PendingState::AfterOp {
1042            op: OperatorKind::Delete,
1043            count1: usize::MAX,
1044            inner_count: 2,
1045        };
1046        assert_eq!(
1047            step(state, Key::Char('w')),
1048            Outcome::Commit(EngineCmd::ApplyOpMotion {
1049                op: OperatorKind::Delete,
1050                motion_key: 'w',
1051                total_count: usize::MAX,
1052            })
1053        );
1054        // Same guard on the OpFind transition fold.
1055        let Outcome::Wait(folded) = step(state, Key::Char('f')) else {
1056            panic!("expected Wait(OpFind)");
1057        };
1058        assert_eq!(
1059            folded,
1060            PendingState::OpFind {
1061                op: OperatorKind::Delete,
1062                total_count: usize::MAX,
1063                forward: true,
1064                till: false,
1065            }
1066        );
1067    }
1068
1069    // Per-operator round-trip tests.
1070
1071    #[test]
1072    fn op_yank_doubled() {
1073        let state = after_op(OperatorKind::Yank, 1);
1074        assert_eq!(
1075            step(state, Key::Char('y')),
1076            Outcome::Commit(EngineCmd::ApplyOpDouble {
1077                op: OperatorKind::Yank,
1078                total_count: 1,
1079            })
1080        );
1081    }
1082
1083    #[test]
1084    fn op_change_doubled() {
1085        let state = after_op(OperatorKind::Change, 1);
1086        assert_eq!(
1087            step(state, Key::Char('c')),
1088            Outcome::Commit(EngineCmd::ApplyOpDouble {
1089                op: OperatorKind::Change,
1090                total_count: 1,
1091            })
1092        );
1093    }
1094
1095    #[test]
1096    fn op_indent_doubled() {
1097        let state = after_op(OperatorKind::Indent, 1);
1098        assert_eq!(
1099            step(state, Key::Char('>')),
1100            Outcome::Commit(EngineCmd::ApplyOpDouble {
1101                op: OperatorKind::Indent,
1102                total_count: 1,
1103            })
1104        );
1105    }
1106
1107    #[test]
1108    fn op_outdent_doubled() {
1109        let state = after_op(OperatorKind::Outdent, 1);
1110        assert_eq!(
1111            step(state, Key::Char('<')),
1112            Outcome::Commit(EngineCmd::ApplyOpDouble {
1113                op: OperatorKind::Outdent,
1114                total_count: 1,
1115            })
1116        );
1117    }
1118
1119    // ── New 2c-v operators: doubled-letter detection ─────────────────────────
1120
1121    #[test]
1122    fn op_uppercase_then_cap_u_commits_double() {
1123        // AfterOp{Uppercase} + 'U' → ApplyOpDouble (gUU = uppercase current line)
1124        let state = after_op(OperatorKind::Uppercase, 1);
1125        assert_eq!(
1126            step(state, Key::Char('U')),
1127            Outcome::Commit(EngineCmd::ApplyOpDouble {
1128                op: OperatorKind::Uppercase,
1129                total_count: 1,
1130            })
1131        );
1132    }
1133
1134    #[test]
1135    fn op_lowercase_then_u_commits_double() {
1136        // AfterOp{Lowercase} + 'u' → ApplyOpDouble (guu = lowercase current line)
1137        let state = after_op(OperatorKind::Lowercase, 1);
1138        assert_eq!(
1139            step(state, Key::Char('u')),
1140            Outcome::Commit(EngineCmd::ApplyOpDouble {
1141                op: OperatorKind::Lowercase,
1142                total_count: 1,
1143            })
1144        );
1145    }
1146
1147    #[test]
1148    fn op_togglecase_then_tilde_commits_double() {
1149        // AfterOp{ToggleCase} + '~' → ApplyOpDouble (g~~ = toggle current line)
1150        let state = after_op(OperatorKind::ToggleCase, 1);
1151        assert_eq!(
1152            step(state, Key::Char('~')),
1153            Outcome::Commit(EngineCmd::ApplyOpDouble {
1154                op: OperatorKind::ToggleCase,
1155                total_count: 1,
1156            })
1157        );
1158    }
1159
1160    #[test]
1161    fn op_reflow_then_q_commits_double() {
1162        // AfterOp{Reflow} + 'q' → ApplyOpDouble (gqq = reflow current line)
1163        let state = after_op(OperatorKind::Reflow, 1);
1164        assert_eq!(
1165            step(state, Key::Char('q')),
1166            Outcome::Commit(EngineCmd::ApplyOpDouble {
1167                op: OperatorKind::Reflow,
1168                total_count: 1,
1169            })
1170        );
1171    }
1172
1173    #[test]
1174    fn op_uppercase_then_w_commits_motion() {
1175        // AfterOp{Uppercase} + 'w' → ApplyOpMotion (gUw = uppercase over word)
1176        let state = after_op(OperatorKind::Uppercase, 1);
1177        assert_eq!(
1178            step(state, Key::Char('w')),
1179            Outcome::Commit(EngineCmd::ApplyOpMotion {
1180                op: OperatorKind::Uppercase,
1181                motion_key: 'w',
1182                total_count: 1,
1183            })
1184        );
1185    }
1186
1187    #[test]
1188    fn op_reflow_then_ap_commits_text_obj() {
1189        // AfterOp{Reflow} + 'a' → Wait(OpTextObj{inner:false}) — verifies 'a'
1190        // transition works for Reflow (gqap = reflow around paragraph).
1191        let state = after_op(OperatorKind::Reflow, 1);
1192        let Outcome::Wait(obj_state) = step(state, Key::Char('a')) else {
1193            panic!("expected Wait(OpTextObj)");
1194        };
1195        assert_eq!(
1196            obj_state,
1197            PendingState::OpTextObj {
1198                op: OperatorKind::Reflow,
1199                total_count: 1,
1200                inner: false,
1201            }
1202        );
1203        // 'p' → commit ApplyOpTextObj
1204        assert_eq!(
1205            step(obj_state, Key::Char('p')),
1206            Outcome::Commit(EngineCmd::ApplyOpTextObj {
1207                op: OperatorKind::Reflow,
1208                ch: 'p',
1209                inner: false,
1210                total_count: 1,
1211            })
1212        );
1213    }
1214
1215    #[test]
1216    fn op_yank_motion() {
1217        let state = after_op(OperatorKind::Yank, 1);
1218        assert_eq!(
1219            step(state, Key::Char('$')),
1220            Outcome::Commit(EngineCmd::ApplyOpMotion {
1221                op: OperatorKind::Yank,
1222                motion_key: '$',
1223                total_count: 1,
1224            })
1225        );
1226    }
1227
1228    #[test]
1229    fn op_change_motion() {
1230        let state = after_op(OperatorKind::Change, 1);
1231        assert_eq!(
1232            step(state, Key::Char('w')),
1233            Outcome::Commit(EngineCmd::ApplyOpMotion {
1234                op: OperatorKind::Change,
1235                motion_key: 'w',
1236                total_count: 1,
1237            })
1238        );
1239    }
1240
1241    #[test]
1242    fn op_indent_motion() {
1243        let state = after_op(OperatorKind::Indent, 1);
1244        assert_eq!(
1245            step(state, Key::Char('j')),
1246            Outcome::Commit(EngineCmd::ApplyOpMotion {
1247                op: OperatorKind::Indent,
1248                motion_key: 'j',
1249                total_count: 1,
1250            })
1251        );
1252    }
1253
1254    #[test]
1255    fn op_outdent_motion() {
1256        let state = after_op(OperatorKind::Outdent, 1);
1257        assert_eq!(
1258            step(state, Key::Char('k')),
1259            Outcome::Commit(EngineCmd::ApplyOpMotion {
1260                op: OperatorKind::Outdent,
1261                motion_key: 'k',
1262                total_count: 1,
1263            })
1264        );
1265    }
1266
1267    // ── OpG reducer unit tests ───────────────────────────────────────────────
1268
1269    fn op_g(op: OperatorKind, total_count: usize) -> PendingState {
1270        PendingState::OpG { op, total_count }
1271    }
1272
1273    #[test]
1274    fn op_d_then_g_then_g_commits_apply_op_g_for_gg() {
1275        // `dgg` → ApplyOpG { Delete, 'g', total_count:1 }
1276        let state = op_g(OperatorKind::Delete, 1);
1277        assert_eq!(
1278            step(state, Key::Char('g')),
1279            Outcome::Commit(EngineCmd::ApplyOpG {
1280                op: OperatorKind::Delete,
1281                ch: 'g',
1282                total_count: 1,
1283            })
1284        );
1285    }
1286
1287    #[test]
1288    fn op_d_then_g_then_e_commits_for_ge() {
1289        // `dge` → ApplyOpG { Delete, 'e', total_count:1 }
1290        let state = op_g(OperatorKind::Delete, 1);
1291        assert_eq!(
1292            step(state, Key::Char('e')),
1293            Outcome::Commit(EngineCmd::ApplyOpG {
1294                op: OperatorKind::Delete,
1295                ch: 'e',
1296                total_count: 1,
1297            })
1298        );
1299    }
1300
1301    #[test]
1302    fn op_d_then_g_then_j_commits_for_gj() {
1303        // `dgj` → ApplyOpG { Delete, 'j', total_count:1 }
1304        let state = op_g(OperatorKind::Delete, 1);
1305        assert_eq!(
1306            step(state, Key::Char('j')),
1307            Outcome::Commit(EngineCmd::ApplyOpG {
1308                op: OperatorKind::Delete,
1309                ch: 'j',
1310                total_count: 1,
1311            })
1312        );
1313    }
1314
1315    #[test]
1316    fn op_2d_3g_g_total_count_6() {
1317        // `2d3gg`: count1=2, inner_count=3 → total=6 folded at AfterOp→OpG.
1318        // Simulate via AfterOp(count1=2, inner_count=3) then 'g', then 'g'.
1319        let state = PendingState::AfterOp {
1320            op: OperatorKind::Delete,
1321            count1: 2,
1322            inner_count: 3,
1323        };
1324        let Outcome::Wait(op_g_state) = step(state, Key::Char('g')) else {
1325            panic!("expected Wait(OpG)");
1326        };
1327        assert_eq!(
1328            op_g_state,
1329            PendingState::OpG {
1330                op: OperatorKind::Delete,
1331                total_count: 6,
1332            }
1333        );
1334        assert_eq!(
1335            step(op_g_state, Key::Char('g')),
1336            Outcome::Commit(EngineCmd::ApplyOpG {
1337                op: OperatorKind::Delete,
1338                ch: 'g',
1339                total_count: 6,
1340            })
1341        );
1342    }
1343
1344    #[test]
1345    fn op_d_g_then_esc_cancels() {
1346        // `dg<Esc>` — Esc after OpG transition cancels.
1347        let state = op_g(OperatorKind::Delete, 1);
1348        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1349    }
1350
1351    #[test]
1352    fn op_d_g_then_enter_cancels() {
1353        // Non-char key after `dg` cancels (mirrors OpFind / OpTextObj arms).
1354        let state = op_g(OperatorKind::Delete, 1);
1355        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1356    }
1357
1358    #[test]
1359    fn op_c_then_g_then_g_commits_change_op_g() {
1360        // `cgg` → ApplyOpG { Change, 'g', total_count:1 }
1361        let state = op_g(OperatorKind::Change, 1);
1362        assert_eq!(
1363            step(state, Key::Char('g')),
1364            Outcome::Commit(EngineCmd::ApplyOpG {
1365                op: OperatorKind::Change,
1366                ch: 'g',
1367                total_count: 1,
1368            })
1369        );
1370    }
1371
1372    // ── SelectRegister reducer unit tests ────────────────────────────────────
1373
1374    #[test]
1375    fn select_register_a_commits() {
1376        // `"a` → SetPendingRegister { reg: 'a' }
1377        let state = PendingState::SelectRegister;
1378        assert_eq!(
1379            step(state, Key::Char('a')),
1380            Outcome::Commit(EngineCmd::SetPendingRegister { reg: 'a' })
1381        );
1382    }
1383
1384    #[test]
1385    fn select_register_plus_commits() {
1386        // `"+` → SetPendingRegister { reg: '+' } (system clipboard register)
1387        let state = PendingState::SelectRegister;
1388        assert_eq!(
1389            step(state, Key::Char('+')),
1390            Outcome::Commit(EngineCmd::SetPendingRegister { reg: '+' })
1391        );
1392    }
1393
1394    #[test]
1395    fn select_register_underscore_commits() {
1396        // `"_` → SetPendingRegister { reg: '_' } (black-hole register)
1397        let state = PendingState::SelectRegister;
1398        assert_eq!(
1399            step(state, Key::Char('_')),
1400            Outcome::Commit(EngineCmd::SetPendingRegister { reg: '_' })
1401        );
1402    }
1403
1404    #[test]
1405    fn select_register_esc_cancels() {
1406        let state = PendingState::SelectRegister;
1407        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1408    }
1409
1410    #[test]
1411    fn select_register_enter_cancels() {
1412        // Non-char key after `"` cancels (engine FSM semantics: no-op = cancel).
1413        let state = PendingState::SelectRegister;
1414        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1415    }
1416
1417    // ── SetMark reducer unit tests ────────────────────────────────────────────
1418
1419    #[test]
1420    fn set_mark_a_commits() {
1421        // `ma` → SetMark { ch: 'a' }
1422        let state = PendingState::SetMark;
1423        assert_eq!(
1424            step(state, Key::Char('a')),
1425            Outcome::Commit(EngineCmd::SetMark { ch: 'a' })
1426        );
1427    }
1428
1429    #[test]
1430    fn set_mark_esc_cancels() {
1431        let state = PendingState::SetMark;
1432        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1433    }
1434
1435    #[test]
1436    fn set_mark_enter_cancels() {
1437        // Non-char key (Enter) after `m` cancels.
1438        let state = PendingState::SetMark;
1439        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1440    }
1441
1442    // ── GotoMarkLine reducer unit tests ───────────────────────────────────────
1443
1444    #[test]
1445    fn goto_mark_line_a_commits() {
1446        // `'a` → GotoMarkLine { ch: 'a' }
1447        let state = PendingState::GotoMarkLine;
1448        assert_eq!(
1449            step(state, Key::Char('a')),
1450            Outcome::Commit(EngineCmd::GotoMarkLine { ch: 'a' })
1451        );
1452    }
1453
1454    #[test]
1455    fn goto_mark_line_esc_cancels() {
1456        let state = PendingState::GotoMarkLine;
1457        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1458    }
1459
1460    #[test]
1461    fn goto_mark_line_enter_cancels() {
1462        // Non-char key (Enter) after `'` cancels.
1463        let state = PendingState::GotoMarkLine;
1464        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1465    }
1466
1467    // ── GotoMarkChar reducer unit tests ───────────────────────────────────────
1468
1469    #[test]
1470    fn goto_mark_char_a_commits() {
1471        // `` `a `` → GotoMarkChar { ch: 'a' }
1472        let state = PendingState::GotoMarkChar;
1473        assert_eq!(
1474            step(state, Key::Char('a')),
1475            Outcome::Commit(EngineCmd::GotoMarkChar { ch: 'a' })
1476        );
1477    }
1478
1479    #[test]
1480    fn goto_mark_char_esc_cancels() {
1481        let state = PendingState::GotoMarkChar;
1482        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1483    }
1484
1485    #[test]
1486    fn goto_mark_char_enter_cancels() {
1487        // Non-char key (Enter) after `` ` `` cancels.
1488        let state = PendingState::GotoMarkChar;
1489        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1490    }
1491
1492    // ── RecordMacroTarget reducer unit tests ─────────────────────────────────
1493
1494    #[test]
1495    fn record_macro_target_a_commits_start_record() {
1496        // `qa` → StartMacroRecord { reg: 'a' }
1497        let state = PendingState::RecordMacroTarget;
1498        assert_eq!(
1499            step(state, Key::Char('a')),
1500            Outcome::Commit(EngineCmd::StartMacroRecord { reg: 'a' })
1501        );
1502    }
1503
1504    #[test]
1505    fn record_macro_target_capital_a_commits_start_record() {
1506        // `qA` → StartMacroRecord { reg: 'A' } (capital = append to lowercase)
1507        let state = PendingState::RecordMacroTarget;
1508        assert_eq!(
1509            step(state, Key::Char('A')),
1510            Outcome::Commit(EngineCmd::StartMacroRecord { reg: 'A' })
1511        );
1512    }
1513
1514    #[test]
1515    fn record_macro_target_esc_cancels() {
1516        let state = PendingState::RecordMacroTarget;
1517        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1518    }
1519
1520    #[test]
1521    fn record_macro_target_enter_cancels() {
1522        // Non-char key (Enter) after `q` cancels.
1523        let state = PendingState::RecordMacroTarget;
1524        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1525    }
1526
1527    #[test]
1528    fn record_macro_target_non_alnum_cancels() {
1529        // Non-alphabetic/digit char (e.g. '!') cancels.
1530        let state = PendingState::RecordMacroTarget;
1531        assert_eq!(step(state, Key::Char('!')), Outcome::Cancel);
1532    }
1533
1534    // ── PlayMacroTarget reducer unit tests ───────────────────────────────────
1535
1536    #[test]
1537    fn play_macro_target_a_commits_play() {
1538        // `@a` → PlayMacro { reg: 'a', count: 1 }
1539        let state = PendingState::PlayMacroTarget { count: 1 };
1540        assert_eq!(
1541            step(state, Key::Char('a')),
1542            Outcome::Commit(EngineCmd::PlayMacro { reg: 'a', count: 1 })
1543        );
1544    }
1545
1546    #[test]
1547    fn play_macro_target_at_sign_commits_play_with_at() {
1548        // `@@` → PlayMacro { reg: '@', count: 1 } (repeat-last semantics)
1549        let state = PendingState::PlayMacroTarget { count: 1 };
1550        assert_eq!(
1551            step(state, Key::Char('@')),
1552            Outcome::Commit(EngineCmd::PlayMacro { reg: '@', count: 1 })
1553        );
1554    }
1555
1556    #[test]
1557    fn play_macro_target_with_count_3_preserves_count() {
1558        // `3@a` — count carried from the app's pending_count through BeginPendingPlayMacro.
1559        let state = PendingState::PlayMacroTarget { count: 3 };
1560        assert_eq!(
1561            step(state, Key::Char('a')),
1562            Outcome::Commit(EngineCmd::PlayMacro { reg: 'a', count: 3 })
1563        );
1564    }
1565
1566    #[test]
1567    fn play_macro_target_esc_cancels() {
1568        let state = PendingState::PlayMacroTarget { count: 1 };
1569        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1570    }
1571
1572    #[test]
1573    fn play_macro_target_enter_cancels() {
1574        // Non-char key (Enter) after `@` cancels.
1575        let state = PendingState::PlayMacroTarget { count: 1 };
1576        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1577    }
1578
1579    #[test]
1580    fn play_macro_target_non_alnum_cancels() {
1581        // Non-alphabetic/digit/@ char cancels (but ':' is a special case below).
1582        let state = PendingState::PlayMacroTarget { count: 1 };
1583        assert_eq!(step(state, Key::Char('!')), Outcome::Cancel);
1584    }
1585
1586    #[test]
1587    fn play_macro_target_colon_commits_play_macro() {
1588        // `@:` → PlayMacro { reg: ':', count: 1 }. Host branches on reg == ':'
1589        // to replay the last ex command (Phase 5d, kryptic-sh/hjkl#71).
1590        let state = PendingState::PlayMacroTarget { count: 1 };
1591        assert_eq!(
1592            step(state, Key::Char(':')),
1593            Outcome::Commit(EngineCmd::PlayMacro { reg: ':', count: 1 })
1594        );
1595    }
1596
1597    #[test]
1598    fn play_macro_target_colon_with_count_3_commits() {
1599        // `3@:` — count preserved through PlayMacroTarget.
1600        let state = PendingState::PlayMacroTarget { count: 3 };
1601        assert_eq!(
1602            step(state, Key::Char(':')),
1603            Outcome::Commit(EngineCmd::PlayMacro { reg: ':', count: 3 })
1604        );
1605    }
1606}