1package layout
2
3import (
4 "image"
5 "reflect"
6 "slices"
7 "testing"
8
9 "github.com/bnema/nefergui/internal/css"
10)
11
12// Layout output is detached: neither the shared paint storage nor a later
13// Layout call, nor a mutation of the input style, may change earlier results.
14func TestPaintOutputDetached(t *testing.T) {
15 root, opts := paintFixture(t, 6)
16 first, err := Layout(root, opts)
17 if err != nil {
18 t.Fatal(err)
19 }
20 want := deepCopyDisplay(first.Display)
21 shadows := 0
22 for _, cmd := range first.Display {
23 if cmd.Shadow != nil {
24 shadows++
25 }
26 }
27 if shadows != 12 {
28 t.Fatalf("fixture shadows=%d, want 12", shadows)
29 }
30 // Mutating the input styles must not reach painted shadows.
31 for _, card := range root.Children {
32 card.Style.BoxShadow[0].Blur = px(99)
33 }
34 second, err := Layout(root, opts)
35 if err != nil {
36 t.Fatal(err)
37 }
38 if !reflect.DeepEqual(first.Display, want) {
39 t.Fatal("first output changed after style mutation and second Layout")
40 }
41 if reflect.DeepEqual(second.Display, want) {
42 t.Fatal("second output should reflect the mutated style")
43 }
44 // Writing through one command's storage must not change any other.
45 for i := range second.Display {
46 if second.Display[i].Shadow != nil {
47 second.Display[i].Shadow.X = px(-7)
48 }
49 }
50 if !reflect.DeepEqual(first.Display, want) {
51 t.Fatal("outputs share shadow storage")
52 }
53}
54
55func deepCopyDisplay(in []Command) []Command {
56 out := make([]Command, len(in))
57 for i, c := range in {
58 if c.Shadow != nil {
59 s := *c.Shadow
60 c.Shadow = &s
61 }
62 c.Runs = append([]Run(nil), c.Runs...)
63 for j := range c.Runs {
64 c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...)
65 }
66 out[i] = c
67 }
68 return out
69}
70
71// Runs and glyphs come from shared blocks; a caller appending to one must
72// reallocate rather than overwrite the next run or command.
73func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) {
74 root, opts := paintFixture(t, 3)
75 out, err := Layout(root, opts)
76 if err != nil {
77 t.Fatal(err)
78 }
79 want := deepCopyDisplay(out.Display)
80 texts := 0
81 for i := range out.Display {
82 cmd := &out.Display[i]
83 if cmd.Op != "text" {
84 continue
85 }
86 texts++
87 if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) {
88 t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs))
89 }
90 for j := range cmd.Runs {
91 if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) {
92 t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g))
93 }
94 }
95 _ = append(cmd.Runs, Run{Start: -1})
96 for j := range cmd.Runs {
97 _ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead})
98 }
99 }
100 if texts != 3 || !reflect.DeepEqual(out.Display, want) {
101 t.Fatal("append through a command overwrote neighbouring data")
102 }
103}
104
105// countCommands must match paint exactly, otherwise the single-allocation
106// guarantee silently degrades.
107func TestPaintCountsMatch(t *testing.T) {
108 root, opts := paintFixture(t, 10)
109 root.Children = append(root.Children,
110 nil,
111 &Node{ID: "hidden", Kind: Text, Content: "hidden", Style: &css.Style{Display: css.KeywordNone}},
112 &Node{ID: "image", Kind: Image, Image: image.NewRGBA(image.Rect(0, 0, 2, 2)), Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20)}},
113 &Node{ID: "outline", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), OutlineStyle: css.KeywordSolid, OutlineWidth: px(2)}},
114 &Node{ID: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}},
115 )
116 ctx := newContext(opts)
117 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
118 if err != nil {
119 t.Fatal(err)
120 }
121 n := countCommands(root, tree)
122 out := ctx.display(root, tree)
123 runs, glyphs, shadows := 0, 0, 0
124 for _, cmd := range out {
125 runs += len(cmd.Runs)
126 for _, r := range cmd.Runs {
127 glyphs += len(r.Glyphs)
128 }
129 if cmd.Shadow != nil {
130 shadows++
131 }
132 }
133 if n.commands != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows {
134 t.Fatalf("counts %+v vs commands=%d runs=%d glyphs=%d shadows=%d", n, len(out), runs, glyphs, shadows)
135 }
136 // An empty arena sizes each block once from the counts: one allocation
137 // each for commands, shadows, runs and glyphs, with no growth.
138 if raceEnabled {
139 return
140 }
141 if got := testing.AllocsPerRun(10, func() {
142 *ctx.arena = Arena{}
143 ctx.display(root, tree)
144 }); got != 4 {
145 t.Fatalf("fresh display allocs=%v, want 4", got)
146 }
147}
148
149func TestPaintAllocations(t *testing.T) {
150 root, opts := paintFixture(t, 50)
151 ctx := newContext(opts)
152 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
153 if err != nil {
154 t.Fatal(err)
155 }
156 // The arena's blocks are reused, so a repeated paint allocates nothing.
157 if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n != 0 {
158 t.Fatalf("paint allocs=%v, want 0", n)
159 }
160}
161
162// TestLayoutArenaReuse checks that a steady layout into a reused arena
163// allocates nothing, and that alternating two arenas keeps the previous
164// Output intact while the next one is built.
165func TestLayoutArenaReuse(t *testing.T) {
166 root, opts := paintFixture(t, 20)
167 var arenas [2]Arena
168 layout := func(i int) Output {
169 o := opts
170 o.Arena = &arenas[i]
171 out, err := Layout(root, o)
172 if err != nil {
173 t.Fatal(err)
174 }
175 return out
176 }
177 prev := layout(0)
178 layout(1)
179 wantID, wantX := prev.Tree.Children[3].ID, prev.Display[5].Rect.X
180 root.Children[3].ID = "changed"
181 layout(1)
182 if prev.Tree.Children[3].ID != wantID || prev.Display[5].Rect.X != wantX {
183 t.Fatal("building into one arena changed the Output of the other")
184 }
185 if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 {
186 t.Fatalf("steady layout allocs=%v, want 0", n)
187 }
188 // A list the caller grew with many decorations and handed back through
189 // Keep is recycled, not dropped as oversized for the layout alone.
190 decorations := make([]Command, 1000) // far beyond retainSlack times the layout's own
191 decorate := func() {
192 out := layout(1)
193 arenas[1].Keep(slices.Insert(out.Display, 0, decorations...))
194 }
195 decorate()
196 if n := testing.AllocsPerRun(20, decorate); n != 0 {
197 t.Fatalf("decorated layout allocs=%v, want 0", n)
198 }
199 // The runtime may lay out twice before one Keep (caret scrolling).
200 if n := testing.AllocsPerRun(20, func() { layout(1); decorate() }); n != 0 {
201 t.Fatalf("relayout before keep allocs=%v, want 0", n)
202 }
203 // Once decorations go away, the large list is released.
204 decorated := cap(arenas[1].commands)
205 arenas[1].Keep(layout(1).Display)
206 if layout(1); cap(arenas[1].commands) >= decorated {
207 t.Fatalf("decorated list kept after decorations went away: cap %d", cap(arenas[1].commands))
208 }
209 // Growth falls back to individual allocations once, then fits.
210 for range 30 {
211 root.Children = append(root.Children, root.Children[0])
212 }
213 layout(1)
214 if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 {
215 t.Fatalf("allocs after growth=%v, want 0", n)
216 }
217}
218
219func TestPaintEmptyAndShadowOrder(t *testing.T) {
220 out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10})
221 if err != nil || out.Display != nil {
222 t.Fatalf("empty paint: %+v %v", out.Display, err)
223 }
224 s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{
225 {Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)},
226 }}
227 out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10})
228 if err != nil {
229 t.Fatal(err)
230 }
231 var got []float64
232 var ops []string
233 for _, c := range out.Display {
234 ops = append(ops, c.Op)
235 if c.Shadow != nil {
236 got = append(got, c.Shadow.Blur.Value)
237 }
238 }
239 if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) {
240 t.Fatalf("order ops=%v blur=%v", ops, got)
241 }
242}