rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package check
2
3import (
4 "os"
5 "regexp"
6 "slices"
7 "strings"
8 "testing"
9
10 "amadan.net/rastrillo/fiesta"
11 "amadan.net/rastrillo/fiesta/example"
12)
13
14// The stylesheet half of the touch-size gates: the zoom floors, the
15// type step, the target floor and the tap inventory, read off
16// tokens.css with the declaration reader. The browser half, which
17// measures what the floors produce, reads the same inventory from the
18// drives.
19
20// touchContext is the one media query every touch-only rule sits in
21// (§1.1 of the mobile ergonomics design spec), as a rule's context
22// names it. Written once so a typo in one floor cannot quietly scope
23// it to a query nothing matches.
24const touchContext = "@media (pointer: coarse), (max-width: 40rem)"
25
26// bareZoomFloor is the zero-weight floor for an app's own inputs: an
27// input with no rst- attribute is sized by the UA stylesheet, which any
28// author rule beats, so :where() still lifts it and any app rule that
29// sizes it still wins.
30const bareZoomFloor = ":where(input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea)"
31
32// TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin is §1.3's position
33// gate. Each component floor must come DIRECTLY after the rule that
34// resets its font (`font: inherit` resets font-size, so a floor before
35// it is switched off) and, for the input, before the rule that makes
36// the primary field big (at equal weight the later rule wins, and
37// max(1rem, 1em) would pull the 19px field down to 16px — the 1em trap
38// this rewrite exists to remove). Moving any floor one rule up or down
39// fails here.
40func TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin(t *testing.T) {
41 // Read as rules, each knowing the top-level statement it sits in:
42 // a floor is a touch query holding exactly one rule, whose only
43 // declaration is font-size: max(1rem, 1em), and "directly after"
44 // is the next statement. Read as text, the rule a floor follows
45 // ended at the first }, a quoted one included.
46 css := stripCSSComments(t, string(fiesta.TokensCSS()))
47 rules := leafRules(t, css)
48 byTop := map[int][]leafRule{}
49 for _, r := range rules {
50 byTop[r.top] = append(byTop[r.top], r)
51 }
52 floors := map[string]int{}
53 for top, in := range byTop {
54 if len(in) != 1 || in[0].context != touchContext || !slices.Equal(in[0].decls, [][3]string{{"font-size", "max(1rem, 1em)", ""}}) {
55 continue
56 }
57 if _, dup := floors[in[0].selector]; dup {
58 t.Errorf("two zoom floors for %q", in[0].selector)
59 }
60 floors[in[0].selector] = top
61 }
62 if len(floors) != 4 {
63 t.Errorf("tokens.css carries %d zoom floors, want 4 (bare elements, input, textarea, search): %v", len(floors), floors)
64 }
65 if _, ok := floors[bareZoomFloor]; !ok {
66 t.Errorf("no bare-element floor spelled %q", bareZoomFloor)
67 }
68 if !strings.HasPrefix(bareZoomFloor, ":where(") || !strings.HasSuffix(bareZoomFloor, ")") || specificity(bareZoomFloor) != [3]int{} {
69 t.Errorf("the bare-element floor is not wholly inside :where(): %v", specificity(bareZoomFloor))
70 }
71 base := func(prelude string) int {
72 for _, r := range rules {
73 if r.context == "" && r.selector == prelude {
74 return r.top
75 }
76 }
77 t.Fatalf("tokens.css has no top-level rule %q", prelude)
78 return 0
79 }
80 for _, c := range []struct{ floor, after, before string }{
81 {".rst-input, [rst-input]", ".rst-input, [rst-input]", `.rst-input--primary, [rst-input~="primary"]`},
82 {".rst-textarea, [rst-textarea]", ".rst-textarea, [rst-textarea]", ""},
83 {`.rst-search input[type="search"], [rst-search] input[type="search"]`, `.rst-search input[type="search"], [rst-search] input[type="search"]`, ""},
84 } {
85 at, ok := floors[c.floor]
86 if !ok {
87 t.Errorf("no zoom floor for %q", c.floor)
88 continue
89 }
90 switch after := base(c.after); {
91 case at < after:
92 t.Errorf("the floor for %q is not directly after the rule that resets its font: it comes before it, so `font: inherit` switches it off", c.floor)
93 case at > after+1:
94 var between []string
95 for top := after + 1; top < at; top++ {
96 for _, r := range byTop[top] {
97 between = append(between, r.selector)
98 }
99 }
100 t.Errorf("the floor for %q is not directly after the rule that resets its font; between them: %q", c.floor, between)
101 }
102 if c.before != "" && at > base(c.before) {
103 t.Errorf("the floor for %q comes after %q, so it overrides the field that is big on purpose", c.floor, c.before)
104 }
105 }
106 // The block this replaces: one pointer-only query sizing the
107 // components at a weight that beat the primary field.
108 if slices.ContainsFunc(rules, func(r leafRule) bool {
109 return strings.Contains(r.context, "@media (pointer: coarse)") && r.context != touchContext
110 }) {
111 t.Error("tokens.css still has a pointer-only query; every touch rule uses the one query of §1.1")
112 }
113}
114
115// The type step itself (§1.2), read as text so a desktop-only edit
116// cannot move it: the four tokens, in the touch query, at :root.
117func TestTheTypeScaleMovesUpOneStepOnSmallOrTouchScreens(t *testing.T) {
118 // Read as declarations: a token or a rule in a comment or a string
119 // is not one, and a rule's body ended at its first }, quoted or not.
120 css := string(fiesta.TokensCSS())
121 decls, _ := parseDeclarations(t, "tokens.css", css)
122 touch := declaredAt(decls, touchContext, ":root")
123 if len(touch) == 0 {
124 t.Fatal("no :root block inside the touch query")
125 }
126 for _, want := range [][2]string{{"--rst-fs-lg", "1.1875rem"}, {"--rst-fs-base", "1rem"}, {"--rst-fs-sm", "0.875rem"}, {"--rst-fs-xs", "0.8125rem"}} {
127 if touch[want[0]] != want[1] {
128 t.Errorf("the touch :root block sets %s to %q, want %s", want[0], touch[want[0]], want[1])
129 }
130 }
131 if !slices.ContainsFunc(decls, func(d declaration) bool { return d.Property == "--rst-tap" && fullValue(d) == "2.75rem" }) {
132 t.Error("--rst-tap: 2.75rem is not declared")
133 }
134 // The two components that spelled the xs step as a literal paint
135 // with the token now, or they would stay 11.5px on a phone.
136 rules := leafRules(t, css)
137 for _, sel := range []string{".rst-lrow--head, [rst-lrow~=\"head\"]", ".rst-tip::after, [rst-tip]::after"} {
138 i := slices.IndexFunc(rules, func(r leafRule) bool { return r.selector == sel })
139 if i < 0 {
140 t.Fatalf("no rule %q", sel)
141 }
142 r := rules[i]
143 literal := slices.ContainsFunc(r.decls, func(d [3]string) bool { return strings.Contains(d[1], "0.71875rem") })
144 if literal || !r.sets("font-size", "var(--rst-fs-xs)") {
145 t.Errorf("%s does not paint with var(--rst-fs-xs): %v", sel, r.decls)
146 }
147 }
148}
149
150// TestTheTargetFloorIsOneToken pins the floor's numbers as text, so a
151// desktop-only edit cannot move the phone's floor or the reverse: every
152// floor reads --rst-target, 2rem (32px) at :root and raised to --rst-tap
153// (44px) by a :root block in the touch query. A floor that read
154// --rst-tap directly would be 44px on a desktop too, or, moved out of
155// the touch query, the 32px desktop floor would never reach it.
156func TestTheTargetFloorIsOneToken(t *testing.T) {
157 // Read as declarations, where the cascade reads them: a :root
158 // block found by a pattern ended at its first }, quoted or not.
159 css := string(fiesta.TokensCSS())
160 decls, _ := parseDeclarations(t, "tokens.css", css)
161 if declaredAt(decls, "", ":root")["--rst-target"] != "2rem" {
162 t.Error("the top-level :root does not declare --rst-target: 2rem, the desktop floor")
163 }
164 if declaredAt(decls, touchContext, ":root")["--rst-target"] != "var(--rst-tap)" {
165 t.Error("no :root block in the touch query raises --rst-target to var(--rst-tap)")
166 }
167 sizes := []string{"min-block-size", "min-inline-size", "block-size", "inline-size"}
168 // A size whose value is var(--rst-tap) itself; the calendar's
169 // grid is seven taps wide, which is a width, not a floor.
170 for _, d := range decls {
171 refs := varRefs(d.Value)
172 if slices.Contains(sizes, d.Property) && strings.HasPrefix(d.Value, "var(") && len(refs) > 0 && refs[0].name == "--rst-tap" {
173 t.Errorf("%s sizes a control with --rst-tap; floors read --rst-target, so the desktop gets its own floor", d)
174 }
175 }
176 // Each control in the inventory has its floor, read through the
177 // token, outside the touch query: a floor only the touch query
178 // holds leaves the control at its old size on a desktop. Named
179 // rather than counted, so a control that loses its floor fails by
180 // name, and two floors merged into one rule pass.
181 var everyWidth []leafRule
182 for _, r := range leafRules(t, css) {
183 if !slices.Contains(strings.Split(r.context, " > "), touchContext) {
184 everyWidth = append(everyWidth, r)
185 }
186 }
187 sized := func(r leafRule) bool {
188 for _, p := range sizes {
189 if r.sets(p, "var(--rst-target)") {
190 return true
191 }
192 }
193 return false
194 }
195 for _, e := range tapInventory {
196 if why, ok := floorElsewhere[e.Key]; ok {
197 if why == "" {
198 t.Errorf("floorElsewhere names %q with no reason", e.Key)
199 }
200 continue
201 }
202 found := false
203 for _, r := range everyWidth {
204 if !sized(r) {
205 continue
206 }
207 for _, s := range splitSelectorList(r.selector) {
208 found = found || strings.Contains(s, e.Key)
209 }
210 }
211 if !found {
212 t.Errorf("%s (%s): no rule outside the touch query sizes it with var(--rst-target)", e.Idiom, e.Key)
213 }
214 }
215 for key := range floorElsewhere {
216 found := false
217 for _, e := range tapInventory {
218 found = found || e.Key == key
219 }
220 if !found {
221 t.Errorf("floorElsewhere names %q, which is not in the inventory", key)
222 }
223 }
224}
225
226// floorBlock cuts the every-width floor block out of tokens.css: the
227// css without it, and the block's own text.
228func floorBlock(t *testing.T) (without, block string) {
229 t.Helper()
230 css := string(fiesta.TokensCSS())
231 start := commentAt(t, css, "/* ── Target floors, at every width")
232 end := commentAt(t, css, "/* ---------------------------------------------------------------------\n Utilities, last on purpose.")
233 if start < 0 || end < start {
234 t.Fatal("tokens.css has no floor block between its heading and the utilities; the markers this test cuts on have moved")
235 }
236 return css[:start] + css[end:], css[start:end]
237}
238
239// floorDeclarations is every property the every-width floor block may
240// set, with the values it may set it to (none listed: any value) and
241// why the floor needs it. The block reaches a desktop, where a control
242// already over 32px must come out of it as it went in, so anything that
243// is not a size or what makes the size take hold belongs in the touch
244// query instead.
245var floorDeclarations = map[string]struct {
246 values []string
247 why string
248}{
249 "min-block-size": {nil, "the floor itself"},
250 "min-inline-size": {nil, "the floor itself"},
251 "block-size": {nil, "the floor, on a control whose size is fixed (an icon button)"},
252 "inline-size": {nil, "the floor, on a control whose size is fixed (an icon button)"},
253 "box-sizing": {[]string{"border-box"}, "the floor is the whole control; on the content box its padding and border landed on top, and a 33.8px summary became 44.8"},
254 "display": {[]string{"inline-flex"}, "min sizes do nothing on an inline box, so a control born inline (a link) becomes an inline-flex box the floor can grow"},
255 "align-items": {[]string{"center"}, "centres the content of an inline-flex control in the box the floor grows"},
256 "justify-content": {[]string{"center"}, "the same, across: an icon-only link or the row checkbox's 16px box in the label the floor widens"},
257 "align-content": {[]string{"center"}, "centres a block's content in the floor without making it a flex box, which would drop the spaces in its label"},
258 "column-gap": {nil, "puts back the space a flex box drops between an icon and its word; acts only where the floor made the flex box"},
259 "padding-block": {[]string{"0"}, "the search box: its input and clear link are the targets and fill its height at the floor; with its own block padding on top a desktop box grew from 35.6 to 43.6"},
260 "align-self": {[]string{"stretch"}, "the search input stretches to the box's height, so the floor reaches the target a tap focuses"},
261 "flex": {[]string{"none"}, "the bulk bar's close link, which its flex row shrank past the floor"},
262}
263
264// TestTheFloorBlockCarriesOnlySizes reads every declaration in the
265// every-width floor block against floorDeclarations. The browser check
266// beside it (TestTheFloorChangesNothingButSizeOnADesktop) compares the
267// properties and boxes it knows to read, so a floor rule that also
268// moved a control (position: relative and an inset, a margin) left its
269// size and every property it reads alone, and passed. A property is
270// either here with its reason, or it goes in the touch query.
271func TestTheFloorBlockCarriesOnlySizes(t *testing.T) {
272 _, block := floorBlock(t)
273 rules := leafRules(t, block)
274 if len(rules) < 30 {
275 t.Fatalf("read %d rules in the floor block; the parse no longer finds them", len(rules))
276 }
277 used := map[string]bool{}
278 for _, r := range rules {
279 for _, d := range r.decls {
280 prop, value := d[0], d[1]
281 if d[2] != "" {
282 value += " " + d[2]
283 }
284 used[prop] = true
285 allowed, ok := floorDeclarations[prop]
286 if !ok {
287 t.Errorf("%s sets %s: %s in the floor block, which reaches a desktop; a declaration that is not a size or what makes one take hold goes in the touch query", firstSelector(r.selector), prop, value)
288 continue
289 }
290 if allowed.values != nil && !slices.Contains(allowed.values, value) {
291 t.Errorf("%s sets %s: %s in the floor block; the floor needs %s only as %s", firstSelector(r.selector), prop, value, prop, strings.Join(allowed.values, " or "))
292 }
293 }
294 }
295 // An entry nothing uses would let the next rule set it unexamined.
296 for prop := range floorDeclarations {
297 if !used[prop] {
298 t.Errorf("floorDeclarations allows %s, which no rule in the floor block sets; take it out", prop)
299 }
300 }
301}
302
303// firstSelector names a rule by the first selector in its list, enough
304// to find it in the block.
305func firstSelector(sel string) string {
306 if s := splitSelectorList(sel); len(s) > 0 {
307 return s[0]
308 }
309 return sel
310}
311
312// floorElsewhere are the inventory's controls whose size does not come
313// from --rst-target, each with where it does come from.
314var floorElsewhere = map[string]string{
315 "[rst-shell-profile] > summary": "the avatar inside it, with the summary's padding, is the target",
316 "[rst-shell-profile][open] > summary": "the same avatar, open",
317 "[rst-shell-nav] > a": "the phone index's rows are 3rem, over the floor, in their own query",
318 "[rst-shell-back] > a": "its own 2.75rem height is over the floor; only its width reads the token",
319}
320
321// tapEntry is one row of spec §1.4's table: Key is a substring of the
322// attribute spelling of the rules that style the idiom, Marker a string
323// the sizing fixture (or the modal sample) must contain for the idiom to
324// be measured at all (for controls a script draws at run time, the
325// attribute that asks for them), and Probe the selector of the element
326// the target drive measures for it. The drive fails if no element
327// matching Probe was measured, so an idiom cannot drop out of the
328// measurement silently. Probe may be prefixed "narrow:" (rendered only
329// below 800px, so not expected at 1024), "modal:" (on the modal page),
330// or be "elsewhere:<Test>" for a control a drive of its own measures in
331// a state the sizing page never reaches.
332type tapEntry struct{ Key, Idiom, Marker, Probe string }
333
334var tapInventory = []tapEntry{
335 {"[rst-btn]", "Buttons, all sizes", "rst-btn", "[rst-btn]"},
336 {"[rst-input]", "Inputs and selects", "rst-input", "[rst-input]"},
337 {"[rst-textarea]", "Textareas", "rst-textarea", "[rst-textarea]"},
338 {"[rst-search]", "Search box", "rst-search", "[rst-search] input[type=search]"},
339 {"[rst-search-clear]", "Search box: its clear link", "rst-search-clear", "[rst-search-clear]"},
340 {"[rst-ftok] a", "Filter chip's remove link", "rst-ftok", "[rst-ftok] a"},
341 {"[rst-help]", "Help link", "rst-help", "[rst-help]"},
342 {"[rst-dropdown] > summary", "Dropdown summaries (list-bar, header, account, locale)", "rst-dropdown", "[rst-dropdown] > summary"},
343 {"[rst-menu-group] > summary", "Nested menu-group summaries", "rst-menu-group", "[rst-menu-group] > summary"},
344 {"[rst-dropdown-menu] a", "Menu items: dropdown", "rst-dropdown-menu", "[rst-dropdown-menu] a"},
345 {"[rst-dropdown-menu] button", "Menu items: dropdown buttons", "rst-dropdown-menu", "[rst-dropdown-menu] button"},
346 {"[rst-row-menu-panel] a", "Menu items: row menu", "rst-row-menu-panel", "[rst-row-menu-panel] a"},
347 {"[rst-row-menu-panel] button", "Menu items: row menu buttons", "rst-row-menu-panel", "[rst-row-menu-panel] button"},
348 {"[rst-locale] button", "Menu items: locale", "rst-locale", "[rst-locale] button"},
349 {"[rst-combo-option]", "Combobox options", "data-rst-select", "[rst-combo-option]"},
350 {"[rst-dtp-row]", "Date-picker rows", "data-rst-date", "[rst-dtp-row]"},
351 {"[rst-dtp-pick]", "Date-picker pick button", "data-rst-date", "[rst-dtp-pick]"},
352 {"[rst-cal-nav]", "Calendar nav", "data-rst-date", "elsewhere:TestTheCalendarDocksAndItsDaysAreTaps"},
353 {"[rst-cal-day]", "Calendar days", "data-rst-date", "elsewhere:TestTheCalendarDocksAndItsDaysAreTaps"},
354 {"[rst-row-action]", "Row action pill", "rst-row-action", "[rst-row-action]"},
355 {"[rst-row-menu] > summary", "Row kebab", "rst-row-menu", "[rst-row-menu] > summary"},
356 {"[rst-selbox]", "Row checkbox", "rst-selbox", "[rst-selbox] input"},
357 {"[rst-person]", "Standalone person link", "rst-person", "a[rst-person]"},
358 {"[rst-pagination] > a", "Pagination chips", "rst-pagination", "[rst-pagination] > a"},
359 {"[rst-seg-tabs] a", "Segmented tabs", "rst-seg-tabs", "[rst-seg-tabs] a"},
360 {"[rst-seg-tabs] > button", "Segmented tabs: buttons", "rst-seg-tabs", "[rst-seg-tabs] > button"},
361 {"[rst-side-nav] a", "Side nav links", "rst-side-nav", "[rst-side-nav] a"},
362 {"[rst-switch]", "Switch", "rst-switch", "[rst-switch] input"},
363 {"[rst-choice-cards] label", "Choice cards", "rst-choice-cards", "[rst-choice-cards] input"},
364 {"[rst-tblock-head]", "Toggle-block head", "rst-tblock-head", "[rst-tblock-head] input"},
365 {"[rst-bulkbar-close]", "Bulk bar: close", "rst-bulkbar-close", "[rst-bulkbar-close]"},
366 {"[rst-bulkbar-escalate]", "Bulk bar: escalate link", "rst-bulkbar-escalate", "[rst-bulkbar-escalate]"},
367 {"[rst-modal-close]", "Modal close", "rst-modal-close", "modal:[rst-modal-close]"},
368 {"[rst-modal-panel] > nav a", "Modal panel nav links", "rst-modal-panel", "modal:[rst-modal-panel] > nav a"},
369 {"[rst-back-nav] a", "Back-nav link", "rst-back-nav", "[rst-back-nav] a"},
370 {"[rst-signin-providers] > a", "Sign-in: the link under the provider buttons", "rst-signin-providers", "[rst-signin-providers] > a"},
371 {"[rst-shell-brand]", "Shell: brand", "rst-shell-brand", "[rst-shell-brand]"},
372 {"[rst-shell-nav] a", "Shell: nav links", "rst-shell-nav", "[rst-shell-nav] a"},
373 {"[rst-shell-menu] > summary", "Shell: Menu summary", "rst-shell-menu", "narrow:[rst-shell-menu] > summary"},
374 {"[rst-shell-menu][open] > summary", "Shell: the open Menu summary", "rst-shell-menu", "elsewhere:TestTheCardsControlsAreTaps"},
375 {"[rst-shell-account] > summary", "Shell: the account summary inside the card", "rst-shell-account", "[rst-shell-account] > summary"},
376 {"[rst-shell-chrome] > summary", "Legacy sidebar drawer summary", "rst-shell-chrome", "narrow:[rst-shell-chrome] > summary"},
377 {"[rst-shell-profile] > summary", "Shell: the profile menu's avatar", "rst-shell-profile", "elsewhere:TestTheProfileMenuOnThePhoneIndex"},
378 {"[rst-shell-profile][open] > summary", "Shell: the open profile menu's avatar", "rst-shell-profile", "elsewhere:TestTheProfileMenuOnThePhoneIndex"},
379 {"[rst-shell-back] > a", "Shell: the back control", "rst-shell-back", "narrow:[rst-shell-back] > a"},
380 {"[rst-shell-nav] > a", "Shell: the index rows (3rem)", "rst-shell-nav", "elsewhere:TestTheIndexRowsRoundEachRunOfLinks"},
381}
382
383// tapExempt are the interactive rules that are deliberately not held to
384// the floor, each with its reason. Nothing else may be added here: a
385// control that does not fit the floor gets a rule in the floor block.
386var tapExempt = map[string]string{
387 "[rst-row-main] > a": "the stretched primary link: the tap target is the whole row (spec §2)",
388 "[rst-media-name] > a": "a media-grid tile's stretched name link: the tap target is the whole tile, frame and caption",
389 "a.rst-nm": "the list grid's stretched identity link: the tap target is the whole row (spec §2)",
390 "[rst-lrow] > a[rst-person]": "the list grid's stretched person link: the tap target is the whole row (spec §2)",
391 ".rst-lrow > a.rst-person": "the class spelling of the same stretched person link",
392 "[rst-no-match] a": "a link in running text, WCAG 2.5.8's inline exception (spec §1.4, Exempt)",
393 ".rst-sr-only:focus-visible": "the hidden search submit: it exists for the keyboard and un-hides on focus; no pointer reaches it",
394}
395
396// iconFree are the inventory's controls that hold no icon of their own,
397// so the rule sizing an icon inside a control need not name them.
398var iconFree = map[string]string{
399 "[rst-input]": "a field: its text, not an icon",
400 "[rst-textarea]": "a field: its text, not an icon",
401 "[rst-cal-day]": "a day's number",
402 "[rst-selbox]": "a checkbox",
403 "[rst-person]": "an avatar, which is not an icon",
404 "[rst-shell-profile] > summary": "an avatar, which is not an icon",
405 "[rst-shell-profile][open] > summary": "an avatar, which is not an icon",
406 "[rst-shell-menu][open] > summary": "the same summary as [rst-shell-menu] > summary, open",
407 "[rst-shell-nav] > a": "the index rows, which [rst-shell-nav] a names",
408}
409
410// TestEveryControlSizesTheIconInIt holds the two lists together: an
411// icon is 16px inside a control and follows its text everywhere else,
412// and "a control" is the inventory the target floor implements. A
413// control added to the floor and not to the icon rule would show its
414// icon at whatever size its text happens to be, which is what the 16px
415// rule exists to stop.
416func TestEveryControlSizesTheIconInIt(t *testing.T) {
417 var sel string
418 for _, r := range leafRules(t, string(fiesta.TokensCSS())) {
419 if strings.HasPrefix(r.selector, ":where(") && strings.Contains(r.selector, ") .icon") && r.sets("block-size", "1rem") {
420 sel = collapseSpace(r.selector)
421 }
422 }
423 if sel == "" {
424 t.Fatal("tokens.css has no :where(...) .icon rule sizing icons in controls at 1rem")
425 }
426 for _, e := range tapInventory {
427 if _, free := iconFree[e.Key]; free {
428 continue
429 }
430 if !strings.Contains(sel, e.Key) {
431 t.Errorf("%s (%s) is a control the floor holds, and the rule that sizes an icon inside a control does not name it", e.Idiom, e.Key)
432 }
433 }
434 for key := range iconFree {
435 found := false
436 for _, e := range tapInventory {
437 found = found || e.Key == key
438 }
439 if !found {
440 t.Errorf("iconFree names %q, which is not in the inventory", key)
441 }
442 }
443}
444
445// endsOnControlElement reports whether a selector's last compound is an
446// a, button, summary or label: the rule styles that element. It reads
447// the selector with cssSpan, so a space or a bracket inside a quoted
448// attribute value is text, not the start of a compound.
449func endsOnControlElement(sel string) bool {
450 depth, last := 0, 0
451 for i := 0; i < len(sel); {
452 end, _, err := cssSpan(sel, i)
453 if err != nil {
454 return false
455 }
456 switch c := sel[i]; c {
457 case '(', '[':
458 depth++
459 case ')', ']':
460 depth--
461 case ' ', '>', '+', '~':
462 if depth == 0 {
463 last = i + 1
464 }
465 }
466 i = end
467 }
468 return regexp.MustCompile(`^(a|button|summary|label)($|[^a-zA-Z0-9_-])`).MatchString(strings.TrimSpace(sel[last:]))
469}
470
471// interactiveCSSRules is every innermost rule in css that carries cursor:
472// pointer or styles an a, button, summary or label.
473func interactiveCSSRules(t testing.TB, css string) []leafRule {
474 t.Helper()
475 var out []leafRule
476 for _, r := range leafRules(t, css) {
477 hit := slices.Contains(r.decls, [3]string{"cursor", "pointer", ""}) || slices.Contains(r.decls, [3]string{"cursor", "pointer", "!important"})
478 for _, s := range splitSelectorList(r.selector) {
479 hit = hit || endsOnControlElement(s)
480 }
481 if hit {
482 out = append(out, r)
483 }
484 }
485 return out
486}
487
488// TestEveryInteractiveRuleIsInTheTapInventory is §10.1's inventory
489// gate: a new control cannot arrive in tokens.css without a decision
490// about its touch size, because its rule has to name an idiom in the
491// table above (or an exemption with a reason), and every idiom in the
492// table has to be on the page the browser drive measures.
493func TestEveryInteractiveRuleIsInTheTapInventory(t *testing.T) {
494 rules := interactiveCSSRules(t, string(fiesta.TokensCSS()))
495 if len(rules) < 40 {
496 t.Fatalf("found only %d interactive rules in tokens.css; the reader is broken, not the file", len(rules))
497 }
498 for _, r := range rules {
499 named := false
500 for _, s := range splitSelectorList(r.selector) {
501 for _, e := range tapInventory {
502 named = named || strings.Contains(s, e.Key)
503 }
504 for key := range tapExempt {
505 named = named || strings.Contains(s, key)
506 }
507 }
508 if !named {
509 t.Errorf("tokens.css styles a control no row of §1.4's inventory names:\n\t%s\n\tadd a floor rule and a tapInventory row, or say why it is exempt", r.selector)
510 }
511 }
512 // Where rastrillo looked in its sizing fixture plus the modal sample, this
513 // looks in the sizing fixture plus every example piece, the modal's
514 // among them: a marker no example carries is one no drive can reach.
515 fixture, err := os.ReadFile("testdata/fixtures/sizing.html")
516 if err != nil {
517 t.Fatal(err)
518 }
519 pieces, err := example.All(fiesta.FS())
520 if err != nil {
521 t.Fatal(err)
522 }
523 var page strings.Builder
524 page.Write(fixture)
525 for _, p := range pieces {
526 page.WriteString(p.Markup)
527 }
528 for _, e := range tapInventory {
529 if !strings.Contains(page.String(), e.Marker) {
530 t.Errorf("%s (%s): neither the sizing fixture nor any example renders anything carrying %q, so the drive never measures it", e.Idiom, e.Key, e.Marker)
531 }
532 }
533}
534