1package input
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/domain/vimkeys"
10 "github.com/bnema/puregotk/v4/gdk"
11)
12
13type stubSequenceTimer struct {
14 onStop func()
15}
16
17func (s stubSequenceTimer) Stop() bool {
18 if s.onStop != nil {
19 s.onStop()
20 }
21 return true
22}
23
24func vimModeSequenceWorkspace(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig {
25 ws := newTestWorkspace()
26 ws.VimMode = entity.VimModeConfig{
27 ActivationShortcut: "ctrl+y",
28 TimeoutMilliseconds: 0,
29 SequenceTimeoutMilliseconds: 500,
30 Actions: actions,
31 }
32 return ws
33}
34
35func enterVimMode(t *testing.T, h *KeyboardHandler) {
36 t.Helper()
37 if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
38 t.Fatal("ctrl+y should enter vim mode")
39 }
40 if h.Mode() != ModeVim {
41 t.Fatalf("mode = %v, want ModeVim", h.Mode())
42 }
43}
44
45func capturePending(h *KeyboardHandler) *[]string {
46 pending := &[]string{}
47 h.SetOnPendingSequenceChange(func(p string) {
48 *pending = append(*pending, p)
49 })
50 return pending
51}
52
53func captureSequenceActions(h *KeyboardHandler) *[]struct {
54 action string
55 count int
56} {
57 actions := &[]struct {
58 action string
59 count int
60 }{}
61 h.SetOnSequenceAction(func(action string, count int) {
62 *actions = append(*actions, struct {
63 action string
64 count int
65 }{action: action, count: count})
66 })
67 return actions
68}
69
70func TestNewVimModeSequenceState_NilTrie(t *testing.T) {
71 state := newVimModeSequenceState(nil, 0)
72 if state.matcher == nil {
73 t.Fatal("matcher is nil")
74 }
75 if got := state.matcher.Pending(); got != "" {
76 t.Fatalf("Pending() = %q, want empty", got)
77 }
78 if state.matcher.Ambiguous() {
79 t.Fatal("Ambiguous() = true, want false")
80 }
81 result := state.matcher.Feed(vimkeys.Key{Sym: "x"})
82 if result.Kind != vimkeys.ResultInvalid {
83 t.Fatalf("Feed kind = %v, want %v", result.Kind, vimkeys.ResultInvalid)
84 }
85}
86
87func TestTrieOwnsBinding(t *testing.T) {
88 cases := map[string]bool{
89 "]]": true,
90 "gi": true,
91 "yah": true,
92 "gO": true,
93 "<C-d>": true,
94 "j": false,
95 "shift+j": false,
96 "}": false,
97 "enter": false,
98 "escape": false,
99 "<Nope>": false,
100 }
101
102 for raw, wantOwned := range cases {
103 t.Run(raw, func(t *testing.T) {
104 seq, owned := trieOwnsBinding(raw)
105 if owned != wantOwned {
106 t.Fatalf("trieOwnsBinding(%q) owned = %v, want %v (seq=%v)", raw, owned, wantOwned, seq)
107 }
108 if !wantOwned {
109 return
110 }
111 if len(seq) == 0 {
112 t.Fatalf("trieOwnsBinding(%q) returned empty sequence", raw)
113 }
114 })
115 }
116}
117
118func TestTrieOwnsBinding_CrossContractAtoms(t *testing.T) {
119 tests := []struct {
120 raw string
121 want vimkeys.Sequence
122 }{
123 {raw: "}", want: vimkeys.Sequence{{Sym: "}"}}},
124 {raw: "<C-d>", want: vimkeys.Sequence{{Sym: "d", Mods: vimkeys.ModCtrl}}},
125 {raw: "<CR>", want: vimkeys.Sequence{{Sym: "CR"}}},
126 {raw: "J", want: vimkeys.Sequence{{Sym: "j", Mods: vimkeys.ModShift}}},
127 {raw: "enter", want: vimkeys.Sequence{{Sym: "CR"}}},
128 {raw: "escape", want: vimkeys.Sequence{{Sym: "Esc"}}},
129 }
130
131 for _, tt := range tests {
132 t.Run(tt.raw, func(t *testing.T) {
133 got, err := vimkeys.ParseBinding(tt.raw)
134 if err != nil {
135 t.Fatalf("ParseBinding(%q) error = %v", tt.raw, err)
136 }
137 if !got.Equal(tt.want) {
138 t.Fatalf("ParseBinding(%q) = %v, want %v", tt.raw, got, tt.want)
139 }
140 _, owned := trieOwnsBinding(tt.raw)
141 switch tt.raw {
142 case "<C-d>", "<CR>":
143 if !owned {
144 t.Fatalf("trieOwnsBinding(%q) = false, want true", tt.raw)
145 }
146 default:
147 if owned {
148 t.Fatalf("trieOwnsBinding(%q) = true, want false", tt.raw)
149 }
150 }
151 })
152 }
153}
154
155func TestBuildVimModeTrie(t *testing.T) {
156 cfg := &entity.VimModeConfig{
157 Actions: map[string]entity.ActionBinding{
158 "vim-scroll-down": {Keys: []string{"j"}},
159 "heading-next": {Keys: []string{"]]"}},
160 "focus-input": {Keys: []string{"gi"}},
161 "yank-section": {Keys: []string{"yah"}},
162 "outline": {Keys: []string{"gO"}},
163 "half-page-down": {Keys: []string{"<C-d>"}},
164 "hint-follow": {Keys: []string{"f"}},
165 "hint-follow-new": {Keys: []string{"F"}},
166 "visual": {Keys: []string{"v"}},
167 "confirm": {Keys: []string{"enter"}},
168 "cancel": {Keys: []string{"escape"}},
169 },
170 }
171
172 trie, owned := buildVimModeTrie(cfg)
173 if trie == nil {
174 t.Fatal("buildVimModeTrie returned nil trie")
175 }
176
177 wantOwned := map[string]bool{
178 "]]": true,
179 "gi": true,
180 "yah": true,
181 "gO": true,
182 "<C-d>": true,
183 "f": true,
184 "F": true,
185 "v": true,
186 }
187 for key, want := range wantOwned {
188 if owned[key] != want {
189 t.Fatalf("owned[%q] = %v, want %v", key, owned[key], want)
190 }
191 }
192 for _, legacy := range []string{"j", "enter", "escape"} {
193 if owned[legacy] {
194 t.Fatalf("legacy binding %q should not be owned", legacy)
195 }
196 }
197
198 seq, err := vimkeys.ParseBinding("]]")
199 if err != nil {
200 t.Fatalf("ParseBinding(]]) error = %v", err)
201 }
202 node, ok := trie.Walk(seq)
203 if !ok || !node.Exact || node.Action != "heading-next" {
204 t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
205 }
206
207 seq, err = vimkeys.ParseBinding("gi")
208 if err != nil {
209 t.Fatalf("ParseBinding(gi) error = %v", err)
210 }
211 node, ok = trie.Walk(seq)
212 if !ok || !node.Exact || node.Action != "focus-input" {
213 t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok)
214 }
215}
216
217func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
218 // Same sequence under two raw aliases; first sorted action wins Insert.
219 // Losing raw key must not be marked owned (insert failed).
220 cfg := &entity.VimModeConfig{
221 Actions: map[string]entity.ActionBinding{
222 "zzz-later": {Keys: []string{"<c-d>"}},
223 "aaa-first": {Keys: []string{"<C-d>"}},
224 "vim-scroll-down": {Keys: []string{"j"}},
225 },
226 }
227
228 trie, owned := buildVimModeTrie(cfg)
229 if !owned["<C-d>"] {
230 t.Fatal("winning raw key <C-d> should be owned after successful Insert")
231 }
232 if owned["<c-d>"] {
233 t.Fatal("conflicting raw key <c-d> must not be marked owned when Insert fails")
234 }
235
236 seq, err := vimkeys.ParseBinding("<C-d>")
237 if err != nil {
238 t.Fatalf("ParseBinding(<C-d>) error = %v", err)
239 }
240 node, ok := trie.Walk(seq)
241 if !ok || !node.Exact || node.Action != "aaa-first" {
242 t.Fatalf("Walk(<C-d>) = (%#v, %v), want exact aaa-first", node, ok)
243 }
244}
245
246func vimModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
247 t.Helper()
248
249 workspace := &entity.WorkspaceConfig{
250 VimMode: entity.VimModeConfig{
251 ActivationShortcut: "ctrl+y",
252 Actions: map[string]entity.ActionBinding{
253 "vim-scroll-down": {Keys: []string{"j"}},
254 "vim-scroll-down-fast": {Keys: []string{"shift+j"}},
255 "confirm": {Keys: []string{"enter"}},
256 "cancel": {Keys: []string{"escape"}},
257 "heading-next": {Keys: []string{"]]"}},
258 "yank-section": {Keys: []string{"yah"}},
259 "outline": {Keys: []string{"gO"}},
260 "half-page-down": {Keys: []string{"<C-d>"}},
261 },
262 },
263 }
264
265 set := NewShortcutSet(context.Background(), workspace, nil)
266 if set.VimModeSequences() == nil {
267 t.Fatal("VimModeSequences() is nil")
268 }
269 return set
270}
271
272func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
273 t.Helper()
274
275 jBinding, jOK := ParseKeyString("j")
276 if !jOK {
277 t.Fatal("ParseKeyString(j) failed")
278 }
279 if action, found := set.VimMode[jBinding]; !found || action != ActionVimScrollDown {
280 t.Fatalf("legacy j missing from VimMode: found=%v action=%q", found, action)
281 }
282
283 enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
284 if action, found := set.VimMode[enterBinding]; !found || action != ActionVimConfirm {
285 t.Fatalf("legacy Vim confirm missing from VimMode: found=%v action=%q", found, action)
286 }
287
288 escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
289 if action, found := set.VimMode[escapeBinding]; !found || action != ActionExitMode {
290 t.Fatalf("legacy escape missing from VimMode: found=%v action=%q", found, action)
291 }
292}
293
294func assertOwnedBindingsAbsentFromVimMode(t *testing.T, set *ShortcutSet) {
295 t.Helper()
296
297 for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
298 if binding, parsed := ParseKeyString(key); parsed {
299 if action, found := set.VimMode[binding]; found {
300 t.Fatalf("owned binding %q leaked into VimMode as %q", key, action)
301 }
302 }
303 }
304}
305
306func assertVimModeTrieYankSection(t *testing.T, set *ShortcutSet) {
307 t.Helper()
308
309 seq, err := vimkeys.ParseBinding("yah")
310 if err != nil {
311 t.Fatalf("ParseBinding(yah) error = %v", err)
312 }
313 node, walked := set.VimModeSequences().Walk(seq)
314 if !walked || !node.Exact || node.Action != "yank-section" {
315 t.Fatalf("VimModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
316 }
317}
318
319func TestShortcutSet_VimModeSequencesOwnership(t *testing.T) {
320 set := vimModeSequencesOwnershipFixture(t)
321
322 t.Run("legacy bindings", func(t *testing.T) {
323 assertLegacyVimModeBindings(t, set)
324 })
325 t.Run("owned bindings absent from legacy table", func(t *testing.T) {
326 assertOwnedBindingsAbsentFromVimMode(t, set)
327 })
328 t.Run("trie resolves owned sequence", func(t *testing.T) {
329 assertVimModeTrieYankSection(t, set)
330 })
331}
332
333func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
334 ctx := context.Background()
335 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
336 "heading-next": {Keys: []string{"]]"}},
337 "vim-scroll-down": {Keys: []string{"j"}},
338 "cancel": {Keys: []string{"escape"}},
339 })
340 h := NewKeyboardHandler(ctx, ws, newTestSession())
341 pending := capturePending(h)
342 actions := captureSequenceActions(h)
343 enterVimMode(t, h)
344
345 if !h.handleKeyPress(uint(']'), 0, 0) {
346 t.Fatal("] prefix should be consumed")
347 }
348 if h.PendingSequence() != "]" {
349 t.Fatalf("PendingSequence() = %q, want %q", h.PendingSequence(), "]")
350 }
351 if len(*pending) != 1 || (*pending)[0] != "]" {
352 t.Fatalf("pending notifications = %#v, want [\"]\"]", *pending)
353 }
354
355 if !h.handleKeyPress(uint(']'), 0, 0) {
356 t.Fatal("]] should be consumed")
357 }
358 if h.PendingSequence() != "" {
359 t.Fatalf("PendingSequence after complete = %q, want empty", h.PendingSequence())
360 }
361 if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 0 {
362 t.Fatalf("actions = %#v, want heading-next count 0", *actions)
363 }
364 if len(*pending) < 2 || (*pending)[len(*pending)-1] != "" {
365 t.Fatalf("pending after complete = %#v, want trailing clear", *pending)
366 }
367}
368
369func TestFeedVimModeSequence_ConfiguredConfirmActivatesAndExits(t *testing.T) {
370 ctx := context.Background()
371 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
372 "confirm": {Keys: []string{"zz"}},
373 })
374 h := NewKeyboardHandler(ctx, ws, newTestSession())
375 actions := captureSequenceActions(h)
376 enterVimMode(t, h)
377
378 if !h.handleKeyPress(uint('z'), 0, 0) {
379 t.Fatal("configured confirm prefix should be consumed")
380 }
381 if !h.handleKeyPress(uint('z'), 0, 0) {
382 t.Fatal("configured confirm completion should be consumed")
383 }
384 if len(*actions) != 1 || (*actions)[0].action != "confirm" || (*actions)[0].count != 0 {
385 t.Fatalf("actions = %#v, want confirm count 0", *actions)
386 }
387 if h.Mode() != ModeNormal {
388 t.Fatalf("mode after configured confirm sequence = %v, want ModeNormal", h.Mode())
389 }
390}
391
392func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
393 ctx := context.Background()
394 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
395 "yank-section": {Keys: []string{"yah"}},
396 "vim-scroll-down": {Keys: []string{"j"}},
397 })
398 h := NewKeyboardHandler(ctx, ws, newTestSession())
399 pending := capturePending(h)
400 actions := captureSequenceActions(h)
401 enterVimMode(t, h)
402
403 for _, step := range []struct {
404 key uint
405 want string
406 }{
407 {uint('y'), "y"},
408 {uint('a'), "ya"},
409 {uint('h'), ""},
410 } {
411 if !h.handleKeyPress(step.key, 0, 0) {
412 t.Fatalf("key %c should be consumed", step.key)
413 }
414 if got := h.PendingSequence(); got != step.want {
415 t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
416 }
417 }
418 if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
419 t.Fatalf("actions = %#v, want yank-section", *actions)
420 }
421 if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
422 t.Fatalf("pending = %#v, want y then ya", *pending)
423 }
424}
425
426func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
427 ctx := context.Background()
428 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
429 "heading-next": {Keys: []string{"]]"}},
430 "vim-scroll-down": {Keys: []string{"j"}},
431 })
432 h := NewKeyboardHandler(ctx, ws, newTestSession())
433 var scrollCalls int
434 h.SetOnAction(func(_ context.Context, action Action) error {
435 if action == ActionVimScrollDown {
436 scrollCalls++
437 }
438 return nil
439 })
440 enterVimMode(t, h)
441
442 // Unknown sequence key falls through; legacy j still works.
443 if !h.handleKeyPress(uint('j'), 0, 0) {
444 t.Fatal("legacy j should be handled")
445 }
446 if scrollCalls != 1 {
447 t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
448 }
449 if h.PendingSequence() != "" {
450 t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
451 }
452}
453
454func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
455 ctx := context.Background()
456 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
457 "heading-next": {Keys: []string{"]]"}},
458 })
459 h := NewKeyboardHandler(ctx, ws, newTestSession())
460 pending := capturePending(h)
461
462 if h.feedVimModeSequence(uint(']'), 0) {
463 t.Fatal("feed outside vim mode should return false")
464 }
465 if len(*pending) != 0 {
466 t.Fatalf("pending = %#v, want none", *pending)
467 }
468}
469
470func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
471 ctx := context.Background()
472 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
473 "yank-section": {Keys: []string{"yah"}},
474 "cancel": {Keys: []string{"escape"}},
475 })
476 h := NewKeyboardHandler(ctx, ws, newTestSession())
477 pending := capturePending(h)
478 enterVimMode(t, h)
479
480 if !h.handleKeyPress(uint('y'), 0, 0) {
481 t.Fatal("y should be consumed")
482 }
483 before := len(*pending)
484
485 if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
486 t.Fatal("escape should exit")
487 }
488 if h.Mode() != ModeNormal {
489 t.Fatalf("mode = %v, want ModeNormal", h.Mode())
490 }
491 if h.PendingSequence() != "" {
492 t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
493 }
494 if len(*pending) != before {
495 t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
496 }
497}
498
499func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
500 ctx := context.Background()
501 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
502 "heading-next": {Keys: []string{"]]"}},
503 })
504 h := NewKeyboardHandler(ctx, ws, newTestSession())
505 actions := captureSequenceActions(h)
506 enterVimMode(t, h)
507
508 if !h.handleKeyPress(uint('2'), 0, 0) {
509 t.Fatal("count digit should be consumed")
510 }
511 if h.PendingSequence() != "2" {
512 t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
513 }
514 if !h.handleKeyPress(uint(']'), 0, 0) {
515 t.Fatal("first ] after count should be consumed")
516 }
517 if !h.handleKeyPress(uint(']'), 0, 0) {
518 t.Fatal("2]] should complete")
519 }
520 if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
521 t.Fatalf("actions = %#v, want heading-next count 2", *actions)
522 }
523}
524
525func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
526 ctx := context.Background()
527 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
528 "bracket-c": {Keys: []string{"]c"}},
529 "bracket-cc": {Keys: []string{"]cc"}},
530 })
531 h := NewKeyboardHandler(ctx, ws, newTestSession())
532 actions := captureSequenceActions(h)
533
534 var fired func()
535 var stops int
536 h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
537 if d != 500*time.Millisecond {
538 t.Fatalf("ambiguity timeout = %v, want 500ms", d)
539 }
540 fired = fn
541 return stubSequenceTimer{onStop: func() { stops++ }}
542 }
543 var queued []func()
544 h.SetSequenceMainThreadScheduler(func(fn func()) {
545 queued = append(queued, fn)
546 })
547
548 enterVimMode(t, h)
549 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
550 t.Fatal("]c prefix should be consumed")
551 }
552 if !h.seq.matcher.Ambiguous() {
553 t.Fatal("]c should be ambiguous against ]cc")
554 }
555 if fired == nil {
556 t.Fatal("expected ambiguity timer arm")
557 }
558
559 fired() // timer expiry queues resolve on scheduler
560 if len(queued) != 1 {
561 t.Fatalf("queued resolves = %d, want 1", len(queued))
562 }
563 queued[0]()
564 if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
565 t.Fatalf("actions = %#v, want bracket-c", *actions)
566 }
567 if h.PendingSequence() != "" {
568 t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
569 }
570 _ = stops
571}
572
573func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
574 ctx := context.Background()
575 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
576 "bracket-c": {Keys: []string{"]c"}},
577 "bracket-cc": {Keys: []string{"]cc"}},
578 })
579 ws.VimMode.SequenceTimeoutMilliseconds = 0
580 h := NewKeyboardHandler(ctx, ws, newTestSession())
581 actions := captureSequenceActions(h)
582
583 var armed time.Duration = -1
584 h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
585 armed = d
586 fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
587 return stubSequenceTimer{}
588 }
589 var queued []func()
590 h.SetSequenceMainThreadScheduler(func(fn func()) {
591 queued = append(queued, fn)
592 })
593
594 enterVimMode(t, h)
595 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
596 t.Fatal("]c should be consumed")
597 }
598 if armed != 0 {
599 t.Fatalf("armed timeout = %v, want 0", armed)
600 }
601 if len(queued) != 1 {
602 t.Fatalf("queued = %d, want 1", len(queued))
603 }
604 queued[0]()
605 if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
606 t.Fatalf("actions = %#v, want bracket-c", *actions)
607 }
608}
609
610func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
611 ctx := context.Background()
612 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
613 "bracket-c": {Keys: []string{"]c"}},
614 "bracket-cc": {Keys: []string{"]cc"}},
615 })
616 h := NewKeyboardHandler(ctx, ws, newTestSession())
617 actions := captureSequenceActions(h)
618
619 var fired func()
620 var stops int
621 h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
622 fired = fn
623 return stubSequenceTimer{onStop: func() { stops++ }}
624 }
625 h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
626
627 enterVimMode(t, h)
628 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
629 t.Fatal("]c should be consumed")
630 }
631 if fired == nil {
632 t.Fatal("expected timer")
633 }
634 if !h.handleKeyPress(uint('c'), 0, 0) {
635 t.Fatal("]cc should complete")
636 }
637 if stops == 0 {
638 t.Fatal("next key must Stop ambiguity timer")
639 }
640 if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
641 t.Fatalf("actions = %#v, want bracket-cc", *actions)
642 }
643 // Stale timer must no-op.
644 fired()
645 if len(*actions) != 1 {
646 t.Fatalf("stale timer mutated actions: %#v", *actions)
647 }
648}
649
650func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
651 ctx := context.Background()
652 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
653 "bracket-c": {Keys: []string{"]c"}},
654 "bracket-cc": {Keys: []string{"]cc"}},
655 "yank-section": {Keys: []string{"yah"}},
656 })
657 h := NewKeyboardHandler(ctx, ws, newTestSession())
658 actions := captureSequenceActions(h)
659 pending := capturePending(h)
660
661 var fired func()
662 h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
663 fired = fn
664 return stubSequenceTimer{}
665 }
666 var queued []func()
667 h.SetSequenceMainThreadScheduler(func(fn func()) {
668 queued = append(queued, fn)
669 })
670
671 enterVimMode(t, h)
672 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
673 t.Fatal("]c should be consumed")
674 }
675 fired() // enqueue stale resolve
676 if len(queued) != 1 {
677 t.Fatalf("queued = %d, want 1", len(queued))
678 }
679 stale := queued[0]
680 queued = nil
681
682 // Interleave: feed/reset/reload before stale closure runs.
683 h.ResetPendingSequence()
684 if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
685 t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
686 }
687 stale()
688 if len(*actions) != 0 {
689 t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
690 }
691
692 // Reload path also invalidates queued resolve.
693 fired = nil
694 queued = nil
695 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
696 t.Fatal("]c again")
697 }
698 fired()
699 stale = queued[0]
700 h.ReloadShortcuts(ctx, ws, newTestSession())
701 stale()
702 if len(*actions) != 0 {
703 t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
704 }
705}
706
707func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
708 ctx := context.Background()
709 wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
710 "action-a": {Keys: []string{"yah"}},
711 "vim-scroll-down": {Keys: []string{"j"}},
712 })
713 h := NewKeyboardHandler(ctx, wsA, newTestSession())
714 actions := captureSequenceActions(h)
715 enterVimMode(t, h)
716
717 if !h.handleKeyPress(uint('y'), 0, 0) {
718 t.Fatal("y should pending under trie A")
719 }
720 if h.PendingSequence() != "y" {
721 t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
722 }
723
724 wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
725 "action-b": {Keys: []string{"gO"}},
726 "vim-scroll-down": {Keys: []string{"j"}},
727 })
728 h.ReloadShortcuts(ctx, wsB, newTestSession())
729 if h.PendingSequence() != "" {
730 t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
731 }
732
733 // Old A sequence must not complete; fall through / invalid.
734 if h.feedVimModeSequence(uint('a'), 0) {
735 t.Fatal("stale A continuation must not be consumed after reload")
736 }
737 if len(*actions) != 0 {
738 t.Fatalf("no A action after reload, got %#v", *actions)
739 }
740
741 // New B sequence works.
742 if !h.handleKeyPress(uint('g'), 0, 0) {
743 t.Fatal("g should pending under trie B")
744 }
745 if h.PendingSequence() != "g" {
746 t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
747 }
748 if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
749 t.Fatal("gO should complete under trie B")
750 }
751 if len(*actions) != 1 || (*actions)[0].action != "action-b" {
752 t.Fatalf("actions = %#v, want action-b", *actions)
753 }
754}