| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "math" |
| 10 | "net/http" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/chromedp/cdproto/input" |
| 16 | "github.com/chromedp/cdproto/runtime" |
| 17 | "github.com/chromedp/chromedp" |
| 18 | "github.com/chromedp/chromedp/kb" |
| 19 | |
| 20 | "amadan.net/rastrillo/fiesta" |
| 21 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 22 | ) |
| 23 | |
| 24 | // The pinned bar: what it reserves, what lands under it, what paints |
| 25 | // over it, its language menu, the controls' heights, the prev/next |
| 26 | // pair under main, and the scheme a returning visitor sees before the |
| 27 | // first paint. |
| 28 | |
| 29 | // focusTopJS is the top of whatever has focus, or of the element a |
| 30 | // fragment targets, in the page's coordinates. Inside a preview frame |
| 31 | // the element's offset is in the frame's own pixels and the frame may |
| 32 | // be drawn scaled, so the offset is multiplied by the frame's rendered |
| 33 | // width over its layout width before the frame's top is added. |
| 34 | const focusTopJS = `((el) => { |
| 35 | let top = 0, k = 1; |
| 36 | el = el || document.activeElement; |
| 37 | while (el && el.tagName === "IFRAME") { |
| 38 | const r = el.getBoundingClientRect(); |
| 39 | top += r.top; k *= r.width / el.offsetWidth; |
| 40 | const d = el.contentDocument; |
| 41 | if (!d || !d.activeElement || d.activeElement === d.body) break; |
| 42 | el = d.activeElement; |
| 43 | } |
| 44 | const r = el.getBoundingClientRect(); |
| 45 | const main = document.getElementById("main"), outer = document.activeElement; |
| 46 | return JSON.stringify({Top: el.tagName === "IFRAME" ? top : top + r.top * k, What: el.tagName.toLowerCase() + (el.id ? "#" + el.id : ""), |
| 47 | Bar: document.querySelector(".ds-top").getBoundingClientRect().bottom, |
| 48 | End: Math.abs(scrollY + innerHeight - document.documentElement.scrollHeight) < 2, |
| 49 | InMain: !!outer && outer !== document.body && main.contains(outer)}); |
| 50 | })` |
| 51 | |
| 52 | type focusReading struct { |
| 53 | Top, Bar float64 |
| 54 | What string |
| 55 | End bool |
| 56 | // InMain says whether what has focus is inside main (a frame's |
| 57 | // own controls count through their frame): the Tab walks stop |
| 58 | // when focus leaves it for the bar, the rail or the browser. |
| 59 | InMain bool |
| 60 | } |
| 61 | |
| 62 | func readFocus(t *testing.T, ctx context.Context, where, target string) focusReading { |
| 63 | t.Helper() |
| 64 | var raw string |
| 65 | mustRun(t, ctx, chromedp.Evaluate(focusTopJS+"("+target+")", &raw)) |
| 66 | var r focusReading |
| 67 | if err := json.Unmarshal([]byte(raw), &r); err != nil { |
| 68 | t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| 69 | } |
| 70 | return r |
| 71 | } |
| 72 | |
| 73 | // TestFocusIsNeverUnderThePinnedBar: focus and fragment targets stop |
| 74 | // below the pinned bar, never under it (WCAG 2.4.11): forty Tabs on |
| 75 | // from the first control in main and forty back, the skip link's |
| 76 | // target, and every rail fragment of the page. The sign-in page, whose |
| 77 | // twelve framed forms give the walk its forty stops inside main, and |
| 78 | // Tokens, whose groups the rail links by fragment. A walk stops where |
| 79 | // focus leaves main for the chrome or the browser, and the forward |
| 80 | // walk is held to at least twenty stops so it cannot stop short. A |
| 81 | // target near the end of the page may stop lower, because the browser |
| 82 | // clamps the scroll at the end, but never higher. |
| 83 | func TestFocusIsNeverUnderThePinnedBar(t *testing.T) { |
| 84 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 85 | ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| 86 | defer cancel() |
| 87 | theme := fiesta.ThemeNames()[0] |
| 88 | fragments := 0 |
| 89 | for _, file := range []string{"signin.html", "tokens.html"} { |
| 90 | for _, locale := range []string{"en", "ar"} { |
| 91 | for _, w := range []int64{1440, 800} { |
| 92 | where := fmt.Sprintf("%s/%s/%s at %dpx", theme, locale, file, w) |
| 93 | mustRun(t, ctx, chromedp.EmulateViewport(w, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, file)), chromedp.WaitReady(`main`, chromedp.ByQuery)) |
| 94 | present(t, ctx, where, "[rst-shell-sidebar] > .ds-rail") |
| 95 | eagerly(t, ctx, where) |
| 96 | under := func(how string, r focusReading) { |
| 97 | if r.Top < r.Bar-1 { |
| 98 | t.Errorf("%s: %s puts %s at %.1fpx, under the bar ending at %.1fpx", where, how, r.What, r.Top, r.Bar) |
| 99 | } |
| 100 | } |
| 101 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("main [rst-page]").querySelector("a[href], button, summary, input:not([type=hidden]), iframe").focus(); true`, nil)) |
| 102 | stops := 0 |
| 103 | for i := 0; i < 40; i++ { |
| 104 | mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) |
| 105 | r := readFocus(t, ctx, where, "") |
| 106 | if !r.InMain { |
| 107 | break |
| 108 | } |
| 109 | stops++ |
| 110 | under(fmt.Sprintf("tab %d", i+1), r) |
| 111 | } |
| 112 | if file == "signin.html" && stops < 20 { |
| 113 | t.Errorf("%s: the forward walk left main after %d stops; the page was chosen for its forty", where, stops) |
| 114 | } |
| 115 | for i := 0; i < 40; i++ { |
| 116 | mustRun(t, ctx, chromedp.KeyEvent(kb.Tab, chromedp.KeyModifiers(input.ModifierShift))) |
| 117 | r := readFocus(t, ctx, where, "") |
| 118 | if !r.InMain { |
| 119 | break |
| 120 | } |
| 121 | under(fmt.Sprintf("shift+tab %d", i+1), r) |
| 122 | } |
| 123 | mustRun(t, ctx, chromedp.Evaluate(`scrollTo(0, 2000); document.querySelector("[rst-skip]").click(); true`, nil)) |
| 124 | under("the skip link", readFocus(t, ctx, where, `document.getElementById("main")`)) |
| 125 | var hrefs []string |
| 126 | mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll("#ds-nav a[href*='#']")].filter(a => a.pathname === location.pathname).map(a => a.hash.slice(1))`, &hrefs)) |
| 127 | for _, id := range hrefs { |
| 128 | fragments++ |
| 129 | mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.querySelector('#ds-nav a[href$="#%s"]').click(); true`, id), nil)) |
| 130 | r := readFocus(t, ctx, where, fmt.Sprintf(`document.getElementById(%q)`, id)) |
| 131 | under("#"+id, r) |
| 132 | if !r.End && r.Top > r.Bar+40 { |
| 133 | t.Errorf("%s: #%s lands at %.1fpx, far below the bar's %.1fpx with the page not at its end; the scroll padding is not what the fragment stopped at", where, id, r.Top, r.Bar) |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | if fragments == 0 { |
| 140 | t.Fatal("the rail links no fragment of any page read; the fragment leg checked nothing") |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // stripBadJS scans down the page in steps of at most half the strip's |
| 145 | // own height, sampling roughly every 32px across the strip's width at |
| 146 | // its top, middle and bottom edges, and reports every sampled point |
| 147 | // where elementFromPoint lands outside the strip. |
| 148 | const stripBadJS = `((sel) => { |
| 149 | const strip = document.querySelector(sel); |
| 150 | const h = strip.getBoundingClientRect().height, step = Math.max(1, h / 2); |
| 151 | const bad = []; |
| 152 | for (let y = 0; y < document.documentElement.scrollHeight; y += step) { |
| 153 | scrollTo(0, y); |
| 154 | const r = strip.getBoundingClientRect(); |
| 155 | for (const yy of [r.top + 2, (r.top + r.bottom) / 2, r.bottom - 2]) { |
| 156 | for (let x = r.left; x <= r.right; x += 32) { |
| 157 | const hit = document.elementFromPoint(x, yy); |
| 158 | if (!hit || !strip.contains(hit)) bad.push({X: Math.round(x), Y: Math.round(yy), Hit: hit ? hit.tagName + "." + hit.className : "nothing"}); |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | return JSON.stringify(bad); |
| 163 | })` |
| 164 | |
| 165 | // stripH2PointsJS finds, at the same scroll steps and sample points, |
| 166 | // every point a <main> heading's own box covers, by geometry: the |
| 167 | // control for the scan's density. |
| 168 | const stripH2PointsJS = `((sel) => { |
| 169 | const strip = document.querySelector(sel); |
| 170 | const h = strip.getBoundingClientRect().height, step = Math.max(1, h / 2); |
| 171 | const pts = []; |
| 172 | for (let y = 0; y < document.documentElement.scrollHeight; y += step) { |
| 173 | scrollTo(0, y); |
| 174 | const r = strip.getBoundingClientRect(); |
| 175 | const h2s = [...document.querySelectorAll("main h2")].map(h2 => h2.getBoundingClientRect()); |
| 176 | for (const yy of [r.top + 2, (r.top + r.bottom) / 2, r.bottom - 2]) { |
| 177 | for (let x = r.left; x <= r.right; x += 32) { |
| 178 | if (h2s.some(hr => x >= hr.left && x <= hr.right && yy >= hr.top && yy <= hr.bottom)) pts.push({X: Math.round(x), Y: Math.round(yy)}); |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | return JSON.stringify(pts); |
| 183 | })` |
| 184 | |
| 185 | type stripPoint struct { |
| 186 | X, Y float64 |
| 187 | Hit string |
| 188 | } |
| 189 | |
| 190 | func evalStripPoints(t *testing.T, ctx context.Context, where, js, selector string) []stripPoint { |
| 191 | t.Helper() |
| 192 | var raw string |
| 193 | mustRun(t, ctx, chromedp.Evaluate(js+"("+fmt.Sprintf("%q", selector)+")", &raw)) |
| 194 | var pts []stripPoint |
| 195 | if err := json.Unmarshal([]byte(raw), &pts); err != nil { |
| 196 | t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| 197 | } |
| 198 | return pts |
| 199 | } |
| 200 | |
| 201 | // TestNothingPaintsOverTheBarOrTheBackStrip: nothing in the content |
| 202 | // paints over the pinned chrome: at 1440px the bar, and at 390px the |
| 203 | // shell's back strip, are what elementFromPoint finds at their edges |
| 204 | // everywhere down a page of frames. A second pass raises every <main> |
| 205 | // heading above the strip and checks the scan catches every point a |
| 206 | // heading's box covers: the control for the scan's own density. |
| 207 | func TestNothingPaintsOverTheBarOrTheBackStrip(t *testing.T) { |
| 208 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 209 | ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) |
| 210 | defer cancel() |
| 211 | theme := fiesta.ThemeNames()[0] |
| 212 | for _, c := range []struct { |
| 213 | w int64 |
| 214 | strip string |
| 215 | }{{1440, ".ds-top"}, {390, "[rst-shell-back]"}} { |
| 216 | where := fmt.Sprintf("signin.html at %dpx", c.w) |
| 217 | mustRun(t, ctx, chromedp.EmulateViewport(c.w, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "signin.html")), chromedp.WaitReady("body")) |
| 218 | present(t, ctx, where, "[rst-shell-sidebar] > .ds-rail") |
| 219 | mustRun(t, ctx, chromedp.WaitVisible(c.strip, chromedp.ByQuery)) |
| 220 | eagerly(t, ctx, where) |
| 221 | before := evalStripPoints(t, ctx, where, stripBadJS, c.strip) |
| 222 | if len(before) > 0 { |
| 223 | if len(before) > 6 { |
| 224 | before = before[:6] |
| 225 | } |
| 226 | t.Errorf("%s: something paints over %s: %v", where, c.strip, before) |
| 227 | } |
| 228 | h2Points := evalStripPoints(t, ctx, where, stripH2PointsJS, c.strip) |
| 229 | if len(h2Points) == 0 { |
| 230 | t.Fatalf("%s: no heading passes under %s at any scroll step, so nothing checks the scan can see one", where, c.strip) |
| 231 | } |
| 232 | mustRun(t, ctx, chromedp.Evaluate(`document.head.insertAdjacentHTML("beforeend", "<style>main h2{position:relative;z-index:11}</style>"); true`, nil)) |
| 233 | after := evalStripPoints(t, ctx, where, stripBadJS, c.strip) |
| 234 | caught := make(map[[2]int]bool, len(after)) |
| 235 | for _, p := range after { |
| 236 | caught[[2]int{int(p.X), int(p.Y)}] = true |
| 237 | } |
| 238 | var missed []stripPoint |
| 239 | for _, p := range h2Points { |
| 240 | if !caught[[2]int{int(p.X), int(p.Y)}] { |
| 241 | missed = append(missed, p) |
| 242 | } |
| 243 | } |
| 244 | if len(missed) > 0 { |
| 245 | if len(missed) > 6 { |
| 246 | missed = missed[:6] |
| 247 | } |
| 248 | t.Errorf("%s: a heading raised above %s went uncaught at %d of %d points its own box covers: %v", where, c.strip, len(missed), len(h2Points), missed) |
| 249 | } |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | // barFit reads the bar's box, the union of what is inside it, whether |
| 254 | // that content shares one line, and the height the content alone |
| 255 | // needs. The bar's own box is fixed at --ds-bar-h, so it cannot |
| 256 | // measure its own overflow; its children can. |
| 257 | const barFit = `(() => { |
| 258 | const bar = document.querySelector(".ds-top"), b = bar.getBoundingClientRect(); |
| 259 | let top = Infinity, bottom = -Infinity, left = Infinity, right = -Infinity, maxTop = -Infinity, minBottom = Infinity; |
| 260 | for (const el of bar.querySelectorAll(".ds-top__brand, .ds-top__controls > *")) { |
| 261 | const r = el.getBoundingClientRect(); |
| 262 | if (!r.width && !r.height) continue; |
| 263 | top = Math.min(top, r.top); bottom = Math.max(bottom, r.bottom); |
| 264 | left = Math.min(left, r.left); right = Math.max(right, r.right); |
| 265 | maxTop = Math.max(maxTop, r.top); minBottom = Math.min(minBottom, r.bottom); |
| 266 | } |
| 267 | const prevStyle = bar.getAttribute("style"); |
| 268 | bar.style.blockSize = "auto"; |
| 269 | const need = bar.getBoundingClientRect().height; |
| 270 | if (prevStyle === null) bar.removeAttribute("style"); else bar.setAttribute("style", prevStyle); |
| 271 | return JSON.stringify({Top: b.top, Bottom: b.bottom, Left: b.left, Right: b.right, |
| 272 | UTop: top, UBottom: bottom, ULeft: left, URight: right, Over: bar.scrollWidth - bar.clientWidth, |
| 273 | Need: need, MaxTop: maxTop, MinBottom: minBottom}); |
| 274 | })()` |
| 275 | |
| 276 | type barReading struct { |
| 277 | Top, Bottom, Left, Right, UTop, UBottom, ULeft, URight, Over, Need, MaxTop, MinBottom float64 |
| 278 | } |
| 279 | |
| 280 | // barHSlack is how far above the measured maximum --ds-bar-h may sit: |
| 281 | // values are rounded up to the next quarter rem (4px), so up to 4px of |
| 282 | // slack is the rounding itself rather than a reservation nobody needs. |
| 283 | const barHSlack = 4.5 |
| 284 | |
| 285 | // TestThePinnedBarFitsItsReservation: every theme and locale, scripts |
| 286 | // on and off, at both edges of both bands: what is in the bar lies |
| 287 | // inside it, nothing in it overflows sideways, it is still at the top |
| 288 | // after scrolling to the end, and from 1024px it never wraps. A |
| 289 | // failure names the height the band needs, which is the number to |
| 290 | // write into --ds-bar-h; the last check does the comparison the other |
| 291 | // way, catching a reservation nobody's content has needed since. |
| 292 | func TestThePinnedBarFitsItsReservation(t *testing.T) { |
| 293 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 294 | ctx, cancel := context.WithTimeout(rig.Context(), 600*time.Second) |
| 295 | defer cancel() |
| 296 | need := map[string]float64{} |
| 297 | reservation := map[string]float64{} |
| 298 | for _, scripts := range []bool{true, false} { |
| 299 | tab := ctx |
| 300 | if !scripts { |
| 301 | tab = noScripts(t, ctx) |
| 302 | } |
| 303 | for _, theme := range fiesta.ThemeNames() { |
| 304 | for _, locale := range Locales { |
| 305 | url := rig.Origin + pageAt(theme, locale, "form.html") |
| 306 | mustRun(t, tab, chromedp.EmulateViewport(1440, 900), chromedp.Navigate(url), chromedp.WaitReady("body")) |
| 307 | present(t, tab, theme+"/"+locale, "[rst-shell-sidebar] > .ds-rail") |
| 308 | if !scripts { |
| 309 | requireScriptsOff(t, tab, fmt.Sprintf("%s/%s scripts off", theme, locale)) |
| 310 | } |
| 311 | for _, w := range []int64{1440, 1024, 1023, 800} { |
| 312 | where := fmt.Sprintf("%s/%s at %dpx, scripts %v", theme, locale, w, scripts) |
| 313 | band := "1024 and up" |
| 314 | if w < 1024 { |
| 315 | band = "800 to 1023" |
| 316 | } |
| 317 | var raw string |
| 318 | var after float64 |
| 319 | mustRun(t, tab, |
| 320 | chromedp.EmulateViewport(w, 900), |
| 321 | chromedp.Evaluate(`scrollTo(0, 0)`, nil), |
| 322 | chromedp.Evaluate(barFit, &raw), |
| 323 | chromedp.Evaluate(`scrollTo(0, document.documentElement.scrollHeight); document.querySelector(".ds-top").getBoundingClientRect().top`, &after), |
| 324 | ) |
| 325 | var r barReading |
| 326 | if err := json.Unmarshal([]byte(raw), &r); err != nil { |
| 327 | t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| 328 | } |
| 329 | need[band] = max(need[band], r.Need) |
| 330 | reservation[band] = r.Bottom - r.Top |
| 331 | if r.UTop < r.Top-0.5 || r.UBottom > r.Bottom+0.5 || r.ULeft < r.Left-0.5 || r.URight > r.Right+0.5 { |
| 332 | t.Errorf("%s: the bar's contents [%v…%v × %v…%v] spill out of its box [%v…%v × %v…%v]; this band needs --ds-bar-h of at least %.0fpx", where, r.ULeft, r.URight, r.UTop, r.UBottom, r.Left, r.Right, r.Top, r.Bottom, r.Need) |
| 333 | } |
| 334 | if r.Over > 0.5 { |
| 335 | t.Errorf("%s: the bar overflows sideways by %.1fpx", where, r.Over) |
| 336 | } |
| 337 | if after < -0.5 || after > 0.5 { |
| 338 | t.Errorf("%s: scrolled to the end, the bar's top is at %.1fpx, not pinned at 0", where, after) |
| 339 | } |
| 340 | if w >= 1024 && r.MaxTop >= r.MinBottom-0.5 { |
| 341 | t.Errorf("%s: the brand and the controls do not share one line (the latest top %.1fpx is not above the earliest bottom %.1fpx); the bar has wrapped", where, r.MaxTop, r.MinBottom) |
| 342 | } |
| 343 | } |
| 344 | } |
| 345 | } |
| 346 | } |
| 347 | for _, band := range []string{"1024 and up", "800 to 1023"} { |
| 348 | if over := reservation[band] - need[band]; over > barHSlack { |
| 349 | t.Errorf("--ds-bar-h for %s reserves %.1fpx but the measured maximum content needs only %.1fpx, %.1fpx more than this drive's rounding slack", band, reservation[band], need[band], over) |
| 350 | } |
| 351 | } |
| 352 | t.Logf("tallest bar contents per band (the --ds-bar-h each needs): %v", need) |
| 353 | } |
| 354 | |
| 355 | // withoutAnchorPositioning replaces the page's tokens.css with a copy |
| 356 | // whose anchor-positioning @supports condition can never hold, which |
| 357 | // is what an engine without the feature gets: the menu panel |
| 358 | // positioned absolutely under its summary instead of fixed against it. |
| 359 | const withoutAnchorPositioning = `(async () => { |
| 360 | const link = document.querySelector('link[href*="/tokens."][href$=".css"]'); |
| 361 | const css = await (await fetch(link.href)).text(); |
| 362 | const style = document.createElement("style"); |
| 363 | style.textContent = css.replaceAll("@supports (position-area: block-end) and (position-try-fallbacks: flip-block)", "@supports (position-area: fiesta-no-such-value)"); |
| 364 | link.replaceWith(style); |
| 365 | return true; |
| 366 | })()` |
| 367 | |
| 368 | // requireAnchorPositioning is the control for a leg that says which |
| 369 | // positioning the panel got: fixed with anchor positioning, not |
| 370 | // without. |
| 371 | func requireAnchorPositioning(t *testing.T, ctx context.Context, where, selector string, want bool) { |
| 372 | t.Helper() |
| 373 | var pos string |
| 374 | mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+fmt.Sprintf("%q", selector)+`)).position`, &pos)) |
| 375 | if (pos == "fixed") != want { |
| 376 | t.Fatalf("%s: the menu panel is position: %s; this leg needs anchor positioning %v", where, pos, want) |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | // TestTheBarsLanguageMenuStaysOnScreen: the bar's language menu, |
| 381 | // opened, lies inside the viewport and every language is reachable by |
| 382 | // keyboard: left to right and right to left, with anchor positioning |
| 383 | // and without, with scripts and without, at both edges of both bands. |
| 384 | // On the sign-in page, whose twelve frames make it tall enough at |
| 385 | // every width to draw the scrollbar the measurement is taken against. |
| 386 | func TestTheBarsLanguageMenuStaysOnScreen(t *testing.T) { |
| 387 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, harness.WithScrollbars()) |
| 388 | ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| 389 | defer cancel() |
| 390 | theme := fiesta.ThemeNames()[0] |
| 391 | for _, scripts := range []bool{true, false} { |
| 392 | tab := ctx |
| 393 | if !scripts { |
| 394 | tab = noScripts(t, ctx) |
| 395 | } |
| 396 | for _, locale := range []string{"en", "ar"} { |
| 397 | for _, anchored := range []bool{true, false} { |
| 398 | for _, w := range []int64{1440, 1024, 1023, 800} { |
| 399 | where := fmt.Sprintf("%s/%s at %dpx, scripts %v, anchor positioning %v", theme, locale, w, scripts, anchored) |
| 400 | mustRun(t, tab, chromedp.EmulateViewport(w, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "signin.html")), chromedp.WaitReady("body")) |
| 401 | present(t, tab, where, "[rst-shell-sidebar] > .ds-rail") |
| 402 | if !scripts { |
| 403 | requireScriptsOff(t, tab, where) |
| 404 | } |
| 405 | if !anchored { |
| 406 | mustRun(t, tab, chromedp.Evaluate(withoutAnchorPositioning, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })) |
| 407 | } |
| 408 | requireAnchorPositioning(t, tab, where, ".ds-top__controls [rst-locale] > [rst-dropdown-menu]", anchored) |
| 409 | requireDrawnScrollbar(t, tab, where) |
| 410 | var raw string |
| 411 | mustRun(t, tab, chromedp.Evaluate(`(() => { |
| 412 | const d = document.querySelector(".ds-top__controls [rst-locale]"); |
| 413 | d.open = true; |
| 414 | const p = d.querySelector("[rst-dropdown-menu]").getBoundingClientRect(); |
| 415 | d.querySelector("summary").focus(); |
| 416 | return JSON.stringify({L: p.left, R: p.right, T: p.top, B: p.bottom, W: document.documentElement.clientWidth, H: document.documentElement.clientHeight}); |
| 417 | })()`, &raw)) |
| 418 | var p struct{ L, R, T, B, W, H float64 } |
| 419 | if err := json.Unmarshal([]byte(raw), &p); err != nil { |
| 420 | t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| 421 | } |
| 422 | if p.L < -0.5 || p.T < -0.5 || p.R > p.W+0.5 || p.B > p.H+0.5 { |
| 423 | t.Errorf("%s: the open panel [%v…%v × %v…%v] is not inside the %vx%v client box", where, p.L, p.R, p.T, p.B, p.W, p.H) |
| 424 | } |
| 425 | for i, want := range Locales { |
| 426 | var got string |
| 427 | mustRun(t, tab, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(`(() => { |
| 428 | const a = document.activeElement, r = a.getBoundingClientRect(); |
| 429 | const de = document.documentElement, inside = r.top >= 0 && r.bottom <= de.clientHeight && r.left >= 0 && r.right <= de.clientWidth; |
| 430 | return a.closest(".ds-top__controls [rst-locale] [rst-dropdown-menu]") && inside ? a.lang : "not a visible language: " + a.outerHTML.slice(0, 60); |
| 431 | })()`, &got)) |
| 432 | if got != want { |
| 433 | t.Errorf("%s: tab %d lands on %s, want the %s link on screen", where, i+1, got, want) |
| 434 | break |
| 435 | } |
| 436 | } |
| 437 | } |
| 438 | } |
| 439 | } |
| 440 | } |
| 441 | } |
| 442 | |
| 443 | // TestTheGallerysOwnControlsAreExactlyTheFloor: the gallery's own |
| 444 | // controls carry ds- classes the system's floors cannot match, so each |
| 445 | // reads --rst-target itself. Held exactly, not "at least": each control |
| 446 | // is the larger of the floor and its own height with the floor lifted, |
| 447 | // so the floor never adds to a control already over it. The rail's |
| 448 | // section headings, the filter box and the prev/next links with a |
| 449 | // mouse at 1280px; the prev/next links at 600px; the display settings |
| 450 | // button and the prev/next links on a coarse pointer at 390px, where |
| 451 | // the floor is 44 and the rail is not drawn. The preview widget's tabs |
| 452 | // and Copy buttons are the widget's own gate. |
| 453 | func TestTheGallerysOwnControlsAreExactlyTheFloor(t *testing.T) { |
| 454 | const read = `(() => { |
| 455 | const floor = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--rst-target")) * parseFloat(getComputedStyle(document.documentElement).fontSize); |
| 456 | const h = e => Math.round(e.getBoundingClientRect().height * 10) / 10; |
| 457 | const each = sel => [...document.querySelectorAll(sel)].filter(e => e.checkVisibility()).map(e => { |
| 458 | const got = h(e); e.style.minBlockSize = "0px"; const own = h(e); e.style.minBlockSize = ""; |
| 459 | return {Got: got, Want: Math.max(floor, own), W: e.getBoundingClientRect().width}; }); |
| 460 | return JSON.stringify({Floor: floor, Heads: each(".ds-nav > details > summary"), Filter: each(".ds-search input"), Steps: each(".ds-updown__prev, .ds-updown__next"), Prefs: each(".ds-prefs > summary")}); |
| 461 | })()` |
| 462 | theme := fiesta.ThemeNames()[0] |
| 463 | for _, c := range []struct { |
| 464 | name string |
| 465 | rig func(*testing.T) *harness.Rig |
| 466 | w, h int64 |
| 467 | floor float64 |
| 468 | rail bool |
| 469 | }{ |
| 470 | {"a mouse at 1280px", func(t *testing.T) *harness.Rig { |
| 471 | return harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 472 | }, 1280, 800, 32, true}, |
| 473 | {"a mouse at 600px", func(t *testing.T) *harness.Rig { |
| 474 | return harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 475 | }, 600, 800, 44, false}, |
| 476 | {"a coarse pointer at 390px", func(t *testing.T) *harness.Rig { return phoneRig(t) }, 390, 844, 44, false}, |
| 477 | } { |
| 478 | rig := c.rig(t) |
| 479 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 480 | var raw string |
| 481 | mustRun(t, ctx, chromedp.EmulateViewport(c.w, c.h), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "tokens.html")), chromedp.WaitReady("body")) |
| 482 | present(t, ctx, c.name, ".ds-updown") |
| 483 | mustRun(t, ctx, chromedp.Evaluate(read, &raw)) |
| 484 | cancel() |
| 485 | type reading struct{ Got, Want, W float64 } |
| 486 | var got struct { |
| 487 | Floor float64 |
| 488 | Heads, Filter, Steps, Prefs []reading |
| 489 | } |
| 490 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 491 | t.Fatalf("%s: decoding %s: %v", c.name, raw, err) |
| 492 | } |
| 493 | if got.Floor != c.floor { |
| 494 | t.Errorf("%s: --rst-target is %vpx, want %v", c.name, got.Floor, c.floor) |
| 495 | } |
| 496 | if len(got.Steps) == 0 || c.rail && (len(got.Heads) == 0 || len(got.Filter) == 0) || !c.rail && c.floor == 44 && c.w == 390 && len(got.Prefs) == 0 { |
| 497 | t.Fatalf("%s: read %d rail headings, %d filter boxes, %d prev/next links and %d display settings buttons; the selectors no longer match the page", c.name, len(got.Heads), len(got.Filter), len(got.Steps), len(got.Prefs)) |
| 498 | } |
| 499 | for what, rs := range map[string][]reading{"a rail section heading": got.Heads, "the filter box": got.Filter, "a prev/next link": got.Steps, "the display settings button": got.Prefs} { |
| 500 | for _, h := range rs { |
| 501 | if math.Abs(h.Got-h.Want) > 0.05 { |
| 502 | t.Errorf("%s: %s is %vpx tall, want %v, the floor or its own height", c.name, what, h.Got, h.Want) |
| 503 | break |
| 504 | } |
| 505 | } |
| 506 | } |
| 507 | } |
| 508 | } |
| 509 | |
| 510 | // TestTheThemeTabsSitInLineWithTheOtherControls: the theme switcher is |
| 511 | // the system's seg-tabs, which leaves a block's gap under itself. In |
| 512 | // the bar's centred row that margin would lift the tabs above the |
| 513 | // menus beside them; in the phone card's column it would more than |
| 514 | // double the space under them. |
| 515 | func TestTheThemeTabsSitInLineWithTheOtherControls(t *testing.T) { |
| 516 | theme := fiesta.ThemeNames()[0] |
| 517 | t.Run("the bar at 1280", func(t *testing.T) { |
| 518 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 519 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 520 | defer cancel() |
| 521 | for _, locale := range []string{"en", "ar"} { |
| 522 | where := theme + "/" + locale + "/index.html at 1280" |
| 523 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) |
| 524 | present(t, ctx, where, ".ds-top__controls [rst-scheme]") |
| 525 | var mid [2]float64 |
| 526 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const m = s => { const r = document.querySelector(".ds-top__controls " + s).getBoundingClientRect(); return r.top + r.height / 2; }; |
| 527 | return [m("[rst-seg-tabs]"), m("[rst-scheme] > summary")]; })()`, &mid)) |
| 528 | if math.Abs(mid[0]-mid[1]) > 1 { |
| 529 | t.Errorf("%s: the theme tabs' centre line is at %.1fpx and the scheme menu's at %.1fpx; want them level", where, mid[0], mid[1]) |
| 530 | } |
| 531 | } |
| 532 | }) |
| 533 | t.Run("the phone card at 390", func(t *testing.T) { |
| 534 | rig := phoneRig(t) |
| 535 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 536 | defer cancel() |
| 537 | for _, locale := range []string{"en", "ar"} { |
| 538 | where := theme + "/" + locale + "/index.html at 390" |
| 539 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) |
| 540 | present(t, ctx, where, ".ds-prefs > summary") |
| 541 | mustRun(t, ctx, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery), chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs]", chromedp.ByQuery)) |
| 542 | requireCoarse(t, ctx) |
| 543 | settleMotion(t, ctx, where) |
| 544 | var gaps []float64 |
| 545 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const kids = [...document.querySelector(".ds-prefs > [rst-dropdown-menu]").children].filter(k => k.checkVisibility()).map(k => k.getBoundingClientRect()); |
| 546 | return kids.slice(1).map((r, i) => r.top - kids[i].bottom); })()`, &gaps)) |
| 547 | if len(gaps) < 2 { |
| 548 | t.Fatalf("%s: the card holds %d groups; it needs three for the gaps between them to be compared", where, len(gaps)+1) |
| 549 | } |
| 550 | for i, g := range gaps[1:] { |
| 551 | if math.Abs(g-gaps[0]) > 0.5 { |
| 552 | t.Errorf("%s: the gap under the theme tabs is %.1fpx and gap %d is %.1fpx; want every group the same distance apart", where, gaps[0], i+2, g) |
| 553 | } |
| 554 | } |
| 555 | } |
| 556 | }) |
| 557 | } |
| 558 | |
| 559 | // TestThePrevNextPairSitsAtTheEndsOfItsRow: previous is flush at the |
| 560 | // inline start and never crosses the middle; next is flush at the |
| 561 | // inline end and begins at or past the middle, so a missing previous |
| 562 | // leaves its space. The two ends and one middle page of the sequence, |
| 563 | // in both directions. |
| 564 | func TestThePrevNextPairSitsAtTheEndsOfItsRow(t *testing.T) { |
| 565 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 566 | ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) |
| 567 | defer cancel() |
| 568 | const measure = `(() => { |
| 569 | const box = document.querySelector(".ds-updown"); |
| 570 | if (!box) return JSON.stringify({error: "no prev/next strip on this page"}); |
| 571 | const dir = getComputedStyle(document.documentElement).direction; |
| 572 | const b = box.getBoundingClientRect(); |
| 573 | const read = (sel) => { |
| 574 | const el = box.querySelector(sel); |
| 575 | if (!el) return null; |
| 576 | const r = el.getBoundingClientRect(); |
| 577 | return {startGap: Math.round(dir === "rtl" ? b.right - r.right : r.left - b.left), endGap: Math.round(dir === "rtl" ? r.left - b.left : b.right - r.right), size: Math.round(r.width)}; |
| 578 | }; |
| 579 | return JSON.stringify({dir: dir, width: Math.round(b.width), prev: read(".ds-updown__prev"), next: read(".ds-updown__next")}); |
| 580 | })()` |
| 581 | type edge struct { |
| 582 | StartGap int `json:"startGap"` |
| 583 | EndGap int `json:"endGap"` |
| 584 | Size int `json:"size"` |
| 585 | } |
| 586 | type strip struct { |
| 587 | Error string `json:"error"` |
| 588 | Dir string `json:"dir"` |
| 589 | Width int `json:"width"` |
| 590 | Prev *edge `json:"prev"` |
| 591 | Next *edge `json:"next"` |
| 592 | } |
| 593 | s := newTestSite(t, fiesta.FS(), mountPath) |
| 594 | theme := fiesta.ThemeNames()[0] |
| 595 | for _, locale := range []string{"en", "ar"} { |
| 596 | seq := s.sequence(theme, locale) |
| 597 | for _, pos := range []int{0, 1, len(seq) - 1} { |
| 598 | name := strings.TrimPrefix(seq[pos].Href, mountPath+"/") |
| 599 | var raw string |
| 600 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+seq[pos].Href), chromedp.WaitReady("body")) |
| 601 | present(t, ctx, name, ".ds-updown") |
| 602 | mustRun(t, ctx, chromedp.Evaluate(measure, &raw)) |
| 603 | var got strip |
| 604 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 605 | t.Fatalf("%s: reading the measurement: %v", name, err) |
| 606 | } |
| 607 | if got.Error != "" { |
| 608 | t.Errorf("%s: %s", name, got.Error) |
| 609 | continue |
| 610 | } |
| 611 | if got.Dir != Dir(locale) { |
| 612 | t.Errorf("%s: the page renders dir=%q, want %q", name, got.Dir, Dir(locale)) |
| 613 | continue |
| 614 | } |
| 615 | if got.Width < 200 { |
| 616 | t.Errorf("%s: the strip is %dpx wide; there is nothing to measure", name, got.Width) |
| 617 | continue |
| 618 | } |
| 619 | half := got.Width / 2 |
| 620 | if (got.Prev != nil) != (pos > 0) { |
| 621 | t.Errorf("%s: previous link present=%v, want %v", name, got.Prev != nil, pos > 0) |
| 622 | } |
| 623 | if (got.Next != nil) != (pos < len(seq)-1) { |
| 624 | t.Errorf("%s: next link present=%v, want %v", name, got.Next != nil, pos < len(seq)-1) |
| 625 | } |
| 626 | if p := got.Prev; p != nil { |
| 627 | if p.StartGap > 2 { |
| 628 | t.Errorf("%s: previous starts %dpx from the strip's inline start, want 0 (%s, strip %dpx)", name, p.StartGap, got.Dir, got.Width) |
| 629 | } |
| 630 | if p.StartGap+p.Size > half+2 { |
| 631 | t.Errorf("%s: previous runs %dpx into a %dpx strip, past its half (%d)", name, p.StartGap+p.Size, got.Width, half) |
| 632 | } |
| 633 | } |
| 634 | if n := got.Next; n != nil { |
| 635 | if n.EndGap > 2 { |
| 636 | t.Errorf("%s: next ends %dpx from the strip's inline end, want 0 (%s, strip %dpx)", name, n.EndGap, got.Dir, got.Width) |
| 637 | } |
| 638 | if start := got.Width - n.EndGap - n.Size; start < half-2 { |
| 639 | t.Errorf("%s: next begins %dpx into a %dpx strip, before its half (%d); the missing previous has not left its space", name, start, got.Width, half) |
| 640 | } |
| 641 | } |
| 642 | if got.Prev == nil && got.Next == nil { |
| 643 | t.Errorf("%s: the strip is empty", name) |
| 644 | } |
| 645 | } |
| 646 | } |
| 647 | } |
| 648 | |
| 649 | // markedJS reads every scheme menu on the page: for each, the value of |
| 650 | // the one button carrying aria-current="true", or "" for none and |
| 651 | // "many" for more than one. |
| 652 | const markedJS = `[...document.querySelectorAll("[rst-scheme]")].map(m => { |
| 653 | const on = [...m.querySelectorAll("button")].filter(b => b.getAttribute("aria-current") === "true"); |
| 654 | return on.length === 1 ? on[0].form.elements.scheme.value : on.length ? "many" : ""; |
| 655 | })` |
| 656 | |
| 657 | // TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint: a returning |
| 658 | // visitor with Dark stored sees a dark page from the first frame, even |
| 659 | // with the theme stylesheet slow to arrive, and after load the Dark |
| 660 | // button carries aria-current in both the bar's and the display |
| 661 | // settings menu's scheme menu while System carries none. fiesta.js |
| 662 | // skips its own restore when data-theme is already on the root, so |
| 663 | // the marking is the gallery script's. The control is a fresh visit, |
| 664 | // where System is marked in both. |
| 665 | func TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint(t *testing.T) { |
| 666 | theme := fiesta.ThemeNames()[0] |
| 667 | slow := func(t *testing.T) http.Handler { |
| 668 | inner := treeHandler(t) |
| 669 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 670 | if strings.Contains(r.URL.Path, "/assets/css/themes/") { |
| 671 | time.Sleep(500 * time.Millisecond) |
| 672 | } |
| 673 | inner.ServeHTTP(w, r) |
| 674 | }) |
| 675 | } |
| 676 | for _, c := range []struct{ name, store, want string }{{"Dark stored", `localStorage.setItem("fiesta-scheme", "dark");`, "dark"}, {"a fresh visit", "", "system"}} { |
| 677 | rig := harness.New(t, func(string) http.Handler { return slow(t) }) |
| 678 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 679 | mustRun(t, ctx, |
| 680 | addInit(c.store+` requestAnimationFrame(() => { window.__firstFrame = document.documentElement.getAttribute("data-theme") || "system"; });`), |
| 681 | chromedp.EmulateViewport(1280, 900), |
| 682 | chromedp.Navigate(rig.Origin+pageAt(theme, "en", "tokens.html")), |
| 683 | chromedp.WaitReady("body"), |
| 684 | ) |
| 685 | present(t, ctx, c.name, ".ds-prefs [rst-scheme]") |
| 686 | until(t, ctx, c.name, `document.readyState === "complete" && window.__firstFrame !== undefined`) |
| 687 | var first string |
| 688 | var marked []string |
| 689 | mustRun(t, ctx, chromedp.Evaluate(`window.__firstFrame`, &first), chromedp.Evaluate(markedJS, &marked)) |
| 690 | cancel() |
| 691 | if first != c.want { |
| 692 | t.Errorf("%s: the first frame painted with data-theme %q, want %q", c.name, first, c.want) |
| 693 | } |
| 694 | if len(marked) != 2 { |
| 695 | t.Fatalf("%s: %d scheme menus on the page, want 2 (the bar's and the display settings menu's)", c.name, len(marked)) |
| 696 | } |
| 697 | for i, m := range marked { |
| 698 | if m != c.want { |
| 699 | t.Errorf("%s: scheme menu %d marks %q current, want %q", c.name, i, m, c.want) |
| 700 | } |
| 701 | } |
| 702 | } |
| 703 | } |
| 704 | |
| 705 | // refusedStorage makes every read and write of localStorage throw, as |
| 706 | // a browser with site data blocked does. |
| 707 | const refusedStorage = `(() => { |
| 708 | const refuse = () => { throw new DOMException("site data is blocked", "SecurityError"); }; |
| 709 | for (const name of ["getItem", "setItem", "removeItem"]) Storage.prototype[name] = refuse; |
| 710 | })();` |
| 711 | |
| 712 | // TestASchemeChosenWithStorageRefusedIsTheOneMarked: with localStorage |
| 713 | // refusing every read and write, choosing Dark in the bar's menu still |
| 714 | // applies Dark to the page, and both copies of the scheme menu, the |
| 715 | // bar's and the display settings menu's, mark Dark current. Nothing |
| 716 | // can be remembered, so the marks follow the scheme on the root, not |
| 717 | // the stored one. |
| 718 | func TestASchemeChosenWithStorageRefusedIsTheOneMarked(t *testing.T) { |
| 719 | where := "storage refused" |
| 720 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 721 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 722 | defer cancel() |
| 723 | mustRun(t, ctx, |
| 724 | addInit(refusedStorage), |
| 725 | chromedp.EmulateViewport(1280, 900), |
| 726 | chromedp.Navigate(rig.Origin+pageAt(fiesta.ThemeNames()[0], "en", "tokens.html")), |
| 727 | chromedp.WaitReady("body"), |
| 728 | ) |
| 729 | present(t, ctx, where, ".ds-prefs [rst-scheme]") |
| 730 | until(t, ctx, where, `document.readyState === "complete"`) |
| 731 | var refused bool |
| 732 | mustRun(t, ctx, chromedp.Evaluate(`(() => { try { localStorage.getItem("fiesta-scheme"); return false; } catch (e) { return true; } })()`, &refused)) |
| 733 | if !refused { |
| 734 | t.Fatalf("%s: localStorage still answers; the drive would be testing working storage", where) |
| 735 | } |
| 736 | |
| 737 | chooseScheme(t, ctx, "dark") |
| 738 | // gallery.js marks both copies one frame after the choice; two |
| 739 | // frames later the marks are settled whichever way they went. |
| 740 | var theme string |
| 741 | var marked []string |
| 742 | mustRun(t, ctx, |
| 743 | chromedp.Evaluate(`new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(done))))`, nil, |
| 744 | func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }), |
| 745 | chromedp.Evaluate(`document.documentElement.getAttribute("data-theme") || "system"`, &theme), |
| 746 | chromedp.Evaluate(markedJS, &marked), |
| 747 | ) |
| 748 | if theme != "dark" { |
| 749 | t.Errorf("%s: after choosing Dark the page is on %q", where, theme) |
| 750 | } |
| 751 | if len(marked) != 2 { |
| 752 | t.Fatalf("%s: %d scheme menus on the page, want 2 (the bar's and the display settings menu's)", where, len(marked)) |
| 753 | } |
| 754 | for i, m := range marked { |
| 755 | if m != "dark" { |
| 756 | t.Errorf("%s: scheme menu %d marks %q current after Dark was chosen, want %q", where, i, m, "dark") |
| 757 | } |
| 758 | } |
| 759 | } |
| 760 | |
| 761 | // TestTheFrameMirrorsRightToLeft: right to left, the shell's grid puts |
| 762 | // the rail on the right and the bar over main on the left, with no rule |
| 763 | // of the gallery's: the grid lines are numbered, not sided. Port of |
| 764 | // Rastrillo's drive, on an example page of day/ar at 1280px, kept with |
| 765 | // the bar's drives as Rastrillo keeps it. |
| 766 | func TestTheFrameMirrorsRightToLeft(t *testing.T) { |
| 767 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 768 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 769 | defer cancel() |
| 770 | where := "day/ar form-layout at 1280px" |
| 771 | openPage(t, ctx, where, pageURL(rig, "ar", "form-layout.html"), 1280, 900) |
| 772 | var r struct { |
| 773 | RailLeft, MainRight, BarBottom, MainTop, BarLeft, BarRight, MainLeft, BrandLeft, ControlsRight float64 |
| 774 | } |
| 775 | at(t, ctx, `(() => { |
| 776 | const b = s => document.querySelector(s).getBoundingClientRect(); |
| 777 | const rail = b("[rst-shell-rail]"), bar = b(".ds-top"), main = b("main"); |
| 778 | const brand = b(".ds-top__brand"), controls = b(".ds-top__controls"); |
| 779 | return JSON.stringify({RailLeft: rail.left, MainRight: main.right, BarBottom: bar.bottom, MainTop: main.top, BarLeft: bar.left, BarRight: bar.right, MainLeft: main.left, BrandLeft: brand.left, ControlsRight: controls.right}); |
| 780 | })()`, &r) |
| 781 | if r.RailLeft < r.MainRight-0.5 { |
| 782 | t.Errorf("%s: the rail starts at %.1fpx, left of main's right edge %.1fpx; it belongs on the right", where, r.RailLeft, r.MainRight) |
| 783 | } |
| 784 | if r.BarBottom > r.MainTop+0.5 || r.BarLeft < r.MainLeft-0.5 || r.BarRight > r.MainRight+0.5 { |
| 785 | t.Errorf("%s: the bar [%v…%v, bottom %v] is not above main [%v…%v, top %v]", where, r.BarLeft, r.BarRight, r.BarBottom, r.MainLeft, r.MainRight, r.MainTop) |
| 786 | } |
| 787 | // The brand is first in source order and justify-content: |
| 788 | // space-between puts the first child at the inline start, which in |
| 789 | // Arabic is the bar's right side, not its left: a rule keyed to |
| 790 | // "left" rather than to the writing direction would swap these two. |
| 791 | if r.BrandLeft < r.ControlsRight-0.5 { |
| 792 | t.Errorf("%s: the brand's left edge is at %.1fpx, not right of the controls' right edge %.1fpx; the brand belongs at the inline start and the controls at the end", where, r.BrandLeft, r.ControlsRight) |
| 793 | } |
| 794 | } |
| 795 | |