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}