| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | "os" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/chromedp/chromedp" |
| 16 | |
| 17 | "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" |
| 18 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 19 | ) |
| 20 | |
| 21 | // The heights in heights.go are measurements, and this is where they |
| 22 | // are measured: every frame on every example page is asked what its |
| 23 | // document needs and held to the box the renderer gave it, at Desktop |
| 24 | // and with Mobile chosen, at a desktop window and at the narrowest |
| 25 | // phone. A frame smaller than its content is a sample a reader has to |
| 26 | // scroll inside a box the size of a paragraph. |
| 27 | // |
| 28 | // heightsEnforced is the switch between reporting and gating. The |
| 29 | // table is calibrated now the shell, the frame padding, the stage and |
| 30 | // the demo are all in, so a box short of its content, at rest or with |
| 31 | // what opens in it open, fails the drive by name. The self-check stays: |
| 32 | // a box made 100px short of its content by hand has to be reported |
| 33 | // short, which proves the measurement. |
| 34 | const heightsEnforced = true |
| 35 | |
| 36 | // sweepFull is the opt-in for the whole table: every locale rather than |
| 37 | // the routine few, and the table printed as Go source. |
| 38 | func sweepFull() bool { |
| 39 | switch v := os.Getenv("FIESTA_SWEEP"); v { |
| 40 | case "": |
| 41 | return false |
| 42 | case "full": |
| 43 | return true |
| 44 | default: |
| 45 | panic(fmt.Sprintf("FIESTA_SWEEP=%q: the only value is \"full\"", v)) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // wideLocales is the desktop drive's languages: English, or all twelve |
| 50 | // under the sweep. |
| 51 | func wideLocales() []string { |
| 52 | if sweepFull() { |
| 53 | return Locales |
| 54 | } |
| 55 | return []string{"en"} |
| 56 | } |
| 57 | |
| 58 | // narrowLocales is the 320px drive's languages: English, the one |
| 59 | // right-to-left locale and the two that wrap longest, or all twelve |
| 60 | // under the sweep. |
| 61 | func narrowLocales() []string { |
| 62 | if sweepFull() { |
| 63 | return Locales |
| 64 | } |
| 65 | return []string{"en", "ar", "ru", "ga"} |
| 66 | } |
| 67 | |
| 68 | // frameKey is the JS that keys a frame as heights.go keys it: the |
| 69 | // <example>/<state> its src names under frames/, demo for the demo |
| 70 | // application, or "?" for anything else. |
| 71 | const frameKey = `const frameKey = f => { |
| 72 | const src = f.getAttribute("src") || ""; |
| 73 | const m = /\/frames\/([^/]+)\/([^/]+)\.html$/.exec(src); |
| 74 | if (m) return m[1] + "/" + m[2]; |
| 75 | return /\/demo\.html$/.test(src) ? "demo" : "?"; |
| 76 | };` |
| 77 | |
| 78 | // measureHeights reads every frame on the page, keyed by frameKey: what |
| 79 | // its document needs in its own pixels, what its box gives it, which is |
| 80 | // --ds-h or --ds-hm resolved, since the frame takes its height back off |
| 81 | // the box in virtual pixels, and whether it frames a sidebar shell, |
| 82 | // whose rail is 100dvh tall. A widget view.js has marked at rest |
| 83 | // (data-ds-rest) gives its rest height as Box, and is unmarked for a |
| 84 | // moment to read the height it opens to as Open; any other's Open is |
| 85 | // its Box. |
| 86 | const measureHeights = `(() => { |
| 87 | ` + frameKey + ` |
| 88 | const out = {}; |
| 89 | for (const f of document.querySelectorAll(".ds-view__frame")) { |
| 90 | const key = frameKey(f); |
| 91 | let d = null; |
| 92 | try { d = f.contentDocument; } catch (e) {} |
| 93 | const need = d && d.body ? Math.ceil(Math.max(d.body.getBoundingClientRect().height, d.body.scrollHeight)) : -1; |
| 94 | const shell = !!(d && d.querySelector("[rst-shell-sidebar]")); |
| 95 | const viewport = !!(d && d.querySelector("[rst-shell-sidebar], [rst-shell-topbar], [rst-shell-console], [rst-stage]")); |
| 96 | const view = f.closest(".ds-view"), rest = view.hasAttribute("data-ds-rest"), box = Math.round(parseFloat(getComputedStyle(f).height)); |
| 97 | let open = box; |
| 98 | if (rest) { |
| 99 | view.removeAttribute("data-ds-rest"); |
| 100 | open = Math.round(parseFloat(getComputedStyle(f).height)); |
| 101 | view.setAttribute("data-ds-rest", ""); |
| 102 | } |
| 103 | out[key] = {Need: need, Box: box, Open: open, Rest: rest, Shell: shell, Viewport: viewport}; |
| 104 | } |
| 105 | return JSON.stringify(out); |
| 106 | })()` |
| 107 | |
| 108 | type heightReading struct { |
| 109 | Need, Box int |
| 110 | // Open is the box while something in the frame is open: Box, but |
| 111 | // for a widget with a rest height (previewRestHeights), which Rest |
| 112 | // says view.js had marked at rest when Box was read. |
| 113 | Open int |
| 114 | Rest bool |
| 115 | // Shell is a frame holding a sidebar shell, whose rail is 100dvh; |
| 116 | // Viewport any document as tall as its frame (a shell, the stage), |
| 117 | // which reads its own box when its content fits. |
| 118 | Shell, Viewport bool |
| 119 | } |
| 120 | |
| 121 | // readHeights decodes one measurement, failing on what makes it |
| 122 | // worthless: no frame, or a frame with no document in it. |
| 123 | func readHeights(t *testing.T, ctx context.Context, where string) map[string]heightReading { |
| 124 | t.Helper() |
| 125 | var got map[string]heightReading |
| 126 | at(t, ctx, measureHeights, &got) |
| 127 | if len(got) == 0 { |
| 128 | t.Fatalf("%s: no frame on the page", where) |
| 129 | } |
| 130 | for key, r := range got { |
| 131 | if r.Need < 0 { |
| 132 | t.Fatalf("%s %s: the frame has no document in it", where, key) |
| 133 | } |
| 134 | // A rest height the drive never sees in force is a box nobody |
| 135 | // measured: the rest readings would be of the open box. |
| 136 | if _, rest := previewRestHeights[key]; rest != r.Rest { |
| 137 | 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) |
| 138 | } |
| 139 | if strings.HasPrefix(key, "?") { |
| 140 | t.Fatalf("%s: a frame's src names no document under frames/ and is not the demo", where) |
| 141 | } |
| 142 | } |
| 143 | return got |
| 144 | } |
| 145 | |
| 146 | // measureOpen is what a box has to hold for what a reader opens in it: |
| 147 | // a menu (a dropdown, a row menu, the language and scheme menus), the |
| 148 | // enhanced select's list, the date and time fields' calendar or clock. |
| 149 | // Every widget's box is first made 1600px tall, because dvh inside a |
| 150 | // frame is the frame, and tokens.css caps a menu at |
| 151 | // min(20rem, max(100dvh - 6rem, 8rem)): measured in its own box, a |
| 152 | // menu would shrink to fit and always read as fitting. Then each thing |
| 153 | // that opens is opened in turn, the bottom of everything visible in the |
| 154 | // document read, the body's own padding added under it, and the thing |
| 155 | // closed again; the boxes get their heights back at the end. A frame |
| 156 | // whose document is a whole page (a shell, the stage, a backdrop) is |
| 157 | // 100dvh tall and is left out: its menus open inside it. |
| 158 | const measureOpen = `(async (prop) => { |
| 159 | ` + frameKey + ` |
| 160 | const settle = (w) => new Promise(r => w.requestAnimationFrame(() => w.requestAnimationFrame(() => setTimeout(r, 30)))); |
| 161 | const views = [...document.querySelectorAll(".ds-view")]; |
| 162 | const kept = views.map(v => v.style.getPropertyValue(prop)); |
| 163 | views.forEach(v => v.style.setProperty(prop, "1600px")); |
| 164 | await settle(window); |
| 165 | // What a modal's panel holds: its own height, or, where something in |
| 166 | // it scrolls, the height that thing would need unclipped, plus the |
| 167 | // overlay's padding round the panel. |
| 168 | const panelNeed = (d, w, panel) => { |
| 169 | const top = panel.getBoundingClientRect().top; |
| 170 | let h = Math.max(panel.getBoundingClientRect().height, panel.scrollHeight); |
| 171 | for (const e of panel.querySelectorAll("*")) { |
| 172 | if (!/auto|scroll|hidden|clip/.test(w.getComputedStyle(e).overflowY)) continue; |
| 173 | const r = e.getBoundingClientRect(); |
| 174 | h = Math.max(h, r.top - top + e.scrollHeight + (panel.getBoundingClientRect().bottom - r.bottom)); |
| 175 | } |
| 176 | const m = w.getComputedStyle(panel.closest("[rst-modal-overlay]") || d.body); |
| 177 | return Math.ceil(h + (parseFloat(m.paddingTop) || 0) + (parseFloat(m.paddingBottom) || 0)); |
| 178 | }; |
| 179 | // Whether a modal's panel clips anything at the box it has: the |
| 180 | // panel, or anything in it that scrolls, holding more than it shows. |
| 181 | const panelClipped = (w, panel) => [panel, ...panel.querySelectorAll("*")].some(e => |
| 182 | (e === panel || /auto|scroll|hidden|clip/.test(w.getComputedStyle(e).overflowY)) && e.scrollHeight > e.clientHeight + 1); |
| 183 | const out = {}; |
| 184 | const modals = []; |
| 185 | for (const f of document.querySelectorAll(".ds-view__frame")) { |
| 186 | const d = f.contentDocument; |
| 187 | if (!d || !d.body) continue; |
| 188 | // A modal is laid out on a fixed overlay the size of the frame, so |
| 189 | // what it asks of the box is its panel unclipped; the page behind |
| 190 | // it is the backdrop's and reads as content at rest. |
| 191 | const panel = d.querySelector("[rst-modal-panel]"); |
| 192 | if (panel) { |
| 193 | const w = d.defaultView; |
| 194 | await settle(w); |
| 195 | const h = Math.ceil(d.body.getBoundingClientRect().height); |
| 196 | out[frameKey(f)] = {Need: panelNeed(d, w, panel), What: "modal panel", Closed: h, After: h}; |
| 197 | modals.push([f, w, panel]); |
| 198 | continue; |
| 199 | } |
| 200 | if (d.querySelector("[rst-shell-sidebar], [rst-shell-topbar], [rst-shell-console], [rst-stage], [rst-backdrop]")) continue; |
| 201 | const w = d.defaultView; |
| 202 | const opens = []; |
| 203 | for (const det of d.querySelectorAll("details[rst-dropdown], details[rst-row-menu]")) { |
| 204 | if (!det.parentElement.closest("details")) opens.push(["menu", det]); |
| 205 | } |
| 206 | for (const input of d.querySelectorAll("[rst-combo] input[role=combobox]")) opens.push(["select", input]); |
| 207 | for (const pick of d.querySelectorAll("[rst-dtp] [rst-dtp-pick]")) opens.push(["picker", pick]); |
| 208 | if (opens.length === 0) continue; |
| 209 | await settle(w); |
| 210 | const pad = parseFloat(w.getComputedStyle(d.body).paddingBottom) || 0; |
| 211 | let need = 0, what = ""; |
| 212 | const closed = Math.ceil(d.body.getBoundingClientRect().height); |
| 213 | for (const [kind, el] of opens) { |
| 214 | if (kind === "menu") el.open = true; |
| 215 | else if (kind === "select") { el.focus(); el.click(); } |
| 216 | else el.click(); |
| 217 | await settle(w); |
| 218 | // A sheet pinned to the frame's foot (the calendar on a narrow |
| 219 | // frame: fixed, and not anchored to anything) is as tall as its |
| 220 | // content up to the frame's height, so what it asks of the box is |
| 221 | // its own content and its margins, and what lies inside it is not |
| 222 | // read again. A menu anchored to its button is fixed too, and is |
| 223 | // read where it lies, as is everything else; but what scrolls |
| 224 | // inside a menu or a list is read as the box that clips it. |
| 225 | const all = [...d.body.querySelectorAll("*")]; |
| 226 | const sheets = all.filter(e => { |
| 227 | const m = w.getComputedStyle(e); |
| 228 | return m.position === "fixed" && (!m.positionArea || m.positionArea === "none") && (!e.checkVisibility || e.checkVisibility()); |
| 229 | }); |
| 230 | const clips = all.filter(e => /auto|scroll|hidden|clip/.test(w.getComputedStyle(e).overflowY)); |
| 231 | let bottom = d.body.getBoundingClientRect().bottom - pad, by = "body"; |
| 232 | for (const e of all) { |
| 233 | if (e.checkVisibility && !e.checkVisibility()) continue; |
| 234 | if (sheets.some(x => x.contains(e)) || clips.some(c => c !== e && c.contains(e))) continue; |
| 235 | const r = e.getBoundingClientRect(); |
| 236 | 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(""); } |
| 237 | } |
| 238 | let n = Math.ceil(bottom + pad + w.scrollY); |
| 239 | for (const x of sheets) { |
| 240 | const sheet = Math.ceil(x.scrollHeight + 2 * (parseFloat(w.getComputedStyle(x).bottom) || 0)); |
| 241 | if (sheet > n) { n = sheet; by = "a sheet"; } |
| 242 | } |
| 243 | if (n > need) { need = n; what = kind + " (" + by + ")"; } |
| 244 | if (kind === "menu") el.open = false; |
| 245 | else { |
| 246 | const input = el.closest("[rst-dtp], [rst-combo]").querySelector("input[role=combobox]"); |
| 247 | input.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true, cancelable: true})); |
| 248 | input.blur(); |
| 249 | } |
| 250 | await settle(w); |
| 251 | } |
| 252 | const after = Math.ceil(d.body.getBoundingClientRect().height); |
| 253 | out[frameKey(f)] = {Need: need, What: what, Closed: closed, After: after}; |
| 254 | } |
| 255 | views.forEach((v, i) => kept[i] ? v.style.setProperty(prop, kept[i]) : v.style.removeProperty(prop)); |
| 256 | await settle(window); |
| 257 | // Back at the boxes the table gives them, the modal panels are read |
| 258 | // for clipping: a panel that scrolls in its own box is a modal a |
| 259 | // reader sees cut short, whatever the arithmetic above says. |
| 260 | for (const [f, w, panel] of modals) { |
| 261 | await settle(w); |
| 262 | out[frameKey(f)].Clipped = panelClipped(w, panel); |
| 263 | } |
| 264 | return JSON.stringify(out); |
| 265 | })` |
| 266 | |
| 267 | type openReading struct { |
| 268 | Need int |
| 269 | What string |
| 270 | Closed, After int |
| 271 | // Clipped is set for a modal whose panel, back in the box the |
| 272 | // table gives it, holds more than it shows. |
| 273 | Clipped bool |
| 274 | } |
| 275 | |
| 276 | // readOpen measures what each frame's box must hold with its menus and |
| 277 | // panels open, at the view prop names (--ds-h for Desktop, --ds-hm for |
| 278 | // Mobile), and fails if a frame's document is not back to its closed |
| 279 | // height afterwards: a panel left open would be read as content by the |
| 280 | // next measurement. |
| 281 | func readOpen(t *testing.T, ctx context.Context, where, prop string) map[string]openReading { |
| 282 | t.Helper() |
| 283 | var got map[string]openReading |
| 284 | at(t, ctx, fmt.Sprintf("%s(%q)", measureOpen, prop), &got, awaitPromise) |
| 285 | for key, o := range got { |
| 286 | if o.After > o.Closed+1 { |
| 287 | 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) |
| 288 | } |
| 289 | } |
| 290 | return got |
| 291 | } |
| 292 | |
| 293 | // shortfall reports one frame's box against its content: an error when |
| 294 | // the heights are enforced, a line in the log until then. grace is the |
| 295 | // slack the box is allowed under its content: Rastrillo's 48px for a |
| 296 | // sidebar shell's 100dvh rail, which no box can fit, and 0 for the rest |
| 297 | // where a drive grants it to shells alone. |
| 298 | func shortfall(t *testing.T, where, key string, r heightReading, grace int) bool { |
| 299 | t.Helper() |
| 300 | if r.Need <= r.Box+grace { |
| 301 | if r.Box > r.Need*4 && r.Box-r.Need > 120 { |
| 302 | 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) |
| 303 | } |
| 304 | return false |
| 305 | } |
| 306 | 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) |
| 307 | if heightsEnforced { |
| 308 | t.Error(msg) |
| 309 | } else { |
| 310 | t.Log("not enforced yet: " + msg) |
| 311 | } |
| 312 | return true |
| 313 | } |
| 314 | |
| 315 | // openShortfall reports one frame's box against what it must hold open: |
| 316 | // no grace, since a menu cut short is a menu a reader cannot use; and a |
| 317 | // modal whose panel is clipped in its box, read directly. |
| 318 | func openShortfall(t *testing.T, where, key string, open openReading, box int) bool { |
| 319 | t.Helper() |
| 320 | short := shortfall(t, where+" with its "+open.What+" open", key, heightReading{Need: open.Need, Box: box}, 0) |
| 321 | if open.Clipped { |
| 322 | 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) |
| 323 | if heightsEnforced { |
| 324 | t.Error(msg) |
| 325 | } else { |
| 326 | t.Log("not enforced yet: " + msg) |
| 327 | } |
| 328 | short = true |
| 329 | } |
| 330 | return short |
| 331 | } |
| 332 | |
| 333 | // selfCheck proves the measurement can see a short box: every widget's |
| 334 | // prop (--ds-h or --ds-hm) is set 100px under what its document needs, |
| 335 | // and the reading must then report the shortfall. |
| 336 | func selfCheck(t *testing.T, ctx context.Context, where, prop string, before map[string]heightReading) { |
| 337 | t.Helper() |
| 338 | for key, r := range before { |
| 339 | mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll(".ds-view").forEach(v => v.style.setProperty(%q, "%dpx")); true`, prop, r.Need-100), nil)) |
| 340 | after := readHeights(t, ctx, where+", self-check")[key] |
| 341 | if after.Need <= after.Box { |
| 342 | 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) |
| 343 | } |
| 344 | t.Logf("%s: self-check, %s %dpx short reads need %d in a box of %d", where, prop, 100, after.Need, after.Box) |
| 345 | return |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | // heightPages is every page that frames something: the Overview, for |
| 350 | // the demo application, then every example's page in manifest order. |
| 351 | func heightPages(t *testing.T) []string { |
| 352 | t.Helper() |
| 353 | out := []string{"index.html"} |
| 354 | for _, e := range manifest(t).Examples { |
| 355 | out = append(out, e.Name+".html") |
| 356 | } |
| 357 | return out |
| 358 | } |
| 359 | |
| 360 | // printTable writes the readings out as Go source: one Desktop height |
| 361 | // per key, the tallest over the locales read plus slack, and the |
| 362 | // Mobile entries where the derived 1.25x does not cover a reading. |
| 363 | // |
| 364 | // It is written so that a second sweep after pasting the first changes |
| 365 | // nothing. A document as tall as the frame it is in (a shell, the |
| 366 | // stage) measures its own box when its content fits, so slack on that |
| 367 | // reading would grow the box on every sweep; such a key keeps the |
| 368 | // reservation the table already gives it, unless a reading found its |
| 369 | // content taller than its box (fit says which keys fitted every time). |
| 370 | // Mobile entries the table already has are kept unless a reading needs |
| 371 | // more: the 320px drive raises them and prints no table, so the wide |
| 372 | // printout carrying them is what keeps a paste from dropping them. |
| 373 | // previewReserved is never printed: it is a floor, not a measurement. |
| 374 | func printTable(t *testing.T, desk, mob map[string]map[string]int, deskFit, mobFit map[string]bool) { |
| 375 | t.Helper() |
| 376 | keys := map[string]bool{} |
| 377 | for _, m := range desk { |
| 378 | for k := range m { |
| 379 | keys[k] = true |
| 380 | } |
| 381 | } |
| 382 | var sorted []string |
| 383 | for k := range keys { |
| 384 | sorted = append(sorted, k) |
| 385 | } |
| 386 | sort.Strings(sorted) |
| 387 | // Slack, then rounded up to the next ten: twelve languages and three |
| 388 | // type families do not lay out to the same pixel. |
| 389 | up := func(n int) int { return (n + 20 + 9) / 10 * 10 } |
| 390 | tallest := func(m map[string]map[string]int, k string) int { |
| 391 | n := 0 |
| 392 | for _, byKey := range m { |
| 393 | n = max(n, byKey[k]) |
| 394 | } |
| 395 | return n |
| 396 | } |
| 397 | var b strings.Builder |
| 398 | b.WriteString("var previewHeights = map[string]int{\n") |
| 399 | h := map[string]int{} |
| 400 | for _, k := range sorted { |
| 401 | cur, ok := previewHeights[k] |
| 402 | if !ok { |
| 403 | cur = previewHeight |
| 404 | } |
| 405 | h[k] = up(tallest(desk, k)) |
| 406 | if deskFit[k] { |
| 407 | h[k] = cur |
| 408 | } |
| 409 | fmt.Fprintf(&b, "\t%q: %d,\n", k, h[k]) |
| 410 | } |
| 411 | b.WriteString("}\nvar previewMobileHeights = map[string]int{\n") |
| 412 | mh := map[string]int{} |
| 413 | for _, k := range sorted { |
| 414 | m := 0 |
| 415 | if n := mob["en"][k]; n > h[k]*5/4 && !mobFit[k] { |
| 416 | m = up(n) |
| 417 | } |
| 418 | m = max(m, previewMobileHeights[k]) |
| 419 | if m > h[k]*5/4 { |
| 420 | mh[k] = m |
| 421 | fmt.Fprintf(&b, "\t%q: %d,\n", k, m) |
| 422 | } |
| 423 | } |
| 424 | b.WriteString("}\nvar previewMobileLocaleHeights = map[string]map[string]int{\n") |
| 425 | locales := map[string]bool{} |
| 426 | for l := range mob { |
| 427 | if l != "en" { |
| 428 | locales[l] = true |
| 429 | } |
| 430 | } |
| 431 | for l := range previewMobileLocaleHeights { |
| 432 | locales[l] = true |
| 433 | } |
| 434 | var sortedLocales []string |
| 435 | for l := range locales { |
| 436 | sortedLocales = append(sortedLocales, l) |
| 437 | } |
| 438 | sort.Strings(sortedLocales) |
| 439 | for _, l := range sortedLocales { |
| 440 | var lines []string |
| 441 | for _, k := range sorted { |
| 442 | base := max(h[k]*5/4, mh[k]) |
| 443 | n := previewMobileLocaleHeights[l][k] |
| 444 | if r := mob[l][k]; r > base && !mobFit[k] { |
| 445 | n = max(n, up(r)) |
| 446 | } |
| 447 | if n > base { |
| 448 | lines = append(lines, fmt.Sprintf("\t\t%q: %d,", k, n)) |
| 449 | } |
| 450 | } |
| 451 | if len(lines) > 0 { |
| 452 | fmt.Fprintf(&b, "\t%q: {\n%s\n\t},\n", l, strings.Join(lines, "\n")) |
| 453 | } |
| 454 | } |
| 455 | b.WriteString("}\n") |
| 456 | t.Logf("the table as measured:\n%s", b.String()) |
| 457 | } |
| 458 | |
| 459 | // The two drives are the gallery's longest tests, so each is dealt into |
| 460 | // parts, one top-level test apiece, which the suite's slices can put on |
| 461 | // shards of their own. A drive's work is its (locale, page) pairs, in |
| 462 | // locale order and heightPages order within each, and the i-th pair |
| 463 | // goes to part i mod n: the parts are a partition of the drive, each |
| 464 | // pair read in exactly one of them. The checks the drive made once over |
| 465 | // everything it read are made where what they check was read: the |
| 466 | // self-check with en/button.html, the demo with en/index.html, each |
| 467 | // sidebar-shell example on every page it is read; and that something |
| 468 | // was measured and opened, in every part, which is only stricter. |
| 469 | // |
| 470 | // Under FIESTA_SWEEP=full a drive is one part again, its first, so the |
| 471 | // table it prints is the whole table; the other parts skip and say so. |
| 472 | // make heights-sweep runs both drives that way. |
| 473 | const ( |
| 474 | wideHeightParts = 3 |
| 475 | narrowHeightParts = 12 |
| 476 | ) |
| 477 | |
| 478 | // heightsUnit is one page of one locale, a drive's unit of work. |
| 479 | type heightsUnit struct{ locale, file string } |
| 480 | |
| 481 | // heightsPart is part k of n of a drive over locales: its pairs, dealt |
| 482 | // as above. |
| 483 | func heightsPart(t *testing.T, locales []string, k, n int) []heightsUnit { |
| 484 | t.Helper() |
| 485 | if sweepFull() { |
| 486 | if k > 1 { |
| 487 | t.Skipf("FIESTA_SWEEP=full reads the whole drive in part 1 of %d, so the table it prints is whole", n) |
| 488 | } |
| 489 | n = 1 |
| 490 | } |
| 491 | var out []heightsUnit |
| 492 | i := 0 |
| 493 | pages := heightPages(t) |
| 494 | for _, locale := range locales { |
| 495 | for _, file := range pages { |
| 496 | if i%n == k-1 { |
| 497 | out = append(out, heightsUnit{locale, file}) |
| 498 | } |
| 499 | i++ |
| 500 | } |
| 501 | } |
| 502 | if len(out) == 0 { |
| 503 | t.Fatalf("part %d of %d of %d pages in %d locale(s) was dealt nothing", k, n, len(pages), len(locales)) |
| 504 | } |
| 505 | return out |
| 506 | } |
| 507 | |
| 508 | // sidebarShellPages are the examples whose frames hold a sidebar shell: |
| 509 | // each, wherever a narrow part reads it, must read as one, which is |
| 510 | // what proves the 48px of grace goes where it should. A name here the |
| 511 | // manifest no longer has fails, so the list cannot go quietly stale. |
| 512 | var sidebarShellPages = []string{"layout-sidebar.html", "layout-sidebar-page.html", "shell-sidebar.html"} |
| 513 | |
| 514 | // TestPreviewFrameHeightsFitTheirContent1of3 to 3of3: on the Overview |
| 515 | // and every example page of day/en (every locale under |
| 516 | // FIESTA_SWEEP=full), at a 1500px window, every frame's document |
| 517 | // against its box at Desktop and, with Mobile chosen, against --ds-hm; |
| 518 | // then, at each, what the box must hold with each of its menus and |
| 519 | // panels open. A stage frame counts like any other: its document is |
| 520 | // 100dvh tall, so one that fits reads equal to its box. The wide drive |
| 521 | // grants every frame Rastrillo's 48px for content at rest, and none for |
| 522 | // what opens. Preservation: the one wide drive, dealt into parts. |
| 523 | func TestPreviewFrameHeightsFitTheirContent1of3(t *testing.T) { |
| 524 | wideHeights(t, 1, wideHeightParts) |
| 525 | } |
| 526 | |
| 527 | func TestPreviewFrameHeightsFitTheirContent2of3(t *testing.T) { |
| 528 | wideHeights(t, 2, wideHeightParts) |
| 529 | } |
| 530 | |
| 531 | func TestPreviewFrameHeightsFitTheirContent3of3(t *testing.T) { |
| 532 | wideHeights(t, 3, wideHeightParts) |
| 533 | } |
| 534 | |
| 535 | // wideHeights is part k of n of the wide drive. |
| 536 | func wideHeights(t *testing.T, k, n int) { |
| 537 | units := heightsPart(t, wideLocales(), k, n) |
| 538 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 539 | deadline := 12 * time.Minute |
| 540 | if sweepFull() { |
| 541 | deadline = 28 * time.Minute |
| 542 | } |
| 543 | ctx, cancel := context.WithTimeout(rig.Context(), deadline) |
| 544 | defer cancel() |
| 545 | desk, mob := map[string]map[string]int{}, map[string]map[string]int{} |
| 546 | // What a widget with a rest height needs at rest, over the locales |
| 547 | // read: its previewRestHeights entry, which the table below, the |
| 548 | // open heights, does not carry. |
| 549 | restDesk, restMob := map[string]int{}, map[string]int{} |
| 550 | measured, opened, short := 0, 0, 0 |
| 551 | readDemo := false |
| 552 | keep := func(m map[string]int, key string, n int) { |
| 553 | if n > m[key] { |
| 554 | m[key] = n |
| 555 | } |
| 556 | } |
| 557 | // fit says, per key, whether every reading of it was a viewport-sized |
| 558 | // document whose content fitted its box: printTable keeps those at |
| 559 | // the reservation they have. |
| 560 | deskFit, mobFit := map[string]bool{}, map[string]bool{} |
| 561 | fits := func(fit map[string]bool, key string, r heightReading) { |
| 562 | ok := r.Viewport && r.Need <= r.Box |
| 563 | if was, read := fit[key]; read { |
| 564 | ok = ok && was |
| 565 | } |
| 566 | fit[key] = ok |
| 567 | } |
| 568 | for _, u := range units { |
| 569 | locale, file := u.locale, u.file |
| 570 | if desk[locale] == nil { |
| 571 | desk[locale], mob[locale] = map[string]int{}, map[string]int{} |
| 572 | } |
| 573 | where := fmt.Sprintf("%s/%s at 1500px", locale, file) |
| 574 | openPage(t, ctx, where, pageURL(rig, locale, file), 1500, 1000) |
| 575 | eagerly(t, ctx, where) |
| 576 | desktop := readHeights(t, ctx, where+" Desktop") |
| 577 | for key, r := range desktop { |
| 578 | measured++ |
| 579 | keep(desk[locale], key, r.Need) |
| 580 | if r.Rest { |
| 581 | keep(restDesk, key, r.Need) |
| 582 | } |
| 583 | fits(deskFit, key, r) |
| 584 | if shortfall(t, where+" Desktop", key, r, 48) { |
| 585 | short++ |
| 586 | } |
| 587 | } |
| 588 | for key, o := range readOpen(t, ctx, where+" Desktop", "--ds-h") { |
| 589 | opened++ |
| 590 | keep(desk[locale], key, o.Need) |
| 591 | deskFit[key] = false |
| 592 | if openShortfall(t, where+" Desktop", key, o, desktop[key].Open) { |
| 593 | short++ |
| 594 | } |
| 595 | } |
| 596 | clickAll(t, ctx, where, clickedMobile, "Mobile") |
| 597 | mobileSettle(t, ctx, where) |
| 598 | mobile := readHeights(t, ctx, where+" Mobile") |
| 599 | for key, r := range mobile { |
| 600 | keep(mob[locale], key, r.Need) |
| 601 | if r.Rest { |
| 602 | keep(restMob, key, r.Need) |
| 603 | } |
| 604 | fits(mobFit, key, r) |
| 605 | if shortfall(t, where+" Mobile", key, r, 48) { |
| 606 | short++ |
| 607 | } |
| 608 | } |
| 609 | for key, o := range readOpen(t, ctx, where+" Mobile", "--ds-hm") { |
| 610 | keep(mob[locale], key, o.Need) |
| 611 | mobFit[key] = false |
| 612 | if openShortfall(t, where+" Mobile", key, o, mobile[key].Open) { |
| 613 | short++ |
| 614 | } |
| 615 | } |
| 616 | if file == "button.html" && locale == "en" { |
| 617 | selfCheck(t, ctx, where+" Mobile", "--ds-hm", mobile) |
| 618 | clickAll(t, ctx, where, clickedDesktop, "Desktop") |
| 619 | selfCheck(t, ctx, where+" Desktop", "--ds-h", desktop) |
| 620 | } |
| 621 | if file == "index.html" && locale == "en" { |
| 622 | if _, ok := desk["en"]["demo"]; !ok { |
| 623 | t.Fatal("the Overview's demo was not measured") |
| 624 | } |
| 625 | readDemo = true |
| 626 | } |
| 627 | } |
| 628 | if measured == 0 || opened == 0 { |
| 629 | t.Fatalf("%d frames measured and %d opened; the sweep read too little", measured, opened) |
| 630 | } |
| 631 | 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) |
| 632 | if sweepFull() { |
| 633 | printTable(t, desk, mob, deskFit, mobFit) |
| 634 | for key, n := range restDesk { |
| 635 | 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) |
| 636 | } |
| 637 | } |
| 638 | } |
| 639 | |
| 640 | // TestPreviewFrameHeightsFitAtThePhonesNarrowest1of12 to 12of12: the same |
| 641 | // with Mobile chosen at a 320px viewport, where a Mobile frame lays out |
| 642 | // at the stage's width and text that fits a line at 390px wraps, over |
| 643 | // en, ar, ru and ga (all twelve under the sweep), closed and open. Only |
| 644 | // a frame holding a sidebar shell gets the 48px of grace, as |
| 645 | // Rastrillo's narrow drive grants it: the rail is 100dvh tall and no |
| 646 | // box can fit it; every other frame gets none. The control: every frame |
| 647 | // is laid out under 390px, at its stage's width. Preservation: the one |
| 648 | // narrow drive, dealt into parts. |
| 649 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest1of12(t *testing.T) { |
| 650 | narrowHeights(t, 1, narrowHeightParts) |
| 651 | } |
| 652 | |
| 653 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest2of12(t *testing.T) { |
| 654 | narrowHeights(t, 2, narrowHeightParts) |
| 655 | } |
| 656 | |
| 657 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest3of12(t *testing.T) { |
| 658 | narrowHeights(t, 3, narrowHeightParts) |
| 659 | } |
| 660 | |
| 661 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest4of12(t *testing.T) { |
| 662 | narrowHeights(t, 4, narrowHeightParts) |
| 663 | } |
| 664 | |
| 665 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest5of12(t *testing.T) { |
| 666 | narrowHeights(t, 5, narrowHeightParts) |
| 667 | } |
| 668 | |
| 669 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest6of12(t *testing.T) { |
| 670 | narrowHeights(t, 6, narrowHeightParts) |
| 671 | } |
| 672 | |
| 673 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest7of12(t *testing.T) { |
| 674 | narrowHeights(t, 7, narrowHeightParts) |
| 675 | } |
| 676 | |
| 677 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest8of12(t *testing.T) { |
| 678 | narrowHeights(t, 8, narrowHeightParts) |
| 679 | } |
| 680 | |
| 681 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest9of12(t *testing.T) { |
| 682 | narrowHeights(t, 9, narrowHeightParts) |
| 683 | } |
| 684 | |
| 685 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest10of12(t *testing.T) { |
| 686 | narrowHeights(t, 10, narrowHeightParts) |
| 687 | } |
| 688 | |
| 689 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest11of12(t *testing.T) { |
| 690 | narrowHeights(t, 11, narrowHeightParts) |
| 691 | } |
| 692 | |
| 693 | func TestPreviewFrameHeightsFitAtThePhonesNarrowest12of12(t *testing.T) { |
| 694 | narrowHeights(t, 12, narrowHeightParts) |
| 695 | } |
| 696 | |
| 697 | // narrowHeights is part k of n of the narrow drive. |
| 698 | func narrowHeights(t *testing.T, k, n int) { |
| 699 | units := heightsPart(t, narrowLocales(), k, n) |
| 700 | pages := map[string]bool{} |
| 701 | for _, file := range heightPages(t) { |
| 702 | pages[file] = true |
| 703 | } |
| 704 | shellPage := map[string]bool{} |
| 705 | for _, file := range sidebarShellPages { |
| 706 | if !pages[file] { |
| 707 | t.Fatalf("%s is listed as a sidebar shell's page, but the manifest has no such example", file) |
| 708 | } |
| 709 | shellPage[file] = true |
| 710 | } |
| 711 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 712 | deadline := 15 * time.Minute |
| 713 | if sweepFull() { |
| 714 | deadline = 28 * time.Minute |
| 715 | } |
| 716 | ctx, cancel := context.WithTimeout(rig.Context(), deadline) |
| 717 | defer cancel() |
| 718 | measured, opened, short, shells := 0, 0, 0, 0 |
| 719 | locales := map[string]bool{} |
| 720 | var raise []string |
| 721 | for _, u := range units { |
| 722 | locale, file := u.locale, u.file |
| 723 | locales[locale] = true |
| 724 | where := fmt.Sprintf("%s/%s at 320px", locale, file) |
| 725 | openPage(t, ctx, where, pageURL(rig, locale, file), 320, 844) |
| 726 | eagerly(t, ctx, where) |
| 727 | clickAll(t, ctx, where, clickedMobile, "Mobile") |
| 728 | mobileSettle(t, ctx, where) |
| 729 | var wide []string |
| 730 | mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll(".ds-view")].filter(v => { |
| 731 | 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)) |
| 732 | if len(wide) > 0 { |
| 733 | t.Fatalf("%s: frames %v are not at the stage's narrow width; the readings would be of the 390px frame", where, wide) |
| 734 | } |
| 735 | got := readHeights(t, ctx, where) |
| 736 | need, openNeed := map[string]int{}, map[string]int{} |
| 737 | pageShells := 0 |
| 738 | for key, r := range got { |
| 739 | measured++ |
| 740 | grace := 0 |
| 741 | if r.Shell { |
| 742 | grace, shells, pageShells = 48, shells+1, pageShells+1 |
| 743 | } |
| 744 | need[key] = r.Need |
| 745 | if shortfall(t, where, key, r, grace) { |
| 746 | short++ |
| 747 | } |
| 748 | } |
| 749 | if shellPage[file] && pageShells == 0 { |
| 750 | t.Errorf("%s: no frame read as a sidebar shell, so the drive cannot say the grace went where it should", where) |
| 751 | } |
| 752 | for key, o := range readOpen(t, ctx, where, "--ds-hm") { |
| 753 | opened++ |
| 754 | openNeed[key] = o.Need |
| 755 | if openShortfall(t, where, key, o, got[key].Open) { |
| 756 | short++ |
| 757 | } |
| 758 | } |
| 759 | for key, n := range need { |
| 760 | grace := 0 |
| 761 | if got[key].Shell { |
| 762 | grace = 48 |
| 763 | } |
| 764 | // A widget without a rest height holds both in one box. |
| 765 | rest, open := n, max(n, openNeed[key]) |
| 766 | if got[key].Rest { |
| 767 | open = openNeed[key] |
| 768 | if rest > got[key].Box+grace { |
| 769 | raise = append(raise, fmt.Sprintf("%s %q: rest Mobile %d (previewRestHeights),", locale, key, (rest+20+9)/10*10)) |
| 770 | } |
| 771 | } |
| 772 | if open > got[key].Open+grace { |
| 773 | raise = append(raise, fmt.Sprintf("%s %q: %d,", locale, key, (open+20+9)/10*10)) |
| 774 | } |
| 775 | } |
| 776 | if file == "button.html" && locale == "en" { |
| 777 | selfCheck(t, ctx, where, "--ds-hm", got) |
| 778 | } |
| 779 | } |
| 780 | if measured == 0 || opened == 0 { |
| 781 | t.Fatalf("%d frames measured and %d opened; the sweep read too little", measured, opened) |
| 782 | } |
| 783 | sort.Strings(raise) |
| 784 | 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) |
| 785 | if len(raise) > 0 { |
| 786 | t.Logf("Mobile entries to raise, as locale key: height:\n\t%s", strings.Join(raise, "\n\t")) |
| 787 | } |
| 788 | } |
| 789 | |
| 790 | // columnFrame reads the column page's one frame: its height in the |
| 791 | // frame's own pixels, which is the box's --ds-h or --ds-hm resolved, |
| 792 | // and whether its language and scheme menus are open, after two frames |
| 793 | // and a beat, so a resize view.js makes on a toggle has landed. act, |
| 794 | // run in the framed document first, is "" or a click to make. |
| 795 | func columnFrame(t *testing.T, ctx context.Context, where, act string) (height float64, open []bool) { |
| 796 | t.Helper() |
| 797 | var got struct { |
| 798 | Height float64 |
| 799 | Open []bool |
| 800 | } |
| 801 | at(t, ctx, `(async () => { |
| 802 | const f = document.querySelector(".ds-view__frame"), d = f.contentDocument, w = d.defaultView; |
| 803 | `+act+` |
| 804 | for (const win of [window, w]) await new Promise(r => win.requestAnimationFrame(() => win.requestAnimationFrame(() => setTimeout(r, 50)))); |
| 805 | return JSON.stringify({Height: parseFloat(getComputedStyle(f).height), Open: [...d.querySelectorAll("details[rst-dropdown]")].map(m => m.open)}); |
| 806 | })()`, &got, awaitPromise) |
| 807 | if len(got.Open) != 2 { |
| 808 | t.Fatalf("%s: the framed column has %d menus, want its footer's two", where, len(got.Open)) |
| 809 | } |
| 810 | return got.Height, got.Open |
| 811 | } |
| 812 | |
| 813 | // TestTheColumnPreviewGrowsOnlyWhileAMenuIsOpen: the column's box is |
| 814 | // its page's height at rest and the open height while a footer menu |
| 815 | // is open, on Desktop and on Mobile. Opening the scheme menu while the |
| 816 | // language menu is open closes the language menu (the two share a |
| 817 | // name), and the box stays open; closing the last one brings it back. |
| 818 | // With scripts off nothing could grow the box, so it keeps the open |
| 819 | // height, and a menu opened there is still all in view. |
| 820 | func TestTheColumnPreviewGrowsOnlyWhileAMenuIsOpen(t *testing.T) { |
| 821 | key := "layout-column/" + mustDefault(t, "layout-column") |
| 822 | rest, ok := previewRestHeights[key] |
| 823 | if !ok { |
| 824 | t.Fatalf("%s has no rest height to switch from", key) |
| 825 | } |
| 826 | openDesktop, openMobile := previewHeightsFor("en", key) |
| 827 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 828 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 829 | defer cancel() |
| 830 | const language, scheme = `d.querySelector("[rst-locale] > summary").click();`, `d.querySelector("[rst-scheme] > summary").click();` |
| 831 | want := func(where string, got, wantH float64, open []bool, wantOpen ...bool) { |
| 832 | t.Helper() |
| 833 | if fmt.Sprint(open) != fmt.Sprint(wantOpen) { |
| 834 | t.Fatalf("%s: the menus' open states are %v, want %v; the step did not do what it says", where, open, wantOpen) |
| 835 | } |
| 836 | if got < wantH-1 || got > wantH+1 { |
| 837 | t.Errorf("%s: the frame is %.0fpx tall, want %.0f", where, got, wantH) |
| 838 | } |
| 839 | } |
| 840 | openPage(t, ctx, "en/layout-column.html", pageURL(rig, "en", "layout-column.html"), 1500, 1000) |
| 841 | eagerly(t, ctx, "en/layout-column.html") |
| 842 | for _, mode := range []struct { |
| 843 | name string |
| 844 | rest, open int |
| 845 | }{{"Desktop", rest.Desktop, openDesktop}, {"Mobile", rest.Mobile, openMobile}} { |
| 846 | if mode.name == "Mobile" { |
| 847 | clickAll(t, ctx, "en/layout-column.html", clickedMobile, "Mobile") |
| 848 | mobileSettle(t, ctx, "en/layout-column.html") |
| 849 | } |
| 850 | where := "en/layout-column.html " + mode.name |
| 851 | h, open := columnFrame(t, ctx, where, "") |
| 852 | want(where+" at rest", h, float64(mode.rest), open, false, false) |
| 853 | h, open = columnFrame(t, ctx, where, language) |
| 854 | want(where+" with Language open", h, float64(mode.open), open, true, false) |
| 855 | h, open = columnFrame(t, ctx, where, scheme) |
| 856 | want(where+" with Colour scheme opened over Language", h, float64(mode.open), open, false, true) |
| 857 | h, open = columnFrame(t, ctx, where, scheme) |
| 858 | want(where+" with both closed again", h, float64(mode.rest), open, false, false) |
| 859 | } |
| 860 | |
| 861 | off := galleryrig.NoScripts(t, ctx) |
| 862 | mustRun(t, off, chromedp.EmulateViewport(1500, 1000), chromedp.Navigate(pageURL(rig, "en", "layout-column.html")), chromedp.WaitReady(".ds-view__frame", chromedp.ByQuery)) |
| 863 | galleryrig.RequireScriptsOff(t, off, "en/layout-column.html, scripts off") |
| 864 | var h float64 |
| 865 | until(t, off, "en/layout-column.html, scripts off", `!!document.querySelector(".ds-view__frame").contentDocument?.querySelector("[rst-page-foot]")`) |
| 866 | at(t, off, `JSON.stringify(parseFloat(getComputedStyle(document.querySelector(".ds-view__frame")).height))`, &h) |
| 867 | if h < float64(openDesktop)-1 || h > float64(openDesktop)+1 { |
| 868 | 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) |
| 869 | } |
| 870 | } |
| 871 | |