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