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 != ActionExitMode {
279 t.Fatalf("legacy enter 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_PendingYankNotifications(t *testing.T) {
364 ctx := context.Background()
365 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
366 "yank-section": {Keys: []string{"yah"}},
367 "vim-scroll-down": {Keys: []string{"j"}},
368 })
369 h := NewKeyboardHandler(ctx, ws, newTestSession())
370 pending := capturePending(h)
371 actions := captureSequenceActions(h)
372 enterVimMode(t, h)
373
374 for _, step := range []struct {
375 key uint
376 want string
377 }{
378 {uint('y'), "y"},
379 {uint('a'), "ya"},
380 {uint('h'), ""},
381 } {
382 if !h.handleKeyPress(step.key, 0, 0) {
383 t.Fatalf("key %c should be consumed", step.key)
384 }
385 if got := h.PendingSequence(); got != step.want {
386 t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
387 }
388 }
389 if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
390 t.Fatalf("actions = %#v, want yank-section", *actions)
391 }
392 if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
393 t.Fatalf("pending = %#v, want y then ya", *pending)
394 }
395}
396
397func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
398 ctx := context.Background()
399 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
400 "heading-next": {Keys: []string{"]]"}},
401 "vim-scroll-down": {Keys: []string{"j"}},
402 })
403 h := NewKeyboardHandler(ctx, ws, newTestSession())
404 var scrollCalls int
405 h.SetOnAction(func(_ context.Context, action Action) error {
406 if action == ActionVimScrollDown {
407 scrollCalls++
408 }
409 return nil
410 })
411 enterVimMode(t, h)
412
413 // Unknown sequence key falls through; legacy j still works.
414 if !h.handleKeyPress(uint('j'), 0, 0) {
415 t.Fatal("legacy j should be handled")
416 }
417 if scrollCalls != 1 {
418 t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
419 }
420 if h.PendingSequence() != "" {
421 t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
422 }
423}
424
425func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
426 ctx := context.Background()
427 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
428 "heading-next": {Keys: []string{"]]"}},
429 })
430 h := NewKeyboardHandler(ctx, ws, newTestSession())
431 pending := capturePending(h)
432
433 if h.feedVimModeSequence(uint(']'), 0) {
434 t.Fatal("feed outside vim mode should return false")
435 }
436 if len(*pending) != 0 {
437 t.Fatalf("pending = %#v, want none", *pending)
438 }
439}
440
441func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
442 ctx := context.Background()
443 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
444 "yank-section": {Keys: []string{"yah"}},
445 "cancel": {Keys: []string{"escape"}},
446 })
447 h := NewKeyboardHandler(ctx, ws, newTestSession())
448 pending := capturePending(h)
449 enterVimMode(t, h)
450
451 if !h.handleKeyPress(uint('y'), 0, 0) {
452 t.Fatal("y should be consumed")
453 }
454 before := len(*pending)
455
456 if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
457 t.Fatal("escape should exit")
458 }
459 if h.Mode() != ModeNormal {
460 t.Fatalf("mode = %v, want ModeNormal", h.Mode())
461 }
462 if h.PendingSequence() != "" {
463 t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
464 }
465 if len(*pending) != before {
466 t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
467 }
468}
469
470func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
471 ctx := context.Background()
472 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
473 "heading-next": {Keys: []string{"]]"}},
474 })
475 h := NewKeyboardHandler(ctx, ws, newTestSession())
476 actions := captureSequenceActions(h)
477 enterVimMode(t, h)
478
479 if !h.handleKeyPress(uint('2'), 0, 0) {
480 t.Fatal("count digit should be consumed")
481 }
482 if h.PendingSequence() != "2" {
483 t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
484 }
485 if !h.handleKeyPress(uint(']'), 0, 0) {
486 t.Fatal("first ] after count should be consumed")
487 }
488 if !h.handleKeyPress(uint(']'), 0, 0) {
489 t.Fatal("2]] should complete")
490 }
491 if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
492 t.Fatalf("actions = %#v, want heading-next count 2", *actions)
493 }
494}
495
496func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
497 ctx := context.Background()
498 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
499 "bracket-c": {Keys: []string{"]c"}},
500 "bracket-cc": {Keys: []string{"]cc"}},
501 })
502 h := NewKeyboardHandler(ctx, ws, newTestSession())
503 actions := captureSequenceActions(h)
504
505 var fired func()
506 var stops int
507 h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
508 if d != 500*time.Millisecond {
509 t.Fatalf("ambiguity timeout = %v, want 500ms", d)
510 }
511 fired = fn
512 return stubSequenceTimer{onStop: func() { stops++ }}
513 }
514 var queued []func()
515 h.SetSequenceMainThreadScheduler(func(fn func()) {
516 queued = append(queued, fn)
517 })
518
519 enterVimMode(t, h)
520 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
521 t.Fatal("]c prefix should be consumed")
522 }
523 if !h.seq.matcher.Ambiguous() {
524 t.Fatal("]c should be ambiguous against ]cc")
525 }
526 if fired == nil {
527 t.Fatal("expected ambiguity timer arm")
528 }
529
530 fired() // timer expiry queues resolve on scheduler
531 if len(queued) != 1 {
532 t.Fatalf("queued resolves = %d, want 1", len(queued))
533 }
534 queued[0]()
535 if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
536 t.Fatalf("actions = %#v, want bracket-c", *actions)
537 }
538 if h.PendingSequence() != "" {
539 t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
540 }
541 _ = stops
542}
543
544func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
545 ctx := context.Background()
546 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
547 "bracket-c": {Keys: []string{"]c"}},
548 "bracket-cc": {Keys: []string{"]cc"}},
549 })
550 ws.VimMode.SequenceTimeoutMilliseconds = 0
551 h := NewKeyboardHandler(ctx, ws, newTestSession())
552 actions := captureSequenceActions(h)
553
554 var armed time.Duration = -1
555 h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
556 armed = d
557 fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
558 return stubSequenceTimer{}
559 }
560 var queued []func()
561 h.SetSequenceMainThreadScheduler(func(fn func()) {
562 queued = append(queued, fn)
563 })
564
565 enterVimMode(t, h)
566 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
567 t.Fatal("]c should be consumed")
568 }
569 if armed != 0 {
570 t.Fatalf("armed timeout = %v, want 0", armed)
571 }
572 if len(queued) != 1 {
573 t.Fatalf("queued = %d, want 1", len(queued))
574 }
575 queued[0]()
576 if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
577 t.Fatalf("actions = %#v, want bracket-c", *actions)
578 }
579}
580
581func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
582 ctx := context.Background()
583 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
584 "bracket-c": {Keys: []string{"]c"}},
585 "bracket-cc": {Keys: []string{"]cc"}},
586 })
587 h := NewKeyboardHandler(ctx, ws, newTestSession())
588 actions := captureSequenceActions(h)
589
590 var fired func()
591 var stops int
592 h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
593 fired = fn
594 return stubSequenceTimer{onStop: func() { stops++ }}
595 }
596 h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
597
598 enterVimMode(t, h)
599 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
600 t.Fatal("]c should be consumed")
601 }
602 if fired == nil {
603 t.Fatal("expected timer")
604 }
605 if !h.handleKeyPress(uint('c'), 0, 0) {
606 t.Fatal("]cc should complete")
607 }
608 if stops == 0 {
609 t.Fatal("next key must Stop ambiguity timer")
610 }
611 if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
612 t.Fatalf("actions = %#v, want bracket-cc", *actions)
613 }
614 // Stale timer must no-op.
615 fired()
616 if len(*actions) != 1 {
617 t.Fatalf("stale timer mutated actions: %#v", *actions)
618 }
619}
620
621func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
622 ctx := context.Background()
623 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
624 "bracket-c": {Keys: []string{"]c"}},
625 "bracket-cc": {Keys: []string{"]cc"}},
626 "yank-section": {Keys: []string{"yah"}},
627 })
628 h := NewKeyboardHandler(ctx, ws, newTestSession())
629 actions := captureSequenceActions(h)
630 pending := capturePending(h)
631
632 var fired func()
633 h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
634 fired = fn
635 return stubSequenceTimer{}
636 }
637 var queued []func()
638 h.SetSequenceMainThreadScheduler(func(fn func()) {
639 queued = append(queued, fn)
640 })
641
642 enterVimMode(t, h)
643 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
644 t.Fatal("]c should be consumed")
645 }
646 fired() // enqueue stale resolve
647 if len(queued) != 1 {
648 t.Fatalf("queued = %d, want 1", len(queued))
649 }
650 stale := queued[0]
651 queued = nil
652
653 // Interleave: feed/reset/reload before stale closure runs.
654 h.ResetPendingSequence()
655 if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
656 t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
657 }
658 stale()
659 if len(*actions) != 0 {
660 t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
661 }
662
663 // Reload path also invalidates queued resolve.
664 fired = nil
665 queued = nil
666 if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
667 t.Fatal("]c again")
668 }
669 fired()
670 stale = queued[0]
671 h.ReloadShortcuts(ctx, ws, newTestSession())
672 stale()
673 if len(*actions) != 0 {
674 t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
675 }
676}
677
678func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
679 ctx := context.Background()
680 wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
681 "action-a": {Keys: []string{"yah"}},
682 "vim-scroll-down": {Keys: []string{"j"}},
683 })
684 h := NewKeyboardHandler(ctx, wsA, newTestSession())
685 actions := captureSequenceActions(h)
686 enterVimMode(t, h)
687
688 if !h.handleKeyPress(uint('y'), 0, 0) {
689 t.Fatal("y should pending under trie A")
690 }
691 if h.PendingSequence() != "y" {
692 t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
693 }
694
695 wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
696 "action-b": {Keys: []string{"gO"}},
697 "vim-scroll-down": {Keys: []string{"j"}},
698 })
699 h.ReloadShortcuts(ctx, wsB, newTestSession())
700 if h.PendingSequence() != "" {
701 t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
702 }
703
704 // Old A sequence must not complete; fall through / invalid.
705 if h.feedVimModeSequence(uint('a'), 0) {
706 t.Fatal("stale A continuation must not be consumed after reload")
707 }
708 if len(*actions) != 0 {
709 t.Fatalf("no A action after reload, got %#v", *actions)
710 }
711
712 // New B sequence works.
713 if !h.handleKeyPress(uint('g'), 0, 0) {
714 t.Fatal("g should pending under trie B")
715 }
716 if h.PendingSequence() != "g" {
717 t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
718 }
719 if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
720 t.Fatal("gO should complete under trie B")
721 }
722 if len(*actions) != 1 || (*actions)[0].action != "action-b" {
723 t.Fatalf("actions = %#v, want action-b", *actions)
724 }
725}