package check import ( "io/fs" "regexp" "slices" "sort" "strings" "testing" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/markup" ) // The examples and the stylesheets, held to each other in both // directions. Every rst- name an example writes has a selector, so a // state in the gallery cannot carry an attribute nothing styles; and // every rst- name the stylesheets style has an example, so a rule // cannot arrive with nothing rendering it. The vocabulary readers // (rstVocabulary, tokensStyle) are from rastrillo's ui/ui_test.go, // where the two checks ran over the styleguide samples and the // layouts; here they run over every example state. // ── Reading the examples ───────────────────────────────────────────── // exampleState is one state's markup with its address. For a layout // the markup is the whole document: a shell's vocabulary starts on // (the stage shell writes ), which the body's // contents alone would miss. type exampleState struct{ Name, State, Markup string } // everyState reads every state of every example: the non-document // states through example.All, and each document entry's states whole, // through example.Document. It fails for a set of a hundred states or // fewer: an empty or truncated examples.json would otherwise pass both // coverage gates by giving them nothing to check. func everyState(t *testing.T, fsys fs.FS) []exampleState { t.Helper() m, err := example.Load(fsys) if err != nil { t.Fatal(err) } pieces, err := example.All(fsys) if err != nil { t.Fatal(err) } var out []exampleState for _, p := range pieces { out = append(out, exampleState{p.Name, p.State, p.Markup}) } for _, e := range m.Examples { if !e.Document { continue } for _, s := range e.States { doc, err := example.Document(fsys, e.Name, s.ID) if err != nil { t.Fatal(err) } out = append(out, exampleState{e.Name, s.ID, doc}) } } if len(out) <= 100 { t.Fatalf("read %d example states, want more than 100; a set this small is a broken manifest, not a design system", len(out)) } return out } // ── The vocabulary ─────────────────────────────────────────────────── // rstVocabulary is every rst- attribute a piece of markup writes, as // the selector tokens.css would need for it: "rst-box" for a kind or a // part, and "rst-lrow~=head" for a variant, one entry per variant in // the value. The utility classes are a separate vocabulary, read by // rstClasses. func rstVocabulary(markup string) map[string]bool { seen := map[string]bool{} for _, tag := range openTagInFixture.FindAllString(markup, -1) { for name := range tagVocabulary(tag) { seen[name] = true } } return seen } // tagVocabulary is rstVocabulary for one open tag. func tagVocabulary(tag string) map[string]bool { seen := map[string]bool{} // The class attribute still carries the seven utilities, and // their names look exactly like an attribute's once the quotes // are ignored. Take it out of the tag before reading. tag = classInMarkup.ReplaceAllString(tag, "") for _, m := range rstAttrInFixture.FindAllStringSubmatch(tag, -1) { seen[m[1]] = true for _, variant := range strings.Fields(m[2]) { seen[m[1]+"~="+variant] = true } } return seen } // rstClasses is every rst- token in a class attribute of markup. func rstClasses(markup string) map[string]bool { seen := map[string]bool{} for _, m := range classInMarkup.FindAllStringSubmatch(markup, -1) { for _, class := range strings.Fields(m[1]) { if strings.HasPrefix(class, "rst-") { seen[class] = true } } } return seen } // qualifiedOnly are the attributes that never appear alone in a // selector, because they mean nothing without the kind they modify. // rst-tone is the tone OF a status, a badge or a stat, and every rule // that reads it names both, [rst-status][rst-tone~="positive"], so a // bare [rst-tone] would be a selector for a concept the grammar does // not have. // // Only the bare name is exempted. The variants an example writes are // still required to exist, so rst-tone="lavender" fails here exactly // as it should. var qualifiedOnly = map[string]bool{"rst-tone": true} // unstyledByDesign are the entries an example writes that the // stylesheets name no selector for, on purpose, each with the reason. // On names the attribute the element must also carry for the entry to // apply, so the callout's default tone is exempt on a callout and // nowhere else; "" is any element. Nothing else may be added here: an // attribute an example writes and nothing styles is a component that // renders as the plain element. The gate holds the map in both // directions: an entry the stylesheets style, or one no example writes // where it applies, fails. var unstyledByDesign = map[string]struct{ On, Why string }{ "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"}, "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"}, } // unstyledHere reports whether name is exempt on an element whose // vocabulary is names. func unstyledHere(name string, names map[string]bool) bool { u, ok := unstyledByDesign[name] return ok && (u.On == "" || names[u.On]) } // tokensStyle reports whether a stylesheet carries a selector for one // entry of that vocabulary, given its attribute selectors, which // styledAttributes reads. func tokensStyle(attributes map[string]bool, name string) bool { if kind, variant, ok := strings.Cut(name, "~="); ok { return attributes["["+kind+"~=\""+variant+"\"]"] } return attributes["["+name+"]"] } // styledAttributes is every attribute selector the rules of css hold, // read with cssRules and selectorAttributes: searched as text, a // [rst-input~="primary"] in a comment or a string counted as styled. func styledAttributes(t testing.TB, css string) map[string]bool { t.Helper() out := map[string]bool{} for _, rule := range cssRules(t, css) { for _, sel := range rule.selectors { for _, attr := range selectorAttributes(sel) { out[attr] = true } } } return out } // styledCSS is tokens.css and shell.css together, each read as a // stylesheet first, so a name mentioned in prose does not count as // styled. func styledCSS(t testing.TB) string { t.Helper() return stripCSSComments(t, string(fiesta.TokensCSS())) + "\n" + stripCSSComments(t, string(fiesta.ShellCSS())) } // styledClasses is every rst- class the selectors of css name, whole: // a class is styled when it is a selector's class token, not when it // is the start of one (.rst-m-hide styles no element whose class is // rst-m). It reads the selectors of css's rules with cssRules and // selectorClasses: searched as text, a .rst- inside a string or a // value counted as a class. func styledClasses(t testing.TB, css string) map[string]bool { t.Helper() out := map[string]bool{} for _, rule := range cssRules(t, css) { for _, sel := range rule.selectors { for _, class := range selectorClasses(sel) { if strings.HasPrefix(class, "rst-") { out[class] = true } } } } return out } // attrInSelector reads one rst- attribute selector, bare or with a // variant, as selectorAttributes returns it. var attrInSelector = regexp.MustCompile(`^\[(rst-[a-z0-9-]+)(?:~="([^"]+)")?\]$`) // styledVocabulary is every entry the two stylesheets' selectors name: // each attribute, each attribute-and-variant, and each utility class. // The other classes are not entries of their own: every one is the // twin of an attribute selector in the same rule (spelling_test.go), // so the attribute's example is its example. func styledVocabulary(t testing.TB) map[string]bool { t.Helper() return vocabularyOf(t, string(fiesta.TokensCSS()), string(fiesta.ShellCSS())) } // vocabularyOf is styledVocabulary over any stylesheets: the baseline's // five as well as the tree's two. func vocabularyOf(t testing.TB, sheets ...string) map[string]bool { t.Helper() out := map[string]bool{} for _, css := range sheets { for _, rule := range cssRules(t, css) { for _, sel := range rule.selectors { for _, attr := range selectorAttributes(sel) { if m := attrInSelector.FindStringSubmatch(attr); m != nil { out[m[1]] = true if m[2] != "" { out[m[1]+"~="+m[2]] = true } } } for _, class := range selectorClasses(sel) { if _, utility := markup.Utilities[class]; utility { out["."+class] = true } } } } } return out } // ── The gates ──────────────────────────────────────────────────────── // TestEveryExampleAttributeAndClassIsStyled: every rst- attribute name, // every token of an rst- attribute's value, and every rst- class in // any example state has a selector in tokens.css or shell.css. An // example that wrote an attribute nothing styles would render as the // plain element and look, in the gallery, like the design system's // idea of that component. func TestEveryExampleAttributeAndClassIsStyled(t *testing.T) { css := styledCSS(t) attributes := styledAttributes(t, css) // The vocabulary below is read off the literal [rst-…] and .rst-…; // a spelling a browser matches and that read misses is refused first. assertPlainSpelling(t, "tokens.css and shell.css", css) classes := styledClasses(t, css) exempted := map[string]bool{} for _, s := range everyState(t, fiesta.FS()) { missing := map[string]bool{} for _, tag := range openTagInFixture.FindAllString(s.Markup, -1) { names := tagVocabulary(tag) for name := range names { if qualifiedOnly[name] { continue } if unstyledHere(name, names) { exempted[name] = true continue } if !tokensStyle(attributes, name) { missing[name] = true } } } for class := range rstClasses(s.Markup) { if !classes[class] { missing["."+class] = true } } names := make([]string, 0, len(missing)) for name := range missing { names = append(names, name) } sort.Strings(names) for _, name := range names { t.Errorf("example %s/%s writes %s, which neither tokens.css nor shell.css has a selector for", s.Name, s.State, name) } } for name, u := range unstyledByDesign { switch { case strings.TrimSpace(u.Why) == "": t.Errorf("unstyledByDesign lists %s with no reason", name) case tokensStyle(attributes, name): t.Errorf("unstyledByDesign lists %s, and the stylesheets style it now; remove the entry", name) case !exempted[name]: t.Errorf("unstyledByDesign lists %s, and no example writes it where the entry applies; remove the entry", name) } } } // selectorsWithoutAnExample is every entry of the stylesheets' // vocabulary that no example state writes, each with the reason it // cannot. Seeded from the gate's first run and reviewed line by line: // an entry whose reason stops being true is removed, and an entry that // gains an example fails the gate until it is removed. var selectorsWithoutAnExample = map[string]string{ // Drawn by a script at run time, on a page that loads it: an // example holds the field the script enhances, never the markup it // draws, so these states are only reachable by the script. "rst-cal": "drawn by calendar.js at run time, inside a data-rst-date field's picker", "rst-cal-day": "drawn by calendar.js at run time: one day of the grid", "rst-cal-grid": "drawn by calendar.js at run time: the grid of days", "rst-cal-head": "drawn by calendar.js at run time: the weekday head row", "rst-cal-nav": "drawn by calendar.js at run time: the month-step buttons", "rst-cal-nav~=next": "drawn by calendar.js at run time: the forward month-step button", "rst-cal-title": "drawn by calendar.js at run time: the month's name", "rst-combo": "drawn by select.js at run time, around a data-rst-select field", "rst-combo-desc": "drawn by select.js at run time: an option's description line", "rst-combo-empty": "drawn by select.js at run time: the no-match row", "rst-combo-has-lead": "drawn by select.js at run time: a list whose options carry a lead", "rst-combo-lead": "drawn by select.js at run time: an option's lead (an avatar or a swatch)", "rst-combo-list": "drawn by select.js at run time: the popup list", "rst-combo-option": "drawn by select.js at run time: one option row", "rst-combo-rows": "drawn by select.js at run time: the scrolling rows inside the list", "rst-combo-sep": "drawn by select.js at run time: the rule between groups", "rst-combo-up": "drawn by select.js at run time: the list flipped above the field", "rst-select-group": "drawn by select.js at run time: an optgroup's heading in the list", "rst-dtp": "drawn by datetime.js at run time, around a data-rst-date field", "rst-dtp-hint": "drawn by datetime.js at run time: the parsed-date hint under the field", "rst-dtp-input": "drawn by datetime.js at run time: the text input it puts in place of the native one", "rst-dtp-label": "drawn by datetime.js at run time: a quick-pick row's label", "rst-dtp-list": "drawn by datetime.js at run time: the quick-pick list", "rst-dtp-pick": "drawn by datetime.js at run time: the button that opens the calendar", "rst-dtp-quick": "drawn by datetime.js at run time: the quick-pick panel", "rst-dtp-row": "drawn by datetime.js at run time: one quick-pick row", "rst-dtp-row~=alt": "drawn by datetime.js at run time: a quick-pick row's alternate shade", "rst-dtp-row~=set": "drawn by datetime.js at run time: the quick-pick row that is the field's value", "rst-dtp-set": "drawn by datetime.js at run time: the chosen value's readout", // Coverage gaps, not exemptions: markup a page can hold that no // example state shows yet. Rastrillo's gallery shows neither either. // Each goes when a state is added for it. "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", "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", // Retired, and kept for an app still on it. "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)", } // TestEverySelectorAppearsInAnExample is the other direction: every // attribute, attribute-and-variant and utility class the stylesheets // style is written by some example state, or is listed above with the // reason it is not. A rule that arrives with nothing rendering it // fails here by name. func TestEverySelectorAppearsInAnExample(t *testing.T) { used := map[string]bool{} for _, s := range everyState(t, fiesta.FS()) { for name := range rstVocabulary(s.Markup) { used[name] = true } for class := range rstClasses(s.Markup) { used["."+class] = true } } // The two stylesheets name some 230 attributes, variants and // utilities between them; far fewer is a reader that stopped // reading, not a design system that shrank. styled := styledVocabulary(t) if len(styled) < 200 { t.Fatalf("read %d styled names from the stylesheets; the reader is broken, not the files", len(styled)) } var missing []string for name := range styled { if used[name] || qualifiedOnly[name] { continue } if why, listed := selectorsWithoutAnExample[name]; listed { if strings.TrimSpace(why) == "" { t.Errorf("selectorsWithoutAnExample lists %s with no reason", name) } continue } missing = append(missing, name) } sort.Strings(missing) for _, name := range missing { t.Errorf("the stylesheets style %s and no example writes it; add a state, or list it in selectorsWithoutAnExample with the reason", name) } for name := range selectorsWithoutAnExample { switch { case !styled[name]: t.Errorf("selectorsWithoutAnExample lists %s, which no stylesheet styles any more; remove it", name) case used[name]: t.Errorf("selectorsWithoutAnExample lists %s, and an example writes it now; remove the entry", name) } } } // The search form sits inside a landmark, the element HTML // added for exactly this, and the toolbar strip sizes the landmark // rather than the form it wraps: the direct-child selector that used // to reach the form reaches the landmark instead. func TestTheSearchFormSitsInASearchLandmark(t *testing.T) { got := defaultMarkup(t, "list-bar-search") if !strings.Contains(got, "") { t.Errorf("the search form is not wrapped in a landmark: %s", got) } sels := ruleSelectors(t, string(fiesta.TokensCSS())) for _, want := range []string{"[rst-lbar] > search", "[rst-list] > search"} { if !slices.ContainsFunc(sels, func(s string) bool { return strings.Contains(s, want) }) { t.Errorf("tokens.css has no %q rule; wrapping the form broke the layout it used to size", want) } } } // The sidebar rail's foot is one profile menu: an avatar button that // opens a card holding the person, the language switcher and the // account's links, in that order, in a wrapper the layout owns. It is // a menu rather than two blocks laid out flat because on a phone the // same control sits at the top of the index, and the foot of a long // index is out of reach. The geometry is measured by the profile // drive; this pins the markup it needs, read off the sidebar layout's // default document. func TestTheSidebarRailFootIsOneProfileMenu(t *testing.T) { s := defaultMarkup(t, "layout-sidebar") foot := strings.Index(s, `
`) if foot < 0 { t.Fatal("the sidebar layout has no rail foot; the profile has nothing to sit at the bottom of") } nav := strings.Index(s, `