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