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 "confirm": {Keys: []string{"enter"}},
165 "cancel": {Keys: []string{"escape"}},
166 },
167 }
168
169 trie, owned := buildVimModeTrie(cfg)
170 if trie == nil {
171 t.Fatal("buildVimModeTrie returned nil trie")
172 }
173
174 wantOwned := map[string]bool{
175 "]]": true,
176 "gi": true,
177 "yah": true,
178 "gO": true,
179 "<C-d>": true,
180 }
181 for key, want := range wantOwned {
182 if owned[key] != want {
183 t.Fatalf("owned[%q] = %v, want %v", key, owned[key], want)
184 }
185 }
186 for _, legacy := range []string{"j", "enter", "escape"} {
187 if owned[legacy] {
188 t.Fatalf("legacy binding %q should not be owned", legacy)
189 }
190 }
191
192 seq, err := vimkeys.ParseBinding("]]")
193 if err != nil {
194 t.Fatalf("ParseBinding(]]) error = %v", err)
195 }
196 node, ok := trie.Walk(seq)
197 if !ok || !node.Exact || node.Action != "heading-next" {
198 t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
199 }
200
201 seq, err = vimkeys.ParseBinding("gi")
202 if err != nil {
203 t.Fatalf("ParseBinding(gi) error = %v", err)
204 }
205 node, ok = trie.Walk(seq)
206 if !ok || !node.Exact || node.Action != "focus-input" {
207 t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok)
208 }
209}
210
211func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
212 // Same sequence under two raw aliases; first sorted action wins Insert.
213 // Losing raw key must not be marked owned (insert failed).
214 cfg := &entity.VimModeConfig{
215 Actions: map[string]entity.ActionBinding{
216 "zzz-later": {Keys: []string{"<c-d>"}},
217 "aaa-first": {Keys: []string{"<C-d>"}},
218 "vim-scroll-down": {Keys: []string{"j"}},
219 },
220 }
221
222 trie, owned := buildVimModeTrie(cfg)
223 if !owned["<C-d>"] {
224 t.Fatal("winning raw key <C-d> should be owned after successful Insert")
225 }
226 if owned["<c-d>"] {
227 t.Fatal("conflicting raw key <c-d> must not be marked owned when Insert fails")
228 }
229
230 seq, err := vimkeys.ParseBinding("<C-d>")
231 if err != nil {
232 t.Fatalf("ParseBinding(<C-d>) error = %v", err)
233 }
234 node, ok := trie.Walk(seq)
235 if !ok || !node.Exact || node.Action != "aaa-first" {
236 t.Fatalf("Walk(<C-d>) = (%#v, %v), want exact aaa-first", node, ok)
237 }
238}
239
240func vimModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
241 t.Helper()
242
243 workspace := &entity.WorkspaceConfig{
244 VimMode: entity.VimModeConfig{
245 ActivationShortcut: "ctrl+y",
246 Actions: map[string]entity.ActionBinding{
247 "vim-scroll-down": {Keys: []string{"j"}},
248 "vim-scroll-down-fast": {Keys: []string{"shift+j"}},
249 "confirm": {Keys: []string{"enter"}},
250 "cancel": {Keys: []string{"escape"}},
251 "heading-next": {Keys: []string{"]]"}},
252 "yank-section": {Keys: []string{"yah"}},
253 "outline": {Keys: []string{"gO"}},
254 "half-page-down": {Keys: []string{"<C-d>"}},
255 },
256 },
257 }
258
259 set := NewShortcutSet(context.Background(), workspace, nil)
260 if set.VimModeSequences() == nil {
261 t.Fatal("VimModeSequences() is nil")
262 }
263 return set
264}
265
266func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
267 t.Helper()
268
269 jBinding, jOK := ParseKeyString("j")
270 if !jOK {
271 t.Fatal("ParseKeyString(j) failed")
272 }
273 if action, found := set.VimMode[jBinding]; !found || action != ActionVimScrollDown {
274 t.Fatalf("legacy j missing from VimMode: found=%v action=%q", found, action)
275 }
276
277 enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
278 if action, found := set.VimMode[enterBinding]; !found || action != ActionVimConfirm {
279 t.Fatalf("legacy Vim confirm missing from VimMode: found=%v action=%q", found, action)
280 }
281
282 escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
283 if action, found := set.VimMode[escapeBinding]; !found || action != ActionExitMode {
284 t.Fatalf("legacy escape missing from VimMode: found=%v action=%q", found, action)
285 }
286}
287
288func assertOwnedBindingsAbsentFromVimMode(t *testing.T, set *ShortcutSet) {
289 t.Helper()
290
291 for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
292 if binding, parsed := ParseKeyString(key); parsed {
293 if action, found := set.VimMode[binding]; found {
294 t.Fatalf("owned binding %q leaked into VimMode as %q", key, action)
295 }
296 }
297 }
298}
299
300func assertVimModeTrieYankSection(t *testing.T, set *ShortcutSet) {
301 t.Helper()
302
303 seq, err := vimkeys.ParseBinding("yah")
304 if err != nil {
305 t.Fatalf("ParseBinding(yah) error = %v", err)
306 }
307 node, walked := set.VimModeSequences().Walk(seq)
308 if !walked || !node.Exact || node.Action != "yank-section" {
309 t.Fatalf("VimModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
310 }
311}
312
313func TestShortcutSet_VimModeSequencesOwnership(t *testing.T) {
314 set := vimModeSequencesOwnershipFixture(t)
315
316 t.Run("legacy bindings", func(t *testing.T) {
317 assertLegacyVimModeBindings(t, set)
318 })
319 t.Run("owned bindings absent from legacy table", func(t *testing.T) {
320 assertOwnedBindingsAbsentFromVimMode(t, set)
321 })
322 t.Run("trie resolves owned sequence", func(t *testing.T) {
323 assertVimModeTrieYankSection(t, set)
324 })
325}
326
327func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
328 ctx := context.Background()
329 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
330 "heading-next": {Keys: []string{"]]"}},
331 "vim-scroll-down": {Keys: []string{"j"}},
332 "cancel": {Keys: []string{"escape"}},
333 })
334 h := NewKeyboardHandler(ctx, ws, newTestSession())
335 pending := capturePending(h)
336 actions := captureSequenceActions(h)
337 enterVimMode(t, h)
338
339 if !h.handleKeyPress(uint(']'), 0, 0) {
340 t.Fatal("] prefix should be consumed")
341 }
342 if h.PendingSequence() != "]" {
343 t.Fatalf("PendingSequence() = %q, want %q", h.PendingSequence(), "]")
344 }
345 if len(*pending) != 1 || (*pending)[0] != "]" {
346 t.Fatalf("pending notifications = %#v, want [\"]\"]", *pending)
347 }
348
349 if !h.handleKeyPress(uint(']'), 0, 0) {
350 t.Fatal("]] should be consumed")
351 }
352 if h.PendingSequence() != "" {
353 t.Fatalf("PendingSequence after complete = %q, want empty", h.PendingSequence())
354 }
355 if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 0 {
356 t.Fatalf("actions = %#v, want heading-next count 0", *actions)
357 }
358 if len(*pending) < 2 || (*pending)[len(*pending)-1] != "" {
359 t.Fatalf("pending after complete = %#v, want trailing clear", *pending)
360 }
361}
362
363func TestFeedVimModeSequence_ConfiguredConfirmActivatesAndExits(t *testing.T) {
364 ctx := context.Background()
365 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
366 "confirm": {Keys: []string{"zz"}},
367 })
368 h := NewKeyboardHandler(ctx, ws, newTestSession())
369 actions := captureSequenceActions(h)
370 enterVimMode(t, h)
371
372 if !h.handleKeyPress(uint('z'), 0, 0) {
373 t.Fatal("configured confirm prefix should be consumed")
374 }
375 if !h.handleKeyPress(uint('z'), 0, 0) {
376 t.Fatal("configured confirm completion should be consumed")
377 }
378 if len(*actions) != 1 || (*actions)[0].action != "confirm" || (*actions)[0].count != 0 {
379 t.Fatalf("actions = %#v, want confirm count 0", *actions)
380 }
381 if h.Mode() != ModeNormal {
382 t.Fatalf("mode after configured confirm sequence = %v, want ModeNormal", h.Mode())
383 }
384}
385
386func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
387 ctx := context.Background()
388 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
389 "yank-section": {Keys: []string{"yah"}},
390 "vim-scroll-down": {Keys: []string{"j"}},
391 })
392 h := NewKeyboardHandler(ctx, ws, newTestSession())
393 pending := capturePending(h)
394 actions := captureSequenceActions(h)
395 enterVimMode(t, h)
396
397 for _, step := range []struct {
398 key uint
399 want string
400 }{
401 {uint('y'), "y"},
402 {uint('a'), "ya"},
403 {uint('h'), ""},
404 } {
405 if !h.handleKeyPress(step.key, 0, 0) {
406 t.Fatalf("key %c should be consumed", step.key)
407 }
408 if got := h.PendingSequence(); got != step.want {
409 t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
410 }
411 }
412 if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
413 t.Fatalf("actions = %#v, want yank-section", *actions)
414 }
415 if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
416 t.Fatalf("pending = %#v, want y then ya", *pending)
417 }
418}
419
420func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
421 ctx := context.Background()
422 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
423 "heading-next": {Keys: []string{"]]"}},
424 "vim-scroll-down": {Keys: []string{"j"}},
425 })
426 h := NewKeyboardHandler(ctx, ws, newTestSession())
427 var scrollCalls int
428 h.SetOnAction(func(_ context.Context, action Action) error {
429 if action == ActionVimScrollDown {
430 scrollCalls++
431 }
432 return nil
433 })
434 enterVimMode(t, h)
435
436 // Unknown sequence key falls through; legacy j still works.
437 if !h.handleKeyPress(uint('j'), 0, 0) {
438 t.Fatal("legacy j should be handled")
439 }
440 if scrollCalls != 1 {
441 t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
442 }
443 if h.PendingSequence() != "" {
444 t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
445 }
446}
447
448func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
449 ctx := context.Background()
450 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
451 "heading-next": {Keys: []string{"]]"}},
452 })
453 h := NewKeyboardHandler(ctx, ws, newTestSession())
454 pending := capturePending(h)
455
456 if h.feedVimModeSequence(uint(']'), 0) {
457 t.Fatal("feed outside vim mode should return false")
458 }
459 if len(*pending) != 0 {
460 t.Fatalf("pending = %#v, want none", *pending)
461 }
462}
463
464func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
465 ctx := context.Background()
466 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
467 "yank-section": {Keys: []string{"yah"}},
468 "cancel": {Keys: []string{"escape"}},
469 })
470 h := NewKeyboardHandler(ctx, ws, newTestSession())
471 pending := capturePending(h)
472 enterVimMode(t, h)
473
474 if !h.handleKeyPress(uint('y'), 0, 0) {
475 t.Fatal("y should be consumed")
476 }
477 before := len(*pending)
478
479 if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
480 t.Fatal("escape should exit")
481 }
482 if h.Mode() != ModeNormal {
483 t.Fatalf("mode = %v, want ModeNormal", h.Mode())
484 }
485 if h.PendingSequence() != "" {
486 t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
487 }
488 if len(*pending) != before {
489 t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
490 }
491}
492
493func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
494 ctx := context.Background()
495 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
496 "heading-next": {Keys: []string{"]]"}},
497 })
498 h := NewKeyboardHandler(ctx, ws, newTestSession())
499 actions := captureSequenceActions(h)
500 enterVimMode(t, h)
501
502 if !h.handleKeyPress(uint('2'), 0, 0) {
503 t.Fatal("count digit should be consumed")
504 }
505 if h.PendingSequence() != "2" {
506 t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
507 }
508 if !h.handleKeyPress(uint(']'), 0, 0) {
509 t.Fatal("first ] after count should be consumed")
510 }
511 if !h.handleKeyPress(uint(']'), 0, 0) {
512 t.Fatal("2]] should complete")
513 }
514 if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
515 t.Fatalf("actions = %#v, want heading-next count 2", *actions)
516 }
517}
518
519func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
520 ctx := context.Background()
521 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
522 "bracket-c": {Keys: []string{"]c"}},
523 "bracket-cc": {Keys: []string{"]cc"}},
524 })
525 h := NewKeyboardHandler(ctx, ws, newTestSession())
526 actions := captureSequenceActions(h)
527
528 var fired func()
529 var stops int
530 h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
531 if d != 500*time.Millisecond {
532 t.Fatalf("ambiguity timeout = %v, want 500ms", d)
533 }
534 fired = fn
535 return stubSequenceTimer{onStop: func() { stops++ }}
536 }
537 var queued []func()
538 h.SetSequenceMainThreadScheduler(func(fn func()) {
539 queued = append(queued, fn)
540 })
541
542 enterVimMode(t, h)
543 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
544 t.Fatal("]c prefix should be consumed")
545 }
546 if !h.seq.matcher.Ambiguous() {
547 t.Fatal("]c should be ambiguous against ]cc")
548 }
549 if fired == nil {
550 t.Fatal("expected ambiguity timer arm")
551 }
552
553 fired() // timer expiry queues resolve on scheduler
554 if len(queued) != 1 {
555 t.Fatalf("queued resolves = %d, want 1", len(queued))
556 }
557 queued[0]()
558 if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
559 t.Fatalf("actions = %#v, want bracket-c", *actions)
560 }
561 if h.PendingSequence() != "" {
562 t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
563 }
564 _ = stops
565}
566
567func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
568 ctx := context.Background()
569 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
570 "bracket-c": {Keys: []string{"]c"}},
571 "bracket-cc": {Keys: []string{"]cc"}},
572 })
573 ws.VimMode.SequenceTimeoutMilliseconds = 0
574 h := NewKeyboardHandler(ctx, ws, newTestSession())
575 actions := captureSequenceActions(h)
576
577 var armed time.Duration = -1
578 h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
579 armed = d
580 fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
581 return stubSequenceTimer{}
582 }
583 var queued []func()
584 h.SetSequenceMainThreadScheduler(func(fn func()) {
585 queued = append(queued, fn)
586 })
587
588 enterVimMode(t, h)
589 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
590 t.Fatal("]c should be consumed")
591 }
592 if armed != 0 {
593 t.Fatalf("armed timeout = %v, want 0", armed)
594 }
595 if len(queued) != 1 {
596 t.Fatalf("queued = %d, want 1", len(queued))
597 }
598 queued[0]()
599 if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
600 t.Fatalf("actions = %#v, want bracket-c", *actions)
601 }
602}
603
604func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
605 ctx := context.Background()
606 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
607 "bracket-c": {Keys: []string{"]c"}},
608 "bracket-cc": {Keys: []string{"]cc"}},
609 })
610 h := NewKeyboardHandler(ctx, ws, newTestSession())
611 actions := captureSequenceActions(h)
612
613 var fired func()
614 var stops int
615 h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
616 fired = fn
617 return stubSequenceTimer{onStop: func() { stops++ }}
618 }
619 h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
620
621 enterVimMode(t, h)
622 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
623 t.Fatal("]c should be consumed")
624 }
625 if fired == nil {
626 t.Fatal("expected timer")
627 }
628 if !h.handleKeyPress(uint('c'), 0, 0) {
629 t.Fatal("]cc should complete")
630 }
631 if stops == 0 {
632 t.Fatal("next key must Stop ambiguity timer")
633 }
634 if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
635 t.Fatalf("actions = %#v, want bracket-cc", *actions)
636 }
637 // Stale timer must no-op.
638 fired()
639 if len(*actions) != 1 {
640 t.Fatalf("stale timer mutated actions: %#v", *actions)
641 }
642}
643
644func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
645 ctx := context.Background()
646 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
647 "bracket-c": {Keys: []string{"]c"}},
648 "bracket-cc": {Keys: []string{"]cc"}},
649 "yank-section": {Keys: []string{"yah"}},
650 })
651 h := NewKeyboardHandler(ctx, ws, newTestSession())
652 actions := captureSequenceActions(h)
653 pending := capturePending(h)
654
655 var fired func()
656 h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
657 fired = fn
658 return stubSequenceTimer{}
659 }
660 var queued []func()
661 h.SetSequenceMainThreadScheduler(func(fn func()) {
662 queued = append(queued, fn)
663 })
664
665 enterVimMode(t, h)
666 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
667 t.Fatal("]c should be consumed")
668 }
669 fired() // enqueue stale resolve
670 if len(queued) != 1 {
671 t.Fatalf("queued = %d, want 1", len(queued))
672 }
673 stale := queued[0]
674 queued = nil
675
676 // Interleave: feed/reset/reload before stale closure runs.
677 h.ResetPendingSequence()
678 if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
679 t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
680 }
681 stale()
682 if len(*actions) != 0 {
683 t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
684 }
685
686 // Reload path also invalidates queued resolve.
687 fired = nil
688 queued = nil
689 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
690 t.Fatal("]c again")
691 }
692 fired()
693 stale = queued[0]
694 h.ReloadShortcuts(ctx, ws, newTestSession())
695 stale()
696 if len(*actions) != 0 {
697 t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
698 }
699}
700
701func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
702 ctx := context.Background()
703 wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
704 "action-a": {Keys: []string{"yah"}},
705 "vim-scroll-down": {Keys: []string{"j"}},
706 })
707 h := NewKeyboardHandler(ctx, wsA, newTestSession())
708 actions := captureSequenceActions(h)
709 enterVimMode(t, h)
710
711 if !h.handleKeyPress(uint('y'), 0, 0) {
712 t.Fatal("y should pending under trie A")
713 }
714 if h.PendingSequence() != "y" {
715 t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
716 }
717
718 wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
719 "action-b": {Keys: []string{"gO"}},
720 "vim-scroll-down": {Keys: []string{"j"}},
721 })
722 h.ReloadShortcuts(ctx, wsB, newTestSession())
723 if h.PendingSequence() != "" {
724 t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
725 }
726
727 // Old A sequence must not complete; fall through / invalid.
728 if h.feedVimModeSequence(uint('a'), 0) {
729 t.Fatal("stale A continuation must not be consumed after reload")
730 }
731 if len(*actions) != 0 {
732 t.Fatalf("no A action after reload, got %#v", *actions)
733 }
734
735 // New B sequence works.
736 if !h.handleKeyPress(uint('g'), 0, 0) {
737 t.Fatal("g should pending under trie B")
738 }
739 if h.PendingSequence() != "g" {
740 t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
741 }
742 if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
743 t.Fatal("gO should complete under trie B")
744 }
745 if len(*actions) != 1 || (*actions)[0].action != "action-b" {
746 t.Fatalf("actions = %#v, want action-b", *actions)
747 }
748}