//go:build browser package gallery import ( "context" "encoding/json" "fmt" "math" "net/http" "slices" "sort" "strconv" "strings" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" "amadan.net/rastrillo/fiesta/internal/harness" ) // The preview widget in a real engine: Rastrillo's drives, each on // this task's own markup. The widget is three radios, a container // query and a scaled frame, and none of what they do shows in the // rendered bytes. // pageURL is one page of the default theme in locale, at the rig. func pageURL(rig *harness.Rig, locale, file string) string { return rig.Origin + href(fiesta.ThemeNames()[0]+"/"+locale+"/"+file) } // requireWidget fails a leg by name when the page carries no widget as // this task renders it: a box on the stage and a Desktop radio per // frame. Every drive here asks this first, so a page without the // widget fails on the spot rather than at a wait's deadline. func requireWidget(t *testing.T, ctx context.Context, where string) { t.Helper() var got struct{ Boxes, Tabs, Frames int } at(t, ctx, `JSON.stringify({Boxes: document.querySelectorAll(".ds-view > .ds-view__stage > .ds-view__box").length, Tabs: document.querySelectorAll(".ds-view__tabs .ds-view__tab--d input[type=radio]").length, Frames: document.querySelectorAll(".ds-view__frame").length})`, &got) if got.Boxes == 0 || got.Tabs != got.Boxes || got.Frames != got.Boxes { 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) } } // pickScheme chooses one scheme from the header's menu: the visible // copy of the controls (a page carries the bar's and the phone menu's), // opened, and its button pressed, as a reader would. Through the // debugger rather than chromedp.Click, which waits on the first copy in // the document being visible. func pickScheme(t *testing.T, ctx context.Context, scheme string) { t.Helper() var ok bool mustRun(t, ctx, chromedp.Evaluate(`(() => { const menus = [...document.querySelectorAll("[rst-scheme]")]; const m = menus.find(m => m.checkVisibility()) || menus[0]; if (!m) return false; m.open = true; const b = m.querySelector('input[value="`+scheme+`"] ~ button'); if (!b) return false; b.click(); return true; })()`, &ok)) if !ok { t.Fatalf("no scheme menu with a %s button on the page", scheme) } } // openPage sizes the viewport, loads url and requires the widget. func openPage(t *testing.T, ctx context.Context, where, url string, w, h int64) { t.Helper() mustRun(t, ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(url), chromedp.WaitReady("body")) requireWidget(t, ctx, where) } // framing is one reading of a widget: the frame's layout, what the // framed document reports, which panel is on screen, and the scheme // the framed document resolved to. type framing struct { Virtual, Painted, Inner, Gutter float64 Body string FrameShown, HTMLShown bool Tabs []string Scheme string } // TestPreviewWidgetDrivesTheWholeJourney: on the callout page, the // widget opens on the framed rendering at a component's 900px; Mobile // is the same document at 390px; HTML replaces the frame with its // panel and nothing else; every link in every framed document goes // nowhere; the grip really resizes the document's viewport; Dark // chosen in the header reaches the framed documents; and with scripts // off the HTML tab still works. Port of Rastrillo's drive without its // Template tab. func TestPreviewWidgetDrivesTheWholeJourney(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() url := pageURL(rig, "en", "callout.html") const widget = `document.querySelector("#info .ds-view")` read := func(sel string) string { return `(() => { const v = ` + sel + `; const f = v.querySelector(".ds-view__frame"), box = v.querySelector(".ds-view__box"), stage = v.querySelector(".ds-view__stage"); const html = v.querySelector(".ds-view__code--h"); const d = f.contentDocument; return JSON.stringify({ Virtual: parseFloat(getComputedStyle(f).width), Painted: box.getBoundingClientRect().width, Inner: d ? d.documentElement.getBoundingClientRect().width : -1, Gutter: d ? d.defaultView.innerWidth - d.documentElement.getBoundingClientRect().width : 0, Body: d ? d.body.innerHTML.trim().slice(0, 120) : "", FrameShown: getComputedStyle(stage).display !== "none", HTMLShown: html ? getComputedStyle(html).display !== "none" : false, Tabs: [...v.querySelectorAll(".ds-view__tab")].map(l => l.textContent.trim()), Scheme: d ? getComputedStyle(d.documentElement).colorScheme + " " + getComputedStyle(d.body).backgroundColor : "" }); })()` } openPage(t, ctx, "callout at 1280px", url, 1280, 900) painted(t, ctx, "callout") var desktop, mobile, htmlTab, dark, scriptless, resized string var deadLinks int step := func(what string, actions ...chromedp.Action) { t.Helper() if err := chromedp.Run(ctx, actions...); err != nil { t.Fatalf("%s: %v", what, err) } } step("reading Desktop", chromedp.Evaluate(read(widget), &desktop)) // Clicking the label, the way a reader does, which is also the // proof that the label and its radio are paired. step("choosing Mobile", chromedp.Click(`#info .ds-view__tab--m`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(read(widget), &mobile)) step("choosing HTML", chromedp.Click(`#info .ds-view__tab--h`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(read(widget), &htmlTab)) // Every link in every framed document goes nowhere, or to a page // of this tree: read off the documents themselves. step("reading the links", chromedp.Evaluate(`(() => { let bad = 0; for (const f of document.querySelectorAll(".ds-view__frame")) { const d = f.contentDocument; if (!d) continue; for (const a of d.querySelectorAll("a[href]")) { const href = a.getAttribute("href"); if (!href.startsWith("#") && !href.startsWith(`+strconv.Quote(mountPath+"/")+`)) bad++; } } return bad; })()`, &deadLinks)) // The grip: the box is what the reader drags, and the frame takes // its height back off the box, so the document inside really does // get taller. step("dragging the grip", chromedp.Click(`#info .ds-view__tab:first-of-type`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(`(() => { const box = document.querySelector("#info .ds-view__box"), f = document.querySelector("#info .ds-view__frame"); const before = f.contentDocument.documentElement.clientHeight; box.style.height = Math.round(box.getBoundingClientRect().height * 2) + "px"; return JSON.stringify({before: before, after: f.contentDocument.documentElement.clientHeight, painted: Math.round(f.getBoundingClientRect().height), box: Math.round(box.getBoundingClientRect().height)}); })()`, &resized)) pickScheme(t, ctx, "dark") step("reading Dark", chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(read(widget), &dark)) // The same with the page's scripts switched off at the engine. off := noScripts(t, rig.Context()) offCtx, cancelOff := context.WithTimeout(off, 120*time.Second) defer cancelOff() openPage(t, offCtx, "callout at 1280px, scripts off", url, 1280, 900) requireScriptsOff(t, offCtx, "callout, scripts off") mustRun(t, offCtx, chromedp.Click(`#info .ds-view__tab--h`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(`(() => { const v = `+widget+`; return JSON.stringify({FrameShown: getComputedStyle(v.querySelector(".ds-view__stage")).display !== "none", HTMLShown: getComputedStyle(v.querySelector(".ds-view__code--h")).display !== "none", Body: "", Virtual: 0, Painted: 0, Inner: 0, Gutter: 0, Scheme: "", Tabs: []}); })()`, &scriptless), ) var d, m, h, dk, o framing for _, p := range []struct { raw string into *framing }{{desktop, &d}, {mobile, &m}, {htmlTab, &h}, {dark, &dk}, {scriptless, &o}} { if err := json.Unmarshal([]byte(p.raw), p.into); err != nil { t.Fatalf("reading a framing (%q): %v", p.raw, err) } } // A component lays out at 900px whatever the column is; the number // is written here on purpose, the one assertion that would still // fail if the stylesheet lost the component class. const componentWidth = 900 if d.Virtual != componentWidth { t.Errorf("the desktop frame lays out at %gpx, want %d", d.Virtual, componentWidth) } if d.Inner+d.Gutter != componentWidth { 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) } if d.Painted < componentWidth-2 || d.Painted > componentWidth+2 { 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) } 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) if !d.FrameShown || d.HTMLShown { t.Error("the page does not open on the framed rendering") } if want := []string{say("gallery.view.desktop"), say("gallery.view.mobile"), say("gallery.html")}; !slices.Equal(d.Tabs, want) { t.Errorf("the callout's tabs are %q, want %q", d.Tabs, want) } if !strings.Contains(d.Body, "rst-callout") { t.Errorf("the framed document is not the callout sample: %q", d.Body) } if m.Virtual != 390 { t.Errorf("the mobile frame lays out at %gpx, want 390", m.Virtual) } if m.Inner+m.Gutter != 390 { 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) } if m.Body != d.Body { t.Error("Desktop and Mobile are not the same document") } if h.FrameShown || !h.HTMLShown { t.Errorf("the HTML tab shows frame=%v HTML=%v", h.FrameShown, h.HTMLShown) } var grip struct{ Before, After, Painted, Box int } if err := json.Unmarshal([]byte(resized), &grip); err != nil { t.Fatalf("reading the resize (%q): %v", resized, err) } if grip.After <= grip.Before { 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) } if grip.Painted < grip.Box-4 { t.Errorf("after the drag the box is %dpx and the rendering in it is %dpx", grip.Box, grip.Painted) } if deadLinks != 0 { t.Errorf("%d links inside the framed documents still point at a route this tree does not serve", deadLinks) } if !strings.HasPrefix(dk.Scheme, "dark ") { t.Errorf("after choosing Dark the framed document is %q; the previews are not following the gallery", dk.Scheme) } if dk.Scheme == d.Scheme { t.Errorf("the framed document is %q both before and after Dark was chosen; the reading proves nothing", d.Scheme) } if o.FrameShown || !o.HTMLShown { t.Errorf("with scripts disabled the HTML tab shows frame=%v HTML=%v; the tabs need JavaScript", o.FrameShown, o.HTMLShown) } } // ── The widget on a phone ──────────────────────────────────────────── // minShownSample is the least of a sample a reader must be able to see // in a box, in CSS pixels: about one line of the smallest sample. const minShownSample = 32.0 // kMin mirrors --ds-kmin in view.css: 12.5px × 0.72 = 9.0px is where // rendered text stops being read. const kMin = 0.72 // previewBox is one widget's geometry as the engine has it. Tabs are // identified by position, 0 Desktop, 1 Mobile, 2 HTML. type previewBox struct { ID string Hidden bool Box float64 Width string Desk string Scale string Lit []int Checked []int View float64 Stage float64 Doc float64 Inner float64 PanX float64 OverX string BoxL, BoxR float64 DocW float64 } const readBoxes = `(() => { const out = []; document.querySelectorAll(".ds-view").forEach(v => { const stage = v.querySelector(".ds-view__stage"), box = v.querySelector(".ds-view__box"), frame = v.querySelector(".ds-view__frame"); let d = null; try { d = frame.contentDocument; } catch (e) {} const tabs = [...v.querySelectorAll(".ds-view__tab")], lit = [], checked = []; tabs.forEach((l, n) => { if (getComputedStyle(l).fontWeight === "600") lit.push(n); const i = l.querySelector("input"); if (i && i.checked) checked.push(n); }); const section = v.closest("article, section"); out.push({ ID: section && section.id ? section.id : "widget-" + out.length, Hidden: getComputedStyle(stage).display === "none", Box: Math.round(box.getBoundingClientRect().height * 10) / 10, Width: frame.offsetWidth + "px", Desk: getComputedStyle(box).getPropertyValue("--ds-wd").trim(), Scale: getComputedStyle(frame).transform, Lit: lit, Checked: checked, View: Math.round(v.getBoundingClientRect().width * 100) / 100, Stage: Math.round(stage.getBoundingClientRect().width * 100) / 100, Doc: d && d.body ? Math.max(d.body.getBoundingClientRect().height, d.body.scrollHeight) : -1, Inner: Math.round(box.clientHeight * 10) / 10, PanX: Math.round((box.scrollWidth - box.clientWidth) * 10) / 10, OverX: getComputedStyle(box).overflowX, BoxL: box.getBoundingClientRect().left, BoxR: box.getBoundingClientRect().right, DocW: document.documentElement.clientWidth, }); }); return JSON.stringify(out); })()` var ( clickedMobile = galleryrig.ClickEvery("m") clickedDesktop = galleryrig.ClickEvery("d") ) // clickAll clicks every radio of one kind and refuses to go on unless // the clicks moved something. func clickAll(t *testing.T, ctx context.Context, where, script, what string) { t.Helper() var raw string mustRun(t, ctx, chromedp.Evaluate(script, &raw), chromedp.Sleep(400*time.Millisecond)) var got struct{ Clicked, Moved int } if err := json.Unmarshal([]byte(raw), &got); err != nil { t.Fatalf("%s: reading the %s click (%q): %v", where, what, raw, err) } if got.Clicked == 0 || got.Moved != got.Clicked { 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) } } func scrolls(overflow string) bool { return overflow == "auto" || overflow == "scroll" } // noSidewaysPage: a preview that pans has to pan inside its own box. func noSidewaysPage(t *testing.T, ctx context.Context, where string) { t.Helper() var over float64 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.scrollWidth - document.documentElement.clientWidth`, &over)) if over > 1 { t.Errorf("%s: the page itself scrolls %.1fpx sideways", where, over) } } func boxes(t *testing.T, ctx context.Context, where string) []previewBox { t.Helper() var got []previewBox at(t, ctx, readBoxes, &got) if len(got) == 0 { t.Fatalf("%s: the page has no preview widget on it at all", where) } return got } var tabNames = [...]string{"Desktop", "Mobile", "HTML"} func tabName(n int) string { if n < 0 || n >= len(tabNames) { return fmt.Sprintf("tab %d", n) } return tabNames[n] } // mobileWidth is the Mobile rendering's width for one widget: the // stage's, up to 390px. func mobileWidth(r previewBox) string { return fmt.Sprintf("%dpx", int(math.Round(math.Min(390, r.View)))) } // showsItsSample: what a reader can see of a sample is its own height // times the scale the frame is drawn at, clipped to the box; and the // scale never drops under the floor; and a box wider than its column // is a scroller. func showsItsSample(t *testing.T, where string, rows []previewBox) { t.Helper() say := loudly(t, where, len(rows)) worst, worstID := math.Inf(1), "" for _, r := range rows { if r.Hidden { continue } if r.Doc < 0 { t.Fatalf("%s: %s has no reachable document in its frame", where, r.ID) } if r.Doc == 0 { t.Fatalf("%s: %s frames a document with no height at all", where, r.ID) } k := scaleOf(t, where, r) shown := math.Min(r.Doc*k, r.Inner) if shown < worst { worst, worstID = shown, r.ID } if shown < minShownSample { 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) } if k < kMin-0.005 { say("%s: %s is scaled to %.3f, under the %.2f floor", where, r.ID, k, kMin) } if r.PanX > 1 && !scrolls(r.OverX) { 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) } } t.Logf("%s: %d widgets, least of a sample shown %.1fpx (%s)", where, len(rows), worst, worstID) } // agree: the lit tab and the rendering on screen are chosen by the // same rule, so they never drift. func agree(t *testing.T, where string, rows []previewBox) { t.Helper() say := loudly(t, where, len(rows)) for _, r := range rows { if len(r.Lit) != 1 { say("%s: %s has %d tabs lit at once (%v)", where, r.ID, len(r.Lit), r.Lit) continue } if len(r.Checked) > 1 { say("%s: %s has %d radios checked (%v); they are not one group", where, r.ID, len(r.Checked), r.Checked) } if r.Hidden { if r.Lit[0] < 2 { say("%s: %s shows the source with %s lit", where, r.ID, tabName(r.Lit[0])) } continue } var w string switch r.Lit[0] { case 0: w = r.Desk case 1: w = mobileWidth(r) default: say("%s: %s lights %s and still shows a frame", where, r.ID, tabName(r.Lit[0])) continue } if w == "" { say("%s: %s computes no --ds-wd at all", where, r.ID) continue } if r.Width != w { 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) } if r.Scale == "none" || r.Scale == "" { say("%s: %s has no transform on its frame (%q); --ds-k did not resolve", where, r.ID, r.Scale) } } } // opensOn asserts what the widget shows a reader who has clicked // nothing: the rendering, and the tab lit over it. func opensOn(t *testing.T, where string, rows []previewBox, tab int, width string) { t.Helper() say := loudly(t, where, len(rows)) for _, r := range rows { want := width if want == "" { want = mobileWidth(r) } if len(r.Checked) != 0 { say("%s: %s opens with %v already checked", where, r.ID, r.Checked) } if r.Width != want { 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) } if len(r.Lit) != 1 || r.Lit[0] != tab { say("%s: %s opens with %v lit, want %s", where, r.ID, r.Lit, tabName(tab)) } } } // loudly caps a per-widget assertion at four named failures and then // counts the rest. func loudly(t *testing.T, where string, of int) func(string, ...any) { t.Helper() var n int t.Cleanup(func() { if n > 4 { t.Errorf("%s: %d assertions failed over %d widgets (4 named above)", where, n, of) } }) return func(format string, args ...any) { n++ if n <= 4 { t.Errorf(format, args...) } } } // scaleOf reads the x scale out of a computed transform. func scaleOf(t *testing.T, where string, r previewBox) float64 { t.Helper() var a, b, c, d, e, f float64 if n, err := fmt.Sscanf(r.Scale, "matrix(%g, %g, %g, %g, %g, %g)", &a, &b, &c, &d, &e, &f); err != nil || n != 6 { 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) return 0 } return a } // calibrate proves the @supports branch that computes --ds-k with // tan(atan2(…)) is live in this engine, by finding a frame the engine // scaled: a page-class widget in a stage narrower than 1200px. func calibrate(t *testing.T, ctx context.Context, rig *harness.Rig, file string) { t.Helper() where := file + " at 1280px, calibrating" openPage(t, ctx, where, pageURL(rig, "en", file), 1280, 900) eagerly(t, ctx, where) for _, r := range boxes(t, ctx, where) { if r.Hidden || dsw(t, where, r.Desk) <= r.Stage { continue } k := scaleOf(t, where, r) if k <= 0 || k >= 1 { 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) } 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) return } t.Fatalf("%s: no widget on this page is wider than its stage, so nothing here can prove the engine scales at all", where) } // TestThePreviewWidgetIsUsableOnAPhone: at 390px and 320px, in English // and in Russian, every widget opens on the Mobile rendering at the // screen's width with the Mobile tab lit, shows its sample, never // scales under the floor; an explicit Desktop pans inside a box that // spans the screen; and the whole of it holds with scripts off. The // controls come first: a wide viewport whose answer is known, clicks // checked for having landed, and an engine proved to scale. func TestThePreviewWidgetIsUsableOnAPhone(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, harness.WithScrollbars()) ctx, cancel := context.WithTimeout(rig.Context(), 400*time.Second) defer cancel() calibrate(t, ctx, rig, "layout-column.html") for _, locale := range []string{"en", "ru"} { for _, file := range []string{"field.html", "layout-column.html"} { kind := locale + " " + file url := pageURL(rig, locale, file) openPage(t, ctx, kind+" at 1280px", url, 1280, 900) eagerly(t, ctx, kind+" at 1280px") wide := boxes(t, ctx, kind+" at 1280px") for _, r := range wide { if r.Width != r.Desk || len(r.Lit) != 1 || r.Lit[0] != 0 { 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) } } agree(t, kind+" at 1280px", wide) showsItsSample(t, kind+" at 1280px", wide) openPage(t, ctx, kind+" at 390px", url, 390, 844) eagerly(t, ctx, kind+" at 390px") requireDrawnScrollbar(t, ctx, kind+" at 390px") phone := boxes(t, ctx, kind+" at 390px") opensOn(t, kind+" at 390px, opened", phone, 1, "") agree(t, kind+" at 390px, opened", phone) showsItsSample(t, kind+" at 390px, opened", phone) noSidewaysPage(t, ctx, kind+" at 390px, opened") mustRun(t, ctx, chromedp.EmulateViewport(320, 844), chromedp.Sleep(400*time.Millisecond)) narrow := boxes(t, ctx, kind+" at 320px") opensOn(t, kind+" at 320px, opened", narrow, 1, "") agree(t, kind+" at 320px, opened", narrow) showsItsSample(t, kind+" at 320px, opened", narrow) noSidewaysPage(t, ctx, kind+" at 320px, opened") mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Sleep(400*time.Millisecond)) clickAll(t, ctx, kind+" at 390px", clickedDesktop, "Desktop") chosen := boxes(t, ctx, kind+" at 390px, Desktop chosen") for _, r := range chosen { if len(r.Checked) != 1 || r.Checked[0] != 0 { t.Fatalf("%s at 390px: %s has %v checked after every Desktop radio was clicked", kind, r.ID, r.Checked) } if r.Width != r.Desk { 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) } } agree(t, kind+" at 390px, Desktop chosen", chosen) showsItsSample(t, kind+" at 390px, Desktop chosen", chosen) panned := 0 for _, r := range chosen { if r.PanX > 1 && scrolls(r.OverX) { panned++ } } if panned != len(chosen) { 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) } for _, r := range chosen { if math.Abs(r.BoxL) > 1 || math.Abs(r.BoxR-r.DocW) > 1 { 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) } } noSidewaysPage(t, ctx, kind+" at 390px, Desktop chosen") clickAll(t, ctx, kind+" at 390px", clickedMobile, "Mobile") back := boxes(t, ctx, kind+" at 390px, Mobile chosen") agree(t, kind+" at 390px, Mobile chosen", back) showsItsSample(t, kind+" at 390px, Mobile chosen", back) } } // The whole of the above with script execution switched off. off := noScripts(t, rig.Context()) offCtx, cancelOff := context.WithTimeout(off, 120*time.Second) defer cancelOff() url := pageURL(rig, "en", "field.html") openPage(t, offCtx, "field at 390px, scripts off", url, 390, 844) eagerly(t, offCtx, "field at 390px, scripts off") requireScriptsOff(t, offCtx, "field at 390px, scripts off") o := boxes(t, offCtx, "field at 390px, scripts off") opensOn(t, "field at 390px, scripts off", o, 1, "") agree(t, "field at 390px, scripts off", o) showsItsSample(t, "field at 390px, scripts off", o) mustRun(t, offCtx, chromedp.Click(`.ds-view__tab:first-of-type`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond)) offChosen := boxes(t, offCtx, "field at 390px, scripts off, Desktop chosen") if len(offChosen[0].Checked) != 1 || offChosen[0].Checked[0] != 0 { t.Fatalf("with scripts off, clicking the Desktop label left %v checked; the tabs need JavaScript", offChosen[0].Checked) } if offChosen[0].Width != offChosen[0].Desk || len(offChosen[0].Lit) != 1 || offChosen[0].Lit[0] != 0 { 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) } agree(t, "field at 390px, scripts off, Desktop chosen", offChosen) showsItsSample(t, "field at 390px, scripts off, the first widget on Desktop", offChosen[:1]) } // ── The default is a function of the stage, not of the window ──────── // stageReading is the widget's opening state at one window width. type stageReading struct { File string VW int View float64 Stage float64 DSW, Desk float64 Lit int K, Box float64 Rail bool } // dsw parses the "900px" a box computes --ds-w to. func dsw(t *testing.T, where, raw string) float64 { t.Helper() v, err := strconv.ParseFloat(strings.TrimSuffix(raw, "px"), 64) if err != nil { t.Fatalf("%s: --ds-w reads %q, which is not a length", where, raw) } return v } // TestThePreviewDefaultIsMonotoneInStageWidth: order every window // width by the stage it produces, and the opening view never goes from // Desktop back to Mobile as the stage gets wider; the threshold is each // widget's own --ds-wd × --ds-kmin; the query container and the stage // are the same width; and --ds-k is the stage over the virtual width, // clamped. Both width classes are swept. The control that makes the // monotone assertion able to fail, a window that grows while its stage // shrinks, is the rail arriving at 800px, and it is required: every // gallery page is the sidebar shell. func TestThePreviewDefaultIsMonotoneInStageWidth(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) defer cancel() classes := map[float64]string{} for _, file := range []string{"field.html", "layout-column.html"} { monotoneSweep(t, ctx, rig, file, classes) } if len(classes) < 2 { t.Errorf("the sweep saw %d width class(es) (%v); it is written to cover both", len(classes), classes) } } func monotoneSweep(t *testing.T, ctx context.Context, rig *harness.Rig, file string, classes map[float64]string) { t.Helper() openPage(t, ctx, file+" at 1280px", pageURL(rig, "en", file), 1280, 900) mustRun(t, ctx, chromedp.Sleep(600*time.Millisecond)) widths := []int{280, 320, 360, 390, 600, 700, 760, 799, 800, 900, 1000, 1100, 1184, 1186, 1200, 1280, 1500} readings := make([]stageReading, 0, len(widths)) for _, vw := range widths { where := fmt.Sprintf("%s at %dpx", file, vw) mustRun(t, ctx, chromedp.EmulateViewport(int64(vw), 900), chromedp.Sleep(300*time.Millisecond)) rows := boxes(t, ctx, where) agree(t, where, rows) r := rows[0] if len(r.Lit) != 1 { t.Fatalf("%s: %v tabs lit, so there is no opening view to record", where, r.Lit) } if len(r.Checked) != 0 { t.Fatalf("%s: %v is checked; this drive reads the opening view and something has chosen for it", where, r.Checked) } var rail bool mustRun(t, ctx, chromedp.Evaluate(`!!document.querySelector("[rst-shell-nav], .ds-nav")`, &rail)) desk := dsw(t, where, r.Desk) classes[desk] = file 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}) } for _, r := range readings { 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)) } var grew, shrank stageReading for _, r := range readings { switch r.VW { case 700: grew = r case 800: shrank = r } } switch { case grew.VW == 0 || shrank.VW == 0: t.Fatalf("the sweep is missing the 700px/800px pair") case !shrank.Rail: 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") case shrank.Stage >= grew.Stage: 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) default: 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) } byStage := append([]stageReading(nil), readings...) sort.Slice(byStage, func(i, j int) bool { return byStage[i].Stage < byStage[j].Stage }) for i, wide := range byStage { for _, narrow := range byStage[:i] { if narrow.Lit == 0 && wide.Lit == 1 { 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) } } } for _, r := range readings { threshold := r.Desk * kMin want := 1 if r.Stage >= threshold { want = 0 } if r.Lit != want { 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) } if math.Abs(r.View-r.Stage) > 0.5 { 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) } if want := math.Min(1, math.Max(kMin, r.Stage/r.DSW)); math.Abs(r.K-want) > 0.01 { 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) } } }