v0.3.0 99dc266

layout_test.go RawHistoryFolder

1package layout
2
3import (
4	"errors"
5	"testing"
6
7	"github.com/bnema/vev/internal/domain"
8	"github.com/stretchr/testify/require"
9)
10
11func TestSolveSplitGeometry(t *testing.T) {
12	t.Parallel()
13
14	tests := []struct {
15		name string
16		root *Node
17		area domain.Rect
18		want map[PaneID]domain.Rect
19	}{
20		{
21			name: "horizontal remainder to first children",
22			root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}},
23			area: domain.Rect{Width: 64, Height: 5},
24			want: map[PaneID]domain.Rect{"a": {Width: 21, Height: 5}, "b": {X: 22, Width: 21, Height: 5}, "c": {X: 44, Width: 20, Height: 5}},
25		},
26		{
27			name: "vertical remainder to first children",
28			root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("a"), NewLeaf("b")}},
29			area: domain.Rect{Width: 20, Height: 7},
30			want: map[PaneID]domain.Rect{"a": {Width: 20, Height: 3}, "b": {Y: 4, Width: 20, Height: 3}},
31		},
32	}
33
34	for _, tt := range tests {
35		t.Run(tt.name, func(t *testing.T) {
36			got, ok := Solve(tt.root, tt.area)
37			require.True(t, ok)
38			require.Len(t, got, len(tt.want))
39			for _, p := range got {
40				require.Equal(t, tt.want[p.ID], p.Content)
41			}
42		})
43	}
44}
45
46func TestSolveNestedTrees(t *testing.T) {
47	t.Parallel()
48	root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
49		NewLeaf("left"),
50		{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}},
51	}}
52	got, ok := Solve(root, domain.Rect{Width: 61, Height: 5})
53	require.True(t, ok)
54	require.Equal(t, map[PaneID]domain.Rect{
55		"left":   {Width: 30, Height: 5},
56		"top":    {X: 31, Width: 30, Height: 2},
57		"bottom": {X: 31, Y: 3, Width: 30, Height: 2},
58	}, contents(got))
59}
60
61func TestSolveStackFitAndOverflow(t *testing.T) {
62	t.Parallel()
63	stack := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}
64	got, ok := Solve(stack, domain.Rect{Width: 20, Height: 4})
65	require.True(t, ok)
66	require.Equal(t, []Placement{
67		{ID: "a", TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true},
68		{ID: "b", Content: domain.Rect{Y: 1, Width: 20, Height: 2}, InStack: true},
69		{ID: "c", TitleBar: domain.Rect{Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true},
70	}, got)
71
72	_, ok = Solve(stack, domain.Rect{Width: 20, Height: 3})
73	require.False(t, ok)
74}
75
76func TestSolveStackExpandedMemberHasNoTitleBar(t *testing.T) {
77	t.Parallel()
78	area := domain.Rect{X: 5, Y: 2, Width: 20, Height: 5}
79	tests := []struct {
80		name     string
81		expanded PaneID
82		want     []Placement
83	}{
84		{
85			name:     "edge member expanded",
86			expanded: "a",
87			want: []Placement{
88				{ID: "a", Content: domain.Rect{X: 5, Y: 2, Width: 20, Height: 3}, InStack: true},
89				{ID: "b", TitleBar: domain.Rect{X: 5, Y: 5, Width: 20, Height: 1}, Collapsed: true, InStack: true},
90				{ID: "c", TitleBar: domain.Rect{X: 5, Y: 6, Width: 20, Height: 1}, Collapsed: true, InStack: true},
91			},
92		},
93		{
94			name:     "middle member expanded",
95			expanded: "b",
96			want: []Placement{
97				{ID: "a", TitleBar: domain.Rect{X: 5, Y: 2, Width: 20, Height: 1}, Collapsed: true, InStack: true},
98				{ID: "b", Content: domain.Rect{X: 5, Y: 3, Width: 20, Height: 3}, InStack: true},
99				{ID: "c", TitleBar: domain.Rect{X: 5, Y: 6, Width: 20, Height: 1}, Collapsed: true, InStack: true},
100			},
101		},
102		{
103			name:     "other edge member expanded",
104			expanded: "c",
105			want: []Placement{
106				{ID: "a", TitleBar: domain.Rect{X: 5, Y: 2, Width: 20, Height: 1}, Collapsed: true, InStack: true},
107				{ID: "b", TitleBar: domain.Rect{X: 5, Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true},
108				{ID: "c", Content: domain.Rect{X: 5, Y: 4, Width: 20, Height: 3}, InStack: true},
109			},
110		},
111	}
112	for _, tt := range tests {
113		t.Run(tt.name, func(t *testing.T) {
114			stack := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: tt.expanded}
115			got, ok := Solve(stack, area)
116			require.True(t, ok)
117			require.Equal(t, tt.want, got)
118
119			// Invariant: total consumed rows equal area.Height, and rows are
120			// contiguous and non-overlapping across title bars and content.
121			rows := make(map[int]PaneID, area.Height)
122			for _, p := range got {
123				if p.TitleBar.Height > 0 {
124					require.Zero(t, p.Content.Height, "expanded member must not have both a title bar and content")
125					for y := p.TitleBar.Y; y < p.TitleBar.Y+p.TitleBar.Height; y++ {
126						_, dup := rows[y]
127						require.False(t, dup, "row %d already occupied", y)
128						rows[y] = p.ID
129					}
130				} else {
131					require.Equal(t, tt.expanded, p.ID, "only the expanded member may omit its title bar")
132					for y := p.Content.Y; y < p.Content.Y+p.Content.Height; y++ {
133						_, dup := rows[y]
134						require.False(t, dup, "row %d already occupied", y)
135						rows[y] = p.ID
136					}
137				}
138			}
139			require.Len(t, rows, area.Height)
140			for y := area.Y; y < area.Y+area.Height; y++ {
141				_, ok := rows[y]
142				require.True(t, ok, "row %d not covered", y)
143			}
144		})
145	}
146}
147
148func TestSplitStackedLeafSplitsWholeStack(t *testing.T) {
149	t.Parallel()
150	tr := &Tree{
151		Root:  &Node{Kind: Stack, Children: []*Node{NewLeaf("one"), NewLeaf("two")}, Expanded: "two"},
152		Focus: "two",
153	}
154	area := domain.Rect{Width: 41, Height: 4}
155
156	require.NoError(t, tr.Split("two", Right, true, "three", area))
157	placements, ok := Solve(tr.Root, area)
158	require.True(t, ok)
159	require.Equal(t, PaneID("three"), tr.Focus)
160	require.Equal(t, domain.Rect{Y: 1, Width: 20, Height: 3}, contents(placements)["two"], "existing stack should remain visible on the left")
161	require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, contents(placements)["three"], "new split pane should appear to the right of the stack")
162}
163
164func TestStackNewEmptyTreeReturnsError(t *testing.T) {
165	t.Parallel()
166	tr := &Tree{}
167	require.ErrorIs(t, tr.StackNew("missing", "new", domain.Rect{Width: 20, Height: 2}), ErrNotFound)
168	require.Nil(t, tr.Root)
169	require.Empty(t, tr.Focus)
170}
171
172func TestSplitEmptyTreeReturnsError(t *testing.T) {
173	t.Parallel()
174	tr := &Tree{}
175	require.ErrorIs(t, tr.Split("missing", Right, true, "new", domain.Rect{Width: 41, Height: 2}), ErrNotFound)
176	require.Nil(t, tr.Root)
177	require.Empty(t, tr.Focus)
178}
179
180func TestNilTreeCloneIsSafe(t *testing.T) {
181	t.Parallel()
182	var tr *Tree
183	require.Nil(t, tr.clone())
184	require.Nil(t, tr.Clone())
185}
186
187func TestTreeCloneReturnsIndependentCopy(t *testing.T) {
188	t.Parallel()
189	tr := &Tree{
190		Root: &Node{
191			Kind: Split,
192			Dir:  Horizontal,
193			Children: []*Node{
194				NewLeaf("a"),
195				NewLeaf("b"),
196			},
197		},
198		Focus: "b",
199	}
200
201	clone := tr.Clone()
202	require.Equal(t, tr, clone)
203
204	clone.Root.Children[0].Leaf = "changed"
205	clone.Focus = "changed"
206	require.Equal(t, PaneID("a"), tr.Root.Children[0].Leaf)
207	require.Equal(t, PaneID("b"), tr.Focus)
208}
209
210func TestTooSmallRefusalsDoNotMutate(t *testing.T) {
211	t.Parallel()
212	tests := []struct {
213		name string
214		op   func(*Tree) error
215	}{
216		{
217			name: "split refuses below min width",
218			op: func(tr *Tree) error {
219				return tr.Split("a", Right, true, "b", domain.Rect{Width: 40, Height: 2})
220			},
221		},
222		{
223			name: "stack refuses overflow",
224			op: func(tr *Tree) error {
225				require.NoError(t, tr.StackNew("a", "b", domain.Rect{Width: 20, Height: 3}))
226				return tr.StackNew("b", "c", domain.Rect{Width: 20, Height: 3})
227			},
228		},
229	}
230
231	for _, tt := range tests {
232		t.Run(tt.name, func(t *testing.T) {
233			tr := NewTree("a")
234			before := tr.clone()
235			err := tt.op(tr)
236			require.ErrorIs(t, err, ErrTooSmall)
237			if tt.name == "stack refuses overflow" {
238				require.Equal(t, PaneID("b"), tr.Focus)
239				require.Equal(t, []PaneID{"a", "b"}, leafIDs(tr.Root))
240				return
241			}
242			require.Equal(t, before, tr)
243		})
244	}
245}
246
247func TestTreeSurgeryShapes(t *testing.T) {
248	t.Parallel()
249
250	t.Run("split preserves and dissolves containers", func(t *testing.T) {
251		tr := NewTree("a")
252		require.NoError(t, tr.Split("a", Right, true, "b", domain.Rect{Width: 41, Height: 2}))
253		require.Equal(t, Split, tr.Root.Kind)
254		require.Equal(t, Horizontal, tr.Root.Dir)
255		require.Equal(t, []PaneID{"a", "b"}, leafIDs(tr.Root))
256		require.NoError(t, tr.Close("a"))
257		require.Equal(t, Leaf, tr.Root.Kind)
258		require.Equal(t, PaneID("b"), tr.Root.Leaf)
259	})
260
261	t.Run("nested split inserts on matching axis", func(t *testing.T) {
262		tr := NewTree("a")
263		require.NoError(t, tr.Split("a", Right, true, "b", domain.Rect{Width: 41, Height: 4}))
264		require.NoError(t, tr.Split("b", Right, true, "c", domain.Rect{Width: 62, Height: 4}))
265		require.Len(t, tr.Root.Children, 3)
266		require.Equal(t, []PaneID{"a", "b", "c"}, leafIDs(tr.Root))
267		require.NoError(t, tr.Split("b", Down, true, "d", domain.Rect{Width: 62, Height: 5}))
268		require.Equal(t, Vertical, tr.Root.Children[1].Dir)
269		require.Equal(t, []PaneID{"a", "b", "d", "c"}, leafIDs(tr.Root))
270	})
271}
272
273func TestToggleStackNestedShapes(t *testing.T) {
274	t.Parallel()
275	area := domain.Rect{Width: 62, Height: 5}
276	tests := []struct {
277		name       string
278		root       *Node
279		target     PaneID
280		wantKind   Kind
281		wantDir    SplitDir
282		wantErr    error
283		wantLeaves []PaneID
284	}{
285		{
286			name: "nested stack toggles directly containing stack",
287			root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
288				NewLeaf("a"),
289				{Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
290			}},
291			target:     "b",
292			wantKind:   Split,
293			wantDir:    Vertical,
294			wantLeaves: []PaneID{"b", "c"},
295		},
296		{
297			name: "nested split toggles directly containing two-leaf split",
298			root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
299				NewLeaf("a"),
300				{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
301			}},
302			target:     "b",
303			wantKind:   Stack,
304			wantLeaves: []PaneID{"b", "c"},
305		},
306		{
307			name: "nested non toggleable split reports not toggleable",
308			root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
309				NewLeaf("a"),
310				{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d")}},
311			}},
312			target:  "b",
313			wantErr: ErrNotToggleable,
314		},
315		{
316			name: "three leaf split reports not toggleable not too small",
317			root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
318				NewLeaf("a"), NewLeaf("b"), NewLeaf("c"),
319			}},
320			target:  "b",
321			wantErr: ErrNotToggleable,
322		},
323	}
324	for _, tt := range tests {
325		t.Run(tt.name, func(t *testing.T) {
326			tr := &Tree{Root: tt.root, Focus: tt.target}
327			err := tr.ToggleStack(tt.target, area)
328			if tt.wantErr != nil {
329				require.ErrorIs(t, err, tt.wantErr)
330				require.NotErrorIs(t, err, ErrTooSmall)
331				return
332			}
333			require.NoError(t, err)
334			container := tr.Root.Children[1]
335			require.Equal(t, tt.wantKind, container.Kind)
336			if tt.wantKind == Split {
337				require.Equal(t, tt.wantDir, container.Dir)
338			}
339			require.Equal(t, tt.wantLeaves, LeafIDs(container))
340		})
341	}
342}
343
344func TestCloseRefocusPrefersSibling(t *testing.T) {
345	t.Parallel()
346	tr := &Tree{Focus: "c", Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
347		NewLeaf("a"),
348		{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
349		NewLeaf("d"),
350	}}}
351	require.NoError(t, tr.Close("c"))
352	require.Equal(t, PaneID("b"), tr.Focus)
353}
354
355func TestCloseNotFoundDoesNotMutate(t *testing.T) {
356	t.Parallel()
357	tr := &Tree{Focus: "b", Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
358		NewLeaf("a"),
359		{Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
360	}}}
361	before := tr.clone()
362	require.ErrorIs(t, tr.Close("missing"), ErrNotFound)
363	require.Equal(t, before, tr)
364}
365
366func TestSolveRejectsStackWithMissingExpanded(t *testing.T) {
367	t.Parallel()
368	_, ok := Solve(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "missing"}, domain.Rect{Width: 20, Height: 4})
369	require.False(t, ok)
370}
371
372func TestExportedLeafHelpers(t *testing.T) {
373	t.Parallel()
374	root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("a"), NewLeaf("b")}}
375	require.True(t, ContainsLeaf(root, "b"))
376	require.False(t, ContainsLeaf(root, "c"))
377	require.Equal(t, []PaneID{"a", "b"}, LeafIDs(root))
378}
379
380func TestFocusDir(t *testing.T) {
381	t.Parallel()
382	area := domain.Rect{Width: 62, Height: 5}
383	tests := []struct {
384		name  string
385		focus PaneID
386		dir   Direction
387		want  PaneID
388	}{
389		{"right chooses overlapping neighbor", "a", Right, "b"},
390		{"left chooses overlapping neighbor", "b", Left, "a"},
391		{"down chooses vertical neighbor", "b", Down, "c"},
392		{"up chooses vertical neighbor", "c", Up, "b"},
393	}
394	for _, tt := range tests {
395		t.Run(tt.name, func(t *testing.T) {
396			tr := &Tree{Focus: tt.focus, Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
397				NewLeaf("a"),
398				{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
399			}}}
400			require.NoError(t, tr.FocusDir(tt.dir, area))
401			require.Equal(t, tt.want, tr.Focus)
402		})
403	}
404
405	t.Run("stack local vertical focus expands member", func(t *testing.T) {
406		tr := &Tree{Focus: "b", Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}}
407		require.NoError(t, tr.FocusDir(Down, domain.Rect{Width: 20, Height: 4}))
408		require.Equal(t, PaneID("c"), tr.Focus)
409		require.Equal(t, PaneID("c"), tr.Root.Expanded)
410		require.NoError(t, tr.FocusDir(Up, domain.Rect{Width: 20, Height: 4}))
411		require.Equal(t, PaneID("b"), tr.Focus)
412		require.Equal(t, PaneID("b"), tr.Root.Expanded)
413	})
414
415	t.Run("no pane in direction", func(t *testing.T) {
416		tr := NewTree("a")
417		err := tr.FocusDir(Left, domain.Rect{Width: 20, Height: 2})
418		require.True(t, errors.Is(err, ErrNoPane))
419	})
420}
421
422func TestFocusDirEnteringStackExpandsMember(t *testing.T) {
423	t.Parallel()
424	tests := []struct {
425		name  string
426		root  *Node
427		area  domain.Rect
428		focus PaneID
429		dir   Direction
430		want  PaneID
431	}{
432		{
433			name:  "from left",
434			area:  domain.Rect{Width: 62, Height: 8},
435			focus: "a",
436			dir:   Right,
437			root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
438				{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("x"), NewLeaf("a")}},
439				{Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d"), NewLeaf("e")}, Expanded: "b"},
440			}},
441			want: "c",
442		},
443		{
444			name:  "from right",
445			area:  domain.Rect{Width: 62, Height: 8},
446			focus: "a",
447			dir:   Left,
448			root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
449				{Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d"), NewLeaf("e")}, Expanded: "b"},
450				{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("x"), NewLeaf("a")}},
451			}},
452			want: "c",
453		},
454		{
455			name:  "from above",
456			area:  domain.Rect{Width: 41, Height: 7},
457			focus: "a",
458			dir:   Down,
459			root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
460				NewLeaf("a"),
461				{Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "c"},
462			}},
463			want: "b",
464		},
465		{
466			name:  "from below",
467			area:  domain.Rect{Width: 41, Height: 7},
468			focus: "a",
469			dir:   Up,
470			root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
471				{Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
472				NewLeaf("a"),
473			}},
474			want: "c",
475		},
476	}
477	for _, tt := range tests {
478		t.Run(tt.name, func(t *testing.T) {
479			tr := &Tree{Root: tt.root, Focus: tt.focus}
480			var stack *Node
481			findStack(tr.Root, &stack)
482			require.NotEqual(t, tt.want, stack.Expanded)
483			require.NoError(t, tr.FocusDir(tt.dir, tt.area))
484			require.Equal(t, tt.want, tr.Focus)
485			require.Equal(t, tt.want, stack.Expanded)
486		})
487	}
488}
489
490func TestFocusDirStackLocalSkipsNonLeafChildren(t *testing.T) {
491	t.Parallel()
492	tr := &Tree{
493		Root: &Node{Kind: Stack, Children: []*Node{
494			NewLeaf("a"),
495			{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("nested")}},
496			NewLeaf("b"),
497		}, Expanded: "a"},
498		Focus: "a",
499	}
500
501	require.NoError(t, tr.FocusDir(Down, domain.Rect{Width: 80, Height: 6}))
502	require.Equal(t, PaneID("b"), tr.Focus)
503	require.Equal(t, PaneID("b"), tr.Root.Expanded)
504}
505
506func contents(ps []Placement) map[PaneID]domain.Rect {
507	out := make(map[PaneID]domain.Rect, len(ps))
508	for _, p := range ps {
509		out[p.ID] = p.Content
510	}
511	return out
512}
513
514func findStack(n *Node, out **Node) {
515	if n == nil || *out != nil {
516		return
517	}
518	if n.Kind == Stack {
519		*out = n
520		return
521	}
522	for _, child := range n.Children {
523		findStack(child, out)
524	}
525}

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 12

Tags 11

↑↓ moveenter openesc close