1package ui
2
3import (
4 "context"
5 "testing"
6 "unsafe"
7
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/ui/component"
10 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
11 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14)
15
16func TestDecorateFloatingOverlay_AddsThemeClasses(t *testing.T) {
17 overlay := layoutmocks.NewMockOverlayWidget(t)
18 overlay.EXPECT().SetHexpand(false).Once()
19 overlay.EXPECT().SetVexpand(false).Once()
20 overlay.EXPECT().AddCssClass("floating-pane-container").Once()
21 overlay.EXPECT().AddCssClass("pane-border").Once()
22 overlay.EXPECT().AddCssClass("pane-active").Once()
23
24 decorateFloatingOverlay(overlay)
25}
26
27func TestConfigureFloatingOverlayMeasurement_DoesNotMeasureOverlay(t *testing.T) {
28 workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
29 floatingOverlay := layoutmocks.NewMockWidget(t)
30
31 workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, false).Once()
32 workspaceOverlay.EXPECT().SetClipOverlay(floatingOverlay, false).Once()
33
34 configureFloatingOverlayMeasurement(workspaceOverlay, floatingOverlay)
35}
36
37func TestShowFloatingWidget_AddsVisibleClassAndEnablesInteraction(t *testing.T) {
38 widget := layoutmocks.NewMockWidget(t)
39
40 widget.EXPECT().AddCssClass(floatingPaneVisibleClass).Once()
41 widget.EXPECT().SetCanTarget(true).Once()
42 widget.EXPECT().SetCanFocus(true).Once()
43
44 showFloatingWidget(widget)
45}
46
47func TestHideFloatingWidget_RemovesVisibleClassAndDisablesInteraction(t *testing.T) {
48 widget := layoutmocks.NewMockWidget(t)
49
50 widget.EXPECT().RemoveCssClass(floatingPaneVisibleClass).Once()
51 widget.EXPECT().SetCanTarget(false).Once()
52 widget.EXPECT().SetCanFocus(false).Once()
53
54 hideFloatingWidget(widget)
55}
56
57func TestFocusFloatingSessionContent_GrabsWebViewFocusWhenOmniboxHidden(t *testing.T) {
58 session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:google")
59 session.pane.Show()
60
61 widget := layoutmocks.NewMockWidget(t)
62 widget.EXPECT().GrabFocus().Return(true).Once()
63 session.focusWidget = widget
64
65 focusFloatingSessionContent(session)
66}
67
68func TestFocusFloatingSessionContent_SkipsWhenOmniboxVisible(t *testing.T) {
69 session := newFloatingPaneSession(entity.TabID("tab-1"), floatingSessionIDDefault)
70 require.NoError(t, session.pane.ShowToggle(context.Background()))
71 require.True(t, session.pane.IsOmniboxVisible())
72
73 widget := layoutmocks.NewMockWidget(t)
74 session.focusWidget = widget
75
76 focusFloatingSessionContent(session)
77}
78
79func TestFloatingAllocationRect_CentersRequestedSize(t *testing.T) {
80 x, y, width, height, ok := floatingAllocationRect(1000, 700, 820, 504)
81
82 require.True(t, ok)
83 assert.Equal(t, 90, x)
84 assert.Equal(t, 98, y)
85 assert.Equal(t, 820, width)
86 assert.Equal(t, 504, height)
87}
88
89func TestFloatingAllocationRect_ClampsToOverlayBounds(t *testing.T) {
90 x, y, width, height, ok := floatingAllocationRect(1000, 700, 1200, 900)
91
92 require.True(t, ok)
93 assert.Equal(t, 0, x)
94 assert.Equal(t, 0, y)
95 assert.Equal(t, 1000, width)
96 assert.Equal(t, 700, height)
97}
98
99func TestWriteOverlayAllocation_WritesRectangleFields(t *testing.T) {
100 // cGtkAllocation mirrors GTK's GdkRectangle/GtkAllocation layout used by
101 // writeOverlayAllocation: four int32 fields in order. allocationPtr uses
102 // unsafe.Pointer to simulate the C allocation callback memory.
103 type cGtkAllocation struct {
104 X int32
105 Y int32
106 Width int32
107 Height int32
108 }
109
110 rect := cGtkAllocation{}
111 allocationPtr := (*uintptr)(unsafe.Pointer(&rect))
112
113 ok := writeOverlayAllocation(allocationPtr, 11, 22, 33, 44)
114
115 require.True(t, ok)
116 assert.Equal(t, int32(11), rect.X)
117 assert.Equal(t, int32(22), rect.Y)
118 assert.Equal(t, int32(33), rect.Width)
119 assert.Equal(t, int32(44), rect.Height)
120}
121
122func TestFloatingPane_ToggleActiveWorkspaceSession(t *testing.T) {
123 app, tabID, session := newFloatingPaneTestApp(t)
124
125 err := app.ToggleFloatingPane(context.Background())
126 require.NoError(t, err)
127 assert.True(t, session.pane.IsVisible())
128 assert.Equal(t, "about:blank", session.pane.CurrentURL())
129
130 err = app.ToggleFloatingPane(context.Background())
131 require.NoError(t, err)
132 assert.False(t, session.pane.IsVisible())
133 assert.Equal(t, "about:blank", session.pane.CurrentURL())
134
135 _, gotTabID := app.activeFloatingSession()
136 assert.Equal(t, tabID, gotTabID)
137}
138
139func TestFloatingPane_ProfileShortcutOpensConfiguredURL(t *testing.T) {
140 app, _, session := newFloatingPaneTestApp(t)
141
142 err := app.OpenFloatingPaneURL(context.Background(), "https://google.com")
143 require.NoError(t, err)
144
145 assert.True(t, session.pane.IsVisible())
146 assert.False(t, session.pane.IsOmniboxVisible())
147 assert.Equal(t, "https://google.com", session.pane.CurrentURL())
148}
149
150func TestFloatingPane_ActivePaneSwitchPreservesSession(t *testing.T) {
151 app, tabID, session := newFloatingPaneTestApp(t)
152
153 err := app.OpenFloatingPaneURL(context.Background(), "https://example.com/start")
154 require.NoError(t, err)
155
156 activeTab := app.tabs.Find(tabID)
157 require.NotNil(t, activeTab)
158 activeTab.Workspace.ActivePaneID = entity.PaneID("pane-two")
159
160 err = app.ToggleFloatingPane(context.Background())
161 require.NoError(t, err)
162 err = app.ToggleFloatingPane(context.Background())
163 require.NoError(t, err)
164
165 assert.Equal(t, "https://example.com/start", session.pane.CurrentURL())
166}
167
168func TestFloatingPane_OmniboxNavigationTargetsFloatingSession(t *testing.T) {
169 app, _, session := newFloatingPaneTestApp(t)
170
171 require.NoError(t, session.pane.ShowToggle(context.Background()))
172 require.True(t, session.pane.IsOmniboxVisible())
173
174 err := app.navigateFromOmnibox(context.Background(), "https://example.com/omnibox")
175 require.NoError(t, err)
176 assert.Equal(t, "https://example.com/omnibox", session.pane.CurrentURL())
177}
178
179func TestFloatingPane_HandleViewportTick_RecalculatesOnWorkspaceResize(t *testing.T) {
180 ctx := context.Background()
181 overlay := layoutmocks.NewMockOverlayWidget(t)
182 width := 1000
183 height := 700
184 overlay.EXPECT().GetAllocatedWidth().RunAndReturn(func() int { return width }).Maybe()
185 overlay.EXPECT().GetAllocatedHeight().RunAndReturn(func() int { return height }).Maybe()
186
187 widget := layoutmocks.NewMockWidget(t)
188 pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
189 WidthPct: 0.82,
190 HeightPct: 0.72,
191 FallbackWidth: floatingPaneFallbackWidth,
192 FallbackHeight: floatingPaneFallbackHeight,
193 })
194 require.NoError(t, pane.ShowURL(ctx, "https://example.com"))
195
196 session := &floatingWorkspaceSession{pane: pane, overlay: overlay, widget: widget}
197 app := &App{}
198
199 widget.EXPECT().SetSizeRequest(820, 504).Once()
200 assert.True(t, app.handleFloatingViewportTick(session))
201
202 width = 1200
203 height = 900
204 widget.EXPECT().SetSizeRequest(983, 648).Once()
205 assert.True(t, app.handleFloatingViewportTick(session))
206
207 pane.Hide(ctx)
208 assert.False(t, app.handleFloatingViewportTick(session))
209}
210
211func TestFloatingPane_HideShowAfterURIUpdateKeepsOmniboxHidden(t *testing.T) {
212 app, _, session := newFloatingPaneTestApp(t)
213
214 require.NoError(t, app.ToggleFloatingPane(context.Background()))
215 require.True(t, session.pane.IsOmniboxVisible())
216
217 app.updateFloatingSessionURI(session.paneID, "https://google.com")
218
219 require.NoError(t, app.ToggleFloatingPane(context.Background()))
220 require.NoError(t, app.ToggleFloatingPane(context.Background()))
221
222 assert.Equal(t, "https://google.com", session.pane.CurrentURL())
223 assert.False(t, session.pane.IsOmniboxVisible())
224}
225
226func TestFloatingPane_ProfileSessionsRemainIndependent(t *testing.T) {
227 app, tabID, _ := newFloatingPaneTestApp(t)
228
229 gmailSession := newFloatingPaneSession(tabID, "profile:gmail")
230 blankSession := newFloatingPaneSession(tabID, "profile:blank")
231 app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = gmailSession
232 app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:blank"}] = blankSession
233
234 require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
235 require.NoError(t, app.ToggleFloatingPane(context.Background()))
236 require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:blank", "about:blank"))
237
238 assert.Equal(t, "https://mail.google.com", gmailSession.pane.CurrentURL())
239 assert.False(t, gmailSession.pane.IsVisible())
240 assert.Equal(t, "about:blank", blankSession.pane.CurrentURL())
241 assert.True(t, blankSession.pane.IsVisible())
242 assert.NotEqual(t, gmailSession.paneID, blankSession.paneID)
243}
244
245func TestFloatingPane_ProfileShortcutTogglesAndPreservesSessionState(t *testing.T) {
246 app, tabID, _ := newFloatingPaneTestApp(t)
247 session := newFloatingPaneSession(tabID, "profile:gmail")
248 app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
249
250 baseURL := "https://mail.google.com"
251 draftURL := "https://mail.google.com/mail/u/0/#inbox?compose=new"
252
253 // First open: navigates to base URL
254 require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
255 assert.True(t, session.pane.IsVisible())
256 assert.Equal(t, baseURL, session.pane.CurrentURL())
257
258 // Simulate user navigating within gmail (compose draft etc.)
259 app.updateFloatingSessionURI(session.paneID, draftURL)
260 assert.Equal(t, draftURL, session.pane.CurrentURL())
261
262 // Second press: hides (toggle off), preserves draft URL
263 require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
264 assert.False(t, session.pane.IsVisible())
265 assert.Equal(t, draftURL, session.pane.CurrentURL())
266
267 // Third press: shows again without re-navigating, draft URL preserved
268 require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
269 assert.True(t, session.pane.IsVisible())
270 assert.Equal(t, draftURL, session.pane.CurrentURL())
271}
272
273func TestFloatingPane_ProfileRapidToggleNeverShowsAboutBlank(t *testing.T) {
274 app, tabID, _ := newFloatingPaneTestApp(t)
275 session := newFloatingPaneSession(tabID, "profile:gmail")
276 app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
277
278 baseURL := "https://mail.google.com"
279
280 // Rapid toggle 10 times: should never end up on about:blank
281 for i := range 10 {
282 require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
283 assert.NotEqual(t, "about:blank", session.pane.CurrentURL(),
284 "iteration %d: profile session should never show about:blank", i)
285 if session.pane.IsVisible() {
286 assert.Equal(t, baseURL, session.pane.CurrentURL())
287 }
288 }
289}
290
291func TestFloatingPane_DefaultSessionStillUsesAboutBlank(t *testing.T) {
292 app, _, session := newFloatingPaneTestApp(t)
293
294 require.NoError(t, app.ToggleFloatingPane(context.Background()))
295 assert.True(t, session.pane.IsVisible())
296 assert.Equal(t, "about:blank", session.pane.CurrentURL())
297}
298
299func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) {
300 app, tabID, _ := newFloatingPaneTestApp(t)
301 session := newFloatingPaneSession(tabID, "profile:gmail")
302 key := floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}
303 app.floatingSessions[key] = session
304
305 require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
306
307 handled := app.closeAndReleaseActiveFloatingPane(context.Background())
308 assert.True(t, handled)
309 _, stillExists := app.floatingSessions[key]
310 assert.False(t, stillExists)
311
312 handled = app.closeAndReleaseActiveFloatingPane(context.Background())
313 assert.False(t, handled)
314}
315
316func TestFloatingPane_HideAndCloseLeaveDetachedBrowserWindowAlive(t *testing.T) {
317 ctx := context.Background()
318 app, tabID, session := newFloatingPaneTestApp(t)
319 pool := &recordingWebViewPool{}
320 contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
321 sourceWebView := &recordingWebView{id: 301}
322 detachedWebView := &recordingWebView{id: 302}
323 detachedPaneID := entity.PaneID("detached-pane")
324 contentCoord.RegisterPopupWebView(session.paneID, sourceWebView)
325 contentCoord.RegisterPopupWebView(detachedPaneID, detachedWebView)
326 app.contentCoord = contentCoord
327 detachedWindow := &browserWindow{id: "detached-window", tabs: entity.NewTabList()}
328 app.browserWindows[detachedWindow.id] = detachedWindow
329
330 require.NoError(t, session.pane.ShowURL(ctx, "https://example.com/source"))
331 app.hideFloatingSession(ctx, session)
332 assert.Same(t, detachedWebView, contentCoord.GetWebView(detachedPaneID))
333 assert.Same(t, detachedWindow, app.browserWindows[detachedWindow.id])
334
335 key := floatingSessionKey{tabID: tabID, sessionID: floatingSessionIDDefault}
336 app.releaseFloatingSession(ctx, key, session)
337
338 assert.Same(t, detachedWebView, contentCoord.GetWebView(detachedPaneID))
339 assert.Same(t, detachedWindow, app.browserWindows[detachedWindow.id])
340 assert.Zero(t, detachedWebView.destroyCalls)
341 require.Len(t, pool.released, 1)
342 assert.Same(t, sourceWebView, pool.released[0])
343}
344
345func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) {
346 app, _, session := newFloatingPaneTestApp(t)
347
348 require.NoError(t, app.ToggleFloatingPane(context.Background()))
349 require.True(t, session.pane.IsVisible())
350
351 handled := app.handleGlobalEscape(context.Background())
352 assert.True(t, handled)
353 assert.False(t, session.pane.IsVisible())
354}
355
356func TestFloatingPane_OpenFloatingPaneSession_OptionalURL(t *testing.T) {
357 app, _, session := newFloatingPaneTestApp(t)
358
359 require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault))
360 assert.True(t, session.pane.IsVisible())
361 assert.Equal(t, "about:blank", session.pane.CurrentURL())
362
363 require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault, "https://example.com"))
364 assert.True(t, session.pane.IsVisible())
365 assert.Equal(t, "https://example.com", session.pane.CurrentURL())
366}
367
368func TestFloatingPane_EnsureFloatingSession_CachedAllowsNilWorkspaceView(t *testing.T) {
369 app, tabID, session := newFloatingPaneTestApp(t)
370
371 got, err := app.ensureFloatingSession(context.Background(), tabID, floatingSessionIDDefault, nil)
372 require.NoError(t, err)
373 assert.Same(t, session, got)
374}
375
376func TestFloatingPane_HideFloatingSession_StopsResizeWatcher(t *testing.T) {
377 session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:test")
378 session.resizeWatcherActive = true
379 session.resizeTickID = 42
380 session.appliedWidth = 960
381 session.appliedHeight = 640
382
383 app := &App{}
384 app.hideFloatingSession(context.Background(), session)
385
386 assert.False(t, session.resizeWatcherActive)
387 assert.Equal(t, uint(0), session.resizeTickID)
388 assert.Equal(t, 0, session.appliedWidth)
389 assert.Equal(t, 0, session.appliedHeight)
390}
391
392func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspaceSession) {
393 t.Helper()
394
395 tabID := entity.TabID("tab-1")
396 workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}
397 tabs := entity.NewTabList()
398 tab := &entity.Tab{ID: tabID, Workspace: workspace}
399 tabs.Add(tab)
400 tabs.SetActive(tabID)
401 bw := &browserWindow{id: "window-1", tabs: tabs}
402
403 session := newFloatingPaneSession(tabID, floatingSessionIDDefault)
404
405 app := &App{
406 tabs: tabs,
407 browserWindows: map[string]*browserWindow{bw.id: bw},
408 lastFocusedWindowID: bw.id,
409 windowForTab: map[entity.TabID]*browserWindow{tabID: bw},
410 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
411 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
412 {tabID: tabID, sessionID: floatingSessionIDDefault}: session,
413 },
414 }
415
416 return app, tabID, session
417}
418
419func newFloatingPaneSession(tabID entity.TabID, sessionID string) *floatingWorkspaceSession {
420 return &floatingWorkspaceSession{
421 paneID: floatingPaneIDForSession(tabID, sessionID),
422 pane: component.NewFloatingPane(nil, component.FloatingPaneOptions{
423 WidthPct: 0.82,
424 HeightPct: 0.72,
425 FallbackWidth: floatingPaneFallbackWidth,
426 FallbackHeight: floatingPaneFallbackHeight,
427 OnNavigate: func(context.Context, string) error {
428 return nil
429 },
430 }),
431 }
432}