app_eject_pane_to_window_test.go RawHistoryFolder
1package ui
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/ui/component"
10)
11
12func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
13 app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab-a", "source-ws-a", "pane-a", "pane-b", "pane-c"))
14 // ActivePaneID is the source of truth. Keep ActiveStackIndex stale to cover
15 // ejection after stack/UI state drift with more than two panes.
16 sourceTab.Workspace.ActivePaneID = "pane-b"
17 sourceTab.Workspace.Root.ActiveStackIndex = 0
18
19 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
20 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
21 }
22
23 if got := len(app.browserWindows); got != 2 {
24 t.Fatalf("browserWindows length = %d, want 2", got)
25 }
26 if app.browserWindows[sourceWindow.id] == nil {
27 t.Fatal("source window should remain registered")
28 }
29 targetWindow := app.browserWindows["target-window"]
30 if targetWindow == nil {
31 t.Fatal("target window was not registered")
32 }
33 if got := targetWindow.tabs.Count(); got != 1 {
34 t.Fatalf("target tab count = %d, want 1", got)
35 }
36 newTab := targetWindow.tabs.Tabs[0]
37 if got := app.windowForTab[newTab.ID]; got != targetWindow {
38 t.Fatalf("windowForTab[%s] = %p, want target %p", newTab.ID, got, targetWindow)
39 }
40 if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-b") {
41 t.Fatalf("target active pane = %q, want pane-b", got)
42 }
43 if got := sourceTab.Workspace.FindPane("pane-b"); got != nil {
44 t.Fatal("source workspace still contains moved pane")
45 }
46 if got := sourceTab.Workspace.ActivePaneID; got != entity.PaneID("pane-c") {
47 t.Fatalf("source active pane = %q, want pane-c", got)
48 }
49 if got := sourceTab.Workspace.Root.ActiveStackIndex; got != 1 {
50 t.Fatalf("source active stack index = %d, want 1", got)
51 }
52 if got := app.lastFocusedWindowID; got != targetWindow.id {
53 t.Fatalf("lastFocusedWindowID = %q, want %q", got, targetWindow.id)
54 }
55}
56
57func TestApp_EjectActivePaneToWindowRetainsSourceWindowOnRemainingActiveTab(t *testing.T) {
58 sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
59 otherTab := entity.NewTab("other-tab", "other-ws", entity.NewPane("pane-b"))
60 app, sourceWindow, _ := newEjectTestApp(t, sourceTab)
61 sourceWindow.tabs.Add(otherTab)
62 app.setBrowserWindowForTab(otherTab.ID, sourceWindow)
63 sourceWindow.tabs.SetActive(sourceTab.ID)
64
65 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
66 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
67 }
68
69 if app.browserWindows[sourceWindow.id] == nil {
70 t.Fatal("source window should remain registered when another tab remains")
71 }
72 if got := sourceWindow.tabs.ActiveTabID; got != otherTab.ID {
73 t.Fatalf("source active tab = %q, want %q", got, otherTab.ID)
74 }
75 if got := app.windowForTab[sourceTab.ID]; got != nil {
76 t.Fatalf("closed source tab ownership remained: %p", got)
77 }
78 if got := app.windowForTab[otherTab.ID]; got != sourceWindow {
79 t.Fatalf("remaining tab owner = %p, want source window %p", got, sourceWindow)
80 }
81}
82
83func TestApp_EjectActivePaneToWindowRemovesEmptySourceWindow(t *testing.T) {
84 sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
85 app, sourceWindow, _ := newEjectTestApp(t, sourceTab)
86
87 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
88 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
89 }
90
91 if app.browserWindows[sourceWindow.id] != nil {
92 t.Fatal("empty source window should be removed")
93 }
94 if got := len(app.browserWindows); got != 1 {
95 t.Fatalf("browserWindows length = %d, want replacement target only", got)
96 }
97 if got := app.windowForTab[sourceTab.ID]; got != nil {
98 t.Fatalf("source tab ownership remained after source tab closed: %p", got)
99 }
100 for _, bw := range app.browserWindows {
101 if bw.tabs == nil || bw.tabs.Count() == 0 {
102 t.Fatalf("registered zero-tab window remains: %#v", bw)
103 }
104 }
105}
106
107func TestApp_EjectActivePaneToWindowSnapshotRetainsSourceWindowWhenTabsRemain(t *testing.T) {
108 app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b", "pane-c"))
109 sourceTab.Workspace.ActivePaneID = "pane-b"
110
111 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
112 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
113 }
114
115 windows, activeWindowIndex := app.GetWindowSnapshotState()
116 if got := len(windows); got != 2 {
117 t.Fatalf("snapshot window count = %d, want 2", got)
118 }
119 if got := activeWindowIndex; got != 1 {
120 t.Fatalf("active window index = %d, want target index 1", got)
121 }
122 if got := windows[0].WindowID; got != entity.WindowID(sourceWindow.id) {
123 t.Fatalf("first snapshot window = %q, want source %q", got, sourceWindow.id)
124 }
125 if got := windows[0].Tabs.Count(); got != 1 {
126 t.Fatalf("source snapshot tab count = %d, want 1", got)
127 }
128 if windows[0].Tabs.Find(sourceTab.ID) == nil {
129 t.Fatalf("source snapshot missing retained tab %q", sourceTab.ID)
130 }
131 assertTargetSnapshotContainsPane(t, windows[1], "pane-b")
132}
133
134func TestApp_EjectActivePaneToWindowHandlesThreePaneSplitTree(t *testing.T) {
135 app, sourceWindow, sourceTab := newEjectTestApp(t, splitTreeTab("source-tab", "source-ws", "pane-a", "pane-b", "pane-c"))
136 sourceTab.Workspace.ActivePaneID = "pane-c"
137
138 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
139 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
140 }
141
142 assertEjectWindowCount(t, app, sourceWindow)
143 assertEjectedThreePaneSplitSource(t, sourceTab)
144 assertEjectedThreePaneSplitTarget(t, app, "pane-c")
145}
146
147func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) {
148 sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
149 app, _, _ := newEjectTestApp(t, sourceTab)
150
151 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
152 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
153 }
154
155 windows, activeWindowIndex := app.GetWindowSnapshotState()
156 if got := len(windows); got != 1 {
157 t.Fatalf("snapshot window count = %d, want replacement target only", got)
158 }
159 if got := activeWindowIndex; got != 0 {
160 t.Fatalf("active window index = %d, want target index 0", got)
161 }
162 assertTargetSnapshotContainsPane(t, windows[0], "pane-a")
163}
164
165func TestApp_EjectActivePaneToWindowTargetUICreationFailureRestoresState(t *testing.T) {
166 app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
167 app.workspaceViewCreateOverride = nil
168 sourceTab.Workspace.ActivePaneID = "pane-b"
169
170 err := app.EjectActivePaneToWindow(context.Background(), "")
171 if err == nil {
172 t.Fatal("EjectActivePaneToWindow returned nil error, want target UI creation error")
173 }
174
175 if got := len(app.browserWindows); got != 1 {
176 t.Fatalf("browserWindows length = %d, want restored source only", got)
177 }
178 if app.browserWindows[sourceWindow.id] != sourceWindow {
179 t.Fatal("source window should remain registered after rollback")
180 }
181 if app.browserWindows["target-window"] != nil {
182 t.Fatal("target window should be removed after rollback")
183 }
184 if got := sourceWindow.tabs.Count(); got != 1 {
185 t.Fatalf("source tab count = %d, want restored source tab", got)
186 }
187 restoredTab := sourceWindow.tabs.Find(sourceTab.ID)
188 if restoredTab == nil {
189 t.Fatal("source tab missing after rollback")
190 }
191 if restoredTab.Workspace.FindPane("pane-a") == nil || restoredTab.Workspace.FindPane("pane-b") == nil {
192 t.Fatal("source workspace panes were not restored after rollback")
193 }
194 if got := app.windowForTab[sourceTab.ID]; got != sourceWindow {
195 t.Fatalf("source tab owner = %p, want source window %p", got, sourceWindow)
196 }
197 if got := app.windowForTabCount(); got != 1 {
198 t.Fatalf("windowForTab length = %d, want restored source only", got)
199 }
200}
201
202func TestApp_EjectActivePaneToWindowUsecaseFailureRollsBackTargetWindow(t *testing.T) {
203 app, _, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
204 sourceTab.Workspace.ActivePaneID = "missing-pane"
205
206 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
207 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
208 }
209
210 if got := len(app.browserWindows); got != 1 {
211 t.Fatalf("browserWindows length = %d, want unchanged source only", got)
212 }
213 if app.browserWindows["target-window"] != nil {
214 t.Fatal("target window should not be registered after failure/no-op")
215 }
216 if got := app.windowForTabCount(); got != 1 {
217 t.Fatalf("windowForTab length = %d, want unchanged source only", got)
218 }
219}
220
221func TestApp_EjectActivePaneToWindowNoopsWhenRequestedPaneIsNotActiveWorkspacePane(t *testing.T) {
222 app, _, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
223 sourceTab.Workspace.ActivePaneID = "pane-a"
224
225 if err := app.EjectActivePaneToWindow(context.Background(), "floating-pane:source-tab:default"); err != nil {
226 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
227 }
228
229 if got := len(app.browserWindows); got != 1 {
230 t.Fatalf("browserWindows length = %d, want unchanged source only", got)
231 }
232 if sourceTab.Workspace.FindPane("pane-a") == nil || sourceTab.Workspace.FindPane("pane-b") == nil {
233 t.Fatal("source workspace changed after mismatched requested pane no-op")
234 }
235}
236
237func TestApp_EjectActivePaneToWindowNoopsForInternalSystemPane(t *testing.T) {
238 pane := entity.NewPane("pane-a")
239 pane.URI = "dumb://history"
240 app, _, sourceTab := newEjectTestApp(t, entity.NewTab("source-tab", "source-ws", pane))
241
242 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
243 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
244 }
245
246 if got := len(app.browserWindows); got != 1 {
247 t.Fatalf("browserWindows length = %d, want unchanged source only", got)
248 }
249 if sourceTab.Workspace.FindPane("pane-a") == nil {
250 t.Fatal("source system pane was moved")
251 }
252}
253
254func newEjectTestApp(t *testing.T, sourceTab *entity.Tab) (*App, *browserWindow, *entity.Tab) {
255 t.Helper()
256 sourceTabs := entity.NewTabList()
257 sourceTabs.Add(sourceTab)
258 sourceWindow := &browserWindow{id: "source-window", tabs: sourceTabs}
259 ids := []string{"new-tab", "new-workspace"}
260 idIndex := 0
261 app := &App{
262 tabs: entity.NewTabList(),
263 browserWindows: map[string]*browserWindow{sourceWindow.id: sourceWindow},
264 browserWindowOrder: []string{sourceWindow.id},
265 lastFocusedWindowID: sourceWindow.id,
266 windowForTab: map[entity.TabID]*browserWindow{sourceTab.ID: sourceWindow},
267 workspaceViews: map[entity.TabID]*component.WorkspaceView{},
268 extractPaneToTabListUC: usecase.NewExtractPaneToTabListUseCase(func() string {
269 if idIndex >= len(ids) {
270 return "extra-id"
271 }
272 id := ids[idIndex]
273 idIndex++
274 return id
275 }),
276 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
277 return &browserWindow{id: "target-window", tabs: entity.NewTabList()}, nil
278 },
279 }
280 app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
281 if tab == nil {
282 return false
283 }
284 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
285 return true
286 }
287 app.tabs.Add(sourceTab)
288 return app, sourceWindow, sourceTab
289}
290
291func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...entity.PaneID) *entity.Tab {
292 children := make([]*entity.PaneNode, 0, len(paneIDs))
293 stack := &entity.PaneNode{ID: "stack", IsStacked: true}
294 for _, paneID := range paneIDs {
295 child := &entity.PaneNode{ID: string(paneID) + "-node", Pane: entity.NewPane(paneID), Parent: stack}
296 children = append(children, child)
297 }
298 stack.Children = children
299 workspace := &entity.Workspace{ID: workspaceID, Root: stack, ActivePaneID: paneIDs[0]}
300 return &entity.Tab{ID: tabID, Workspace: workspace}
301}
302
303func assertEjectWindowCount(t *testing.T, app *App, sourceWindow *browserWindow) {
304 t.Helper()
305 if got := len(app.browserWindows); got != 2 {
306 t.Fatalf("browserWindows length = %d, want source and target", got)
307 }
308 if app.browserWindows[sourceWindow.id] == nil {
309 t.Fatal("source window should remain registered")
310 }
311}
312
313func assertEjectedThreePaneSplitSource(t *testing.T, sourceTab *entity.Tab) {
314 t.Helper()
315 if got := sourceTab.Workspace.PaneCount(); got != 2 {
316 t.Fatalf("source pane count = %d, want 2", got)
317 }
318 if sourceTab.Workspace.FindPane("pane-a") == nil || sourceTab.Workspace.FindPane("pane-b") == nil {
319 t.Fatal("source workspace missing remaining split panes")
320 }
321 if got := sourceTab.Workspace.FindPane("pane-c"); got != nil {
322 t.Fatal("source workspace still contains moved split pane")
323 }
324 if got := sourceTab.Workspace.ActivePaneID; got != entity.PaneID("pane-b") {
325 t.Fatalf("source active pane = %q, want pane-b", got)
326 }
327 assertPromotedLeftSplitRoot(t, sourceTab.Workspace.Root)
328}
329
330func assertPromotedLeftSplitRoot(t *testing.T, root *entity.PaneNode) {
331 t.Helper()
332 if root == nil || root.ID != "left-split" || !root.IsSplit() {
333 t.Fatalf("source root = %#v, want promoted left split", root)
334 }
335 if got := root.Parent; got != nil {
336 t.Fatalf("source root parent = %#v, want nil", got)
337 }
338 assertLeafPaneID(t, root.Left(), "pane-a")
339 assertLeafPaneID(t, root.Right(), "pane-b")
340 assertPaneTreeParents(t, root, nil)
341}
342
343func assertEjectedThreePaneSplitTarget(t *testing.T, app *App, paneID entity.PaneID) {
344 t.Helper()
345 targetWindow := app.browserWindows["target-window"]
346 if targetWindow == nil || targetWindow.tabs.Count() != 1 {
347 t.Fatalf("target window/tabs not created correctly: %#v", targetWindow)
348 }
349 newTab := targetWindow.tabs.Tabs[0]
350 if got := newTab.Workspace.ActivePaneID; got != paneID {
351 t.Fatalf("target active pane = %q, want %q", got, paneID)
352 }
353 if got := newTab.Workspace.Root.Parent; got != nil {
354 t.Fatalf("target root parent = %#v, want nil", got)
355 }
356 if got := app.windowForTab[newTab.ID]; got != targetWindow {
357 t.Fatalf("target owner = %p, want target window %p", got, targetWindow)
358 }
359}
360
361func assertLeafPaneID(t *testing.T, node *entity.PaneNode, paneID entity.PaneID) {
362 t.Helper()
363 if node == nil || node.Pane == nil || node.Pane.ID != paneID {
364 t.Fatalf("leaf = %#v, want %s", node, paneID)
365 }
366}
367
368func splitTreeTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneAID, paneBID, paneCID entity.PaneID) *entity.Tab {
369 paneA := entity.NewPane(paneAID)
370 paneB := entity.NewPane(paneBID)
371 paneC := entity.NewPane(paneCID)
372
373 leftSplit := &entity.PaneNode{ID: "left-split", SplitDir: entity.SplitVertical, SplitRatio: 0.5}
374 leafA := &entity.PaneNode{ID: string(paneAID) + "-node", Pane: paneA, Parent: leftSplit}
375 leafB := &entity.PaneNode{ID: string(paneBID) + "-node", Pane: paneB, Parent: leftSplit}
376 leftSplit.Children = []*entity.PaneNode{leafA, leafB}
377
378 root := &entity.PaneNode{ID: "root-split", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
379 leafC := &entity.PaneNode{ID: string(paneCID) + "-node", Pane: paneC, Parent: root}
380 leftSplit.Parent = root
381 root.Children = []*entity.PaneNode{leftSplit, leafC}
382
383 workspace := &entity.Workspace{ID: workspaceID, Root: root, ActivePaneID: paneAID}
384 return &entity.Tab{ID: tabID, Workspace: workspace}
385}
386
387func assertPaneTreeParents(t *testing.T, node, wantParent *entity.PaneNode) {
388 t.Helper()
389 if node == nil {
390 return
391 }
392 if node.Parent != wantParent {
393 t.Fatalf("node %q parent = %#v, want %#v", node.ID, node.Parent, wantParent)
394 }
395 for _, child := range node.Children {
396 assertPaneTreeParents(t, child, node)
397 }
398}
399
400func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) {
401 t.Helper()
402 if got := window.WindowID; got != entity.WindowID("target-window") {
403 t.Fatalf("target snapshot window ID = %q, want target-window", got)
404 }
405 if got := window.Tabs.Count(); got != 1 {
406 t.Fatalf("target snapshot tab count = %d, want 1", got)
407 }
408 newTab := window.Tabs.Tabs[0]
409 if newTab.Workspace == nil || newTab.Workspace.FindPane(paneID) == nil {
410 t.Fatalf("target snapshot missing moved pane %q", paneID)
411 }
412}
413
414func (a *App) windowForTabCount() int { return len(a.windowForTab) }