rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package gallery
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "math"
10 "net/http"
11 "slices"
12 "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.
31func 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.
39func 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.
55func 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.
74func 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.
83type 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.
99func 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.
256const 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.
260const 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.
264type 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
283const 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
315var (
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.
322func 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
335func scrolls(overflow string) bool { return overflow == "auto" || overflow == "scroll" }
336
337// noSidewaysPage: a preview that pans has to pan inside its own box.
338func 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
347func 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
357var tabNames = [...]string{"Desktop", "Mobile", "HTML"}
358
359func 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.
368func 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.
376func 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.
410func 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.
452func 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.
474func 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.
491func 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.
504func 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.
530func 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.
630type 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.
642func 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.
660func 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
673func 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