| 1 | //go:build browser |
| 2 | |
| 3 | // The touch-size drives from rastrillo's ui/sizing_browser_test.go at |
| 4 | // the seed commit: the type step, the target floor, the icons inside |
| 5 | // controls, the calendar's docking and the floor block's desktop |
| 6 | // neutrality, each reading the sizing page through fixture so the |
| 7 | // page it measures is the page the rastrillo drive measured. The |
| 8 | // readers the drives share (readTargets, settleUntil, the type and |
| 9 | // font readers) live in rig_browser_test.go. |
| 10 | package check |
| 11 | |
| 12 | import ( |
| 13 | "context" |
| 14 | "encoding/json" |
| 15 | "fmt" |
| 16 | "math" |
| 17 | "net/http" |
| 18 | "strings" |
| 19 | "testing" |
| 20 | "time" |
| 21 | |
| 22 | "github.com/chromedp/chromedp" |
| 23 | "github.com/chromedp/chromedp/kb" |
| 24 | |
| 25 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 26 | ) |
| 27 | |
| 28 | // sizingFixture is the seeded sizing page: every partial with its own |
| 29 | // test data, every styleguide sample except the modal, and the extras |
| 30 | // (small parents, the bare and primary inputs, the button scale, the |
| 31 | // language menu, the short labels and the icon-and-text controls), as |
| 32 | // rastrillo's sizingFixture built it. The modal is its own page: its overlay |
| 33 | // is fixed over the whole viewport and would occlude every hit test on |
| 34 | // this one. |
| 35 | func sizingFixture(t *testing.T) string { |
| 36 | t.Helper() |
| 37 | return fixture(t, "sizing_fixture_test.go", "sizingFixture", "") |
| 38 | } |
| 39 | |
| 40 | // modalSample is the modal the sizing drives measure on a page of its |
| 41 | // own: the index names it as the example state that carries the |
| 42 | // markup rastrillo's Styleguide()["modal"] held. |
| 43 | func modalSample(t *testing.T, builder string) string { |
| 44 | t.Helper() |
| 45 | return fixtureExample(t, "sizing_browser_test.go", builder, "modal") |
| 46 | } |
| 47 | |
| 48 | // sizingIcon is an icon as fiesta.Icon emits one: svg.icon, sized by |
| 49 | // the control around it. |
| 50 | const sizingIcon = `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 12h16"/></svg>` |
| 51 | |
| 52 | // readType loads path at w×h and returns the token and field readings. |
| 53 | func readType(t *testing.T, ctx context.Context, url string, w, h int64) (typeReading, []fontReading, float64) { |
| 54 | t.Helper() |
| 55 | var rawType, rawFonts string |
| 56 | var primary float64 |
| 57 | if err := chromedp.Run(ctx, |
| 58 | chromedp.EmulateViewport(w, h), |
| 59 | chromedp.Navigate(url), |
| 60 | chromedp.WaitReady("body"), |
| 61 | chromedp.Evaluate(tokensJS, &rawType), |
| 62 | chromedp.Evaluate(fontsJS, &rawFonts), |
| 63 | chromedp.Evaluate(`parseFloat(getComputedStyle(document.getElementById("sizing-primary")).fontSize)`, &primary), |
| 64 | ); err != nil { |
| 65 | t.Fatalf("reading type at %dx%d: %v", w, h, err) |
| 66 | } |
| 67 | var tr typeReading |
| 68 | var fonts []fontReading |
| 69 | if err := json.Unmarshal([]byte(rawType), &tr); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | if err := json.Unmarshal([]byte(rawFonts), &fonts); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | // The small-parent fields and the bare and primary inputs are the |
| 76 | // cases this drive exists for; each is required by name, so losing |
| 77 | // one from the fixture fails here rather than shrinking the sweep. |
| 78 | named := map[string]bool{} |
| 79 | for _, f := range fonts { |
| 80 | named[f.Name] = true |
| 81 | } |
| 82 | for _, id := range []string{"sizing-bulk-search", "sizing-bulk-input", "sizing-bulk-note", "sizing-menu-search", "sizing-menu-input", "sizing-menu-note", "sizing-bare", "sizing-primary"} { |
| 83 | if !named[id] { |
| 84 | t.Fatalf("the sizing page has no %s; the fixture is not the one this drive measures", id) |
| 85 | } |
| 86 | } |
| 87 | return tr, fonts, primary |
| 88 | } |
| 89 | |
| 90 | // TestTextControlsAreSixteenPixelsOnSmallOrTouchScreens is the type |
| 91 | // half of §10.1 of the mobile ergonomics design spec |
| 92 | // (docs/superpowers/specs/2026-09-30-mobile-ergonomics-design.md): at |
| 93 | // 390 with touch, at 1024 with touch (the pointer half of the query |
| 94 | // alone) and at 600 with a mouse (the width half alone), the four |
| 95 | // tokens are one step up, every text-entry control is at least 16px — |
| 96 | // including the ones in a bulk bar and a menu panel — and the primary |
| 97 | // field is exactly --rst-fs-lg, 19px (the 1em bug gave 16). |
| 98 | func TestTextControlsAreSixteenPixelsOnSmallOrTouchScreens(t *testing.T) { |
| 99 | pages := map[string]string{"/": sizingDoc("sizing", sizingFixture(t))} |
| 100 | for _, coarse := range []bool{true, false} { |
| 101 | rig := sizingRig(t, coarse, pages) |
| 102 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 103 | legs := []struct { |
| 104 | name string |
| 105 | w, h int64 |
| 106 | }{{"600x800 mouse, the width half alone", 600, 800}} |
| 107 | if coarse { |
| 108 | legs = []struct { |
| 109 | name string |
| 110 | w, h int64 |
| 111 | }{{"390x844 touch", 390, 844}, {"1024x768 touch, the pointer half alone", 1024, 768}} |
| 112 | } |
| 113 | for _, leg := range legs { |
| 114 | tr, fonts, primary := readType(t, ctx, rig.Origin+"/", leg.w, leg.h) |
| 115 | requirePointer(t, ctx, coarse) |
| 116 | // Each leg exists to exercise one half of the query: the 1024 |
| 117 | // touch leg proves the pointer half only if the page really is |
| 118 | // wider than 40rem, and the 600 mouse leg the width half only if |
| 119 | // the pointer really is fine. |
| 120 | if tr.Width != int(leg.w) || tr.Coarse != coarse { |
| 121 | t.Fatalf("%s: the page read width %d, coarse %v; this leg needs width %d, coarse %v", leg.name, tr.Width, tr.Coarse, leg.w, coarse) |
| 122 | } |
| 123 | if tr.Lg != 19 || tr.Base != 16 || tr.Sm != 14 || tr.Xs != 13 || tr.Body != 16 { |
| 124 | t.Errorf("%s: tokens %v/%v/%v/%v body %v, want 19/16/14/13 body 16", leg.name, tr.Lg, tr.Base, tr.Sm, tr.Xs, tr.Body) |
| 125 | } |
| 126 | for _, f := range fonts { |
| 127 | if f.Px < 16 { |
| 128 | t.Errorf("%s: %s is %vpx; a phone zooms the page when it is focused", leg.name, f.Name, f.Px) |
| 129 | } |
| 130 | } |
| 131 | if primary != 19 { |
| 132 | t.Errorf("%s: the primary field is %vpx, want --rst-fs-lg (19px); 16 is the 1em trap", leg.name, primary) |
| 133 | } |
| 134 | } |
| 135 | cancel() |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // TestDesktopDensityIsPinned is §10.1's 1280×900 mouse leg: every value |
| 140 | // below is today's, measured, and any change fails, except the |
| 141 | // deliberate desktop changes, pinned at their new values: the whole-row |
| 142 | // target (§1.5), which TestTheWholeRowIsTheTarget holds, and the 32px |
| 143 | // desktop floor with its 16px icons and 12px carets (2026-10-07), which |
| 144 | // lifted the small button, the kebab and the row checkbox's label to 32, |
| 145 | // and the even button scale (2026-10-09), which draws the small button |
| 146 | // at 26 with its hit area at the floor. Desktop targets had ranged from 24 to 36px, sized one control at a |
| 147 | // time; the floor is one number for all of them. |
| 148 | func TestDesktopDensityIsPinned(t *testing.T) { |
| 149 | rig := sizingRig(t, false, map[string]string{"/": sizingDoc("sizing", sizingFixture(t)), "/modal": sizingDoc("modal", modalSample(t, "TestDesktopDensityIsPinned"))}) |
| 150 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 151 | defer cancel() |
| 152 | tr, fonts, primary := readType(t, ctx, rig.Origin+"/", 1280, 900) |
| 153 | requirePointer(t, ctx, false) |
| 154 | if tr.Lg != 17 || tr.Base != 14 || tr.Sm != 12.5 || tr.Xs != 11.5 || tr.Body != 14 { |
| 155 | t.Errorf("desktop tokens %v/%v/%v/%v body %v, want 17/14/12.5/11.5 body 14", tr.Lg, tr.Base, tr.Sm, tr.Xs, tr.Body) |
| 156 | } |
| 157 | if primary != 17 { |
| 158 | t.Errorf("desktop primary field %vpx, want 17", primary) |
| 159 | } |
| 160 | // The control for the touch drive's "every field ≥ 16": on a desktop |
| 161 | // some field is under 16, so the instrument can see a small one. |
| 162 | small := false |
| 163 | for _, f := range fonts { |
| 164 | small = small || f.Px < 16 |
| 165 | } |
| 166 | if !small { |
| 167 | t.Error("CONTROL: every text control is ≥16px on a 1280px desktop, so the touch drive's ≥16 reading proves nothing") |
| 168 | } |
| 169 | var raw string |
| 170 | if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { |
| 171 | const box = sel => { const r = document.querySelector(sel).getBoundingClientRect(); return [Math.round(r.width * 100) / 100, Math.round(r.height * 100) / 100]; }; |
| 172 | return JSON.stringify({Sm: box("#sizing-btn-sm")[1], Md: box("#sizing-btn")[1], Lg: box("#sizing-btn-lg")[1], |
| 173 | Kebab: box('[data-sample="list-grid"] [rst-row-menu] > summary'), Label: box('[data-sample="selbox"] [rst-selbox]'), |
| 174 | Box: box('[data-sample="selbox"] [rst-selbox] input'), |
| 175 | Chip: box([...document.querySelectorAll("[rst-pagination] a")].filter(a => /^\d$/.test(a.textContent.trim())).map(a => "#" + (a.id || (a.id = "sizing-chip")))[0])}); |
| 176 | })()`, &raw)); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | var d struct { |
| 180 | Sm, Md, Lg float64 |
| 181 | Kebab, Label, Box [2]float64 |
| 182 | Chip [2]float64 |
| 183 | } |
| 184 | if err := json.Unmarshal([]byte(raw), &d); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | // Measured before the touch rules existed: 27.69, 33.88 and 43.97, a |
| 188 | // 26×26 kebab, a 16px checkbox in a 24×24 label. The floor then |
| 189 | // lifted the small button to 32, beside a 34px default, and the |
| 190 | // three sizes read as two. They are now an even scale, 26/34/42 |
| 191 | // (2026-10-09), with sm's hit area at the floor, which |
| 192 | // TestEveryTapTargetIsAtLeast44Pixels measures; the drawn heights are |
| 193 | // pinned here and on touch in TestTheButtonSizesAreAnEvenScale. |
| 194 | if math.Round(d.Sm) != 26 || math.Round(d.Md) != 34 || math.Round(d.Lg) != 42 { |
| 195 | t.Errorf("desktop button heights %v/%v/%v, want 26/34/42", d.Sm, d.Md, d.Lg) |
| 196 | } |
| 197 | if d.Kebab != [2]float64{32, 32} { |
| 198 | t.Errorf("desktop kebab %v, want 32×32", d.Kebab) |
| 199 | } |
| 200 | if d.Box != [2]float64{16, 16} || d.Label != [2]float64{32, 32} { |
| 201 | t.Errorf("desktop row checkbox %v in a label %v, want the 16px box in a 32×32 target", d.Box, d.Label) |
| 202 | } |
| 203 | // A one-digit page chip was 53.19px wide before the floor reached the |
| 204 | // desktop (its 2rem minimum on the content box, with its padding and |
| 205 | // border on top). The floor's border-box made it 32; the floor |
| 206 | // raises the height to 32 and must not narrow it. |
| 207 | if d.Chip != [2]float64{53.19, 32} { |
| 208 | t.Errorf("desktop page chip %v, want 53.2×32: the old width, at the floor's height", d.Chip) |
| 209 | } |
| 210 | assertIcons(t, ctx, "1280 mouse") |
| 211 | // The floor is a minimum on the whole box, padding and border |
| 212 | // included. Measured on the content box it added the padding on top: |
| 213 | // a dropdown summary already 33.8px tall grew to 44.8, the topbar |
| 214 | // with it to 61.8, and the bulk bar's Actions to 41. These are exact, |
| 215 | // because a floor that only checks "at least 32" cannot see growth. |
| 216 | var rawFloor string |
| 217 | if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { |
| 218 | const h = sel => Math.round(document.querySelector(sel).getBoundingClientRect().height * 10) / 10; |
| 219 | const group = document.querySelector('[data-sample="dropdown"] [rst-menu-group] > summary'); |
| 220 | for (let d = group.closest("details"); d; d = d.parentElement && d.parentElement.closest("details")) d.open = true; |
| 221 | const out = {Summary: h('[data-sample="dropdown"] [rst-dropdown] > summary'), Bulk: h('[data-partial="bulk-bar"] [rst-dropdown] > summary'), |
| 222 | Group: Math.round(group.getBoundingClientRect().height * 10) / 10, Help: h('[data-sample="help"] [rst-help]'), |
| 223 | Bar: h('[data-sample="shell-topbar"] [rst-shell-bar]'), |
| 224 | Search: h('[rst-search]:has([rst-search-clear])'), SearchInput: h('[rst-search]:has([rst-search-clear]) input[type="search"]'), |
| 225 | BulkSearch: h('#sizing-bulk [rst-search]')}; |
| 226 | document.querySelectorAll("details[open]").forEach(d => { d.open = false; }); |
| 227 | return JSON.stringify(out); |
| 228 | })()`, &rawFloor)); err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | var f struct{ Summary, Bulk, Group, Help, Bar, ModalNav, Search, SearchInput, BulkSearch float64 } |
| 232 | if err := json.Unmarshal([]byte(rawFloor), &f); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/modal"), chromedp.WaitReady("body"), |
| 236 | chromedp.Evaluate(`Math.round(document.querySelector("[rst-modal-panel] > nav a").getBoundingClientRect().height * 10) / 10`, &f.ModalNav)); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | for _, c := range []struct { |
| 240 | name string |
| 241 | got, want float64 |
| 242 | }{ |
| 243 | {"a dropdown summary (its own padding, over the floor)", f.Summary, 33.8}, |
| 244 | {"the bulk bar's Actions summary", f.Bulk, 32}, |
| 245 | {"a menu-group summary", f.Group, 32}, |
| 246 | {"the help button, border included", f.Help, 32}, |
| 247 | {"the topbar's bar", f.Bar, 50.8}, |
| 248 | {"a modal nav link (its own padding, over the floor)", f.ModalNav, 33.8}, |
| 249 | // Before the floor reached the desktop the search box was 35.6 |
| 250 | // (a 33 without its clear link) around a 21.4px input, under the |
| 251 | // floor. Its input and clear link are the targets, and at 32 with |
| 252 | // the box's own 0.3rem block padding on top the box was 43.6 and |
| 253 | // every list bar grew with it. The padding goes and the box is |
| 254 | // the floor plus its border; the input fills it. |
| 255 | {"a search box with its clear link", f.Search, 34}, |
| 256 | {"a search box's input", f.SearchInput, 32}, |
| 257 | {"the bulk bar's search box", f.BulkSearch, 34}, |
| 258 | } { |
| 259 | if c.got != c.want { |
| 260 | t.Errorf("desktop %s is %vpx tall, want %v", c.name, c.got, c.want) |
| 261 | } |
| 262 | } |
| 263 | c := openCalendar(t, ctx, rig.Origin+"/", 1280, 900) |
| 264 | if c.Position != "absolute" || c.InlineSize != "288px" { |
| 265 | t.Errorf("desktop calendar is %s, inline-size %s; want today's anchored 18rem (288px) panel", c.Position, c.InlineSize) |
| 266 | } |
| 267 | // The calendar is outside the target drive (it opens by script), so |
| 268 | // its controls are held to the desktop floor here. |
| 269 | if c.MinDayW < 31.5 || c.MinDayH < 31.5 || c.MinNavW < 31.5 || c.MinNavH < 31.5 { |
| 270 | t.Errorf("desktop calendar: smallest day %.1f×%.1f, month button %.1f×%.1f; want both at least 32×32", c.MinDayW, c.MinDayH, c.MinNavW, c.MinNavH) |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | // TestAnIconKeepsItsSpaceBesideItsText: a menu item (a link or a |
| 275 | // button, in both spellings: a bulk action is a button), a nav link, the |
| 276 | // brand, the back link, a row action and the sign-in link are flex boxes |
| 277 | // on a phone, and the ones born inline are on a desktop too, so the |
| 278 | // floor reaches them. A flex box drops the whitespace between an icon |
| 279 | // and the word after it, and the icon touched the word (4px of space |
| 280 | // became 0); each sets a gap, and a control that stays a block keeps its |
| 281 | // space. This reads the space between the icon's edge and the word's, |
| 282 | // with a mouse at 1280 and a coarse pointer at 390, in both directions |
| 283 | // of text. |
| 284 | func TestAnIconKeepsItsSpaceBesideItsText(t *testing.T) { |
| 285 | ids := []string{"icon-item", "icon-button", "icon-button-class", "icon-nav", "icon-brand", "icon-back", "icon-action", "icon-signin"} |
| 286 | for _, leg := range []struct { |
| 287 | name string |
| 288 | w, h int64 |
| 289 | coarse bool |
| 290 | }{{"1280x900 mouse", 1280, 900, false}, {"390x844 touch", 390, 844, true}} { |
| 291 | rig := sizingRig(t, leg.coarse, map[string]string{"/": sizingDoc("sizing", sizingFixture(t))}) |
| 292 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 293 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body")); err != nil { |
| 294 | cancel() |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | requirePointer(t, ctx, leg.coarse) |
| 298 | for _, dir := range []string{"ltr", "rtl"} { |
| 299 | var raw string |
| 300 | if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { |
| 301 | document.documentElement.dir = %q; |
| 302 | document.querySelectorAll('[data-extra="icon-text"] details').forEach(d => { d.open = true; }); |
| 303 | const out = {}; |
| 304 | for (const id of %s) { |
| 305 | const a = document.getElementById(id); |
| 306 | if (!a.checkVisibility()) continue; |
| 307 | const i = a.querySelector("svg").getBoundingClientRect(); |
| 308 | // From the word's first letter: in a block the space is |
| 309 | // the start of the text node, and is the gap itself. |
| 310 | const r = document.createRange(), w = a.lastChild; r.setStart(w, w.textContent.search(/\S/)); r.setEnd(w, w.textContent.length); |
| 311 | const tx = r.getBoundingClientRect(); |
| 312 | out[id] = document.documentElement.dir === "rtl" ? i.left - tx.right : tx.left - i.right; |
| 313 | } |
| 314 | document.querySelectorAll('[data-extra="icon-text"] details').forEach(d => { d.open = false; }); |
| 315 | return JSON.stringify(out); |
| 316 | })()`, dir, "[\""+strings.Join(ids, "\",\"")+"\"]"), &raw)); err != nil { |
| 317 | cancel() |
| 318 | t.Fatal(err) |
| 319 | } |
| 320 | var got map[string]float64 |
| 321 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 322 | cancel() |
| 323 | t.Fatal(err) |
| 324 | } |
| 325 | // The rail is not drawn on a phone; every other control is. |
| 326 | if want := len(ids) - map[bool]int{true: 1, false: 0}[leg.coarse]; len(got) < want { |
| 327 | t.Errorf("%s, %s: measured %d of the icon-and-text controls, want %d: %v", leg.name, dir, len(got), want, got) |
| 328 | } |
| 329 | for id, gap := range got { |
| 330 | if gap < 3 { |
| 331 | t.Errorf("%s, %s: in #%s the icon is %.1fpx from its text; it touches the word", leg.name, dir, id, gap) |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | cancel() |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | // measureFn is the measuring half every target reading shares, written |
| 340 | // once so the closed page, each opened overlay and the modal are the |
| 341 | // same instrument. The activation area is the element a tap activates: |
| 342 | // a checkbox or radio is measured as its label, a stretched link (an |
| 343 | // absolutely positioned ::after with content) as its positioned |
| 344 | // ancestor, the row, and a control that contains its own such |
| 345 | // pseudo-element (an sm button, drawn under the floor) as its border box grown by that |
| 346 | // hit area, with each edge of it hit-tested as well as the centre. Each area is scrolled into view (scrollIntoView |
| 347 | // scrolls every scrolling ancestor, a rail or a menu panel included) |
| 348 | // and measured again after the scroll, and the point tested is the |
| 349 | // centre of the part inside the viewport. elementFromPoint there must |
| 350 | // return the control or a descendant of it (a switch's track, a |
| 351 | // summary's icon), or for a labelled input its label: anything else is |
| 352 | // an occlusion failure, never a skip. Only a control that is not |
| 353 | // rendered at all is skipped. An element marked |
| 354 | // data-sizing-not-an-idiom is an app's own control on the fixture for |
| 355 | // the font test and is not an idiom this floor covers. |
| 356 | const measureFn = `function measure(root, skip) { |
| 357 | const CONTROLS = 'a[href], button, summary, input:not([type=hidden]), select, textarea, [rst-cal-day], [role="option"]'; |
| 358 | const describe = el => el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + |
| 359 | [...el.attributes].filter(a => a.name.startsWith("rst-")).map(a => "[" + a.name + (a.value ? "=" + a.value : "") + "]").join("") + |
| 360 | " “" + (el.getAttribute("aria-label") || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 30) + "”"; |
| 361 | const rendered = el => { |
| 362 | if (el.checkVisibility && !el.checkVisibility({visibilityProperty: true})) return false; |
| 363 | const r = el.getBoundingClientRect(); |
| 364 | return r.width >= 2 && r.height >= 2 && getComputedStyle(el).clipPath !== "inset(50%)"; |
| 365 | }; |
| 366 | const area = el => { |
| 367 | if (el.matches("input[type=checkbox], input[type=radio]")) { |
| 368 | const l = el.closest("label") || (el.id && document.querySelector('label[for="' + el.id + '"]')); |
| 369 | if (l) return l; |
| 370 | } |
| 371 | // A link positioned itself contains its own ::after, so that is a |
| 372 | // hit area (an sm link-button's) rather than a stretch to the row. |
| 373 | if (el.tagName === "A" && getComputedStyle(el).position === "static") { |
| 374 | const after = getComputedStyle(el, "::after"); |
| 375 | // pointer-events: none is a tooltip ([rst-tip]::after), which a tap |
| 376 | // passes through; only an overlay that takes the tap stretches the |
| 377 | // link, and mistaking the tip for one measured the whole page. |
| 378 | if (after.content !== "none" && after.position === "absolute" && after.pointerEvents !== "none" && el.offsetParent) return el.offsetParent; |
| 379 | } |
| 380 | return el; |
| 381 | }; |
| 382 | // The box a tap reaches: the element's border box, grown by its own |
| 383 | // ::before or ::after where that is an absolutely positioned hit area |
| 384 | // the element contains (sm's, drawn under the floor). The ::after's insets are |
| 385 | // used values from the padding box, so the borders go back in. |
| 386 | const reach = el => { |
| 387 | const r = el.getBoundingClientRect(), s = getComputedStyle(el), px = v => parseFloat(v) || 0; |
| 388 | const u = {left: r.left, top: r.top, right: r.right, bottom: r.bottom}; |
| 389 | for (const pseudo of ["::before", "::after"]) { |
| 390 | const a = getComputedStyle(el, pseudo); |
| 391 | if (s.position === "static" || a.content === "none" || a.position !== "absolute" || a.pointerEvents === "none") continue; |
| 392 | u.top = Math.min(u.top, r.top + px(s.borderTopWidth) + px(a.top)); u.bottom = Math.max(u.bottom, r.bottom - px(s.borderBottomWidth) - px(a.bottom)); |
| 393 | u.left = Math.min(u.left, r.left + px(s.borderLeftWidth) + px(a.left)); u.right = Math.max(u.right, r.right - px(s.borderRightWidth) - px(a.right)); |
| 394 | } |
| 395 | u.width = u.right - u.left; u.height = u.bottom - u.top; |
| 396 | return {box: u, grown: u.width > r.width + 0.5 || u.height > r.height + 0.5}; |
| 397 | }; |
| 398 | const out = [], seen = new Set(); |
| 399 | root.querySelectorAll(CONTROLS).forEach(el => { |
| 400 | if (skip && skip(el)) return; |
| 401 | if (el.closest("[data-sizing-not-an-idiom]")) return; |
| 402 | // Inert content (a modal's backdrop) is unreachable on purpose. |
| 403 | if (el.closest("[inert]")) return; |
| 404 | const a = area(el); |
| 405 | if (seen.has(a) || !rendered(a)) return; |
| 406 | seen.add(a); |
| 407 | el.setAttribute("data-measured", ""); |
| 408 | a.setAttribute("data-measured-area", ""); |
| 409 | a.scrollIntoView({block: "center", inline: "center"}); |
| 410 | const {box: r, grown} = reach(a); |
| 411 | const x0 = Math.max(r.left, 0), y0 = Math.max(r.top, 0), x1 = Math.min(r.right, innerWidth), y1 = Math.min(r.bottom, innerHeight); |
| 412 | // A grown area is only as big as the taps it really takes: each |
| 413 | // edge's middle, half a pixel in, must reach the control too, or a |
| 414 | // neighbour over the hit area (or a clipping ancestor) has it. |
| 415 | const points = [[(x0 + x1) / 2, (y0 + y1) / 2]]; |
| 416 | if (grown) points.push([(x0 + x1) / 2, y0 + 0.5], [(x0 + x1) / 2, y1 - 0.5], [x0 + 0.5, (y0 + y1) / 2], [x1 - 0.5, (y0 + y1) / 2]); |
| 417 | let hit = null; |
| 418 | for (const [x, y] of points) { |
| 419 | hit = document.elementFromPoint(x, y); |
| 420 | if (!(hit === el || (hit && el.contains(hit)) || (a !== el && a.tagName === "LABEL" && a.contains(hit)))) break; |
| 421 | } |
| 422 | out.push({Name: describe(el), W: r.width, H: r.height, |
| 423 | Fits: r.left >= -0.5 && r.top >= -0.5 && r.right <= innerWidth + 0.5 && r.bottom <= innerHeight + 0.5, |
| 424 | // Ownership is the control, a descendant of it (a summary's icon, |
| 425 | // a switch's track), or, for a labelled input only, its label and |
| 426 | // what the label holds. Never "anything in the measured box": for a |
| 427 | // stretched link the box is the whole row, and a row holds other |
| 428 | // controls, so a hit on the kebab must not count for the name link. |
| 429 | Owns: !!hit && (hit === el || el.contains(hit) || (a !== el && a.tagName === "LABEL" && a.contains(hit))), |
| 430 | Hit: hit ? describe(hit) : "nothing", |
| 431 | Inline: el.tagName === "A" && !el.hasAttribute("rst-btn") && !!el.closest("p, [rst-field-help]") && !el.closest("nav")}); |
| 432 | }); |
| 433 | return out; |
| 434 | }` |
| 435 | |
| 436 | // targetsJS measures the page as it loads: every overlay closed. |
| 437 | const targetsJS = `(() => { ` + measureFn + `; return JSON.stringify(measure(document)); })()` |
| 438 | |
| 439 | // overlayJS opens the i-th <details> on the page (and every <details> |
| 440 | // around it), measures what it revealed — its own summary was measured |
| 441 | // closed — and closes everything again, so one open overlay never |
| 442 | // counts as occluding the next. It is a function the caller applies to |
| 443 | // i, not a Sprintf format: measureFn holds "inset(50%)", which a format |
| 444 | // would mangle into a script error. |
| 445 | const overlayJS = `(async (i) => { ` + measureFn + `; |
| 446 | const all = document.querySelectorAll("details"); |
| 447 | if (i >= all.length) return JSON.stringify({Done: true}); |
| 448 | const d = all[i]; |
| 449 | for (let p = d; p; p = p.parentElement && p.parentElement.closest("details")) p.open = true; |
| 450 | // A menu inside the topbar's or console's tail is behind a SIBLING |
| 451 | // disclosure (the tail is the shell menu's next sibling, not its |
| 452 | // content), which the ancestor walk above cannot see. |
| 453 | const tail = d.closest("[rst-shell-tail], .rst-shell__tail"); |
| 454 | if (tail && tail.previousElementSibling && tail.previousElementSibling.matches("details")) tail.previousElementSibling.open = true; |
| 455 | // A menu panel is position: fixed and anchored to its summary where |
| 456 | // anchor positioning is supported, and scrollIntoView on a fixed |
| 457 | // panel's items cannot scroll the page, so the anchor would stay |
| 458 | // wherever the last control left the page and the panel with it. |
| 459 | // Bring the outermost summary into view first, as a tap on it would, |
| 460 | // and wait two frames: an anchored panel takes up its anchor's new |
| 461 | // scroll position at the next frame, so a rect read at once is stale. |
| 462 | let outer = d; |
| 463 | while (outer.parentElement && outer.parentElement.closest("details")) outer = outer.parentElement.closest("details"); |
| 464 | outer.querySelector(":scope > summary").scrollIntoView({block: "center", inline: "center"}); |
| 465 | await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); |
| 466 | const summary = d.querySelector(":scope > summary"); |
| 467 | const got = measure(d, el => el === summary); |
| 468 | // A shell disclosure reveals its next SIBLING (the topbar's tail, the |
| 469 | // old sidebar's rail), not its own content. |
| 470 | if (d.matches("[rst-shell-menu], [rst-shell-chrome]") && d.nextElementSibling) got.push(...measure(d.nextElementSibling)); |
| 471 | document.querySelectorAll("details[open]").forEach(x => { x.open = false; }); |
| 472 | return JSON.stringify({Done: false, Targets: got}); |
| 473 | })` |
| 474 | |
| 475 | // assertTargets holds every measured control to 44×44, the floor on a |
| 476 | // small or touch screen. |
| 477 | func assertTargets(t *testing.T, where string, got []targetReading) { |
| 478 | t.Helper() |
| 479 | assertTargetsAt(t, where, got, 44) |
| 480 | } |
| 481 | |
| 482 | // assertTargetsAt holds every measured control to floor×floor, whole in |
| 483 | // the viewport and hittable at its centre. A link in running text is the |
| 484 | // one exemption (WCAG 2.5.8's inline exception). The floor is 44 on a |
| 485 | // small or touch screen and 32 on a desktop with a mouse. |
| 486 | func assertTargetsAt(t *testing.T, where string, got []targetReading, floor float64) { |
| 487 | t.Helper() |
| 488 | for _, g := range got { |
| 489 | switch { |
| 490 | case !g.Fits: |
| 491 | t.Errorf("%s: %s is %.1f×%.1f and cannot be brought wholly into the viewport; too big to hit", where, g.Name, g.W, g.H) |
| 492 | case !g.Owns: |
| 493 | t.Errorf("%s: %s is occluded: a tap at its centre lands on %s", where, g.Name, g.Hit) |
| 494 | case g.Inline: |
| 495 | case g.W < floor-0.5 || g.H < floor-0.5: |
| 496 | t.Errorf("%s: %s is %.1f×%.1f, under the %v×%v floor", where, g.Name, g.W, g.H, floor, floor) |
| 497 | } |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | // measureEverything reads the closed page, then every overlay alone, |
| 502 | // then the combobox's list and the date field's list, each opened by |
| 503 | // the script that owns it, holding each control to floor. |
| 504 | func measureEverything(t *testing.T, ctx context.Context, where string, floor float64) int { |
| 505 | t.Helper() |
| 506 | settleUntil(t, ctx, `!!document.querySelector("[rst-combo] [role=combobox]") && !!document.querySelector("[rst-dtp-pick]")`) |
| 507 | got := readTargets(t, ctx, targetsJS) |
| 508 | n := len(got) |
| 509 | assertTargetsAt(t, where+", overlays closed", got, floor) |
| 510 | for i := 0; ; i++ { |
| 511 | var raw string |
| 512 | if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf("%s(%d)", overlayJS, i), &raw, awaitPromise)); err != nil { |
| 513 | t.Fatalf("%s: opening overlay %d: %v", where, i, err) |
| 514 | } |
| 515 | var o struct { |
| 516 | Done bool |
| 517 | Targets []targetReading |
| 518 | } |
| 519 | if err := json.Unmarshal([]byte(raw), &o); err != nil { |
| 520 | t.Fatal(err) |
| 521 | } |
| 522 | if o.Done { |
| 523 | break |
| 524 | } |
| 525 | n += len(o.Targets) |
| 526 | assertTargetsAt(t, fmt.Sprintf("%s, overlay %d open", where, i), o.Targets, floor) |
| 527 | } |
| 528 | for _, open := range []struct{ name, js, list string }{ |
| 529 | {"the combobox's list", `(() => { const i = document.querySelector("#sizing-combo").closest("[rst-field]").querySelector("[role=combobox]"); i.focus(); i.click(); return true; })()`, "[rst-combo-list]"}, |
| 530 | {"the date field's list", `(() => { const i = document.querySelector("[rst-dtp] [role=combobox]"); i.focus(); i.value = "t"; i.dispatchEvent(new Event("input", {bubbles: true})); return true; })()`, "[rst-dtp-list]"}, |
| 531 | } { |
| 532 | if err := chromedp.Run(ctx, chromedp.Evaluate(open.js, nil)); err != nil { |
| 533 | t.Fatalf("%s: opening %s: %v", where, open.name, err) |
| 534 | } |
| 535 | settleUntil(t, ctx, `(() => { const l = document.querySelector("`+open.list+`"); return !!l && l.checkVisibility() && l.querySelectorAll("[role=option]").length > 0; })()`) |
| 536 | got := readTargets(t, ctx, `(() => { `+measureFn+`; return JSON.stringify(measure(document.querySelector("`+open.list+`"))); })()`) |
| 537 | if len(got) == 0 { |
| 538 | t.Fatalf("%s: %s opened with no options to measure", where, open.name) |
| 539 | } |
| 540 | n += len(got) |
| 541 | assertTargetsAt(t, where+", "+open.name, got, floor) |
| 542 | chromedp.Run(ctx, chromedp.KeyEvent(kb.Escape)) |
| 543 | } |
| 544 | return n |
| 545 | } |
| 546 | |
| 547 | // iconsJS reads every rendered icon inside a control on the page as |
| 548 | // it loads: fiesta.Icon's svg.icon, sized by the control and not by |
| 549 | // the text around it, and whether it sits in a caret. |
| 550 | const iconsJS = `JSON.stringify([...document.querySelectorAll(":is(a[href], button, summary, label, [role=button]) svg.icon")] |
| 551 | .filter(i => i.checkVisibility({visibilityProperty: true})) |
| 552 | .map(i => { const r = i.getBoundingClientRect(), c = i.closest("a[href], button, summary, label, [role=button]"); |
| 553 | return {Name: c.tagName.toLowerCase() + [...c.attributes].filter(a => a.name.startsWith("rst-")).map(a => "[" + a.name + "]").join("") + " “" + (c.getAttribute("aria-label") || c.textContent).trim().slice(0, 30) + "”", |
| 554 | Caret: !!i.closest("[rst-caret], .rst-caret"), W: Math.round(r.width * 100) / 100, H: Math.round(r.height * 100) / 100}; }))` |
| 555 | |
| 556 | // iconReading is one icon inside a control, as iconsJS reads it. |
| 557 | type iconReading struct { |
| 558 | Name string |
| 559 | Caret bool |
| 560 | W, H float64 |
| 561 | } |
| 562 | |
| 563 | // readIcons reads every icon inside a control on the page. |
| 564 | func readIcons(t *testing.T, ctx context.Context, where string) []iconReading { |
| 565 | t.Helper() |
| 566 | var raw string |
| 567 | if err := chromedp.Run(ctx, chromedp.Evaluate(iconsJS, &raw)); err != nil { |
| 568 | t.Fatalf("%s: reading icons: %v", where, err) |
| 569 | } |
| 570 | var got []iconReading |
| 571 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 572 | t.Fatal(err) |
| 573 | } |
| 574 | return got |
| 575 | } |
| 576 | |
| 577 | // assertIconSizes holds every icon read to 16×16 and a caret's chevron |
| 578 | // to 12×12, and returns how many of each kind it read. |
| 579 | func assertIconSizes(t *testing.T, where string, got []iconReading) (carets, icons int) { |
| 580 | t.Helper() |
| 581 | for _, g := range got { |
| 582 | want := 16.0 |
| 583 | if g.Caret { |
| 584 | want, carets = 12, carets+1 |
| 585 | } else { |
| 586 | icons++ |
| 587 | } |
| 588 | if g.W != want || g.H != want { |
| 589 | t.Errorf("%s: the icon in %s is %v×%v, want %v×%v", where, g.Name, g.W, g.H, want, want) |
| 590 | } |
| 591 | } |
| 592 | return carets, icons |
| 593 | } |
| 594 | |
| 595 | // assertIcons holds every icon inside a control to 16×16 and a caret's |
| 596 | // chevron to 12×12. An icon sized from its font came out 9 to 20px |
| 597 | // depending on the text beside it (a caret in an xs summary, a menu |
| 598 | // icon in a 1.25rem button), so two icons in one toolbar disagreed. At |
| 599 | // least one of each kind must be read, or a fixture that lost its |
| 600 | // carets would pass with nothing measured. |
| 601 | func assertIcons(t *testing.T, ctx context.Context, where string) { |
| 602 | t.Helper() |
| 603 | carets, icons := assertIconSizes(t, where, readIcons(t, ctx, where)) |
| 604 | if carets == 0 || icons == 0 { |
| 605 | t.Errorf("%s: read %d caret icons and %d other icons in controls; the fixture must show at least one of each", where, carets, icons) |
| 606 | } |
| 607 | } |
| 608 | |
| 609 | // TestAnIconOutsideAControlFollowsItsText pins where the fixed icon |
| 610 | // size stops. Inside a control an icon is 16px whatever the text beside |
| 611 | // it (assertIcons); everywhere else it is 1em and follows its text, as |
| 612 | // it always did: an icon in a 24px heading is 24px, one in a line of |
| 613 | // prose or in a link in that line is the line's size. A 1rem .icon |
| 614 | // made every one of them 16px, shrinking heading icons and growing |
| 615 | // small print's. The button beside them is the control: 16px. |
| 616 | func TestAnIconOutsideAControlFollowsItsText(t *testing.T) { |
| 617 | page := map[string]string{"/": sizingDoc("icons", `<div rst-page>`+ |
| 618 | `<h2 id="heading" style="font-size: 24px">`+sizingIcon+` Orders</h2>`+ |
| 619 | `<p id="prose" style="font-size: 12px">`+sizingIcon+` Shipped. <a href="#guide" id="prose-link">`+sizingIcon+` Read the guide</a></p>`+ |
| 620 | `<button rst-btn type="button" id="button">`+sizingIcon+` Save</button></div>`)} |
| 621 | for _, leg := range []struct { |
| 622 | name string |
| 623 | w, h int64 |
| 624 | coarse bool |
| 625 | }{{"1280x900 mouse", 1280, 900, false}, {"390x844 touch", 390, 844, true}} { |
| 626 | rig := sizingRig(t, leg.coarse, page) |
| 627 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 628 | var got map[string]float64 |
| 629 | var raw string |
| 630 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#button", chromedp.ByQuery), |
| 631 | chromedp.Evaluate(`JSON.stringify(Object.fromEntries(["heading", "prose", "prose-link", "button"].map(id => [id, document.querySelector("#" + id + " > svg").getBoundingClientRect().height])))`, &raw)); err != nil { |
| 632 | cancel() |
| 633 | t.Fatal(err) |
| 634 | } |
| 635 | cancel() |
| 636 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 637 | t.Fatal(err) |
| 638 | } |
| 639 | for id, want := range map[string]float64{"heading": 24, "prose": 12, "prose-link": 12, "button": 16} { |
| 640 | if got[id] != want { |
| 641 | t.Errorf("%s: the icon in #%s is %vpx, want %v", leg.name, id, got[id], want) |
| 642 | } |
| 643 | } |
| 644 | } |
| 645 | } |
| 646 | |
| 647 | // assertCovered is what makes "every control passed" mean something, |
| 648 | // in two halves. Every rendered control on the closed page was measured |
| 649 | // (none skipped by a bug in measure itself), and for every inventory row |
| 650 | // on this page at least one element matching its Probe was measured |
| 651 | // (measureFn marks each with data-measured), so an idiom cannot drop out |
| 652 | // of the drive because its only fixture went away. Each named fixture |
| 653 | // control is also required by id in TestTextControlsAreSixteenPixels. |
| 654 | func assertCovered(t *testing.T, ctx context.Context, where, page string, wide bool) { |
| 655 | t.Helper() |
| 656 | var missed []string |
| 657 | if err := chromedp.Run(ctx, chromedp.Evaluate(`[...document.querySelectorAll('a[href], button, summary, input:not([type=hidden]), select, textarea')] |
| 658 | .filter(e => !e.hasAttribute("data-measured") && !e.closest("[data-sizing-not-an-idiom], [inert]") && e.checkVisibility({visibilityProperty: true}) && getComputedStyle(e).clipPath !== "inset(50%)" && e.getBoundingClientRect().width >= 2) |
| 659 | .filter(e => !(e.matches("input[type=checkbox], input[type=radio]") && e.closest("label") && e.closest("label").hasAttribute("data-measured-area"))) |
| 660 | .map(e => e.outerHTML.slice(0, 80))`, &missed)); err != nil { |
| 661 | t.Fatalf("%s: listing unmeasured controls: %v", where, err) |
| 662 | } |
| 663 | if len(missed) > 0 { |
| 664 | t.Errorf("%s: %d rendered controls were never measured: %v", where, len(missed), missed) |
| 665 | } |
| 666 | for _, e := range tapInventory { |
| 667 | sel, entryPage := e.Probe, "" |
| 668 | switch { |
| 669 | case strings.HasPrefix(sel, "elsewhere:"): |
| 670 | continue |
| 671 | case strings.HasPrefix(sel, "narrow:"): |
| 672 | if wide { |
| 673 | continue |
| 674 | } |
| 675 | sel = strings.TrimPrefix(sel, "narrow:") |
| 676 | case strings.HasPrefix(sel, "modal:"): |
| 677 | sel, entryPage = strings.TrimPrefix(sel, "modal:"), "modal" |
| 678 | } |
| 679 | if entryPage != page { |
| 680 | continue |
| 681 | } |
| 682 | var n int |
| 683 | if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`[...document.querySelectorAll(%q)].filter(e => e.hasAttribute("data-measured")).length`, sel), &n)); err != nil { |
| 684 | t.Fatalf("%s: counting %s: %v", where, sel, err) |
| 685 | } |
| 686 | if n == 0 { |
| 687 | t.Errorf("%s: no %s (%s) was measured; the idiom dropped out of the drive", where, sel, e.Idiom) |
| 688 | } |
| 689 | } |
| 690 | } |
| 691 | |
| 692 | // fixtureCounts are controls the sizing fixture itself owns, with how |
| 693 | // many of each must have been measured. Declared here, not read off the |
| 694 | // page: a control that disappears from the fixture is absent from the |
| 695 | // DOM too, so only a number written down independently can notice it, |
| 696 | // even while another control of the same idiom survives. |
| 697 | var fixtureCounts = []struct { |
| 698 | Sel string |
| 699 | N int |
| 700 | }{ |
| 701 | {`[data-extra="buttons"] [rst-btn]`, 3}, |
| 702 | {`[data-extra="locale"] [rst-locale] button`, 3}, |
| 703 | {`[data-extra="combobox"] [role=option]`, 12}, |
| 704 | {`[data-extra="legacy-drawer"] [rst-shell-nav] a`, 2}, |
| 705 | {`[data-extra="small-parents"] :is(input, textarea)`, 7}, // the bare input is the app's own, not measured |
| 706 | // Previous and 1 are spans (disabled, current); 9 and Next are links |
| 707 | // at every width. 2, beside the current page, is a link only a wide |
| 708 | // strip shows, so it is measured where it is drawn and not counted. |
| 709 | {`[data-partial="pagination"] [rst-pagination] a:not([rst-pagination-wide])`, 2}, |
| 710 | {`[data-partial="bulk-bar"] [rst-dropdown-menu] button`, 2}, |
| 711 | {`[data-partial="seg-tabs"] a`, 2}, |
| 712 | {`[data-partial="seg-tabs"] [rst-seg-tabs] > button`, 2}, |
| 713 | {`[data-partial="side-nav"] [rst-side-nav] a`, 2}, |
| 714 | {`[data-partial="dropdown"] [rst-dropdown-menu] a`, 1}, |
| 715 | {`[data-extra="short-labels"] [rst-bulkbar-escalate]`, 1}, |
| 716 | {`[data-extra="short-labels"] a[rst-person]`, 1}, |
| 717 | {`[data-extra="signin-providers"] [rst-signin-providers] > a`, 1}, |
| 718 | } |
| 719 | |
| 720 | func assertFixtureCounts(t *testing.T, ctx context.Context, where string) { |
| 721 | t.Helper() |
| 722 | for _, c := range fixtureCounts { |
| 723 | var n int |
| 724 | if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`[...document.querySelectorAll(%q)].filter(e => e.hasAttribute("data-measured")).length`, c.Sel), &n)); err != nil { |
| 725 | t.Fatalf("%s: counting %s: %v", where, c.Sel, err) |
| 726 | } |
| 727 | if n != c.N { |
| 728 | t.Errorf("%s: %d of %s were measured, want exactly %d", where, n, c.Sel, c.N) |
| 729 | } |
| 730 | } |
| 731 | } |
| 732 | |
| 733 | // TestEveryTapTargetIsAtLeast44Pixels is §10.1's target half, at 390 |
| 734 | // and 1024 with a coarse pointer and at 600 with a mouse, then the modal |
| 735 | // on a page of its own. The 1280 mouse leg is the desktop floor: the |
| 736 | // same drive, every control at least 32×32, so a control that is only |
| 737 | // floored on touch (a 26px kebab, a 24px clear link) fails there. Every |
| 738 | // leg also holds the icons inside controls to 16px and a caret's to 12. |
| 739 | func TestEveryTapTargetIsAtLeast44Pixels(t *testing.T) { |
| 740 | pages := map[string]string{ |
| 741 | "/": sizingDoc("sizing", sizingFixture(t)), |
| 742 | "/modal": sizingDoc("modal", modalSample(t, "TestEveryTapTargetIsAtLeast44Pixels")), |
| 743 | } |
| 744 | for _, leg := range []struct { |
| 745 | name string |
| 746 | w, h int64 |
| 747 | coarse bool |
| 748 | floor float64 |
| 749 | }{ |
| 750 | {"390x844 touch", 390, 844, true, 44}, |
| 751 | {"1024x768 touch", 1024, 768, true, 44}, |
| 752 | {"600x800 mouse", 600, 800, false, 44}, |
| 753 | {"1280x900 mouse", 1280, 900, false, 32}, |
| 754 | } { |
| 755 | t.Run(leg.name, func(t *testing.T) { |
| 756 | rig := sizingRig(t, leg.coarse, pages) |
| 757 | ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) |
| 758 | defer cancel() |
| 759 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+"/")); err != nil { |
| 760 | t.Fatal(err) |
| 761 | } |
| 762 | requirePointer(t, ctx, leg.coarse) |
| 763 | assertIcons(t, ctx, leg.name) |
| 764 | n := measureEverything(t, ctx, leg.name, leg.floor) |
| 765 | t.Logf("%s: %d controls measured", leg.name, n) |
| 766 | assertCovered(t, ctx, leg.name, "", leg.w >= 800) |
| 767 | assertFixtureCounts(t, ctx, leg.name) |
| 768 | if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/modal")); err != nil { |
| 769 | t.Fatal(err) |
| 770 | } |
| 771 | got := readTargets(t, ctx, targetsJS) |
| 772 | assertTargetsAt(t, leg.name+", the modal", got, leg.floor) |
| 773 | assertCovered(t, ctx, leg.name+", the modal", "modal", leg.w >= 800) |
| 774 | }) |
| 775 | } |
| 776 | } |
| 777 | |
| 778 | // TestTheKebabOverflowsIntoTheGapOnALiteralColumn is §2.3's claim for |
| 779 | // an app that keeps --rst-cols' literal 32px: on a wide touch screen the |
| 780 | // 44px kebab is justify-self: end, so it spills 12px toward the inline |
| 781 | // start, into the 0.85rem gap, and never over the cell beside it. |
| 782 | func TestTheKebabOverflowsIntoTheGapOnALiteralColumn(t *testing.T) { |
| 783 | page := sizingDoc("literal", `<div rst-page><div rst-card style="--rst-cols: 1fr 110px 32px"><div rst-lrow>`+ |
| 784 | `<a class="rst-nm" href="#">Grace Hopper</a><span id="cell" class="rst-cell-mut">Paid</span>`+ |
| 785 | `<details rst-row-menu name="rst-menus"><summary id="kebab" aria-label="Actions">⋮</summary><div rst-row-menu-panel><a href="#">View</a></div></details>`+ |
| 786 | `</div></div></div>`) |
| 787 | rig := sizingRig(t, true, map[string]string{"/": page}) |
| 788 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 789 | defer cancel() |
| 790 | var raw string |
| 791 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(1024, 768), chromedp.Navigate(rig.Origin+"/"), |
| 792 | chromedp.Evaluate(`(() => { const c = document.getElementById("cell").getBoundingClientRect(), k = document.getElementById("kebab").getBoundingClientRect(); |
| 793 | return JSON.stringify({CellEnd: c.right, KebabStart: k.left, W: k.width}); })()`, &raw)); err != nil { |
| 794 | t.Fatal(err) |
| 795 | } |
| 796 | requirePointer(t, ctx, true) |
| 797 | var g struct{ CellEnd, KebabStart, W float64 } |
| 798 | json.Unmarshal([]byte(raw), &g) |
| 799 | if g.W < 43.5 { |
| 800 | t.Fatalf("the kebab is %.1fpx wide on a touch screen; the premise (a 44px kebab in a 32px track) is not met", g.W) |
| 801 | } |
| 802 | if g.KebabStart < g.CellEnd-0.5 { |
| 803 | t.Errorf("the 44px kebab starts at %.1f and the cell beside it ends at %.1f: it overlaps the neighbouring cell", g.KebabStart, g.CellEnd) |
| 804 | } |
| 805 | } |
| 806 | |
| 807 | // calJS reads the open calendar: where the panel is, whether it |
| 808 | // scrolls, how big each day is, and whether every day and both month |
| 809 | // buttons can be scrolled to and hit. |
| 810 | const calJS = `(() => { |
| 811 | const cal = document.querySelector("[rst-cal]"); |
| 812 | if (!cal || !cal.checkVisibility()) return JSON.stringify({Open: false}); |
| 813 | const items = [...cal.querySelectorAll("[rst-cal-nav], [rst-cal-day]")]; |
| 814 | let minW = 1e9, minH = 1e9, navW = 1e9, navH = 1e9; const unhittable = []; |
| 815 | for (const el of items) { |
| 816 | el.scrollIntoView({block: "nearest", inline: "nearest"}); |
| 817 | const b = el.getBoundingClientRect(); |
| 818 | if (el.matches("[rst-cal-day]")) { minW = Math.min(minW, b.width); minH = Math.min(minH, b.height); } |
| 819 | else { navW = Math.min(navW, b.width); navH = Math.min(navH, b.height); } |
| 820 | const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); |
| 821 | if (!hit || !(hit === el || el.contains(hit))) unhittable.push((el.getAttribute("data-rst-day") || "nav") + " -> " + (hit ? hit.tagName : "nothing")); |
| 822 | } |
| 823 | cal.scrollTop = 0; |
| 824 | const r = cal.getBoundingClientRect(), g = cal.querySelector("[rst-cal-grid]").getBoundingClientRect(), cs = getComputedStyle(cal); |
| 825 | return JSON.stringify({Open: true, Position: cs.position, InlineSize: cs.inlineSize, Left: r.left, Top: r.top, Right: r.right, Bottom: r.bottom, H: r.height, |
| 826 | VW: document.documentElement.clientWidth, VH: innerHeight, GridW: g.width, ScrollH: cal.scrollHeight, ClientH: cal.clientHeight, |
| 827 | Gutter: cal.offsetWidth - cal.clientWidth - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth), |
| 828 | Days: cal.querySelectorAll("[rst-cal-day]").length, Navs: cal.querySelectorAll("[rst-cal-nav]").length, |
| 829 | MinDayW: minW, MinDayH: minH, MinNavW: navW, MinNavH: navH, Unhittable: unhittable}); |
| 830 | })()` |
| 831 | |
| 832 | type calReading struct { |
| 833 | Open bool |
| 834 | Position, InlineSize string |
| 835 | Left, Top, Right, Bottom, H, VW, VH float64 |
| 836 | GridW, ScrollH, ClientH, MinDayW, MinDayH float64 |
| 837 | MinNavW, MinNavH, Gutter float64 |
| 838 | Days, Navs int |
| 839 | Unhittable []string |
| 840 | } |
| 841 | |
| 842 | // openCalendar loads url at w×h, presses the first date field's |
| 843 | // calendar button (through the page, which is what a tap does) and |
| 844 | // reads the panel. |
| 845 | func openCalendar(t *testing.T, ctx context.Context, url string, w, h int64) calReading { |
| 846 | t.Helper() |
| 847 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(url)); err != nil { |
| 848 | t.Fatal(err) |
| 849 | } |
| 850 | settleUntil(t, ctx, `!!document.querySelector("[rst-dtp-pick]")`) |
| 851 | if err := chromedp.Run(ctx, chromedp.Evaluate(`document.querySelector("[rst-dtp-pick]").click(), true`, nil)); err != nil { |
| 852 | t.Fatal(err) |
| 853 | } |
| 854 | settleUntil(t, ctx, `document.querySelectorAll("[rst-cal] [rst-cal-day]").length === 42`) |
| 855 | var raw string |
| 856 | if err := chromedp.Run(ctx, chromedp.Evaluate(calJS, &raw)); err != nil { |
| 857 | t.Fatal(err) |
| 858 | } |
| 859 | var c calReading |
| 860 | if err := json.Unmarshal([]byte(raw), &c); err != nil { |
| 861 | t.Fatalf("reading the calendar (%q): %v", raw, err) |
| 862 | } |
| 863 | if !c.Open || c.Days != 42 || c.Navs != 2 { |
| 864 | t.Fatalf("the calendar is open=%v with %d days and %d month buttons, want six weeks and two buttons", c.Open, c.Days, c.Navs) |
| 865 | } |
| 866 | return c |
| 867 | } |
| 868 | |
| 869 | func inViewport(c calReading) bool { |
| 870 | return c.Left >= -0.5 && c.Top >= -0.5 && c.Right <= c.VW+0.5 && c.Bottom <= c.VH+0.5 |
| 871 | } |
| 872 | |
| 873 | // TestTheCalendarDocksAndItsDaysAreTaps is §10.1's calendar: docked |
| 874 | // inside the viewport with 44×44 days at 390; no taller than a |
| 875 | // landscape phone and scrolling itself at 640×320, with every day and |
| 876 | // both month buttons reachable; with classic scrollbars at 600×320 and |
| 877 | // 390×320, mouse and touch, still scrolling, days still 44 wide, the |
| 878 | // grid exactly seven taps (the gutter widens the panel, never narrows a |
| 879 | // day); at 320 the deliberate exception (days ≥24 wide, 44 tall, no |
| 880 | // overflow); and with the field at the inline end of a field row, still |
| 881 | // inside the viewport. |
| 882 | func TestTheCalendarDocksAndItsDaysAreTaps(t *testing.T) { |
| 883 | field := fixture(t, "sizing_browser_test.go", "TestTheCalendarDocksAndItsDaysAreTaps", "field") |
| 884 | pages := map[string]string{ |
| 885 | "/": sizingDoc("calendar", `<div rst-page>`+field+`</div>`), |
| 886 | "/end": sizingDoc("calendar at the end of a row", `<div rst-page><div rst-field-row><div rst-field class="rst-grow"><label rst-field-label for="n">Name</label><input rst-input id="n" name="n"></div><div>`+field+`</div></div></div>`), |
| 887 | } |
| 888 | touch := sizingRig(t, true, pages) |
| 889 | ctx, cancel := context.WithTimeout(touch.Context(), 180*time.Second) |
| 890 | defer cancel() |
| 891 | |
| 892 | c := openCalendar(t, ctx, touch.Origin+"/", 390, 844) |
| 893 | requirePointer(t, ctx, true) |
| 894 | if c.Position != "fixed" || !inViewport(c) { |
| 895 | t.Errorf("390 touch: the calendar is %s at %.0f..%.0f × %.0f..%.0f in a %.0f×%.0f viewport; it is not docked inside it", c.Position, c.Left, c.Right, c.Top, c.Bottom, c.VW, c.VH) |
| 896 | } |
| 897 | if c.MinDayW < 43.5 || c.MinDayH < 43.5 { |
| 898 | t.Errorf("390 touch: the smallest day is %.1f×%.1f, want 44×44", c.MinDayW, c.MinDayH) |
| 899 | } |
| 900 | if math.Abs(c.GridW-308) > 0.5 { |
| 901 | t.Errorf("390 touch: the grid is %.1fpx, want seven taps (308)", c.GridW) |
| 902 | } |
| 903 | |
| 904 | // The pointer half of the query alone: a tablet wider than 40rem |
| 905 | // docks too (spec §1.4), so a docking rule scoped to width only |
| 906 | // would leave it the anchored 18rem panel with 41px days. |
| 907 | c = openCalendar(t, ctx, touch.Origin+"/", 1024, 768) |
| 908 | if c.Position != "fixed" || !inViewport(c) { |
| 909 | t.Errorf("1024 touch: the calendar is %s at %.0f..%.0f × %.0f..%.0f in a %.0f×%.0f viewport; it is not docked inside it", c.Position, c.Left, c.Right, c.Top, c.Bottom, c.VW, c.VH) |
| 910 | } |
| 911 | if c.MinDayW < 43.5 || c.MinDayH < 43.5 { |
| 912 | t.Errorf("1024 touch: the smallest day is %.1f×%.1f, want 44×44", c.MinDayW, c.MinDayH) |
| 913 | } |
| 914 | if math.Abs(c.GridW-308) > 0.5 { |
| 915 | t.Errorf("1024 touch: the grid is %.1fpx, want seven taps (308)", c.GridW) |
| 916 | } |
| 917 | |
| 918 | c = openCalendar(t, ctx, touch.Origin+"/", 640, 320) |
| 919 | if c.H > c.VH+0.5 || c.ScrollH <= c.ClientH || len(c.Unhittable) > 0 { |
| 920 | t.Errorf("640x320 touch: panel %.0fpx in a %.0fpx viewport, scrolls=%v, unreachable %v", c.H, c.VH, c.ScrollH > c.ClientH, c.Unhittable) |
| 921 | } |
| 922 | |
| 923 | c = openCalendar(t, ctx, touch.Origin+"/", 320, 640) |
| 924 | // 40, not 24: 24 is WCAG 2.5.8's floor, but the deliberate |
| 925 | // exception is the geometry of a clamped panel, about 41px |
| 926 | // days, and a regression to anything narrower is a change to it. |
| 927 | // That is a phone's geometry, whose overlay scrollbar takes no |
| 928 | // width. Headless Chromium hides scrollbars without making them |
| 929 | // overlay ones, so scrollbar-gutter: stable still reserves a classic |
| 930 | // 15px here and takes a seventh of it from each day (38.7px); the |
| 931 | // gutter is measured and given back, so the floor is the phone's. |
| 932 | if phone := c.MinDayW + c.Gutter/7; phone < 40 || c.MinDayH < 43.5 || c.Right > c.VW+0.5 || c.Left < -0.5 { |
| 933 | t.Errorf("320 touch: days %.1f×%.1f (%.1f wide without the %.0fpx gutter), panel %.0f..%.0f in %.0f; the approved exception is about 41 wide, 44 tall, no overflow", c.MinDayW, c.MinDayH, phone, c.Gutter, c.Left, c.Right, c.VW) |
| 934 | } |
| 935 | |
| 936 | c = openCalendar(t, ctx, touch.Origin+"/end", 390, 844) |
| 937 | if !inViewport(c) { |
| 938 | t.Errorf("390 touch, field at the row's end: the calendar is at %.0f..%.0f in %.0f", c.Left, c.Right, c.VW) |
| 939 | } |
| 940 | |
| 941 | // Classic scrollbars, which the harness hides by default: the case |
| 942 | // where the gutter once came out of the days. |
| 943 | for _, coarse := range []bool{false, true} { |
| 944 | rig := sizingRig(t, coarse, pages, harness.WithScrollbars()) |
| 945 | sctx, scancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 946 | // The days alone cannot tell whether the gutter is reserved: |
| 947 | // Chromium already counts a scrollbar it draws in a fit-content |
| 948 | // box's width, so they stay 44 either way. What the reservation |
| 949 | // adds is a panel that keeps one width whether or not it scrolls, |
| 950 | // so a month that fits a tall viewport is measured as the control |
| 951 | // and every short viewport must match it. |
| 952 | tall := openCalendar(t, sctx, rig.Origin+"/", 390, 844) |
| 953 | if tall.ScrollH > tall.ClientH { |
| 954 | t.Errorf("390x844 classic scrollbars, coarse=%v: the panel scrolls (%.0f > %.0f), so it cannot be the width of a panel that does not", coarse, tall.ScrollH, tall.ClientH) |
| 955 | } |
| 956 | for _, vp := range [][2]int64{{600, 320}, {390, 320}} { |
| 957 | c := openCalendar(t, sctx, rig.Origin+"/", vp[0], vp[1]) |
| 958 | requirePointer(t, sctx, coarse) |
| 959 | where := fmt.Sprintf("%dx%d classic scrollbars, coarse=%v", vp[0], vp[1], coarse) |
| 960 | if w, tw := c.Right-c.Left, tall.Right-tall.Left; math.Abs(w-tw) > 0.5 { |
| 961 | t.Errorf("%s: the scrolling panel is %.1fpx and the same month at 390x844 is %.1fpx; the gutter is not reserved, so the panel jumps when the viewport gets short", where, w, tw) |
| 962 | } |
| 963 | if c.ScrollH <= c.ClientH { |
| 964 | t.Errorf("%s: the panel is not scrolling (%.0f ≤ %.0f); the gutter case this leg is for has not arisen", where, c.ScrollH, c.ClientH) |
| 965 | } |
| 966 | if c.MinDayW < 43.5 { |
| 967 | t.Errorf("%s: a day is %.1fpx wide; the scrollbar's gutter came out of the days", where, c.MinDayW) |
| 968 | } |
| 969 | if c.MinDayH < 43.5 { |
| 970 | t.Errorf("%s: a day is %.1fpx tall, want 44", where, c.MinDayH) |
| 971 | } |
| 972 | if math.Abs(c.GridW-308) > 0.5 { |
| 973 | t.Errorf("%s: the grid is %.1fpx, want exactly seven taps (308)", where, c.GridW) |
| 974 | } |
| 975 | if !inViewport(c) { |
| 976 | t.Errorf("%s: the panel is at %.0f..%.0f × %.0f..%.0f, outside the viewport", where, c.Left, c.Right, c.Top, c.Bottom) |
| 977 | } |
| 978 | } |
| 979 | scancel() |
| 980 | } |
| 981 | } |
| 982 | |
| 983 | // floorOnly are the layout declarations the floor block may carry |
| 984 | // besides the floor, each because the floor does not work without it, |
| 985 | // keyed by the attribute spelling of the control that carries them. |
| 986 | // An <a> is inline, and min sizes do nothing on an inline box, so the |
| 987 | // controls born inline become inline-flex, centred in the box the floor |
| 988 | // grows (or, blockified by a flex or grid parent, flex). The search |
| 989 | // box's input is its target, so the box gives up its block padding and |
| 990 | // the input stretches to the floor; the bulk bar's close button would |
| 991 | // shrink below the floor in its flex row; the row checkbox centres its |
| 992 | // 16px box in the label the floor widens. |
| 993 | var floorOnly = map[string]string{ |
| 994 | "[rst-row-action]": "display align-items justify-content", |
| 995 | "[rst-bulkbar-escalate]": "display align-items justify-content", |
| 996 | "[rst-modal-close]": "display align-items justify-content", |
| 997 | "[rst-back-nav] a": "display align-items", |
| 998 | "[rst-shell-brand]": "display align-items", |
| 999 | "[rst-search]": "padding-top padding-bottom", |
| 1000 | "[rst-search] input": "align-self", |
| 1001 | "[rst-bulkbar-close]": "flex-shrink", |
| 1002 | "[rst-selbox]": "justify-content", |
| 1003 | "[rst-signin-providers] > a": "display align-items align-self", |
| 1004 | } |
| 1005 | |
| 1006 | // mayShrink are the controls the floor makes smaller on a desktop, by |
| 1007 | // selector as the floor block spells it, each with why. |
| 1008 | var mayShrink = map[string]string{ |
| 1009 | "[rst-search]": "the box gives up its block padding so its input, the target, can be the floor: 35.6px became 34", |
| 1010 | `[rst-search] input[type="search"]`: "it stretches to the box's height, and its width loses what the box's border-box floor takes", |
| 1011 | } |
| 1012 | |
| 1013 | // TestTheFloorChangesNothingButSizeOnADesktop: the floor block reaches |
| 1014 | // every width, and the layout it carried from its touch-only days came |
| 1015 | // with it: a row action 9.6px wider than the floor needed, a date |
| 1016 | // suggestion's label moved off its baseline, a full-width button's |
| 1017 | // label pulled to its middle, menu items, tabs and nav links made flex |
| 1018 | // boxes that drop the spaces in their labels. Every control the block |
| 1019 | // names is read at 1280 with a mouse, once as served and once with the |
| 1020 | // block cut out; the two may differ in size, box-sizing and |
| 1021 | // align-content (which centres a block's content in the floor), and in |
| 1022 | // floorOnly's declarations, and in nothing else. And the size may only |
| 1023 | // grow: a floor once replaced the textarea's own 5rem minimum (one |
| 1024 | // property under two names), and a note field shrank to two lines. |
| 1025 | func TestTheFloorChangesNothingButSizeOnADesktop(t *testing.T) { |
| 1026 | without, block := floorBlock(t) |
| 1027 | var sels []string |
| 1028 | for _, r := range leafRules(t, block) { |
| 1029 | for _, s := range splitSelectorList(r.selector) { |
| 1030 | if !strings.HasPrefix(s, ":where(") { |
| 1031 | sels = append(sels, s) |
| 1032 | } |
| 1033 | } |
| 1034 | } |
| 1035 | body := sizingFixture(t) + `<div rst-page data-extra="floor-layout"><div rst-dtp-row id="dtp-row"><span>Tomorrow</span><span>Thu 9 Oct</span></div>` + |
| 1036 | `<p><a rst-btn href="#wide" style="display: flex">Continue</a></p></div>` |
| 1037 | pages := map[string]string{"/": sizingDoc("floor", body), "/bare": strings.Replace(sizingDoc("floor", body), "/tokens.css", "/tokens-nofloor.css", 1)} |
| 1038 | rig := harness.New(t, func(string) http.Handler { |
| 1039 | mux := sizingMux(t, pages) |
| 1040 | mux.HandleFunc("GET /tokens-nofloor.css", func(w http.ResponseWriter, _ *http.Request) { |
| 1041 | w.Header().Set("Content-Type", "text/css") |
| 1042 | fmt.Fprint(w, without) |
| 1043 | }) |
| 1044 | return mux |
| 1045 | }) |
| 1046 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 1047 | defer cancel() |
| 1048 | read := `(() => { |
| 1049 | const sels = ` + mustJSON(t, sels) + `, props = ` + mustJSON(t, strings.Fields("display align-items justify-content padding-top padding-right padding-bottom padding-left align-self flex-shrink flex-grow row-gap column-gap text-align vertical-align")) + `; |
| 1050 | const seen = new Map(); |
| 1051 | for (const sel of sels) for (const e of document.querySelectorAll(sel)) if (!seen.has(e)) seen.set(e, sel); |
| 1052 | const all = [...document.querySelectorAll("*")]; |
| 1053 | return JSON.stringify([...seen].map(([e, sel]) => { |
| 1054 | const cs = getComputedStyle(e), out = {I: all.indexOf(e), Sel: sel, Name: e.tagName.toLowerCase() + (e.id ? "#" + e.id : "") + " “" + (e.getAttribute("aria-label") || e.textContent).trim().slice(0, 24) + "”", P: {}}; |
| 1055 | for (const p of props) out.P[p] = cs.getPropertyValue(p); |
| 1056 | const box = e.getBoundingClientRect(); out.W = box.width; out.H = box.height; |
| 1057 | return out; |
| 1058 | })); |
| 1059 | })()` |
| 1060 | type styled struct { |
| 1061 | I int |
| 1062 | Sel, Name string |
| 1063 | P map[string]string |
| 1064 | W, H float64 |
| 1065 | } |
| 1066 | at := func(path string) map[int]styled { |
| 1067 | var raw string |
| 1068 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("#dtp-row", chromedp.ByQuery), chromedp.Evaluate(read, &raw)); err != nil { |
| 1069 | t.Fatalf("%s: %v", path, err) |
| 1070 | } |
| 1071 | var got []styled |
| 1072 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 1073 | t.Fatal(err) |
| 1074 | } |
| 1075 | out := map[int]styled{} |
| 1076 | for _, g := range got { |
| 1077 | out[g.I] = g |
| 1078 | } |
| 1079 | return out |
| 1080 | } |
| 1081 | served, bare := at("/"), at("/bare") |
| 1082 | if len(served) < 50 { |
| 1083 | t.Fatalf("read %d controls the floor block names; the selectors no longer match the fixture", len(served)) |
| 1084 | } |
| 1085 | for i, s := range served { |
| 1086 | b, ok := bare[i] |
| 1087 | if !ok { |
| 1088 | t.Errorf("%s: not on the page without the floor block", s.Name) |
| 1089 | continue |
| 1090 | } |
| 1091 | if _, ok := mayShrink[s.Sel]; !ok && (s.W < b.W-1 || s.H < b.H-1) { |
| 1092 | t.Errorf("%s (%s): the floor shrinks it on a desktop from %.1f×%.1f to %.1f×%.1f; a floor only raises", s.Name, s.Sel, b.W, b.H, s.W, s.H) |
| 1093 | } |
| 1094 | may := "" |
| 1095 | for key, props := range floorOnly { |
| 1096 | if strings.Contains(s.Sel, key) { |
| 1097 | may += " " + props |
| 1098 | } |
| 1099 | } |
| 1100 | // A gap does nothing outside a flex or grid box; the one that |
| 1101 | // puts back an icon's space applies where the floor made one, |
| 1102 | // which is floorOnly's to allow. |
| 1103 | if d := s.P["display"]; !strings.Contains(d, "flex") && !strings.Contains(d, "grid") || !strings.Contains(b.P["display"], "flex") { |
| 1104 | may += " column-gap row-gap" |
| 1105 | } |
| 1106 | for p, v := range s.P { |
| 1107 | if v != b.P[p] && !strings.Contains(" "+may+" ", " "+p+" ") { |
| 1108 | t.Errorf("%s (%s): the floor changes %s on a desktop from %q to %q", s.Name, s.Sel, p, b.P[p], v) |
| 1109 | } |
| 1110 | } |
| 1111 | } |
| 1112 | } |
| 1113 | |
| 1114 | func mustJSON(t *testing.T, v any) string { |
| 1115 | t.Helper() |
| 1116 | b, err := json.Marshal(v) |
| 1117 | if err != nil { |
| 1118 | t.Fatal(err) |
| 1119 | } |
| 1120 | return string(b) |
| 1121 | } |
| 1122 | |