1package css
2
3import (
4 "fmt"
5 "reflect"
6 "strings"
7 "testing"
8)
9
10func TestMemoIdentityAndEviction(t *testing.T) {
11 en := Compile(UA(), Parse(`.on button {color:red} .on:hover > button {background: blue} #x {opacity:.5} button {color:green}`))
12 root := Element{Type: "app", Classes: []string{"on"}}
13 child := Element{Type: "button"}
14 a := en.Compute(&root, nil)
15 first := en.Compute(&child, a)
16 en.EndFrame()
17 if en.Compute(&root, nil) != a || en.Compute(&child, a) != first {
18 t.Fatal("memo miss")
19 }
20 root.State = Hover
21 b := en.Compute(&root, nil)
22 second := en.Compute(&child, b)
23 if second == first || second.Style.BackgroundColor != namedColors["blue"] {
24 t.Fatal("state invalidation", second.Style)
25 }
26 root.State = 0
27 root.Classes = []string{"off"}
28 c := en.Compute(&root, nil)
29 third := en.Compute(&child, c)
30 if third == first || third.Style.Color != namedColors["green"] {
31 t.Fatal("class invalidation")
32 }
33 inline1, ds := ParseInline("color: red; opacity: .2")
34 if len(ds) > 0 {
35 t.Fatal(ds)
36 }
37 inline2, _ := ParseInline("color: blue; opacity: .2")
38 child.Inline = inline1
39 fourth := en.Compute(&child, a)
40 child.Inline = inline2
41 fifth := en.Compute(&child, a)
42 if fourth == fifth || fourth.Style.Color != namedColors["red"] || fifth.Style.Color != namedColors["blue"] {
43 t.Fatal("inline pointer invalidation")
44 }
45 for i := 0; i < 4; i++ {
46 en.EndFrame()
47 }
48 if len(en.memo) != 0 {
49 t.Fatalf("retained %d stale nodes", len(en.memo))
50 }
51}
52func TestCascadeVariablesAndSpecificity(t *testing.T) {
53 tests := []struct {
54 css, inline string
55 want Color
56 padding float64
57 }{
58 {`.x {color:red} .x {color:blue}`, "", namedColors["blue"], 0},
59 {`.x {color:red!important} #id {color:blue}`, "", namedColors["red"], 0},
60 {`.x {color:red!important}`, "color: blue", namedColors["red"], 0},
61 {`.x {color:red!important}`, "color: blue!important", namedColors["blue"], 0},
62 {`.x {--a: 3px; padding: var(--a); color:var(--missing,red)}`, "", namedColors["red"], 3},
63 {`.x {color:green;color:var(--missing)}`, "", Color{A: 1}, 0},
64 }
65 for _, tt := range tests {
66 en := Compile(UA(), Parse(tt.css))
67 d, _ := ParseInline(tt.inline)
68 s := en.Compute(&Element{Type: "box", ID: "id", Classes: []string{"x"}, Inline: d}, nil).Style
69 if s.Color != tt.want || s.Padding.Top.Value != tt.padding {
70 t.Fatalf("%s %s: %v padding %v", tt.css, tt.inline, s.Color, s.Padding.Top)
71 }
72 }
73}
74func TestClassSetOrder(t *testing.T) {
75 en := Compile(UA(), Parse(`.a.b {color:red}`))
76 a := en.Compute(&Element{Classes: []string{"a", "b"}}, nil)
77 b := en.Compute(&Element{Classes: []string{"b", "a"}}, nil)
78 if a != b || a.Style.Color != namedColors["red"] {
79 t.Fatal("class sets must be order independent")
80 }
81}
82
83func benchmarkTree() ([]Element, []int, Sheet) {
84 var src strings.Builder
85 types := []string{"app", "row", "column", "button", "text", "box"}
86 for i := 0; i < 200; i++ {
87 switch i % 5 {
88 case 0:
89 fmt.Fprintf(&src, "%s {padding:2px; color:#123}\n", types[i%len(types)])
90 case 1:
91 fmt.Fprintf(&src, ".c%d > button:hover {color:red}\n", i%12)
92 case 2:
93 fmt.Fprintf(&src, "#id%d {opacity:.5}\n", i%24)
94 case 3:
95 fmt.Fprintf(&src, ".c%d text {font-size:18px}\n", i%12)
96 case 4:
97 fmt.Fprintf(&src, ".c%d {margin:1px}\n", i%12)
98 }
99 }
100 nodes := make([]Element, 1000)
101 parents := make([]int, len(nodes))
102 for i := range nodes {
103 nodes[i].Type = types[i%len(types)]
104 nodes[i].Classes = []string{fmt.Sprintf("c%d", i%12)}
105 if i%7 == 0 {
106 nodes[i].ID = fmt.Sprintf("id%d", i%24)
107 }
108 if i%11 == 0 {
109 nodes[i].State = Hover
110 }
111 parents[i] = (i - 1) / 3
112 }
113 return nodes, parents, Parse(src.String())
114}
115func computeTree(en *Engine, nodes []Element, parents []int, results []*Computed) {
116 for i := range nodes {
117 var parent *Computed
118 if i > 0 {
119 parent = results[parents[i]]
120 }
121 results[i] = en.Compute(&nodes[i], parent)
122 }
123}
124func BenchmarkEngine(b *testing.B) {
125 nodes, parents, sheet := benchmarkTree()
126 for _, mode := range []string{"cold", "warm", "state-change"} {
127 b.Run(mode, func(b *testing.B) {
128 b.ReportAllocs()
129 en := Compile(UA(), sheet)
130 results := make([]*Computed, len(nodes))
131 computeTree(en, nodes, parents, results)
132 en.EndFrame()
133 b.ResetTimer()
134 for i := 0; i < b.N; i++ {
135 if mode == "cold" {
136 en = Compile(UA(), sheet)
137 }
138 if mode == "state-change" {
139 nodes[10].State ^= Hover
140 }
141 computeTree(en, nodes, parents, results)
142 en.EndFrame()
143 }
144 })
145 }
146}
147
148func TestTypedPropertiesAndVariables(t *testing.T) {
149 en := Compile(UA(), Parse(`app {color:#123;font-size:20px; --pair: 1em 2px; --ink: rebeccapurple} app > button {padding:var(--pair);border:2px solid var(--ink);font-family:"A B", sans-serif;box-shadow:1px 2px 3px red;line-height:1.5} button:focus-visible {opacity:.4}`))
150 root := en.Compute(&Element{Type: "app"}, nil)
151 c := en.Compute(&Element{Type: "button", State: FocusVisible}, root).Style
152 if c.Padding.Top.Value != 20 || c.Padding.Right.Value != 2 || c.BorderWidth.Left.Value != 2 || c.BorderColor.Left != namedColors["rebeccapurple"] || c.BorderStyle.Left != KeywordSolid || c.Opacity != .4 || len(c.FontFamily) != 2 || c.FontFamily[0] != "A B" || len(c.BoxShadow) != 1 || c.LineHeightKind != KeywordNumber || c.LineHeightNumber != 1.5 {
153 t.Fatalf("typed style: %+v", c)
154 }
155}
156func TestDuplicateSelectorListUsesHighestMatchingSpecificity(t *testing.T) {
157 en := Compile(Sheet{}, Parse(`button, #x {color:red} button {color:blue}`))
158 if got := en.Compute(&Element{Type: "button", ID: "x"}, nil).Style.Color; got != namedColors["red"] {
159 t.Fatal(got)
160 }
161}
162func TestEndFrameKeepsAncestorsOfLiveDescendant(t *testing.T) {
163 en := Compile(UA(), Parse(`.a button {color:red}`))
164 p := en.Compute(&Element{Type: "app", Classes: []string{"a"}}, nil)
165 c := en.Compute(&Element{Type: "button"}, p)
166 for i := 0; i < 5; i++ {
167 en.Compute(&Element{Type: "button"}, p)
168 en.EndFrame()
169 }
170 if en.memo[p.node.key] != p || en.Compute(&Element{Type: "button"}, p) != c {
171 t.Fatal("lost live ancestor")
172 }
173 for i := 0; i < 3; i++ {
174 en.EndFrame()
175 }
176 if len(en.memo) != 0 {
177 t.Fatal("eviction leak", len(en.memo))
178 }
179}
180
181func TestEvictionBoundAcrossChangingStates(t *testing.T) {
182 en := Compile(UA(), Parse(`.a:hover button{color:red}`))
183 root := Element{Type: "app", Classes: []string{"a"}}
184 child := Element{Type: "button"}
185 for frame := 0; frame < 100; frame++ {
186 root.State = StateFlags(frame%2) * Hover
187 p := en.Compute(&root, nil)
188 en.Compute(&child, p)
189 en.EndFrame()
190 if len(en.memo) > 4 {
191 t.Fatalf("frame %d: %d nodes", frame, len(en.memo))
192 }
193 }
194}
195
196func TestVariableCSSWideAndInvalidShorthand(t *testing.T) {
197 en := Compile(Sheet{}, Parse(`app{color:blue;font-size:22px} button{--s:inherit;color:var(--s);--x:bad; margin:7px;margin:var(--x); border:1px solid red;border:var(--x)}`))
198 p := en.Compute(&Element{Type: "app"}, nil)
199 s := en.Compute(&Element{Type: "button"}, p).Style
200 if s.Color != p.Style.Color || s.Margin.Top.Value != 0 || s.BorderWidth.Top.Value != 0 {
201 t.Fatalf("variable cascade: %+v", s)
202 }
203}
204
205func TestInlineInvalidAndSheetSnapshot(t *testing.T) {
206 sheet := Parse(`button{color:red}`)
207 en := Compile(Sheet{}, sheet)
208 sheet.Rules[0].Declarations[0].Value = "blue"
209 d, _ := ParseInline("color: nonsense; opacity:.2")
210 got := en.Compute(&Element{Type: "button", Inline: d}, nil).Style
211 if got.Color != namedColors["red"] || got.Opacity != .2 {
212 t.Fatalf("invalid inline or sheet mutation: %+v", got)
213 }
214}
215
216func TestStylePropertyCoverage(t *testing.T) {
217 if len(propertyNames) != int(propertyCount) {
218 t.Fatal("property enum and table diverged")
219 }
220 for id, name := range propertyNames {
221 if got, ok := propertyIndex(name); !ok || int(got) != id {
222 t.Fatalf("property %q: %d %v", name, got, ok)
223 }
224 v, ok := parseValue(name, initial(name), Color{A: 1}, 16, 16)
225 if !ok {
226 t.Fatalf("invalid default for %q", name)
227 }
228 s := &Style{}
229 s.set(propertyID(id), v)
230 want := s.value(propertyID(id))
231 if name == "line-height" && want.Keyword != "normal" {
232 t.Fatalf("line-height default: %+v", want)
233 }
234 }
235}
236
237func TestKeywordAndCurrentColorInheritance(t *testing.T) {
238 en := Compile(Sheet{}, Parse(`app{color:blue; font-style:italic; font-size:20px} button{color:currentColor; border-color:currentColor; border-style:solid; font-style:oblique; font-size:1em; line-height:2; letter-spacing:normal}`))
239 p := en.Compute(&Element{Type: "app"}, nil)
240 s := en.Compute(&Element{Type: "button"}, p).Style
241 if s.Color != p.Style.Color || s.BorderColor.Top != s.Color || s.BorderStyle.Top != KeywordSolid || s.FontStyle != KeywordOblique || s.FontSize.Value != 20 || s.LineHeightKind != KeywordNumber || s.LineHeightNumber != 2 || !s.LetterSpacingNormal {
242 t.Fatalf("keyword/color style %+v", s)
243 }
244}
245
246func TestVariableInvalidatesWholeWinningShorthand(t *testing.T) {
247 en := Compile(Sheet{}, Parse(`button{--p:5px nonsense;padding:3px;padding:var(--p)}`))
248 s := en.Compute(&Element{Type: "button"}, nil).Style
249 if s.Padding.Top.Value != 0 || s.Padding.Right.Value != 0 {
250 t.Fatal("invalid shorthand did not reset", s.Padding)
251 }
252}
253
254func TestInvalidStaticShorthandIgnored(t *testing.T) {
255 en := Compile(Sheet{}, Parse(`button{padding:3px;padding:5px nonsense;border:2px solid red;border:garbage green}`))
256 s := en.Compute(&Element{Type: "button"}, nil).Style
257 if s.Padding.Top.Value != 3 || s.BorderWidth.Top.Value != 2 || s.BorderColor.Top != namedColors["red"] {
258 t.Fatalf("partial invalid shorthand: %+v", s)
259 }
260}
261
262func TestCSSWideShorthand(t *testing.T) {
263 en := Compile(Sheet{}, Parse(`app{color:blue} button{padding:5px; padding:initial; border:2px solid red; border:unset; color:red; color:inherit}`))
264 p := en.Compute(&Element{Type: "app"}, nil)
265 s := en.Compute(&Element{Type: "button"}, p).Style
266 if s.Padding.Top.Value != 0 || s.BorderWidth.Top.Value != 0 || s.Color != p.Style.Color {
267 t.Fatalf("wide shorthand: %+v", s)
268 }
269}
270
271func TestCSSWideVariableShorthand(t *testing.T) {
272 en := Compile(Sheet{}, Parse(`button{--x:initial; padding:9px; padding:var(--x); border:2px solid red;border:var(--x)}`))
273 s := en.Compute(&Element{Type: "button"}, nil).Style
274 if s.Padding.Top.Value != 0 || s.BorderWidth.Top.Value != 0 || s.BorderStyle.Top != KeywordNone {
275 t.Fatalf("wide var shorthand: %+v", s)
276 }
277}
278
279func TestUnusedMemoEvictedAfterTwoFrames(t *testing.T) {
280 en := Compile(Sheet{}, Sheet{})
281 en.Compute(&Element{Type: "app"}, nil)
282 en.EndFrame()
283 if len(en.memo) != 1 {
284 t.Fatal("evicted too early")
285 }
286 en.EndFrame()
287 if len(en.memo) != 0 {
288 t.Fatal("evicted too late")
289 }
290}
291
292func TestStyleInitialMatchesPropertyDefaults(t *testing.T) {
293 s := Compile(Sheet{}, Sheet{}).Compute(&Element{Type: "app"}, nil).Style
294 for id, name := range propertyNames {
295 want, ok := parseValue(name, initial(name), s.Color, 16, 16)
296 if !ok {
297 t.Fatalf("invalid initial %s", name)
298 }
299 got := s.value(propertyID(id))
300 // Slices are shared immutable values and not directly comparable.
301 switch name {
302 case "font-family":
303 if !reflect.DeepEqual(got.Families, want.Families) {
304 t.Fatalf("%s: %+v vs %+v", name, got, want)
305 }
306 case "box-shadow":
307 if len(got.Shadows) != len(want.Shadows) {
308 t.Fatalf("%s: %+v vs %+v", name, got, want)
309 }
310 default:
311 if got.Length != want.Length || got.Color != want.Color || got.Number != want.Number || got.Keyword != want.Keyword {
312 t.Fatalf("%s: %+v vs %+v", name, got, want)
313 }
314 }
315 }
316}
317
318func TestInheritedFieldsAndCurrentColorDefaults(t *testing.T) {
319 en := Compile(Sheet{}, Parse(`app{color:red; font-family:serif; font-size:22px; font-style:italic; cursor:pointer; text-align:center;line-height:2;letter-spacing:1px;accent-color:green} button{background-color:blue}`))
320 p := en.Compute(&Element{Type: "app"}, nil)
321 c := en.Compute(&Element{Type: "button"}, p).Style
322 if c.Color != p.Style.Color || c.AccentColor != namedColors["green"] || c.BorderColor.Top != c.Color || c.OutlineColor != c.Color || c.FontSize != p.Style.FontSize || c.FontStyle != KeywordItalic || c.FontFamily[0] != "serif" || c.Cursor != KeywordPointer || c.TextAlign != KeywordCenter || c.LineHeightNumber != 2 || c.LetterSpacing.Value != 1 || c.BackgroundColor != namedColors["blue"] {
323 t.Fatalf("inheritance: %+v", c)
324 }
325}
326
327func TestComputedInitialInheritsColorButResetsBackground(t *testing.T) {
328 en := Compile(Sheet{}, Parse(`app{color:red;background:blue}button{background:initial}`))
329 p := en.Compute(&Element{Type: "app"}, nil)
330 c := en.Compute(&Element{Type: "button"}, p).Style
331 if c.Color != namedColors["red"] || c.BackgroundColor != (Color{}) || c.BorderColor.Top != c.Color {
332 t.Fatalf("computed initial: %+v", c)
333 }
334}
335
336func TestChangedStateMissesOnlySubtree(t *testing.T) {
337 nodes, parents, sheet := benchmarkTree()
338 en := Compile(UA(), sheet)
339 results := make([]*Computed, len(nodes))
340 computeTree(en, nodes, parents, results)
341 before := append([]*Computed(nil), results...)
342 en.EndFrame()
343 const changed = 10
344 nodes[changed].State ^= Hover
345 computeTree(en, nodes, parents, results)
346 descendants := make([]bool, len(nodes))
347 descendants[changed] = true
348 expected, misses := 0, 0
349 for i := range nodes {
350 if i > changed && descendants[parents[i]] {
351 descendants[i] = true
352 }
353 if descendants[i] {
354 expected++
355 }
356 if results[i] != before[i] {
357 misses++
358 if !descendants[i] {
359 t.Fatalf("unrelated node %d recomputed", i)
360 }
361 } else if descendants[i] {
362 t.Fatalf("descendant %d reused stale result", i)
363 }
364 }
365 if misses != expected {
366 t.Fatalf("Compute misses %d; affected subtree size %d", misses, expected)
367 }
368}
369
370func TestPendingVariableShorthandsAtomic(t *testing.T) {
371 cases := []struct {
372 property, base, valid, invalid string
373 targets []string
374 }{
375 {"background", "blue", "red", "no-color", []string{"background-color"}},
376 {"margin", "9px", "1px 2px 3px", "1px nope", shorthandTargets("margin")},
377 {"padding", "9px", "1px 2px 3px", "1px nope", shorthandTargets("padding")},
378 {"border-width", "9px", "1px 2px 3px", "1px nope", shorthandTargets("border-width")},
379 {"border-style", "none", "solid", "solid dotted", shorthandTargets("border-style")},
380 {"border-color", "blue", "red green", "red nope", shorthandTargets("border-color")},
381 {"border-radius", "9px", "1px 2px 3px", "1px nope", shorthandTargets("border-radius")},
382 {"border-top", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border-top")},
383 {"border-right", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border-right")},
384 {"border-bottom", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border-bottom")},
385 {"border-left", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border-left")},
386 {"border", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border")},
387 {"outline", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("outline")},
388 {"flex", "none", "2 3 10px", "2 garbage", shorthandTargets("flex")},
389 {"gap", "9px", "1px 2px", "1px nope", shorthandTargets("gap")},
390 {"overflow", "hidden", "scroll auto", "scroll garbage", shorthandTargets("overflow")},
391 }
392 baseline := Compile(Sheet{}, Sheet{}).Compute(&Element{Type: "button"}, nil).Style
393 for _, tt := range cases {
394 t.Run(tt.property, func(t *testing.T) {
395 css := func(v string) *Style {
396 src := "button{--x:" + v + ";" + tt.property + ":" + tt.base + ";" + tt.property + ":var(--x)}"
397 return Compile(Sheet{}, Parse(src)).Compute(&Element{Type: "button"}, nil).Style
398 }
399 good, bad := css(tt.valid), css(tt.invalid)
400 for _, name := range tt.targets {
401 id, ok := propertyIndex(name)
402 if !ok {
403 t.Fatalf("unknown target %s", name)
404 }
405 if reflect.DeepEqual(good.value(id), baseline.value(id)) {
406 t.Errorf("valid %s did not override %s", tt.valid, name)
407 }
408 if !reflect.DeepEqual(bad.value(id), baseline.value(id)) {
409 t.Errorf("invalid %s did not reset %s: %+v", tt.invalid, name, bad.value(id))
410 }
411 }
412 })
413 }
414}
415
416func TestPendingShorthandWinnerPerLonghand(t *testing.T) {
417 en := Compile(Sheet{}, Parse(`button{--pair:1px 2px; padding:var(--pair); padding-left:7px; padding-right:9px!important}`))
418 s := en.Compute(&Element{Type: "button"}, nil).Style
419 if s.Padding.Top.Value != 1 || s.Padding.Bottom.Value != 1 || s.Padding.Left.Value != 7 || s.Padding.Right.Value != 9 {
420 t.Fatal(s.Padding)
421 }
422}
423
424func TestPendingShorthandInheritedCustomAndInline(t *testing.T) {
425 en := Compile(Sheet{}, Parse(`app{--space:1em 2px;color:red}button{padding:var(--space);margin:9px;margin:var(--missing);border:1px solid blue;border:var(--missing)}`))
426 p := en.Compute(&Element{Type: "app"}, nil)
427 in, _ := ParseInline(`padding:var(--space);border:var(--missing, 2px solid currentColor)`)
428 c := en.Compute(&Element{Type: "button", Inline: in}, p).Style
429 if c.Padding.Top.Value != 16 || c.Padding.Right.Value != 2 || c.Margin.Top.Value != 0 || c.BorderWidth.Left.Value != 2 || c.BorderColor.Left != namedColors["red"] {
430 t.Fatalf("pending group inheritance/inline %+v", c)
431 }
432}