package gallery import ( "bytes" "encoding/json" "fmt" "io/fs" "regexp" "slices" "sort" "strings" "testing" "testing/fstest" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" ) // The shape of the whole tree, and what every page in it carries: the // files the four declared-expectation seams name and no other, the // twelve locales, the sequence of pages, the Overview's routes, the // hashed assets, the titles and ids, and the password screen's // warning. These gates read their expectations through pageKinds(), // frameFiles(), extraDocumentNames() and the asset list, so a later // task that extends the tree through its own seam keeps them exact // without an edit here. // newTestSite is a site after its setup, with nothing rendered: the // seams a test reads expectations from live on it. func newTestSite(t *testing.T, src fs.FS, mount string) *site { t.Helper() s, err := newSite(src, mount) if err != nil { t.Fatalf("newSite: %v", err) } return s } // dirs lists every theme and locale directory, with its trailing // slash, in render order. func dirs() []string { var out []string for _, theme := range fiesta.ThemeNames() { for _, locale := range Locales { out = append(out, theme+"/"+locale+"/") } } return out } // isFrame says whether a tree path is a frame document. func isFrame(name string) bool { return strings.Contains(name, "/frames/") } // isSampleDocument says whether a file of the tree is a sample's own // document rather than a gallery page: a frame, or one of the extra // documents (the demo application's four and the modal route), whose // chrome is a sample's and not the gallery's. A gate on what every // gallery page carries reads the pages alone. func isSampleDocument(name string) bool { return isFrame(name) || reserved(name[strings.LastIndex(name, "/")+1:]) } // titleOf is the document's text. func titleOf(t *testing.T, doc node) string { t.Helper() titles := doc.find(func(n node) bool { return n.n.Data == "title" }) if len(titles) != 1 { t.Fatalf("the document has %d <title> elements, want 1", len(titles)) } return titles[0].text() } // TestTreeShapeIsComplete: for every theme and locale, the tree holds // every file the four seams name and the assets hashed and plain, with // index.html at the root, and nothing else. The names no seam may // lose are checked by fixed name as well, so a seam that forgot its // own files could not agree with an output that forgot them too. // // The named mutation that turns this red: a file written in theme() // that no seam names. func TestTreeShapeIsComplete(t *testing.T) { files := render(t, "") s := newTestSite(t, fiesta.FS(), "") m := s.m want := map[string]bool{"index.html": true} for _, dir := range dirs() { for _, pk := range s.pageKinds() { want[dir+pk.File] = true } for _, e := range m.Examples { want[dir+e.Name+".html"] = true for _, f := range s.frameFiles(e) { want[dir+f] = true } } for _, n := range s.extraDocumentNames() { want[dir+n] = true } } // The assets: the declaration, not what was written. Each declared // name is expected plain and under one hashed sibling with the same // bytes; the hashed name is worked out from the plain file's bytes, // so a declared asset the render skipped is a missing plain file. names, err := s.assetNames() if err != nil { t.Fatal(err) } if len(names) == 0 { t.Fatal("the site declares no assets; the check below would expect none") } for _, name := range names { want["assets/"+name] = true if body, ok := files["assets/"+name]; ok { want["assets/"+hashedName(name, body)] = true } } // By fixed name: what no seam may lose. var fixed []string for _, dir := range dirs() { fixed = append(fixed, dir+"index.html", dir+"tokens.html", dir+"icons.html") fixed = append(fixed, dir+"demo.html", dir+"demo-dashboard.html", dir+"demo-requests.html", dir+"demo-request.html", dir+"demo-modal.html") for _, f := range groupFamilies(m.Examples) { fixed = append(fixed, dir+f.Key+".html") } for _, e := range m.Examples { fixed = append(fixed, dir+e.Name+".html") for _, st := range e.States { fixed = append(fixed, dir+"frames/"+e.Name+"/"+st.ID+".html") } } } for _, name := range fixed { if !want[name] { t.Errorf("no seam names %s, which the tree may not lose", name) } if _, ok := files[name]; !ok { t.Errorf("the render wrote no %s", name) } } for name := range want { if _, ok := files[name]; !ok { t.Errorf("the seams name %s and the render wrote no such file", name) } } for name := range files { if !want[name] { t.Errorf("the render wrote %s, which no seam names", name) } } if len(files) != len(want) { t.Errorf("the tree holds %d files, the seams name %d", len(files), len(want)) } t.Logf("%d files: %d directories of %d pages, %d example pages, %d frames and %d other documents each; %d assets twice; the root index", len(files), len(dirs()), len(s.pageKinds()), len(m.Examples), len(fixed)/len(dirs())-3-5-len(groupFamilies(m.Examples))-len(m.Examples), len(s.extraDocumentNames()), len(names)) } // TestTheAssetDeclarationDrivesTheTree: the assets the tree ships are // the declared ones, read from the source and the registry, not from // what was written; an asset declared from any file is in the tree // under its plain and its hashed name the next render. The control is // a scratch declaration, registered and then withdrawn. func TestTheAssetDeclarationDrivesTheTree(t *testing.T) { s := newTestSite(t, fiesta.FS(), "") names, err := s.assetNames() if err != nil { t.Fatal(err) } for _, want := range []string{"css/tokens.css", "css/themes/day.css", "css/shell.css", "js/fiesta.js", "images/sample-logo.svg", "gallery.css", "gallery.js", "frame.js"} { if !slices.Contains(names, want) { t.Errorf("assetNames does not declare %s", want) } } saved := declaredAssets t.Cleanup(func() { declaredAssets = saved }) declareAsset("scratch/test.css", func(*site) ([]byte, error) { return []byte("/* scratch */"), nil }) files, err := Render(fiesta.FS(), Options{}) if err != nil { t.Fatal(err) } hashed := hashedName("scratch/test.css", []byte("/* scratch */")) if _, ok := files["assets/scratch/test.css"]; !ok { t.Error("a declared asset is not in the tree under its plain name") } if _, ok := files["assets/"+hashed]; !ok { t.Errorf("a declared asset is not in the tree under %s", hashed) } declareAsset("scratch/test.css", func(*site) ([]byte, error) { return nil, nil }) if _, err := Render(fiesta.FS(), Options{}); err == nil || !strings.Contains(err.Error(), "declared twice") { t.Errorf("a name declared twice did not fail the render by name: %v", err) } } // TestTheReservedDocumentsCollideWithNoPage: the names the demo // application and the modal route will take are reserved, and no page // kind or example page takes one; the modal example's page is // modal.html, so the route is demo-modal.html. The control is a // scratch manifest with an example named demo, which the render // refuses by name. func TestTheReservedDocumentsCollideWithNoPage(t *testing.T) { s := newTestSite(t, fiesta.FS(), "") for _, want := range []string{"demo.html", "demo-dashboard.html", "demo-requests.html", "demo-request.html", "demo-modal.html"} { if !reserved(want) { t.Errorf("%s is not reserved", want) } } if reserved("modal.html") { t.Error("modal.html is reserved, and it is the modal example's own page") } for _, pk := range s.pageKinds() { if reserved(pk.File) { t.Errorf("the %s page kind takes the reserved %s", pk.Kind, pk.File) } } for _, e := range s.m.Examples { if reserved(e.Name + ".html") { t.Errorf("the %s example takes a reserved name", e.Name) } } for _, n := range s.extraDocumentNames() { if !reserved(n) { t.Errorf("extraDocumentNames lists %s, which is not reserved", n) } } raw, err := fs.ReadFile(fiesta.FS(), "examples/examples.json") if err != nil { t.Fatal(err) } scratch := overlay{fiesta.FS(), map[string][]byte{"examples/examples.json": bytes.Replace(raw, []byte(`"name": "button"`), []byte(`"name": "demo"`), 1)}} if _, err := Render(scratch, Options{}); err == nil || !strings.Contains(err.Error(), "reserved") { t.Errorf("an example named demo did not fail the render as reserved: %v", err) } } // TestRootIndexIsTheDefaultThemeInEnglishAtTheTreeRoot: index.html at // the root is day/en/index.html byte for byte. func TestRootIndexIsTheDefaultThemeInEnglishAtTheTreeRoot(t *testing.T) { files := render(t, "") root, ok := files["index.html"] if !ok || len(root) == 0 { t.Fatal("no index.html at the tree root") } want := files[fiesta.ThemeNames()[0]+"/en/index.html"] if len(want) == 0 { t.Fatalf("no %s/en/index.html", fiesta.ThemeNames()[0]) } if !bytes.Equal(root, want) { t.Errorf("index.html differs from %s/en/index.html", fiesta.ThemeNames()[0]) } } // expectedSequence is the order the plan and the spec give the pages: // the page kinds in table order, and inside each family its examples // in manifest order after the family's own page. func expectedSequence(s *site, theme, locale string) []link { var out []link for _, pk := range s.pageKinds() { out = append(out, link{Href: s.href(theme, locale, pk.File), Label: s.titleOf(locale, pk)}) for _, f := range s.families { if f.Key != pk.Kind { continue } for _, e := range f.Examples { out = append(out, link{Href: s.href(theme, locale, e.Name+".html"), Label: e.Name}) } } } return out } // TestEveryPageEndsWithItsPlaceInTheSequence: walking sequence() on // day/en, every page's .ds-updown holds the previous and next links // with the destination's label; the Overview has no previous, the last // page no next; a family page's next is its first example and the last // example of a family leads to the next family page. func TestEveryPageEndsWithItsPlaceInTheSequence(t *testing.T) { files := render(t, "") s := newTestSite(t, fiesta.FS(), "") theme, locale := fiesta.ThemeNames()[0], "en" seq := s.sequence(theme, locale) want := expectedSequence(s, theme, locale) if len(seq) < 10 { t.Fatalf("the sequence has %d steps; the walk below would read too little", len(seq)) } if len(seq) != len(want) { t.Fatalf("sequence() has %d steps, the table and the manifest give %d", len(seq), len(want)) } for i := range seq { if seq[i] != want[i] { t.Errorf("step %d is %+v, want %+v", i, seq[i], want[i]) } } if seq[0].Href != s.href(theme, locale, "index.html") { t.Errorf("the sequence opens on %s, not the Overview", seq[0].Href) } for i, step := range seq { name := strings.TrimPrefix(step.Href, "/") page, ok := files[name] if !ok { t.Errorf("step %d, %s: no such page", i, name) continue } doc := parse(t, page) updown, ok := doc.first(byClass("ds-updown")) if !ok { t.Errorf("%s has no .ds-updown", name) continue } prev, hasPrev := updown.first(byClass("ds-updown__prev")) next, hasNext := updown.first(byClass("ds-updown__next")) if i == 0 { if hasPrev { t.Errorf("%s, the first page, has a previous link", name) } } else { if !hasPrev { t.Errorf("%s has no previous link", name) } else if href, _ := prev.attr("href"); href != seq[i-1].Href || prev.text() != s.say(locale, "gallery.step.prev", "page", seq[i-1].Label) { t.Errorf("%s: previous is %q to %s, want %q to %s", name, prev.text(), href, s.say(locale, "gallery.step.prev", "page", seq[i-1].Label), seq[i-1].Href) } } if i == len(seq)-1 { if hasNext { t.Errorf("%s, the last page, has a next link", name) } } else { if !hasNext { t.Errorf("%s has no next link", name) } else if href, _ := next.attr("href"); href != seq[i+1].Href || next.text() != s.say(locale, "gallery.step.next", "page", seq[i+1].Label) { t.Errorf("%s: next is %q to %s, want %q to %s", name, next.text(), href, s.say(locale, "gallery.step.next", "page", seq[i+1].Label), seq[i+1].Href) } } } // The family order is the gallery's own and fixed, and the walk ends // on the last shell: asserted against the literal list, and by // following the rendered Next links from the Overview to the end, // independently of sequence(). var keys []string for _, f := range s.families { keys = append(keys, f.Key) } if want := []string{"list-screen", "display", "form", "choices-and-actions", "date-and-time", "route", "primitives", "formats", "screens", "shells"}; !slices.Equal(keys, want) { t.Errorf("the families are ordered %v, want %v", keys, want) } at := theme + "/" + locale + "/index.html" visited := map[string]bool{} for { visited[at] = true next, ok := parse(t, files[at]).first(byClass("ds-updown__next")) if !ok { break } href, _ := next.attr("href") at = strings.TrimPrefix(href, "/") if visited[at] { t.Fatalf("the Next links loop back to %s", at) } } if want := theme + "/" + locale + "/layout-stage.html"; at != want { t.Errorf("following Next from the Overview ends on %s, want %s", at, want) } if len(visited) != len(seq) { t.Errorf("following Next visits %d pages, the sequence has %d", len(visited), len(seq)) } // The shape the spec describes, read off the expectation the // implementation was held to above. for _, f := range s.families { at := -1 for i, step := range seq { if step.Href == s.href(theme, locale, f.Key+".html") { at = i } } if at < 0 || at+1 >= len(seq) || seq[at+1].Label != f.Examples[0].Name { t.Errorf("the %s page's next is not its first example %s", f.Key, f.Examples[0].Name) } } } // TestTheOverviewRoutesIntoEveryOtherPage: the Overview's .ds-routes // lists every page kind but itself, in table order, each with a // non-empty blurb and a link to its file. func TestTheOverviewRoutesIntoEveryOtherPage(t *testing.T) { files := render(t, "") s := newTestSite(t, fiesta.FS(), "") theme, locale := fiesta.ThemeNames()[0], "en" doc := parse(t, files[theme+"/"+locale+"/index.html"]) routes, ok := doc.first(byClass("ds-routes")) if !ok { t.Fatal("the Overview has no .ds-routes") } items := routes.find(func(n node) bool { return n.n.Data == "li" }) kinds := s.pageKinds() if len(kinds) < 4 { t.Fatalf("pageKinds() lists %d kinds; the check below would read too little", len(kinds)) } var others []pageKind for _, pk := range kinds { if pk.Kind != "overview" { others = append(others, pk) } } if len(items) != len(others) { t.Fatalf("the Overview routes to %d pages, the table has %d others", len(items), len(others)) } for i, pk := range others { a, ok := items[i].first(func(n node) bool { return n.n.Data == "a" }) if !ok { t.Errorf("route %d has no link", i) continue } if href, _ := a.attr("href"); href != s.href(theme, locale, pk.File) { t.Errorf("route %d links %s, want %s", i, href, s.href(theme, locale, pk.File)) } if a.text() != s.titleOf(locale, pk) { t.Errorf("route %d reads %q, want %q", i, a.text(), s.titleOf(locale, pk)) } blurb, ok := items[i].first(func(n node) bool { return n.n.Data == "span" }) if !ok || blurb.text() == "" { t.Errorf("route %d (%s) has no blurb", i, pk.Kind) } } } // hashedSegment is the sixteen hex digits a hashed name carries before // its extension. var hashedSegment = regexp.MustCompile(`\.[0-9a-f]{16}\.[a-z]+$`) // TestEveryAssetIsWrittenHashedAndPlainAndPagesLinkTheHash: every path // under assets/ appears twice, plain and hashed with the same bytes; // no page or frame links a plain name; every hashed link resolves. func TestEveryAssetIsWrittenHashedAndPlainAndPagesLinkTheHash(t *testing.T) { const mount = "/x" files := render(t, mount) plain, hashed := map[string]bool{}, map[string]bool{} for name := range files { if !strings.HasPrefix(name, "assets/") { continue } if hashedSegment.MatchString(name) { hashed[name] = true } else { plain[name] = true } } if len(plain) == 0 || len(hashed) == 0 { t.Fatalf("%d plain and %d hashed assets; the pairing below would read nothing", len(plain), len(hashed)) } for name := range plain { ext := name[strings.LastIndex(name, "."):] stem := strings.TrimSuffix(name, ext) found := "" for h := range hashed { if strings.HasPrefix(h, stem+".") && strings.HasSuffix(h, ext) && len(h) == len(stem)+1+16+len(ext) { found = h } } if found == "" { t.Errorf("%s has no hashed sibling", name) continue } if !bytes.Equal(files[name], files[found]) { t.Errorf("%s and %s differ", name, found) } delete(hashed, found) } for h := range hashed { t.Errorf("%s has no plain sibling", h) } linked := 0 for name, body := range files { if !strings.HasSuffix(name, ".html") { continue } for _, u := range galleryURLs(t, body) { rel := strings.TrimPrefix(u, mount+"/") if !strings.HasPrefix(rel, "assets/") { continue } linked++ if !hashedSegment.MatchString(rel) { t.Errorf("%s links the plain asset %s", name, u) } if _, ok := files[rel]; !ok { t.Errorf("%s links %s, which the render did not write", name, u) } } } if linked == 0 { t.Fatal("no page links an asset; the check above read nothing") } } // TestEveryPageIsAWholeLocalisedDocument: every page under // <theme>/<locale>/ has <html lang="<locale>" dir="<Dir(locale)>">, // one <title>, and exactly one <h1> outside [rst-shell-title]; every // frame has the lang and dir and one <title>. Each page's <title> // names it in its language: a page kind as the catalog has its title, // which is not the English unless leakUnchanged says the locale keeps // it. func TestEveryPageIsAWholeLocalisedDocument(t *testing.T) { files := render(t, "") pages, frames := 0, 0 for _, theme := range fiesta.ThemeNames() { for _, locale := range Locales { dir := theme + "/" + locale + "/" for name, body := range files { if !strings.HasPrefix(name, dir) || !strings.HasSuffix(name, ".html") { continue } doc := parse(t, body) html, ok := doc.first(func(n node) bool { return n.n.Data == "html" }) if !ok { t.Errorf("%s has no <html>", name) continue } if lang, _ := html.attr("lang"); lang != locale { t.Errorf("%s says lang=%q, want %q", name, lang, locale) } if d, _ := html.attr("dir"); d != Dir(locale) { t.Errorf("%s says dir=%q, want %q", name, d, Dir(locale)) } if n := len(doc.find(func(n node) bool { return n.n.Data == "title" })); n != 1 { t.Errorf("%s has %d <title> elements", name, n) } if isSampleDocument(name) { frames++ continue } pages++ h1s := contentHeadings(doc) if len(h1s) != 1 { t.Errorf("%s has %d <h1> outside the shell title, want 1", name, len(h1s)) } } } } if pages == 0 || frames == 0 { t.Fatalf("%d pages and %d frames; the walk above read one side only", pages, frames) } // The <title> names the page in the page's language: a page kind by // its title as the locale's catalog has it, an example by its name, // which is code and the same in every language. s := newTestSite(t, fiesta.FS(), "") english := englishStrings(t) cs := loadedCatalogs(t) for _, theme := range fiesta.ThemeNames() { for _, locale := range Locales { for _, pk := range s.pageKinds() { name := theme + "/" + locale + "/" + pk.File want := english[pk.Title] if locale != "en" { if want = cs[locale][pk.Title]; want == "" { t.Errorf("%s: the %s catalog has no name for the page, %s", name, locale, pk.Title) continue } if want == english[pk.Title] && !unchangedIn(locale, want) { t.Errorf("%s: the %s catalog names the page %q, its English", name, locale, want) } } if got := titleOf(t, parse(t, files[name])); !strings.Contains(got, want) { t.Errorf("%s is titled %q, which does not name the page as %q", name, got, want) } } for _, e := range s.m.Examples { name := theme + "/" + locale + "/" + e.Name + ".html" if got := titleOf(t, parse(t, files[name])); !strings.Contains(got, e.Name) { t.Errorf("%s is titled %q, which does not name the example", name, got) } } } } } // contentHeadings is every <h1> of the page's content: not the shell's // own title, <h1 rst-shell-title>, nor anything inside it. The one // selector both heading tests read, so the shell's index heading is // never counted as the page's. func contentHeadings(doc node) []node { return doc.find(func(n node) bool { if n.n.Data != "h1" { return false } for p := n.n; p != nil; p = p.Parent { if _, shellTitle := (node{p}).attr("rst-shell-title"); shellTitle { return false } } return true }) } // anchorIDs is the set of ids on the elements carrying data-ds-anchor. func anchorIDs(t *testing.T, doc []byte) []string { t.Helper() var out []string for _, n := range parse(t, doc).find(byAttr("data-ds-anchor", "")) { id, ok := n.attr("id") if !ok { t.Errorf("an element carries data-ds-anchor and no id") } out = append(out, id) } sort.Strings(out) return out } // TestAnchorIDsAreTheSameInEveryThemeAndLocale: the data-ds-anchor ids // on tokens.html, icons.html and button.html are the same set in all // 36 directories, since they are built from English names. func TestAnchorIDsAreTheSameInEveryThemeAndLocale(t *testing.T) { files := render(t, "") ref := fiesta.ThemeNames()[0] + "/en/" for _, file := range []string{"tokens.html", "icons.html", "button.html"} { want := anchorIDs(t, files[ref+file]) if len(want) == 0 { t.Errorf("%s%s carries no data-ds-anchor", ref, file) continue } for _, dir := range dirs() { got := anchorIDs(t, files[dir+file]) if strings.Join(got, " ") != strings.Join(want, " ") { t.Errorf("%s%s anchors %v, %s%s anchors %v", dir, file, got, ref, file, want) } } } } // TestNoPageCarriesTheSameIdTwice: on every page (frames are the // examples' own documents), every id is unique. func TestNoPageCarriesTheSameIdTwice(t *testing.T) { files := render(t, "") checked := 0 for name, body := range files { if !strings.HasSuffix(name, ".html") || isFrame(name) { continue } seen := map[string]int{} for _, n := range parse(t, body).find(byAttr("id", "")) { id, _ := n.attr("id") seen[id]++ checked++ } for id, n := range seen { if n > 1 { t.Errorf("%s carries id=%q %d times", name, id, n) } } } if checked == 0 { t.Fatal("no id on any page; the check above read nothing") } } // TestEveryPagesH1IsItsOwnTitle: the <h1> of every page reads the // page's own name, the first part of its <title>. func TestEveryPagesH1IsItsOwnTitle(t *testing.T) { files := render(t, "") checked := 0 for name, body := range files { if !strings.HasSuffix(name, ".html") || isSampleDocument(name) { continue } doc := parse(t, body) title := titleOf(t, doc) h1s := contentHeadings(doc) if len(h1s) == 0 { t.Errorf("%s has no <h1>", name) continue } h1 := h1s[0] page, _, _ := strings.Cut(title, " · ") if h1.text() != page { t.Errorf("%s: the <h1> reads %q, the title opens %q", name, h1.text(), page) } checked++ } if checked == 0 { t.Fatal("no page was checked") } } // TestTitlesUseTheApprovedSeparators: a page's <title> is three parts // joined by " · ", the middle one the site's name, and no part carries // another separator. The frames' titles are the examples' own. func TestTitlesUseTheApprovedSeparators(t *testing.T) { files := render(t, "") checked := 0 for name, body := range files { if !strings.HasSuffix(name, ".html") || isFrame(name) { continue } title := titleOf(t, parse(t, body)) parts := strings.Split(title, " · ") if len(parts) != 3 || parts[1] != say("gallery.brand") { t.Errorf("%s: the title %q is not page · Fiesta · theme", name, title) } for _, part := range parts { for _, piece := range strings.Split(part, ", ") { if strings.ContainsAny(piece, "·:|–—") || strings.Contains(piece, " - ") { t.Errorf("%s: the title %q carries a separator that is not · or a comma", name, title) } } } checked++ } if checked == 0 { t.Fatal("no page was checked") } } // TestNoTitleOrStateLabelCarriesAnEmDash: no page title, frame title or // manifest state label carries an em dash. func TestNoTitleOrStateLabelCarriesAnEmDash(t *testing.T) { files := render(t, "") m := manifest(t) checked := 0 for name, body := range files { if !strings.HasSuffix(name, ".html") { continue } if title := titleOf(t, parse(t, body)); strings.Contains(title, "—") { t.Errorf("%s: the title %q carries an em dash", name, title) } checked++ } for _, e := range m.Examples { for _, st := range e.States { if strings.Contains(st.Label, "—") { t.Errorf("%s/%s: the label %q carries an em dash", e.Name, st.ID, st.Label) } checked++ } } if checked == 0 { t.Fatal("nothing was checked") } } // TestEverySamplePictureIsUsedOnce: every file under examples/assets/ // is the src of at least one frame's picture under its hashed name, // and under its plain name the src of none. func TestEverySamplePictureIsUsedOnce(t *testing.T) { const mount = "/x" files := render(t, mount) s := newTestSite(t, fiesta.FS(), mount) pictures, err := fs.ReadDir(fiesta.FS(), "examples/assets") if err != nil { t.Fatal(err) } if len(pictures) == 0 { t.Fatal("examples/assets is empty") } srcs := map[string]int{} for name, body := range files { if !isFrame(name) { continue } attrsOf(t, body, func(tag string, attrs map[string]string) { if tag == "img" { srcs[attrs["src"]]++ } }) } for _, p := range pictures { plain := "images/" + p.Name() hashed, ok := s.hashed[plain] if !ok { t.Errorf("%s is not among the assets", plain) continue } if srcs[s.url("assets/"+hashed)] == 0 { t.Errorf("no frame shows %s", hashed) } if n := srcs[s.url("assets/"+plain)]; n > 0 { t.Errorf("%d frames show %s by its plain name", n, plain) } } } // overlay serves the files it holds and everything else from the tree // under it. type overlay struct { fs.FS files map[string][]byte } func (o overlay) Open(name string) (fs.File, error) { if b, ok := o.files[name]; ok { return fstest.MapFS{name: &fstest.MapFile{Data: b}}.Open(name) } return o.FS.Open(name) } // withWarning is the source with the password screen's warning in its // prose, for as long as the real prose.json has none: the title is // Rastrillo's, carried, and the body the approved note from its second // sentence on, as the review of the split has it. Once the entry is // in the file this returns src itself and the test reads the real // words. func withWarning(t *testing.T, src fs.FS) fs.FS { t.Helper() raw, err := fs.ReadFile(src, proseFile) if err != nil { t.Fatal(err) } var doc map[string]json.RawMessage if err := json.Unmarshal(raw, &doc); err != nil { t.Fatal(err) } var examples map[string]map[string]json.RawMessage if err := json.Unmarshal(doc["examples"], &examples); err != nil { t.Fatal(err) } signin := examples["signin"] if _, ok := signin["warnings"]; ok { return src } var states map[string]string if err := json.Unmarshal(signin["states"], &states); err != nil { t.Fatal(err) } note := states["email-and-password"] first, rest, ok := strings.Cut(note, ". ") if !ok { t.Fatalf("the email-and-password note has one sentence: %q", note) } title, body, ok := strings.Cut(rest, ": ") if !ok { t.Fatalf("the note's second sentence has no colon to split at: %q", rest) } states["email-and-password"] = first + "." warnings := map[string]map[string]string{"email-and-password": {"title": title, "body": strings.ToUpper(body[:1]) + body[1:]}} signin["states"], _ = json.Marshal(states) signin["warnings"], _ = json.Marshal(warnings) doc["examples"], _ = json.Marshal(examples) out, _ := json.Marshal(doc) t.Logf("prose.json has no signin warning yet; using a scratch entry titled %q", title) return overlay{src, map[string][]byte{proseFile: out}} } // TestThePasswordScreenCarriesItsWarning: on signin.html the // email-and-password section holds one warning callout after its note, // with <strong> the warning's title and <p> its body; no other state // does. func TestThePasswordScreenCarriesItsWarning(t *testing.T) { src := withWarning(t, fiesta.FS()) files, err := Render(src, Options{}) if err != nil { t.Fatalf("Render: %v", err) } p, err := loadProse(src) if err != nil { t.Fatal(err) } warning, ok := p.Examples["signin"].Warnings["email-and-password"] if !ok || warning.Title == "" || warning.Body == "" { t.Fatalf("the prose has no whole warning for signin/email-and-password: %+v", warning) } e, ok := manifest(t).Entry("signin") if !ok { t.Fatal("no signin example") } page := files[fiesta.ThemeNames()[0]+"/en/signin.html"] if len(page) == 0 { t.Fatal("no day/en/signin.html") } for _, st := range e.States { sec := stateSection(t, page, "signin", st.ID) callouts := sec.find(func(n node) bool { _, callout := n.attr("rst-callout") tone, _ := n.attr("rst-tone") return callout && tone == "warning" }) if st.ID != "email-and-password" { if len(callouts) != 0 { t.Errorf("signin/%s carries %d warning callouts", st.ID, len(callouts)) } continue } if len(callouts) != 1 { t.Fatalf("signin/%s carries %d warning callouts, want 1", st.ID, len(callouts)) } order := sec.find(func(n node) bool { _, callout := n.attr("rst-callout") return callout || n.hasClass("ds-note") }) if len(order) != 2 || !order[0].hasClass("ds-note") { t.Errorf("signin/%s: the callout does not follow the note", st.ID) } strong, ok := callouts[0].first(func(n node) bool { return n.n.Data == "strong" }) if !ok || strong.text() != squash(warning.Title) { t.Errorf("signin/%s: the callout's title reads %q, want %q", st.ID, strong.text(), warning.Title) } body, ok := callouts[0].first(func(n node) bool { return n.n.Data == "p" }) if !ok || body.text() != squash(warning.Body) { t.Errorf("signin/%s: the callout's body reads %q, want %q", st.ID, body.text(), warning.Body) } } } // TestAnExampleDocumentFramesLikeASeededOne: a document exampleDocument // builds goes through frame like a seeded example does: frame.js first // in the head, the stylesheets and scripts hashed, the theme's own, // the locale on <html>, the style kept and the body's links deadened. // Written against the finished seam; its control is the document // built with no stylesheet, which frame refuses by name. func TestAnExampleDocumentFramesLikeASeededOne(t *testing.T) { s := newTestSite(t, fiesta.FS(), "/x") e := mustEntry(t, "button") doc := exampleDocument("A test", []string{"light.js"}, "body { padding: 0 }", `<p><a href="/orders">Orders</a></p>`) framed, err := s.frame(doc, "plain", "ar", e, e.Default) if err != nil { t.Fatalf("frame: %v", err) } html, ok := parse(t, []byte(framed)).first(func(n node) bool { return n.n.Data == "html" }) if !ok { t.Fatal("the framed document has no <html>") } if lang, _ := html.attr("lang"); lang != "ar" { t.Errorf("the framed document says lang=%q, want ar", lang) } if dir, _ := html.attr("dir"); dir != "rtl" { t.Errorf("the framed document says dir=%q, want rtl", dir) } for _, want := range []string{ `<title>A test`, ``, `href="` + s.asset("css/tokens.css") + `"`, `href="` + s.asset("css/themes/plain.css") + `"`, `src="` + s.asset("js/light.js") + `"`, ``, `Orders`, } { if !strings.Contains(framed, want) { t.Errorf("the framed document lacks %s:\n%s", want, framed) } } if i, j := strings.Index(framed, "frame."), strings.Index(framed, "tokens."); i < 0 || j < 0 || i > j { t.Errorf("frame.js is not before the first stylesheet") } bare := strings.Replace(strings.Replace(doc, ``+"\n", "", 1), ``+"\n", "", 1) bare = strings.Replace(bare, ``+"\n", "", 1) if _, err := s.frame(bare, "plain", "ar", e, e.Default); err == nil { t.Error("frame accepted a document with nothing in its head to put frame.js before") } } // TestEveryLookupIsTraced: a tracer installed with setTrace hears every // id a render asks for, chrome, prose and labels alike, and hears // nothing once removed. This is the hook the translation parity gates // read. A state the manifest leaves unlabelled (button's default) is // not a lookup, as an empty prose slot is not, so no catalog is asked // to translate nothing. func TestEveryLookupIsTraced(t *testing.T) { seen := map[string]int{} calls := 0 stop := setTrace(func(id string) { seen[id]++; calls++ }) if _, err := Render(fiesta.FS(), Options{}); err != nil { t.Fatal(err) } stop() for _, id := range []string{"gallery.title", "gallery.step.next", "gallery.locale.name", "families.display.title", "examples.button.blurb", "examples.signin.states.a-passkey", "pages.idioms.box.title", "labels.signin.asking-for-an-address"} { if seen[id] == 0 { t.Errorf("no render asked for %s", id) } } if seen["labels.button.default"] != 0 { t.Errorf("a render asked for labels.button.default, which the manifest leaves empty, %d times", seen["labels.button.default"]) } for id := range seen { if strings.HasPrefix(id, "gallery.direction") { t.Errorf("a render asked for %s, which is gone", id) } } if calls == 0 { t.Fatal("the tracer heard nothing") } before := calls if _, err := Render(fiesta.FS(), Options{}); err != nil { t.Fatal(err) } if calls != before { t.Errorf("the tracer was called %d more times after it was removed", calls-before) } } // TestARegisteredTemplateIsParsedForEveryLocale: a template registered // from any file, through registerTemplate, is in the tree every // locale's pages are drawn from, as the five body templates are. The // control is a scratch define, registered and then withdrawn. func TestARegisteredTemplateIsParsedForEveryLocale(t *testing.T) { s := newTestSite(t, fiesta.FS(), "") for _, locale := range []string{"en", "ar"} { tmpl, err := s.templates(locale) if err != nil { t.Fatal(err) } for _, name := range []string{"ds-page", "ds-controls", "ds-view", "ds-body-overview", "ds-body-family", "ds-body-example", "ds-body-tokens", "ds-body-icons"} { if tmpl.Lookup(name) == nil { t.Errorf("%s: no template %s", locale, name) } } if tmpl.Lookup("ds-body-scratch") != nil { t.Fatal("a scratch template is already registered") } } saved := templateSources t.Cleanup(func() { templateSources = saved }) registerTemplate(`{{define "ds-body-scratch"}}

{{say "gallery.brand"}}

{{end}}`) fresh := newTestSite(t, fiesta.FS(), "") tmpl, err := fresh.templates("ga") if err != nil { t.Fatal(err) } if tmpl.Lookup("ds-body-scratch") == nil { t.Error("a template registered after the others is not parsed") } var b strings.Builder if err := tmpl.ExecuteTemplate(&b, "ds-body-scratch", nil); err != nil || b.String() != "

Fiesta

" { t.Errorf("the registered template rendered %q, %v", b.String(), err) } } // TestContentHeadingsSkipTheShellTitle: the heading selector the two // heading tests share counts a content

and not the shell's own //

, nor a heading inside a shell-title element. func TestContentHeadingsSkipTheShellTitle(t *testing.T) { doc := parse(t, []byte(`

Fiesta

inner

Tokens

`)) got := contentHeadings(doc) if len(got) != 1 || got[0].text() != "Tokens" { var texts []string for _, n := range got { texts = append(texts, n.text()) } t.Errorf("contentHeadings found %v, want [Tokens]", texts) } if n := len(doc.find(func(n node) bool { return n.n.Data == "h1" })); n < 3 { t.Fatalf("the fixture parsed to %d h1 elements; the selector had nothing to skip", n) } } // mustEntry is the manifest entry for name. func mustEntry(t *testing.T, name string) example.Entry { t.Helper() e, ok := manifest(t).Entry(name) if !ok { t.Fatalf("no %s example", name) } return e } // frameFilesOf is a sanity check on the seam the gates above read: // one frame per state, under frames//. func TestFrameFilesNameOneDocumentPerState(t *testing.T) { s := newTestSite(t, fiesta.FS(), "") e := mustEntry(t, "button") got := s.frameFiles(e) if len(got) != len(e.States) { t.Fatalf("frameFiles(button) = %v, want one per state (%d)", got, len(e.States)) } for i, st := range e.States { if want := fmt.Sprintf("frames/%s/%s.html", e.Name, st.ID); got[i] != want { t.Errorf("frameFiles(button)[%d] = %q, want %q", i, got[i], want) } } }