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 enabledIndicatorSnapshot() entity.RuntimeConfigSnapshot {
1023 return entity.RuntimeConfigSnapshot{
1024 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1025 Styling: entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "off"},
1026 }},
1027 }
1028}
1029
1030func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1031 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1032 bw, view := newFakeModeToasterWindow("win-1")
1033 app.browserWindows = map[string]*browserWindow{bw.id: bw}
1034
1035 app.updateModeFrame(bw, input.ModeVim)
1036 require.True(t, view.pillShown)
1037 require.Equal(t, "VIM MODE", view.header)
1038
1039 // Nothing but a mode change hides it: no timer dismisses the persistent pill.
1040 deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)
1041 mainContext := glib.MainContextDefault()
1042 for time.Now().Before(deadline) {
1043 time.Sleep(10 * time.Millisecond)
1044 for mainContext.Pending() {
1045 mainContext.Iteration(false)
1046 }
1047 }
1048 assert.True(t, view.pillShown, "Vim Mode pill must remain visible until mode exit")
1049}
1050
1051func TestVimMode_ModeExitHidesToaster(t *testing.T) {
1052 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1053 bw, view := newFakeModeToasterWindow("win-1")
1054
1055 app.updateModeFrame(bw, input.ModeVim)
1056 require.True(t, view.pillShown)
1057 app.updateModeFrame(bw, input.ModeNormal)
1058
1059 assert.False(t, view.pillShown, "leaving Vim Mode must hide its persistent pill")
1060}
1061
1062func TestVimMode_DisabledIndicatorWithLegendOffShowsNothing(t *testing.T) {
1063 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1064 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1065 Styling: entity.WorkspaceStylingConfig{ModeLegend: "off"},
1066 }},
1067 })}
1068 bw, view := newFakeModeToasterWindow("win-1")
1069
1070 app.updateModeFrame(bw, input.ModeVim)
1071
1072 assert.False(t, view.pillShown)
1073 assert.False(t, view.bodyShown)
1074}
1075
1076func TestVimMode_DisabledIndicatorKeepsPillAsLegendHeader(t *testing.T) {
1077 snapshot := entity.RuntimeConfigSnapshot{
1078 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1079 Styling: entity.WorkspaceStylingConfig{ModeLegend: "always"},
1080 VimMode: entity.VimModeConfig{Actions: map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}}},
1081 }},
1082 }
1083 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(snapshot)}
1084 bw, view := newFakeModeToasterWindow("win-1")
1085
1086 app.updateModeFrame(bw, input.ModeVim)
1087
1088 assert.True(t, view.pillShown, "the pill is the legend header even when the indicator is disabled")
1089 assert.True(t, view.bodyShown)
1090}
1091
1092func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {
1093 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1094 bw1, view1 := newFakeModeToasterWindow("win-1")
1095 bw2, view2 := newFakeModeToasterWindow("win-2")
1096
1097 app.updateModeFrame(bw1, input.ModeVim)
1098 app.updateModeFrame(bw2, input.ModeVim)
1099 app.updateModeFrame(bw1, input.ModeNormal)
1100
1101 assert.False(t, view1.pillShown)
1102 assert.True(t, view2.pillShown, "exiting one window must not hide another window's pill")
1103}
1104
1105func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) {
1106 keyboardHandler := newKeyboardHandlerInVimMode(t)
1107 bw, view := newFakeModeToasterWindow("win-1")
1108 bw.keyboardHandler = keyboardHandler
1109 app := &App{
1110 runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot()),
1111 browserWindows: map[string]*browserWindow{bw.id: bw},
1112 }
1113
1114 app.updateModeFrame(bw, input.ModeVim)
1115 require.True(t, view.pillShown)
1116 disabled := enabledIndicatorSnapshot()
1117 disabled.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false
1118 app.applyRuntimeConfigChange(context.Background(), disabled)
1119
1120 assert.False(t, view.pillShown, "runtime preference disable must reconcile an existing pill")
1121}
1122
1123func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1124 keyboardHandler := newKeyboardHandlerInVimMode(t)
1125 bw, view := newFakeModeToasterWindow("win-1")
1126 bw.keyboardHandler = keyboardHandler
1127 off := enabledIndicatorSnapshot()
1128 off.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false
1129 app := &App{
1130 runtimeConfig: runtimeConfigStateFromSnapshotForTest(off),
1131 browserWindows: map[string]*browserWindow{bw.id: bw},
1132 }
1133
1134 app.applyRuntimeConfigChange(context.Background(), enabledIndicatorSnapshot())
1135
1136 assert.True(t, view.pillShown, "runtime preference enable must reconcile active Vim Mode")
1137}
1138
1139// ============================================================================
1140// Pulse Debounce
1141// ============================================================================
1142
1143func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) {
1144 f := newSingleWindowVimModeFixture(t)
1145 enterVimMode(t, f.app, f.bw1)
1146
1147 // Set up expectations for exactly ONE normal pulse.
1148 attachTestModeFrame(f.bw1, f.overlay1A)
1149 setUpNormalPulse(f.overlay1A)
1150
1151 // First call fires the pulse (debounce timer is zero).
1152 f.app.triggerVimModePulse(context.Background(), false)
1153
1154 // Second call within the same frame should be debounced — no
1155 // mock expectations set up for it, so the test fails if it fires.
1156 f.app.triggerVimModePulse(context.Background(), false)
1157}
1158
1159func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
1160 // Verify that the debounce timer starts at zero on a fresh App,
1161 // so the first pulse after entering vim mode always fires.
1162 // The exit-and-re-enter path is covered by the
1163 // TestVimMode_Pulse_DebounceResetOnClearOwnership test below.
1164
1165 f := newSingleWindowVimModeFixture(t)
1166
1167 // Fresh app: debounce timer is zero.
1168 assert.True(t, f.app.vimMode.pulseIdle(),
1169 "fresh app should have zero pulse timer")
1170
1171 enterVimMode(t, f.app, f.bw1)
1172
1173 // Pulse #1 fires because timer is zero (time.Since(zero) is huge).
1174 attachTestModeFrame(f.bw1, f.overlay1A)
1175 setUpNormalPulse(f.overlay1A)
1176 f.app.triggerVimModePulse(context.Background(), false)
1177
1178 // Timer is now set, proving the first pulse registered.
1179 assert.False(t, f.app.vimMode.pulseIdle(),
1180 "pulse timer should be non-zero after first pulse")
1181}
1182
1183func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
1184 // Verify that clearVimModeOwnership resets the debounce timer
1185 // so a subsequent pulse in a new vim mode session is not skipped.
1186 // We check the timer state directly rather than triggering a second
1187 // pulse (which would need pulse-cycle alternation handling).
1188
1189 f := newSingleWindowVimModeFixture(t)
1190 enterVimMode(t, f.app, f.bw1)
1191
1192 // Pulse #1 fires and sets the timer.
1193 attachTestModeFrame(f.bw1, f.overlay1A)
1194 setUpNormalPulse(f.overlay1A)
1195 f.app.triggerVimModePulse(context.Background(), false)
1196 assert.False(t, f.app.vimMode.pulseIdle(),
1197 "pulse timer should be non-zero after first pulse")
1198
1199 // Clear ownership — this resets the debounce timer.
1200 expectVimModeAccentHidden(f.overlay1A)
1201 f.app.clearVimModeOwnership(context.Background(), f.bw1)
1202 assert.Empty(t, f.bw1.vimModePaneID)
1203
1204 // Debounce timer should now be back to zero.
1205 assert.True(t, f.app.vimMode.pulseIdle(),
1206 "pulse timer should be reset after clearVimModeOwnership")
1207}
1208
1209func TestVimMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) {
1210 // Even if triggerVimModePulse is called rapidly on a fresh
1211 // App (e.g., first keystroke after vim mode entry), the first
1212 // call must always fire. This is guaranteed by the zero value
1213 // of time.Time producing a time.Since result much larger than
1214 // the debounce interval.
1215 f := newSingleWindowVimModeFixture(t)
1216 enterVimMode(t, f.app, f.bw1)
1217
1218 // Rapid double pulse — only first should fire regardless of order.
1219 // Set up just one set of expectations.
1220 attachTestModeFrame(f.bw1, f.overlay1A)
1221 setUpNormalPulse(f.overlay1A)
1222 f.app.triggerVimModePulse(context.Background(), false)
1223 f.app.triggerVimModePulse(context.Background(), false)
1224}
1225
1226// ============================================================================
1227// UI Scale: Vim mode CSS uses relative (em) units
1228// ============================================================================
1229
1230func TestVimMode_CSSUsesEms(t *testing.T) {
1231 css := theme.GenerateCSS(theme.DefaultDarkPalette())
1232
1233 assert.Contains(t, css, "box-shadow: inset 0 0 0 0.125em",
1234 "vim mode overlay box-shadow should use em for scaling")
1235 assert.Contains(t, css, "0.2em",
1236 "vim mode overlay pulse glow radius uses em")
1237 assert.Contains(t, css, "0.26em",
1238 "vim mode overlay fast-pulse glow radius uses em")
1239 assert.NotContains(t, css, "vim-mode-indicator",
1240 "removed PAGE label styling must not remain")
1241}
1242
1243func TestVimMode_CSSPulseTimingUsesDefaultTransitionDuration(t *testing.T) {
1244 // The pulse animation durations should be derived from the
1245 // default transition duration (normal = 3x, fast = 6x).
1246 // Default = 120ms → normal = 360ms, fast = 720ms.
1247 css := theme.GenerateCSS(theme.DefaultDarkPalette())
1248
1249 assert.Contains(t, css, "360ms",
1250 "normal pulse duration should be 3x transition duration (360ms for 120ms base)")
1251 assert.Contains(t, css, "720ms",
1252 "fast pulse duration should be 6x transition duration (720ms for 120ms base)")
1253}
1254
1255func TestVimMode_CSSWithCustomTransitionDuration(t *testing.T) {
1256 // Custom transition duration (e.g., 200ms) should produce
1257 // adjusted pulse timings: normal = 600ms, fast = 1200ms.
1258 css := theme.GenerateCSSFullWithTiming(
1259 theme.DefaultDarkPalette(),
1260 1.0,
1261 theme.DefaultFontConfig(),
1262 theme.DefaultModeColors(),
1263 200,
1264 )
1265
1266 assert.Contains(t, css, "600ms",
1267 "normal pulse should be 3x custom transition duration (200ms -> 600ms)")
1268 assert.Contains(t, css, "1200ms",
1269 "fast pulse should be 6x custom transition duration (200ms -> 1200ms)")
1270}