| 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. |
| 8 | package check |
| 9 | |
| 10 | import ( |
| 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. |
| 41 | func 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. |
| 48 | func 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. |
| 60 | func 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). |
| 85 | func 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 | |
| 116 | func 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. |
| 129 | func 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. |
| 207 | func 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. |
| 259 | func 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. |
| 327 | func 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. |
| 386 | func 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. |
| 421 | func 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. |
| 460 | func 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. |
| 475 | func 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. |
| 558 | func 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 | |