rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package gallery
4
5import (
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.
32const clipboardStub = `window.__clipCalls = []; window.__clipMode = "resolve";
33Object.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.
38const 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.
42func 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.
59func 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.
123func 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.
156func 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.
202func 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.
242func 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.
284const 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
301type 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.
311const 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.
355func TestTheHTMLTabRendersExactlyLikeThePreviewEn900(t *testing.T) {
356 htmlTabRendersLikeThePreview(t, "en", 900)
357}
358
359func TestTheHTMLTabRendersExactlyLikeThePreviewEn390(t *testing.T) {
360 htmlTabRendersLikeThePreview(t, "en", 390)
361}
362
363func TestTheHTMLTabRendersExactlyLikeThePreviewJa900(t *testing.T) {
364 htmlTabRendersLikeThePreview(t, "ja", 900)
365}
366
367func TestTheHTMLTabRendersExactlyLikeThePreviewJa390(t *testing.T) {
368 htmlTabRendersLikeThePreview(t, "ja", 390)
369}
370
371// htmlTabRendersLikeThePreview is TestTheHTMLTabRendersExactlyLikeThePreview
372// in one language at one width.
373func 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.
513func 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.
544func 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