rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package www_test
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io/fs"
8 "regexp"
9 "strconv"
10 "strings"
11 "testing"
12 "testing/fstest"
13
14 xhtml "golang.org/x/net/html"
15
16 "amadan.net/rastrillo/fiesta"
17 "amadan.net/rastrillo/fiesta/internal/www"
18)
19
20// landingDir is a site source holding the real landing/ files (the
21// template and every catalog) with files overlaid on top.
22func landingDir(t *testing.T, files map[string]string) fstest.MapFS {
23 t.Helper()
24 return withLanding(t, siteOf(files))
25}
26
27// withLanding copies the real landing/ files, the template and every
28// catalog, from the repository's site/ into m, under any file m
29// already has at that path. Pages needs them for the root page, so
30// every test of the tree starts from this.
31func withLanding(t *testing.T, m fstest.MapFS) fstest.MapFS {
32 t.Helper()
33 src := siteSource(t).FS()
34 err := fs.WalkDir(src, "landing", func(p string, d fs.DirEntry, err error) error {
35 if err != nil {
36 return err
37 }
38 if d.IsDir() {
39 return nil
40 }
41 if _, overlaid := m[p]; overlaid {
42 return nil
43 }
44 data, err := fs.ReadFile(src, p)
45 if err != nil {
46 return err
47 }
48 m[p] = &fstest.MapFile{Data: data}
49 return nil
50 })
51 if err != nil {
52 t.Fatalf("reading the site's landing/ directory: %v", err)
53 }
54 return m
55}
56
57// english renders the English landing page from the real site source.
58func english(t *testing.T) []byte {
59 t.Helper()
60 out, err := www.Landing(siteSource(t).FS(), "en")
61 if err != nil {
62 t.Fatalf("landing: %v", err)
63 }
64 return out
65}
66
67// parse parses a page, failed loudly.
68func parse(t *testing.T, doc []byte) *xhtml.Node {
69 t.Helper()
70 n, err := xhtml.Parse(bytes.NewReader(doc))
71 if err != nil {
72 t.Fatalf("parsing the page: %v", err)
73 }
74 return n
75}
76
77// attr is one attribute's value, "" when absent.
78func attr(n *xhtml.Node, key string) string {
79 for _, a := range n.Attr {
80 if a.Key == key {
81 return a.Val
82 }
83 }
84 return ""
85}
86
87// walk calls fn on every element node in document order.
88func walk(n *xhtml.Node, fn func(*xhtml.Node)) {
89 if n.Type == xhtml.ElementNode {
90 fn(n)
91 }
92 for c := n.FirstChild; c != nil; c = c.NextSibling {
93 walk(c, fn)
94 }
95}
96
97// text is the text under n, with the whitespace collapsed.
98func text(n *xhtml.Node) string {
99 var b strings.Builder
100 var rec func(*xhtml.Node)
101 rec = func(n *xhtml.Node) {
102 if n.Type == xhtml.TextNode {
103 b.WriteString(n.Data)
104 }
105 for c := n.FirstChild; c != nil; c = c.NextSibling {
106 rec(c)
107 }
108 }
109 rec(n)
110 return strings.Join(strings.Fields(b.String()), " ")
111}
112
113// TestTheLandingKeepsTheSpecsOrder: the page's sections carry the
114// spec's ids, top to bottom in the spec's order, with the footer
115// after the reasons. Document order is what a reader without CSS
116// gets, so it is the order that matters.
117func TestTheLandingKeepsTheSpecsOrder(t *testing.T) {
118 want := []string{"top", "bunting", "headline", "install", "actions", "themes", "sample", "reasons", "footer"}
119 wanted := map[string]bool{}
120 for _, id := range want {
121 wanted[id] = true
122 }
123 var got []string
124 walk(parse(t, english(t)), func(n *xhtml.Node) {
125 if id := attr(n, "id"); wanted[id] {
126 got = append(got, id)
127 }
128 })
129 if strings.Join(got, " ") != strings.Join(want, " ") {
130 t.Errorf("the landing page's sections are %v in document order; want %v", got, want)
131 }
132}
133
134// urlRef is a quoted root-relative path inside the head's inline
135// script: the one place a URL can hide from the link and script tags.
136// The theme's URL is built from pieces there, and the piece that
137// starts with / is the one that says where it loads from.
138var urlRef = regexp.MustCompile(`["']/[^"' ]*["']`)
139
140// TestTheLandingLinksFiestaByItsPublicURLs: every stylesheet and
141// script the page loads is /1/..., the public release URL, or the
142// site's own /static/..., or inline. The design system copies Fiesta's
143// source; the landing page must not, so it is served what any user
144// is. The inline script's URLs are held to the same rule, since it is
145// what writes the theme's link.
146func TestTheLandingLinksFiestaByItsPublicURLs(t *testing.T) {
147 public := func(u string) bool {
148 return strings.HasPrefix(u, "/1/") || strings.HasPrefix(u, "/static/")
149 }
150 seen := 0
151 walk(parse(t, english(t)), func(n *xhtml.Node) {
152 switch n.Data {
153 case "link":
154 if attr(n, "rel") != "stylesheet" {
155 return
156 }
157 seen++
158 if u := attr(n, "href"); !public(u) {
159 t.Errorf("a stylesheet is loaded from %q; the landing page loads Fiesta only by its public URLs", u)
160 }
161 case "script":
162 if src := attr(n, "src"); src != "" {
163 seen++
164 if !public(src) {
165 t.Errorf("a script is loaded from %q; the landing page loads Fiesta only by its public URLs", src)
166 }
167 return
168 }
169 for _, m := range urlRef.FindAllString(text(n), -1) {
170 seen++
171 u := strings.Trim(m, `"'`)
172 if !public(u) {
173 t.Errorf("the inline script names %q; the landing page loads Fiesta only by its public URLs", u)
174 }
175 }
176 }
177 })
178 if seen < 6 {
179 t.Errorf("only %d stylesheet and script URLs were seen; the page loads at least three stylesheets and five scripts", seen)
180 }
181}
182
183// installLink is the one line a user pastes, as the install box shows
184// it: the whole URL, nothing elided.
185const installLink = `<link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css">`
186
187// TestTheLinkBoxShowsTheWholeURL: the install section holds the link
188// tag in a code element, left to right and never translated, with the
189// whole URL in its text.
190func TestTheLinkBoxShowsTheWholeURL(t *testing.T) {
191 found := false
192 walk(parse(t, english(t)), func(n *xhtml.Node) {
193 if n.Data != "code" || text(n) != installLink {
194 return
195 }
196 found = true
197 if attr(n, "dir") != "ltr" {
198 t.Errorf("the link box's code has dir=%q; want ltr, so the URL reads left to right in every language", attr(n, "dir"))
199 }
200 if attr(n, "translate") != "no" {
201 t.Errorf("the link box's code has translate=%q; want no", attr(n, "translate"))
202 }
203 inInstall := false
204 for p := n.Parent; p != nil; p = p.Parent {
205 if attr(p, "id") == "install" {
206 inInstall = true
207 }
208 }
209 if !inInstall {
210 t.Error("the link box is not inside the install section")
211 }
212 })
213 if !found {
214 t.Errorf("no code element on the page reads %s", installLink)
215 }
216}
217
218// jsConstant reads one `NAME = <number>` from bunting.js, failing when
219// the script no longer declares it: a constant that moved is a drift
220// this test can no longer see.
221func jsConstant(t *testing.T, js, name string) int {
222 t.Helper()
223 m := regexp.MustCompile(`\b` + name + ` = (\d+)\b`).FindStringSubmatch(js)
224 if m == nil {
225 t.Fatalf("bunting.js no longer declares %s = <number>; the still bunting cannot be held to it", name)
226 }
227 n, _ := strconv.Atoi(m[1])
228 return n
229}
230
231// TestTheStillBuntingIsTheAnimations: the still SVG in the markup,
232// which a reader with no JS or under reduced motion sees, hangs the
233// same bunting bunting.js animates: the same row width and height,
234// the same number of swags, the same pennants at the same places,
235// read from the script's own constants. The pennants' colours cycle
236// in the order the script reads from the markup.
237func TestTheStillBuntingIsTheAnimations(t *testing.T) {
238 js, err := fs.ReadFile(siteSource(t).FS(), "static/bunting.js")
239 if err != nil {
240 t.Fatal(err)
241 }
242 pitch, per, end := jsConstant(t, string(js), "PITCH"), jsConstant(t, string(js), "PER"), jsConstant(t, string(js), "END")
243 fw, fh, h := jsConstant(t, string(js), "FW"), jsConstant(t, string(js), "FH"), jsConstant(t, string(js), "H")
244 m := regexp.MustCompile(`\bROW = SPAN \* (\d+)\b`).FindStringSubmatch(string(js))
245 if m == nil {
246 t.Fatal("bunting.js no longer declares ROW = SPAN * <swags>")
247 }
248 swags, _ := strconv.Atoi(m[1])
249 span := pitch*per + 2*end
250
251 var svg *xhtml.Node
252 walk(parse(t, english(t)), func(n *xhtml.Node) {
253 if n.Data == "svg" && attr(n, "class") == "site-flags" {
254 svg = n
255 }
256 })
257 if svg == nil {
258 t.Fatal("the page has no svg.site-flags")
259 }
260 if want := fmt.Sprintf("0 0 %d %d", span*swags, h); attr(svg, "viewBox") != want {
261 t.Errorf("the still bunting's viewBox is %q; the animation's row is %q", attr(svg, "viewBox"), want)
262 }
263 ropes, tacks, flags, defs := 0, 0, 0, 0
264 translate := regexp.MustCompile(`^translate\((-?[\d.]+) (-?[\d.]+)\)( rotate\(-?[\d.]+\))?$`)
265 point := regexp.MustCompile(`[ML](-?[\d.]+) (-?[\d.]+)`)
266 walk(svg, func(n *xhtml.Node) {
267 switch {
268 case n.Data == "path" && attr(n, "class") == "site-span":
269 // The rope is one path through every node of the hang, as
270 // the canvas draws it; its tacks are the nodes that never
271 // move, one at each span's end, at the rope's own height.
272 ropes++
273 for _, m := range point.FindAllStringSubmatch(attr(n, "d"), -1) {
274 x, _ := strconv.ParseFloat(m[1], 64)
275 y, _ := strconv.ParseFloat(m[2], 64)
276 if int(x)%span == 0 && x == float64(int(x)) && y == float64(jsConstant(t, string(js), "TOPY")) {
277 tacks++
278 }
279 }
280 case n.Data == "polygon" && strings.Contains(attr(n, "points"), fmt.Sprintf("0,%d", fh)) && strings.HasPrefix(attr(n, "points"), fmt.Sprintf("-%d,0 %d,0", fw/2, fw/2)):
281 defs++
282 case n.Data == "g" && attr(n, "class") == "site-flag":
283 k := flags
284 flags++
285 tm := translate.FindStringSubmatch(attr(n, "transform"))
286 if tm == nil {
287 t.Errorf("pennant %d has transform %q; want a translate, with at most a rotate", k, attr(n, "transform"))
288 return
289 }
290 x, _ := strconv.ParseFloat(tm[1], 64)
291 want := float64((k/per)*span + end + (k%per)*pitch + pitch/2)
292 // Within four units of where it is tied: as the rope sags
293 // it draws a swag's pennants a little toward its middle,
294 // and the picture test holds the exact hang. A pennant out
295 // by a pitch, or a swag out by its span, is still caught.
296 if x < want-4 || x > want+4 {
297 t.Errorf("pennant %d hangs at x=%g; the animation ties it at %g", k, x, want)
298 }
299 if c, _ := strconv.Atoi(attr(n, "data-c")); c != k%6 {
300 t.Errorf("pennant %d is cloth %d; want %d, cycling through the six", k, c, k%6)
301 }
302 }
303 })
304 if ropes != 1 {
305 t.Errorf("the still bunting has %d rope paths; the canvas draws one rope", ropes)
306 }
307 if tacks != swags+1 {
308 t.Errorf("the still bunting's rope has %d tacks; the animation's %d swags hang between %d", tacks, swags, swags+1)
309 }
310 if flags != swags*per {
311 t.Errorf("the still bunting has %d pennants; the animation hangs %d, %d a swag", flags, swags*per, per)
312 }
313 if defs != 6 {
314 t.Errorf("%d pennant definitions are %d wide and %d tall; want all six", defs, fw, fh)
315 }
316}
317
318// marked renders the landing page from siteDir with every English
319// string wrapped as ⟦value⟧, so what came from the catalog can be told
320// from what did not.
321func marked(t *testing.T, siteDir fstest.MapFS) []byte {
322 t.Helper()
323 real, err := www.Strings(siteSource(t).FS(), "en")
324 if err != nil {
325 t.Fatal(err)
326 }
327 wrapped := map[string]string{}
328 for k, v := range real {
329 wrapped[k] = "⟦" + v + "⟧"
330 }
331 data, err := json.Marshal(wrapped)
332 if err != nil {
333 t.Fatal(err)
334 }
335 siteDir["landing/strings/en.json"] = &fstest.MapFile{Data: data}
336 out, err := www.Landing(siteDir, "en")
337 if err != nil {
338 t.Fatalf("landing: %v", err)
339 }
340 return out
341}
342
343// unmarkedSurfaces lists every text surface on doc that did not come
344// from the catalog: a text node with something in it, outside
345// translate="no", script, style and noscript (whose content the parser
346// keeps as text, and which holds the no-JS stylesheet link, not
347// words), that has no ⟦; and an aria-label, placeholder, title or alt
348// attribute without one.
349func unmarkedSurfaces(t *testing.T, doc []byte) []string {
350 t.Helper()
351 var out []string
352 var rec func(n *xhtml.Node, where string)
353 rec = func(n *xhtml.Node, where string) {
354 switch n.Type {
355 case xhtml.TextNode:
356 if s := strings.TrimSpace(n.Data); s != "" && !strings.Contains(s, "⟦") {
357 out = append(out, where+": text "+strconvQuote(s))
358 }
359 return
360 case xhtml.ElementNode:
361 if n.Data == "script" || n.Data == "style" || n.Data == "noscript" || attr(n, "translate") == "no" {
362 return
363 }
364 where += "/" + n.Data
365 if id := attr(n, "id"); id != "" {
366 where += "#" + id
367 }
368 for _, a := range n.Attr {
369 switch a.Key {
370 case "aria-label", "placeholder", "title", "alt":
371 if !strings.Contains(a.Val, "⟦") {
372 out = append(out, where+": "+a.Key+"="+strconvQuote(a.Val))
373 }
374 }
375 }
376 }
377 for c := n.FirstChild; c != nil; c = c.NextSibling {
378 rec(c, where)
379 }
380 }
381 rec(parse(t, doc), "")
382 return out
383}
384
385func strconvQuote(s string) string {
386 if len(s) > 60 {
387 s = s[:60] + "…"
388 }
389 return `"` + s + `"`
390}
391
392// TestEveryTextSurfaceComesFromTheCatalog: rendered with a catalog
393// whose every value is wrapped in ⟦ ⟧, every text node outside
394// translate="no", script and style carries the mark, and so does every
395// aria-label, placeholder, title and alt. The control is the same
396// template with one hard-coded English word, which the check must
397// name.
398func TestEveryTextSurfaceComesFromTheCatalog(t *testing.T) {
399 if got := unmarkedSurfaces(t, marked(t, landingDir(t, nil))); len(got) > 0 {
400 t.Errorf("%d text surfaces did not come from the catalog:\n %s", len(got), strings.Join(got, "\n "))
401 }
402
403 src, err := fs.ReadFile(siteSource(t).FS(), "landing/page.html.tmpl")
404 if err != nil {
405 t.Fatal(err)
406 }
407 control := strings.Replace(string(src), "</body>", "<p>Hello</p></body>", 1)
408 if control == string(src) {
409 t.Fatal("the template has no </body> to plant the control after")
410 }
411 got := unmarkedSurfaces(t, marked(t, landingDir(t, map[string]string{"landing/page.html.tmpl": control})))
412 if len(got) != 1 || !strings.Contains(got[0], `"Hello"`) {
413 t.Errorf("the control, one hard-coded word, was reported as %v; want exactly it", got)
414 }
415}
416
417// pagePath is where a language's landing page lives in the tree:
418// English at the root, every other language under its code.
419func pagePath(lang string) string {
420 if lang == "en" {
421 return "index.html"
422 }
423 return lang + "/index.html"
424}
425
426// pageURL is the same page's URL on the site, a directory with a
427// trailing slash, which is what the hreflang links and the language
428// menu point at.
429func pageURL(lang string) string {
430 if lang == "en" {
431 return "/"
432 }
433 return "/" + lang + "/"
434}
435
436// hasScheme reports whether u is an absolute URL, scheme and all.
437var hasScheme = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)
438
439// TestEveryLanguageHasAPage: Pages writes one landing page per
440// language in www.Languages, English at index.html and every other
441// at <code>/index.html, each with its own lang and dir on the html
442// element and its own catalog's title. The root page is English and
443// does not redirect. And since every other page lives one directory
444// down, every URL on it is root-relative, a fragment or absolute:
445// a document-relative URL would resolve against /<code>/ and miss.
446func TestEveryLanguageHasAPage(t *testing.T) {
447 out := pages(t, siteOf(nil))
448 for _, lang := range www.Languages {
449 doc, ok := out[pagePath(lang)]
450 if !ok {
451 t.Errorf("Pages wrote no %s; the landing page in %s is missing", pagePath(lang), lang)
452 continue
453 }
454 strs, err := www.Strings(siteSource(t).FS(), lang)
455 if err != nil {
456 t.Fatal(err)
457 }
458 var html *xhtml.Node
459 var title string
460 var relative []string
461 walk(parse(t, doc), func(n *xhtml.Node) {
462 switch n.Data {
463 case "html":
464 html = n
465 case "title":
466 title = text(n)
467 case "meta":
468 if strings.EqualFold(attr(n, "http-equiv"), "refresh") {
469 t.Errorf("%s carries a meta refresh; a landing page never redirects", pagePath(lang))
470 }
471 }
472 for _, a := range n.Attr {
473 if a.Key != "href" && a.Key != "src" && a.Key != "action" {
474 continue
475 }
476 if strings.HasPrefix(a.Val, "/") || strings.HasPrefix(a.Val, "#") || hasScheme.MatchString(a.Val) {
477 continue
478 }
479 relative = append(relative, "<"+n.Data+" "+a.Key+"="+strconvQuote(a.Val)+">")
480 }
481 })
482 if html == nil {
483 t.Errorf("%s has no html element", pagePath(lang))
484 continue
485 }
486 if got := attr(html, "lang"); got != lang {
487 t.Errorf("%s has <html lang=%q>; want %q", pagePath(lang), got, lang)
488 }
489 if got, want := attr(html, "dir"), www.Dir(lang); got != want {
490 t.Errorf("%s has <html dir=%q>; want %q", pagePath(lang), got, want)
491 }
492 if title != strs["title"] {
493 t.Errorf("%s is titled %q; the %s catalog says %q", pagePath(lang), title, lang, strs["title"])
494 }
495 if len(relative) > 0 {
496 t.Errorf("%s has %d document-relative URLs, which resolve against %s rather than the root:\n %s", pagePath(lang), len(relative), pageURL(lang), strings.Join(relative, "\n "))
497 }
498 }
499 if _, ok := out["en/index.html"]; ok {
500 t.Error("Pages wrote en/index.html; English lives at the root, not under /en/")
501 }
502}
503
504// endonyms reads each language's name in its own language from
505// Fiesta's locale-menu example, so the site's menu names languages as
506// Fiesta does and invents nothing.
507func endonyms(t *testing.T) map[string]string {
508 t.Helper()
509 doc, err := fs.ReadFile(fiesta.FS(), "examples/locale-menu/twelve-languages-current-highlighted.html")
510 if err != nil {
511 t.Fatalf("reading the locale-menu example: %v", err)
512 }
513 out := map[string]string{}
514 walk(parse(t, doc), func(n *xhtml.Node) {
515 if n.Data == "button" && attr(n, "lang") != "" {
516 out[attr(n, "lang")] = text(n)
517 }
518 })
519 for _, lang := range www.Languages {
520 if out[lang] == "" {
521 t.Fatalf("the locale-menu example names no %s; the site's menu has nothing to take its name from", lang)
522 }
523 }
524 return out
525}
526
527// TestEveryPageLinksEveryLanguage: every landing page carries a
528// rel=alternate hreflang link for each of the eleven languages and an
529// x-default pointing at the root, and its language menu lists the
530// eleven as links with lang set, named by the locale-menu example's
531// endonyms, never translated, with the page's own language marked
532// aria-current and no other.
533func TestEveryPageLinksEveryLanguage(t *testing.T) {
534 out := pages(t, siteOf(nil))
535 names := endonyms(t)
536 for _, lang := range www.Languages {
537 doc, ok := out[pagePath(lang)]
538 if !ok {
539 t.Errorf("Pages wrote no %s", pagePath(lang))
540 continue
541 }
542 alternates := map[string]string{}
543 var menu []*xhtml.Node
544 walk(parse(t, doc), func(n *xhtml.Node) {
545 switch {
546 case n.Data == "link" && attr(n, "rel") == "alternate":
547 if hl := attr(n, "hreflang"); hl != "" {
548 if _, dup := alternates[hl]; dup {
549 t.Errorf("%s has two hreflang=%q links", pagePath(lang), hl)
550 }
551 alternates[hl] = attr(n, "href")
552 }
553 case n.Data == "a" && attr(n, "lang") != "":
554 inMenu := false
555 for p := n.Parent; p != nil; p = p.Parent {
556 if attr(p, "class") == "site-lang" {
557 inMenu = true
558 }
559 }
560 if inMenu {
561 menu = append(menu, n)
562 }
563 }
564 })
565 // hreflang wants a fully qualified URL, so every one is the
566 // site's origin and the page's path.
567 for _, l := range www.Languages {
568 if got, want := alternates[l], www.Origin+pageURL(l); got != want {
569 t.Errorf("%s links hreflang=%q to %q; want %q", pagePath(lang), l, got, want)
570 }
571 }
572 if got, want := alternates["x-default"], www.Origin+"/"; got != want {
573 t.Errorf("%s links hreflang=x-default to %q; want %q", pagePath(lang), got, want)
574 }
575 if len(alternates) != len(www.Languages)+1 {
576 t.Errorf("%s has %d hreflang links; want %d, the eleven languages and x-default", pagePath(lang), len(alternates), len(www.Languages)+1)
577 }
578
579 if len(menu) != len(www.Languages) {
580 t.Errorf("%s's language menu lists %d languages; want %d", pagePath(lang), len(menu), len(www.Languages))
581 }
582 seen := map[string]bool{}
583 var current []string
584 for _, a := range menu {
585 code := attr(a, "lang")
586 seen[code] = true
587 if got, want := attr(a, "href"), pageURL(code); got != want {
588 t.Errorf("%s's menu links %s to %q; want %q", pagePath(lang), code, got, want)
589 }
590 if got, want := text(a), names[code]; got != want {
591 t.Errorf("%s's menu names %s %q; the locale-menu example says %q", pagePath(lang), code, got, want)
592 }
593 // The current page's entry says aria-current="page", and
594 // no other entry carries the attribute at all: a screen
595 // reader announces any value, "false" included.
596 switch cur := attr(a, "aria-current"); {
597 case cur == "page":
598 current = append(current, code)
599 case cur != "":
600 t.Errorf("%s's menu entry for %s has aria-current=%q; the current entry says \"page\" and the rest say nothing", pagePath(lang), code, cur)
601 }
602 noTranslate := false
603 for p := a; p != nil; p = p.Parent {
604 if p.Type == xhtml.ElementNode && attr(p, "translate") == "no" {
605 noTranslate = true
606 }
607 }
608 if !noTranslate {
609 t.Errorf("%s's menu entry for %s is not translate=\"no\"; a language's name stays in that language", pagePath(lang), code)
610 }
611 }
612 for _, l := range www.Languages {
613 if !seen[l] {
614 t.Errorf("%s's language menu has no entry for %s", pagePath(lang), l)
615 }
616 }
617 if len(current) != 1 || current[0] != lang {
618 t.Errorf("%s's menu marks %v aria-current; want %s alone", pagePath(lang), current, lang)
619 }
620 }
621}
622
623// designSystemURL is where the landing page in lang sends a reader who
624// asks for the design system: the design system's own root for
625// English, which is the default theme's English Overview, and that
626// theme's Overview in lang for every other language.
627func designSystemURL(lang string) string {
628 if lang == "en" {
629 return www.Mount + "/"
630 }
631 return www.Mount + "/day/" + lang + "/"
632}
633
634// TestTheLandingLinksTheDesignSystemInItsLanguage: on the landing page
635// in each language, "Design system" in the nav and the footer and "See
636// the design system" under the headline link the design system in the
637// page's own language: /es/ links /design-system/day/es/, and / links
638// /design-system/. The links are found by their words, read from the
639// page's own catalog, so the check names the link a reader sees; and
640// no link on the page goes anywhere else under the design system, so a
641// link left on the English root is caught wherever it sits.
642func TestTheLandingLinksTheDesignSystemInItsLanguage(t *testing.T) {
643 for _, lang := range www.Languages {
644 doc, err := www.Landing(siteSource(t).FS(), lang)
645 if err != nil {
646 t.Fatalf("landing in %s: %v", lang, err)
647 }
648 strs, err := www.Strings(siteSource(t).FS(), lang)
649 if err != nil {
650 t.Fatal(err)
651 }
652 want := designSystemURL(lang)
653 found := map[string]int{}
654 walk(parse(t, doc), func(n *xhtml.Node) {
655 if n.Data != "a" {
656 return
657 }
658 href := attr(n, "href")
659 named := false
660 for _, key := range []string{"nav.design", "cta.see"} {
661 if text(n) != strs[key] {
662 continue
663 }
664 named = true
665 found[key]++
666 if href != want {
667 t.Errorf("%s: %q links %q; want %q, the design system in the page's language", pageURL(lang), text(n), href, want)
668 }
669 }
670 if !named && (href == www.Mount || strings.HasPrefix(href, www.Mount+"/")) && href != want {
671 t.Errorf("%s: a link reading %q goes to %q; every link to the design system goes to %q", pageURL(lang), text(n), href, want)
672 }
673 })
674 for key, least := range map[string]int{"nav.design": 2, "cta.see": 1} {
675 if found[key] < least {
676 t.Errorf("%s: %d links read %q (%s); want at least %d", pageURL(lang), found[key], strs[key], key, least)
677 }
678 }
679 }
680}
681