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// The whole-row target and the rows around it, from rastrillo's
4// ui/rows_browser_test.go at the seed commit, each page read
5// through fixture so the rows driven are the rows the rastrillo
6// drive drove. The probes (at, probe, point) live in
7// rig_browser_test.go.
8package check
9
10import (
11 "context"
12 "fmt"
13 "math"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 cdppage "github.com/chromedp/cdproto/page"
20 "github.com/chromedp/chromedp"
21 "github.com/chromedp/chromedp/kb"
22
23 "amadan.net/rastrillo/fiesta/internal/harness"
24)
25
26// rowsFixture is every row idiom of §2.2 of the mobile ergonomics
27// design spec
28// (docs/superpowers/specs/2026-09-30-mobile-ergonomics-design.md) that
29// has a primary link (the list grid's status-pill row among them,
30// §10.2), a row with none in each of the two idioms, and a row holding
31// the three controls that position themselves (a switch, a date field,
32// an enhanced select) with the same three outside any row to compare
33// against. The three sit in one stacked cell of the same width in and
34// out of the row: below 800px the list grid becomes three columns
35// whatever --rst-cols says (tokens.css's narrow list-grid rule), and a
36// fourth child would land a date field in the 44px kebab column under
37// its own picker. The lifting rule reaches controls at any depth in a
38// row, so a wrapper cell is the honest test of it. Row A's menu is a
39// hand-written five-item row menu, so its open panel reaches down over
40// the row below it.
41func rowsFixture(t *testing.T) string {
42 t.Helper()
43 return fixture(t, "rows_browser_test.go", "rowsFixture", "")
44}
45
46// rowsPages serves the fixture at / and a landing page for every /go/
47// href, so a click that navigates is a URL change the drive can read.
48func rowsPages(t *testing.T) map[string]string {
49 return map[string]string{
50 "/": sizingDoc("rows", rowsFixture(t)),
51 "/go/": sizingDoc("landed", `<p id="landed">landed</p>`),
52 }
53}
54
55// clickAndLand clicks the page at p and waits for the landing page at
56// want: the URL is want AND the landing page's own marker, #landed, is
57// in the document. No page under test carries that marker, so a reading
58// taken in the departing document can never pass for the destination,
59// which reading location.pathname alone could.
60func clickAndLand(t *testing.T, ctx context.Context, p point, want string) {
61 t.Helper()
62 if err := chromedp.Run(ctx, chromedp.MouseClickXY(p.X, p.Y)); err != nil {
63 t.Fatal(err)
64 }
65 var last string
66 deadline := time.Now().Add(10 * time.Second)
67 for time.Now().Before(deadline) {
68 if err := chromedp.Run(ctx, chromedp.Evaluate(`location.pathname + (document.getElementById("landed") ? " (landed)" : "")`, &last)); err == nil && last == want+" (landed)" {
69 return
70 }
71 time.Sleep(100 * time.Millisecond)
72 }
73 t.Errorf("a click at (%.0f, %.0f), over %s, did not land on %s; the page is at %q", p.X, p.Y, p.Hit, want, last)
74}
75
76// clickAndStay clicks the page at p, waits for done (a page expression
77// naming what the click should have done: a menu open, a box checked)
78// and fails if the click asked for any navigation. Navigation requests
79// are read off CDP's Page.frameRequestedNavigation, which the browser
80// sends for every navigation the page asks for, scripts on or off, and
81// in order before the reply to the evaluation that finds done true. So
82// "it did not navigate" is an observed absence, not a timed wait. arm is
83// an optional expression run before the click (to give a click with no
84// visible effect something to report).
85func clickAndStay(t *testing.T, ctx context.Context, p point, arm, done string) {
86 t.Helper()
87 var mu sync.Mutex
88 var asked []string
89 active := true
90 chromedp.ListenTarget(ctx, func(ev any) {
91 if e, ok := ev.(*cdppage.EventFrameRequestedNavigation); ok {
92 mu.Lock()
93 if active {
94 asked = append(asked, e.URL)
95 }
96 mu.Unlock()
97 }
98 })
99 defer func() { mu.Lock(); active = false; mu.Unlock() }()
100 if arm != "" {
101 if err := chromedp.Run(ctx, chromedp.Evaluate(arm, nil)); err != nil {
102 t.Fatal(err)
103 }
104 }
105 if err := chromedp.Run(ctx, chromedp.MouseClickXY(p.X, p.Y)); err != nil {
106 t.Fatal(err)
107 }
108 settleUntil(t, ctx, done)
109 mu.Lock()
110 defer mu.Unlock()
111 if len(asked) > 0 {
112 t.Errorf("a click at (%.0f, %.0f), over %s, asked to navigate to %v; it must not navigate", p.X, p.Y, p.Hit, asked)
113 }
114}
115
116func home(t *testing.T, ctx context.Context, origin string) {
117 t.Helper()
118 if err := chromedp.Run(ctx, chromedp.Navigate(origin+"/"), chromedp.WaitVisible("#grid", chromedp.ByQuery)); err != nil {
119 t.Fatal(err)
120 }
121 settleUntil(t, ctx, `!!document.querySelector("#combo-in [role=combobox]") && !!document.querySelector("#date-in [rst-dtp-pick]")`)
122}
123
124// TestTheWholeRowIsTheTarget is §10.2 at 1280 with a mouse and 390 with
125// a coarse pointer. For every row idiom with a primary link, a click at
126// the row's far empty edge and inside an empty cell goes to the primary
127// href; the status pill is part of the target now; the row's own
128// controls each do their own thing and never navigate.
129func TestTheWholeRowIsTheTarget(t *testing.T) {
130 for _, leg := range []struct {
131 name string
132 w, h int64
133 coarse bool
134 }{{"1280 mouse", 1280, 900, false}, {"390 touch", 390, 844, true}} {
135 t.Run(leg.name, func(t *testing.T) {
136 rig := sizingRig(t, leg.coarse, rowsPages(t))
137 ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
138 defer cancel()
139 if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h)); err != nil {
140 t.Fatal(err)
141 }
142 home(t, ctx, rig.Origin)
143 requirePointer(t, ctx, leg.coarse)
144
145 for _, c := range []struct {
146 where, sel string
147 fx, fy, dx float64
148 link, target string
149 wideOnly bool
150 }{
151 {"the list grid row's far edge", "#row-a", 1, 0.5, -12, "link-a", "/go/row-a", false},
152 // The cell is rst-m-hide: below 800px the list grid is three
153 // columns and the column that is hidden has no box to probe.
154 {"an empty cell of the list grid row", "#cell-a", 0.5, 0.5, 0, "link-a", "/go/row-a", true},
155 {"the person row's far edge", "#row-p", 1, 0.5, -12, "link-p", "/go/person", false},
156 {"list-row-action's empty main area", "#lra [rst-row-main]", 1, 0.5, -8, "A", "/go/lra", false},
157 {"list-row-action's status pill", "#lra [rst-status]", 0.5, 0.5, 0, "A", "/go/lra", false},
158 // List-grid pills were never lifted; this pins that they
159 // stay under the overlay now that there is one.
160 {"the list grid row's status pill", "#row-s [rst-status]", 0.5, 0.5, 0, "link-s", "/go/row-s", false},
161 } {
162 if c.wideOnly && leg.coarse {
163 continue
164 }
165 home(t, ctx, rig.Origin)
166 p := probe(t, ctx, c.sel, c.fx, c.fy, c.dx, 0)
167 if p.Hit != c.link {
168 t.Errorf("%s: at %s the element under the pointer is %s, want the primary link %s", leg.name, c.where, p.Hit, c.link)
169 }
170 clickAndLand(t, ctx, p, c.target)
171 }
172
173 // The row's own controls.
174 home(t, ctx, rig.Origin)
175 clickAndLand(t, ctx, probe(t, ctx, "#lra [rst-row-action]", 0.5, 0.5, 0, 0), "/go/lra-edit")
176 home(t, ctx, rig.Origin)
177 clickAndStay(t, ctx, probe(t, ctx, "#kebab-a", 0.5, 0.5, 0, 0), "", `document.getElementById("menu-a").open`)
178 for _, c := range []struct {
179 where string
180 fx, fy, dx float64
181 }{{"the checkbox itself", 0.5, 0.5, 0}, {"its label's padding", 0, 0, 2}} {
182 home(t, ctx, rig.Origin)
183 p := probe(t, ctx, "#label-a", c.fx, c.fy, c.dx, c.dx)
184 clickAndStay(t, ctx, p, "", `document.getElementById("check-a").checked`)
185 }
186
187 // Sizes: 44×44 for everything in the rows on a phone; on the
188 // desktop the checkbox's label is the 32×32 desktop floor.
189 home(t, ctx, rig.Origin)
190 if leg.coarse {
191 assertTargets(t, leg.name+", rows", readTargets(t, ctx, `(() => { `+measureFn+`; return JSON.stringify([...measure(document.getElementById("lra")), ...measure(document.getElementById("statuses")), ...measure(document.getElementById("grid")), ...measure(document.getElementById("people"))]); })()`))
192 } else {
193 var box [2]float64
194 at(t, ctx, `(() => { const r = document.getElementById("label-a").getBoundingClientRect(); return JSON.stringify([r.width, r.height]); })()`, &box)
195 if box != [2]float64{32, 32} {
196 t.Errorf("%s: the row checkbox's label is %v, want 32×32", leg.name, box)
197 }
198 }
199 })
200 }
201}
202
203// TestAnOpenRowMenuStaysAboveTheRowsBelowIt: only the summary is lifted
204// (§2.3), so the open panel — z-index 40, or position: fixed under
205// anchor positioning — paints above the next row's lifted controls, and
206// a click on an item lying over row B's kebab hits the item.
207func TestAnOpenRowMenuStaysAboveTheRowsBelowIt(t *testing.T) {
208 for _, coarse := range []bool{false, true} {
209 t.Run(fmt.Sprintf("coarse %v", coarse), func(t *testing.T) {
210 rig := sizingRig(t, coarse, rowsPages(t))
211 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
212 defer cancel()
213 w := int64(1280)
214 if coarse {
215 w = 390
216 }
217 if err := chromedp.Run(ctx, chromedp.EmulateViewport(w, 844)); err != nil {
218 t.Fatal(err)
219 }
220 home(t, ctx, rig.Origin)
221 if err := chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById("kebab-a").click(), true`, nil)); err != nil {
222 t.Fatal(err)
223 }
224 var got struct {
225 Overlapping int
226 Wrong []string
227 }
228 at(t, ctx, `(() => {
229 const k = document.getElementById("kebab-b").getBoundingClientRect(), wrong = []; let n = 0;
230 for (const item of document.querySelectorAll("#panel-a a")) {
231 const r = item.getBoundingClientRect();
232 const x0 = Math.max(r.left, k.left), x1 = Math.min(r.right, k.right), y0 = Math.max(r.top, k.top), y1 = Math.min(r.bottom, k.bottom);
233 if (x1 - x0 < 2 || y1 - y0 < 2) continue;
234 n++;
235 const h = document.elementFromPoint((x0 + x1) / 2, (y0 + y1) / 2);
236 if (!(h === item || item.contains(h))) wrong.push(item.textContent + " -> " + (h ? (h.id || h.tagName) : "nothing"));
237 }
238 return JSON.stringify({Overlapping: n, Wrong: wrong});
239 })()`, &got)
240 if got.Overlapping == 0 {
241 t.Fatalf("coarse=%v: no item of row A's open menu lies over row B's kebab; the case this leg is for has not arisen", coarse)
242 }
243 if len(got.Wrong) > 0 {
244 t.Errorf("coarse=%v: over row B's kebab, a click on row A's menu lands elsewhere: %v", coarse, got.Wrong)
245 }
246 })
247 }
248}
249
250// TestControlsInARowKeepTheirOwnBoxes: the lifting rule is wholly inside
251// :where(), so it gives a static control a position and never replaces
252// one a component chose. A switch's input
253// stays over its track, the date field's pick button inside its field,
254// select.js's native select out of flow, each the same size in a row as
255// outside one. And each is OPERATED, at 1280 with a mouse and at 390
256// with a coarse pointer: the switch toggles, the calendar opens and a
257// day is picked into the native input, the combobox opens and an option
258// is picked into the native select, and none of it opens the row.
259func TestControlsInARowKeepTheirOwnBoxes(t *testing.T) {
260 for _, leg := range []struct {
261 name string
262 w, h int64
263 coarse bool
264 }{{"1280 mouse", 1280, 900, false}, {"390 touch", 390, 844, true}} {
265 t.Run(leg.name, func(t *testing.T) {
266 rig := sizingRig(t, leg.coarse, rowsPages(t))
267 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
268 defer cancel()
269 if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h)); err != nil {
270 t.Fatal(err)
271 }
272 home(t, ctx, rig.Origin)
273 requirePointer(t, ctx, leg.coarse)
274 var g map[string]string
275 at(t, ctx, `(() => {
276 const pos = el => getComputedStyle(el).position;
277 const size = el => { const r = el.getBoundingClientRect(); return Math.round(r.width) + "x" + Math.round(r.height); };
278 // fx is the fraction across the box to test: the date input is
279 // tested a quarter of the way in, clear of the picker button
280 // that sits over its inline end on purpose.
281 const own = (sel, fx) => { const el = document.querySelector(sel); el.scrollIntoView({block: "center"}); const r = el.getBoundingClientRect(), h = document.elementFromPoint(r.left + r.width * (fx || 0.5), r.top + r.height / 2); return h && (h === el || el.contains(h)) ? "ok" : "hit " + (h ? h.tagName : "nothing"); };
282 const pick = side => { const p = document.querySelector("#date-" + side + " [rst-dtp-pick]"), d = p.closest("[rst-dtp]"); return Math.round(d.getBoundingClientRect().right - p.getBoundingClientRect().right) + "px from the end"; };
283 return JSON.stringify({
284 switchIn: pos(document.querySelector("#switch-in input")) + " " + size(document.querySelector("#switch-in [rst-switch-track]")),
285 switchOut: pos(document.querySelector("#switch-out input")) + " " + size(document.querySelector("#switch-out [rst-switch-track]")),
286 pickIn: pos(document.querySelector("#date-in [rst-dtp-pick]")) + " " + size(document.querySelector("#date-in [rst-dtp-pick]")) + " " + pick("in"),
287 pickOut: pos(document.querySelector("#date-out [rst-dtp-pick]")) + " " + size(document.querySelector("#date-out [rst-dtp-pick]")) + " " + pick("out"),
288 selectIn: pos(document.querySelector("#combo_in")), selectOut: pos(document.querySelector("#combo_out")),
289 switchHit: own("#switch-in"), dateHit: own("#date-in [role=combobox]", 0.25), pickHit: own("#date-in [rst-dtp-pick]"), comboHit: own("#combo-in [role=combobox]")});
290 })()`, &g)
291 for _, pair := range [][2]string{{"switchIn", "switchOut"}, {"pickIn", "pickOut"}, {"selectIn", "selectOut"}} {
292 if g[pair[0]] != g[pair[1]] {
293 t.Errorf("in a row %s is %q; outside one it is %q", pair[0], g[pair[0]], g[pair[1]])
294 }
295 }
296 if !strings.HasPrefix(g["switchIn"], "absolute") || g["selectIn"] != "absolute" {
297 t.Errorf("the lifting rule replaced a component's own position: switch input %q, native select %q", g["switchIn"], g["selectIn"])
298 }
299 for _, k := range []string{"switchHit", "dateHit", "pickHit", "comboHit"} {
300 if g[k] != "ok" {
301 t.Errorf("in a row, %s: %s", k, g[k])
302 }
303 }
304
305 // Operated. Each click must do its job and ask for no
306 // navigation (the row's link is under all of them).
307 clickAndStay(t, ctx, probe(t, ctx, "#switch-in [rst-switch-track]", 0.5, 0.5, 0, 0), "", `document.querySelector("#switch-in input").checked`)
308 clickAndStay(t, ctx, probe(t, ctx, "#date-in [rst-dtp-pick]", 0.5, 0.5, 0, 0), "", `!!document.querySelector('[rst-cal] [data-rst-day="2026-08-12"]')`)
309 clickAndStay(t, ctx, probe(t, ctx, `[rst-cal] [data-rst-day="2026-08-12"]`, 0.5, 0.5, 0, 0), "", `document.querySelector('#date-in input[name="due_in"]').value === "2026-08-12"`)
310 clickAndStay(t, ctx, probe(t, ctx, "#combo-in [role=combobox]", 0.5, 0.5, 0, 0), "", `document.querySelectorAll("#combo-in [role=option]").length === 12 && document.querySelector("#combo-in [role=option]").checkVisibility()`)
311 if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const o = [...document.querySelectorAll("#combo-in [role=option]")].find(o => o.textContent.trim() === "Option 7"); o.scrollIntoView({block: "center"}); return true; })()`, nil)); err != nil {
312 t.Fatal(err)
313 }
314 at(t, ctx, `JSON.stringify((() => { const o = [...document.querySelectorAll("#combo-in [role=option]")].find(o => o.textContent.trim() === "Option 7"); o.setAttribute("data-pick", ""); return true; })())`, new(bool))
315 clickAndStay(t, ctx, probe(t, ctx, "#combo-in [data-pick]", 0.5, 0.5, 0, 0), "", `document.getElementById("combo_in").value === "7"`)
316 })
317 }
318}
319
320// TestARowWithNoLinkLooksAndActsInert: no overlay, no hover fill, in
321// both idioms. The linked row of the same idiom is each one's control:
322// its hover DOES fill. The rig declares a mouse that can hover, and
323// the page is frozen first (C14): the row hovers sit in @media
324// (hover: hover) and ease in over 0.25s, so a default rig, which
325// reports no hover, and a read in the first frame of the transition
326// both found the control at rest and failed it.
327func TestARowWithNoLinkLooksAndActsInert(t *testing.T) {
328 rig := sizingRig(t, false, rowsPages(t), harness.WithHover())
329 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
330 defer cancel()
331 if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900)); err != nil {
332 t.Fatal(err)
333 }
334 home(t, ctx, rig.Origin)
335 still(t, ctx)
336 bg := func(sel string) string {
337 var s string
338 if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`getComputedStyle(document.querySelector(%q)).backgroundColor`, sel), &s)); err != nil {
339 t.Fatal(err)
340 }
341 return s
342 }
343 hover := func(sel string) string {
344 p := probe(t, ctx, sel, 0.5, 0.5, 0, 0)
345 if err := chromedp.Run(ctx, chromedp.MouseEvent("mouseMoved", p.X, p.Y)); err != nil {
346 t.Fatal(err)
347 }
348 return bg(sel)
349 }
350 for _, c := range []struct{ idiom, inert, linked string }{
351 {"list grid", "#row-n", "#row-a"},
352 {"list-row-action", "#lra-n", "#lra [rst-row]"},
353 } {
354 if err := chromedp.Run(ctx, chromedp.MouseEvent("mouseMoved", 1, 1)); err != nil {
355 t.Fatal(err)
356 }
357 restN, restA := bg(c.inert), bg(c.linked)
358 if hovered := hover(c.linked); hovered == restA {
359 t.Fatalf("%s CONTROL: hovering a linked row does not change its background, so the inert row's unchanged background proves nothing", c.idiom)
360 }
361 if err := chromedp.Run(ctx, chromedp.MouseEvent("mouseMoved", 1, 1)); err != nil {
362 t.Fatal(err)
363 }
364 if hovered := hover(c.inert); hovered != restN {
365 t.Errorf("%s: hovering a row with no link fills it (%s -> %s): it looks clickable and is not", c.idiom, restN, hovered)
366 }
367 p := probe(t, ctx, c.inert, 0.5, 0.5, 0, 0)
368 // Any link at all, by tag, not by the id-less "A" probe reports:
369 // a link with an id would otherwise read as its id and slip by.
370 var link string
371 at(t, ctx, fmt.Sprintf(`(() => { const h = document.elementFromPoint(%v, %v), a = h && h.closest("a"); return JSON.stringify(a ? a.outerHTML.slice(0, 60) : ""); })()`, p.X, p.Y), &link)
372 if link != "" {
373 t.Errorf("%s: the centre of a row with no link is under a link: %s", c.idiom, link)
374 }
375 clickAndStay(t, ctx, p,
376 fmt.Sprintf(`window.rowClicked = false, document.querySelector(%q).addEventListener("click", () => { window.rowClicked = true; }), true`, c.inert), `window.rowClicked === true`)
377 }
378}
379
380// TestMeasureRejectsAHitOnAnotherPartOfTheRow pins the target drive's
381// ownership rule itself. A stretched link is measured over its whole
382// row, and the row holds other things; a hit at the row's centre on an
383// unrelated child (here a positioned span covering the row) must count
384// as occlusion, never as the link. The same row without the cover is
385// the control and must read as owned.
386func TestMeasureRejectsAHitOnAnotherPartOfTheRow(t *testing.T) {
387 row := func(cover string) string {
388 return `<div rst-card style="--rst-cols: minmax(0, 1fr) 110px"><div rst-lrow><a class="rst-nm" id="name" href="/go/x">Grace Hopper</a><span class="rst-cell-mut">Paid</span>` + cover + `</div></div>`
389 }
390 pages := map[string]string{
391 "/covered": sizingDoc("covered", `<div rst-page>`+row(`<span id="cover" style="position: absolute; inset: 0; z-index: 2"></span>`)+`</div>`),
392 "/clear": sizingDoc("clear", `<div rst-page>`+row("")+`</div>`),
393 }
394 rig := sizingRig(t, false, pages)
395 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
396 defer cancel()
397 read := func(path string) targetReading {
398 if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("#name", chromedp.ByQuery)); err != nil {
399 t.Fatal(err)
400 }
401 for _, g := range readTargets(t, ctx, `(() => { `+measureFn+`; return JSON.stringify(measure(document)); })()`) {
402 if strings.Contains(g.Name, "#name") {
403 return g
404 }
405 }
406 t.Fatalf("%s: the stretched link was not measured", path)
407 return targetReading{}
408 }
409 if g := read("/clear"); !g.Owns {
410 t.Fatalf("CONTROL: an unobstructed stretched link reads as not owning its row (hit %s); the instrument is broken", g.Hit)
411 }
412 if g := read("/covered"); g.Owns {
413 t.Errorf("a hit on an unrelated child of the row (%s) counted as the stretched link's own; the ownership rule is accepting any descendant of the measured box", g.Hit)
414 }
415}
416
417// TestFocusDrawsTheRingAroundTheWholeRow: the primary link's ring moves
418// to its overlay, 2px inside the row, so the focused row is outlined
419// whole; the link itself also underlines, which is what the keyboard
420// walk's element-and-ancestors reading sees.
421func TestFocusDrawsTheRingAroundTheWholeRow(t *testing.T) {
422 rig := sizingRig(t, false, rowsPages(t))
423 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
424 defer cancel()
425 if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900)); err != nil {
426 t.Fatal(err)
427 }
428 home(t, ctx, rig.Origin)
429 if err := chromedp.Run(ctx, chromedp.Focus("#check-a", chromedp.ByQuery), chromedp.KeyEvent(kb.Tab)); err != nil {
430 t.Fatal(err)
431 }
432 var g struct {
433 Active, Style, Width, Offset, Own, Deco, Corner1, Corner2 string
434 Visible bool
435 }
436 at(t, ctx, `(() => {
437 const a = document.activeElement, row = document.getElementById("row-a"), r = row.getBoundingClientRect(), after = getComputedStyle(a, "::after");
438 const hit = (x, y) => { const h = document.elementFromPoint(x, y); return h ? (h.id || h.tagName) : "nothing"; };
439 return JSON.stringify({Active: a.id, Visible: a.matches(":focus-visible"), Style: after.outlineStyle, Width: after.outlineWidth, Offset: after.outlineOffset,
440 Own: getComputedStyle(a).outlineStyle, Deco: getComputedStyle(a).textDecorationLine,
441 Corner1: hit(r.left + 3, r.top + 3), Corner2: hit(r.right - 3, r.bottom - 3)});
442 })()`, &g)
443 if g.Active != "link-a" || !g.Visible {
444 t.Fatalf("Tab from the checkbox focused %q (focus-visible %v), want link-a; the rest of this leg reads nothing", g.Active, g.Visible)
445 }
446 if g.Style != "solid" || g.Width != "2px" || g.Offset != "-2px" {
447 t.Errorf("the overlay's ring is %s %s offset %s, want a solid 2px ring inset 2px", g.Style, g.Width, g.Offset)
448 }
449 if g.Own != "none" || g.Deco != "underline" {
450 t.Errorf("the link itself has outline %s and decoration %s, want none and underline", g.Own, g.Deco)
451 }
452 if g.Corner1 != "link-a" || g.Corner2 != "link-a" {
453 t.Errorf("the overlay does not reach the row's corners (%s, %s): the ring is not around the whole row", g.Corner1, g.Corner2)
454 }
455}
456
457// narrowRowsPage is list-row-action with a status in each of the shapes
458// a row can take around it (with and without a lead, an action pill and
459// a kebab), and one status pill outside any row to compare sizes with.
460func narrowRowsPage(t *testing.T) map[string]string {
461 t.Helper()
462 return map[string]string{"/": sizingDoc("narrow rows", fixture(t, "rows_browser_test.go", "narrowRowsPage", ""))}
463}
464
465// TestANarrowRowGivesItsTitleTheWidth: on a phone a row held avatar,
466// title, status pill and action side by side, and the title was left a
467// narrow column ("Release notes, August" in three lines beside its
468// pill). Below 34rem the status moves onto its own line under the
469// title and meta, smaller than a pill elsewhere, and the title takes
470// every pixel the row's other first-line items leave. At 1280 the
471// status stays beside the title, the control that this is a narrow
472// rule and not a general one. Every leg runs in RTL too: edges are read
473// from the inline start, so a physical left or right in the rule would
474// put the status under the wrong edge of the title there.
475func TestANarrowRowGivesItsTitleTheWidth(t *testing.T) {
476 ltr := narrowRowsPage(t)["/"]
477 pages := map[string]string{"/": ltr, "/rtl": strings.Replace(ltr, `dir="ltr"`, `dir="rtl"`, 1)}
478 type leg struct {
479 name string
480 w int64
481 coarse, narrow bool
482 path string
483 }
484 var legs []leg
485 for _, path := range []string{"/", "/rtl"} {
486 for _, l := range []leg{{"1280 mouse", 1280, false, false, ""}, {"390 touch", 390, true, true, ""}, {"320 mouse", 320, false, true, ""}} {
487 l.path = path
488 if path == "/rtl" {
489 l.name = "RTL " + l.name
490 }
491 legs = append(legs, l)
492 }
493 }
494 for _, leg := range legs {
495 t.Run(leg.name, func(t *testing.T) {
496 rig := sizingRig(t, leg.coarse, pages)
497 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
498 defer cancel()
499 mustRun(t, ctx, chromedp.EmulateViewport(leg.w, 844), chromedp.Navigate(rig.Origin+leg.path), chromedp.WaitVisible("#loose", chromedp.ByQuery))
500 requirePointer(t, ctx, leg.coarse)
501 var rows []struct {
502 ID string
503 RTL bool
504 MainStart, MainEnd, MainB, MainW float64
505 StatusStart, StatusT, Room float64
506 StatusSize, LooseSize float64
507 }
508 at(t, ctx, `(() => { const loose = parseFloat(getComputedStyle(document.querySelector("#loose [rst-status]")).fontSize);
509 const h = document.documentElement.getBoundingClientRect(), rtl = document.documentElement.dir === "rtl";
510 const start = r => rtl ? h.right - r.right : r.left - h.left, end = r => rtl ? h.right - r.left : r.right - h.left;
511 return JSON.stringify([...document.querySelectorAll("[rst-list] > div")].map(w => {
512 const row = w.querySelector("[rst-row]"), cs = getComputedStyle(row), main = row.querySelector("[rst-row-main]").getBoundingClientRect(), st = row.querySelector("[rst-status]");
513 const sr = st.getBoundingClientRect(), gap = parseFloat(cs.columnGap);
514 // The room the title is owed: the row's content box less the
515 // other items on its first line and one gap beside each.
516 const others = [...row.children].filter(c => !c.matches("[rst-row-main], [rst-status]"));
517 const room = row.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - others.reduce((n, c) => n + c.getBoundingClientRect().width + gap, 0);
518 return {ID: w.id, RTL: getComputedStyle(row).direction === "rtl", MainStart: start(main), MainEnd: end(main), MainB: main.bottom, MainW: main.width,
519 StatusStart: start(sr), StatusT: sr.top, Room: room, StatusSize: parseFloat(getComputedStyle(st).fontSize), LooseSize: loose};
520 })); })()`, &rows)
521 if len(rows) != 3 {
522 t.Fatalf("measured %d rows, want 3", len(rows))
523 }
524 for _, r := range rows {
525 if r.RTL != (leg.path == "/rtl") {
526 t.Fatalf("%s: the row's direction is RTL %v; this leg needs %v", r.ID, r.RTL, !r.RTL)
527 }
528 if !leg.narrow {
529 if r.StatusT >= r.MainB || r.StatusStart < r.MainEnd {
530 t.Errorf("%s: at %d the status starts %.0fpx along the line at %.0fpx down, not beside the title ending %.0fpx along with its foot at %.0fpx", r.ID, leg.w, r.StatusStart, r.StatusT, r.MainEnd, r.MainB)
531 }
532 continue
533 }
534 if r.StatusT < r.MainB-0.5 {
535 t.Errorf("%s: the status starts at %.0f, above the foot of the title and meta at %.0f; it must sit on its own line under them", r.ID, r.StatusT, r.MainB)
536 }
537 if d := r.StatusStart - r.MainStart; d < -0.5 || d > 0.5 {
538 t.Errorf("%s: the status starts %.0fpx along the line and the title %.0fpx; it must line up under the title", r.ID, r.StatusStart, r.MainStart)
539 }
540 if r.MainW < r.Room-0.5 {
541 t.Errorf("%s: the title is %.0fpx wide with %.0fpx free on its line; it must take the room", r.ID, r.MainW, r.Room)
542 }
543 if r.StatusSize >= r.LooseSize {
544 t.Errorf("%s: the status is %.1fpx, a pill outside a row %.1fpx; under the title it must be smaller", r.ID, r.StatusSize, r.LooseSize)
545 }
546 }
547 })
548 }
549}
550
551// A list title is a line of text, and what an app writes inside it
552// stays inline: an order number in a <bdi>, an <em>, an icon. The floor
553// on the identity cell once made it a grid, so every inline piece of
554// the title became a grid row of its own and "Order AB3PX" stacked
555// into two lines; the <small> under it is block, and stays on its own
556// line. Read at 1280 with a mouse and at 390 with a coarse pointer,
557// where the floor is 32 and 44.
558func TestAListTitleKeepsItsInlineFormatting(t *testing.T) {
559 page := map[string]string{"/": sizingDoc("titles", `<div rst-page><div rst-card style="--rst-cols: minmax(0, 1fr) auto">`+
560 `<div rst-lrow><a class="rst-nm" id="title" href="/go/x">Order <bdi id="ref">AB3PX</bdi> for <em id="who">Fiona</em><small id="sub">Yesterday</small></a><span class="rst-cell-mut">Paid</span></div>`+
561 `</div></div>`)}
562 for _, c := range []struct {
563 name string
564 coarse bool
565 w, h int64
566 }{{"a mouse at 1280px", false, 1280, 900}, {"a coarse pointer at 390px", true, 390, 844}} {
567 rig := sizingRig(t, c.coarse, page)
568 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
569 if err := chromedp.Run(ctx, chromedp.EmulateViewport(c.w, c.h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#title", chromedp.ByQuery)); err != nil {
570 cancel()
571 t.Fatal(err)
572 }
573 var g struct{ Order, Ref, Who, Sub, Cell, Floor float64 }
574 at(t, ctx, `(() => {
575 const a = document.getElementById("title"), r = document.createRange();
576 r.setStart(a.firstChild, 0); r.setEnd(a.firstChild, 5);
577 const top = e => e.getBoundingClientRect().top;
578 return JSON.stringify({Order: r.getBoundingClientRect().top, Ref: top(document.getElementById("ref")), Who: top(document.getElementById("who")),
579 Sub: top(document.getElementById("sub")), Cell: a.getBoundingClientRect().height,
580 Floor: parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--rst-target")) * parseFloat(getComputedStyle(document.documentElement).fontSize)});
581 })()`, &g)
582 cancel()
583 if math.Abs(g.Ref-g.Order) > 1 || math.Abs(g.Who-g.Order) > 1 {
584 t.Errorf("%s: the title's inline pieces are not on one line: \"Order\" at %.1f, the <bdi> at %.1f, the <em> at %.1f", c.name, g.Order, g.Ref, g.Who)
585 }
586 if g.Sub <= g.Order+1 {
587 t.Errorf("%s: the <small> under the title is at %.1f, not on a line below the title at %.1f", c.name, g.Sub, g.Order)
588 }
589 if g.Cell < g.Floor-0.5 {
590 t.Errorf("%s: the identity cell is %.1fpx tall, under the %.0fpx floor", c.name, g.Cell, g.Floor)
591 }
592 }
593}
594