| 1 | //go:build browser |
| 2 | |
| 3 | // The pagination strip on a phone and on a desktop, from rastrillo's |
| 4 | // ui/pagination_browser_test.go at the seed commit, its page read |
| 5 | // through fixture. |
| 6 | package check |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "fmt" |
| 11 | "math" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/chromedp/chromedp" |
| 17 | ) |
| 18 | |
| 19 | // paginationCases are the strips the seeded page holds, each in its |
| 20 | // own <div id="pg-<id>">, with what a phone must show: ‹ and › for the |
| 21 | // chevrons, the page numbers, … for an ellipsis. "mid" is the gallery's |
| 22 | // sample. "flat" lists every page with no gaps, the way the generated |
| 23 | // list actions do, so the phone's ellipses there are the partial's own. |
| 24 | // Each comment is the strip as rastrillo's pageStrip(current, shown...) |
| 25 | // built it: Previous, the pages shown with a gap wherever a run is |
| 26 | // skipped, and Next, disabled at either end. |
| 27 | var paginationCases = []struct { |
| 28 | ID string |
| 29 | Narrow string |
| 30 | }{ |
| 31 | {"mid", "‹ 1 … 4 … 9 ›"}, // pageStrip(4, 1, 3, 4, 5, 9) |
| 32 | {"first", "‹ 1 2 ›"}, // pageStrip(1, 1, 2) |
| 33 | {"p2", "‹ 1 2 … 9 ›"}, // pageStrip(2, 1, 2, 3, 9) |
| 34 | {"p3", "‹ 1 … 3 … 9 ›"}, // pageStrip(3, 1, 2, 3, 4, 9) |
| 35 | {"p8", "‹ 1 … 8 9 ›"}, // pageStrip(8, 1, 7, 8, 9) |
| 36 | {"last", "‹ 1 … 9 ›"}, // pageStrip(9, 1, 8, 9) |
| 37 | {"flat", "‹ 1 … 4 … 7 ›"}, // pageStrip(4, 1, 2, 3, 4, 5, 6, 7) |
| 38 | {"only", "‹ 1 ›"}, // pageStrip(1, 1) |
| 39 | } |
| 40 | |
| 41 | func paginationPage(t *testing.T, dir string) string { |
| 42 | t.Helper() |
| 43 | return strings.Replace(sizingDoc("pagination", fixture(t, "pagination_browser_test.go", "paginationPage", "")), `dir="ltr"`, `dir="`+dir+`"`, 1) |
| 44 | } |
| 45 | |
| 46 | // stripItem is one visible child of a strip: its box, its text, whether |
| 47 | // it shows an icon, how wide its words are drawn, which way its icon's |
| 48 | // point faces along the inline axis (-1 left, 1 right), and whether it |
| 49 | // is a target (a link or a span that is not an ellipsis). |
| 50 | type stripItem struct { |
| 51 | Text string |
| 52 | L, T, R, B float64 |
| 53 | Icon bool |
| 54 | WordsW, Point float64 |
| 55 | Target, Ellips bool |
| 56 | } |
| 57 | |
| 58 | type stripReading struct { |
| 59 | ID string |
| 60 | L, R float64 // the strip's content box |
| 61 | Items []stripItem |
| 62 | Hidden []string // every child laid out with no box, by text |
| 63 | Center, RTL bool |
| 64 | } |
| 65 | |
| 66 | // paginationJS reads every strip. An icon's direction is read off the |
| 67 | // glyph: the midpoint of its path is the chevron's point, its two ends |
| 68 | // the arms, and the point's offset from the arms' middle, run through |
| 69 | // the icon's transform, says which way it faces on screen. |
| 70 | const paginationJS = `(() => JSON.stringify([...document.querySelectorAll("[id^=pg-] [rst-pagination]")].map(nav => { |
| 71 | const cs = getComputedStyle(nav), nb = nav.getBoundingClientRect(); |
| 72 | 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: []}; |
| 73 | for (const el of nav.children) { |
| 74 | const b = el.getBoundingClientRect(), text = el.textContent.trim(); |
| 75 | if (b.width === 0 && b.height === 0) { out.Hidden.push(text); continue; } |
| 76 | const svg = el.querySelector("svg"), sb = svg ? svg.getBoundingClientRect() : null, icon = !!sb && sb.width > 0; |
| 77 | let point = 0; |
| 78 | if (icon) { |
| 79 | const p = svg.querySelector("path"), len = p.getTotalLength(), a = p.getPointAtLength(0), z = p.getPointAtLength(len), m = p.getPointAtLength(len / 2); |
| 80 | const t = new DOMMatrix(getComputedStyle(svg).transform === "none" ? undefined : getComputedStyle(svg).transform); |
| 81 | 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)); |
| 82 | point = Math.sign(Math.round((v.x - o.x) * 100)); |
| 83 | } |
| 84 | let words = 0; |
| 85 | for (const n of el.querySelectorAll("*")) if (n.tagName !== "svg" && !n.closest("svg") && n.textContent.trim()) words = Math.max(words, n.getBoundingClientRect().width); |
| 86 | 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); } |
| 87 | const ellips = el.hasAttribute("rst-pagination-gap") || text === "…"; |
| 88 | 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}); |
| 89 | } |
| 90 | return out; |
| 91 | })))()` |
| 92 | |
| 93 | func readStrips(t *testing.T, ctx context.Context) map[string]stripReading { |
| 94 | t.Helper() |
| 95 | var got []stripReading |
| 96 | at(t, ctx, paginationJS, &got) |
| 97 | if len(got) != len(paginationCases) { |
| 98 | t.Fatalf("measured %d strips, want %d", len(got), len(paginationCases)) |
| 99 | } |
| 100 | out := map[string]stripReading{} |
| 101 | for _, g := range got { |
| 102 | out[g.ID] = g |
| 103 | } |
| 104 | return out |
| 105 | } |
| 106 | |
| 107 | // narrowSequence spells a strip as a phone shows it: a chevron for an |
| 108 | // item that draws an icon and no words, its text otherwise. |
| 109 | func narrowSequence(s stripReading) string { |
| 110 | var parts []string |
| 111 | for i, it := range s.Items { |
| 112 | switch { |
| 113 | case it.Icon && it.WordsW <= 1 && i == 0: |
| 114 | parts = append(parts, "‹") |
| 115 | case it.Icon && it.WordsW <= 1: |
| 116 | parts = append(parts, "›") |
| 117 | default: |
| 118 | parts = append(parts, it.Text) |
| 119 | } |
| 120 | } |
| 121 | return strings.Join(parts, " ") |
| 122 | } |
| 123 | |
| 124 | // TestPaginationIsOneBalancedLineOnAPhone: at 390 the gallery's "middle |
| 125 | // of a long list" strip (Previous 1 … 3 4 5 … 9 Next) wrapped onto two |
| 126 | // rows. On a phone Previous and Next are chevrons that keep their words |
| 127 | // for a screen reader, only the first, current and last pages show, |
| 128 | // with one ellipsis wherever pages are skipped, and the strip is one |
| 129 | // line spread across the width. The cases put the current page at |
| 130 | // either end and beside each, where a careless rule draws two ellipses |
| 131 | // in a row or one between two adjacent pages. Each target stays a tap, |
| 132 | // down to a 320px phone. |
| 133 | func TestPaginationIsOneBalancedLineOnAPhone(t *testing.T) { |
| 134 | for _, leg := range []struct { |
| 135 | w int64 |
| 136 | dir string |
| 137 | }{{390, "ltr"}, {390, "rtl"}, {320, "ltr"}, {320, "rtl"}} { |
| 138 | dir := leg.dir |
| 139 | t.Run(fmt.Sprintf("%d touch %s", leg.w, dir), func(t *testing.T) { |
| 140 | rig := sizingRig(t, true, map[string]string{"/": paginationPage(t, dir)}) |
| 141 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 142 | defer cancel() |
| 143 | mustRun(t, ctx, chromedp.EmulateViewport(leg.w, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#pg-only", chromedp.ByQuery)) |
| 144 | requirePointer(t, ctx, true) |
| 145 | strips := readStrips(t, ctx) |
| 146 | for _, c := range paginationCases { |
| 147 | s := strips[c.ID] |
| 148 | if s.RTL != (dir == "rtl") { |
| 149 | t.Fatalf("%s: the strip's direction is rtl=%v, the leg needs %s", c.ID, s.RTL, dir) |
| 150 | } |
| 151 | if got := narrowSequence(s); got != c.Narrow { |
| 152 | t.Errorf("%s: the phone shows %q, want %q", c.ID, got, c.Narrow) |
| 153 | continue |
| 154 | } |
| 155 | checkStripLine(t, c.ID, s) |
| 156 | for i, it := range s.Items { |
| 157 | if it.Target && (it.R-it.L < 43.5 || it.B-it.T < 43.5) { |
| 158 | t.Errorf("%s: %q is %.1f×%.1f, under the 44px tap", c.ID, it.Text, it.R-it.L, it.B-it.T) |
| 159 | } |
| 160 | if !it.Icon { |
| 161 | continue |
| 162 | } |
| 163 | // The chevron points out of the strip, at the end it |
| 164 | // sits on: Previous at the start of the line, Next at |
| 165 | // its end, whichever way the line runs. |
| 166 | first := i == 0 |
| 167 | wantText := map[bool]string{true: "Previous", false: "Next"}[first] |
| 168 | if it.Text != wantText { |
| 169 | t.Errorf("%s: the chevron at position %d reads %q to a screen reader, want %q", c.ID, i, it.Text, wantText) |
| 170 | } |
| 171 | onLeft := (first && dir == "ltr") || (!first && dir == "rtl") |
| 172 | wantPoint := map[bool]float64{true: -1, false: 1}[onLeft] |
| 173 | if it.Point != wantPoint { |
| 174 | t.Errorf("%s: %s's chevron points %v, want %v (toward its own end of the strip)", c.ID, it.Text, it.Point, wantPoint) |
| 175 | } |
| 176 | if (onLeft && it.L-s.L > 0.5) || (!onLeft && s.R-it.R > 0.5) { |
| 177 | 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) |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | }) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // checkStripLine holds a strip to one line, spread from edge to edge |
| 186 | // with the same space between every pair of neighbours. |
| 187 | func checkStripLine(t *testing.T, id string, s stripReading) { |
| 188 | t.Helper() |
| 189 | items := s.Items |
| 190 | for _, it := range items[1:] { |
| 191 | if math.Abs(it.T-items[0].T) > 1 { |
| 192 | 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) |
| 193 | } |
| 194 | } |
| 195 | left, right := items[0].L, items[0].R |
| 196 | for _, it := range items { |
| 197 | left, right = math.Min(left, it.L), math.Max(right, it.R) |
| 198 | } |
| 199 | if left-s.L > 0.5 || s.R-right > 0.5 { |
| 200 | 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) |
| 201 | } |
| 202 | var spaces []float64 |
| 203 | for i := 1; i < len(items); i++ { |
| 204 | a, b := items[i-1], items[i] |
| 205 | if s.RTL { |
| 206 | spaces = append(spaces, a.L-b.R) |
| 207 | } else { |
| 208 | spaces = append(spaces, b.L-a.R) |
| 209 | } |
| 210 | } |
| 211 | lo, hi := math.Inf(1), math.Inf(-1) |
| 212 | for _, sp := range spaces { |
| 213 | lo, hi = math.Min(lo, sp), math.Max(hi, sp) |
| 214 | } |
| 215 | if hi-lo > 1 { |
| 216 | t.Errorf("%s: the spaces between items run from %.1f to %.1f; the strip is not balanced", id, lo, hi) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // TestPaginationIsUnchangedOnADesktop pins the gallery's strip at 1280 |
| 221 | // with a mouse as it was before the phone's strip arrived: every item |
| 222 | // shown with its words and no icon, at the same place and size. |
| 223 | func TestPaginationIsUnchangedOnADesktop(t *testing.T) { |
| 224 | rig := sizingRig(t, false, map[string]string{"/": paginationPage(t, "ltr")}) |
| 225 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 226 | defer cancel() |
| 227 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#pg-only", chromedp.ByQuery)) |
| 228 | requirePointer(t, ctx, false) |
| 229 | s := readStrips(t, ctx)["mid"] |
| 230 | type pinned struct { |
| 231 | Text string |
| 232 | L, W float64 |
| 233 | } |
| 234 | 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}, |
| 235 | {"5", 276.078, 53.188}, {"…", 333.266, 22.5}, {"9", 359.766, 53.188}, {"Next", 416.953, 53.188}} |
| 236 | var got []pinned |
| 237 | for _, it := range s.Items { |
| 238 | if it.Icon { |
| 239 | t.Errorf("%q draws an icon on a desktop", it.Text) |
| 240 | } |
| 241 | if it.T-s.Items[0].T != 0 || it.B-it.T != s.Items[0].B-s.Items[0].T { |
| 242 | 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) |
| 243 | } |
| 244 | got = append(got, pinned{it.Text, math.Round((it.L-s.L)*1000) / 1000, math.Round((it.R-it.L)*1000) / 1000}) |
| 245 | } |
| 246 | if len(s.Hidden) != 0 { |
| 247 | t.Errorf("the desktop strip hides %q", s.Hidden) |
| 248 | } |
| 249 | if fmt.Sprint(got) != fmt.Sprint(want) { |
| 250 | t.Errorf("the desktop strip moved:\n got %v\nwant %v", got, want) |
| 251 | } |
| 252 | if h := s.Items[0].B - s.Items[0].T; math.Abs(h-32) > 0.01 { |
| 253 | t.Errorf("the desktop chips are %.2fpx tall, pinned at 32", h) |
| 254 | } |
| 255 | } |
| 256 | |