| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 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. |
| 30 | const 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 | |
| 37 | type 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. |
| 48 | func 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. |
| 56 | func 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. |
| 79 | func 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. |
| 87 | var 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. |
| 104 | func 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. |
| 216 | func 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. |
| 258 | func 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)". |
| 293 | func 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. |
| 317 | func 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. |
| 407 | func 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. |
| 494 | func 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 | |