//go:build browser package gallery import ( "context" "fmt" "net/http" "os" "sort" "strings" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" "amadan.net/rastrillo/fiesta/internal/harness" ) // The heights in heights.go are measurements, and this is where they // are measured: every frame on every example page is asked what its // document needs and held to the box the renderer gave it, at Desktop // and with Mobile chosen, at a desktop window and at the narrowest // phone. A frame smaller than its content is a sample a reader has to // scroll inside a box the size of a paragraph. // // heightsEnforced is the switch between reporting and gating. The // table is calibrated now the shell, the frame padding, the stage and // the demo are all in, so a box short of its content, at rest or with // what opens in it open, fails the drive by name. The self-check stays: // a box made 100px short of its content by hand has to be reported // short, which proves the measurement. const heightsEnforced = true // sweepFull is the opt-in for the whole table: every locale rather than // the routine few, and the table printed as Go source. func sweepFull() bool { switch v := os.Getenv("FIESTA_SWEEP"); v { case "": return false case "full": return true default: panic(fmt.Sprintf("FIESTA_SWEEP=%q: the only value is \"full\"", v)) } } // wideLocales is the desktop drive's languages: English, or all twelve // under the sweep. func wideLocales() []string { if sweepFull() { return Locales } return []string{"en"} } // narrowLocales is the 320px drive's languages: English, the one // right-to-left locale and the two that wrap longest, or all twelve // under the sweep. func narrowLocales() []string { if sweepFull() { return Locales } return []string{"en", "ar", "ru", "ga"} } // frameKey is the JS that keys a frame as heights.go keys it: the // / its src names under frames/, demo for the demo // application, or "?" for anything else. const frameKey = `const frameKey = f => { const src = f.getAttribute("src") || ""; const m = /\/frames\/([^/]+)\/([^/]+)\.html$/.exec(src); if (m) return m[1] + "/" + m[2]; return /\/demo\.html$/.test(src) ? "demo" : "?"; };` // measureHeights reads every frame on the page, keyed by frameKey: what // its document needs in its own pixels, what its box gives it, which is // --ds-h or --ds-hm resolved, since the frame takes its height back off // the box in virtual pixels, and whether it frames a sidebar shell, // whose rail is 100dvh tall. A widget view.js has marked at rest // (data-ds-rest) gives its rest height as Box, and is unmarked for a // moment to read the height it opens to as Open; any other's Open is // its Box. const measureHeights = `(() => { ` + frameKey + ` const out = {}; for (const f of document.querySelectorAll(".ds-view__frame")) { const key = frameKey(f); let d = null; try { d = f.contentDocument; } catch (e) {} const need = d && d.body ? Math.ceil(Math.max(d.body.getBoundingClientRect().height, d.body.scrollHeight)) : -1; const shell = !!(d && d.querySelector("[rst-shell-sidebar]")); const viewport = !!(d && d.querySelector("[rst-shell-sidebar], [rst-shell-topbar], [rst-shell-console], [rst-stage]")); const view = f.closest(".ds-view"), rest = view.hasAttribute("data-ds-rest"), box = Math.round(parseFloat(getComputedStyle(f).height)); let open = box; if (rest) { view.removeAttribute("data-ds-rest"); open = Math.round(parseFloat(getComputedStyle(f).height)); view.setAttribute("data-ds-rest", ""); } out[key] = {Need: need, Box: box, Open: open, Rest: rest, Shell: shell, Viewport: viewport}; } return JSON.stringify(out); })()` type heightReading struct { Need, Box int // Open is the box while something in the frame is open: Box, but // for a widget with a rest height (previewRestHeights), which Rest // says view.js had marked at rest when Box was read. Open int Rest bool // Shell is a frame holding a sidebar shell, whose rail is 100dvh; // Viewport any document as tall as its frame (a shell, the stage), // which reads its own box when its content fits. Shell, Viewport bool } // readHeights decodes one measurement, failing on what makes it // worthless: no frame, or a frame with no document in it. func readHeights(t *testing.T, ctx context.Context, where string) map[string]heightReading { t.Helper() var got map[string]heightReading at(t, ctx, measureHeights, &got) if len(got) == 0 { t.Fatalf("%s: no frame on the page", where) } for key, r := range got { if r.Need < 0 { t.Fatalf("%s %s: the frame has no document in it", where, key) } // A rest height the drive never sees in force is a box nobody // measured: the rest readings would be of the open box. if _, rest := previewRestHeights[key]; rest != r.Rest { t.Fatalf("%s %s: view.js has the widget at rest %v, and previewRestHeights says it has a rest height %v; the readings would be of the wrong box", where, key, r.Rest, rest) } if strings.HasPrefix(key, "?") { t.Fatalf("%s: a frame's src names no document under frames/ and is not the demo", where) } } return got } // measureOpen is what a box has to hold for what a reader opens in it: // a menu (a dropdown, a row menu, the language and scheme menus), the // enhanced select's list, the date and time fields' calendar or clock. // Every widget's box is first made 1600px tall, because dvh inside a // frame is the frame, and tokens.css caps a menu at // min(20rem, max(100dvh - 6rem, 8rem)): measured in its own box, a // menu would shrink to fit and always read as fitting. Then each thing // that opens is opened in turn, the bottom of everything visible in the // document read, the body's own padding added under it, and the thing // closed again; the boxes get their heights back at the end. A frame // whose document is a whole page (a shell, the stage, a backdrop) is // 100dvh tall and is left out: its menus open inside it. const measureOpen = `(async (prop) => { ` + frameKey + ` const settle = (w) => new Promise(r => w.requestAnimationFrame(() => w.requestAnimationFrame(() => setTimeout(r, 30)))); const views = [...document.querySelectorAll(".ds-view")]; const kept = views.map(v => v.style.getPropertyValue(prop)); views.forEach(v => v.style.setProperty(prop, "1600px")); await settle(window); // What a modal's panel holds: its own height, or, where something in // it scrolls, the height that thing would need unclipped, plus the // overlay's padding round the panel. const panelNeed = (d, w, panel) => { const top = panel.getBoundingClientRect().top; let h = Math.max(panel.getBoundingClientRect().height, panel.scrollHeight); for (const e of panel.querySelectorAll("*")) { if (!/auto|scroll|hidden|clip/.test(w.getComputedStyle(e).overflowY)) continue; const r = e.getBoundingClientRect(); h = Math.max(h, r.top - top + e.scrollHeight + (panel.getBoundingClientRect().bottom - r.bottom)); } const m = w.getComputedStyle(panel.closest("[rst-modal-overlay]") || d.body); return Math.ceil(h + (parseFloat(m.paddingTop) || 0) + (parseFloat(m.paddingBottom) || 0)); }; // Whether a modal's panel clips anything at the box it has: the // panel, or anything in it that scrolls, holding more than it shows. const panelClipped = (w, panel) => [panel, ...panel.querySelectorAll("*")].some(e => (e === panel || /auto|scroll|hidden|clip/.test(w.getComputedStyle(e).overflowY)) && e.scrollHeight > e.clientHeight + 1); const out = {}; const modals = []; for (const f of document.querySelectorAll(".ds-view__frame")) { const d = f.contentDocument; if (!d || !d.body) continue; // A modal is laid out on a fixed overlay the size of the frame, so // what it asks of the box is its panel unclipped; the page behind // it is the backdrop's and reads as content at rest. const panel = d.querySelector("[rst-modal-panel]"); if (panel) { const w = d.defaultView; await settle(w); const h = Math.ceil(d.body.getBoundingClientRect().height); out[frameKey(f)] = {Need: panelNeed(d, w, panel), What: "modal panel", Closed: h, After: h}; modals.push([f, w, panel]); continue; } if (d.querySelector("[rst-shell-sidebar], [rst-shell-topbar], [rst-shell-console], [rst-stage], [rst-backdrop]")) continue; const w = d.defaultView; const opens = []; for (const det of d.querySelectorAll("details[rst-dropdown], details[rst-row-menu]")) { if (!det.parentElement.closest("details")) opens.push(["menu", det]); } for (const input of d.querySelectorAll("[rst-combo] input[role=combobox]")) opens.push(["select", input]); for (const pick of d.querySelectorAll("[rst-dtp] [rst-dtp-pick]")) opens.push(["picker", pick]); if (opens.length === 0) continue; await settle(w); const pad = parseFloat(w.getComputedStyle(d.body).paddingBottom) || 0; let need = 0, what = ""; const closed = Math.ceil(d.body.getBoundingClientRect().height); for (const [kind, el] of opens) { if (kind === "menu") el.open = true; else if (kind === "select") { el.focus(); el.click(); } else el.click(); await settle(w); // A sheet pinned to the frame's foot (the calendar on a narrow // frame: fixed, and not anchored to anything) is as tall as its // content up to the frame's height, so what it asks of the box is // its own content and its margins, and what lies inside it is not // read again. A menu anchored to its button is fixed too, and is // read where it lies, as is everything else; but what scrolls // inside a menu or a list is read as the box that clips it. const all = [...d.body.querySelectorAll("*")]; const sheets = all.filter(e => { const m = w.getComputedStyle(e); return m.position === "fixed" && (!m.positionArea || m.positionArea === "none") && (!e.checkVisibility || e.checkVisibility()); }); const clips = all.filter(e => /auto|scroll|hidden|clip/.test(w.getComputedStyle(e).overflowY)); let bottom = d.body.getBoundingClientRect().bottom - pad, by = "body"; for (const e of all) { if (e.checkVisibility && !e.checkVisibility()) continue; if (sheets.some(x => x.contains(e)) || clips.some(c => c !== e && c.contains(e))) continue; const r = e.getBoundingClientRect(); if (r.height > 0 && r.bottom > bottom) { bottom = r.bottom; by = e.tagName.toLowerCase() + [...e.attributes].filter(a => a.name.startsWith("rst-")).map(a => "[" + a.name + "]").join(""); } } let n = Math.ceil(bottom + pad + w.scrollY); for (const x of sheets) { const sheet = Math.ceil(x.scrollHeight + 2 * (parseFloat(w.getComputedStyle(x).bottom) || 0)); if (sheet > n) { n = sheet; by = "a sheet"; } } if (n > need) { need = n; what = kind + " (" + by + ")"; } if (kind === "menu") el.open = false; else { const input = el.closest("[rst-dtp], [rst-combo]").querySelector("input[role=combobox]"); input.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true, cancelable: true})); input.blur(); } await settle(w); } const after = Math.ceil(d.body.getBoundingClientRect().height); out[frameKey(f)] = {Need: need, What: what, Closed: closed, After: after}; } views.forEach((v, i) => kept[i] ? v.style.setProperty(prop, kept[i]) : v.style.removeProperty(prop)); await settle(window); // Back at the boxes the table gives them, the modal panels are read // for clipping: a panel that scrolls in its own box is a modal a // reader sees cut short, whatever the arithmetic above says. for (const [f, w, panel] of modals) { await settle(w); out[frameKey(f)].Clipped = panelClipped(w, panel); } return JSON.stringify(out); })` type openReading struct { Need int What string Closed, After int // Clipped is set for a modal whose panel, back in the box the // table gives it, holds more than it shows. Clipped bool } // readOpen measures what each frame's box must hold with its menus and // panels open, at the view prop names (--ds-h for Desktop, --ds-hm for // Mobile), and fails if a frame's document is not back to its closed // height afterwards: a panel left open would be read as content by the // next measurement. func readOpen(t *testing.T, ctx context.Context, where, prop string) map[string]openReading { t.Helper() var got map[string]openReading at(t, ctx, fmt.Sprintf("%s(%q)", measureOpen, prop), &got, awaitPromise) for key, o := range got { if o.After > o.Closed+1 { t.Fatalf("%s %s: %dpx before its %s was opened and %dpx after it was closed; something was left open", where, key, o.Closed, o.What, o.After) } } return got } // shortfall reports one frame's box against its content: an error when // the heights are enforced, a line in the log until then. grace is the // slack the box is allowed under its content: Rastrillo's 48px for a // sidebar shell's 100dvh rail, which no box can fit, and 0 for the rest // where a drive grants it to shells alone. func shortfall(t *testing.T, where, key string, r heightReading, grace int) bool { t.Helper() if r.Need <= r.Box+grace { if r.Box > r.Need*4 && r.Box-r.Need > 120 { t.Logf("%s %s: %dpx of frame for %dpx of document; deliberate headroom, or a number to bring down", where, key, r.Box, r.Need) } return false } msg := fmt.Sprintf("%s %s: its document needs %dpx and its frame is %dpx; raise its height (previewHeights for Desktop, previewMobileHeights or previewMobileLocaleHeights for Mobile, or its previewRestHeights entry if it has one and nothing is open) to at least %d", where, key, r.Need, r.Box, r.Need+20) if heightsEnforced { t.Error(msg) } else { t.Log("not enforced yet: " + msg) } return true } // openShortfall reports one frame's box against what it must hold open: // no grace, since a menu cut short is a menu a reader cannot use; and a // modal whose panel is clipped in its box, read directly. func openShortfall(t *testing.T, where, key string, open openReading, box int) bool { t.Helper() short := shortfall(t, where+" with its "+open.What+" open", key, heightReading{Need: open.Need, Box: box}, 0) if open.Clipped { msg := fmt.Sprintf("%s %s: the modal's panel is clipped in a frame of %dpx; it needs %dpx to show whole", where, key, box, open.Need) if heightsEnforced { t.Error(msg) } else { t.Log("not enforced yet: " + msg) } short = true } return short } // selfCheck proves the measurement can see a short box: every widget's // prop (--ds-h or --ds-hm) is set 100px under what its document needs, // and the reading must then report the shortfall. func selfCheck(t *testing.T, ctx context.Context, where, prop string, before map[string]heightReading) { t.Helper() for key, r := range before { mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll(".ds-view").forEach(v => v.style.setProperty(%q, "%dpx")); true`, prop, r.Need-100), nil)) after := readHeights(t, ctx, where+", self-check")[key] if after.Need <= after.Box { t.Fatalf("%s: with %s set to %dpx under its content, %s reads need %d in a box of %d; the measurement cannot see a short box", where, prop, 100, key, after.Need, after.Box) } t.Logf("%s: self-check, %s %dpx short reads need %d in a box of %d", where, prop, 100, after.Need, after.Box) return } } // heightPages is every page that frames something: the Overview, for // the demo application, then every example's page in manifest order. func heightPages(t *testing.T) []string { t.Helper() out := []string{"index.html"} for _, e := range manifest(t).Examples { out = append(out, e.Name+".html") } return out } // printTable writes the readings out as Go source: one Desktop height // per key, the tallest over the locales read plus slack, and the // Mobile entries where the derived 1.25x does not cover a reading. // // It is written so that a second sweep after pasting the first changes // nothing. A document as tall as the frame it is in (a shell, the // stage) measures its own box when its content fits, so slack on that // reading would grow the box on every sweep; such a key keeps the // reservation the table already gives it, unless a reading found its // content taller than its box (fit says which keys fitted every time). // Mobile entries the table already has are kept unless a reading needs // more: the 320px drive raises them and prints no table, so the wide // printout carrying them is what keeps a paste from dropping them. // previewReserved is never printed: it is a floor, not a measurement. func printTable(t *testing.T, desk, mob map[string]map[string]int, deskFit, mobFit map[string]bool) { t.Helper() keys := map[string]bool{} for _, m := range desk { for k := range m { keys[k] = true } } var sorted []string for k := range keys { sorted = append(sorted, k) } sort.Strings(sorted) // Slack, then rounded up to the next ten: twelve languages and three // type families do not lay out to the same pixel. up := func(n int) int { return (n + 20 + 9) / 10 * 10 } tallest := func(m map[string]map[string]int, k string) int { n := 0 for _, byKey := range m { n = max(n, byKey[k]) } return n } var b strings.Builder b.WriteString("var previewHeights = map[string]int{\n") h := map[string]int{} for _, k := range sorted { cur, ok := previewHeights[k] if !ok { cur = previewHeight } h[k] = up(tallest(desk, k)) if deskFit[k] { h[k] = cur } fmt.Fprintf(&b, "\t%q: %d,\n", k, h[k]) } b.WriteString("}\nvar previewMobileHeights = map[string]int{\n") mh := map[string]int{} for _, k := range sorted { m := 0 if n := mob["en"][k]; n > h[k]*5/4 && !mobFit[k] { m = up(n) } m = max(m, previewMobileHeights[k]) if m > h[k]*5/4 { mh[k] = m fmt.Fprintf(&b, "\t%q: %d,\n", k, m) } } b.WriteString("}\nvar previewMobileLocaleHeights = map[string]map[string]int{\n") locales := map[string]bool{} for l := range mob { if l != "en" { locales[l] = true } } for l := range previewMobileLocaleHeights { locales[l] = true } var sortedLocales []string for l := range locales { sortedLocales = append(sortedLocales, l) } sort.Strings(sortedLocales) for _, l := range sortedLocales { var lines []string for _, k := range sorted { base := max(h[k]*5/4, mh[k]) n := previewMobileLocaleHeights[l][k] if r := mob[l][k]; r > base && !mobFit[k] { n = max(n, up(r)) } if n > base { lines = append(lines, fmt.Sprintf("\t\t%q: %d,", k, n)) } } if len(lines) > 0 { fmt.Fprintf(&b, "\t%q: {\n%s\n\t},\n", l, strings.Join(lines, "\n")) } } b.WriteString("}\n") t.Logf("the table as measured:\n%s", b.String()) } // The two drives are the gallery's longest tests, so each is dealt into // parts, one top-level test apiece, which the suite's slices can put on // shards of their own. A drive's work is its (locale, page) pairs, in // locale order and heightPages order within each, and the i-th pair // goes to part i mod n: the parts are a partition of the drive, each // pair read in exactly one of them. The checks the drive made once over // everything it read are made where what they check was read: the // self-check with en/button.html, the demo with en/index.html, each // sidebar-shell example on every page it is read; and that something // was measured and opened, in every part, which is only stricter. // // Under FIESTA_SWEEP=full a drive is one part again, its first, so the // table it prints is the whole table; the other parts skip and say so. // make heights-sweep runs both drives that way. const ( wideHeightParts = 3 narrowHeightParts = 12 ) // heightsUnit is one page of one locale, a drive's unit of work. type heightsUnit struct{ locale, file string } // heightsPart is part k of n of a drive over locales: its pairs, dealt // as above. func heightsPart(t *testing.T, locales []string, k, n int) []heightsUnit { t.Helper() if sweepFull() { if k > 1 { t.Skipf("FIESTA_SWEEP=full reads the whole drive in part 1 of %d, so the table it prints is whole", n) } n = 1 } var out []heightsUnit i := 0 pages := heightPages(t) for _, locale := range locales { for _, file := range pages { if i%n == k-1 { out = append(out, heightsUnit{locale, file}) } i++ } } if len(out) == 0 { t.Fatalf("part %d of %d of %d pages in %d locale(s) was dealt nothing", k, n, len(pages), len(locales)) } return out } // sidebarShellPages are the examples whose frames hold a sidebar shell: // each, wherever a narrow part reads it, must read as one, which is // what proves the 48px of grace goes where it should. A name here the // manifest no longer has fails, so the list cannot go quietly stale. var sidebarShellPages = []string{"layout-sidebar.html", "layout-sidebar-page.html", "shell-sidebar.html"} // TestPreviewFrameHeightsFitTheirContent1of3 to 3of3: on the Overview // and every example page of day/en (every locale under // FIESTA_SWEEP=full), at a 1500px window, every frame's document // against its box at Desktop and, with Mobile chosen, against --ds-hm; // then, at each, what the box must hold with each of its menus and // panels open. A stage frame counts like any other: its document is // 100dvh tall, so one that fits reads equal to its box. The wide drive // grants every frame Rastrillo's 48px for content at rest, and none for // what opens. Preservation: the one wide drive, dealt into parts. func TestPreviewFrameHeightsFitTheirContent1of3(t *testing.T) { wideHeights(t, 1, wideHeightParts) } func TestPreviewFrameHeightsFitTheirContent2of3(t *testing.T) { wideHeights(t, 2, wideHeightParts) } func TestPreviewFrameHeightsFitTheirContent3of3(t *testing.T) { wideHeights(t, 3, wideHeightParts) } // wideHeights is part k of n of the wide drive. func wideHeights(t *testing.T, k, n int) { units := heightsPart(t, wideLocales(), k, n) rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) deadline := 12 * time.Minute if sweepFull() { deadline = 28 * time.Minute } ctx, cancel := context.WithTimeout(rig.Context(), deadline) defer cancel() desk, mob := map[string]map[string]int{}, map[string]map[string]int{} // What a widget with a rest height needs at rest, over the locales // read: its previewRestHeights entry, which the table below, the // open heights, does not carry. restDesk, restMob := map[string]int{}, map[string]int{} measured, opened, short := 0, 0, 0 readDemo := false keep := func(m map[string]int, key string, n int) { if n > m[key] { m[key] = n } } // fit says, per key, whether every reading of it was a viewport-sized // document whose content fitted its box: printTable keeps those at // the reservation they have. deskFit, mobFit := map[string]bool{}, map[string]bool{} fits := func(fit map[string]bool, key string, r heightReading) { ok := r.Viewport && r.Need <= r.Box if was, read := fit[key]; read { ok = ok && was } fit[key] = ok } for _, u := range units { locale, file := u.locale, u.file if desk[locale] == nil { desk[locale], mob[locale] = map[string]int{}, map[string]int{} } where := fmt.Sprintf("%s/%s at 1500px", locale, file) openPage(t, ctx, where, pageURL(rig, locale, file), 1500, 1000) eagerly(t, ctx, where) desktop := readHeights(t, ctx, where+" Desktop") for key, r := range desktop { measured++ keep(desk[locale], key, r.Need) if r.Rest { keep(restDesk, key, r.Need) } fits(deskFit, key, r) if shortfall(t, where+" Desktop", key, r, 48) { short++ } } for key, o := range readOpen(t, ctx, where+" Desktop", "--ds-h") { opened++ keep(desk[locale], key, o.Need) deskFit[key] = false if openShortfall(t, where+" Desktop", key, o, desktop[key].Open) { short++ } } clickAll(t, ctx, where, clickedMobile, "Mobile") mobileSettle(t, ctx, where) mobile := readHeights(t, ctx, where+" Mobile") for key, r := range mobile { keep(mob[locale], key, r.Need) if r.Rest { keep(restMob, key, r.Need) } fits(mobFit, key, r) if shortfall(t, where+" Mobile", key, r, 48) { short++ } } for key, o := range readOpen(t, ctx, where+" Mobile", "--ds-hm") { keep(mob[locale], key, o.Need) mobFit[key] = false if openShortfall(t, where+" Mobile", key, o, mobile[key].Open) { short++ } } if file == "button.html" && locale == "en" { selfCheck(t, ctx, where+" Mobile", "--ds-hm", mobile) clickAll(t, ctx, where, clickedDesktop, "Desktop") selfCheck(t, ctx, where+" Desktop", "--ds-h", desktop) } if file == "index.html" && locale == "en" { if _, ok := desk["en"]["demo"]; !ok { t.Fatal("the Overview's demo was not measured") } readDemo = true } } if measured == 0 || opened == 0 { t.Fatalf("%d frames measured and %d opened; the sweep read too little", measured, opened) } t.Logf("part %d: %d pages, %d frames measured and %d opened over %d locale(s); %d short (enforced: %v; the Overview's demo read here: %v)", k, len(units), measured, opened, len(desk), short, heightsEnforced, readDemo) if sweepFull() { printTable(t, desk, mob, deskFit, mobFit) for key, n := range restDesk { t.Logf("previewRestHeights as measured at 1500px (the 320px drive may raise Mobile): %q: {Desktop: %d, Mobile: %d},", key, (n+20+9)/10*10, (restMob[key]+20+9)/10*10) } } } // TestPreviewFrameHeightsFitAtThePhonesNarrowest1of12 to 12of12: the same // with Mobile chosen at a 320px viewport, where a Mobile frame lays out // at the stage's width and text that fits a line at 390px wraps, over // en, ar, ru and ga (all twelve under the sweep), closed and open. Only // a frame holding a sidebar shell gets the 48px of grace, as // Rastrillo's narrow drive grants it: the rail is 100dvh tall and no // box can fit it; every other frame gets none. The control: every frame // is laid out under 390px, at its stage's width. Preservation: the one // narrow drive, dealt into parts. func TestPreviewFrameHeightsFitAtThePhonesNarrowest1of12(t *testing.T) { narrowHeights(t, 1, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest2of12(t *testing.T) { narrowHeights(t, 2, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest3of12(t *testing.T) { narrowHeights(t, 3, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest4of12(t *testing.T) { narrowHeights(t, 4, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest5of12(t *testing.T) { narrowHeights(t, 5, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest6of12(t *testing.T) { narrowHeights(t, 6, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest7of12(t *testing.T) { narrowHeights(t, 7, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest8of12(t *testing.T) { narrowHeights(t, 8, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest9of12(t *testing.T) { narrowHeights(t, 9, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest10of12(t *testing.T) { narrowHeights(t, 10, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest11of12(t *testing.T) { narrowHeights(t, 11, narrowHeightParts) } func TestPreviewFrameHeightsFitAtThePhonesNarrowest12of12(t *testing.T) { narrowHeights(t, 12, narrowHeightParts) } // narrowHeights is part k of n of the narrow drive. func narrowHeights(t *testing.T, k, n int) { units := heightsPart(t, narrowLocales(), k, n) pages := map[string]bool{} for _, file := range heightPages(t) { pages[file] = true } shellPage := map[string]bool{} for _, file := range sidebarShellPages { if !pages[file] { t.Fatalf("%s is listed as a sidebar shell's page, but the manifest has no such example", file) } shellPage[file] = true } rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) deadline := 15 * time.Minute if sweepFull() { deadline = 28 * time.Minute } ctx, cancel := context.WithTimeout(rig.Context(), deadline) defer cancel() measured, opened, short, shells := 0, 0, 0, 0 locales := map[string]bool{} var raise []string for _, u := range units { locale, file := u.locale, u.file locales[locale] = true where := fmt.Sprintf("%s/%s at 320px", locale, file) openPage(t, ctx, where, pageURL(rig, locale, file), 320, 844) eagerly(t, ctx, where) clickAll(t, ctx, where, clickedMobile, "Mobile") mobileSettle(t, ctx, where) var wide []string mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll(".ds-view")].filter(v => { const f = v.querySelector(".ds-view__frame"); return !(f.offsetWidth < 390 && Math.abs(f.offsetWidth - v.clientWidth) <= 1); }).map(v => v.querySelector(".ds-view__frame").title)`, &wide)) if len(wide) > 0 { t.Fatalf("%s: frames %v are not at the stage's narrow width; the readings would be of the 390px frame", where, wide) } got := readHeights(t, ctx, where) need, openNeed := map[string]int{}, map[string]int{} pageShells := 0 for key, r := range got { measured++ grace := 0 if r.Shell { grace, shells, pageShells = 48, shells+1, pageShells+1 } need[key] = r.Need if shortfall(t, where, key, r, grace) { short++ } } if shellPage[file] && pageShells == 0 { t.Errorf("%s: no frame read as a sidebar shell, so the drive cannot say the grace went where it should", where) } for key, o := range readOpen(t, ctx, where, "--ds-hm") { opened++ openNeed[key] = o.Need if openShortfall(t, where, key, o, got[key].Open) { short++ } } for key, n := range need { grace := 0 if got[key].Shell { grace = 48 } // A widget without a rest height holds both in one box. rest, open := n, max(n, openNeed[key]) if got[key].Rest { open = openNeed[key] if rest > got[key].Box+grace { raise = append(raise, fmt.Sprintf("%s %q: rest Mobile %d (previewRestHeights),", locale, key, (rest+20+9)/10*10)) } } if open > got[key].Open+grace { raise = append(raise, fmt.Sprintf("%s %q: %d,", locale, key, (open+20+9)/10*10)) } } if file == "button.html" && locale == "en" { selfCheck(t, ctx, where, "--ds-hm", got) } } if measured == 0 || opened == 0 { t.Fatalf("%d frames measured and %d opened; the sweep read too little", measured, opened) } sort.Strings(raise) t.Logf("part %d: %d pages, %d frames measured and %d opened at 320px over %d locale(s), %d sidebar shells given grace; %d short (enforced: %v)", k, len(units), measured, opened, len(locales), shells, short, heightsEnforced) if len(raise) > 0 { t.Logf("Mobile entries to raise, as locale key: height:\n\t%s", strings.Join(raise, "\n\t")) } } // columnFrame reads the column page's one frame: its height in the // frame's own pixels, which is the box's --ds-h or --ds-hm resolved, // and whether its language and scheme menus are open, after two frames // and a beat, so a resize view.js makes on a toggle has landed. act, // run in the framed document first, is "" or a click to make. func columnFrame(t *testing.T, ctx context.Context, where, act string) (height float64, open []bool) { t.Helper() var got struct { Height float64 Open []bool } at(t, ctx, `(async () => { const f = document.querySelector(".ds-view__frame"), d = f.contentDocument, w = d.defaultView; `+act+` for (const win of [window, w]) await new Promise(r => win.requestAnimationFrame(() => win.requestAnimationFrame(() => setTimeout(r, 50)))); return JSON.stringify({Height: parseFloat(getComputedStyle(f).height), Open: [...d.querySelectorAll("details[rst-dropdown]")].map(m => m.open)}); })()`, &got, awaitPromise) if len(got.Open) != 2 { t.Fatalf("%s: the framed column has %d menus, want its footer's two", where, len(got.Open)) } return got.Height, got.Open } // TestTheColumnPreviewGrowsOnlyWhileAMenuIsOpen: the column's box is // its page's height at rest and the open height while a footer menu // is open, on Desktop and on Mobile. Opening the scheme menu while the // language menu is open closes the language menu (the two share a // name), and the box stays open; closing the last one brings it back. // With scripts off nothing could grow the box, so it keeps the open // height, and a menu opened there is still all in view. func TestTheColumnPreviewGrowsOnlyWhileAMenuIsOpen(t *testing.T) { key := "layout-column/" + mustDefault(t, "layout-column") rest, ok := previewRestHeights[key] if !ok { t.Fatalf("%s has no rest height to switch from", key) } openDesktop, openMobile := previewHeightsFor("en", key) rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() const language, scheme = `d.querySelector("[rst-locale] > summary").click();`, `d.querySelector("[rst-scheme] > summary").click();` want := func(where string, got, wantH float64, open []bool, wantOpen ...bool) { t.Helper() if fmt.Sprint(open) != fmt.Sprint(wantOpen) { t.Fatalf("%s: the menus' open states are %v, want %v; the step did not do what it says", where, open, wantOpen) } if got < wantH-1 || got > wantH+1 { t.Errorf("%s: the frame is %.0fpx tall, want %.0f", where, got, wantH) } } openPage(t, ctx, "en/layout-column.html", pageURL(rig, "en", "layout-column.html"), 1500, 1000) eagerly(t, ctx, "en/layout-column.html") for _, mode := range []struct { name string rest, open int }{{"Desktop", rest.Desktop, openDesktop}, {"Mobile", rest.Mobile, openMobile}} { if mode.name == "Mobile" { clickAll(t, ctx, "en/layout-column.html", clickedMobile, "Mobile") mobileSettle(t, ctx, "en/layout-column.html") } where := "en/layout-column.html " + mode.name h, open := columnFrame(t, ctx, where, "") want(where+" at rest", h, float64(mode.rest), open, false, false) h, open = columnFrame(t, ctx, where, language) want(where+" with Language open", h, float64(mode.open), open, true, false) h, open = columnFrame(t, ctx, where, scheme) want(where+" with Colour scheme opened over Language", h, float64(mode.open), open, false, true) h, open = columnFrame(t, ctx, where, scheme) want(where+" with both closed again", h, float64(mode.rest), open, false, false) } off := galleryrig.NoScripts(t, ctx) mustRun(t, off, chromedp.EmulateViewport(1500, 1000), chromedp.Navigate(pageURL(rig, "en", "layout-column.html")), chromedp.WaitReady(".ds-view__frame", chromedp.ByQuery)) galleryrig.RequireScriptsOff(t, off, "en/layout-column.html, scripts off") var h float64 until(t, off, "en/layout-column.html, scripts off", `!!document.querySelector(".ds-view__frame").contentDocument?.querySelector("[rst-page-foot]")`) at(t, off, `JSON.stringify(parseFloat(getComputedStyle(document.querySelector(".ds-view__frame")).height))`, &h) if h < float64(openDesktop)-1 || h > float64(openDesktop)+1 { t.Errorf("en/layout-column.html, scripts off: the frame is %.0fpx tall, want the open height %d, since nothing can grow it when a menu opens", h, openDesktop) } }