| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "math" |
| 10 | "net/http" |
| 11 | "slices" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/chromedp/chromedp" |
| 17 | |
| 18 | "amadan.net/rastrillo/fiesta" |
| 19 | "amadan.net/rastrillo/fiesta/example" |
| 20 | "amadan.net/rastrillo/fiesta/internal/axe" |
| 21 | "amadan.net/rastrillo/fiesta/internal/codeview" |
| 22 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 23 | ) |
| 24 | |
| 25 | // The code blocks in a real engine: the Copy button, the soft wrap on |
| 26 | // a phone, axe over the open panels, left-to-right code on an Arabic |
| 27 | // page, and the HTML tab laying out exactly like the preview. |
| 28 | |
| 29 | // clipboardStub replaces the clipboard with one the leg controls: |
| 30 | // every write is recorded, and window.__clipMode decides whether it |
| 31 | // resolves or rejects. |
| 32 | const clipboardStub = `window.__clipCalls = []; window.__clipMode = "resolve"; |
| 33 | Object.defineProperty(Navigator.prototype, "clipboard", {configurable: true, get() { return { |
| 34 | writeText: s => { window.__clipCalls.push(s); return window.__clipMode === "resolve" ? Promise.resolve() : Promise.reject(new DOMException("denied", "NotAllowedError")); } |
| 35 | }; }});` |
| 36 | |
| 37 | // noClipboard is a plain-HTTP origin's clipboard: not there at all. |
| 38 | const noClipboard = `Object.defineProperty(Navigator.prototype, "clipboard", {configurable: true, get() { return undefined; }});` |
| 39 | |
| 40 | // requireCopyButtons fails a leg by name when the page has no Copy |
| 41 | // button once its scripts have run. |
| 42 | func requireCopyButtons(t *testing.T, ctx context.Context, where string) { |
| 43 | t.Helper() |
| 44 | until(t, ctx, where+", scripts run", `document.readyState !== "loading"`) |
| 45 | var n int |
| 46 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".ds-copy").length`, &n)) |
| 47 | if n == 0 { |
| 48 | t.Fatalf("%s: no Copy button on the page", where) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // TestTheCopyButtonCopiesAnnouncesAndFailsSafely: on the field page, |
| 53 | // every copyable block has a button named uniquely from text already |
| 54 | // on screen; a resolved write puts exactly the block's text on the |
| 55 | // clipboard, says Copied in the live region and on the button, and |
| 56 | // reverts; a refused write announces the failure and selects the |
| 57 | // block; a grouped page's rows are named apart; and with no clipboard |
| 58 | // API no button is drawn. |
| 59 | func TestTheCopyButtonCopiesAnnouncesAndFailsSafely(t *testing.T) { |
| 60 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 61 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 62 | defer cancel() |
| 63 | field := pageURL(rig, "en", "field.html") |
| 64 | mustRun(t, ctx, addInit(clipboardStub)) |
| 65 | openPage(t, ctx, "field with a stubbed clipboard", field, 1280, 900) |
| 66 | requireCopyButtons(t, ctx, "field") |
| 67 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelector(".ds-view__tab--h input").click(); true`, nil)) |
| 68 | assertNames(t, ctx, "field") |
| 69 | |
| 70 | const first = `document.querySelector(".ds-view__code--h .ds-copy")` |
| 71 | mustRun(t, ctx, chromedp.Evaluate(first+`.click(); true`, nil)) |
| 72 | until(t, ctx, "a resolved copy", `document.querySelector("[data-ds-copy-status]").textContent === document.querySelector("[data-ds-copy-status]").dataset.copied`) |
| 73 | var got struct { |
| 74 | Calls []string |
| 75 | Text string |
| 76 | Label string |
| 77 | } |
| 78 | at(t, ctx, `JSON.stringify({Calls: window.__clipCalls, |
| 79 | Text: `+first+`.nextElementSibling.querySelector("code").textContent, |
| 80 | Label: `+first+`.firstChild.nodeValue})`, &got) |
| 81 | if len(got.Calls) != 1 || got.Calls[0] != got.Text { |
| 82 | t.Errorf("the clipboard got %q, want exactly the block's text %q", got.Calls, got.Text) |
| 83 | } |
| 84 | if !strings.HasPrefix(got.Text, "<div rst-field>") { |
| 85 | t.Errorf("the first block on the field page copied %q; it should be the field without its wrapper", got.Text) |
| 86 | } |
| 87 | if got.Label != say("gallery.copied") { |
| 88 | t.Errorf("the button reads %q after a copy, want %q", got.Label, say("gallery.copied")) |
| 89 | } |
| 90 | until(t, ctx, "the label returns", first+`.firstChild.nodeValue === document.querySelector("[data-ds-copy-status]").dataset.copy`) |
| 91 | |
| 92 | mustRun(t, ctx, chromedp.Evaluate(`window.__clipMode = "reject"; window.__errors = 0; |
| 93 | addEventListener("error", () => window.__errors++); addEventListener("unhandledrejection", () => window.__errors++); |
| 94 | `+first+`.click(); true`, nil)) |
| 95 | until(t, ctx, "a refused copy", `document.querySelector("[data-ds-copy-status]").textContent === document.querySelector("[data-ds-copy-status]").dataset.failed`) |
| 96 | until(t, ctx, "the block selected", `getSelection().toString() === `+first+`.nextElementSibling.querySelector("code").textContent && window.__errors === 0`) |
| 97 | |
| 98 | // A grouped page: four blocks under one heading, named apart by |
| 99 | // their row labels. |
| 100 | openPage(t, ctx, "status-pill", pageURL(rig, "en", "status-pill.html"), 1280, 900) |
| 101 | requireCopyButtons(t, ctx, "status-pill") |
| 102 | assertNames(t, ctx, "status-pill") |
| 103 | |
| 104 | // No clipboard API, no button: a fresh tab with the init script |
| 105 | // standing in for a missing clipboard, scripts on, since the page's |
| 106 | // own script is what decides. |
| 107 | tab2, cancel2 := chromedp.NewContext(rig.Context()) |
| 108 | defer cancel2() |
| 109 | var buttons, blocks int |
| 110 | mustRun(t, tab2, addInit(noClipboard), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(field), chromedp.WaitReady(`[data-ds-copy-status]`, chromedp.ByQuery)) |
| 111 | until(t, tab2, "field with no clipboard, scripts run", `document.readyState === "complete"`) |
| 112 | mustRun(t, tab2, |
| 113 | chromedp.Evaluate(`document.querySelectorAll(".ds-copy").length`, &buttons), |
| 114 | chromedp.Evaluate(`document.querySelectorAll("pre.ds-src").length`, &blocks)) |
| 115 | if buttons != 0 || blocks == 0 { |
| 116 | t.Errorf("with no clipboard API: %d buttons over %d blocks, want none over some", buttons, blocks) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // assertNames holds one page's copy buttons to the two promises: one |
| 121 | // per copyable block, and names unique, each starting with the visible |
| 122 | // label so a voice user saying "Copy" reaches them. |
| 123 | func assertNames(t *testing.T, ctx context.Context, where string) { |
| 124 | t.Helper() |
| 125 | var got struct { |
| 126 | Names []string |
| 127 | Blocks int |
| 128 | Label string |
| 129 | } |
| 130 | at(t, ctx, `JSON.stringify({ |
| 131 | Names: [...document.querySelectorAll(".ds-copy")].map(b => b.textContent), |
| 132 | Blocks: document.querySelectorAll("pre.ds-src:not([data-ds-nocopy])").length, |
| 133 | Label: document.querySelector("[data-ds-copy-status]").dataset.copy})`, &got) |
| 134 | if len(got.Names) == 0 || len(got.Names) != got.Blocks { |
| 135 | t.Fatalf("%s: %d copy buttons for %d copyable blocks", where, len(got.Names), got.Blocks) |
| 136 | } |
| 137 | seen := map[string]bool{} |
| 138 | for _, n := range got.Names { |
| 139 | if seen[n] { |
| 140 | t.Errorf("%s: two copy buttons are both named %q", where, n) |
| 141 | } |
| 142 | seen[n] = true |
| 143 | if !strings.HasPrefix(n, got.Label+" ") { |
| 144 | t.Errorf("%s: %q does not start with the visible label %q", where, n, got.Label) |
| 145 | } |
| 146 | } |
| 147 | t.Logf("%s: %d copy buttons, for instance %q", where, len(got.Names), got.Names[len(got.Names)-1]) |
| 148 | } |
| 149 | |
| 150 | // TestNoCodeBlockScrollsSidewaysOnAPhone: with every widget's HTML tab |
| 151 | // chosen, no source block on the heaviest pages scrolls sideways at |
| 152 | // 320px or 390px, in English and in Arabic: the soft wrap holds a |
| 153 | // select's option list, a list bar and the stage layout's whole |
| 154 | // document with its one-line SVG. Each pass first requires that what |
| 155 | // it chose is on screen. |
| 156 | func TestNoCodeBlockScrollsSidewaysOnAPhone(t *testing.T) { |
| 157 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 158 | ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| 159 | defer cancel() |
| 160 | for _, locale := range []string{"en", "ar"} { |
| 161 | for _, file := range []string{"field-select.html", "list-bar.html", "layout-stage.html", "status-pill.html"} { |
| 162 | for _, w := range []int64{320, 390} { |
| 163 | where := fmt.Sprintf("day/%s %s at %dpx, every HTML tab chosen", locale, file, w) |
| 164 | openPage(t, ctx, where, pageURL(rig, locale, file), w, 844) |
| 165 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".ds-view__tab--h input").forEach(i => i.checked = true); true`, nil)) |
| 166 | var got struct { |
| 167 | Views, Blocks int |
| 168 | Blind, Wide []string |
| 169 | Page float64 |
| 170 | } |
| 171 | at(t, ctx, `(() => { |
| 172 | const shown = p => p.checkVisibility() && p.getBoundingClientRect().height > 0; |
| 173 | const views = [...document.querySelectorAll(".ds-view")]; |
| 174 | const blind = []; |
| 175 | let blocks = 0; |
| 176 | views.forEach(v => { |
| 177 | const n = [...v.querySelectorAll(".ds-view__code--h .ds-src")].filter(shown).length; |
| 178 | if (n === 0) blind.push(v.querySelector("iframe").title); |
| 179 | blocks += n; |
| 180 | }); |
| 181 | const wide = [...document.querySelectorAll(".ds-src")].filter(p => shown(p) && p.scrollWidth > p.clientWidth).map(p => p.textContent.slice(0, 50)); |
| 182 | return JSON.stringify({Views: views.length, Blocks: blocks, Blind: blind, Wide: wide, Page: document.documentElement.scrollWidth - document.documentElement.clientWidth}); |
| 183 | })()`, &got) |
| 184 | if got.Views == 0 || len(got.Blind) > 0 { |
| 185 | t.Fatalf("%s: %d widgets, and %d show no source block: %q", where, got.Views, len(got.Blind), got.Blind) |
| 186 | } |
| 187 | if len(got.Wide) > 0 { |
| 188 | t.Errorf("%s: %d of %d source blocks scroll sideways: %q", where, len(got.Wide), got.Blocks, got.Wide) |
| 189 | } |
| 190 | if got.Page > 1 { |
| 191 | t.Errorf("%s: the page scrolls %.0fpx sideways with the code open", where, got.Page) |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | // TestA11yScansTheCodePanels: axe with the HTML panels open, which the |
| 199 | // page scans never see because the panels are display: none until a |
| 200 | // tab is chosen. Each leg first asserts which highlight elements are |
| 201 | // on the page, so the coverage cannot quietly shrink. |
| 202 | func TestA11yScansTheCodePanels(t *testing.T) { |
| 203 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 204 | ctx, cancel := context.WithTimeout(rig.Context(), 600*time.Second) |
| 205 | defer cancel() |
| 206 | total := 0 |
| 207 | for _, file := range []string{"field.html", "badge.html"} { |
| 208 | for _, theme := range fiesta.ThemeNames() { |
| 209 | for _, scheme := range []string{"light", "dark"} { |
| 210 | where := fmt.Sprintf("%s/en %s, every HTML tab chosen (%s)", theme, file, scheme) |
| 211 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(theme+"/en/"+file)), chromedp.WaitReady("body")) |
| 212 | requireWidget(t, ctx, where) |
| 213 | var shown []string |
| 214 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".ds-view__tab--h input").forEach(i => i.checked = true); |
| 215 | ["ds-t", "ds-a", "ds-v", "ds-x", "ds-s"].filter(n => [...document.querySelectorAll(n)].some(e => e.getBoundingClientRect().width > 0))`, &shown)) |
| 216 | for _, w := range []string{"ds-t", "ds-a", "ds-v"} { |
| 217 | if !slices.Contains(shown, w) { |
| 218 | t.Fatalf("%s: no visible %s; this scan would not be checking that colour", where, w) |
| 219 | } |
| 220 | } |
| 221 | var bg string |
| 222 | mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) |
| 223 | res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| 224 | if err != nil { |
| 225 | t.Fatalf("%s: %v", where, err) |
| 226 | } |
| 227 | total += report(t, where, res.Violations) |
| 228 | t.Logf("%s on %s: needs review, axe could not decide: %v", where, bg, res.Incomplete) |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | if total == 0 { |
| 233 | t.Log("clean: the HTML panels on the field and badge pages in every theme and scheme") |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | // TestCodeIsLeftToRightOnAnArabicPage: a <pre> inherits the page's |
| 238 | // dir="rtl", and in a right-to-left run the bidi algorithm mirrors |
| 239 | // brackets and moves punctuation, so what a reader sees is not what |
| 240 | // Copy puts on the clipboard. Computed style, every block and every |
| 241 | // wrapper-line name, on four Arabic pages; the counts are the control. |
| 242 | func TestCodeIsLeftToRightOnAnArabicPage(t *testing.T) { |
| 243 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 244 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 245 | defer cancel() |
| 246 | var blocks, wrapped int |
| 247 | for _, file := range []string{"field.html", "status-pill.html", "layout-column.html", "format-money.html"} { |
| 248 | where := "day/ar " + file |
| 249 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(pageURL(rig, "ar", file)), chromedp.WaitReady("body")) |
| 250 | var got struct { |
| 251 | Page string |
| 252 | Src, Wrap int |
| 253 | Bad []string |
| 254 | } |
| 255 | at(t, ctx, `(() => { |
| 256 | if (document.documentElement.dir !== "rtl") return JSON.stringify({Page: document.documentElement.dir}); |
| 257 | const bad = []; |
| 258 | const read = sel => { const es = [...document.querySelectorAll(sel)]; es.forEach(e => { |
| 259 | const s = getComputedStyle(e); |
| 260 | if (s.direction !== "ltr" || !["start", "left"].includes(s.textAlign)) bad.push(sel + " " + s.direction + "/" + s.textAlign + ": " + e.textContent.slice(0, 40)); |
| 261 | }); return es.length; }; |
| 262 | return JSON.stringify({Page: "rtl", Src: read(".ds-src"), Wrap: read(".ds-wrap code"), Bad: bad.slice(0, 6)}); |
| 263 | })()`, &got) |
| 264 | if got.Page != "rtl" { |
| 265 | t.Fatalf("%s: the page is dir=%q, so this leg reads nothing a right-to-left page does to code", where, got.Page) |
| 266 | } |
| 267 | blocks += got.Src |
| 268 | wrapped += got.Wrap |
| 269 | if len(got.Bad) > 0 { |
| 270 | t.Errorf("%s: code laid out right to left: %q", where, got.Bad) |
| 271 | } |
| 272 | } |
| 273 | if blocks == 0 || wrapped == 0 { |
| 274 | t.Fatalf("read %d source blocks and %d wrapper-line names over the ar pages; want both above zero", blocks, wrapped) |
| 275 | } |
| 276 | t.Logf("%d source blocks and %d wrapper-line names, all left to right", blocks, wrapped) |
| 277 | } |
| 278 | |
| 279 | // ── The HTML tab lays out like the preview ─────────────────────────── |
| 280 | |
| 281 | // layoutProbe reads what a reader sees: every element's boxes, every |
| 282 | // run of text's boxes, in document order, and the page's visible text, |
| 283 | // with every animation stopped at its start. |
| 284 | const layoutProbe = `(() => { |
| 285 | for (const a of document.getAnimations()) { a.pause(); a.currentTime = 0; } |
| 286 | const round = r => [r.x, r.y, r.width, r.height]; |
| 287 | const boxes = []; |
| 288 | for (const el of document.body.querySelectorAll("*")) { |
| 289 | boxes.push(el.tagName + " " + JSON.stringify([...el.getClientRects()].map(round))); |
| 290 | } |
| 291 | const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); |
| 292 | for (let n = walk.nextNode(); n; n = walk.nextNode()) { |
| 293 | if (!n.data.trim()) continue; |
| 294 | const range = document.createRange(); |
| 295 | range.selectNodeContents(n); |
| 296 | boxes.push("#text " + JSON.stringify(n.data.trim()) + " " + JSON.stringify([...range.getClientRects()].map(round))); |
| 297 | } |
| 298 | return JSON.stringify({boxes, text: document.body.innerText}); |
| 299 | })()` |
| 300 | |
| 301 | type layoutReading struct { |
| 302 | Boxes []string |
| 303 | Text string |
| 304 | } |
| 305 | |
| 306 | // disclosureState puts a document's disclosures into state k and |
| 307 | // resolves once the page has drawn it: -1 closes every <details>, -2 |
| 308 | // opens every one, and k >= 0 opens the k-th one, the disclosures it |
| 309 | // sits in, and whatever else it takes for it to be seen, with every |
| 310 | // other closed. |
| 311 | const disclosureState = `k => new Promise(done => { |
| 312 | const all = [...document.querySelectorAll("details")]; |
| 313 | const chain = d => { const c = []; for (; d; d = d.parentElement && d.parentElement.closest("details")) c.unshift(d); return c; }; |
| 314 | const shown = d => (d.querySelector(":scope > summary") || d).getClientRects().length > 0; |
| 315 | for (const d of all) d.open = false; |
| 316 | if (k === -2) for (const d of all) d.open = true; |
| 317 | if (k >= 0) { |
| 318 | const target = all[k], open = chain(target); |
| 319 | for (const d of open) d.open = true; |
| 320 | const names = new Set(open.map(d => d.name).filter(Boolean)); |
| 321 | for (const d of all) { |
| 322 | if (shown(target)) break; |
| 323 | const more = chain(d).filter(x => !open.includes(x)); |
| 324 | if (!more.length || target.contains(d) || more.some(x => x.name && names.has(x.name))) continue; |
| 325 | for (const x of more) x.open = true; |
| 326 | if (shown(target)) { |
| 327 | open.push(...more); |
| 328 | for (const x of more) if (x.name) names.add(x.name); |
| 329 | } else { |
| 330 | for (const x of more) x.open = false; |
| 331 | } |
| 332 | } |
| 333 | } |
| 334 | requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 0))); |
| 335 | })` |
| 336 | |
| 337 | // TestTheHTMLTabRendersExactlyLikeThePreview: codeview.Format puts a |
| 338 | // child on a line of its own only where whitespace cannot change what |
| 339 | // renders. Every component state whose formatted markup differs from |
| 340 | // the seeded body is rendered twice, as the frame holds it and as the |
| 341 | // HTML tab lays it out, each inside the body's own wrapper, and every |
| 342 | // element and run of text has to land in the same place within half a |
| 343 | // pixel, at a desktop frame's width and a phone's, as loaded and in |
| 344 | // every disclosure state. The Copy button copies the formatted text, |
| 345 | // so this is what makes pasting it safe. It runs in English and again |
| 346 | // in Japanese, on the samples as day/ja shows and copies them: the |
| 347 | // words change, and with them where a line may break. |
| 348 | // |
| 349 | // It is one test per language and width, so the suite's slices can run |
| 350 | // them on shards of their own: the En900 test and the three after it. |
| 351 | // Each builds every state's two copies in both languages, so the count |
| 352 | // of states the formatter changes is checked whole as before, then |
| 353 | // renders its own language at its own width; and each runs both |
| 354 | // controls first, which the one test ran once. |
| 355 | func TestTheHTMLTabRendersExactlyLikeThePreviewEn900(t *testing.T) { |
| 356 | htmlTabRendersLikeThePreview(t, "en", 900) |
| 357 | } |
| 358 | |
| 359 | func TestTheHTMLTabRendersExactlyLikeThePreviewEn390(t *testing.T) { |
| 360 | htmlTabRendersLikeThePreview(t, "en", 390) |
| 361 | } |
| 362 | |
| 363 | func TestTheHTMLTabRendersExactlyLikeThePreviewJa900(t *testing.T) { |
| 364 | htmlTabRendersLikeThePreview(t, "ja", 900) |
| 365 | } |
| 366 | |
| 367 | func TestTheHTMLTabRendersExactlyLikeThePreviewJa390(t *testing.T) { |
| 368 | htmlTabRendersLikeThePreview(t, "ja", 390) |
| 369 | } |
| 370 | |
| 371 | // htmlTabRendersLikeThePreview is TestTheHTMLTabRendersExactlyLikeThePreview |
| 372 | // in one language at one width. |
| 373 | func htmlTabRendersLikeThePreview(t *testing.T, only string, onlyWidth int64) { |
| 374 | s := newTestSite(t, fiesta.FS(), mountPath) |
| 375 | theme := fiesta.ThemeNames()[0] |
| 376 | docs := map[string]string{} |
| 377 | var serveIn func(locale, name string, e example.Entry, body string) string |
| 378 | type job struct{ locale, name, raw, fmt string } |
| 379 | var jobs []job |
| 380 | serve := func(name string, e example.Entry, body string) string { |
| 381 | return serveIn("en", name, e, body) |
| 382 | } |
| 383 | serveIn = func(locale, name string, e example.Entry, body string) string { |
| 384 | t.Helper() |
| 385 | doc, err := s.frame(exampleDocument(name, e.Scripts, "", body), theme, locale, e, e.Default) |
| 386 | if err != nil { |
| 387 | t.Fatalf("%s: %v", name, err) |
| 388 | } |
| 389 | return doc |
| 390 | } |
| 391 | var abbr example.Entry |
| 392 | var abbrBody string |
| 393 | for _, e := range s.m.Examples { |
| 394 | if e.Document { |
| 395 | continue |
| 396 | } |
| 397 | for _, st := range e.States { |
| 398 | english, err := example.Markup(fiesta.FS(), e.Name, st.ID) |
| 399 | if err != nil { |
| 400 | t.Fatal(err) |
| 401 | } |
| 402 | for _, locale := range []string{"en", "ja"} { |
| 403 | raw := localMarkup(locale, e.Name, english) |
| 404 | if e.Name == moneyExample && st.ID == e.Default { |
| 405 | raw, _ = s.moneyBody(locale) |
| 406 | } |
| 407 | inner, err := unwrap(raw, e.Wrapper) |
| 408 | if err != nil { |
| 409 | t.Fatalf("%s/%s: %v", e.Name, st.ID, err) |
| 410 | } |
| 411 | i := strings.Index(raw, inner) |
| 412 | if i < 0 { |
| 413 | t.Fatalf("%s/%s: the unwrapped body is not a slice of the body", e.Name, st.ID) |
| 414 | } |
| 415 | formatted := raw[:i] + codeview.Format(inner) + raw[i+len(inner):] |
| 416 | if e.Name == "format-abbr" && locale == "en" { |
| 417 | abbr, abbrBody = e, raw |
| 418 | } |
| 419 | if formatted == raw { |
| 420 | continue |
| 421 | } |
| 422 | name := locale + "/" + e.Name + "/" + st.ID |
| 423 | base := "/fmt/" + name |
| 424 | docs[base+"-raw.html"] = serveIn(locale, name, e, raw) |
| 425 | docs[base+"-fmt.html"] = serveIn(locale, name, e, formatted) |
| 426 | jobs = append(jobs, job{locale, name, base + "-raw.html", base + "-fmt.html"}) |
| 427 | } |
| 428 | } |
| 429 | } |
| 430 | // The control: the short-forms sample with a line break put between |
| 431 | // "(" and its <abbr>, mid-sentence, where a space shows. A probe |
| 432 | // that cannot tell that copy from the preview is measuring nothing. |
| 433 | broken := strings.Replace(abbrBody, "(<abbr", "(\n<abbr", 1) |
| 434 | if abbrBody == "" || broken == abbrBody { |
| 435 | t.Fatal("the control's sentence is not in the short-forms sample") |
| 436 | } |
| 437 | docs["/fmt/control-raw.html"] = serve("control", abbr, abbrBody) |
| 438 | docs["/fmt/control.html"] = serve("control", abbr, broken) |
| 439 | // The second control is the same space where a reading taken on |
| 440 | // load cannot see it: in content that is display: none until a |
| 441 | // disclosure opens. |
| 442 | hidden := `<style>details:not([open]) + div { display: none; }</style><details><summary>More</summary></details><div><p>Read the <abbr title="frequently asked questions">FAQ</abbr>first.</p></div>` |
| 443 | docs["/fmt/hidden-raw.html"] = serve("hidden control", abbr, hidden) |
| 444 | docs["/fmt/hidden.html"] = serve("hidden control", abbr, strings.Replace(hidden, "</abbr>first", "</abbr>\nfirst", 1)) |
| 445 | if len(jobs) < 100 { |
| 446 | t.Fatalf("only %d states change when formatted; the sample walk or the formatter has stopped", len(jobs)) |
| 447 | } |
| 448 | |
| 449 | tree := treeHandler(t) |
| 450 | rig := harness.New(t, func(string) http.Handler { |
| 451 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 452 | if doc, ok := docs[r.URL.Path]; ok { |
| 453 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 454 | w.Write([]byte(doc)) |
| 455 | return |
| 456 | } |
| 457 | tree.ServeHTTP(w, r) |
| 458 | }) |
| 459 | }) |
| 460 | ctx, cancel := context.WithTimeout(rig.Context(), 15*time.Minute) |
| 461 | defer cancel() |
| 462 | |
| 463 | read := func(url string) []layoutReading { |
| 464 | probe := func() layoutReading { |
| 465 | var out layoutReading |
| 466 | at(t, ctx, layoutProbe, &out) |
| 467 | return out |
| 468 | } |
| 469 | var n int |
| 470 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+url), |
| 471 | chromedp.Evaluate(`new Promise(r => document.fonts.ready.then(() => requestAnimationFrame(() => requestAnimationFrame(r))))`, nil, awaitPromise), |
| 472 | chromedp.Evaluate(`document.querySelectorAll("details").length`, &n)) |
| 473 | out := []layoutReading{probe()} |
| 474 | for k := -2; k < n; k++ { |
| 475 | mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(%s)(%d)`, disclosureState, k), nil, awaitPromise)) |
| 476 | out = append(out, probe()) |
| 477 | } |
| 478 | return out |
| 479 | } |
| 480 | mustRun(t, ctx, chromedp.EmulateViewport(900, 900)) |
| 481 | if want, got := read("/fmt/control-raw.html"), read("/fmt/control.html"); sameReadings(want, got) == "" { |
| 482 | t.Fatal("a space inside a sentence moved no box; this drive cannot see a formatter that changes rendering") |
| 483 | } |
| 484 | if want, got := read("/fmt/hidden-raw.html"), read("/fmt/hidden.html"); sameReadings(want, got) == "" { |
| 485 | t.Fatal("a space inside a closed disclosure moved no box once opened; this drive cannot see a formatter that changes what a menu shows") |
| 486 | } |
| 487 | var compared, states int |
| 488 | for _, width := range []int64{900, 390} { |
| 489 | if width != onlyWidth { |
| 490 | continue |
| 491 | } |
| 492 | mustRun(t, ctx, chromedp.EmulateViewport(width, 900)) |
| 493 | for _, j := range jobs { |
| 494 | if j.locale != only { |
| 495 | continue |
| 496 | } |
| 497 | want, got := read(j.raw), read(j.fmt) |
| 498 | compared++ |
| 499 | states += len(want) |
| 500 | if diff := sameReadings(want, got); diff != "" { |
| 501 | t.Errorf("%s at %dpx: %s", j.name, width, diff) |
| 502 | } |
| 503 | } |
| 504 | } |
| 505 | if compared == 0 { |
| 506 | t.Fatalf("no %s sample was compared at %dpx", only, onlyWidth) |
| 507 | } |
| 508 | t.Logf("compared %d %s samples at %dpx in %d states, each rendered as written and as formatted", compared, only, onlyWidth, states) |
| 509 | } |
| 510 | |
| 511 | // sameReadings compares a document's readings state by state, and says |
| 512 | // how the first difference shows; "" when there is none. |
| 513 | func sameReadings(want, got []layoutReading) string { |
| 514 | if len(want) != len(got) { |
| 515 | return fmt.Sprintf("%d disclosure states in the preview, %d in the formatted copy", len(want), len(got)) |
| 516 | } |
| 517 | for s := range want { |
| 518 | state := "as loaded" |
| 519 | switch { |
| 520 | case s == 1: |
| 521 | state = "with every disclosure open" |
| 522 | case s == 2: |
| 523 | state = "with every disclosure closed" |
| 524 | case s > 2: |
| 525 | state = fmt.Sprintf("with disclosure %d open, and what shows it", s-3) |
| 526 | } |
| 527 | if got[s].Text != want[s].Text { |
| 528 | return fmt.Sprintf("%s, the visible text differs\npreview: %q\n tab: %q", state, want[s].Text, got[s].Text) |
| 529 | } |
| 530 | if len(got[s].Boxes) != len(want[s].Boxes) { |
| 531 | return fmt.Sprintf("%s, %d boxes in the preview, %d in the formatted copy", state, len(want[s].Boxes), len(got[s].Boxes)) |
| 532 | } |
| 533 | for k := range want[s].Boxes { |
| 534 | if !sameBoxes(want[s].Boxes[k], got[s].Boxes[k]) { |
| 535 | return fmt.Sprintf("%s, a box moved\npreview: %s\n tab: %s", state, want[s].Boxes[k], got[s].Boxes[k]) |
| 536 | } |
| 537 | } |
| 538 | } |
| 539 | return "" |
| 540 | } |
| 541 | |
| 542 | // sameBoxes compares two probe lines: the same element or text, and |
| 543 | // every rectangle within half a pixel. |
| 544 | func sameBoxes(a, b string) bool { |
| 545 | if a == b { |
| 546 | return true |
| 547 | } |
| 548 | ia, ib := strings.LastIndex(a, " ["), strings.LastIndex(b, " [") |
| 549 | if ia < 0 || ib < 0 || a[:ia] != b[:ib] { |
| 550 | return false |
| 551 | } |
| 552 | var ra, rb [][]float64 |
| 553 | if json.Unmarshal([]byte(a[ia+1:]), &ra) != nil || json.Unmarshal([]byte(b[ib+1:]), &rb) != nil || len(ra) != len(rb) { |
| 554 | return false |
| 555 | } |
| 556 | for i := range ra { |
| 557 | for k := range ra[i] { |
| 558 | if math.Abs(ra[i][k]-rb[i][k]) > 0.5 { |
| 559 | return false |
| 560 | } |
| 561 | } |
| 562 | } |
| 563 | return true |
| 564 | } |
| 565 | |