1package component_test
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/puregotk/v4/gtk"
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/domain/entity"
13 "github.com/bnema/dumber/internal/ui/component"
14 "github.com/bnema/dumber/internal/ui/layout"
15 "github.com/bnema/dumber/internal/ui/layout/mocks"
16)
17
18func setupLoadingSkeletonMocksWorkspace(
19 t *testing.T,
20 mockFactory *mocks.MockWidgetFactory,
21 mockOverlay *mocks.MockOverlayWidget,
22 mockLoadingContainer *mocks.MockBoxWidget,
23 mockLoadingContent *mocks.MockBoxWidget,
24 mockLoadingSpinner *mocks.MockSpinnerWidget,
25 mockLoadingLogo *mocks.MockImageWidget,
26) {
27 mockLoadingVersion := mocks.NewMockLabelWidget(t)
28
29 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
30 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
31 mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()
32 mockLoadingContainer.EXPECT().SetHalign(mock.Anything).Maybe()
33 mockLoadingContainer.EXPECT().SetValign(mock.Anything).Maybe()
34 mockLoadingContainer.EXPECT().SetCanFocus(false).Maybe()
35 mockLoadingContainer.EXPECT().SetCanTarget(false).Maybe()
36 mockLoadingContainer.EXPECT().AddCssClass("loading-skeleton").Maybe()
37 mockLoadingContainer.EXPECT().SetVisible(true).Maybe()
38
39 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockLoadingContent).Once()
40 mockLoadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
41 mockLoadingContent.EXPECT().SetValign(mock.Anything).Maybe()
42 mockLoadingContent.EXPECT().SetCanFocus(false).Maybe()
43 mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()
44 mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()
45
46 mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
47 mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
48 mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
49 mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
50 mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
51 mockLoadingLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
52 mockLoadingLogo.EXPECT().SetPixelSize(mock.Anything).Maybe()
53 mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
54 mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
55
56 mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()
57 mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()
58 mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()
59 mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe()
60 mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe()
61 mockLoadingSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
62 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
63 mockLoadingSpinner.EXPECT().Start().Maybe()
64
65 mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once()
66 mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe()
67 mockLoadingVersion.EXPECT().SetValign(mock.Anything).Maybe()
68 mockLoadingVersion.EXPECT().SetCanFocus(false).Maybe()
69 mockLoadingVersion.EXPECT().SetCanTarget(false).Maybe()
70 mockLoadingVersion.EXPECT().SetMaxWidthChars(mock.Anything).Maybe()
71 mockLoadingVersion.EXPECT().SetEllipsize(mock.Anything).Maybe()
72 mockLoadingVersion.EXPECT().AddCssClass("loading-skeleton-version").Maybe()
73
74 mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
75 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
76 mockLoadingContent.EXPECT().Append(mockLoadingVersion).Maybe()
77 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
78
79 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
80 mockOverlay.EXPECT().SetClipOverlay(mockLoadingContainer, false).Once()
81 mockOverlay.EXPECT().SetMeasureOverlay(mockLoadingContainer, false).Once()
82}
83
84func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) (context.Context, *mocks.MockBoxWidget) {
85 ctx := context.Background()
86 mockContainer := mocks.NewMockBoxWidget(t)
87 mockOverlay := mocks.NewMockOverlayWidget(t)
88
89 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
90 mockContainer.EXPECT().SetHexpand(true).Once()
91 mockContainer.EXPECT().SetVexpand(true).Once()
92 mockContainer.EXPECT().SetVisible(true).Once()
93
94 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
95 mockOverlay.EXPECT().SetHexpand(true).Once()
96 mockOverlay.EXPECT().SetVexpand(true).Once()
97 mockOverlay.EXPECT().SetChild(mockContainer).Once()
98 mockOverlay.EXPECT().SetVisible(true).Once()
99
100 return ctx, mockContainer
101}
102
103func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) (*mocks.MockOverlayWidget, *mocks.MockBoxWidget) {
104 mockOverlay := mocks.NewMockOverlayWidget(t)
105 mockBorderBox := mocks.NewMockBoxWidget(t)
106
107 mockLoadingContainer := mocks.NewMockBoxWidget(t)
108 mockLoadingContent := mocks.NewMockBoxWidget(t)
109 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
110 mockLoadingLogo := mocks.NewMockImageWidget(t)
111
112 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
113 mockOverlay.EXPECT().SetHexpand(true).Once()
114 mockOverlay.EXPECT().SetVexpand(true).Once()
115 mockOverlay.EXPECT().SetVisible(true).Once()
116 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
117
118 setupLoadingSkeletonMocksWorkspace(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
119
120 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
121 mockBorderBox.EXPECT().SetCanFocus(false).Once()
122 mockBorderBox.EXPECT().SetCanTarget(false).Once()
123 mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
124 mockBorderBox.EXPECT().SetHexpand(true).Once()
125 mockBorderBox.EXPECT().SetVexpand(true).Once()
126 mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
127 mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
128 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
129
130 // GtkWidget is called when attaching hover handler - return nil for tests
131 mockOverlay.EXPECT().GtkWidget().Return(nil).Once()
132
133 return mockOverlay, mockBorderBox
134}
135
136func setupStackedLeafMocks(
137 t *testing.T,
138 mockFactory *mocks.MockWidgetFactory,
139 container *mocks.MockOverlayWidget,
140) *mocks.MockBoxWidget {
141 mockStackBox := mocks.NewMockBoxWidget(t)
142 mockTitleBar := mocks.NewMockBoxWidget(t)
143 mockFavicon := mocks.NewMockImageWidget(t)
144 mockLabel := mocks.NewMockLabelWidget(t)
145 mockCloseButton := mocks.NewMockButtonWidget(t)
146
147 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once()
148 mockStackBox.EXPECT().SetHexpand(true).Once()
149 mockStackBox.EXPECT().SetVexpand(true).Once()
150
151 // Title bar creation - now directly used without button wrapper
152 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
153 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
154 mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
155 mockTitleBar.EXPECT().SetVexpand(false).Once()
156 mockTitleBar.EXPECT().SetHexpand(true).Once()
157
158 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
159 mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()
160 mockFavicon.EXPECT().SetPixelSize(16).Once()
161 mockTitleBar.EXPECT().Append(mockFavicon).Once()
162
163 mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLabel).Once()
164 mockLabel.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
165 mockLabel.EXPECT().SetMaxWidthChars(30).Once()
166 mockLabel.EXPECT().SetHexpand(true).Once()
167 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
168 mockTitleBar.EXPECT().Append(mockLabel).Once()
169
170 // Close button (uses SetIconName directly instead of child image)
171 mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
172 mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
173 mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
174 mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
175 mockCloseButton.EXPECT().SetVexpand(false).Once()
176 mockCloseButton.EXPECT().SetHexpand(false).Once()
177 mockTitleBar.EXPECT().Append(mockCloseButton).Once()
178
179 // GestureClick is added to titleBar via AddController
180 mockTitleBar.EXPECT().AddController(mock.Anything).Once()
181
182 // Close button click handler
183 mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
184
185 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
186 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
187
188 // Adding to main box - now titleBar is added directly (no button wrapper)
189 mockStackBox.EXPECT().Append(mockTitleBar).Once()
190 mockStackBox.EXPECT().Append(container).Once()
191
192 // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it
193 mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
194 container.EXPECT().SetVisible(true).Once()
195 mockTitleBar.EXPECT().AddCssClass("active").Once()
196
197 return mockStackBox
198}
199
200func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) {
201 // Arrange
202 mockFactory := mocks.NewMockWidgetFactory(t)
203 ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
204
205 // Act
206 wv := component.NewWorkspaceView(ctx, mockFactory)
207
208 // Assert
209 require.NotNil(t, wv)
210 assert.Equal(t, 0, wv.PaneCount())
211 assert.Equal(t, mockBox, wv.Container())
212}
213
214func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) {
215 // Arrange
216 mockFactory := mocks.NewMockWidgetFactory(t)
217 ctx, _ := setupWorkspaceViewBase(t, mockFactory)
218
219 wv := component.NewWorkspaceView(ctx, mockFactory)
220
221 // Act
222 err := wv.SetWorkspace(ctx, nil)
223
224 // Assert
225 assert.ErrorIs(t, err, component.ErrNilWorkspace)
226}
227
228func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
229 // Arrange
230 mockFactory := mocks.NewMockWidgetFactory(t)
231 ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
232
233 wv := component.NewWorkspaceView(ctx, mockFactory)
234
235 mockOverlay, mockBorderBox := setupWorkspacePaneViewMocks(t, mockFactory)
236 mockStackBox := setupStackedLeafMocks(t, mockFactory, mockOverlay)
237
238 mockStackBox.EXPECT().SetVisible(true).Once()
239 mockBox.EXPECT().Append(mockStackBox).Once()
240 mockBox.EXPECT().AddCssClass("single-pane").Once()
241 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
242
243 pane := entity.NewPane(entity.PaneID("pane-1"))
244 node := &entity.PaneNode{ID: "node-1", Pane: pane}
245
246 ws := &entity.Workspace{
247 ID: "ws-1",
248 Root: node,
249 ActivePaneID: pane.ID,
250 }
251
252 // Act
253 err := wv.SetWorkspace(ctx, ws)
254
255 // Assert
256 require.NoError(t, err)
257 assert.Equal(t, 1, wv.PaneCount())
258 assert.Equal(t, pane.ID, wv.GetActivePaneID())
259 assert.NotNil(t, wv.GetPaneView(pane.ID))
260}
261
262func TestWorkspaceView_HoverFocusLockState(t *testing.T) {
263 // Intentional zero-value check: hoverFocusLock is an atomic.Bool and should
264 // be safe with its language-level zero value before constructor wiring.
265 var wv component.WorkspaceView
266
267 assert.False(t, wv.IsHoverFocusLocked())
268 wv.SetHoverFocusLocked(true)
269 assert.True(t, wv.IsHoverFocusLocked())
270
271 wv.SetHoverFocusLocked(false)
272 assert.False(t, wv.IsHoverFocusLocked())
273}
274
275func TestAddWorkspaceOverlayWidget_DoesNotReaddWhenAlreadyAttached(t *testing.T) {
276 mockFactory := mocks.NewMockWidgetFactory(t)
277 ctx := context.Background()
278
279 mockContainer := mocks.NewMockBoxWidget(t)
280 mockOverlay := mocks.NewMockOverlayWidget(t)
281 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
282 mockContainer.EXPECT().SetHexpand(true).Once()
283 mockContainer.EXPECT().SetVexpand(true).Once()
284 mockContainer.EXPECT().SetVisible(true).Once()
285 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
286 mockOverlay.EXPECT().SetHexpand(true).Once()
287 mockOverlay.EXPECT().SetVexpand(true).Once()
288 mockOverlay.EXPECT().SetChild(mockContainer).Once()
289 mockOverlay.EXPECT().SetVisible(true).Once()
290
291 wv := component.NewWorkspaceView(ctx, mockFactory)
292
293 widget := mocks.NewMockWidget(t)
294 widget.EXPECT().GetParent().Return(mockOverlay).Once()
295
296 wv.AddWorkspaceOverlayWidget(widget)
297}
298
299func TestAddWorkspaceOverlayWidget_UnparentsBeforeAdding(t *testing.T) {
300 mockFactory := mocks.NewMockWidgetFactory(t)
301 ctx := context.Background()
302
303 mockContainer := mocks.NewMockBoxWidget(t)
304 mockOverlay := mocks.NewMockOverlayWidget(t)
305 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
306 mockContainer.EXPECT().SetHexpand(true).Once()
307 mockContainer.EXPECT().SetVexpand(true).Once()
308 mockContainer.EXPECT().SetVisible(true).Once()
309 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
310 mockOverlay.EXPECT().SetHexpand(true).Once()
311 mockOverlay.EXPECT().SetVexpand(true).Once()
312 mockOverlay.EXPECT().SetChild(mockContainer).Once()
313 mockOverlay.EXPECT().SetVisible(true).Once()
314
315 wv := component.NewWorkspaceView(ctx, mockFactory)
316
317 widget := mocks.NewMockWidget(t)
318 otherParent := mocks.NewMockWidget(t)
319 widget.EXPECT().GetParent().Return(otherParent).Once()
320 otherParent.EXPECT().GtkWidget().Return(nil).Once()
321 mockOverlay.EXPECT().GtkWidget().Return(nil).Once()
322 widget.EXPECT().Unparent().Once()
323 mockOverlay.EXPECT().AddOverlay(widget).Once()
324 mockOverlay.EXPECT().SetClipOverlay(widget, false).Once()
325 mockOverlay.EXPECT().SetMeasureOverlay(widget, false).Once()
326
327 wv.AddWorkspaceOverlayWidget(widget)
328}
329
330func TestAddWorkspaceOverlayWidget_DoesNotReaddWhenParentGTKMatchesOverlay(t *testing.T) {
331 mockFactory := mocks.NewMockWidgetFactory(t)
332 ctx := context.Background()
333
334 mockContainer := mocks.NewMockBoxWidget(t)
335 mockOverlay := mocks.NewMockOverlayWidget(t)
336 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
337 mockContainer.EXPECT().SetHexpand(true).Once()
338 mockContainer.EXPECT().SetVexpand(true).Once()
339 mockContainer.EXPECT().SetVisible(true).Once()
340 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
341 mockOverlay.EXPECT().SetHexpand(true).Once()
342 mockOverlay.EXPECT().SetVexpand(true).Once()
343 mockOverlay.EXPECT().SetChild(mockContainer).Once()
344 mockOverlay.EXPECT().SetVisible(true).Once()
345
346 wv := component.NewWorkspaceView(ctx, mockFactory)
347
348 widget := mocks.NewMockWidget(t)
349 otherParent := mocks.NewMockWidget(t)
350 gtkWidget := >k.Widget{}
351
352 widget.EXPECT().GetParent().Return(otherParent).Once()
353 otherParent.EXPECT().GtkWidget().Return(gtkWidget).Once()
354 mockOverlay.EXPECT().GtkWidget().Return(gtkWidget).Once()
355
356 wv.AddWorkspaceOverlayWidget(widget)
357}
358
359func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
360 // Arrange
361 mockFactory := mocks.NewMockWidgetFactory(t)
362 ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
363
364 wv := component.NewWorkspaceView(ctx, mockFactory)
365
366 // Setup two panes
367 pane1 := entity.NewPane(entity.PaneID("pane-1"))
368 pane2 := entity.NewPane(entity.PaneID("pane-2"))
369 node1 := &entity.PaneNode{ID: "node-1", Pane: pane1}
370 node2 := &entity.PaneNode{ID: "node-2", Pane: pane2}
371
372 // Create mock widgets for split
373 mockPaned := mocks.NewMockPanedWidget(t)
374 mockOverlay1, mockBorderBox1 := setupWorkspacePaneViewMocks(t, mockFactory)
375 mockStackBox1 := setupStackedLeafMocks(t, mockFactory, mockOverlay1)
376 mockOverlay2, mockBorderBox2 := setupWorkspacePaneViewMocks(t, mockFactory)
377 mockStackBox2 := setupStackedLeafMocks(t, mockFactory, mockOverlay2)
378
379 // Split view creation
380 mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once()
381 mockPaned.EXPECT().SetResizeStartChild(true).Once()
382 mockPaned.EXPECT().SetResizeEndChild(true).Once()
383 mockPaned.EXPECT().SetVisible(true).Twice()
384 mockStackBox1.EXPECT().SetVisible(true).Once()
385 mockStackBox2.EXPECT().SetVisible(true).Once()
386 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()
387 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()
388 mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
389 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
390 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
391 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
392
393 mockBox.EXPECT().Append(mockPaned).Once()
394
395 // Initial active pane is pane-1
396 mockBox.EXPECT().RemoveCssClass("single-pane").Once()
397 mockBorderBox1.EXPECT().AddCssClass("pane-active").Once()
398
399 splitNode := &entity.PaneNode{
400 ID: "split",
401 SplitDir: entity.SplitHorizontal,
402 SplitRatio: 0.5,
403 Children: []*entity.PaneNode{node1, node2},
404 }
405
406 ws := &entity.Workspace{
407 ID: "ws-1",
408 Root: splitNode,
409 ActivePaneID: pane1.ID,
410 }
411
412 err := wv.SetWorkspace(ctx, ws)
413 require.NoError(t, err)
414
415 // Now change active pane
416 mockBorderBox1.EXPECT().RemoveCssClass("pane-active").Once()
417 mockBorderBox2.EXPECT().AddCssClass("pane-active").Once()
418
419 // Act
420 err = wv.SetActivePaneID(pane2.ID)
421
422 // Assert
423 require.NoError(t, err)
424 assert.Equal(t, pane2.ID, wv.GetActivePaneID())
425}
426
427func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
428 // Arrange
429 mockFactory := mocks.NewMockWidgetFactory(t)
430 ctx, _ := setupWorkspaceViewBase(t, mockFactory)
431
432 wv := component.NewWorkspaceView(ctx, mockFactory)
433
434 // Act
435 err := wv.SetActivePaneID(entity.PaneID("non-existent"))
436
437 // Assert
438 assert.ErrorIs(t, err, component.ErrPaneNotFound)
439}