1package ui
2
3import (
4 "context"
5 "errors"
6 "reflect"
7 "testing"
8 "unsafe"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/ui/component"
13 "github.com/bnema/dumber/internal/ui/focus"
14 "github.com/bnema/dumber/internal/ui/layout"
15 "github.com/bnema/dumber/internal/ui/window"
16 "github.com/bnema/puregotk/v4/gtk"
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19)
20
21func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
22 mainWindow := &window.MainWindow{}
23 removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
24 remaining := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
25 app := &App{
26 mainWindow: mainWindow,
27 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
28 lastFocusedWindowID: remaining.id,
29 }
30
31 setShellField(t, removed, "appToaster", &component.Toaster{})
32 setShellField(t, removed, "modeToaster", &component.Toaster{})
33 setShellField(t, removed, "borderMgr", &focus.BorderManager{})
34 setShellField(t, removed, "sessionManager", &component.SessionManager{})
35 setShellField(t, removed, "tabPicker", &component.TabPicker{})
36 setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))
37 setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))
38 setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))
39 setShellField(t, removed, "accentPicker", &component.AccentPicker{})
40 setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
41 setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
42
43 app.removeBrowserWindow(removed.id)
44
45 for _, name := range []string{
46 "appToaster",
47 "modeToaster",
48 "borderMgr",
49 "sessionManager",
50 "tabPicker",
51 "tabPickerWidget",
52 "tabPickerPaneID",
53 "insertAccentUC",
54 "accentPicker",
55 "keyboardHandler",
56 "globalShortcutHandler",
57 "permissionDialog",
58 "webrtcIndicator",
59 } {
60 if !fieldIsZero(t, removed, name) {
61 t.Fatalf("browserWindow.%s was not cleared", name)
62 }
63 }
64}
65
66func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
67 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
68 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
69 removedMainWindow := &window.MainWindow{}
70 remainingMainWindow := &window.MainWindow{}
71 removedTabs := entity.NewTabList()
72 removedTabs.Add(ownedTab)
73 remainingTabs := entity.NewTabList()
74 remainingTabs.Add(otherTab)
75 removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow, tabs: removedTabs}
76 remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow, tabs: remainingTabs}
77 ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
78 otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
79 app := &App{
80 mainWindow: removedMainWindow,
81 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
82 tabs: entity.NewTabList(),
83 workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: &component.WorkspaceView{}, otherTab.ID: &component.WorkspaceView{}},
84 windowForTab: map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
85 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
86 ownedSessionKey: {},
87 otherSessionKey: {},
88 },
89 }
90 app.tabs.Add(ownedTab)
91 app.tabs.Add(otherTab)
92
93 app.removeBrowserWindow(removed.id)
94
95 if app.workspaceViews[ownedTab.ID] != nil {
96 t.Fatalf("owned workspace view was not removed")
97 }
98 if app.windowForTab[ownedTab.ID] != nil {
99 t.Fatalf("owned window mapping was not removed")
100 }
101 if app.tabs.Find(ownedTab.ID) != nil {
102 t.Fatalf("owned tab was not removed")
103 }
104 if app.workspaceViews[otherTab.ID] == nil {
105 t.Fatalf("other workspace view should remain")
106 }
107 if app.windowForTab[otherTab.ID] != remaining {
108 t.Fatalf("other tab should remain mapped to remaining window")
109 }
110 if app.tabs.Find(otherTab.ID) == nil {
111 t.Fatalf("other tab should remain")
112 }
113 if _, ok := app.floatingSessions[ownedSessionKey]; ok {
114 t.Fatalf("owned floating session was not released")
115 }
116 if _, ok := app.floatingSessions[otherSessionKey]; !ok {
117 t.Fatalf("other floating session should remain")
118 }
119 assertWindowOwnershipInvariant(t, app)
120}
121
122func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
123 app := &App{browserWindows: make(map[string]*browserWindow)}
124 first := &browserWindow{id: "window-1"}
125 second := &browserWindow{id: "window-2"}
126
127 app.registerBrowserWindow(first)
128 app.registerBrowserWindow(second)
129
130 if got := len(app.browserWindows); got != 2 {
131 t.Fatalf("browserWindows length = %d, want 2", got)
132 }
133 if _, ok := app.browserWindows[first.id]; !ok {
134 t.Fatalf("first browser window was not registered")
135 }
136 if _, ok := app.browserWindows[second.id]; !ok {
137 t.Fatalf("second browser window was not registered")
138 }
139 if first.tabs == nil || second.tabs == nil {
140 t.Fatalf("registered browser windows must have non-nil tab lists")
141 }
142
143 app.removeBrowserWindow(first.id)
144
145 if _, ok := app.browserWindows[first.id]; ok {
146 t.Fatalf("first browser window was not removed")
147 }
148 if _, ok := app.browserWindows[second.id]; !ok {
149 t.Fatalf("second browser window should remain registered")
150 }
151}
152
153func TestBrowserWindow_RemovePromotesRemainingMainWindow(t *testing.T) {
154 mainWindow := &window.MainWindow{}
155 otherWindow := &window.MainWindow{}
156 first := &browserWindow{id: "window-1", mainWindow: mainWindow}
157 second := &browserWindow{id: "window-2", mainWindow: otherWindow}
158 app := &App{
159 mainWindow: mainWindow,
160 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
161 }
162
163 app.removeBrowserWindow(first.id)
164
165 if app.mainWindow != otherWindow {
166 t.Fatalf("mainWindow = %p, want promoted window %p", app.mainWindow, otherWindow)
167 }
168 if len(app.browserWindows) != 1 {
169 t.Fatalf("browserWindows length = %d, want 1", len(app.browserWindows))
170 }
171 if _, ok := app.browserWindows[second.id]; !ok {
172 t.Fatalf("remaining browser window was not kept")
173 }
174}
175
176func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
177 mainWindow := &window.MainWindow{}
178 first := &browserWindow{id: "window-1", mainWindow: mainWindow}
179 app := &App{
180 mainWindow: mainWindow,
181 browserWindows: map[string]*browserWindow{first.id: first},
182 }
183
184 app.removeBrowserWindow(first.id)
185
186 if app.mainWindow != nil {
187 t.Fatalf("mainWindow = %p, want nil", app.mainWindow)
188 }
189 if len(app.browserWindows) != 0 {
190 t.Fatalf("browserWindows length = %d, want 0", len(app.browserWindows))
191 }
192}
193
194func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) {
195 mainWindow := &window.MainWindow{}
196 otherWindow := &window.MainWindow{}
197 removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
198 remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
199 resizeTarget := &testLayoutWidget{}
200 app := &App{
201 mainWindow: mainWindow,
202 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
203 resizeModeBorderTarget: resizeTarget,
204 }
205
206 app.removeBrowserWindow(removed.id)
207
208 if app.resizeModeBorderTarget != nil {
209 t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget)
210 }
211}
212
213func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
214 app := &App{browserWindows: make(map[string]*browserWindow)}
215
216 dispatched := false
217 createdURL := ""
218 app.dispatchOnMainThread = func(fn func()) {
219 dispatched = true
220 fn()
221 }
222 app.browserWindowFactory = func(ctx context.Context, url string) (*browserWindow, error) {
223 createdURL = url
224 return &browserWindow{id: "window-1", initialURL: url}, nil
225 }
226
227 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
228 t.Fatalf("OpenFreshWindow returned error: %v", err)
229 }
230 if !dispatched {
231 t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher")
232 }
233 if createdURL != "https://example.com" {
234 t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
235 }
236 if got := len(app.browserWindows); got != 1 {
237 t.Fatalf("browserWindows length = %d, want 1", got)
238 }
239 if got := app.browserWindows["window-1"]; got == nil {
240 t.Fatalf("browser window was not tracked")
241 }
242 if app.browserWindows["window-1"].initialURL != "https://example.com" {
243 t.Fatalf("browser window initialURL = %q, want %q", app.browserWindows["window-1"].initialURL, "https://example.com")
244 }
245}
246
247func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
248 app := &App{browserWindows: make(map[string]*browserWindow)}
249 app.dispatchOnMainThread = func(fn func()) { fn() }
250 wantErr := errors.New("factory error")
251 app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
252 return nil, wantErr
253 }
254
255 err := app.OpenFreshWindow(context.Background(), "https://example.com")
256
257 if !errors.Is(err, wantErr) {
258 t.Fatalf("expected error %v, got %v", wantErr, err)
259 }
260 if got := len(app.browserWindows); got != 0 {
261 t.Fatalf("browserWindows length = %d, want 0", got)
262 }
263}
264
265func setShellField(t *testing.T, bw *browserWindow, name string, value any) {
266 t.Helper()
267 rv := reflect.ValueOf(bw).Elem()
268 field := rv.FieldByName(name)
269 if !field.IsValid() {
270 t.Fatalf("browserWindow missing field %s", name)
271 }
272 reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
273}
274
275func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool {
276 t.Helper()
277 rv := reflect.ValueOf(bw).Elem()
278 field := rv.FieldByName(name)
279 if !field.IsValid() {
280 t.Fatalf("browserWindow missing field %s", name)
281 }
282 return field.IsZero()
283}
284
285type testLayoutWidget struct{}
286
287func (*testLayoutWidget) Show() {}
288func (*testLayoutWidget) Hide() {}
289func (*testLayoutWidget) SetVisible(bool) {}
290func (*testLayoutWidget) IsVisible() bool { return false }
291func (*testLayoutWidget) SetOpacity(float64) {}
292func (*testLayoutWidget) GrabFocus() bool { return false }
293func (*testLayoutWidget) HasFocus() bool { return false }
294func (*testLayoutWidget) SetCanFocus(bool) {}
295func (*testLayoutWidget) SetFocusable(bool) {}
296func (*testLayoutWidget) SetFocusOnClick(bool) {}
297func (*testLayoutWidget) SetCanTarget(bool) {}
298func (*testLayoutWidget) SetHexpand(bool) {}
299func (*testLayoutWidget) SetVexpand(bool) {}
300func (*testLayoutWidget) GetHexpand() bool { return false }
301func (*testLayoutWidget) GetVexpand() bool { return false }
302func (*testLayoutWidget) SetHalign(gtk.Align) {}
303func (*testLayoutWidget) SetValign(gtk.Align) {}
304func (*testLayoutWidget) SetSizeRequest(int, int) {}
305func (*testLayoutWidget) GetAllocatedWidth() int { return 0 }
306func (*testLayoutWidget) GetAllocatedHeight() int { return 0 }
307func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) {
308 return 0, 0, false
309}
310func (*testLayoutWidget) AddCssClass(string) {}
311func (*testLayoutWidget) RemoveCssClass(string) {}
312func (*testLayoutWidget) HasCssClass(string) bool { return false }
313func (*testLayoutWidget) Unparent() {}
314func (*testLayoutWidget) GetParent() layout.Widget { return nil }
315func (*testLayoutWidget) GtkWidget() *gtk.Widget { return nil }
316func (*testLayoutWidget) AddController(*gtk.EventController) {}
317
318type testPermissionDialogPresenter struct{}
319
320func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
321}
322
323// --- Window tab list tests ---
324
325func TestGetWindowTabLists_RespectsRegistrationOrder(t *testing.T) {
326 // Register windows in non-lexicographic order: window-2 first, then window-1.
327 // The result should preserve creation order, not sorted order.
328 app := &App{
329 browserWindows: make(map[string]*browserWindow),
330 }
331
332 first := &browserWindow{id: "window-2"}
333 second := &browserWindow{id: "window-1"}
334 app.registerBrowserWindow(first)
335 app.registerBrowserWindow(second)
336
337 // Give each window a tab so they show up in results.
338 pane1 := entity.NewPane(entity.PaneID("p-w2"))
339 tab1 := entity.NewTab(entity.TabID("t-w2"), entity.WorkspaceID("ws-w2"), pane1)
340 pane2 := entity.NewPane(entity.PaneID("p-w1"))
341 tab2 := entity.NewTab(entity.TabID("t-w1"), entity.WorkspaceID("ws-w1"), pane2)
342
343 app.tabs = entity.NewTabList()
344 app.tabs.Add(tab1)
345 app.tabs.Add(tab2)
346 first.tabs.Add(tab1)
347 second.tabs.Add(tab2)
348 app.windowForTab = map[entity.TabID]*browserWindow{
349 tab1.ID: first,
350 tab2.ID: second,
351 }
352
353 result := app.GetWindowTabLists()
354
355 require.Len(t, result, 2)
356 assert.Equal(t, entity.WindowID("window-2"), result[0].WindowID, "first registered window should be first")
357 assert.Equal(t, entity.WindowID("window-1"), result[1].WindowID, "second registered window should be second")
358}
359
360func TestGetWindowTabLists_NoBrowserWindowsReturnsGlobalSnapshot(t *testing.T) {
361 pane := entity.NewPane(entity.PaneID("p1"))
362 tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
363 tabs := entity.NewTabList()
364 tabs.Add(tab)
365
366 app := &App{tabs: tabs}
367
368 result := app.GetWindowTabLists()
369
370 require.Len(t, result, 1)
371 assert.Equal(t, entity.WindowID(""), result[0].WindowID)
372 // Snapshot returns a copy so pointer identity is not expected; verify
373 // structural equality instead.
374 assert.Equal(t, tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
375 assert.Len(t, result[0].Tabs.Tabs, len(tabs.Tabs))
376}
377
378func TestGetWindowTabLists_AppliesActiveTabIndex(t *testing.T) {
379 // A window with its own TabList where the second tab is active.
380 app := &App{
381 browserWindows: make(map[string]*browserWindow),
382 }
383
384 pane1 := entity.NewPane(entity.PaneID("p1"))
385 tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
386 pane2 := entity.NewPane(entity.PaneID("p2"))
387 tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
388
389 app.tabs = entity.NewTabList()
390 app.tabs.Add(tab1)
391 app.tabs.Add(tab2)
392
393 bwTabs := entity.NewTabList()
394 bwTabs.Add(tab1)
395 bwTabs.Add(tab2)
396 bwTabs.SetActive(entity.TabID("t2"))
397 bw := &browserWindow{id: "w1", tabs: bwTabs}
398 app.registerBrowserWindow(bw)
399 app.windowForTab = map[entity.TabID]*browserWindow{
400 tab1.ID: bw,
401 tab2.ID: bw,
402 }
403
404 result := app.GetWindowTabLists()
405
406 require.Len(t, result, 1)
407 assert.Equal(t, entity.TabID("t2"), result[0].Tabs.ActiveTabID, "should reflect bw.tabs.ActiveTabID")
408}
409
410func TestGetWindowTabLists_PreservesPreviousActiveTabID(t *testing.T) {
411 app := &App{
412 browserWindows: make(map[string]*browserWindow),
413 }
414
415 pane1 := entity.NewPane(entity.PaneID("p1"))
416 tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
417 pane2 := entity.NewPane(entity.PaneID("p2"))
418 tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
419
420 app.tabs = entity.NewTabList()
421 app.tabs.Add(tab1)
422 app.tabs.Add(tab2)
423
424 bwTabs := entity.NewTabList()
425 bwTabs.Add(tab1)
426 bwTabs.Add(tab2)
427 bwTabs.SetActive(entity.TabID("t2")) // t2 becomes active, t1 becomes previous
428
429 bw := &browserWindow{id: "w1", tabs: bwTabs}
430 app.registerBrowserWindow(bw)
431 app.windowForTab = map[entity.TabID]*browserWindow{
432 tab1.ID: bw,
433 tab2.ID: bw,
434 }
435
436 result := app.GetWindowTabLists()
437
438 require.Len(t, result, 1)
439 assert.Equal(t, entity.TabID("t1"), result[0].Tabs.PreviousActiveTabID, "should preserve bw.tabs.PreviousActiveTabID")
440}
441
442func TestGetActiveWindowIndex_MatchesRegistrationOrder(t *testing.T) {
443 // Register windows in non-lexicographic order; lastFocusedWindowID should
444 // map to the correct index in registration order.
445 app := &App{
446 browserWindows: make(map[string]*browserWindow),
447 }
448
449 app.registerBrowserWindow(&browserWindow{id: "z-window"})
450 app.registerBrowserWindow(&browserWindow{id: "a-window"})
451 app.lastFocusedWindowID = "a-window"
452
453 idx := app.GetActiveWindowIndex()
454 assert.Equal(t, 1, idx, "a-window is the second registered window, index should be 1")
455}
456
457func TestGetActiveWindowIndex_MatchesGetWindowTabLists(t *testing.T) {
458 // The order of windows in GetWindowTabLists must match the indices returned
459 // by GetActiveWindowIndex.
460 app := &App{
461 browserWindows: make(map[string]*browserWindow),
462 }
463
464 w1 := &browserWindow{id: "w1"}
465 w2 := &browserWindow{id: "w2"}
466 w3 := &browserWindow{id: "w3"}
467 app.registerBrowserWindow(w1)
468 app.registerBrowserWindow(w2)
469 app.registerBrowserWindow(w3)
470
471 // Give each window a tab.
472 p1 := entity.NewPane(entity.PaneID("p1"))
473 t1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), p1)
474 p2 := entity.NewPane(entity.PaneID("p2"))
475 t2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), p2)
476 p3 := entity.NewPane(entity.PaneID("p3"))
477 t3 := entity.NewTab(entity.TabID("t3"), entity.WorkspaceID("ws3"), p3)
478 app.tabs = entity.NewTabList()
479 app.tabs.Add(t1)
480 app.tabs.Add(t2)
481 app.tabs.Add(t3)
482 w1.tabs.Add(t1)
483 w2.tabs.Add(t2)
484 w3.tabs.Add(t3)
485 app.windowForTab = map[entity.TabID]*browserWindow{
486 t1.ID: w1,
487 t2.ID: w2,
488 t3.ID: w3,
489 }
490
491 app.lastFocusedWindowID = "w2"
492
493 lists := app.GetWindowTabLists()
494 idx := app.GetActiveWindowIndex()
495
496 require.Len(t, lists, 3)
497 assert.Equal(t, entity.WindowID("w2"), lists[idx].WindowID, "active window ID must match the lists index")
498}
499
500func TestWindowOrder_FallbackWhenRegistrationOrderEmpty(t *testing.T) {
501 // When browserWindowOrder is empty (e.g., old tests / direct map manipulation),
502 // windowOrder must fall back to a deterministic (sorted) order.
503 app := &App{
504 browserWindows: map[string]*browserWindow{
505 "z-window": {id: "z-window"},
506 "a-window": {id: "a-window"},
507 },
508 }
509
510 order := app.windowOrder()
511
512 require.Len(t, order, 2)
513 // Sorted order: "a-window" < "z-window"
514 assert.Equal(t, "a-window", order[0])
515 assert.Equal(t, "z-window", order[1])
516}
517
518func TestRemoveBrowserWindow_RemovesFromOrder(t *testing.T) {
519 app := &App{
520 browserWindows: make(map[string]*browserWindow),
521 }
522
523 w1 := &browserWindow{id: "w1"}
524 w2 := &browserWindow{id: "w2"}
525 w3 := &browserWindow{id: "w3"}
526 app.registerBrowserWindow(w1)
527 app.registerBrowserWindow(w2)
528 app.registerBrowserWindow(w3)
529
530 app.removeBrowserWindow("w2")
531
532 require.Len(t, app.browserWindowOrder, 2)
533 assert.Equal(t, "w1", app.browserWindowOrder[0])
534 assert.Equal(t, "w3", app.browserWindowOrder[1])
535}
536
537func TestRegisterBrowserWindow_DuplicateIgnores(t *testing.T) {
538 app := &App{
539 browserWindows: make(map[string]*browserWindow),
540 }
541
542 bw := &browserWindow{id: "w1"}
543 app.registerBrowserWindow(bw)
544 app.registerBrowserWindow(bw) // duplicate
545
546 require.Len(t, app.browserWindowOrder, 1)
547}
548
549func TestApp_GetWindowTabListsUsesCreationOrderAndActiveIndex(t *testing.T) {
550 // Create two windows with per-window bw.tabs as the real source of truth.
551 // Ensure GetWindowTabLists returns them in registration order, with correct
552 // WindowID, and that the returned Tabs pointers are the per-window bw.tabs
553 // (not copies or derived from global App.tabs).
554 firstTabs := entity.NewTabList()
555 firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
556 firstTabs.Add(firstTab)
557 firstTabs.SetActive(entity.TabID("tab-1"))
558
559 secondTabs := entity.NewTabList()
560 secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
561 secondTabs.Add(secondTab)
562
563 first := &browserWindow{id: "window-1", tabs: firstTabs}
564 second := &browserWindow{id: "window-2", tabs: secondTabs}
565
566 app := &App{
567 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
568 browserWindowOrder: []string{"window-1", "window-2"},
569 lastFocusedWindowID: "window-2",
570 }
571
572 result := app.GetWindowTabLists()
573
574 require.Len(t, result, 2)
575 assert.Equal(t, entity.WindowID("window-1"), result[0].WindowID)
576 assert.Equal(t, entity.WindowID("window-2"), result[1].WindowID)
577
578 // The returned Tabs are snapshot copies of per-window bw.tabs.
579 // Verify structural equality (not pointer identity, since
580 // GetWindowTabLists returns a snapshot for safe concurrent read).
581 assert.Equal(t, firstTabs.ActiveTabID, result[0].Tabs.ActiveTabID)
582 assert.Len(t, result[0].Tabs.Tabs, len(firstTabs.Tabs))
583 assert.Equal(t, secondTabs.ActiveTabID, result[1].Tabs.ActiveTabID)
584 assert.Len(t, result[1].Tabs.Tabs, len(secondTabs.Tabs))
585
586 // Active state reflects per-window bw.tabs.
587 assert.Equal(t, entity.TabID("tab-1"), result[0].Tabs.ActiveTabID)
588 assert.Equal(t, entity.TabID("tab-2"), result[1].Tabs.ActiveTabID)
589
590 // GetActiveWindowIndex respects lastFocusedWindowID mapped to registration order.
591 idx := app.GetActiveWindowIndex()
592 assert.Equal(t, 1, idx, "window-2 is second registered, index should be 1")
593}
594
595func TestApp_AssignRestoredWindowTabListsUsesFreshRuntimeWindowIDs(t *testing.T) {
596 // Create two restored TabLists with distinct tabs.
597 firstTabs := entity.NewTabList()
598 firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
599 firstTabs.Add(firstTab)
600 firstTabs.SetActive(entity.TabID("tab-1"))
601
602 secondTabs := entity.NewTabList()
603 secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
604 secondTabs.Add(secondTab)
605
606 // Create two runtime browser windows with fresh IDs distinct from saved IDs.
607 firstBW := &browserWindow{id: "runtime-w1"}
608 secondBW := &browserWindow{id: "runtime-w2"}
609
610 app := &App{
611 browserWindows: map[string]*browserWindow{"runtime-w1": firstBW, "runtime-w2": secondBW},
612 }
613
614 // Assign restored state using saved window IDs (simulating session restore).
615 app.assignRestoredWindowTabList(firstBW, entity.WindowTabListState{
616 WindowID: "saved-w1",
617 Tabs: firstTabs,
618 })
619 app.assignRestoredWindowTabList(secondBW, entity.WindowTabListState{
620 WindowID: "saved-w2",
621 Tabs: secondTabs,
622 })
623
624 // Tabs must be owned by runtime windows, not saved.
625 require.Same(t, firstTabs, firstBW.tabs, "first window tabs must be assigned")
626 require.Same(t, secondTabs, secondBW.tabs, "second window tabs must be assigned")
627
628 // windowForTab must map tabs to runtime windows.
629 require.Equal(t, firstBW, app.browserWindowForTab(firstTab.ID), "tab-1 must be owned by runtime-w1")
630 require.Equal(t, secondBW, app.browserWindowForTab(secondTab.ID), "tab-2 must be owned by runtime-w2")
631
632 // Saved window IDs must never leak into browserWindows.
633 require.Nil(t, app.browserWindows["saved-w1"], "saved-w1 must not appear in browserWindows")
634 require.Nil(t, app.browserWindows["saved-w2"], "saved-w2 must not appear in browserWindows")
635 assertWindowOwnershipInvariant(t, app)
636}
637
638func TestApp_BrowserWindowsOwnIndependentTabLists(t *testing.T) {
639 // Construct first browser window with its own TabList and distinct tabs.
640 firstTabs := entity.NewTabList()
641 firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
642 firstTabs.Add(firstTab)
643
644 first := &browserWindow{
645 id: "window-first",
646 tabs: firstTabs,
647 }
648
649 // Construct second browser window with its own TabList and distinct tabs.
650 secondTabs := entity.NewTabList()
651 secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
652 secondTabs.Add(secondTab)
653
654 second := &browserWindow{
655 id: "window-second",
656 tabs: secondTabs,
657 }
658
659 app := &App{
660 browserWindows: map[string]*browserWindow{
661 first.id: first,
662 second.id: second,
663 },
664 }
665
666 // tabListForBrowserWindow returns the correct TabList for each window.
667 got := app.tabListForBrowserWindow(first)
668 if got != firstTabs {
669 t.Fatalf("tabListForBrowserWindow(first) = %p, want %p", got, firstTabs)
670 }
671 got = app.tabListForBrowserWindow(second)
672 if got != secondTabs {
673 t.Fatalf("tabListForBrowserWindow(second) = %p, want %p", got, secondTabs)
674 }
675
676 // Each window's tabs do not contain the other window's tabs.
677 if firstTabs.Find(secondTab.ID) != nil {
678 t.Fatalf("first window tabs unexpectedly contain second tab")
679 }
680 if secondTabs.Find(firstTab.ID) != nil {
681 t.Fatalf("second window tabs unexpectedly contain first tab")
682 }
683
684 // activeTabForBrowserWindow returns the correct active tab for each window.
685 gotTab := app.activeTabForBrowserWindow(first)
686 if gotTab != firstTab {
687 t.Fatalf("activeTabForBrowserWindow(first) = %p, want %p", gotTab, firstTab)
688 }
689 gotTab = app.activeTabForBrowserWindow(second)
690 if gotTab != secondTab {
691 t.Fatalf("activeTabForBrowserWindow(second) = %p, want %p", gotTab, secondTab)
692 }
693
694 app.windowForTab = map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}
695 assertWindowOwnershipInvariant(t, app)
696}
697
698func TestRestoreSession_RemovesStaleBrowserWindows(t *testing.T) {
699 // After restore, only runtime windows should remain in browserWindows
700 // and browserWindowOrder. Stale pre-existing windows must be removed.
701
702 // Runtime windows with tabs (what restoreSession produces).
703 runtime1Tabs := entity.NewTabList()
704 r1Tab := entity.NewTab(entity.TabID("r1-t1"), entity.WorkspaceID("ws-r1"), entity.NewPane(entity.PaneID("p-r1")))
705 runtime1Tabs.Add(r1Tab)
706 runtime1Tabs.SetActive(entity.TabID("r1-t1"))
707
708 runtime2Tabs := entity.NewTabList()
709 r2Tab := entity.NewTab(entity.TabID("r2-t1"), entity.WorkspaceID("ws-r2"), entity.NewPane(entity.PaneID("p-r2")))
710 runtime2Tabs.Add(r2Tab)
711 runtime2Tabs.SetActive(entity.TabID("r2-t1"))
712
713 runtime1 := &browserWindow{id: "runtime-w1", tabs: runtime1Tabs}
714 runtime2 := &browserWindow{id: "runtime-w2", tabs: runtime2Tabs}
715
716 // Stale pre-existing browser window (no longer in runtime set).
717 staleTabs := entity.NewTabList()
718 staleTab := entity.NewTab(entity.TabID("stale-t1"), entity.WorkspaceID("ws-stale"), entity.NewPane(entity.PaneID("p-stale")))
719 staleTabs.Add(staleTab)
720 stale := &browserWindow{id: "stale-window", tabs: staleTabs}
721
722 app := &App{
723 browserWindows: map[string]*browserWindow{
724 "stale-window": stale,
725 "runtime-w1": runtime1,
726 "runtime-w2": runtime2,
727 },
728 tabs: entity.NewTabList(),
729 windowForTab: map[entity.TabID]*browserWindow{
730 staleTab.ID: stale,
731 r1Tab.ID: runtime1,
732 r2Tab.ID: runtime2,
733 },
734 }
735
736 // Simulate post-restore cleanup: remove stale windows not in runtime set.
737 runtimeIDs := []string{"runtime-w1", "runtime-w2"}
738 app.pruneStaleBrowserWindows([]*browserWindow{runtime1, runtime2})
739
740 // After cleanup, browserWindows contains only runtime windows.
741 require.Len(t, app.browserWindows, 2)
742 require.NotNil(t, app.browserWindows["runtime-w1"])
743 require.NotNil(t, app.browserWindows["runtime-w2"])
744 require.Nil(t, app.browserWindows["stale-window"])
745
746 // browserWindowOrder matches runtime order.
747 require.Equal(t, runtimeIDs, app.browserWindowOrder)
748
749 // GetWindowTabLists returns only runtime windows.
750 result := app.GetWindowTabLists()
751 require.Len(t, result, 2)
752 assert.Equal(t, entity.WindowID("runtime-w1"), result[0].WindowID)
753 assert.Equal(t, entity.WindowID("runtime-w2"), result[1].WindowID)
754
755 // No stale tabs leak into the result.
756 // Verify structural equality (snapshot copies, not live pointers).
757 assert.Equal(t, runtime1Tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
758 assert.Len(t, result[0].Tabs.Tabs, len(runtime1Tabs.Tabs))
759 assert.Equal(t, runtime2Tabs.ActiveTabID, result[1].Tabs.ActiveTabID)
760 assert.Len(t, result[1].Tabs.Tabs, len(runtime2Tabs.Tabs))
761 assertWindowOwnershipInvariant(t, app)
762}
763
764func TestRestoreSession_BrowserWindowOrderStartsWithFirstReusedWindow(t *testing.T) {
765 // When the first restored window reuses a browser window that was not
766 // already in browserWindowOrder, the restored order must start with
767 // the reused window ID followed by newly-created window IDs.
768
769 reuseTabs := entity.NewTabList()
770 reuseTab := entity.NewTab(entity.TabID("reuse-t1"), entity.WorkspaceID("ws-reuse"), entity.NewPane(entity.PaneID("p-reuse")))
771 reuseTabs.Add(reuseTab)
772 reuseBW := &browserWindow{id: "reused-window", tabs: reuseTabs}
773
774 createdTabs := entity.NewTabList()
775 createdTab := entity.NewTab(entity.TabID("created-t1"), entity.WorkspaceID("ws-created"), entity.NewPane(entity.PaneID("p-created")))
776 createdTabs.Add(createdTab)
777 createdBW := &browserWindow{id: "created-window", tabs: createdTabs}
778
779 // Pre-existing browserWindowOrder does NOT contain the reused window.
780 app := &App{
781 browserWindows: map[string]*browserWindow{
782 "reused-window": reuseBW,
783 "created-window": createdBW,
784 },
785 browserWindowOrder: []string{"old-stale-window"}, // reused window not present
786 tabs: entity.NewTabList(),
787 windowForTab: map[entity.TabID]*browserWindow{},
788 }
789
790 // Exercise the helper used by restoreSession to prune stale windows and
791 // replace browserWindowOrder with the runtime restore order.
792 runtimeWindows := []*browserWindow{reuseBW, createdBW}
793 app.pruneStaleBrowserWindows(runtimeWindows)
794
795 require.Len(t, app.browserWindowOrder, 2)
796 assert.Equal(t, "reused-window", app.browserWindowOrder[0],
797 "first reused window must be first in order")
798 assert.Equal(t, "created-window", app.browserWindowOrder[1],
799 "newly-created window must follow the reused window")
800
801 // GetWindowTabLists respects the order.
802 result := app.GetWindowTabLists()
803 require.Len(t, result, 2)
804 assert.Equal(t, entity.WindowID("reused-window"), result[0].WindowID)
805 assert.Equal(t, entity.WindowID("created-window"), result[1].WindowID)
806}
807
808func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
809 // After restore, GetActiveWindowIndex must return the restored active index.
810 // The focused window state must be consistent: lastFocusedWindowID matches
811 // the window at that index, and the active window's tab target aligns.
812
813 runtime1Tabs := entity.NewTabList()
814 r1Tab := entity.NewTab(entity.TabID("a1-t1"), entity.WorkspaceID("ws-a1"), entity.NewPane(entity.PaneID("p-a1")))
815 runtime1Tabs.Add(r1Tab)
816 runtime1Tabs.SetActive(entity.TabID("a1-t1"))
817
818 runtime2Tabs := entity.NewTabList()
819 r2Tab := entity.NewTab(entity.TabID("a2-t1"), entity.WorkspaceID("ws-a2"), entity.NewPane(entity.PaneID("p-a2")))
820 runtime2Tabs.Add(r2Tab)
821 runtime2Tabs.SetActive(entity.TabID("a2-t1"))
822
823 runtime1 := &browserWindow{id: "active-w1", tabs: runtime1Tabs}
824 runtime2 := &browserWindow{id: "active-w2", tabs: runtime2Tabs}
825
826 app := &App{
827 browserWindows: map[string]*browserWindow{
828 "active-w1": runtime1,
829 "active-w2": runtime2,
830 },
831 browserWindowOrder: []string{"active-w1", "active-w2"},
832 lastFocusedWindowID: "active-w2",
833 tabs: entity.NewTabList(),
834 windowForTab: map[entity.TabID]*browserWindow{},
835 }
836
837 // Active index should be 1 (second window in order).
838 idx := app.GetActiveWindowIndex()
839 assert.Equal(t, 1, idx)
840
841 // The focused window is the one at that index.
842 focused := app.lastFocusedBrowserWindow()
843 require.NotNil(t, focused)
844 assert.Equal(t, "active-w2", focused.id)
845
846 // Verify that GetActiveWindowIndex and GetWindowTabLists are consistent:
847 // the window at the active index matches the focused window.
848 result := app.GetWindowTabLists()
849 require.Len(t, result, 2)
850 assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
851 "window at active index must match focused window ID")
852}