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
3// The column's footer row as a reader meets it: under the page, quiet,
4// its menus flat, and each menu's panel on the screen however the
5// browser places it.
6package check
7
8import (
9 "context"
10 "encoding/json"
11 "fmt"
12 "math"
13 "net/http"
14 "os"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/chromedp/cdproto/emulation"
20 cdppage "github.com/chromedp/cdproto/page"
21 "github.com/chromedp/chromedp"
22
23 "amadan.net/rastrillo/fiesta/internal/harness"
24)
25
26// columnDoc is the column example as the rig serves it, its assets
27// flattened beside the page.
28func columnDoc(t *testing.T) string {
29 t.Helper()
30 return parityLinks(defaultMarkup(t, "layout-column"))
31}
32
33// inArabic is a document re-rooted right to left, as an Arabic page
34// of the same app would be.
35func inArabic(t *testing.T, doc string) string {
36 t.Helper()
37 const root = `<html lang="en" dir="ltr">`
38 if !strings.Contains(doc, root) {
39 t.Fatalf("the document has no %s to turn right to left", root)
40 }
41 return strings.Replace(doc, root, `<html lang="ar" dir="rtl">`, 1)
42}
43
44// TestTheFooterRowSitsUnderThePage: the page's title is the first
45// thing on screen and the menus come after the content, at the inline
46// end of the column (the right in English, the left in Arabic), side by
47// side where they fit and wrapping without a sideways scrollbar where
48// they do not; the row is muted, under a hairline in the line colour.
49func TestTheFooterRowSitsUnderThePage(t *testing.T) {
50 doc := columnDoc(t)
51 rig := sizingRig(t, false, map[string]string{"/": doc, "/rtl": inArabic(t, doc)})
52 ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
53 defer cancel()
54 for _, size := range [][2]int64{{1280, 800}, {390, 844}, {320, 640}} {
55 for _, path := range []string{"/", "/rtl"} {
56 where := fmt.Sprintf("%s at %d", path, size[0])
57 mustRun(t, ctx, chromedp.EmulateViewport(size[0], size[1]), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("[rst-page-header] h1", chromedp.ByQuery))
58 var foot bool
59 at(t, ctx, `JSON.stringify(!!document.querySelector("main[rst-page] > [rst-page-foot]"))`, &foot)
60 if !foot {
61 t.Fatalf("%s: the column page has no footer row in its <main rst-page>", where)
62 }
63 var g struct {
64 Last, RTL bool
65 TitleTop, CountBottom, FootTop float64
66 SummaryTops []float64
67 SchemeEnd, ContentEnd float64
68 ScrollW, ClientW float64
69 Colour, Muted string
70 BorderWidth, BorderStyle, Border, Line string
71 }
72 at(t, ctx, `(() => {
73 const main = document.querySelector("main[rst-page]"), foot = document.querySelector("[rst-page-foot]");
74 const last = main.lastElementChild === foot, f = foot.getBoundingClientRect(), cs = getComputedStyle(foot), rtl = cs.direction === "rtl";
75 const end = rtl ? f.left + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft) : f.right - parseFloat(cs.borderRightWidth) - parseFloat(cs.paddingRight);
76 const scheme = foot.querySelector("[rst-scheme] > summary").getBoundingClientRect();
77 const probe = document.createElement("span"); probe.style.color = "var(--rst-text-muted)"; probe.style.borderTop = "1px solid var(--rst-line)"; main.append(probe);
78 const p = getComputedStyle(probe), muted = p.color, line = p.borderTopColor; probe.remove();
79 return JSON.stringify({Last: last, RTL: rtl,
80 TitleTop: document.querySelector("[rst-page-header] h1").getBoundingClientRect().top,
81 CountBottom: document.querySelector("[rst-count-line]").getBoundingClientRect().bottom, FootTop: f.top,
82 SummaryTops: [...document.querySelectorAll("[rst-dropdown] > summary")].map(s => s.getBoundingClientRect().top),
83 SchemeEnd: rtl ? scheme.left : scheme.right, ContentEnd: end,
84 ScrollW: document.documentElement.scrollWidth, ClientW: document.documentElement.clientWidth,
85 Colour: cs.color, Muted: muted, BorderWidth: cs.borderTopWidth, BorderStyle: cs.borderTopStyle, Border: cs.borderTopColor, Line: line}); })()`, &g)
86 if g.RTL != (path == "/rtl") {
87 t.Fatalf("%s: the footer reads direction rtl=%v; this leg is not the direction it claims", where, g.RTL)
88 }
89 if len(g.SummaryTops) != 2 {
90 t.Fatalf("%s: %d dropdown summaries on the page, want the footer's two", where, len(g.SummaryTops))
91 }
92 for _, top := range g.SummaryTops {
93 if top <= g.TitleTop {
94 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)
95 }
96 }
97 if !g.Last {
98 t.Errorf("%s: the footer row is not the page's last element", where)
99 }
100 if g.FootTop < g.CountBottom-0.5 {
101 t.Errorf("%s: the footer starts at %.1f, above the count line's foot at %.1f", where, g.FootTop, g.CountBottom)
102 }
103 if math.Abs(g.SchemeEnd-g.ContentEnd) > 1 {
104 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)
105 }
106 if size[0] != 320 && math.Abs(g.SummaryTops[0]-g.SummaryTops[1]) > 0.5 {
107 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])
108 }
109 if size[0] == 320 && g.ScrollW > g.ClientW {
110 t.Errorf("%s: the page is %.0f wide in a %.0f viewport; the row must wrap, not scroll sideways", where, g.ScrollW, g.ClientW)
111 }
112 if g.Colour != g.Muted {
113 t.Errorf("%s: the footer's text is %s, want the muted text colour %s", where, g.Colour, g.Muted)
114 }
115 if g.BorderWidth != "1px" || g.BorderStyle != "solid" || g.Border != g.Line {
116 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)
117 }
118 }
119 }
120}
121
122// TestTheFootersMenusWearNoFace: in day, light, the footer row's
123// summaries stay flat in every state, as the menus in a shell's tail
124// do: a quiet row of muted words, not two buttons at the foot of the
125// page. The same dropdown in a page of its own wears the summary face,
126// so the check can see a face when there is one. Both spellings.
127func TestTheFootersMenusWearNoFace(t *testing.T) {
128 doc := columnDoc(t)
129 i, j := strings.Index(doc, "<footer rst-page-foot>"), strings.Index(doc, "</footer>")
130 if i < 0 || j < i {
131 t.Fatal("the column example has no footer row to spell with classes")
132 }
133 classFoot := classSpelling(t, `<main rst-page>`+doc[i:j+len("</footer>")]+`</main>`)
134 if !strings.Contains(classFoot, `class="rst-page__foot"`) {
135 t.Fatalf("the class-spelled footer is %.120q; it does not carry .rst-page__foot", classFoot)
136 }
137 pages := map[string]string{
138 "/": doc,
139 "/class": sizingDoc("footer, classes", strings.Replace(classFoot, `class="rst-page__foot"`, `class="rst-page__foot" id="class-foot"`, 1)),
140 "/alone": sizingDoc("a dropdown alone", `<div id="alone">`+dropdownSample(t)+`</div>`),
141 }
142 for _, width := range []int64{1280, 390} {
143 rig := sizingRig(t, false, pages, harness.WithHover())
144 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
145 mustRun(t, ctx, chromedp.EmulateViewport(width, 844), emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}}))
146 mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/alone"), chromedp.WaitVisible("#alone", chromedp.ByQuery))
147 still(t, ctx)
148 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" {
149 t.Errorf("%d: CONTROL: the dropdown alone is %s with box-shadow %q, no face; the check below cannot see one", width, bg, sh)
150 }
151 for _, page := range []struct{ path, sel string }{
152 {"/", "[rst-page-foot] [rst-locale] > summary"},
153 {"/", "[rst-page-foot] [rst-scheme] > summary"},
154 {"/class", "#class-foot .rst-scheme > summary"},
155 } {
156 mustRun(t, ctx, chromedp.Navigate(rig.Origin+page.path), chromedp.WaitVisible(page.sel, chromedp.ByQuery))
157 still(t, ctx)
158 var hov bool
159 at(t, ctx, `JSON.stringify(matchMedia("(hover: hover)").matches)`, &hov)
160 if !hov {
161 t.Fatalf("%d: (hover: hover) is false; the face's hover rules cannot be read here", width)
162 }
163 for _, states := range [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}} {
164 forceState(t, ctx, page.sel, states...)
165 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" {
166 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)
167 }
168 forceState(t, ctx, page.sel)
169 }
170 }
171 cancel()
172 }
173}
174
175// longestLabels is the pair of footer labels, Language and Colour
176// scheme, of the left-to-right locale whose two summaries are widest
177// on screen, read from the gallery's sample catalogs (the words the
178// column demo shows in each language), measured in ctx's browser.
179func longestLabels(t *testing.T, ctx context.Context, origin string) (lang, language, scheme string) {
180 t.Helper()
181 var pairs []struct{ Lang, Language, Scheme string }
182 for _, locale := range []string{"bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"} {
183 language, scheme := sampleWord(t, locale, "Language"), sampleWord(t, locale, "Colour scheme")
184 pairs = append(pairs, struct{ Lang, Language, Scheme string }{locale, language, scheme})
185 }
186 raw, _ := json.Marshal(pairs)
187 var widest int
188 mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(origin+"/measure"), chromedp.WaitReady("[rst-page]", chromedp.ByQuery))
189 at(t, ctx, `(() => { const pairs = `+string(raw)+`, page = document.querySelector("[rst-page]"); let best = -1, at = 0;
190 pairs.forEach((p, i) => { const f = document.createElement("footer"); f.setAttribute("rst-page-foot", ""); f.lang = p.Lang;
191 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); }
192 page.append(f); const w = [...f.querySelectorAll("summary")].reduce((n, s) => n + s.getBoundingClientRect().width, 0); f.remove();
193 if (w > best) { best = w; at = i; } });
194 return JSON.stringify(at); })()`, &widest)
195 return pairs[widest].Lang, pairs[widest].Language, pairs[widest].Scheme
196}
197
198// sampleWord is word in locale's sample catalog, the translation the
199// gallery puts on its pages.
200func sampleWord(t *testing.T, locale, word string) string {
201 t.Helper()
202 raw, err := os.ReadFile("../internal/gallery/i18n/samples." + locale + ".json")
203 if err != nil {
204 t.Fatal(err)
205 }
206 var catalog map[string]string
207 if err := json.Unmarshal(raw, &catalog); err != nil {
208 t.Fatalf("samples.%s.json: %v", locale, err)
209 }
210 got, ok := catalog[word]
211 if !ok || got == "" {
212 t.Fatalf("samples.%s.json has no %q", locale, word)
213 }
214 return got
215}
216
217// labelled is the column document with its two footer summaries
218// reading language and scheme, in lang, in dir.
219func labelled(t *testing.T, doc, lang, dir, language, scheme string) string {
220 t.Helper()
221 for _, swap := range [][2]string{
222 {`<html lang="en" dir="ltr">`, `<html lang="` + lang + `" dir="` + dir + `">`},
223 {`<summary>Language<`, `<summary>` + language + `<`},
224 {`<summary>Colour scheme<`, `<summary>` + scheme + `<`},
225 } {
226 if !strings.Contains(doc, swap[0]) {
227 t.Fatalf("the column document has no %s to relabel", swap[0])
228 }
229 doc = strings.Replace(doc, swap[0], swap[1], 1)
230 }
231 return doc
232}
233
234// TestTheFootersMenusOpenOnScreen: on a phone, scrolled to the foot of
235// the page, each footer menu opens on the screen, with the longest
236// labels a left-to-right translation gives the row and with Arabic's,
237// right to left. Where the browser anchors a panel to its summary it
238// flips it up and over as it must. Where it cannot, a panel hangs from
239// the box that holds it, and in the row Language is not at the end:
240// hung from its own <details>, its panel, at least 176px wide, runs
241// past the opposite edge at 320. So the row holds every panel, under
242// it and flush with its inline end, whichever menu it belongs to, with
243// anchor.js running and with no script at all, in both spellings, and
244// the page grows to hold the panel so it can be scrolled to. A panel
245// at the row's inline start would be inside the row and on the screen
246// too, and under nothing it belongs to.
247func TestTheFootersMenusOpenOnScreen(t *testing.T) {
248 doc := columnDoc(t)
249 noAnchor := tokensWithoutAnchorPositioning(t)
250 measure := sizingRig(t, false, map[string]string{"/measure": sizingDoc("measure", `<main rst-page></main>`)})
251 mctx, mcancel := context.WithTimeout(measure.Context(), 30*time.Second)
252 lang, language, scheme := longestLabels(t, mctx, measure.Origin)
253 mcancel()
254 t.Logf("the widest left-to-right labels are %s's: %q and %q", lang, language, scheme)
255 ltr := labelled(t, doc, lang, "ltr", language, scheme)
256 rtl := labelled(t, doc, "ar", "rtl", sampleWord(t, "ar", "Language"), sampleWord(t, "ar", "Colour scheme"))
257 for _, leg := range []struct {
258 name string
259 anchor, scripts bool
260 }{
261 {"anchor positioning", true, true},
262 {"no anchor positioning, anchor.js", false, true},
263 {"no anchor positioning, no scripts", false, false},
264 } {
265 t.Run(leg.name, func(t *testing.T) {
266 pages := map[string]string{"/ltr": ltr, "/rtl": rtl, "/ltr-class": classSpelling(t, ltr), "/rtl-class": classSpelling(t, rtl)}
267 if !leg.anchor {
268 for path, page := range pages {
269 pages[path] = strings.Replace(page, `href="tokens.css"`, `href="tokens-noanchor.css"`, 1)
270 }
271 }
272 rig := harness.New(t, func(string) http.Handler {
273 mux := sizingMux(t, pages)
274 mux.HandleFunc("GET /tokens-noanchor.css", func(w http.ResponseWriter, _ *http.Request) {
275 w.Header().Set("Content-Type", "text/css")
276 fmt.Fprint(w, noAnchor)
277 })
278 return mux
279 }, harness.WithCoarsePointer())
280 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
281 defer cancel()
282 if !leg.anchor && leg.scripts {
283 mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error {
284 _, err := cdppage.AddScriptToEvaluateOnNewDocument(withoutAnchorSupport).Do(ctx)
285 return err
286 }))
287 }
288 if !leg.scripts {
289 mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true))
290 }
291 for _, size := range [][2]int64{{390, 640}, {320, 568}} {
292 for _, path := range []string{"/ltr", "/rtl", "/ltr-class", "/rtl-class"} {
293 for _, menu := range []string{"rst-locale", "rst-scheme"} {
294 where := fmt.Sprintf("%s at %dx%d, %s", path, size[0], size[1], menu)
295 details, panel := "[rst-page-foot] > ["+menu+"]", "[rst-dropdown-menu]"
296 if strings.HasSuffix(path, "-class") {
297 details, panel = ".rst-page__foot > ."+menu, ".rst-dropdown__menu"
298 }
299 mustRun(t, ctx, chromedp.EmulateViewport(size[0], size[1]), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible(details, chromedp.ByQuery))
300 var flips bool
301 at(t, ctx, `JSON.stringify(document.documentElement.hasAttribute("data-rst-flip-menus"))`, &flips)
302 if want := !leg.anchor && leg.scripts; flips != want {
303 t.Fatalf("%s: <html> data-rst-flip-menus is %v, want %v; anchor.js is not doing what this leg needs", where, flips, want)
304 }
305 RequireAnchorPositioning(t, ctx, where, details+" > "+panel, leg.anchor)
306 mustRun(t, ctx, chromedp.Evaluate(`window.scrollTo(0, document.documentElement.scrollHeight)`, nil),
307 chromedp.Click(details+" > summary", chromedp.ByQuery))
308 if leg.scripts {
309 // anchor.js places a panel on toggle, which
310 // fires after the click.
311 mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise))
312 }
313 var g struct {
314 Open bool
315 L, R, T, B, FL, FR, FB, VW float64
316 VH, SY, SH float64
317 PanelEnd, RowEnd float64
318 }
319 // The row's inline end is its padding box's, the
320 // edge an absolute panel's inset-inline-end
321 // measures from: the right in LTR, the left in RTL.
322 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";
323 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,
324 VW: document.documentElement.clientWidth, VH: innerHeight, SY: scrollY, SH: document.documentElement.scrollHeight,
325 PanelEnd: rtl ? p.left : p.right, RowEnd: rtl ? f.left + parseFloat(cs.borderLeftWidth) : f.right - parseFloat(cs.borderRightWidth)}); })()`, &g)
326 if !g.Open {
327 t.Fatalf("%s: the menu did not open; this leg proves nothing", where)
328 }
329 if g.L < -0.5 || g.R > g.VW+0.5 {
330 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)
331 }
332 if leg.anchor {
333 if g.T < -0.5 || g.B > g.VH+0.5 {
334 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)
335 }
336 continue
337 }
338 if g.L < g.FL-0.5 || g.R > g.FR+0.5 {
339 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)
340 }
341 if math.Abs(g.PanelEnd-g.RowEnd) > 0.5 {
342 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)
343 }
344 if g.T < g.FB-0.5 {
345 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)
346 }
347 if g.B+g.SY > g.SH+0.5 {
348 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)
349 }
350 }
351 }
352 }
353 })
354 }
355}
356
357// TestAnEmptyFooterRowDrawsNothing: an app with one language and no
358// scheme menu renders the row with nothing in it but the template's
359// whitespace. That row is not drawn: no hairline and no gap under the
360// page. :empty would miss it, since whitespace is a child. The control
361// is the same row with one menu in it, which is drawn. Both spellings.
362func TestAnEmptyFooterRowDrawsNothing(t *testing.T) {
363 menu := `<details rst-dropdown><summary>Language</summary><div rst-dropdown-menu><a href="#">English</a></div></details>`
364 body := `<main rst-page><p>Content</p>` +
365 `<footer rst-page-foot id="empty">` + "\n \n" + `</footer>` +
366 `<footer rst-page-foot id="held">` + menu + `</footer></main>` +
367 `<main class="rst-page"><p>Content</p>` +
368 `<footer class="rst-page__foot" id="empty-c">` + "\n \n" + `</footer>` +
369 `<footer class="rst-page__foot" id="held-c">` + strings.NewReplacer("rst-dropdown-menu", `class="rst-dropdown__menu"`, "rst-dropdown", `class="rst-dropdown"`).Replace(menu) + `</footer></main>`
370 rig := sizingRig(t, false, map[string]string{"/": sizingDoc("empty footer", body)})
371 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
372 defer cancel()
373 mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#held", chromedp.ByQuery))
374 for sel, want := range map[string]string{"#empty": "none", "#held": "flex", "#empty-c": "none", "#held-c": "flex"} {
375 if got := computed(t, ctx, sel, "display"); got != want {
376 t.Errorf("footer %s is display %s, want %s", sel, got, want)
377 }
378 }
379}
380