detached ac967b8

test: add coordinator unit tests and expand mockery coverage

- Add 13 new port interfaces to .mockery.yaml (Engine, ContentInjector,
  SchemeHandler, MessageRouter, SettingsApplier, FilterApplier,
  FaviconDatabase, IdleInhibitor, PopupCapable, OAuthCallbackCapable,
  DevToolsOpener, Printer)
- lifecycle_test.go: EnsureWebView (reuse, acquire, errors),
  ReleaseWebView (pool release, idle uninhibit, destroy fallback),
  GetWebView, RegisterPopupWebView
- popup_test.go: DetectPopupType, PopupType.String, GetBehavior with
  various configs, OAuth state tracking, SetPopupConfig, concurrency
- title_test.go: GetTitle, SetNavigationOrigin, appearance queue
  management (theme/script queueing), concurrent access safety

58 new tests for the content coordinator package.
commit
ac967b884015a47c5292fadcacac1d6d9d7ea6c8
author
brice · · 2026-03-15 18:34 UTC
parent
9dd9450
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +4439 -1722
M .mockery.yaml +12 -0
@@ -33,6 +33,18 @@ packages:
3333 DownloadResponse: {}
3434 PermissionDialogPresenter: {}
3535 PermissionRepository: {}
36+ Engine: {}
37+ ContentInjector: {}
38+ SchemeHandler: {}
39+ MessageRouter: {}
40+ SettingsApplier: {}
41+ FilterApplier: {}
42+ FaviconDatabase: {}
43+ IdleInhibitor: {}
44+ PopupCapable: {}
45+ OAuthCallbackCapable: {}
46+ DevToolsOpener: {}
47+ Printer: {}
3648 github.com/bnema/dumber/internal/domain/repository:
3749 config:
3850 dir: '{{.InterfaceDir}}/mocks'
@@ -33,6 +33,18 @@ packages:
33 DownloadResponse: {}33 DownloadResponse: {}
34 PermissionDialogPresenter: {}34 PermissionDialogPresenter: {}
35 PermissionRepository: {}35 PermissionRepository: {}
36+ Engine: {}
37+ ContentInjector: {}
38+ SchemeHandler: {}
39+ MessageRouter: {}
40+ SettingsApplier: {}
41+ FilterApplier: {}
42+ FaviconDatabase: {}
43+ IdleInhibitor: {}
44+ PopupCapable: {}
45+ OAuthCallbackCapable: {}
46+ DevToolsOpener: {}
47+ Printer: {}
36 github.com/bnema/dumber/internal/domain/repository:48 github.com/bnema/dumber/internal/domain/repository:
37 config:49 config:
38 dir: '{{.InterfaceDir}}/mocks'50 dir: '{{.InterfaceDir}}/mocks'
M internal/application/port/mocks/mocks.go +3391 -1722 large diff (5113 lines changed): collapsed
A internal/ui/coordinator/content/lifecycle_test.go +255 -0
@@ -0,0 +1,255 @@
1+package content
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/port/mocks"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+)
16+
17+// newMinimalCoordinator returns a Coordinator with the maps required by
18+// ReleaseWebView initialized so that calls to clearPendingAppearance,
19+// titleMu / navOriginMu locks do not panic.
20+func newMinimalCoordinator() *Coordinator {
21+ return &Coordinator{
22+ webViews: make(map[entity.PaneID]port.WebView),
23+ paneTitles: make(map[entity.PaneID]string),
24+ navOrigins: make(map[entity.PaneID]string),
25+ }
26+}
27+
28+// ---------------------------------------------------------------------------
29+// EnsureWebView
30+// ---------------------------------------------------------------------------
31+
32+func TestLifecycle_EnsureWebView_ReusesExistingNonDestroyed(t *testing.T) {
33+ t.Parallel()
34+
35+ wv := mocks.NewMockWebView(t)
36+ wv.EXPECT().IsDestroyed().Return(false)
37+
38+ pool := mocks.NewMockWebViewPool(t)
39+ // pool.Acquire must NOT be called
40+
41+ c := newMinimalCoordinator()
42+ c.pool = pool
43+ c.webViews[entity.PaneID("pane-1")] = wv
44+
45+ got, err := c.EnsureWebView(context.Background(), "pane-1")
46+
47+ require.NoError(t, err)
48+ assert.Equal(t, wv, got)
49+}
50+
51+func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) {
52+ t.Parallel()
53+
54+ newWV := mocks.NewMockWebView(t)
55+ newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
56+
57+ pool := mocks.NewMockWebViewPool(t)
58+ pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
59+
60+ c := newMinimalCoordinator()
61+ c.pool = pool
62+
63+ got, err := c.EnsureWebView(context.Background(), "pane-1")
64+
65+ require.NoError(t, err)
66+ assert.Equal(t, newWV, got)
67+ assert.Equal(t, newWV, c.webViews[entity.PaneID("pane-1")])
68+}
69+
70+func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *testing.T) {
71+ t.Parallel()
72+
73+ oldWV := mocks.NewMockWebView(t)
74+ oldWV.EXPECT().IsDestroyed().Return(true)
75+
76+ newWV := mocks.NewMockWebView(t)
77+ newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
78+
79+ pool := mocks.NewMockWebViewPool(t)
80+ pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
81+
82+ c := newMinimalCoordinator()
83+ c.pool = pool
84+ c.webViews[entity.PaneID("pane-1")] = oldWV
85+
86+ got, err := c.EnsureWebView(context.Background(), "pane-1")
87+
88+ require.NoError(t, err)
89+ assert.Equal(t, newWV, got)
90+}
91+
92+func TestLifecycle_EnsureWebView_ErrorWhenPoolIsNil(t *testing.T) {
93+ t.Parallel()
94+
95+ c := newMinimalCoordinator()
96+ // pool remains nil
97+
98+ _, err := c.EnsureWebView(context.Background(), "pane-1")
99+
100+ require.Error(t, err)
101+ assert.Contains(t, err.Error(), "webview pool not configured")
102+}
103+
104+func TestLifecycle_EnsureWebView_ErrorWhenAcquireFails(t *testing.T) {
105+ t.Parallel()
106+
107+ acquireErr := errors.New("pool exhausted")
108+
109+ pool := mocks.NewMockWebViewPool(t)
110+ pool.EXPECT().Acquire(mock.Anything).Return(nil, acquireErr)
111+
112+ c := newMinimalCoordinator()
113+ c.pool = pool
114+
115+ _, err := c.EnsureWebView(context.Background(), "pane-1")
116+
117+ require.Error(t, err)
118+ assert.Equal(t, acquireErr, err)
119+}
120+
121+// ---------------------------------------------------------------------------
122+// ReleaseWebView
123+// ---------------------------------------------------------------------------
124+
125+func TestLifecycle_ReleaseWebView_ReleasesToPool(t *testing.T) {
126+ t.Parallel()
127+
128+ wv := mocks.NewMockWebView(t)
129+ // idleInhibitor is nil so these won't be called
130+ wv.EXPECT().IsFullscreen().Return(false).Maybe()
131+ wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
132+
133+ pool := mocks.NewMockWebViewPool(t)
134+ pool.EXPECT().Release(wv)
135+
136+ c := newMinimalCoordinator()
137+ c.pool = pool
138+ c.webViews[entity.PaneID("pane-1")] = wv
139+
140+ c.ReleaseWebView(context.Background(), "pane-1")
141+
142+ _, exists := c.webViews[entity.PaneID("pane-1")]
143+ assert.False(t, exists, "webview should be removed from map after release")
144+}
145+
146+func TestLifecycle_ReleaseWebView_NoopForUnknownPane(t *testing.T) {
147+ t.Parallel()
148+
149+ pool := mocks.NewMockWebViewPool(t)
150+ // pool.Release must NOT be called
151+
152+ c := newMinimalCoordinator()
153+ c.pool = pool
154+
155+ // Should not panic
156+ c.ReleaseWebView(context.Background(), "unknown-pane")
157+}
158+
159+func TestLifecycle_ReleaseWebView_UninhibitsIdleIfFullscreen(t *testing.T) {
160+ t.Parallel()
161+
162+ wv := mocks.NewMockWebView(t)
163+ wv.EXPECT().IsFullscreen().Return(true)
164+ wv.EXPECT().IsPlayingAudio().Return(false)
165+
166+ inhibitor := mocks.NewMockIdleInhibitor(t)
167+ inhibitor.EXPECT().Uninhibit(mock.Anything).Return(nil)
168+
169+ pool := mocks.NewMockWebViewPool(t)
170+ pool.EXPECT().Release(wv)
171+
172+ c := newMinimalCoordinator()
173+ c.pool = pool
174+ c.idleInhibitor = inhibitor
175+ c.webViews[entity.PaneID("pane-1")] = wv
176+
177+ c.ReleaseWebView(context.Background(), "pane-1")
178+}
179+
180+func TestLifecycle_ReleaseWebView_UninhibitsIdleIfPlayingAudio(t *testing.T) {
181+ t.Parallel()
182+
183+ wv := mocks.NewMockWebView(t)
184+ wv.EXPECT().IsFullscreen().Return(false)
185+ wv.EXPECT().IsPlayingAudio().Return(true)
186+
187+ inhibitor := mocks.NewMockIdleInhibitor(t)
188+ inhibitor.EXPECT().Uninhibit(mock.Anything).Return(nil)
189+
190+ pool := mocks.NewMockWebViewPool(t)
191+ pool.EXPECT().Release(wv)
192+
193+ c := newMinimalCoordinator()
194+ c.pool = pool
195+ c.idleInhibitor = inhibitor
196+ c.webViews[entity.PaneID("pane-1")] = wv
197+
198+ c.ReleaseWebView(context.Background(), "pane-1")
199+}
200+
201+func TestLifecycle_ReleaseWebView_DestroysWebViewWhenPoolIsNil(t *testing.T) {
202+ t.Parallel()
203+
204+ wv := mocks.NewMockWebView(t)
205+ // idleInhibitor is nil so these won't be called
206+ wv.EXPECT().IsFullscreen().Return(false).Maybe()
207+ wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
208+ wv.EXPECT().Destroy()
209+
210+ c := newMinimalCoordinator()
211+ // pool remains nil
212+ c.webViews[entity.PaneID("pane-1")] = wv
213+
214+ c.ReleaseWebView(context.Background(), "pane-1")
215+}
216+
217+// ---------------------------------------------------------------------------
218+// GetWebView / RegisterPopupWebView
219+// ---------------------------------------------------------------------------
220+
221+func TestLifecycle_GetWebView_ReturnsRegisteredWebView(t *testing.T) {
222+ t.Parallel()
223+
224+ wv := mocks.NewMockWebView(t)
225+
226+ c := newMinimalCoordinator()
227+ c.webViews[entity.PaneID("pane-1")] = wv
228+
229+ got := c.GetWebView("pane-1")
230+
231+ assert.Equal(t, wv, got)
232+}
233+
234+func TestLifecycle_RegisterPopupWebView_StoresWebView(t *testing.T) {
235+ t.Parallel()
236+
237+ wv := mocks.NewMockWebView(t)
238+
239+ c := newMinimalCoordinator()
240+
241+ c.RegisterPopupWebView("popup-1", wv)
242+
243+ assert.Equal(t, wv, c.GetWebView("popup-1"))
244+}
245+
246+func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) {
247+ t.Parallel()
248+
249+ c := newMinimalCoordinator()
250+
251+ // Should not panic
252+ c.RegisterPopupWebView("popup-1", nil)
253+
254+ assert.Nil(t, c.GetWebView("popup-1"))
255+}
@@ -0,0 +1,255 @@
1+package content
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/port/mocks"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+)
16+
17+// newMinimalCoordinator returns a Coordinator with the maps required by
18+// ReleaseWebView initialized so that calls to clearPendingAppearance,
19+// titleMu / navOriginMu locks do not panic.
20+func newMinimalCoordinator() *Coordinator {
21+ return &Coordinator{
22+ webViews: make(map[entity.PaneID]port.WebView),
23+ paneTitles: make(map[entity.PaneID]string),
24+ navOrigins: make(map[entity.PaneID]string),
25+ }
26+}
27+
28+// ---------------------------------------------------------------------------
29+// EnsureWebView
30+// ---------------------------------------------------------------------------
31+
32+func TestLifecycle_EnsureWebView_ReusesExistingNonDestroyed(t *testing.T) {
33+ t.Parallel()
34+
35+ wv := mocks.NewMockWebView(t)
36+ wv.EXPECT().IsDestroyed().Return(false)
37+
38+ pool := mocks.NewMockWebViewPool(t)
39+ // pool.Acquire must NOT be called
40+
41+ c := newMinimalCoordinator()
42+ c.pool = pool
43+ c.webViews[entity.PaneID("pane-1")] = wv
44+
45+ got, err := c.EnsureWebView(context.Background(), "pane-1")
46+
47+ require.NoError(t, err)
48+ assert.Equal(t, wv, got)
49+}
50+
51+func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) {
52+ t.Parallel()
53+
54+ newWV := mocks.NewMockWebView(t)
55+ newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
56+
57+ pool := mocks.NewMockWebViewPool(t)
58+ pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
59+
60+ c := newMinimalCoordinator()
61+ c.pool = pool
62+
63+ got, err := c.EnsureWebView(context.Background(), "pane-1")
64+
65+ require.NoError(t, err)
66+ assert.Equal(t, newWV, got)
67+ assert.Equal(t, newWV, c.webViews[entity.PaneID("pane-1")])
68+}
69+
70+func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *testing.T) {
71+ t.Parallel()
72+
73+ oldWV := mocks.NewMockWebView(t)
74+ oldWV.EXPECT().IsDestroyed().Return(true)
75+
76+ newWV := mocks.NewMockWebView(t)
77+ newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
78+
79+ pool := mocks.NewMockWebViewPool(t)
80+ pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
81+
82+ c := newMinimalCoordinator()
83+ c.pool = pool
84+ c.webViews[entity.PaneID("pane-1")] = oldWV
85+
86+ got, err := c.EnsureWebView(context.Background(), "pane-1")
87+
88+ require.NoError(t, err)
89+ assert.Equal(t, newWV, got)
90+}
91+
92+func TestLifecycle_EnsureWebView_ErrorWhenPoolIsNil(t *testing.T) {
93+ t.Parallel()
94+
95+ c := newMinimalCoordinator()
96+ // pool remains nil
97+
98+ _, err := c.EnsureWebView(context.Background(), "pane-1")
99+
100+ require.Error(t, err)
101+ assert.Contains(t, err.Error(), "webview pool not configured")
102+}
103+
104+func TestLifecycle_EnsureWebView_ErrorWhenAcquireFails(t *testing.T) {
105+ t.Parallel()
106+
107+ acquireErr := errors.New("pool exhausted")
108+
109+ pool := mocks.NewMockWebViewPool(t)
110+ pool.EXPECT().Acquire(mock.Anything).Return(nil, acquireErr)
111+
112+ c := newMinimalCoordinator()
113+ c.pool = pool
114+
115+ _, err := c.EnsureWebView(context.Background(), "pane-1")
116+
117+ require.Error(t, err)
118+ assert.Equal(t, acquireErr, err)
119+}
120+
121+// ---------------------------------------------------------------------------
122+// ReleaseWebView
123+// ---------------------------------------------------------------------------
124+
125+func TestLifecycle_ReleaseWebView_ReleasesToPool(t *testing.T) {
126+ t.Parallel()
127+
128+ wv := mocks.NewMockWebView(t)
129+ // idleInhibitor is nil so these won't be called
130+ wv.EXPECT().IsFullscreen().Return(false).Maybe()
131+ wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
132+
133+ pool := mocks.NewMockWebViewPool(t)
134+ pool.EXPECT().Release(wv)
135+
136+ c := newMinimalCoordinator()
137+ c.pool = pool
138+ c.webViews[entity.PaneID("pane-1")] = wv
139+
140+ c.ReleaseWebView(context.Background(), "pane-1")
141+
142+ _, exists := c.webViews[entity.PaneID("pane-1")]
143+ assert.False(t, exists, "webview should be removed from map after release")
144+}
145+
146+func TestLifecycle_ReleaseWebView_NoopForUnknownPane(t *testing.T) {
147+ t.Parallel()
148+
149+ pool := mocks.NewMockWebViewPool(t)
150+ // pool.Release must NOT be called
151+
152+ c := newMinimalCoordinator()
153+ c.pool = pool
154+
155+ // Should not panic
156+ c.ReleaseWebView(context.Background(), "unknown-pane")
157+}
158+
159+func TestLifecycle_ReleaseWebView_UninhibitsIdleIfFullscreen(t *testing.T) {
160+ t.Parallel()
161+
162+ wv := mocks.NewMockWebView(t)
163+ wv.EXPECT().IsFullscreen().Return(true)
164+ wv.EXPECT().IsPlayingAudio().Return(false)
165+
166+ inhibitor := mocks.NewMockIdleInhibitor(t)
167+ inhibitor.EXPECT().Uninhibit(mock.Anything).Return(nil)
168+
169+ pool := mocks.NewMockWebViewPool(t)
170+ pool.EXPECT().Release(wv)
171+
172+ c := newMinimalCoordinator()
173+ c.pool = pool
174+ c.idleInhibitor = inhibitor
175+ c.webViews[entity.PaneID("pane-1")] = wv
176+
177+ c.ReleaseWebView(context.Background(), "pane-1")
178+}
179+
180+func TestLifecycle_ReleaseWebView_UninhibitsIdleIfPlayingAudio(t *testing.T) {
181+ t.Parallel()
182+
183+ wv := mocks.NewMockWebView(t)
184+ wv.EXPECT().IsFullscreen().Return(false)
185+ wv.EXPECT().IsPlayingAudio().Return(true)
186+
187+ inhibitor := mocks.NewMockIdleInhibitor(t)
188+ inhibitor.EXPECT().Uninhibit(mock.Anything).Return(nil)
189+
190+ pool := mocks.NewMockWebViewPool(t)
191+ pool.EXPECT().Release(wv)
192+
193+ c := newMinimalCoordinator()
194+ c.pool = pool
195+ c.idleInhibitor = inhibitor
196+ c.webViews[entity.PaneID("pane-1")] = wv
197+
198+ c.ReleaseWebView(context.Background(), "pane-1")
199+}
200+
201+func TestLifecycle_ReleaseWebView_DestroysWebViewWhenPoolIsNil(t *testing.T) {
202+ t.Parallel()
203+
204+ wv := mocks.NewMockWebView(t)
205+ // idleInhibitor is nil so these won't be called
206+ wv.EXPECT().IsFullscreen().Return(false).Maybe()
207+ wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
208+ wv.EXPECT().Destroy()
209+
210+ c := newMinimalCoordinator()
211+ // pool remains nil
212+ c.webViews[entity.PaneID("pane-1")] = wv
213+
214+ c.ReleaseWebView(context.Background(), "pane-1")
215+}
216+
217+// ---------------------------------------------------------------------------
218+// GetWebView / RegisterPopupWebView
219+// ---------------------------------------------------------------------------
220+
221+func TestLifecycle_GetWebView_ReturnsRegisteredWebView(t *testing.T) {
222+ t.Parallel()
223+
224+ wv := mocks.NewMockWebView(t)
225+
226+ c := newMinimalCoordinator()
227+ c.webViews[entity.PaneID("pane-1")] = wv
228+
229+ got := c.GetWebView("pane-1")
230+
231+ assert.Equal(t, wv, got)
232+}
233+
234+func TestLifecycle_RegisterPopupWebView_StoresWebView(t *testing.T) {
235+ t.Parallel()
236+
237+ wv := mocks.NewMockWebView(t)
238+
239+ c := newMinimalCoordinator()
240+
241+ c.RegisterPopupWebView("popup-1", wv)
242+
243+ assert.Equal(t, wv, c.GetWebView("popup-1"))
244+}
245+
246+func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) {
247+ t.Parallel()
248+
249+ c := newMinimalCoordinator()
250+
251+ // Should not panic
252+ c.RegisterPopupWebView("popup-1", nil)
253+
254+ assert.Nil(t, c.GetWebView("popup-1"))
255+}
A internal/ui/coordinator/content/popup_test.go +341 -0
@@ -0,0 +1,341 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
13+)
14+
15+// ---------------------------------------------------------------------------
16+// DetectPopupType
17+// ---------------------------------------------------------------------------
18+
19+func TestDetectPopupType_BlankIsTab(t *testing.T) {
20+ t.Parallel()
21+
22+ assert.Equal(t, PopupTypeTab, DetectPopupType("_blank"))
23+}
24+
25+func TestDetectPopupType_EmptyIsPopup(t *testing.T) {
26+ t.Parallel()
27+
28+ assert.Equal(t, PopupTypePopup, DetectPopupType(""))
29+}
30+
31+func TestDetectPopupType_NamedFrameIsPopup(t *testing.T) {
32+ t.Parallel()
33+
34+ assert.Equal(t, PopupTypePopup, DetectPopupType("myFrame"))
35+}
36+
37+// ---------------------------------------------------------------------------
38+// PopupType.String()
39+// ---------------------------------------------------------------------------
40+
41+func TestPopupTypeString_Tab(t *testing.T) {
42+ t.Parallel()
43+
44+ assert.Equal(t, "tab", PopupTypeTab.String())
45+}
46+
47+func TestPopupTypeString_Popup(t *testing.T) {
48+ t.Parallel()
49+
50+ assert.Equal(t, "popup", PopupTypePopup.String())
51+}
52+
53+func TestPopupTypeString_Unknown(t *testing.T) {
54+ t.Parallel()
55+
56+ assert.Equal(t, "unknown", PopupType(99).String())
57+}
58+
59+// ---------------------------------------------------------------------------
60+// GetBehavior
61+// ---------------------------------------------------------------------------
62+
63+func TestGetBehavior_NilConfigReturnsSplit(t *testing.T) {
64+ t.Parallel()
65+
66+ assert.Equal(t, config.PopupBehaviorSplit, GetBehavior(PopupTypeTab, nil))
67+ assert.Equal(t, config.PopupBehaviorSplit, GetBehavior(PopupTypePopup, nil))
68+}
69+
70+func TestGetBehavior_TabPopup_DefaultConfig(t *testing.T) {
71+ t.Parallel()
72+
73+ // BlankTargetBehavior is empty → falls through to default "stacked"
74+ cfg := &config.PopupBehaviorConfig{}
75+ assert.Equal(t, config.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
76+}
77+
78+func TestGetBehavior_TabPopup_SplitConfig(t *testing.T) {
79+ t.Parallel()
80+
81+ cfg := &config.PopupBehaviorConfig{BlankTargetBehavior: "split"}
82+ assert.Equal(t, config.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
83+}
84+
85+func TestGetBehavior_TabPopup_StackedConfig(t *testing.T) {
86+ t.Parallel()
87+
88+ cfg := &config.PopupBehaviorConfig{BlankTargetBehavior: "stacked"}
89+ assert.Equal(t, config.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
90+}
91+
92+func TestGetBehavior_TabPopup_TabbedConfig(t *testing.T) {
93+ t.Parallel()
94+
95+ cfg := &config.PopupBehaviorConfig{BlankTargetBehavior: "tabbed"}
96+ assert.Equal(t, config.PopupBehaviorTabbed, GetBehavior(PopupTypeTab, cfg))
97+}
98+
99+func TestGetBehavior_JSPopup_UsesBehaviorField(t *testing.T) {
100+ t.Parallel()
101+
102+ cfg := &config.PopupBehaviorConfig{Behavior: config.PopupBehaviorWindowed}
103+ assert.Equal(t, config.PopupBehaviorWindowed, GetBehavior(PopupTypePopup, cfg))
104+}
105+
106+func TestGetBehavior_JSPopup_DefaultConfig(t *testing.T) {
107+ t.Parallel()
108+
109+ // Behavior zero-value is empty string; GetBehavior returns it as-is.
110+ cfg := &config.PopupBehaviorConfig{}
111+ assert.Equal(t, config.PopupBehavior(""), GetBehavior(PopupTypePopup, cfg))
112+}
113+
114+func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
115+ t.Parallel()
116+
117+ // GetBehavior itself does not honor OpenInNewPane — that is enforced by
118+ // handlePopupCreate. GetBehavior still returns the configured value.
119+ cfg := &config.PopupBehaviorConfig{
120+ OpenInNewPane: false,
121+ BlankTargetBehavior: "split",
122+ }
123+ assert.Equal(t, config.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
124+}
125+
126+// ---------------------------------------------------------------------------
127+// trackOAuthPopup / capturePopupOAuthState
128+// ---------------------------------------------------------------------------
129+
130+func TestTrackOAuthPopup_StoresState(t *testing.T) {
131+ t.Parallel()
132+
133+ c := &Coordinator{
134+ webViews: make(map[entity.PaneID]port.WebView),
135+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
136+ }
137+
138+ popupID := port.WebViewID(1)
139+ parentPaneID := entity.PaneID("parent-pane")
140+
141+ c.trackOAuthPopup(popupID, parentPaneID)
142+
143+ c.popupMu.RLock()
144+ state, ok := c.popupOAuth[popupID]
145+ c.popupMu.RUnlock()
146+
147+ assert.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
148+ assert.Equal(t, parentPaneID, state.ParentPaneID)
149+ assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
150+}
151+
152+func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
153+ t.Parallel()
154+
155+ c := &Coordinator{
156+ webViews: make(map[entity.PaneID]port.WebView),
157+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
158+ }
159+
160+ popupID := port.WebViewID(2)
161+ c.trackOAuthPopup(popupID, entity.PaneID("parent"))
162+ c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
163+
164+ c.popupMu.RLock()
165+ state := c.popupOAuth[popupID]
166+ c.popupMu.RUnlock()
167+
168+ assert.True(t, state.Seen)
169+ assert.True(t, state.Success)
170+ assert.False(t, state.Error)
171+ assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI)
172+}
173+
174+func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
175+ t.Parallel()
176+
177+ c := &Coordinator{
178+ webViews: make(map[entity.PaneID]port.WebView),
179+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
180+ }
181+
182+ popupID := port.WebViewID(3)
183+ c.trackOAuthPopup(popupID, entity.PaneID("parent"))
184+ c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
185+
186+ c.popupMu.RLock()
187+ state := c.popupOAuth[popupID]
188+ c.popupMu.RUnlock()
189+
190+ assert.True(t, state.Seen)
191+ assert.False(t, state.Success)
192+ assert.True(t, state.Error)
193+}
194+
195+func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
196+ t.Parallel()
197+
198+ c := &Coordinator{
199+ webViews: make(map[entity.PaneID]port.WebView),
200+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
201+ }
202+
203+ // Should not panic when no tracking entry exists.
204+ c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
205+
206+ c.popupMu.RLock()
207+ _, ok := c.popupOAuth[port.WebViewID(999)]
208+ c.popupMu.RUnlock()
209+
210+ assert.False(t, ok)
211+}
212+
213+// ---------------------------------------------------------------------------
214+// SetPopupConfig
215+// ---------------------------------------------------------------------------
216+
217+func TestSetPopupConfig_SetsFields(t *testing.T) {
218+ t.Parallel()
219+
220+ c := &Coordinator{}
221+
222+ factory := &stubFactory{}
223+ cfg := &config.PopupBehaviorConfig{Behavior: config.PopupBehaviorSplit}
224+ genID := func() string { return "test-id" }
225+
226+ c.SetPopupConfig(factory, cfg, genID)
227+
228+ assert.Equal(t, factory, c.factory)
229+ assert.Equal(t, cfg, c.popupConfig)
230+ assert.NotNil(t, c.generateID)
231+ assert.Equal(t, "test-id", c.generateID())
232+}
233+
234+func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
235+ t.Parallel()
236+
237+ c := &Coordinator{}
238+ c.SetPopupConfig(nil, nil, nil)
239+
240+ assert.Nil(t, c.factory)
241+ assert.Nil(t, c.popupConfig)
242+ assert.Nil(t, c.generateID)
243+}
244+
245+// stubFactory satisfies port.WebViewFactory without importing the mocks package.
246+type stubFactory struct{}
247+
248+func (s *stubFactory) Create(_ context.Context) (port.WebView, error) {
249+ return nil, nil
250+}
251+
252+func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
253+ return nil, nil
254+}
255+
256+// ---------------------------------------------------------------------------
257+// Concurrent access to pendingPopups
258+// ---------------------------------------------------------------------------
259+
260+func TestPendingPopups_ConcurrentAccess(t *testing.T) {
261+ t.Parallel()
262+
263+ c := &Coordinator{
264+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
265+ }
266+
267+ const workers = 20
268+ var wg sync.WaitGroup
269+ wg.Add(workers * 2)
270+
271+ // Writers
272+ for i := 0; i < workers; i++ {
273+ go func(id int) {
274+ defer wg.Done()
275+ popupID := port.WebViewID(id)
276+ c.popupMu.Lock()
277+ c.pendingPopups[popupID] = &PendingPopup{
278+ FrameName: "_blank",
279+ PopupType: PopupTypeTab,
280+ }
281+ c.popupMu.Unlock()
282+ }(i)
283+ }
284+
285+ // Readers
286+ for i := 0; i < workers; i++ {
287+ go func(id int) {
288+ defer wg.Done()
289+ popupID := port.WebViewID(id)
290+ c.popupMu.RLock()
291+ _ = c.pendingPopups[popupID]
292+ c.popupMu.RUnlock()
293+ }(i)
294+ }
295+
296+ wg.Wait()
297+
298+ c.popupMu.RLock()
299+ count := len(c.pendingPopups)
300+ c.popupMu.RUnlock()
301+
302+ assert.LessOrEqual(t, count, workers, "pending popups should not exceed number of writers")
303+}
304+
305+func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
306+ t.Parallel()
307+
308+ c := &Coordinator{
309+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
310+ }
311+
312+ // Pre-populate
313+ for i := 0; i < 10; i++ {
314+ c.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
315+ }
316+
317+ var wg sync.WaitGroup
318+ wg.Add(20)
319+
320+ // Deleters
321+ for i := 0; i < 10; i++ {
322+ go func(id int) {
323+ defer wg.Done()
324+ c.popupMu.Lock()
325+ delete(c.pendingPopups, port.WebViewID(id))
326+ c.popupMu.Unlock()
327+ }(i)
328+ }
329+
330+ // Readers
331+ for i := 0; i < 10; i++ {
332+ go func(id int) {
333+ defer wg.Done()
334+ c.popupMu.RLock()
335+ _ = c.pendingPopups[port.WebViewID(id)]
336+ c.popupMu.RUnlock()
337+ }(i)
338+ }
339+
340+ wg.Wait()
341+}
@@ -0,0 +1,341 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
13+)
14+
15+// ---------------------------------------------------------------------------
16+// DetectPopupType
17+// ---------------------------------------------------------------------------
18+
19+func TestDetectPopupType_BlankIsTab(t *testing.T) {
20+ t.Parallel()
21+
22+ assert.Equal(t, PopupTypeTab, DetectPopupType("_blank"))
23+}
24+
25+func TestDetectPopupType_EmptyIsPopup(t *testing.T) {
26+ t.Parallel()
27+
28+ assert.Equal(t, PopupTypePopup, DetectPopupType(""))
29+}
30+
31+func TestDetectPopupType_NamedFrameIsPopup(t *testing.T) {
32+ t.Parallel()
33+
34+ assert.Equal(t, PopupTypePopup, DetectPopupType("myFrame"))
35+}
36+
37+// ---------------------------------------------------------------------------
38+// PopupType.String()
39+// ---------------------------------------------------------------------------
40+
41+func TestPopupTypeString_Tab(t *testing.T) {
42+ t.Parallel()
43+
44+ assert.Equal(t, "tab", PopupTypeTab.String())
45+}
46+
47+func TestPopupTypeString_Popup(t *testing.T) {
48+ t.Parallel()
49+
50+ assert.Equal(t, "popup", PopupTypePopup.String())
51+}
52+
53+func TestPopupTypeString_Unknown(t *testing.T) {
54+ t.Parallel()
55+
56+ assert.Equal(t, "unknown", PopupType(99).String())
57+}
58+
59+// ---------------------------------------------------------------------------
60+// GetBehavior
61+// ---------------------------------------------------------------------------
62+
63+func TestGetBehavior_NilConfigReturnsSplit(t *testing.T) {
64+ t.Parallel()
65+
66+ assert.Equal(t, config.PopupBehaviorSplit, GetBehavior(PopupTypeTab, nil))
67+ assert.Equal(t, config.PopupBehaviorSplit, GetBehavior(PopupTypePopup, nil))
68+}
69+
70+func TestGetBehavior_TabPopup_DefaultConfig(t *testing.T) {
71+ t.Parallel()
72+
73+ // BlankTargetBehavior is empty → falls through to default "stacked"
74+ cfg := &config.PopupBehaviorConfig{}
75+ assert.Equal(t, config.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
76+}
77+
78+func TestGetBehavior_TabPopup_SplitConfig(t *testing.T) {
79+ t.Parallel()
80+
81+ cfg := &config.PopupBehaviorConfig{BlankTargetBehavior: "split"}
82+ assert.Equal(t, config.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
83+}
84+
85+func TestGetBehavior_TabPopup_StackedConfig(t *testing.T) {
86+ t.Parallel()
87+
88+ cfg := &config.PopupBehaviorConfig{BlankTargetBehavior: "stacked"}
89+ assert.Equal(t, config.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
90+}
91+
92+func TestGetBehavior_TabPopup_TabbedConfig(t *testing.T) {
93+ t.Parallel()
94+
95+ cfg := &config.PopupBehaviorConfig{BlankTargetBehavior: "tabbed"}
96+ assert.Equal(t, config.PopupBehaviorTabbed, GetBehavior(PopupTypeTab, cfg))
97+}
98+
99+func TestGetBehavior_JSPopup_UsesBehaviorField(t *testing.T) {
100+ t.Parallel()
101+
102+ cfg := &config.PopupBehaviorConfig{Behavior: config.PopupBehaviorWindowed}
103+ assert.Equal(t, config.PopupBehaviorWindowed, GetBehavior(PopupTypePopup, cfg))
104+}
105+
106+func TestGetBehavior_JSPopup_DefaultConfig(t *testing.T) {
107+ t.Parallel()
108+
109+ // Behavior zero-value is empty string; GetBehavior returns it as-is.
110+ cfg := &config.PopupBehaviorConfig{}
111+ assert.Equal(t, config.PopupBehavior(""), GetBehavior(PopupTypePopup, cfg))
112+}
113+
114+func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
115+ t.Parallel()
116+
117+ // GetBehavior itself does not honor OpenInNewPane — that is enforced by
118+ // handlePopupCreate. GetBehavior still returns the configured value.
119+ cfg := &config.PopupBehaviorConfig{
120+ OpenInNewPane: false,
121+ BlankTargetBehavior: "split",
122+ }
123+ assert.Equal(t, config.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
124+}
125+
126+// ---------------------------------------------------------------------------
127+// trackOAuthPopup / capturePopupOAuthState
128+// ---------------------------------------------------------------------------
129+
130+func TestTrackOAuthPopup_StoresState(t *testing.T) {
131+ t.Parallel()
132+
133+ c := &Coordinator{
134+ webViews: make(map[entity.PaneID]port.WebView),
135+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
136+ }
137+
138+ popupID := port.WebViewID(1)
139+ parentPaneID := entity.PaneID("parent-pane")
140+
141+ c.trackOAuthPopup(popupID, parentPaneID)
142+
143+ c.popupMu.RLock()
144+ state, ok := c.popupOAuth[popupID]
145+ c.popupMu.RUnlock()
146+
147+ assert.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
148+ assert.Equal(t, parentPaneID, state.ParentPaneID)
149+ assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
150+}
151+
152+func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
153+ t.Parallel()
154+
155+ c := &Coordinator{
156+ webViews: make(map[entity.PaneID]port.WebView),
157+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
158+ }
159+
160+ popupID := port.WebViewID(2)
161+ c.trackOAuthPopup(popupID, entity.PaneID("parent"))
162+ c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
163+
164+ c.popupMu.RLock()
165+ state := c.popupOAuth[popupID]
166+ c.popupMu.RUnlock()
167+
168+ assert.True(t, state.Seen)
169+ assert.True(t, state.Success)
170+ assert.False(t, state.Error)
171+ assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI)
172+}
173+
174+func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
175+ t.Parallel()
176+
177+ c := &Coordinator{
178+ webViews: make(map[entity.PaneID]port.WebView),
179+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
180+ }
181+
182+ popupID := port.WebViewID(3)
183+ c.trackOAuthPopup(popupID, entity.PaneID("parent"))
184+ c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
185+
186+ c.popupMu.RLock()
187+ state := c.popupOAuth[popupID]
188+ c.popupMu.RUnlock()
189+
190+ assert.True(t, state.Seen)
191+ assert.False(t, state.Success)
192+ assert.True(t, state.Error)
193+}
194+
195+func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
196+ t.Parallel()
197+
198+ c := &Coordinator{
199+ webViews: make(map[entity.PaneID]port.WebView),
200+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
201+ }
202+
203+ // Should not panic when no tracking entry exists.
204+ c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
205+
206+ c.popupMu.RLock()
207+ _, ok := c.popupOAuth[port.WebViewID(999)]
208+ c.popupMu.RUnlock()
209+
210+ assert.False(t, ok)
211+}
212+
213+// ---------------------------------------------------------------------------
214+// SetPopupConfig
215+// ---------------------------------------------------------------------------
216+
217+func TestSetPopupConfig_SetsFields(t *testing.T) {
218+ t.Parallel()
219+
220+ c := &Coordinator{}
221+
222+ factory := &stubFactory{}
223+ cfg := &config.PopupBehaviorConfig{Behavior: config.PopupBehaviorSplit}
224+ genID := func() string { return "test-id" }
225+
226+ c.SetPopupConfig(factory, cfg, genID)
227+
228+ assert.Equal(t, factory, c.factory)
229+ assert.Equal(t, cfg, c.popupConfig)
230+ assert.NotNil(t, c.generateID)
231+ assert.Equal(t, "test-id", c.generateID())
232+}
233+
234+func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
235+ t.Parallel()
236+
237+ c := &Coordinator{}
238+ c.SetPopupConfig(nil, nil, nil)
239+
240+ assert.Nil(t, c.factory)
241+ assert.Nil(t, c.popupConfig)
242+ assert.Nil(t, c.generateID)
243+}
244+
245+// stubFactory satisfies port.WebViewFactory without importing the mocks package.
246+type stubFactory struct{}
247+
248+func (s *stubFactory) Create(_ context.Context) (port.WebView, error) {
249+ return nil, nil
250+}
251+
252+func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
253+ return nil, nil
254+}
255+
256+// ---------------------------------------------------------------------------
257+// Concurrent access to pendingPopups
258+// ---------------------------------------------------------------------------
259+
260+func TestPendingPopups_ConcurrentAccess(t *testing.T) {
261+ t.Parallel()
262+
263+ c := &Coordinator{
264+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
265+ }
266+
267+ const workers = 20
268+ var wg sync.WaitGroup
269+ wg.Add(workers * 2)
270+
271+ // Writers
272+ for i := 0; i < workers; i++ {
273+ go func(id int) {
274+ defer wg.Done()
275+ popupID := port.WebViewID(id)
276+ c.popupMu.Lock()
277+ c.pendingPopups[popupID] = &PendingPopup{
278+ FrameName: "_blank",
279+ PopupType: PopupTypeTab,
280+ }
281+ c.popupMu.Unlock()
282+ }(i)
283+ }
284+
285+ // Readers
286+ for i := 0; i < workers; i++ {
287+ go func(id int) {
288+ defer wg.Done()
289+ popupID := port.WebViewID(id)
290+ c.popupMu.RLock()
291+ _ = c.pendingPopups[popupID]
292+ c.popupMu.RUnlock()
293+ }(i)
294+ }
295+
296+ wg.Wait()
297+
298+ c.popupMu.RLock()
299+ count := len(c.pendingPopups)
300+ c.popupMu.RUnlock()
301+
302+ assert.LessOrEqual(t, count, workers, "pending popups should not exceed number of writers")
303+}
304+
305+func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
306+ t.Parallel()
307+
308+ c := &Coordinator{
309+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
310+ }
311+
312+ // Pre-populate
313+ for i := 0; i < 10; i++ {
314+ c.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
315+ }
316+
317+ var wg sync.WaitGroup
318+ wg.Add(20)
319+
320+ // Deleters
321+ for i := 0; i < 10; i++ {
322+ go func(id int) {
323+ defer wg.Done()
324+ c.popupMu.Lock()
325+ delete(c.pendingPopups, port.WebViewID(id))
326+ c.popupMu.Unlock()
327+ }(i)
328+ }
329+
330+ // Readers
331+ for i := 0; i < 10; i++ {
332+ go func(id int) {
333+ defer wg.Done()
334+ c.popupMu.RLock()
335+ _ = c.pendingPopups[port.WebViewID(id)]
336+ c.popupMu.RUnlock()
337+ }(i)
338+ }
339+
340+ wg.Wait()
341+}
A internal/ui/coordinator/content/title_test.go +440 -0
@@ -0,0 +1,440 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+// TestGetTitle tests the GetTitle method for title state retrieval.
14+
15+func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) {
16+ t.Parallel()
17+
18+ c := &Coordinator{
19+ paneTitles: make(map[entity.PaneID]string),
20+ }
21+
22+ title := c.GetTitle("unknown-pane")
23+ assert.Empty(t, title)
24+}
25+
26+func TestGetTitle_ReturnsTrackedTitle(t *testing.T) {
27+ t.Parallel()
28+
29+ paneID := entity.PaneID("pane-1")
30+ c := &Coordinator{
31+ paneTitles: map[entity.PaneID]string{
32+ paneID: "My Page Title",
33+ },
34+ }
35+
36+ title := c.GetTitle(paneID)
37+ assert.Equal(t, "My Page Title", title)
38+}
39+
40+func TestGetTitle_DirectMapUpdate(t *testing.T) {
41+ t.Parallel()
42+
43+ paneID := entity.PaneID("pane-1")
44+ c := &Coordinator{
45+ paneTitles: make(map[entity.PaneID]string),
46+ }
47+
48+ // Simulate the write that onTitleChanged performs
49+ c.titleMu.Lock()
50+ c.paneTitles[paneID] = "Updated Title"
51+ c.titleMu.Unlock()
52+
53+ title := c.GetTitle(paneID)
54+ assert.Equal(t, "Updated Title", title)
55+}
56+
57+func TestGetTitle_EmptyStringAfterZeroValue(t *testing.T) {
58+ t.Parallel()
59+
60+ paneID := entity.PaneID("pane-1")
61+ c := &Coordinator{
62+ paneTitles: map[entity.PaneID]string{
63+ paneID: "",
64+ },
65+ }
66+
67+ title := c.GetTitle(paneID)
68+ assert.Empty(t, title)
69+}
70+
71+// TestPaneTitles_ConcurrentAccess verifies that concurrent reads and writes to
72+// paneTitles do not race.
73+func TestPaneTitles_ConcurrentAccess(t *testing.T) {
74+ t.Parallel()
75+
76+ c := &Coordinator{
77+ paneTitles: make(map[entity.PaneID]string),
78+ }
79+
80+ paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3"}
81+
82+ var wg sync.WaitGroup
83+ for i := 0; i < 50; i++ {
84+ for _, paneID := range paneIDs {
85+ wg.Add(2)
86+
87+ go func(id entity.PaneID) {
88+ defer wg.Done()
89+ c.titleMu.Lock()
90+ c.paneTitles[id] = "title for " + string(id)
91+ c.titleMu.Unlock()
92+ }(paneID)
93+
94+ go func(id entity.PaneID) {
95+ defer wg.Done()
96+ _ = c.GetTitle(id)
97+ }(paneID)
98+ }
99+ }
100+
101+ wg.Wait()
102+ // Test passes if no race conditions occur
103+}
104+
105+func TestPaneTitles_MultiplepanesTrackedIndependently(t *testing.T) {
106+ t.Parallel()
107+
108+ pane1 := entity.PaneID("pane-1")
109+ pane2 := entity.PaneID("pane-2")
110+
111+ c := &Coordinator{
112+ paneTitles: map[entity.PaneID]string{
113+ pane1: "Title One",
114+ pane2: "Title Two",
115+ },
116+ }
117+
118+ assert.Equal(t, "Title One", c.GetTitle(pane1))
119+ assert.Equal(t, "Title Two", c.GetTitle(pane2))
120+}
121+
122+// TestSetNavigationOrigin tests navigation origin storage and retrieval.
123+
124+func TestSetNavigationOrigin_StoresOriginURL(t *testing.T) {
125+ t.Parallel()
126+
127+ paneID := entity.PaneID("pane-1")
128+ c := &Coordinator{
129+ navOrigins: make(map[entity.PaneID]string),
130+ }
131+
132+ c.SetNavigationOrigin(paneID, "https://google.fr/search?q=test")
133+
134+ c.navOriginMu.RLock()
135+ origin := c.navOrigins[paneID]
136+ c.navOriginMu.RUnlock()
137+
138+ assert.Equal(t, "https://google.fr/search?q=test", origin)
139+}
140+
141+func TestSetNavigationOrigin_OverwritesPreviousOrigin(t *testing.T) {
142+ t.Parallel()
143+
144+ paneID := entity.PaneID("pane-1")
145+ c := &Coordinator{
146+ navOrigins: make(map[entity.PaneID]string),
147+ }
148+
149+ c.SetNavigationOrigin(paneID, "https://google.fr/")
150+ c.SetNavigationOrigin(paneID, "https://example.com/new")
151+
152+ c.navOriginMu.RLock()
153+ origin := c.navOrigins[paneID]
154+ c.navOriginMu.RUnlock()
155+
156+ assert.Equal(t, "https://example.com/new", origin)
157+}
158+
159+func TestSetNavigationOrigin_IndependentPerPane(t *testing.T) {
160+ t.Parallel()
161+
162+ pane1 := entity.PaneID("pane-1")
163+ pane2 := entity.PaneID("pane-2")
164+ c := &Coordinator{
165+ navOrigins: make(map[entity.PaneID]string),
166+ }
167+
168+ c.SetNavigationOrigin(pane1, "https://pane1.example.com/")
169+ c.SetNavigationOrigin(pane2, "https://pane2.example.com/")
170+
171+ c.navOriginMu.RLock()
172+ origin1 := c.navOrigins[pane1]
173+ origin2 := c.navOrigins[pane2]
174+ c.navOriginMu.RUnlock()
175+
176+ assert.Equal(t, "https://pane1.example.com/", origin1)
177+ assert.Equal(t, "https://pane2.example.com/", origin2)
178+}
179+
180+func TestSetNavigationOrigin_EmptyOrigin(t *testing.T) {
181+ t.Parallel()
182+
183+ paneID := entity.PaneID("pane-1")
184+ c := &Coordinator{
185+ navOrigins: map[entity.PaneID]string{
186+ paneID: "https://old-origin.example.com/",
187+ },
188+ }
189+
190+ c.SetNavigationOrigin(paneID, "")
191+
192+ c.navOriginMu.RLock()
193+ origin := c.navOrigins[paneID]
194+ c.navOriginMu.RUnlock()
195+
196+ assert.Empty(t, origin)
197+}
198+
199+// TestNavOrigins_ConcurrentAccess verifies that concurrent reads and writes to
200+// navOrigins do not race.
201+func TestNavOrigins_ConcurrentAccess(t *testing.T) {
202+ t.Parallel()
203+
204+ c := &Coordinator{
205+ navOrigins: make(map[entity.PaneID]string),
206+ }
207+
208+ paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3"}
209+
210+ var wg sync.WaitGroup
211+ for i := 0; i < 50; i++ {
212+ for _, paneID := range paneIDs {
213+ wg.Add(2)
214+
215+ go func(id entity.PaneID) {
216+ defer wg.Done()
217+ c.SetNavigationOrigin(id, "https://origin-for-"+string(id)+".example.com/")
218+ }(paneID)
219+
220+ go func(id entity.PaneID) {
221+ defer wg.Done()
222+ c.navOriginMu.RLock()
223+ _ = c.navOrigins[id]
224+ c.navOriginMu.RUnlock()
225+ }(paneID)
226+ }
227+ }
228+
229+ wg.Wait()
230+ // Test passes if no race conditions occur
231+}
232+
233+// TestQueueThemeApply tests the appearance queue management for theme applies.
234+
235+func TestQueueThemeApply_TakeReturnsTrue(t *testing.T) {
236+ t.Parallel()
237+
238+ paneID := entity.PaneID("pane-1")
239+ c := &Coordinator{}
240+
241+ c.queueThemeApply(paneID, true, "body { color: white; }")
242+
243+ update, ok := c.takePendingThemeApply(paneID)
244+ assert.True(t, ok)
245+ assert.True(t, update.prefersDark)
246+ assert.Equal(t, "body { color: white; }", update.cssText)
247+}
248+
249+func TestQueueThemeApply_SecondTakeReturnsFalse(t *testing.T) {
250+ t.Parallel()
251+
252+ paneID := entity.PaneID("pane-1")
253+ c := &Coordinator{}
254+
255+ c.queueThemeApply(paneID, false, "body { color: black; }")
256+
257+ _, firstOk := c.takePendingThemeApply(paneID)
258+ assert.True(t, firstOk)
259+
260+ _, secondOk := c.takePendingThemeApply(paneID)
261+ assert.False(t, secondOk)
262+}
263+
264+func TestQueueThemeApply_TakeWithoutQueue_ReturnsFalse(t *testing.T) {
265+ t.Parallel()
266+
267+ paneID := entity.PaneID("pane-1")
268+ c := &Coordinator{}
269+
270+ _, ok := c.takePendingThemeApply(paneID)
271+ assert.False(t, ok)
272+}
273+
274+func TestQueueThemeApply_MultiplePanesQueued_Independently(t *testing.T) {
275+ t.Parallel()
276+
277+ pane1 := entity.PaneID("pane-1")
278+ pane2 := entity.PaneID("pane-2")
279+ c := &Coordinator{}
280+
281+ // pendingThemeUpdate is a single shared payload; queueThemeApply always
282+ // overwrites it with the latest call. Both panes are marked as pending,
283+ // but they share the last-written update.
284+ c.queueThemeApply(pane1, true, "shared-css")
285+ c.queueThemeApply(pane2, true, "shared-css")
286+
287+ // Both panes should be independently takeable
288+ update1, ok1 := c.takePendingThemeApply(pane1)
289+ assert.True(t, ok1)
290+ assert.Equal(t, "shared-css", update1.cssText)
291+
292+ // pane2 should still be queued after taking pane1
293+ update2, ok2 := c.takePendingThemeApply(pane2)
294+ assert.True(t, ok2)
295+ assert.Equal(t, "shared-css", update2.cssText)
296+
297+ // Neither should be available after both are taken
298+ _, ok3 := c.takePendingThemeApply(pane1)
299+ assert.False(t, ok3)
300+ _, ok4 := c.takePendingThemeApply(pane2)
301+ assert.False(t, ok4)
302+}
303+
304+func TestQueueThemeApply_LastWriteWins(t *testing.T) {
305+ t.Parallel()
306+
307+ paneID := entity.PaneID("pane-1")
308+ c := &Coordinator{}
309+
310+ // Queue twice; the second call overwrites pendingThemeUpdate
311+ c.queueThemeApply(paneID, true, "first-css")
312+ c.queueThemeApply(paneID, false, "second-css")
313+
314+ update, ok := c.takePendingThemeApply(paneID)
315+ assert.True(t, ok)
316+ assert.False(t, update.prefersDark)
317+ assert.Equal(t, "second-css", update.cssText)
318+}
319+
320+func TestQueueThemeApply_HasPendingFlagClearedWhenAllTaken(t *testing.T) {
321+ t.Parallel()
322+
323+ pane1 := entity.PaneID("pane-1")
324+ pane2 := entity.PaneID("pane-2")
325+ c := &Coordinator{}
326+
327+ c.queueThemeApply(pane1, true, "css")
328+ c.queueThemeApply(pane2, true, "css")
329+
330+ _, _ = c.takePendingThemeApply(pane1)
331+
332+ // hasPendingThemeUpdate should still be true since pane2 is pending
333+ c.appearanceMu.Lock()
334+ assert.True(t, c.hasPendingThemeUpdate)
335+ c.appearanceMu.Unlock()
336+
337+ _, _ = c.takePendingThemeApply(pane2)
338+
339+ // Now hasPendingThemeUpdate should be false
340+ c.appearanceMu.Lock()
341+ assert.False(t, c.hasPendingThemeUpdate)
342+ c.appearanceMu.Unlock()
343+}
344+
345+// TestQueueScriptRefresh tests the appearance queue management for script refreshes.
346+
347+func TestQueueScriptRefresh_TakeReturnsTrue(t *testing.T) {
348+ t.Parallel()
349+
350+ paneID := entity.PaneID("pane-1")
351+ c := &Coordinator{}
352+
353+ c.queueScriptRefresh(paneID)
354+
355+ ok := c.takePendingScriptRefresh(paneID)
356+ assert.True(t, ok)
357+}
358+
359+func TestQueueScriptRefresh_SecondTakeReturnsFalse(t *testing.T) {
360+ t.Parallel()
361+
362+ paneID := entity.PaneID("pane-1")
363+ c := &Coordinator{}
364+
365+ c.queueScriptRefresh(paneID)
366+
367+ firstOk := c.takePendingScriptRefresh(paneID)
368+ assert.True(t, firstOk)
369+
370+ secondOk := c.takePendingScriptRefresh(paneID)
371+ assert.False(t, secondOk)
372+}
373+
374+func TestQueueScriptRefresh_TakeWithoutQueue_ReturnsFalse(t *testing.T) {
375+ t.Parallel()
376+
377+ paneID := entity.PaneID("pane-1")
378+ c := &Coordinator{}
379+
380+ ok := c.takePendingScriptRefresh(paneID)
381+ assert.False(t, ok)
382+}
383+
384+func TestQueueScriptRefresh_MultiplePanesQueuedIndependently(t *testing.T) {
385+ t.Parallel()
386+
387+ pane1 := entity.PaneID("pane-1")
388+ pane2 := entity.PaneID("pane-2")
389+ pane3 := entity.PaneID("pane-3")
390+ c := &Coordinator{}
391+
392+ c.queueScriptRefresh(pane1)
393+ c.queueScriptRefresh(pane2)
394+
395+ // pane3 was never queued
396+ assert.True(t, c.takePendingScriptRefresh(pane1))
397+ assert.True(t, c.takePendingScriptRefresh(pane2))
398+ assert.False(t, c.takePendingScriptRefresh(pane3))
399+}
400+
401+// TestApplyPendingThemeUpdate tests the state management aspect of applyPendingThemeUpdate.
402+
403+func TestApplyPendingThemeUpdate_ReturnsFalseWhenNothingQueued(t *testing.T) {
404+ t.Parallel()
405+
406+ paneID := entity.PaneID("pane-1")
407+ c := &Coordinator{}
408+
409+ // applyPendingThemeUpdate calls takePendingThemeApply; with nothing queued it
410+ // returns false immediately without touching the WebView.
411+ result := c.applyPendingThemeUpdate(context.TODO(), paneID, nil)
412+ assert.False(t, result)
413+}
414+
415+// TestClearPendingAppearance tests that clearPendingAppearance removes both
416+// theme and script queues for the targeted pane only.
417+func TestClearPendingAppearance_ClearsBothQueuesForPane(t *testing.T) {
418+ t.Parallel()
419+
420+ pane1 := entity.PaneID("pane-1")
421+ pane2 := entity.PaneID("pane-2")
422+ c := &Coordinator{}
423+
424+ c.queueThemeApply(pane1, true, "css")
425+ c.queueScriptRefresh(pane1)
426+ c.queueThemeApply(pane2, true, "css")
427+ c.queueScriptRefresh(pane2)
428+
429+ c.clearPendingAppearance(pane1)
430+
431+ // pane1 queues should be gone
432+ assert.False(t, c.takePendingScriptRefresh(pane1))
433+ _, themeOk1 := c.takePendingThemeApply(pane1)
434+ assert.False(t, themeOk1)
435+
436+ // pane2 queues should still be present
437+ assert.True(t, c.takePendingScriptRefresh(pane2))
438+ _, themeOk2 := c.takePendingThemeApply(pane2)
439+ assert.True(t, themeOk2)
440+}
@@ -0,0 +1,440 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+// TestGetTitle tests the GetTitle method for title state retrieval.
14+
15+func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) {
16+ t.Parallel()
17+
18+ c := &Coordinator{
19+ paneTitles: make(map[entity.PaneID]string),
20+ }
21+
22+ title := c.GetTitle("unknown-pane")
23+ assert.Empty(t, title)
24+}
25+
26+func TestGetTitle_ReturnsTrackedTitle(t *testing.T) {
27+ t.Parallel()
28+
29+ paneID := entity.PaneID("pane-1")
30+ c := &Coordinator{
31+ paneTitles: map[entity.PaneID]string{
32+ paneID: "My Page Title",
33+ },
34+ }
35+
36+ title := c.GetTitle(paneID)
37+ assert.Equal(t, "My Page Title", title)
38+}
39+
40+func TestGetTitle_DirectMapUpdate(t *testing.T) {
41+ t.Parallel()
42+
43+ paneID := entity.PaneID("pane-1")
44+ c := &Coordinator{
45+ paneTitles: make(map[entity.PaneID]string),
46+ }
47+
48+ // Simulate the write that onTitleChanged performs
49+ c.titleMu.Lock()
50+ c.paneTitles[paneID] = "Updated Title"
51+ c.titleMu.Unlock()
52+
53+ title := c.GetTitle(paneID)
54+ assert.Equal(t, "Updated Title", title)
55+}
56+
57+func TestGetTitle_EmptyStringAfterZeroValue(t *testing.T) {
58+ t.Parallel()
59+
60+ paneID := entity.PaneID("pane-1")
61+ c := &Coordinator{
62+ paneTitles: map[entity.PaneID]string{
63+ paneID: "",
64+ },
65+ }
66+
67+ title := c.GetTitle(paneID)
68+ assert.Empty(t, title)
69+}
70+
71+// TestPaneTitles_ConcurrentAccess verifies that concurrent reads and writes to
72+// paneTitles do not race.
73+func TestPaneTitles_ConcurrentAccess(t *testing.T) {
74+ t.Parallel()
75+
76+ c := &Coordinator{
77+ paneTitles: make(map[entity.PaneID]string),
78+ }
79+
80+ paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3"}
81+
82+ var wg sync.WaitGroup
83+ for i := 0; i < 50; i++ {
84+ for _, paneID := range paneIDs {
85+ wg.Add(2)
86+
87+ go func(id entity.PaneID) {
88+ defer wg.Done()
89+ c.titleMu.Lock()
90+ c.paneTitles[id] = "title for " + string(id)
91+ c.titleMu.Unlock()
92+ }(paneID)
93+
94+ go func(id entity.PaneID) {
95+ defer wg.Done()
96+ _ = c.GetTitle(id)
97+ }(paneID)
98+ }
99+ }
100+
101+ wg.Wait()
102+ // Test passes if no race conditions occur
103+}
104+
105+func TestPaneTitles_MultiplepanesTrackedIndependently(t *testing.T) {
106+ t.Parallel()
107+
108+ pane1 := entity.PaneID("pane-1")
109+ pane2 := entity.PaneID("pane-2")
110+
111+ c := &Coordinator{
112+ paneTitles: map[entity.PaneID]string{
113+ pane1: "Title One",
114+ pane2: "Title Two",
115+ },
116+ }
117+
118+ assert.Equal(t, "Title One", c.GetTitle(pane1))
119+ assert.Equal(t, "Title Two", c.GetTitle(pane2))
120+}
121+
122+// TestSetNavigationOrigin tests navigation origin storage and retrieval.
123+
124+func TestSetNavigationOrigin_StoresOriginURL(t *testing.T) {
125+ t.Parallel()
126+
127+ paneID := entity.PaneID("pane-1")
128+ c := &Coordinator{
129+ navOrigins: make(map[entity.PaneID]string),
130+ }
131+
132+ c.SetNavigationOrigin(paneID, "https://google.fr/search?q=test")
133+
134+ c.navOriginMu.RLock()
135+ origin := c.navOrigins[paneID]
136+ c.navOriginMu.RUnlock()
137+
138+ assert.Equal(t, "https://google.fr/search?q=test", origin)
139+}
140+
141+func TestSetNavigationOrigin_OverwritesPreviousOrigin(t *testing.T) {
142+ t.Parallel()
143+
144+ paneID := entity.PaneID("pane-1")
145+ c := &Coordinator{
146+ navOrigins: make(map[entity.PaneID]string),
147+ }
148+
149+ c.SetNavigationOrigin(paneID, "https://google.fr/")
150+ c.SetNavigationOrigin(paneID, "https://example.com/new")
151+
152+ c.navOriginMu.RLock()
153+ origin := c.navOrigins[paneID]
154+ c.navOriginMu.RUnlock()
155+
156+ assert.Equal(t, "https://example.com/new", origin)
157+}
158+
159+func TestSetNavigationOrigin_IndependentPerPane(t *testing.T) {
160+ t.Parallel()
161+
162+ pane1 := entity.PaneID("pane-1")
163+ pane2 := entity.PaneID("pane-2")
164+ c := &Coordinator{
165+ navOrigins: make(map[entity.PaneID]string),
166+ }
167+
168+ c.SetNavigationOrigin(pane1, "https://pane1.example.com/")
169+ c.SetNavigationOrigin(pane2, "https://pane2.example.com/")
170+
171+ c.navOriginMu.RLock()
172+ origin1 := c.navOrigins[pane1]
173+ origin2 := c.navOrigins[pane2]
174+ c.navOriginMu.RUnlock()
175+
176+ assert.Equal(t, "https://pane1.example.com/", origin1)
177+ assert.Equal(t, "https://pane2.example.com/", origin2)
178+}
179+
180+func TestSetNavigationOrigin_EmptyOrigin(t *testing.T) {
181+ t.Parallel()
182+
183+ paneID := entity.PaneID("pane-1")
184+ c := &Coordinator{
185+ navOrigins: map[entity.PaneID]string{
186+ paneID: "https://old-origin.example.com/",
187+ },
188+ }
189+
190+ c.SetNavigationOrigin(paneID, "")
191+
192+ c.navOriginMu.RLock()
193+ origin := c.navOrigins[paneID]
194+ c.navOriginMu.RUnlock()
195+
196+ assert.Empty(t, origin)
197+}
198+
199+// TestNavOrigins_ConcurrentAccess verifies that concurrent reads and writes to
200+// navOrigins do not race.
201+func TestNavOrigins_ConcurrentAccess(t *testing.T) {
202+ t.Parallel()
203+
204+ c := &Coordinator{
205+ navOrigins: make(map[entity.PaneID]string),
206+ }
207+
208+ paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3"}
209+
210+ var wg sync.WaitGroup
211+ for i := 0; i < 50; i++ {
212+ for _, paneID := range paneIDs {
213+ wg.Add(2)
214+
215+ go func(id entity.PaneID) {
216+ defer wg.Done()
217+ c.SetNavigationOrigin(id, "https://origin-for-"+string(id)+".example.com/")
218+ }(paneID)
219+
220+ go func(id entity.PaneID) {
221+ defer wg.Done()
222+ c.navOriginMu.RLock()
223+ _ = c.navOrigins[id]
224+ c.navOriginMu.RUnlock()
225+ }(paneID)
226+ }
227+ }
228+
229+ wg.Wait()
230+ // Test passes if no race conditions occur
231+}
232+
233+// TestQueueThemeApply tests the appearance queue management for theme applies.
234+
235+func TestQueueThemeApply_TakeReturnsTrue(t *testing.T) {
236+ t.Parallel()
237+
238+ paneID := entity.PaneID("pane-1")
239+ c := &Coordinator{}
240+
241+ c.queueThemeApply(paneID, true, "body { color: white; }")
242+
243+ update, ok := c.takePendingThemeApply(paneID)
244+ assert.True(t, ok)
245+ assert.True(t, update.prefersDark)
246+ assert.Equal(t, "body { color: white; }", update.cssText)
247+}
248+
249+func TestQueueThemeApply_SecondTakeReturnsFalse(t *testing.T) {
250+ t.Parallel()
251+
252+ paneID := entity.PaneID("pane-1")
253+ c := &Coordinator{}
254+
255+ c.queueThemeApply(paneID, false, "body { color: black; }")
256+
257+ _, firstOk := c.takePendingThemeApply(paneID)
258+ assert.True(t, firstOk)
259+
260+ _, secondOk := c.takePendingThemeApply(paneID)
261+ assert.False(t, secondOk)
262+}
263+
264+func TestQueueThemeApply_TakeWithoutQueue_ReturnsFalse(t *testing.T) {
265+ t.Parallel()
266+
267+ paneID := entity.PaneID("pane-1")
268+ c := &Coordinator{}
269+
270+ _, ok := c.takePendingThemeApply(paneID)
271+ assert.False(t, ok)
272+}
273+
274+func TestQueueThemeApply_MultiplePanesQueued_Independently(t *testing.T) {
275+ t.Parallel()
276+
277+ pane1 := entity.PaneID("pane-1")
278+ pane2 := entity.PaneID("pane-2")
279+ c := &Coordinator{}
280+
281+ // pendingThemeUpdate is a single shared payload; queueThemeApply always
282+ // overwrites it with the latest call. Both panes are marked as pending,
283+ // but they share the last-written update.
284+ c.queueThemeApply(pane1, true, "shared-css")
285+ c.queueThemeApply(pane2, true, "shared-css")
286+
287+ // Both panes should be independently takeable
288+ update1, ok1 := c.takePendingThemeApply(pane1)
289+ assert.True(t, ok1)
290+ assert.Equal(t, "shared-css", update1.cssText)
291+
292+ // pane2 should still be queued after taking pane1
293+ update2, ok2 := c.takePendingThemeApply(pane2)
294+ assert.True(t, ok2)
295+ assert.Equal(t, "shared-css", update2.cssText)
296+
297+ // Neither should be available after both are taken
298+ _, ok3 := c.takePendingThemeApply(pane1)
299+ assert.False(t, ok3)
300+ _, ok4 := c.takePendingThemeApply(pane2)
301+ assert.False(t, ok4)
302+}
303+
304+func TestQueueThemeApply_LastWriteWins(t *testing.T) {
305+ t.Parallel()
306+
307+ paneID := entity.PaneID("pane-1")
308+ c := &Coordinator{}
309+
310+ // Queue twice; the second call overwrites pendingThemeUpdate
311+ c.queueThemeApply(paneID, true, "first-css")
312+ c.queueThemeApply(paneID, false, "second-css")
313+
314+ update, ok := c.takePendingThemeApply(paneID)
315+ assert.True(t, ok)
316+ assert.False(t, update.prefersDark)
317+ assert.Equal(t, "second-css", update.cssText)
318+}
319+
320+func TestQueueThemeApply_HasPendingFlagClearedWhenAllTaken(t *testing.T) {
321+ t.Parallel()
322+
323+ pane1 := entity.PaneID("pane-1")
324+ pane2 := entity.PaneID("pane-2")
325+ c := &Coordinator{}
326+
327+ c.queueThemeApply(pane1, true, "css")
328+ c.queueThemeApply(pane2, true, "css")
329+
330+ _, _ = c.takePendingThemeApply(pane1)
331+
332+ // hasPendingThemeUpdate should still be true since pane2 is pending
333+ c.appearanceMu.Lock()
334+ assert.True(t, c.hasPendingThemeUpdate)
335+ c.appearanceMu.Unlock()
336+
337+ _, _ = c.takePendingThemeApply(pane2)
338+
339+ // Now hasPendingThemeUpdate should be false
340+ c.appearanceMu.Lock()
341+ assert.False(t, c.hasPendingThemeUpdate)
342+ c.appearanceMu.Unlock()
343+}
344+
345+// TestQueueScriptRefresh tests the appearance queue management for script refreshes.
346+
347+func TestQueueScriptRefresh_TakeReturnsTrue(t *testing.T) {
348+ t.Parallel()
349+
350+ paneID := entity.PaneID("pane-1")
351+ c := &Coordinator{}
352+
353+ c.queueScriptRefresh(paneID)
354+
355+ ok := c.takePendingScriptRefresh(paneID)
356+ assert.True(t, ok)
357+}
358+
359+func TestQueueScriptRefresh_SecondTakeReturnsFalse(t *testing.T) {
360+ t.Parallel()
361+
362+ paneID := entity.PaneID("pane-1")
363+ c := &Coordinator{}
364+
365+ c.queueScriptRefresh(paneID)
366+
367+ firstOk := c.takePendingScriptRefresh(paneID)
368+ assert.True(t, firstOk)
369+
370+ secondOk := c.takePendingScriptRefresh(paneID)
371+ assert.False(t, secondOk)
372+}
373+
374+func TestQueueScriptRefresh_TakeWithoutQueue_ReturnsFalse(t *testing.T) {
375+ t.Parallel()
376+
377+ paneID := entity.PaneID("pane-1")
378+ c := &Coordinator{}
379+
380+ ok := c.takePendingScriptRefresh(paneID)
381+ assert.False(t, ok)
382+}
383+
384+func TestQueueScriptRefresh_MultiplePanesQueuedIndependently(t *testing.T) {
385+ t.Parallel()
386+
387+ pane1 := entity.PaneID("pane-1")
388+ pane2 := entity.PaneID("pane-2")
389+ pane3 := entity.PaneID("pane-3")
390+ c := &Coordinator{}
391+
392+ c.queueScriptRefresh(pane1)
393+ c.queueScriptRefresh(pane2)
394+
395+ // pane3 was never queued
396+ assert.True(t, c.takePendingScriptRefresh(pane1))
397+ assert.True(t, c.takePendingScriptRefresh(pane2))
398+ assert.False(t, c.takePendingScriptRefresh(pane3))
399+}
400+
401+// TestApplyPendingThemeUpdate tests the state management aspect of applyPendingThemeUpdate.
402+
403+func TestApplyPendingThemeUpdate_ReturnsFalseWhenNothingQueued(t *testing.T) {
404+ t.Parallel()
405+
406+ paneID := entity.PaneID("pane-1")
407+ c := &Coordinator{}
408+
409+ // applyPendingThemeUpdate calls takePendingThemeApply; with nothing queued it
410+ // returns false immediately without touching the WebView.
411+ result := c.applyPendingThemeUpdate(context.TODO(), paneID, nil)
412+ assert.False(t, result)
413+}
414+
415+// TestClearPendingAppearance tests that clearPendingAppearance removes both
416+// theme and script queues for the targeted pane only.
417+func TestClearPendingAppearance_ClearsBothQueuesForPane(t *testing.T) {
418+ t.Parallel()
419+
420+ pane1 := entity.PaneID("pane-1")
421+ pane2 := entity.PaneID("pane-2")
422+ c := &Coordinator{}
423+
424+ c.queueThemeApply(pane1, true, "css")
425+ c.queueScriptRefresh(pane1)
426+ c.queueThemeApply(pane2, true, "css")
427+ c.queueScriptRefresh(pane2)
428+
429+ c.clearPendingAppearance(pane1)
430+
431+ // pane1 queues should be gone
432+ assert.False(t, c.takePendingScriptRefresh(pane1))
433+ _, themeOk1 := c.takePendingThemeApply(pane1)
434+ assert.False(t, themeOk1)
435+
436+ // pane2 queues should still be present
437+ assert.True(t, c.takePendingScriptRefresh(pane2))
438+ _, themeOk2 := c.takePendingThemeApply(pane2)
439+ assert.True(t, themeOk2)
440+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close