1package entity_test
2
3import (
4 "encoding/json"
5 "sync/atomic"
6 "testing"
7 "time"
8
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12)
13
14// mockIDGenerator creates a simple ID generator for testing.
15func mockIDGenerator() entity.IDGenerator {
16 var counter uint64
17 return func() string {
18 id := atomic.AddUint64(&counter, 1)
19 return string(rune('a'-1+id)) + "_new"
20 }
21}
22
23func TestTabListFromSnapshot_EmptyState(t *testing.T) {
24 idGen := mockIDGenerator()
25
26 // Nil state
27 tabs := entity.TabListFromSnapshot(nil, idGen)
28 require.NotNil(t, tabs)
29 assert.Empty(t, tabs.Tabs)
30
31 // Empty state
32 state := &entity.SessionState{
33 Version: 1,
34 Tabs: []entity.TabSnapshot{},
35 }
36 tabs = entity.TabListFromSnapshot(state, idGen)
37 require.NotNil(t, tabs)
38 assert.Empty(t, tabs.Tabs)
39}
40
41func TestTabListFromSnapshot_SingleTab(t *testing.T) {
42 idGen := mockIDGenerator()
43
44 state := &entity.SessionState{
45 Version: 1,
46 SessionID: "test_session",
47 ActiveTabIndex: 0,
48 Tabs: []entity.TabSnapshot{
49 {
50 ID: "old_tab_1",
51 Name: "Test Tab",
52 Position: 0,
53 IsPinned: true,
54 Workspace: entity.WorkspaceSnapshot{
55 ID: "old_ws_1",
56 ActivePaneID: "old_pane_1",
57 Root: &entity.PaneNodeSnapshot{
58 ID: "old_node_1",
59 Pane: &entity.PaneSnapshot{
60 ID: "old_pane_1",
61 URI: "https://example.com",
62 Title: "Example",
63 ZoomFactor: 1.25,
64 },
65 },
66 },
67 },
68 },
69 SavedAt: time.Now(),
70 }
71
72 tabs := entity.TabListFromSnapshot(state, idGen)
73
74 require.NotNil(t, tabs)
75 require.Len(t, tabs.Tabs, 1)
76
77 tab := tabs.Tabs[0]
78 assert.NotEqual(t, entity.TabID("old_tab_1"), tab.ID, "should generate new ID")
79 assert.Equal(t, "Test Tab", tab.Name)
80 assert.True(t, tab.IsPinned)
81
82 require.NotNil(t, tab.Workspace)
83 require.NotNil(t, tab.Workspace.Root)
84 require.NotNil(t, tab.Workspace.Root.Pane)
85
86 pane := tab.Workspace.Root.Pane
87 assert.NotEqual(t, entity.PaneID("old_pane_1"), pane.ID, "should generate new pane ID")
88 assert.Equal(t, "https://example.com", pane.URI)
89 assert.Equal(t, "Example", pane.Title)
90 assert.InDelta(t, 1.25, pane.ZoomFactor, 0.001)
91
92 // Active tab should be set
93 assert.Equal(t, tab.ID, tabs.ActiveTabID)
94}
95
96func TestTabListFromSnapshot_MultipleTabs(t *testing.T) {
97 idGen := mockIDGenerator()
98
99 state := &entity.SessionState{
100 Version: 1,
101 ActiveTabIndex: 1, // Second tab is active
102 Tabs: []entity.TabSnapshot{
103 {
104 ID: "tab_1",
105 Name: "First",
106 Workspace: entity.WorkspaceSnapshot{
107 Root: &entity.PaneNodeSnapshot{
108 Pane: &entity.PaneSnapshot{
109 ID: "pane_1",
110 URI: "https://first.com",
111 },
112 },
113 },
114 },
115 {
116 ID: "tab_2",
117 Name: "Second",
118 Workspace: entity.WorkspaceSnapshot{
119 Root: &entity.PaneNodeSnapshot{
120 Pane: &entity.PaneSnapshot{
121 ID: "pane_2",
122 URI: "https://second.com",
123 },
124 },
125 },
126 },
127 },
128 }
129
130 tabs := entity.TabListFromSnapshot(state, idGen)
131
132 require.Len(t, tabs.Tabs, 2)
133 assert.Equal(t, "First", tabs.Tabs[0].Name)
134 assert.Equal(t, "Second", tabs.Tabs[1].Name)
135
136 // Active tab should be the second one
137 assert.Equal(t, tabs.Tabs[1].ID, tabs.ActiveTabID)
138}
139
140func TestTabListFromSnapshot_SplitPanes(t *testing.T) {
141 idGen := mockIDGenerator()
142
143 // Horizontal split with two panes
144 state := &entity.SessionState{
145 Version: 1,
146 Tabs: []entity.TabSnapshot{
147 {
148 ID: "tab_1",
149 Name: "Split Tab",
150 Workspace: entity.WorkspaceSnapshot{
151 Root: &entity.PaneNodeSnapshot{
152 ID: "container",
153 SplitDir: entity.SplitHorizontal,
154 SplitRatio: 0.5,
155 Children: []*entity.PaneNodeSnapshot{
156 {
157 ID: "left_node",
158 Pane: &entity.PaneSnapshot{
159 ID: "left_pane",
160 URI: "https://left.com",
161 },
162 },
163 {
164 ID: "right_node",
165 Pane: &entity.PaneSnapshot{
166 ID: "right_pane",
167 URI: "https://right.com",
168 },
169 },
170 },
171 },
172 },
173 },
174 },
175 }
176
177 tabs := entity.TabListFromSnapshot(state, idGen)
178
179 require.Len(t, tabs.Tabs, 1)
180 ws := tabs.Tabs[0].Workspace
181 require.NotNil(t, ws.Root)
182
183 // Root should be a container with children
184 assert.Nil(t, ws.Root.Pane)
185 assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
186 assert.InDelta(t, 0.5, ws.Root.SplitRatio, 0.001)
187 require.Len(t, ws.Root.Children, 2)
188
189 // Check children
190 leftPane := ws.Root.Children[0].Pane
191 rightPane := ws.Root.Children[1].Pane
192 require.NotNil(t, leftPane)
193 require.NotNil(t, rightPane)
194 assert.Equal(t, "https://left.com", leftPane.URI)
195 assert.Equal(t, "https://right.com", rightPane.URI)
196}
197
198func TestTabListFromSnapshot_StackedPanes(t *testing.T) {
199 idGen := mockIDGenerator()
200
201 state := &entity.SessionState{
202 Version: 1,
203 Tabs: []entity.TabSnapshot{
204 {
205 ID: "tab_1",
206 Workspace: entity.WorkspaceSnapshot{
207 Root: &entity.PaneNodeSnapshot{
208 ID: "stack",
209 IsStacked: true,
210 ActiveStackIndex: 1,
211 Children: []*entity.PaneNodeSnapshot{
212 {Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://a.com"}},
213 {Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://b.com"}},
214 {Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://c.com"}},
215 },
216 },
217 },
218 },
219 },
220 }
221
222 tabs := entity.TabListFromSnapshot(state, idGen)
223
224 ws := tabs.Tabs[0].Workspace
225 require.NotNil(t, ws.Root)
226 assert.True(t, ws.Root.IsStacked)
227 assert.Equal(t, 1, ws.Root.ActiveStackIndex)
228 require.Len(t, ws.Root.Children, 3)
229}
230
231func TestTabList_ReplaceFrom(t *testing.T) {
232 // Create original list
233 original := entity.NewTabList()
234 pane1 := entity.NewPane("pane1")
235 pane1.URI = "https://old.com"
236 tab1 := entity.NewTab("tab1", "ws1", pane1)
237 original.Add(tab1)
238 original.SetActive("tab1")
239
240 // Create new list
241 newList := entity.NewTabList()
242 pane2 := entity.NewPane("pane2")
243 pane2.URI = "https://new1.com"
244 pane3 := entity.NewPane("pane3")
245 pane3.URI = "https://new2.com"
246 tab2 := entity.NewTab("tab2", "ws2", pane2)
247 tab3 := entity.NewTab("tab3", "ws3", pane3)
248 newList.Add(tab2)
249 newList.Add(tab3)
250 newList.SetActive("tab3")
251
252 // Keep reference to original
253 ref := original
254
255 // Replace contents
256 original.ReplaceFrom(newList)
257
258 // Verify the reference still points to the modified list
259 assert.Same(t, original, ref)
260 assert.Len(t, ref.Tabs, 2)
261 assert.Equal(t, entity.TabID("tab3"), ref.ActiveTabID)
262 assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
263}
264
265func TestTabList_SnapshotClonesTabsAndWorkspace(t *testing.T) {
266 parentPaneID := entity.PaneID("parent-pane")
267 pane := entity.NewPane("pane-1")
268 pane.URI = "https://original.example"
269 pane.ParentPaneID = &parentPaneID
270 tab := entity.NewTab("tab-1", "workspace-1", pane)
271 tab.Name = "Original"
272 tabs := entity.NewTabList()
273 tabs.Add(tab)
274
275 snapshot := tabs.Snapshot()
276
277 tab.Position = 99
278 tab.Name = "Mutated"
279 tab.Workspace.Root.Pane.URI = "https://mutated.example"
280 *tab.Workspace.Root.Pane.ParentPaneID = entity.PaneID("mutated-parent")
281
282 require.Len(t, snapshot.Tabs, 1)
283 clonedTab := snapshot.Tabs[0]
284 assert.NotSame(t, tab, clonedTab)
285 assert.Equal(t, 0, clonedTab.Position)
286 assert.Equal(t, "Original", clonedTab.Name)
287 require.NotNil(t, clonedTab.Workspace)
288 assert.NotSame(t, tab.Workspace, clonedTab.Workspace)
289 require.NotNil(t, clonedTab.Workspace.Root)
290 assert.NotSame(t, tab.Workspace.Root, clonedTab.Workspace.Root)
291 require.NotNil(t, clonedTab.Workspace.Root.Pane)
292 assert.NotSame(t, tab.Workspace.Root.Pane, clonedTab.Workspace.Root.Pane)
293 assert.Equal(t, "https://original.example", clonedTab.Workspace.Root.Pane.URI)
294 require.NotNil(t, clonedTab.Workspace.Root.Pane.ParentPaneID)
295 assert.Equal(t, entity.PaneID("parent-pane"), *clonedTab.Workspace.Root.Pane.ParentPaneID)
296}
297
298func TestTabList_ReplaceFromClonesSnapshotSource(t *testing.T) {
299 pane := entity.NewPane("pane-1")
300 pane.URI = "https://original.example"
301 tab := entity.NewTab("tab-1", "workspace-1", pane)
302 other := entity.NewTabList()
303 other.Add(tab)
304
305 original := entity.NewTabList()
306 original.ReplaceFrom(other)
307
308 tab.Name = "Mutated"
309 tab.Workspace.Root.Pane.URI = "https://mutated.example"
310
311 restored := original.Find("tab-1")
312 require.NotNil(t, restored)
313 assert.NotSame(t, tab, restored)
314 assert.Empty(t, restored.Name)
315 assert.Equal(t, "https://original.example", restored.Workspace.Root.Pane.URI)
316}
317
318func TestTabList_ReplaceFrom_Nil(t *testing.T) {
319 original := entity.NewTabList()
320 pane := entity.NewPane("pane1")
321 tab := entity.NewTab("tab1", "ws1", pane)
322 original.Add(tab)
323
324 original.ReplaceFrom(nil)
325
326 assert.Empty(t, original.Tabs)
327 assert.Empty(t, original.ActiveTabID)
328}
329
330func TestSnapshotRoundTrip(t *testing.T) {
331 // Create a complex tab structure
332 idGen := mockIDGenerator()
333
334 pane1 := entity.NewPane("p1")
335 pane1.URI = "https://google.com"
336 pane1.Title = "Google"
337 pane1.ZoomFactor = 1.5
338
339 pane2 := entity.NewPane("p2")
340 pane2.URI = "https://github.com"
341 pane2.Title = "GitHub"
342
343 tab1 := entity.NewTab("t1", "ws1", pane1)
344 tab1.Name = "Search"
345 tab1.IsPinned = true
346
347 tab2 := entity.NewTab("t2", "ws2", pane2)
348 tab2.Name = "Code"
349
350 original := entity.NewTabList()
351 original.Add(tab1)
352 original.Add(tab2)
353 original.SetActive("t2")
354
355 // Snapshot
356 sessionID := entity.SessionID("test_session")
357 state := entity.SnapshotFromTabList(sessionID, original)
358
359 require.NotNil(t, state)
360 assert.Equal(t, sessionID, state.SessionID)
361 assert.Len(t, state.Tabs, 2)
362 assert.Equal(t, 1, state.ActiveTabIndex) // Second tab active
363
364 // Restore
365 restored := entity.TabListFromSnapshot(state, idGen)
366
367 require.NotNil(t, restored)
368 require.Len(t, restored.Tabs, 2)
369
370 // Check first tab
371 restoredTab1 := restored.Tabs[0]
372 assert.Equal(t, "Search", restoredTab1.Name)
373 assert.True(t, restoredTab1.IsPinned)
374 require.NotNil(t, restoredTab1.Workspace.Root.Pane)
375 assert.Equal(t, "https://google.com", restoredTab1.Workspace.Root.Pane.URI)
376 assert.Equal(t, "Google", restoredTab1.Workspace.Root.Pane.Title)
377 assert.InDelta(t, 1.5, restoredTab1.Workspace.Root.Pane.ZoomFactor, 0.001)
378
379 // Check second tab
380 restoredTab2 := restored.Tabs[1]
381 assert.Equal(t, "Code", restoredTab2.Name)
382 assert.False(t, restoredTab2.IsPinned)
383 assert.Equal(t, "https://github.com", restoredTab2.Workspace.Root.Pane.URI)
384
385 // Active tab should be second
386 assert.Equal(t, restoredTab2.ID, restored.ActiveTabID)
387}
388
389func TestFindPaneAcrossTabs(t *testing.T) {
390 // Create tabs with panes
391 pane1 := entity.NewPane("pane_in_tab1")
392 pane1.URI = "https://tab1.com"
393 tab1 := entity.NewTab("t1", "ws1", pane1)
394
395 pane2 := entity.NewPane("pane_in_tab2")
396 pane2.URI = "https://tab2.com"
397 tab2 := entity.NewTab("t2", "ws2", pane2)
398
399 tabs := entity.NewTabList()
400 tabs.Add(tab1)
401 tabs.Add(tab2)
402
403 // Helper function to find pane across all tabs (mirrors App.updatePaneURIInAllTabs logic)
404 findPaneInTabs := func(tabs *entity.TabList, paneID entity.PaneID) *entity.Pane {
405 for _, tab := range tabs.Tabs {
406 if tab.Workspace == nil {
407 continue
408 }
409 paneNode := tab.Workspace.FindPane(paneID)
410 if paneNode != nil && paneNode.Pane != nil {
411 return paneNode.Pane
412 }
413 }
414 return nil
415 }
416
417 // Find pane in first tab
418 found := findPaneInTabs(tabs, "pane_in_tab1")
419 require.NotNil(t, found)
420 assert.Equal(t, "https://tab1.com", found.URI)
421
422 // Find pane in second tab
423 found = findPaneInTabs(tabs, "pane_in_tab2")
424 require.NotNil(t, found)
425 assert.Equal(t, "https://tab2.com", found.URI)
426
427 // Update URI and verify
428 found.URI = "https://updated.com"
429 foundAgain := findPaneInTabs(tabs, "pane_in_tab2")
430 assert.Equal(t, "https://updated.com", foundAgain.URI)
431
432 // Non-existent pane
433 notFound := findPaneInTabs(tabs, "non_existent")
434 assert.Nil(t, notFound)
435}
436
437// --- Window-scoped snapshot tests ---
438
439func TestSnapshotFromWindowTabLists_V2State(t *testing.T) {
440 // Create two windows, each with tabs
441 pane1_1 := entity.NewPane("p1_w1")
442 pane1_1.URI = "https://window1.com"
443 tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1_1)
444 tab1_1.Name = "Window1 Tab1"
445
446 pane1_2 := entity.NewPane("p1_w2")
447 pane1_2.URI = "https://window2.com"
448 tab1_2 := entity.NewTab("t1_w2", "ws1_w2", pane1_2)
449 tab1_2.Name = "Window2 Tab1"
450
451 pane1_2b := entity.NewPane("p2_w2")
452 pane1_2b.URI = "https://window2-tab2.com"
453 tab1_2b := entity.NewTab("t2_w2", "ws2_w2", pane1_2b)
454 tab1_2b.Name = "Window2 Tab2"
455
456 tabs1 := entity.NewTabList()
457 tabs1.Add(tab1_1)
458
459 tabs2 := entity.NewTabList()
460 tabs2.Add(tab1_2)
461 tabs2.Add(tab1_2b)
462 tabs2.SetActive("t2_w2")
463
464 windows := []entity.WindowTabListState{
465 {WindowID: "w1", Tabs: tabs1},
466 {WindowID: "w2", Tabs: tabs2},
467 }
468
469 sessionID := entity.SessionID("test_v2")
470 state := entity.SnapshotFromWindowTabLists(sessionID, windows, 1, time.Unix(123, 0))
471
472 require.NotNil(t, state)
473 assert.Equal(t, 2, state.Version)
474 assert.Equal(t, sessionID, state.SessionID)
475
476 // Windows field populated
477 require.Len(t, state.Windows, 2)
478 assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
479 assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
480
481 // Active window index set
482 assert.Equal(t, 1, state.ActiveWindowIndex)
483
484 // No legacy Tabs when using windows
485 assert.Empty(t, state.Tabs)
486
487 // CountPanes across windows
488 assert.Equal(t, 3, state.CountPanes())
489}
490
491func TestWindowTabListsFromSnapshot_V2Restore(t *testing.T) {
492 // Create a v2 snapshot from windows, then restore
493 pane := entity.NewPane("p_w1_t1")
494 pane.URI = "https://example.com"
495 tab := entity.NewTab("t_w1_t1", "ws_w1", pane)
496 tab.Name = "MyTab"
497
498 tabs := entity.NewTabList()
499 tabs.Add(tab)
500
501 windows := []entity.WindowTabListState{
502 {WindowID: "window-1", Tabs: tabs},
503 }
504
505 state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
506
507 idGen := mockIDGenerator()
508 restored := entity.WindowTabListsFromSnapshot(state, idGen)
509
510 require.Len(t, restored, 1)
511 assert.Equal(t, entity.WindowID("window-1"), restored[0].WindowID)
512 require.NotNil(t, restored[0].Tabs)
513 require.Len(t, restored[0].Tabs.Tabs, 1)
514 assert.Equal(t, "MyTab", restored[0].Tabs.Tabs[0].Name)
515 assert.Equal(t, "https://example.com", restored[0].Tabs.Tabs[0].Workspace.Root.Pane.URI)
516}
517
518func TestWindowTabListsFromSnapshot_V1LegacySingleWindow(t *testing.T) {
519 // Legacy v1 flat tabs should be restored as a single window with empty WindowID
520 state := &entity.SessionState{
521 Version: 1,
522 SessionID: "old_sess",
523 ActiveTabIndex: 1,
524 Tabs: []entity.TabSnapshot{
525 {
526 ID: "tab1",
527 Name: "First",
528 Workspace: entity.WorkspaceSnapshot{
529 Root: &entity.PaneNodeSnapshot{
530 Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.com"},
531 },
532 },
533 },
534 {
535 ID: "tab2",
536 Name: "Second",
537 Workspace: entity.WorkspaceSnapshot{
538 Root: &entity.PaneNodeSnapshot{
539 Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.com"},
540 },
541 },
542 },
543 },
544 }
545
546 idGen := mockIDGenerator()
547 restored := entity.WindowTabListsFromSnapshot(state, idGen)
548
549 require.Len(t, restored, 1)
550 // Legacy window gets empty WindowID
551 assert.Equal(t, entity.WindowID(""), restored[0].WindowID)
552 require.NotNil(t, restored[0].Tabs)
553 require.Len(t, restored[0].Tabs.Tabs, 2)
554 assert.Equal(t, "Second", restored[0].Tabs.Tabs[1].Name)
555 // Active tab should be second (index 1)
556 assert.Equal(t, restored[0].Tabs.Tabs[1].ID, restored[0].Tabs.ActiveTabID)
557}
558
559func TestWindowTabListsFromSnapshot_NilState(t *testing.T) {
560 idGen := mockIDGenerator()
561 restored := entity.WindowTabListsFromSnapshot(nil, idGen)
562 assert.Nil(t, restored)
563}
564
565func TestWindowTabListsFromSnapshot_InvalidIndexesFallback(t *testing.T) {
566 // Tab with active tab index out of range should fall back to first tab
567 state := &entity.SessionState{
568 Version: 1,
569 ActiveTabIndex: 999, // beyond bounds
570 Tabs: []entity.TabSnapshot{
571 {
572 ID: "tab_a",
573 Name: "TabA",
574 Workspace: entity.WorkspaceSnapshot{
575 Root: &entity.PaneNodeSnapshot{
576 Pane: &entity.PaneSnapshot{ID: "pa", URI: "https://a.com"},
577 },
578 },
579 },
580 },
581 }
582
583 idGen := mockIDGenerator()
584 restored := entity.WindowTabListsFromSnapshot(state, idGen)
585
586 require.Len(t, restored, 1)
587 require.NotNil(t, restored[0].Tabs)
588 // Should have fallen back to first tab
589 assert.Equal(t, restored[0].Tabs.Tabs[0].ID, restored[0].Tabs.ActiveTabID)
590}
591
592func TestTabListFromSnapshot_ActiveWindowWithZeroTabsFallsBackToFirstOverallTab(t *testing.T) {
593 // Regression: when the active window has zero tabs, the active tab should
594 // be the first tab overall (window 0's tab), not the third window's tab.
595 state := &entity.SessionState{
596 Version: entity.SessionStateVersion,
597 ActiveWindowIndex: 1,
598 Windows: []entity.WindowSnapshot{
599 {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
600 {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
601 }},
602 {ID: "w2", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{}},
603 {ID: "w3", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
604 {Name: "Third", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://third.example"}}}},
605 }},
606 },
607 }
608
609 tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
610
611 require.Len(t, tabs.Tabs, 2)
612 assert.Equal(t, "First", tabs.ActiveTab().Name, "active tab should be the first overall tab, not the third window's tab")
613}
614
615func TestTabListFromSnapshot_FlattensV2WindowSnapshots(t *testing.T) {
616 state := &entity.SessionState{
617 Version: entity.SessionStateVersion,
618 ActiveWindowIndex: 1,
619 Windows: []entity.WindowSnapshot{
620 {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
621 {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
622 }},
623 {ID: "w2", ActiveTabIndex: 1, Tabs: []entity.TabSnapshot{
624 {Name: "Second", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.example"}}}},
625 {Name: "Active", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://active.example"}}}},
626 }},
627 },
628 }
629
630 tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
631
632 require.Len(t, tabs.Tabs, 3)
633 assert.Equal(t, "Active", tabs.ActiveTab().Name)
634}
635
636func TestWindowTabListsFromSnapshot_FutureV2VersionWithEmptyWindowsStaysEmpty(t *testing.T) {
637 state := &entity.SessionState{Version: entity.SessionStateVersion + 1, Windows: []entity.WindowSnapshot{}}
638
639 restored := entity.WindowTabListsFromSnapshot(state, mockIDGenerator())
640
641 assert.Empty(t, restored)
642}
643
644func TestSnapshotFromWindowTabLists_NilTabs(t *testing.T) {
645 windows := []entity.WindowTabListState{
646 {WindowID: "w1", Tabs: nil},
647 }
648
649 state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
650
651 require.Len(t, state.Windows, 1)
652 assert.Empty(t, state.Windows[0].Tabs)
653 assert.Equal(t, 0, state.CountPanes())
654}
655
656func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) {
657 state := entity.SnapshotFromWindowTabLists("sess", nil, 0, time.Unix(123, 0))
658
659 require.NotNil(t, state)
660 assert.Equal(t, 2, state.Version)
661 assert.Empty(t, state.Windows)
662 assert.Equal(t, 0, state.ActiveWindowIndex)
663}
664
665func TestSessionStateCountPanesUsesVersionForWindowSnapshots(t *testing.T) {
666 state := &entity.SessionState{
667 Version: entity.SessionStateVersion,
668 Tabs: []entity.TabSnapshot{{
669 Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "legacy-pane"}}},
670 }},
671 }
672
673 assert.Equal(t, 0, state.CountPanes())
674}
675
676func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) {
677 pane := entity.NewPane("p1")
678 pane.URI = "https://x.com"
679 tab := entity.NewTab("t1", "ws1", pane)
680 tabs := entity.NewTabList()
681 tabs.Add(tab)
682
683 windows := []entity.WindowTabListState{
684 {WindowID: "w1", Tabs: tabs},
685 }
686
687 // Active window index out of range
688 state := entity.SnapshotFromWindowTabLists("sess", windows, 999, time.Unix(123, 0))
689
690 // Should clamp to 0
691 assert.Equal(t, 0, state.ActiveWindowIndex)
692}
693
694func TestFindPaneInNestedStructure(t *testing.T) {
695 // Create a tab with split panes
696 pane1 := entity.NewPane("left_pane")
697 pane1.URI = "https://left.com"
698
699 pane2 := entity.NewPane("right_pane")
700 pane2.URI = "https://right.com"
701
702 // Build a split structure manually
703 tab := entity.NewTab("t1", "ws1", pane1)
704
705 // Add second pane as sibling (simulating a horizontal split)
706 rightNode := &entity.PaneNode{
707 ID: "right_node",
708 Pane: pane2,
709 }
710
711 // Create container node
712 containerNode := &entity.PaneNode{
713 ID: "container",
714 SplitDir: entity.SplitHorizontal,
715 Children: []*entity.PaneNode{
716 tab.Workspace.Root, // left pane
717 rightNode, // right pane
718 },
719 }
720 tab.Workspace.Root = containerNode
721
722 tabs := entity.NewTabList()
723 tabs.Add(tab)
724
725 // Find left pane
726 leftNode := tab.Workspace.FindPane("left_pane")
727 require.NotNil(t, leftNode)
728 assert.Equal(t, "https://left.com", leftNode.Pane.URI)
729
730 // Find right pane
731 rightNodeFound := tab.Workspace.FindPane("right_pane")
732 require.NotNil(t, rightNodeFound)
733 assert.Equal(t, "https://right.com", rightNodeFound.Pane.URI)
734
735 // Update URI on nested pane
736 rightNodeFound.Pane.URI = "https://right-updated.com"
737
738 // Verify update persisted
739 rightNodeAgain := tab.Workspace.FindPane("right_pane")
740 assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
741}
742
743func TestV2SnapshotJSON_OmitEmptyLegacyFields(t *testing.T) {
744 // When using SnapshotFromWindowTabLists (v2 path), the JSON output must
745 // NOT include top-level legacy "tabs" or "active_tab_index" fields.
746 // They should be omitted by omitempty.
747 pane := entity.NewPane(entity.PaneID("p1"))
748 pane.URI = "https://example.com"
749 tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
750 tabs := entity.NewTabList()
751 tabs.Add(tab)
752
753 windows := []entity.WindowTabListState{
754 {WindowID: entity.WindowID("w1"), Tabs: tabs},
755 }
756
757 state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
758
759 data, err := json.Marshal(state)
760 require.NoError(t, err)
761
762 var raw map[string]any
763 err = json.Unmarshal(data, &raw)
764 require.NoError(t, err)
765
766 // v2 JSON must not contain top-level legacy fields.
767 _, hasTabs := raw["tabs"]
768 _, hasActiveTabIdx := raw["active_tab_index"]
769 assert.False(t, hasTabs, "v2 JSON must not include legacy 'tabs' field")
770 assert.False(t, hasActiveTabIdx, "v2 JSON must not include legacy 'active_tab_index' field")
771
772 // But must still have the v2 windows field.
773 windowsRaw, ok := raw["windows"]
774 require.True(t, ok, "v2 JSON must include 'windows' field")
775 windowsArr, _ := windowsRaw.([]any)
776 require.Len(t, windowsArr, 1)
777}
778
779func TestV2SnapshotJSON_CanStillReadV1(t *testing.T) {
780 // When reading a v1 JSON with the legacy fields, they must still be
781 // populated correctly (omitempty is only for writes).
782 v1JSON := `{
783 "version": 1,
784 "session_id": "old-sess",
785 "tabs": [{
786 "id": "tab1",
787 "name": "MyTab",
788 "position": 0,
789 "is_pinned": false,
790 "workspace": {
791 "id": "ws1",
792 "root": {
793 "id": "node1",
794 "pane": {"id": "p1", "uri": "https://example.com", "title": "", "zoom_factor": 0},
795 "split_dir": 0,
796 "split_ratio": 0,
797 "is_stacked": false,
798 "active_stack_index": 0
799 },
800 "active_pane_id": ""
801 }
802 }],
803 "active_tab_index": 0,
804 "saved_at": "2026-01-01T00:00:00Z"
805 }`
806
807 var state entity.SessionState
808 err := json.Unmarshal([]byte(v1JSON), &state)
809 require.NoError(t, err)
810
811 assert.Equal(t, 1, state.Version)
812 assert.Equal(t, entity.SessionID("old-sess"), state.SessionID)
813 require.Len(t, state.Tabs, 1)
814 assert.Equal(t, entity.TabID("tab1"), state.Tabs[0].ID)
815 assert.Equal(t, "MyTab", state.Tabs[0].Name)
816 assert.Equal(t, 0, state.ActiveTabIndex)
817}