rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package gallery
2
3import (
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.
26func 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.
47func 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.
68func 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
78var (
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.
98func 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.
165func 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.
196func 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.
291func 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.
324func 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.
372func 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.
455var 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.
459var railTitle = regexp.MustCompile(`(?s)<summary>(?:<span rst-caret aria-hidden="true">.*?</span>)?(.*?)</summary>`)
460
461type 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.
469func 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.
493func 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.
568func 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.
592func 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.
644func 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.
672func 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.
732func 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.
780func 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.
800func 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.
827func 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.
907func 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.
930func 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.
951func 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.
988func 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.
1004func 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