| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "math" |
| 10 | "net/http" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/chromedp/chromedp" |
| 16 | |
| 17 | "amadan.net/rastrillo/fiesta" |
| 18 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 19 | ) |
| 20 | |
| 21 | // buttonScaleJS reads the gallery's button sample and an sm link: each |
| 22 | // size's drawn height, and sm's reach, the run of points down its |
| 23 | // middle that a click lands on it from. Each point is hit-tested, so |
| 24 | // the reach is what a tap really gets, not what the ::before claims. |
| 25 | const buttonScaleJS = `(() => { |
| 26 | const h = el => Math.round(el.getBoundingClientRect().height * 100) / 100; |
| 27 | const sizes = {}; |
| 28 | for (const el of document.querySelectorAll('[rst-btn~="primary"]')) sizes[el.textContent.trim()] = h(el); |
| 29 | const reach = el => { |
| 30 | el.scrollIntoView({block: "center"}); |
| 31 | const r = el.getBoundingClientRect(), x = r.left + Math.min(r.width / 2, 8); |
| 32 | let top = r.top, bottom = r.bottom; |
| 33 | while (document.elementFromPoint(x, top - 0.25) === el) top -= 0.25; |
| 34 | while (document.elementFromPoint(x, bottom + 0.25) === el) bottom += 0.25; |
| 35 | return Math.round((bottom - top) * 100) / 100; |
| 36 | }; |
| 37 | const sm = document.querySelector('[rst-btn="primary sm"]'), link = document.querySelector("#sm-link"); |
| 38 | return JSON.stringify({Sm: sizes.Small, Md: sizes.Default, Lg: sizes.Large, Reach: reach(sm), LinkH: h(link), LinkReach: reach(link), |
| 39 | MdReach: reach(document.querySelector('[rst-btn="primary"]'))}); |
| 40 | })()` |
| 41 | |
| 42 | // TestTheButtonSizesAreAnEvenScale pins the three drawn sizes at 1280 |
| 43 | // with a mouse and at 390 with touch, and sm's hit area at the floor on |
| 44 | // both. The floor lifted sm to the default's 44px on a phone (32 beside |
| 45 | // 34 on a desktop), so the three read as two sizes. sm is now drawn |
| 46 | // under the floor and reaches it with an invisible ::before, measured |
| 47 | // here by hit-testing: on a button and on a link, since a link is not |
| 48 | // positioned by the browser's own stylesheet and a button is not either, |
| 49 | // so a missing position: relative would show on both. |
| 50 | func TestTheButtonSizesAreAnEvenScale(t *testing.T) { |
| 51 | body := `<div rst-page>` + idiomMarkup(t, "button") + `<p><a rst-btn="sm" href="#" id="sm-link">Edit</a></p></div>` |
| 52 | for _, leg := range []struct { |
| 53 | name string |
| 54 | w, h int64 |
| 55 | coarse bool |
| 56 | sm, md, lg, floor float64 |
| 57 | }{ |
| 58 | {"1280x900 mouse", 1280, 900, false, 26, 34, 42, 32}, |
| 59 | {"390x844 touch", 390, 844, true, 32, 44, 52, 44}, |
| 60 | } { |
| 61 | t.Run(leg.name, func(t *testing.T) { |
| 62 | rig := sizingRig(t, leg.coarse, map[string]string{"/": sizingDoc("buttons", body)}) |
| 63 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 64 | defer cancel() |
| 65 | var raw string |
| 66 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#sm-link", chromedp.ByQuery)); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | requirePointer(t, ctx, leg.coarse) |
| 70 | if err := chromedp.Run(ctx, chromedp.Evaluate(buttonScaleJS, &raw)); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | var got struct{ Sm, Md, Lg, Reach, LinkH, LinkReach, MdReach float64 } |
| 74 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if math.Round(got.Sm) != leg.sm || math.Round(got.Md) != leg.md || math.Round(got.Lg) != leg.lg { |
| 78 | t.Errorf("drawn heights %v/%v/%v, want %v/%v/%v", got.Sm, got.Md, got.Lg, leg.sm, leg.md, leg.lg) |
| 79 | } |
| 80 | if math.Round(got.LinkH) != leg.sm { |
| 81 | t.Errorf("an sm link is drawn %vpx tall, want %v like the sm button", got.LinkH, leg.sm) |
| 82 | } |
| 83 | // Within the 0.25px step of the walk, plus rounding: the |
| 84 | // reach is the floor, not the drawn box and not more. |
| 85 | for _, r := range []struct { |
| 86 | name string |
| 87 | got float64 |
| 88 | }{{"the sm button", got.Reach}, {"the sm link", got.LinkReach}} { |
| 89 | if math.Abs(r.got-leg.floor) > 0.75 { |
| 90 | t.Errorf("%s takes clicks over %vpx of its height, want the %vpx floor", r.name, r.got, leg.floor) |
| 91 | } |
| 92 | } |
| 93 | // The control: the default button has no hit area of its own, |
| 94 | // so the walk must stop at its border, or the walk measures |
| 95 | // something other than the ::before. |
| 96 | if math.Abs(got.MdReach-got.Md) > 0.75 { |
| 97 | t.Errorf("CONTROL: the default button takes clicks over %vpx but is drawn %vpx; the walk is not reading the hit area", got.MdReach, got.Md) |
| 98 | } |
| 99 | }) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // buttonLabelsJS reads every button in the sample: its label's colour |
| 104 | // against the fill behind it (the button's own, or the first opaque one |
| 105 | // above it for a ghost), and the box the label's text takes. A label |
| 106 | // can go missing in six ways: painted in its fill's colour, collapsed to |
| 107 | // nothing, laid out outside the button where it is cut off or off |
| 108 | // screen, hidden, made transparent by the button or anything around |
| 109 | // it, or covered by another box (the point at the label's middle must |
| 110 | // reach the button). The text fill is read rather than color, because |
| 111 | // the busy state hides a label with a transparent fill and leaves color |
| 112 | // alone. |
| 113 | const buttonLabelsJS = `(() => { |
| 114 | const rgba = s => { const m = s.match(/[\d.]+/g).map(Number); return m.length === 3 ? [...m, 1] : m; }; |
| 115 | const lum = c => { const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); }; |
| 116 | const fill = el => { for (let e = el; e; e = e.parentElement) { const c = rgba(getComputedStyle(e).backgroundColor); if (c[3] > 0.5) return c; } return rgba(getComputedStyle(document.documentElement).backgroundColor); }; |
| 117 | return JSON.stringify([...document.querySelectorAll("[rst-btn]")].map(el => { |
| 118 | const cs = getComputedStyle(el), fg = rgba(cs.webkitTextFillColor || cs.color), bg = fill(el); |
| 119 | const [a, b] = [lum(fg), lum(bg)].sort((x, y) => y - x); |
| 120 | el.scrollIntoView({block: "center"}); |
| 121 | const range = document.createRange(); range.selectNodeContents(el); |
| 122 | const t = range.getBoundingClientRect(), r = el.getBoundingClientRect(); |
| 123 | let opacity = 1; |
| 124 | for (let e = el; e; e = e.parentElement) opacity *= parseFloat(getComputedStyle(e).opacity); |
| 125 | const at = document.elementFromPoint(t.left + t.width / 2, t.top + t.height / 2); |
| 126 | return {Name: el.textContent.trim(), Disabled: el.disabled === true, Ratio: fg[3] < 0.5 ? 0 : Math.round((a + 0.05) / (b + 0.05) * 100) / 100, |
| 127 | W: t.width, H: t.height, Inside: t.left >= r.left - 0.5 && t.right <= r.right + 0.5 && t.top >= r.top - 0.5 && t.bottom <= r.bottom + 0.5, |
| 128 | OnScreen: t.left >= 0 && t.right <= innerWidth, Visibility: cs.visibility, Opacity: opacity, |
| 129 | Reached: !!at && (at === el || el.contains(at))}; |
| 130 | })); |
| 131 | })()` |
| 132 | |
| 133 | // TestEveryButtonLabelIsVisible holds every button in the gallery's |
| 134 | // sample to a label a reader can see, in every theme and both schemes, |
| 135 | // at 1280 with a mouse and 390 with touch: the text fill against the |
| 136 | // button's own fill at 4.5:1 (3:1 for the disabled one, which WCAG |
| 137 | // exempts but which must still read), and a text box with size, inside |
| 138 | // the button and on the screen. It was written for a report of the |
| 139 | // full-width primary button showing as a bar with no label in light |
| 140 | // mode, which no engine here reproduced (see the branch's report). Three |
| 141 | // negative controls, a label hidden, transparent and covered, must each |
| 142 | // fail the same checker, or the checker cannot see those ways at all. |
| 143 | func TestEveryButtonLabelIsVisible(t *testing.T) { |
| 144 | pages := map[string]string{} |
| 145 | for _, theme := range fiesta.ThemeNames() { |
| 146 | for _, scheme := range []string{"light", "dark"} { |
| 147 | doc := strings.Replace(sizingDoc("labels", `<div rst-page>`+idiomMarkup(t, "button")+`</div>`), `/theme.css`, `/theme-`+theme+`.css`, 1) |
| 148 | pages["/"+theme+"-"+scheme] = strings.Replace(doc, `<html lang="en"`, `<html data-theme="`+scheme+`" lang="en"`, 1) |
| 149 | } |
| 150 | } |
| 151 | // The negative controls: a label hidden, one under opacity 0, and one |
| 152 | // covered by another box, each still the right colour and size. |
| 153 | pages["/controls"] = sizingDoc("controls", `<div rst-page>`+ |
| 154 | `<p><button rst-btn="primary" type="button" style="visibility: hidden">Hidden</button></p>`+ |
| 155 | `<p style="opacity: 0"><button rst-btn="primary" type="button">Transparent</button></p>`+ |
| 156 | `<p style="position: relative; inline-size: fit-content"><button rst-btn="primary block" type="button">Covered</button><span style="background: #fff; inset: 0; position: absolute"></span></p></div>`) |
| 157 | build := func(string) http.Handler { |
| 158 | mux := sizingMux(t, pages) |
| 159 | for _, theme := range fiesta.ThemeNames() { |
| 160 | css, _ := fiesta.ThemeCSS(theme) |
| 161 | mux.HandleFunc("GET /theme-"+theme+".css", func(w http.ResponseWriter, _ *http.Request) { |
| 162 | w.Header().Set("Content-Type", "text/css") |
| 163 | w.Write(css) |
| 164 | }) |
| 165 | } |
| 166 | return mux |
| 167 | } |
| 168 | for _, leg := range []struct { |
| 169 | name string |
| 170 | w, h int64 |
| 171 | coarse bool |
| 172 | }{{"1280x900 mouse", 1280, 900, false}, {"390x844 touch", 390, 844, true}} { |
| 173 | t.Run(leg.name, func(t *testing.T) { |
| 174 | var opts []harness.Option |
| 175 | if leg.coarse { |
| 176 | opts = append(opts, harness.WithCoarsePointer()) |
| 177 | } |
| 178 | rig := harness.New(t, build, opts...) |
| 179 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 180 | defer cancel() |
| 181 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h)); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | for path := range pages { |
| 185 | var raw string |
| 186 | if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("[rst-btn~=block]", chromedp.ByQuery), chromedp.Evaluate(buttonLabelsJS, &raw)); err != nil { |
| 187 | t.Fatalf("%s: %v", path, err) |
| 188 | } |
| 189 | var got []labelReading |
| 190 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | if path == "/controls" { |
| 194 | // Each control hides its label one way; a checker that |
| 195 | // passes any of them could pass a hidden label anywhere. |
| 196 | for _, b := range got { |
| 197 | if b.problem() == "" { |
| 198 | t.Errorf("CONTROL: %q's label is hidden, and the checker passed it", b.Name) |
| 199 | } |
| 200 | } |
| 201 | continue |
| 202 | } |
| 203 | if len(got) != 10 { |
| 204 | t.Fatalf("%s: read %d buttons, want the sample's 10", path, len(got)) |
| 205 | } |
| 206 | for _, b := range got { |
| 207 | if p := b.problem(); p != "" { |
| 208 | t.Errorf("%s: %q's label %s", path, b.Name, p) |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | }) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // labelReading is one button's label as buttonLabelsJS reads it. |
| 217 | type labelReading struct { |
| 218 | Name string |
| 219 | Disabled, Inside, OnScreen, Reached bool |
| 220 | Ratio, W, H, Opacity float64 |
| 221 | Visibility string |
| 222 | } |
| 223 | |
| 224 | // problem says what keeps the label from being seen, or "". |
| 225 | func (b labelReading) problem() string { |
| 226 | min := 4.5 |
| 227 | if b.Disabled { |
| 228 | min = 3 |
| 229 | } |
| 230 | switch { |
| 231 | case b.Ratio < min: |
| 232 | return fmt.Sprintf("is %.2f:1 against its fill, want %v:1", b.Ratio, min) |
| 233 | case b.W < 1 || b.H < 1: |
| 234 | return fmt.Sprintf("is %.1f×%.1f; it has collapsed", b.W, b.H) |
| 235 | case !b.Inside || !b.OnScreen: |
| 236 | return "is outside its button or off the screen" |
| 237 | case b.Visibility != "visible": |
| 238 | return "is visibility: " + b.Visibility |
| 239 | case b.Opacity < 0.5: |
| 240 | return fmt.Sprintf("is painted at opacity %.2f", b.Opacity) |
| 241 | case !b.Reached: |
| 242 | return "is covered: the point at its middle does not reach the button" |
| 243 | } |
| 244 | return "" |
| 245 | } |
| 246 | |
| 247 | // TestSmallButtonsKeepTheirOwnEdges: sm's hit area reaches the floor |
| 248 | // without taking a tap from anything beside it. Two sm buttons with no |
| 249 | // gap between them, stacked in a column and wrapped in a line, are each |
| 250 | // hit at their own drawn edges; the space the hit area needs is |
| 251 | // reserved in layout instead. And sm composes with a tooltip, which is |
| 252 | // drawn by its own pseudo-element: the hit area still reaches the floor |
| 253 | // and the tip still shows its words. At 1280 with a mouse and 390 with |
| 254 | // touch. |
| 255 | func TestSmallButtonsKeepTheirOwnEdges(t *testing.T) { |
| 256 | body := `<div rst-page><div id="col" style="display: flex; flex-direction: column; align-items: flex-start; gap: 0">` + |
| 257 | `<button rst-btn="sm" type="button" id="a">Archive</button><button rst-btn="sm" type="button" id="b">Delete</button></div>` + |
| 258 | `<p id="row" style="inline-size: 5rem"><button rst-btn="sm" type="button" id="c">Archive</button><button rst-btn="sm" type="button" id="d">Delete</button></p>` + |
| 259 | `<p><button rst-btn="sm" type="button" id="tip" rst-tip data-tip="Copy the link">Copy</button></p></div>` |
| 260 | for _, leg := range []struct { |
| 261 | name string |
| 262 | w, h int64 |
| 263 | coarse bool |
| 264 | floor float64 |
| 265 | }{{"1280x900 mouse", 1280, 900, false, 32}, {"390x844 touch", 390, 844, true, 44}} { |
| 266 | t.Run(leg.name, func(t *testing.T) { |
| 267 | rig := sizingRig(t, leg.coarse, map[string]string{"/": sizingDoc("small", body)}) |
| 268 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 269 | defer cancel() |
| 270 | mustRun(t, ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#tip", chromedp.ByQuery)) |
| 271 | requirePointer(t, ctx, leg.coarse) |
| 272 | var g struct { |
| 273 | Edges []string |
| 274 | Stacked bool |
| 275 | TipReach float64 |
| 276 | TipContent string |
| 277 | } |
| 278 | at(t, ctx, `(() => { |
| 279 | const hit = (el, y) => { const r = el.getBoundingClientRect(), h = document.elementFromPoint(r.left + r.width / 2, y); return h && h.closest("button") ? h.closest("button").id : "nothing"; }; |
| 280 | const edges = []; |
| 281 | for (const [x, y] of [["a", "b"], ["c", "d"]]) { |
| 282 | const ex = document.getElementById(x), ey = document.getElementById(y); |
| 283 | edges.push(x + ":" + hit(ex, ex.getBoundingClientRect().bottom - 0.5), y + ":" + hit(ey, ey.getBoundingClientRect().top + 0.5)); |
| 284 | } |
| 285 | const tip = document.getElementById("tip"), r = tip.getBoundingClientRect(), x = r.left + 8; |
| 286 | let top = r.top, bottom = r.bottom; |
| 287 | while (document.elementFromPoint(x, top - 0.25) === tip) top -= 0.25; |
| 288 | while (document.elementFromPoint(x, bottom + 0.25) === tip) bottom += 0.25; |
| 289 | return JSON.stringify({Edges: edges, Stacked: document.getElementById("d").getBoundingClientRect().top >= document.getElementById("c").getBoundingClientRect().bottom - 0.5, |
| 290 | TipReach: bottom - top, TipContent: getComputedStyle(tip, "::after").content}); |
| 291 | })()`, &g) |
| 292 | if !g.Stacked { |
| 293 | t.Fatalf("the two sm buttons in the narrow line did not wrap onto two lines; the line case proves nothing") |
| 294 | } |
| 295 | for _, e := range g.Edges { |
| 296 | if id, got, _ := strings.Cut(e, ":"); id != got { |
| 297 | t.Errorf("a click at the drawn edge of #%s lands on %s", id, got) |
| 298 | } |
| 299 | } |
| 300 | if math.Abs(g.TipReach-leg.floor) > 0.75 { |
| 301 | t.Errorf("an sm button with a tooltip takes clicks over %.2fpx of its height, want the %vpx floor", g.TipReach, leg.floor) |
| 302 | } |
| 303 | if g.TipContent != `"Copy the link"` { |
| 304 | t.Errorf("the sm button's tooltip reads %s, want \"Copy the link\"", g.TipContent) |
| 305 | } |
| 306 | }) |
| 307 | } |
| 308 | } |
| 309 | |