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()))
}
}