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// Two layouts a page asks for on a phone, by an attribute value 1.0
4// never had: a stat band two to a line, and a short status kept on its
5// row's first line. Each is held where it applies, below 34rem in both
6// directions, and where it must not: at 1280 the marked markup paints
7// pixel for pixel what the unmarked markup paints, so the value changes
8// a phone and nothing else.
9package check
10
11import (
12 "context"
13 "fmt"
14 "math"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/chromedp/chromedp"
20
21 "amadan.net/rastrillo/fiesta"
22 "amadan.net/rastrillo/fiesta/example"
23 "amadan.net/rastrillo/fiesta/internal/harness"
24)
25
26// stateDoc is one state of an example as a document, its assets
27// flattened beside the page as the sizing mux serves them, found by
28// its label so a renamed state fails here by name rather than reading
29// some other state.
30func stateDoc(t *testing.T, name, label string) string {
31 t.Helper()
32 entry, ok := manifest(t).Entry(name)
33 if !ok {
34 t.Fatalf("examples.json has no %s entry", name)
35 }
36 for _, s := range entry.States {
37 if s.Label == label {
38 doc, err := example.Document(fiesta.FS(), name, s.ID)
39 if err != nil {
40 t.Fatal(err)
41 }
42 return parityLinks(doc)
43 }
44 }
45 t.Fatalf("examples.json has no %s state labelled %q", name, label)
46 return ""
47}
48
49// swapped is doc with was replaced by is exactly once, so a fixture
50// edit that no longer finds its text fails rather than testing the
51// unedited document twice.
52func swapped(t *testing.T, doc, was, is string) string {
53 t.Helper()
54 if strings.Count(doc, was) != 1 {
55 t.Fatalf("the document holds %q %d times; this edit needs it once", was, strings.Count(doc, was))
56 }
57 return strings.Replace(doc, was, is, 1)
58}
59
60// phoneVariantRig serves pages in a browser that rasters on one
61// thread, so the 1280 comparisons can hold zero tolerance: a pixel
62// painted twice the same way is the same pixel.
63func phoneVariantRig(t *testing.T, pages map[string]string) (*harness.Rig, context.Context) {
64 t.Helper()
65 rig := sizingRig(t, false, pages, harness.WithDeterministicRaster())
66 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
67 t.Cleanup(cancel)
68 return rig, ctx
69}
70
71// sameAtDesktop photographs the two paths at 1280 and fails unless they
72// are the same to the pixel: what proves a phone layout leaves a
73// desktop alone.
74func sameAtDesktop(t *testing.T, ctx context.Context, origin, marked, plain, what string) {
75 t.Helper()
76 a := pageShot(t, ctx, origin+marked, 1280, "light")
77 b := pageShot(t, ctx, origin+plain, 1280, "light")
78 if same, why := samePixelsWithin(t, a, b, 0); !same {
79 t.Errorf("at 1280 %s paints differently from the same markup unmarked (%s): the value must change a phone only", what, why)
80 }
81}
82
83// statCell is one cell of a band as the pairs test reads it: its box,
84// its two leading hairlines as computed, and its figure's box.
85type statCell struct {
86 Left, Right, Top, Bottom float64
87 Start, BlockStart string
88 Num struct{ Left, Right, Top, Bottom float64 }
89}
90
91type bandReading struct {
92 Cells []statCell
93 RTL bool
94 DocScroll, DocClient float64
95}
96
97const bandJS = `(() => {
98 const band = document.querySelector("[rst-stats]");
99 const box = (el) => { const r = el.getBoundingClientRect(); return {Left: r.left, Right: r.right, Top: r.top, Bottom: r.bottom}; };
100 return JSON.stringify({RTL: getComputedStyle(band).direction === "rtl",
101 Cells: [...band.children].map((c) => { const cs = getComputedStyle(c);
102 return {...box(c), Start: cs.borderInlineStartWidth, BlockStart: cs.borderBlockStartWidth, Num: box(c.querySelector("[rst-stat-num]"))}; }),
103 DocScroll: document.documentElement.scrollWidth, DocClient: document.documentElement.clientWidth});
104})()`
105
106func readBand(t *testing.T, ctx context.Context, url string, width int64) bandReading {
107 t.Helper()
108 mustRun(t, ctx, chromedp.EmulateViewport(width, 844), chromedp.Navigate(url), chromedp.WaitReady("[rst-stats]", chromedp.ByQuery))
109 settleUntil(t, ctx, `document.readyState === "complete"`)
110 var b bandReading
111 at(t, ctx, bandJS, &b)
112 if len(b.Cells) != 4 {
113 t.Fatalf("%s: the band holds %d stats; the state has four", url, len(b.Cells))
114 }
115 return b
116}
117
118// TestPairsPutTwoStatsOnALineOnAPhone: at 390 and 320 a band marked
119// pairs is two columns: stats 1 and 2 share a top, 3 and 4 share one
120// below them, the first of each pair has no hairline at its inline
121// start (the band's edge is there) and the second has one, and the
122// second pair has a hairline above it. At 320 each figure stays inside
123// its cell and the page does not scroll sideways. In Arabic the pairs
124// mirror: the first stat of a pair is on the right. At 1280 the band
125// is the band without the value, pixel for pixel.
126func TestPairsPutTwoStatsOnALineOnAPhone(t *testing.T) {
127 doc := stateDoc(t, "stat-band", "Two to a line on a phone")
128 plain := swapped(t, doc, `<div rst-stats="pairs">`, `<div rst-stats>`)
129 rig, ctx := phoneVariantRig(t, map[string]string{
130 "/": doc, "/rtl": inArabic(t, doc), "/plain": plain, "/plain-rtl": inArabic(t, plain),
131 })
132 for _, path := range []string{"/", "/rtl"} {
133 for _, width := range []int64{390, 320} {
134 leg := fmt.Sprintf("%s at %d", path, width)
135 b := readBand(t, ctx, rig.Origin+path, width)
136 if b.RTL != (path == "/rtl") {
137 t.Fatalf("%s: the band reads right to left %v; this leg needs %v", leg, b.RTL, path == "/rtl")
138 }
139 c := b.Cells
140 if !sameEdge(c[0].Top, c[1].Top) || !sameEdge(c[2].Top, c[3].Top) {
141 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)
142 }
143 if c[2].Top < c[0].Bottom-0.5 {
144 t.Errorf("%s: the second pair starts at %.1f, above the first pair's foot at %.1f", leg, c[2].Top, c[0].Bottom)
145 }
146 // The first of a pair is at the inline start: the left in
147 // English, the right in Arabic.
148 for _, p := range [][2]int{{0, 1}, {2, 3}} {
149 first, second := c[p[0]], c[p[1]]
150 if (!b.RTL && first.Left >= second.Left) || (b.RTL && first.Right <= second.Right) {
151 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)
152 }
153 }
154 for i, want := range []string{"0px", "1px", "0px", "1px"} {
155 if c[i].Start != want {
156 t.Errorf("%s: stat %d's inline-start hairline is %s, want %s", leg, i+1, c[i].Start, want)
157 }
158 }
159 for i, want := range []string{"0px", "0px", "1px", "1px"} {
160 if c[i].BlockStart != want {
161 t.Errorf("%s: stat %d's top hairline is %s, want %s", leg, i+1, c[i].BlockStart, want)
162 }
163 }
164 if width != 320 {
165 continue
166 }
167 for i, cell := range c {
168 if n := cell.Num; n.Left < cell.Left-0.5 || n.Right > cell.Right+0.5 {
169 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)
170 }
171 }
172 if b.DocScroll > b.DocClient {
173 t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; it scrolls sideways", leg, b.DocScroll, b.DocClient)
174 }
175 }
176 }
177 sameAtDesktop(t, ctx, rig.Origin, "/", "/plain", `rst-stats="pairs"`)
178 sameAtDesktop(t, ctx, rig.Origin, "/rtl", "/plain-rtl", `rst-stats="pairs" in Arabic`)
179}
180
181// rowItem is one child of a row as the inline test reads it; Present
182// is false for a part the row does not have.
183type rowItem struct {
184 Present bool
185 Left, Right, Top, Bottom float64
186}
187
188// rowReading is the first row on the page: its four parts, how many
189// lines the name's link takes, the row's content box (its inline start
190// and end, and its top) and column gap, the row's computed display and
191// the status's computed grid placement (row start and end, column start
192// and end), and the document's scroll and client widths.
193type rowReading struct {
194 Lead, Name, Status, Action rowItem
195 NameLines int
196 Start, End, Top, Gap float64
197 RTL bool
198 Display, Placement string
199 DocScroll, DocClient float64
200}
201
202const rowJS = `(() => {
203 const row = document.querySelector("[rst-row]"), cs = getComputedStyle(row), r = row.getBoundingClientRect(), px = (v) => parseFloat(v) || 0;
204 const item = (sel) => { const el = row.querySelector(":scope > " + sel); if (!el) return {Present: false};
205 const b = el.getBoundingClientRect(); return {Present: true, Left: b.left, Right: b.right, Top: b.top, Bottom: b.bottom}; };
206 const words = document.createRange(); words.selectNodeContents(row.querySelector("[rst-row-main] > a"));
207 const rtl = cs.direction === "rtl", left = r.left + px(cs.borderLeftWidth) + px(cs.paddingLeft), right = r.right - px(cs.borderRightWidth) - px(cs.paddingRight);
208 return JSON.stringify({Lead: item("[rst-row-lead]"), Name: item("[rst-row-main]"), Status: item("[rst-status]"), Action: item("[rst-row-action]"),
209 NameLines: new Set([...words.getClientRects()].map((q) => Math.round(q.top))).size,
210 Start: rtl ? right : left, End: rtl ? left : right, Top: r.top + px(cs.borderTopWidth) + px(cs.paddingTop), Gap: px(cs.columnGap), RTL: rtl,
211 Display: cs.display, Placement: ((s) => [s.gridRowStart, s.gridRowEnd, s.gridColumnStart, s.gridColumnEnd].join(" / "))(getComputedStyle(row.querySelector(":scope > [rst-status]"))),
212 DocScroll: document.documentElement.scrollWidth, DocClient: document.documentElement.clientWidth});
213})()`
214
215func readRow(t *testing.T, ctx context.Context, url string, width int64) rowReading {
216 t.Helper()
217 mustRun(t, ctx, chromedp.EmulateViewport(width, 844), chromedp.Navigate(url), chromedp.WaitReady("[rst-row]", chromedp.ByQuery))
218 settleUntil(t, ctx, `document.readyState === "complete"`)
219 var r rowReading
220 at(t, ctx, rowJS, &r)
221 if !r.Name.Present || !r.Status.Present {
222 t.Fatalf("%s: the row has no name or no status to read", url)
223 }
224 return r
225}
226
227// span is a part's start and end measured along the row from its
228// inline start: from the left in English, from the right in Arabic, so
229// one ordering holds both directions.
230func (r rowReading) span(it rowItem) (start, end float64) {
231 if r.RTL {
232 return r.Start - it.Right, r.Start - it.Left
233 }
234 return it.Left - r.Start, it.Right - r.Start
235}
236
237// width is the row's content width.
238func (r rowReading) width() float64 { return max(r.End-r.Start, r.Start-r.End) }
239
240// onFirstLine is whether a part starts at the top of the row's content.
241func (r rowReading) onFirstLine(it rowItem) bool { return math.Abs(it.Top-r.Top) <= 1 }
242
243// inlinePlacement is the status's computed grid placement the inline
244// value asks for: row 1, and the column the grid's auto-placement picks,
245// the implicit one straight after the name's.
246const inlinePlacement = "1 / auto / auto / auto"
247
248// placedByTheGrid fails unless the row is a grid and the status is
249// placed in it on row 1 with an auto column. The geometry alone cannot
250// tell this from a wrapping flex line: in these rows the name shrinks
251// first, so a flex line holds every part on the first line too, and
252// only drops the action or the status, label by label, once they
253// outgrow the row. The grid placement is the design, so it is held.
254func placedByTheGrid(t *testing.T, leg string, r rowReading) {
255 t.Helper()
256 if r.Display != "grid" {
257 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)
258 }
259 if r.Placement != inlinePlacement {
260 t.Errorf("%s: the status's grid placement (row start / end / column start / end) is %s, want %s", leg, r.Placement, inlinePlacement)
261 }
262}
263
264// TestAnInlineStatusStaysOnTheNamesLine: at 390 and 320, in both
265// directions, the list-row-action state with its status marked inline
266// keeps all four parts on the row's first line, in source order from
267// the inline start (lead, name, status, action), and the name's column
268// takes the room the other three leave. A name long enough to wrap
269// wraps inside its column and the status and action stay up; with no
270// lead and no action the status ends at the row's content end. Without
271// the value each of those rows puts the status under the name, as 1.0
272// does, which is the control that the legs above see the value's work.
273// Every marked row is a grid with the status on row 1 in an auto
274// column, the placement the design asks for rather than a flex line
275// that happens to fit. At 1280 each row, in both directions, paints as
276// the same row unmarked, pixel for pixel.
277func TestAnInlineStatusStaysOnTheNamesLine(t *testing.T) {
278 doc := stateDoc(t, "list-row-action", "A short status beside the name")
279 long := swapped(t, doc, ">Release notes, August</a>", ">Release notes for August, with every change since the spring</a>")
280 bare := swapped(t, doc, "\n <span rst-row-lead data-lead=\"accent\" aria-hidden=\"true\">RN</span>", "")
281 bare = swapped(t, bare, "\n <a rst-row-action href=\"/posts/1/edit\" aria-label=\"Edit Release notes, August\">Edit</a>", "")
282 pages := map[string]string{}
283 for name, d := range map[string]string{"/": doc, "/long": long, "/bare": bare} {
284 for _, dir := range []string{"", "-rtl"} {
285 if dir != "" {
286 d = inArabic(t, d)
287 }
288 pages[name+dir] = d
289 pages[name+dir+"-plain"] = swapped(t, d, `<span rst-status="inline" `, `<span rst-status `)
290 }
291 }
292 rig, ctx := phoneVariantRig(t, pages)
293 for _, dir := range []string{"", "-rtl"} {
294 for _, width := range []int64{390, 320} {
295 leg := func(name string) string { return fmt.Sprintf("%s%s at %d", name, dir, width) }
296 r := readRow(t, ctx, rig.Origin+"/"+dir, width)
297 if r.RTL != (dir != "") {
298 t.Fatalf("%s: the row reads right to left %v; this leg needs %v", leg("/"), r.RTL, dir != "")
299 }
300 parts := []struct {
301 name string
302 it rowItem
303 }{{"the lead", r.Lead}, {"the name", r.Name}, {"the status", r.Status}, {"the action", r.Action}}
304 placedByTheGrid(t, leg("/"), r)
305 room := r.width()
306 for i, p := range parts {
307 if !p.it.Present {
308 t.Fatalf("%s: the state's row has no %s", leg("/"), p.name)
309 }
310 if !r.onFirstLine(p.it) {
311 t.Errorf("%s: %s starts at %.1f, off the row's first line at %.1f", leg("/"), p.name, p.it.Top, r.Top)
312 }
313 start, end := r.span(p.it)
314 if i > 0 {
315 if _, before := r.span(parts[i-1].it); start < before-0.5 {
316 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)
317 }
318 }
319 if p.name != "the name" {
320 room -= end - start + r.Gap
321 }
322 }
323 if start, end := r.span(r.Name); end-start < room-0.5 {
324 t.Errorf("%s: the name is %.1fpx wide with %.1fpx left by the others; its column must take the room", leg("/"), end-start, room)
325 }
326
327 l := readRow(t, ctx, rig.Origin+"/long"+dir, width)
328 placedByTheGrid(t, leg("/long"), l)
329 if l.NameLines < 2 {
330 t.Errorf("%s: the long name takes %d line; it must wrap inside its column", leg("/long"), l.NameLines)
331 }
332 _, nameEnd := l.span(l.Name)
333 if statusStart, _ := l.span(l.Status); statusStart < nameEnd-0.5 {
334 t.Errorf("%s: the status starts %.1fpx along, inside the long name's column, which ends at %.1fpx", leg("/long"), statusStart, nameEnd)
335 }
336 if !l.onFirstLine(l.Status) || !l.onFirstLine(l.Action) {
337 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)
338 }
339 if l.DocScroll > l.DocClient {
340 t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; it scrolls sideways", leg("/long"), l.DocScroll, l.DocClient)
341 }
342
343 b := readRow(t, ctx, rig.Origin+"/bare"+dir, width)
344 if b.Lead.Present || b.Action.Present {
345 t.Fatalf("%s: the scratch row still has a lead or an action", leg("/bare"))
346 }
347 placedByTheGrid(t, leg("/bare"), b)
348 if _, end := b.span(b.Status); math.Abs(end-b.width()) > 0.5 {
349 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())
350 }
351 if !b.onFirstLine(b.Status) {
352 t.Errorf("%s: the status starts at %.1f, off the first line at %.1f", leg("/bare"), b.Status.Top, b.Top)
353 }
354
355 for _, name := range []string{"/", "/long", "/bare"} {
356 p := readRow(t, ctx, rig.Origin+name+dir+"-plain", width)
357 if p.Status.Top < p.Name.Bottom-0.5 {
358 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)
359 }
360 }
361 }
362 }
363 for _, name := range []string{"/", "/long", "/bare", "/-rtl", "/long-rtl", "/bare-rtl"} {
364 sameAtDesktop(t, ctx, rig.Origin, name, name+"-plain", `rst-status="inline" (`+name+`)`)
365 }
366}
367