| 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. |
| 9 | package check |
| 10 | |
| 11 | import ( |
| 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. |
| 30 | func 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. |
| 52 | func 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. |
| 63 | func 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. |
| 74 | func 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. |
| 85 | type statCell struct { |
| 86 | Left, Right, Top, Bottom float64 |
| 87 | Start, BlockStart string |
| 88 | Num struct{ Left, Right, Top, Bottom float64 } |
| 89 | } |
| 90 | |
| 91 | type bandReading struct { |
| 92 | Cells []statCell |
| 93 | RTL bool |
| 94 | DocScroll, DocClient float64 |
| 95 | } |
| 96 | |
| 97 | const 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 | |
| 106 | func 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. |
| 126 | func 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. |
| 183 | type 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. |
| 193 | type 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 | |
| 202 | const 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 | |
| 215 | func 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. |
| 230 | func (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. |
| 238 | func (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. |
| 241 | func (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. |
| 246 | const 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. |
| 254 | func 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. |
| 277 | func 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 | |