rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package check
4
5import (
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.
25const 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.
50func 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.
113const 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.
143func 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.
217type 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 "".
225func (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.
255func 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