1package entity_test
2
3import (
4 "sync/atomic"
5 "testing"
6 "time"
7
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11)
12
13// mockIDGenerator creates a simple ID generator for testing.
14func mockIDGenerator() entity.IDGenerator {
15 var counter uint64
16 return func() string {
17 id := atomic.AddUint64(&counter, 1)
18 return string(rune('a'-1+id)) + "_new"
19 }
20}
21
22func TestTabListFromSnapshot_EmptyState(t *testing.T) {
23 idGen := mockIDGenerator()
24
25 // Nil state
26 tabs := entity.TabListFromSnapshot(nil, idGen)
27 require.NotNil(t, tabs)
28 assert.Empty(t, tabs.Tabs)
29
30 // Empty state
31 state := &entity.SessionState{
32 Version: 1,
33 Tabs: []entity.TabSnapshot{},
34 }
35 tabs = entity.TabListFromSnapshot(state, idGen)
36 require.NotNil(t, tabs)
37 assert.Empty(t, tabs.Tabs)
38}
39
40func TestTabListFromSnapshot_SingleTab(t *testing.T) {
41 idGen := mockIDGenerator()
42
43 state := &entity.SessionState{
44 Version: 1,
45 SessionID: "test_session",
46 ActiveTabIndex: 0,
47 Tabs: []entity.TabSnapshot{
48 {
49 ID: "old_tab_1",
50 Name: "Test Tab",
51 Position: 0,
52 IsPinned: true,
53 Workspace: entity.WorkspaceSnapshot{
54 ID: "old_ws_1",
55 ActivePaneID: "old_pane_1",
56 Root: &entity.PaneNodeSnapshot{
57 ID: "old_node_1",
58 Pane: &entity.PaneSnapshot{
59 ID: "old_pane_1",
60 URI: "https://example.com",
61 Title: "Example",
62 ZoomFactor: 1.25,
63 },
64 },
65 },
66 },
67 },
68 SavedAt: time.Now(),
69 }
70
71 tabs := entity.TabListFromSnapshot(state, idGen)
72
73 require.NotNil(t, tabs)
74 require.Len(t, tabs.Tabs, 1)
75
76 tab := tabs.Tabs[0]
77 assert.NotEqual(t, entity.TabID("old_tab_1"), tab.ID, "should generate new ID")
78 assert.Equal(t, "Test Tab", tab.Name)
79 assert.True(t, tab.IsPinned)
80
81 require.NotNil(t, tab.Workspace)
82 require.NotNil(t, tab.Workspace.Root)
83 require.NotNil(t, tab.Workspace.Root.Pane)
84
85 pane := tab.Workspace.Root.Pane
86 assert.NotEqual(t, entity.PaneID("old_pane_1"), pane.ID, "should generate new pane ID")
87 assert.Equal(t, "https://example.com", pane.URI)
88 assert.Equal(t, "Example", pane.Title)
89 assert.InDelta(t, 1.25, pane.ZoomFactor, 0.001)
90
91 // Active tab should be set
92 assert.Equal(t, tab.ID, tabs.ActiveTabID)
93}
94
95func TestTabListFromSnapshot_MultipleTabs(t *testing.T) {
96 idGen := mockIDGenerator()
97
98 state := &entity.SessionState{
99 Version: 1,
100 ActiveTabIndex: 1, // Second tab is active
101 Tabs: []entity.TabSnapshot{
102 {
103 ID: "tab_1",
104 Name: "First",
105 Workspace: entity.WorkspaceSnapshot{
106 Root: &entity.PaneNodeSnapshot{
107 Pane: &entity.PaneSnapshot{
108 ID: "pane_1",
109 URI: "https://first.com",
110 },
111 },
112 },
113 },
114 {
115 ID: "tab_2",
116 Name: "Second",
117 Workspace: entity.WorkspaceSnapshot{
118 Root: &entity.PaneNodeSnapshot{
119 Pane: &entity.PaneSnapshot{
120 ID: "pane_2",
121 URI: "https://second.com",
122 },
123 },
124 },
125 },
126 },
127 }
128
129 tabs := entity.TabListFromSnapshot(state, idGen)
130
131 require.Len(t, tabs.Tabs, 2)
132 assert.Equal(t, "First", tabs.Tabs[0].Name)
133 assert.Equal(t, "Second", tabs.Tabs[1].Name)
134
135 // Active tab should be the second one
136 assert.Equal(t, tabs.Tabs[1].ID, tabs.ActiveTabID)
137}
138
139func TestTabListFromSnapshot_SplitPanes(t *testing.T) {
140 idGen := mockIDGenerator()
141
142 // Horizontal split with two panes
143 state := &entity.SessionState{
144 Version: 1,
145 Tabs: []entity.TabSnapshot{
146 {
147 ID: "tab_1",
148 Name: "Split Tab",
149 Workspace: entity.WorkspaceSnapshot{
150 Root: &entity.PaneNodeSnapshot{
151 ID: "container",
152 SplitDir: entity.SplitHorizontal,
153 SplitRatio: 0.5,
154 Children: []*entity.PaneNodeSnapshot{
155 {
156 ID: "left_node",
157 Pane: &entity.PaneSnapshot{
158 ID: "left_pane",
159 URI: "https://left.com",
160 },
161 },
162 {
163 ID: "right_node",
164 Pane: &entity.PaneSnapshot{
165 ID: "right_pane",
166 URI: "https://right.com",
167 },
168 },
169 },
170 },
171 },
172 },
173 },
174 }
175
176 tabs := entity.TabListFromSnapshot(state, idGen)
177
178 require.Len(t, tabs.Tabs, 1)
179 ws := tabs.Tabs[0].Workspace
180 require.NotNil(t, ws.Root)
181
182 // Root should be a container with children
183 assert.Nil(t, ws.Root.Pane)
184 assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
185 assert.InDelta(t, 0.5, ws.Root.SplitRatio, 0.001)
186 require.Len(t, ws.Root.Children, 2)
187
188 // Check children
189 leftPane := ws.Root.Children[0].Pane
190 rightPane := ws.Root.Children[1].Pane
191 require.NotNil(t, leftPane)
192 require.NotNil(t, rightPane)
193 assert.Equal(t, "https://left.com", leftPane.URI)
194 assert.Equal(t, "https://right.com", rightPane.URI)
195}
196
197func TestTabListFromSnapshot_StackedPanes(t *testing.T) {
198 idGen := mockIDGenerator()
199
200 state := &entity.SessionState{
201 Version: 1,
202 Tabs: []entity.TabSnapshot{
203 {
204 ID: "tab_1",
205 Workspace: entity.WorkspaceSnapshot{
206 Root: &entity.PaneNodeSnapshot{
207 ID: "stack",
208 IsStacked: true,
209 ActiveStackIndex: 1,
210 Children: []*entity.PaneNodeSnapshot{
211 {Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://a.com"}},
212 {Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://b.com"}},
213 {Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://c.com"}},
214 },
215 },
216 },
217 },
218 },
219 }
220
221 tabs := entity.TabListFromSnapshot(state, idGen)
222
223 ws := tabs.Tabs[0].Workspace
224 require.NotNil(t, ws.Root)
225 assert.True(t, ws.Root.IsStacked)
226 assert.Equal(t, 1, ws.Root.ActiveStackIndex)
227 require.Len(t, ws.Root.Children, 3)
228}
229
230func TestTabList_ReplaceFrom(t *testing.T) {
231 // Create original list
232 original := entity.NewTabList()
233 pane1 := entity.NewPane("pane1")
234 pane1.URI = "https://old.com"
235 tab1 := entity.NewTab("tab1", "ws1", pane1)
236 original.Add(tab1)
237 original.SetActive("tab1")
238
239 // Create new list
240 newList := entity.NewTabList()
241 pane2 := entity.NewPane("pane2")
242 pane2.URI = "https://new1.com"
243 pane3 := entity.NewPane("pane3")
244 pane3.URI = "https://new2.com"
245 tab2 := entity.NewTab("tab2", "ws2", pane2)
246 tab3 := entity.NewTab("tab3", "ws3", pane3)
247 newList.Add(tab2)
248 newList.Add(tab3)
249 newList.SetActive("tab3")
250
251 // Keep reference to original
252 ref := original
253
254 // Replace contents
255 original.ReplaceFrom(newList)
256
257 // Verify the reference still points to the modified list
258 assert.Same(t, original, ref)
259 assert.Len(t, ref.Tabs, 2)
260 assert.Equal(t, entity.TabID("tab3"), ref.ActiveTabID)
261 assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
262}
263
264func TestTabList_ReplaceFrom_Nil(t *testing.T) {
265 original := entity.NewTabList()
266 pane := entity.NewPane("pane1")
267 tab := entity.NewTab("tab1", "ws1", pane)
268 original.Add(tab)
269
270 original.ReplaceFrom(nil)
271
272 assert.Empty(t, original.Tabs)
273 assert.Empty(t, original.ActiveTabID)
274}
275
276func TestSnapshotRoundTrip(t *testing.T) {
277 // Create a complex tab structure
278 idGen := mockIDGenerator()
279
280 pane1 := entity.NewPane("p1")
281 pane1.URI = "https://google.com"
282 pane1.Title = "Google"
283 pane1.ZoomFactor = 1.5
284
285 pane2 := entity.NewPane("p2")
286 pane2.URI = "https://github.com"
287 pane2.Title = "GitHub"
288
289 tab1 := entity.NewTab("t1", "ws1", pane1)
290 tab1.Name = "Search"
291 tab1.IsPinned = true
292
293 tab2 := entity.NewTab("t2", "ws2", pane2)
294 tab2.Name = "Code"
295
296 original := entity.NewTabList()
297 original.Add(tab1)
298 original.Add(tab2)
299 original.SetActive("t2")
300
301 // Snapshot
302 sessionID := entity.SessionID("test_session")
303 state := entity.SnapshotFromTabList(sessionID, original)
304
305 require.NotNil(t, state)
306 assert.Equal(t, sessionID, state.SessionID)
307 assert.Len(t, state.Tabs, 2)
308 assert.Equal(t, 1, state.ActiveTabIndex) // Second tab active
309
310 // Restore
311 restored := entity.TabListFromSnapshot(state, idGen)
312
313 require.NotNil(t, restored)
314 require.Len(t, restored.Tabs, 2)
315
316 // Check first tab
317 restoredTab1 := restored.Tabs[0]
318 assert.Equal(t, "Search", restoredTab1.Name)
319 assert.True(t, restoredTab1.IsPinned)
320 require.NotNil(t, restoredTab1.Workspace.Root.Pane)
321 assert.Equal(t, "https://google.com", restoredTab1.Workspace.Root.Pane.URI)
322 assert.Equal(t, "Google", restoredTab1.Workspace.Root.Pane.Title)
323 assert.InDelta(t, 1.5, restoredTab1.Workspace.Root.Pane.ZoomFactor, 0.001)
324
325 // Check second tab
326 restoredTab2 := restored.Tabs[1]
327 assert.Equal(t, "Code", restoredTab2.Name)
328 assert.False(t, restoredTab2.IsPinned)
329 assert.Equal(t, "https://github.com", restoredTab2.Workspace.Root.Pane.URI)
330
331 // Active tab should be second
332 assert.Equal(t, restoredTab2.ID, restored.ActiveTabID)
333}
334
335func TestFindPaneAcrossTabs(t *testing.T) {
336 // Create tabs with panes
337 pane1 := entity.NewPane("pane_in_tab1")
338 pane1.URI = "https://tab1.com"
339 tab1 := entity.NewTab("t1", "ws1", pane1)
340
341 pane2 := entity.NewPane("pane_in_tab2")
342 pane2.URI = "https://tab2.com"
343 tab2 := entity.NewTab("t2", "ws2", pane2)
344
345 tabs := entity.NewTabList()
346 tabs.Add(tab1)
347 tabs.Add(tab2)
348
349 // Helper function to find pane across all tabs (mirrors App.updatePaneURIInAllTabs logic)
350 findPaneInTabs := func(tabs *entity.TabList, paneID entity.PaneID) *entity.Pane {
351 for _, tab := range tabs.Tabs {
352 if tab.Workspace == nil {
353 continue
354 }
355 paneNode := tab.Workspace.FindPane(paneID)
356 if paneNode != nil && paneNode.Pane != nil {
357 return paneNode.Pane
358 }
359 }
360 return nil
361 }
362
363 // Find pane in first tab
364 found := findPaneInTabs(tabs, "pane_in_tab1")
365 require.NotNil(t, found)
366 assert.Equal(t, "https://tab1.com", found.URI)
367
368 // Find pane in second tab
369 found = findPaneInTabs(tabs, "pane_in_tab2")
370 require.NotNil(t, found)
371 assert.Equal(t, "https://tab2.com", found.URI)
372
373 // Update URI and verify
374 found.URI = "https://updated.com"
375 foundAgain := findPaneInTabs(tabs, "pane_in_tab2")
376 assert.Equal(t, "https://updated.com", foundAgain.URI)
377
378 // Non-existent pane
379 notFound := findPaneInTabs(tabs, "non_existent")
380 assert.Nil(t, notFound)
381}
382
383func TestFindPaneInNestedStructure(t *testing.T) {
384 // Create a tab with split panes
385 pane1 := entity.NewPane("left_pane")
386 pane1.URI = "https://left.com"
387
388 pane2 := entity.NewPane("right_pane")
389 pane2.URI = "https://right.com"
390
391 // Build a split structure manually
392 tab := entity.NewTab("t1", "ws1", pane1)
393
394 // Add second pane as sibling (simulating a horizontal split)
395 rightNode := &entity.PaneNode{
396 ID: "right_node",
397 Pane: pane2,
398 }
399
400 // Create container node
401 containerNode := &entity.PaneNode{
402 ID: "container",
403 SplitDir: entity.SplitHorizontal,
404 Children: []*entity.PaneNode{
405 tab.Workspace.Root, // left pane
406 rightNode, // right pane
407 },
408 }
409 tab.Workspace.Root = containerNode
410
411 tabs := entity.NewTabList()
412 tabs.Add(tab)
413
414 // Find left pane
415 leftNode := tab.Workspace.FindPane("left_pane")
416 require.NotNil(t, leftNode)
417 assert.Equal(t, "https://left.com", leftNode.Pane.URI)
418
419 // Find right pane
420 rightNodeFound := tab.Workspace.FindPane("right_pane")
421 require.NotNil(t, rightNodeFound)
422 assert.Equal(t, "https://right.com", rightNodeFound.Pane.URI)
423
424 // Update URI on nested pane
425 rightNodeFound.Pane.URI = "https://right-updated.com"
426
427 // Verify update persisted
428 rightNodeAgain := tab.Workspace.FindPane("right_pane")
429 assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
430}