rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package gallery
2
3import (
4 "fmt"
5 "html/template"
6 "io/fs"
7 "regexp"
8 "sort"
9 "strings"
10 "testing"
11 "testing/fstest"
12
13 "amadan.net/rastrillo/fiesta"
14 "amadan.net/rastrillo/fiesta/internal/build"
15)
16
17// Getting started, the Icons page and the Overview's intro. The
18// weights page is held to the release build itself: every figure on it
19// is re-measured here from build.Dist, so a number beside a file name
20// and the file cannot disagree. The Icons page is held to icons/*.svg
21// the same way, and the two pages' off-site links are the only two
22// kinds of link that leave the mount, each with its gate.
23
24// shippedRows is the order Getting started lists the release, as the
25// spec gives it: the one-file themes, the tokens, each theme, the
26// shell, the scripts fiesta.js first and calendar.js last, the sprite,
27// then the strings for every language the source carries. Written out
28// here rather than read off the page, so the page is held to a list it
29// did not write.
30func shippedRows(t *testing.T) []string {
31 t.Helper()
32 rows := []string{
33 "fiesta.css", "fiesta-plain.css", "fiesta-signal.css",
34 "tokens.css", "day.css", "plain.css", "signal.css", "shell.css",
35 "fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "transition.js", "copy.js", "select.js", "datetime.js", "calendar.js",
36 "icons.svg",
37 }
38 entries, err := fs.ReadDir(fiesta.FS(), "i18n")
39 if err != nil {
40 t.Fatal(err)
41 }
42 langs := 0
43 for _, e := range entries {
44 if strings.HasSuffix(e.Name(), ".json") {
45 rows = append(rows, "i18n/"+strings.TrimSuffix(e.Name(), ".json")+".js")
46 langs++
47 }
48 }
49 if langs != 12 {
50 t.Fatalf("the source carries %d languages, want 12", langs)
51 }
52 return rows
53}
54
55// releaseBuild is the release build of the real source, failed loudly.
56func releaseBuild(t *testing.T) map[string][]byte {
57 t.Helper()
58 dist, err := build.Dist(fiesta.FS(), fiesta.Version)
59 if err != nil {
60 t.Fatalf("build.Dist: %v", err)
61 }
62 return dist
63}
64
65// TestTheGettingStartedPageWeighsTheRealAssets: in every theme and
66// locale the page lists exactly the shipped files, in the spec's order,
67// each with its anchor, its link to the release in the one link form,
68// and a weight that is weigh() of that very file in the release build;
69// the total is the arithmetic over fiesta.css and the scripts; the rail
70// entries assetNav returns are one per row, in order, by file name
71// (checked as a unit, since the rendered rail is the shell's); and the
72// gallery's own furniture has no row.
73//
74// The control for the row itself is a File misspelled geting-started.html
75// in pageKinds: the page is then missing by this name.
76func TestTheGettingStartedPageWeighsTheRealAssets(t *testing.T) {
77 files := render(t, "")
78 s := newTestSite(t, fiesta.FS(), "")
79 dist := releaseBuild(t)
80 rows := shippedRows(t)
81 for _, name := range rows {
82 if _, ok := dist[name]; !ok {
83 t.Fatalf("the release has no %s; the list of rows is wrong", name)
84 }
85 }
86 totalRaw, totalGzip := 0, 0
87 for _, name := range rows {
88 if name == "fiesta.css" || (strings.HasSuffix(name, ".js") && !strings.Contains(name, "/")) {
89 w := weigh(dist[name])
90 totalRaw, totalGzip = totalRaw+w.Raw, totalGzip+w.Gzip
91 }
92 }
93 if totalGzip >= totalRaw {
94 t.Fatalf("the total gzipped, %d, is not below the total as written, %d", totalGzip, totalRaw)
95 }
96 for _, theme := range fiesta.ThemeNames() {
97 for _, locale := range Locales {
98 name := theme + "/" + locale + "/getting-started.html"
99 body, ok := files[name]
100 if !ok {
101 t.Errorf("the render wrote no %s", name)
102 continue
103 }
104 doc := parse(t, body)
105 list, ok := doc.first(byClass("ds-files"))
106 if !ok {
107 t.Errorf("%s has no .ds-files list", name)
108 continue
109 }
110 items := list.find(func(n node) bool { return n.n.Data == "li" })
111 if len(items) != len(rows) {
112 t.Errorf("%s lists %d files, the release ships %d rows", name, len(items), len(rows))
113 }
114 p := s.newPage(theme, locale, "getting-started", "getting-started.html", "")
115 nav := s.assetNav(p)
116 if len(nav) != len(rows) {
117 t.Errorf("%s: assetNav returns %d entries, the page has %d rows", name, len(nav), len(rows))
118 }
119 page := s.href(theme, locale, "getting-started.html")
120 for i, want := range rows {
121 id := anchorID("asset", want)
122 if i < len(nav) {
123 if nav[i].Label != want || nav[i].Href != page+"#"+id || !nav[i].Code {
124 t.Errorf("%s: rail entry %d is %+v, want %s at %s#%s in the mono face", name, i, nav[i], want, page, id)
125 }
126 }
127 if i >= len(items) {
128 break
129 }
130 li := items[i]
131 if got, _ := li.attr("id"); got != id {
132 t.Errorf("%s: row %d has id %q, want %q", name, i, got, id)
133 }
134 if _, ok := li.attr("data-ds-anchor"); !ok {
135 t.Errorf("%s: row %s carries no data-ds-anchor", name, want)
136 }
137 a, ok := li.first(func(n node) bool { return n.n.Data == "a" })
138 if !ok {
139 t.Errorf("%s: row %s has no link", name, want)
140 continue
141 }
142 if href, _ := a.attr("href"); href != ReleaseBase+want {
143 t.Errorf("%s: row %s links %q, want %s", name, want, href, ReleaseBase+want)
144 }
145 if _, ok := a.attr("data-gallery-site"); !ok {
146 t.Errorf("%s: row %s's link carries no data-gallery-site", name, want)
147 }
148 if a.text() != want {
149 t.Errorf("%s: row %d reads %q, want %q", name, i, a.text(), want)
150 }
151 weight := s.weightIn(locale, weigh(dist[want]))
152 if !strings.Contains(li.text(), weight) {
153 t.Errorf("%s: row %s does not read %q; the weight on the page is not that of the release file", name, want, weight)
154 }
155 }
156 total := s.say(locale, "gallery.started.total", "raw", kibUnit(locale, totalRaw), "gzip", kibUnit(locale, totalGzip))
157 if !strings.Contains(string(body), template.HTMLEscapeString(total)) {
158 t.Errorf("%s does not say %q; the total is not the sum of fiesta.css and the scripts", name, total)
159 }
160 link := `<link rel="stylesheet" href="` + ReleaseBase + `fiesta.css">`
161 own := `<link rel="stylesheet" href="tokens.css">` + "\n" + `<link rel="stylesheet" href="` + theme + `.css">`
162 var codes []string
163 for _, pre := range doc.find(byClass("ds-src")) {
164 codes = append(codes, pre.text())
165 }
166 for _, want := range []string{link, own} {
167 found := false
168 for _, c := range codes {
169 found = found || c == strings.Join(strings.Fields(want), " ")
170 }
171 if !found {
172 t.Errorf("%s: no code block reads %q; the blocks read %q", name, want, codes)
173 }
174 }
175 _, read, ok := strings.Cut(string(body), "</head>")
176 if !ok {
177 t.Errorf("%s: no </head>", name)
178 continue
179 }
180 for _, own := range []string{anchorID("asset", "gallery.js"), anchorID("asset", "gallery.css")} {
181 if strings.Contains(read, own) {
182 t.Errorf("%s lists the gallery's own %s, which no page ships", name, own)
183 }
184 }
185 }
186 }
187}
188
189// TestEveryReleaseLinkIsTheOneLinkForm: every link marked
190// data-gallery-site is an <a> whose href is ReleaseBase followed by a
191// file the release build holds, and no href starting with ReleaseBase
192// lacks the mark. Asserted non-vacuous: Getting started links every
193// shipped file.
194func TestEveryReleaseLinkIsTheOneLinkForm(t *testing.T) {
195 files := render(t, "")
196 dist := releaseBuild(t)
197 marked := 0
198 for name, body := range files {
199 if !strings.HasSuffix(name, ".html") {
200 continue
201 }
202 attrsOf(t, body, func(tag string, attrs map[string]string) {
203 href, hasHref := attrs["href"]
204 if _, site := attrs["data-gallery-site"]; site {
205 marked++
206 if tag != "a" {
207 t.Errorf("%s: a <%s> carries data-gallery-site; only a link does", name, tag)
208 }
209 file := strings.TrimPrefix(href, ReleaseBase)
210 switch {
211 case !hasHref || file == href:
212 t.Errorf("%s: a data-gallery-site link reads %q, which does not start with %s", name, href, ReleaseBase)
213 case dist[file] == nil:
214 t.Errorf("%s: links %s, and the release has no %s", name, href, file)
215 }
216 return
217 }
218 if hasHref && strings.HasPrefix(href, ReleaseBase) {
219 t.Errorf("%s: <%s href=%q> links the release without data-gallery-site", name, tag, href)
220 }
221 })
222 }
223 if marked == 0 {
224 t.Error("no data-gallery-site link in the tree; Getting started links every shipped file")
225 }
226}
227
228// lucideNameShape is what lucide.dev names an icon: lowercase words
229// joined by single hyphens.
230var lucideNameShape = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
231
232// copyFS is src read whole into a map, so a test can add a file beside
233// the real ones and the directory listings see it.
234func copyFS(t *testing.T, src fs.FS) fstest.MapFS {
235 t.Helper()
236 out := fstest.MapFS{}
237 err := fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error {
238 if err != nil || d.IsDir() {
239 return err
240 }
241 b, err := fs.ReadFile(src, path)
242 if err != nil {
243 return err
244 }
245 out[path] = &fstest.MapFile{Data: b}
246 return nil
247 })
248 if err != nil {
249 t.Fatal(err)
250 }
251 return out
252}
253
254// iconsPageReads holds one Icons page to one set of slugs: a chip per
255// slug with its anchor, its slug, its file and its lucide.dev address;
256// as many chips and addresses as slugs, each address well formed; the
257// renamed count read off the page equal to the number of addresses
258// that do not repeat the slug and to the count the sentence says; the
259// lead's count; and the one link to the set.
260func iconsPageReads(t *testing.T, name string, doc node, s *site, locale string, slugs []string) {
261 t.Helper()
262 chips := doc.find(func(n node) bool {
263 id, _ := n.attr("id")
264 return n.hasClass("ds-tok") && strings.HasPrefix(id, "icon-")
265 })
266 if len(chips) != len(slugs) {
267 t.Errorf("%s draws %d icons, the source has %d slugs", name, len(chips), len(slugs))
268 }
269 renamed := 0
270 addresses := 0
271 for _, slug := range slugs {
272 li, ok := doc.first(byAttr("id", anchorID("icon", slug)))
273 if !ok {
274 t.Errorf("%s: no chip with id %q; the page is not reading the slugs", name, anchorID("icon", slug))
275 continue
276 }
277 if _, ok := li.attr("data-ds-anchor"); !ok {
278 t.Errorf("%s: the chip for %s carries no data-ds-anchor", name, slug)
279 }
280 if _, ok := li.first(func(n node) bool { return n.n.Data == "svg" }); !ok {
281 t.Errorf("%s: the chip for %s draws no svg", name, slug)
282 }
283 values := li.find(byClass("ds-tok__value"))
284 if len(values) < 2 {
285 t.Errorf("%s: the chip for %s has %d value lines, want the file and the address", name, slug, len(values))
286 continue
287 }
288 if got := values[0].text(); got != "icons/"+slug+".svg" {
289 t.Errorf("%s: the chip for %s names the file %q, want icons/%s.svg", name, slug, got, slug)
290 }
291 address := values[len(values)-1].text()
292 lucide, ok := strings.CutPrefix(address, "lucide.dev/icons/")
293 if !ok {
294 t.Errorf("%s: the last line under %s is %q, want a lucide.dev/icons/ address", name, slug, address)
295 continue
296 }
297 addresses++
298 if !lucideNameShape.MatchString(lucide) {
299 t.Errorf("%s: the address under %s is %q, which names no icon", name, slug, address)
300 }
301 if lucide != lucideName(slug) {
302 t.Errorf("%s: the address under %s names %q, lucideNames says %q", name, slug, lucide, lucideName(slug))
303 }
304 if lucide != slug {
305 renamed++
306 }
307 }
308 if addresses != len(slugs) {
309 t.Errorf("%s prints %d addresses, want one per slug (%d)", name, addresses, len(slugs))
310 }
311 if renamed == 0 {
312 t.Errorf("%s: no address differs from its slug, and the source renames five of Lucide's", name)
313 }
314 total := fmt.Sprint(len(slugs))
315 for _, tc := range []struct{ what, want string }{
316 {"the lead", s.say(locale, "gallery.icons.lead", "total", total)},
317 {"the renamed sentence", s.say(locale, "gallery.icons.names.body", "renamed", fmt.Sprint(renamed), "total", total)},
318 } {
319 found := false
320 for _, p := range doc.find(func(n node) bool { return n.n.Data == "p" }) {
321 found = found || p.text() == tc.want
322 }
323 if !found {
324 t.Errorf("%s: %s does not read %q; a number on the page is typed rather than counted", name, tc.what, tc.want)
325 }
326 }
327 links := doc.find(func(n node) bool { href, _ := n.attr("href"); return n.n.Data == "a" && href == lucideHome })
328 if len(links) != 1 {
329 t.Errorf("%s links %s %d times, want once", name, lucideHome, len(links))
330 } else {
331 a := links[0]
332 if target, _ := a.attr("target"); target != "_blank" {
333 t.Errorf("%s: the link to %s does not open a new tab", name, lucideHome)
334 }
335 if rel, _ := a.attr("rel"); rel != "noopener" {
336 t.Errorf("%s: the link to %s has rel=%q, want noopener", name, lucideHome, rel)
337 }
338 if !strings.Contains(a.text(), s.say(locale, "gallery.newtab")) {
339 t.Errorf("%s: the link to %s does not say it opens in a new tab", name, lucideHome)
340 }
341 }
342}
343
344// TestTheIconsPageIsAReadingOfIconSlugs: in every theme and locale the
345// Icons page draws one chip per icons/*.svg, each with its lucide.dev
346// address, and the renamed count equals the addresses that do not
347// repeat their slug; lucideNames names only real slugs; and a scratch
348// source with one more icon grows the page by one chip and one address.
349func TestTheIconsPageIsAReadingOfIconSlugs(t *testing.T) {
350 files := render(t, "")
351 s := newTestSite(t, fiesta.FS(), "")
352 slugs := fiesta.IconSlugs()
353 if len(slugs) == 0 {
354 t.Fatal("fiesta.IconSlugs() is empty; there is nothing for this gate to read")
355 }
356 for slug, lucide := range lucideNames {
357 found := false
358 for _, have := range slugs {
359 found = found || have == slug
360 }
361 if !found {
362 t.Errorf("lucideNames renames %q, which is not an icon", slug)
363 }
364 if lucide == slug || !lucideNameShape.MatchString(lucide) {
365 t.Errorf("lucideNames[%q] is %q, which is not a different, well-formed Lucide name", slug, lucide)
366 }
367 }
368 for _, theme := range fiesta.ThemeNames() {
369 for _, locale := range Locales {
370 name := theme + "/" + locale + "/icons.html"
371 body, ok := files[name]
372 if !ok {
373 t.Errorf("the render wrote no %s", name)
374 continue
375 }
376 iconsPageReads(t, name, parse(t, body), s, locale, slugs)
377 }
378 }
379
380 src := copyFS(t, fiesta.FS())
381 src["icons/scratch-glyph.svg"] = &fstest.MapFile{Data: []byte(`<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 12h16"/></svg>`)}
382 s2 := newTestSite(t, src, "")
383 body, err := s2.iconsBody(s2.newPage("day", "en", "icons", "icons.html", ""))
384 if err != nil {
385 t.Fatalf("iconsBody on the scratch source: %v", err)
386 }
387 more := append(append([]string(nil), slugs...), "scratch-glyph")
388 sort.Strings(more)
389 iconsPageReads(t, "the scratch source's Icons page", parse(t, []byte(body)), s2, "en", more)
390}
391
392// TestTheIntroOpensTheOverview: in every theme and locale the Overview
393// carries exactly one .ds-intro paragraph, reading the intro, before
394// any other block of its body; no other page carries one.
395func TestTheIntroOpensTheOverview(t *testing.T) {
396 files := render(t, "")
397 s := newTestSite(t, fiesta.FS(), "")
398 blocks := []string{"ds-intro", "ds-lead", "ds-sub", "ds-routes", "ds-demo", "ds-note"}
399 for _, dir := range dirs() {
400 locale := strings.Split(dir, "/")[1]
401 name := dir + "index.html"
402 doc := parse(t, files[name])
403 main, ok := doc.first(byAttr("id", "main"))
404 if !ok {
405 t.Errorf("%s has no #main", name)
406 continue
407 }
408 intros := main.find(byClass("ds-intro"))
409 if len(intros) != 1 {
410 t.Errorf("%s carries %d .ds-intro, want 1", name, len(intros))
411 continue
412 }
413 if intros[0].n.Data != "p" {
414 t.Errorf("%s: the intro is a <%s>, want a paragraph", name, intros[0].n.Data)
415 }
416 if want := s.say(locale, "gallery.overview.intro"); intros[0].text() != want {
417 t.Errorf("%s: the intro reads %q, want %q", name, intros[0].text(), want)
418 }
419 first, ok := main.first(func(n node) bool {
420 for _, c := range blocks {
421 if n.hasClass(c) {
422 return true
423 }
424 }
425 return false
426 })
427 if !ok || !first.hasClass("ds-intro") {
428 class, _ := first.attr("class")
429 t.Errorf("%s opens with a %q block, want the intro first", name, class)
430 }
431 }
432 for name, body := range files {
433 if !strings.HasSuffix(name, ".html") || strings.HasSuffix(name, "/index.html") || name == "index.html" {
434 continue
435 }
436 if strings.Contains(string(body), `class="ds-intro"`) {
437 t.Errorf("%s carries the intro, which is the Overview's alone", name)
438 }
439 }
440}
441
442// TestEveryOutboundLinkIsAllowedAndUsed: every https:// link in the
443// tree that is not a release link is in outboundLinks, exactly; every
444// entry there is https, has its reason, and is linked by some page;
445// nothing links plaintext http. Asserted non-vacuous: the Icons page
446// links lucide.dev.
447func TestEveryOutboundLinkIsAllowedAndUsed(t *testing.T) {
448 files := render(t, "")
449 seen := map[string]int{}
450 for name, body := range files {
451 if !strings.HasSuffix(name, ".html") {
452 continue
453 }
454 attrsOf(t, body, func(tag string, attrs map[string]string) {
455 href, ok := attrs["href"]
456 if !ok || tag != "a" {
457 return
458 }
459 if strings.HasPrefix(href, "http://") {
460 t.Errorf("%s links %s; this tree does not send a reader to plaintext", name, href)
461 }
462 if _, site := attrs["data-gallery-site"]; site || !strings.HasPrefix(href, "https://") {
463 return
464 }
465 seen[href]++
466 })
467 }
468 for href, why := range outboundLinks {
469 if !strings.HasPrefix(href, "https://") {
470 t.Errorf("outboundLinks[%q] is not https", href)
471 }
472 if why == "" {
473 t.Errorf("outboundLinks[%q] has no reason beside it", href)
474 }
475 if seen[href] == 0 {
476 t.Errorf("outboundLinks names %q and nothing in the tree links it: delete the entry", href)
477 }
478 }
479 for href, n := range seen {
480 if _, ok := outboundLinks[href]; !ok {
481 t.Errorf("%d links go to %q, which is not in outboundLinks", n, href)
482 }
483 }
484 if len(seen) == 0 {
485 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")
486 }
487}
488
489// TestEveryShippedFileWeighsLessCompressed: every file of the release
490// build gzips to fewer bytes than it holds, so the two figures on a
491// Getting started row are never the wrong way round.
492//
493// Already passing when written: the release has no file so small that
494// gzip's framing outweighs its saving. The named mutation that turns
495// it red: weigh reporting Gzip as Raw.
496func TestEveryShippedFileWeighsLessCompressed(t *testing.T) {
497 dist := releaseBuild(t)
498 if len(dist) == 0 {
499 t.Fatal("the release build is empty")
500 }
501 for name, body := range dist {
502 if w := weigh(body); w.Gzip >= w.Raw {
503 t.Errorf("%s weighs %d as written and %d gzipped; gzip must shrink it", name, w.Raw, w.Gzip)
504 }
505 }
506}
507
508// TestTheScriptsParagraphNamesEveryShippedScript: Getting started's
509// paragraph on the optional scripts names every script the release
510// ships. It is the one place a reader learns a script exists and may be
511// deleted, so a script left out of it is one nobody loads: copy.js
512// shipped once with the paragraph silent about it. The list is read
513// off the release build, not written here, so a new script fails this
514// until the English names it. The English is the source of the eleven
515// translations, so it is the one held here.
516func TestTheScriptsParagraphNamesEveryShippedScript(t *testing.T) {
517 body := say("gallery.started.scripts.body")
518 scripts := 0
519 for name := range releaseBuild(t) {
520 if strings.Contains(name, "/") || !strings.HasSuffix(name, ".js") {
521 continue
522 }
523 scripts++
524 // Bounded on both sides, so fiesta.js is not found inside a
525 // longer name such as notfiesta.js.
526 named := regexp.MustCompile(`(^|[^\w.-])` + regexp.QuoteMeta(name) + `($|[^\w-])`)
527 if !named.MatchString(body) {
528 t.Errorf("the release ships %s, but Getting started's scripts paragraph does not name it:\n %s", name, body)
529 }
530 }
531 // A build that ships no scripts passes whatever the paragraph says.
532 if scripts < len(fiesta.ScriptNames()) {
533 t.Fatalf("the release build ships %d top-level scripts, want at least %d; this is reading the wrong files", scripts, len(fiesta.ScriptNames()))
534 }
535}
536