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).saturating_mul(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: total,
215                        inner: true,
216                    })
217                } else if ch == 'a' {
218                    Outcome::Wait(PendingState::OpTextObj {
219                        op,
220                        total_count: total,
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: total,
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: total,
239                        forward: true,
240                        till: false,
241                    })
242                } else if ch == 'F' {
243                    Outcome::Wait(PendingState::OpFind {
244                        op,
245                        total_count: total,
246                        forward: false,
247                        till: false,
248                    })
249                } else if ch == 't' {
250                    Outcome::Wait(PendingState::OpFind {
251                        op,
252                        total_count: total,
253                        forward: true,
254                        till: true,
255                    })
256                } else if ch == 'T' {
257                    Outcome::Wait(PendingState::OpFind {
258                        op,
259                        total_count: total,
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    #[test]
435    fn g_ampersand_dispatches_via_g_chord() {
436        // `g&` must emit AfterGChord { ch: '&', count: 1 }, not be treated as
437        // the standalone `&` substitute-char.
438        let state = PendingState::AfterG { count: 1 };
439        assert_eq!(
440            step(state, Key::Char('&')),
441            Outcome::Commit(EngineCmd::AfterGChord { ch: '&', count: 1 })
442        );
443    }
444
445    // ── AfterZ reducer unit tests ────────────────────────────────────────────
446
447    #[test]
448    fn after_z_zz_commits() {
449        let state = PendingState::AfterZ { count: 1 };
450        assert_eq!(
451            step(state, Key::Char('z')),
452            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 1 })
453        );
454    }
455
456    #[test]
457    fn after_z_zf_commits() {
458        let state = PendingState::AfterZ { count: 1 };
459        assert_eq!(
460            step(state, Key::Char('f')),
461            Outcome::Commit(EngineCmd::AfterZChord { ch: 'f', count: 1 })
462        );
463    }
464
465    #[test]
466    fn after_z_esc_cancels() {
467        let state = PendingState::AfterZ { count: 1 };
468        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
469    }
470
471    #[test]
472    fn after_z_count_carry_through() {
473        // 3zz enters with count=3 — AfterZChord carries it through.
474        let state = PendingState::AfterZ { count: 3 };
475        assert_eq!(
476            step(state, Key::Char('z')),
477            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 3 })
478        );
479    }
480
481    #[test]
482    fn after_z_non_char_cancels() {
483        // Non-char, non-Esc key (e.g. Enter) cancels.
484        let state = PendingState::AfterZ { count: 1 };
485        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
486    }
487
488    // ── AfterOp reducer unit tests ───────────────────────────────────────────
489
490    fn after_op(op: OperatorKind, count1: usize) -> PendingState {
491        PendingState::AfterOp {
492            op,
493            count1,
494            inner_count: 0,
495        }
496    }
497
498    #[test]
499    fn op_d_then_w_commits_motion() {
500        let state = after_op(OperatorKind::Delete, 1);
501        assert_eq!(
502            step(state, Key::Char('w')),
503            Outcome::Commit(EngineCmd::ApplyOpMotion {
504                op: OperatorKind::Delete,
505                motion_key: 'w',
506                total_count: 1,
507            })
508        );
509    }
510
511    #[test]
512    fn op_d_then_d_commits_double() {
513        let state = after_op(OperatorKind::Delete, 1);
514        assert_eq!(
515            step(state, Key::Char('d')),
516            Outcome::Commit(EngineCmd::ApplyOpDouble {
517                op: OperatorKind::Delete,
518                total_count: 1,
519            })
520        );
521    }
522
523    #[test]
524    fn op_d_inner_count_d3w_commits_motion_with_count_3() {
525        // d3w: count1=1, inner_count accumulates to 3, total=3.
526        let state = after_op(OperatorKind::Delete, 1);
527        // Type '3'.
528        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
529            panic!("expected Wait");
530        };
531        assert_eq!(
532            state2,
533            PendingState::AfterOp {
534                op: OperatorKind::Delete,
535                count1: 1,
536                inner_count: 3
537            }
538        );
539        // Type 'w'.
540        assert_eq!(
541            step(state2, Key::Char('w')),
542            Outcome::Commit(EngineCmd::ApplyOpMotion {
543                op: OperatorKind::Delete,
544                motion_key: 'w',
545                total_count: 3,
546            })
547        );
548    }
549
550    #[test]
551    fn op_2d_d_commits_double_with_count_2() {
552        // 2dd: count1=2, inner_count=0, doubled → total=2.
553        let state = after_op(OperatorKind::Delete, 2);
554        assert_eq!(
555            step(state, Key::Char('d')),
556            Outcome::Commit(EngineCmd::ApplyOpDouble {
557                op: OperatorKind::Delete,
558                total_count: 2,
559            })
560        );
561    }
562
563    #[test]
564    fn op_2d_3w_commits_motion_with_total_6() {
565        // 2d3w: count1=2, inner=3, total=6.
566        let state = after_op(OperatorKind::Delete, 2);
567        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
568            panic!("expected Wait");
569        };
570        assert_eq!(
571            step(state2, Key::Char('w')),
572            Outcome::Commit(EngineCmd::ApplyOpMotion {
573                op: OperatorKind::Delete,
574                motion_key: 'w',
575                total_count: 6,
576            })
577        );
578    }
579
580    #[test]
581    fn op_d_then_i_transitions_to_op_text_obj_inner() {
582        // `di` → Wait(OpTextObj { inner:true, total_count:1 })
583        let state = after_op(OperatorKind::Delete, 1);
584        assert_eq!(
585            step(state, Key::Char('i')),
586            Outcome::Wait(PendingState::OpTextObj {
587                op: OperatorKind::Delete,
588                total_count: 1,
589                inner: true,
590            })
591        );
592    }
593
594    #[test]
595    fn op_d_then_a_transitions_to_op_text_obj_around() {
596        // `da` → Wait(OpTextObj { inner:false, total_count:1 })
597        let state = after_op(OperatorKind::Delete, 1);
598        assert_eq!(
599            step(state, Key::Char('a')),
600            Outcome::Wait(PendingState::OpTextObj {
601                op: OperatorKind::Delete,
602                total_count: 1,
603                inner: false,
604            })
605        );
606    }
607
608    #[test]
609    fn op_d_then_g_transitions_to_op_g() {
610        let state = after_op(OperatorKind::Delete, 1);
611        assert_eq!(
612            step(state, Key::Char('g')),
613            Outcome::Wait(PendingState::OpG {
614                op: OperatorKind::Delete,
615                total_count: 1,
616            })
617        );
618    }
619
620    #[test]
621    fn op_d_then_f_transitions_to_op_find_forward_not_till() {
622        // `df` → Wait(OpFind { forward:true, till:false, total_count:1 })
623        let state = after_op(OperatorKind::Delete, 1);
624        assert_eq!(
625            step(state, Key::Char('f')),
626            Outcome::Wait(PendingState::OpFind {
627                op: OperatorKind::Delete,
628                total_count: 1,
629                forward: true,
630                till: false,
631            })
632        );
633    }
634
635    #[test]
636    fn op_d_then_cap_f_transitions_to_op_find_backward_not_till() {
637        // `dF` → Wait(OpFind { forward:false, till:false, total_count:1 })
638        let state = after_op(OperatorKind::Delete, 1);
639        assert_eq!(
640            step(state, Key::Char('F')),
641            Outcome::Wait(PendingState::OpFind {
642                op: OperatorKind::Delete,
643                total_count: 1,
644                forward: false,
645                till: false,
646            })
647        );
648    }
649
650    #[test]
651    fn op_d_then_t_transitions_to_op_find_forward_till() {
652        // `dt` → Wait(OpFind { forward:true, till:true, total_count:1 })
653        let state = after_op(OperatorKind::Delete, 1);
654        assert_eq!(
655            step(state, Key::Char('t')),
656            Outcome::Wait(PendingState::OpFind {
657                op: OperatorKind::Delete,
658                total_count: 1,
659                forward: true,
660                till: true,
661            })
662        );
663    }
664
665    #[test]
666    fn op_d_then_cap_t_transitions_to_op_find_backward_till() {
667        // `dT` → Wait(OpFind { forward:false, till:true, total_count:1 })
668        let state = after_op(OperatorKind::Delete, 1);
669        assert_eq!(
670            step(state, Key::Char('T')),
671            Outcome::Wait(PendingState::OpFind {
672                op: OperatorKind::Delete,
673                total_count: 1,
674                forward: false,
675                till: true,
676            })
677        );
678    }
679
680    // ── OpFind reducer unit tests ────────────────────────────────────────────
681
682    fn op_find(op: OperatorKind, total_count: usize, forward: bool, till: bool) -> PendingState {
683        PendingState::OpFind {
684            op,
685            total_count,
686            forward,
687            till,
688        }
689    }
690
691    #[test]
692    fn op_d_then_f_then_x_commits_apply_op_find() {
693        // `dfx` → ApplyOpFind { Delete, 'x', forward:true, till:false, total:1 }
694        let state = op_find(OperatorKind::Delete, 1, true, false);
695        assert_eq!(
696            step(state, Key::Char('x')),
697            Outcome::Commit(EngineCmd::ApplyOpFind {
698                op: OperatorKind::Delete,
699                ch: 'x',
700                forward: true,
701                till: false,
702                total_count: 1,
703            })
704        );
705    }
706
707    #[test]
708    fn op_d_then_cap_f_then_x_commits_apply_op_find_backward() {
709        // `dFx` → ApplyOpFind { Delete, 'x', forward:false, till:false, total:1 }
710        let state = op_find(OperatorKind::Delete, 1, false, false);
711        assert_eq!(
712            step(state, Key::Char('x')),
713            Outcome::Commit(EngineCmd::ApplyOpFind {
714                op: OperatorKind::Delete,
715                ch: 'x',
716                forward: false,
717                till: false,
718                total_count: 1,
719            })
720        );
721    }
722
723    #[test]
724    fn op_d_then_t_then_x_commits_apply_op_find_till() {
725        // `dtx` → ApplyOpFind { Delete, 'x', forward:true, till:true, total:1 }
726        let state = op_find(OperatorKind::Delete, 1, true, true);
727        assert_eq!(
728            step(state, Key::Char('x')),
729            Outcome::Commit(EngineCmd::ApplyOpFind {
730                op: OperatorKind::Delete,
731                ch: 'x',
732                forward: true,
733                till: true,
734                total_count: 1,
735            })
736        );
737    }
738
739    #[test]
740    fn op_d_then_cap_t_then_x_commits_apply_op_find_backward_till() {
741        // `dTx` → ApplyOpFind { Delete, 'x', forward:false, till:true, total:1 }
742        let state = op_find(OperatorKind::Delete, 1, false, true);
743        assert_eq!(
744            step(state, Key::Char('x')),
745            Outcome::Commit(EngineCmd::ApplyOpFind {
746                op: OperatorKind::Delete,
747                ch: 'x',
748                forward: false,
749                till: true,
750                total_count: 1,
751            })
752        );
753    }
754
755    #[test]
756    fn op_2d_3f_x_commits_total_count_6() {
757        // `2d3fx`: count1=2, inner_count=3 → total=6 folded at AfterOp→OpFind.
758        // Simulate via AfterOp(count1=2, inner_count=3) then 'f', then 'x'.
759        let state = PendingState::AfterOp {
760            op: OperatorKind::Delete,
761            count1: 2,
762            inner_count: 3,
763        };
764        let Outcome::Wait(op_find_state) = step(state, Key::Char('f')) else {
765            panic!("expected Wait(OpFind)");
766        };
767        assert_eq!(
768            op_find_state,
769            PendingState::OpFind {
770                op: OperatorKind::Delete,
771                total_count: 6,
772                forward: true,
773                till: false,
774            }
775        );
776        assert_eq!(
777            step(op_find_state, Key::Char('x')),
778            Outcome::Commit(EngineCmd::ApplyOpFind {
779                op: OperatorKind::Delete,
780                ch: 'x',
781                forward: true,
782                till: false,
783                total_count: 6,
784            })
785        );
786    }
787
788    #[test]
789    fn op_d_f_then_esc_cancels() {
790        // `df<Esc>` — vim cancels f<Esc>, so OpFind on Esc → Cancel.
791        let state = op_find(OperatorKind::Delete, 1, true, false);
792        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
793    }
794
795    #[test]
796    fn op_d_f_then_enter_cancels() {
797        // Non-char key after `df` cancels (mirrors Find arm).
798        let state = op_find(OperatorKind::Delete, 1, true, false);
799        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
800    }
801
802    #[test]
803    fn op_d_then_esc_cancels() {
804        let state = after_op(OperatorKind::Delete, 1);
805        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
806    }
807
808    #[test]
809    fn op_d_non_char_cancels() {
810        let state = after_op(OperatorKind::Delete, 1);
811        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
812    }
813
814    // ── OpTextObj reducer unit tests ─────────────────────────────────────────
815
816    fn op_text_obj(op: OperatorKind, total_count: usize, inner: bool) -> PendingState {
817        PendingState::OpTextObj {
818            op,
819            total_count,
820            inner,
821        }
822    }
823
824    #[test]
825    fn op_d_then_i_then_w_commits_apply_op_text_obj_inner() {
826        // `diw` → ApplyOpTextObj { Delete, 'w', inner:true, total_count:1 }
827        let state = op_text_obj(OperatorKind::Delete, 1, true);
828        assert_eq!(
829            step(state, Key::Char('w')),
830            Outcome::Commit(EngineCmd::ApplyOpTextObj {
831                op: OperatorKind::Delete,
832                ch: 'w',
833                inner: true,
834                total_count: 1,
835            })
836        );
837    }
838
839    #[test]
840    fn op_d_then_a_then_w_commits_apply_op_text_obj_around() {
841        // `daw` → ApplyOpTextObj { Delete, 'w', inner:false, total_count:1 }
842        let state = op_text_obj(OperatorKind::Delete, 1, false);
843        assert_eq!(
844            step(state, Key::Char('w')),
845            Outcome::Commit(EngineCmd::ApplyOpTextObj {
846                op: OperatorKind::Delete,
847                ch: 'w',
848                inner: false,
849                total_count: 1,
850            })
851        );
852    }
853
854    #[test]
855    fn op_d_then_i_then_quote_commits_with_quote_char() {
856        // `di"` → ApplyOpTextObj { Delete, '"', inner:true, total_count:1 }
857        let state = op_text_obj(OperatorKind::Delete, 1, true);
858        assert_eq!(
859            step(state, Key::Char('"')),
860            Outcome::Commit(EngineCmd::ApplyOpTextObj {
861                op: OperatorKind::Delete,
862                ch: '"',
863                inner: true,
864                total_count: 1,
865            })
866        );
867    }
868
869    #[test]
870    fn op_d_then_i_then_paren_commits_with_paren() {
871        // `di(` → ApplyOpTextObj { Delete, '(', inner:true, total_count:1 }
872        let state = op_text_obj(OperatorKind::Delete, 1, true);
873        assert_eq!(
874            step(state, Key::Char('(')),
875            Outcome::Commit(EngineCmd::ApplyOpTextObj {
876                op: OperatorKind::Delete,
877                ch: '(',
878                inner: true,
879                total_count: 1,
880            })
881        );
882    }
883
884    #[test]
885    fn op_c_then_i_then_p_commits_change_paragraph_inner() {
886        // `cip` → ApplyOpTextObj { Change, 'p', inner:true, total_count:1 }
887        let state = op_text_obj(OperatorKind::Change, 1, true);
888        assert_eq!(
889            step(state, Key::Char('p')),
890            Outcome::Commit(EngineCmd::ApplyOpTextObj {
891                op: OperatorKind::Change,
892                ch: 'p',
893                inner: true,
894                total_count: 1,
895            })
896        );
897    }
898
899    #[test]
900    fn op_d_i_then_esc_cancels() {
901        // `di<Esc>` — Esc after OpTextObj transition cancels.
902        let state = op_text_obj(OperatorKind::Delete, 1, true);
903        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
904    }
905
906    #[test]
907    fn op_d_i_then_enter_cancels() {
908        // Non-char key after `di` cancels (mirrors OpFind arm).
909        let state = op_text_obj(OperatorKind::Delete, 1, true);
910        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
911    }
912
913    #[test]
914    fn op_2d_i_w_total_count_2_preserved() {
915        // `2diw`: count1=2, inner_count=0 → total=2. Check count carry-through.
916        // Simulate via AfterOp(count1=2, inner_count=0) then 'i', then 'w'.
917        let state = PendingState::AfterOp {
918            op: OperatorKind::Delete,
919            count1: 2,
920            inner_count: 0,
921        };
922        let Outcome::Wait(obj_state) = step(state, Key::Char('i')) else {
923            panic!("expected Wait(OpTextObj)");
924        };
925        assert_eq!(
926            obj_state,
927            PendingState::OpTextObj {
928                op: OperatorKind::Delete,
929                total_count: 2,
930                inner: true,
931            }
932        );
933        assert_eq!(
934            step(obj_state, Key::Char('w')),
935            Outcome::Commit(EngineCmd::ApplyOpTextObj {
936                op: OperatorKind::Delete,
937                ch: 'w',
938                inner: true,
939                total_count: 2,
940            })
941        );
942    }
943
944    #[test]
945    fn op_d_bare_zero_is_line_start_motion() {
946        // Bare '0' with inner_count=0 → LineStart motion (total=1).
947        let state = after_op(OperatorKind::Delete, 1);
948        assert_eq!(
949            step(state, Key::Char('0')),
950            Outcome::Commit(EngineCmd::ApplyOpMotion {
951                op: OperatorKind::Delete,
952                motion_key: '0',
953                total_count: 1,
954            })
955        );
956    }
957
958    #[test]
959    fn op_d_zero_accumulates_when_inner_count_nonzero() {
960        // d10w: '1' accumulates to inner=1, then '0' accumulates (inner>0) to inner=10.
961        let state = after_op(OperatorKind::Delete, 1);
962        let Outcome::Wait(s2) = step(state, Key::Char('1')) else {
963            panic!("expected Wait");
964        };
965        let Outcome::Wait(s3) = step(s2, Key::Char('0')) else {
966            panic!("expected Wait");
967        };
968        assert_eq!(
969            s3,
970            PendingState::AfterOp {
971                op: OperatorKind::Delete,
972                count1: 1,
973                inner_count: 10,
974            }
975        );
976        assert_eq!(
977            step(s3, Key::Char('w')),
978            Outcome::Commit(EngineCmd::ApplyOpMotion {
979                op: OperatorKind::Delete,
980                motion_key: 'w',
981                total_count: 10,
982            })
983        );
984    }
985
986    #[test]
987    fn op_total_count_saturates_instead_of_overflowing() {
988        // Pathological counts: count1 * inner_count must saturate, not
989        // overflow (overflow panics in debug builds).
990        let state = PendingState::AfterOp {
991            op: OperatorKind::Delete,
992            count1: usize::MAX,
993            inner_count: 2,
994        };
995        assert_eq!(
996            step(state, Key::Char('w')),
997            Outcome::Commit(EngineCmd::ApplyOpMotion {
998                op: OperatorKind::Delete,
999                motion_key: 'w',
1000                total_count: usize::MAX,
1001            })
1002        );
1003        // Same guard on the OpFind transition fold.
1004        let Outcome::Wait(folded) = step(state, Key::Char('f')) else {
1005            panic!("expected Wait(OpFind)");
1006        };
1007        assert_eq!(
1008            folded,
1009            PendingState::OpFind {
1010                op: OperatorKind::Delete,
1011                total_count: usize::MAX,
1012                forward: true,
1013                till: false,
1014            }
1015        );
1016    }
1017
1018    // Per-operator round-trip tests.
1019
1020    #[test]
1021    fn op_yank_doubled() {
1022        let state = after_op(OperatorKind::Yank, 1);
1023        assert_eq!(
1024            step(state, Key::Char('y')),
1025            Outcome::Commit(EngineCmd::ApplyOpDouble {
1026                op: OperatorKind::Yank,
1027                total_count: 1,
1028            })
1029        );
1030    }
1031
1032    #[test]
1033    fn op_change_doubled() {
1034        let state = after_op(OperatorKind::Change, 1);
1035        assert_eq!(
1036            step(state, Key::Char('c')),
1037            Outcome::Commit(EngineCmd::ApplyOpDouble {
1038                op: OperatorKind::Change,
1039                total_count: 1,
1040            })
1041        );
1042    }
1043
1044    #[test]
1045    fn op_indent_doubled() {
1046        let state = after_op(OperatorKind::Indent, 1);
1047        assert_eq!(
1048            step(state, Key::Char('>')),
1049            Outcome::Commit(EngineCmd::ApplyOpDouble {
1050                op: OperatorKind::Indent,
1051                total_count: 1,
1052            })
1053        );
1054    }
1055
1056    #[test]
1057    fn op_outdent_doubled() {
1058        let state = after_op(OperatorKind::Outdent, 1);
1059        assert_eq!(
1060            step(state, Key::Char('<')),
1061            Outcome::Commit(EngineCmd::ApplyOpDouble {
1062                op: OperatorKind::Outdent,
1063                total_count: 1,
1064            })
1065        );
1066    }
1067
1068    // ── New 2c-v operators: doubled-letter detection ─────────────────────────
1069
1070    #[test]
1071    fn op_uppercase_then_cap_u_commits_double() {
1072        // AfterOp{Uppercase} + 'U' → ApplyOpDouble (gUU = uppercase current line)
1073        let state = after_op(OperatorKind::Uppercase, 1);
1074        assert_eq!(
1075            step(state, Key::Char('U')),
1076            Outcome::Commit(EngineCmd::ApplyOpDouble {
1077                op: OperatorKind::Uppercase,
1078                total_count: 1,
1079            })
1080        );
1081    }
1082
1083    #[test]
1084    fn op_lowercase_then_u_commits_double() {
1085        // AfterOp{Lowercase} + 'u' → ApplyOpDouble (guu = lowercase current line)
1086        let state = after_op(OperatorKind::Lowercase, 1);
1087        assert_eq!(
1088            step(state, Key::Char('u')),
1089            Outcome::Commit(EngineCmd::ApplyOpDouble {
1090                op: OperatorKind::Lowercase,
1091                total_count: 1,
1092            })
1093        );
1094    }
1095
1096    #[test]
1097    fn op_togglecase_then_tilde_commits_double() {
1098        // AfterOp{ToggleCase} + '~' → ApplyOpDouble (g~~ = toggle current line)
1099        let state = after_op(OperatorKind::ToggleCase, 1);
1100        assert_eq!(
1101            step(state, Key::Char('~')),
1102            Outcome::Commit(EngineCmd::ApplyOpDouble {
1103                op: OperatorKind::ToggleCase,
1104                total_count: 1,
1105            })
1106        );
1107    }
1108
1109    #[test]
1110    fn op_reflow_then_q_commits_double() {
1111        // AfterOp{Reflow} + 'q' → ApplyOpDouble (gqq = reflow current line)
1112        let state = after_op(OperatorKind::Reflow, 1);
1113        assert_eq!(
1114            step(state, Key::Char('q')),
1115            Outcome::Commit(EngineCmd::ApplyOpDouble {
1116                op: OperatorKind::Reflow,
1117                total_count: 1,
1118            })
1119        );
1120    }
1121
1122    #[test]
1123    fn op_uppercase_then_w_commits_motion() {
1124        // AfterOp{Uppercase} + 'w' → ApplyOpMotion (gUw = uppercase over word)
1125        let state = after_op(OperatorKind::Uppercase, 1);
1126        assert_eq!(
1127            step(state, Key::Char('w')),
1128            Outcome::Commit(EngineCmd::ApplyOpMotion {
1129                op: OperatorKind::Uppercase,
1130                motion_key: 'w',
1131                total_count: 1,
1132            })
1133        );
1134    }
1135
1136    #[test]
1137    fn op_reflow_then_ap_commits_text_obj() {
1138        // AfterOp{Reflow} + 'a' → Wait(OpTextObj{inner:false}) — verifies 'a'
1139        // transition works for Reflow (gqap = reflow around paragraph).
1140        let state = after_op(OperatorKind::Reflow, 1);
1141        let Outcome::Wait(obj_state) = step(state, Key::Char('a')) else {
1142            panic!("expected Wait(OpTextObj)");
1143        };
1144        assert_eq!(
1145            obj_state,
1146            PendingState::OpTextObj {
1147                op: OperatorKind::Reflow,
1148                total_count: 1,
1149                inner: false,
1150            }
1151        );
1152        // 'p' → commit ApplyOpTextObj
1153        assert_eq!(
1154            step(obj_state, Key::Char('p')),
1155            Outcome::Commit(EngineCmd::ApplyOpTextObj {
1156                op: OperatorKind::Reflow,
1157                ch: 'p',
1158                inner: false,
1159                total_count: 1,
1160            })
1161        );
1162    }
1163
1164    #[test]
1165    fn op_yank_motion() {
1166        let state = after_op(OperatorKind::Yank, 1);
1167        assert_eq!(
1168            step(state, Key::Char('$')),
1169            Outcome::Commit(EngineCmd::ApplyOpMotion {
1170                op: OperatorKind::Yank,
1171                motion_key: '$',
1172                total_count: 1,
1173            })
1174        );
1175    }
1176
1177    #[test]
1178    fn op_change_motion() {
1179        let state = after_op(OperatorKind::Change, 1);
1180        assert_eq!(
1181            step(state, Key::Char('w')),
1182            Outcome::Commit(EngineCmd::ApplyOpMotion {
1183                op: OperatorKind::Change,
1184                motion_key: 'w',
1185                total_count: 1,
1186            })
1187        );
1188    }
1189
1190    #[test]
1191    fn op_indent_motion() {
1192        let state = after_op(OperatorKind::Indent, 1);
1193        assert_eq!(
1194            step(state, Key::Char('j')),
1195            Outcome::Commit(EngineCmd::ApplyOpMotion {
1196                op: OperatorKind::Indent,
1197                motion_key: 'j',
1198                total_count: 1,
1199            })
1200        );
1201    }
1202
1203    #[test]
1204    fn op_outdent_motion() {
1205        let state = after_op(OperatorKind::Outdent, 1);
1206        assert_eq!(
1207            step(state, Key::Char('k')),
1208            Outcome::Commit(EngineCmd::ApplyOpMotion {
1209                op: OperatorKind::Outdent,
1210                motion_key: 'k',
1211                total_count: 1,
1212            })
1213        );
1214    }
1215
1216    // ── OpG reducer unit tests ───────────────────────────────────────────────
1217
1218    fn op_g(op: OperatorKind, total_count: usize) -> PendingState {
1219        PendingState::OpG { op, total_count }
1220    }
1221
1222    #[test]
1223    fn op_d_then_g_then_g_commits_apply_op_g_for_gg() {
1224        // `dgg` → ApplyOpG { Delete, 'g', total_count:1 }
1225        let state = op_g(OperatorKind::Delete, 1);
1226        assert_eq!(
1227            step(state, Key::Char('g')),
1228            Outcome::Commit(EngineCmd::ApplyOpG {
1229                op: OperatorKind::Delete,
1230                ch: 'g',
1231                total_count: 1,
1232            })
1233        );
1234    }
1235
1236    #[test]
1237    fn op_d_then_g_then_e_commits_for_ge() {
1238        // `dge` → ApplyOpG { Delete, 'e', total_count:1 }
1239        let state = op_g(OperatorKind::Delete, 1);
1240        assert_eq!(
1241            step(state, Key::Char('e')),
1242            Outcome::Commit(EngineCmd::ApplyOpG {
1243                op: OperatorKind::Delete,
1244                ch: 'e',
1245                total_count: 1,
1246            })
1247        );
1248    }
1249
1250    #[test]
1251    fn op_d_then_g_then_j_commits_for_gj() {
1252        // `dgj` → ApplyOpG { Delete, 'j', total_count:1 }
1253        let state = op_g(OperatorKind::Delete, 1);
1254        assert_eq!(
1255            step(state, Key::Char('j')),
1256            Outcome::Commit(EngineCmd::ApplyOpG {
1257                op: OperatorKind::Delete,
1258                ch: 'j',
1259                total_count: 1,
1260            })
1261        );
1262    }
1263
1264    #[test]
1265    fn op_2d_3g_g_total_count_6() {
1266        // `2d3gg`: count1=2, inner_count=3 → total=6 folded at AfterOp→OpG.
1267        // Simulate via AfterOp(count1=2, inner_count=3) then 'g', then 'g'.
1268        let state = PendingState::AfterOp {
1269            op: OperatorKind::Delete,
1270            count1: 2,
1271            inner_count: 3,
1272        };
1273        let Outcome::Wait(op_g_state) = step(state, Key::Char('g')) else {
1274            panic!("expected Wait(OpG)");
1275        };
1276        assert_eq!(
1277            op_g_state,
1278            PendingState::OpG {
1279                op: OperatorKind::Delete,
1280                total_count: 6,
1281            }
1282        );
1283        assert_eq!(
1284            step(op_g_state, Key::Char('g')),
1285            Outcome::Commit(EngineCmd::ApplyOpG {
1286                op: OperatorKind::Delete,
1287                ch: 'g',
1288                total_count: 6,
1289            })
1290        );
1291    }
1292
1293    #[test]
1294    fn op_d_g_then_esc_cancels() {
1295        // `dg<Esc>` — Esc after OpG transition cancels.
1296        let state = op_g(OperatorKind::Delete, 1);
1297        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1298    }
1299
1300    #[test]
1301    fn op_d_g_then_enter_cancels() {
1302        // Non-char key after `dg` cancels (mirrors OpFind / OpTextObj arms).
1303        let state = op_g(OperatorKind::Delete, 1);
1304        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1305    }
1306
1307    #[test]
1308    fn op_c_then_g_then_g_commits_change_op_g() {
1309        // `cgg` → ApplyOpG { Change, 'g', total_count:1 }
1310        let state = op_g(OperatorKind::Change, 1);
1311        assert_eq!(
1312            step(state, Key::Char('g')),
1313            Outcome::Commit(EngineCmd::ApplyOpG {
1314                op: OperatorKind::Change,
1315                ch: 'g',
1316                total_count: 1,
1317            })
1318        );
1319    }
1320
1321    // ── SelectRegister reducer unit tests ────────────────────────────────────
1322
1323    #[test]
1324    fn select_register_a_commits() {
1325        // `"a` → SetPendingRegister { reg: 'a' }
1326        let state = PendingState::SelectRegister;
1327        assert_eq!(
1328            step(state, Key::Char('a')),
1329            Outcome::Commit(EngineCmd::SetPendingRegister { reg: 'a' })
1330        );
1331    }
1332
1333    #[test]
1334    fn select_register_plus_commits() {
1335        // `"+` → SetPendingRegister { reg: '+' } (system clipboard register)
1336        let state = PendingState::SelectRegister;
1337        assert_eq!(
1338            step(state, Key::Char('+')),
1339            Outcome::Commit(EngineCmd::SetPendingRegister { reg: '+' })
1340        );
1341    }
1342
1343    #[test]
1344    fn select_register_underscore_commits() {
1345        // `"_` → SetPendingRegister { reg: '_' } (black-hole register)
1346        let state = PendingState::SelectRegister;
1347        assert_eq!(
1348            step(state, Key::Char('_')),
1349            Outcome::Commit(EngineCmd::SetPendingRegister { reg: '_' })
1350        );
1351    }
1352
1353    #[test]
1354    fn select_register_esc_cancels() {
1355        let state = PendingState::SelectRegister;
1356        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1357    }
1358
1359    #[test]
1360    fn select_register_enter_cancels() {
1361        // Non-char key after `"` cancels (engine FSM semantics: no-op = cancel).
1362        let state = PendingState::SelectRegister;
1363        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1364    }
1365
1366    // ── SetMark reducer unit tests ────────────────────────────────────────────
1367
1368    #[test]
1369    fn set_mark_a_commits() {
1370        // `ma` → SetMark { ch: 'a' }
1371        let state = PendingState::SetMark;
1372        assert_eq!(
1373            step(state, Key::Char('a')),
1374            Outcome::Commit(EngineCmd::SetMark { ch: 'a' })
1375        );
1376    }
1377
1378    #[test]
1379    fn set_mark_esc_cancels() {
1380        let state = PendingState::SetMark;
1381        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1382    }
1383
1384    #[test]
1385    fn set_mark_enter_cancels() {
1386        // Non-char key (Enter) after `m` cancels.
1387        let state = PendingState::SetMark;
1388        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1389    }
1390
1391    // ── GotoMarkLine reducer unit tests ───────────────────────────────────────
1392
1393    #[test]
1394    fn goto_mark_line_a_commits() {
1395        // `'a` → GotoMarkLine { ch: 'a' }
1396        let state = PendingState::GotoMarkLine;
1397        assert_eq!(
1398            step(state, Key::Char('a')),
1399            Outcome::Commit(EngineCmd::GotoMarkLine { ch: 'a' })
1400        );
1401    }
1402
1403    #[test]
1404    fn goto_mark_line_esc_cancels() {
1405        let state = PendingState::GotoMarkLine;
1406        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1407    }
1408
1409    #[test]
1410    fn goto_mark_line_enter_cancels() {
1411        // Non-char key (Enter) after `'` cancels.
1412        let state = PendingState::GotoMarkLine;
1413        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1414    }
1415
1416    // ── GotoMarkChar reducer unit tests ───────────────────────────────────────
1417
1418    #[test]
1419    fn goto_mark_char_a_commits() {
1420        // `` `a `` → GotoMarkChar { ch: 'a' }
1421        let state = PendingState::GotoMarkChar;
1422        assert_eq!(
1423            step(state, Key::Char('a')),
1424            Outcome::Commit(EngineCmd::GotoMarkChar { ch: 'a' })
1425        );
1426    }
1427
1428    #[test]
1429    fn goto_mark_char_esc_cancels() {
1430        let state = PendingState::GotoMarkChar;
1431        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1432    }
1433
1434    #[test]
1435    fn goto_mark_char_enter_cancels() {
1436        // Non-char key (Enter) after `` ` `` cancels.
1437        let state = PendingState::GotoMarkChar;
1438        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1439    }
1440
1441    // ── RecordMacroTarget reducer unit tests ─────────────────────────────────
1442
1443    #[test]
1444    fn record_macro_target_a_commits_start_record() {
1445        // `qa` → StartMacroRecord { reg: 'a' }
1446        let state = PendingState::RecordMacroTarget;
1447        assert_eq!(
1448            step(state, Key::Char('a')),
1449            Outcome::Commit(EngineCmd::StartMacroRecord { reg: 'a' })
1450        );
1451    }
1452
1453    #[test]
1454    fn record_macro_target_capital_a_commits_start_record() {
1455        // `qA` → StartMacroRecord { reg: 'A' } (capital = append to lowercase)
1456        let state = PendingState::RecordMacroTarget;
1457        assert_eq!(
1458            step(state, Key::Char('A')),
1459            Outcome::Commit(EngineCmd::StartMacroRecord { reg: 'A' })
1460        );
1461    }
1462
1463    #[test]
1464    fn record_macro_target_esc_cancels() {
1465        let state = PendingState::RecordMacroTarget;
1466        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1467    }
1468
1469    #[test]
1470    fn record_macro_target_enter_cancels() {
1471        // Non-char key (Enter) after `q` cancels.
1472        let state = PendingState::RecordMacroTarget;
1473        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1474    }
1475
1476    #[test]
1477    fn record_macro_target_non_alnum_cancels() {
1478        // Non-alphabetic/digit char (e.g. '!') cancels.
1479        let state = PendingState::RecordMacroTarget;
1480        assert_eq!(step(state, Key::Char('!')), Outcome::Cancel);
1481    }
1482
1483    // ── PlayMacroTarget reducer unit tests ───────────────────────────────────
1484
1485    #[test]
1486    fn play_macro_target_a_commits_play() {
1487        // `@a` → PlayMacro { reg: 'a', count: 1 }
1488        let state = PendingState::PlayMacroTarget { count: 1 };
1489        assert_eq!(
1490            step(state, Key::Char('a')),
1491            Outcome::Commit(EngineCmd::PlayMacro { reg: 'a', count: 1 })
1492        );
1493    }
1494
1495    #[test]
1496    fn play_macro_target_at_sign_commits_play_with_at() {
1497        // `@@` → PlayMacro { reg: '@', count: 1 } (repeat-last semantics)
1498        let state = PendingState::PlayMacroTarget { count: 1 };
1499        assert_eq!(
1500            step(state, Key::Char('@')),
1501            Outcome::Commit(EngineCmd::PlayMacro { reg: '@', count: 1 })
1502        );
1503    }
1504
1505    #[test]
1506    fn play_macro_target_with_count_3_preserves_count() {
1507        // `3@a` — count carried from the app's pending_count through BeginPendingPlayMacro.
1508        let state = PendingState::PlayMacroTarget { count: 3 };
1509        assert_eq!(
1510            step(state, Key::Char('a')),
1511            Outcome::Commit(EngineCmd::PlayMacro { reg: 'a', count: 3 })
1512        );
1513    }
1514
1515    #[test]
1516    fn play_macro_target_esc_cancels() {
1517        let state = PendingState::PlayMacroTarget { count: 1 };
1518        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1519    }
1520
1521    #[test]
1522    fn play_macro_target_enter_cancels() {
1523        // Non-char key (Enter) after `@` cancels.
1524        let state = PendingState::PlayMacroTarget { count: 1 };
1525        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1526    }
1527
1528    #[test]
1529    fn play_macro_target_non_alnum_cancels() {
1530        // Non-alphabetic/digit/@ char cancels (but ':' is a special case below).
1531        let state = PendingState::PlayMacroTarget { count: 1 };
1532        assert_eq!(step(state, Key::Char('!')), Outcome::Cancel);
1533    }
1534
1535    #[test]
1536    fn play_macro_target_colon_commits_play_macro() {
1537        // `@:` → PlayMacro { reg: ':', count: 1 }. Host branches on reg == ':'
1538        // to replay the last ex command (Phase 5d, kryptic-sh/hjkl#71).
1539        let state = PendingState::PlayMacroTarget { count: 1 };
1540        assert_eq!(
1541            step(state, Key::Char(':')),
1542            Outcome::Commit(EngineCmd::PlayMacro { reg: ':', count: 1 })
1543        );
1544    }
1545
1546    #[test]
1547    fn play_macro_target_colon_with_count_3_commits() {
1548        // `3@:` — count preserved through PlayMacroTarget.
1549        let state = PendingState::PlayMacroTarget { count: 3 };
1550        assert_eq!(
1551            step(state, Key::Char(':')),
1552            Outcome::Commit(EngineCmd::PlayMacro { reg: ':', count: 3 })
1553        );
1554    }
1555}