1package component_test
2
3import (
4 "context"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/ui/component"
13 "github.com/bnema/dumber/internal/ui/layout"
14 "github.com/bnema/dumber/internal/ui/layout/mocks"
15)
16
17func setupLoadingSkeletonMocksWorkspace(
18 mockFactory *mocks.MockWidgetFactory,
19 mockOverlay *mocks.MockOverlayWidget,
20 mockLoadingContainer *mocks.MockBoxWidget,
21 mockLoadingContent *mocks.MockBoxWidget,
22 mockLoadingSpinner *mocks.MockSpinnerWidget,
23 mockLoadingLogo *mocks.MockImageWidget,
24) {
25 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
26 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
27 mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()
28 mockLoadingContainer.EXPECT().SetHalign(mock.Anything).Maybe()
29 mockLoadingContainer.EXPECT().SetValign(mock.Anything).Maybe()
30 mockLoadingContainer.EXPECT().SetCanFocus(false).Maybe()
31 mockLoadingContainer.EXPECT().SetCanTarget(false).Maybe()
32 mockLoadingContainer.EXPECT().AddCssClass("loading-skeleton").Maybe()
33 mockLoadingContainer.EXPECT().SetVisible(true).Maybe()
34
35 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockLoadingContent).Once()
36 mockLoadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
37 mockLoadingContent.EXPECT().SetValign(mock.Anything).Maybe()
38 mockLoadingContent.EXPECT().SetCanFocus(false).Maybe()
39 mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()
40 mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()
41
42 mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()
43 mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()
44 mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()
45 mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe()
46 mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe()
47 mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe()
48 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
49 mockLoadingSpinner.EXPECT().Start().Maybe()
50
51 mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
52 mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
53 mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
54 mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
55 mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
56 mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()
57 mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()
58 mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
59 mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
60
61 mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
62 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
63 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
64
65 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
66 mockOverlay.EXPECT().SetClipOverlay(mockLoadingContainer, false).Once()
67 mockOverlay.EXPECT().SetMeasureOverlay(mockLoadingContainer, false).Once()
68}
69
70func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) (context.Context, *mocks.MockBoxWidget) {
71 ctx := context.Background()
72 mockContainer := mocks.NewMockBoxWidget(t)
73 mockOverlay := mocks.NewMockOverlayWidget(t)
74
75 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
76 mockContainer.EXPECT().SetHexpand(true).Once()
77 mockContainer.EXPECT().SetVexpand(true).Once()
78 mockContainer.EXPECT().SetVisible(true).Once()
79
80 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
81 mockOverlay.EXPECT().SetHexpand(true).Once()
82 mockOverlay.EXPECT().SetVexpand(true).Once()
83 mockOverlay.EXPECT().SetChild(mockContainer).Once()
84 mockOverlay.EXPECT().SetVisible(true).Once()
85
86 return ctx, mockContainer
87}
88
89func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) (*mocks.MockOverlayWidget, *mocks.MockBoxWidget) {
90 mockOverlay := mocks.NewMockOverlayWidget(t)
91 mockBorderBox := mocks.NewMockBoxWidget(t)
92
93 mockLoadingContainer := mocks.NewMockBoxWidget(t)
94 mockLoadingContent := mocks.NewMockBoxWidget(t)
95 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
96 mockLoadingLogo := mocks.NewMockImageWidget(t)
97
98 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
99 mockOverlay.EXPECT().SetHexpand(true).Once()
100 mockOverlay.EXPECT().SetVexpand(true).Once()
101 mockOverlay.EXPECT().SetVisible(true).Once()
102 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
103
104 setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
105
106 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
107 mockBorderBox.EXPECT().SetCanFocus(false).Once()
108 mockBorderBox.EXPECT().SetCanTarget(false).Once()
109 mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
110 mockBorderBox.EXPECT().SetHexpand(true).Once()
111 mockBorderBox.EXPECT().SetVexpand(true).Once()
112 mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
113 mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
114 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
115
116 // GtkWidget is called when attaching hover handler - return nil for tests
117 mockOverlay.EXPECT().GtkWidget().Return(nil).Once()
118
119 return mockOverlay, mockBorderBox
120}
121
122func setupStackedLeafMocks(
123 t *testing.T,
124 mockFactory *mocks.MockWidgetFactory,
125 container *mocks.MockOverlayWidget,
126) *mocks.MockBoxWidget {
127 mockStackBox := mocks.NewMockBoxWidget(t)
128 mockTitleBar := mocks.NewMockBoxWidget(t)
129 mockFavicon := mocks.NewMockImageWidget(t)
130 mockLabel := mocks.NewMockLabelWidget(t)
131 mockCloseButton := mocks.NewMockButtonWidget(t)
132
133 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once()
134 mockStackBox.EXPECT().SetHexpand(true).Once()
135 mockStackBox.EXPECT().SetVexpand(true).Once()
136
137 // Title bar creation - now directly used without button wrapper
138 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
139 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
140 mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
141 mockTitleBar.EXPECT().SetVexpand(false).Once()
142 mockTitleBar.EXPECT().SetHexpand(true).Once()
143
144 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
145 mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()
146 mockFavicon.EXPECT().SetPixelSize(16).Once()
147 mockTitleBar.EXPECT().Append(mockFavicon).Once()
148
149 mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLabel).Once()
150 mockLabel.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
151 mockLabel.EXPECT().SetMaxWidthChars(30).Once()
152 mockLabel.EXPECT().SetHexpand(true).Once()
153 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
154 mockTitleBar.EXPECT().Append(mockLabel).Once()
155
156 // Close button (uses SetIconName directly instead of child image)
157 mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
158 mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
159 mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
160 mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
161 mockCloseButton.EXPECT().SetVexpand(false).Once()
162 mockCloseButton.EXPECT().SetHexpand(false).Once()
163 mockTitleBar.EXPECT().Append(mockCloseButton).Once()
164
165 // GestureClick is added to titleBar via AddController
166 mockTitleBar.EXPECT().AddController(mock.Anything).Once()
167
168 // Close button click handler
169 mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
170
171 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
172 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
173
174 // Adding to main box - now titleBar is added directly (no button wrapper)
175 mockStackBox.EXPECT().Append(mockTitleBar).Once()
176 mockStackBox.EXPECT().Append(container).Once()
177
178 // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it
179 mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
180 container.EXPECT().SetVisible(true).Once()
181 mockTitleBar.EXPECT().AddCssClass("active").Once()
182
183 return mockStackBox
184}
185
186func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) {
187 // Arrange
188 mockFactory := mocks.NewMockWidgetFactory(t)
189 ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
190
191 // Act
192 wv := component.NewWorkspaceView(ctx, mockFactory)
193
194 // Assert
195 require.NotNil(t, wv)
196 assert.Equal(t, 0, wv.PaneCount())
197 assert.Equal(t, mockBox, wv.Container())
198}
199
200func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) {
201 // Arrange
202 mockFactory := mocks.NewMockWidgetFactory(t)
203 ctx, _ := setupWorkspaceViewBase(t, mockFactory)
204
205 wv := component.NewWorkspaceView(ctx, mockFactory)
206
207 // Act
208 err := wv.SetWorkspace(ctx, nil)
209
210 // Assert
211 assert.ErrorIs(t, err, component.ErrNilWorkspace)
212}
213
214func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
215 // Arrange
216 mockFactory := mocks.NewMockWidgetFactory(t)
217 ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
218
219 wv := component.NewWorkspaceView(ctx, mockFactory)
220
221 mockOverlay, mockBorderBox := setupWorkspacePaneViewMocks(t, mockFactory)
222 mockStackBox := setupStackedLeafMocks(t, mockFactory, mockOverlay)
223
224 mockStackBox.EXPECT().SetVisible(true).Once()
225 mockBox.EXPECT().Append(mockStackBox).Once()
226 mockBox.EXPECT().AddCssClass("single-pane").Once()
227 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
228
229 pane := entity.NewPane(entity.PaneID("pane-1"))
230 node := &entity.PaneNode{ID: "node-1", Pane: pane}
231
232 ws := &entity.Workspace{
233 ID: "ws-1",
234 Root: node,
235 ActivePaneID: pane.ID,
236 }
237
238 // Act
239 err := wv.SetWorkspace(ctx, ws)
240
241 // Assert
242 require.NoError(t, err)
243 assert.Equal(t, 1, wv.PaneCount())
244 assert.Equal(t, pane.ID, wv.GetActivePaneID())
245 assert.NotNil(t, wv.GetPaneView(pane.ID))
246}
247
248func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
249 // Arrange
250 mockFactory := mocks.NewMockWidgetFactory(t)
251 ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
252
253 wv := component.NewWorkspaceView(ctx, mockFactory)
254
255 // Setup two panes
256 pane1 := entity.NewPane(entity.PaneID("pane-1"))
257 pane2 := entity.NewPane(entity.PaneID("pane-2"))
258 node1 := &entity.PaneNode{ID: "node-1", Pane: pane1}
259 node2 := &entity.PaneNode{ID: "node-2", Pane: pane2}
260
261 // Create mock widgets for split
262 mockPaned := mocks.NewMockPanedWidget(t)
263 mockOverlay1, mockBorderBox1 := setupWorkspacePaneViewMocks(t, mockFactory)
264 mockStackBox1 := setupStackedLeafMocks(t, mockFactory, mockOverlay1)
265 mockOverlay2, mockBorderBox2 := setupWorkspacePaneViewMocks(t, mockFactory)
266 mockStackBox2 := setupStackedLeafMocks(t, mockFactory, mockOverlay2)
267
268 // Split view creation
269 mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once()
270 mockPaned.EXPECT().SetResizeStartChild(true).Once()
271 mockPaned.EXPECT().SetResizeEndChild(true).Once()
272 mockPaned.EXPECT().SetVisible(true).Twice()
273 mockStackBox1.EXPECT().SetVisible(true).Once()
274 mockStackBox2.EXPECT().SetVisible(true).Once()
275 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()
276 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()
277 mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
278 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
279 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
280 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
281
282 mockBox.EXPECT().Append(mockPaned).Once()
283
284 // Initial active pane is pane-1
285 mockBox.EXPECT().RemoveCssClass("single-pane").Once()
286 mockBorderBox1.EXPECT().AddCssClass("pane-active").Once()
287
288 splitNode := &entity.PaneNode{
289 ID: "split",
290 SplitDir: entity.SplitHorizontal,
291 SplitRatio: 0.5,
292 Children: []*entity.PaneNode{node1, node2},
293 }
294
295 ws := &entity.Workspace{
296 ID: "ws-1",
297 Root: splitNode,
298 ActivePaneID: pane1.ID,
299 }
300
301 err := wv.SetWorkspace(ctx, ws)
302 require.NoError(t, err)
303
304 // Now change active pane
305 mockBorderBox1.EXPECT().RemoveCssClass("pane-active").Once()
306 mockBorderBox2.EXPECT().AddCssClass("pane-active").Once()
307
308 // Act
309 err = wv.SetActivePaneID(pane2.ID)
310
311 // Assert
312 require.NoError(t, err)
313 assert.Equal(t, pane2.ID, wv.GetActivePaneID())
314}
315
316func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
317 // Arrange
318 mockFactory := mocks.NewMockWidgetFactory(t)
319 ctx, _ := setupWorkspaceViewBase(t, mockFactory)
320
321 wv := component.NewWorkspaceView(ctx, mockFactory)
322
323 // Act
324 err := wv.SetActivePaneID(entity.PaneID("non-existent"))
325
326 // Assert
327 assert.ErrorIs(t, err, component.ErrPaneNotFound)
328}