manage_panes_consume_expel_test.go RawHistoryFolder
1package usecase
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/dumber/internal/domain/entity"
8)
9
10func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) {
11 uc := NewManagePanesUseCase(func() string { return "id" })
12 ctx := context.Background()
13
14 left := leaf("left")
15 right := leaf("right")
16 root := split(entity.SplitHorizontal, left, right)
17 ws := &entity.Workspace{Root: root, ActivePaneID: right.Pane.ID}
18
19 // First press: horizontal -> vertical split (left on top, right on bottom).
20 res, err := uc.ConsumeOrExpel(ctx, ws, right, ConsumeOrExpelLeft)
21 if err != nil {
22 t.Fatalf("unexpected error: %v", err)
23 }
24 if res == nil || res.Action != "consumed" {
25 t.Fatalf("result action=%v, want consumed", res)
26 }
27 if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
28 t.Fatalf("root should be a vertical split")
29 }
30 if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "left" {
31 t.Fatalf("top pane=%v, want left", ws.Root.Left())
32 }
33 if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "right" {
34 t.Fatalf("bottom pane=%v, want right", ws.Root.Right())
35 }
36 if ws.ActivePaneID != "right" {
37 t.Fatalf("active=%s, want right", ws.ActivePaneID)
38 }
39
40 // Second press: vertical split -> stack.
41 active := ws.ActivePane()
42 if active == nil {
43 t.Fatalf("expected active pane")
44 }
45 res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelLeft)
46 if err != nil {
47 t.Fatalf("unexpected error: %v", err)
48 }
49 if res == nil || res.Action != "consumed" {
50 t.Fatalf("result action=%v, want consumed", res)
51 }
52 if ws.Root == nil || !ws.Root.IsStacked {
53 t.Fatalf("root should be a stack")
54 }
55 if got := panesInOrder(ws.Root); got != "left,right" {
56 t.Fatalf("stack panes=%s, want left,right", got)
57 }
58 if ws.ActivePaneID != "right" {
59 t.Fatalf("active=%s, want right", ws.ActivePaneID)
60 }
61}
62
63func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) {
64 uc := NewManagePanesUseCase(func() string { return "id" })
65 ctx := context.Background()
66
67 left := leaf("left")
68 right := leaf("right")
69 root := split(entity.SplitHorizontal, left, right)
70 ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
71
72 // First press: horizontal -> vertical split (right on top, left on bottom).
73 res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelRight)
74 if err != nil {
75 t.Fatalf("unexpected error: %v", err)
76 }
77 if res == nil || res.Action != "consumed" {
78 t.Fatalf("result action=%v, want consumed", res)
79 }
80 if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
81 t.Fatalf("root should be a vertical split")
82 }
83 if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "right" {
84 t.Fatalf("top pane=%v, want right", ws.Root.Left())
85 }
86 if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "left" {
87 t.Fatalf("bottom pane=%v, want left", ws.Root.Right())
88 }
89 if ws.ActivePaneID != "left" {
90 t.Fatalf("active=%s, want left", ws.ActivePaneID)
91 }
92
93 // Second press: vertical split -> stack.
94 active := ws.ActivePane()
95 if active == nil {
96 t.Fatalf("expected active pane")
97 }
98 res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelRight)
99 if err != nil {
100 t.Fatalf("unexpected error: %v", err)
101 }
102 if res == nil || res.Action != "consumed" {
103 t.Fatalf("result action=%v, want consumed", res)
104 }
105 if ws.Root == nil || !ws.Root.IsStacked {
106 t.Fatalf("root should be a stack")
107 }
108 if got := panesInOrder(ws.Root); got != "right,left" {
109 t.Fatalf("stack panes=%s, want right,left", got)
110 }
111 if ws.ActivePaneID != "left" {
112 t.Fatalf("active=%s, want left", ws.ActivePaneID)
113 }
114}
115
116func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) {
117 uc := NewManagePanesUseCase(func() string { return "id" })
118 ctx := context.Background()
119
120 stackNode := stack(leaf("a"), leaf("b"))
121 c := leaf("c")
122 root := split(entity.SplitHorizontal, stackNode, c)
123 ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID}
124
125 res, err := uc.ConsumeOrExpel(ctx, ws, c, ConsumeOrExpelLeft)
126 if err != nil {
127 t.Fatalf("unexpected error: %v", err)
128 }
129 if res == nil || res.Action != "consumed" {
130 t.Fatalf("result action=%v, want consumed", res)
131 }
132 if ws.Root == nil || !ws.Root.IsStacked {
133 t.Fatalf("root should be a stack")
134 }
135 if got := panesInOrder(ws.Root); got != "a,b,c" {
136 t.Fatalf("stack panes=%s, want a,b,c", got)
137 }
138 if ws.ActivePaneID != "c" {
139 t.Fatalf("active=%s, want c", ws.ActivePaneID)
140 }
141}
142
143func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) {
144 uc := NewManagePanesUseCase(func() string { return "id" })
145 ctx := context.Background()
146
147 top := leaf("top")
148 bottomStack := stack(leaf("a"), leaf("b"))
149 root := split(entity.SplitVertical, top, bottomStack)
150 ws := &entity.Workspace{Root: root, ActivePaneID: top.Pane.ID}
151
152 res, err := uc.ConsumeOrExpel(ctx, ws, top, ConsumeOrExpelLeft)
153 if err != nil {
154 t.Fatalf("unexpected error: %v", err)
155 }
156 if res == nil || res.Action != "none" {
157 t.Fatalf("result action=%v, want none", res)
158 }
159 if res.ErrorMessage != "No pane to the left" {
160 t.Fatalf("error=%q, want %q", res.ErrorMessage, "No pane to the left")
161 }
162 if ws.Root != root {
163 t.Fatalf("tree should be unchanged")
164 }
165}
166
167func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {
168 uc := NewManagePanesUseCase(func() string { return "id" })
169 ctx := context.Background()
170
171 stackNode := stack(leaf("a"), leaf("b"))
172 b := stackNode.Children[1]
173 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
174
175 res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
176 if err != nil {
177 t.Fatalf("unexpected error: %v", err)
178 }
179 if res == nil || res.Action != "expelled" {
180 t.Fatalf("result action=%v, want expelled", res)
181 }
182 if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
183 t.Fatalf("root should be a vertical split")
184 }
185
186 top := ws.Root.Left()
187 bottom := ws.Root.Right()
188 if top == nil || bottom == nil {
189 t.Fatalf("split children should exist")
190 }
191 if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" {
192 t.Fatalf("top child should be leaf a")
193 }
194 if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" {
195 t.Fatalf("bottom child should be leaf b")
196 }
197 if ws.ActivePaneID != "b" {
198 t.Fatalf("active=%s, want b", ws.ActivePaneID)
199 }
200}
201
202func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) {
203 uc := NewManagePanesUseCase(func() string { return "id" })
204 ctx := context.Background()
205
206 stackNode := stack(leaf("a"), leaf("b"))
207 b := stackNode.Children[1]
208 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
209
210 // First press (on stacked pane): stack -> vertical split with active on bottom.
211 mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled")
212 mustSplitRoot(t, ws, entity.SplitVertical)
213 if string(ws.ActivePaneID) != "b" {
214 t.Fatalf("active=%s, want b", ws.ActivePaneID)
215 }
216
217 active := ws.ActivePane()
218 mustLeafPaneID(t, active, "b")
219
220 // Second press (on bottom pane): vertical -> horizontal split with active on right.
221 mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelRight, "consumed")
222 root := mustSplitRoot(t, ws, entity.SplitHorizontal)
223 mustLeafPaneID(t, root.Left(), "a")
224 mustLeafPaneID(t, root.Right(), "b")
225 if string(ws.ActivePaneID) != "b" {
226 t.Fatalf("active=%s, want b", ws.ActivePaneID)
227 }
228}
229
230func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) {
231 uc := NewManagePanesUseCase(func() string { return "id" })
232 ctx := context.Background()
233
234 stackNode := stack(leaf("a"), leaf("b"))
235 b := stackNode.Children[1]
236 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
237
238 // First press (on stacked pane): stack -> vertical split with active on bottom.
239 mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled")
240 mustSplitRoot(t, ws, entity.SplitVertical)
241 if string(ws.ActivePaneID) != "b" {
242 t.Fatalf("active=%s, want b", ws.ActivePaneID)
243 }
244
245 active := ws.ActivePane()
246 mustLeafPaneID(t, active, "b")
247
248 // Second press (on bottom pane): vertical -> stack (return to original).
249 mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelLeft, "consumed")
250 if ws.Root == nil || !ws.Root.IsStacked {
251 t.Fatalf("root should be a stack")
252 }
253 if got := panesInOrder(ws.Root); got != "a,b" {
254 t.Fatalf("stack panes=%s, want a,b", got)
255 }
256 if string(ws.ActivePaneID) != "b" {
257 t.Fatalf("active=%s, want b", ws.ActivePaneID)
258 }
259}
260
261func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
262 uc := NewManagePanesUseCase(func() string { return "id" })
263 ctx := context.Background()
264
265 stackNode := stack(leaf("a"), leaf("b"), leaf("c"))
266 b := stackNode.Children[1]
267 ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
268
269 res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
270 if err != nil {
271 t.Fatalf("unexpected error: %v", err)
272 }
273 if res == nil || res.Action != "expelled" {
274 t.Fatalf("result action=%v, want expelled", res)
275 }
276 if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
277 t.Fatalf("root should be a vertical split")
278 }
279 top := ws.Root.Left()
280 bottom := ws.Root.Right()
281 if top == nil || !top.IsStacked {
282 t.Fatalf("top child should be stack")
283 }
284 if panesInOrder(top) != "a,c" {
285 t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top))
286 }
287 if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" {
288 t.Fatalf("bottom child should be expelled leaf b")
289 }
290 if ws.ActivePaneID != "b" {
291 t.Fatalf("active=%s, want b", ws.ActivePaneID)
292 }
293}
294
295func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) {
296 uc := NewManagePanesUseCase(func() string { return "id" })
297 ctx := context.Background()
298
299 left := leaf("left")
300 right := leaf("right")
301 root := split(entity.SplitHorizontal, left, right)
302 ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
303
304 res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelLeft)
305 if err != nil {
306 t.Fatalf("unexpected error: %v", err)
307 }
308 if res == nil || res.Action != "none" {
309 t.Fatalf("result action=%v, want none", res)
310 }
311 if res.ErrorMessage == "" {
312 t.Fatalf("expected warning message")
313 }
314 if ws.Root != root {
315 t.Fatalf("tree should be unchanged")
316 }
317}
318
319func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) {
320 uc := NewManagePanesUseCase(func() string { return "id" })
321 ctx := context.Background()
322
323 a := leaf("a")
324 b := leaf("b")
325 top := split(entity.SplitHorizontal, a, b)
326 c := leaf("c")
327 root := split(entity.SplitVertical, top, c)
328 ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID}
329
330 res, err := uc.ConsumeOrExpel(ctx, ws, a, ConsumeOrExpelDown)
331 if err != nil {
332 t.Fatalf("unexpected error: %v", err)
333 }
334 if res == nil || res.Action != "consumed" {
335 t.Fatalf("result action=%v, want consumed", res)
336 }
337 if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
338 t.Fatalf("root should remain a vertical split")
339 }
340 if ws.Root.Right() == nil || !ws.Root.Right().IsStacked {
341 t.Fatalf("expected bottom pane to become a stack")
342 }
343 if panesInOrder(ws.Root.Right()) != "c,a" {
344 t.Fatalf("bottom stack panes=%s, want c,a", panesInOrder(ws.Root.Right()))
345 }
346}
347
348func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
349 uc := NewManagePanesUseCase(func() string { return "id" })
350 ctx := context.Background()
351
352 only := leaf("only")
353 ws := &entity.Workspace{Root: only, ActivePaneID: only.Pane.ID}
354
355 res, err := uc.ConsumeOrExpel(ctx, ws, only, ConsumeOrExpelLeft)
356 if err != nil {
357 t.Fatalf("unexpected error: %v", err)
358 }
359 if res == nil || res.Action != "none" {
360 t.Fatalf("result action=%v, want none", res)
361 }
362 if res.ErrorMessage == "" {
363 t.Fatalf("expected warning message")
364 }
365}
366
367func mustConsumeOrExpelAction(
368 t *testing.T,
369 uc *ManagePanesUseCase,
370 ctx context.Context,
371 ws *entity.Workspace,
372 node *entity.PaneNode,
373 direction ConsumeOrExpelDirection,
374 wantAction string,
375) {
376 t.Helper()
377 res, err := uc.ConsumeOrExpel(ctx, ws, node, direction)
378 if err != nil {
379 t.Fatalf("unexpected error: %v", err)
380 }
381 if res == nil || res.Action != wantAction {
382 t.Fatalf("result action=%v, want %s", res, wantAction)
383 }
384}
385
386func mustSplitRoot(t *testing.T, ws *entity.Workspace, dir entity.SplitDirection) *entity.PaneNode {
387 t.Helper()
388 if ws == nil || ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != dir {
389 t.Fatalf("root should be a %v split", dir)
390 }
391 return ws.Root
392}
393
394func mustLeafPaneID(t *testing.T, node *entity.PaneNode, want string) {
395 t.Helper()
396 if node == nil || !node.IsLeaf() || node.Pane == nil || string(node.Pane.ID) != want {
397 t.Fatalf("leaf pane=%v, want %s", node, want)
398 }
399}
400
401func leaf(id string) *entity.PaneNode {
402 return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}}
403}
404
405func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.PaneNode {
406 root := &entity.PaneNode{ID: "split", SplitDir: dir, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}}
407 left.Parent = root
408 right.Parent = root
409 return root
410}
411
412func stack(panes ...*entity.PaneNode) *entity.PaneNode {
413 n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
414 children := make([]*entity.PaneNode, 0, len(panes))
415 for _, p := range panes {
416 p.Parent = n
417 children = append(children, p)
418 }
419 n.Children = children
420 return n
421}
422
423func panesInOrder(stackNode *entity.PaneNode) string {
424 if stackNode == nil || len(stackNode.Children) == 0 {
425 return ""
426 }
427 out := ""
428 for i, child := range stackNode.Children {
429 if child == nil || child.Pane == nil {
430 continue
431 }
432 if i > 0 {
433 out += ","
434 }
435 out += string(child.Pane.ID)
436 }
437 return out
438}