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