//go:build browser // Two layouts a page asks for on a phone, by an attribute value 1.0 // never had: a stat band two to a line, and a short status kept on its // row's first line. Each is held where it applies, below 34rem in both // directions, and where it must not: at 1280 the marked markup paints // pixel for pixel what the unmarked markup paints, so the value changes // a phone and nothing else. package check import ( "context" "fmt" "math" "strings" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/harness" ) // stateDoc is one state of an example as a document, its assets // flattened beside the page as the sizing mux serves them, found by // its label so a renamed state fails here by name rather than reading // some other state. func stateDoc(t *testing.T, name, label string) string { t.Helper() entry, ok := manifest(t).Entry(name) if !ok { t.Fatalf("examples.json has no %s entry", name) } for _, s := range entry.States { if s.Label == label { doc, err := example.Document(fiesta.FS(), name, s.ID) if err != nil { t.Fatal(err) } return parityLinks(doc) } } t.Fatalf("examples.json has no %s state labelled %q", name, label) return "" } // swapped is doc with was replaced by is exactly once, so a fixture // edit that no longer finds its text fails rather than testing the // unedited document twice. func swapped(t *testing.T, doc, was, is string) string { t.Helper() if strings.Count(doc, was) != 1 { t.Fatalf("the document holds %q %d times; this edit needs it once", was, strings.Count(doc, was)) } return strings.Replace(doc, was, is, 1) } // phoneVariantRig serves pages in a browser that rasters on one // thread, so the 1280 comparisons can hold zero tolerance: a pixel // painted twice the same way is the same pixel. func phoneVariantRig(t *testing.T, pages map[string]string) (*harness.Rig, context.Context) { t.Helper() rig := sizingRig(t, false, pages, harness.WithDeterministicRaster()) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) t.Cleanup(cancel) return rig, ctx } // sameAtDesktop photographs the two paths at 1280 and fails unless they // are the same to the pixel: what proves a phone layout leaves a // desktop alone. func sameAtDesktop(t *testing.T, ctx context.Context, origin, marked, plain, what string) { t.Helper() a := pageShot(t, ctx, origin+marked, 1280, "light") b := pageShot(t, ctx, origin+plain, 1280, "light") if same, why := samePixelsWithin(t, a, b, 0); !same { t.Errorf("at 1280 %s paints differently from the same markup unmarked (%s): the value must change a phone only", what, why) } } // statCell is one cell of a band as the pairs test reads it: its box, // its two leading hairlines as computed, and its figure's box. type statCell struct { Left, Right, Top, Bottom float64 Start, BlockStart string Num struct{ Left, Right, Top, Bottom float64 } } type bandReading struct { Cells []statCell RTL bool DocScroll, DocClient float64 } const bandJS = `(() => { const band = document.querySelector("[rst-stats]"); const box = (el) => { const r = el.getBoundingClientRect(); return {Left: r.left, Right: r.right, Top: r.top, Bottom: r.bottom}; }; return JSON.stringify({RTL: getComputedStyle(band).direction === "rtl", Cells: [...band.children].map((c) => { const cs = getComputedStyle(c); return {...box(c), Start: cs.borderInlineStartWidth, BlockStart: cs.borderBlockStartWidth, Num: box(c.querySelector("[rst-stat-num]"))}; }), DocScroll: document.documentElement.scrollWidth, DocClient: document.documentElement.clientWidth}); })()` func readBand(t *testing.T, ctx context.Context, url string, width int64) bandReading { t.Helper() mustRun(t, ctx, chromedp.EmulateViewport(width, 844), chromedp.Navigate(url), chromedp.WaitReady("[rst-stats]", chromedp.ByQuery)) settleUntil(t, ctx, `document.readyState === "complete"`) var b bandReading at(t, ctx, bandJS, &b) if len(b.Cells) != 4 { t.Fatalf("%s: the band holds %d stats; the state has four", url, len(b.Cells)) } return b } // TestPairsPutTwoStatsOnALineOnAPhone: at 390 and 320 a band marked // pairs is two columns: stats 1 and 2 share a top, 3 and 4 share one // below them, the first of each pair has no hairline at its inline // start (the band's edge is there) and the second has one, and the // second pair has a hairline above it. At 320 each figure stays inside // its cell and the page does not scroll sideways. In Arabic the pairs // mirror: the first stat of a pair is on the right. At 1280 the band // is the band without the value, pixel for pixel. func TestPairsPutTwoStatsOnALineOnAPhone(t *testing.T) { doc := stateDoc(t, "stat-band", "Two to a line on a phone") plain := swapped(t, doc, `
`, `
`) rig, ctx := phoneVariantRig(t, map[string]string{ "/": doc, "/rtl": inArabic(t, doc), "/plain": plain, "/plain-rtl": inArabic(t, plain), }) for _, path := range []string{"/", "/rtl"} { for _, width := range []int64{390, 320} { leg := fmt.Sprintf("%s at %d", path, width) b := readBand(t, ctx, rig.Origin+path, width) if b.RTL != (path == "/rtl") { t.Fatalf("%s: the band reads right to left %v; this leg needs %v", leg, b.RTL, path == "/rtl") } c := b.Cells if !sameEdge(c[0].Top, c[1].Top) || !sameEdge(c[2].Top, c[3].Top) { t.Errorf("%s: the stats' tops are %.1f, %.1f, %.1f, %.1f; stats 1 and 2 must share a line, and 3 and 4", leg, c[0].Top, c[1].Top, c[2].Top, c[3].Top) } if c[2].Top < c[0].Bottom-0.5 { t.Errorf("%s: the second pair starts at %.1f, above the first pair's foot at %.1f", leg, c[2].Top, c[0].Bottom) } // The first of a pair is at the inline start: the left in // English, the right in Arabic. for _, p := range [][2]int{{0, 1}, {2, 3}} { first, second := c[p[0]], c[p[1]] if (!b.RTL && first.Left >= second.Left) || (b.RTL && first.Right <= second.Right) { t.Errorf("%s: stat %d (%.1f to %.1f) is not at the inline start of stat %d (%.1f to %.1f)", leg, p[0]+1, first.Left, first.Right, p[1]+1, second.Left, second.Right) } } for i, want := range []string{"0px", "1px", "0px", "1px"} { if c[i].Start != want { t.Errorf("%s: stat %d's inline-start hairline is %s, want %s", leg, i+1, c[i].Start, want) } } for i, want := range []string{"0px", "0px", "1px", "1px"} { if c[i].BlockStart != want { t.Errorf("%s: stat %d's top hairline is %s, want %s", leg, i+1, c[i].BlockStart, want) } } if width != 320 { continue } for i, cell := range c { if n := cell.Num; n.Left < cell.Left-0.5 || n.Right > cell.Right+0.5 { t.Errorf("%s: stat %d's figure runs %.1f to %.1f, outside its cell's %.1f to %.1f", leg, i+1, n.Left, n.Right, cell.Left, cell.Right) } } if b.DocScroll > b.DocClient { t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; it scrolls sideways", leg, b.DocScroll, b.DocClient) } } } sameAtDesktop(t, ctx, rig.Origin, "/", "/plain", `rst-stats="pairs"`) sameAtDesktop(t, ctx, rig.Origin, "/rtl", "/plain-rtl", `rst-stats="pairs" in Arabic`) } // rowItem is one child of a row as the inline test reads it; Present // is false for a part the row does not have. type rowItem struct { Present bool Left, Right, Top, Bottom float64 } // rowReading is the first row on the page: its four parts, how many // lines the name's link takes, the row's content box (its inline start // and end, and its top) and column gap, the row's computed display and // the status's computed grid placement (row start and end, column start // and end), and the document's scroll and client widths. type rowReading struct { Lead, Name, Status, Action rowItem NameLines int Start, End, Top, Gap float64 RTL bool Display, Placement string DocScroll, DocClient float64 } const rowJS = `(() => { const row = document.querySelector("[rst-row]"), cs = getComputedStyle(row), r = row.getBoundingClientRect(), px = (v) => parseFloat(v) || 0; const item = (sel) => { const el = row.querySelector(":scope > " + sel); if (!el) return {Present: false}; const b = el.getBoundingClientRect(); return {Present: true, Left: b.left, Right: b.right, Top: b.top, Bottom: b.bottom}; }; const words = document.createRange(); words.selectNodeContents(row.querySelector("[rst-row-main] > a")); const rtl = cs.direction === "rtl", left = r.left + px(cs.borderLeftWidth) + px(cs.paddingLeft), right = r.right - px(cs.borderRightWidth) - px(cs.paddingRight); return JSON.stringify({Lead: item("[rst-row-lead]"), Name: item("[rst-row-main]"), Status: item("[rst-status]"), Action: item("[rst-row-action]"), NameLines: new Set([...words.getClientRects()].map((q) => Math.round(q.top))).size, Start: rtl ? right : left, End: rtl ? left : right, Top: r.top + px(cs.borderTopWidth) + px(cs.paddingTop), Gap: px(cs.columnGap), RTL: rtl, Display: cs.display, Placement: ((s) => [s.gridRowStart, s.gridRowEnd, s.gridColumnStart, s.gridColumnEnd].join(" / "))(getComputedStyle(row.querySelector(":scope > [rst-status]"))), DocScroll: document.documentElement.scrollWidth, DocClient: document.documentElement.clientWidth}); })()` func readRow(t *testing.T, ctx context.Context, url string, width int64) rowReading { t.Helper() mustRun(t, ctx, chromedp.EmulateViewport(width, 844), chromedp.Navigate(url), chromedp.WaitReady("[rst-row]", chromedp.ByQuery)) settleUntil(t, ctx, `document.readyState === "complete"`) var r rowReading at(t, ctx, rowJS, &r) if !r.Name.Present || !r.Status.Present { t.Fatalf("%s: the row has no name or no status to read", url) } return r } // span is a part's start and end measured along the row from its // inline start: from the left in English, from the right in Arabic, so // one ordering holds both directions. func (r rowReading) span(it rowItem) (start, end float64) { if r.RTL { return r.Start - it.Right, r.Start - it.Left } return it.Left - r.Start, it.Right - r.Start } // width is the row's content width. func (r rowReading) width() float64 { return max(r.End-r.Start, r.Start-r.End) } // onFirstLine is whether a part starts at the top of the row's content. func (r rowReading) onFirstLine(it rowItem) bool { return math.Abs(it.Top-r.Top) <= 1 } // inlinePlacement is the status's computed grid placement the inline // value asks for: row 1, and the column the grid's auto-placement picks, // the implicit one straight after the name's. const inlinePlacement = "1 / auto / auto / auto" // placedByTheGrid fails unless the row is a grid and the status is // placed in it on row 1 with an auto column. The geometry alone cannot // tell this from a wrapping flex line: in these rows the name shrinks // first, so a flex line holds every part on the first line too, and // only drops the action or the status, label by label, once they // outgrow the row. The grid placement is the design, so it is held. func placedByTheGrid(t *testing.T, leg string, r rowReading) { t.Helper() if r.Display != "grid" { t.Errorf("%s: the row's display is %s; a row with an inline status is a grid, not a wrapping flex line", leg, r.Display) } if r.Placement != inlinePlacement { t.Errorf("%s: the status's grid placement (row start / end / column start / end) is %s, want %s", leg, r.Placement, inlinePlacement) } } // TestAnInlineStatusStaysOnTheNamesLine: at 390 and 320, in both // directions, the list-row-action state with its status marked inline // keeps all four parts on the row's first line, in source order from // the inline start (lead, name, status, action), and the name's column // takes the room the other three leave. A name long enough to wrap // wraps inside its column and the status and action stay up; with no // lead and no action the status ends at the row's content end. Without // the value each of those rows puts the status under the name, as 1.0 // does, which is the control that the legs above see the value's work. // Every marked row is a grid with the status on row 1 in an auto // column, the placement the design asks for rather than a flex line // that happens to fit. At 1280 each row, in both directions, paints as // the same row unmarked, pixel for pixel. func TestAnInlineStatusStaysOnTheNamesLine(t *testing.T) { doc := stateDoc(t, "list-row-action", "A short status beside the name") long := swapped(t, doc, ">Release notes, August", ">Release notes for August, with every change since the spring") bare := swapped(t, doc, "\n RN", "") bare = swapped(t, bare, "\n Edit", "") pages := map[string]string{} for name, d := range map[string]string{"/": doc, "/long": long, "/bare": bare} { for _, dir := range []string{"", "-rtl"} { if dir != "" { d = inArabic(t, d) } pages[name+dir] = d pages[name+dir+"-plain"] = swapped(t, d, ` 0 { if _, before := r.span(parts[i-1].it); start < before-0.5 { t.Errorf("%s: %s starts %.1fpx along the row, before %s ends at %.1fpx; the line must read lead, name, status, action", leg("/"), p.name, start, parts[i-1].name, before) } } if p.name != "the name" { room -= end - start + r.Gap } } if start, end := r.span(r.Name); end-start < room-0.5 { t.Errorf("%s: the name is %.1fpx wide with %.1fpx left by the others; its column must take the room", leg("/"), end-start, room) } l := readRow(t, ctx, rig.Origin+"/long"+dir, width) placedByTheGrid(t, leg("/long"), l) if l.NameLines < 2 { t.Errorf("%s: the long name takes %d line; it must wrap inside its column", leg("/long"), l.NameLines) } _, nameEnd := l.span(l.Name) if statusStart, _ := l.span(l.Status); statusStart < nameEnd-0.5 { t.Errorf("%s: the status starts %.1fpx along, inside the long name's column, which ends at %.1fpx", leg("/long"), statusStart, nameEnd) } if !l.onFirstLine(l.Status) || !l.onFirstLine(l.Action) { t.Errorf("%s: under a long name the status starts at %.1f and the action at %.1f; both must stay on the first line at %.1f", leg("/long"), l.Status.Top, l.Action.Top, l.Top) } if l.DocScroll > l.DocClient { t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; it scrolls sideways", leg("/long"), l.DocScroll, l.DocClient) } b := readRow(t, ctx, rig.Origin+"/bare"+dir, width) if b.Lead.Present || b.Action.Present { t.Fatalf("%s: the scratch row still has a lead or an action", leg("/bare")) } placedByTheGrid(t, leg("/bare"), b) if _, end := b.span(b.Status); math.Abs(end-b.width()) > 0.5 { t.Errorf("%s: with no lead and no action the status ends %.1fpx along a row whose content ends at %.1fpx", leg("/bare"), end, b.width()) } if !b.onFirstLine(b.Status) { t.Errorf("%s: the status starts at %.1f, off the first line at %.1f", leg("/bare"), b.Status.Top, b.Top) } for _, name := range []string{"/", "/long", "/bare"} { p := readRow(t, ctx, rig.Origin+name+dir+"-plain", width) if p.Status.Top < p.Name.Bottom-0.5 { t.Errorf("%s unmarked: the status starts at %.1f, above the name's foot at %.1f; 1.0 puts it under the name", leg(name), p.Status.Top, p.Name.Bottom) } } } } for _, name := range []string{"/", "/long", "/bare", "/-rtl", "/long-rtl", "/bare-rtl"} { sameAtDesktop(t, ctx, rig.Origin, name, name+"-plain", `rst-status="inline" (`+name+`)`) } }