//go:build browser
// The bulk bar on a phone and on a desktop, from rastrillo's
// ui/bulkbar_browser_test.go at the seed commit, its page
// read through fixture.
package check
import (
"context"
"math"
"strings"
"testing"
"time"
"github.com/chromedp/chromedp"
)
// bulkBarCase is one bulk bar the drives below render: the gallery's
// own sample, one with nothing to escalate to, the gallery's sample
// written the length four of its locales would write it, and two
// links longer than any phone's bar, one of them a single word with
// nowhere to break. At 390 the Russian and Arabic links are wider than
// the room beside the count and run on under Actions, below its row;
// that is the case the overlap checks exist for. The seeded page holds
// each case in its own
; the table is
// what the readers expect to find there.
type bulkBarCase struct {
ID, Count, Escalate, Menu string
}
var bulkBarCases = []bulkBarCase{
{"en", "3 selected", "Select all 412 matching", "Actions"},
{"short", "3 selected", "", "Actions"},
{"pt", "3 selecionados", "Selecionar os 412 resultados", "Ações"},
{"ru", "Выбрано: 3", "Выбрать все 412 подходящих", "Действия"},
{"bn", "৩টি নির্বাচিত", "মিলে যাওয়া সব ৪১২টি নির্বাচন করুন", "কাজগুলো"},
{"ar", "تم تحديد 3", "تحديد كل العناصر المطابقة وعددها 412", "إجراءات"},
{"long", "3 selected", "Select all 4,812 matching records in every folder you can see", "Actions"},
{"word", "3 selected", "Selectallfourthousandeighthundredtwelvematchingrecords", "Actions"},
}
// bulkBarPage is every case in a list card, as select mode shows it,
// in one direction.
func bulkBarPage(t *testing.T, dir string) string {
t.Helper()
return strings.Replace(sizingDoc("bulk-bar", fixture(t, "bulkbar_browser_test.go", "bulkBarPage", "")), `dir="ltr"`, `dir="`+dir+`"`, 1)
}
// box is a rectangle as the page lays it out.
type box struct{ L, T, R, B float64 }
func (b box) w() float64 { return b.R - b.L }
func (b box) cy() float64 { return (b.T + b.B) / 2 }
func (b box) overlaps(o box) bool {
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
}
// bulkBarReading is one bar measured: its content box, each part's
// border box, how many lines each text took, and the column gap.
type bulkBarReading struct {
ID string
Bar, Close, Count, Summary box
Escalate *box
CountLines, EscalateLines int
CountOneLine, EscalateOneLine float64 // each text's width on one line
Gap float64
RTL bool
}
const bulkBarJS = `(() => {
const r = el => { const b = el.getBoundingClientRect(); return {L: b.left, T: b.top, R: b.right, B: b.bottom}; };
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; };
return JSON.stringify([...document.querySelectorAll("[rst-list][id^=bulk-]")].map(list => {
const bar = list.querySelector("[rst-bulkbar]"), cs = getComputedStyle(bar), b = bar.getBoundingClientRect();
const esc = bar.querySelector("[rst-bulkbar-escalate]");
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; };
return {ID: list.id.slice(5), RTL: cs.direction === "rtl", Gap: parseFloat(cs.columnGap),
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)},
Close: r(bar.querySelector("[rst-bulkbar-close]")), Count: r(bar.querySelector("[rst-bulkbar-count]")),
Summary: r(bar.querySelector("[rst-dropdown] > summary")), Escalate: esc ? r(esc) : null,
CountLines: lines(bar.querySelector("[rst-bulkbar-count]")), EscalateLines: esc ? lines(esc) : 0, EscalateOneLine: one(esc), CountOneLine: one(bar.querySelector("[rst-bulkbar-count]"))};
}));
})()`
func readBulkBars(t *testing.T, ctx context.Context) map[string]bulkBarReading {
t.Helper()
var got []bulkBarReading
at(t, ctx, bulkBarJS, &got)
if len(got) != len(bulkBarCases) {
t.Fatalf("measured %d bulk bars, want %d", len(got), len(bulkBarCases))
}
out := map[string]bulkBarReading{}
for _, g := range got {
out[g.ID] = g
}
return out
}
// TestTheBulkBarNeverWrapsItsText: on a 390px phone the bar squeezed
// "3 selected" and "Select all 412 matching" onto two lines each,
// between a close button at one end and Actions at the other. Each
// text keeps one line. When the bar's parts fit one row they stay on
// it; when they do not, the escalate link moves to a second row that
// spans the bar, starting where the close button starts, and the close
// button, the count and Actions keep the first, Actions at the
// trailing edge. A link wider than the whole bar wraps, as the one
// thing left that keeps it inside the card. The legs pin both shapes
// on the gallery's own sample (two rows at 390, one at 540) and on a
// bar with no escalate link (one row), and hold the longer locales,
// the over-long links, 320px and a right-to-left page to the same
// rule.
func TestTheBulkBarNeverWrapsItsText(t *testing.T) {
for _, leg := range []struct {
name string
w int64
dir string
twoRows map[string]bool // the shape a case must take; absent: whichever its width calls for
}{
{"390 touch", 390, "ltr", map[string]bool{"en": true, "short": false}},
{"540 touch", 540, "ltr", map[string]bool{"en": false, "short": false}},
{"390 touch rtl", 390, "rtl", map[string]bool{"short": false}},
{"320 touch", 320, "ltr", map[string]bool{"short": false, "long": true, "word": true}},
{"320 touch rtl", 320, "rtl", map[string]bool{"short": false, "long": true, "word": true}},
} {
t.Run(leg.name, func(t *testing.T) {
rig := sizingRig(t, true, map[string]string{"/": bulkBarPage(t, leg.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("#bulk-ar", chromedp.ByQuery))
requirePointer(t, ctx, true)
bars := readBulkBars(t, ctx)
for _, c := range bulkBarCases {
checkBulkBar(t, bars[c.ID], leg.dir == "rtl", leg.twoRows)
}
})
}
}
func checkBulkBar(t *testing.T, g bulkBarReading, rtl bool, twoRows map[string]bool) {
t.Helper()
if g.RTL != rtl {
t.Fatalf("%s: the bar's direction is rtl=%v, the leg needs %v", g.ID, g.RTL, rtl)
}
// One line unless the first row, close, count and Actions with the
// gaps between them, is wider than the bar with the count on one.
if first := g.Close.w() + g.CountOneLine + g.Summary.w() + 2*g.Gap; first <= g.Bar.w()+0.5 && g.CountLines != 1 {
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())
}
sameRow := func(a, b box) bool { return math.Abs(a.cy()-b.cy()) < 2 }
// start and end read the inline axis whichever way it runs.
start := func(b box) float64 {
if rtl {
return -b.R
}
return b.L
}
end := func(b box) float64 {
if rtl {
return -b.L
}
return b.R
}
if math.Abs(g.Close.T-g.Count.T) > 0.5 || math.Abs(g.Count.T-g.Summary.T) > 0.5 {
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)
}
if !(end(g.Close) <= start(g.Count)+0.5 && end(g.Count) <= start(g.Summary)+0.5) {
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)
}
if d := end(g.Bar) - end(g.Summary); math.Abs(d) > 0.5 {
t.Errorf("%s: Actions ends %.1fpx short of the bar's trailing edge, want it at the edge", g.ID, d)
}
for name, b := range map[string]box{"close": g.Close, "count": g.Count, "Actions": g.Summary} {
if b.L < g.Bar.L-0.5 || b.R > g.Bar.R+0.5 {
t.Errorf("%s: the %s button runs outside the bar: %+v in %+v", g.ID, name, b, g.Bar)
}
}
if g.Escalate == nil {
if want, ok := twoRows[g.ID]; ok && want {
t.Fatalf("%s: the leg wants two rows, but the bar has no escalate link", g.ID)
}
return
}
e := *g.Escalate
// One line unless the link on one line is wider than the bar.
if g.EscalateOneLine <= g.Bar.w()+0.5 && g.EscalateLines != 1 {
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())
}
if e.L < g.Bar.L-0.5 || e.R > g.Bar.R+0.5 {
t.Errorf("%s: the escalate link runs outside the bar: %+v in %+v", g.ID, e, g.Bar)
}
for name, b := range map[string]box{"close": g.Close, "count": g.Count, "Actions": g.Summary} {
if e.overlaps(b) {
t.Errorf("%s: the escalate link overlaps the %s: %+v and %+v", g.ID, name, e, b)
}
}
// One row fits when the four parts and the gaps between them do.
fits := g.Close.w()+g.Count.w()+g.EscalateOneLine+g.Summary.w()+3*g.Gap <= g.Bar.w()+0.5
two := !sameRow(e, g.Count)
if want, ok := twoRows[g.ID]; ok && want != two {
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())
}
if fits && two {
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())
}
if !two {
if !(end(g.Count) <= start(e)+0.5 && end(e) <= start(g.Summary)+0.5) {
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)
}
return
}
if e.T < g.Count.B-0.5 {
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)
}
if d := start(e) - start(g.Close); math.Abs(d) > 0.5 {
t.Errorf("%s: the escalate link starts %.1fpx from the close button's start, want its row to span the bar", g.ID, d)
}
}
// TestTheBulkBarIsUnchangedOnADesktop pins the gallery sample's bar at
// 1280 with a mouse, measured before the phone's two-row layout
// arrived: every part's box relative to the bar's, and the bar's
// height. The phone's layout is in the touch query, and this is the
// proof that none of it leaked out.
func TestTheBulkBarIsUnchangedOnADesktop(t *testing.T) {
rig := sizingRig(t, false, map[string]string{"/": bulkBarPage(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("#bulk-en", chromedp.ByQuery))
requirePointer(t, ctx, false)
var got map[string]box
at(t, ctx, `(() => { const bar = document.querySelector("#bulk-en [rst-bulkbar]"), o = bar.getBoundingClientRect();
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}; };
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)
want := map[string]box{
"Bar": {0, 0, 990, 47},
"Close": {9.59375, 7, 41.59375, 39},
"Count": {52.78125, 13.625, 125.328125, 32.375},
"Escalate": {136.515625, 7, 288.921875, 39},
"Summary": {890.328125, 7, 976.40625, 39},
}
for name, w := range want {
g := got[name]
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 {
t.Errorf("%s at 1280: %+v, pinned at %+v", name, g, w)
}
}
}