| 1 | package gallery |
| 2 | |
| 3 | import ( |
| 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. |
| 30 | func 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. |
| 41 | func 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. |
| 52 | func 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. |
| 59 | func isSampleDocument(name string) bool { |
| 60 | return isFrame(name) || reserved(name[strings.LastIndex(name, "/")+1:]) |
| 61 | } |
| 62 | |
| 63 | // titleOf is the document's <title> text. |
| 64 | func 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. |
| 81 | func 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. |
| 165 | func 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. |
| 202 | func 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. |
| 239 | func 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. |
| 257 | func 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. |
| 278 | func 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. |
| 385 | func 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. |
| 429 | var 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. |
| 434 | func 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. |
| 503 | func 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. |
| 582 | func 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. |
| 597 | func 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. |
| 614 | func 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. |
| 634 | func 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>. |
| 660 | func 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. |
| 689 | func 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. |
| 717 | func 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. |
| 746 | func 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. |
| 786 | type overlay struct { |
| 787 | fs.FS |
| 788 | files map[string][]byte |
| 789 | } |
| 790 | |
| 791 | func (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. |
| 804 | func 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. |
| 849 | func 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. |
| 911 | func 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. |
| 958 | func 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. |
| 995 | func 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. |
| 1031 | func 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. |
| 1047 | func 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>/. |
| 1058 | func 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 | |