| 1 | //go:build browser |
| 2 | |
| 3 | // The design-system floors on the examples: every state of every |
| 4 | // non-layout example, each loaded as its own document, held to the |
| 5 | // tap-target floor, the 16px text floor, the icon sizes and spacing, |
| 6 | // a clean load, and the same controls with no script at all. The |
| 7 | // seeded sizing fixture (sizing_browser_test.go) is the preservation |
| 8 | // drive for the same floors; this is the coverage drive, which the |
| 9 | // examples feed. The layouts are held with the shells, not here. |
| 10 | package check |
| 11 | |
| 12 | import ( |
| 13 | "context" |
| 14 | "encoding/json" |
| 15 | "fmt" |
| 16 | "net/http" |
| 17 | "sort" |
| 18 | "strings" |
| 19 | "sync" |
| 20 | "testing" |
| 21 | "time" |
| 22 | |
| 23 | "github.com/chromedp/chromedp" |
| 24 | |
| 25 | "amadan.net/rastrillo/fiesta" |
| 26 | "amadan.net/rastrillo/fiesta/example" |
| 27 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 28 | ) |
| 29 | |
| 30 | // floorState is one example state as the floors drive visits it: its |
| 31 | // address, and the path of its document and of its no-script twin. |
| 32 | type floorState struct { |
| 33 | Name, State string |
| 34 | Path, NoScript string |
| 35 | } |
| 36 | |
| 37 | // floorServer serves the states and counts what it served, by path, |
| 38 | // so the drive can show it visited every state of the manifest rather |
| 39 | // than every state of its own list. |
| 40 | type floorServer struct { |
| 41 | http.Handler |
| 42 | mu sync.Mutex |
| 43 | served map[string]int |
| 44 | } |
| 45 | |
| 46 | // snapshot returns how many times each path has been served so far, |
| 47 | // as a copy, so a leg can subtract the count before it from the count |
| 48 | // after it. |
| 49 | func (s *floorServer) snapshot() map[string]int { |
| 50 | s.mu.Lock() |
| 51 | defer s.mu.Unlock() |
| 52 | out := map[string]int{} |
| 53 | for k, v := range s.served { |
| 54 | out[k] = v |
| 55 | } |
| 56 | return out |
| 57 | } |
| 58 | |
| 59 | // floorPages reads every state of every non-layout example through |
| 60 | // the manifest and example.Document, and serves each at the depth the |
| 61 | // tree keeps it, examples/<name>/<state>.html, so the document's own |
| 62 | // relative links (../../css/tokens.css, ../../js/<file>, |
| 63 | // ../assets/<picture>) resolve against the embedded tree beside it |
| 64 | // and nothing in the document is rewritten. Beside each is its |
| 65 | // no-script twin, the same document with every script tag stripped, |
| 66 | // at examples/<name>/<state>.noscript.html, the same depth. The rest |
| 67 | // of the tree (css/, js/, examples/assets/) is served as it is. |
| 68 | func floorPages(t *testing.T) ([]floorState, *floorServer) { |
| 69 | t.Helper() |
| 70 | m, err := example.Load(fiesta.FS()) |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | var states []floorState |
| 75 | pages := map[string]string{} |
| 76 | for _, e := range m.Examples { |
| 77 | if e.Document || e.Kind == "layout" { |
| 78 | continue |
| 79 | } |
| 80 | for _, s := range e.States { |
| 81 | doc, err := example.Document(fiesta.FS(), e.Name, s.ID) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if !strings.Contains(doc, "<body") { |
| 86 | t.Fatalf("%s/%s is not a whole document", e.Name, s.ID) |
| 87 | } |
| 88 | st := floorState{Name: e.Name, State: s.ID, |
| 89 | Path: "/examples/" + e.Name + "/" + s.ID + ".html", |
| 90 | NoScript: "/examples/" + e.Name + "/" + s.ID + ".noscript.html"} |
| 91 | pages[st.Path] = doc |
| 92 | pages[st.NoScript] = scriptTags.ReplaceAllString(doc, "") |
| 93 | if strings.Contains(pages[st.NoScript], "<script") { |
| 94 | t.Fatalf("%s/%s still links a script with its tags stripped", e.Name, s.ID) |
| 95 | } |
| 96 | states = append(states, st) |
| 97 | } |
| 98 | } |
| 99 | if len(states) <= 100 { |
| 100 | t.Fatalf("the manifest lists %d non-layout states, want more than 100; a set this small is a broken manifest, not a design system", len(states)) |
| 101 | } |
| 102 | srv := &floorServer{served: map[string]int{}} |
| 103 | mux := http.NewServeMux() |
| 104 | tree := http.FileServerFS(fiesta.FS()) |
| 105 | for _, prefix := range []string{"/css/", "/js/", "/examples/assets/"} { |
| 106 | mux.Handle("GET "+prefix, tree) |
| 107 | } |
| 108 | for path, doc := range pages { |
| 109 | path, doc := path, doc |
| 110 | mux.HandleFunc("GET "+path, func(w http.ResponseWriter, _ *http.Request) { |
| 111 | srv.mu.Lock() |
| 112 | srv.served[path]++ |
| 113 | srv.mu.Unlock() |
| 114 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 115 | fmt.Fprint(w, doc) |
| 116 | }) |
| 117 | } |
| 118 | srv.Handler = mux |
| 119 | return states, srv |
| 120 | } |
| 121 | |
| 122 | // manifestStates is the manifest's own list of non-layout states, as |
| 123 | // name/state, read apart from floorPages so the visited check compares |
| 124 | // what the server served against the manifest rather than against the |
| 125 | // loop that asked for it. |
| 126 | func manifestStates(t *testing.T) map[string]floorState { |
| 127 | t.Helper() |
| 128 | m, err := example.Load(fiesta.FS()) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | out := map[string]floorState{} |
| 133 | for _, e := range m.Examples { |
| 134 | if e.Document || e.Kind == "layout" { |
| 135 | continue |
| 136 | } |
| 137 | for _, s := range e.States { |
| 138 | out[e.Name+"/"+s.ID] = floorState{Name: e.Name, State: s.ID, |
| 139 | Path: "/examples/" + e.Name + "/" + s.ID + ".html", |
| 140 | NoScript: "/examples/" + e.Name + "/" + s.ID + ".noscript.html"} |
| 141 | } |
| 142 | } |
| 143 | return out |
| 144 | } |
| 145 | |
| 146 | // floorProbes is the tap inventory as selector lists: every probe the |
| 147 | // floor holds on a page (the narrow: and modal: prefixes stripped, the |
| 148 | // elsewhere: entries left out, since a drive of their own measures |
| 149 | // them in a state no example reaches), plus the stretched links the |
| 150 | // inventory exempts, which measureFn measures as the row or tile they |
| 151 | // stretch over (the whole-row target); and the exemptions proper, the |
| 152 | // inline link and the keyboard-only submit. An example state carries |
| 153 | // whatever controls its component has, and only the ones the |
| 154 | // inventory names are the floor's to hold: a link in running prose is |
| 155 | // not. The lists are what the measurement filters on. |
| 156 | func floorProbes(wide bool) (probes, exempt string, keyed map[string]string) { |
| 157 | var sels, ex []string |
| 158 | keyed = map[string]string{} |
| 159 | for _, e := range tapInventory { |
| 160 | sel := e.Probe |
| 161 | switch { |
| 162 | case strings.HasPrefix(sel, "elsewhere:"): |
| 163 | continue |
| 164 | case strings.HasPrefix(sel, "narrow:"): |
| 165 | if wide { |
| 166 | continue |
| 167 | } |
| 168 | sel = strings.TrimPrefix(sel, "narrow:") |
| 169 | case strings.HasPrefix(sel, "modal:"): |
| 170 | sel = strings.TrimPrefix(sel, "modal:") |
| 171 | } |
| 172 | sels = append(sels, sel) |
| 173 | keyed[sel] = e.Idiom |
| 174 | } |
| 175 | for sel, why := range tapExempt { |
| 176 | if strings.Contains(why, "stretched") { |
| 177 | sels = append(sels, sel) |
| 178 | keyed[sel] = "stretched link, measured as its row or tile" |
| 179 | continue |
| 180 | } |
| 181 | ex = append(ex, sel) |
| 182 | } |
| 183 | sort.Strings(ex) |
| 184 | return strings.Join(sels, ", "), strings.Join(ex, ", "), keyed |
| 185 | } |
| 186 | |
| 187 | // scriptBuilt are the inventory idioms a script draws at run time, so |
| 188 | // a page with no script cannot carry them: the no-script leg allows |
| 189 | // their absence and nothing else's. |
| 190 | var scriptBuilt = map[string]string{ |
| 191 | "[rst-combo-option]": "select.js builds the combobox's list", |
| 192 | "[rst-dtp-pick]": "datetime.js builds the pick button", |
| 193 | "[rst-dtp-row]": "datetime.js builds the suggestion list", |
| 194 | } |
| 195 | |
| 196 | // scriptShown are the controls one example's markup carries hidden and |
| 197 | // a script shows, keyed by example name and inventory selector: with no |
| 198 | // script a button that could only do nothing stays hidden, on purpose, |
| 199 | // so the no-script leg allows its absence on that example alone. Keyed |
| 200 | // that narrowly so a control another example needs a script to show |
| 201 | // still fails, and each entry must excuse an absence, so one left |
| 202 | // behind by a renamed example fails too. |
| 203 | var scriptShown = map[string]string{ |
| 204 | "snippet [rst-btn]": "copy.js shows the Copy button only where there is a clipboard; with no script it stays hidden on purpose", |
| 205 | } |
| 206 | |
| 207 | // floorGaps are the inventory idioms no example state renders, each |
| 208 | // with why, which the seeded sizing page holds instead. A new gap |
| 209 | // fails the drive, and so does a pinned gap that an example has since |
| 210 | // filled. |
| 211 | var floorGaps = map[string]string{ |
| 212 | "[rst-shell-chrome] > summary": "the legacy sidebar drawer: no example carries legacy shell markup", |
| 213 | } |
| 214 | |
| 215 | // floorsJS measures one state as measureEverything measures the sizing |
| 216 | // page, over the inventory's controls only: the closed page, then every |
| 217 | // <details> opened alone (the way overlayJS opens it, the shell's |
| 218 | // sibling tail included), then every combobox's list and every date |
| 219 | // field's list, each opened by the script that owns it; a list that |
| 220 | // does not open within ninety frames is reported as failed. It repeats |
| 221 | // overlayJS's walk and measureEverything's list opening rather than |
| 222 | // calling them because both are written against the sizing page (the |
| 223 | // walk returns one overlay per evaluate, the lists are found by |
| 224 | // #sizing-combo and the one [rst-dtp]) and they take no filter; the |
| 225 | // filter here is what keeps a prose link out of the floor. It is a |
| 226 | // function the caller applies to the two selector lists. It also |
| 227 | // returns which probes are rendered on the page, exemptions aside, |
| 228 | // with no element measured (an idiom an example renders that the |
| 229 | // floor never read), how many elements each probe had measured, and |
| 230 | // the gap between every icon in a control and the word beside it, in |
| 231 | // both directions of text, with every overlay open. |
| 232 | const floorsJS = `(async (probes, exempt) => { ` + measureFn + `; |
| 233 | const floored = el => el.matches(probes) && !el.matches(exempt); |
| 234 | const skip = el => !floored(el); |
| 235 | const frame = () => new Promise(r => requestAnimationFrame(r)); |
| 236 | const frames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); |
| 237 | const until = async cond => { for (let i = 0; i < 90; i++) { if (cond()) return true; await frame(); } return false; }; |
| 238 | const name = el => el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + |
| 239 | [...el.attributes].filter(a => a.name.startsWith("rst-")).map(a => "[" + a.name + (a.value ? "=" + a.value : "") + "]").join("") + |
| 240 | " “" + (el.getAttribute("aria-label") || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 30) + "”"; |
| 241 | const out = {Closed: measure(document, skip), Overlays: [], Lists: {}, Failed: [], Unmeasured: [], Counts: {}, Gaps: []}; |
| 242 | for (const d of document.querySelectorAll("details")) { |
| 243 | for (let p = d; p; p = p.parentElement && p.parentElement.closest("details")) p.open = true; |
| 244 | const tail = d.closest("[rst-shell-tail], .rst-shell__tail"); |
| 245 | if (tail && tail.previousElementSibling && tail.previousElementSibling.matches("details")) tail.previousElementSibling.open = true; |
| 246 | let outer = d; |
| 247 | while (outer.parentElement && outer.parentElement.closest("details")) outer = outer.parentElement.closest("details"); |
| 248 | const os = outer.querySelector(":scope > summary"); |
| 249 | if (os) os.scrollIntoView({block: "center", inline: "center"}); |
| 250 | await frames(); |
| 251 | const summary = d.querySelector(":scope > summary"); |
| 252 | const got = measure(d, el => el === summary || skip(el)); |
| 253 | if (d.matches("[rst-shell-menu], [rst-shell-chrome]") && d.nextElementSibling) got.push(...measure(d.nextElementSibling, skip)); |
| 254 | document.querySelectorAll("details[open]").forEach(x => { x.open = false; }); |
| 255 | out.Overlays.push(got); |
| 256 | } |
| 257 | // Each field's own list, not the page's first: a date range holds |
| 258 | // two fields, and the first one's list stays closed while the |
| 259 | // second's is open. |
| 260 | const shown = l => !!l && l.checkVisibility() && l.querySelectorAll("[role=option]").length > 0; |
| 261 | for (const combo of document.querySelectorAll("[rst-combo] [role=combobox]")) { |
| 262 | const label = "the combobox's list (" + name(combo) + ")", list = combo.closest("[rst-combo]").querySelector("[rst-combo-list]"); |
| 263 | combo.focus(); combo.click(); |
| 264 | if (await until(() => shown(list))) out.Lists[label] = measure(list, skip); |
| 265 | else out.Failed.push(label); |
| 266 | combo.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true})); |
| 267 | await frames(); |
| 268 | } |
| 269 | for (const dtp of document.querySelectorAll("[rst-dtp] [role=combobox]")) { |
| 270 | const label = "the date field's list (" + name(dtp) + ")", list = dtp.closest("[rst-dtp]").querySelector("[rst-dtp-list]"); |
| 271 | dtp.focus(); dtp.value = "t"; dtp.dispatchEvent(new Event("input", {bubbles: true})); |
| 272 | if (await until(() => shown(list))) out.Lists[label] = measure(list, skip); |
| 273 | else out.Failed.push(label); |
| 274 | dtp.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true})); |
| 275 | await frames(); |
| 276 | } |
| 277 | const rendered = el => { if (el.checkVisibility && !el.checkVisibility({visibilityProperty: true})) return false; const r = el.getBoundingClientRect(); return r.width >= 2 && r.height >= 2 && getComputedStyle(el).clipPath !== "inset(50%)"; }; |
| 278 | for (const sel of probes.split(", ")) { |
| 279 | const all = [...document.querySelectorAll(sel)]; |
| 280 | out.Counts[sel] = all.filter(e => e.hasAttribute("data-measured")).length; |
| 281 | const present = all.filter(e => rendered(e) && !e.matches(exempt) && !e.closest("[inert]")); |
| 282 | if (present.length && !present.some(e => e.hasAttribute("data-measured"))) out.Unmeasured.push(sel); |
| 283 | } |
| 284 | // The space between an icon and the word beside it, read as |
| 285 | // TestAnIconKeepsItsSpaceBesideItsText reads it: from the icon's edge |
| 286 | // to the word's first letter, with every overlay open so menu items |
| 287 | // are drawn, in the page's direction and the other one. |
| 288 | const was = document.documentElement.dir; |
| 289 | document.querySelectorAll("details").forEach(d => { d.open = true; }); |
| 290 | for (const dir of ["ltr", "rtl"]) { |
| 291 | document.documentElement.dir = dir; |
| 292 | await frames(); |
| 293 | for (const c of document.querySelectorAll(probes)) { |
| 294 | if (c.matches(exempt) || !c.checkVisibility()) continue; |
| 295 | for (const i of c.querySelectorAll("svg.icon")) { |
| 296 | if (!i.checkVisibility()) continue; |
| 297 | const ir = i.getBoundingClientRect(); |
| 298 | for (const [side, n] of [["after", i.nextSibling], ["before", i.previousSibling]]) { |
| 299 | if (!n || n.nodeType !== 3 || !n.textContent.trim()) continue; |
| 300 | const r = document.createRange(); |
| 301 | if (side === "after") { r.setStart(n, n.textContent.search(/\S/)); r.setEnd(n, n.textContent.length); } |
| 302 | else { r.setStart(n, 0); r.setEnd(n, n.textContent.replace(/\s+$/, "").length); } |
| 303 | const tx = r.getBoundingClientRect(); |
| 304 | if (tx.width === 0) continue; |
| 305 | const gap = side === "after" ? (dir === "rtl" ? ir.left - tx.right : tx.left - ir.right) : (dir === "rtl" ? tx.left - ir.right : ir.left - tx.right); |
| 306 | out.Gaps.push({Name: name(c), Dir: dir, Side: side, Gap: Math.round(gap * 10) / 10}); |
| 307 | } |
| 308 | } |
| 309 | } |
| 310 | } |
| 311 | document.documentElement.dir = was; |
| 312 | document.querySelectorAll("details[open]").forEach(d => { d.open = false; }); |
| 313 | return JSON.stringify(out); |
| 314 | })` |
| 315 | |
| 316 | // floorReading is what floorsJS returns for one state. |
| 317 | type floorReading struct { |
| 318 | Closed []targetReading |
| 319 | Overlays [][]targetReading |
| 320 | Lists map[string][]targetReading |
| 321 | Failed []string |
| 322 | Unmeasured []string |
| 323 | Counts map[string]int |
| 324 | Gaps []struct { |
| 325 | Name, Dir, Side string |
| 326 | Gap float64 |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | // TestEveryExampleStateMeetsTheFloors is the floors on the examples. |
| 331 | // For every state of every non-layout example, each loaded as its own |
| 332 | // document at 390px under a coarse pointer and at 1280px with a |
| 333 | // mouse, and at 1280px with every script tag stripped (the page as a |
| 334 | // browser with no script sees it): |
| 335 | // |
| 336 | // - every interactive element the tap inventory names is at least |
| 337 | // 44 pixels on the phone and 32 on the desktop, whole in the |
| 338 | // viewport and hittable, overlays and lists included, a stretched |
| 339 | // link measured as the row or tile it stretches over; |
| 340 | // - every text control is at least 16px on the phone; |
| 341 | // - every icon inside a control is 16px, a caret's 12, and keeps at |
| 342 | // least 3px between its edge and the word beside it, in both |
| 343 | // directions of text; |
| 344 | // - the page loads with no console error, no failed request and no |
| 345 | // JS value leaked to the screen (the rig's screen gate); |
| 346 | // - with no script, the same idioms are measured on each state as |
| 347 | // with scripts, the three idioms a script builds and the one |
| 348 | // control scriptShown names aside, so a component whose controls |
| 349 | // need a script to appear fails here. |
| 350 | // |
| 351 | // Every inventory idiom a state renders must have been measured on it, |
| 352 | // so an idiom cannot drop out of the floor because its example moved; |
| 353 | // the idioms no example renders at all are pinned in floorGaps, so a |
| 354 | // new gap fails. The drive checks, against the manifest's own list, |
| 355 | // that the server served every state's page on every leg, and that |
| 356 | // the manifest lists more than a hundred, so an emptied manifest or a |
| 357 | // loop that skipped cannot pass by measuring nothing. |
| 358 | // |
| 359 | // It is two tests, so the suite's slices can run them on shards of |
| 360 | // their own: the phone's leg, and the two desktop legs together, since |
| 361 | // the no-script leg is held to what the scripted one measured. Every |
| 362 | // leg's checks are as they were. Of the checks over every leg, the |
| 363 | // pinned gaps are checked in both, each for what it measures, and an |
| 364 | // idiom no state renders is looked for where the inventory says it is |
| 365 | // rendered: a narrow: idiom on the phone, every other one at 1280. |
| 366 | // That last is stricter than the one test, which accepted an idiom |
| 367 | // seen on the phone alone; and both tests need a caret and an icon. |
| 368 | func TestEveryExampleStateMeetsTheFloorsOnAPhone(t *testing.T) { |
| 369 | meetTheFloors(t, true, []floorLeg{ |
| 370 | {"390 touch", 390, 844, true, 44, false}, |
| 371 | }) |
| 372 | } |
| 373 | |
| 374 | func TestEveryExampleStateMeetsTheFloorsWithAMouse(t *testing.T) { |
| 375 | meetTheFloors(t, false, []floorLeg{ |
| 376 | {"1280 mouse", 1280, 900, false, 32, false}, |
| 377 | {"1280 mouse, no scripts", 1280, 900, false, 32, true}, |
| 378 | }) |
| 379 | } |
| 380 | |
| 381 | // floorLeg is one way the floors drive loads every state. |
| 382 | type floorLeg struct { |
| 383 | name string |
| 384 | w, h int64 |
| 385 | coarse bool |
| 386 | floor float64 |
| 387 | noscript bool |
| 388 | } |
| 389 | |
| 390 | // meetTheFloors drives legs, the phone's or the desktop's, over every |
| 391 | // state. |
| 392 | func meetTheFloors(t *testing.T, phone bool, legs []floorLeg) { |
| 393 | states, srv := floorPages(t) |
| 394 | want := manifestStates(t) |
| 395 | if len(want) <= 100 { |
| 396 | t.Fatalf("the manifest lists %d non-layout states, want more than 100", len(want)) |
| 397 | } |
| 398 | seen := map[string]int{} |
| 399 | withScripts := map[string]map[string]bool{} |
| 400 | shownUsed := map[string]bool{} |
| 401 | noscript := false |
| 402 | var carets, icons int |
| 403 | for _, leg := range legs { |
| 404 | t.Run(leg.name, func(t *testing.T) { |
| 405 | var opts []harness.Option |
| 406 | if leg.coarse { |
| 407 | opts = append(opts, harness.WithCoarsePointer()) |
| 408 | } |
| 409 | noscript = noscript || leg.noscript |
| 410 | rig := harness.New(t, func(string) http.Handler { return srv }, opts...) |
| 411 | ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Minute) |
| 412 | defer cancel() |
| 413 | mustRun(t, ctx, chromedp.EmulateViewport(leg.w, leg.h)) |
| 414 | probes, exempt, keyed := floorProbes(leg.w >= 800) |
| 415 | before := srv.snapshot() |
| 416 | measured := 0 |
| 417 | for _, st := range states { |
| 418 | key := st.Name + "/" + st.State |
| 419 | where := fmt.Sprintf("%s, %s", leg.name, key) |
| 420 | path := st.Path |
| 421 | if leg.noscript { |
| 422 | path = st.NoScript |
| 423 | } |
| 424 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body", chromedp.ByQuery)) |
| 425 | settleUntil(t, ctx, `document.readyState === "complete"`) |
| 426 | mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) |
| 427 | requirePointer(t, ctx, leg.coarse) |
| 428 | |
| 429 | if leg.coarse { |
| 430 | var raw string |
| 431 | mustRun(t, ctx, chromedp.Evaluate(fontsJS, &raw)) |
| 432 | var fonts []fontReading |
| 433 | if err := json.Unmarshal([]byte(raw), &fonts); err != nil { |
| 434 | t.Fatalf("%s: reading fonts (%q): %v", where, raw, err) |
| 435 | } |
| 436 | for _, f := range fonts { |
| 437 | if f.Px < 16 { |
| 438 | t.Errorf("%s: %s is %vpx; a phone zooms the page when it is focused", where, f.Name, f.Px) |
| 439 | } |
| 440 | } |
| 441 | } |
| 442 | c, i := assertIconSizes(t, where, readIcons(t, ctx, where)) |
| 443 | carets, icons = carets+c, icons+i |
| 444 | |
| 445 | var raw string |
| 446 | mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf("%s(%s, %s)", floorsJS, mustJSON(t, probes), mustJSON(t, exempt)), &raw, awaitPromise)) |
| 447 | var got floorReading |
| 448 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 449 | t.Fatalf("%s: reading the floors (%q): %v", where, raw, err) |
| 450 | } |
| 451 | assertTargetsAt(t, where+", overlays closed", got.Closed, leg.floor) |
| 452 | measured += len(got.Closed) |
| 453 | for n, o := range got.Overlays { |
| 454 | assertTargetsAt(t, fmt.Sprintf("%s, overlay %d open", where, n), o, leg.floor) |
| 455 | measured += len(o) |
| 456 | } |
| 457 | for name, l := range got.Lists { |
| 458 | if len(l) == 0 { |
| 459 | t.Errorf("%s: %s opened with no options to measure", where, name) |
| 460 | } |
| 461 | assertTargetsAt(t, where+", "+name, l, leg.floor) |
| 462 | measured += len(l) |
| 463 | } |
| 464 | for _, name := range got.Failed { |
| 465 | t.Errorf("%s: %s never opened; its options were not measured", where, name) |
| 466 | } |
| 467 | for _, sel := range got.Unmeasured { |
| 468 | t.Errorf("%s: %s (%s) is rendered and was never measured; the idiom dropped out of the floor", where, sel, keyed[sel]) |
| 469 | } |
| 470 | for _, g := range got.Gaps { |
| 471 | if g.Gap < 3 { |
| 472 | t.Errorf("%s, %s: in %s the icon is %.1fpx from the word %s it; it touches the word", where, g.Dir, g.Name, g.Gap, g.Side) |
| 473 | } |
| 474 | } |
| 475 | here := map[string]bool{} |
| 476 | for sel, n := range got.Counts { |
| 477 | seen[sel] += n |
| 478 | if n > 0 { |
| 479 | here[sel] = true |
| 480 | } |
| 481 | } |
| 482 | switch { |
| 483 | case leg.noscript: |
| 484 | with := withScripts[key] |
| 485 | if with == nil { |
| 486 | t.Fatalf("%s: no record of what the scripted leg measured on this state", where) |
| 487 | } |
| 488 | for sel := range with { |
| 489 | _, built := scriptBuilt[sel] |
| 490 | _, shown := scriptShown[st.Name+" "+sel] |
| 491 | if !here[sel] && shown { |
| 492 | shownUsed[st.Name+" "+sel] = true |
| 493 | } |
| 494 | if !here[sel] && !built && !shown { |
| 495 | t.Errorf("%s: %s (%s) is measured with scripts and not without; the component does not work with no script", where, sel, keyed[sel]) |
| 496 | } |
| 497 | } |
| 498 | for sel := range here { |
| 499 | if !with[sel] { |
| 500 | t.Errorf("%s: %s (%s) is measured without scripts and not with them; the scripted page hides a control the plain one shows", where, sel, keyed[sel]) |
| 501 | } |
| 502 | } |
| 503 | case !leg.coarse: |
| 504 | withScripts[key] = here |
| 505 | } |
| 506 | rig.Screen("body", where) |
| 507 | } |
| 508 | after := srv.snapshot() |
| 509 | missing := 0 |
| 510 | for key, st := range want { |
| 511 | path := st.Path |
| 512 | if leg.noscript { |
| 513 | path = st.NoScript |
| 514 | } |
| 515 | if after[path]-before[path] < 1 { |
| 516 | missing++ |
| 517 | t.Errorf("%s: the manifest's %s was never served on this leg", leg.name, key) |
| 518 | } |
| 519 | } |
| 520 | if measured < 100 { |
| 521 | t.Errorf("%s: measured %d controls over %d states; the instrument read next to nothing", leg.name, measured, len(want)-missing) |
| 522 | } |
| 523 | t.Logf("%s: %d of the manifest's %d states served, %d controls measured", leg.name, len(want)-missing, len(want), measured) |
| 524 | }) |
| 525 | } |
| 526 | for key := range scriptShown { |
| 527 | if noscript && !shownUsed[key] { |
| 528 | t.Errorf("scriptShown excuses %s with no script, and no state needed it; drop the entry or fix its key", key) |
| 529 | } |
| 530 | } |
| 531 | if carets == 0 || icons == 0 { |
| 532 | t.Errorf("read %d caret icons and %d other icons in controls across every state; the examples must show at least one of each", carets, icons) |
| 533 | } |
| 534 | for _, e := range tapInventory { |
| 535 | if strings.HasPrefix(e.Probe, "elsewhere:") { |
| 536 | continue |
| 537 | } |
| 538 | narrow := strings.HasPrefix(e.Probe, "narrow:") |
| 539 | if narrow && !phone { |
| 540 | continue |
| 541 | } |
| 542 | sel := strings.TrimPrefix(strings.TrimPrefix(e.Probe, "narrow:"), "modal:") |
| 543 | _, pinned := floorGaps[sel] |
| 544 | switch { |
| 545 | case seen[sel] == 0 && !pinned && narrow == phone: |
| 546 | t.Errorf("no example state renders %s (%s), and it is not a pinned gap; the examples lost the idiom", sel, e.Idiom) |
| 547 | case seen[sel] > 0 && pinned: |
| 548 | t.Errorf("%s (%s) is pinned as a gap in floorGaps but an example renders it; drop the pin", sel, e.Idiom) |
| 549 | } |
| 550 | } |
| 551 | } |
| 552 | |