consume_expel_test.go RawHistoryFolder

1package layout
2
3import (
4	"testing"
5
6	"github.com/bnema/vev/internal/domain"
7	"github.com/stretchr/testify/require"
8)
9
10func TestConsumeOrExpelPaneTransitions(t *testing.T) {
11	t.Parallel()
12
13	area := domain.Rect{Width: 101, Height: 12}
14	tests := []struct {
15		name       string
16		tree       *Tree
17		target     PaneID
18		dir        Direction
19		want       *Tree
20		wantChange bool
21	}{
22		{
23			name:       "consume singleton left into leaf",
24			tree:       &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
25			target:     "b",
26			dir:        Left,
27			want:       &Tree{Root: vertical("a", "b"), Focus: "b"},
28			wantChange: true,
29		},
30		{
31			name:       "consume singleton right into leaf",
32			tree:       &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
33			target:     "a",
34			dir:        Right,
35			want:       &Tree{Root: vertical("b", "a"), Focus: "a"},
36			wantChange: true,
37		},
38		{
39			name:   "consume singleton left into vertical column",
40			tree:   &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"},
41			target: "c",
42			dir:    Left,
43			want: &Tree{Root: verticalNodes(
44				consumeExpelWeightedLeaf("a", 6),
45				consumeExpelWeightedLeaf("b", 5),
46				consumeExpelWeightedLeaf("c", 5.5),
47			), Focus: "c"},
48			wantChange: true,
49		},
50		{
51			name:   "consume singleton right into vertical column",
52			tree:   &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"},
53			target: "a",
54			dir:    Right,
55			want: &Tree{Root: verticalNodes(
56				consumeExpelWeightedLeaf("b", 6),
57				consumeExpelWeightedLeaf("c", 5),
58				consumeExpelWeightedLeaf("a", 5.5),
59			), Focus: "a"},
60			wantChange: true,
61		},
62		{
63			name:       "consume singleton left into stack appends and expands",
64			tree:       &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"},
65			target:     "c",
66			dir:        Left,
67			want:       &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"},
68			wantChange: true,
69		},
70		{
71			name:       "consume singleton right into stack appends and expands",
72			tree:       &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"},
73			target:     "a",
74			dir:        Right,
75			want:       &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"},
76			wantChange: true,
77		},
78		{
79			name:       "expel middle vertical member left from sole root column",
80			tree:       &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
81			target:     "b",
82			dir:        Left,
83			want:       &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"},
84			wantChange: true,
85		},
86		{
87			name:       "expel middle vertical member right from sole root column",
88			tree:       &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
89			target:     "b",
90			dir:        Right,
91			want:       &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
92			wantChange: true,
93		},
94		{
95			name:   "expel vertical member left beside its source column",
96			tree:   &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
97			target: "b",
98			dir:    Left,
99			want: &Tree{Root: horizontal(
100				consumeExpelWeightedLeaf("b", 50),
101				consumeExpelWeightedNode(vertical("a", "c"), 50),
102				consumeExpelWeightedLeaf("d", 50),
103			), Focus: "b"},
104			wantChange: true,
105		},
106		{
107			name:   "expel vertical member right beside its source column",
108			tree:   &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
109			target: "b",
110			dir:    Right,
111			want: &Tree{Root: horizontal(
112				consumeExpelWeightedNode(vertical("a", "c"), 50),
113				consumeExpelWeightedLeaf("b", 50),
114				consumeExpelWeightedLeaf("d", 50),
115			), Focus: "b"},
116			wantChange: true,
117		},
118		{
119			name:       "expel stack member left from sole root column",
120			tree:       &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
121			target:     "b",
122			dir:        Left,
123			want:       &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"},
124			wantChange: true,
125		},
126		{
127			name:       "expel stack member right from sole root column",
128			tree:       &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
129			target:     "b",
130			dir:        Right,
131			want:       &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
132			wantChange: true,
133		},
134		{
135			name:       "expelling expanded stack member repairs source expansion",
136			tree:       &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"},
137			target:     "b",
138			dir:        Right,
139			want:       &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
140			wantChange: true,
141		},
142		{
143			name:       "expel collapses two-member vertical column",
144			tree:       &Tree{Root: vertical("a", "b"), Focus: "a"},
145			target:     "a",
146			dir:        Left,
147			want:       &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
148			wantChange: true,
149		},
150		{
151			name:       "expel collapses two-member stack column",
152			tree:       &Tree{Root: stack("b", "a", "b"), Focus: "b"},
153			target:     "b",
154			dir:        Right,
155			want:       &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
156			wantChange: true,
157		},
158		{
159			name:       "real move focuses explicitly targeted non-focused pane",
160			tree:       &Tree{Root: vertical("a", "b", "c"), Focus: "a"},
161			target:     "b",
162			dir:        Right,
163			want:       &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
164			wantChange: true,
165		},
166		{
167			name:       "left edge singleton is exact no-op and preserves different focus",
168			tree:       &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
169			target:     "a",
170			dir:        Left,
171			want:       &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
172			wantChange: false,
173		},
174		{
175			name:       "right edge singleton is exact no-op and preserves different focus",
176			tree:       &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
177			target:     "b",
178			dir:        Right,
179			want:       &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
180			wantChange: false,
181		},
182		{
183			name:       "sole singleton root is exact no-op",
184			tree:       NewTree("a"),
185			target:     "a",
186			dir:        Left,
187			want:       NewTree("a"),
188			wantChange: false,
189		},
190	}
191
192	for _, tt := range tests {
193		t.Run(tt.name, func(t *testing.T) {
194			before := tt.tree.Clone()
195			beforeIDs := LeafIDs(tt.tree.Root)
196			requireUniquePaneIDs(t, beforeIDs)
197
198			changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area)
199			require.NoError(t, err)
200			require.Equal(t, tt.wantChange, changed)
201			require.Equal(t, tt.want, tt.tree)
202			if !tt.wantChange {
203				require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree")
204			}
205
206			afterIDs := LeafIDs(tt.tree.Root)
207			require.ElementsMatch(t, beforeIDs, afterIDs)
208			require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs")
209			requireUniquePaneIDs(t, afterIDs)
210			_, solved := Solve(tt.tree.Root, area)
211			require.True(t, solved, "every successful result must solve")
212		})
213	}
214}
215
216func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) {
217	t.Parallel()
218
219	area := domain.Rect{Width: 101, Height: 12}
220	tests := []struct {
221		name   string
222		tree   *Tree
223		target PaneID
224		dir    Direction
225		area   domain.Rect
226		want   error
227	}{
228		{name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
229		{name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
230		{name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound},
231		{name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
232		{name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout},
233		{name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
234		{name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
235		{name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
236		{name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
237		{name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
238		{name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
239		{name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
240		{name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
241		{name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
242		{name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
243		{name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
244		{name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
245		{name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection},
246		{name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection},
247		{name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection},
248		{name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall},
249	}
250
251	for _, tt := range tests {
252		t.Run(tt.name, func(t *testing.T) {
253			before := tt.tree.Clone()
254			changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
255			require.False(t, changed)
256			require.ErrorIs(t, err, tt.want)
257			require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree")
258		})
259	}
260}
261
262func horizontal(columns ...*Node) *Node {
263	return &Node{Kind: Split, Dir: Horizontal, Children: columns}
264}
265
266func vertical(ids ...PaneID) *Node {
267	children := make([]*Node, 0, len(ids))
268	for _, id := range ids {
269		children = append(children, NewLeaf(id))
270	}
271	return verticalNodes(children...)
272}
273
274func verticalNodes(children ...*Node) *Node {
275	return &Node{Kind: Split, Dir: Vertical, Children: children}
276}
277
278func stack(expanded PaneID, ids ...PaneID) *Node {
279	children := make([]*Node, 0, len(ids))
280	for _, id := range ids {
281		children = append(children, NewLeaf(id))
282	}
283	return &Node{Kind: Stack, Children: children, Expanded: expanded}
284}
285
286func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node {
287	return &Node{Kind: Leaf, Leaf: id, Weight: weight}
288}
289
290func consumeExpelWeightedNode(node *Node, weight float64) *Node {
291	node.Weight = weight
292	return node
293}
294
295func requireUniquePaneIDs(t *testing.T, ids []PaneID) {
296	t.Helper()
297	seen := make(map[PaneID]struct{}, len(ids))
298	for _, id := range ids {
299		require.NotEmpty(t, id)
300		_, duplicate := seen[id]
301		require.False(t, duplicate, "pane ID %q occurs more than once", id)
302		seen[id] = struct{}{}
303	}
304}

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