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 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.
6package check
7
8import (
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.
27var 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
41func 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).
50type stripItem struct {
51 Text string
52 L, T, R, B float64
53 Icon bool
54 WordsW, Point float64
55 Target, Ellips bool
56}
57
58type 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.
70const 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
93func 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.
109func 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.
133func 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.
187func 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.
223func 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