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