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 "math"
9 "regexp"
10 "strconv"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/chromedp/cdproto/emulation"
16 "github.com/chromedp/cdproto/input"
17 "github.com/chromedp/chromedp"
18
19 "amadan.net/rastrillo/fiesta/internal/harness"
20)
21
22// switchJS reads one switch: its checkbox, the events it fired since the
23// page counted, where the thumb is drawn (the ::after's translateX, in
24// px, signed as the screen has it), the track's transition and
25// touch-action, its box (W by H, K the knob's width, Run the knob's
26// travel: the track's width less its height, since the knob is the
27// height less the 2px inset each side), the two numbers the shim
28// writes (P the knob's share of the travel, On the on colour's share)
29// and the track's painted colour.
30const switchJS = `((id) => { const l = document.getElementById(id), t = l.querySelector("[rst-switch-track]"), a = getComputedStyle(t, "::after"), ts = getComputedStyle(t);
31 const m = new DOMMatrix(a.transform === "none" ? "" : a.transform), r = t.getBoundingClientRect();
32 return JSON.stringify({Checked: l.querySelector("input").checked, Changes: (window.changes || {})[id] || 0, Inputs: (window.inputs || {})[id] || 0,
33 X: m.m41, Duration: ts.transitionDuration, Easing: ts.transitionTimingFunction, TouchAction: ts.touchAction, Dragging: l.hasAttribute("data-rst-drag"), Settling: l.hasAttribute("data-rst-settle"),
34 L: r.left, R: r.right, Y: r.top + r.height / 2, W: r.width, H: r.height, K: parseFloat(a.width), Run: r.width - r.height,
35 P: ts.getPropertyValue("--rst-switch-p").trim(), On: ts.getPropertyValue("--rst-switch-on").trim(), Bg: ts.backgroundColor}); })`
36
37type switchReading struct {
38 Checked, Dragging, Settling bool
39 Changes, Inputs int
40 X, L, R, Y, W, H, K, Run float64
41 Duration, Easing, TouchAction string
42 P, On, Bg string
43}
44
45// thumbAt is the screen x of the knob's centre at rest at the inline
46// start (off), 2px in and half a knob across; toward the inline end is
47// +x in LTR and -x in RTL.
48func thumbAt(s switchReading, sign float64) float64 {
49 if sign > 0 {
50 return s.L + 2 + s.K/2
51 }
52 return s.R - 2 - s.K/2
53}
54
55// read runs switchJS on the switch with this id.
56func read(t *testing.T, ctx context.Context, id string) switchReading {
57 t.Helper()
58 var raw string
59 mustRun(t, ctx, chromedp.Evaluate(switchJS+`("`+id+`")`, &raw))
60 var s switchReading
61 if err := json.Unmarshal([]byte(raw), &s); err != nil {
62 t.Fatal(err)
63 }
64 return s
65}
66
67// TestASwitchSlidesAndSnaps: the thumb can be dragged. It follows the
68// pointer along the track, and on release snaps to the nearer end; the
69// checkbox toggles, once, with one input and one change event, only when
70// that end is the other state. A drag that comes back before release
71// changes nothing, a tap toggles once as it always has (the label's own
72// behaviour, so never twice), a disabled switch does not move (nor one
73// in a disabled fieldset), and in
74// RTL the track runs the other way. The thumb snaps with the CSS
75// transition, which reduced motion turns off; the track lets a vertical
76// swipe scroll the page and keeps a horizontal one for the drag. With a
77// mouse, and with touch. The journey is driveSwitchDrag, so another rig
78// serving switchDragPages can be held to the same drive.
79func TestASwitchSlidesAndSnaps(t *testing.T) {
80 driveSwitchDrag(t, sizingRig(t, false, switchDragPages))
81}
82
83// switchDragPages is the pair of pages driveSwitchDrag navigates to: six
84// switches (one to drag on, one to drag out and back, one to tap, a
85// disabled one, one in a disabled fieldset, one for a finger) with the
86// counters switchJS reads, in LTR at / and in RTL at /rtl.
87var switchDragPages = func() map[string]string {
88 sw := func(id, extra string) string {
89 return `<p><label rst-switch id="` + id + `"><input type="checkbox" name="` + id + `"` + extra + `><span rst-switch-track aria-hidden="true"></span> Email me about replies</label></p>`
90 }
91 body := `<div rst-page>` + sw("drag", "") + sw("back", "") + sw("tap", "") + sw("off", " disabled") + `<fieldset disabled>` + sw("fieldset", "") + `</fieldset>` + sw("touch", "") + `</div>` +
92 `<script>window.changes = {}; window.inputs = {}; for (const [k, m] of [["change", window.changes], ["input", window.inputs]]) document.addEventListener(k, e => { const l = e.target.closest("[rst-switch]"); if (l) m[l.id] = (m[l.id] || 0) + 1; });</script>`
93 return map[string]string{"/": sizingDoc("switch", body), "/rtl": strings.Replace(sizingDoc("switch", body), `dir="ltr"`, `dir="rtl"`, 1)}
94}()
95
96// driveSwitchDrag is the drag journey of TestASwitchSlidesAndSnaps on a
97// rig that serves switchDragPages: LTR, RTL and LTR under reduced
98// motion, each a subtest on the one browser. A leg leaves the browser
99// as it found it, touch emulation off and the emulated media cleared,
100// so the next leg's mouse drags read the page the leg before read. The
101// reset runs on a context of its own, so a leg that timed out does not
102// fail a second time on its reset, and a leg already red says nothing
103// more; only a green leg whose reset fails reports it.
104func driveSwitchDrag(t *testing.T, rig *harness.Rig) {
105 t.Helper()
106 for _, leg := range []struct {
107 name, path string
108 reduce bool
109 }{{"ltr", "/", false}, {"rtl", "/rtl", false}, {"ltr reduced motion", "/", true}} {
110 t.Run(leg.name, func(t *testing.T) {
111 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
112 defer cancel()
113 defer func() {
114 rctx, rcancel := context.WithTimeout(rig.Context(), 10*time.Second)
115 defer rcancel()
116 if err := chromedp.Run(rctx, emulation.SetTouchEmulationEnabled(false), emulation.SetEmulatedMedia()); err != nil && !t.Failed() {
117 t.Errorf("resetting the browser after the leg: %v", err)
118 }
119 }()
120 if leg.reduce {
121 mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}}))
122 }
123 mustRun(t, ctx, chromedp.EmulateViewport(800, 700), chromedp.Navigate(rig.Origin+leg.path), chromedp.WaitReady("#touch", chromedp.ByQuery),
124 chromedp.Evaluate(`new Promise(r => addEventListener("load", r, {once: true}) || document.readyState === "complete" && r())`, nil, awaitPromise))
125 // sign turns "toward on" into screen pixels; the thumb starts
126 // at the track's inline start, 2px in, a knob across. Offsets
127 // are shares of the knob's travel (Run), read from the page,
128 // so the drive holds for any theme's size.
129 sign := 1.0
130 if leg.path == "/rtl" {
131 sign = -1
132 }
133 thumb := func(s switchReading) float64 { return thumbAt(s, sign) }
134 mouse := func(typ input.MouseType, x, y float64) chromedp.Action {
135 return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1)
136 }
137 // drag presses on the thumb, moves through each offset (toward
138 // on, as a share of the travel) and releases at the last.
139 drag := func(id string, shares ...float64) (mid switchReading) {
140 s := read(t, ctx, id)
141 x := thumb(s)
142 mustRun(t, ctx, mouse(input.MousePressed, x, s.Y))
143 for _, o := range shares {
144 mustRun(t, ctx, mouse(input.MouseMoved, x+sign*o*s.Run, s.Y))
145 }
146 mid = read(t, ctx, id)
147 mustRun(t, ctx, mouse(input.MouseReleased, x+sign*shares[len(shares)-1]*s.Run, s.Y), chromedp.Sleep(350*time.Millisecond))
148 return mid
149 }
150 run := read(t, ctx, "drag").Run
151
152 if s := read(t, ctx, "drag"); s.TouchAction != "pan-y" {
153 t.Errorf("the track's touch-action is %q; want pan-y, so a vertical swipe scrolls and a horizontal one drags", s.TouchAction)
154 }
155 if s := read(t, ctx, "drag"); leg.reduce && s.Duration != "0s" || !leg.reduce && s.Duration == "0s" {
156 t.Errorf("the track's transition is %s with reduced motion %v; the knob should settle with a transition, and without one under reduced motion", s.Duration, leg.reduce)
157 }
158
159 mid := drag("drag", 0.2, 0.4, 0.6, 0.8)
160 if !mid.Dragging || math.Abs(mid.X-sign*0.8*run) > 1 {
161 t.Errorf("80%% into a drag the thumb is at %.1f (dragging %v); it should follow the pointer to %.1f", mid.X, mid.Dragging, sign*0.8*run)
162 }
163 if s := read(t, ctx, "drag"); !s.Checked || s.Changes != 1 || s.Inputs != 1 || math.Abs(s.X-sign*run) > 0.5 || s.Dragging {
164 t.Errorf("released past the middle: checked %v, %d change and %d input events, thumb at %.1f; want on, one of each, at %.1f", s.Checked, s.Changes, s.Inputs, s.X, sign*run)
165 }
166
167 drag("back", 0.25, 0.5, 0.8, 0.25, 0.1)
168 if s := read(t, ctx, "back"); s.Checked || s.Changes != 0 || math.Abs(s.X) > 0.5 {
169 t.Errorf("dragged out and back before release: checked %v, %d change events, thumb at %.1f; want nothing changed", s.Checked, s.Changes, s.X)
170 }
171
172 s := read(t, ctx, "tap")
173 mustRun(t, ctx, mouse(input.MousePressed, thumb(s), s.Y), mouse(input.MouseReleased, thumb(s), s.Y), chromedp.Sleep(350*time.Millisecond))
174 if s := read(t, ctx, "tap"); !s.Checked || s.Changes != 1 {
175 t.Errorf("a tap: checked %v, %d change events; want on, once", s.Checked, s.Changes)
176 }
177
178 // Disabled itself, and disabled by its fieldset, where the
179 // checkbox's own disabled property is false but it matches
180 // :disabled and a click does nothing.
181 for _, id := range []string{"off", "fieldset"} {
182 mid = drag(id, 0.25, 0.5, 0.8)
183 if s := read(t, ctx, id); s.Checked || s.Changes != 0 || mid.Dragging || math.Abs(mid.X) > 0.5 || s.Dragging {
184 t.Errorf("#%s, disabled, dragged: checked %v, %d change events, knob at %.1f mid-drag (dragging %v); want it not to move", id, s.Checked, s.Changes, mid.X, mid.Dragging)
185 }
186 }
187
188 // Touch: the same drag with a finger. Read after touch is on:
189 // a coarse pointer grows the rows, and the switch moves.
190 mustRun(t, ctx, emulation.SetTouchEmulationEnabled(true))
191 s = read(t, ctx, "touch")
192 x := thumb(s)
193 mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error {
194 if err := input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: x, Y: s.Y}}).Do(ctx); err != nil {
195 return err
196 }
197 for _, o := range []float64{0.2, 0.4, 0.6, 0.8, 0.93} {
198 if err := input.DispatchTouchEvent(input.TouchMove, []*input.TouchPoint{{X: x + sign*o*s.Run, Y: s.Y}}).Do(ctx); err != nil {
199 return err
200 }
201 }
202 return input.DispatchTouchEvent(input.TouchEnd, []*input.TouchPoint{}).Do(ctx)
203 }), chromedp.Sleep(350*time.Millisecond))
204 if s := read(t, ctx, "touch"); !s.Checked || s.Changes != 1 {
205 t.Errorf("a finger dragged past the middle: checked %v, %d change events; want on, once", s.Checked, s.Changes)
206 }
207 })
208 }
209}
210
211// TestASwitchFollowsItsOwnDirection: the knob's travel comes from the
212// switch's own direction, not from an ancestor's dir attribute. An LTR
213// section inside an RTL page and an RTL one inside an LTR page each
214// draw their checked knob at their own inline end, inside the track,
215// and a drag toward that end turns them on.
216func TestASwitchFollowsItsOwnDirection(t *testing.T) {
217 sw := func(id, checked string) string {
218 return `<label rst-switch id="` + id + `"><input type="checkbox" name="` + id + `"` + checked + `><span rst-switch-track aria-hidden="true"></span> Email me</label>`
219 }
220 body := func(inner string) string {
221 return `<div rst-page><section dir="` + inner + `"><p>` + sw("on", " checked") + `</p><p>` + sw("drag", "") + `</p></section></div>` +
222 `<script>window.changes = {}; window.inputs = {}; document.addEventListener("change", e => { const l = e.target.closest("[rst-switch]"); if (l) window.changes[l.id] = (window.changes[l.id] || 0) + 1; });</script>`
223 }
224 pages := map[string]string{
225 "/ltr-in-rtl": strings.Replace(sizingDoc("switch", body("ltr")), `dir="ltr"`, `dir="rtl"`, 1),
226 "/rtl-in-ltr": sizingDoc("switch", body("rtl")),
227 }
228 rig := sizingRig(t, false, pages)
229 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
230 defer cancel()
231 for path, sign := range map[string]float64{"/ltr-in-rtl": 1, "/rtl-in-ltr": -1} {
232 mustRun(t, ctx, chromedp.EmulateViewport(800, 700), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("#drag", chromedp.ByQuery),
233 chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise))
234 if s := read(t, ctx, "on"); math.Abs(s.X-sign*s.Run) > 0.5 {
235 t.Errorf("%s: the checked knob is moved %.1fpx; want %.1f, toward the switch's own inline end", path, s.X, sign*s.Run)
236 }
237 s := read(t, ctx, "drag")
238 x := thumbAt(s, sign)
239 mouse := func(typ input.MouseType, x float64) chromedp.Action {
240 return input.DispatchMouseEvent(typ, x, s.Y).WithButton(input.Left).WithClickCount(1)
241 }
242 mustRun(t, ctx, mouse(input.MousePressed, x))
243 for _, o := range []float64{0.2, 0.4, 0.6, 0.8} {
244 mustRun(t, ctx, mouse(input.MouseMoved, x+sign*o*s.Run))
245 }
246 mustRun(t, ctx, mouse(input.MouseReleased, x+sign*0.8*s.Run), chromedp.Sleep(350*time.Millisecond))
247 if s := read(t, ctx, "drag"); !s.Checked || s.Changes != 1 || math.Abs(s.X-sign*s.Run) > 0.5 {
248 t.Errorf("%s: dragged toward its inline end: checked %v, %d change events, knob at %.1f; want on, once, at %.1f", path, s.Checked, s.Changes, s.X, sign*s.Run)
249 }
250 }
251}
252
253// TestASwitchTakesOneDragAtATime: a second finger on another switch
254// while the first is dragging is ignored, and when both lift, neither
255// switch is left mid-drag. The second press overwrote the first drag,
256// which kept its drag mark and its knob position for good, where a
257// keyboard toggle or a form reset could no longer move it.
258func TestASwitchTakesOneDragAtATime(t *testing.T) {
259 sw := func(id string) string {
260 return `<p><label rst-switch id="` + id + `"><input type="checkbox" name="` + id + `"><span rst-switch-track aria-hidden="true"></span> Email me</label></p>`
261 }
262 body := `<div rst-page>` + sw("a") + sw("b") + `</div><script>window.changes = {}; window.inputs = {}; document.addEventListener("change", e => { const l = e.target.closest("[rst-switch]"); if (l) window.changes[l.id] = (window.changes[l.id] || 0) + 1; });</script>`
263 rig := sizingRig(t, true, map[string]string{"/": sizingDoc("switch", body)})
264 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
265 defer cancel()
266 mustRun(t, ctx, chromedp.EmulateViewport(390, 700), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#b", chromedp.ByQuery),
267 chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise))
268 a, b := read(t, ctx, "a"), read(t, ctx, "b")
269 ax, bx := thumbAt(a, 1), thumbAt(b, 1)
270 touch := func(typ input.TouchType, pts ...*input.TouchPoint) chromedp.Action {
271 return input.DispatchTouchEvent(typ, pts)
272 }
273 // Offsets are shares of the knob's travel.
274 pa := func(o float64) *input.TouchPoint { return &input.TouchPoint{X: ax + o*a.Run, Y: a.Y, ID: 1} }
275 pb := func(o float64) *input.TouchPoint { return &input.TouchPoint{X: bx + o*b.Run, Y: b.Y, ID: 2} }
276 mustRun(t, ctx,
277 touch(input.TouchStart, pa(0)), touch(input.TouchMove, pa(0.4)), touch(input.TouchMove, pa(0.8)),
278 touch(input.TouchStart, pa(0.8), pb(0)), touch(input.TouchMove, pa(0.85), pb(0.4)), touch(input.TouchMove, pa(0.9), pb(0.8)),
279 touch(input.TouchEnd), chromedp.Sleep(350*time.Millisecond))
280 if s := read(t, ctx, "a"); !s.Checked || s.Changes != 1 || s.Dragging || math.Abs(s.X-a.Run) > 0.5 {
281 t.Errorf("the first switch: checked %v, %d change events, still dragging %v, knob at %.1f; want on, once, settled at %.1f", s.Checked, s.Changes, s.Dragging, s.X, a.Run)
282 }
283 if s := read(t, ctx, "b"); s.Checked || s.Changes != 0 || s.Dragging || math.Abs(s.X) > 0.5 {
284 t.Errorf("the second switch, pressed mid-drag: checked %v, %d change events, dragging %v, knob at %.1f; want it untouched", s.Checked, s.Changes, s.Dragging, s.X)
285 }
286}
287
288// switchSheen reads the on track's sheen position as the browser
289// resolved it: the x offset of the 40px radial gradient relative to the
290// track's centre, in px, signed as the screen has it. The gradient is
291// written as calc(50% + dir * (x - 7.5px)); Chromium serialises the
292// resolved calc as "calc(50% + Npx)" or "calc(50% - Npx)".
293func switchSheen(t *testing.T, ctx context.Context, sel string) (present bool, offset float64) {
294 t.Helper()
295 var img string
296 mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+jsStr(sel)+` + " [rst-switch-track]")).backgroundImage`, &img))
297 m := regexp.MustCompile(`radial-gradient\(40px at calc\(50% ([+-]) ([0-9.]+)px\)`).FindStringSubmatch(img)
298 if m == nil {
299 return false, 0
300 }
301 v, _ := strconv.ParseFloat(m[2], 64)
302 if m[1] == "-" {
303 v = -v
304 }
305 return true, v
306}
307
308// TestTheKnobIsLitAndTheTrackSheenFollowsIt (spec §3): the
309// knob is a white-topped gradient in both schemes; the off track is
310// --rst-track-off and the on track the accent fill; hovered, the on
311// track's sheen sits over the knob, at the inline end in LTR and the
312// inline start in RTL; the tblock variant does the same; and the
313// sheen's position is pure CSS: no script writes on a switch. The knob
314// carries its highlight, its contact shadow and the knob shadow, and
315// the track sits in with an inset contact shadow; a disabled switch
316// takes no hover, as a disabled button takes none.
317func TestTheKnobIsLitAndTheTrackSheenFollowsIt(t *testing.T) {
318 sw := `<p><label rst-switch id="s"><input type="checkbox" checked><span rst-switch-track aria-hidden="true"></span> On</label></p>` +
319 `<p><label rst-switch id="dis"><input type="checkbox" checked disabled><span rst-switch-track aria-hidden="true"></span> Locked</label></p>` +
320 `<p><label rst-switch id="off"><input type="checkbox"><span rst-switch-track aria-hidden="true"></span> Off</label></p>` +
321 `<div rst-tblock id="tb"><label rst-tblock-head id="head"><input type="checkbox" checked><span rst-switch-track aria-hidden="true"></span><span rst-tblock-title>Block</span></label><div rst-tblock-body>body</div></div>`
322 doc := sizingDoc("knob", `<div rst-page>`+sw+`</div>`)
323 pages := map[string]string{"/": doc, "/rtl": strings.Replace(doc, `dir="ltr"`, `dir="rtl"`, 1)}
324 for _, leg := range []struct{ name, path, scheme string }{{"ltr light", "/", "light"}, {"rtl light", "/rtl", "light"}, {"ltr dark", "/", "dark"}} {
325 t.Run(leg.name, func(t *testing.T) {
326 rig := sizingRig(t, false, pages, harness.WithHover())
327 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
328 defer cancel()
329 mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+leg.path), chromedp.WaitVisible("#tb", chromedp.ByQuery),
330 chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+leg.scheme+`"), true`, nil))
331 still(t, ctx)
332 wantOff, wantOn := "rgb(138, 138, 149)", "rgb(32, 89, 221)"
333 if leg.scheme == "dark" {
334 wantOff, wantOn = "rgb(113, 115, 124)", "rgb(49, 96, 234)"
335 }
336 var knob, offTrack, onTrack string
337 mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector("#off [rst-switch-track]"), "::after").backgroundImage`, &knob),
338 chromedp.Evaluate(`getComputedStyle(document.querySelector("#off [rst-switch-track]")).backgroundColor`, &offTrack),
339 chromedp.Evaluate(`getComputedStyle(document.querySelector("#s [rst-switch-track]")).backgroundColor`, &onTrack))
340 // The on track is day's derived fill, which serialises as
341 // oklch(); the literal above is its eight-bit triple.
342 offTrack, onTrack = canonColours(offTrack), canonColours(onTrack)
343 if offTrack != wantOff || !strings.Contains(knob, "linear-gradient(rgb(255, 255, 255)") {
344 t.Errorf("off switch: track %s (want %s), knob %q (want a white-topped gradient)", offTrack, wantOff, knob)
345 }
346 if onTrack != wantOn {
347 t.Errorf("on switch: track %s, want %s", onTrack, wantOn)
348 }
349 if present, _ := switchSheen(t, ctx, "#s"); present {
350 t.Error("the on track has a sheen at rest; the sheen is a hover")
351 }
352 var knobShadow string
353 mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector("#off [rst-switch-track]"), "::after").boxShadow`, &knobShadow))
354 if n := len(layers(knobShadow)); n < 3 || !strings.HasSuffix(layers(knobShadow)[0], "inset") {
355 t.Errorf("knob box-shadow %q: want the inset highlight first, then its contact shadow and the theme's knob shadow", knobShadow)
356 }
357 if sh := computed(t, ctx, "#off [rst-switch-track]", "box-shadow"); !strings.HasSuffix(layers(sh)[0], "0px 1px 2px 0px inset") {
358 t.Errorf("track box-shadow %q, want an inset contact shadow first", sh)
359 }
360 var restKnob, hoverKnob string
361 knobOf := `getComputedStyle(document.querySelector("#dis [rst-switch-track]"), "::after").backgroundImage`
362 mustRun(t, ctx, chromedp.Evaluate(knobOf, &restKnob))
363 forceState(t, ctx, "#dis", "hover")
364 present, _ := switchSheen(t, ctx, "#dis")
365 mustRun(t, ctx, chromedp.Evaluate(knobOf, &hoverKnob))
366 forceState(t, ctx, "#dis")
367 if present || hoverKnob != restKnob {
368 t.Errorf("a disabled switch hovered: sheen %v, knob changed %v; want neither", present, hoverKnob != restKnob)
369 }
370 for _, sel := range []string{"#s", "#head"} {
371 forceState(t, ctx, sel, "hover")
372 present, off := switchSheen(t, ctx, sel)
373 forceState(t, ctx, sel)
374 if !present {
375 t.Errorf("%s hovered has no 40px sheen", sel)
376 continue
377 }
378 // On: the knob's centre is half the travel past the
379 // middle toward the inline end, which is +x in LTR and
380 // -x in RTL.
381 half := read(t, ctx, sel[1:]).Run / 2
382 if (leg.path == "/rtl") != (off < 0) || math.Abs(math.Abs(off)-half) > 0.5 {
383 t.Errorf("%s hovered: sheen offset %v on %s; want %.2fpx toward the inline end (C13)", sel, off, leg.path, half)
384 }
385 }
386 if got := computed(t, ctx, "#head", "-webkit-tap-highlight-color"); got != "rgba(0, 0, 0, 0)" {
387 t.Errorf("tblock head tap highlight %s, want transparent", got)
388 }
389 })
390 }
391}
392
393// TestTheSheenFollowsADraggedKnob (C13): with a finger (touch
394// emulation on the mouse rig, as TestASwitchSlidesAndSnaps drags; no
395// hover rig, so nothing hovers), the knob is dragged part way and the
396// sheen sits over it, written by fiesta.js on the track and read by
397// the gradient; the drag is cancelled and the knob snaps back with no
398// sheen; a second drag loses its capture (a lostpointercapture with
399// the drag's own pointer id, which the shim checks) and the knob snaps
400// back the same way. In LTR and RTL, and a third time on the LTR page
401// with light.js linked, where an observer on the switch records every
402// style write during the drags: each must be the shim's own
403// --rst-switch-p and --rst-switch-on (or their removal) and nothing else, because light.js
404// writes nothing on a switch while fiesta.js owns the drag (C13).
405// The fixture initialises the counters switchJS reads, as the other
406// switch fixtures do.
407func TestTheSheenFollowsADraggedKnob(t *testing.T) {
408 sw := `<p><label rst-switch id="s"><input type="checkbox" checked><span rst-switch-track aria-hidden="true"></span> On</label></p>` +
409 `<script>window.changes = {}; window.inputs = {}; window.__pid = null; addEventListener("pointerdown", e => { window.__pid = e.pointerId; });</script>`
410 doc := sizingDoc("drag", `<div rst-page>`+sw+`</div>`)
411 pages := map[string]string{"/": doc, "/rtl": strings.Replace(doc, `dir="ltr"`, `dir="rtl"`, 1),
412 "/light": strings.Replace(doc, `<script defer src="/busy.js"></script>`, `<script defer src="/busy.js"></script><script defer src="/light.js"></script>`, 1)}
413 for _, path := range []string{"/", "/rtl", "/light"} {
414 t.Run(path, func(t *testing.T) {
415 rig := sizingRig(t, false, pages)
416 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
417 defer cancel()
418 mustRun(t, ctx, chromedp.EmulateViewport(390, 700), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#s", chromedp.ByQuery),
419 chromedp.Evaluate(`new Promise(r => addEventListener("load", r, {once: true}) || document.readyState === "complete" && r())`, nil, awaitPromise),
420 emulation.SetTouchEmulationEnabled(true))
421 if path == "/light" {
422 mustRun(t, ctx, chromedp.Evaluate(`window.__sw = []; new MutationObserver(m => m.forEach(r => window.__sw.push(r.target.getAttribute("style") || ""))).observe(document.getElementById("s"), {attributes: true, subtree: true, attributeFilter: ["style"], attributeOldValue: true}); true`, nil))
423 }
424 s := read(t, ctx, "s")
425 sign := 1.0
426 if path == "/rtl" {
427 sign = -1
428 }
429 // Start at the on end, drag 9px toward off in three steps
430 // (the shim needs 4px before it takes the drag): the knob's
431 // x becomes the travel less 9px, so the sheen sits that far
432 // past the middle toward the inline end (negative: short of
433 // it, on day's 17px travel).
434 startX := thumbAt(s, sign) + sign*s.Run
435 knobX, sheenAt := sign*(s.Run-9), sign*(s.Run/2-9)
436 drag := func() {
437 mustRun(t, ctx, input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: startX, Y: s.Y}}),
438 input.DispatchTouchEvent(input.TouchMove, []*input.TouchPoint{{X: startX - 3*sign, Y: s.Y}}),
439 input.DispatchTouchEvent(input.TouchMove, []*input.TouchPoint{{X: startX - 6*sign, Y: s.Y}}),
440 input.DispatchTouchEvent(input.TouchMove, []*input.TouchPoint{{X: startX - 9*sign, Y: s.Y}}),
441 chromedp.Sleep(100*time.Millisecond))
442 }
443 drag()
444 if mid := read(t, ctx, "s"); !mid.Dragging || math.Abs(mid.X-knobX) > 0.5 {
445 t.Fatalf("mid-drag: dragging %v, knob at %v; want a drag with the knob at %v", mid.Dragging, mid.X, knobX)
446 }
447 if present, off := switchSheen(t, ctx, "#s"); !present || math.Abs(off-sheenAt) > 0.6 {
448 t.Errorf("mid-drag sheen offset %v (present %v), want %v: the sheen follows the knob", off, present, sheenAt)
449 }
450 mustRun(t, ctx, input.DispatchTouchEvent(input.TouchCancel, nil), chromedp.Sleep(350*time.Millisecond))
451 if after := read(t, ctx, "s"); after.Dragging || math.Abs(after.X-s.Run*sign) > 0.5 || !after.Checked {
452 t.Errorf("after a cancelled drag: dragging %v, knob at %v, checked %v; want the knob back at %v, still on", after.Dragging, after.X, after.Checked, s.Run*sign)
453 }
454 if present, _ := switchSheen(t, ctx, "#s"); present {
455 t.Error("after a cancelled drag the sheen is still there; nothing hovers a finger's switch")
456 }
457 drag()
458 if !read(t, ctx, "s").Dragging {
459 t.Fatal("the second drag did not take")
460 }
461 mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("#s [rst-switch-track]").dispatchEvent(new PointerEvent("lostpointercapture", {pointerId: window.__pid, bubbles: true})), true`, nil), chromedp.Sleep(350*time.Millisecond))
462 if after := read(t, ctx, "s"); after.Dragging || math.Abs(after.X-s.Run*sign) > 0.5 {
463 t.Errorf("after lostpointercapture: dragging %v, knob at %v; want the drag dropped and the knob back at %v", after.Dragging, after.X, s.Run*sign)
464 }
465 if path == "/light" {
466 var seen []string
467 mustRun(t, ctx, chromedp.Evaluate(`window.__sw`, &seen))
468 if len(seen) == 0 {
469 t.Error("the observer saw no style write on the switch during two drags; the shim writes --rst-switch-p and --rst-switch-on there, so the leg proves nothing")
470 }
471 own := regexp.MustCompile(`^(--rst-switch-(p|on):\s*[^;]*;?\s*)*$`)
472 for _, v := range seen {
473 if v != "" && !own.MatchString(v) {
474 t.Errorf("a style write on the switch during a drag left %q; only the shim's --rst-switch-p and --rst-switch-on may be written there (C13)", v)
475 }
476 }
477 }
478 mustRun(t, ctx, input.DispatchTouchEvent(input.TouchEnd, nil))
479 })
480 }
481}
482
483// TestATblockReachesOnlyItsOwnHeadSwitch (#104 item 1): the toggle
484// block's checked, focus and reveal rules once read ":has a checked
485// input in a head, anywhere below" and painted "every track below", so
486// an unchecked switch in a block's body drew as on, a checked block
487// nested in an unchecked one flipped the outer head and opened its
488// body, and a focused head outlined every track in the block. The
489// rules are scoped to the block's own head: the head switch is as it
490// was, a body switch answers to itself, and a nested block to itself.
491// In day, plain and signal, each theme's stylesheet swapped in, with
492// the stand-alone switches on the page as the reference for on and
493// off. Frozen first, for forceState.
494func TestATblockReachesOnlyItsOwnHeadSwitch(t *testing.T) {
495 const track = " [rst-switch-track]"
496 body := `<div rst-page>` +
497 `<p><label rst-switch id="ref-on"><input type="checkbox" checked><span rst-switch-track aria-hidden="true"></span> On</label></p>` +
498 `<p><label rst-switch id="ref-off"><input type="checkbox"><span rst-switch-track aria-hidden="true"></span> Off</label></p>` +
499 `<div rst-tblock id="open"><label rst-tblock-head id="open-head"><input type="checkbox" checked id="open-in"><span rst-switch-track aria-hidden="true"></span><span rst-tblock-title>Open block</span></label>` +
500 `<div rst-tblock-body><label rst-switch id="body-sw"><input type="checkbox" id="body-in"><span rst-switch-track aria-hidden="true"></span> Body switch, off</label></div></div>` +
501 `<div rst-tblock id="shut"><label rst-tblock-head id="shut-head"><input type="checkbox" id="shut-in"><span rst-switch-track aria-hidden="true"></span><span rst-tblock-title>Shut block</span></label>` +
502 `<div rst-tblock-body><div rst-tblock id="inner"><label rst-tblock-head id="inner-head"><input type="checkbox" checked id="inner-in"><span rst-switch-track aria-hidden="true"></span><span rst-tblock-title>Inner block, on</span></label><div rst-tblock-body>Inner body</div></div></div></div>` +
503 `<p id="field"></p></div>`
504 rig := sizingRig(t, false, map[string]string{"/": sizingDoc("tblock", body)}, harness.WithHover())
505 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
506 defer cancel()
507 open(t, ctx, rig, "/", false)
508 still(t, ctx)
509 face := func(sel string) string {
510 return computed(t, ctx, sel+track, "background-color") + " knob " + computed(t, ctx, sel+track, "--rst-switch-p")
511 }
512 outline := func(sel string) string { return computed(t, ctx, sel+track, "outline-style") }
513 for _, theme := range []string{"day", "plain", "signal"} {
514 swapTheme(t, ctx, theme)
515 on, off := face("#ref-on"), face("#ref-off")
516 if on == off {
517 t.Fatalf("%s: a stand-alone switch draws %s on and off alike; the drive would prove nothing", theme, on)
518 }
519 for _, c := range []struct{ what, sel, want string }{
520 {"the open block's head switch", "#open-head", on},
521 {"an off switch in the open block's body", "#body-sw", off},
522 {"the shut block's head switch", "#shut-head", off},
523 {"the on block nested in the shut one", "#inner-head", on},
524 } {
525 if got := face(c.sel); got != c.want {
526 t.Errorf("%s: %s draws %s, want %s", theme, c.what, got, c.want)
527 }
528 }
529 if got := computed(t, ctx, "#shut > [rst-tblock-body]", "display"); got != "none" {
530 t.Errorf("%s: the shut block's body is display %s; a checked block nested inside opened it", theme, got)
531 }
532 if got := computed(t, ctx, "#inner > [rst-tblock-body]", "display"); got != "block" {
533 t.Errorf("%s: the nested on block's body is display %s, want block", theme, got)
534 }
535 forceState(t, ctx, "#open-in", "focus-visible")
536 if got := outline("#open-head"); got == "none" {
537 t.Errorf("%s: the focused head's switch has no outline", theme)
538 }
539 if got := outline("#body-sw"); got != "none" {
540 t.Errorf("%s: the head is focused and the body switch is outlined too (%s)", theme, got)
541 }
542 forceState(t, ctx, "#open-in")
543 forceState(t, ctx, "#inner-in", "focus-visible")
544 if got := outline("#inner-head"); got == "none" {
545 t.Errorf("%s: the focused nested head's switch has no outline", theme)
546 }
547 if got := outline("#shut-head"); got != "none" {
548 t.Errorf("%s: the nested head is focused and the outer head's switch is outlined too (%s)", theme, got)
549 }
550 forceState(t, ctx, "#inner-in")
551 }
552}
553