| 1 | package check |
| 2 | |
| 3 | import ( |
| 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. |
| 31 | type 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. |
| 38 | func 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. |
| 77 | func 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. |
| 88 | func 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. |
| 104 | func 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. |
| 126 | var 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. |
| 137 | var 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. |
| 144 | func 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. |
| 152 | func 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. |
| 162 | func 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. |
| 178 | func 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. |
| 189 | func 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. |
| 206 | var 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. |
| 213 | func 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. |
| 220 | func 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. |
| 253 | func 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. |
| 309 | var 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. |
| 356 | func 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. |
| 404 | func 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. |
| 425 | func 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 | |