//go:build browser // The pagination strip on a phone and on a desktop, from rastrillo's // ui/pagination_browser_test.go at the seed commit, its page read // through fixture. package check import ( "context" "fmt" "math" "strings" "testing" "time" "github.com/chromedp/chromedp" ) // paginationCases are the strips the seeded page holds, each in its // own
, with what a phone must show: ‹ and › for the // chevrons, the page numbers, … for an ellipsis. "mid" is the gallery's // sample. "flat" lists every page with no gaps, the way the generated // list actions do, so the phone's ellipses there are the partial's own. // Each comment is the strip as rastrillo's pageStrip(current, shown...) // built it: Previous, the pages shown with a gap wherever a run is // skipped, and Next, disabled at either end. var paginationCases = []struct { ID string Narrow string }{ {"mid", "‹ 1 … 4 … 9 ›"}, // pageStrip(4, 1, 3, 4, 5, 9) {"first", "‹ 1 2 ›"}, // pageStrip(1, 1, 2) {"p2", "‹ 1 2 … 9 ›"}, // pageStrip(2, 1, 2, 3, 9) {"p3", "‹ 1 … 3 … 9 ›"}, // pageStrip(3, 1, 2, 3, 4, 9) {"p8", "‹ 1 … 8 9 ›"}, // pageStrip(8, 1, 7, 8, 9) {"last", "‹ 1 … 9 ›"}, // pageStrip(9, 1, 8, 9) {"flat", "‹ 1 … 4 … 7 ›"}, // pageStrip(4, 1, 2, 3, 4, 5, 6, 7) {"only", "‹ 1 ›"}, // pageStrip(1, 1) } func paginationPage(t *testing.T, dir string) string { t.Helper() return strings.Replace(sizingDoc("pagination", fixture(t, "pagination_browser_test.go", "paginationPage", "")), `dir="ltr"`, `dir="`+dir+`"`, 1) } // stripItem is one visible child of a strip: its box, its text, whether // it shows an icon, how wide its words are drawn, which way its icon's // point faces along the inline axis (-1 left, 1 right), and whether it // is a target (a link or a span that is not an ellipsis). type stripItem struct { Text string L, T, R, B float64 Icon bool WordsW, Point float64 Target, Ellips bool } type stripReading struct { ID string L, R float64 // the strip's content box Items []stripItem Hidden []string // every child laid out with no box, by text Center, RTL bool } // paginationJS reads every strip. An icon's direction is read off the // glyph: the midpoint of its path is the chevron's point, its two ends // the arms, and the point's offset from the arms' middle, run through // the icon's transform, says which way it faces on screen. const paginationJS = `(() => JSON.stringify([...document.querySelectorAll("[id^=pg-] [rst-pagination]")].map(nav => { const cs = getComputedStyle(nav), nb = nav.getBoundingClientRect(); const out = {ID: nav.parentElement.id.slice(3), L: nb.left + parseFloat(cs.paddingLeft), R: nb.right - parseFloat(cs.paddingRight), RTL: cs.direction === "rtl", Items: [], Hidden: []}; for (const el of nav.children) { const b = el.getBoundingClientRect(), text = el.textContent.trim(); if (b.width === 0 && b.height === 0) { out.Hidden.push(text); continue; } const svg = el.querySelector("svg"), sb = svg ? svg.getBoundingClientRect() : null, icon = !!sb && sb.width > 0; let point = 0; if (icon) { const p = svg.querySelector("path"), len = p.getTotalLength(), a = p.getPointAtLength(0), z = p.getPointAtLength(len), m = p.getPointAtLength(len / 2); const t = new DOMMatrix(getComputedStyle(svg).transform === "none" ? undefined : getComputedStyle(svg).transform); const v = t.transformPoint(new DOMPoint(m.x - (a.x + z.x) / 2, m.y - (a.y + z.y) / 2)), o = t.transformPoint(new DOMPoint(0, 0)); point = Math.sign(Math.round((v.x - o.x) * 100)); } let words = 0; for (const n of el.querySelectorAll("*")) if (n.tagName !== "svg" && !n.closest("svg") && n.textContent.trim()) words = Math.max(words, n.getBoundingClientRect().width); if (!el.children.length || [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) { const g = document.createRange(); g.selectNodeContents(el); words = Math.max(words, g.getBoundingClientRect().width); } const ellips = el.hasAttribute("rst-pagination-gap") || text === "…"; out.Items.push({Text: text, L: b.left, T: b.top, R: b.right, B: b.bottom, Icon: icon, WordsW: words, Point: point, Target: !ellips, Ellips: ellips}); } return out; })))()` func readStrips(t *testing.T, ctx context.Context) map[string]stripReading { t.Helper() var got []stripReading at(t, ctx, paginationJS, &got) if len(got) != len(paginationCases) { t.Fatalf("measured %d strips, want %d", len(got), len(paginationCases)) } out := map[string]stripReading{} for _, g := range got { out[g.ID] = g } return out } // narrowSequence spells a strip as a phone shows it: a chevron for an // item that draws an icon and no words, its text otherwise. func narrowSequence(s stripReading) string { var parts []string for i, it := range s.Items { switch { case it.Icon && it.WordsW <= 1 && i == 0: parts = append(parts, "‹") case it.Icon && it.WordsW <= 1: parts = append(parts, "›") default: parts = append(parts, it.Text) } } return strings.Join(parts, " ") } // TestPaginationIsOneBalancedLineOnAPhone: at 390 the gallery's "middle // of a long list" strip (Previous 1 … 3 4 5 … 9 Next) wrapped onto two // rows. On a phone Previous and Next are chevrons that keep their words // for a screen reader, only the first, current and last pages show, // with one ellipsis wherever pages are skipped, and the strip is one // line spread across the width. The cases put the current page at // either end and beside each, where a careless rule draws two ellipses // in a row or one between two adjacent pages. Each target stays a tap, // down to a 320px phone. func TestPaginationIsOneBalancedLineOnAPhone(t *testing.T) { for _, leg := range []struct { w int64 dir string }{{390, "ltr"}, {390, "rtl"}, {320, "ltr"}, {320, "rtl"}} { dir := leg.dir t.Run(fmt.Sprintf("%d touch %s", leg.w, dir), func(t *testing.T) { rig := sizingRig(t, true, map[string]string{"/": paginationPage(t, dir)}) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(leg.w, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#pg-only", chromedp.ByQuery)) requirePointer(t, ctx, true) strips := readStrips(t, ctx) for _, c := range paginationCases { s := strips[c.ID] if s.RTL != (dir == "rtl") { t.Fatalf("%s: the strip's direction is rtl=%v, the leg needs %s", c.ID, s.RTL, dir) } if got := narrowSequence(s); got != c.Narrow { t.Errorf("%s: the phone shows %q, want %q", c.ID, got, c.Narrow) continue } checkStripLine(t, c.ID, s) for i, it := range s.Items { if it.Target && (it.R-it.L < 43.5 || it.B-it.T < 43.5) { t.Errorf("%s: %q is %.1f×%.1f, under the 44px tap", c.ID, it.Text, it.R-it.L, it.B-it.T) } if !it.Icon { continue } // The chevron points out of the strip, at the end it // sits on: Previous at the start of the line, Next at // its end, whichever way the line runs. first := i == 0 wantText := map[bool]string{true: "Previous", false: "Next"}[first] if it.Text != wantText { t.Errorf("%s: the chevron at position %d reads %q to a screen reader, want %q", c.ID, i, it.Text, wantText) } onLeft := (first && dir == "ltr") || (!first && dir == "rtl") wantPoint := map[bool]float64{true: -1, false: 1}[onLeft] if it.Point != wantPoint { t.Errorf("%s: %s's chevron points %v, want %v (toward its own end of the strip)", c.ID, it.Text, it.Point, wantPoint) } if (onLeft && it.L-s.L > 0.5) || (!onLeft && s.R-it.R > 0.5) { t.Errorf("%s: %s is not at its end of the strip: %.1f–%.1f in %.1f–%.1f", c.ID, it.Text, it.L, it.R, s.L, s.R) } } } }) } } // checkStripLine holds a strip to one line, spread from edge to edge // with the same space between every pair of neighbours. func checkStripLine(t *testing.T, id string, s stripReading) { t.Helper() items := s.Items for _, it := range items[1:] { if math.Abs(it.T-items[0].T) > 1 { t.Errorf("%s: %q sits at %.1f and %q at %.1f; the strip is not one line", id, it.Text, it.T, items[0].Text, items[0].T) } } left, right := items[0].L, items[0].R for _, it := range items { left, right = math.Min(left, it.L), math.Max(right, it.R) } if left-s.L > 0.5 || s.R-right > 0.5 { t.Errorf("%s: the strip's items span %.1f–%.1f of %.1f–%.1f; it is not spread across the width", id, left, right, s.L, s.R) } var spaces []float64 for i := 1; i < len(items); i++ { a, b := items[i-1], items[i] if s.RTL { spaces = append(spaces, a.L-b.R) } else { spaces = append(spaces, b.L-a.R) } } lo, hi := math.Inf(1), math.Inf(-1) for _, sp := range spaces { lo, hi = math.Min(lo, sp), math.Max(hi, sp) } if hi-lo > 1 { t.Errorf("%s: the spaces between items run from %.1f to %.1f; the strip is not balanced", id, lo, hi) } } // TestPaginationIsUnchangedOnADesktop pins the gallery's strip at 1280 // with a mouse as it was before the phone's strip arrived: every item // shown with its words and no icon, at the same place and size. func TestPaginationIsUnchangedOnADesktop(t *testing.T) { rig := sizingRig(t, false, map[string]string{"/": paginationPage(t, "ltr")}) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(1280, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#pg-only", chromedp.ByQuery)) requirePointer(t, ctx, false) s := readStrips(t, ctx)["mid"] type pinned struct { Text string L, W float64 } want := []pinned{{"Previous", 0, 74.016}, {"1", 78.016, 53.188}, {"…", 135.203, 22.5}, {"3", 161.703, 53.188}, {"4", 218.891, 53.188}, {"5", 276.078, 53.188}, {"…", 333.266, 22.5}, {"9", 359.766, 53.188}, {"Next", 416.953, 53.188}} var got []pinned for _, it := range s.Items { if it.Icon { t.Errorf("%q draws an icon on a desktop", it.Text) } if it.T-s.Items[0].T != 0 || it.B-it.T != s.Items[0].B-s.Items[0].T { t.Errorf("%q is at %.2f, %.2f tall; the first item is at %.2f, %.2f tall", it.Text, it.T, it.B-it.T, s.Items[0].T, s.Items[0].B-s.Items[0].T) } got = append(got, pinned{it.Text, math.Round((it.L-s.L)*1000) / 1000, math.Round((it.R-it.L)*1000) / 1000}) } if len(s.Hidden) != 0 { t.Errorf("the desktop strip hides %q", s.Hidden) } if fmt.Sprint(got) != fmt.Sprint(want) { t.Errorf("the desktop strip moved:\n got %v\nwant %v", got, want) } if h := s.Items[0].B - s.Items[0].T; math.Abs(h-32) > 0.01 { t.Errorf("the desktop chips are %.2fpx tall, pinned at 32", h) } }