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 "html"
5 "html/template"
6 "io/fs"
7 "regexp"
8 "strings"
9 "testing"
10
11 "amadan.net/rastrillo/fiesta"
12 "amadan.net/rastrillo/fiesta/example"
13 "amadan.net/rastrillo/fiesta/internal/codeview"
14)
15
16// The preview widget as the render writes it: Desktop, Mobile and HTML
17// behind three radios, one widget per state or one per grouped
18// example, the HTML tab without the wrapper and with the line naming
19// it, the row documents, the Copy blocks, the frame titles and the
20// shells' open buttons. The browser drives in view_browser_test.go and
21// its siblings take over where the engine has to answer.
22
23// examplePage is one example's page in one directory, failed loudly.
24func examplePage(t *testing.T, files map[string][]byte, theme, locale, name string) string {
25 t.Helper()
26 page := string(files[theme+"/"+locale+"/"+name+".html"])
27 if page == "" {
28 t.Fatalf("no %s/%s/%s.html in the rendered tree", theme, locale, name)
29 }
30 return page
31}
32
33// widgetOpen is a widget's opening tag, matched exactly: `<div
34// class="ds-view` alone also matches the stage and the box inside
35// every widget.
36var widgetOpen = regexp.MustCompile(`<div class="ds-view(?: ds-view--[a-z]+)*" style="`)
37
38// widgetsOf cuts a page into its preview widgets: everything from one
39// widget's opening tag up to the next one, or to the end.
40func widgetsOf(page string) []string {
41 parts := widgetOpen.Split(page, -1)
42 if len(parts) < 2 {
43 return nil
44 }
45 return parts[1:]
46}
47
48// tabClass reads a widget's tabs in order, by their modifier letter.
49var tabClass = regexp.MustCompile(`class="ds-view__tab ds-view__tab--([a-z])"`)
50
51var radioInput = regexp.MustCompile(`<input type="radio" name="([^"]*)"( checked)?>`)
52
53// panelOf is a widget's HTML panel, or "" when it has none.
54func panelOf(widget string) string {
55 return regexp.MustCompile(`(?s)<div class="ds-view__code ds-view__code--h">.*?</div>`).FindString(widget)
56}
57
58// sourceBlock finds every source block on a page: the code a reader
59// copies, highlighted.
60var sourceBlock = regexp.MustCompile(`(?s)<pre class="ds-src[^"]*"[^>]*><code>(.*?)</code></pre>`)
61
62var anyElement = regexp.MustCompile(`<[^>]*>`)
63
64// sourceTexts is every source block in a fragment as a reader sees and
65// copies it: the highlighting removed and the entities decoded.
66func sourceTexts(fragment string) []string {
67 var out []string
68 for _, m := range sourceBlock.FindAllStringSubmatch(fragment, -1) {
69 out = append(out, html.UnescapeString(anyElement.ReplaceAllString(m[1], "")))
70 }
71 return out
72}
73
74// TestEveryExampleIsFramedDesktopMobileAndHTML: on every example page
75// of day/en, one widget per state, or one per grouped example; three
76// radios in each, one name per widget and the name unique on the page,
77// none checked; the HTML panel on every widget, one block per state;
78// ds-view--page on the page-frame examples and on no other; and the
79// rules that make the tabs switch anything, in view.css.
80func TestEveryExampleIsFramedDesktopMobileAndHTML(t *testing.T) {
81 files := render(t, "")
82 s := newTestSite(t, fiesta.FS(), "")
83 theme := fiesta.ThemeNames()[0]
84 total := 0
85 var pageClass []string
86 for _, e := range s.m.Examples {
87 page := examplePage(t, files, theme, "en", e.Name)
88 widgets := widgetsOf(page)
89 want := len(e.States)
90 if grouped[e.Name] {
91 want = 1
92 }
93 if len(widgets) != want {
94 t.Errorf("%s: %d preview widgets, want %d", e.Name, len(widgets), want)
95 }
96 if strings.Contains(page, "ds-view--page") {
97 pageClass = append(pageClass, e.Name)
98 }
99 if n := strings.Count(page, "ds-view--page"); n != 0 && n != len(widgets) {
100 t.Errorf("%s: %d of %d widgets take the page class; a page's widgets are all one class", e.Name, n, len(widgets))
101 }
102 total += len(widgets)
103 groups := map[string]bool{}
104 for i, w := range widgets {
105 radios := radioInput.FindAllStringSubmatch(w, -1)
106 if len(radios) != 3 {
107 t.Errorf("%s widget %d has %d tabs, want 3: Desktop, Mobile and HTML", e.Name, i, len(radios))
108 continue
109 }
110 for _, r := range radios {
111 if r[1] != radios[0][1] {
112 t.Errorf("%s widget %d: tabs in one widget carry two names (%q, %q), so they are not one group", e.Name, i, radios[0][1], r[1])
113 }
114 if r[2] != "" {
115 t.Errorf("%s widget %d: a tab starts checked; none may, or the stylesheet cannot tell a reader's choice from the markup's default", e.Name, i)
116 }
117 }
118 if groups[radios[0][1]] {
119 t.Errorf("%s widget %d: the radio name %q is already used by another widget on this page", e.Name, i, radios[0][1])
120 }
121 groups[radios[0][1]] = true
122 var order string
123 for _, m := range tabClass.FindAllStringSubmatch(w, -1) {
124 order += m[1]
125 }
126 if order != "dmh" {
127 t.Errorf("%s widget %d: tabs %q, want Desktop, Mobile, HTML", e.Name, i, order)
128 }
129 if n := strings.Count(w, `<iframe class="ds-view__frame"`); n != 1 {
130 t.Errorf("%s widget %d frames %d documents, want 1", e.Name, i, n)
131 }
132 if !strings.Contains(w, `<div class="ds-view__stage"><div class="ds-view__box"><iframe`) {
133 t.Errorf("%s widget %d: the frame is not in a box on the stage", e.Name, i)
134 }
135 panel := panelOf(w)
136 if panel == "" {
137 t.Errorf("%s widget %d has no HTML panel", e.Name, i)
138 continue
139 }
140 blocks, wantBlocks := strings.Count(panel, `<pre class="ds-src`), 1
141 if grouped[e.Name] {
142 wantBlocks = len(e.States)
143 for _, st := range e.States {
144 if !strings.Contains(panel, `<p class="ds-state">`+template.HTMLEscapeString(st.Label)+`</p>`) {
145 t.Errorf("%s: the HTML panel does not label the %s row", e.Name, st.ID)
146 }
147 }
148 }
149 if blocks != wantBlocks {
150 t.Errorf("%s widget %d: %d source blocks in the HTML panel, want %d", e.Name, i, blocks, wantBlocks)
151 }
152 }
153 }
154 // The page class: every layout document, the two shell idioms and
155 // the modal, and nothing else. The sign-in screens are components,
156 // as in Rastrillo: a card, not a window.
157 var wantPage []string
158 for _, e := range s.m.Examples {
159 if e.Document || strings.HasPrefix(e.Name, "shell-") || e.Name == "modal" {
160 wantPage = append(wantPage, e.Name)
161 }
162 }
163 if len(wantPage) != 10 {
164 t.Errorf("%d examples should take the page class (%v), the manifest was written with 10", len(wantPage), wantPage)
165 }
166 if strings.Join(pageClass, " ") != strings.Join(wantPage, " ") {
167 t.Errorf("the page class is on %v, want %v", pageClass, wantPage)
168 }
169 if total < 100 {
170 t.Errorf("the whole gallery frames %d widgets on day/en; it has well over a hundred states to frame", total)
171 }
172 // The mechanism, in the widget's half of the stylesheet. Without
173 // these the tabs are three radios that change nothing.
174 css := string(viewCSS)
175 for _, rule := range []string{
176 `.ds-view:has(.ds-view__tab--m input:checked) .ds-view__box`,
177 `.ds-view:has(.ds-view__tab--h input:checked) .ds-view__stage { display: none; }`,
178 `.ds-view:has(.ds-view__tab--h input:checked) .ds-view__code--h { display: block; }`,
179 `.ds-view__box { --ds-k: clamp(var(--ds-kmin), tan(atan2(100cqw, var(--ds-w))), 1); }`,
180 `container-name: ds-view; container-type: inline-size;`,
181 `@container ds-view (min-width: 40.5rem) { .ds-view:not(.ds-view--page):not(:has(input:checked)) .ds-view__tab--d {`,
182 `.ds-view:not(.ds-view--page):not(:has(input:checked)) .ds-view__tab--m {`,
183 `.ds-view:not(.ds-view--page):not(:has(.ds-view__tab--d input:checked)) .ds-view__box { --ds-h: var(--ds-hm); --ds-w: var(--ds-wm); }`,
184 `@container ds-view (min-width: 54rem) { .ds-view--page:not(:has(input:checked)) .ds-view__tab--d {`,
185 `.ds-view--page:not(:has(input:checked)) .ds-view__tab--m {`,
186 `.ds-view--page:not(:has(.ds-view__tab--d input:checked)) .ds-view__box { --ds-h: var(--ds-hm); --ds-w: var(--ds-wm); }`,
187 `.ds-view { --ds-wm: min(390px, 100cqw); }`,
188 `[dir="rtl"] .ds-view__frame { transform-origin: top right; }`,
189 `.ds-view { --ds-wd: 900px; --ds-w: var(--ds-wd);`,
190 `.ds-view--page { --ds-wd: 1200px; }`,
191 `overflow: hidden; overflow-x: auto; overscroll-behavior-x: contain;`,
192 `scrollbar-width: thin;`,
193 `min-block-size: calc(var(--ds-h) * var(--ds-kmin));`,
194 `@supports not (container-type: inline-size) { .ds-view:not(:has(input:checked)) .ds-view__tab--d {`,
195 `:root[data-gallery-js] .ds-view__frame:not([data-gallery-painted]) { visibility: hidden; }`,
196 } {
197 if !strings.Contains(css, rule) {
198 t.Errorf("view.css carries no rule %q; the tabs would switch nothing", rule)
199 }
200 }
201 if strings.Contains(css, "ds-view__tab--t") {
202 t.Error("view.css still carries the Template tab's rules, which Fiesta has no tab for")
203 }
204 // And nothing scripted, anywhere in the tree: an inline handler
205 // would be a widget that stops working with scripts off.
206 for name, body := range files {
207 if !strings.HasSuffix(name, ".html") {
208 continue
209 }
210 for _, on := range []string{" onclick=", " onchange=", " oninput=", " onload=", " onsubmit="} {
211 if strings.Contains(string(body), on) {
212 t.Errorf("%s carries an inline%shandler", name, on)
213 }
214 }
215 }
216 t.Logf("%d preview widgets across %d example pages", total, len(s.m.Examples))
217}
218
219// wrapperOpenings are the first tags the four wrappers put around a
220// body. A source block opening with one is a block a reader would
221// copy twice-wrapped.
222var wrapperOpenings = []string{"<section rst-box>", "<div rst-list>", "<div rst-stats>"}
223
224// TestTheHTMLTabNeverCarriesTheWrapper: the 22 wrapped examples show
225// their body without its outer wrapper element(s) in the HTML tab,
226// while the frame document still carries it; the seeded
227// action="#" leaves with the wrapper; and the line above the block
228// names the container with its opening markup.
229func TestTheHTMLTabNeverCarriesTheWrapper(t *testing.T) {
230 files := render(t, "")
231 theme := fiesta.ThemeNames()[0]
232 wrapped, states := 0, 0
233 for _, e := range manifest(t).Examples {
234 page := examplePage(t, files, theme, "en", e.Name)
235 if e.Wrapper == "" {
236 if strings.Contains(page, `class="ds-wrap"`) {
237 t.Errorf("%s has no wrapper and its page carries a wrapper line", e.Name)
238 }
239 continue
240 }
241 wrapped++
242 open, err := wrapperMarkup(e.Wrapper)
243 if err != nil || open == "" {
244 t.Fatalf("%s: wrapperMarkup(%q) = %q, %v", e.Name, e.Wrapper, open, err)
245 }
246 line := `<p class="ds-wrap">Put this inside <code>` + template.HTMLEscapeString(open) + `</code>.</p>`
247 if n := strings.Count(page, line); n != len(e.States) {
248 t.Errorf("%s: %d widgets say %q, want one per state (%d)", e.Name, n, line, len(e.States))
249 }
250 for _, st := range e.States {
251 states++
252 tab := strings.TrimSpace(htmlTab(t, []byte(page), e.Name, st.ID))
253 for _, w := range wrapperOpenings {
254 if strings.HasPrefix(tab, w) {
255 t.Errorf("%s/%s: the HTML tab opens with the wrapper %s", e.Name, st.ID, w)
256 }
257 }
258 if strings.Contains(tab, `action="#"`) {
259 t.Errorf("%s/%s: the HTML tab carries the placeholder action=\"#\"", e.Name, st.ID)
260 }
261 // The control: the frame body still opens with it.
262 frame := string(files[theme+"/en/frames/"+e.Name+"/"+st.ID+".html"])
263 body := frame[strings.Index(frame, "<body"):]
264 body = strings.TrimSpace(body[strings.Index(body, ">")+1:])
265 if !strings.HasPrefix(body, strings.Split(open, ">")[0]) {
266 t.Errorf("%s/%s: the frame body does not open with %s:\n%.80s", e.Name, st.ID, open, body)
267 }
268 }
269 }
270 if wrapped != 22 {
271 t.Errorf("%d wrapped examples in the manifest, the spec counts 22", wrapped)
272 }
273 if states == 0 {
274 t.Fatal("no wrapped state was checked")
275 }
276}
277
278// TestUnwrapStripsTheWrapperAndRefusesABodyWithoutIt: unwrap takes the
279// outer wrapper element(s) off a body, the seeded form attributes with
280// them, keeps an inner form that is the component's own, leaves an
281// unwrapped body alone, and is an error for a body that does not open
282// with its wrapper or a wrapper the manifest does not have.
283func TestUnwrapStripsTheWrapperAndRefusesABodyWithoutIt(t *testing.T) {
284 for _, c := range []struct{ wrapper, in, want string }{
285 {"form", `<section rst-box><form rst-form method="post" action="#"><div rst-field>\n<input></div></form></section>`, `<div rst-field>\n<input></div>`},
286 {"form", "<section rst-box>\n<form rst-form>\n<p>a</p>\n</form>\n</section>\n", "\n<p>a</p>\n"},
287 {"box", `<section rst-box><form rst-form-actions method="post" action="/x"><button>Go</button></form></section>`, `<form rst-form-actions method="post" action="/x"><button>Go</button></form>`},
288 {"list", `<div rst-list><div rst-row>r</div></div>`, `<div rst-row>r</div>`},
289 {"stats", `<div rst-stats><div rst-stat>1</div></div>`, `<div rst-stat>1</div>`},
290 {"", `<span rst-status>Draft</span>`, `<span rst-status>Draft</span>`},
291 } {
292 got, err := unwrap(c.in, c.wrapper)
293 if err != nil || got != c.want {
294 t.Errorf("unwrap(%q, %q) = %q, %v; want %q", c.in, c.wrapper, got, err, c.want)
295 }
296 }
297 for _, c := range []struct{ wrapper, in, why string }{
298 {"form", `<div rst-field><input></div>`, "a body with no wrapper"},
299 {"form", `<section rst-box><div rst-field><input></div></section>`, "a box without the form"},
300 {"box", `<section><p>a</p></section>`, "a section without rst-box"},
301 {"list", `<div rst-list><div rst-row>r</div>`, "a wrapper never closed"},
302 {"card", `<div rst-card>a</div>`, "a wrapper the manifest does not have"},
303 } {
304 if got, err := unwrap(c.in, c.wrapper); err == nil {
305 t.Errorf("unwrap(%q, %q) = %q with no error; %s should fail", c.in, c.wrapper, got, c.why)
306 }
307 }
308 for w, want := range map[string]string{"list": "<div rst-list>", "form": "<section rst-box><form rst-form>", "box": "<section rst-box>", "stats": "<div rst-stats>", "": ""} {
309 if got, err := wrapperMarkup(w); err != nil || got != want {
310 t.Errorf("wrapperMarkup(%q) = %q, %v; want %q", w, got, err, want)
311 }
312 }
313 if _, err := wrapperMarkup("card"); err == nil {
314 t.Error("wrapperMarkup accepted a wrapper the manifest does not have")
315 }
316}
317
318// TestABodyWithoutItsWrapperFailsTheRender: a seeded document whose
319// body has lost its wrapper fails the render naming the state, rather
320// than being shown with a wrapper line over markup that has none.
321func TestABodyWithoutItsWrapperFailsTheRender(t *testing.T) {
322 const file = "examples/field-text/bare.html"
323 raw, err := fs.ReadFile(fiesta.FS(), file)
324 if err != nil {
325 t.Fatal(err)
326 }
327 stripped := strings.Replace(string(raw), "<section rst-box>", "", 1)
328 stripped = strings.Replace(stripped, "</section>", "", 1)
329 if stripped == string(raw) {
330 t.Fatal("the control did not change the document")
331 }
332 _, err = Render(overlay{fiesta.FS(), map[string][]byte{file: []byte(stripped)}}, Options{})
333 if err == nil {
334 t.Fatal("a body without its wrapper rendered")
335 }
336 if !strings.Contains(err.Error(), "field-text/bare") {
337 t.Errorf("the error does not name the state: %v", err)
338 }
339}
340
341// TestAGroupedPartialNeedsNoWrapper: the grouped examples are the three
342// Rastrillo groups, each in the manifest and none wrapped: the grouped
343// panels carry no wrapper line, so a wrapped one would be copied
344// without its container.
345func TestAGroupedPartialNeedsNoWrapper(t *testing.T) {
346 if len(grouped) == 0 {
347 t.Fatal("no example is grouped, so this checked nothing")
348 }
349 m := manifest(t)
350 for _, name := range []string{"status-pill", "badge", "meter"} {
351 if !grouped[name] {
352 t.Errorf("%s is not grouped", name)
353 }
354 }
355 for name := range grouped {
356 e, ok := m.Entry(name)
357 if !ok {
358 t.Errorf("%s is grouped and not in the manifest", name)
359 continue
360 }
361 if w, err := wrapperMarkup(e.Wrapper); err != nil || w != "" {
362 t.Errorf("%s is grouped and wrapped in %s; the grouped panels would drop the line saying so", name, w)
363 }
364 if len(e.States) < 2 {
365 t.Errorf("%s is grouped with %d state; a group is a row of several", name, len(e.States))
366 }
367 }
368}
369
370// TestAGroupedNoteNamesItsStateApart: under a grouped widget, every
371// state has a block of its own naming the state in an element of its
372// own, with the note, where there is one, as a paragraph after it and
373// nothing written between the two: a joiner would be copy no review
374// saw, and an ASCII colon is wrong punctuation in ja, zh-Hans and yue.
375func TestAGroupedNoteNamesItsStateApart(t *testing.T) {
376 files := render(t, "")
377 p := loadedProse(t)
378 s := newTestSite(t, fiesta.FS(), "")
379 m := s.m
380 theme := fiesta.ThemeNames()[0]
381 notes, blocks := 0, 0
382 for _, locale := range Locales {
383 for name := range grouped {
384 e, ok := m.Entry(name)
385 if !ok {
386 t.Fatalf("no %s example", name)
387 }
388 page := examplePage(t, files, theme, locale, name)
389 for _, st := range e.States {
390 blocks++
391 // The label and the note in the page's language, read
392 // through the lookups the page renders them with.
393 label := s.label(locale, e, st)
394 open := `<div class="ds-group-note" id="` + st.ID + `" data-ds-anchor data-gallery-state="` + name + "/" + st.ID + `"><b class="ds-note__state">` + template.HTMLEscapeString(label) + "</b>\n"
395 if !strings.Contains(page, open) {
396 t.Errorf("%s/%s %s: no block names the %s state apart", theme, locale, name, st.ID)
397 continue
398 }
399 note := s.proseIn(locale, "examples."+name+".states."+st.ID, p.Examples[name].States[st.ID])
400 if note == "" {
401 if strings.Contains(page, open+`<p class="ds-note">`) {
402 t.Errorf("%s/%s %s/%s has a note and the prose has none", theme, locale, name, st.ID)
403 }
404 continue
405 }
406 notes++
407 if !strings.Contains(page, open+`<p class="ds-note">`+template.HTMLEscapeString(note)+"</p>\n</div>") {
408 t.Errorf("%s/%s %s/%s: the note does not follow its state on a line of its own", theme, locale, name, st.ID)
409 }
410 if joined := template.HTMLEscapeString(label + ": " + note); strings.Contains(page, joined) {
411 t.Errorf("%s/%s %s/%s: the note is joined to its state as %q", theme, locale, name, st.ID, joined)
412 }
413 }
414 }
415 }
416 if notes == 0 || blocks == 0 {
417 t.Fatalf("%d grouped notes and %d state blocks checked; the walk read nothing", notes, blocks)
418 }
419}
420
421// TestEveryPreviewIsAFileOneFrameNames: every frame document in the
422// tree is the src of exactly one frame on one page, except the
423// per-state documents of a grouped example, which no frame names (the
424// accessibility gate scans them one state at a time) and which are
425// counted here. The root index is day/en/index.html again and is not
426// walked twice.
427func TestEveryPreviewIsAFileOneFrameNames(t *testing.T) {
428 files := render(t, "")
429 named := map[string]int{}
430 frames := 0
431 for name, body := range files {
432 if !strings.HasSuffix(name, ".html") || isFrame(name) || name == "index.html" {
433 continue
434 }
435 attrsOf(t, body, func(tag string, attrs map[string]string) {
436 if tag == "iframe" && attrs["class"] == "ds-view__frame" {
437 frames++
438 named[strings.TrimPrefix(attrs["src"], "/")]++
439 }
440 })
441 }
442 if len(grouped) == 0 {
443 t.Fatal("no example is grouped, so the exception below is never exercised")
444 }
445 previews, unframed := 0, 0
446 for name := range files {
447 if !isFrame(name) {
448 continue
449 }
450 previews++
451 parts := strings.Split(name, "/")
452 example, state := parts[len(parts)-2], strings.TrimSuffix(parts[len(parts)-1], ".html")
453 want := 1
454 if grouped[example] && state != "row" {
455 want, unframed = 0, unframed+1
456 }
457 if n := named[name]; n != want {
458 t.Errorf("%s is the src of %d frames, want %d", name, n, want)
459 }
460 }
461 if frames == 0 || previews == 0 || unframed == 0 {
462 t.Fatalf("%d frames, %d frame documents, %d of them a grouped state's; this gate is checking nothing", frames, previews, unframed)
463 }
464 t.Logf("%d frames name %d documents; %d grouped state documents are scanned and not framed", frames, previews-unframed, unframed)
465}
466
467// TestTheTextOnScreenIsTheTextCopied: every HTML panel's blocks, with
468// the highlighting removed, are exactly the source they stand for, in
469// order: the formatted body without its wrapper for a component, the
470// whole formatted document for a layout, and one block per state for
471// a grouped example. In English and in Arabic, where the source is the
472// sample in the page's language: the body through localMarkup, the
473// layout's document with its body and title in Arabic and its <html>
474// saying so, and the money sample the money table's Arabic markup.
475func TestTheTextOnScreenIsTheTextCopied(t *testing.T) {
476 files := render(t, "")
477 m := manifest(t)
478 s := newTestSite(t, fiesta.FS(), "")
479 theme := fiesta.ThemeNames()[0]
480 checked := 0
481 for _, locale := range []string{"en", "ar"} {
482 for _, e := range m.Examples {
483 var want []string
484 for _, st := range e.States {
485 var src string
486 if e.Document {
487 doc, err := example.Document(fiesta.FS(), e.Name, st.ID)
488 if err != nil {
489 t.Fatal(err)
490 }
491 if doc, err = s.localDocument(locale, e, st, doc); err != nil {
492 t.Fatal(err)
493 }
494 if src, err = localHTML(doc, locale); err != nil {
495 t.Fatal(err)
496 }
497 } else {
498 markup, err := example.Markup(fiesta.FS(), e.Name, st.ID)
499 if err != nil {
500 t.Fatal(err)
501 }
502 markup = localMarkup(locale, e.Name, markup)
503 if e.Name == moneyExample && st.ID == e.Default {
504 markup, _ = s.moneyBody(locale)
505 }
506 if src, err = unwrap(markup, e.Wrapper); err != nil {
507 t.Fatalf("%s/%s: %v", e.Name, st.ID, err)
508 }
509 }
510 want = append(want, codeview.Format(src))
511 }
512 // The control, independent of unwrap: a wrapped example's
513 // expected block does not open with its wrapper.
514 for i, w := range want {
515 if e.Wrapper == "" {
516 continue
517 }
518 for _, open := range wrapperOpenings {
519 if strings.HasPrefix(strings.TrimSpace(w), open) {
520 t.Errorf("%s/%s block %d: the expected source still opens with %s; unwrap left the wrapper in", locale, e.Name, i, open)
521 }
522 }
523 }
524 got := sourceTexts(examplePage(t, files, theme, locale, e.Name))
525 if len(got) != len(want) {
526 t.Errorf("%s/%s: %d source blocks on the page, want %d", locale, e.Name, len(got), len(want))
527 continue
528 }
529 for i := range want {
530 checked++
531 if got[i] != want[i] {
532 t.Errorf("%s/%s block %d: on screen\n%s\nwant\n%s", locale, e.Name, i, got[i], want[i])
533 }
534 }
535 }
536 }
537 if checked == 0 {
538 t.Fatal("no block was compared")
539 }
540}
541
542// TestEveryCodeBlockOffersTheCopyButton: no block in the tree refuses
543// the Copy button (no Fiesta example is an illustration), and on day/en
544// every block is a copyable block inside an HTML panel, one per state.
545func TestEveryCodeBlockOffersTheCopyButton(t *testing.T) {
546 files := render(t, "")
547 m := manifest(t)
548 theme := fiesta.ThemeNames()[0]
549 for name, body := range files {
550 if strings.HasSuffix(name, ".html") && strings.Contains(string(body), "data-ds-nocopy") {
551 t.Errorf("%s carries a block that refuses the Copy button", name)
552 }
553 }
554 const block = `<pre class="ds-src rst-mono" tabindex="0"><code>`
555 blocks, inPanels, states := 0, 0, 0
556 for _, e := range m.Examples {
557 page := examplePage(t, files, theme, "en", e.Name)
558 states += len(e.States)
559 blocks += strings.Count(page, block)
560 for _, w := range widgetsOf(page) {
561 inPanels += strings.Count(panelOf(w), block)
562 }
563 }
564 if blocks == 0 || blocks != states || inPanels != blocks {
565 t.Errorf("day/en has %d copyable blocks, %d of them in HTML panels, over %d states; want one per state, every one in a panel", blocks, inPanels, states)
566 }
567}
568
569// TestEveryFrameTitleIsUniqueOnThePage: a frame's title is an
570// accessible name, unique on its page, and never the empty label's
571// bare "name: ": a state with no label takes the example's name.
572func TestEveryFrameTitleIsUniqueOnThePage(t *testing.T) {
573 files := render(t, "")
574 titleAttr := regexp.MustCompile(`<iframe class="ds-view__frame"[^>]*\stitle="([^"]*)"`)
575 frames := 0
576 for name, body := range files {
577 if !strings.HasSuffix(name, ".html") || isSampleDocument(name) {
578 continue
579 }
580 seen := map[string]int{}
581 for _, m := range titleAttr.FindAllStringSubmatch(string(body), -1) {
582 frames++
583 title := html.UnescapeString(m[1])
584 seen[title]++
585 if title == "" || strings.HasSuffix(title, ": ") || strings.HasSuffix(title, ":") {
586 t.Errorf("%s has a frame titled %q", name, title)
587 }
588 }
589 for title, n := range seen {
590 if n > 1 {
591 t.Errorf("%s has %d frames titled %q", name, n, title)
592 }
593 }
594 }
595 if frames == 0 {
596 t.Fatal("no frame title was read")
597 }
598}
599
600// TestEveryShellHasABlurb: the seven layout pages have a blurb, and the
601// five shells (the -page pair members excluded) end with the button
602// that opens the shell's own frame document in a new tab.
603func TestEveryShellHasABlurb(t *testing.T) {
604 files := render(t, "")
605 p := loadedProse(t)
606 s := newTestSite(t, fiesta.FS(), "")
607 theme := fiesta.ThemeNames()[0]
608 layouts := 0
609 var shells []string
610 for _, e := range s.m.Examples {
611 page := examplePage(t, files, theme, "en", e.Name)
612 if !e.Document {
613 if strings.Contains(page, say("gallery.shell.open", "shell", "")) {
614 t.Errorf("%s is not a layout and carries an open-shell button", e.Name)
615 }
616 continue
617 }
618 layouts++
619 if p.Examples[e.Name].Blurb == "" {
620 t.Errorf("the %s layout has no blurb", e.Name)
621 }
622 shell := strings.TrimPrefix(e.Name, "layout-")
623 button := `<p><a rst-btn data-gallery-nav href="` + s.href(theme, "en", "frames/"+e.Name+"/"+e.Default+".html") + `" target="_blank" rel="noopener">` +
624 template.HTMLEscapeString(say("gallery.shell.open", "shell", shell)) + `<span class="rst-sr-only"> (` + say("gallery.newtab") + `)</span></a></p>`
625 has := strings.Contains(page, button)
626 if strings.HasSuffix(e.Name, "-page") {
627 if has || strings.Contains(page, `<p><a rst-btn data-gallery-nav`) {
628 t.Errorf("%s is the pair's page and carries an open-shell button", e.Name)
629 }
630 continue
631 }
632 shells = append(shells, shell)
633 if !has {
634 t.Errorf("%s does not end with %s", e.Name, button)
635 }
636 if i, j := strings.Index(page, button), strings.Index(page, `<nav class="ds-updown"`); i >= 0 && j >= 0 && i > j {
637 t.Errorf("%s: the open button is after the previous/next pair, not at the end of the body", e.Name)
638 }
639 }
640 if layouts != 7 {
641 t.Errorf("%d layout examples, want 7", layouts)
642 }
643 if want := "column topbar sidebar console stage"; strings.Join(shells, " ") != want {
644 t.Errorf("the shells with an open button are %v, want %s", shells, want)
645 }
646}
647