| 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. |
| 6 | package check |
| 7 | |
| 8 | import ( |
| 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. |
| 27 | type bulkBarCase struct { |
| 28 | ID, Count, Escalate, Menu string |
| 29 | } |
| 30 | |
| 31 | var 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. |
| 44 | func 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. |
| 50 | type box struct{ L, T, R, B float64 } |
| 51 | |
| 52 | func (b box) w() float64 { return b.R - b.L } |
| 53 | func (b box) cy() float64 { return (b.T + b.B) / 2 } |
| 54 | func (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. |
| 60 | type 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 | |
| 70 | const 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 | |
| 85 | func 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. |
| 112 | func 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 | |
| 139 | func 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. |
| 224 | func 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 | |