//go:build browser // The column's footer row as a reader meets it: under the page, quiet, // its menus flat, and each menu's panel on the screen however the // browser places it. package check import ( "context" "encoding/json" "fmt" "math" "net/http" "os" "strings" "testing" "time" "github.com/chromedp/cdproto/emulation" cdppage "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta/internal/harness" ) // columnDoc is the column example as the rig serves it, its assets // flattened beside the page. func columnDoc(t *testing.T) string { t.Helper() return parityLinks(defaultMarkup(t, "layout-column")) } // inArabic is a document re-rooted right to left, as an Arabic page // of the same app would be. func inArabic(t *testing.T, doc string) string { t.Helper() const root = `` if !strings.Contains(doc, root) { t.Fatalf("the document has no %s to turn right to left", root) } return strings.Replace(doc, root, ``, 1) } // TestTheFooterRowSitsUnderThePage: the page's title is the first // thing on screen and the menus come after the content, at the inline // end of the column (the right in English, the left in Arabic), side by // side where they fit and wrapping without a sideways scrollbar where // they do not; the row is muted, under a hairline in the line colour. func TestTheFooterRowSitsUnderThePage(t *testing.T) { doc := columnDoc(t) rig := sizingRig(t, false, map[string]string{"/": doc, "/rtl": inArabic(t, doc)}) ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) defer cancel() for _, size := range [][2]int64{{1280, 800}, {390, 844}, {320, 640}} { for _, path := range []string{"/", "/rtl"} { where := fmt.Sprintf("%s at %d", path, size[0]) mustRun(t, ctx, chromedp.EmulateViewport(size[0], size[1]), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("[rst-page-header] h1", chromedp.ByQuery)) var foot bool at(t, ctx, `JSON.stringify(!!document.querySelector("main[rst-page] > [rst-page-foot]"))`, &foot) if !foot { t.Fatalf("%s: the column page has no footer row in its
", where) } var g struct { Last, RTL bool TitleTop, CountBottom, FootTop float64 SummaryTops []float64 SchemeEnd, ContentEnd float64 ScrollW, ClientW float64 Colour, Muted string BorderWidth, BorderStyle, Border, Line string } at(t, ctx, `(() => { const main = document.querySelector("main[rst-page]"), foot = document.querySelector("[rst-page-foot]"); const last = main.lastElementChild === foot, f = foot.getBoundingClientRect(), cs = getComputedStyle(foot), rtl = cs.direction === "rtl"; const end = rtl ? f.left + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft) : f.right - parseFloat(cs.borderRightWidth) - parseFloat(cs.paddingRight); const scheme = foot.querySelector("[rst-scheme] > summary").getBoundingClientRect(); const probe = document.createElement("span"); probe.style.color = "var(--rst-text-muted)"; probe.style.borderTop = "1px solid var(--rst-line)"; main.append(probe); const p = getComputedStyle(probe), muted = p.color, line = p.borderTopColor; probe.remove(); return JSON.stringify({Last: last, RTL: rtl, TitleTop: document.querySelector("[rst-page-header] h1").getBoundingClientRect().top, CountBottom: document.querySelector("[rst-count-line]").getBoundingClientRect().bottom, FootTop: f.top, SummaryTops: [...document.querySelectorAll("[rst-dropdown] > summary")].map(s => s.getBoundingClientRect().top), SchemeEnd: rtl ? scheme.left : scheme.right, ContentEnd: end, ScrollW: document.documentElement.scrollWidth, ClientW: document.documentElement.clientWidth, Colour: cs.color, Muted: muted, BorderWidth: cs.borderTopWidth, BorderStyle: cs.borderTopStyle, Border: cs.borderTopColor, Line: line}); })()`, &g) if g.RTL != (path == "/rtl") { t.Fatalf("%s: the footer reads direction rtl=%v; this leg is not the direction it claims", where, g.RTL) } if len(g.SummaryTops) != 2 { t.Fatalf("%s: %d dropdown summaries on the page, want the footer's two", where, len(g.SummaryTops)) } for _, top := range g.SummaryTops { if top <= g.TitleTop { t.Errorf("%s: a menu's summary starts at %.0f and the title at %.0f; the title must be the first thing on screen", where, top, g.TitleTop) } } if !g.Last { t.Errorf("%s: the footer row is not the page's last element", where) } if g.FootTop < g.CountBottom-0.5 { t.Errorf("%s: the footer starts at %.1f, above the count line's foot at %.1f", where, g.FootTop, g.CountBottom) } if math.Abs(g.SchemeEnd-g.ContentEnd) > 1 { t.Errorf("%s: the scheme menu ends at %.1f, the footer's content box at %.1f; the menus belong at the row's inline end", where, g.SchemeEnd, g.ContentEnd) } if size[0] != 320 && math.Abs(g.SummaryTops[0]-g.SummaryTops[1]) > 0.5 { t.Errorf("%s: the two summaries start at %.1f and %.1f; at this width they fit on one line", where, g.SummaryTops[0], g.SummaryTops[1]) } if size[0] == 320 && g.ScrollW > g.ClientW { t.Errorf("%s: the page is %.0f wide in a %.0f viewport; the row must wrap, not scroll sideways", where, g.ScrollW, g.ClientW) } if g.Colour != g.Muted { t.Errorf("%s: the footer's text is %s, want the muted text colour %s", where, g.Colour, g.Muted) } if g.BorderWidth != "1px" || g.BorderStyle != "solid" || g.Border != g.Line { t.Errorf("%s: the footer's top border is %s %s %s, want 1px solid %s, the line colour", where, g.BorderWidth, g.BorderStyle, g.Border, g.Line) } } } } // TestTheFootersMenusWearNoFace: in day, light, the footer row's // summaries stay flat in every state, as the menus in a shell's tail // do: a quiet row of muted words, not two buttons at the foot of the // page. The same dropdown in a page of its own wears the summary face, // so the check can see a face when there is one. Both spellings. func TestTheFootersMenusWearNoFace(t *testing.T) { doc := columnDoc(t) i, j := strings.Index(doc, "") if i < 0 || j < i { t.Fatal("the column example has no footer row to spell with classes") } classFoot := classSpelling(t, `
`+doc[i:j+len("")]+`
`) if !strings.Contains(classFoot, `class="rst-page__foot"`) { t.Fatalf("the class-spelled footer is %.120q; it does not carry .rst-page__foot", classFoot) } pages := map[string]string{ "/": doc, "/class": sizingDoc("footer, classes", strings.Replace(classFoot, `class="rst-page__foot"`, `class="rst-page__foot" id="class-foot"`, 1)), "/alone": sizingDoc("a dropdown alone", `
`+dropdownSample(t)+`
`), } for _, width := range []int64{1280, 390} { rig := sizingRig(t, false, pages, harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) mustRun(t, ctx, chromedp.EmulateViewport(width, 844), emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}})) mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/alone"), chromedp.WaitVisible("#alone", chromedp.ByQuery)) still(t, ctx) if bg, sh := computed(t, ctx, "#alone > [rst-dropdown] > summary", "background-color"), computed(t, ctx, "#alone > [rst-dropdown] > summary", "box-shadow"); bg == "rgba(0, 0, 0, 0)" && sh == "none" { t.Errorf("%d: CONTROL: the dropdown alone is %s with box-shadow %q, no face; the check below cannot see one", width, bg, sh) } for _, page := range []struct{ path, sel string }{ {"/", "[rst-page-foot] [rst-locale] > summary"}, {"/", "[rst-page-foot] [rst-scheme] > summary"}, {"/class", "#class-foot .rst-scheme > summary"}, } { mustRun(t, ctx, chromedp.Navigate(rig.Origin+page.path), chromedp.WaitVisible(page.sel, chromedp.ByQuery)) still(t, ctx) var hov bool at(t, ctx, `JSON.stringify(matchMedia("(hover: hover)").matches)`, &hov) if !hov { t.Fatalf("%d: (hover: hover) is false; the face's hover rules cannot be read here", width) } for _, states := range [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}} { forceState(t, ctx, page.sel, states...) if bg, sh := computed(t, ctx, page.sel, "background-color"), computed(t, ctx, page.sel, "box-shadow"); bg != "rgba(0, 0, 0, 0)" || sh != "none" { t.Errorf("%d: footer summary %s %v is %s with box-shadow %q; want no face (rgba(0, 0, 0, 0), none)", width, page.sel, states, bg, sh) } forceState(t, ctx, page.sel) } } cancel() } } // longestLabels is the pair of footer labels, Language and Colour // scheme, of the left-to-right locale whose two summaries are widest // on screen, read from the gallery's sample catalogs (the words the // column demo shows in each language), measured in ctx's browser. func longestLabels(t *testing.T, ctx context.Context, origin string) (lang, language, scheme string) { t.Helper() var pairs []struct{ Lang, Language, Scheme string } for _, locale := range []string{"bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"} { language, scheme := sampleWord(t, locale, "Language"), sampleWord(t, locale, "Colour scheme") pairs = append(pairs, struct{ Lang, Language, Scheme string }{locale, language, scheme}) } raw, _ := json.Marshal(pairs) var widest int mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(origin+"/measure"), chromedp.WaitReady("[rst-page]", chromedp.ByQuery)) at(t, ctx, `(() => { const pairs = `+string(raw)+`, page = document.querySelector("[rst-page]"); let best = -1, at = 0; pairs.forEach((p, i) => { const f = document.createElement("footer"); f.setAttribute("rst-page-foot", ""); f.lang = p.Lang; for (const w of [p.Language, p.Scheme]) { const d = document.createElement("details"); d.setAttribute("rst-dropdown", ""); const s = document.createElement("summary"); s.textContent = w; d.append(s); f.append(d); } page.append(f); const w = [...f.querySelectorAll("summary")].reduce((n, s) => n + s.getBoundingClientRect().width, 0); f.remove(); if (w > best) { best = w; at = i; } }); return JSON.stringify(at); })()`, &widest) return pairs[widest].Lang, pairs[widest].Language, pairs[widest].Scheme } // sampleWord is word in locale's sample catalog, the translation the // gallery puts on its pages. func sampleWord(t *testing.T, locale, word string) string { t.Helper() raw, err := os.ReadFile("../internal/gallery/i18n/samples." + locale + ".json") if err != nil { t.Fatal(err) } var catalog map[string]string if err := json.Unmarshal(raw, &catalog); err != nil { t.Fatalf("samples.%s.json: %v", locale, err) } got, ok := catalog[word] if !ok || got == "" { t.Fatalf("samples.%s.json has no %q", locale, word) } return got } // labelled is the column document with its two footer summaries // reading language and scheme, in lang, in dir. func labelled(t *testing.T, doc, lang, dir, language, scheme string) string { t.Helper() for _, swap := range [][2]string{ {``, ``}, {`Language<`, `` + language + `<`}, {`Colour scheme<`, `` + scheme + `<`}, } { if !strings.Contains(doc, swap[0]) { t.Fatalf("the column document has no %s to relabel", swap[0]) } doc = strings.Replace(doc, swap[0], swap[1], 1) } return doc } // TestTheFootersMenusOpenOnScreen: on a phone, scrolled to the foot of // the page, each footer menu opens on the screen, with the longest // labels a left-to-right translation gives the row and with Arabic's, // right to left. Where the browser anchors a panel to its summary it // flips it up and over as it must. Where it cannot, a panel hangs from // the box that holds it, and in the row Language is not at the end: // hung from its own
, its panel, at least 176px wide, runs // past the opposite edge at 320. So the row holds every panel, under // it and flush with its inline end, whichever menu it belongs to, with // anchor.js running and with no script at all, in both spellings, and // the page grows to hold the panel so it can be scrolled to. A panel // at the row's inline start would be inside the row and on the screen // too, and under nothing it belongs to. func TestTheFootersMenusOpenOnScreen(t *testing.T) { doc := columnDoc(t) noAnchor := tokensWithoutAnchorPositioning(t) measure := sizingRig(t, false, map[string]string{"/measure": sizingDoc("measure", `
`)}) mctx, mcancel := context.WithTimeout(measure.Context(), 30*time.Second) lang, language, scheme := longestLabels(t, mctx, measure.Origin) mcancel() t.Logf("the widest left-to-right labels are %s's: %q and %q", lang, language, scheme) ltr := labelled(t, doc, lang, "ltr", language, scheme) rtl := labelled(t, doc, "ar", "rtl", sampleWord(t, "ar", "Language"), sampleWord(t, "ar", "Colour scheme")) for _, leg := range []struct { name string anchor, scripts bool }{ {"anchor positioning", true, true}, {"no anchor positioning, anchor.js", false, true}, {"no anchor positioning, no scripts", false, false}, } { t.Run(leg.name, func(t *testing.T) { pages := map[string]string{"/ltr": ltr, "/rtl": rtl, "/ltr-class": classSpelling(t, ltr), "/rtl-class": classSpelling(t, rtl)} if !leg.anchor { for path, page := range pages { pages[path] = strings.Replace(page, `href="tokens.css"`, `href="tokens-noanchor.css"`, 1) } } rig := harness.New(t, func(string) http.Handler { mux := sizingMux(t, pages) mux.HandleFunc("GET /tokens-noanchor.css", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/css") fmt.Fprint(w, noAnchor) }) return mux }, harness.WithCoarsePointer()) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() if !leg.anchor && leg.scripts { mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { _, err := cdppage.AddScriptToEvaluateOnNewDocument(withoutAnchorSupport).Do(ctx) return err })) } if !leg.scripts { mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true)) } for _, size := range [][2]int64{{390, 640}, {320, 568}} { for _, path := range []string{"/ltr", "/rtl", "/ltr-class", "/rtl-class"} { for _, menu := range []string{"rst-locale", "rst-scheme"} { where := fmt.Sprintf("%s at %dx%d, %s", path, size[0], size[1], menu) details, panel := "[rst-page-foot] > ["+menu+"]", "[rst-dropdown-menu]" if strings.HasSuffix(path, "-class") { details, panel = ".rst-page__foot > ."+menu, ".rst-dropdown__menu" } mustRun(t, ctx, chromedp.EmulateViewport(size[0], size[1]), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible(details, chromedp.ByQuery)) var flips bool at(t, ctx, `JSON.stringify(document.documentElement.hasAttribute("data-rst-flip-menus"))`, &flips) if want := !leg.anchor && leg.scripts; flips != want { t.Fatalf("%s: data-rst-flip-menus is %v, want %v; anchor.js is not doing what this leg needs", where, flips, want) } RequireAnchorPositioning(t, ctx, where, details+" > "+panel, leg.anchor) mustRun(t, ctx, chromedp.Evaluate(`window.scrollTo(0, document.documentElement.scrollHeight)`, nil), chromedp.Click(details+" > summary", chromedp.ByQuery)) if leg.scripts { // anchor.js places a panel on toggle, which // fires after the click. mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) } var g struct { Open bool L, R, T, B, FL, FR, FB, VW float64 VH, SY, SH float64 PanelEnd, RowEnd float64 } // The row's inline end is its padding box's, the // edge an absolute panel's inset-inline-end // measures from: the right in LTR, the left in RTL. at(t, ctx, `(() => { const d = document.querySelector(`+jsStr(details)+`), p = d.querySelector(":scope > `+panel+`").getBoundingClientRect(), row = d.parentElement, f = row.getBoundingClientRect(), cs = getComputedStyle(row), rtl = cs.direction === "rtl"; return JSON.stringify({Open: d.open, L: p.left, R: p.right, T: p.top, B: p.bottom, FL: f.left, FR: f.right, FB: f.bottom, VW: document.documentElement.clientWidth, VH: innerHeight, SY: scrollY, SH: document.documentElement.scrollHeight, PanelEnd: rtl ? p.left : p.right, RowEnd: rtl ? f.left + parseFloat(cs.borderLeftWidth) : f.right - parseFloat(cs.borderRightWidth)}); })()`, &g) if !g.Open { t.Fatalf("%s: the menu did not open; this leg proves nothing", where) } if g.L < -0.5 || g.R > g.VW+0.5 { t.Errorf("%s: the panel spans %.1f to %.1f of a %.0fpx viewport; it must open on the screen", where, g.L, g.R, g.VW) } if leg.anchor { if g.T < -0.5 || g.B > g.VH+0.5 { t.Errorf("%s: the panel runs from %.1f to %.1f of a %.0fpx viewport; anchored, it must flip to stay on the screen", where, g.T, g.B, g.VH) } continue } if g.L < g.FL-0.5 || g.R > g.FR+0.5 { t.Errorf("%s: the panel spans %.1f to %.1f and the footer %.1f to %.1f; the row must hold its panel", where, g.L, g.R, g.FL, g.FR) } if math.Abs(g.PanelEnd-g.RowEnd) > 0.5 { t.Errorf("%s: the panel's inline end is at %.1f and the row's at %.1f; without anchoring every panel opens flush with the row's inline end", where, g.PanelEnd, g.RowEnd) } if g.T < g.FB-0.5 { t.Errorf("%s: the panel starts at %.1f, above the footer's foot at %.1f; it must open under the row", where, g.T, g.FB) } if g.B+g.SY > g.SH+0.5 { t.Errorf("%s: the panel ends at %.1f of the document, which is %.0f tall; the page must grow to hold it", where, g.B+g.SY, g.SH) } } } } }) } } // TestAnEmptyFooterRowDrawsNothing: an app with one language and no // scheme menu renders the row with nothing in it but the template's // whitespace. That row is not drawn: no hairline and no gap under the // page. :empty would miss it, since whitespace is a child. The control // is the same row with one menu in it, which is drawn. Both spellings. func TestAnEmptyFooterRowDrawsNothing(t *testing.T) { menu := `
Language
` body := `

Content

` + `
` + "\n \n" + `
` + `
` + menu + `
` + `

Content

` + `
` + "\n \n" + `
` + `
` rig := sizingRig(t, false, map[string]string{"/": sizingDoc("empty footer", body)}) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#held", chromedp.ByQuery)) for sel, want := range map[string]string{"#empty": "none", "#held": "flex", "#empty-c": "none", "#held-c": "flex"} { if got := computed(t, ctx, sel, "display"); got != want { t.Errorf("footer %s is display %s, want %s", sel, got, want) } } }