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 "bytes"
5 "encoding/json"
6 "fmt"
7 "io/fs"
8 "regexp"
9 "slices"
10 "sort"
11 "strings"
12 "testing"
13 "testing/fstest"
14
15 "amadan.net/rastrillo/fiesta"
16 "amadan.net/rastrillo/fiesta/example"
17)
18
19// The shape of the whole tree, and what every page in it carries: the
20// files the four declared-expectation seams name and no other, the
21// twelve locales, the sequence of pages, the Overview's routes, the
22// hashed assets, the titles and ids, and the password screen's
23// warning. These gates read their expectations through pageKinds(),
24// frameFiles(), extraDocumentNames() and the asset list, so a later
25// task that extends the tree through its own seam keeps them exact
26// without an edit here.
27
28// newTestSite is a site after its setup, with nothing rendered: the
29// seams a test reads expectations from live on it.
30func newTestSite(t *testing.T, src fs.FS, mount string) *site {
31 t.Helper()
32 s, err := newSite(src, mount)
33 if err != nil {
34 t.Fatalf("newSite: %v", err)
35 }
36 return s
37}
38
39// dirs lists every theme and locale directory, with its trailing
40// slash, in render order.
41func dirs() []string {
42 var out []string
43 for _, theme := range fiesta.ThemeNames() {
44 for _, locale := range Locales {
45 out = append(out, theme+"/"+locale+"/")
46 }
47 }
48 return out
49}
50
51// isFrame says whether a tree path is a frame document.
52func isFrame(name string) bool { return strings.Contains(name, "/frames/") }
53
54// isSampleDocument says whether a file of the tree is a sample's own
55// document rather than a gallery page: a frame, or one of the extra
56// documents (the demo application's four and the modal route), whose
57// chrome is a sample's and not the gallery's. A gate on what every
58// gallery page carries reads the pages alone.
59func isSampleDocument(name string) bool {
60 return isFrame(name) || reserved(name[strings.LastIndex(name, "/")+1:])
61}
62
63// titleOf is the document's <title> text.
64func titleOf(t *testing.T, doc node) string {
65 t.Helper()
66 titles := doc.find(func(n node) bool { return n.n.Data == "title" })
67 if len(titles) != 1 {
68 t.Fatalf("the document has %d <title> elements, want 1", len(titles))
69 }
70 return titles[0].text()
71}
72
73// TestTreeShapeIsComplete: for every theme and locale, the tree holds
74// every file the four seams name and the assets hashed and plain, with
75// index.html at the root, and nothing else. The names no seam may
76// lose are checked by fixed name as well, so a seam that forgot its
77// own files could not agree with an output that forgot them too.
78//
79// The named mutation that turns this red: a file written in theme()
80// that no seam names.
81func TestTreeShapeIsComplete(t *testing.T) {
82 files := render(t, "")
83 s := newTestSite(t, fiesta.FS(), "")
84 m := s.m
85
86 want := map[string]bool{"index.html": true}
87 for _, dir := range dirs() {
88 for _, pk := range s.pageKinds() {
89 want[dir+pk.File] = true
90 }
91 for _, e := range m.Examples {
92 want[dir+e.Name+".html"] = true
93 for _, f := range s.frameFiles(e) {
94 want[dir+f] = true
95 }
96 }
97 for _, n := range s.extraDocumentNames() {
98 want[dir+n] = true
99 }
100 }
101 // The assets: the declaration, not what was written. Each declared
102 // name is expected plain and under one hashed sibling with the same
103 // bytes; the hashed name is worked out from the plain file's bytes,
104 // so a declared asset the render skipped is a missing plain file.
105 names, err := s.assetNames()
106 if err != nil {
107 t.Fatal(err)
108 }
109 if len(names) == 0 {
110 t.Fatal("the site declares no assets; the check below would expect none")
111 }
112 for _, name := range names {
113 want["assets/"+name] = true
114 if body, ok := files["assets/"+name]; ok {
115 want["assets/"+hashedName(name, body)] = true
116 }
117 }
118
119 // By fixed name: what no seam may lose.
120 var fixed []string
121 for _, dir := range dirs() {
122 fixed = append(fixed, dir+"index.html", dir+"tokens.html", dir+"icons.html")
123 fixed = append(fixed, dir+"demo.html", dir+"demo-dashboard.html", dir+"demo-requests.html", dir+"demo-request.html", dir+"demo-modal.html")
124 for _, f := range groupFamilies(m.Examples) {
125 fixed = append(fixed, dir+f.Key+".html")
126 }
127 for _, e := range m.Examples {
128 fixed = append(fixed, dir+e.Name+".html")
129 for _, st := range e.States {
130 fixed = append(fixed, dir+"frames/"+e.Name+"/"+st.ID+".html")
131 }
132 }
133 }
134 for _, name := range fixed {
135 if !want[name] {
136 t.Errorf("no seam names %s, which the tree may not lose", name)
137 }
138 if _, ok := files[name]; !ok {
139 t.Errorf("the render wrote no %s", name)
140 }
141 }
142
143 for name := range want {
144 if _, ok := files[name]; !ok {
145 t.Errorf("the seams name %s and the render wrote no such file", name)
146 }
147 }
148 for name := range files {
149 if !want[name] {
150 t.Errorf("the render wrote %s, which no seam names", name)
151 }
152 }
153 if len(files) != len(want) {
154 t.Errorf("the tree holds %d files, the seams name %d", len(files), len(want))
155 }
156 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",
157 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))
158}
159
160// TestTheAssetDeclarationDrivesTheTree: the assets the tree ships are
161// the declared ones, read from the source and the registry, not from
162// what was written; an asset declared from any file is in the tree
163// under its plain and its hashed name the next render. The control is
164// a scratch declaration, registered and then withdrawn.
165func TestTheAssetDeclarationDrivesTheTree(t *testing.T) {
166 s := newTestSite(t, fiesta.FS(), "")
167 names, err := s.assetNames()
168 if err != nil {
169 t.Fatal(err)
170 }
171 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"} {
172 if !slices.Contains(names, want) {
173 t.Errorf("assetNames does not declare %s", want)
174 }
175 }
176 saved := declaredAssets
177 t.Cleanup(func() { declaredAssets = saved })
178 declareAsset("scratch/test.css", func(*site) ([]byte, error) { return []byte("/* scratch */"), nil })
179 files, err := Render(fiesta.FS(), Options{})
180 if err != nil {
181 t.Fatal(err)
182 }
183 hashed := hashedName("scratch/test.css", []byte("/* scratch */"))
184 if _, ok := files["assets/scratch/test.css"]; !ok {
185 t.Error("a declared asset is not in the tree under its plain name")
186 }
187 if _, ok := files["assets/"+hashed]; !ok {
188 t.Errorf("a declared asset is not in the tree under %s", hashed)
189 }
190 declareAsset("scratch/test.css", func(*site) ([]byte, error) { return nil, nil })
191 if _, err := Render(fiesta.FS(), Options{}); err == nil || !strings.Contains(err.Error(), "declared twice") {
192 t.Errorf("a name declared twice did not fail the render by name: %v", err)
193 }
194}
195
196// TestTheReservedDocumentsCollideWithNoPage: the names the demo
197// application and the modal route will take are reserved, and no page
198// kind or example page takes one; the modal example's page is
199// modal.html, so the route is demo-modal.html. The control is a
200// scratch manifest with an example named demo, which the render
201// refuses by name.
202func TestTheReservedDocumentsCollideWithNoPage(t *testing.T) {
203 s := newTestSite(t, fiesta.FS(), "")
204 for _, want := range []string{"demo.html", "demo-dashboard.html", "demo-requests.html", "demo-request.html", "demo-modal.html"} {
205 if !reserved(want) {
206 t.Errorf("%s is not reserved", want)
207 }
208 }
209 if reserved("modal.html") {
210 t.Error("modal.html is reserved, and it is the modal example's own page")
211 }
212 for _, pk := range s.pageKinds() {
213 if reserved(pk.File) {
214 t.Errorf("the %s page kind takes the reserved %s", pk.Kind, pk.File)
215 }
216 }
217 for _, e := range s.m.Examples {
218 if reserved(e.Name + ".html") {
219 t.Errorf("the %s example takes a reserved name", e.Name)
220 }
221 }
222 for _, n := range s.extraDocumentNames() {
223 if !reserved(n) {
224 t.Errorf("extraDocumentNames lists %s, which is not reserved", n)
225 }
226 }
227 raw, err := fs.ReadFile(fiesta.FS(), "examples/examples.json")
228 if err != nil {
229 t.Fatal(err)
230 }
231 scratch := overlay{fiesta.FS(), map[string][]byte{"examples/examples.json": bytes.Replace(raw, []byte(`"name": "button"`), []byte(`"name": "demo"`), 1)}}
232 if _, err := Render(scratch, Options{}); err == nil || !strings.Contains(err.Error(), "reserved") {
233 t.Errorf("an example named demo did not fail the render as reserved: %v", err)
234 }
235}
236
237// TestRootIndexIsTheDefaultThemeInEnglishAtTheTreeRoot: index.html at
238// the root is day/en/index.html byte for byte.
239func TestRootIndexIsTheDefaultThemeInEnglishAtTheTreeRoot(t *testing.T) {
240 files := render(t, "")
241 root, ok := files["index.html"]
242 if !ok || len(root) == 0 {
243 t.Fatal("no index.html at the tree root")
244 }
245 want := files[fiesta.ThemeNames()[0]+"/en/index.html"]
246 if len(want) == 0 {
247 t.Fatalf("no %s/en/index.html", fiesta.ThemeNames()[0])
248 }
249 if !bytes.Equal(root, want) {
250 t.Errorf("index.html differs from %s/en/index.html", fiesta.ThemeNames()[0])
251 }
252}
253
254// expectedSequence is the order the plan and the spec give the pages:
255// the page kinds in table order, and inside each family its examples
256// in manifest order after the family's own page.
257func expectedSequence(s *site, theme, locale string) []link {
258 var out []link
259 for _, pk := range s.pageKinds() {
260 out = append(out, link{Href: s.href(theme, locale, pk.File), Label: s.titleOf(locale, pk)})
261 for _, f := range s.families {
262 if f.Key != pk.Kind {
263 continue
264 }
265 for _, e := range f.Examples {
266 out = append(out, link{Href: s.href(theme, locale, e.Name+".html"), Label: e.Name})
267 }
268 }
269 }
270 return out
271}
272
273// TestEveryPageEndsWithItsPlaceInTheSequence: walking sequence() on
274// day/en, every page's .ds-updown holds the previous and next links
275// with the destination's label; the Overview has no previous, the last
276// page no next; a family page's next is its first example and the last
277// example of a family leads to the next family page.
278func TestEveryPageEndsWithItsPlaceInTheSequence(t *testing.T) {
279 files := render(t, "")
280 s := newTestSite(t, fiesta.FS(), "")
281 theme, locale := fiesta.ThemeNames()[0], "en"
282 seq := s.sequence(theme, locale)
283 want := expectedSequence(s, theme, locale)
284 if len(seq) < 10 {
285 t.Fatalf("the sequence has %d steps; the walk below would read too little", len(seq))
286 }
287 if len(seq) != len(want) {
288 t.Fatalf("sequence() has %d steps, the table and the manifest give %d", len(seq), len(want))
289 }
290 for i := range seq {
291 if seq[i] != want[i] {
292 t.Errorf("step %d is %+v, want %+v", i, seq[i], want[i])
293 }
294 }
295 if seq[0].Href != s.href(theme, locale, "index.html") {
296 t.Errorf("the sequence opens on %s, not the Overview", seq[0].Href)
297 }
298 for i, step := range seq {
299 name := strings.TrimPrefix(step.Href, "/")
300 page, ok := files[name]
301 if !ok {
302 t.Errorf("step %d, %s: no such page", i, name)
303 continue
304 }
305 doc := parse(t, page)
306 updown, ok := doc.first(byClass("ds-updown"))
307 if !ok {
308 t.Errorf("%s has no .ds-updown", name)
309 continue
310 }
311 prev, hasPrev := updown.first(byClass("ds-updown__prev"))
312 next, hasNext := updown.first(byClass("ds-updown__next"))
313 if i == 0 {
314 if hasPrev {
315 t.Errorf("%s, the first page, has a previous link", name)
316 }
317 } else {
318 if !hasPrev {
319 t.Errorf("%s has no previous link", name)
320 } else if href, _ := prev.attr("href"); href != seq[i-1].Href || prev.text() != s.say(locale, "gallery.step.prev", "page", seq[i-1].Label) {
321 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)
322 }
323 }
324 if i == len(seq)-1 {
325 if hasNext {
326 t.Errorf("%s, the last page, has a next link", name)
327 }
328 } else {
329 if !hasNext {
330 t.Errorf("%s has no next link", name)
331 } else if href, _ := next.attr("href"); href != seq[i+1].Href || next.text() != s.say(locale, "gallery.step.next", "page", seq[i+1].Label) {
332 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)
333 }
334 }
335 }
336 // The family order is the gallery's own and fixed, and the walk ends
337 // on the last shell: asserted against the literal list, and by
338 // following the rendered Next links from the Overview to the end,
339 // independently of sequence().
340 var keys []string
341 for _, f := range s.families {
342 keys = append(keys, f.Key)
343 }
344 if want := []string{"list-screen", "display", "form", "choices-and-actions", "date-and-time", "route", "primitives", "formats", "screens", "shells"}; !slices.Equal(keys, want) {
345 t.Errorf("the families are ordered %v, want %v", keys, want)
346 }
347 at := theme + "/" + locale + "/index.html"
348 visited := map[string]bool{}
349 for {
350 visited[at] = true
351 next, ok := parse(t, files[at]).first(byClass("ds-updown__next"))
352 if !ok {
353 break
354 }
355 href, _ := next.attr("href")
356 at = strings.TrimPrefix(href, "/")
357 if visited[at] {
358 t.Fatalf("the Next links loop back to %s", at)
359 }
360 }
361 if want := theme + "/" + locale + "/layout-stage.html"; at != want {
362 t.Errorf("following Next from the Overview ends on %s, want %s", at, want)
363 }
364 if len(visited) != len(seq) {
365 t.Errorf("following Next visits %d pages, the sequence has %d", len(visited), len(seq))
366 }
367 // The shape the spec describes, read off the expectation the
368 // implementation was held to above.
369 for _, f := range s.families {
370 at := -1
371 for i, step := range seq {
372 if step.Href == s.href(theme, locale, f.Key+".html") {
373 at = i
374 }
375 }
376 if at < 0 || at+1 >= len(seq) || seq[at+1].Label != f.Examples[0].Name {
377 t.Errorf("the %s page's next is not its first example %s", f.Key, f.Examples[0].Name)
378 }
379 }
380}
381
382// TestTheOverviewRoutesIntoEveryOtherPage: the Overview's .ds-routes
383// lists every page kind but itself, in table order, each with a
384// non-empty blurb and a link to its file.
385func TestTheOverviewRoutesIntoEveryOtherPage(t *testing.T) {
386 files := render(t, "")
387 s := newTestSite(t, fiesta.FS(), "")
388 theme, locale := fiesta.ThemeNames()[0], "en"
389 doc := parse(t, files[theme+"/"+locale+"/index.html"])
390 routes, ok := doc.first(byClass("ds-routes"))
391 if !ok {
392 t.Fatal("the Overview has no .ds-routes")
393 }
394 items := routes.find(func(n node) bool { return n.n.Data == "li" })
395 kinds := s.pageKinds()
396 if len(kinds) < 4 {
397 t.Fatalf("pageKinds() lists %d kinds; the check below would read too little", len(kinds))
398 }
399 var others []pageKind
400 for _, pk := range kinds {
401 if pk.Kind != "overview" {
402 others = append(others, pk)
403 }
404 }
405 if len(items) != len(others) {
406 t.Fatalf("the Overview routes to %d pages, the table has %d others", len(items), len(others))
407 }
408 for i, pk := range others {
409 a, ok := items[i].first(func(n node) bool { return n.n.Data == "a" })
410 if !ok {
411 t.Errorf("route %d has no link", i)
412 continue
413 }
414 if href, _ := a.attr("href"); href != s.href(theme, locale, pk.File) {
415 t.Errorf("route %d links %s, want %s", i, href, s.href(theme, locale, pk.File))
416 }
417 if a.text() != s.titleOf(locale, pk) {
418 t.Errorf("route %d reads %q, want %q", i, a.text(), s.titleOf(locale, pk))
419 }
420 blurb, ok := items[i].first(func(n node) bool { return n.n.Data == "span" })
421 if !ok || blurb.text() == "" {
422 t.Errorf("route %d (%s) has no blurb", i, pk.Kind)
423 }
424 }
425}
426
427// hashedSegment is the sixteen hex digits a hashed name carries before
428// its extension.
429var hashedSegment = regexp.MustCompile(`\.[0-9a-f]{16}\.[a-z]+$`)
430
431// TestEveryAssetIsWrittenHashedAndPlainAndPagesLinkTheHash: every path
432// under assets/ appears twice, plain and hashed with the same bytes;
433// no page or frame links a plain name; every hashed link resolves.
434func TestEveryAssetIsWrittenHashedAndPlainAndPagesLinkTheHash(t *testing.T) {
435 const mount = "/x"
436 files := render(t, mount)
437 plain, hashed := map[string]bool{}, map[string]bool{}
438 for name := range files {
439 if !strings.HasPrefix(name, "assets/") {
440 continue
441 }
442 if hashedSegment.MatchString(name) {
443 hashed[name] = true
444 } else {
445 plain[name] = true
446 }
447 }
448 if len(plain) == 0 || len(hashed) == 0 {
449 t.Fatalf("%d plain and %d hashed assets; the pairing below would read nothing", len(plain), len(hashed))
450 }
451 for name := range plain {
452 ext := name[strings.LastIndex(name, "."):]
453 stem := strings.TrimSuffix(name, ext)
454 found := ""
455 for h := range hashed {
456 if strings.HasPrefix(h, stem+".") && strings.HasSuffix(h, ext) && len(h) == len(stem)+1+16+len(ext) {
457 found = h
458 }
459 }
460 if found == "" {
461 t.Errorf("%s has no hashed sibling", name)
462 continue
463 }
464 if !bytes.Equal(files[name], files[found]) {
465 t.Errorf("%s and %s differ", name, found)
466 }
467 delete(hashed, found)
468 }
469 for h := range hashed {
470 t.Errorf("%s has no plain sibling", h)
471 }
472 linked := 0
473 for name, body := range files {
474 if !strings.HasSuffix(name, ".html") {
475 continue
476 }
477 for _, u := range galleryURLs(t, body) {
478 rel := strings.TrimPrefix(u, mount+"/")
479 if !strings.HasPrefix(rel, "assets/") {
480 continue
481 }
482 linked++
483 if !hashedSegment.MatchString(rel) {
484 t.Errorf("%s links the plain asset %s", name, u)
485 }
486 if _, ok := files[rel]; !ok {
487 t.Errorf("%s links %s, which the render did not write", name, u)
488 }
489 }
490 }
491 if linked == 0 {
492 t.Fatal("no page links an asset; the check above read nothing")
493 }
494}
495
496// TestEveryPageIsAWholeLocalisedDocument: every page under
497// <theme>/<locale>/ has <html lang="<locale>" dir="<Dir(locale)>">,
498// one <title>, and exactly one <h1> outside [rst-shell-title]; every
499// frame has the lang and dir and one <title>. Each page's <title>
500// names it in its language: a page kind as the catalog has its title,
501// which is not the English unless leakUnchanged says the locale keeps
502// it.
503func TestEveryPageIsAWholeLocalisedDocument(t *testing.T) {
504 files := render(t, "")
505 pages, frames := 0, 0
506 for _, theme := range fiesta.ThemeNames() {
507 for _, locale := range Locales {
508 dir := theme + "/" + locale + "/"
509 for name, body := range files {
510 if !strings.HasPrefix(name, dir) || !strings.HasSuffix(name, ".html") {
511 continue
512 }
513 doc := parse(t, body)
514 html, ok := doc.first(func(n node) bool { return n.n.Data == "html" })
515 if !ok {
516 t.Errorf("%s has no <html>", name)
517 continue
518 }
519 if lang, _ := html.attr("lang"); lang != locale {
520 t.Errorf("%s says lang=%q, want %q", name, lang, locale)
521 }
522 if d, _ := html.attr("dir"); d != Dir(locale) {
523 t.Errorf("%s says dir=%q, want %q", name, d, Dir(locale))
524 }
525 if n := len(doc.find(func(n node) bool { return n.n.Data == "title" })); n != 1 {
526 t.Errorf("%s has %d <title> elements", name, n)
527 }
528 if isSampleDocument(name) {
529 frames++
530 continue
531 }
532 pages++
533 h1s := contentHeadings(doc)
534 if len(h1s) != 1 {
535 t.Errorf("%s has %d <h1> outside the shell title, want 1", name, len(h1s))
536 }
537 }
538 }
539 }
540 if pages == 0 || frames == 0 {
541 t.Fatalf("%d pages and %d frames; the walk above read one side only", pages, frames)
542 }
543
544 // The <title> names the page in the page's language: a page kind by
545 // its title as the locale's catalog has it, an example by its name,
546 // which is code and the same in every language.
547 s := newTestSite(t, fiesta.FS(), "")
548 english := englishStrings(t)
549 cs := loadedCatalogs(t)
550 for _, theme := range fiesta.ThemeNames() {
551 for _, locale := range Locales {
552 for _, pk := range s.pageKinds() {
553 name := theme + "/" + locale + "/" + pk.File
554 want := english[pk.Title]
555 if locale != "en" {
556 if want = cs[locale][pk.Title]; want == "" {
557 t.Errorf("%s: the %s catalog has no name for the page, %s", name, locale, pk.Title)
558 continue
559 }
560 if want == english[pk.Title] && !unchangedIn(locale, want) {
561 t.Errorf("%s: the %s catalog names the page %q, its English", name, locale, want)
562 }
563 }
564 if got := titleOf(t, parse(t, files[name])); !strings.Contains(got, want) {
565 t.Errorf("%s is titled %q, which does not name the page as %q", name, got, want)
566 }
567 }
568 for _, e := range s.m.Examples {
569 name := theme + "/" + locale + "/" + e.Name + ".html"
570 if got := titleOf(t, parse(t, files[name])); !strings.Contains(got, e.Name) {
571 t.Errorf("%s is titled %q, which does not name the example", name, got)
572 }
573 }
574 }
575 }
576}
577
578// contentHeadings is every <h1> of the page's content: not the shell's
579// own title, <h1 rst-shell-title>, nor anything inside it. The one
580// selector both heading tests read, so the shell's index heading is
581// never counted as the page's.
582func contentHeadings(doc node) []node {
583 return doc.find(func(n node) bool {
584 if n.n.Data != "h1" {
585 return false
586 }
587 for p := n.n; p != nil; p = p.Parent {
588 if _, shellTitle := (node{p}).attr("rst-shell-title"); shellTitle {
589 return false
590 }
591 }
592 return true
593 })
594}
595
596// anchorIDs is the set of ids on the elements carrying data-ds-anchor.
597func anchorIDs(t *testing.T, doc []byte) []string {
598 t.Helper()
599 var out []string
600 for _, n := range parse(t, doc).find(byAttr("data-ds-anchor", "")) {
601 id, ok := n.attr("id")
602 if !ok {
603 t.Errorf("an element carries data-ds-anchor and no id")
604 }
605 out = append(out, id)
606 }
607 sort.Strings(out)
608 return out
609}
610
611// TestAnchorIDsAreTheSameInEveryThemeAndLocale: the data-ds-anchor ids
612// on tokens.html, icons.html and button.html are the same set in all
613// 36 directories, since they are built from English names.
614func TestAnchorIDsAreTheSameInEveryThemeAndLocale(t *testing.T) {
615 files := render(t, "")
616 ref := fiesta.ThemeNames()[0] + "/en/"
617 for _, file := range []string{"tokens.html", "icons.html", "button.html"} {
618 want := anchorIDs(t, files[ref+file])
619 if len(want) == 0 {
620 t.Errorf("%s%s carries no data-ds-anchor", ref, file)
621 continue
622 }
623 for _, dir := range dirs() {
624 got := anchorIDs(t, files[dir+file])
625 if strings.Join(got, " ") != strings.Join(want, " ") {
626 t.Errorf("%s%s anchors %v, %s%s anchors %v", dir, file, got, ref, file, want)
627 }
628 }
629 }
630}
631
632// TestNoPageCarriesTheSameIdTwice: on every page (frames are the
633// examples' own documents), every id is unique.
634func TestNoPageCarriesTheSameIdTwice(t *testing.T) {
635 files := render(t, "")
636 checked := 0
637 for name, body := range files {
638 if !strings.HasSuffix(name, ".html") || isFrame(name) {
639 continue
640 }
641 seen := map[string]int{}
642 for _, n := range parse(t, body).find(byAttr("id", "")) {
643 id, _ := n.attr("id")
644 seen[id]++
645 checked++
646 }
647 for id, n := range seen {
648 if n > 1 {
649 t.Errorf("%s carries id=%q %d times", name, id, n)
650 }
651 }
652 }
653 if checked == 0 {
654 t.Fatal("no id on any page; the check above read nothing")
655 }
656}
657
658// TestEveryPagesH1IsItsOwnTitle: the <h1> of every page reads the
659// page's own name, the first part of its <title>.
660func TestEveryPagesH1IsItsOwnTitle(t *testing.T) {
661 files := render(t, "")
662 checked := 0
663 for name, body := range files {
664 if !strings.HasSuffix(name, ".html") || isSampleDocument(name) {
665 continue
666 }
667 doc := parse(t, body)
668 title := titleOf(t, doc)
669 h1s := contentHeadings(doc)
670 if len(h1s) == 0 {
671 t.Errorf("%s has no <h1>", name)
672 continue
673 }
674 h1 := h1s[0]
675 page, _, _ := strings.Cut(title, " · ")
676 if h1.text() != page {
677 t.Errorf("%s: the <h1> reads %q, the title opens %q", name, h1.text(), page)
678 }
679 checked++
680 }
681 if checked == 0 {
682 t.Fatal("no page was checked")
683 }
684}
685
686// TestTitlesUseTheApprovedSeparators: a page's <title> is three parts
687// joined by " · ", the middle one the site's name, and no part carries
688// another separator. The frames' titles are the examples' own.
689func TestTitlesUseTheApprovedSeparators(t *testing.T) {
690 files := render(t, "")
691 checked := 0
692 for name, body := range files {
693 if !strings.HasSuffix(name, ".html") || isFrame(name) {
694 continue
695 }
696 title := titleOf(t, parse(t, body))
697 parts := strings.Split(title, " · ")
698 if len(parts) != 3 || parts[1] != say("gallery.brand") {
699 t.Errorf("%s: the title %q is not page · Fiesta · theme", name, title)
700 }
701 for _, part := range parts {
702 for _, piece := range strings.Split(part, ", ") {
703 if strings.ContainsAny(piece, "·:|–—") || strings.Contains(piece, " - ") {
704 t.Errorf("%s: the title %q carries a separator that is not · or a comma", name, title)
705 }
706 }
707 }
708 checked++
709 }
710 if checked == 0 {
711 t.Fatal("no page was checked")
712 }
713}
714
715// TestNoTitleOrStateLabelCarriesAnEmDash: no page title, frame title or
716// manifest state label carries an em dash.
717func TestNoTitleOrStateLabelCarriesAnEmDash(t *testing.T) {
718 files := render(t, "")
719 m := manifest(t)
720 checked := 0
721 for name, body := range files {
722 if !strings.HasSuffix(name, ".html") {
723 continue
724 }
725 if title := titleOf(t, parse(t, body)); strings.Contains(title, "—") {
726 t.Errorf("%s: the title %q carries an em dash", name, title)
727 }
728 checked++
729 }
730 for _, e := range m.Examples {
731 for _, st := range e.States {
732 if strings.Contains(st.Label, "—") {
733 t.Errorf("%s/%s: the label %q carries an em dash", e.Name, st.ID, st.Label)
734 }
735 checked++
736 }
737 }
738 if checked == 0 {
739 t.Fatal("nothing was checked")
740 }
741}
742
743// TestEverySamplePictureIsUsedOnce: every file under examples/assets/
744// is the src of at least one frame's picture under its hashed name,
745// and under its plain name the src of none.
746func TestEverySamplePictureIsUsedOnce(t *testing.T) {
747 const mount = "/x"
748 files := render(t, mount)
749 s := newTestSite(t, fiesta.FS(), mount)
750 pictures, err := fs.ReadDir(fiesta.FS(), "examples/assets")
751 if err != nil {
752 t.Fatal(err)
753 }
754 if len(pictures) == 0 {
755 t.Fatal("examples/assets is empty")
756 }
757 srcs := map[string]int{}
758 for name, body := range files {
759 if !isFrame(name) {
760 continue
761 }
762 attrsOf(t, body, func(tag string, attrs map[string]string) {
763 if tag == "img" {
764 srcs[attrs["src"]]++
765 }
766 })
767 }
768 for _, p := range pictures {
769 plain := "images/" + p.Name()
770 hashed, ok := s.hashed[plain]
771 if !ok {
772 t.Errorf("%s is not among the assets", plain)
773 continue
774 }
775 if srcs[s.url("assets/"+hashed)] == 0 {
776 t.Errorf("no frame shows %s", hashed)
777 }
778 if n := srcs[s.url("assets/"+plain)]; n > 0 {
779 t.Errorf("%d frames show %s by its plain name", n, plain)
780 }
781 }
782}
783
784// overlay serves the files it holds and everything else from the tree
785// under it.
786type overlay struct {
787 fs.FS
788 files map[string][]byte
789}
790
791func (o overlay) Open(name string) (fs.File, error) {
792 if b, ok := o.files[name]; ok {
793 return fstest.MapFS{name: &fstest.MapFile{Data: b}}.Open(name)
794 }
795 return o.FS.Open(name)
796}
797
798// withWarning is the source with the password screen's warning in its
799// prose, for as long as the real prose.json has none: the title is
800// Rastrillo's, carried, and the body the approved note from its second
801// sentence on, as the review of the split has it. Once the entry is
802// in the file this returns src itself and the test reads the real
803// words.
804func withWarning(t *testing.T, src fs.FS) fs.FS {
805 t.Helper()
806 raw, err := fs.ReadFile(src, proseFile)
807 if err != nil {
808 t.Fatal(err)
809 }
810 var doc map[string]json.RawMessage
811 if err := json.Unmarshal(raw, &doc); err != nil {
812 t.Fatal(err)
813 }
814 var examples map[string]map[string]json.RawMessage
815 if err := json.Unmarshal(doc["examples"], &examples); err != nil {
816 t.Fatal(err)
817 }
818 signin := examples["signin"]
819 if _, ok := signin["warnings"]; ok {
820 return src
821 }
822 var states map[string]string
823 if err := json.Unmarshal(signin["states"], &states); err != nil {
824 t.Fatal(err)
825 }
826 note := states["email-and-password"]
827 first, rest, ok := strings.Cut(note, ". ")
828 if !ok {
829 t.Fatalf("the email-and-password note has one sentence: %q", note)
830 }
831 title, body, ok := strings.Cut(rest, ": ")
832 if !ok {
833 t.Fatalf("the note's second sentence has no colon to split at: %q", rest)
834 }
835 states["email-and-password"] = first + "."
836 warnings := map[string]map[string]string{"email-and-password": {"title": title, "body": strings.ToUpper(body[:1]) + body[1:]}}
837 signin["states"], _ = json.Marshal(states)
838 signin["warnings"], _ = json.Marshal(warnings)
839 doc["examples"], _ = json.Marshal(examples)
840 out, _ := json.Marshal(doc)
841 t.Logf("prose.json has no signin warning yet; using a scratch entry titled %q", title)
842 return overlay{src, map[string][]byte{proseFile: out}}
843}
844
845// TestThePasswordScreenCarriesItsWarning: on signin.html the
846// email-and-password section holds one warning callout after its note,
847// with <strong> the warning's title and <p> its body; no other state
848// does.
849func TestThePasswordScreenCarriesItsWarning(t *testing.T) {
850 src := withWarning(t, fiesta.FS())
851 files, err := Render(src, Options{})
852 if err != nil {
853 t.Fatalf("Render: %v", err)
854 }
855 p, err := loadProse(src)
856 if err != nil {
857 t.Fatal(err)
858 }
859 warning, ok := p.Examples["signin"].Warnings["email-and-password"]
860 if !ok || warning.Title == "" || warning.Body == "" {
861 t.Fatalf("the prose has no whole warning for signin/email-and-password: %+v", warning)
862 }
863 e, ok := manifest(t).Entry("signin")
864 if !ok {
865 t.Fatal("no signin example")
866 }
867 page := files[fiesta.ThemeNames()[0]+"/en/signin.html"]
868 if len(page) == 0 {
869 t.Fatal("no day/en/signin.html")
870 }
871 for _, st := range e.States {
872 sec := stateSection(t, page, "signin", st.ID)
873 callouts := sec.find(func(n node) bool {
874 _, callout := n.attr("rst-callout")
875 tone, _ := n.attr("rst-tone")
876 return callout && tone == "warning"
877 })
878 if st.ID != "email-and-password" {
879 if len(callouts) != 0 {
880 t.Errorf("signin/%s carries %d warning callouts", st.ID, len(callouts))
881 }
882 continue
883 }
884 if len(callouts) != 1 {
885 t.Fatalf("signin/%s carries %d warning callouts, want 1", st.ID, len(callouts))
886 }
887 order := sec.find(func(n node) bool {
888 _, callout := n.attr("rst-callout")
889 return callout || n.hasClass("ds-note")
890 })
891 if len(order) != 2 || !order[0].hasClass("ds-note") {
892 t.Errorf("signin/%s: the callout does not follow the note", st.ID)
893 }
894 strong, ok := callouts[0].first(func(n node) bool { return n.n.Data == "strong" })
895 if !ok || strong.text() != squash(warning.Title) {
896 t.Errorf("signin/%s: the callout's title reads %q, want %q", st.ID, strong.text(), warning.Title)
897 }
898 body, ok := callouts[0].first(func(n node) bool { return n.n.Data == "p" })
899 if !ok || body.text() != squash(warning.Body) {
900 t.Errorf("signin/%s: the callout's body reads %q, want %q", st.ID, body.text(), warning.Body)
901 }
902 }
903}
904
905// TestAnExampleDocumentFramesLikeASeededOne: a document exampleDocument
906// builds goes through frame like a seeded example does: frame.js first
907// in the head, the stylesheets and scripts hashed, the theme's own,
908// the locale on <html>, the style kept and the body's links deadened.
909// Written against the finished seam; its control is the document
910// built with no stylesheet, which frame refuses by name.
911func TestAnExampleDocumentFramesLikeASeededOne(t *testing.T) {
912 s := newTestSite(t, fiesta.FS(), "/x")
913 e := mustEntry(t, "button")
914 doc := exampleDocument("A test", []string{"light.js"}, "body { padding: 0 }", `<p><a href="/orders">Orders</a></p>`)
915 framed, err := s.frame(doc, "plain", "ar", e, e.Default)
916 if err != nil {
917 t.Fatalf("frame: %v", err)
918 }
919 html, ok := parse(t, []byte(framed)).first(func(n node) bool { return n.n.Data == "html" })
920 if !ok {
921 t.Fatal("the framed document has no <html>")
922 }
923 if lang, _ := html.attr("lang"); lang != "ar" {
924 t.Errorf("the framed document says lang=%q, want ar", lang)
925 }
926 if dir, _ := html.attr("dir"); dir != "rtl" {
927 t.Errorf("the framed document says dir=%q, want rtl", dir)
928 }
929 for _, want := range []string{
930 `<title>A test</title>`,
931 `<script src="` + s.asset("frame.js") + `"></script>`,
932 `href="` + s.asset("css/tokens.css") + `"`,
933 `href="` + s.asset("css/themes/plain.css") + `"`,
934 `src="` + s.asset("js/light.js") + `"`,
935 `<style>body { padding: 0 }</style>`,
936 `<a href="#">Orders</a>`,
937 } {
938 if !strings.Contains(framed, want) {
939 t.Errorf("the framed document lacks %s:\n%s", want, framed)
940 }
941 }
942 if i, j := strings.Index(framed, "frame."), strings.Index(framed, "tokens."); i < 0 || j < 0 || i > j {
943 t.Errorf("frame.js is not before the first stylesheet")
944 }
945 bare := strings.Replace(strings.Replace(doc, `<link rel="stylesheet" href="../../css/tokens.css">`+"\n", "", 1), `<link rel="stylesheet" href="../../css/themes/day.css">`+"\n", "", 1)
946 bare = strings.Replace(bare, `<script defer src="../../js/light.js"></script>`+"\n", "", 1)
947 if _, err := s.frame(bare, "plain", "ar", e, e.Default); err == nil {
948 t.Error("frame accepted a document with nothing in its head to put frame.js before")
949 }
950}
951
952// TestEveryLookupIsTraced: a tracer installed with setTrace hears every
953// id a render asks for, chrome, prose and labels alike, and hears
954// nothing once removed. This is the hook the translation parity gates
955// read. A state the manifest leaves unlabelled (button's default) is
956// not a lookup, as an empty prose slot is not, so no catalog is asked
957// to translate nothing.
958func TestEveryLookupIsTraced(t *testing.T) {
959 seen := map[string]int{}
960 calls := 0
961 stop := setTrace(func(id string) { seen[id]++; calls++ })
962 if _, err := Render(fiesta.FS(), Options{}); err != nil {
963 t.Fatal(err)
964 }
965 stop()
966 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"} {
967 if seen[id] == 0 {
968 t.Errorf("no render asked for %s", id)
969 }
970 }
971 if seen["labels.button.default"] != 0 {
972 t.Errorf("a render asked for labels.button.default, which the manifest leaves empty, %d times", seen["labels.button.default"])
973 }
974 for id := range seen {
975 if strings.HasPrefix(id, "gallery.direction") {
976 t.Errorf("a render asked for %s, which is gone", id)
977 }
978 }
979 if calls == 0 {
980 t.Fatal("the tracer heard nothing")
981 }
982 before := calls
983 if _, err := Render(fiesta.FS(), Options{}); err != nil {
984 t.Fatal(err)
985 }
986 if calls != before {
987 t.Errorf("the tracer was called %d more times after it was removed", calls-before)
988 }
989}
990
991// TestARegisteredTemplateIsParsedForEveryLocale: a template registered
992// from any file, through registerTemplate, is in the tree every
993// locale's pages are drawn from, as the five body templates are. The
994// control is a scratch define, registered and then withdrawn.
995func TestARegisteredTemplateIsParsedForEveryLocale(t *testing.T) {
996 s := newTestSite(t, fiesta.FS(), "")
997 for _, locale := range []string{"en", "ar"} {
998 tmpl, err := s.templates(locale)
999 if err != nil {
1000 t.Fatal(err)
1001 }
1002 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"} {
1003 if tmpl.Lookup(name) == nil {
1004 t.Errorf("%s: no template %s", locale, name)
1005 }
1006 }
1007 if tmpl.Lookup("ds-body-scratch") != nil {
1008 t.Fatal("a scratch template is already registered")
1009 }
1010 }
1011 saved := templateSources
1012 t.Cleanup(func() { templateSources = saved })
1013 registerTemplate(`{{define "ds-body-scratch"}}<p>{{say "gallery.brand"}}</p>{{end}}`)
1014 fresh := newTestSite(t, fiesta.FS(), "")
1015 tmpl, err := fresh.templates("ga")
1016 if err != nil {
1017 t.Fatal(err)
1018 }
1019 if tmpl.Lookup("ds-body-scratch") == nil {
1020 t.Error("a template registered after the others is not parsed")
1021 }
1022 var b strings.Builder
1023 if err := tmpl.ExecuteTemplate(&b, "ds-body-scratch", nil); err != nil || b.String() != "<p>Fiesta</p>" {
1024 t.Errorf("the registered template rendered %q, %v", b.String(), err)
1025 }
1026}
1027
1028// TestContentHeadingsSkipTheShellTitle: the heading selector the two
1029// heading tests share counts a content <h1> and not the shell's own
1030// <h1 rst-shell-title>, nor a heading inside a shell-title element.
1031func TestContentHeadingsSkipTheShellTitle(t *testing.T) {
1032 doc := parse(t, []byte(`<body><div rst-shell-sidebar="index"><h1 rst-shell-title><span></span><span>Fiesta</span></h1><div rst-shell-title><h1>inner</h1></div><main><h1 id="x">Tokens</h1></main></div></body>`))
1033 got := contentHeadings(doc)
1034 if len(got) != 1 || got[0].text() != "Tokens" {
1035 var texts []string
1036 for _, n := range got {
1037 texts = append(texts, n.text())
1038 }
1039 t.Errorf("contentHeadings found %v, want [Tokens]", texts)
1040 }
1041 if n := len(doc.find(func(n node) bool { return n.n.Data == "h1" })); n < 3 {
1042 t.Fatalf("the fixture parsed to %d h1 elements; the selector had nothing to skip", n)
1043 }
1044}
1045
1046// mustEntry is the manifest entry for name.
1047func mustEntry(t *testing.T, name string) example.Entry {
1048 t.Helper()
1049 e, ok := manifest(t).Entry(name)
1050 if !ok {
1051 t.Fatalf("no %s example", name)
1052 }
1053 return e
1054}
1055
1056// frameFilesOf is a sanity check on the seam the gates above read:
1057// one frame per state, under frames/<name>/.
1058func TestFrameFilesNameOneDocumentPerState(t *testing.T) {
1059 s := newTestSite(t, fiesta.FS(), "")
1060 e := mustEntry(t, "button")
1061 got := s.frameFiles(e)
1062 if len(got) != len(e.States) {
1063 t.Fatalf("frameFiles(button) = %v, want one per state (%d)", got, len(e.States))
1064 }
1065 for i, st := range e.States {
1066 if want := fmt.Sprintf("frames/%s/%s.html", e.Name, st.ID); got[i] != want {
1067 t.Errorf("frameFiles(button)[%d] = %q, want %q", i, got[i], want)
1068 }
1069 }
1070}
1071