1package layout
2
3import (
4 "image"
5 "reflect"
6 "testing"
7
8 "github.com/bnema/nefergui/internal/css"
9)
10
11// Layout output is detached: neither the shared paint storage nor a later
12// Layout call, nor a mutation of the input style, may change earlier results.
13func TestPaintOutputDetached(t *testing.T) {
14 root, opts := paintFixture(t, 6)
15 first, err := Layout(root, opts)
16 if err != nil {
17 t.Fatal(err)
18 }
19 want := deepCopyDisplay(first.Display)
20 shadows := 0
21 for _, cmd := range first.Display {
22 if cmd.Shadow != nil {
23 shadows++
24 }
25 }
26 if shadows != 12 {
27 t.Fatalf("fixture shadows=%d, want 12", shadows)
28 }
29 // Mutating the input styles must not reach painted shadows.
30 for _, card := range root.Children {
31 card.Style.BoxShadow[0].Blur = px(99)
32 }
33 second, err := Layout(root, opts)
34 if err != nil {
35 t.Fatal(err)
36 }
37 if !reflect.DeepEqual(first.Display, want) {
38 t.Fatal("first output changed after style mutation and second Layout")
39 }
40 if reflect.DeepEqual(second.Display, want) {
41 t.Fatal("second output should reflect the mutated style")
42 }
43 // Writing through one command's storage must not change any other.
44 for i := range second.Display {
45 if second.Display[i].Shadow != nil {
46 second.Display[i].Shadow.X = px(-7)
47 }
48 }
49 if !reflect.DeepEqual(first.Display, want) {
50 t.Fatal("outputs share shadow storage")
51 }
52}
53
54func deepCopyDisplay(in []Command) []Command {
55 out := make([]Command, len(in))
56 for i, c := range in {
57 if c.Shadow != nil {
58 s := *c.Shadow
59 c.Shadow = &s
60 }
61 c.Runs = append([]Run(nil), c.Runs...)
62 for j := range c.Runs {
63 c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...)
64 }
65 out[i] = c
66 }
67 return out
68}
69
70// Runs and glyphs come from shared blocks; a caller appending to one must
71// reallocate rather than overwrite the next run or command.
72func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) {
73 root, opts := paintFixture(t, 3)
74 out, err := Layout(root, opts)
75 if err != nil {
76 t.Fatal(err)
77 }
78 want := deepCopyDisplay(out.Display)
79 texts := 0
80 for i := range out.Display {
81 cmd := &out.Display[i]
82 if cmd.Op != "text" {
83 continue
84 }
85 texts++
86 if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) {
87 t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs))
88 }
89 for j := range cmd.Runs {
90 if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) {
91 t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g))
92 }
93 }
94 _ = append(cmd.Runs, Run{Start: -1})
95 for j := range cmd.Runs {
96 _ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead})
97 }
98 }
99 if texts != 3 || !reflect.DeepEqual(out.Display, want) {
100 t.Fatal("append through a command overwrote neighbouring data")
101 }
102}
103
104// countCommands must match paint exactly, otherwise the single-allocation
105// guarantee silently degrades.
106func TestPaintCountsMatch(t *testing.T) {
107 root, opts := paintFixture(t, 10)
108 root.Children = append(root.Children,
109 nil,
110 &Node{ID: "hidden", Kind: Text, Content: "hidden", Style: &css.Style{Display: css.KeywordNone}},
111 &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)}},
112 &Node{ID: "outline", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), OutlineStyle: css.KeywordSolid, OutlineWidth: px(2)}},
113 &Node{ID: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}},
114 )
115 ctx := context{options: opts}
116 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
117 if err != nil {
118 t.Fatal(err)
119 }
120 n := countCommands(root, tree)
121 out := ctx.display(root, tree)
122 runs, glyphs, shadows := 0, 0, 0
123 for _, cmd := range out {
124 runs += len(cmd.Runs)
125 for _, r := range cmd.Runs {
126 glyphs += len(r.Glyphs)
127 }
128 if cmd.Shadow != nil {
129 shadows++
130 }
131 }
132 if n.commands != len(out) || cap(out) != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows {
133 t.Fatalf("counts %+v vs commands=%d cap=%d runs=%d glyphs=%d shadows=%d", n, len(out), cap(out), runs, glyphs, shadows)
134 }
135}
136
137func TestPaintAllocations(t *testing.T) {
138 root, opts := paintFixture(t, 50)
139 ctx := context{options: opts}
140 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
141 if err != nil {
142 t.Fatal(err)
143 }
144 // One block each for commands, shadows, runs and glyphs, whatever the tree size.
145 if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n > 4 {
146 t.Fatalf("paint allocs=%v, want <= 4", n)
147 }
148}
149
150func TestPaintEmptyAndShadowOrder(t *testing.T) {
151 out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10})
152 if err != nil || out.Display != nil {
153 t.Fatalf("empty paint: %+v %v", out.Display, err)
154 }
155 s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{
156 {Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)},
157 }}
158 out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10})
159 if err != nil {
160 t.Fatal(err)
161 }
162 var got []float64
163 var ops []string
164 for _, c := range out.Display {
165 ops = append(ops, c.Op)
166 if c.Shadow != nil {
167 got = append(got, c.Shadow.Blur.Value)
168 }
169 }
170 if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) {
171 t.Fatalf("order ops=%v blur=%v", ops, got)
172 }
173}