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}