| 1 | package gallery |
| 2 | |
| 3 | import ( |
| 4 | "html/template" |
| 5 | "io/fs" |
| 6 | "regexp" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "amadan.net/rastrillo/fiesta" |
| 12 | ) |
| 13 | |
| 14 | // The shell every page is drawn in: the sidebar shell Fiesta ships, |
| 15 | // with the rail and its filter, the pinned bar, the phone index and |
| 16 | // its way back, the display settings menu, and the scripts a gallery |
| 17 | // page loads. These read the rendered bytes; the drives in the |
| 18 | // browser-tagged files beside this one read what the scripts do to |
| 19 | // them. Where a gate below is Rastrillo's, its name is kept and its |
| 20 | // expectations are read off pageKinds() and the manifest, so a page |
| 21 | // kind or an example added later is covered the day it lands. |
| 22 | |
| 23 | // railOf cuts the sidebar's nav out of a page. Everything below reads |
| 24 | // this slice rather than the whole document, so a stray anchor |
| 25 | // somewhere in a sample cannot make the rail look complete. |
| 26 | func railOf(t *testing.T, name, page string) string { |
| 27 | t.Helper() |
| 28 | _, after, ok := strings.Cut(page, `<nav class="ds-nav" rst-shell-nav id="ds-nav"`) |
| 29 | if !ok { |
| 30 | t.Errorf("%s: no sidebar nav", name) |
| 31 | return "" |
| 32 | } |
| 33 | rail, _, ok := strings.Cut(after, "</nav>") |
| 34 | if !ok { |
| 35 | t.Errorf("%s: the sidebar nav never closes", name) |
| 36 | return "" |
| 37 | } |
| 38 | return rail |
| 39 | } |
| 40 | |
| 41 | // pageNames lists the pages a whole-tree gate reads, sorted: every |
| 42 | // page-kind page of every theme and locale, and every page of the |
| 43 | // default theme in English, the example pages included. The example |
| 44 | // pages are one template over one body each, so reading the hundred |
| 45 | // or so of one directory proves the shell on them; a regexp gate over |
| 46 | // all 3,888 took over a minute. |
| 47 | func pageNames(s *site, files map[string][]byte) []string { |
| 48 | var out []string |
| 49 | for _, dir := range dirs() { |
| 50 | if dir == fiesta.ThemeNames()[0]+"/en/" { |
| 51 | for name := range files { |
| 52 | if strings.HasPrefix(name, dir) && strings.HasSuffix(name, ".html") && !isSampleDocument(name) { |
| 53 | out = append(out, name) |
| 54 | } |
| 55 | } |
| 56 | continue |
| 57 | } |
| 58 | for _, pk := range s.pageKinds() { |
| 59 | out = append(out, dir+pk.File) |
| 60 | } |
| 61 | } |
| 62 | sort.Strings(out) |
| 63 | return out |
| 64 | } |
| 65 | |
| 66 | // kindPage is one page-kind page of one directory, by file, with a |
| 67 | // fatal if the renderer did not produce it. |
| 68 | func kindPage(t *testing.T, files map[string][]byte, theme, locale, file string) string { |
| 69 | t.Helper() |
| 70 | name := theme + "/" + locale + "/" + file |
| 71 | page := string(files[name]) |
| 72 | if page == "" { |
| 73 | t.Fatalf("no %s in the rendered tree", name) |
| 74 | } |
| 75 | return page |
| 76 | } |
| 77 | |
| 78 | var ( |
| 79 | anchorMarker = regexp.MustCompile(`id="([^"]*)" data-ds-anchor`) |
| 80 | navHref = regexp.MustCompile(`<a[^>]*\shref="([^"]*)"`) |
| 81 | elementID = regexp.MustCompile(`\bid="([^"]*)"`) |
| 82 | shellRoot = regexp.MustCompile(`<div rst-shell-sidebar="([a-z]+)">`) |
| 83 | backLink = regexp.MustCompile(`<a rst-skip href="#main">[^<]*</a>\n<div rst-shell-back><a data-gallery-nav href="([^"]*)" rel="up" aria-label="([^"]*)">([^<]*)</a>`) |
| 84 | prefsCard = regexp.MustCompile(`(?s)<details rst-dropdown class="ds-prefs" id="ds-prefs" name="ds-prefs"><summary aria-label="([^"]*)">(.*?)</summary><div rst-dropdown-menu>(.*?)</div></details>`) |
| 85 | barCtl = regexp.MustCompile(`(?s)<div class="ds-top__controls">(.*?)</header>`) |
| 86 | indexNav = regexp.MustCompile(`(?s)<nav class="ds-index" rst-shell-nav aria-label="([^"]*)">(.*?)</nav>`) |
| 87 | schemeMenu = regexp.MustCompile(`(?s)<details rst-dropdown rst-scheme.*?</details>`) |
| 88 | ) |
| 89 | |
| 90 | // TestTheGalleryIsTheShippedShell: every page's body opens the |
| 91 | // sidebar shell, "index" on the Overview and "page" everywhere else, |
| 92 | // with the back control right after the skip link on a content page, |
| 93 | // in the page's language, and never on the index; the index pieces (the shell title, the lead |
| 94 | // and the rows) on the Overview alone; one display settings menu per |
| 95 | // page, in the index's header row or in the back strip; shell.css |
| 96 | // linked hashed and shell.js loaded deferred and render-blocking, as |
| 97 | // the layouts load it. |
| 98 | func TestTheGalleryIsTheShippedShell(t *testing.T) { |
| 99 | files := render(t, "") |
| 100 | s := newTestSite(t, fiesta.FS(), "") |
| 101 | checked := 0 |
| 102 | for _, name := range pageNames(s, files) { |
| 103 | page := string(files[name]) |
| 104 | locale := "en" |
| 105 | if parts := strings.Split(name, "/"); len(parts) == 3 { |
| 106 | locale = parts[1] |
| 107 | } |
| 108 | label := s.say(locale, "gallery.up.label") |
| 109 | aria := template.HTMLEscapeString(s.say(locale, "gallery.up", "name", label)) |
| 110 | index := strings.HasSuffix(name, "/index.html") |
| 111 | want := map[bool]string{true: "index", false: "page"}[index] |
| 112 | if m := shellRoot.FindAllStringSubmatch(page, -1); len(m) != 1 || m[0][1] != want { |
| 113 | t.Errorf("%s: shell root %v, want one rst-shell-sidebar=%q", name, m, want) |
| 114 | } |
| 115 | backs := backLink.FindAllStringSubmatch(page, -1) |
| 116 | if n := strings.Count(page, "<div rst-shell-back>"); index && n != 0 || !index && (n != 1 || len(backs) != 1) { |
| 117 | t.Errorf("%s: %d back controls (%d right after the skip link), want %d", name, n, len(backs), map[bool]int{false: 1}[index]) |
| 118 | } else if !index && (backs[0][2] != aria || backs[0][3] != template.HTMLEscapeString(label)) { |
| 119 | t.Errorf("%s: the back control reads %q named %q, want %q named %q", name, backs[0][3], backs[0][2], label, aria) |
| 120 | } |
| 121 | for _, piece := range []string{`<h1 rst-shell-title>`, `<p class="ds-index-lead">`, `<nav class="ds-index"`} { |
| 122 | if strings.Contains(page, piece) != index { |
| 123 | t.Errorf("%s: %s present=%v; it belongs on the Overview and nowhere else", name, piece, !index) |
| 124 | } |
| 125 | } |
| 126 | if strings.Contains(page, "<div rst-shell-rail-foot") { |
| 127 | t.Errorf("%s: the rail has a foot; the controls are in the display settings menu", name) |
| 128 | } |
| 129 | if n := strings.Count(page, `class="ds-prefs"`); n != 1 { |
| 130 | t.Errorf("%s: %d display settings menus, want one", name, n) |
| 131 | } |
| 132 | where := `<div rst-shell-back><a ` |
| 133 | if index { |
| 134 | where = `<h1 rst-shell-title><span></span><span>` + template.HTMLEscapeString(s.say("en", "gallery.brand")) + `</span></h1>` + "\n" + `<details rst-dropdown class="ds-prefs"` |
| 135 | } |
| 136 | if !strings.Contains(page, where) { |
| 137 | t.Errorf("%s: the display settings menu is not where a phone looks for it (%s)", name, where) |
| 138 | } |
| 139 | if i := strings.Index(page, "<div rst-shell-back>"); !index && i >= 0 { |
| 140 | strip := page[i:] |
| 141 | if strings.Index(strip, `class="ds-prefs"`) > strings.Index(strip, "</div>\n") { |
| 142 | t.Errorf("%s: the display settings menu is not inside the back strip", name) |
| 143 | } |
| 144 | } |
| 145 | for _, want := range []string{ |
| 146 | `<link rel="stylesheet" href="` + s.asset("css/shell.css") + `">`, |
| 147 | `<script defer blocking="render" src="` + s.asset("js/shell.js") + `"></script>`, |
| 148 | } { |
| 149 | if !strings.Contains(page, want) { |
| 150 | t.Errorf("%s: no %s; the slide, Back through history and the focus return are shell.css and shell.js", name, want) |
| 151 | } |
| 152 | } |
| 153 | checked++ |
| 154 | } |
| 155 | if checked < 36*len(s.pageKinds())+len(s.m.Examples) { |
| 156 | t.Fatalf("%d pages checked; the tree has more", checked) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // shellSkeleton is the shape of a document's shell: for every element |
| 161 | // carrying one of the shell's structural attributes, its tag, the |
| 162 | // attribute, and the nearest such attribute above it. The gallery's |
| 163 | // skeleton has to be drawn from the shell-sidebar example's: the same |
| 164 | // tags carrying the same attributes under the same parents. |
| 165 | func shellSkeleton(doc node) map[string]bool { |
| 166 | structural := []string{"rst-shell-sidebar", "rst-skip", "rst-shell-back", "rst-shell-rail", "rst-shell-title", "rst-shell-nav", "rst-shell-main", "rst-page"} |
| 167 | mark := func(n node) string { |
| 168 | for _, a := range structural { |
| 169 | if _, ok := n.attr(a); ok { |
| 170 | return a |
| 171 | } |
| 172 | } |
| 173 | return "" |
| 174 | } |
| 175 | out := map[string]bool{} |
| 176 | for _, n := range doc.find(func(n node) bool { return mark(n) != "" }) { |
| 177 | parent := "" |
| 178 | for p := n.n.Parent; p != nil && parent == ""; p = p.Parent { |
| 179 | parent = mark(node{p}) |
| 180 | } |
| 181 | out[n.n.Data+"["+mark(n)+"] under "+parent] = true |
| 182 | } |
| 183 | return out |
| 184 | } |
| 185 | |
| 186 | // TestTheSidebarIsTheShellTheGalleryDocuments: the gallery is laid out |
| 187 | // in the vocabulary it documents. Every structural piece of a page's |
| 188 | // shell (the root, the skip link, the back strip, the rail, its title |
| 189 | // and navs, main and the page column) sits under the same parent, on |
| 190 | // the same tag, as in the shell-sidebar example, and the rail, the |
| 191 | // nav and main are there by name; the filter is a real search input |
| 192 | // in a search landmark naming the nav it filters, with the no-matches |
| 193 | // line rendered by the page and hidden; one disclosure per page kind |
| 194 | // with entries plus Demos, exactly one open; every section named in |
| 195 | // the page's language; the disclosure glyph the icon set's chevron. |
| 196 | func TestTheSidebarIsTheShellTheGalleryDocuments(t *testing.T) { |
| 197 | files := render(t, "") |
| 198 | s := newTestSite(t, fiesta.FS(), "") |
| 199 | example, ok := files["day/en/frames/shell-sidebar/default.html"] |
| 200 | if !ok { |
| 201 | t.Fatal("no shell-sidebar frame to read the shell's skeleton from") |
| 202 | } |
| 203 | reference := shellSkeleton(parse(t, example)) |
| 204 | if len(reference) < 6 { |
| 205 | t.Fatalf("the shell-sidebar example's skeleton has %d pieces; the comparison below would read too little", len(reference)) |
| 206 | } |
| 207 | disclosing := 1 |
| 208 | for _, pk := range s.pageKinds() { |
| 209 | if pk.Nav != nil { |
| 210 | disclosing++ |
| 211 | } |
| 212 | } |
| 213 | for _, theme := range fiesta.ThemeNames() { |
| 214 | for _, locale := range Locales { |
| 215 | for _, pk := range s.pageKinds() { |
| 216 | name := theme + "/" + locale + "/" + pk.File |
| 217 | page := kindPage(t, files, theme, locale, pk.File) |
| 218 | skeleton := shellSkeleton(parse(t, []byte(page))) |
| 219 | for piece := range skeleton { |
| 220 | if !reference[piece] { |
| 221 | t.Errorf("%s: the shell has %s, which the shell-sidebar example does not", name, piece) |
| 222 | } |
| 223 | } |
| 224 | for _, want := range []string{ |
| 225 | `<aside class="ds-rail" rst-shell-rail>`, |
| 226 | `<nav class="ds-nav" rst-shell-nav id="ds-nav"`, |
| 227 | `<main rst-shell-main id="main">`, |
| 228 | } { |
| 229 | if !strings.Contains(page, want) { |
| 230 | t.Errorf("%s: the page is not laid out in the sidebar shell (no %s)", name, want) |
| 231 | } |
| 232 | } |
| 233 | for _, want := range []string{ |
| 234 | `<search class="ds-search">`, |
| 235 | `<input id="ds-filter" type="search"`, |
| 236 | `aria-controls="ds-nav"`, |
| 237 | `data-ds-filter`, |
| 238 | `data-ds-filter-empty role="status" hidden`, |
| 239 | } { |
| 240 | if !strings.Contains(page, want) { |
| 241 | t.Errorf("%s: the filter box is not there as specified (no %s)", name, want) |
| 242 | } |
| 243 | } |
| 244 | rail := railOf(t, name, page) |
| 245 | if n := strings.Count(rail, "<details"); n != disclosing { |
| 246 | t.Errorf("%s: %d sidebar disclosures, want %d", name, n, disclosing) |
| 247 | } |
| 248 | want := 0 |
| 249 | if pk.Nav != nil { |
| 250 | want = 1 |
| 251 | } |
| 252 | if n := strings.Count(rail, "<details open"); n != want { |
| 253 | t.Errorf("%s: %d sidebar sections arrive open, want %d (the one this page is)", name, n, want) |
| 254 | } |
| 255 | for _, pk2 := range s.pageKinds() { |
| 256 | title := template.HTMLEscapeString(s.titleOf(locale, pk2)) |
| 257 | if pk2.Nav == nil { |
| 258 | if !strings.Contains(rail, `class="ds-nav__page"`) || !strings.Contains(rail, `>`+title+`</a>`) { |
| 259 | t.Errorf("%s: no sidebar link named %q for the page with nothing to list", name, s.titleOf(locale, pk2)) |
| 260 | } |
| 261 | continue |
| 262 | } |
| 263 | if !strings.Contains(rail, `</span>`+title+`</summary>`) { |
| 264 | t.Errorf("%s: no sidebar section named %q", name, s.titleOf(locale, pk2)) |
| 265 | } |
| 266 | } |
| 267 | if title := template.HTMLEscapeString(s.say(locale, "gallery.nav.demos")); !strings.Contains(rail, `</span>`+title+`</summary>`) { |
| 268 | t.Errorf("%s: no sidebar section named %q", name, s.say(locale, "gallery.nav.demos")) |
| 269 | } |
| 270 | if n := strings.Count(rail, `<span rst-caret aria-hidden="true"><svg`); n != disclosing { |
| 271 | t.Errorf("%s: %d rail disclosures draw a chevron, want %d", name, n, disclosing) |
| 272 | } |
| 273 | for _, glyph := range []string{"▸", "▾", `content: "\25b8"`, `content: "\25be"`} { |
| 274 | if strings.Contains(page, glyph) { |
| 275 | t.Errorf("%s still draws a disclosure glyph as the character %q", name, glyph) |
| 276 | } |
| 277 | } |
| 278 | if want := `aria-label="` + template.HTMLEscapeString(s.say(locale, "gallery.nav.sections")) + `"`; !strings.Contains(page, want) { |
| 279 | t.Errorf("%s: the sidebar nav is not named in this page's language", name) |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | // TestTheRailIsTheSameOnEveryPage: the rail is the same on every page |
| 287 | // of a directory, byte for byte apart from which section and entry |
| 288 | // carry aria-current="page". That is the promise the rail makes to a |
| 289 | // reader: the whole vocabulary is one list, and moving between the |
| 290 | // pages does not move the list under them. |
| 291 | func TestTheRailIsTheSameOnEveryPage(t *testing.T) { |
| 292 | files := render(t, "") |
| 293 | s := newTestSite(t, fiesta.FS(), "") |
| 294 | current := strings.NewReplacer(` open aria-current="page"`, "", ` aria-current="page"`, "") |
| 295 | for _, dir := range dirs() { |
| 296 | var first, firstName string |
| 297 | for _, name := range pageNames(s, files) { |
| 298 | if !strings.HasPrefix(name, dir) { |
| 299 | continue |
| 300 | } |
| 301 | rail := current.Replace(railOf(t, name, string(files[name]))) |
| 302 | if rail == "" { |
| 303 | continue |
| 304 | } |
| 305 | if first == "" { |
| 306 | first, firstName = rail, name |
| 307 | continue |
| 308 | } |
| 309 | if rail != first { |
| 310 | t.Errorf("%s and %s carry different rails", firstName, name) |
| 311 | } |
| 312 | } |
| 313 | if first == "" { |
| 314 | t.Errorf("%s: no rail on any page", dir) |
| 315 | } |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | // TestTheRailSaysWhichPageYouAreOn: on a page-kind page exactly one |
| 320 | // section of the rail is marked current and it is the page's own, |
| 321 | // open where it discloses and a plain link where it does not; on an |
| 322 | // example page its family's section is open and current and the |
| 323 | // example's own entry is current too, and nothing else is. |
| 324 | func TestTheRailSaysWhichPageYouAreOn(t *testing.T) { |
| 325 | files := render(t, "") |
| 326 | s := newTestSite(t, fiesta.FS(), "") |
| 327 | for _, theme := range fiesta.ThemeNames() { |
| 328 | for _, locale := range Locales { |
| 329 | for _, pk := range s.pageKinds() { |
| 330 | name := theme + "/" + locale + "/" + pk.File |
| 331 | rail := railOf(t, name, kindPage(t, files, theme, locale, pk.File)) |
| 332 | if n := strings.Count(rail, `aria-current="page"`); n != 1 { |
| 333 | t.Errorf("%s: %d things in the rail are marked current, want 1", name, n) |
| 334 | continue |
| 335 | } |
| 336 | title := template.HTMLEscapeString(s.titleOf(locale, pk)) |
| 337 | open := regexp.MustCompile(`(?s)<details open aria-current="page"><summary><span rst-caret aria-hidden="true"><svg.*?</svg></span>` + regexp.QuoteMeta(title) + `</summary>`) |
| 338 | link := `<a class="ds-nav__page" data-gallery-nav href="` + s.href(theme, locale, pk.File) + `" aria-current="page">` + title + `</a>` |
| 339 | if !strings.Contains(rail, link) && !open.MatchString(rail) { |
| 340 | t.Errorf("%s: the current section of the rail is not %q", name, s.titleOf(locale, pk)) |
| 341 | } |
| 342 | } |
| 343 | } |
| 344 | } |
| 345 | theme, locale := fiesta.ThemeNames()[0], "en" |
| 346 | for _, e := range s.m.Examples { |
| 347 | name := theme + "/" + locale + "/" + e.Name + ".html" |
| 348 | rail := railOf(t, name, string(files[name])) |
| 349 | if n := strings.Count(rail, `aria-current="page"`); n != 2 { |
| 350 | t.Errorf("%s: %d things in the rail are marked current, want 2 (the family's section and the example's entry)", name, n) |
| 351 | continue |
| 352 | } |
| 353 | f, _ := s.familyByKey(e.Family) |
| 354 | open := regexp.MustCompile(`(?s)<details open aria-current="page"><summary><span rst-caret aria-hidden="true"><svg.*?</svg></span>` + regexp.QuoteMeta(template.HTMLEscapeString(f.Title)) + `</summary>`) |
| 355 | if !open.MatchString(rail) { |
| 356 | t.Errorf("%s: the open section of the rail is not the %s family", name, e.Family) |
| 357 | } |
| 358 | if want := `<a data-gallery-nav href="` + s.href(theme, locale, e.Name+".html") + `" aria-current="page" class="rst-mono"`; !strings.Contains(rail, want) { |
| 359 | t.Errorf("%s: the rail does not mark the example's own entry current (no %s)", name, want) |
| 360 | } |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | // TestTheSidebarLinksEverythingOnThePageExactlyOnce: the rail is a |
| 365 | // reading of the page. On a page that anchors things (Tokens, Icons) |
| 366 | // the fragments the rail links into it, in rail order, are exactly |
| 367 | // the anchored elements on the page, in page order; on a family page |
| 368 | // the section's entries are exactly the page's own list of examples, |
| 369 | // in order. Everything the rail links is a page of this tree, and the |
| 370 | // links out of the gallery's pages are the Demos alone: the five shell |
| 371 | // demos, the modal route and the demo application. |
| 372 | func TestTheSidebarLinksEverythingOnThePageExactlyOnce(t *testing.T) { |
| 373 | files := render(t, "") |
| 374 | s := newTestSite(t, fiesta.FS(), "") |
| 375 | anchored, listed := 0, 0 |
| 376 | for _, theme := range fiesta.ThemeNames() { |
| 377 | for _, locale := range Locales { |
| 378 | for _, pk := range s.pageKinds() { |
| 379 | name := theme + "/" + locale + "/" + pk.File |
| 380 | page := kindPage(t, files, theme, locale, pk.File) |
| 381 | rail := railOf(t, name, page) |
| 382 | if rail == "" { |
| 383 | continue |
| 384 | } |
| 385 | self := s.href(theme, locale, pk.File) |
| 386 | var anchors []string |
| 387 | for _, m := range anchorMarker.FindAllStringSubmatch(page, -1) { |
| 388 | anchors = append(anchors, m[1]) |
| 389 | } |
| 390 | var fragments []string |
| 391 | for _, m := range navHref.FindAllStringSubmatch(rail, -1) { |
| 392 | if strings.HasPrefix(m[1], self+"#") { |
| 393 | fragments = append(fragments, strings.TrimPrefix(m[1], self+"#")) |
| 394 | } |
| 395 | } |
| 396 | if len(anchors) > 0 { |
| 397 | anchored++ |
| 398 | } |
| 399 | if strings.Join(fragments, " ") != strings.Join(anchors, " ") { |
| 400 | t.Errorf("%s: the sidebar links the fragments %v, the page anchors %v", name, fragments, anchors) |
| 401 | } |
| 402 | if _, isFamily := s.familyByKey(pk.Kind); isFamily { |
| 403 | listed++ |
| 404 | doc := parse(t, []byte(page)) |
| 405 | var examples []string |
| 406 | if routes, ok := doc.first(byClass("ds-routes")); ok { |
| 407 | for _, a := range routes.find(func(n node) bool { return n.n.Data == "a" }) { |
| 408 | href, _ := a.attr("href") |
| 409 | examples = append(examples, href) |
| 410 | } |
| 411 | } |
| 412 | var items []string |
| 413 | for _, g := range railSections(rail) { |
| 414 | if g.Title == template.HTMLEscapeString(s.titleOf(locale, pk)) { |
| 415 | for i, m := range navHref.FindAllStringSubmatch(g.Body, -1) { |
| 416 | if i > 0 { |
| 417 | items = append(items, m[1]) |
| 418 | } |
| 419 | } |
| 420 | } |
| 421 | } |
| 422 | if len(examples) == 0 || strings.Join(items, " ") != strings.Join(examples, " ") { |
| 423 | t.Errorf("%s: the section lists %v, the page lists %v", name, items, examples) |
| 424 | } |
| 425 | } |
| 426 | away := 0 |
| 427 | for _, m := range navHref.FindAllStringSubmatch(rail, -1) { |
| 428 | path, _, _ := strings.Cut(strings.TrimPrefix(m[1], "/"), "#") |
| 429 | if !strings.HasPrefix(m[1], "/") { |
| 430 | t.Errorf("%s: the sidebar links %q, which is not an absolute path", name, m[1]) |
| 431 | continue |
| 432 | } |
| 433 | if _, ok := files[path]; !ok { |
| 434 | t.Errorf("%s: the sidebar links %q, which is not a page of this tree", name, m[1]) |
| 435 | continue |
| 436 | } |
| 437 | if isSampleDocument(path) { |
| 438 | away++ |
| 439 | } |
| 440 | } |
| 441 | if want := len(shellNames) + 2; away != want { |
| 442 | t.Errorf("%s: the sidebar has %d links out of the gallery's pages, want %d (one per shell demo, the modal route and the demo application)", name, away, want) |
| 443 | } |
| 444 | } |
| 445 | } |
| 446 | } |
| 447 | if anchored == 0 || listed == 0 { |
| 448 | t.Fatalf("%d anchored pages and %d family pages read; the check above read one side only", anchored, listed) |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | // railSection is one group of the rail as the page renders it: a |
| 453 | // <details> holding a title and a run of links, or the plain link a |
| 454 | // section with nothing to list renders as instead. |
| 455 | var railSection = regexp.MustCompile(`(?s)<details([^>]*)><summary>.*?</summary>(.*?)</details>|<a class="ds-nav__page" data-gallery-nav href="([^"]*)"[^>]*>(.*?)</a>`) |
| 456 | |
| 457 | // railTitle reads the section's own name out of either shape: past the |
| 458 | // caret icon in a <summary>, or the whole text of the plain link. |
| 459 | var railTitle = regexp.MustCompile(`(?s)<summary>(?:<span rst-caret aria-hidden="true">.*?</span>)?(.*?)</summary>`) |
| 460 | |
| 461 | type railGroup struct { |
| 462 | Title string |
| 463 | Body string // the links inside a disclosure; empty for a plain link |
| 464 | Href string // set only for the plain-link shape |
| 465 | Open bool // true for the disclosure shape |
| 466 | } |
| 467 | |
| 468 | // railSections splits one rendered rail into its groups, in order. |
| 469 | func railSections(rail string) []railGroup { |
| 470 | var out []railGroup |
| 471 | for _, m := range railSection.FindAllString(rail, -1) { |
| 472 | sub := railSection.FindStringSubmatch(m) |
| 473 | if strings.HasPrefix(m, "<details") { |
| 474 | title := "" |
| 475 | if t := railTitle.FindStringSubmatch(m); t != nil { |
| 476 | title = t[1] |
| 477 | } |
| 478 | out = append(out, railGroup{Title: title, Body: sub[2], Open: true}) |
| 479 | continue |
| 480 | } |
| 481 | out = append(out, railGroup{Title: sub[4], Href: sub[3]}) |
| 482 | } |
| 483 | return out |
| 484 | } |
| 485 | |
| 486 | // TestEverySectionOfTheRailRoutesToItsOwnPage: every section of the |
| 487 | // rail has a route to the top of its own page, and it is the first |
| 488 | // thing under the section's name: the plain link where the section |
| 489 | // lists nothing, and otherwise an Overview item named for a screen |
| 490 | // reader by its section, whose accessible name contains its visible |
| 491 | // label (WCAG 2.2 SC 2.5.3). Exactly one route per page kind, and |
| 492 | // none in Demos, which is not a page of this gallery. |
| 493 | func TestEverySectionOfTheRailRoutesToItsOwnPage(t *testing.T) { |
| 494 | files := render(t, "") |
| 495 | s := newTestSite(t, fiesta.FS(), "") |
| 496 | for _, theme := range fiesta.ThemeNames() { |
| 497 | for _, locale := range Locales { |
| 498 | label := s.say(locale, "gallery.nav.overview") |
| 499 | for _, pk := range s.pageKinds() { |
| 500 | name := theme + "/" + locale + "/" + pk.File |
| 501 | rail := railOf(t, name, kindPage(t, files, theme, locale, pk.File)) |
| 502 | if rail == "" { |
| 503 | continue |
| 504 | } |
| 505 | groups := railSections(rail) |
| 506 | if want := len(s.pageKinds()) + 1; len(groups) != want { |
| 507 | t.Errorf("%s: the rail has %d sections, want %d (one per page kind, plus Demos)", name, len(groups), want) |
| 508 | continue |
| 509 | } |
| 510 | overviews := 0 |
| 511 | for i, pk2 := range s.pageKinds() { |
| 512 | g := groups[i] |
| 513 | want := s.href(theme, locale, pk2.File) |
| 514 | title := template.HTMLEscapeString(s.titleOf(locale, pk2)) |
| 515 | if g.Title != title { |
| 516 | t.Errorf("%s: rail section %d is %q, want %q", name, i, g.Title, title) |
| 517 | continue |
| 518 | } |
| 519 | if n := strings.Count(rail, `href="`+want+`"`); n != 1 { |
| 520 | t.Errorf("%s: the rail carries %d links to %s, want exactly 1", name, n, want) |
| 521 | } |
| 522 | if !g.Open { |
| 523 | if g.Href != want { |
| 524 | t.Errorf("%s: the %s section links %q, want %q", name, pk2.Kind, g.Href, want) |
| 525 | } |
| 526 | continue |
| 527 | } |
| 528 | first := navHref.FindStringIndex(g.Body) |
| 529 | if first == nil { |
| 530 | t.Errorf("%s: the %s section of the rail has no links at all", name, pk2.Kind) |
| 531 | continue |
| 532 | } |
| 533 | item := g.Body[first[0]:] |
| 534 | if i := strings.Index(item, "</a>"); i >= 0 { |
| 535 | item = item[:i+len("</a>")] |
| 536 | } |
| 537 | spoken := s.say(locale, "gallery.nav.overview.aria", "section", s.titleOf(locale, pk2)) |
| 538 | wantItem := `<a data-gallery-nav href="` + want + `" aria-label="` + template.HTMLEscapeString(spoken) + `">` + template.HTMLEscapeString(label) + `</a>` |
| 539 | if item != wantItem { |
| 540 | t.Errorf("%s: the first item under %s is\n %s\nwant\n %s", name, pk2.Kind, item, wantItem) |
| 541 | continue |
| 542 | } |
| 543 | overviews++ |
| 544 | if !strings.Contains(strings.ToLower(spoken), strings.ToLower(label)) { |
| 545 | t.Errorf("%s: the %s section's accessible name %q does not contain its visible label %q (WCAG 2.2 SC 2.5.3)", name, pk2.Kind, spoken, label) |
| 546 | } |
| 547 | } |
| 548 | var bodies string |
| 549 | for _, g := range groups { |
| 550 | bodies += g.Body |
| 551 | } |
| 552 | if n := strings.Count(bodies, ` aria-label="`); n != overviews { |
| 553 | t.Errorf("%s: %d rail links carry an accessible name, want %d (one per section that discloses, and none in Demos)", name, n, overviews) |
| 554 | } |
| 555 | if demos := groups[len(groups)-1]; demos.Title != template.HTMLEscapeString(s.say(locale, "gallery.nav.demos")) { |
| 556 | t.Errorf("%s: the last rail section is %q, want Demos", name, demos.Title) |
| 557 | } else if strings.Contains(demos.Body, ` aria-label="`) { |
| 558 | t.Errorf("%s: the Demos section carries an overview link; it is not a page of this gallery", name) |
| 559 | } |
| 560 | } |
| 561 | } |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | // demoHrefs are the Demos' addresses in one directory: the five shell |
| 566 | // demos (the layout examples' default frame documents), the modal route |
| 567 | // and the demo application. |
| 568 | func demoHrefs(t *testing.T, s *site, theme, locale string) map[string]bool { |
| 569 | t.Helper() |
| 570 | out := map[string]bool{} |
| 571 | for _, shell := range shellNames { |
| 572 | state, ok := s.m.Default("layout-" + shell) |
| 573 | if !ok { |
| 574 | t.Fatalf("no layout-%s example", shell) |
| 575 | } |
| 576 | out[s.href(theme, locale, "frames/layout-"+shell+"/"+state+".html")] = true |
| 577 | } |
| 578 | out[s.href(theme, locale, "demo-modal.html")] = true |
| 579 | out[s.href(theme, locale, "demo.html")] = true |
| 580 | return out |
| 581 | } |
| 582 | |
| 583 | // TestEveryDemoLinkOpensInANewTab: every link the renderer owns that |
| 584 | // leaves the gallery's pages for a demo opens in a new tab, and the |
| 585 | // rail carries one to each of the seven on every page: the five shells, |
| 586 | // the modal route and the demo application. The three primitives that |
| 587 | // are pages of their own (the modal, and the sidebar and topbar shell |
| 588 | // idioms) each end with a link to that page, in a new tab, saying so. |
| 589 | // A reader is a long way down a page with a filter typed into the |
| 590 | // rail; losing that to look at a demo is a poor trade. The switchers |
| 591 | // are not in this set: a theme or a language is the same page again. |
| 592 | func TestEveryDemoLinkOpensInANewTab(t *testing.T) { |
| 593 | files := render(t, "") |
| 594 | s := newTestSite(t, fiesta.FS(), "") |
| 595 | away := regexp.MustCompile(`<a[^>]*\shref="([^"]*)"[^>]*>`) |
| 596 | for _, theme := range fiesta.ThemeNames() { |
| 597 | for _, locale := range Locales { |
| 598 | demos := demoHrefs(t, s, theme, locale) |
| 599 | if len(demos) != len(shellNames)+2 { |
| 600 | t.Fatalf("%d demos, want %d", len(demos), len(shellNames)+2) |
| 601 | } |
| 602 | seen := map[string]int{} |
| 603 | pages := 0 |
| 604 | for _, name := range pageNames(s, files) { |
| 605 | if !strings.HasPrefix(name, theme+"/"+locale+"/") { |
| 606 | continue |
| 607 | } |
| 608 | pages++ |
| 609 | for _, m := range away.FindAllStringSubmatch(string(files[name]), -1) { |
| 610 | if !demos[m[1]] { |
| 611 | continue |
| 612 | } |
| 613 | seen[m[1]]++ |
| 614 | if !strings.Contains(m[0], `target="_blank"`) || !strings.Contains(m[0], `rel="noopener"`) { |
| 615 | t.Errorf("%s: %s does not open in a new tab", name, m[0]) |
| 616 | } |
| 617 | } |
| 618 | } |
| 619 | for href := range demos { |
| 620 | if seen[href] < pages { |
| 621 | t.Errorf("%s/%s: %s is linked %d times, want at least %d (the rail, on every page)", theme, locale, href, seen[href], pages) |
| 622 | } |
| 623 | } |
| 624 | // The primitives' own links, on their pages in every |
| 625 | // directory. |
| 626 | newTab := `<span class="rst-sr-only"> (` + template.HTMLEscapeString(s.say(locale, "gallery.newtab")) + `)</span></a>` |
| 627 | for _, live := range []struct{ example, label, href string }{ |
| 628 | {"modal", "gallery.primitives.modal.live", s.href(theme, locale, "demo-modal.html")}, |
| 629 | {"shell-sidebar", "gallery.primitives.sidebar.live", s.href(theme, locale, "frames/layout-sidebar/"+mustEntry(t, "layout-sidebar").Default+".html")}, |
| 630 | {"shell-topbar", "gallery.primitives.topbar.live", s.href(theme, locale, "frames/layout-topbar/"+mustEntry(t, "layout-topbar").Default+".html")}, |
| 631 | } { |
| 632 | want := `<a data-gallery-nav href="` + live.href + `" target="_blank" rel="noopener">` + template.HTMLEscapeString(s.say(locale, live.label)) + newTab |
| 633 | if n := strings.Count(examplePage(t, files, theme, locale, live.example), want); n != 1 { |
| 634 | t.Errorf("%s/%s/%s.html carries %d links %s, want one", theme, locale, live.example, n, want) |
| 635 | } |
| 636 | } |
| 637 | } |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | // TestThePhoneIndexSaysItsDemosOpenANewTab: the phone index's Demos |
| 642 | // rows open a new tab and say so to a screen reader; a reader who is |
| 643 | // not told finds Back dead and their filter gone. |
| 644 | func TestThePhoneIndexSaysItsDemosOpenANewTab(t *testing.T) { |
| 645 | files := render(t, "") |
| 646 | s := newTestSite(t, fiesta.FS(), "") |
| 647 | blank := regexp.MustCompile(`<a data-gallery-nav href="[^"]*" target="_blank" rel="noopener">(.*?)</a>`) |
| 648 | for _, locale := range Locales { |
| 649 | m := indexNav.FindStringSubmatch(kindPage(t, files, fiesta.ThemeNames()[0], locale, "index.html")) |
| 650 | if m == nil { |
| 651 | t.Fatalf("%s: no rows nav on the Overview", locale) |
| 652 | } |
| 653 | rows := blank.FindAllStringSubmatch(m[2], -1) |
| 654 | if len(rows) == 0 { |
| 655 | t.Fatalf("%s: no Demos row opens a new tab, so this checked nothing", locale) |
| 656 | } |
| 657 | say := `<span class="rst-sr-only"> (` + template.HTMLEscapeString(s.say(locale, "gallery.newtab")) + `)</span>` |
| 658 | for _, r := range rows { |
| 659 | if !strings.HasSuffix(r[1], say) { |
| 660 | t.Errorf("%s: the Demos row %q opens a new tab without saying %s", locale, r[1], say) |
| 661 | } |
| 662 | } |
| 663 | } |
| 664 | } |
| 665 | |
| 666 | // TestEveryWayUpNamesItsRow: every content page's back control |
| 667 | // returns to its own row on the index, index.html#nav-<kind>, a |
| 668 | // fragment naming a link in the rows nav whose href is that page; an |
| 669 | // example page's way up is its family's row. The rows are the rail's |
| 670 | // sections in its order, but the Overview and Demos, and the rows end |
| 671 | // with the Demos group. |
| 672 | func TestEveryWayUpNamesItsRow(t *testing.T) { |
| 673 | files := render(t, "") |
| 674 | s := newTestSite(t, fiesta.FS(), "") |
| 675 | for _, theme := range fiesta.ThemeNames() { |
| 676 | for _, locale := range Locales { |
| 677 | overview := kindPage(t, files, theme, locale, "index.html") |
| 678 | m := indexNav.FindStringSubmatch(overview) |
| 679 | if m == nil { |
| 680 | t.Fatalf("%s/%s: no rows nav on the Overview", theme, locale) |
| 681 | } |
| 682 | if want := template.HTMLEscapeString(s.say(locale, "gallery.up.label")); m[1] != want { |
| 683 | t.Errorf("%s/%s: the rows nav is labelled %q, want the back control's word %q, so Back to it lands on a list of that name", theme, locale, m[1], want) |
| 684 | } |
| 685 | rows := m[2] |
| 686 | for _, pk := range s.pageKinds() { |
| 687 | if pk.Kind == "overview" { |
| 688 | continue |
| 689 | } |
| 690 | b := backLink.FindStringSubmatch(kindPage(t, files, theme, locale, pk.File)) |
| 691 | if want := s.href(theme, locale, "index.html") + "#nav-" + pk.Kind; b == nil || b[1] != want { |
| 692 | t.Errorf("%s/%s/%s: way up %v, want %s", theme, locale, pk.File, b, want) |
| 693 | } |
| 694 | if row := `<a id="nav-` + pk.Kind + `" data-gallery-nav href="` + s.href(theme, locale, pk.File) + `">`; !strings.Contains(rows, row) { |
| 695 | t.Errorf("%s/%s: no row %s", theme, locale, row) |
| 696 | } |
| 697 | } |
| 698 | for _, e := range s.m.Examples { |
| 699 | b := backLink.FindStringSubmatch(string(files[theme+"/"+locale+"/"+e.Name+".html"])) |
| 700 | if want := s.href(theme, locale, "index.html") + "#nav-" + e.Family; b == nil || b[1] != want { |
| 701 | t.Errorf("%s/%s/%s.html: way up %v, want its family's row %s", theme, locale, e.Name, b, want) |
| 702 | } |
| 703 | } |
| 704 | var tree, listed []string |
| 705 | for _, g := range railSections(railOf(t, "overview", overview)) { |
| 706 | tree = append(tree, g.Title) |
| 707 | } |
| 708 | for _, r := range regexp.MustCompile(`<a id="nav-[^"]+" data-gallery-nav href="[^"]*">([^<]*)</a>`).FindAllStringSubmatch(rows, -1) { |
| 709 | listed = append(listed, r[1]) |
| 710 | } |
| 711 | if len(tree) < 3 { |
| 712 | t.Fatalf("%s/%s: the rail has %d sections", theme, locale, len(tree)) |
| 713 | } |
| 714 | if want := tree[1 : len(tree)-1]; strings.Join(listed, "|") != strings.Join(want, "|") { |
| 715 | t.Errorf("%s/%s: rows %v, want the tree's sections but the Overview and Demos, %v", theme, locale, listed, want) |
| 716 | } |
| 717 | if !strings.Contains(rows, `<p rst-shell-group>`+tree[len(tree)-1]+`</p>`) { |
| 718 | t.Errorf("%s/%s: the rows do not end with the Demos group", theme, locale) |
| 719 | } |
| 720 | } |
| 721 | } |
| 722 | } |
| 723 | |
| 724 | // TestTheTwoCopiesOfTheControlsAgree: every page writes its controls |
| 725 | // twice, in the bar for a wide screen and in the display settings menu |
| 726 | // for a phone, and only one is ever shown. Same themes, the same |
| 727 | // scheme menu, same languages in the same order, no fragment on either |
| 728 | // (gallery.js adds the reader's place at the click), and no id in |
| 729 | // either, since an id written twice is two elements one fragment |
| 730 | // cannot both reach. The menu's button is the cog, named with the |
| 731 | // approved string. |
| 732 | func TestTheTwoCopiesOfTheControlsAgree(t *testing.T) { |
| 733 | files := render(t, "") |
| 734 | s := newTestSite(t, fiesta.FS(), "") |
| 735 | for _, theme := range fiesta.ThemeNames() { |
| 736 | for _, locale := range Locales { |
| 737 | for _, pk := range s.pageKinds() { |
| 738 | where := theme + "/" + locale + "/" + pk.File |
| 739 | page := kindPage(t, files, theme, locale, pk.File) |
| 740 | card, bar := prefsCard.FindStringSubmatch(page), barCtl.FindStringSubmatch(page) |
| 741 | if card == nil || bar == nil { |
| 742 | t.Fatalf("%s: display settings found %v, bar found %v", where, card != nil, bar != nil) |
| 743 | } |
| 744 | if want := template.HTMLEscapeString(s.say(locale, "gallery.prefs")); card[1] != want { |
| 745 | t.Errorf("%s: the display settings button is named %q, want %q", where, card[1], want) |
| 746 | } |
| 747 | if !strings.HasPrefix(card[2], `<svg class="icon"`) || !strings.Contains(card[2], `aria-hidden="true"`) || strings.Contains(card[2], "<span") { |
| 748 | t.Errorf("%s: the display settings button is not the bare, hidden icon its name stands for: %s", where, card[2]) |
| 749 | } |
| 750 | if !strings.Contains(card[2], `<circle cx="12" cy="12" r="3"/>`) || !strings.Contains(card[2], `<path d="M9.671 4.136`) { |
| 751 | t.Errorf("%s: the display settings button is not the cog: %s", where, card[2]) |
| 752 | } |
| 753 | ch, bh := navHref.FindAllStringSubmatch(card[3], -1), navHref.FindAllStringSubmatch(bar[1], -1) |
| 754 | if len(ch) == 0 || len(ch) != len(bh) { |
| 755 | t.Errorf("%s: %d links in the menu, %d in the bar", where, len(ch), len(bh)) |
| 756 | continue |
| 757 | } |
| 758 | for i := range ch { |
| 759 | if strings.Contains(bh[i][1], "#") || ch[i][1] != bh[i][1] { |
| 760 | t.Errorf("%s link %d: menu %s, bar %s; want the same address and no fragment", where, i, ch[i][1], bh[i][1]) |
| 761 | } |
| 762 | } |
| 763 | a, b := schemeMenu.FindString(card[3]), schemeMenu.FindString(bar[1]) |
| 764 | if a == "" || a != b || strings.Count(a, "<form") != 3 { |
| 765 | t.Errorf("%s: the scheme menu in the menu and in the bar differ, or one is missing", where) |
| 766 | } |
| 767 | for what, html := range map[string]string{"menu": card[3], "bar": bar[1]} { |
| 768 | if elementID.MatchString(html) { |
| 769 | t.Errorf("%s: the %s's controls carry an id", where, what) |
| 770 | } |
| 771 | } |
| 772 | } |
| 773 | } |
| 774 | } |
| 775 | } |
| 776 | |
| 777 | // TestTheFilterBoxCarriesTheSearchIcon: the filter box carries the |
| 778 | // search icon at its inline start, drawn by the icon set and hidden |
| 779 | // from the accessible name, which is the field's label. |
| 780 | func TestTheFilterBoxCarriesTheSearchIcon(t *testing.T) { |
| 781 | files := render(t, "") |
| 782 | s := newTestSite(t, fiesta.FS(), "") |
| 783 | box := regexp.MustCompile(`(?s)<search class="ds-search">(.*?)</search>`) |
| 784 | for _, pk := range s.pageKinds() { |
| 785 | m := box.FindStringSubmatch(kindPage(t, files, fiesta.ThemeNames()[0], "en", pk.File)) |
| 786 | if m == nil { |
| 787 | t.Fatalf("%s: no filter box", pk.File) |
| 788 | } |
| 789 | icon, input := strings.Index(m[1], string(s.iconMarkup("search"))), strings.Index(m[1], "<input") |
| 790 | if icon < 0 || input < icon { |
| 791 | t.Errorf("%s: the filter box has no search icon before its field: %s", pk.File, m[1]) |
| 792 | } |
| 793 | } |
| 794 | } |
| 795 | |
| 796 | // TestEverySearchTermNamesAnAnchor: every synonym names an example |
| 797 | // that exists, and rides on the rail's link to its page. A synonym |
| 798 | // whose example was renamed would leave its words pointing at |
| 799 | // nothing, and a reader typing "checkbox" would see No matches again. |
| 800 | func TestEverySearchTermNamesAnAnchor(t *testing.T) { |
| 801 | files := render(t, "") |
| 802 | s := newTestSite(t, fiesta.FS(), "") |
| 803 | theme := fiesta.ThemeNames()[0] |
| 804 | rail := railOf(t, "overview", kindPage(t, files, theme, "en", "index.html")) |
| 805 | for name, terms := range searchTerms { |
| 806 | if _, ok := s.m.Entry(name); !ok { |
| 807 | t.Errorf("searchTerms names %q, which is no example", name) |
| 808 | } |
| 809 | if !regexp.MustCompile(`<a data-gallery-nav href="` + regexp.QuoteMeta(s.href(theme, "en", name+".html")) + `"[^>]* data-ds-terms="` + regexp.QuoteMeta(terms) + `"`).MatchString(rail) { |
| 810 | t.Errorf("the rail link to %s.html does not carry its terms %q", name, terms) |
| 811 | } |
| 812 | } |
| 813 | if len(searchTerms) == 0 { |
| 814 | t.Fatal("no search terms") |
| 815 | } |
| 816 | } |
| 817 | |
| 818 | // TestTheChromeCarriesTheThreeSwitchers: on every page-kind page the |
| 819 | // bar sits after the rail and before main, which opens with the page |
| 820 | // column; its brand links the Overview; its controls hold one theme |
| 821 | // link per theme with exactly one current, Fiesta's scheme menu with |
| 822 | // its three forms and System current, and one language link per |
| 823 | // locale (twelve, counted as links, with exactly one current, the one |
| 824 | // in the page's own language), each language named in itself as |
| 825 | // Fiesta's locale-menu example names it; and every switcher link is |
| 826 | // this page in another theme or language, never the Overview. |
| 827 | func TestTheChromeCarriesTheThreeSwitchers(t *testing.T) { |
| 828 | files := render(t, "") |
| 829 | s := newTestSite(t, fiesta.FS(), "") |
| 830 | names := exampleEndonyms(t) |
| 831 | localeLink := regexp.MustCompile(`<a data-gallery-nav href="([^"]*)" lang="([^"]*)" dir="[^"]*"( aria-current="true")?>([^<]*)</a>`) |
| 832 | for _, theme := range fiesta.ThemeNames() { |
| 833 | for _, locale := range Locales { |
| 834 | for _, pk := range s.pageKinds() { |
| 835 | name := theme + "/" + locale + "/" + pk.File |
| 836 | page := kindPage(t, files, theme, locale, pk.File) |
| 837 | at := strings.Index(page, `<header class="ds-top">`) |
| 838 | if at < 0 || !strings.Contains(page[:at], "</aside>") { |
| 839 | t.Errorf("%s: no bar after the rail", name) |
| 840 | continue |
| 841 | } |
| 842 | bar, rest, ok := strings.Cut(page[at:], "</header>") |
| 843 | if !ok { |
| 844 | t.Errorf("%s: the bar never closes", name) |
| 845 | continue |
| 846 | } |
| 847 | if !strings.HasPrefix(strings.TrimSpace(rest), `<main rst-shell-main id="main">`) { |
| 848 | t.Errorf("%s: main does not follow the bar", name) |
| 849 | } |
| 850 | if _, m, _ := strings.Cut(rest, `<main rst-shell-main id="main">`); !strings.HasPrefix(strings.TrimSpace(m), `<div rst-page>`) { |
| 851 | t.Errorf("%s: main does not open with the content column", name) |
| 852 | } |
| 853 | if want := `<a class="ds-top__brand" data-gallery-nav href="` + s.href(theme, locale, "index.html") + `">`; !strings.Contains(bar, want) { |
| 854 | t.Errorf("%s: the bar's brand does not link the Overview", name) |
| 855 | } |
| 856 | _, chrome, ok := strings.Cut(bar, `<div class="ds-top__controls">`) |
| 857 | if !ok { |
| 858 | t.Errorf("%s: the bar has no controls", name) |
| 859 | continue |
| 860 | } |
| 861 | if n := strings.Count(chrome, `aria-current="page"`); n != 1 { |
| 862 | t.Errorf("%s: %d themes marked current, want 1", name, n) |
| 863 | } |
| 864 | locales := localeLink.FindAllStringSubmatch(chrome, -1) |
| 865 | current := 0 |
| 866 | for _, m := range locales { |
| 867 | if m[3] != "" { |
| 868 | current++ |
| 869 | if m[2] != locale || m[1] != s.href(theme, locale, pk.File) { |
| 870 | t.Errorf("%s: the language marked current is %s at %s, want this page's own, %s", name, m[2], m[1], locale) |
| 871 | } |
| 872 | } |
| 873 | if m[4] != names[m[2]] { |
| 874 | t.Errorf("%s: the %s link reads %q, want %q, the language's name in itself", name, m[2], m[4], names[m[2]]) |
| 875 | } |
| 876 | } |
| 877 | if len(locales) != len(Locales) || current != 1 { |
| 878 | t.Errorf("%s: %d language links with %d marked current, want %d and 1", name, len(locales), current, len(Locales)) |
| 879 | } |
| 880 | if n := strings.Count(chrome, `<a data-gallery-nav href="`); n != len(fiesta.ThemeNames())+len(Locales) { |
| 881 | t.Errorf("%s: the chrome has %d in-tree links, want one per theme and one per locale", name, n) |
| 882 | } |
| 883 | menu := schemeMenu.FindString(chrome) |
| 884 | if menu == "" || strings.Count(menu, "<form") != 3 || !strings.Contains(menu, `value="system"`) { |
| 885 | t.Errorf("%s: the chrome has no scheme menu with its three forms", name) |
| 886 | } |
| 887 | if n := strings.Count(menu, `aria-current="true"`); n != 1 || !regexp.MustCompile(`value="system">(?:<input[^>]*>)*<button type="submit" aria-current="true"`).MatchString(menu) { |
| 888 | t.Errorf("%s: System is not the one scheme marked current with no JavaScript", name) |
| 889 | } |
| 890 | for _, label := range []string{s.say(locale, "gallery.theme.label"), s.say(locale, "gallery.language")} { |
| 891 | if !strings.Contains(chrome, template.HTMLEscapeString(label)) { |
| 892 | t.Errorf("%s: the chrome does not name %q", name, label) |
| 893 | } |
| 894 | } |
| 895 | for _, m := range navHref.FindAllStringSubmatch(chrome, -1) { |
| 896 | if !strings.HasSuffix(m[1], "/"+pk.File) { |
| 897 | t.Errorf("%s: the chrome links %q, which is not this page in another theme or language", name, m[1]) |
| 898 | } |
| 899 | } |
| 900 | } |
| 901 | } |
| 902 | } |
| 903 | } |
| 904 | |
| 905 | // exampleEndonyms is each language's name in itself, read off Fiesta's |
| 906 | // locale-menu example, the names the framework's catalogs give them. |
| 907 | func exampleEndonyms(t *testing.T) map[string]string { |
| 908 | t.Helper() |
| 909 | doc, err := fs.ReadFile(fiesta.FS(), "examples/locale-menu/twelve-languages-current-highlighted.html") |
| 910 | if err != nil { |
| 911 | t.Fatal(err) |
| 912 | } |
| 913 | out := map[string]string{} |
| 914 | for _, b := range parse(t, doc).find(func(n node) bool { return n.n.Data == "button" }) { |
| 915 | if lang, ok := b.attr("lang"); ok { |
| 916 | out[lang] = b.text() |
| 917 | } |
| 918 | } |
| 919 | for _, l := range Locales { |
| 920 | if out[l] == "" { |
| 921 | t.Fatalf("the locale-menu example names no %s", l) |
| 922 | } |
| 923 | } |
| 924 | return out |
| 925 | } |
| 926 | |
| 927 | // TestGalleryScriptLoadsBeforeTheBody: gallery.js is a blocking script |
| 928 | // in <head>, not deferred, so a stored scheme is on the root and the |
| 929 | // frame-hiding rule is armed before the first paint. |
| 930 | func TestGalleryScriptLoadsBeforeTheBody(t *testing.T) { |
| 931 | files := render(t, "") |
| 932 | s := newTestSite(t, fiesta.FS(), "") |
| 933 | page := kindPage(t, files, fiesta.ThemeNames()[0], "en", "index.html") |
| 934 | tag := `<script src="` + s.asset("gallery.js") + `"></script>` |
| 935 | i := strings.Index(page, tag) |
| 936 | if i < 0 { |
| 937 | t.Fatalf("no blocking gallery.js tag on the page (want %s)", tag) |
| 938 | } |
| 939 | if body := strings.Index(page, "<body>"); i > body { |
| 940 | t.Error("gallery.js loads after <body>; the scheme it restores will flash") |
| 941 | } |
| 942 | if strings.Contains(page, `<script defer src="`+s.asset("gallery.js")+`"></script>`) { |
| 943 | t.Error("gallery.js is deferred; see its header comment for why it is not") |
| 944 | } |
| 945 | } |
| 946 | |
| 947 | // TestNoGalleryPageLoadsTheEnhancementScripts: a gallery page loads |
| 948 | // exactly gallery.js, fiesta.js, anchor.js and shell.js. The frames |
| 949 | // load what their markup hooks; the page's own buttons do not light |
| 950 | // and its fields are not enhanced. |
| 951 | func TestNoGalleryPageLoadsTheEnhancementScripts(t *testing.T) { |
| 952 | files := render(t, "") |
| 953 | s := newTestSite(t, fiesta.FS(), "") |
| 954 | want := map[string]bool{"gallery.js": true, "js/fiesta.js": true, "js/anchor.js": true, "js/shell.js": true} |
| 955 | plain := map[string]string{} |
| 956 | for name, hashed := range s.hashed { |
| 957 | plain[s.url("assets/"+hashed)] = name |
| 958 | } |
| 959 | checked := 0 |
| 960 | for _, name := range pageNames(s, files) { |
| 961 | got := map[string]bool{} |
| 962 | attrsOf(t, files[name], func(tag string, attrs map[string]string) { |
| 963 | if tag == "script" { |
| 964 | got[plain[attrs["src"]]] = true |
| 965 | } |
| 966 | }) |
| 967 | for script := range got { |
| 968 | if !want[script] { |
| 969 | t.Errorf("%s loads %s, which nothing on the page uses", name, script) |
| 970 | } |
| 971 | } |
| 972 | for script := range want { |
| 973 | if !got[script] { |
| 974 | t.Errorf("%s does not load %s", name, script) |
| 975 | } |
| 976 | } |
| 977 | checked++ |
| 978 | } |
| 979 | if checked == 0 { |
| 980 | t.Fatal("no page was checked") |
| 981 | } |
| 982 | } |
| 983 | |
| 984 | // TestNoGalleryPageAsksForPrerender: no page asks to be prerendered. |
| 985 | // The gallery is static files on a site with no headers file, and an |
| 986 | // inline speculation ruleset would need a CSP the system avoids. The |
| 987 | // slide and the focus return work without it. |
| 988 | func TestNoGalleryPageAsksForPrerender(t *testing.T) { |
| 989 | files := render(t, "") |
| 990 | for name, body := range files { |
| 991 | if strings.Contains(string(body), "speculationrules") || strings.Contains(string(body), "_speculation-rules") { |
| 992 | t.Errorf("%s asks for prerendering", name) |
| 993 | } |
| 994 | } |
| 995 | if len(files) == 0 { |
| 996 | t.Fatal("no files rendered") |
| 997 | } |
| 998 | } |
| 999 | |
| 1000 | // TestNoPageCarriesAPageWideViewSwitcher: no page carries a page-wide |
| 1001 | // view switcher, and neither half of the stylesheet or the script |
| 1002 | // draws one or reads the view it once remembered. Each example's own |
| 1003 | // tabs are the one view control. |
| 1004 | func TestNoPageCarriesAPageWideViewSwitcher(t *testing.T) { |
| 1005 | files := render(t, "") |
| 1006 | pages := 0 |
| 1007 | for name, b := range files { |
| 1008 | if !strings.HasSuffix(name, ".html") { |
| 1009 | continue |
| 1010 | } |
| 1011 | pages++ |
| 1012 | if page := string(b); strings.Contains(page, "ds-viewall") || strings.Contains(page, "data-ds-view=") { |
| 1013 | t.Errorf("%s carries the page-wide view switcher", name) |
| 1014 | } |
| 1015 | } |
| 1016 | if pages == 0 { |
| 1017 | t.Fatal("no pages rendered, so this checked nothing") |
| 1018 | } |
| 1019 | for name, src := range map[string][]byte{"gallery.css": galleryCSS, "view.css": viewCSS, "gallery.js": galleryJS, "view.js": viewJS} { |
| 1020 | for _, gone := range []string{"ds-viewall", "rst-ds-view", "data-ds-view", "dsView"} { |
| 1021 | if strings.Contains(string(src), gone) { |
| 1022 | t.Errorf("%s mentions %s, which only the page-wide switcher used", name, gone) |
| 1023 | } |
| 1024 | } |
| 1025 | } |
| 1026 | } |
| 1027 | |