1package ui
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/ui/input"
14)
15
16// newVimPageTestWindow builds a window in Vim Mode with a mode toaster bound
17// to the pending-sequence callback, as initChrome does. It records every header
18// the pill shows so tests can assert what the user sees.
19func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) {
20 t.Helper()
21 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
22 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
23 ModeIndicatorToasterEnabled: true,
24 ModeLegend: "off",
25 }}},
26 })}
27 bw, view := newFakeModeToasterWindow("win-1")
28 app.browserWindows = map[string]*browserWindow{bw.id: bw}
29 bw.keyboardHandler = newKeyboardHandlerInVimMode(t)
30 app.bindVimModeSequenceToaster(context.Background(), bw)
31 app.updateModeFrame(bw, input.ModeVim)
32 return app, bw, &view.headers
33}
34
35var visualOutcome = hintsOutcome(dto.VimPageVisual)
36
37func hintsOutcome(kind dto.VimPageInteractionKind) dto.VimNavigationOutcome {
38 return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: kind, PageMode: dto.VimPageModeHints}
39}
40
41// Starting a key-capturing interaction must leave the sub-mode label on the
42// toast. The capture path used to reset the pending sequence with
43// notification, which repainted the toast back to plain "VIM MODE" in the
44// same instant and made the interaction look as if it had ended.
45func TestCaptureVimPageKeysKeepsSubModeLabelOnToast(t *testing.T) {
46 app, bw, shown := newVimPageTestWindow(t)
47 wv := portmocks.NewMockWebView(t)
48
49 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
50
51 require.NotEmpty(t, *shown)
52 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1], "every interaction opens on hints")
53 require.True(t, bw.keyboardHandler.PageKeyCaptureActive())
54}
55
56// A completed sequence is reported with an empty pending string before its
57// action runs. When that action starts a page interaction, the stale pending
58// notification must not reach the toast after the sub-mode label.
59func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) {
60 app, bw, shown := newVimPageTestWindow(t)
61 wv := portmocks.NewMockWebView(t)
62
63 app.showPendingSequence(context.Background(), bw, "")
64 app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollow))
65 // Any pending notification emitted while the page owns the keys is noise.
66 app.showPendingSequence(context.Background(), bw, "")
67
68 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
69}
70
71// The legend is a key reference for Vim Mode bindings; while the page owns the
72// keys it would describe keys that do not apply, so it must be hidden and
73// restored when the interaction ends.
74func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
75 app, bw, _ := newVimPageTestWindow(t)
76 toaster := bw.modeToaster
77 wv := portmocks.NewMockWebView(t)
78
79 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
80 require.True(t, toaster.suspended, "legend must be suspended while the page captures keys")
81
82 wv.EXPECT().ID().Return(1).Maybe()
83 app.endVimPageInteraction(context.Background(), bw, true)
84 require.False(t, toaster.suspended, "legend must be restored when the interaction ends")
85 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
86}
87
88// The page moves between hints, caret, visual and visual line without ending
89// the interaction: the toast follows each state while key capture and the
90// suspended legend stay in place.
91func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
92 app, bw, shown := newVimPageTestWindow(t)
93 toaster := bw.modeToaster
94 wv := portmocks.NewMockWebView(t)
95 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
96 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
97
98 for mode, want := range map[dto.VimPageMode]string{
99 dto.VimPageModeCaret: "VIM MODE · CARET",
100 dto.VimPageModeVisual: "VIM MODE · VISUAL",
101 dto.VimPageModeVisualLine: "VIM MODE · VISUAL LINE",
102 dto.VimPageModeHints: "VIM MODE · HINTS",
103 } {
104 app.showVimPageMode(context.Background(), bw, mode)
105 require.Equal(t, want, (*shown)[len(*shown)-1])
106 require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode)
107 require.NotNil(t, bw.vimPageInteractionWebView)
108 require.True(t, toaster.suspended, "legend stays hidden while the page owns the keys")
109 }
110}
111
112func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) {
113 app, bw, shown := newVimPageTestWindow(t)
114 before := len(*shown)
115
116 app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret)
117
118 require.Len(t, *shown, before, "a late mode report must not repaint the indicator once the interaction ended")
119}
120
121// exitModeWithin runs fn and fails the test if it does not return in time,
122// so a lock re-entry deadlock shows up as a failure instead of a hung run.
123func exitModeWithin(t *testing.T, fn func()) {
124 t.Helper()
125 done := make(chan struct{})
126 go func() {
127 defer close(done)
128 fn()
129 }()
130 select {
131 case <-done:
132 case <-time.After(3 * time.Second):
133 t.Fatal("ExitMode did not return: mode-change callback re-entered the modal lock")
134 }
135}
136
137// ExitMode runs the mode-change callback under the modal lock. Releasing the
138// page capture from that callback must not read the mode back through the lock.
139func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) {
140 app, bw, _ := newVimPageTestWindow(t)
141 bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
142 app.handleModeChange(context.Background(), bw, from, to)
143 })
144 wv := portmocks.NewMockWebView(t)
145 wv.EXPECT().ID().Return(1).Maybe()
146 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
147
148 exitModeWithin(t, bw.keyboardHandler.ExitMode)
149
150 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
151 require.Nil(t, bw.vimPageInteractionWebView)
152 require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode())
153}
154
155func TestVimPageModeLabel(t *testing.T) {
156 for mode, want := range map[dto.VimPageMode]string{
157 dto.VimPageModeHints: "HINTS",
158 dto.VimPageModeCaret: "CARET",
159 dto.VimPageModeVisual: "VISUAL",
160 dto.VimPageModeVisualLine: "VISUAL LINE",
161 } {
162 got, ok := vimPageModeLabel(mode)
163 require.True(t, ok, mode)
164 require.Equal(t, want, got)
165 }
166 _, ok := vimPageModeLabel("bogus")
167 require.False(t, ok)
168}
169
170// One mapping drives every label: the sub-mode, plus the target of hints that
171// do more than follow. Later page-reported modes drop the hint target.
172func TestVimPageLabel(t *testing.T) {
173 tests := []struct {
174 kind dto.VimPageInteractionKind
175 mode dto.VimPageMode
176 want string
177 }{
178 {dto.VimPageVisual, dto.VimPageModeHints, "HINTS"},
179 {dto.VimPageHintFollow, dto.VimPageModeHints, "HINTS"},
180 {dto.VimPageHintFollowNew, dto.VimPageModeHints, "HINTS · NEW PANE"},
181 {dto.VimPageHintYankURL, dto.VimPageModeHints, "HINTS · YANK URL"},
182 {dto.VimPageVisual, dto.VimPageModeCaret, "CARET"},
183 {dto.VimPageVisual, dto.VimPageModeVisual, "VISUAL"},
184 {dto.VimPageVisual, dto.VimPageModeVisualLine, "VISUAL LINE"},
185 }
186 for _, tt := range tests {
187 got, ok := vimPageLabel(tt.kind, tt.mode)
188 require.True(t, ok)
189 require.Equal(t, tt.want, got, "kind %d mode %s", tt.kind, tt.mode)
190 }
191 _, ok := vimPageLabel(dto.VimPageVisual, "bogus")
192 require.False(t, ok)
193}
194
195func TestVimPageInteractionLabelsFollowTheOutcome(t *testing.T) {
196 app, bw, shown := newVimPageTestWindow(t)
197 wv := portmocks.NewMockWebView(t)
198
199 app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollowNew))
200 require.Equal(t, "VIM MODE · HINTS · NEW PANE", (*shown)[len(*shown)-1])
201 app.releaseVimPageKeys(context.Background(), bw, "test", true)
202
203 app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintYankURL))
204 require.Equal(t, "VIM MODE · HINTS · YANK URL", (*shown)[len(*shown)-1])
205}