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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +4439 -1722
M .mockery.yaml +12 -0
| @@ -33,6 +33,18 @@ packages: | |||
| 33 | 33 | DownloadResponse: {} | |
| 34 | 34 | PermissionDialogPresenter: {} | |
| 35 | 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 | 48 | github.com/bnema/dumber/internal/domain/repository: | |
| 37 | 49 | config: | |
| 38 | 50 | 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 | + | } | |||