1package coordinator
2
3import (
4 "context"
5 "strconv"
6 "testing"
7
8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/ui/window"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13)
14
15// TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally verifies that
16// closing the only tab in one window fires onCurrentWindowEmpty and does NOT
17// call onQuit when another browser window still has tabs.
18func TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally(t *testing.T) {
19 ctx := context.Background()
20
21 // Two independent TabLists, each with one tab.
22 firstTabs := entity.NewTabList()
23 firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
24
25 secondTabs := entity.NewTabList()
26 secondTabs.Add(entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))))
27
28 gen := counterIDGen()
29 tabsUC := usecase.NewManageTabsUseCase(gen, nil)
30
31 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
32 TabsUC: tabsUC,
33 })
34
35 firstMainWindow := &window.MainWindow{}
36 secondMainWindow := &window.MainWindow{}
37 firstTarget := TabTarget{Tabs: firstTabs, MainWindow: firstMainWindow}
38 secondTarget := TabTarget{Tabs: secondTabs, MainWindow: secondMainWindow}
39
40 var (
41 quitCalled bool
42 windowEmptyCalled bool
43 windowEmptyTarget *window.MainWindow
44 )
45
46 coord.SetOnQuit(func() {
47 quitCalled = true
48 })
49 coord.SetOnCurrentWindowEmpty(func(_ context.Context, target TabTarget) {
50 windowEmptyCalled = true
51 windowEmptyTarget = target.MainWindow
52 })
53
54 // Close the only tab in the first window.
55 err := coord.Close(ctx, firstTarget)
56 require.NoError(t, err, "Close on first target returned error")
57
58 // onQuit must NOT be called (second window still has tabs).
59 assert.False(t, quitCalled, "onQuit was called, but second window still has tabs")
60
61 // onCurrentWindowEmpty must be called for the first window.
62 assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the emptied window")
63 assert.Same(t, firstMainWindow, windowEmptyTarget)
64
65 // firstTabs must be empty.
66 assert.Equal(t, 0, firstTabs.Count())
67
68 // secondTabs must be unchanged (still has its tab).
69 assert.Equal(t, 1, secondTabs.Count())
70 assert.Equal(t, entity.TabID("second-tab"), secondTabs.ActiveTabID)
71
72 // Closing the last tab globally (second window's only tab) should also fire window-empty.
73 windowEmptyCalled = false
74 windowEmptyTarget = nil
75 err = coord.Close(ctx, secondTarget)
76 require.NoError(t, err, "Close on second target returned error")
77
78 // onQuit must still NOT be called (App decides when to quit).
79 assert.False(t, quitCalled, "onQuit was called on last window; onQuit is no longer used by Close")
80
81 // onCurrentWindowEmpty must be called for the second window.
82 assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the last window")
83 assert.Same(t, secondMainWindow, windowEmptyTarget)
84}
85
86// counterIDGen returns a function that generates sequential IDs.
87func counterIDGen() func() string {
88 var counter int
89 return func() string {
90 counter++
91 return strconv.Itoa(counter)
92 }
93}
94
95func TestTabCoordinator_SetMainWindowNilClearsCurrentTargetWindowPreservingTabs(t *testing.T) {
96 tabs := entity.NewTabList()
97 mainWindow := &window.MainWindow{}
98 coord := NewTabCoordinator(context.Background(), TabCoordinatorConfig{
99 Tabs: tabs,
100 MainWindow: mainWindow,
101 })
102
103 coord.SetMainWindow(nil)
104
105 got := coord.CurrentTarget()
106 if got.MainWindow != nil {
107 t.Fatalf("current target main window = %p, want nil", got.MainWindow)
108 }
109 if got.Tabs != tabs {
110 t.Fatalf("current target tabs = %p, want %p", got.Tabs, tabs)
111 }
112}
113
114func TestTabCoordinator_SwitchByIndexUsesTargetTabListOnly(t *testing.T) {
115 ctx := context.Background()
116
117 // Create two independent TabLists, each with two tabs.
118 firstTabs := entity.NewTabList()
119 firstTabs.Add(entity.NewTab(entity.TabID("first-tab-1"), entity.WorkspaceID("ws-first-1"), entity.NewPane(entity.PaneID("pane-first-1"))))
120 firstTabs.Add(entity.NewTab(entity.TabID("first-tab-2"), entity.WorkspaceID("ws-first-2"), entity.NewPane(entity.PaneID("pane-first-2"))))
121
122 secondTabs := entity.NewTabList()
123 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
124 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
125
126 gen := counterIDGen()
127 tabsUC := usecase.NewManageTabsUseCase(gen, nil)
128
129 // Create TabCoordinator without global tabs (target-driven).
130 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
131 TabsUC: tabsUC,
132 // No Tabs, no MainWindow — target is provided per-call.
133 })
134
135 firstTarget := TabTarget{Tabs: firstTabs}
136 secondTarget := TabTarget{Tabs: secondTabs}
137
138 // Confirm initial active tab IDs are as expected (first tab in each list).
139 if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
140 t.Fatalf("firstTabs.ActiveTabID = %q, want %q", got, "first-tab-1")
141 }
142 if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-1") {
143 t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-1")
144 }
145
146 // Switch second window to its second tab (index 1).
147 err := coord.SwitchByIndex(ctx, secondTarget, 1)
148 if err != nil {
149 t.Fatalf("SwitchByIndex on second target failed: %v", err)
150 }
151
152 _ = firstTarget // first target is deliberately not passed to SwitchByIndex below
153
154 // Assert firstTabs.ActiveTabID is unchanged.
155 if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
156 t.Fatalf("firstTabs.ActiveTabID = %q, want %q (should be unchanged)", got, "first-tab-1")
157 }
158
159 if got := secondTabs.PreviousActiveTabID; got != entity.TabID("second-tab-1") {
160 t.Fatalf("secondTabs.PreviousActiveTabID = %q, want %q", got, "second-tab-1")
161 }
162
163 // Assert secondTabs.ActiveTabID is now the second window's tab 2.
164 if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-2") {
165 t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-2")
166 }
167}
168
169func TestTabCoordinator_CreateAndSwitchCallbacksCarryTarget(t *testing.T) {
170 ctx := context.Background()
171
172 firstTabs := entity.NewTabList()
173 firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
174 secondTabs := entity.NewTabList()
175 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
176 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
177
178 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
179 coord.SetCurrentTarget(TabTarget{Tabs: firstTabs})
180
181 secondTarget := TabTarget{Tabs: secondTabs}
182 var createdTarget TabTarget
183 var switchedTargets []TabTarget
184 coord.SetOnTabCreated(func(_ context.Context, target TabTarget, tab *entity.Tab) {
185 createdTarget = target
186 })
187 coord.SetOnTabSwitched(func(_ context.Context, target TabTarget, tab *entity.Tab) {
188 switchedTargets = append(switchedTargets, target)
189 })
190
191 if _, err := coord.Create(ctx, secondTarget, "https://example.com"); err != nil {
192 t.Fatalf("Create returned error: %v", err)
193 }
194 if createdTarget.Tabs != secondTabs {
195 t.Fatalf("created callback target tabs = %p, want %p", createdTarget.Tabs, secondTabs)
196 }
197 if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
198 t.Fatalf("create switch callback targets = %#v, want second target", switchedTargets)
199 }
200
201 switchedTargets = nil
202 if err := coord.Switch(ctx, secondTarget, entity.TabID("second-tab-1")); err != nil {
203 t.Fatalf("Switch returned error: %v", err)
204 }
205 if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
206 t.Fatalf("switch callback targets = %#v, want second target", switchedTargets)
207 }
208 if firstTabs.ActiveTabID != entity.TabID("first-tab") {
209 t.Fatalf("first target active tab changed to %q", firstTabs.ActiveTabID)
210 }
211}
212
213// TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs verifies that
214// SwitchByIndexCreating creates new tabs when the requested index exceeds
215// the current tab count, and only modifies the specified target.
216func TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs(t *testing.T) {
217 ctx := context.Background()
218
219 firstTabs := entity.NewTabList()
220 firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
221
222 secondTabs := entity.NewTabList()
223 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
224
225 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
226
227 secondTarget := TabTarget{Tabs: secondTabs}
228
229 // Call SwitchByIndexCreating with index 3 (0-based), which is beyond the
230 // current 1 tab. This should create 3 new tabs (indices 1, 2, 3) and
231 // activate index 3.
232 err := coord.SwitchByIndexCreating(ctx, secondTarget, 3, "about:blank")
233 require.NoError(t, err, "SwitchByIndexCreating should not return error")
234
235 // secondTabs should have 4 tabs now (1 existing + 3 created).
236 require.Equal(t, 4, secondTabs.Count(), "second target should have 4 tabs after creating up to index 3")
237 require.Greater(t, len(secondTabs.Tabs), 3, "secondTabs.Tabs must have at least 4 elements")
238 assert.Equal(t, secondTabs.Tabs[3].ID, secondTabs.ActiveTabID, "active tab should be at index 3")
239
240 // Verify created tabs/panes have URI "about:blank".
241 for i := 1; i <= 3; i++ {
242 require.Less(t, i, len(secondTabs.Tabs), "tab index %d out of range", i)
243 require.NotNil(t, secondTabs.Tabs[i].Workspace, "tab %d workspace should not be nil", i)
244 pane := secondTabs.Tabs[i].Workspace.ActivePane()
245 require.NotNil(t, pane, "tab %d ActivePane should not be nil", i)
246 require.NotNil(t, pane.Pane, "tab %d pane.Pane should not be nil", i)
247 assert.Equal(t, "about:blank", pane.Pane.URI, "tab %d pane URI should be about:blank", i)
248 }
249
250 // firstTabs must be completely unchanged.
251 assert.Equal(t, 1, firstTabs.Count(), "first target should still have 1 tab")
252 assert.Equal(t, entity.TabID("first-tab"), firstTabs.ActiveTabID, "first target active tab should be unchanged")
253}
254
255// TestTabCoordinator_SwitchByIndexCreatingMissingURLError verifies that
256// SwitchByIndexCreating returns an error when the initialURL is empty.
257func TestTabCoordinator_SwitchByIndexCreatingMissingURLError(t *testing.T) {
258 ctx := context.Background()
259
260 targetTabs := entity.NewTabList()
261 targetTabs.Add(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))))
262
263 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
264
265 target := TabTarget{Tabs: targetTabs}
266
267 // Call with empty URL — should return an error.
268 err := coord.SwitchByIndexCreating(ctx, target, 1, "")
269 require.Error(t, err, "SwitchByIndexCreating with empty URL should return error")
270
271 // Tab count should remain 1.
272 assert.Equal(t, 1, targetTabs.Count(), "tab count should remain 1 after error")
273}