main 5041353

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}

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