| 1 | package gallery |
| 2 | |
| 3 | import ( |
| 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. |
| 24 | func 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. |
| 36 | var 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. |
| 40 | func 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. |
| 49 | var tabClass = regexp.MustCompile(`class="ds-view__tab ds-view__tab--([a-z])"`) |
| 50 | |
| 51 | var radioInput = regexp.MustCompile(`<input type="radio" name="([^"]*)"( checked)?>`) |
| 52 | |
| 53 | // panelOf is a widget's HTML panel, or "" when it has none. |
| 54 | func 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. |
| 60 | var sourceBlock = regexp.MustCompile(`(?s)<pre class="ds-src[^"]*"[^>]*><code>(.*?)</code></pre>`) |
| 61 | |
| 62 | var 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. |
| 66 | func 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. |
| 80 | func 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. |
| 222 | var 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. |
| 229 | func 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. |
| 283 | func 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. |
| 321 | func 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. |
| 345 | func 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. |
| 375 | func 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. |
| 427 | func 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. |
| 475 | func 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. |
| 545 | func 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. |
| 572 | func 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. |
| 603 | func 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 | |