1package ui
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/puregotk/v4/glib"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/dumber/internal/application/dto"
14 "github.com/bnema/dumber/internal/application/port"
15 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
16 "github.com/bnema/dumber/internal/application/usecase"
17 "github.com/bnema/dumber/internal/domain/entity"
18 "github.com/bnema/dumber/internal/ui/component"
19 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
20 "github.com/bnema/dumber/internal/ui/input"
21 "github.com/bnema/dumber/internal/ui/layout"
22 "github.com/bnema/dumber/internal/ui/layout/mocks"
23 "github.com/bnema/dumber/internal/ui/theme"
24)
25
26// ---------------------------------------------------------------------------
27// Mock-based PaneView and WorkspaceView creation helpers
28//
29// These are used by vimModeTestFixture to build a minimal test environment
30// without any GTK infrastructure.
31// ---------------------------------------------------------------------------
32
33// newLoadingSkeletonMocks sets up the mock expectations for the loading
34// skeleton that NewPaneView creates internally.
35func newLoadingSkeletonMocks(
36 t *testing.T,
37 factory *mocks.MockWidgetFactory,
38 overlay *mocks.MockOverlayWidget,
39) {
40 t.Helper()
41
42 loadingContainer := mocks.NewMockBoxWidget(t)
43 loadingContent := mocks.NewMockBoxWidget(t)
44 spinner := mocks.NewMockSpinnerWidget(t)
45 logo := mocks.NewMockImageWidget(t)
46 version := mocks.NewMockLabelWidget(t)
47
48 factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(loadingContainer).Once()
49 loadingContainer.EXPECT().SetHexpand(true).Maybe()
50 loadingContainer.EXPECT().SetVexpand(true).Maybe()
51 loadingContainer.EXPECT().SetHalign(mock.Anything).Maybe()
52 loadingContainer.EXPECT().SetValign(mock.Anything).Maybe()
53 loadingContainer.EXPECT().SetCanFocus(false).Maybe()
54 loadingContainer.EXPECT().SetCanTarget(false).Maybe()
55 loadingContainer.EXPECT().AddCssClass(mock.Anything).Maybe()
56 loadingContainer.EXPECT().SetVisible(mock.Anything).Maybe()
57
58 factory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(loadingContent).Once()
59 loadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
60 loadingContent.EXPECT().SetValign(mock.Anything).Maybe()
61 loadingContent.EXPECT().SetCanFocus(false).Maybe()
62 loadingContent.EXPECT().SetCanTarget(false).Maybe()
63 loadingContent.EXPECT().AddCssClass(mock.Anything).Maybe()
64
65 factory.EXPECT().NewImage().Return(logo).Once()
66 logo.EXPECT().SetHalign(mock.Anything).Maybe()
67 logo.EXPECT().SetValign(mock.Anything).Maybe()
68 logo.EXPECT().SetCanFocus(false).Maybe()
69 logo.EXPECT().SetCanTarget(false).Maybe()
70 logo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
71 logo.EXPECT().SetPixelSize(mock.Anything).Maybe()
72 logo.EXPECT().AddCssClass(mock.Anything).Maybe()
73 logo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
74
75 factory.EXPECT().NewSpinner().Return(spinner).Once()
76 spinner.EXPECT().SetHalign(mock.Anything).Maybe()
77 spinner.EXPECT().SetValign(mock.Anything).Maybe()
78 spinner.EXPECT().SetCanFocus(false).Maybe()
79 spinner.EXPECT().SetCanTarget(false).Maybe()
80 spinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
81 spinner.EXPECT().AddCssClass(mock.Anything).Maybe()
82 spinner.EXPECT().Start().Maybe()
83 spinner.EXPECT().Stop().Maybe()
84
85 factory.EXPECT().NewLabel(mock.Anything).Return(version).Once()
86 version.EXPECT().SetHalign(mock.Anything).Maybe()
87 version.EXPECT().SetValign(mock.Anything).Maybe()
88 version.EXPECT().SetCanFocus(false).Maybe()
89 version.EXPECT().SetCanTarget(false).Maybe()
90 version.EXPECT().SetMaxWidthChars(mock.Anything).Maybe()
91 version.EXPECT().SetEllipsize(mock.Anything).Maybe()
92 version.EXPECT().AddCssClass(mock.Anything).Maybe()
93
94 loadingContent.EXPECT().Append(logo).Maybe()
95 loadingContent.EXPECT().Append(spinner).Maybe()
96 loadingContent.EXPECT().Append(version).Maybe()
97 loadingContainer.EXPECT().Append(loadingContent).Maybe()
98
99 overlay.EXPECT().AddOverlay(loadingContainer).Once()
100 overlay.EXPECT().SetClipOverlay(loadingContainer, false).Once()
101 overlay.EXPECT().SetMeasureOverlay(loadingContainer, false).Once()
102}
103
104// createTestPaneView builds a mock-backed PaneView. Returns the PaneView and
105// its overlay mock so subsequent vim-mode indicator / pulse expectations can
106// be set up by the caller.
107func createTestPaneView(
108 t *testing.T,
109 factory *mocks.MockWidgetFactory,
110 paneID entity.PaneID,
111) (*component.PaneView, *mocks.MockOverlayWidget) {
112 t.Helper()
113
114 overlay := mocks.NewMockOverlayWidget(t)
115 borderBox := mocks.NewMockBoxWidget(t)
116
117 // Overlay
118 factory.EXPECT().NewOverlay().Return(overlay).Once()
119 overlay.EXPECT().SetHexpand(true).Once()
120 overlay.EXPECT().SetVexpand(true).Once()
121 overlay.EXPECT().SetVisible(true).Once()
122 overlay.EXPECT().AddCssClass("pane-overlay").Once()
123
124 // Loading skeleton
125 newLoadingSkeletonMocks(t, factory, overlay)
126
127 // Border box
128 factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(borderBox).Once()
129 borderBox.EXPECT().SetCanFocus(false).Once()
130 borderBox.EXPECT().SetCanTarget(false).Once()
131 borderBox.EXPECT().AddCssClass("pane-border").Once()
132 borderBox.EXPECT().SetHexpand(true).Once()
133 borderBox.EXPECT().SetVexpand(true).Once()
134 overlay.EXPECT().AddOverlay(borderBox).Once()
135 overlay.EXPECT().SetClipOverlay(borderBox, false).Once()
136 overlay.EXPECT().SetMeasureOverlay(borderBox, false).Once()
137
138 pv := component.NewPaneView(context.Background(), factory, paneID, nil)
139 require.NotNil(t, pv)
140
141 return pv, overlay
142}
143
144// enterVimMode marks the owning pane with the pane-local visual accent.
145func enterVimMode(t *testing.T, app *App, bw *browserWindow) {
146 t.Helper()
147 app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
148}
149
150func expectVimModeAccentHidden(_ *mocks.MockOverlayWidget) {}
151
152type accessibilityEnablingWebView struct {
153 *portmocks.MockWebView
154 enableAccessibilityCalls int
155}
156
157func newAccessibilityEnablingWebView(t *testing.T, id port.WebViewID) *accessibilityEnablingWebView {
158 t.Helper()
159 wv := portmocks.NewMockWebView(t)
160 wv.EXPECT().ID().Return(id).Once()
161 return &accessibilityEnablingWebView{MockWebView: wv}
162}
163
164func (wv *accessibilityEnablingWebView) EnableAccessibility() {
165 wv.enableAccessibilityCalls++
166}
167
168type semanticNavigatingWebView struct {
169 *portmocks.MockWebView
170 requests []dto.SemanticNavigationRequest
171 activateNavigationTargetCalls int
172 clearNavigationHighlightCalls int
173}
174
175func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView {
176 t.Helper()
177 wv := portmocks.NewMockWebView(t)
178 wv.EXPECT().ID().Return(id)
179 return &semanticNavigatingWebView{MockWebView: wv}
180}
181
182func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request dto.SemanticNavigationRequest) error {
183 wv.requests = append(wv.requests, request)
184 return nil
185}
186
187func (wv *semanticNavigatingWebView) ActivateSemanticNavigationTarget(context.Context) error {
188 wv.activateNavigationTargetCalls++
189 return nil
190}
191
192func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error {
193 wv.clearNavigationHighlightCalls++
194 return nil
195}
196
197type inputFocusingWebView struct {
198 *portmocks.MockWebView
199 focusNextInputCalls int
200 pageFocusNavigationCalls []bool
201}
202
203func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView {
204 t.Helper()
205 wv := portmocks.NewMockWebView(t)
206 wv.EXPECT().ID().Return(id).Once()
207 return &inputFocusingWebView{MockWebView: wv}
208}
209
210func (wv *inputFocusingWebView) FocusNextInput() {
211 wv.focusNextInputCalls++
212}
213
214func (wv *inputFocusingWebView) NavigatePageFocus(backward bool) {
215 wv.pageFocusNavigationCalls = append(wv.pageFocusNavigationCalls, backward)
216}
217
218func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
219 t.Helper()
220 paneID := entity.PaneID("pane-a")
221 ws := &entity.Workspace{
222 ID: "ws-1",
223 ActivePaneID: paneID,
224 Root: &entity.PaneNode{
225 ID: "ws-1-root",
226 Pane: entity.NewPane(paneID),
227 },
228 }
229 tab := &entity.Tab{ID: "tab-1", Workspace: ws}
230 tabs := entity.NewTabList()
231 tabs.Add(tab)
232 tabs.SetActive(tab.ID)
233 bw := &browserWindow{id: "win-1", tabs: tabs}
234 return &App{
235 contentCoord: contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil),
236 }, bw, paneID
237}
238
239// setupWorkspaceViewMocks creates a mock-backed WorkspaceView.
240func setupWorkspaceViewMocks(
241 t *testing.T,
242 factory *mocks.MockWidgetFactory,
243) *component.WorkspaceView {
244 t.Helper()
245
246 box := mocks.NewMockBoxWidget(t)
247 overlay := mocks.NewMockOverlayWidget(t)
248
249 factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(box).Once()
250 box.EXPECT().SetHexpand(true).Once()
251 box.EXPECT().SetVexpand(true).Once()
252 box.EXPECT().SetVisible(true).Once()
253
254 factory.EXPECT().NewOverlay().Return(overlay).Once()
255 overlay.EXPECT().SetHexpand(true).Once()
256 overlay.EXPECT().SetVexpand(true).Once()
257 overlay.EXPECT().SetChild(box).Once()
258 overlay.EXPECT().SetVisible(true).Once()
259
260 wv := component.NewWorkspaceView(context.Background(), factory)
261 require.NotNil(t, wv)
262
263 return wv
264}
265
266// newKeyboardHandlerInVimMode creates a KeyboardHandler whose Mode() returns
267// ModeVim — no GTK widgets required.
268func newKeyboardHandlerInVimMode(t *testing.T) *input.KeyboardHandler {
269 t.Helper()
270 kh := input.NewKeyboardHandler(
271 context.Background(),
272 &entity.WorkspaceConfig{},
273 &entity.SessionConfig{},
274 )
275 kh.EnterVimMode()
276 return kh
277}
278
279func bindVimModeKeyboardHandler(t *testing.T, app *App, bw *browserWindow) *input.KeyboardHandler {
280 t.Helper()
281 kh := newKeyboardHandlerInVimMode(t)
282 kh.SetOnModeChange(func(from, to input.Mode) {
283 app.handleModeChange(context.Background(), bw, from, to)
284 })
285 bw.keyboardHandler = kh
286 return kh
287}
288
289// ---------------------------------------------------------------------------
290// vimModeTestFixture — complete, minimal App + workspace environment
291//
292// Provides two browser windows, each with a two-pane workspace. The active
293// pane on both workspaces starts at "pane-a".
294// ---------------------------------------------------------------------------
295
296type vimModeTestFixture struct {
297 app *App
298 bw1 *browserWindow
299 bw2 *browserWindow
300 pv1A *component.PaneView // bw1 / pane-a (active)
301 pv1B *component.PaneView // bw1 / pane-b
302 pv2A *component.PaneView // bw2 / pane-a (active)
303 pv2B *component.PaneView // bw2 / pane-b
304
305 factory *mocks.MockWidgetFactory
306 overlay1A *mocks.MockOverlayWidget
307 overlay1B *mocks.MockOverlayWidget
308 overlay2A *mocks.MockOverlayWidget
309 overlay2B *mocks.MockOverlayWidget
310}
311
312func newVimModeTestFixture(t *testing.T) *vimModeTestFixture {
313 t.Helper()
314 factory := mocks.NewMockWidgetFactory(t)
315
316 // ---- Browser window 1 ----
317 pv1A, ol1A := createTestPaneView(t, factory, "pane-a")
318 pv1B, ol1B := createTestPaneView(t, factory, "pane-b")
319
320 ws1 := &entity.Workspace{
321 ID: "ws-1",
322 ActivePaneID: "pane-a",
323 Root: &entity.PaneNode{
324 ID: "ws-1-root",
325 Children: []*entity.PaneNode{
326 {ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")},
327 {ID: "ws-1-pane-b", Pane: entity.NewPane("pane-b")},
328 },
329 SplitDir: entity.SplitHorizontal,
330 },
331 }
332 tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1}
333 tabs1 := entity.NewTabList()
334 tabs1.Add(tab1)
335 tabs1.SetActive(tab1.ID)
336
337 wv1 := setupWorkspaceViewMocks(t, factory)
338 wv1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{
339 "pane-a": pv1A, "pane-b": pv1B,
340 })
341
342 bw1 := &browserWindow{id: "win-1", tabs: tabs1}
343
344 // ---- Browser window 2 ----
345 pv2A, ol2A := createTestPaneView(t, factory, "pane-a")
346 pv2B, ol2B := createTestPaneView(t, factory, "pane-b")
347
348 ws2 := &entity.Workspace{
349 ID: "ws-2",
350 ActivePaneID: "pane-a",
351 Root: &entity.PaneNode{
352 ID: "ws-2-root",
353 Children: []*entity.PaneNode{
354 {ID: "ws-2-pane-a", Pane: entity.NewPane("pane-a")},
355 {ID: "ws-2-pane-b", Pane: entity.NewPane("pane-b")},
356 },
357 SplitDir: entity.SplitHorizontal,
358 },
359 }
360 tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2}
361 tabs2 := entity.NewTabList()
362 tabs2.Add(tab2)
363 tabs2.SetActive(tab2.ID)
364
365 wv2 := setupWorkspaceViewMocks(t, factory)
366 wv2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{
367 "pane-a": pv2A, "pane-b": pv2B,
368 })
369
370 bw2 := &browserWindow{id: "win-2", tabs: tabs2}
371
372 app := &App{
373 workspaceViews: map[entity.TabID]*component.WorkspaceView{
374 "tab-1": wv1, "tab-2": wv2,
375 },
376 browserWindows: map[string]*browserWindow{
377 "win-1": bw1, "win-2": bw2,
378 },
379 lastFocusedWindowID: "win-1",
380 }
381
382 return &vimModeTestFixture{
383 app: app, bw1: bw1, bw2: bw2,
384 pv1A: pv1A, pv1B: pv1B, pv2A: pv2A, pv2B: pv2B,
385 factory: factory,
386 overlay1A: ol1A, overlay1B: ol1B,
387 overlay2A: ol2A, overlay2B: ol2B,
388 }
389}
390
391func newSingleWindowVimModeFixture(t *testing.T) *vimModeTestFixture {
392 t.Helper()
393 f := newVimModeTestFixture(t)
394 delete(f.app.browserWindows, "win-2")
395 delete(f.app.workspaceViews, entity.TabID("tab-2"))
396 f.bw2 = nil
397 f.pv2A = nil
398 f.pv2B = nil
399 f.overlay2A = nil
400 f.overlay2B = nil
401 return f
402}
403
404// ============================================================================
405// 1. Entering Vim mode marks only the active pane
406// ============================================================================
407
408func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) {
409 f := newVimModeTestFixture(t)
410
411 enterVimMode(t, f.app, f.bw1)
412
413 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID,
414 "entering vim mode sets ownership on the active pane")
415 assert.True(t, f.pv1A.IsVimMode(),
416 "the active pane view enters vim mode")
417 assert.False(t, f.pv1B.IsVimMode(),
418 "the inactive pane is NOT in vim mode")
419 assert.Empty(t, f.bw2.vimModePaneID,
420 "second window is untouched")
421 assert.False(t, f.pv2A.IsVimMode(),
422 "second window active pane is NOT in vim mode")
423}
424
425func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {
426 f := newVimModeTestFixture(t)
427
428 enterVimMode(t, f.app, f.bw2)
429
430 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
431 assert.True(t, f.pv2A.IsVimMode())
432 assert.Empty(t, f.bw1.vimModePaneID)
433 assert.False(t, f.pv1A.IsVimMode())
434}
435
436func TestVimMode_Enter_EnablesAccessibilityForSupportedActiveWebView(t *testing.T) {
437 app, bw, paneID := newVimModeAccessibilityFixture(t)
438 wv := newAccessibilityEnablingWebView(t, 1)
439 app.contentCoord.RegisterPopupWebView(paneID, wv)
440
441 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
442
443 assert.Equal(t, 1, wv.enableAccessibilityCalls)
444}
445
446func TestVimMode_Enter_AccessibilityNoopsForUnsupportedActiveWebView(t *testing.T) {
447 app, bw, paneID := newVimModeAccessibilityFixture(t)
448 wv := portmocks.NewMockWebView(t)
449 wv.EXPECT().ID().Return(port.WebViewID(2)).Once()
450 app.contentCoord.RegisterPopupWebView(paneID, wv)
451
452 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
453}
454
455func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) {
456 app, bw, _ := newVimModeAccessibilityFixture(t)
457
458 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
459}
460
461func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) {
462 app, _, paneID := newVimModeAccessibilityFixture(t)
463 wv := newAccessibilityEnablingWebView(t, 4)
464 app.contentCoord.RegisterPopupWebView(paneID, wv)
465 app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
466 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
467 VimMode: entity.VimModeConfig{PreloadAccessibility: true},
468 }},
469 })
470
471 app.preloadAccessibilityForShownPane(paneID)
472
473 assert.Equal(t, 1, wv.enableAccessibilityCalls)
474}
475
476func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) {
477 app, _, paneID := newVimModeAccessibilityFixture(t)
478 wv := newAccessibilityEnablingWebView(t, 5)
479 app.contentCoord.RegisterPopupWebView(paneID, wv)
480
481 app.preloadAccessibilityForShownPane(paneID)
482
483 assert.Equal(t, 0, wv.enableAccessibilityCalls)
484}
485
486func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) {
487 resolved := entity.ResolvedTheme{
488 PrefersDark: true,
489 DarkPalette: entity.ColorPalette{
490 Accent: "#22c55e",
491 },
492 }
493 app := &App{deps: &Dependencies{Theme: theme.NewManager(context.Background(), resolved)}}
494
495 assert.Equal(t, "#22c55e", app.vimNavigationHighlightColor())
496}
497
498func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
499 app, bw, paneID := newVimModeAccessibilityFixture(t)
500 wv := newSemanticNavigatingWebView(t, 4)
501 app.contentCoord.RegisterPopupWebView(paneID, wv)
502
503 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3)
504 app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0)
505 app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1)
506
507 assert.Equal(t, []dto.SemanticNavigationRequest{
508 {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationForward, Count: 3},
509 {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationBackward, Count: 0},
510 }, wv.requests)
511}
512
513func TestVimMode_ConfirmActivatesSelectedHeadingLink(t *testing.T) {
514 app, bw, paneID := newVimModeAccessibilityFixture(t)
515 wv := newSemanticNavigatingWebView(t, 12)
516 app.contentCoord.RegisterPopupWebView(paneID, wv)
517
518 app.navigateVimSequenceAction(context.Background(), bw, "confirm", 0)
519
520 assert.Equal(t, 1, wv.activateNavigationTargetCalls)
521}
522
523func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {
524 app, bw, paneID := newVimModeAccessibilityFixture(t)
525 wv := newInputFocusingWebView(t, 6)
526 app.contentCoord.RegisterPopupWebView(paneID, wv)
527
528 app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
529
530 assert.Equal(t, 1, wv.focusNextInputCalls)
531}
532
533func TestVimMode_Leave_ClearsNavigationHighlight(t *testing.T) {
534 app, bw, paneID := newVimModeAccessibilityFixture(t)
535 wv := newSemanticNavigatingWebView(t, 8)
536 app.contentCoord.RegisterPopupWebView(paneID, wv)
537 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
538 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
539
540 app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
541
542 assert.Equal(t, 1, wv.clearNavigationHighlightCalls)
543 assert.Empty(t, bw.vimNavigationHighlightedWebViews)
544 assert.Empty(t, bw.vimNavigationHighlightedWebViewIDs)
545}
546
547func TestVimMode_Leave_ClearsNavigationHighlightAfterPaneSwitch(t *testing.T) {
548 f := newSingleWindowVimModeFixture(t)
549 f.app.contentCoord = contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil)
550 oldWebView := newSemanticNavigatingWebView(t, 9)
551 newWebView := newSemanticNavigatingWebView(t, 10)
552 f.app.contentCoord.RegisterPopupWebView("pane-a", oldWebView)
553 f.app.contentCoord.RegisterPopupWebView("pane-b", newWebView)
554
555 f.app.navigateVimSequenceAction(context.Background(), f.bw1, "heading-next", 1)
556 f.bw1.tabs.ActiveTab().Workspace.ActivePaneID = "pane-b"
557 f.app.handleModeChange(context.Background(), f.bw1, input.ModeVim, input.ModeNormal)
558
559 assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
560 assert.Equal(t, 0, newWebView.clearNavigationHighlightCalls)
561}
562
563func TestVimMode_Leave_ClearsNavigationHighlightAfterTabSwitch(t *testing.T) {
564 app, bw, paneID := newVimModeAccessibilityFixture(t)
565 oldWebView := newSemanticNavigatingWebView(t, 11)
566 app.contentCoord.RegisterPopupWebView(paneID, oldWebView)
567 otherPaneID := entity.PaneID("pane-b")
568 otherTab := entity.NewTab("tab-2", "ws-2", entity.NewPane(otherPaneID))
569 bw.tabs.Add(otherTab)
570 bw.tabs.SetActive("tab-1")
571
572 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
573 bw.tabs.SetActive(otherTab.ID)
574 app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
575
576 assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
577}
578
579func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) {
580 app, bw, paneID := newVimModeAccessibilityFixture(t)
581 wv := newInputFocusingWebView(t, 7)
582 app.contentCoord.RegisterPopupWebView(paneID, wv)
583 app.vimMode.editableFocus = map[entity.PaneID]bool{paneID: true}
584
585 assert.True(t, app.navigatePageFocus(context.Background(), bw, true))
586 assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls)
587}
588
589func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
590 app, bw, paneID := newVimModeAccessibilityFixture(t)
591 wv := portmocks.NewMockWebView(t)
592 wv.EXPECT().ID().Return(port.WebViewID(5)).Once()
593 app.contentCoord.RegisterPopupWebView(paneID, wv)
594
595 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
596 app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
597}
598
599func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
600 app, bw, paneID := newVimModeAccessibilityFixture(t)
601 wv := newAccessibilityEnablingWebView(t, 3)
602 app.contentCoord.RegisterPopupWebView(paneID, wv)
603
604 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeTab)
605 app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
606 app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeVim)
607 assert.Equal(t, 0, wv.enableAccessibilityCalls)
608
609 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
610 assert.Equal(t, 1, wv.enableAccessibilityCalls)
611
612 app.handleModeChange(context.Background(), bw, input.ModePane, input.ModeVim)
613 assert.Equal(t, 2, wv.enableAccessibilityCalls)
614}
615
616// ============================================================================
617// 2. Leaving Vim mode clears ownership
618// ============================================================================
619
620func TestVimMode_Leave_ClearsOwnershipFromOwningPane(t *testing.T) {
621 f := newVimModeTestFixture(t)
622
623 // Enter first
624 enterVimMode(t, f.app, f.bw1)
625 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
626
627 // Leave — SetVimMode(false) hides indicator and removes the CSS class
628 expectVimModeAccentHidden(f.overlay1A)
629 f.app.handleVimModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModeVim)
630
631 assert.Empty(t, f.bw1.vimModePaneID,
632 "leaving vim mode clears ownership field")
633 assert.False(t, f.pv1A.IsVimMode(),
634 "the previously owning pane exits vim mode")
635}
636
637// ============================================================================
638// 3. Transfer within the same window
639// ============================================================================
640
641func TestVimMode_Transfer_MovesOwnershipToNewPane(t *testing.T) {
642 f := newVimModeTestFixture(t)
643
644 // Enter vim mode on bw1 pane-a — save label for hide expectation
645 enterVimMode(t, f.app, f.bw1)
646 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
647
648 // Give bw1 a keyboard handler in vim mode so transfer performs the switch
649 f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
650
651 // transferVimModeOwnershipToPane calls SetVimMode(false) on the old
652 // pane before SetVimMode(true) on the new pane. Set up both.
653 expectVimModeAccentHidden(f.overlay1A)
654
655 // Transfer ownership to pane-b
656 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
657
658 assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID,
659 "ownership transferred to new pane")
660 assert.False(t, f.pv1A.IsVimMode(),
661 "previous owning pane deactivated")
662 assert.True(t, f.pv1B.IsVimMode(),
663 "new pane is now in vim mode")
664}
665
666func TestVimMode_Transfer_NoVimModeClearsStale(t *testing.T) {
667 f := newVimModeTestFixture(t)
668
669 f.bw1.vimModePaneID = entity.PaneID("pane-a")
670 f.bw1.keyboardHandler = input.NewKeyboardHandler(
671 context.Background(),
672 &entity.WorkspaceConfig{},
673 &entity.SessionConfig{},
674 )
675 // No EnterVimMode — handler stays in ModeNormal.
676
677 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
678
679 assert.Empty(t, f.bw1.vimModePaneID,
680 "stale ownership cleared when window is not in vim mode")
681 assert.False(t, f.pv1A.IsVimMode())
682 assert.False(t, f.pv1B.IsVimMode())
683}
684
685func TestVimMode_Transfer_NilBwDoesNotCrash(t *testing.T) {
686 app := &App{}
687 app.transferVimModeOwnershipToPane(context.Background(), nil, entity.PaneID("pane-1"))
688}
689
690func TestVimMode_Transfer_SamePaneIsNoop(t *testing.T) {
691 app := &App{}
692 paneID := entity.PaneID("pane-a")
693 bw := &browserWindow{vimModePaneID: paneID}
694 app.transferVimModeOwnershipToPane(context.Background(), bw, paneID)
695 assert.Equal(t, paneID, bw.vimModePaneID)
696}
697
698func TestVimMode_Transfer_EmptyOwnershipIsNoop(t *testing.T) {
699 app := &App{}
700 bw := &browserWindow{}
701 app.transferVimModeOwnershipToPane(context.Background(), bw, entity.PaneID("pane-a"))
702 assert.Empty(t, bw.vimModePaneID)
703}
704
705// ============================================================================
706// 4. Pulse targeting — normal vs fast
707// ============================================================================
708
709// attachTestModeFrame uses the pane overlay as the frame's CSS target so the
710// pulse assertions below observe the classes the frame border receives.
711func attachTestModeFrame(bw *browserWindow, target layout.Widget) {
712 bw.modeFrame = &modeFrame{mode: input.ModeVim, target: target, frameClass: "vim-mode-active", borderStyle: target}
713}
714
715func setUpNormalPulse(overlay *mocks.MockOverlayWidget) {
716 overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
717 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
718 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
719 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
720 overlay.EXPECT().AddCssClass("vim-mode-pulse").Once()
721 overlay.EXPECT().AddCssClass("vim-mode-pulse-cycle-a").Once()
722}
723
724func setUpFastPulse(overlay *mocks.MockOverlayWidget) {
725 overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
726 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
727 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
728 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
729 overlay.EXPECT().AddCssClass("vim-mode-pulse-fast").Once()
730 overlay.EXPECT().AddCssClass("vim-mode-pulse-cycle-a").Once()
731}
732
733func TestVimMode_Pulse_NormalTriggersOnOwningPane(t *testing.T) {
734 f := newVimModeTestFixture(t)
735
736 enterVimMode(t, f.app, f.bw1)
737 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
738
739 attachTestModeFrame(f.bw1, f.overlay1A)
740 setUpNormalPulse(f.overlay1A)
741 f.app.triggerVimModePulse(context.Background(), false)
742}
743
744func TestVimMode_Pulse_FastTriggersOnOwningPane(t *testing.T) {
745 f := newVimModeTestFixture(t)
746
747 enterVimMode(t, f.app, f.bw1)
748 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
749
750 attachTestModeFrame(f.bw1, f.overlay1A)
751 setUpFastPulse(f.overlay1A)
752 f.app.triggerVimModePulse(context.Background(), true)
753}
754
755func TestVimMode_Pulse_NoOwnerIsNoop(t *testing.T) {
756 f := newVimModeTestFixture(t)
757 f.app.triggerVimModePulse(context.Background(), false)
758 f.app.triggerVimModePulse(context.Background(), true)
759 // No pane has ownership — no pulse expectations needed.
760}
761
762// ============================================================================
763// 5. Multi-window isolation
764// ============================================================================
765
766func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) {
767 f := newVimModeTestFixture(t)
768
769 enterVimMode(t, f.app, f.bw1)
770 enterVimMode(t, f.app, f.bw2)
771
772 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
773 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
774
775 // Leave bw1 only
776 expectVimModeAccentHidden(f.overlay1A)
777 f.app.handleVimModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModeVim)
778
779 assert.Empty(t, f.bw1.vimModePaneID)
780 assert.False(t, f.pv1A.IsVimMode())
781
782 // bw2 unchanged
783 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
784 assert.True(t, f.pv2A.IsVimMode())
785}
786
787func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
788 f := newVimModeTestFixture(t)
789
790 enterVimMode(t, f.app, f.bw1)
791 enterVimMode(t, f.app, f.bw2)
792
793 f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
794
795 // transferVimModeOwnershipToPane calls SetVimMode(false) on old pane first
796 expectVimModeAccentHidden(f.overlay1A)
797
798 // Transfer bw1 to pane-b.
799 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
800
801 assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID)
802 assert.True(t, f.pv1B.IsVimMode())
803 assert.False(t, f.pv1A.IsVimMode())
804
805 // bw2 unchanged
806 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
807 assert.True(t, f.pv2A.IsVimMode())
808}
809
810func TestVimMode_MultiWindow_PulseIsolated(t *testing.T) {
811 f := newVimModeTestFixture(t)
812
813 enterVimMode(t, f.app, f.bw1)
814 enterVimMode(t, f.app, f.bw2)
815
816 // Pulse on bw1 (lastFocusedWindowID = "win-1")
817 attachTestModeFrame(f.bw1, f.overlay1A)
818 setUpNormalPulse(f.overlay1A)
819 f.app.triggerVimModePulse(context.Background(), false)
820
821 // bw2 pane still in vim mode
822 assert.True(t, f.pv2A.IsVimMode())
823}
824
825// ============================================================================
826// Edge cases — nil / no-workspace / startup guards
827// ============================================================================
828
829func TestVimMode_Enter_NoWorkspaceIsNoop(t *testing.T) {
830 app := &App{}
831 bw := &browserWindow{id: "win-1"}
832 app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
833 assert.Empty(t, bw.vimModePaneID)
834}
835
836func TestVimMode_Clear_NilBwDoesNotCrash(t *testing.T) {
837 (&App{}).clearVimModeOwnership(context.Background(), nil)
838}
839
840func TestVimMode_Clear_NoPaneID(t *testing.T) {
841 app := &App{}
842 bw := &browserWindow{id: "test-win"}
843 app.clearVimModeOwnership(context.Background(), bw)
844 assert.Empty(t, bw.vimModePaneID)
845}
846
847func TestVimMode_Clear_StalePaneWithoutWorkspace(t *testing.T) {
848 app := &App{}
849 bw := &browserWindow{id: "test-win", vimModePaneID: entity.PaneID("stale-pane")}
850 app.clearVimModeOwnership(context.Background(), bw)
851 assert.Empty(t, bw.vimModePaneID)
852}
853
854func TestVimMode_HandleModeChange_NilBwDoesNotCrash(t *testing.T) {
855 (&App{}).handleModeChange(context.Background(), nil, input.ModeNormal, input.ModeVim)
856}
857
858func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) {
859 f := newSingleWindowVimModeFixture(t)
860 enterVimMode(t, f.app, f.bw1)
861 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
862
863 expectVimModeAccentHidden(f.overlay1A)
864 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true)
865
866 assert.Equal(t, input.ModeNormal, kh.Mode())
867 assert.Empty(t, f.bw1.vimModePaneID)
868 assert.False(t, f.pv1A.IsVimMode())
869}
870
871func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) {
872 f := newSingleWindowVimModeFixture(t)
873 enterVimMode(t, f.app, f.bw1)
874 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
875
876 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-b"), true)
877
878 assert.Equal(t, input.ModeVim, kh.Mode())
879 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
880 assert.True(t, f.pv1A.IsVimMode())
881}
882
883func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {
884 f := newSingleWindowVimModeFixture(t)
885 enterVimMode(t, f.app, f.bw1)
886 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
887 f.app.vimMode.editableFocus = map[entity.PaneID]bool{
888 "pane-b": true,
889 }
890
891 expectVimModeAccentHidden(f.overlay1A)
892 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
893
894 assert.Equal(t, input.ModeNormal, kh.Mode())
895 assert.Empty(t, f.bw1.vimModePaneID)
896 assert.False(t, f.pv1A.IsVimMode())
897 assert.False(t, f.pv1B.IsVimMode())
898}
899
900func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t *testing.T) {
901 factory := mocks.NewMockWidgetFactory(t)
902 pv1, _ := createTestPaneView(t, factory, "pane-a")
903 pv2, _ := createTestPaneView(t, factory, "pane-x")
904
905 ws1 := &entity.Workspace{ID: "ws-1", ActivePaneID: "pane-a", Root: &entity.PaneNode{ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")}}
906 ws2 := &entity.Workspace{ID: "ws-2", ActivePaneID: "pane-x", Root: &entity.PaneNode{ID: "ws-2-pane-x", Pane: entity.NewPane("pane-x")}}
907 bw1 := &browserWindow{id: "win-1", tabs: entity.NewTabList()}
908 bw2 := &browserWindow{id: "win-2", tabs: entity.NewTabList()}
909 tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1}
910 tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2}
911 bw1.tabs.Add(tab1)
912 bw1.tabs.SetActive(tab1.ID)
913 bw2.tabs.Add(tab2)
914 bw2.tabs.SetActive(tab2.ID)
915 view1 := setupWorkspaceViewMocks(t, factory)
916 view1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-a": pv1})
917 view2 := setupWorkspaceViewMocks(t, factory)
918 view2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-x": pv2})
919
920 app := &App{
921 workspaceViews: map[entity.TabID]*component.WorkspaceView{"tab-1": view1, "tab-2": view2},
922 browserWindows: map[string]*browserWindow{"win-1": bw1, "win-2": bw2},
923 lastFocusedWindowID: "win-1",
924 }
925
926 enterVimMode(t, app, bw1)
927 kh := bindVimModeKeyboardHandler(t, app, bw1)
928
929 app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-x"), true)
930
931 assert.Equal(t, input.ModeVim, kh.Mode())
932 assert.Equal(t, entity.PaneID("pane-a"), bw1.vimModePaneID)
933 assert.True(t, pv1.IsVimMode())
934}
935
936func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) {
937 f := newSingleWindowVimModeFixture(t)
938 enterVimMode(t, f.app, f.bw1)
939 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
940
941 expectVimModeAccentHidden(f.overlay1A)
942 f.app.handleVimModeFocusTrigger(context.Background(), f.bw1, usecase.VimModePolicyTriggerOmniboxFocus)
943
944 assert.Equal(t, input.ModeNormal, kh.Mode())
945 assert.Empty(t, f.bw1.vimModePaneID)
946}
947
948func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) {
949 f := newSingleWindowVimModeFixture(t)
950 enterVimMode(t, f.app, f.bw1)
951 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
952
953 expectVimModeAccentHidden(f.overlay1A)
954 f.app.handleVimModeFocusTrigger(context.Background(), f.bw1, usecase.VimModePolicyTriggerFindBarFocus)
955
956 assert.Equal(t, input.ModeNormal, kh.Mode())
957 assert.Empty(t, f.bw1.vimModePaneID)
958}
959
960func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) {
961 f := newSingleWindowVimModeFixture(t)
962 enterVimMode(t, f.app, f.bw1)
963 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
964
965 tab2 := &entity.Tab{ID: "tab-2b", Workspace: &entity.Workspace{
966 ID: "ws-2b",
967 ActivePaneID: "pane-c",
968 Root: &entity.PaneNode{ID: "ws-2b-pane-c", Pane: entity.NewPane("pane-c")},
969 }}
970 f.bw1.tabs.Add(tab2)
971 f.bw1.tabs.SetActive(tab2.ID)
972 f.app.workspaceViews[tab2.ID] = setupWorkspaceViewMocks(t, f.factory)
973
974 expectVimModeAccentHidden(f.overlay1A)
975 f.app.handleVimModeTabSwitch(context.Background(), f.bw1)
976
977 assert.Equal(t, input.ModeNormal, kh.Mode())
978 assert.Empty(t, f.bw1.vimModePaneID)
979 assert.False(t, f.pv1A.IsVimMode())
980}
981
982func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) {
983 f := newSingleWindowVimModeFixture(t)
984 f.app.vimMode.editableFocus = map[entity.PaneID]bool{
985 "pane-a": true,
986 }
987
988 assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
989}
990
991func TestVimMode_ActivationAvailabilityDoesNotDependOnEditableFocus(t *testing.T) {
992 f := newSingleWindowVimModeFixture(t)
993
994 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true)
995 assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
996
997 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), false)
998 assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
999}
1000
1001func TestVimMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) {
1002 f := newSingleWindowVimModeFixture(t)
1003 f.app.vimMode.editableFocus = map[entity.PaneID]bool{"pane-a": true}
1004
1005 f.app.clearPageEditableFocusState(entity.PaneID("pane-a"))
1006
1007 assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
1008}
1009
1010// ============================================================================
1011// CSS class constants
1012// ============================================================================
1013
1014func TestVimMode_CSSClassConstants(t *testing.T) {
1015 assert.Equal(t, "vim-mode-active", modeFrameClass(input.ModeVim))
1016}
1017
1018// ============================================================================
1019// Mode toaster
1020// ============================================================================
1021
1022func newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) {
1023 t.Helper()
1024
1025 container := mocks.NewMockBoxWidget(t)
1026 label := mocks.NewMockLabelWidget(t)
1027 factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once()
1028 container.EXPECT().AddCssClass("toast").Once()
1029 container.EXPECT().AddCssClass("toast-info").Once()
1030 container.EXPECT().SetHalign(mock.Anything).Once()
1031 container.EXPECT().SetValign(mock.Anything).Once()
1032 container.EXPECT().SetHexpand(false).Once()
1033 container.EXPECT().SetVexpand(false).Once()
1034 container.EXPECT().SetCanTarget(false).Once()
1035 container.EXPECT().SetCanFocus(false).Once()
1036 container.EXPECT().SetVisible(false).Once()
1037 factory.EXPECT().NewLabel("").Return(label).Once()
1038 label.EXPECT().SetCanTarget(false).Once()
1039 label.EXPECT().SetCanFocus(false).Once()
1040 container.EXPECT().Append(label).Once()
1041
1042 return component.NewToaster(factory), container, label
1043}
1044
1045func expectVimModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) {
1046 container.EXPECT().RemoveCssClass("toast-info").Once()
1047 container.EXPECT().AddCssClass("toast-vim-mode").Once()
1048 container.EXPECT().SetHalign(mock.Anything).Once()
1049 container.EXPECT().SetValign(mock.Anything).Once()
1050 label.EXPECT().SetText("VIM MODE").Once()
1051 container.EXPECT().SetVisible(true).Once()
1052}
1053
1054func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1055 factory := mocks.NewMockWidgetFactory(t)
1056 toaster, container, label := newModeToasterForVimModeTest(t, factory)
1057 expectVimModeToastShown(container, label)
1058 // These optional calls are made only by the old brief auto-dismiss path.
1059 container.EXPECT().RemoveCssClass("toast-vim-mode").Maybe()
1060 container.EXPECT().SetVisible(false).Maybe()
1061
1062 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1063 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1064 ModeIndicatorToasterEnabled: true,
1065 }}},
1066 })}
1067 bw := &browserWindow{id: "win-1", modeToaster: toaster}
1068 app.browserWindows = map[string]*browserWindow{bw.id: bw}
1069 app.lastFocusedWindowID = bw.id
1070
1071 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1072 require.True(t, toaster.IsVisible())
1073
1074 deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)
1075 mainContext := glib.MainContextDefault()
1076 for time.Now().Before(deadline) {
1077 time.Sleep(10 * time.Millisecond)
1078 for mainContext.Pending() {
1079 mainContext.Iteration(false)
1080 }
1081 }
1082
1083 assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit")
1084}
1085
1086func TestVimMode_ModeExitHidesToaster(t *testing.T) {
1087 factory := mocks.NewMockWidgetFactory(t)
1088 toaster, container, label := newModeToasterForVimModeTest(t, factory)
1089 expectVimModeToastShown(container, label)
1090 container.EXPECT().RemoveCssClass("toast-custom").Once()
1091 container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1092 container.EXPECT().SetVisible(false).Once()
1093
1094 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1095 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1096 ModeIndicatorToasterEnabled: true,
1097 }}},
1098 })}
1099 bw := &browserWindow{id: "win-1", modeToaster: toaster}
1100
1101 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1102 require.True(t, toaster.IsVisible())
1103 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal)
1104
1105 assert.False(t, toaster.IsVisible(), "leaving Vim Mode must hide its persistent toast")
1106}
1107
1108func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) {
1109 factory := mocks.NewMockWidgetFactory(t)
1110 toaster, _, _ := newModeToasterForVimModeTest(t, factory)
1111 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})}
1112 bw := &browserWindow{id: "win-1", modeToaster: toaster}
1113
1114 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1115
1116 assert.False(t, toaster.IsVisible())
1117}
1118
1119func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {
1120 factory := mocks.NewMockWidgetFactory(t)
1121 toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory)
1122 toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory)
1123 expectVimModeToastShown(container1, label1)
1124 expectVimModeToastShown(container2, label2)
1125 container1.EXPECT().RemoveCssClass("toast-custom").Once()
1126 container1.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1127 container1.EXPECT().SetVisible(false).Once()
1128
1129 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1130 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1131 ModeIndicatorToasterEnabled: true,
1132 }}},
1133 })}
1134 bw1 := &browserWindow{id: "win-1", modeToaster: toaster1}
1135 bw2 := &browserWindow{id: "win-2", modeToaster: toaster2}
1136
1137 app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim)
1138 app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim)
1139 app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal)
1140
1141 assert.False(t, toaster1.IsVisible())
1142 assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast")
1143}
1144
1145func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) {
1146 factory := mocks.NewMockWidgetFactory(t)
1147 toaster, container, label := newModeToasterForVimModeTest(t, factory)
1148 expectVimModeToastShown(container, label)
1149 container.EXPECT().RemoveCssClass("toast-custom").Once()
1150 container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1151 container.EXPECT().SetVisible(false).Once()
1152
1153 enabled := entity.RuntimeConfigSnapshot{
1154 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1155 ModeIndicatorToasterEnabled: true,
1156 }}},
1157 }
1158 keyboardHandler := newKeyboardHandlerInVimMode(t)
1159 bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1160 app := &App{
1161 runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabled),
1162 browserWindows: map[string]*browserWindow{bw.id: bw},
1163 }
1164
1165 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1166 require.True(t, toaster.IsVisible())
1167 app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{})
1168
1169 assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast")
1170}
1171
1172func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1173 factory := mocks.NewMockWidgetFactory(t)
1174 toaster, container, label := newModeToasterForVimModeTest(t, factory)
1175 expectVimModeToastShown(container, label)
1176
1177 keyboardHandler := newKeyboardHandlerInVimMode(t)
1178 bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1179 app := &App{
1180 runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}),
1181 browserWindows: map[string]*browserWindow{bw.id: bw},
1182 }
1183 enabled := entity.RuntimeConfigSnapshot{
1184 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1185 ModeIndicatorToasterEnabled: true,
1186 }}},
1187 }
1188
1189 app.applyRuntimeConfigChange(context.Background(), enabled)
1190
1191 assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")
1192}
1193
1194// ============================================================================
1195// Pulse Debounce
1196// ============================================================================
1197
1198func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) {
1199 f := newSingleWindowVimModeFixture(t)
1200 enterVimMode(t, f.app, f.bw1)
1201
1202 // Set up expectations for exactly ONE normal pulse.
1203 attachTestModeFrame(f.bw1, f.overlay1A)
1204 setUpNormalPulse(f.overlay1A)
1205
1206 // First call fires the pulse (debounce timer is zero).
1207 f.app.triggerVimModePulse(context.Background(), false)
1208
1209 // Second call within the same frame should be debounced — no
1210 // mock expectations set up for it, so the test fails if it fires.
1211 f.app.triggerVimModePulse(context.Background(), false)
1212}
1213
1214func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
1215 // Verify that the debounce timer starts at zero on a fresh App,
1216 // so the first pulse after entering vim mode always fires.
1217 // The exit-and-re-enter path is covered by the
1218 // TestVimMode_Pulse_DebounceResetOnClearOwnership test below.
1219
1220 f := newSingleWindowVimModeFixture(t)
1221
1222 // Fresh app: debounce timer is zero.
1223 assert.True(t, f.app.vimMode.pulseIdle(),
1224 "fresh app should have zero pulse timer")
1225
1226 enterVimMode(t, f.app, f.bw1)
1227
1228 // Pulse #1 fires because timer is zero (time.Since(zero) is huge).
1229 attachTestModeFrame(f.bw1, f.overlay1A)
1230 setUpNormalPulse(f.overlay1A)
1231 f.app.triggerVimModePulse(context.Background(), false)
1232
1233 // Timer is now set, proving the first pulse registered.
1234 assert.False(t, f.app.vimMode.pulseIdle(),
1235 "pulse timer should be non-zero after first pulse")
1236}
1237
1238func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
1239 // Verify that clearVimModeOwnership resets the debounce timer
1240 // so a subsequent pulse in a new vim mode session is not skipped.
1241 // We check the timer state directly rather than triggering a second
1242 // pulse (which would need pulse-cycle alternation handling).
1243
1244 f := newSingleWindowVimModeFixture(t)
1245 enterVimMode(t, f.app, f.bw1)
1246
1247 // Pulse #1 fires and sets the timer.
1248 attachTestModeFrame(f.bw1, f.overlay1A)
1249 setUpNormalPulse(f.overlay1A)
1250 f.app.triggerVimModePulse(context.Background(), false)
1251 assert.False(t, f.app.vimMode.pulseIdle(),
1252 "pulse timer should be non-zero after first pulse")
1253
1254 // Clear ownership — this resets the debounce timer.
1255 expectVimModeAccentHidden(f.overlay1A)
1256 f.app.clearVimModeOwnership(context.Background(), f.bw1)
1257 assert.Empty(t, f.bw1.vimModePaneID)
1258
1259 // Debounce timer should now be back to zero.
1260 assert.True(t, f.app.vimMode.pulseIdle(),
1261 "pulse timer should be reset after clearVimModeOwnership")
1262}
1263
1264func TestVimMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) {
1265 // Even if triggerVimModePulse is called rapidly on a fresh
1266 // App (e.g., first keystroke after vim mode entry), the first
1267 // call must always fire. This is guaranteed by the zero value
1268 // of time.Time producing a time.Since result much larger than
1269 // the debounce interval.
1270 f := newSingleWindowVimModeFixture(t)
1271 enterVimMode(t, f.app, f.bw1)
1272
1273 // Rapid double pulse — only first should fire regardless of order.
1274 // Set up just one set of expectations.
1275 attachTestModeFrame(f.bw1, f.overlay1A)
1276 setUpNormalPulse(f.overlay1A)
1277 f.app.triggerVimModePulse(context.Background(), false)
1278 f.app.triggerVimModePulse(context.Background(), false)
1279}
1280
1281// ============================================================================
1282// UI Scale: Vim mode CSS uses relative (em) units
1283// ============================================================================
1284
1285func TestVimMode_CSSUsesEms(t *testing.T) {
1286 css := theme.GenerateCSS(theme.DefaultDarkPalette())
1287
1288 assert.Contains(t, css, "box-shadow: inset 0 0 0 0.125em",
1289 "vim mode overlay box-shadow should use em for scaling")
1290 assert.Contains(t, css, "0.2em",
1291 "vim mode overlay pulse glow radius uses em")
1292 assert.Contains(t, css, "0.26em",
1293 "vim mode overlay fast-pulse glow radius uses em")
1294 assert.NotContains(t, css, "vim-mode-indicator",
1295 "removed PAGE label styling must not remain")
1296}
1297
1298func TestVimMode_CSSPulseTimingUsesDefaultTransitionDuration(t *testing.T) {
1299 // The pulse animation durations should be derived from the
1300 // default transition duration (normal = 3x, fast = 6x).
1301 // Default = 120ms → normal = 360ms, fast = 720ms.
1302 css := theme.GenerateCSS(theme.DefaultDarkPalette())
1303
1304 assert.Contains(t, css, "360ms",
1305 "normal pulse duration should be 3x transition duration (360ms for 120ms base)")
1306 assert.Contains(t, css, "720ms",
1307 "fast pulse duration should be 6x transition duration (720ms for 120ms base)")
1308}
1309
1310func TestVimMode_CSSWithCustomTransitionDuration(t *testing.T) {
1311 // Custom transition duration (e.g., 200ms) should produce
1312 // adjusted pulse timings: normal = 600ms, fast = 1200ms.
1313 css := theme.GenerateCSSFullWithTiming(
1314 theme.DefaultDarkPalette(),
1315 1.0,
1316 theme.DefaultFontConfig(),
1317 theme.DefaultModeColors(),
1318 200,
1319 )
1320
1321 assert.Contains(t, css, "600ms",
1322 "normal pulse should be 3x custom transition duration (200ms -> 600ms)")
1323 assert.Contains(t, css, "1200ms",
1324 "fast pulse should be 6x custom transition duration (200ms -> 1200ms)")
1325}