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 side nav as a reader meets it: a column of links beside the
4// panel's content where the panel is wide, and a row of links above it
5// where the panel is narrow, whatever the window's width, since a
6// settings card on a desktop is as narrow as a phone; links that go
7// somewhere with no script; and links that look and answer like the
8// rows of a menu, focus ring included, on a page with no rst-page.
9package check
10
11import (
12 "context"
13 "fmt"
14 "maps"
15 "math"
16 "slices"
17 "strings"
18 "testing"
19 "time"
20
21 "github.com/chromedp/cdproto/emulation"
22 "github.com/chromedp/cdproto/input"
23 "github.com/chromedp/chromedp"
24 "github.com/chromedp/chromedp/kb"
25
26 "amadan.net/rastrillo/fiesta"
27 "amadan.net/rastrillo/fiesta/example"
28 "amadan.net/rastrillo/fiesta/internal/harness"
29)
30
31// sideNavExample is the side-nav example's one state (its id and the
32// routes its links point at) and that state as a document, its assets
33// flattened beside the page as the sizing mux serves them.
34func sideNavExample(t *testing.T) (example.State, string) {
35 t.Helper()
36 entry, ok := manifest(t).Entry("side-nav")
37 if !ok || len(entry.States) != 1 {
38 t.Fatal("examples.json has no side-nav entry with one state")
39 }
40 state := entry.States[0]
41 doc, err := example.Document(fiesta.FS(), "side-nav", state.ID)
42 if err != nil {
43 t.Fatal(err)
44 }
45 return state, parityLinks(doc)
46}
47
48// inPanel is doc with its body's markup inside a panel of the given
49// width, the way a page puts the side nav in a card.
50func inPanel(t *testing.T, doc, width string) string {
51 t.Helper()
52 if !strings.Contains(doc, "<body>\n") || !strings.Contains(doc, "\n</body>") {
53 t.Fatal("the side-nav document has no <body> lines to wrap in a panel")
54 }
55 doc = strings.Replace(doc, "<body>\n", `<body>`+"\n"+`<div id="panel" style="inline-size: `+width+`">`+"\n", 1)
56 return strings.Replace(doc, "\n</body>", "\n</div>\n</body>", 1)
57}
58
59// longLabels is doc with each link's label made long, so four of them
60// cannot fit a narrow panel side by side.
61func longLabels(t *testing.T, doc string) string {
62 t.Helper()
63 for _, l := range []string{"General", "Members", "Billing", "Notifications"} {
64 was := ">" + l + "</a>"
65 if !strings.Contains(doc, was) {
66 t.Fatalf("the side-nav document has no link labelled %s", l)
67 }
68 doc = strings.Replace(doc, was, ">"+l+" for the whole workspace</a>", 1)
69 }
70 return doc
71}
72
73// sideGeometry is what the layout tests read: the panel's content box
74// (the body's, for the page with no panel), the nav's box with its
75// scroll and client widths and its four border widths, the content's
76// box, each link's box and how many lines its label takes, and the
77// document's scroll and client widths.
78type sideGeometry struct {
79 Panel, Nav, Content sideBox
80 Links []sideBox
81 Lines []int
82 NavScroll, NavClient float64
83 Start, End, Top, Bottom string
84 DocScroll, DocClient float64
85}
86
87type sideBox struct{ Left, Right, Top, Bottom float64 }
88
89const sideGeometryJS = `(() => {
90 const side = document.querySelector("[rst-side]"), nav = side.querySelector("[rst-side-nav]"),
91 content = side.querySelector(":scope > :not([rst-side-nav])"), ns = getComputedStyle(nav);
92 const b = (el) => { const r = el.getBoundingClientRect(); return {Left: r.left, Right: r.right, Top: r.top, Bottom: r.bottom}; };
93 const p = side.parentElement, pr = p.getBoundingClientRect(), ps = getComputedStyle(p), px = (v) => parseFloat(v) || 0;
94 const rtl = getComputedStyle(side).direction === "rtl";
95 return JSON.stringify({
96 Panel: {Left: pr.left + px(ps.borderLeftWidth) + px(ps.paddingLeft), Right: pr.right - px(ps.borderRightWidth) - px(ps.paddingRight), Top: pr.top, Bottom: pr.bottom},
97 Nav: b(nav), Content: b(content), Links: [...nav.querySelectorAll("a")].map(b),
98 Lines: [...nav.querySelectorAll("a")].map((a) => { const r = document.createRange(); r.selectNodeContents(a); return new Set([...r.getClientRects()].map((q) => Math.round(q.top))).size; }),
99 NavScroll: nav.scrollWidth, NavClient: nav.clientWidth,
100 Start: rtl ? ns.borderRightWidth : ns.borderLeftWidth, End: rtl ? ns.borderLeftWidth : ns.borderRightWidth,
101 Top: ns.borderTopWidth, Bottom: ns.borderBottomWidth,
102 DocScroll: document.documentElement.scrollWidth, DocClient: document.documentElement.clientWidth});
103})()`
104
105// readSide opens path at the given viewport width and reads it.
106func readSide(t *testing.T, ctx context.Context, origin, path string, width int64) sideGeometry {
107 t.Helper()
108 mustRun(t, ctx, chromedp.EmulateViewport(width, 800), chromedp.Navigate(origin+path), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery))
109 settleUntil(t, ctx, `document.readyState === "complete"`)
110 var g sideGeometry
111 at(t, ctx, sideGeometryJS, &g)
112 if len(g.Links) != 4 {
113 t.Fatalf("%s: the nav holds %d links; the example has four", path, len(g.Links))
114 }
115 return g
116}
117
118// sameEdge is whether a and b are the same edge, to half a pixel.
119func sameEdge(a, b float64) bool { return math.Abs(a-b) <= 0.5 }
120
121// TestTheSideNavIsAColumnBesideItsPanel: in a 640px panel the links
122// stack in a column with one shared edge, the content sits beside the
123// column, and a 1px hairline at the column's inline end divides them;
124// right to left, all of it mirrors.
125func TestTheSideNavIsAColumnBesideItsPanel(t *testing.T) {
126 _, doc := sideNavExample(t)
127 wide := inPanel(t, doc, "640px")
128 rig := sizingRig(t, false, map[string]string{"/": wide, "/rtl": inArabic(t, wide)})
129 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
130 defer cancel()
131 for _, path := range []string{"/", "/rtl"} {
132 g := readSide(t, ctx, rig.Origin, path, 1280)
133 rtl := path == "/rtl"
134 // The link's edge at the column's inline start: the left in
135 // English, the right in Arabic.
136 start := func(b sideBox) float64 {
137 if rtl {
138 return b.Right
139 }
140 return b.Left
141 }
142 for i, l := range g.Links {
143 if !sameEdge(start(l), start(g.Links[0])) {
144 t.Errorf("%s: link %d starts at %.1f, link 1 at %.1f; the links of a column share one edge", path, i+1, start(l), start(g.Links[0]))
145 }
146 if i > 0 && l.Top < g.Links[i-1].Bottom-0.5 {
147 t.Errorf("%s: link %d's top is %.1f, above link %d's bottom %.1f; the links stack", path, i+1, l.Top, i, g.Links[i-1].Bottom)
148 }
149 }
150 if !rtl && g.Content.Left < g.Nav.Right-0.5 {
151 t.Errorf("%s: the content's left edge is %.1f, left of the nav's right edge %.1f; the content sits beside the nav", path, g.Content.Left, g.Nav.Right)
152 }
153 if rtl && g.Content.Right > g.Nav.Left+0.5 {
154 t.Errorf("%s: the content's right edge is %.1f, right of the nav's left edge %.1f; right to left, the content sits to the nav's left", path, g.Content.Right, g.Nav.Left)
155 }
156 if g.End != "1px" || g.Start != "0px" || g.Bottom != "0px" {
157 t.Errorf("%s: the nav's borders are %s at its inline end, %s at its start and %s below; want one 1px hairline at the inline end", path, g.End, g.Start, g.Bottom)
158 }
159 if !sameEdge(g.Nav.Top, g.Content.Top) {
160 t.Errorf("%s: the nav's top is %.1f and the content's %.1f; side by side, they start on one line", path, g.Nav.Top, g.Content.Top)
161 }
162 }
163}
164
165// TestANarrowPanelPutsTheLinksInARow: in a 300px panel on a wide
166// screen, and on a 320px phone, the nav spans the panel above the
167// content, its links one row, each label on one line, with the
168// hairline under the row; with
169// labels too long for the width, the row scrolls inside the nav while
170// the page itself never scrolls sideways.
171func TestANarrowPanelPutsTheLinksInARow(t *testing.T) {
172 _, doc := sideNavExample(t)
173 narrow, long := inPanel(t, doc, "300px"), inPanel(t, longLabels(t, doc), "300px")
174 pages := map[string]string{
175 "/panel": narrow, "/panel-rtl": inArabic(t, narrow),
176 "/panel-long": long, "/panel-long-rtl": inArabic(t, long),
177 "/page": doc, "/page-rtl": inArabic(t, doc),
178 "/page-long": longLabels(t, doc), "/page-long-rtl": inArabic(t, longLabels(t, doc)),
179 }
180 rig := sizingRig(t, false, pages)
181 ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
182 defer cancel()
183 for _, path := range slices.Sorted(maps.Keys(pages)) {
184 width := int64(1280)
185 if strings.HasPrefix(path, "/page") {
186 width = 320
187 }
188 where := fmt.Sprintf("%s at %d", path, width)
189 g := readSide(t, ctx, rig.Origin, path, width)
190 if !sameEdge(g.Nav.Left, g.Panel.Left) || !sameEdge(g.Nav.Right, g.Panel.Right) {
191 t.Errorf("%s: the nav spans %.1f to %.1f in a panel of %.1f to %.1f; in a narrow panel it spans the panel", where, g.Nav.Left, g.Nav.Right, g.Panel.Left, g.Panel.Right)
192 }
193 if g.Content.Top < g.Nav.Bottom-0.5 {
194 t.Errorf("%s: the content's top is %.1f, above the nav's bottom %.1f; in a narrow panel the nav sits above the content", where, g.Content.Top, g.Nav.Bottom)
195 }
196 for i, l := range g.Links {
197 if !sameEdge(l.Top, g.Links[0].Top) {
198 t.Errorf("%s: link %d's top is %.1f, link 1's %.1f; in a narrow panel the links are one row", where, i+1, l.Top, g.Links[0].Top)
199 }
200 if g.Lines[i] != 1 {
201 t.Errorf("%s: link %d's label takes %d lines; in the row a label stays on one line and the row scrolls", where, i+1, g.Lines[i])
202 }
203 }
204 if g.Bottom != "1px" || g.End != "0px" || g.Start != "0px" {
205 t.Errorf("%s: the nav's borders are %s below, %s at its inline end and %s at its start; want the hairline under the nav and none beside it", where, g.Bottom, g.End, g.Start)
206 }
207 if strings.Contains(path, "-long") && g.NavScroll <= g.NavClient {
208 t.Errorf("%s: the nav's row is %.0fpx wide in %.0fpx; four long labels must overflow it, or this leg proves nothing about scrolling", where, g.NavScroll, g.NavClient)
209 }
210 if g.DocScroll > g.DocClient {
211 t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; the row scrolls inside the nav, never the page", where, g.DocScroll, g.DocClient)
212 }
213 }
214}
215
216// arrivedPage is what each of the example's routes answers: a page
217// that a drive can tell it arrived at, and whose own script would
218// rewrite it, so the drive can also tell that scripts were off.
219const arrivedPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>arrived</title></head><body><p id="arrived">arrived</p>` +
220 `<script>document.getElementById("arrived").textContent = "scripted";</script></body></html>`
221
222// TestTheSideNavLinksWorkWithNoScript: with scripts off, each link is
223// reached with Tab and followed with Enter to the route it names, and
224// every route is one the state declares, so the reference markup has no
225// link that goes nowhere and needs no script to switch sections.
226func TestTheSideNavLinksWorkWithNoScript(t *testing.T) {
227 state, doc := sideNavExample(t)
228 pages := map[string]string{"/": doc}
229 for _, u := range state.SampleURLs {
230 pages[u] = arrivedPage
231 }
232 rig := sizingRig(t, false, pages)
233 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
234 defer cancel()
235 mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true))
236 var hrefs []string
237 mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery))
238 at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getAttribute("href")))`, &hrefs)
239 if len(hrefs) != 4 {
240 t.Fatalf("the nav holds %d links; the example has four", len(hrefs))
241 }
242 for _, h := range hrefs {
243 if !slices.Contains(state.SampleURLs, h) {
244 t.Fatalf("the link to %q is not one of the state's sampleURLs %v; a link must go somewhere the app has", h, state.SampleURLs)
245 }
246 }
247 for i, h := range hrefs {
248 mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery))
249 for range i + 1 {
250 mustRun(t, ctx, chromedp.KeyEvent(kb.Tab))
251 }
252 var focused string
253 at(t, ctx, `JSON.stringify(document.activeElement.getAttribute("href") || document.activeElement.tagName)`, &focused)
254 if focused != h {
255 t.Errorf("after %d presses of Tab the focus is on %q, want the link to %q", i+1, focused, h)
256 continue
257 }
258 var path, said string
259 mustRun(t, ctx, chromedp.KeyEvent(kb.Enter), chromedp.WaitReady("#arrived", chromedp.ByQuery))
260 at(t, ctx, `JSON.stringify(location.pathname)`, &path)
261 at(t, ctx, `JSON.stringify(document.getElementById("arrived").textContent)`, &said)
262 if said != "arrived" {
263 t.Fatalf("the route's own script ran (%q); this leg is not the page a reader with no script gets", said)
264 }
265 if path != h {
266 t.Errorf("Enter on the link to %q arrived at %q", h, path)
267 }
268 }
269}
270
271// menuRowsPage is the example beside a dropdown's menu, open, with a
272// current item and a plain one, in Day's light scheme, so each link's
273// states are read against a menu row's under the same stylesheets.
274func menuRowsPage(t *testing.T, doc string) string {
275 t.Helper()
276 const menu = `<details rst-dropdown open><summary>Menu</summary><div rst-dropdown-menu>` +
277 `<a id="menu-current" href="#one" aria-current="page">One</a><a id="menu-plain" href="#two">Two</a></div></details>`
278 doc = strings.Replace(doc, `<html lang="en" dir="ltr">`, `<html lang="en" dir="ltr" data-theme="light">`, 1)
279 return strings.Replace(doc, "\n</body>", "\n"+menu+"\n</body>", 1)
280}
281
282// TestTheSideNavLinksAreMenuRows: in Day, light, with a mouse, the
283// current link wears the menu's current row, a hovered link the menu's
284// hovered row with its top highlight, a pressed one the press tint and
285// a tapped one the tap tint; every link is at least the target floor
286// tall; and a link focused from the keyboard shows the 2px ring though
287// no rst-page is around it.
288func TestTheSideNavLinksAreMenuRows(t *testing.T) {
289 _, doc := sideNavExample(t)
290 rig := sizingRig(t, false, map[string]string{"/": menuRowsPage(t, doc)}, harness.WithHover())
291 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
292 defer cancel()
293 mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery))
294 var hover bool
295 at(t, ctx, `JSON.stringify(matchMedia("(hover: hover)").matches)`, &hover)
296 if !hover {
297 t.Fatal("(hover: hover) is false on this rig; the hover rules cannot be read here")
298 }
299 still(t, ctx)
300 const current, plain = `[rst-side-nav] a[aria-current]`, `[rst-side-nav] a[href="/settings/members"]`
301 face := func(sel string, states ...string) (bg, fg, shadow string) {
302 forceState(t, ctx, sel, states...)
303 bg, fg, shadow = computed(t, ctx, sel, "background-color"), computed(t, ctx, sel, "color"), computed(t, ctx, sel, "box-shadow")
304 forceState(t, ctx, sel)
305 return bg, fg, shadow
306 }
307 for _, c := range []struct {
308 what, link, item string
309 states []string
310 }{
311 {"current", current, "#menu-current", nil},
312 {"hovered", plain, "#menu-plain", []string{"hover"}},
313 {"pressed", plain, "#menu-plain", []string{"hover", "active"}},
314 {"tapped", plain, "#menu-plain", []string{"active"}},
315 } {
316 lb, lf, ls := face(c.link, c.states...)
317 mb, mf, ms := face(c.item, c.states...)
318 if lb != mb || ls != ms {
319 t.Errorf("the %s link is %s with box-shadow %q; the %s menu row is %s with %q", c.what, lb, ls, c.what, mb, ms)
320 }
321 if c.what != "tapped" && lf != mf {
322 t.Errorf("the %s link's text is %s; the %s menu row's is %s", c.what, lf, c.what, mf)
323 }
324 }
325 if rest, _, _ := face(plain); rest == computed(t, ctx, "#menu-current", "background-color") {
326 t.Errorf("a link at rest is %s, the current row's wash; only the current link is on it", rest)
327 }
328 if w := computed(t, ctx, current, "font-weight"); w != "600" {
329 t.Errorf("the current link's weight is %s, want 600, as a current menu row's label", w)
330 }
331 var floor struct {
332 Target float64
333 Heights []float64
334 }
335 at(t, ctx, `(() => { const p = document.createElement("div"); p.style.blockSize = "var(--rst-target)"; document.body.append(p);
336 const target = p.getBoundingClientRect().height; p.remove();
337 return JSON.stringify({Target: target, Heights: [...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getBoundingClientRect().height)}); })()`, &floor)
338 if floor.Target < 30 {
339 t.Fatalf("--rst-target reads %.1fpx; the probe is broken, not the links", floor.Target)
340 }
341 for i, h := range floor.Heights {
342 if h < floor.Target-0.5 {
343 t.Errorf("link %d is %.1fpx tall, under the %.1fpx target floor", i+1, h, floor.Target)
344 }
345 }
346 mustRun(t, ctx, chromedp.KeyEvent(kb.Tab))
347 var ring struct {
348 Href, Style, Width string
349 InPage bool
350 }
351 at(t, ctx, `(() => { const a = document.activeElement, s = getComputedStyle(a);
352 return JSON.stringify({Href: a.getAttribute("href") || a.tagName, Style: s.outlineStyle, Width: s.outlineWidth, InPage: !!a.closest("[rst-page], .rst-page")}); })()`, &ring)
353 if ring.Href != "/settings/general" {
354 t.Fatalf("the first Tab focused %q, not the first link", ring.Href)
355 }
356 if ring.InPage {
357 t.Fatal("the focused link is inside an rst-page; this leg is for a side nav outside one")
358 }
359 if ring.Style != "solid" || ring.Width != "2px" {
360 t.Errorf("a link focused from the keyboard has outline %s %s, want solid 2px though no rst-page is around it", ring.Style, ring.Width)
361 }
362}
363
364// ringJS reads the focused element's whole focus ring (its border box
365// grown by the outline's offset and width, which a negative offset
366// shrinks) against the nav's scrollport (its padding box), where the
367// row's overflow clips anything outside.
368const ringJS = `(() => {
369 const a = document.activeElement, nav = a.closest("[rst-side-nav]");
370 if (!nav) return JSON.stringify({Href: a.getAttribute("href") || a.tagName});
371 const s = getComputedStyle(a), ns = getComputedStyle(nav), r = a.getBoundingClientRect(), n = nav.getBoundingClientRect();
372 const px = (v) => parseFloat(v) || 0, grow = s.outlineStyle === "none" ? 0 : px(s.outlineOffset) + px(s.outlineWidth);
373 return JSON.stringify({Href: a.getAttribute("href"), Style: s.outlineStyle, Grow: grow,
374 Ring: {Left: r.left - grow, Right: r.right + grow, Top: r.top - grow, Bottom: r.bottom + grow},
375 Port: {Left: n.left + px(ns.borderLeftWidth), Right: n.right - px(ns.borderRightWidth), Top: n.top + px(ns.borderTopWidth), Bottom: n.bottom - px(ns.borderBottomWidth)}});
376})()`
377
378// TestKeyboardFocusShowsTheWholeRingInTheRow: in a narrow panel whose
379// row of links is wider than the panel, Tab reaches each link in turn,
380// links that start out of view included, and Shift+Tab walks back to
381// the first after the row has scrolled to its end; each focused link
382// and its whole focus ring are inside the row's visible part, left to
383// right and right to left; and the ring is drawn inside the link, since
384// the row's edge cuts anything outside a link that meets it.
385func TestKeyboardFocusShowsTheWholeRingInTheRow(t *testing.T) {
386 _, doc := sideNavExample(t)
387 long := inPanel(t, longLabels(t, doc), "300px")
388 pages := map[string]string{"/": long, "/rtl": inArabic(t, long), "/page": longLabels(t, doc), "/page-rtl": inArabic(t, longLabels(t, doc))}
389 rig := sizingRig(t, false, pages)
390 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
391 defer cancel()
392 for _, path := range slices.Sorted(maps.Keys(pages)) {
393 width := int64(1280)
394 if strings.HasPrefix(path, "/page") {
395 width = 320
396 }
397 g := readSide(t, ctx, rig.Origin, path, width)
398 if g.NavScroll <= g.NavClient {
399 t.Fatalf("%s: the row is %.0fpx in %.0fpx; with nothing out of view this leg proves nothing", path, g.NavScroll, g.NavClient)
400 }
401 var hrefs []string
402 at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getAttribute("href")))`, &hrefs)
403 var hidden int
404 at(t, ctx, `(() => { const n = document.querySelector("[rst-side-nav]").getBoundingClientRect();
405 return JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].filter(a => { const r = a.getBoundingClientRect(); return r.right > n.right + 0.5 || r.left < n.left - 0.5; }).length); })()`, &hidden)
406 if hidden == 0 {
407 t.Fatalf("%s: every link starts in view; this leg is for links the row has to scroll to", path)
408 }
409 steps := []struct {
410 key string
411 want string
412 }{}
413 for _, h := range hrefs {
414 steps = append(steps, struct{ key, want string }{"Tab", h})
415 }
416 for i := len(hrefs) - 2; i >= 0; i-- {
417 steps = append(steps, struct{ key, want string }{"Shift+Tab", hrefs[i]})
418 }
419 for _, s := range steps {
420 if s.key == "Tab" {
421 mustRun(t, ctx, chromedp.KeyEvent(kb.Tab))
422 } else {
423 mustRun(t, ctx, chromedp.KeyEvent(kb.Tab, chromedp.KeyModifiers(input.ModifierShift)))
424 }
425 var f struct {
426 Href, Style string
427 Grow float64
428 Ring, Port sideBox
429 }
430 at(t, ctx, ringJS, &f)
431 where := fmt.Sprintf("%s at %d, %s to %s", path, width, s.key, s.want)
432 if f.Href != s.want {
433 t.Errorf("%s: the focus is on %q", where, f.Href)
434 continue
435 }
436 if f.Style == "none" {
437 t.Errorf("%s: the focused link draws no outline", where)
438 }
439 if f.Grow > 0 {
440 t.Errorf("%s: the focus ring reaches %.1fpx outside the link; in the row it is drawn inside, or the row's edge cuts it wherever the link meets that edge", where, f.Grow)
441 }
442 if f.Ring.Left < f.Port.Left-0.5 || f.Ring.Right > f.Port.Right+0.5 || f.Ring.Top < f.Port.Top-0.5 || f.Ring.Bottom > f.Port.Bottom+0.5 {
443 t.Errorf("%s: the focus ring spans %.1f to %.1f across and %.1f to %.1f down, outside the row's visible %.1f to %.1f and %.1f to %.1f; the row cuts it off",
444 where, f.Ring.Left, f.Ring.Right, f.Ring.Top, f.Ring.Bottom, f.Port.Left, f.Port.Right, f.Port.Top, f.Port.Bottom)
445 }
446 }
447 }
448}
449
450// flexRows are a side in a bounded flex row: given its width by
451// flex: 1 1 0 beside a 200px column, in a wide row and a narrow one;
452// and left to size itself, as a flex item does by its content.
453const flexRows = `<div id="wide" style="display: flex; inline-size: 1000px"><div style="flex: none; inline-size: 200px">Aside</div>%[1]s</div>` +
454 `<div id="narrow" style="display: flex; inline-size: 500px"><div style="flex: none; inline-size: 200px">Aside</div>%[1]s</div>` +
455 `<div id="unsized" style="display: flex; inline-size: 1000px">%[2]s</div>`
456
457// TestASideInAFlexRowIsGivenItsWidth: the side answers to its own
458// width, so it cannot take that width from its content; a flex item
459// sized by its content is zero wide. Given flex: 1 1 0 in a bounded
460// row it fills what the row leaves, a column of links in a wide row
461// and a row of links in a narrow one. Left to size itself it
462// collapses, which is why a page must give it its width; if a browser
463// ever sizes it some other way, this says so.
464func TestASideInAFlexRowIsGivenItsWidth(t *testing.T) {
465 _, doc := sideNavExample(t)
466 side, err := example.Element(doc, "rst-side")
467 if err != nil {
468 t.Fatal(err)
469 }
470 given := strings.Replace(side, "<div rst-side>", `<div rst-side style="flex: 1 1 0">`, 1)
471 if given == side {
472 t.Fatal("the example's side is not written <div rst-side>")
473 }
474 page := strings.Replace(doc, side, fmt.Sprintf(flexRows, given, side), 1)
475 rig := sizingRig(t, false, map[string]string{"/": page})
476 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
477 defer cancel()
478 mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#unsized", chromedp.ByQuery))
479 var got map[string]struct {
480 Width float64
481 Tops int
482 Flow string
483 Content float64
484 }
485 at(t, ctx, `(() => { const out = {};
486 for (const id of ["wide", "narrow", "unsized"]) {
487 const side = document.querySelector("#" + id + " [rst-side]"), nav = side.querySelector("[rst-side-nav]");
488 out[id] = {Width: side.getBoundingClientRect().width, Tops: new Set([...nav.querySelectorAll("a")].map(a => Math.round(a.getBoundingClientRect().top))).size,
489 Flow: getComputedStyle(nav).gridAutoFlow, Content: side.querySelector(":scope > :not([rst-side-nav])").getBoundingClientRect().width};
490 }
491 return JSON.stringify(out); })()`, &got)
492 if w := got["wide"]; !sameEdge(w.Width, 800) || w.Tops != 4 || w.Content <= 0 {
493 t.Errorf("in a 1000px row beside a 200px column, flex: 1 1 0: the side is %.1fpx wide, its links on %d lines, its content %.1fpx; want 800px, a column of four, and room for the content", w.Width, w.Tops, w.Content)
494 }
495 if n := got["narrow"]; !sameEdge(n.Width, 300) || n.Tops != 1 || n.Flow != "column" {
496 t.Errorf("in a 500px row beside a 200px column, flex: 1 1 0: the side is %.1fpx wide, its links on %d lines, flowing %s; want 300px and one row", n.Width, n.Tops, n.Flow)
497 }
498 if u := got["unsized"]; u.Width > 1 {
499 t.Errorf("left to size itself in a flex row, the side is %.1fpx wide; it was zero wide, and pages are told to give it its width: change that advice if this is now true", u.Width)
500 }
501}
502