package gallery import ( "fmt" "html/template" "io/fs" "regexp" "sort" "strings" "testing" "testing/fstest" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/build" ) // Getting started, the Icons page and the Overview's intro. The // weights page is held to the release build itself: every figure on it // is re-measured here from build.Dist, so a number beside a file name // and the file cannot disagree. The Icons page is held to icons/*.svg // the same way, and the two pages' off-site links are the only two // kinds of link that leave the mount, each with its gate. // shippedRows is the order Getting started lists the release, as the // spec gives it: the one-file themes, the tokens, each theme, the // shell, the scripts fiesta.js first and calendar.js last, the sprite, // then the strings for every language the source carries. Written out // here rather than read off the page, so the page is held to a list it // did not write. func shippedRows(t *testing.T) []string { t.Helper() rows := []string{ "fiesta.css", "fiesta-plain.css", "fiesta-signal.css", "tokens.css", "day.css", "plain.css", "signal.css", "shell.css", "fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "transition.js", "copy.js", "select.js", "datetime.js", "calendar.js", "icons.svg", } entries, err := fs.ReadDir(fiesta.FS(), "i18n") if err != nil { t.Fatal(err) } langs := 0 for _, e := range entries { if strings.HasSuffix(e.Name(), ".json") { rows = append(rows, "i18n/"+strings.TrimSuffix(e.Name(), ".json")+".js") langs++ } } if langs != 12 { t.Fatalf("the source carries %d languages, want 12", langs) } return rows } // releaseBuild is the release build of the real source, failed loudly. func releaseBuild(t *testing.T) map[string][]byte { t.Helper() dist, err := build.Dist(fiesta.FS(), fiesta.Version) if err != nil { t.Fatalf("build.Dist: %v", err) } return dist } // TestTheGettingStartedPageWeighsTheRealAssets: in every theme and // locale the page lists exactly the shipped files, in the spec's order, // each with its anchor, its link to the release in the one link form, // and a weight that is weigh() of that very file in the release build; // the total is the arithmetic over fiesta.css and the scripts; the rail // entries assetNav returns are one per row, in order, by file name // (checked as a unit, since the rendered rail is the shell's); and the // gallery's own furniture has no row. // // The control for the row itself is a File misspelled geting-started.html // in pageKinds: the page is then missing by this name. func TestTheGettingStartedPageWeighsTheRealAssets(t *testing.T) { files := render(t, "") s := newTestSite(t, fiesta.FS(), "") dist := releaseBuild(t) rows := shippedRows(t) for _, name := range rows { if _, ok := dist[name]; !ok { t.Fatalf("the release has no %s; the list of rows is wrong", name) } } totalRaw, totalGzip := 0, 0 for _, name := range rows { if name == "fiesta.css" || (strings.HasSuffix(name, ".js") && !strings.Contains(name, "/")) { w := weigh(dist[name]) totalRaw, totalGzip = totalRaw+w.Raw, totalGzip+w.Gzip } } if totalGzip >= totalRaw { t.Fatalf("the total gzipped, %d, is not below the total as written, %d", totalGzip, totalRaw) } for _, theme := range fiesta.ThemeNames() { for _, locale := range Locales { name := theme + "/" + locale + "/getting-started.html" body, ok := files[name] if !ok { t.Errorf("the render wrote no %s", name) continue } doc := parse(t, body) list, ok := doc.first(byClass("ds-files")) if !ok { t.Errorf("%s has no .ds-files list", name) continue } items := list.find(func(n node) bool { return n.n.Data == "li" }) if len(items) != len(rows) { t.Errorf("%s lists %d files, the release ships %d rows", name, len(items), len(rows)) } p := s.newPage(theme, locale, "getting-started", "getting-started.html", "") nav := s.assetNav(p) if len(nav) != len(rows) { t.Errorf("%s: assetNav returns %d entries, the page has %d rows", name, len(nav), len(rows)) } page := s.href(theme, locale, "getting-started.html") for i, want := range rows { id := anchorID("asset", want) if i < len(nav) { if nav[i].Label != want || nav[i].Href != page+"#"+id || !nav[i].Code { t.Errorf("%s: rail entry %d is %+v, want %s at %s#%s in the mono face", name, i, nav[i], want, page, id) } } if i >= len(items) { break } li := items[i] if got, _ := li.attr("id"); got != id { t.Errorf("%s: row %d has id %q, want %q", name, i, got, id) } if _, ok := li.attr("data-ds-anchor"); !ok { t.Errorf("%s: row %s carries no data-ds-anchor", name, want) } a, ok := li.first(func(n node) bool { return n.n.Data == "a" }) if !ok { t.Errorf("%s: row %s has no link", name, want) continue } if href, _ := a.attr("href"); href != ReleaseBase+want { t.Errorf("%s: row %s links %q, want %s", name, want, href, ReleaseBase+want) } if _, ok := a.attr("data-gallery-site"); !ok { t.Errorf("%s: row %s's link carries no data-gallery-site", name, want) } if a.text() != want { t.Errorf("%s: row %d reads %q, want %q", name, i, a.text(), want) } weight := s.weightIn(locale, weigh(dist[want])) if !strings.Contains(li.text(), weight) { t.Errorf("%s: row %s does not read %q; the weight on the page is not that of the release file", name, want, weight) } } total := s.say(locale, "gallery.started.total", "raw", kibUnit(locale, totalRaw), "gzip", kibUnit(locale, totalGzip)) if !strings.Contains(string(body), template.HTMLEscapeString(total)) { t.Errorf("%s does not say %q; the total is not the sum of fiesta.css and the scripts", name, total) } link := `` own := `` + "\n" + `` var codes []string for _, pre := range doc.find(byClass("ds-src")) { codes = append(codes, pre.text()) } for _, want := range []string{link, own} { found := false for _, c := range codes { found = found || c == strings.Join(strings.Fields(want), " ") } if !found { t.Errorf("%s: no code block reads %q; the blocks read %q", name, want, codes) } } _, read, ok := strings.Cut(string(body), "") if !ok { t.Errorf("%s: no ", name) continue } for _, own := range []string{anchorID("asset", "gallery.js"), anchorID("asset", "gallery.css")} { if strings.Contains(read, own) { t.Errorf("%s lists the gallery's own %s, which no page ships", name, own) } } } } } // TestEveryReleaseLinkIsTheOneLinkForm: every link marked // data-gallery-site is an whose href is ReleaseBase followed by a // file the release build holds, and no href starting with ReleaseBase // lacks the mark. Asserted non-vacuous: Getting started links every // shipped file. func TestEveryReleaseLinkIsTheOneLinkForm(t *testing.T) { files := render(t, "") dist := releaseBuild(t) marked := 0 for name, body := range files { if !strings.HasSuffix(name, ".html") { continue } attrsOf(t, body, func(tag string, attrs map[string]string) { href, hasHref := attrs["href"] if _, site := attrs["data-gallery-site"]; site { marked++ if tag != "a" { t.Errorf("%s: a <%s> carries data-gallery-site; only a link does", name, tag) } file := strings.TrimPrefix(href, ReleaseBase) switch { case !hasHref || file == href: t.Errorf("%s: a data-gallery-site link reads %q, which does not start with %s", name, href, ReleaseBase) case dist[file] == nil: t.Errorf("%s: links %s, and the release has no %s", name, href, file) } return } if hasHref && strings.HasPrefix(href, ReleaseBase) { t.Errorf("%s: <%s href=%q> links the release without data-gallery-site", name, tag, href) } }) } if marked == 0 { t.Error("no data-gallery-site link in the tree; Getting started links every shipped file") } } // lucideNameShape is what lucide.dev names an icon: lowercase words // joined by single hyphens. var lucideNameShape = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) // copyFS is src read whole into a map, so a test can add a file beside // the real ones and the directory listings see it. func copyFS(t *testing.T, src fs.FS) fstest.MapFS { t.Helper() out := fstest.MapFS{} err := fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } b, err := fs.ReadFile(src, path) if err != nil { return err } out[path] = &fstest.MapFile{Data: b} return nil }) if err != nil { t.Fatal(err) } return out } // iconsPageReads holds one Icons page to one set of slugs: a chip per // slug with its anchor, its slug, its file and its lucide.dev address; // as many chips and addresses as slugs, each address well formed; the // renamed count read off the page equal to the number of addresses // that do not repeat the slug and to the count the sentence says; the // lead's count; and the one link to the set. func iconsPageReads(t *testing.T, name string, doc node, s *site, locale string, slugs []string) { t.Helper() chips := doc.find(func(n node) bool { id, _ := n.attr("id") return n.hasClass("ds-tok") && strings.HasPrefix(id, "icon-") }) if len(chips) != len(slugs) { t.Errorf("%s draws %d icons, the source has %d slugs", name, len(chips), len(slugs)) } renamed := 0 addresses := 0 for _, slug := range slugs { li, ok := doc.first(byAttr("id", anchorID("icon", slug))) if !ok { t.Errorf("%s: no chip with id %q; the page is not reading the slugs", name, anchorID("icon", slug)) continue } if _, ok := li.attr("data-ds-anchor"); !ok { t.Errorf("%s: the chip for %s carries no data-ds-anchor", name, slug) } if _, ok := li.first(func(n node) bool { return n.n.Data == "svg" }); !ok { t.Errorf("%s: the chip for %s draws no svg", name, slug) } values := li.find(byClass("ds-tok__value")) if len(values) < 2 { t.Errorf("%s: the chip for %s has %d value lines, want the file and the address", name, slug, len(values)) continue } if got := values[0].text(); got != "icons/"+slug+".svg" { t.Errorf("%s: the chip for %s names the file %q, want icons/%s.svg", name, slug, got, slug) } address := values[len(values)-1].text() lucide, ok := strings.CutPrefix(address, "lucide.dev/icons/") if !ok { t.Errorf("%s: the last line under %s is %q, want a lucide.dev/icons/ address", name, slug, address) continue } addresses++ if !lucideNameShape.MatchString(lucide) { t.Errorf("%s: the address under %s is %q, which names no icon", name, slug, address) } if lucide != lucideName(slug) { t.Errorf("%s: the address under %s names %q, lucideNames says %q", name, slug, lucide, lucideName(slug)) } if lucide != slug { renamed++ } } if addresses != len(slugs) { t.Errorf("%s prints %d addresses, want one per slug (%d)", name, addresses, len(slugs)) } if renamed == 0 { t.Errorf("%s: no address differs from its slug, and the source renames five of Lucide's", name) } total := fmt.Sprint(len(slugs)) for _, tc := range []struct{ what, want string }{ {"the lead", s.say(locale, "gallery.icons.lead", "total", total)}, {"the renamed sentence", s.say(locale, "gallery.icons.names.body", "renamed", fmt.Sprint(renamed), "total", total)}, } { found := false for _, p := range doc.find(func(n node) bool { return n.n.Data == "p" }) { found = found || p.text() == tc.want } if !found { t.Errorf("%s: %s does not read %q; a number on the page is typed rather than counted", name, tc.what, tc.want) } } links := doc.find(func(n node) bool { href, _ := n.attr("href"); return n.n.Data == "a" && href == lucideHome }) if len(links) != 1 { t.Errorf("%s links %s %d times, want once", name, lucideHome, len(links)) } else { a := links[0] if target, _ := a.attr("target"); target != "_blank" { t.Errorf("%s: the link to %s does not open a new tab", name, lucideHome) } if rel, _ := a.attr("rel"); rel != "noopener" { t.Errorf("%s: the link to %s has rel=%q, want noopener", name, lucideHome, rel) } if !strings.Contains(a.text(), s.say(locale, "gallery.newtab")) { t.Errorf("%s: the link to %s does not say it opens in a new tab", name, lucideHome) } } } // TestTheIconsPageIsAReadingOfIconSlugs: in every theme and locale the // Icons page draws one chip per icons/*.svg, each with its lucide.dev // address, and the renamed count equals the addresses that do not // repeat their slug; lucideNames names only real slugs; and a scratch // source with one more icon grows the page by one chip and one address. func TestTheIconsPageIsAReadingOfIconSlugs(t *testing.T) { files := render(t, "") s := newTestSite(t, fiesta.FS(), "") slugs := fiesta.IconSlugs() if len(slugs) == 0 { t.Fatal("fiesta.IconSlugs() is empty; there is nothing for this gate to read") } for slug, lucide := range lucideNames { found := false for _, have := range slugs { found = found || have == slug } if !found { t.Errorf("lucideNames renames %q, which is not an icon", slug) } if lucide == slug || !lucideNameShape.MatchString(lucide) { t.Errorf("lucideNames[%q] is %q, which is not a different, well-formed Lucide name", slug, lucide) } } for _, theme := range fiesta.ThemeNames() { for _, locale := range Locales { name := theme + "/" + locale + "/icons.html" body, ok := files[name] if !ok { t.Errorf("the render wrote no %s", name) continue } iconsPageReads(t, name, parse(t, body), s, locale, slugs) } } src := copyFS(t, fiesta.FS()) src["icons/scratch-glyph.svg"] = &fstest.MapFile{Data: []byte(``)} s2 := newTestSite(t, src, "") body, err := s2.iconsBody(s2.newPage("day", "en", "icons", "icons.html", "")) if err != nil { t.Fatalf("iconsBody on the scratch source: %v", err) } more := append(append([]string(nil), slugs...), "scratch-glyph") sort.Strings(more) iconsPageReads(t, "the scratch source's Icons page", parse(t, []byte(body)), s2, "en", more) } // TestTheIntroOpensTheOverview: in every theme and locale the Overview // carries exactly one .ds-intro paragraph, reading the intro, before // any other block of its body; no other page carries one. func TestTheIntroOpensTheOverview(t *testing.T) { files := render(t, "") s := newTestSite(t, fiesta.FS(), "") blocks := []string{"ds-intro", "ds-lead", "ds-sub", "ds-routes", "ds-demo", "ds-note"} for _, dir := range dirs() { locale := strings.Split(dir, "/")[1] name := dir + "index.html" doc := parse(t, files[name]) main, ok := doc.first(byAttr("id", "main")) if !ok { t.Errorf("%s has no #main", name) continue } intros := main.find(byClass("ds-intro")) if len(intros) != 1 { t.Errorf("%s carries %d .ds-intro, want 1", name, len(intros)) continue } if intros[0].n.Data != "p" { t.Errorf("%s: the intro is a <%s>, want a paragraph", name, intros[0].n.Data) } if want := s.say(locale, "gallery.overview.intro"); intros[0].text() != want { t.Errorf("%s: the intro reads %q, want %q", name, intros[0].text(), want) } first, ok := main.first(func(n node) bool { for _, c := range blocks { if n.hasClass(c) { return true } } return false }) if !ok || !first.hasClass("ds-intro") { class, _ := first.attr("class") t.Errorf("%s opens with a %q block, want the intro first", name, class) } } for name, body := range files { if !strings.HasSuffix(name, ".html") || strings.HasSuffix(name, "/index.html") || name == "index.html" { continue } if strings.Contains(string(body), `class="ds-intro"`) { t.Errorf("%s carries the intro, which is the Overview's alone", name) } } } // TestEveryOutboundLinkIsAllowedAndUsed: every https:// link in the // tree that is not a release link is in outboundLinks, exactly; every // entry there is https, has its reason, and is linked by some page; // nothing links plaintext http. Asserted non-vacuous: the Icons page // links lucide.dev. func TestEveryOutboundLinkIsAllowedAndUsed(t *testing.T) { files := render(t, "") seen := map[string]int{} for name, body := range files { if !strings.HasSuffix(name, ".html") { continue } attrsOf(t, body, func(tag string, attrs map[string]string) { href, ok := attrs["href"] if !ok || tag != "a" { return } if strings.HasPrefix(href, "http://") { t.Errorf("%s links %s; this tree does not send a reader to plaintext", name, href) } if _, site := attrs["data-gallery-site"]; site || !strings.HasPrefix(href, "https://") { return } seen[href]++ }) } for href, why := range outboundLinks { if !strings.HasPrefix(href, "https://") { t.Errorf("outboundLinks[%q] is not https", href) } if why == "" { t.Errorf("outboundLinks[%q] has no reason beside it", href) } if seen[href] == 0 { t.Errorf("outboundLinks names %q and nothing in the tree links it: delete the entry", href) } } for href, n := range seen { if _, ok := outboundLinks[href]; !ok { t.Errorf("%d links go to %q, which is not in outboundLinks", n, href) } } if len(seen) == 0 { t.Error("no off-tree links found at all; the Icons page has lost its link to the set, or this gate has stopped looking") } } // TestEveryShippedFileWeighsLessCompressed: every file of the release // build gzips to fewer bytes than it holds, so the two figures on a // Getting started row are never the wrong way round. // // Already passing when written: the release has no file so small that // gzip's framing outweighs its saving. The named mutation that turns // it red: weigh reporting Gzip as Raw. func TestEveryShippedFileWeighsLessCompressed(t *testing.T) { dist := releaseBuild(t) if len(dist) == 0 { t.Fatal("the release build is empty") } for name, body := range dist { if w := weigh(body); w.Gzip >= w.Raw { t.Errorf("%s weighs %d as written and %d gzipped; gzip must shrink it", name, w.Raw, w.Gzip) } } } // TestTheScriptsParagraphNamesEveryShippedScript: Getting started's // paragraph on the optional scripts names every script the release // ships. It is the one place a reader learns a script exists and may be // deleted, so a script left out of it is one nobody loads: copy.js // shipped once with the paragraph silent about it. The list is read // off the release build, not written here, so a new script fails this // until the English names it. The English is the source of the eleven // translations, so it is the one held here. func TestTheScriptsParagraphNamesEveryShippedScript(t *testing.T) { body := say("gallery.started.scripts.body") scripts := 0 for name := range releaseBuild(t) { if strings.Contains(name, "/") || !strings.HasSuffix(name, ".js") { continue } scripts++ // Bounded on both sides, so fiesta.js is not found inside a // longer name such as notfiesta.js. named := regexp.MustCompile(`(^|[^\w.-])` + regexp.QuoteMeta(name) + `($|[^\w-])`) if !named.MatchString(body) { t.Errorf("the release ships %s, but Getting started's scripts paragraph does not name it:\n %s", name, body) } } // A build that ships no scripts passes whatever the paragraph says. if scripts < len(fiesta.ScriptNames()) { t.Fatalf("the release build ships %d top-level scripts, want at least %d; this is reading the wrong files", scripts, len(fiesta.ScriptNames())) } }