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