1package layout
2
3import (
4 "testing"
5
6 "github.com/bnema/vev/internal/domain"
7 "github.com/stretchr/testify/require"
8)
9
10func TestWeightedSplitAllocation(t *testing.T) {
11 t.Parallel()
12
13 tests := []struct {
14 name string
15 root *Node
16 area domain.Rect
17 want map[PaneID]int
18 }{
19 {
20 name: "zero weights preserve equal split and remainder order",
21 root: split(Horizontal, NewLeaf("a"), NewLeaf("b"), NewLeaf("c")),
22 area: domain.Rect{Width: 64, Height: 2},
23 want: map[PaneID]int{"a": 21, "b": 21, "c": 20},
24 },
25 {
26 name: "two to one weights",
27 root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)),
28 area: domain.Rect{Width: 61, Height: 2},
29 want: map[PaneID]int{"a": 40, "b": 20},
30 },
31 {
32 name: "largest remainder ties use child order",
33 root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 2)),
34 area: domain.Rect{Width: 84, Height: 2},
35 want: map[PaneID]int{"a": 21, "b": 20, "c": 41},
36 },
37 {
38 name: "minimum constrained children pin and remainder redistributes proportionally",
39 root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 8)),
40 area: domain.Rect{Width: 102, Height: 2},
41 want: map[PaneID]int{"a": 20, "b": 20, "c": 60},
42 },
43 {
44 name: "negative and zero in memory weights use default",
45 root: split(Horizontal, weightedLeaf("a", -4), weightedLeaf("b", 0), weightedLeaf("c", 2)),
46 area: domain.Rect{Width: 82, Height: 2},
47 want: map[PaneID]int{"a": 20, "b": 20, "c": 40},
48 },
49 }
50
51 for _, tt := range tests {
52 t.Run(tt.name, func(t *testing.T) {
53 placements, ok := Solve(tt.root, tt.area)
54 require.True(t, ok)
55 for id, want := range tt.want {
56 require.Equal(t, want, contents(placements)[id].Width)
57 }
58 })
59 }
60}
61
62func TestDistributePinsChildrenFromOneQuotaSnapshot(t *testing.T) {
63 t.Parallel()
64
65 allocations, ok := distribute(100, []int{20, 33, 42}, []float64{1, 4, 5})
66 require.True(t, ok)
67 require.Equal(t, []int{20, 36, 44}, allocations)
68 require.Equal(t, 100, allocations[0]+allocations[1]+allocations[2])
69}
70
71func TestWeightedSplitNestedMinimumRedistributesWithFreshDenominator(t *testing.T) {
72 t.Parallel()
73
74 root := split(Horizontal,
75 weightedNode(split(Horizontal, NewLeaf("a"), NewLeaf("b")), 2),
76 weightedLeaf("c", 2),
77 weightedLeaf("d", 6),
78 )
79
80 rects, ok := splitChildRects(root, domain.Rect{Width: 132, Height: 2})
81 require.True(t, ok)
82 require.Equal(t, []int{41, 22, 67}, []int{rects[0].Width, rects[1].Width, rects[2].Width})
83}
84
85func TestWeightedSplitRecursiveMinima(t *testing.T) {
86 t.Parallel()
87
88 tests := []struct {
89 name string
90 root *Node
91 area domain.Rect
92 wantOK bool
93 want map[PaneID]domain.Rect
94 }{
95 {
96 name: "same axis nested minima sum children and separators",
97 root: split(Horizontal,
98 weightedNode(split(Horizontal, NewLeaf("a"), NewLeaf("b")), 1),
99 weightedLeaf("c", 9),
100 ),
101 area: domain.Rect{Width: 102, Height: 2},
102 wantOK: true,
103 want: map[PaneID]domain.Rect{
104 "a": {Width: 20, Height: 2},
105 "b": {X: 21, Width: 20, Height: 2},
106 "c": {X: 42, Width: 60, Height: 2},
107 },
108 },
109 {
110 name: "cross axis nested minima take maximum",
111 root: split(Horizontal,
112 split(Vertical, NewLeaf("a"), NewLeaf("b")),
113 NewLeaf("c"),
114 ),
115 area: domain.Rect{Width: 41, Height: 5},
116 wantOK: true,
117 },
118 {
119 name: "stack vertical minimum is titles plus one content row",
120 root: split(Vertical,
121 weightedNode(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}, 1),
122 weightedLeaf("d", 9),
123 ),
124 area: domain.Rect{Width: 20, Height: 7},
125 wantOK: true,
126 },
127 {
128 name: "insufficient same axis recursive minimum fails without placements",
129 root: split(Horizontal,
130 split(Horizontal, NewLeaf("a"), NewLeaf("b")),
131 NewLeaf("c"),
132 ),
133 area: domain.Rect{Width: 61, Height: 2},
134 wantOK: false,
135 },
136 {
137 name: "insufficient cross axis recursive minimum fails without placements",
138 root: split(Horizontal,
139 split(Vertical, NewLeaf("a"), NewLeaf("b")),
140 NewLeaf("c"),
141 ),
142 area: domain.Rect{Width: 41, Height: 4},
143 wantOK: false,
144 },
145 }
146
147 for _, tt := range tests {
148 t.Run(tt.name, func(t *testing.T) {
149 placements, ok := Solve(tt.root, tt.area)
150 require.Equal(t, tt.wantOK, ok)
151 if !tt.wantOK {
152 require.Nil(t, placements)
153 return
154 }
155 if tt.want != nil {
156 require.Equal(t, tt.want, contents(placements))
157 }
158 })
159 }
160}
161
162func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T) {
163 t.Parallel()
164
165 tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
166 area := domain.Rect{Width: 82, Height: 2}
167 require.NoError(t, tr.Split("b", Right, true, "c", area))
168
169 require.Equal(t, 54.0, tr.Root.Children[0].Weight)
170 require.Equal(t, 27.0, tr.Root.Children[1].Weight)
171 require.Zero(t, tr.Root.Children[2].Weight)
172 placements, ok := Solve(tr.Root, area)
173 require.True(t, ok)
174 require.Equal(t, map[PaneID]int{"a": 40, "b": 20, "c": 20}, placementWidths(placements))
175}
176
177func TestWeightCrossAxisSplitTransfersParentShareAndClearsMembers(t *testing.T) {
178 t.Parallel()
179
180 tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
181 require.NoError(t, tr.Split("a", Down, true, "c", domain.Rect{Width: 81, Height: 5}))
182
183 wrapper := tr.Root.Children[0]
184 require.Equal(t, Vertical, wrapper.Dir)
185 require.Equal(t, 3.0, wrapper.Weight)
186 require.Zero(t, wrapper.Children[0].Weight)
187 require.Zero(t, wrapper.Children[1].Weight)
188 placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 5})
189 require.True(t, ok)
190 require.Equal(t, 60, contents(placements)["a"].Width)
191 require.Equal(t, 20, contents(placements)["b"].Width)
192}
193
194func TestWeightStackNewTransfersParentShareAndClearsMembers(t *testing.T) {
195 t.Parallel()
196
197 tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
198 require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 81, Height: 3}))
199
200 stackNode := tr.Root.Children[0]
201 require.Equal(t, Stack, stackNode.Kind)
202 require.Equal(t, 3.0, stackNode.Weight)
203 require.Zero(t, stackNode.Children[0].Weight)
204 require.Zero(t, stackNode.Children[1].Weight)
205 placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 3})
206 require.True(t, ok)
207 require.Equal(t, 60, contents(placements)["c"].Width)
208 require.Equal(t, 20, contents(placements)["b"].Width)
209}
210
211func TestWeightStackNewClearsExistingStackMemberWeights(t *testing.T) {
212 t.Parallel()
213
214 tr := &Tree{Root: &Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 3), weightedLeaf("b", 1)}, Expanded: "a", Weight: 4}, Focus: "a"}
215 require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 20, Height: 4}))
216
217 require.Equal(t, 4.0, tr.Root.Weight)
218 for _, child := range tr.Root.Children {
219 require.Zero(t, child.Weight)
220 }
221}
222
223func TestWeightToggleStackClearsMemberWeights(t *testing.T) {
224 t.Parallel()
225
226 tests := []struct {
227 name string
228 root *Node
229 wantKind Kind
230 }{
231 {
232 name: "split to stack",
233 root: &Node{Kind: Split, Dir: Vertical, Weight: 3, Children: []*Node{
234 weightedLeaf("a", 2), weightedLeaf("b", 1),
235 }},
236 wantKind: Stack,
237 },
238 {
239 name: "stack to split",
240 root: &Node{Kind: Stack, Weight: 3, Children: []*Node{
241 weightedLeaf("a", 2), weightedLeaf("b", 1),
242 }, Expanded: "a"},
243 wantKind: Split,
244 },
245 }
246
247 for _, tt := range tests {
248 t.Run(tt.name, func(t *testing.T) {
249 tr := &Tree{Root: split(Horizontal, tt.root, weightedLeaf("c", 1)), Focus: "a"}
250 area := domain.Rect{Width: 81, Height: 5}
251 require.NoError(t, tr.ToggleStack("a", area))
252 container := tr.Root.Children[0]
253 require.Equal(t, tt.wantKind, container.Kind)
254 require.Equal(t, 3.0, container.Weight)
255 for _, child := range container.Children {
256 require.Zero(t, child.Weight)
257 }
258 placements, ok := Solve(tr.Root, area)
259 require.True(t, ok)
260 require.Equal(t, 60, contents(placements)["a"].Width)
261 require.Equal(t, 20, contents(placements)["c"].Width)
262 if tt.wantKind == Split {
263 require.Equal(t, map[PaneID]int{"a": 2, "b": 2, "c": 5}, placementHeights(placements))
264 }
265 })
266 }
267}
268
269func TestWeightClosePromotionTransfersContainerShare(t *testing.T) {
270 t.Parallel()
271
272 tests := []struct {
273 name string
274 container *Node
275 }{
276 {
277 name: "split",
278 container: split(Vertical, NewLeaf("a"), NewLeaf("b")),
279 },
280 {
281 name: "stack",
282 container: &Node{Kind: Stack, Children: []*Node{
283 NewLeaf("a"), NewLeaf("b"),
284 }, Expanded: "a"},
285 },
286 }
287
288 for _, tt := range tests {
289 t.Run(tt.name, func(t *testing.T) {
290 tr := &Tree{Root: split(Horizontal,
291 weightedNode(tt.container, 3),
292 weightedLeaf("c", 1),
293 ), Focus: "a"}
294 area := domain.Rect{Width: 81, Height: 5}
295 before, ok := Solve(tr.Root, area)
296 require.True(t, ok)
297 require.Equal(t, 60, contents(before)["a"].Width)
298
299 require.NoError(t, tr.Close("a"))
300 require.Equal(t, PaneID("b"), tr.Root.Children[0].Leaf)
301 require.Equal(t, 3.0, tr.Root.Children[0].Weight)
302 after, ok := Solve(tr.Root, area)
303 require.True(t, ok)
304 require.Equal(t, 60, contents(after)["b"].Width)
305 require.Equal(t, 20, contents(after)["c"].Width)
306 })
307 }
308}
309
310func TestWeightSplitStackWrapperTransfersShare(t *testing.T) {
311 t.Parallel()
312
313 tr := &Tree{Root: &Node{Kind: Stack, Weight: 4, Children: []*Node{
314 weightedLeaf("a", 2), weightedLeaf("b", 1),
315 }, Expanded: "a"}, Focus: "a"}
316 require.NoError(t, tr.Split("a", Right, true, "c", domain.Rect{Width: 41, Height: 3}))
317
318 require.Equal(t, 4.0, tr.Root.Weight)
319 require.Zero(t, tr.Root.Children[0].Weight)
320 require.Equal(t, Stack, tr.Root.Children[0].Kind)
321 for _, member := range tr.Root.Children[0].Children {
322 require.Zero(t, member.Weight)
323 }
324}
325
326func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
327 t.Parallel()
328
329 tests := []struct {
330 name string
331 tree *Tree
332 target PaneID
333 dir Direction
334 area domain.Rect
335 check func(*testing.T, *Tree, []Placement, []Placement)
336 }{
337 {
338 name: "horizontal insertion preserves normalized sibling shares and gives expelled column fair-share weight",
339 tree: &Tree{Root: weightedNode(horizontal(
340 weightedNode(vertical("a", "b"), 3),
341 weightedLeaf("c", 1),
342 ), 11), Focus: "b"},
343 target: "b",
344 dir: Right,
345 area: domain.Rect{Width: 202, Height: 5},
346 check: func(t *testing.T, tree *Tree, before, after []Placement) {
347 require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before))
348 require.Equal(t, 11.0, tree.Root.Weight)
349 require.Equal(t, []float64{151, 100.5, 50}, childWeights(tree.Root))
350 require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
351 require.Equal(t, map[PaneID]int{"a": 100, "b": 67, "c": 33}, placementWidths(after))
352 },
353 },
354 {
355 name: "vertical consume preserves solved member shares and gives moved pane fair-share weight",
356 tree: &Tree{Root: weightedNode(horizontal(
357 weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2),
358 weightedLeaf("c", 1),
359 ), 9), Focus: "c"},
360 target: "c",
361 dir: Left,
362 area: domain.Rect{Width: 101, Height: 20},
363 check: func(t *testing.T, tree *Tree, before, after []Placement) {
364 require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before))
365 require.Equal(t, Vertical, tree.Root.Dir)
366 require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share")
367 require.Equal(t, []float64{14, 5, 9.5}, childWeights(tree.Root))
368 require.Equal(t, map[PaneID]int{"a": 9, "b": 3, "c": 6}, placementHeights(after))
369 },
370 },
371 {
372 name: "stack consume clears member weights and preserves normalized outer column share",
373 tree: &Tree{Root: horizontal(
374 weightedLeaf("x", 1),
375 weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3),
376 weightedLeaf("c", 1),
377 ), Focus: "c"},
378 target: "c",
379 dir: Left,
380 area: domain.Rect{Width: 103, Height: 4},
381 check: func(t *testing.T, tree *Tree, before, after []Placement) {
382 require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before))
383 require.Len(t, tree.Root.Children, 2)
384 stackColumn := tree.Root.Children[1]
385 require.Equal(t, Stack, stackColumn.Kind)
386 require.Equal(t, 61.0, stackColumn.Weight)
387 require.Equal(t, PaneID("c"), stackColumn.Expanded)
388 require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn))
389 require.Equal(t, 77, placementSpanWidth(after, "a"))
390 require.Equal(t, 77, placementSpanWidth(after, "b"))
391 require.Equal(t, 77, placementSpanWidth(after, "c"))
392 },
393 },
394 {
395 name: "stack expel clears remaining weights and repairs expanded member",
396 tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{
397 weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3),
398 }, Expanded: "b"}, 7), Focus: "b"},
399 target: "b",
400 dir: Right,
401 area: domain.Rect{Width: 101, Height: 4},
402 check: func(t *testing.T, tree *Tree, _, after []Placement) {
403 require.Equal(t, Horizontal, tree.Root.Dir)
404 require.Equal(t, 7.0, tree.Root.Weight)
405 require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
406 stackColumn := tree.Root.Children[0]
407 require.Equal(t, PaneID("a"), stackColumn.Expanded)
408 require.Equal(t, []float64{0, 0}, childWeights(stackColumn))
409 require.Equal(t, 50, placementSpanWidth(after, "a"))
410 require.Equal(t, 50, placementSpanWidth(after, "c"))
411 require.Equal(t, 50, placementSpanWidth(after, "b"))
412 },
413 },
414 {
415 name: "column collapse copies normalized container weight to promoted leaf",
416 tree: &Tree{Root: horizontal(
417 weightedLeaf("x", 1),
418 weightedNode(vertical("a", "b"), 3),
419 weightedLeaf("y", 1),
420 ), Focus: "a"},
421 target: "a",
422 dir: Left,
423 area: domain.Rect{Width: 103, Height: 5},
424 check: func(t *testing.T, tree *Tree, _, after []Placement) {
425 require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root))
426 require.Equal(t, Leaf, tree.Root.Children[2].Kind)
427 require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf)
428 require.Equal(t, 61.0, tree.Root.Children[2].Weight)
429 weights := childWeights(tree.Root)
430 require.Equal(t, 20.0, weights[0])
431 require.InDelta(t, 101.0/3.0, weights[1], 1e-9)
432 require.Equal(t, 61.0, weights[2])
433 require.Equal(t, 20.0, weights[3])
434 require.Equal(t, map[PaneID]int{"x": 20, "a": 21, "b": 39, "y": 20}, placementWidths(after))
435 },
436 },
437 {
438 name: "sole-column expel creates equal default horizontal children and retains wrapper weight",
439 tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"},
440 target: "a",
441 dir: Left,
442 area: domain.Rect{Width: 101, Height: 5},
443 check: func(t *testing.T, tree *Tree, _, after []Placement) {
444 require.Equal(t, Horizontal, tree.Root.Dir)
445 require.Equal(t, 9.0, tree.Root.Weight)
446 require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
447 require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after))
448 },
449 },
450 {
451 name: "expel from equal columns yields three homogeneous columns",
452 tree: &Tree{Root: horizontal(
453 NewLeaf("a"),
454 verticalNodes(NewLeaf("b"), NewLeaf("c")),
455 ), Focus: "c"},
456 target: "c",
457 dir: Right,
458 area: domain.Rect{Width: 122, Height: 10},
459 check: func(t *testing.T, tree *Tree, _, after []Placement) {
460 require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
461 require.Equal(t, map[PaneID]int{"a": 40, "b": 40, "c": 40}, placementWidths(after))
462 },
463 },
464 }
465
466 for _, tt := range tests {
467 t.Run(tt.name, func(t *testing.T) {
468 beforeIDs := LeafIDs(tt.tree.Root)
469 requireUniquePaneIDs(t, beforeIDs)
470 before, solved := Solve(tt.tree.Root, tt.area)
471 require.True(t, solved, "weight fixture must solve before surgery")
472
473 changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
474 require.NoError(t, err)
475 require.True(t, changed)
476 require.Equal(t, tt.target, tt.tree.Focus)
477
478 afterIDs := LeafIDs(tt.tree.Root)
479 require.ElementsMatch(t, beforeIDs, afterIDs)
480 require.Len(t, afterIDs, len(beforeIDs))
481 requireUniquePaneIDs(t, afterIDs)
482 after, solved := Solve(tt.tree.Root, tt.area)
483 require.True(t, solved, "weighted result must solve")
484 tt.check(t, tt.tree, before, after)
485 })
486 }
487}
488
489func childWeights(node *Node) []float64 {
490 weights := make([]float64, len(node.Children))
491 for i, child := range node.Children {
492 weights[i] = child.Weight
493 }
494 return weights
495}
496
497func placementSpanWidth(placements []Placement, id PaneID) int {
498 for _, placement := range placements {
499 if placement.ID != id {
500 continue
501 }
502 if placement.Content.Width > 0 {
503 return placement.Content.Width
504 }
505 return placement.TitleBar.Width
506 }
507 return 0
508}
509
510func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
511 t.Parallel()
512
513 tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
514 clone := tr.Clone()
515 require.Equal(t, tr, clone)
516 clone.Root.Children[0].Weight = 9
517 require.Equal(t, 2.0, tr.Root.Children[0].Weight)
518}
519
520func split(dir SplitDir, children ...*Node) *Node {
521 return &Node{Kind: Split, Dir: dir, Children: children}
522}
523
524func weightedLeaf(id PaneID, weight float64) *Node {
525 return &Node{Kind: Leaf, Leaf: id, Weight: weight}
526}
527
528func weightedNode(n *Node, weight float64) *Node {
529 n.Weight = weight
530 return n
531}
532
533func placementWidths(placements []Placement) map[PaneID]int {
534 out := make(map[PaneID]int, len(placements))
535 for _, placement := range placements {
536 out[placement.ID] = placement.Content.Width
537 }
538 return out
539}
540
541func placementHeights(placements []Placement) map[PaneID]int {
542 out := make(map[PaneID]int, len(placements))
543 for _, placement := range placements {
544 out[placement.ID] = placement.Content.Height
545 }
546 return out
547}