| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "math" |
| 10 | "net/http" |
| 11 | "slices" |
| 12 | "sort" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/chromedp/chromedp" |
| 19 | |
| 20 | "amadan.net/rastrillo/fiesta" |
| 21 | "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" |
| 22 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 23 | ) |
| 24 | |
| 25 | // The preview widget in a real engine: Rastrillo's drives, each on |
| 26 | // this task's own markup. The widget is three radios, a container |
| 27 | // query and a scaled frame, and none of what they do shows in the |
| 28 | // rendered bytes. |
| 29 | |
| 30 | // pageURL is one page of the default theme in locale, at the rig. |
| 31 | func pageURL(rig *harness.Rig, locale, file string) string { |
| 32 | return rig.Origin + href(fiesta.ThemeNames()[0]+"/"+locale+"/"+file) |
| 33 | } |
| 34 | |
| 35 | // requireWidget fails a leg by name when the page carries no widget as |
| 36 | // this task renders it: a box on the stage and a Desktop radio per |
| 37 | // frame. Every drive here asks this first, so a page without the |
| 38 | // widget fails on the spot rather than at a wait's deadline. |
| 39 | func requireWidget(t *testing.T, ctx context.Context, where string) { |
| 40 | t.Helper() |
| 41 | var got struct{ Boxes, Tabs, Frames int } |
| 42 | at(t, ctx, `JSON.stringify({Boxes: document.querySelectorAll(".ds-view > .ds-view__stage > .ds-view__box").length, |
| 43 | Tabs: document.querySelectorAll(".ds-view__tabs .ds-view__tab--d input[type=radio]").length, |
| 44 | Frames: document.querySelectorAll(".ds-view__frame").length})`, &got) |
| 45 | if got.Boxes == 0 || got.Tabs != got.Boxes || got.Frames != got.Boxes { |
| 46 | t.Fatalf("%s: %d widget boxes, %d Desktop tabs, %d frames; the preview widget is not on this page as this drive expects", where, got.Boxes, got.Tabs, got.Frames) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | // pickScheme chooses one scheme from the header's menu: the visible |
| 51 | // copy of the controls (a page carries the bar's and the phone menu's), |
| 52 | // opened, and its button pressed, as a reader would. Through the |
| 53 | // debugger rather than chromedp.Click, which waits on the first copy in |
| 54 | // the document being visible. |
| 55 | func pickScheme(t *testing.T, ctx context.Context, scheme string) { |
| 56 | t.Helper() |
| 57 | var ok bool |
| 58 | mustRun(t, ctx, chromedp.Evaluate(`(() => { |
| 59 | const menus = [...document.querySelectorAll("[rst-scheme]")]; |
| 60 | const m = menus.find(m => m.checkVisibility()) || menus[0]; |
| 61 | if (!m) return false; |
| 62 | m.open = true; |
| 63 | const b = m.querySelector('input[value="`+scheme+`"] ~ button'); |
| 64 | if (!b) return false; |
| 65 | b.click(); |
| 66 | return true; |
| 67 | })()`, &ok)) |
| 68 | if !ok { |
| 69 | t.Fatalf("no scheme menu with a %s button on the page", scheme) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // openPage sizes the viewport, loads url and requires the widget. |
| 74 | func openPage(t *testing.T, ctx context.Context, where, url string, w, h int64) { |
| 75 | t.Helper() |
| 76 | mustRun(t, ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(url), chromedp.WaitReady("body")) |
| 77 | requireWidget(t, ctx, where) |
| 78 | } |
| 79 | |
| 80 | // framing is one reading of a widget: the frame's layout, what the |
| 81 | // framed document reports, which panel is on screen, and the scheme |
| 82 | // the framed document resolved to. |
| 83 | type framing struct { |
| 84 | Virtual, Painted, Inner, Gutter float64 |
| 85 | Body string |
| 86 | FrameShown, HTMLShown bool |
| 87 | Tabs []string |
| 88 | Scheme string |
| 89 | } |
| 90 | |
| 91 | // TestPreviewWidgetDrivesTheWholeJourney: on the callout page, the |
| 92 | // widget opens on the framed rendering at a component's 900px; Mobile |
| 93 | // is the same document at 390px; HTML replaces the frame with its |
| 94 | // panel and nothing else; every link in every framed document goes |
| 95 | // nowhere; the grip really resizes the document's viewport; Dark |
| 96 | // chosen in the header reaches the framed documents; and with scripts |
| 97 | // off the HTML tab still works. Port of Rastrillo's drive without its |
| 98 | // Template tab. |
| 99 | func TestPreviewWidgetDrivesTheWholeJourney(t *testing.T) { |
| 100 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 101 | ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| 102 | defer cancel() |
| 103 | url := pageURL(rig, "en", "callout.html") |
| 104 | const widget = `document.querySelector("#info .ds-view")` |
| 105 | read := func(sel string) string { |
| 106 | return `(() => { |
| 107 | const v = ` + sel + `; |
| 108 | const f = v.querySelector(".ds-view__frame"), box = v.querySelector(".ds-view__box"), stage = v.querySelector(".ds-view__stage"); |
| 109 | const html = v.querySelector(".ds-view__code--h"); |
| 110 | const d = f.contentDocument; |
| 111 | return JSON.stringify({ |
| 112 | Virtual: parseFloat(getComputedStyle(f).width), |
| 113 | Painted: box.getBoundingClientRect().width, |
| 114 | Inner: d ? d.documentElement.getBoundingClientRect().width : -1, |
| 115 | Gutter: d ? d.defaultView.innerWidth - d.documentElement.getBoundingClientRect().width : 0, |
| 116 | Body: d ? d.body.innerHTML.trim().slice(0, 120) : "", |
| 117 | FrameShown: getComputedStyle(stage).display !== "none", |
| 118 | HTMLShown: html ? getComputedStyle(html).display !== "none" : false, |
| 119 | Tabs: [...v.querySelectorAll(".ds-view__tab")].map(l => l.textContent.trim()), |
| 120 | Scheme: d ? getComputedStyle(d.documentElement).colorScheme + " " + getComputedStyle(d.body).backgroundColor : "" |
| 121 | }); |
| 122 | })()` |
| 123 | } |
| 124 | openPage(t, ctx, "callout at 1280px", url, 1280, 900) |
| 125 | painted(t, ctx, "callout") |
| 126 | |
| 127 | var desktop, mobile, htmlTab, dark, scriptless, resized string |
| 128 | var deadLinks int |
| 129 | step := func(what string, actions ...chromedp.Action) { |
| 130 | t.Helper() |
| 131 | if err := chromedp.Run(ctx, actions...); err != nil { |
| 132 | t.Fatalf("%s: %v", what, err) |
| 133 | } |
| 134 | } |
| 135 | step("reading Desktop", chromedp.Evaluate(read(widget), &desktop)) |
| 136 | // Clicking the label, the way a reader does, which is also the |
| 137 | // proof that the label and its radio are paired. |
| 138 | step("choosing Mobile", chromedp.Click(`#info .ds-view__tab--m`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(read(widget), &mobile)) |
| 139 | step("choosing HTML", chromedp.Click(`#info .ds-view__tab--h`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(read(widget), &htmlTab)) |
| 140 | // Every link in every framed document goes nowhere, or to a page |
| 141 | // of this tree: read off the documents themselves. |
| 142 | step("reading the links", chromedp.Evaluate(`(() => { |
| 143 | let bad = 0; |
| 144 | for (const f of document.querySelectorAll(".ds-view__frame")) { |
| 145 | const d = f.contentDocument; |
| 146 | if (!d) continue; |
| 147 | for (const a of d.querySelectorAll("a[href]")) { |
| 148 | const href = a.getAttribute("href"); |
| 149 | if (!href.startsWith("#") && !href.startsWith(`+strconv.Quote(mountPath+"/")+`)) bad++; |
| 150 | } |
| 151 | } |
| 152 | return bad; |
| 153 | })()`, &deadLinks)) |
| 154 | // The grip: the box is what the reader drags, and the frame takes |
| 155 | // its height back off the box, so the document inside really does |
| 156 | // get taller. |
| 157 | step("dragging the grip", chromedp.Click(`#info .ds-view__tab:first-of-type`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(`(() => { |
| 158 | const box = document.querySelector("#info .ds-view__box"), f = document.querySelector("#info .ds-view__frame"); |
| 159 | const before = f.contentDocument.documentElement.clientHeight; |
| 160 | box.style.height = Math.round(box.getBoundingClientRect().height * 2) + "px"; |
| 161 | return JSON.stringify({before: before, after: f.contentDocument.documentElement.clientHeight, |
| 162 | painted: Math.round(f.getBoundingClientRect().height), box: Math.round(box.getBoundingClientRect().height)}); |
| 163 | })()`, &resized)) |
| 164 | pickScheme(t, ctx, "dark") |
| 165 | step("reading Dark", chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(read(widget), &dark)) |
| 166 | |
| 167 | // The same with the page's scripts switched off at the engine. |
| 168 | off := noScripts(t, rig.Context()) |
| 169 | offCtx, cancelOff := context.WithTimeout(off, 120*time.Second) |
| 170 | defer cancelOff() |
| 171 | openPage(t, offCtx, "callout at 1280px, scripts off", url, 1280, 900) |
| 172 | requireScriptsOff(t, offCtx, "callout, scripts off") |
| 173 | mustRun(t, offCtx, |
| 174 | chromedp.Click(`#info .ds-view__tab--h`, chromedp.ByQuery), |
| 175 | chromedp.Sleep(400*time.Millisecond), |
| 176 | chromedp.Evaluate(`(() => { |
| 177 | const v = `+widget+`; |
| 178 | return JSON.stringify({FrameShown: getComputedStyle(v.querySelector(".ds-view__stage")).display !== "none", |
| 179 | HTMLShown: getComputedStyle(v.querySelector(".ds-view__code--h")).display !== "none", |
| 180 | Body: "", Virtual: 0, Painted: 0, Inner: 0, Gutter: 0, Scheme: "", Tabs: []}); |
| 181 | })()`, &scriptless), |
| 182 | ) |
| 183 | |
| 184 | var d, m, h, dk, o framing |
| 185 | for _, p := range []struct { |
| 186 | raw string |
| 187 | into *framing |
| 188 | }{{desktop, &d}, {mobile, &m}, {htmlTab, &h}, {dark, &dk}, {scriptless, &o}} { |
| 189 | if err := json.Unmarshal([]byte(p.raw), p.into); err != nil { |
| 190 | t.Fatalf("reading a framing (%q): %v", p.raw, err) |
| 191 | } |
| 192 | } |
| 193 | // A component lays out at 900px whatever the column is; the number |
| 194 | // is written here on purpose, the one assertion that would still |
| 195 | // fail if the stylesheet lost the component class. |
| 196 | const componentWidth = 900 |
| 197 | if d.Virtual != componentWidth { |
| 198 | t.Errorf("the desktop frame lays out at %gpx, want %d", d.Virtual, componentWidth) |
| 199 | } |
| 200 | if d.Inner+d.Gutter != componentWidth { |
| 201 | t.Errorf("the framed document laid out at %gpx with a %gpx scrollbar gutter, %gpx in all, want %d", d.Inner, d.Gutter, d.Inner+d.Gutter, componentWidth) |
| 202 | } |
| 203 | if d.Painted < componentWidth-2 || d.Painted > componentWidth+2 { |
| 204 | t.Errorf("the desktop frame is painted %gpx wide in a 1280px window and its virtual page is %dpx; a component that fits its stage is not scaled", d.Painted, componentWidth) |
| 205 | } |
| 206 | t.Logf("desktop preview: a %gpx virtual page painted %gpx wide in a 1280px window, scale %.3f", d.Virtual, d.Painted, d.Painted/d.Virtual) |
| 207 | if !d.FrameShown || d.HTMLShown { |
| 208 | t.Error("the page does not open on the framed rendering") |
| 209 | } |
| 210 | if want := []string{say("gallery.view.desktop"), say("gallery.view.mobile"), say("gallery.html")}; !slices.Equal(d.Tabs, want) { |
| 211 | t.Errorf("the callout's tabs are %q, want %q", d.Tabs, want) |
| 212 | } |
| 213 | if !strings.Contains(d.Body, "rst-callout") { |
| 214 | t.Errorf("the framed document is not the callout sample: %q", d.Body) |
| 215 | } |
| 216 | if m.Virtual != 390 { |
| 217 | t.Errorf("the mobile frame lays out at %gpx, want 390", m.Virtual) |
| 218 | } |
| 219 | if m.Inner+m.Gutter != 390 { |
| 220 | t.Errorf("the framed document laid out at %gpx with a %gpx gutter on the Mobile tab, %gpx in all, want 390", m.Inner, m.Gutter, m.Inner+m.Gutter) |
| 221 | } |
| 222 | if m.Body != d.Body { |
| 223 | t.Error("Desktop and Mobile are not the same document") |
| 224 | } |
| 225 | if h.FrameShown || !h.HTMLShown { |
| 226 | t.Errorf("the HTML tab shows frame=%v HTML=%v", h.FrameShown, h.HTMLShown) |
| 227 | } |
| 228 | var grip struct{ Before, After, Painted, Box int } |
| 229 | if err := json.Unmarshal([]byte(resized), &grip); err != nil { |
| 230 | t.Fatalf("reading the resize (%q): %v", resized, err) |
| 231 | } |
| 232 | if grip.After <= grip.Before { |
| 233 | t.Errorf("the box was dragged to %dpx and the framed document's viewport stayed %dpx; the grip moves the box and not the document", grip.Box, grip.After) |
| 234 | } |
| 235 | if grip.Painted < grip.Box-4 { |
| 236 | t.Errorf("after the drag the box is %dpx and the rendering in it is %dpx", grip.Box, grip.Painted) |
| 237 | } |
| 238 | if deadLinks != 0 { |
| 239 | t.Errorf("%d links inside the framed documents still point at a route this tree does not serve", deadLinks) |
| 240 | } |
| 241 | if !strings.HasPrefix(dk.Scheme, "dark ") { |
| 242 | t.Errorf("after choosing Dark the framed document is %q; the previews are not following the gallery", dk.Scheme) |
| 243 | } |
| 244 | if dk.Scheme == d.Scheme { |
| 245 | t.Errorf("the framed document is %q both before and after Dark was chosen; the reading proves nothing", d.Scheme) |
| 246 | } |
| 247 | if o.FrameShown || !o.HTMLShown { |
| 248 | t.Errorf("with scripts disabled the HTML tab shows frame=%v HTML=%v; the tabs need JavaScript", o.FrameShown, o.HTMLShown) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // ── The widget on a phone ──────────────────────────────────────────── |
| 253 | |
| 254 | // minShownSample is the least of a sample a reader must be able to see |
| 255 | // in a box, in CSS pixels: about one line of the smallest sample. |
| 256 | const minShownSample = 32.0 |
| 257 | |
| 258 | // kMin mirrors --ds-kmin in view.css: 12.5px × 0.72 = 9.0px is where |
| 259 | // rendered text stops being read. |
| 260 | const kMin = 0.72 |
| 261 | |
| 262 | // previewBox is one widget's geometry as the engine has it. Tabs are |
| 263 | // identified by position, 0 Desktop, 1 Mobile, 2 HTML. |
| 264 | type previewBox struct { |
| 265 | ID string |
| 266 | Hidden bool |
| 267 | Box float64 |
| 268 | Width string |
| 269 | Desk string |
| 270 | Scale string |
| 271 | Lit []int |
| 272 | Checked []int |
| 273 | View float64 |
| 274 | Stage float64 |
| 275 | Doc float64 |
| 276 | Inner float64 |
| 277 | PanX float64 |
| 278 | OverX string |
| 279 | BoxL, BoxR float64 |
| 280 | DocW float64 |
| 281 | } |
| 282 | |
| 283 | const readBoxes = `(() => { |
| 284 | const out = []; |
| 285 | document.querySelectorAll(".ds-view").forEach(v => { |
| 286 | const stage = v.querySelector(".ds-view__stage"), box = v.querySelector(".ds-view__box"), frame = v.querySelector(".ds-view__frame"); |
| 287 | let d = null; try { d = frame.contentDocument; } catch (e) {} |
| 288 | const tabs = [...v.querySelectorAll(".ds-view__tab")], lit = [], checked = []; |
| 289 | tabs.forEach((l, n) => { |
| 290 | if (getComputedStyle(l).fontWeight === "600") lit.push(n); |
| 291 | const i = l.querySelector("input"); |
| 292 | if (i && i.checked) checked.push(n); |
| 293 | }); |
| 294 | const section = v.closest("article, section"); |
| 295 | out.push({ |
| 296 | ID: section && section.id ? section.id : "widget-" + out.length, |
| 297 | Hidden: getComputedStyle(stage).display === "none", |
| 298 | Box: Math.round(box.getBoundingClientRect().height * 10) / 10, |
| 299 | Width: frame.offsetWidth + "px", |
| 300 | Desk: getComputedStyle(box).getPropertyValue("--ds-wd").trim(), |
| 301 | Scale: getComputedStyle(frame).transform, |
| 302 | Lit: lit, Checked: checked, |
| 303 | View: Math.round(v.getBoundingClientRect().width * 100) / 100, |
| 304 | Stage: Math.round(stage.getBoundingClientRect().width * 100) / 100, |
| 305 | Doc: d && d.body ? Math.max(d.body.getBoundingClientRect().height, d.body.scrollHeight) : -1, |
| 306 | Inner: Math.round(box.clientHeight * 10) / 10, |
| 307 | PanX: Math.round((box.scrollWidth - box.clientWidth) * 10) / 10, |
| 308 | OverX: getComputedStyle(box).overflowX, |
| 309 | BoxL: box.getBoundingClientRect().left, BoxR: box.getBoundingClientRect().right, DocW: document.documentElement.clientWidth, |
| 310 | }); |
| 311 | }); |
| 312 | return JSON.stringify(out); |
| 313 | })()` |
| 314 | |
| 315 | var ( |
| 316 | clickedMobile = galleryrig.ClickEvery("m") |
| 317 | clickedDesktop = galleryrig.ClickEvery("d") |
| 318 | ) |
| 319 | |
| 320 | // clickAll clicks every radio of one kind and refuses to go on unless |
| 321 | // the clicks moved something. |
| 322 | func clickAll(t *testing.T, ctx context.Context, where, script, what string) { |
| 323 | t.Helper() |
| 324 | var raw string |
| 325 | mustRun(t, ctx, chromedp.Evaluate(script, &raw), chromedp.Sleep(400*time.Millisecond)) |
| 326 | var got struct{ Clicked, Moved int } |
| 327 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 328 | t.Fatalf("%s: reading the %s click (%q): %v", where, what, raw, err) |
| 329 | } |
| 330 | if got.Clicked == 0 || got.Moved != got.Clicked { |
| 331 | t.Fatalf("%s: %d %s radios clicked and %d moved; the readings after this click are readings of the state before it", where, got.Clicked, what, got.Moved) |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | func scrolls(overflow string) bool { return overflow == "auto" || overflow == "scroll" } |
| 336 | |
| 337 | // noSidewaysPage: a preview that pans has to pan inside its own box. |
| 338 | func noSidewaysPage(t *testing.T, ctx context.Context, where string) { |
| 339 | t.Helper() |
| 340 | var over float64 |
| 341 | mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.scrollWidth - document.documentElement.clientWidth`, &over)) |
| 342 | if over > 1 { |
| 343 | t.Errorf("%s: the page itself scrolls %.1fpx sideways", where, over) |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | func boxes(t *testing.T, ctx context.Context, where string) []previewBox { |
| 348 | t.Helper() |
| 349 | var got []previewBox |
| 350 | at(t, ctx, readBoxes, &got) |
| 351 | if len(got) == 0 { |
| 352 | t.Fatalf("%s: the page has no preview widget on it at all", where) |
| 353 | } |
| 354 | return got |
| 355 | } |
| 356 | |
| 357 | var tabNames = [...]string{"Desktop", "Mobile", "HTML"} |
| 358 | |
| 359 | func tabName(n int) string { |
| 360 | if n < 0 || n >= len(tabNames) { |
| 361 | return fmt.Sprintf("tab %d", n) |
| 362 | } |
| 363 | return tabNames[n] |
| 364 | } |
| 365 | |
| 366 | // mobileWidth is the Mobile rendering's width for one widget: the |
| 367 | // stage's, up to 390px. |
| 368 | func mobileWidth(r previewBox) string { |
| 369 | return fmt.Sprintf("%dpx", int(math.Round(math.Min(390, r.View)))) |
| 370 | } |
| 371 | |
| 372 | // showsItsSample: what a reader can see of a sample is its own height |
| 373 | // times the scale the frame is drawn at, clipped to the box; and the |
| 374 | // scale never drops under the floor; and a box wider than its column |
| 375 | // is a scroller. |
| 376 | func showsItsSample(t *testing.T, where string, rows []previewBox) { |
| 377 | t.Helper() |
| 378 | say := loudly(t, where, len(rows)) |
| 379 | worst, worstID := math.Inf(1), "" |
| 380 | for _, r := range rows { |
| 381 | if r.Hidden { |
| 382 | continue |
| 383 | } |
| 384 | if r.Doc < 0 { |
| 385 | t.Fatalf("%s: %s has no reachable document in its frame", where, r.ID) |
| 386 | } |
| 387 | if r.Doc == 0 { |
| 388 | t.Fatalf("%s: %s frames a document with no height at all", where, r.ID) |
| 389 | } |
| 390 | k := scaleOf(t, where, r) |
| 391 | shown := math.Min(r.Doc*k, r.Inner) |
| 392 | if shown < worst { |
| 393 | worst, worstID = shown, r.ID |
| 394 | } |
| 395 | if shown < minShownSample { |
| 396 | say("%s: %s shows %.1fpx of its sample: the document is %.0f virtual px tall, the frame is scaled to %.3f, and the box can show %.1fpx", where, r.ID, shown, r.Doc, k, r.Inner) |
| 397 | } |
| 398 | if k < kMin-0.005 { |
| 399 | say("%s: %s is scaled to %.3f, under the %.2f floor", where, r.ID, k, kMin) |
| 400 | } |
| 401 | if r.PanX > 1 && !scrolls(r.OverX) { |
| 402 | say("%s: %s has %.0fpx of sample past the edge of its box and overflow-x is %q; that part is cropped", where, r.ID, r.PanX, r.OverX) |
| 403 | } |
| 404 | } |
| 405 | t.Logf("%s: %d widgets, least of a sample shown %.1fpx (%s)", where, len(rows), worst, worstID) |
| 406 | } |
| 407 | |
| 408 | // agree: the lit tab and the rendering on screen are chosen by the |
| 409 | // same rule, so they never drift. |
| 410 | func agree(t *testing.T, where string, rows []previewBox) { |
| 411 | t.Helper() |
| 412 | say := loudly(t, where, len(rows)) |
| 413 | for _, r := range rows { |
| 414 | if len(r.Lit) != 1 { |
| 415 | say("%s: %s has %d tabs lit at once (%v)", where, r.ID, len(r.Lit), r.Lit) |
| 416 | continue |
| 417 | } |
| 418 | if len(r.Checked) > 1 { |
| 419 | say("%s: %s has %d radios checked (%v); they are not one group", where, r.ID, len(r.Checked), r.Checked) |
| 420 | } |
| 421 | if r.Hidden { |
| 422 | if r.Lit[0] < 2 { |
| 423 | say("%s: %s shows the source with %s lit", where, r.ID, tabName(r.Lit[0])) |
| 424 | } |
| 425 | continue |
| 426 | } |
| 427 | var w string |
| 428 | switch r.Lit[0] { |
| 429 | case 0: |
| 430 | w = r.Desk |
| 431 | case 1: |
| 432 | w = mobileWidth(r) |
| 433 | default: |
| 434 | say("%s: %s lights %s and still shows a frame", where, r.ID, tabName(r.Lit[0])) |
| 435 | continue |
| 436 | } |
| 437 | if w == "" { |
| 438 | say("%s: %s computes no --ds-wd at all", where, r.ID) |
| 439 | continue |
| 440 | } |
| 441 | if r.Width != w { |
| 442 | say("%s: %s lights %s and renders at --ds-w: %s (want %s); the lit tab and the rendering disagree", where, r.ID, tabName(r.Lit[0]), r.Width, w) |
| 443 | } |
| 444 | if r.Scale == "none" || r.Scale == "" { |
| 445 | say("%s: %s has no transform on its frame (%q); --ds-k did not resolve", where, r.ID, r.Scale) |
| 446 | } |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | // opensOn asserts what the widget shows a reader who has clicked |
| 451 | // nothing: the rendering, and the tab lit over it. |
| 452 | func opensOn(t *testing.T, where string, rows []previewBox, tab int, width string) { |
| 453 | t.Helper() |
| 454 | say := loudly(t, where, len(rows)) |
| 455 | for _, r := range rows { |
| 456 | want := width |
| 457 | if want == "" { |
| 458 | want = mobileWidth(r) |
| 459 | } |
| 460 | if len(r.Checked) != 0 { |
| 461 | say("%s: %s opens with %v already checked", where, r.ID, r.Checked) |
| 462 | } |
| 463 | if r.Width != want { |
| 464 | say("%s: %s opens with its frame laid out %s wide, want %s; the opening view does not follow the reader's width", where, r.ID, r.Width, want) |
| 465 | } |
| 466 | if len(r.Lit) != 1 || r.Lit[0] != tab { |
| 467 | say("%s: %s opens with %v lit, want %s", where, r.ID, r.Lit, tabName(tab)) |
| 468 | } |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | // loudly caps a per-widget assertion at four named failures and then |
| 473 | // counts the rest. |
| 474 | func loudly(t *testing.T, where string, of int) func(string, ...any) { |
| 475 | t.Helper() |
| 476 | var n int |
| 477 | t.Cleanup(func() { |
| 478 | if n > 4 { |
| 479 | t.Errorf("%s: %d assertions failed over %d widgets (4 named above)", where, n, of) |
| 480 | } |
| 481 | }) |
| 482 | return func(format string, args ...any) { |
| 483 | n++ |
| 484 | if n <= 4 { |
| 485 | t.Errorf(format, args...) |
| 486 | } |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | // scaleOf reads the x scale out of a computed transform. |
| 491 | func scaleOf(t *testing.T, where string, r previewBox) float64 { |
| 492 | t.Helper() |
| 493 | var a, b, c, d, e, f float64 |
| 494 | if n, err := fmt.Sscanf(r.Scale, "matrix(%g, %g, %g, %g, %g, %g)", &a, &b, &c, &d, &e, &f); err != nil || n != 6 { |
| 495 | t.Errorf("%s: %s has transform %q, which is not a matrix; the frame is not being scaled at all", where, r.ID, r.Scale) |
| 496 | return 0 |
| 497 | } |
| 498 | return a |
| 499 | } |
| 500 | |
| 501 | // calibrate proves the @supports branch that computes --ds-k with |
| 502 | // tan(atan2(…)) is live in this engine, by finding a frame the engine |
| 503 | // scaled: a page-class widget in a stage narrower than 1200px. |
| 504 | func calibrate(t *testing.T, ctx context.Context, rig *harness.Rig, file string) { |
| 505 | t.Helper() |
| 506 | where := file + " at 1280px, calibrating" |
| 507 | openPage(t, ctx, where, pageURL(rig, "en", file), 1280, 900) |
| 508 | eagerly(t, ctx, where) |
| 509 | for _, r := range boxes(t, ctx, where) { |
| 510 | if r.Hidden || dsw(t, where, r.Desk) <= r.Stage { |
| 511 | continue |
| 512 | } |
| 513 | k := scaleOf(t, where, r) |
| 514 | if k <= 0 || k >= 1 { |
| 515 | t.Fatalf("%s: %s is a %s page in a %.0fpx stage and is scaled by %v; it has to be scaled down, so the @supports guard is not admitting this engine", where, r.ID, r.Desk, r.Stage, k) |
| 516 | } |
| 517 | t.Logf("%s: %s is a %s page in a %.0fpx stage, scaled %.3f; the trig branch is live", where, r.ID, r.Desk, r.Stage, k) |
| 518 | return |
| 519 | } |
| 520 | t.Fatalf("%s: no widget on this page is wider than its stage, so nothing here can prove the engine scales at all", where) |
| 521 | } |
| 522 | |
| 523 | // TestThePreviewWidgetIsUsableOnAPhone: at 390px and 320px, in English |
| 524 | // and in Russian, every widget opens on the Mobile rendering at the |
| 525 | // screen's width with the Mobile tab lit, shows its sample, never |
| 526 | // scales under the floor; an explicit Desktop pans inside a box that |
| 527 | // spans the screen; and the whole of it holds with scripts off. The |
| 528 | // controls come first: a wide viewport whose answer is known, clicks |
| 529 | // checked for having landed, and an engine proved to scale. |
| 530 | func TestThePreviewWidgetIsUsableOnAPhone(t *testing.T) { |
| 531 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, harness.WithScrollbars()) |
| 532 | ctx, cancel := context.WithTimeout(rig.Context(), 400*time.Second) |
| 533 | defer cancel() |
| 534 | calibrate(t, ctx, rig, "layout-column.html") |
| 535 | |
| 536 | for _, locale := range []string{"en", "ru"} { |
| 537 | for _, file := range []string{"field.html", "layout-column.html"} { |
| 538 | kind := locale + " " + file |
| 539 | url := pageURL(rig, locale, file) |
| 540 | openPage(t, ctx, kind+" at 1280px", url, 1280, 900) |
| 541 | eagerly(t, ctx, kind+" at 1280px") |
| 542 | wide := boxes(t, ctx, kind+" at 1280px") |
| 543 | for _, r := range wide { |
| 544 | if r.Width != r.Desk || len(r.Lit) != 1 || r.Lit[0] != 0 { |
| 545 | t.Fatalf("%s at 1280px: %s renders at --ds-w: %s with %v lit over a %.0fpx stage, want its own desktop width (%s) under a lit Desktop (threshold %.0fpx of stage)", kind, r.ID, r.Width, r.Lit, r.Stage, r.Desk, dsw(t, kind, r.Desk)*kMin) |
| 546 | } |
| 547 | } |
| 548 | agree(t, kind+" at 1280px", wide) |
| 549 | showsItsSample(t, kind+" at 1280px", wide) |
| 550 | |
| 551 | openPage(t, ctx, kind+" at 390px", url, 390, 844) |
| 552 | eagerly(t, ctx, kind+" at 390px") |
| 553 | requireDrawnScrollbar(t, ctx, kind+" at 390px") |
| 554 | phone := boxes(t, ctx, kind+" at 390px") |
| 555 | opensOn(t, kind+" at 390px, opened", phone, 1, "") |
| 556 | agree(t, kind+" at 390px, opened", phone) |
| 557 | showsItsSample(t, kind+" at 390px, opened", phone) |
| 558 | noSidewaysPage(t, ctx, kind+" at 390px, opened") |
| 559 | |
| 560 | mustRun(t, ctx, chromedp.EmulateViewport(320, 844), chromedp.Sleep(400*time.Millisecond)) |
| 561 | narrow := boxes(t, ctx, kind+" at 320px") |
| 562 | opensOn(t, kind+" at 320px, opened", narrow, 1, "") |
| 563 | agree(t, kind+" at 320px, opened", narrow) |
| 564 | showsItsSample(t, kind+" at 320px, opened", narrow) |
| 565 | noSidewaysPage(t, ctx, kind+" at 320px, opened") |
| 566 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Sleep(400*time.Millisecond)) |
| 567 | |
| 568 | clickAll(t, ctx, kind+" at 390px", clickedDesktop, "Desktop") |
| 569 | chosen := boxes(t, ctx, kind+" at 390px, Desktop chosen") |
| 570 | for _, r := range chosen { |
| 571 | if len(r.Checked) != 1 || r.Checked[0] != 0 { |
| 572 | t.Fatalf("%s at 390px: %s has %v checked after every Desktop radio was clicked", kind, r.ID, r.Checked) |
| 573 | } |
| 574 | if r.Width != r.Desk { |
| 575 | t.Errorf("%s at 390px: %s was told Desktop and renders at --ds-w: %s rather than its class's %s", kind, r.ID, r.Width, r.Desk) |
| 576 | } |
| 577 | } |
| 578 | agree(t, kind+" at 390px, Desktop chosen", chosen) |
| 579 | showsItsSample(t, kind+" at 390px, Desktop chosen", chosen) |
| 580 | panned := 0 |
| 581 | for _, r := range chosen { |
| 582 | if r.PanX > 1 && scrolls(r.OverX) { |
| 583 | panned++ |
| 584 | } |
| 585 | } |
| 586 | if panned != len(chosen) { |
| 587 | t.Errorf("%s at 390px with Desktop chosen: %d of %d boxes both overflow and can be scrolled; --ds-k is clamped at %.2f, so the rest of the page needs somewhere to go", kind, panned, len(chosen), kMin) |
| 588 | } |
| 589 | for _, r := range chosen { |
| 590 | if math.Abs(r.BoxL) > 1 || math.Abs(r.BoxR-r.DocW) > 1 { |
| 591 | t.Errorf("%s at 390px with Desktop chosen: %s's box runs %.1f…%.1f in a %.0fpx document; it should span the screen", kind, r.ID, r.BoxL, r.BoxR, r.DocW) |
| 592 | } |
| 593 | } |
| 594 | noSidewaysPage(t, ctx, kind+" at 390px, Desktop chosen") |
| 595 | |
| 596 | clickAll(t, ctx, kind+" at 390px", clickedMobile, "Mobile") |
| 597 | back := boxes(t, ctx, kind+" at 390px, Mobile chosen") |
| 598 | agree(t, kind+" at 390px, Mobile chosen", back) |
| 599 | showsItsSample(t, kind+" at 390px, Mobile chosen", back) |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | // The whole of the above with script execution switched off. |
| 604 | off := noScripts(t, rig.Context()) |
| 605 | offCtx, cancelOff := context.WithTimeout(off, 120*time.Second) |
| 606 | defer cancelOff() |
| 607 | url := pageURL(rig, "en", "field.html") |
| 608 | openPage(t, offCtx, "field at 390px, scripts off", url, 390, 844) |
| 609 | eagerly(t, offCtx, "field at 390px, scripts off") |
| 610 | requireScriptsOff(t, offCtx, "field at 390px, scripts off") |
| 611 | o := boxes(t, offCtx, "field at 390px, scripts off") |
| 612 | opensOn(t, "field at 390px, scripts off", o, 1, "") |
| 613 | agree(t, "field at 390px, scripts off", o) |
| 614 | showsItsSample(t, "field at 390px, scripts off", o) |
| 615 | mustRun(t, offCtx, chromedp.Click(`.ds-view__tab:first-of-type`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond)) |
| 616 | offChosen := boxes(t, offCtx, "field at 390px, scripts off, Desktop chosen") |
| 617 | if len(offChosen[0].Checked) != 1 || offChosen[0].Checked[0] != 0 { |
| 618 | t.Fatalf("with scripts off, clicking the Desktop label left %v checked; the tabs need JavaScript", offChosen[0].Checked) |
| 619 | } |
| 620 | if offChosen[0].Width != offChosen[0].Desk || len(offChosen[0].Lit) != 1 || offChosen[0].Lit[0] != 0 { |
| 621 | t.Errorf("with scripts off, choosing Desktop left the widget rendering at --ds-w: %s rather than its class's %s, with %v lit", offChosen[0].Width, offChosen[0].Desk, offChosen[0].Lit) |
| 622 | } |
| 623 | agree(t, "field at 390px, scripts off, Desktop chosen", offChosen) |
| 624 | showsItsSample(t, "field at 390px, scripts off, the first widget on Desktop", offChosen[:1]) |
| 625 | } |
| 626 | |
| 627 | // ── The default is a function of the stage, not of the window ──────── |
| 628 | |
| 629 | // stageReading is the widget's opening state at one window width. |
| 630 | type stageReading struct { |
| 631 | File string |
| 632 | VW int |
| 633 | View float64 |
| 634 | Stage float64 |
| 635 | DSW, Desk float64 |
| 636 | Lit int |
| 637 | K, Box float64 |
| 638 | Rail bool |
| 639 | } |
| 640 | |
| 641 | // dsw parses the "900px" a box computes --ds-w to. |
| 642 | func dsw(t *testing.T, where, raw string) float64 { |
| 643 | t.Helper() |
| 644 | v, err := strconv.ParseFloat(strings.TrimSuffix(raw, "px"), 64) |
| 645 | if err != nil { |
| 646 | t.Fatalf("%s: --ds-w reads %q, which is not a length", where, raw) |
| 647 | } |
| 648 | return v |
| 649 | } |
| 650 | |
| 651 | // TestThePreviewDefaultIsMonotoneInStageWidth: order every window |
| 652 | // width by the stage it produces, and the opening view never goes from |
| 653 | // Desktop back to Mobile as the stage gets wider; the threshold is each |
| 654 | // widget's own --ds-wd × --ds-kmin; the query container and the stage |
| 655 | // are the same width; and --ds-k is the stage over the virtual width, |
| 656 | // clamped. Both width classes are swept. The control that makes the |
| 657 | // monotone assertion able to fail, a window that grows while its stage |
| 658 | // shrinks, is the rail arriving at 800px, and it is required: every |
| 659 | // gallery page is the sidebar shell. |
| 660 | func TestThePreviewDefaultIsMonotoneInStageWidth(t *testing.T) { |
| 661 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 662 | ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) |
| 663 | defer cancel() |
| 664 | classes := map[float64]string{} |
| 665 | for _, file := range []string{"field.html", "layout-column.html"} { |
| 666 | monotoneSweep(t, ctx, rig, file, classes) |
| 667 | } |
| 668 | if len(classes) < 2 { |
| 669 | t.Errorf("the sweep saw %d width class(es) (%v); it is written to cover both", len(classes), classes) |
| 670 | } |
| 671 | } |
| 672 | |
| 673 | func monotoneSweep(t *testing.T, ctx context.Context, rig *harness.Rig, file string, classes map[float64]string) { |
| 674 | t.Helper() |
| 675 | openPage(t, ctx, file+" at 1280px", pageURL(rig, "en", file), 1280, 900) |
| 676 | mustRun(t, ctx, chromedp.Sleep(600*time.Millisecond)) |
| 677 | widths := []int{280, 320, 360, 390, 600, 700, 760, 799, 800, 900, 1000, 1100, 1184, 1186, 1200, 1280, 1500} |
| 678 | readings := make([]stageReading, 0, len(widths)) |
| 679 | for _, vw := range widths { |
| 680 | where := fmt.Sprintf("%s at %dpx", file, vw) |
| 681 | mustRun(t, ctx, chromedp.EmulateViewport(int64(vw), 900), chromedp.Sleep(300*time.Millisecond)) |
| 682 | rows := boxes(t, ctx, where) |
| 683 | agree(t, where, rows) |
| 684 | r := rows[0] |
| 685 | if len(r.Lit) != 1 { |
| 686 | t.Fatalf("%s: %v tabs lit, so there is no opening view to record", where, r.Lit) |
| 687 | } |
| 688 | if len(r.Checked) != 0 { |
| 689 | t.Fatalf("%s: %v is checked; this drive reads the opening view and something has chosen for it", where, r.Checked) |
| 690 | } |
| 691 | var rail bool |
| 692 | mustRun(t, ctx, chromedp.Evaluate(`!!document.querySelector("[rst-shell-nav], .ds-nav")`, &rail)) |
| 693 | desk := dsw(t, where, r.Desk) |
| 694 | classes[desk] = file |
| 695 | readings = append(readings, stageReading{File: file, VW: vw, View: r.View, Stage: r.Stage, DSW: dsw(t, where, r.Width), Desk: desk, Lit: r.Lit[0], K: scaleOf(t, where, r), Box: r.Box, Rail: rail}) |
| 696 | } |
| 697 | for _, r := range readings { |
| 698 | t.Logf("%-18s vw=%-5d view=%-7.1f stage=%-7.1f --ds-wd=%-5.0f --ds-w=%-5.0f k=%.3f box=%.1f opens on %s", r.File, r.VW, r.View, r.Stage, r.Desk, r.DSW, r.K, r.Box, tabName(r.Lit)) |
| 699 | } |
| 700 | var grew, shrank stageReading |
| 701 | for _, r := range readings { |
| 702 | switch r.VW { |
| 703 | case 700: |
| 704 | grew = r |
| 705 | case 800: |
| 706 | shrank = r |
| 707 | } |
| 708 | } |
| 709 | switch { |
| 710 | case grew.VW == 0 || shrank.VW == 0: |
| 711 | t.Fatalf("the sweep is missing the 700px/800px pair") |
| 712 | case !shrank.Rail: |
| 713 | t.Fatalf("no rail on the page at 800px: the gallery is the sidebar shell, and its rail arriving is the control this drive needs") |
| 714 | case shrank.Stage >= grew.Stage: |
| 715 | t.Fatalf("the control is gone: with the rail on the page the stage is %.1fpx in a 700px window and %.1fpx in an 800px one, so no window grows while its stage shrinks", grew.Stage, shrank.Stage) |
| 716 | default: |
| 717 | t.Logf("control: the stage shrinks from %.1fpx to %.1fpx as the window grows from 700px to 800px; the rail arrives", grew.Stage, shrank.Stage) |
| 718 | } |
| 719 | byStage := append([]stageReading(nil), readings...) |
| 720 | sort.Slice(byStage, func(i, j int) bool { return byStage[i].Stage < byStage[j].Stage }) |
| 721 | for i, wide := range byStage { |
| 722 | for _, narrow := range byStage[:i] { |
| 723 | if narrow.Lit == 0 && wide.Lit == 1 { |
| 724 | t.Errorf("a %.1fpx stage (a %dpx window) opens on Desktop and a wider %.1fpx stage (a %dpx window) opens on Mobile", narrow.Stage, narrow.VW, wide.Stage, wide.VW) |
| 725 | } |
| 726 | } |
| 727 | } |
| 728 | for _, r := range readings { |
| 729 | threshold := r.Desk * kMin |
| 730 | want := 1 |
| 731 | if r.Stage >= threshold { |
| 732 | want = 0 |
| 733 | } |
| 734 | if r.Lit != want { |
| 735 | t.Errorf("%s: a %.1fpx stage (a %dpx window) on a %.0fpx virtual page opens on %s; at or over %.0fpx of stage the desktop rendering is the legible one and under it the 390px one is", r.File, r.Stage, r.VW, r.Desk, tabName(r.Lit), threshold) |
| 736 | } |
| 737 | if math.Abs(r.View-r.Stage) > 0.5 { |
| 738 | t.Errorf("at %dpx the query container .ds-view is %.2fpx wide and the stage inside it is %.2fpx; the two have to be the same ruler", r.VW, r.View, r.Stage) |
| 739 | } |
| 740 | if want := math.Min(1, math.Max(kMin, r.Stage/r.DSW)); math.Abs(r.K-want) > 0.01 { |
| 741 | t.Errorf("at %dpx the frame is scaled by %.4f and the stage is %.1fpx of a %.0fpx virtual page, which clamps to %.4f", r.VW, r.K, r.Stage, r.DSW, want) |
| 742 | } |
| 743 | } |
| 744 | } |
| 745 | |