1package dispatcher
2
3import (
4 "context"
5 "fmt"
6 "testing"
7
8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/port/mocks"
10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/ui/coordinator"
13 "github.com/bnema/dumber/internal/ui/input"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16)
17
18func TestKeyboardDispatcher_TabActionsUseInjectedKeyboardActions(t *testing.T) {
19 ctx := context.Background()
20 called := make([]string, 0)
21 var switchedIndex int
22 d := NewKeyboardDispatcher(
23 ctx,
24 &coordinator.WorkspaceCoordinator{},
25 &coordinator.NavigationCoordinator{},
26 nil,
27 nil,
28 KeyboardActions{
29 NewTab: func(context.Context) error {
30 called = append(called, "new")
31 return nil
32 },
33 CloseTab: func(context.Context) error {
34 called = append(called, "close")
35 return nil
36 },
37 NextTab: func(context.Context) error {
38 called = append(called, "next")
39 return nil
40 },
41 PreviousTab: func(context.Context) error {
42 called = append(called, "previous")
43 return nil
44 },
45 SwitchLastTab: func(context.Context) error {
46 called = append(called, "last")
47 return nil
48 },
49 SwitchTabIndex: func(_ context.Context, index int) error {
50 called = append(called, "index")
51 switchedIndex = index
52 return nil
53 },
54 },
55 func(context.Context) entity.PaneID { return "" },
56 )
57
58 require.NoError(t, d.Dispatch(ctx, input.ActionNewTab))
59 require.NoError(t, d.Dispatch(ctx, input.ActionCloseTab))
60 require.NoError(t, d.Dispatch(ctx, input.ActionNextTab))
61 require.NoError(t, d.Dispatch(ctx, input.ActionPreviousTab))
62 require.NoError(t, d.Dispatch(ctx, input.ActionSwitchLastTab))
63 require.NoError(t, d.Dispatch(ctx, input.ActionSwitchTabIndex5))
64
65 assert.Equal(t, []string{"new", "close", "next", "previous", "last", "index"}, called)
66 assert.Equal(t, 4, switchedIndex)
67}
68
69func TestKeyboardDispatcher_ToggleHistorySidebarCallsCallback(t *testing.T) {
70 ctx := context.Background()
71 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
72
73 var called bool
74 d.SetOnToggleHistorySidebar(func(context.Context) error {
75 called = true
76 return nil
77 })
78
79 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
80 require.NoError(t, err)
81 assert.True(t, called, "onToggleHistorySidebar should have been called")
82}
83
84func TestKeyboardDispatcher_ToggleHistorySystemViewReturnsErrorWhenHandlerMissing(t *testing.T) {
85 ctx := context.Background()
86 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
87
88 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
89 require.Error(t, err)
90 assert.ErrorContains(t, err, "history sidebar unavailable")
91}
92
93func TestKeyboardDispatcher_ToggleHistorySidebarErrorPropagation(t *testing.T) {
94 ctx := context.Background()
95 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
96
97 wantErr := fmt.Errorf("sidebar toggle failed")
98 d.SetOnToggleHistorySidebar(func(context.Context) error {
99 return wantErr
100 })
101
102 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
103 require.Error(t, err)
104 assert.ErrorIs(t, err, wantErr, "onToggleHistorySidebar error should propagate")
105}
106
107func TestKeyboardDispatcher_ToggleHistorySidebarSetThenUnsetReturnsError(t *testing.T) {
108 ctx := context.Background()
109 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
110
111 d.SetOnToggleHistorySidebar(func(context.Context) error {
112 return nil
113 })
114 d.SetOnToggleHistorySidebar(nil)
115
116 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
117 require.Error(t, err)
118 assert.ErrorContains(t, err, "history sidebar unavailable")
119}
120
121func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErrors(t *testing.T) {
122 ctx := context.Background()
123 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
124
125 missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
126 require.Error(t, missingErr)
127 require.ErrorContains(t, missingErr, "favorites sidebar unavailable")
128
129 wantErr := fmt.Errorf("favorites failed")
130 d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
131 err := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
132 require.Error(t, err)
133 require.ErrorIs(t, err, wantErr)
134
135 var called bool
136 d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })
137 require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSidebar))
138 assert.True(t, called)
139}
140
141func TestKeyboardDispatcher_ToggleCurrentPageFavoriteCallsCallbackAndPropagatesErrors(t *testing.T) {
142 ctx := context.Background()
143 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
144
145 missingErr := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
146 require.Error(t, missingErr)
147 require.ErrorContains(t, missingErr, "toggle current page handler not wired")
148
149 wantErr := fmt.Errorf("toggle failed")
150 d.SetOnToggleCurrentPageFavorite(func(context.Context) error { return wantErr })
151 err := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
152 require.Error(t, err)
153 require.ErrorIs(t, err, wantErr)
154
155 var called bool
156 d.SetOnToggleCurrentPageFavorite(func(context.Context) error { called = true; return nil })
157 require.NoError(t, d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite))
158 assert.True(t, called)
159}
160
161func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
162 ctx := context.Background()
163 activePaneID := entity.PaneID("pane-1")
164 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID {
165 return activePaneID
166 })
167
168 tests := []struct {
169 name string
170 set func(func(context.Context, entity.PaneID) error)
171 invoke func(context.Context) error
172 }{
173 {
174 name: "session open",
175 set: d.SetOnSessionOpen,
176 invoke: d.handleSessionOpen,
177 },
178 {
179 name: "move pane to tab",
180 set: d.SetOnMovePaneToTab,
181 invoke: d.handleMovePaneToTab,
182 },
183 {
184 name: "move pane to next tab",
185 set: d.SetOnMovePaneToNextTab,
186 invoke: d.handleMovePaneToNextTab,
187 },
188 {
189 name: "eject pane to window",
190 set: d.SetOnEjectPaneToWindow,
191 invoke: d.handleEjectPaneToWindow,
192 },
193 }
194
195 for _, tc := range tests {
196 t.Run(tc.name, func(t *testing.T) {
197 var gotPaneID entity.PaneID
198 tc.set(func(_ context.Context, paneID entity.PaneID) error {
199 gotPaneID = paneID
200 return nil
201 })
202
203 require.NoError(t, tc.invoke(ctx))
204 assert.Equal(t, activePaneID, gotPaneID)
205 })
206 }
207}
208
209type mockScrollableWebView struct {
210 *mocks.MockWebView
211 *mocks.MockPageScrollable
212}
213
214type lifecycleScrollableWebView struct {
215 *mockScrollableWebView
216 cancelCalls int
217}
218
219func (wv *lifecycleScrollableWebView) CancelPageScroll(context.Context) {
220 wv.cancelCalls++
221}
222
223func TestKeyboardDispatcher_VimModeActionsRouteToCorrectScrollCommand(t *testing.T) {
224 ctx := context.Background()
225
226 tests := []struct {
227 action input.Action
228 command port.PageScrollCommand
229 expectedDx int
230 expectedDy int
231 }{
232 {input.ActionVimScrollLeft, port.PageScrollCommandLeft, -80, 0},
233 {input.ActionVimScrollRight, port.PageScrollCommandRight, 80, 0},
234 {input.ActionVimScrollUp, port.PageScrollCommandUp, 0, -80},
235 {input.ActionVimScrollDown, port.PageScrollCommandDown, 0, 80},
236 {input.ActionVimScrollUpFast, port.PageScrollCommandUpFast, 0, -320},
237 {input.ActionVimScrollDownFast, port.PageScrollCommandDownFast, 0, 320},
238 }
239
240 for _, tc := range tests {
241 t.Run(string(tc.action), func(t *testing.T) {
242 base := mocks.NewMockWebView(t)
243 scroller := mocks.NewMockPageScrollable(t)
244 wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}
245
246 navCoord := &coordinator.NavigationCoordinator{}
247 navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
248
249 d := NewKeyboardDispatcher(
250 ctx,
251 &coordinator.WorkspaceCoordinator{},
252 navCoord,
253 nil,
254 nil,
255 KeyboardActions{
256 ActiveWebView: func(context.Context) port.WebView { return wv },
257 },
258 func(context.Context) entity.PaneID { return "" },
259 )
260
261 base.EXPECT().ID().Return(port.WebViewID(42)).Once()
262 req := port.PageScrollRequest{Command: tc.command, FallbackDX: tc.expectedDx, FallbackDY: tc.expectedDy}
263 scroller.EXPECT().ScrollPage(ctx, req).Return(nil).Once()
264
265 err := d.Dispatch(ctx, tc.action)
266 require.NoError(t, err)
267 })
268 }
269}
270
271func TestKeyboardDispatcher_VimScrollLifecycleRoutesContinuousAndStop(t *testing.T) {
272 ctx := context.Background()
273 base := mocks.NewMockWebView(t)
274 scroller := mocks.NewMockPageScrollable(t)
275 wv := &lifecycleScrollableWebView{mockScrollableWebView: &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}}
276 navCoord := &coordinator.NavigationCoordinator{}
277 navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
278 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, navCoord, nil, nil, KeyboardActions{
279 ActiveWebView: func(context.Context) port.WebView { return wv },
280 }, func(context.Context) entity.PaneID { return "" })
281
282 scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{
283 Command: port.PageScrollCommandDown, FallbackDY: 80, Continuous: true,
284 }).Return(nil).Once()
285 require.NoError(t, d.DispatchVimScrollLifecycle(ctx, input.ActionVimScrollDown, input.VimScrollContinuous))
286 require.NoError(t, d.DispatchVimScrollLifecycle(ctx, input.ActionVimScrollDown, input.VimScrollStop))
287 assert.Equal(t, 1, wv.cancelCalls)
288}
289
290func TestKeyboardDispatcher_VimModeNoopWhenNoActiveWebView(t *testing.T) {
291 ctx := context.Background()
292
293 navCoord := &coordinator.NavigationCoordinator{}
294 navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
295
296 d := NewKeyboardDispatcher(
297 ctx,
298 &coordinator.WorkspaceCoordinator{},
299 navCoord,
300 nil,
301 nil,
302 KeyboardActions{},
303 func(context.Context) entity.PaneID { return "" },
304 )
305
306 // No ActiveWebView set — dispatcher should no-op cleanly
307 err := d.Dispatch(ctx, input.ActionVimScrollDown)
308 require.NoError(t, err)
309}
310
311func TestKeyboardDispatcher_VimModeNoopWhenActiveWebViewReturnsNil(t *testing.T) {
312 ctx := context.Background()
313
314 navCoord := &coordinator.NavigationCoordinator{}
315 navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
316
317 d := NewKeyboardDispatcher(
318 ctx,
319 &coordinator.WorkspaceCoordinator{},
320 navCoord,
321 nil,
322 nil,
323 KeyboardActions{
324 ActiveWebView: func(context.Context) port.WebView { return nil },
325 },
326 func(context.Context) entity.PaneID { return "" },
327 )
328
329 err := d.Dispatch(ctx, input.ActionVimScrollDown)
330 require.NoError(t, err)
331}
332
333func TestVimScrollSpec_IsSingleSourceForCommandAndPulse(t *testing.T) {
334 tests := []struct {
335 action input.Action
336 cmd usecase.PageScrollCommand
337 fast bool
338 }{
339 {input.ActionVimScrollLeft, usecase.PageScrollLeft, false},
340 {input.ActionVimScrollRight, usecase.PageScrollRight, false},
341 {input.ActionVimScrollUp, usecase.PageScrollUp, false},
342 {input.ActionVimScrollDown, usecase.PageScrollDown, false},
343 {input.ActionVimScrollUpFast, usecase.PageScrollUpFast, true},
344 {input.ActionVimScrollDownFast, usecase.PageScrollDownFast, true},
345 }
346 for _, tc := range tests {
347 spec, ok := vimScrollSpec(tc.action)
348 require.True(t, ok, string(tc.action))
349 assert.Equal(t, tc.cmd, spec.cmd)
350 assert.Equal(t, tc.fast, spec.fast)
351 cmd, ok := vimScrollCommand(tc.action)
352 require.True(t, ok)
353 assert.Equal(t, tc.cmd, cmd)
354 }
355 _, ok := vimScrollSpec(input.ActionQuit)
356 assert.False(t, ok)
357}
358
359func TestKeyboardDispatcher_VimScrollTapUsesSharedSpecPulse(t *testing.T) {
360 ctx := context.Background()
361 base := mocks.NewMockWebView(t)
362 scroller := mocks.NewMockPageScrollable(t)
363 wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}
364 navCoord := &coordinator.NavigationCoordinator{}
365 navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
366 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, navCoord, nil, nil, KeyboardActions{
367 ActiveWebView: func(context.Context) port.WebView { return wv },
368 }, func(context.Context) entity.PaneID { return "" })
369
370 var pulses []bool
371 d.SetOnVimModePulse(func(_ context.Context, fast bool) {
372 pulses = append(pulses, fast)
373 })
374
375 base.EXPECT().ID().Return(port.WebViewID(7)).Twice()
376 scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{
377 Command: port.PageScrollCommandDown, FallbackDY: 80,
378 }).Return(nil).Once()
379 scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{
380 Command: port.PageScrollCommandDownFast, FallbackDY: 320,
381 }).Return(nil).Once()
382
383 require.NoError(t, d.Dispatch(ctx, input.ActionVimScrollDown))
384 require.NoError(t, d.Dispatch(ctx, input.ActionVimScrollDownFast))
385 assert.Equal(t, []bool{false, true}, pulses)
386}