| 1 | //go:build browser |
| 2 | |
| 3 | // The row menu: its native exclusivity, dismissal, flip, spinner slot |
| 4 | // and placement, from rastrillo's ui/rowmenu_browser_test.go at the |
| 5 | // seed commit, its pages read through fixture. mustRun lives in |
| 6 | // rig_browser_test.go. |
| 7 | package check |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "fmt" |
| 12 | "io" |
| 13 | "net/http" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/chromedp/cdproto/emulation" |
| 19 | "github.com/chromedp/chromedp" |
| 20 | "github.com/chromedp/chromedp/kb" |
| 21 | |
| 22 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 23 | ) |
| 24 | |
| 25 | // rowMenuPage is three list-grid rows, each with a row menu rendered by |
| 26 | // the partial: a GET item (/go/view-<row>), a POST item to /act/<row> |
| 27 | // carrying two hidden fields (state=archived, from=list), and a |
| 28 | // destructive link. The third row's POST label is forty-odd |
| 29 | // characters, the case the spinner slot exists for. /bottom is the same |
| 30 | // list pushed to the foot of the viewport, and /go/ is the landing |
| 31 | // page every item's href resolves to. |
| 32 | func rowMenuPage(t *testing.T) (map[string]string, chan string) { |
| 33 | t.Helper() |
| 34 | return map[string]string{ |
| 35 | "/": sizingDoc("row menus", fixture(t, "rowmenu_browser_test.go", "rowMenuPage", "root")), |
| 36 | "/bottom": sizingDoc("row menus at the bottom", fixture(t, "rowmenu_browser_test.go", "rowMenuPage", "bottom")), |
| 37 | "/go/": sizingDoc("landed", `<p id="landed">landed</p>`), |
| 38 | }, make(chan string, 8) |
| 39 | } |
| 40 | |
| 41 | func rowMenuRig(t *testing.T, coarse bool, opts ...harness.Option) (*harness.Rig, chan string) { |
| 42 | t.Helper() |
| 43 | pages, posted := rowMenuPage(t) |
| 44 | if coarse { |
| 45 | opts = append(opts, harness.WithCoarsePointer()) |
| 46 | } |
| 47 | rig := harness.New(t, func(string) http.Handler { |
| 48 | mux := sizingMux(t, pages) |
| 49 | mux.HandleFunc("POST /act/", func(w http.ResponseWriter, r *http.Request) { |
| 50 | body, _ := io.ReadAll(r.Body) |
| 51 | posted <- r.URL.Path + "?" + string(body) |
| 52 | http.Redirect(w, r, "/go/done", http.StatusSeeOther) |
| 53 | }) |
| 54 | return mux |
| 55 | }, opts...) |
| 56 | return rig, posted |
| 57 | } |
| 58 | |
| 59 | func openMenus(t *testing.T, ctx context.Context) string { |
| 60 | t.Helper() |
| 61 | var s string |
| 62 | mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll("[rst-row-menu]")].filter(d => d.open).map(d => d.closest("[rst-lrow]").id).join(",")`, &s)) |
| 63 | return s |
| 64 | } |
| 65 | |
| 66 | // TestRowMenusExcludeEachOtherWithNoScript: the native group, with |
| 67 | // script execution disabled in the engine. B opens first, then A: an |
| 68 | // open panel drops over the rows below it, so a click aimed at B's |
| 69 | // kebab with A open would land on A's items and submit or follow one. |
| 70 | func TestRowMenusExcludeEachOtherWithNoScript(t *testing.T) { |
| 71 | rig, _ := rowMenuRig(t, false) |
| 72 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 73 | defer cancel() |
| 74 | mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true), chromedp.EmulateViewport(1280, 900), |
| 75 | chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#list", chromedp.ByQuery), |
| 76 | chromedp.Click("#row-b summary", chromedp.ByQuery)) |
| 77 | if got := openMenus(t, ctx); got != "row-b" { |
| 78 | t.Fatalf("with no script, open menus are %q after opening B, want row-b; the second click proves nothing", got) |
| 79 | } |
| 80 | mustRun(t, ctx, chromedp.Click("#row-a summary", chromedp.ByQuery)) |
| 81 | if got := openMenus(t, ctx); got != "row-a" { |
| 82 | t.Errorf("with no script, open menus are %q after opening B then A, want only row-a", got) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // TestRowMenuDismissesAndHandsFocusBack: an outside click and Escape |
| 87 | // close it (fiesta.js), and Escape puts focus back on its summary. |
| 88 | // Clicking an item goes to the item, never the row. |
| 89 | func TestRowMenuDismissesAndHandsFocusBack(t *testing.T) { |
| 90 | rig, _ := rowMenuRig(t, false) |
| 91 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 92 | defer cancel() |
| 93 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#list", chromedp.ByQuery)) |
| 94 | mustRun(t, ctx, chromedp.Click("#row-a summary", chromedp.ByQuery)) |
| 95 | if got := openMenus(t, ctx); got != "row-a" { |
| 96 | t.Fatalf("clicking row A's kebab left %q open, want row-a; the dismissal legs prove nothing", got) |
| 97 | } |
| 98 | mustRun(t, ctx, chromedp.Click("#outside", chromedp.ByQuery)) |
| 99 | if got := openMenus(t, ctx); got != "" { |
| 100 | t.Errorf("after an outside click, %q is still open", got) |
| 101 | } |
| 102 | var focus string |
| 103 | mustRun(t, ctx, |
| 104 | chromedp.Click("#row-a summary", chromedp.ByQuery), chromedp.Focus("#row-a [rst-row-menu-panel] a", chromedp.ByQuery), |
| 105 | chromedp.KeyEvent(kb.Escape), |
| 106 | chromedp.Evaluate(`document.activeElement.closest("[rst-lrow]").id + " " + document.activeElement.tagName`, &focus)) |
| 107 | if got := openMenus(t, ctx); got != "" || focus != "row-a SUMMARY" { |
| 108 | t.Errorf("after Escape: open %q, focus on %q; want none open and focus on row-a's summary", got, focus) |
| 109 | } |
| 110 | mustRun(t, ctx, chromedp.Click("#row-a summary", chromedp.ByQuery)) |
| 111 | clickAndLand(t, ctx, probe(t, ctx, "#row-a [rst-row-menu-panel] a", 0.5, 0.5, 0, 0), "/go/view-a") |
| 112 | } |
| 113 | |
| 114 | // TestARowMenuNearTheBottomOpensUpward: anchor positioning's flip-block |
| 115 | // (Chromium implements it), so the last row's menu is not cut off. |
| 116 | func TestARowMenuNearTheBottomOpensUpward(t *testing.T) { |
| 117 | rig, _ := rowMenuRig(t, false) |
| 118 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 119 | defer cancel() |
| 120 | var g struct { |
| 121 | Open bool |
| 122 | PanelTop, PanelBottom, PanelH, SummaryTop, VH float64 |
| 123 | } |
| 124 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 700), chromedp.Navigate(rig.Origin+"/bottom"), chromedp.WaitReady("#list", chromedp.ByQuery), |
| 125 | chromedp.Evaluate(`window.scrollTo(0, document.documentElement.scrollHeight), true`, nil), |
| 126 | chromedp.Evaluate(`document.querySelector("#row-c summary").click(), true`, nil)) |
| 127 | at(t, ctx, `(() => { const d = document.querySelector("#row-c [rst-row-menu]"), p = d.querySelector("[rst-row-menu-panel]").getBoundingClientRect(), s = d.querySelector("summary").getBoundingClientRect(); return JSON.stringify({Open: d.open, PanelTop: p.top, PanelBottom: p.bottom, PanelH: p.height, SummaryTop: s.top, VH: innerHeight}); })()`, &g) |
| 128 | // The panel keeps a box while its menu is closed (Chromium reports a |
| 129 | // non-zero height for it), so the height proves nothing and the Open |
| 130 | // check is what matters: without it the leg would pass on a click |
| 131 | // that opened nothing. |
| 132 | if !g.Open || g.PanelH <= 0 { |
| 133 | t.Fatalf("the last row's menu did not open (open %v, panel %.0fpx tall); this leg proves nothing", g.Open, g.PanelH) |
| 134 | } |
| 135 | if g.SummaryTop < g.VH-200 { |
| 136 | t.Fatalf("the last row's kebab is at %.0f in a %.0fpx viewport; it is not near the bottom and this leg proves nothing", g.SummaryTop, g.VH) |
| 137 | } |
| 138 | if g.PanelBottom > g.SummaryTop+0.5 { |
| 139 | t.Errorf("the panel ends at %.0f, below its summary's top (%.0f): it opened downward off the screen", g.PanelBottom, g.SummaryTop) |
| 140 | } |
| 141 | if g.PanelTop < 0 { |
| 142 | t.Errorf("the panel starts at %.0f, above the viewport: it flipped upward and was cut off at the top", g.PanelTop) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // postBusyJS clicks a row's POST item and reads, in the same task, the item |
| 147 | // and the panel before and after busy.js has put its spinner in. |
| 148 | const postBusyJS = `((row) => { |
| 149 | const d = document.querySelector("#row-" + row + " [rst-row-menu]"); d.open = true; |
| 150 | const btn = d.querySelector("[rst-row-menu-panel] button"), panel = d.querySelector("[rst-row-menu-panel]"); |
| 151 | const box = el => { const r = el.getBoundingClientRect(); return [r.width, r.height]; }; |
| 152 | const before = [box(btn), box(panel)]; |
| 153 | btn.click(); |
| 154 | const spin = btn.querySelector("[rst-spin]"); |
| 155 | const sr = spin ? spin.getBoundingClientRect() : null, br = btn.getBoundingClientRect(); |
| 156 | // The label as it is drawn: every line box of its text. The spinner is |
| 157 | // out of flow, so the boxes above cannot see it sit on the words; this |
| 158 | // is the reading that can. |
| 159 | const range = document.createRange(), lines = []; |
| 160 | for (const n of btn.childNodes) { |
| 161 | if (n.nodeType !== 3 || !n.textContent.trim()) continue; |
| 162 | range.selectNodeContents(n); |
| 163 | lines.push(...range.getClientRects()); |
| 164 | } |
| 165 | const hits = sr ? lines.filter(l => l.right > sr.left + 0.5 && l.left < sr.right - 0.5 && l.bottom > sr.top + 0.5 && l.top < sr.bottom - 0.5).length : 0; |
| 166 | return JSON.stringify({Before: before, After: [box(btn), box(panel)], Spin: !!spin, Lines: lines.length, Overlap: hits, |
| 167 | InSlot: !!sr && sr.left >= br.right - 40 && sr.right <= br.right + 0.5, |
| 168 | Animation: spin ? getComputedStyle(spin).animationName : ""}); |
| 169 | })(%q)` |
| 170 | |
| 171 | // TestAPostItemsSpinnerNeverMovesTheMenu: busy.js's spinner sits in its |
| 172 | // reserved slot at the inline end, and neither the item's nor the |
| 173 | // panel's box changes size, including for a forty-odd-character label |
| 174 | // at 320px; under reduced motion the ring is still. The request goes to |
| 175 | // the item's own action with only its own fields. |
| 176 | // |
| 177 | // The long label at 1280 is the leg that catches a missing slot: there |
| 178 | // the panel is as wide as that label on one line, so the text runs to |
| 179 | // the item's content edge, where the spinner goes. At 320 the panel is |
| 180 | // held to the room beside the kebab and the label wraps at a word, which |
| 181 | // leaves slack at the end of each line that an unreserved spinner can |
| 182 | // happen to fit in. |
| 183 | func TestAPostItemsSpinnerNeverMovesTheMenu(t *testing.T) { |
| 184 | for _, leg := range []struct { |
| 185 | name string |
| 186 | w int64 |
| 187 | row string |
| 188 | reduced, coarse bool |
| 189 | }{ |
| 190 | {"1280, a short label", 1280, "a", false, false}, |
| 191 | {"1280, a long label on one line", 1280, "c", false, false}, |
| 192 | {"320, a long label", 320, "c", false, false}, |
| 193 | {"320, reduced motion", 320, "c", true, false}, |
| 194 | // On a touch screen the item is a 44px flex row, a different |
| 195 | // layout for the spinner to sit in. |
| 196 | {"320 touch, a long label", 320, "c", false, true}, |
| 197 | } { |
| 198 | t.Run(leg.name, func(t *testing.T) { |
| 199 | rig, posted := rowMenuRig(t, leg.coarse) |
| 200 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 201 | defer cancel() |
| 202 | acts := []chromedp.Action{chromedp.EmulateViewport(leg.w, 800)} |
| 203 | if leg.reduced { |
| 204 | acts = append(acts, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}})) |
| 205 | } |
| 206 | acts = append(acts, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#list", chromedp.ByQuery)) |
| 207 | mustRun(t, ctx, acts...) |
| 208 | // busy.js is deferred: a click before it has wired the submit |
| 209 | // handler would post with no spinner and read as a failure of |
| 210 | // the slot rather than of the wait. |
| 211 | settleUntil(t, ctx, `document.readyState === "complete"`) |
| 212 | requirePointer(t, ctx, leg.coarse) |
| 213 | var g struct { |
| 214 | Before, After [2][2]float64 |
| 215 | Spin, InSlot bool |
| 216 | Lines, Overlap int |
| 217 | Animation string |
| 218 | } |
| 219 | at(t, ctx, fmt.Sprintf(postBusyJS, leg.row), &g) |
| 220 | if !g.Spin { |
| 221 | t.Fatalf("no spinner appeared; busy.js did not run and this leg proves nothing") |
| 222 | } |
| 223 | if g.Before != g.After { |
| 224 | t.Errorf("item and panel went from %v to %v when the spinner appeared", g.Before, g.After) |
| 225 | } |
| 226 | if !g.InSlot { |
| 227 | t.Errorf("the spinner is not in the reserved slot at the item's inline end") |
| 228 | } |
| 229 | if g.Lines == 0 { |
| 230 | t.Fatalf("the label has no rendered text to compare with the spinner; the leg reads nothing") |
| 231 | } |
| 232 | if g.Overlap > 0 { |
| 233 | t.Errorf("the spinner sits on %d of the label's %d lines; the reserved slot is not keeping them apart", g.Overlap, g.Lines) |
| 234 | } |
| 235 | if leg.reduced && g.Animation != "none" { |
| 236 | t.Errorf("the spinner animates (%s) under reduced motion", g.Animation) |
| 237 | } |
| 238 | select { |
| 239 | case got := <-posted: |
| 240 | if want := "/act/" + leg.row + "?state=archived&from=list"; got != want { |
| 241 | t.Errorf("the POST was %q, want %q (its own action, only its own fields, in order)", got, want) |
| 242 | } |
| 243 | case <-time.After(5 * time.Second): |
| 244 | t.Errorf("the POST item never submitted") |
| 245 | } |
| 246 | }) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // TestRowMenuTargetsAreTapsOnAPhone: trigger and items at 44px. |
| 251 | func TestRowMenuTargetsAreTapsOnAPhone(t *testing.T) { |
| 252 | rig, _ := rowMenuRig(t, true) |
| 253 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 254 | defer cancel() |
| 255 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#list", chromedp.ByQuery), |
| 256 | chromedp.Evaluate(`document.querySelector("#row-a [rst-row-menu]").open = true`, nil)) |
| 257 | requirePointer(t, ctx, true) |
| 258 | got := readTargets(t, ctx, `(() => { `+measureFn+`; return JSON.stringify(measure(document.querySelector("#row-a [rst-row-menu]"))); })()`) |
| 259 | if len(got) != 4 { |
| 260 | t.Fatalf("measured %d controls in row A's open menu, want the summary and three items", len(got)) |
| 261 | } |
| 262 | assertTargets(t, "390 touch, row A's menu", got) |
| 263 | } |
| 264 | |
| 265 | // TestARowMenuOpensBesideItsOwnKebab: the panel hangs from the kebab, |
| 266 | // wherever the <details> around it is put. The panel used to hang from |
| 267 | // the <details>, which only hugged the kebab where something shrank it: |
| 268 | // a list grid cell, or an engine that applies justify-self to a block. |
| 269 | // Anywhere else (a flex column here, the gallery's bare sample in |
| 270 | // Safari) it was as wide as its container and the panel opened at the |
| 271 | // container's far edge, nowhere near the kebab just pressed. |
| 272 | // |
| 273 | // Left alone, the menu sits at its container's trailing edge in every |
| 274 | // engine, and the panel's trailing edge meets the kebab's. An app that |
| 275 | // pins it to the leading edge (the /pinned legs) gets a panel that flips |
| 276 | // to open from the kebab's leading edge instead. An engine without |
| 277 | // anchor positioning cannot flip, which is why the menu must reach the |
| 278 | // trailing edge on its own: the no-anchor leg is a kebab that would sit |
| 279 | // at the leading edge if margins left it there, and its panel must still |
| 280 | // open on screen. Every leg runs in RTL too, where the edges mirror. |
| 281 | func TestARowMenuOpensBesideItsOwnKebab(t *testing.T) { |
| 282 | menu := fixture(t, "rowmenu_browser_test.go", "TestARowMenuOpensBesideItsOwnKebab", "menu") |
| 283 | stack := func(style string) string { |
| 284 | return `<style>#box { display: flex; flex-direction: column; padding: 1rem; } ` + style + `</style><div id="box">` + menu + `</div>` |
| 285 | } |
| 286 | pages := map[string]string{ |
| 287 | "/start": sizingDoc("a menu in a stack", stack("")), |
| 288 | "/pinned": sizingDoc("a menu pinned to the leading edge", stack("#box > [rst-row-menu] { margin-inline-start: 0; }")), |
| 289 | } |
| 290 | rtl := map[string]string{} |
| 291 | for path, html := range pages { |
| 292 | rtl["/rtl"+path] = strings.Replace(html, `dir="ltr"`, `dir="rtl"`, 1) |
| 293 | } |
| 294 | for path, html := range rtl { |
| 295 | pages[path] = html |
| 296 | } |
| 297 | type leg struct { |
| 298 | name, path string |
| 299 | w int64 |
| 300 | coarse bool |
| 301 | anchor bool |
| 302 | edge string // the kebab edge, logical, the panel must share |
| 303 | } |
| 304 | var legs []leg |
| 305 | for _, dir := range []string{"", "/rtl"} { |
| 306 | for _, l := range []leg{ |
| 307 | {"1280, left where it falls", "/start", 1280, false, true, "end"}, |
| 308 | {"1280, pinned to the leading edge", "/pinned", 1280, false, true, "start"}, |
| 309 | {"390 touch, left where it falls", "/start", 390, true, true, "end"}, |
| 310 | {"390 touch, pinned to the leading edge", "/pinned", 390, true, true, "start"}, |
| 311 | {"1280 without anchor positioning", "/start", 1280, false, false, "end"}, |
| 312 | {"390 touch without anchor positioning", "/start", 390, true, false, "end"}, |
| 313 | } { |
| 314 | l.path = dir + l.path |
| 315 | if dir != "" { |
| 316 | l.name = "RTL " + l.name |
| 317 | } |
| 318 | legs = append(legs, l) |
| 319 | } |
| 320 | } |
| 321 | for _, leg := range legs { |
| 322 | t.Run(leg.name, func(t *testing.T) { |
| 323 | rig := sizingRig(t, leg.coarse, pages) |
| 324 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 325 | defer cancel() |
| 326 | mustRun(t, ctx, chromedp.EmulateViewport(leg.w, 800), chromedp.Navigate(rig.Origin+leg.path), chromedp.WaitVisible("#box", chromedp.ByQuery)) |
| 327 | requirePointer(t, ctx, leg.coarse) |
| 328 | if !leg.anchor { |
| 329 | mustRun(t, ctx, chromedp.Evaluate(WithoutAnchorPositioning, nil, awaitPromise)) |
| 330 | } |
| 331 | RequireAnchorPositioning(t, ctx, leg.name, "[rst-row-menu-panel]", leg.anchor) |
| 332 | // Edges are read in the inline direction: Start and End are |
| 333 | // distances from the root box's leading edge, so one set of |
| 334 | // comparisons serves both directions. The root box, not the |
| 335 | // viewport: tokens.css reserves a stable scrollbar gutter, and |
| 336 | // RTL puts it on the left. |
| 337 | var g struct { |
| 338 | Open, RTL bool |
| 339 | SummaryStart, SummaryEnd, PanelStart, PanelEnd float64 |
| 340 | PanelTop, SummaryBottom, VW float64 |
| 341 | } |
| 342 | at(t, ctx, `(() => { const d = document.querySelector("[rst-row-menu]"); d.querySelector("summary").click(); |
| 343 | const s = d.querySelector("summary").getBoundingClientRect(), p = d.querySelector("[rst-row-menu-panel]").getBoundingClientRect(); |
| 344 | const rtl = getComputedStyle(d).direction === "rtl", h = document.documentElement.getBoundingClientRect(); |
| 345 | const start = r => rtl ? h.right - r.right : r.left - h.left, end = r => rtl ? h.right - r.left : r.right - h.left; |
| 346 | return JSON.stringify({Open: d.open, RTL: rtl, SummaryStart: start(s), SummaryEnd: end(s), PanelStart: start(p), PanelEnd: end(p), PanelTop: p.top, SummaryBottom: s.bottom, VW: h.width}); })()`, &g) |
| 347 | if !g.Open { |
| 348 | t.Fatalf("the menu did not open; this leg proves nothing") |
| 349 | } |
| 350 | if g.RTL != strings.HasPrefix(leg.path, "/rtl") { |
| 351 | t.Fatalf("the menu's direction is RTL %v; this leg needs %v", g.RTL, !g.RTL) |
| 352 | } |
| 353 | if leg.edge == "start" && g.SummaryStart > 0.25*g.VW { |
| 354 | t.Fatalf("the kebab starts %.0fpx from the leading edge of a %.0fpx viewport; it is not pinned there and this leg proves nothing", g.SummaryStart, g.VW) |
| 355 | } |
| 356 | switch leg.edge { |
| 357 | case "end": |
| 358 | if g.SummaryEnd < 0.75*g.VW { |
| 359 | t.Errorf("the kebab ends %.0fpx from the leading edge of a %.0fpx viewport; left where it falls it must sit at the trailing edge", g.SummaryEnd, g.VW) |
| 360 | } |
| 361 | if d := g.PanelEnd - g.SummaryEnd; d < -0.5 || d > 0.5 { |
| 362 | t.Errorf("the panel's trailing edge is at %.0f, the kebab's at %.0f; it must open from the kebab", g.PanelEnd, g.SummaryEnd) |
| 363 | } |
| 364 | case "start": |
| 365 | if d := g.PanelStart - g.SummaryStart; d < -0.5 || d > 0.5 { |
| 366 | t.Errorf("the panel's leading edge is at %.0f, the kebab's at %.0f; with no room before the kebab it must flip and open from it", g.PanelStart, g.SummaryStart) |
| 367 | } |
| 368 | } |
| 369 | if g.PanelStart < 0 || g.PanelEnd > g.VW+0.5 { |
| 370 | t.Errorf("the panel spans %.0f to %.0f from the leading edge of a %.0fpx viewport; it must stay on screen", g.PanelStart, g.PanelEnd, g.VW) |
| 371 | } |
| 372 | if g.PanelTop < g.SummaryBottom { |
| 373 | t.Errorf("the panel starts at %.0f, above the kebab's foot at %.0f; it must drop below the kebab", g.PanelTop, g.SummaryBottom) |
| 374 | } |
| 375 | }) |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | // TestARowsKebabSitsAtItsTrailingEdge: below 800px the list grid is |
| 380 | // three columns, name, one auto cell and the kebab's, whatever |
| 381 | // --rst-cols says. A row whose middle cells are all rst-m-hide has two |
| 382 | // children left, and auto-placement put the kebab in the auto cell: |
| 383 | // mid-row, beside the name, with the empty kebab column to its right. |
| 384 | // The kebab belongs in the last column at every width. |
| 385 | // |
| 386 | // With the kebab there, the empty auto column still cost the name a |
| 387 | // column gap (13.6px at 390) it could not use, so the name spans it. |
| 388 | // The control is row V, the same row with its middle cell shown: its |
| 389 | // name must stop at the cell, not run under it or push it down a line. |
| 390 | func TestARowsKebabSitsAtItsTrailingEdge(t *testing.T) { |
| 391 | for _, leg := range []struct { |
| 392 | name string |
| 393 | w int64 |
| 394 | coarse, narrow bool |
| 395 | }{{"1280 mouse", 1280, false, false}, {"600 mouse", 600, false, true}, {"390 touch", 390, true, true}} { |
| 396 | t.Run(leg.name, func(t *testing.T) { |
| 397 | rig, _ := rowMenuRig(t, leg.coarse) |
| 398 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 399 | defer cancel() |
| 400 | mustRun(t, ctx, chromedp.EmulateViewport(leg.w, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#list", chromedp.ByQuery), |
| 401 | chromedp.Evaluate(`(() => { const v = document.getElementById("row-a").cloneNode(true); v.id = "row-v"; v.querySelector(".rst-m-hide").classList.remove("rst-m-hide"); document.getElementById("list").append(v); return true; })()`, nil)) |
| 402 | requirePointer(t, ctx, leg.coarse) |
| 403 | var rows []struct { |
| 404 | ID string |
| 405 | KebabRight, RowEnd, NameEnd, Gap, Menu float64 |
| 406 | CellStart, NameBottom, CellTop float64 |
| 407 | } |
| 408 | at(t, ctx, `JSON.stringify([...document.querySelectorAll("#list [rst-lrow]")].map(r => { |
| 409 | const s = getComputedStyle(r), b = r.getBoundingClientRect(), n = r.querySelector(".rst-nm").getBoundingClientRect(), c = r.querySelector(":scope > span").getBoundingClientRect(); |
| 410 | return {ID: r.id, KebabRight: r.querySelector("[rst-row-menu] > summary").getBoundingClientRect().right, |
| 411 | RowEnd: b.right - parseFloat(s.paddingRight) - parseFloat(s.borderRightWidth), NameEnd: n.right, Gap: parseFloat(s.columnGap), |
| 412 | Menu: parseFloat(s.gridTemplateColumns.split(" ").pop()), CellStart: c.left, NameBottom: n.bottom, CellTop: c.top}; |
| 413 | }))`, &rows) |
| 414 | if len(rows) != 4 { |
| 415 | t.Fatalf("measured %d rows, want 4", len(rows)) |
| 416 | } |
| 417 | for _, r := range rows { |
| 418 | if d := r.RowEnd - r.KebabRight; d < -0.5 || d > 0.5 { |
| 419 | t.Errorf("%s: the kebab ends at %.0f, %.0fpx short of the row's trailing edge at %.0f", r.ID, r.KebabRight, d, r.RowEnd) |
| 420 | } |
| 421 | if !leg.narrow { |
| 422 | continue |
| 423 | } |
| 424 | if r.ID == "row-v" { |
| 425 | if d := r.CellStart - r.Gap - r.NameEnd; d < -0.5 || d > 0.5 || r.CellTop >= r.NameBottom { |
| 426 | t.Errorf("%s: the name ends at %.0f and the shown cell starts at %.0f, at %.0fpx down against the name's foot at %.0fpx; want the name to stop one gap before the cell, on the same line", r.ID, r.NameEnd, r.CellStart, r.CellTop, r.NameBottom) |
| 427 | } |
| 428 | continue |
| 429 | } |
| 430 | if want := r.RowEnd - r.Menu - r.Gap; r.NameEnd < want-0.5 || r.NameEnd > want+0.5 { |
| 431 | t.Errorf("%s: the name ends at %.0f, %.0fpx short of the kebab column's gap at %.0f; with every middle cell hidden it must take their column", r.ID, r.NameEnd, want-r.NameEnd, want) |
| 432 | } |
| 433 | } |
| 434 | }) |
| 435 | } |
| 436 | } |
| 437 | |