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 bulk bar on a phone and on a desktop, from rastrillo's
4// ui/bulkbar_browser_test.go at the seed commit, its page
5// read through fixture.
6package check
7
8import (
9 "context"
10 "math"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/chromedp/chromedp"
16)
17
18// bulkBarCase is one bulk bar the drives below render: the gallery's
19// own sample, one with nothing to escalate to, the gallery's sample
20// written the length four of its locales would write it, and two
21// links longer than any phone's bar, one of them a single word with
22// nowhere to break. At 390 the Russian and Arabic links are wider than
23// the room beside the count and run on under Actions, below its row;
24// that is the case the overlap checks exist for. The seeded page holds
25// each case in its own <div rst-list id="bulk-<id>">; the table is
26// what the readers expect to find there.
27type bulkBarCase struct {
28 ID, Count, Escalate, Menu string
29}
30
31var bulkBarCases = []bulkBarCase{
32 {"en", "3 selected", "Select all 412 matching", "Actions"},
33 {"short", "3 selected", "", "Actions"},
34 {"pt", "3 selecionados", "Selecionar os 412 resultados", "Ações"},
35 {"ru", "Выбрано: 3", "Выбрать все 412 подходящих", "Действия"},
36 {"bn", "৩টি নির্বাচিত", "মিলে যাওয়া সব ৪১২টি নির্বাচন করুন", "কাজগুলো"},
37 {"ar", "تم تحديد 3", "تحديد كل العناصر المطابقة وعددها 412", "إجراءات"},
38 {"long", "3 selected", "Select all 4,812 matching records in every folder you can see", "Actions"},
39 {"word", "3 selected", "Selectallfourthousandeighthundredtwelvematchingrecords", "Actions"},
40}
41
42// bulkBarPage is every case in a list card, as select mode shows it,
43// in one direction.
44func bulkBarPage(t *testing.T, dir string) string {
45 t.Helper()
46 return strings.Replace(sizingDoc("bulk-bar", fixture(t, "bulkbar_browser_test.go", "bulkBarPage", "")), `dir="ltr"`, `dir="`+dir+`"`, 1)
47}
48
49// box is a rectangle as the page lays it out.
50type box struct{ L, T, R, B float64 }
51
52func (b box) w() float64 { return b.R - b.L }
53func (b box) cy() float64 { return (b.T + b.B) / 2 }
54func (b box) overlaps(o box) bool {
55 return b.L < o.R-0.5 && o.L < b.R-0.5 && b.T < o.B-0.5 && o.T < b.B-0.5
56}
57
58// bulkBarReading is one bar measured: its content box, each part's
59// border box, how many lines each text took, and the column gap.
60type bulkBarReading struct {
61 ID string
62 Bar, Close, Count, Summary box
63 Escalate *box
64 CountLines, EscalateLines int
65 CountOneLine, EscalateOneLine float64 // each text's width on one line
66 Gap float64
67 RTL bool
68}
69
70const bulkBarJS = `(() => {
71 const r = el => { const b = el.getBoundingClientRect(); return {L: b.left, T: b.top, R: b.right, B: b.bottom}; };
72 const lines = el => { const g = document.createRange(); g.selectNodeContents(el); return new Set([...g.getClientRects()].filter(x => x.width > 0).map(x => Math.round(x.top))).size; };
73 return JSON.stringify([...document.querySelectorAll("[rst-list][id^=bulk-]")].map(list => {
74 const bar = list.querySelector("[rst-bulkbar]"), cs = getComputedStyle(bar), b = bar.getBoundingClientRect();
75 const esc = bar.querySelector("[rst-bulkbar-escalate]");
76 const one = el => { if (!el) return 0; const c = el.cloneNode(true); c.style.cssText = "position: absolute; visibility: hidden; white-space: nowrap; max-inline-size: none; inline-size: max-content"; el.parentNode.appendChild(c); const w = c.getBoundingClientRect().width; c.remove(); return w; };
77 return {ID: list.id.slice(5), RTL: cs.direction === "rtl", Gap: parseFloat(cs.columnGap),
78 Bar: {L: b.left + parseFloat(cs.paddingLeft), T: b.top + parseFloat(cs.paddingTop), R: b.right - parseFloat(cs.paddingRight), B: b.bottom - parseFloat(cs.paddingBottom)},
79 Close: r(bar.querySelector("[rst-bulkbar-close]")), Count: r(bar.querySelector("[rst-bulkbar-count]")),
80 Summary: r(bar.querySelector("[rst-dropdown] > summary")), Escalate: esc ? r(esc) : null,
81 CountLines: lines(bar.querySelector("[rst-bulkbar-count]")), EscalateLines: esc ? lines(esc) : 0, EscalateOneLine: one(esc), CountOneLine: one(bar.querySelector("[rst-bulkbar-count]"))};
82 }));
83})()`
84
85func readBulkBars(t *testing.T, ctx context.Context) map[string]bulkBarReading {
86 t.Helper()
87 var got []bulkBarReading
88 at(t, ctx, bulkBarJS, &got)
89 if len(got) != len(bulkBarCases) {
90 t.Fatalf("measured %d bulk bars, want %d", len(got), len(bulkBarCases))
91 }
92 out := map[string]bulkBarReading{}
93 for _, g := range got {
94 out[g.ID] = g
95 }
96 return out
97}
98
99// TestTheBulkBarNeverWrapsItsText: on a 390px phone the bar squeezed
100// "3 selected" and "Select all 412 matching" onto two lines each,
101// between a close button at one end and Actions at the other. Each
102// text keeps one line. When the bar's parts fit one row they stay on
103// it; when they do not, the escalate link moves to a second row that
104// spans the bar, starting where the close button starts, and the close
105// button, the count and Actions keep the first, Actions at the
106// trailing edge. A link wider than the whole bar wraps, as the one
107// thing left that keeps it inside the card. The legs pin both shapes
108// on the gallery's own sample (two rows at 390, one at 540) and on a
109// bar with no escalate link (one row), and hold the longer locales,
110// the over-long links, 320px and a right-to-left page to the same
111// rule.
112func TestTheBulkBarNeverWrapsItsText(t *testing.T) {
113 for _, leg := range []struct {
114 name string
115 w int64
116 dir string
117 twoRows map[string]bool // the shape a case must take; absent: whichever its width calls for
118 }{
119 {"390 touch", 390, "ltr", map[string]bool{"en": true, "short": false}},
120 {"540 touch", 540, "ltr", map[string]bool{"en": false, "short": false}},
121 {"390 touch rtl", 390, "rtl", map[string]bool{"short": false}},
122 {"320 touch", 320, "ltr", map[string]bool{"short": false, "long": true, "word": true}},
123 {"320 touch rtl", 320, "rtl", map[string]bool{"short": false, "long": true, "word": true}},
124 } {
125 t.Run(leg.name, func(t *testing.T) {
126 rig := sizingRig(t, true, map[string]string{"/": bulkBarPage(t, leg.dir)})
127 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
128 defer cancel()
129 mustRun(t, ctx, chromedp.EmulateViewport(leg.w, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#bulk-ar", chromedp.ByQuery))
130 requirePointer(t, ctx, true)
131 bars := readBulkBars(t, ctx)
132 for _, c := range bulkBarCases {
133 checkBulkBar(t, bars[c.ID], leg.dir == "rtl", leg.twoRows)
134 }
135 })
136 }
137}
138
139func checkBulkBar(t *testing.T, g bulkBarReading, rtl bool, twoRows map[string]bool) {
140 t.Helper()
141 if g.RTL != rtl {
142 t.Fatalf("%s: the bar's direction is rtl=%v, the leg needs %v", g.ID, g.RTL, rtl)
143 }
144 // One line unless the first row, close, count and Actions with the
145 // gaps between them, is wider than the bar with the count on one.
146 if first := g.Close.w() + g.CountOneLine + g.Summary.w() + 2*g.Gap; first <= g.Bar.w()+0.5 && g.CountLines != 1 {
147 t.Errorf("%s: the count takes %d lines; the first row needs %.1fpx of the bar's %.1f with it on one", g.ID, g.CountLines, first, g.Bar.w())
148 }
149 sameRow := func(a, b box) bool { return math.Abs(a.cy()-b.cy()) < 2 }
150 // start and end read the inline axis whichever way it runs.
151 start := func(b box) float64 {
152 if rtl {
153 return -b.R
154 }
155 return b.L
156 }
157 end := func(b box) float64 {
158 if rtl {
159 return -b.L
160 }
161 return b.R
162 }
163 if math.Abs(g.Close.T-g.Count.T) > 0.5 || math.Abs(g.Count.T-g.Summary.T) > 0.5 {
164 t.Errorf("%s: the close button (%.1f), the count (%.1f) and Actions (%.1f) do not start one row", g.ID, g.Close.T, g.Count.T, g.Summary.T)
165 }
166 if !(end(g.Close) <= start(g.Count)+0.5 && end(g.Count) <= start(g.Summary)+0.5) {
167 t.Errorf("%s: the first row is not close, count, Actions in reading order: %+v %+v %+v", g.ID, g.Close, g.Count, g.Summary)
168 }
169 if d := end(g.Bar) - end(g.Summary); math.Abs(d) > 0.5 {
170 t.Errorf("%s: Actions ends %.1fpx short of the bar's trailing edge, want it at the edge", g.ID, d)
171 }
172 for name, b := range map[string]box{"close": g.Close, "count": g.Count, "Actions": g.Summary} {
173 if b.L < g.Bar.L-0.5 || b.R > g.Bar.R+0.5 {
174 t.Errorf("%s: the %s button runs outside the bar: %+v in %+v", g.ID, name, b, g.Bar)
175 }
176 }
177 if g.Escalate == nil {
178 if want, ok := twoRows[g.ID]; ok && want {
179 t.Fatalf("%s: the leg wants two rows, but the bar has no escalate link", g.ID)
180 }
181 return
182 }
183 e := *g.Escalate
184 // One line unless the link on one line is wider than the bar.
185 if g.EscalateOneLine <= g.Bar.w()+0.5 && g.EscalateLines != 1 {
186 t.Errorf("%s: the escalate link takes %d lines; on one it is %.1fpx, and the bar is %.1f", g.ID, g.EscalateLines, g.EscalateOneLine, g.Bar.w())
187 }
188 if e.L < g.Bar.L-0.5 || e.R > g.Bar.R+0.5 {
189 t.Errorf("%s: the escalate link runs outside the bar: %+v in %+v", g.ID, e, g.Bar)
190 }
191 for name, b := range map[string]box{"close": g.Close, "count": g.Count, "Actions": g.Summary} {
192 if e.overlaps(b) {
193 t.Errorf("%s: the escalate link overlaps the %s: %+v and %+v", g.ID, name, e, b)
194 }
195 }
196 // One row fits when the four parts and the gaps between them do.
197 fits := g.Close.w()+g.Count.w()+g.EscalateOneLine+g.Summary.w()+3*g.Gap <= g.Bar.w()+0.5
198 two := !sameRow(e, g.Count)
199 if want, ok := twoRows[g.ID]; ok && want != two {
200 t.Errorf("%s: two rows is %v, this leg wants %v (one row needs %.1fpx of the bar's %.1f)", g.ID, two, want, g.Close.w()+g.Count.w()+g.EscalateOneLine+g.Summary.w()+3*g.Gap, g.Bar.w())
201 }
202 if fits && two {
203 t.Errorf("%s: the parts fit one row (%.1fpx of %.1f) but the escalate link took a second", g.ID, g.Close.w()+g.Count.w()+g.EscalateOneLine+g.Summary.w()+3*g.Gap, g.Bar.w())
204 }
205 if !two {
206 if !(end(g.Count) <= start(e)+0.5 && end(e) <= start(g.Summary)+0.5) {
207 t.Errorf("%s: on one row the escalate link is not between the count and Actions: %+v %+v %+v", g.ID, g.Count, e, g.Summary)
208 }
209 return
210 }
211 if e.T < g.Count.B-0.5 {
212 t.Errorf("%s: the escalate link's second row starts at %.1f, above the count's bottom, %.1f", g.ID, e.T, g.Count.B)
213 }
214 if d := start(e) - start(g.Close); math.Abs(d) > 0.5 {
215 t.Errorf("%s: the escalate link starts %.1fpx from the close button's start, want its row to span the bar", g.ID, d)
216 }
217}
218
219// TestTheBulkBarIsUnchangedOnADesktop pins the gallery sample's bar at
220// 1280 with a mouse, measured before the phone's two-row layout
221// arrived: every part's box relative to the bar's, and the bar's
222// height. The phone's layout is in the touch query, and this is the
223// proof that none of it leaked out.
224func TestTheBulkBarIsUnchangedOnADesktop(t *testing.T) {
225 rig := sizingRig(t, false, map[string]string{"/": bulkBarPage(t, "ltr")})
226 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
227 defer cancel()
228 mustRun(t, ctx, chromedp.EmulateViewport(1280, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#bulk-en", chromedp.ByQuery))
229 requirePointer(t, ctx, false)
230 var got map[string]box
231 at(t, ctx, `(() => { const bar = document.querySelector("#bulk-en [rst-bulkbar]"), o = bar.getBoundingClientRect();
232 const r = sel => { const b = bar.querySelector(sel).getBoundingClientRect(); return {L: b.left - o.left, T: b.top - o.top, R: b.right - o.left, B: b.bottom - o.top}; };
233 return JSON.stringify({Bar: {L: 0, T: 0, R: o.width, B: o.height}, Close: r("[rst-bulkbar-close]"), Count: r("[rst-bulkbar-count]"), Escalate: r("[rst-bulkbar-escalate]"), Summary: r("[rst-dropdown] > summary")}); })()`, &got)
234 want := map[string]box{
235 "Bar": {0, 0, 990, 47},
236 "Close": {9.59375, 7, 41.59375, 39},
237 "Count": {52.78125, 13.625, 125.328125, 32.375},
238 "Escalate": {136.515625, 7, 288.921875, 39},
239 "Summary": {890.328125, 7, 976.40625, 39},
240 }
241 for name, w := range want {
242 g := got[name]
243 if math.Abs(g.L-w.L) > 0.05 || math.Abs(g.T-w.T) > 0.05 || math.Abs(g.R-w.R) > 0.05 || math.Abs(g.B-w.B) > 0.05 {
244 t.Errorf("%s at 1280: %+v, pinned at %+v", name, g, w)
245 }
246 }
247}
248