//go:build browser // The whole-row target and the rows around it, from rastrillo's // ui/rows_browser_test.go at the seed commit, each page read // through fixture so the rows driven are the rows the rastrillo // drive drove. The probes (at, probe, point) live in // rig_browser_test.go. package check import ( "context" "fmt" "math" "strings" "sync" "testing" "time" cdppage "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta/internal/harness" ) // rowsFixture is every row idiom of §2.2 of the mobile ergonomics // design spec // (docs/superpowers/specs/2026-09-30-mobile-ergonomics-design.md) that // has a primary link (the list grid's status-pill row among them, // §10.2), a row with none in each of the two idioms, and a row holding // the three controls that position themselves (a switch, a date field, // an enhanced select) with the same three outside any row to compare // against. The three sit in one stacked cell of the same width in and // out of the row: below 800px the list grid becomes three columns // whatever --rst-cols says (tokens.css's narrow list-grid rule), and a // fourth child would land a date field in the 44px kebab column under // its own picker. The lifting rule reaches controls at any depth in a // row, so a wrapper cell is the honest test of it. Row A's menu is a // hand-written five-item row menu, so its open panel reaches down over // the row below it. func rowsFixture(t *testing.T) string { t.Helper() return fixture(t, "rows_browser_test.go", "rowsFixture", "") } // rowsPages serves the fixture at / and a landing page for every /go/ // href, so a click that navigates is a URL change the drive can read. func rowsPages(t *testing.T) map[string]string { return map[string]string{ "/": sizingDoc("rows", rowsFixture(t)), "/go/": sizingDoc("landed", `

landed

`), } } // clickAndLand clicks the page at p and waits for the landing page at // want: the URL is want AND the landing page's own marker, #landed, is // in the document. No page under test carries that marker, so a reading // taken in the departing document can never pass for the destination, // which reading location.pathname alone could. func clickAndLand(t *testing.T, ctx context.Context, p point, want string) { t.Helper() if err := chromedp.Run(ctx, chromedp.MouseClickXY(p.X, p.Y)); err != nil { t.Fatal(err) } var last string deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if err := chromedp.Run(ctx, chromedp.Evaluate(`location.pathname + (document.getElementById("landed") ? " (landed)" : "")`, &last)); err == nil && last == want+" (landed)" { return } time.Sleep(100 * time.Millisecond) } 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) } // clickAndStay clicks the page at p, waits for done (a page expression // naming what the click should have done: a menu open, a box checked) // and fails if the click asked for any navigation. Navigation requests // are read off CDP's Page.frameRequestedNavigation, which the browser // sends for every navigation the page asks for, scripts on or off, and // in order before the reply to the evaluation that finds done true. So // "it did not navigate" is an observed absence, not a timed wait. arm is // an optional expression run before the click (to give a click with no // visible effect something to report). func clickAndStay(t *testing.T, ctx context.Context, p point, arm, done string) { t.Helper() var mu sync.Mutex var asked []string active := true chromedp.ListenTarget(ctx, func(ev any) { if e, ok := ev.(*cdppage.EventFrameRequestedNavigation); ok { mu.Lock() if active { asked = append(asked, e.URL) } mu.Unlock() } }) defer func() { mu.Lock(); active = false; mu.Unlock() }() if arm != "" { if err := chromedp.Run(ctx, chromedp.Evaluate(arm, nil)); err != nil { t.Fatal(err) } } if err := chromedp.Run(ctx, chromedp.MouseClickXY(p.X, p.Y)); err != nil { t.Fatal(err) } settleUntil(t, ctx, done) mu.Lock() defer mu.Unlock() if len(asked) > 0 { 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) } } func home(t *testing.T, ctx context.Context, origin string) { t.Helper() if err := chromedp.Run(ctx, chromedp.Navigate(origin+"/"), chromedp.WaitVisible("#grid", chromedp.ByQuery)); err != nil { t.Fatal(err) } settleUntil(t, ctx, `!!document.querySelector("#combo-in [role=combobox]") && !!document.querySelector("#date-in [rst-dtp-pick]")`) } // TestTheWholeRowIsTheTarget is §10.2 at 1280 with a mouse and 390 with // a coarse pointer. For every row idiom with a primary link, a click at // the row's far empty edge and inside an empty cell goes to the primary // href; the status pill is part of the target now; the row's own // controls each do their own thing and never navigate. func TestTheWholeRowIsTheTarget(t *testing.T) { for _, leg := range []struct { name string w, h int64 coarse bool }{{"1280 mouse", 1280, 900, false}, {"390 touch", 390, 844, true}} { t.Run(leg.name, func(t *testing.T) { rig := sizingRig(t, leg.coarse, rowsPages(t)) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h)); err != nil { t.Fatal(err) } home(t, ctx, rig.Origin) requirePointer(t, ctx, leg.coarse) for _, c := range []struct { where, sel string fx, fy, dx float64 link, target string wideOnly bool }{ {"the list grid row's far edge", "#row-a", 1, 0.5, -12, "link-a", "/go/row-a", false}, // The cell is rst-m-hide: below 800px the list grid is three // columns and the column that is hidden has no box to probe. {"an empty cell of the list grid row", "#cell-a", 0.5, 0.5, 0, "link-a", "/go/row-a", true}, {"the person row's far edge", "#row-p", 1, 0.5, -12, "link-p", "/go/person", false}, {"list-row-action's empty main area", "#lra [rst-row-main]", 1, 0.5, -8, "A", "/go/lra", false}, {"list-row-action's status pill", "#lra [rst-status]", 0.5, 0.5, 0, "A", "/go/lra", false}, // List-grid pills were never lifted; this pins that they // stay under the overlay now that there is one. {"the list grid row's status pill", "#row-s [rst-status]", 0.5, 0.5, 0, "link-s", "/go/row-s", false}, } { if c.wideOnly && leg.coarse { continue } home(t, ctx, rig.Origin) p := probe(t, ctx, c.sel, c.fx, c.fy, c.dx, 0) if p.Hit != c.link { 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) } clickAndLand(t, ctx, p, c.target) } // The row's own controls. home(t, ctx, rig.Origin) clickAndLand(t, ctx, probe(t, ctx, "#lra [rst-row-action]", 0.5, 0.5, 0, 0), "/go/lra-edit") home(t, ctx, rig.Origin) clickAndStay(t, ctx, probe(t, ctx, "#kebab-a", 0.5, 0.5, 0, 0), "", `document.getElementById("menu-a").open`) for _, c := range []struct { where string fx, fy, dx float64 }{{"the checkbox itself", 0.5, 0.5, 0}, {"its label's padding", 0, 0, 2}} { home(t, ctx, rig.Origin) p := probe(t, ctx, "#label-a", c.fx, c.fy, c.dx, c.dx) clickAndStay(t, ctx, p, "", `document.getElementById("check-a").checked`) } // Sizes: 44×44 for everything in the rows on a phone; on the // desktop the checkbox's label is the 32×32 desktop floor. home(t, ctx, rig.Origin) if leg.coarse { 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"))]); })()`)) } else { var box [2]float64 at(t, ctx, `(() => { const r = document.getElementById("label-a").getBoundingClientRect(); return JSON.stringify([r.width, r.height]); })()`, &box) if box != [2]float64{32, 32} { t.Errorf("%s: the row checkbox's label is %v, want 32×32", leg.name, box) } } }) } } // TestAnOpenRowMenuStaysAboveTheRowsBelowIt: only the summary is lifted // (§2.3), so the open panel — z-index 40, or position: fixed under // anchor positioning — paints above the next row's lifted controls, and // a click on an item lying over row B's kebab hits the item. func TestAnOpenRowMenuStaysAboveTheRowsBelowIt(t *testing.T) { for _, coarse := range []bool{false, true} { t.Run(fmt.Sprintf("coarse %v", coarse), func(t *testing.T) { rig := sizingRig(t, coarse, rowsPages(t)) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() w := int64(1280) if coarse { w = 390 } if err := chromedp.Run(ctx, chromedp.EmulateViewport(w, 844)); err != nil { t.Fatal(err) } home(t, ctx, rig.Origin) if err := chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById("kebab-a").click(), true`, nil)); err != nil { t.Fatal(err) } var got struct { Overlapping int Wrong []string } at(t, ctx, `(() => { const k = document.getElementById("kebab-b").getBoundingClientRect(), wrong = []; let n = 0; for (const item of document.querySelectorAll("#panel-a a")) { const r = item.getBoundingClientRect(); 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); if (x1 - x0 < 2 || y1 - y0 < 2) continue; n++; const h = document.elementFromPoint((x0 + x1) / 2, (y0 + y1) / 2); if (!(h === item || item.contains(h))) wrong.push(item.textContent + " -> " + (h ? (h.id || h.tagName) : "nothing")); } return JSON.stringify({Overlapping: n, Wrong: wrong}); })()`, &got) if got.Overlapping == 0 { 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) } if len(got.Wrong) > 0 { t.Errorf("coarse=%v: over row B's kebab, a click on row A's menu lands elsewhere: %v", coarse, got.Wrong) } }) } } // TestControlsInARowKeepTheirOwnBoxes: the lifting rule is wholly inside // :where(), so it gives a static control a position and never replaces // one a component chose. A switch's input // stays over its track, the date field's pick button inside its field, // select.js's native select out of flow, each the same size in a row as // outside one. And each is OPERATED, at 1280 with a mouse and at 390 // with a coarse pointer: the switch toggles, the calendar opens and a // day is picked into the native input, the combobox opens and an option // is picked into the native select, and none of it opens the row. func TestControlsInARowKeepTheirOwnBoxes(t *testing.T) { for _, leg := range []struct { name string w, h int64 coarse bool }{{"1280 mouse", 1280, 900, false}, {"390 touch", 390, 844, true}} { t.Run(leg.name, func(t *testing.T) { rig := sizingRig(t, leg.coarse, rowsPages(t)) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h)); err != nil { t.Fatal(err) } home(t, ctx, rig.Origin) requirePointer(t, ctx, leg.coarse) var g map[string]string at(t, ctx, `(() => { const pos = el => getComputedStyle(el).position; const size = el => { const r = el.getBoundingClientRect(); return Math.round(r.width) + "x" + Math.round(r.height); }; // fx is the fraction across the box to test: the date input is // tested a quarter of the way in, clear of the picker button // that sits over its inline end on purpose. 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"); }; 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"; }; return JSON.stringify({ switchIn: pos(document.querySelector("#switch-in input")) + " " + size(document.querySelector("#switch-in [rst-switch-track]")), switchOut: pos(document.querySelector("#switch-out input")) + " " + size(document.querySelector("#switch-out [rst-switch-track]")), pickIn: pos(document.querySelector("#date-in [rst-dtp-pick]")) + " " + size(document.querySelector("#date-in [rst-dtp-pick]")) + " " + pick("in"), pickOut: pos(document.querySelector("#date-out [rst-dtp-pick]")) + " " + size(document.querySelector("#date-out [rst-dtp-pick]")) + " " + pick("out"), selectIn: pos(document.querySelector("#combo_in")), selectOut: pos(document.querySelector("#combo_out")), 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]")}); })()`, &g) for _, pair := range [][2]string{{"switchIn", "switchOut"}, {"pickIn", "pickOut"}, {"selectIn", "selectOut"}} { if g[pair[0]] != g[pair[1]] { t.Errorf("in a row %s is %q; outside one it is %q", pair[0], g[pair[0]], g[pair[1]]) } } if !strings.HasPrefix(g["switchIn"], "absolute") || g["selectIn"] != "absolute" { t.Errorf("the lifting rule replaced a component's own position: switch input %q, native select %q", g["switchIn"], g["selectIn"]) } for _, k := range []string{"switchHit", "dateHit", "pickHit", "comboHit"} { if g[k] != "ok" { t.Errorf("in a row, %s: %s", k, g[k]) } } // Operated. Each click must do its job and ask for no // navigation (the row's link is under all of them). clickAndStay(t, ctx, probe(t, ctx, "#switch-in [rst-switch-track]", 0.5, 0.5, 0, 0), "", `document.querySelector("#switch-in input").checked`) 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"]')`) 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"`) 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()`) 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 { t.Fatal(err) } 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)) clickAndStay(t, ctx, probe(t, ctx, "#combo-in [data-pick]", 0.5, 0.5, 0, 0), "", `document.getElementById("combo_in").value === "7"`) }) } } // TestARowWithNoLinkLooksAndActsInert: no overlay, no hover fill, in // both idioms. The linked row of the same idiom is each one's control: // its hover DOES fill. The rig declares a mouse that can hover, and // the page is frozen first (C14): the row hovers sit in @media // (hover: hover) and ease in over 0.25s, so a default rig, which // reports no hover, and a read in the first frame of the transition // both found the control at rest and failed it. func TestARowWithNoLinkLooksAndActsInert(t *testing.T) { rig := sizingRig(t, false, rowsPages(t), harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900)); err != nil { t.Fatal(err) } home(t, ctx, rig.Origin) still(t, ctx) bg := func(sel string) string { var s string if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`getComputedStyle(document.querySelector(%q)).backgroundColor`, sel), &s)); err != nil { t.Fatal(err) } return s } hover := func(sel string) string { p := probe(t, ctx, sel, 0.5, 0.5, 0, 0) if err := chromedp.Run(ctx, chromedp.MouseEvent("mouseMoved", p.X, p.Y)); err != nil { t.Fatal(err) } return bg(sel) } for _, c := range []struct{ idiom, inert, linked string }{ {"list grid", "#row-n", "#row-a"}, {"list-row-action", "#lra-n", "#lra [rst-row]"}, } { if err := chromedp.Run(ctx, chromedp.MouseEvent("mouseMoved", 1, 1)); err != nil { t.Fatal(err) } restN, restA := bg(c.inert), bg(c.linked) if hovered := hover(c.linked); hovered == restA { t.Fatalf("%s CONTROL: hovering a linked row does not change its background, so the inert row's unchanged background proves nothing", c.idiom) } if err := chromedp.Run(ctx, chromedp.MouseEvent("mouseMoved", 1, 1)); err != nil { t.Fatal(err) } if hovered := hover(c.inert); hovered != restN { t.Errorf("%s: hovering a row with no link fills it (%s -> %s): it looks clickable and is not", c.idiom, restN, hovered) } p := probe(t, ctx, c.inert, 0.5, 0.5, 0, 0) // Any link at all, by tag, not by the id-less "A" probe reports: // a link with an id would otherwise read as its id and slip by. var link string 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) if link != "" { t.Errorf("%s: the centre of a row with no link is under a link: %s", c.idiom, link) } clickAndStay(t, ctx, p, fmt.Sprintf(`window.rowClicked = false, document.querySelector(%q).addEventListener("click", () => { window.rowClicked = true; }), true`, c.inert), `window.rowClicked === true`) } } // TestMeasureRejectsAHitOnAnotherPartOfTheRow pins the target drive's // ownership rule itself. A stretched link is measured over its whole // row, and the row holds other things; a hit at the row's centre on an // unrelated child (here a positioned span covering the row) must count // as occlusion, never as the link. The same row without the cover is // the control and must read as owned. func TestMeasureRejectsAHitOnAnotherPartOfTheRow(t *testing.T) { row := func(cover string) string { return `
Grace HopperPaid` + cover + `
` } pages := map[string]string{ "/covered": sizingDoc("covered", `
`+row(``)+`
`), "/clear": sizingDoc("clear", `
`+row("")+`
`), } rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() read := func(path string) targetReading { if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("#name", chromedp.ByQuery)); err != nil { t.Fatal(err) } for _, g := range readTargets(t, ctx, `(() => { `+measureFn+`; return JSON.stringify(measure(document)); })()`) { if strings.Contains(g.Name, "#name") { return g } } t.Fatalf("%s: the stretched link was not measured", path) return targetReading{} } if g := read("/clear"); !g.Owns { t.Fatalf("CONTROL: an unobstructed stretched link reads as not owning its row (hit %s); the instrument is broken", g.Hit) } if g := read("/covered"); g.Owns { 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) } } // TestFocusDrawsTheRingAroundTheWholeRow: the primary link's ring moves // to its overlay, 2px inside the row, so the focused row is outlined // whole; the link itself also underlines, which is what the keyboard // walk's element-and-ancestors reading sees. func TestFocusDrawsTheRingAroundTheWholeRow(t *testing.T) { rig := sizingRig(t, false, rowsPages(t)) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900)); err != nil { t.Fatal(err) } home(t, ctx, rig.Origin) if err := chromedp.Run(ctx, chromedp.Focus("#check-a", chromedp.ByQuery), chromedp.KeyEvent(kb.Tab)); err != nil { t.Fatal(err) } var g struct { Active, Style, Width, Offset, Own, Deco, Corner1, Corner2 string Visible bool } at(t, ctx, `(() => { const a = document.activeElement, row = document.getElementById("row-a"), r = row.getBoundingClientRect(), after = getComputedStyle(a, "::after"); const hit = (x, y) => { const h = document.elementFromPoint(x, y); return h ? (h.id || h.tagName) : "nothing"; }; return JSON.stringify({Active: a.id, Visible: a.matches(":focus-visible"), Style: after.outlineStyle, Width: after.outlineWidth, Offset: after.outlineOffset, Own: getComputedStyle(a).outlineStyle, Deco: getComputedStyle(a).textDecorationLine, Corner1: hit(r.left + 3, r.top + 3), Corner2: hit(r.right - 3, r.bottom - 3)}); })()`, &g) if g.Active != "link-a" || !g.Visible { 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) } if g.Style != "solid" || g.Width != "2px" || g.Offset != "-2px" { t.Errorf("the overlay's ring is %s %s offset %s, want a solid 2px ring inset 2px", g.Style, g.Width, g.Offset) } if g.Own != "none" || g.Deco != "underline" { t.Errorf("the link itself has outline %s and decoration %s, want none and underline", g.Own, g.Deco) } if g.Corner1 != "link-a" || g.Corner2 != "link-a" { 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) } } // narrowRowsPage is list-row-action with a status in each of the shapes // a row can take around it (with and without a lead, an action pill and // a kebab), and one status pill outside any row to compare sizes with. func narrowRowsPage(t *testing.T) map[string]string { t.Helper() return map[string]string{"/": sizingDoc("narrow rows", fixture(t, "rows_browser_test.go", "narrowRowsPage", ""))} } // TestANarrowRowGivesItsTitleTheWidth: on a phone a row held avatar, // title, status pill and action side by side, and the title was left a // narrow column ("Release notes, August" in three lines beside its // pill). Below 34rem the status moves onto its own line under the // title and meta, smaller than a pill elsewhere, and the title takes // every pixel the row's other first-line items leave. At 1280 the // status stays beside the title, the control that this is a narrow // rule and not a general one. Every leg runs in RTL too: edges are read // from the inline start, so a physical left or right in the rule would // put the status under the wrong edge of the title there. func TestANarrowRowGivesItsTitleTheWidth(t *testing.T) { ltr := narrowRowsPage(t)["/"] pages := map[string]string{"/": ltr, "/rtl": strings.Replace(ltr, `dir="ltr"`, `dir="rtl"`, 1)} type leg struct { name string w int64 coarse, narrow bool path string } var legs []leg for _, path := range []string{"/", "/rtl"} { for _, l := range []leg{{"1280 mouse", 1280, false, false, ""}, {"390 touch", 390, true, true, ""}, {"320 mouse", 320, false, true, ""}} { l.path = path if path == "/rtl" { l.name = "RTL " + l.name } legs = append(legs, l) } } for _, leg := range legs { t.Run(leg.name, func(t *testing.T) { rig := sizingRig(t, leg.coarse, pages) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(leg.w, 844), chromedp.Navigate(rig.Origin+leg.path), chromedp.WaitVisible("#loose", chromedp.ByQuery)) requirePointer(t, ctx, leg.coarse) var rows []struct { ID string RTL bool MainStart, MainEnd, MainB, MainW float64 StatusStart, StatusT, Room float64 StatusSize, LooseSize float64 } at(t, ctx, `(() => { const loose = parseFloat(getComputedStyle(document.querySelector("#loose [rst-status]")).fontSize); const h = document.documentElement.getBoundingClientRect(), rtl = document.documentElement.dir === "rtl"; const start = r => rtl ? h.right - r.right : r.left - h.left, end = r => rtl ? h.right - r.left : r.right - h.left; return JSON.stringify([...document.querySelectorAll("[rst-list] > div")].map(w => { const row = w.querySelector("[rst-row]"), cs = getComputedStyle(row), main = row.querySelector("[rst-row-main]").getBoundingClientRect(), st = row.querySelector("[rst-status]"); const sr = st.getBoundingClientRect(), gap = parseFloat(cs.columnGap); // The room the title is owed: the row's content box less the // other items on its first line and one gap beside each. const others = [...row.children].filter(c => !c.matches("[rst-row-main], [rst-status]")); const room = row.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - others.reduce((n, c) => n + c.getBoundingClientRect().width + gap, 0); return {ID: w.id, RTL: getComputedStyle(row).direction === "rtl", MainStart: start(main), MainEnd: end(main), MainB: main.bottom, MainW: main.width, StatusStart: start(sr), StatusT: sr.top, Room: room, StatusSize: parseFloat(getComputedStyle(st).fontSize), LooseSize: loose}; })); })()`, &rows) if len(rows) != 3 { t.Fatalf("measured %d rows, want 3", len(rows)) } for _, r := range rows { if r.RTL != (leg.path == "/rtl") { t.Fatalf("%s: the row's direction is RTL %v; this leg needs %v", r.ID, r.RTL, !r.RTL) } if !leg.narrow { if r.StatusT >= r.MainB || r.StatusStart < r.MainEnd { 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) } continue } if r.StatusT < r.MainB-0.5 { 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) } if d := r.StatusStart - r.MainStart; d < -0.5 || d > 0.5 { 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) } if r.MainW < r.Room-0.5 { 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) } if r.StatusSize >= r.LooseSize { 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) } } }) } } // A list title is a line of text, and what an app writes inside it // stays inline: an order number in a , an , an icon. The floor // on the identity cell once made it a grid, so every inline piece of // the title became a grid row of its own and "Order AB3PX" stacked // into two lines; the under it is block, and stays on its own // line. Read at 1280 with a mouse and at 390 with a coarse pointer, // where the floor is 32 and 44. func TestAListTitleKeepsItsInlineFormatting(t *testing.T) { page := map[string]string{"/": sizingDoc("titles", `
`+ ``+ `
`)} for _, c := range []struct { name string coarse bool w, h int64 }{{"a mouse at 1280px", false, 1280, 900}, {"a coarse pointer at 390px", true, 390, 844}} { rig := sizingRig(t, c.coarse, page) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) if err := chromedp.Run(ctx, chromedp.EmulateViewport(c.w, c.h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#title", chromedp.ByQuery)); err != nil { cancel() t.Fatal(err) } var g struct{ Order, Ref, Who, Sub, Cell, Floor float64 } at(t, ctx, `(() => { const a = document.getElementById("title"), r = document.createRange(); r.setStart(a.firstChild, 0); r.setEnd(a.firstChild, 5); const top = e => e.getBoundingClientRect().top; return JSON.stringify({Order: r.getBoundingClientRect().top, Ref: top(document.getElementById("ref")), Who: top(document.getElementById("who")), Sub: top(document.getElementById("sub")), Cell: a.getBoundingClientRect().height, Floor: parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--rst-target")) * parseFloat(getComputedStyle(document.documentElement).fontSize)}); })()`, &g) cancel() if math.Abs(g.Ref-g.Order) > 1 || math.Abs(g.Who-g.Order) > 1 { t.Errorf("%s: the title's inline pieces are not on one line: \"Order\" at %.1f, the at %.1f, the at %.1f", c.name, g.Order, g.Ref, g.Who) } if g.Sub <= g.Order+1 { t.Errorf("%s: the under the title is at %.1f, not on a line below the title at %.1f", c.name, g.Sub, g.Order) } if g.Cell < g.Floor-0.5 { t.Errorf("%s: the identity cell is %.1fpx tall, under the %.0fpx floor", c.name, g.Cell, g.Floor) } } }