rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package example_test
2
3import (
4 "io/fs"
5 "path"
6 "regexp"
7 "slices"
8 "sort"
9 "strconv"
10 "strings"
11 "testing"
12 "testing/fstest"
13
14 "golang.org/x/net/html"
15
16 "amadan.net/rastrillo/fiesta"
17 "amadan.net/rastrillo/fiesta/example"
18)
19
20// ── The package on a synthetic tree ──────────────────────────────────
21
22// synthBody is a body with a nested container and a <template>, the
23// two shapes a tokenizer that reads structure rather than bytes gets
24// wrong: a <section rst-box> is not a <body>, and a <template>'s
25// content is inert in the DOM but is bytes on disk all the same.
26const synthBody = `<section rst-box><form rst-form method="post" action="#">
27<template id="row"><div rst-field><label rst-field-label for="t">Title</label><input rst-input id="t" name="t"></div></template>
28<div rst-field><label rst-field-label for="f_bare">Title</label><input rst-input id="f_bare" name="f_bare"></div>
29</form></section>`
30
31func synthDoc(body string) string {
32 return "<!doctype html>\n<html lang=\"en\" dir=\"ltr\">\n<head>\n<meta charset=\"utf-8\">\n" +
33 "<meta name=\"viewport\" content=\"width=device-width\">\n<title>field</title>\n" +
34 "<link rel=\"stylesheet\" href=\"../../css/tokens.css\">\n</head>\n<body>\n" + body + "\n</body>\n</html>\n"
35}
36
37func synthFS() fstest.MapFS {
38 return fstest.MapFS{
39 "examples/examples.json": {Data: []byte(`{"examples":[{"name":"field","title":"field","family":"form","kind":"component","document":false,"wrapper":"form","scripts":[],"default":"bare","states":[{"id":"bare","label":"Bare","sampleURLs":[]}]}],"idioms":{}}`)},
40 "examples/field/bare.html": {Data: []byte(synthDoc(synthBody))},
41 }
42}
43
44func TestMarkupReturnsTheBodyAsWritten(t *testing.T) {
45 got, err := example.Markup(synthFS(), "field", "bare")
46 if err != nil {
47 t.Fatalf("Markup: %v", err)
48 }
49 if got != synthBody {
50 t.Fatalf("Markup returned\n%s\nwant\n%s", got, synthBody)
51 }
52 // Exactly one newline is trimmed at each end: a body that ends in a
53 // blank line keeps it.
54 fsys := synthFS()
55 fsys["examples/field/blank.html"] = &fstest.MapFile{Data: []byte(synthDoc(synthBody + "\n"))}
56 got, err = example.Markup(fsys, "field", "blank")
57 if err != nil {
58 t.Fatalf("Markup: %v", err)
59 }
60 if got != synthBody+"\n" {
61 t.Fatalf("Markup trimmed more than one trailing newline:\n%q", got[len(got)-20:])
62 }
63}
64
65func TestMarkupRefusesAMissingState(t *testing.T) {
66 if _, err := example.Markup(synthFS(), "field", "with-a-hint"); err == nil {
67 t.Fatal("Markup of a state with no file returned no error")
68 }
69 if _, err := example.Markup(synthFS(), "nothing", "bare"); err == nil {
70 t.Fatal("Markup of an example with no directory returned no error")
71 }
72}
73
74func TestSlotReplacesOnlyTheSlot(t *testing.T) {
75 doc := "<main>\n<!-- fiesta:slot -->\n<h1>Posts</h1>\n<!-- /fiesta:slot -->\n</main>"
76 got, err := example.Slot(doc, "<p>Hello</p>")
77 if err != nil {
78 t.Fatalf("Slot: %v", err)
79 }
80 want := "<main>\n<!-- fiesta:slot --><p>Hello</p><!-- /fiesta:slot -->\n</main>"
81 if got != want {
82 t.Fatalf("Slot returned\n%s\nwant\n%s", got, want)
83 }
84 if _, err := example.Slot("<main><h1>Posts</h1></main>", "<p>Hello</p>"); err == nil {
85 t.Fatal("Slot on a document with no slot returned no error")
86 }
87}
88
89func TestSlotRefusesTwoSlots(t *testing.T) {
90 doc := "<!-- fiesta:slot -->a<!-- /fiesta:slot --><!-- fiesta:slot -->b<!-- /fiesta:slot -->"
91 if _, err := example.Slot(doc, "c"); err == nil {
92 t.Fatal("Slot on a document with two slots returned no error")
93 }
94}
95
96func TestSlotRefusesReversedMarkers(t *testing.T) {
97 doc := "<main><!-- /fiesta:slot -->a<!-- fiesta:slot --></main>"
98 if _, err := example.Slot(doc, "c"); err == nil {
99 t.Fatal("Slot on a document whose slot closes before it opens returned no error")
100 }
101}
102
103// ── The real examples ────────────────────────────────────────────────
104
105// tree is the embedded design system, which carries examples/ from
106// this task on.
107func tree(t *testing.T) fs.FS {
108 t.Helper()
109 fsys := fiesta.FS()
110 if _, err := fs.Stat(fsys, "examples/examples.json"); err != nil {
111 t.Fatalf("the embedded tree has no examples/examples.json: %v", err)
112 }
113 return fsys
114}
115
116func manifest(t *testing.T) (fs.FS, *example.Manifest) {
117 t.Helper()
118 fsys := tree(t)
119 m, err := example.Load(fsys)
120 if err != nil {
121 t.Fatalf("Load: %v", err)
122 }
123 if len(m.Examples) == 0 {
124 t.Fatal("the manifest lists no examples")
125 }
126 return fsys, m
127}
128
129func TestDefaultIsAState(t *testing.T) {
130 _, m := manifest(t)
131 for _, e := range m.Examples {
132 id, ok := m.Default(e.Name)
133 if !ok {
134 t.Errorf("%s: Default reports no default", e.Name)
135 continue
136 }
137 if id != e.Default {
138 t.Errorf("%s: Default returned %q, the entry says %q", e.Name, id, e.Default)
139 }
140 if !slices.ContainsFunc(e.States, func(s example.State) bool { return s.ID == id }) {
141 t.Errorf("%s: the default state %q is not one of its states", e.Name, id)
142 }
143 }
144 if _, ok := m.Default("no-such-example"); ok {
145 t.Error("Default of an unknown example reports ok")
146 }
147}
148
149var (
150 kinds = []string{"component", "idiom", "layout", "format", "screen"}
151 wrappers = []string{"", "list", "form", "box", "stats"}
152)
153
154func TestTheManifestMatchesTheFiles(t *testing.T) {
155 fsys, m := manifest(t)
156 scripts := fiesta.ScriptNames()
157 seen := map[string]bool{}
158 for _, e := range m.Examples {
159 if seen[e.Name] {
160 t.Errorf("%s is listed twice", e.Name)
161 }
162 seen[e.Name] = true
163 if !slices.Contains(kinds, e.Kind) {
164 t.Errorf("%s: kind %q is not one of %v", e.Name, e.Kind, kinds)
165 }
166 if !slices.Contains(wrappers, e.Wrapper) {
167 t.Errorf("%s: wrapper %q is not one of %v", e.Name, e.Wrapper, wrappers)
168 }
169 for _, s := range e.Scripts {
170 if !slices.Contains(scripts, s) {
171 t.Errorf("%s: script %q is not one fiesta ships (%v)", e.Name, s, scripts)
172 }
173 }
174 entries, err := fs.ReadDir(fsys, path.Join("examples", e.Name))
175 if err != nil {
176 t.Errorf("%s: no directory: %v", e.Name, err)
177 continue
178 }
179 files := map[string]bool{}
180 for _, f := range entries {
181 files[f.Name()] = true
182 }
183 ids := map[string]bool{}
184 if len(e.States) == 0 {
185 t.Errorf("%s: no states", e.Name)
186 }
187 for _, s := range e.States {
188 if ids[s.ID] {
189 t.Errorf("%s: state id %q is used twice", e.Name, s.ID)
190 }
191 ids[s.ID] = true
192 if !files[s.ID+".html"] {
193 t.Errorf("%s: state %q has no file", e.Name, s.ID)
194 }
195 delete(files, s.ID+".html")
196 }
197 for f := range files {
198 t.Errorf("%s: %s is not a state in the manifest", e.Name, f)
199 }
200 if _, ok := m.Entry(e.Name); !ok {
201 t.Errorf("%s: Entry reports no entry", e.Name)
202 }
203 }
204 entries, err := fs.ReadDir(fsys, "examples")
205 if err != nil {
206 t.Fatal(err)
207 }
208 for _, d := range entries {
209 if d.IsDir() && d.Name() != "assets" && !seen[d.Name()] {
210 t.Errorf("examples/%s is a directory no entry names", d.Name())
211 }
212 }
213}
214
215// attr returns one attribute of a start tag, and whether it is there.
216func attr(tok html.Token, name string) (string, bool) {
217 for _, a := range tok.Attr {
218 if a.Key == name {
219 return a.Val, true
220 }
221 }
222 return "", false
223}
224
225// walk tokenizes one document and hands each start or self-closing
226// tag to fn with whether it sits inside <head>.
227func walk(t *testing.T, doc string, fn func(tok html.Token, inHead bool)) {
228 t.Helper()
229 z := html.NewTokenizer(strings.NewReader(doc))
230 inHead := false
231 for {
232 switch z.Next() {
233 case html.ErrorToken:
234 return
235 case html.StartTagToken, html.SelfClosingTagToken:
236 tok := z.Token()
237 if tok.Data == "head" {
238 inHead = true
239 }
240 fn(tok, inHead)
241 case html.EndTagToken:
242 if z.Token().Data == "head" {
243 inHead = false
244 }
245 }
246 }
247}
248
249func everyDocument(t *testing.T, fn func(e example.Entry, s example.State, doc string)) {
250 t.Helper()
251 fsys, m := manifest(t)
252 for _, e := range m.Examples {
253 for _, s := range e.States {
254 doc, err := example.Document(fsys, e.Name, s.ID)
255 if err != nil {
256 t.Errorf("%s/%s: %v", e.Name, s.ID, err)
257 continue
258 }
259 fn(e, s, doc)
260 }
261 }
262}
263
264func TestExamplesLinkOnlyFiestasOwnFiles(t *testing.T) {
265 fsys := tree(t)
266 everyDocument(t, func(e example.Entry, s example.State, doc string) {
267 where := e.Name + "/" + s.ID
268 walk(t, doc, func(tok html.Token, inHead bool) {
269 for _, name := range []string{"href", "src"} {
270 v, ok := attr(tok, name)
271 if !ok || !inHead {
272 continue
273 }
274 if !strings.HasPrefix(v, "../../css/") && !strings.HasPrefix(v, "../../js/") {
275 t.Errorf("%s: the head's %s=%q is not under ../../css or ../../js", where, name, v)
276 continue
277 }
278 if _, err := fs.Stat(fsys, strings.TrimPrefix(v, "../../")); err != nil {
279 t.Errorf("%s: the head links %s, which the tree does not have", where, v)
280 }
281 }
282 if inHead {
283 return
284 }
285 for _, name := range []string{"src", "srcset", "poster"} {
286 v, ok := attr(tok, name)
287 if !ok {
288 continue
289 }
290 for _, cand := range strings.Split(v, ",") {
291 fields := strings.Fields(cand)
292 if len(fields) == 0 {
293 t.Errorf("%s: the body's %s=%q has an empty candidate", where, name, v)
294 continue
295 }
296 file := fields[0]
297 if !strings.HasPrefix(file, "../assets/") {
298 t.Errorf("%s: the body's %s=%q is not under ../assets/", where, name, v)
299 continue
300 }
301 if _, err := fs.Stat(fsys, path.Join("examples", strings.TrimPrefix(file, "../"))); err != nil {
302 t.Errorf("%s: the body names %s, which examples/assets does not have", where, file)
303 }
304 }
305 }
306 })
307 })
308}
309
310func TestEveryIdiomIsMapped(t *testing.T) {
311 _, m := manifest(t)
312 if len(m.Idioms) == 0 {
313 t.Fatal("the manifest maps no idioms")
314 }
315 for key, ref := range m.Idioms {
316 name, state, ok := m.Idiom(key)
317 if !ok || name != ref.Example || state != ref.State {
318 t.Errorf("Idiom(%q) = %q, %q, %v; the manifest says %q, %q", key, name, state, ok, ref.Example, ref.State)
319 }
320 e, ok := m.Entry(ref.Example)
321 if !ok {
322 t.Errorf("idiom %q names example %q, which does not exist", key, ref.Example)
323 continue
324 }
325 if !slices.ContainsFunc(e.States, func(s example.State) bool { return s.ID == ref.State }) {
326 t.Errorf("idiom %q names state %q of %q, which does not exist", key, ref.State, ref.Example)
327 }
328 }
329 if name, state, ok := m.Idiom("dropdown"); !ok || name != "dropdown" || state != "menu-idiom" {
330 t.Errorf("Idiom(dropdown) = %q, %q, %v; want dropdown, menu-idiom, true", name, state, ok)
331 }
332 if _, _, ok := m.Idiom("no-such-idiom"); ok {
333 t.Error("Idiom of an unknown key reports ok")
334 }
335}
336
337// applicationURL says whether a link or action is one of the routes a
338// sample stands for: not a fragment, not one of Fiesta's own files and
339// not one of the example pictures.
340func applicationURL(v string) bool {
341 return v != "" && !strings.HasPrefix(v, "#") && !strings.HasPrefix(v, "../../") && !strings.HasPrefix(v, "../assets/")
342}
343
344func TestSampleURLsAreDeclared(t *testing.T) {
345 everyDocument(t, func(e example.Entry, s example.State, doc string) {
346 where := e.Name + "/" + s.ID
347 found := map[string]bool{}
348 walk(t, doc, func(tok html.Token, inHead bool) {
349 if inHead {
350 return
351 }
352 if v, ok := attr(tok, "href"); ok && applicationURL(v) {
353 found[v] = true
354 }
355 if v, ok := attr(tok, "action"); ok && tok.Data == "form" && applicationURL(v) {
356 found[v] = true
357 }
358 })
359 var want []string
360 for v := range found {
361 want = append(want, v)
362 }
363 sort.Strings(want)
364 got := s.SampleURLs
365 if got == nil {
366 got = []string{}
367 }
368 if !sort.StringsAreSorted(got) {
369 t.Errorf("%s: sampleURLs are not sorted: %v", where, got)
370 }
371 if !slices.Equal(got, want) {
372 t.Errorf("%s: sampleURLs are %v; the markup links %v", where, got, want)
373 }
374 })
375}
376
377func TestEveryStateIsValidOnItsOwn(t *testing.T) {
378 everyDocument(t, func(e example.Entry, s example.State, doc string) {
379 where := e.Name + "/" + s.ID
380 ids := map[string]bool{}
381 type ref struct{ attr, id string }
382 var refs []ref
383 walk(t, doc, func(tok html.Token, _ bool) {
384 if id, ok := attr(tok, "id"); ok {
385 if ids[id] {
386 t.Errorf("%s: id %q is used twice", where, id)
387 }
388 ids[id] = true
389 }
390 if v, ok := attr(tok, "for"); ok {
391 refs = append(refs, ref{"for", v})
392 }
393 for _, name := range []string{"aria-labelledby", "aria-describedby", "aria-controls"} {
394 if v, ok := attr(tok, name); ok {
395 for _, id := range strings.Fields(v) {
396 refs = append(refs, ref{name, id})
397 }
398 }
399 }
400 })
401 for _, r := range refs {
402 if !ids[r.id] {
403 t.Errorf("%s: %s=%q names no id in the document", where, r.attr, r.id)
404 }
405 }
406 })
407}
408
409// headProblems lists what is wrong with one document's standard head:
410// the doctype, <html lang="en" dir="ltr"> (with the scheme store
411// attribute only where store says so), the charset, the viewport, a
412// non-empty <title>, and the two stylesheets.
413func headProblems(t *testing.T, doc string, store bool) []string {
414 t.Helper()
415 var problems []string
416 if !strings.HasPrefix(doc, "<!doctype html>\n") {
417 problems = append(problems, "no <!doctype html> first")
418 }
419 var sawHTML, charset, viewport, title, tokens, day bool
420 walk(t, doc, func(tok html.Token, inHead bool) {
421 switch {
422 case tok.Data == "html":
423 sawHTML = true
424 if v, _ := attr(tok, "lang"); v != "en" {
425 problems = append(problems, "<html> lang is "+strconv.Quote(v)+", want en")
426 }
427 if v, _ := attr(tok, "dir"); v != "ltr" {
428 problems = append(problems, "<html> dir is "+strconv.Quote(v)+", want ltr")
429 }
430 if v, ok := attr(tok, "data-rst-scheme-store"); ok != store || (ok && v != "local") {
431 problems = append(problems, "<html> data-rst-scheme-store is "+strconv.Quote(v)+", present "+strconv.FormatBool(ok)+", want present "+strconv.FormatBool(store))
432 }
433 case !inHead:
434 case tok.Data == "meta":
435 if v, ok := attr(tok, "charset"); ok && strings.EqualFold(v, "utf-8") {
436 charset = true
437 }
438 if v, _ := attr(tok, "name"); v == "viewport" {
439 viewport = true
440 }
441 case tok.Data == "link":
442 v, _ := attr(tok, "href")
443 tokens = tokens || v == "../../css/tokens.css"
444 day = day || v == "../../css/themes/day.css"
445 }
446 })
447 if m := titlePattern.FindStringSubmatch(doc); m != nil && strings.TrimSpace(m[1]) != "" {
448 title = true
449 }
450 for _, c := range []struct {
451 ok bool
452 what string
453 }{{sawHTML, "no <html> tag"}, {charset, "no <meta charset=\"utf-8\"> in the head"}, {viewport, "no viewport meta in the head"},
454 {title, "no non-empty <title>"}, {tokens, "the head does not link ../../css/tokens.css"}, {day, "the head does not link ../../css/themes/day.css"}} {
455 if !c.ok {
456 problems = append(problems, c.what)
457 }
458 }
459 return problems
460}
461
462var titlePattern = regexp.MustCompile(`(?s)<title>(.*?)</title>`)
463
464// TestEveryExampleHasTheStandardHead holds the example file format
465// the exporter wrote and the README now calls hand-owned: a hand edit
466// that drops the viewport or the theme fails here. The control leg
467// plants each fault in a copy of a real document and expects the
468// checker to name it, so the pass below is not vacuous.
469func TestEveryExampleHasTheStandardHead(t *testing.T) {
470 fsys, m := manifest(t)
471 t.Run("control", func(t *testing.T) {
472 doc, err := example.Document(fsys, m.Examples[0].Name, m.Examples[0].Default)
473 if err != nil {
474 t.Fatal(err)
475 }
476 for _, c := range []struct {
477 name, from, to, want string
478 }{
479 {"doctype", "<!doctype html>\n", "", "no <!doctype html> first"},
480 {"lang", `lang="en"`, `lang="es"`, `<html> lang is "es", want en`},
481 {"viewport", `<meta name="viewport" content="width=device-width">`, "", "no viewport meta in the head"},
482 {"theme", `href="../../css/themes/day.css"`, `href="../../css/themes/night.css"`, "the head does not link ../../css/themes/day.css"},
483 {"store", `dir="ltr"`, `dir="ltr" data-rst-scheme-store="local"`, `<html> data-rst-scheme-store is "local", present true, want present false`},
484 } {
485 mutated := strings.Replace(doc, c.from, c.to, 1)
486 if mutated == doc {
487 t.Fatalf("%s: the mutation changed nothing; the control proves nothing", c.name)
488 }
489 if got := headProblems(t, mutated, false); !slices.Contains(got, c.want) {
490 t.Errorf("%s: the checker reported %v, want it to include %q", c.name, got, c.want)
491 }
492 }
493 if got := headProblems(t, doc, false); len(got) != 0 {
494 t.Errorf("the unmutated document reports %v", got)
495 }
496 })
497 everyDocument(t, func(e example.Entry, s example.State, doc string) {
498 store := e.Name == "scheme-menu" && s.ID == "standalone"
499 for _, p := range headProblems(t, doc, store) {
500 t.Errorf("%s/%s: %s", e.Name, s.ID, p)
501 }
502 })
503}
504
505// layoutProblems lists what is wrong with a layout entry and its one
506// document: it has exactly one state, default, which is its Default,
507// and the document has exactly one slot. A non-layout document has no
508// slot at all.
509func layoutProblems(e example.Entry, doc string) []string {
510 var problems []string
511 if !e.Document {
512 if strings.Contains(doc, "<!-- fiesta:slot -->") || strings.Contains(doc, "<!-- /fiesta:slot -->") {
513 problems = append(problems, "a non-layout document carries a slot marker")
514 }
515 return problems
516 }
517 if len(e.States) != 1 || e.States[0].ID != "default" {
518 var ids []string
519 for _, s := range e.States {
520 ids = append(ids, s.ID)
521 }
522 problems = append(problems, "a layout must have the one state default, has "+strings.Join(ids, ", "))
523 }
524 if e.Default != "default" {
525 problems = append(problems, "a layout's default must be default, is "+e.Default)
526 }
527 if _, err := example.Slot(doc, "<p>x</p>"); err != nil {
528 problems = append(problems, err.Error())
529 }
530 return problems
531}
532
533func TestEveryLayoutHasOneDefaultAndOneSlot(t *testing.T) {
534 fsys, m := manifest(t)
535 t.Run("control", func(t *testing.T) {
536 e, ok := m.Entry("layout-sidebar")
537 if !ok || !e.Document {
538 t.Fatal("no layout-sidebar layout to mutate")
539 }
540 doc, err := example.Document(fsys, e.Name, "default")
541 if err != nil {
542 t.Fatal(err)
543 }
544 two := e
545 two.States = append(append([]example.State(nil), e.States...), example.State{ID: "wide"})
546 if got := layoutProblems(two, doc); len(got) == 0 {
547 t.Error("an entry with two states passed")
548 }
549 if got := layoutProblems(e, strings.Replace(doc, "<!-- fiesta:slot -->", "<!-- fiesta:slot --><!-- fiesta:slot -->", 1)); len(got) == 0 {
550 t.Error("a document with two slots passed")
551 }
552 if got := layoutProblems(e, strings.ReplaceAll(doc, "<!-- fiesta:slot -->", "")); len(got) == 0 {
553 t.Error("a document with no slot passed")
554 }
555 plain := e
556 plain.Document = false
557 if got := layoutProblems(plain, doc); len(got) == 0 {
558 t.Error("a non-layout document with a slot passed")
559 }
560 if got := layoutProblems(e, doc); len(got) != 0 {
561 t.Errorf("the unmutated layout reports %v", got)
562 }
563 })
564 layouts := 0
565 everyDocument(t, func(e example.Entry, s example.State, doc string) {
566 if e.Document {
567 layouts++
568 }
569 for _, p := range layoutProblems(e, doc) {
570 t.Errorf("%s/%s: %s", e.Name, s.ID, p)
571 }
572 })
573 if layouts != 7 {
574 t.Errorf("%d layout documents, want 7", layouts)
575 }
576}
577
578func TestTheStandaloneSchemeStateOwnsItsStorage(t *testing.T) {
579 fsys, m := manifest(t)
580 def, ok := m.Default("scheme-menu")
581 if !ok {
582 t.Fatal("no scheme-menu example")
583 }
584 if def != "standalone" {
585 t.Errorf("scheme-menu's default is %q, want standalone", def)
586 }
587 htmlAttr := func(doc string) (string, bool) {
588 var v string
589 var found bool
590 walk(t, doc, func(tok html.Token, _ bool) {
591 if tok.Data == "html" {
592 v, found = attr(tok, "data-rst-scheme-store")
593 }
594 })
595 return v, found
596 }
597 doc, err := example.Document(fsys, "scheme-menu", "standalone")
598 if err != nil {
599 t.Fatal(err)
600 }
601 if v, ok := htmlAttr(doc); !ok || v != "local" {
602 t.Errorf("scheme-menu/standalone: <html> carries data-rst-scheme-store=%q, %v; want \"local\"", v, ok)
603 }
604 if strings.Contains(doc, " action=") {
605 t.Error("scheme-menu/standalone: a form still names an action")
606 }
607 everyDocument(t, func(e example.Entry, s example.State, doc string) {
608 if e.Name == "scheme-menu" && s.ID == "standalone" {
609 return
610 }
611 if _, ok := htmlAttr(doc); ok {
612 t.Errorf("%s/%s: <html> carries data-rst-scheme-store; only scheme-menu/standalone does", e.Name, s.ID)
613 }
614 })
615}
616
617func TestAllWalksTheManifestInOrder(t *testing.T) {
618 fsys, m := manifest(t)
619 pieces, err := example.All(fsys)
620 if err != nil {
621 t.Fatal(err)
622 }
623 var want []string
624 for _, e := range m.Examples {
625 if e.Document {
626 continue
627 }
628 for _, s := range e.States {
629 want = append(want, e.Name+"/"+s.ID)
630 }
631 }
632 var got []string
633 for _, p := range pieces {
634 got = append(got, p.Name+"/"+p.State)
635 markup, err := example.Markup(fsys, p.Name, p.State)
636 if err != nil {
637 t.Errorf("%s/%s: %v", p.Name, p.State, err)
638 } else if markup != p.Markup {
639 t.Errorf("%s/%s: All's markup differs from Markup's", p.Name, p.State)
640 }
641 if strings.Contains(p.Markup, "<body") || strings.Contains(p.Markup, "</body>") {
642 t.Errorf("%s/%s: the markup holds a body tag", p.Name, p.State)
643 }
644 }
645 if !slices.Equal(got, want) {
646 t.Errorf("All returned %d pieces in this order:\n%v\nwant %d:\n%v", len(got), got, len(want), want)
647 }
648}
649
650// ── Reading one component out of its row ─────────────────────────────
651
652// rowed is a dropdown in a section head, the shape the reworked states
653// take: a heading, then the menu, whose panel holds a nested <details>
654// that a reader counting the wrong tag would end the menu on.
655const (
656 rowedMenu = `<details rst-dropdown name="a"><summary>S</summary><div rst-dropdown-menu><details rst-menu-group><summary>G</summary></details></div></details>`
657 rowed = `<div rst-box-head><h2>Posts</h2>` + rowedMenu + `</div>`
658)
659
660func TestElementReturnsTheCarrierAsWritten(t *testing.T) {
661 got, err := example.Element(rowed, "rst-dropdown")
662 if err != nil {
663 t.Fatalf("Element: %v", err)
664 }
665 if got != rowedMenu {
666 t.Fatalf("Element returned\n%s\nwant\n%s", got, rowedMenu)
667 }
668}
669
670func TestContentsReturnsWhatLiesInside(t *testing.T) {
671 got, err := example.Contents(rowed, "rst-box-head")
672 if err != nil {
673 t.Fatalf("Contents: %v", err)
674 }
675 if want := `<h2>Posts</h2>` + rowedMenu; got != want {
676 t.Fatalf("Contents returned\n%s\nwant\n%s", got, want)
677 }
678}
679
680func TestElementReadsAVoidElement(t *testing.T) {
681 got, err := example.Element(`<p><input rst-input name="q"></p>`, "rst-input")
682 if err != nil {
683 t.Fatalf("Element: %v", err)
684 }
685 if want := `<input rst-input name="q">`; got != want {
686 t.Fatalf("Element returned %q, want %q", got, want)
687 }
688}
689
690// refusal holds one call to an error that names the attribute, since
691// an error that does not say which component was missing sends its
692// reader through every row on the page.
693func refusal(t *testing.T, markup, why string) {
694 t.Helper()
695 got, err := example.Element(markup, "rst-dropdown")
696 if err == nil {
697 t.Fatalf("Element of %s returned %q and no error", why, got)
698 }
699 if !strings.Contains(err.Error(), "rst-dropdown") {
700 t.Errorf("Element of %s: the error %q does not name rst-dropdown", why, err)
701 }
702}
703
704func TestElementRefusesAMissingCarrier(t *testing.T) {
705 refusal(t, `<div rst-box-head><h2>Posts</h2></div>`, "markup with no dropdown")
706}
707
708func TestElementRefusesTwoCarriers(t *testing.T) {
709 refusal(t, `<div rst-box-head><h2>Posts</h2>`+rowedMenu+rowedMenu+`</div>`, "two sibling dropdowns")
710}
711
712func TestElementRefusesAnUnclosedCarrier(t *testing.T) {
713 refusal(t, `<div rst-box-head><h2>Posts</h2><details rst-dropdown name="a"><summary>S</summary>`, "a dropdown cut short")
714}
715
716func TestANestedCarrierCountsOnce(t *testing.T) {
717 outer := `<details rst-dropdown name="o"><summary>O</summary><div rst-dropdown-menu>` + rowedMenu + `</div></details>`
718 got, err := example.Element(`<div rst-box-head>`+outer+`</div>`, "rst-dropdown")
719 if err != nil {
720 t.Fatalf("Element: %v", err)
721 }
722 if got != outer {
723 t.Fatalf("Element returned\n%s\nwant the outer dropdown\n%s", got, outer)
724 }
725}
726
727// TestEachRowedStateHoldsOneComponent: the five component states that
728// show their menu in a row still give a reader the bare component, the
729// way part 2's golden tests and the gallery take it out: Element for
730// four, and for the menu idiom Contents of its toolbar, since the idiom
731// is the menu and its applied-filter chip together. The standalone
732// scheme state's component is the partial with its forms' action
733// removed, as the seeder made it, so it must carry no action.
734func TestEachRowedStateHoldsOneComponent(t *testing.T) {
735 cases := []struct {
736 name, state, attribute string
737 read func(markup, attribute string) (string, error)
738 }{
739 {"dropdown", "a-filter-menu-with-one-choice-applied", "rst-dropdown", example.Element},
740 {"dropdown", "menu-idiom", "rst-lbar", example.Contents},
741 {"locale-menu", "twelve-languages-current-highlighted", "rst-locale", example.Element},
742 {"scheme-menu", "three-choices-dark-current", "rst-scheme", example.Element},
743 {"scheme-menu", "standalone", "rst-scheme", example.Element},
744 }
745 for _, c := range cases {
746 m, err := example.Markup(fiesta.FS(), c.name, c.state)
747 if err != nil {
748 t.Fatal(err)
749 }
750 component, err := c.read(m, c.attribute)
751 if err != nil {
752 t.Errorf("%s/%s: %v", c.name, c.state, err)
753 continue
754 }
755 switch c.name + "/" + c.state {
756 case "dropdown/menu-idiom":
757 for _, part := range []string{"rst-dropdown", "rst-ftok"} {
758 if _, err := example.Element(component, part); err != nil {
759 t.Errorf("dropdown/menu-idiom: the toolbar should hold one %s: %v", part, err)
760 }
761 }
762 case "scheme-menu/standalone":
763 if strings.Contains(component, " action=") {
764 t.Error("scheme-menu/standalone: the component's forms name an action")
765 }
766 }
767 }
768}
769