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 "io/fs"
5 "regexp"
6 "slices"
7 "sort"
8 "strings"
9 "testing"
10
11 "amadan.net/rastrillo/fiesta"
12 "amadan.net/rastrillo/fiesta/example"
13 "amadan.net/rastrillo/fiesta/internal/markup"
14)
15
16// The examples and the stylesheets, held to each other in both
17// directions. Every rst- name an example writes has a selector, so a
18// state in the gallery cannot carry an attribute nothing styles; and
19// every rst- name the stylesheets style has an example, so a rule
20// cannot arrive with nothing rendering it. The vocabulary readers
21// (rstVocabulary, tokensStyle) are from rastrillo's ui/ui_test.go,
22// where the two checks ran over the styleguide samples and the
23// layouts; here they run over every example state.
24
25// ── Reading the examples ─────────────────────────────────────────────
26
27// exampleState is one state's markup with its address. For a layout
28// the markup is the whole document: a shell's vocabulary starts on
29// <body> (the stage shell writes <body rst-stage>), which the body's
30// contents alone would miss.
31type exampleState struct{ Name, State, Markup string }
32
33// everyState reads every state of every example: the non-document
34// states through example.All, and each document entry's states whole,
35// through example.Document. It fails for a set of a hundred states or
36// fewer: an empty or truncated examples.json would otherwise pass both
37// coverage gates by giving them nothing to check.
38func everyState(t *testing.T, fsys fs.FS) []exampleState {
39 t.Helper()
40 m, err := example.Load(fsys)
41 if err != nil {
42 t.Fatal(err)
43 }
44 pieces, err := example.All(fsys)
45 if err != nil {
46 t.Fatal(err)
47 }
48 var out []exampleState
49 for _, p := range pieces {
50 out = append(out, exampleState{p.Name, p.State, p.Markup})
51 }
52 for _, e := range m.Examples {
53 if !e.Document {
54 continue
55 }
56 for _, s := range e.States {
57 doc, err := example.Document(fsys, e.Name, s.ID)
58 if err != nil {
59 t.Fatal(err)
60 }
61 out = append(out, exampleState{e.Name, s.ID, doc})
62 }
63 }
64 if len(out) <= 100 {
65 t.Fatalf("read %d example states, want more than 100; a set this small is a broken manifest, not a design system", len(out))
66 }
67 return out
68}
69
70// ── The vocabulary ───────────────────────────────────────────────────
71
72// rstVocabulary is every rst- attribute a piece of markup writes, as
73// the selector tokens.css would need for it: "rst-box" for a kind or a
74// part, and "rst-lrow~=head" for a variant, one entry per variant in
75// the value. The utility classes are a separate vocabulary, read by
76// rstClasses.
77func rstVocabulary(markup string) map[string]bool {
78 seen := map[string]bool{}
79 for _, tag := range openTagInFixture.FindAllString(markup, -1) {
80 for name := range tagVocabulary(tag) {
81 seen[name] = true
82 }
83 }
84 return seen
85}
86
87// tagVocabulary is rstVocabulary for one open tag.
88func tagVocabulary(tag string) map[string]bool {
89 seen := map[string]bool{}
90 // The class attribute still carries the seven utilities, and
91 // their names look exactly like an attribute's once the quotes
92 // are ignored. Take it out of the tag before reading.
93 tag = classInMarkup.ReplaceAllString(tag, "")
94 for _, m := range rstAttrInFixture.FindAllStringSubmatch(tag, -1) {
95 seen[m[1]] = true
96 for _, variant := range strings.Fields(m[2]) {
97 seen[m[1]+"~="+variant] = true
98 }
99 }
100 return seen
101}
102
103// rstClasses is every rst- token in a class attribute of markup.
104func rstClasses(markup string) map[string]bool {
105 seen := map[string]bool{}
106 for _, m := range classInMarkup.FindAllStringSubmatch(markup, -1) {
107 for _, class := range strings.Fields(m[1]) {
108 if strings.HasPrefix(class, "rst-") {
109 seen[class] = true
110 }
111 }
112 }
113 return seen
114}
115
116// qualifiedOnly are the attributes that never appear alone in a
117// selector, because they mean nothing without the kind they modify.
118// rst-tone is the tone OF a status, a badge or a stat, and every rule
119// that reads it names both, [rst-status][rst-tone~="positive"], so a
120// bare [rst-tone] would be a selector for a concept the grammar does
121// not have.
122//
123// Only the bare name is exempted. The variants an example writes are
124// still required to exist, so rst-tone="lavender" fails here exactly
125// as it should.
126var qualifiedOnly = map[string]bool{"rst-tone": true}
127
128// unstyledByDesign are the entries an example writes that the
129// stylesheets name no selector for, on purpose, each with the reason.
130// On names the attribute the element must also carry for the entry to
131// apply, so the callout's default tone is exempt on a callout and
132// nowhere else; "" is any element. Nothing else may be added here: an
133// attribute an example writes and nothing styles is a component that
134// renders as the plain element. The gate holds the map in both
135// directions: an entry the stylesheets style, or one no example writes
136// where it applies, fails.
137var unstyledByDesign = map[string]struct{ On, Why string }{
138 "rst-job": {"", "a semantic hook, never a style: the busy shim finds the job by data-poll, and an app keys its own CSS off it"},
139 "rst-tone~=info": {"rst-callout", "the callout's default tone: the base [rst-callout] rule paints it, and only the three others carry a tone rule"},
140}
141
142// unstyledHere reports whether name is exempt on an element whose
143// vocabulary is names.
144func unstyledHere(name string, names map[string]bool) bool {
145 u, ok := unstyledByDesign[name]
146 return ok && (u.On == "" || names[u.On])
147}
148
149// tokensStyle reports whether a stylesheet carries a selector for one
150// entry of that vocabulary, given its attribute selectors, which
151// styledAttributes reads.
152func tokensStyle(attributes map[string]bool, name string) bool {
153 if kind, variant, ok := strings.Cut(name, "~="); ok {
154 return attributes["["+kind+"~=\""+variant+"\"]"]
155 }
156 return attributes["["+name+"]"]
157}
158
159// styledAttributes is every attribute selector the rules of css hold,
160// read with cssRules and selectorAttributes: searched as text, a
161// [rst-input~="primary"] in a comment or a string counted as styled.
162func styledAttributes(t testing.TB, css string) map[string]bool {
163 t.Helper()
164 out := map[string]bool{}
165 for _, rule := range cssRules(t, css) {
166 for _, sel := range rule.selectors {
167 for _, attr := range selectorAttributes(sel) {
168 out[attr] = true
169 }
170 }
171 }
172 return out
173}
174
175// styledCSS is tokens.css and shell.css together, each read as a
176// stylesheet first, so a name mentioned in prose does not count as
177// styled.
178func styledCSS(t testing.TB) string {
179 t.Helper()
180 return stripCSSComments(t, string(fiesta.TokensCSS())) + "\n" + stripCSSComments(t, string(fiesta.ShellCSS()))
181}
182
183// styledClasses is every rst- class the selectors of css name, whole:
184// a class is styled when it is a selector's class token, not when it
185// is the start of one (.rst-m-hide styles no element whose class is
186// rst-m). It reads the selectors of css's rules with cssRules and
187// selectorClasses: searched as text, a .rst- inside a string or a
188// value counted as a class.
189func styledClasses(t testing.TB, css string) map[string]bool {
190 t.Helper()
191 out := map[string]bool{}
192 for _, rule := range cssRules(t, css) {
193 for _, sel := range rule.selectors {
194 for _, class := range selectorClasses(sel) {
195 if strings.HasPrefix(class, "rst-") {
196 out[class] = true
197 }
198 }
199 }
200 }
201 return out
202}
203
204// attrInSelector reads one rst- attribute selector, bare or with a
205// variant, as selectorAttributes returns it.
206var attrInSelector = regexp.MustCompile(`^\[(rst-[a-z0-9-]+)(?:~="([^"]+)")?\]$`)
207
208// styledVocabulary is every entry the two stylesheets' selectors name:
209// each attribute, each attribute-and-variant, and each utility class.
210// The other classes are not entries of their own: every one is the
211// twin of an attribute selector in the same rule (spelling_test.go),
212// so the attribute's example is its example.
213func styledVocabulary(t testing.TB) map[string]bool {
214 t.Helper()
215 return vocabularyOf(t, string(fiesta.TokensCSS()), string(fiesta.ShellCSS()))
216}
217
218// vocabularyOf is styledVocabulary over any stylesheets: the baseline's
219// five as well as the tree's two.
220func vocabularyOf(t testing.TB, sheets ...string) map[string]bool {
221 t.Helper()
222 out := map[string]bool{}
223 for _, css := range sheets {
224 for _, rule := range cssRules(t, css) {
225 for _, sel := range rule.selectors {
226 for _, attr := range selectorAttributes(sel) {
227 if m := attrInSelector.FindStringSubmatch(attr); m != nil {
228 out[m[1]] = true
229 if m[2] != "" {
230 out[m[1]+"~="+m[2]] = true
231 }
232 }
233 }
234 for _, class := range selectorClasses(sel) {
235 if _, utility := markup.Utilities[class]; utility {
236 out["."+class] = true
237 }
238 }
239 }
240 }
241 }
242 return out
243}
244
245// ── The gates ────────────────────────────────────────────────────────
246
247// TestEveryExampleAttributeAndClassIsStyled: every rst- attribute name,
248// every token of an rst- attribute's value, and every rst- class in
249// any example state has a selector in tokens.css or shell.css. An
250// example that wrote an attribute nothing styles would render as the
251// plain element and look, in the gallery, like the design system's
252// idea of that component.
253func TestEveryExampleAttributeAndClassIsStyled(t *testing.T) {
254 css := styledCSS(t)
255 attributes := styledAttributes(t, css)
256 // The vocabulary below is read off the literal [rst-…] and .rst-…;
257 // a spelling a browser matches and that read misses is refused first.
258 assertPlainSpelling(t, "tokens.css and shell.css", css)
259 classes := styledClasses(t, css)
260 exempted := map[string]bool{}
261 for _, s := range everyState(t, fiesta.FS()) {
262 missing := map[string]bool{}
263 for _, tag := range openTagInFixture.FindAllString(s.Markup, -1) {
264 names := tagVocabulary(tag)
265 for name := range names {
266 if qualifiedOnly[name] {
267 continue
268 }
269 if unstyledHere(name, names) {
270 exempted[name] = true
271 continue
272 }
273 if !tokensStyle(attributes, name) {
274 missing[name] = true
275 }
276 }
277 }
278 for class := range rstClasses(s.Markup) {
279 if !classes[class] {
280 missing["."+class] = true
281 }
282 }
283 names := make([]string, 0, len(missing))
284 for name := range missing {
285 names = append(names, name)
286 }
287 sort.Strings(names)
288 for _, name := range names {
289 t.Errorf("example %s/%s writes %s, which neither tokens.css nor shell.css has a selector for", s.Name, s.State, name)
290 }
291 }
292 for name, u := range unstyledByDesign {
293 switch {
294 case strings.TrimSpace(u.Why) == "":
295 t.Errorf("unstyledByDesign lists %s with no reason", name)
296 case tokensStyle(attributes, name):
297 t.Errorf("unstyledByDesign lists %s, and the stylesheets style it now; remove the entry", name)
298 case !exempted[name]:
299 t.Errorf("unstyledByDesign lists %s, and no example writes it where the entry applies; remove the entry", name)
300 }
301 }
302}
303
304// selectorsWithoutAnExample is every entry of the stylesheets'
305// vocabulary that no example state writes, each with the reason it
306// cannot. Seeded from the gate's first run and reviewed line by line:
307// an entry whose reason stops being true is removed, and an entry that
308// gains an example fails the gate until it is removed.
309var selectorsWithoutAnExample = map[string]string{
310 // Drawn by a script at run time, on a page that loads it: an
311 // example holds the field the script enhances, never the markup it
312 // draws, so these states are only reachable by the script.
313 "rst-cal": "drawn by calendar.js at run time, inside a data-rst-date field's picker",
314 "rst-cal-day": "drawn by calendar.js at run time: one day of the grid",
315 "rst-cal-grid": "drawn by calendar.js at run time: the grid of days",
316 "rst-cal-head": "drawn by calendar.js at run time: the weekday head row",
317 "rst-cal-nav": "drawn by calendar.js at run time: the month-step buttons",
318 "rst-cal-nav~=next": "drawn by calendar.js at run time: the forward month-step button",
319 "rst-cal-title": "drawn by calendar.js at run time: the month's name",
320 "rst-combo": "drawn by select.js at run time, around a data-rst-select field",
321 "rst-combo-desc": "drawn by select.js at run time: an option's description line",
322 "rst-combo-empty": "drawn by select.js at run time: the no-match row",
323 "rst-combo-has-lead": "drawn by select.js at run time: a list whose options carry a lead",
324 "rst-combo-lead": "drawn by select.js at run time: an option's lead (an avatar or a swatch)",
325 "rst-combo-list": "drawn by select.js at run time: the popup list",
326 "rst-combo-option": "drawn by select.js at run time: one option row",
327 "rst-combo-rows": "drawn by select.js at run time: the scrolling rows inside the list",
328 "rst-combo-sep": "drawn by select.js at run time: the rule between groups",
329 "rst-combo-up": "drawn by select.js at run time: the list flipped above the field",
330 "rst-select-group": "drawn by select.js at run time: an optgroup's heading in the list",
331 "rst-dtp": "drawn by datetime.js at run time, around a data-rst-date field",
332 "rst-dtp-hint": "drawn by datetime.js at run time: the parsed-date hint under the field",
333 "rst-dtp-input": "drawn by datetime.js at run time: the text input it puts in place of the native one",
334 "rst-dtp-label": "drawn by datetime.js at run time: a quick-pick row's label",
335 "rst-dtp-list": "drawn by datetime.js at run time: the quick-pick list",
336 "rst-dtp-pick": "drawn by datetime.js at run time: the button that opens the calendar",
337 "rst-dtp-quick": "drawn by datetime.js at run time: the quick-pick panel",
338 "rst-dtp-row": "drawn by datetime.js at run time: one quick-pick row",
339 "rst-dtp-row~=alt": "drawn by datetime.js at run time: a quick-pick row's alternate shade",
340 "rst-dtp-row~=set": "drawn by datetime.js at run time: the quick-pick row that is the field's value",
341 "rst-dtp-set": "drawn by datetime.js at run time: the chosen value's readout",
342 // Coverage gaps, not exemptions: markup a page can hold that no
343 // example state shows yet. Rastrillo's gallery shows neither either.
344 // Each goes when a state is added for it.
345 "rst-pagination-gap~=narrow": "no example state shows this yet; Rastrillo's gallery has none either. It is the ellipsis a phone shows where a run of pages it hides carries no gap of its own, as a pagination with every page listed would write; the long-list state puts a gap in every such run",
346 "rst-person-av~=placeholder": "no example state shows this yet; Rastrillo's gallery has none either. It is the empty avatar a layout draws for an app with no profile block, and every layout example supplies one; the markup exists in testdata/fixtures/profile_browser_test",
347 // Retired, and kept for an app still on it.
348 "rst-shell-chrome": "the retired sidebar drawer: no layout writes it, and tokens.css keeps its rules for an app that still does (see the comment above them)",
349}
350
351// TestEverySelectorAppearsInAnExample is the other direction: every
352// attribute, attribute-and-variant and utility class the stylesheets
353// style is written by some example state, or is listed above with the
354// reason it is not. A rule that arrives with nothing rendering it
355// fails here by name.
356func TestEverySelectorAppearsInAnExample(t *testing.T) {
357 used := map[string]bool{}
358 for _, s := range everyState(t, fiesta.FS()) {
359 for name := range rstVocabulary(s.Markup) {
360 used[name] = true
361 }
362 for class := range rstClasses(s.Markup) {
363 used["."+class] = true
364 }
365 }
366 // The two stylesheets name some 230 attributes, variants and
367 // utilities between them; far fewer is a reader that stopped
368 // reading, not a design system that shrank.
369 styled := styledVocabulary(t)
370 if len(styled) < 200 {
371 t.Fatalf("read %d styled names from the stylesheets; the reader is broken, not the files", len(styled))
372 }
373 var missing []string
374 for name := range styled {
375 if used[name] || qualifiedOnly[name] {
376 continue
377 }
378 if why, listed := selectorsWithoutAnExample[name]; listed {
379 if strings.TrimSpace(why) == "" {
380 t.Errorf("selectorsWithoutAnExample lists %s with no reason", name)
381 }
382 continue
383 }
384 missing = append(missing, name)
385 }
386 sort.Strings(missing)
387 for _, name := range missing {
388 t.Errorf("the stylesheets style %s and no example writes it; add a state, or list it in selectorsWithoutAnExample with the reason", name)
389 }
390 for name := range selectorsWithoutAnExample {
391 switch {
392 case !styled[name]:
393 t.Errorf("selectorsWithoutAnExample lists %s, which no stylesheet styles any more; remove it", name)
394 case used[name]:
395 t.Errorf("selectorsWithoutAnExample lists %s, and an example writes it now; remove the entry", name)
396 }
397 }
398}
399
400// The search form sits inside a <search> landmark, the element HTML
401// added for exactly this, and the toolbar strip sizes the landmark
402// rather than the form it wraps: the direct-child selector that used
403// to reach the form reaches the landmark instead.
404func TestTheSearchFormSitsInASearchLandmark(t *testing.T) {
405 got := defaultMarkup(t, "list-bar-search")
406 if !strings.Contains(got, "<search><form") || !strings.Contains(got, "</form></search>") {
407 t.Errorf("the search form is not wrapped in a <search> landmark: %s", got)
408 }
409 sels := ruleSelectors(t, string(fiesta.TokensCSS()))
410 for _, want := range []string{"[rst-lbar] > search", "[rst-list] > search"} {
411 if !slices.ContainsFunc(sels, func(s string) bool { return strings.Contains(s, want) }) {
412 t.Errorf("tokens.css has no %q rule; wrapping the form broke the layout it used to size", want)
413 }
414 }
415}
416
417// The sidebar rail's foot is one profile menu: an avatar button that
418// opens a card holding the person, the language switcher and the
419// account's links, in that order, in a wrapper the layout owns. It is
420// a menu rather than two blocks laid out flat because on a phone the
421// same control sits at the top of the index, and the foot of a long
422// index is out of reach. The geometry is measured by the profile
423// drive; this pins the markup it needs, read off the sidebar layout's
424// default document.
425func TestTheSidebarRailFootIsOneProfileMenu(t *testing.T) {
426 s := defaultMarkup(t, "layout-sidebar")
427 foot := strings.Index(s, `<div rst-shell-rail-foot>`)
428 if foot < 0 {
429 t.Fatal("the sidebar layout has no rail foot; the profile has nothing to sit at the bottom of")
430 }
431 nav := strings.Index(s, `<nav rst-shell-nav>`)
432 if nav < 0 || nav > foot {
433 t.Error("the rail's nav does not come before its foot: focus order on a desktop must match the rail, top to bottom")
434 }
435 // rst-shell-menu, NOT rst-menus: <details name> exclusivity is
436 // document-wide, and the language menu inside the card is in
437 // rst-menus, so sharing that group would close the card the moment
438 // the language menu opened.
439 const open = `<div rst-shell-rail-foot><details rst-dropdown rst-shell-profile name="rst-shell-menu"><summary><span rst-shell-avatar>`
440 if !strings.HasPrefix(s[foot:], open) {
441 t.Errorf("the rail foot does not open with the profile menu and its avatar summary; it reads %.200q", s[foot:])
442 }
443 end := strings.Index(s[foot:], `</details></div>`)
444 if end < 0 {
445 t.Fatal("the rail foot never closes its profile menu")
446 }
447 card := s[foot : foot+end]
448 last := -1
449 for _, part := range []string{`<div rst-dropdown-menu><div rst-shell-who>`, `<details rst-dropdown rst-locale name="rst-menus">`, `<a href="#">Profile</a>`, `<hr><a href="#">Sign out</a>`} {
450 at := strings.Index(card, part)
451 if at < 0 {
452 t.Errorf("the profile card has no %s", part)
453 continue
454 }
455 if at < last {
456 t.Errorf("the profile card holds %s out of order: the person, then the language, then the account's links", part)
457 }
458 last = at
459 }
460 if !slices.ContainsFunc(ruleSelectors(t, string(fiesta.TokensCSS())), func(s string) bool {
461 return strings.Contains(s, `[rst-shell-rail-foot]:has([rst-shell-who]:only-child [rst-person-av~="placeholder"])`)
462 }) {
463 t.Error("tokens.css does not hide the placeholder profile with nothing to choose: an app with no profile, no account and no language would get a button opening an empty card")
464 }
465}
466