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 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.
7package check
8
9import (
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.
32func 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
41func 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
59func 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.
70func 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.
89func 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.
116func 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.
148const 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.
183func 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.
251func 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.
281func 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.
390func 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