v0.23.2 bfd5eb4

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close