| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "math" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/chromedp/cdproto/emulation" |
| 15 | "github.com/chromedp/cdproto/input" |
| 16 | "github.com/chromedp/chromedp" |
| 17 | |
| 18 | "amadan.net/rastrillo/fiesta" |
| 19 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 20 | ) |
| 21 | |
| 22 | // The day switch (the switch rework, 2026-10-24): its size comes from |
| 23 | // theme tokens with a fine-pointer set and a touch set; the track sits |
| 24 | // in and the knob stands on a drop shadow that deepens on hover; the |
| 25 | // on colour follows the knob, dragged or clicked; a released knob |
| 26 | // settles at the nearer end with a firm curve. plain and signal keep |
| 27 | // today's switch. |
| 28 | |
| 29 | // swapTheme replaces the page's theme stylesheet with the named |
| 30 | // theme's CSS, inline, so one page can be read under every theme. |
| 31 | func swapTheme(t *testing.T, ctx context.Context, name string) { |
| 32 | t.Helper() |
| 33 | css, ok := fiesta.ThemeCSS(name) |
| 34 | if !ok { |
| 35 | t.Fatalf("no theme %q", name) |
| 36 | } |
| 37 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const l = document.querySelector('link[href="/theme.css"]'); if (l) l.remove(); for (const s of document.querySelectorAll("style[data-swap]")) s.remove(); const s = document.createElement("style"); s.dataset.swap = "1"; s.textContent = `+jsStr(string(css))+`; document.head.append(s); return true; })()`, nil)) |
| 38 | } |
| 39 | |
| 40 | // daySwitchBody is a page with an on switch, an off switch and a |
| 41 | // toggle block with an on head, and the counters switchJS reads. |
| 42 | const daySwitchBody = `<div rst-page><p><label rst-switch id="on"><input type="checkbox" checked><span rst-switch-track aria-hidden="true"></span> On</label></p>` + |
| 43 | `<p><label rst-switch id="off"><input type="checkbox"><span rst-switch-track aria-hidden="true"></span> Off</label></p>` + |
| 44 | `<p><label rst-switch id="dis"><input type="checkbox" checked disabled><span rst-switch-track aria-hidden="true"></span> Locked</label></p>` + |
| 45 | `<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></div>` + |
| 46 | `<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],[rst-tblock-head]"); if (l) m[l.id] = (m[l.id] || 0) + 1; });</script>` |
| 47 | |
| 48 | // TestTheSwitchTakesItsSizeFromTheTheme: the track's width and height |
| 49 | // and the knob's size are theme tokens in two sets, one for a fine |
| 50 | // pointer and one for a coarse one, and the knob's travel follows from |
| 51 | // them. day is 40x23 with a 19px knob under a mouse and 51x31 with a |
| 52 | // 27px knob on a touch screen, the toggle block's head the same; plain |
| 53 | // and signal are 34x19 with a 15px knob under both. The label's |
| 54 | // minimum block size, the target floor, is not the switch's to change. |
| 55 | func TestTheSwitchTakesItsSizeFromTheTheme(t *testing.T) { |
| 56 | type size struct{ W, H, K, Run float64 } |
| 57 | want := map[bool]map[string]size{ |
| 58 | false: {"day": {40, 23, 19, 17}, "plain": {34, 19, 15, 15}, "signal": {34, 19, 15, 15}}, |
| 59 | true: {"day": {51, 31, 27, 20}, "plain": {34, 19, 15, 15}, "signal": {34, 19, 15, 15}}, |
| 60 | } |
| 61 | for _, coarse := range []bool{false, true} { |
| 62 | name := "fine" |
| 63 | if coarse { |
| 64 | name = "coarse" |
| 65 | } |
| 66 | t.Run(name, func(t *testing.T) { |
| 67 | rig := sizingRig(t, coarse, map[string]string{"/": sizingDoc("switch size", daySwitchBody)}) |
| 68 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 69 | defer cancel() |
| 70 | mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#tb", chromedp.ByQuery)) |
| 71 | requirePointer(t, ctx, coarse) |
| 72 | still(t, ctx) |
| 73 | floor := computed(t, ctx, "#on", "min-block-size") |
| 74 | for _, theme := range []string{"day", "plain", "signal"} { |
| 75 | swapTheme(t, ctx, theme) |
| 76 | w := want[coarse][theme] |
| 77 | for _, id := range []string{"on", "off", "head"} { |
| 78 | s := read(t, ctx, id) |
| 79 | if s.W != w.W || s.H != w.H || s.K != w.K { |
| 80 | t.Errorf("%s, %s pointer: #%s is %gx%g with a %gpx knob; want %gx%g with %gpx", theme, name, id, s.W, s.H, s.K, w.W, w.H, w.K) |
| 81 | } |
| 82 | if on := id != "off"; on && math.Abs(s.X-w.Run) > 0.5 || !on && math.Abs(s.X) > 0.5 { |
| 83 | t.Errorf("%s, %s pointer: #%s's knob is moved %.1fpx; want %g on and 0 off, the track's width less its height", theme, name, id, s.X, w.Run) |
| 84 | } |
| 85 | } |
| 86 | if got := computed(t, ctx, "#on", "min-block-size"); got != floor { |
| 87 | t.Errorf("%s, %s pointer: the label's min-block-size is %s, was %s under day; the target floor is not the switch's to change", theme, name, got, floor) |
| 88 | } |
| 89 | } |
| 90 | }) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // shadowBlur reads one box-shadow layer's blur radius, the third |
| 95 | // length after its colour ("rgba(…) 0px 1px 2px 0px inset" is 2). |
| 96 | func shadowBlur(t *testing.T, layer string) float64 { |
| 97 | t.Helper() |
| 98 | i := strings.LastIndex(layer, ")") |
| 99 | f := strings.Fields(layer[i+1:]) |
| 100 | if len(f) < 4 { |
| 101 | t.Fatalf("box-shadow layer %q: want a colour and four lengths", layer) |
| 102 | } |
| 103 | v, err := strconv.ParseFloat(strings.TrimSuffix(f[2], "px"), 64) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | return v |
| 108 | } |
| 109 | |
| 110 | // painted is the layers of a box-shadow that paint: the ones whose |
| 111 | // colour is not transparent, in order. A transparent layer's geometry |
| 112 | // is nothing on screen, so two shadows with the same painted layers |
| 113 | // are the same pixels. |
| 114 | func painted(v string) []string { |
| 115 | var out []string |
| 116 | for _, l := range layers(v) { |
| 117 | if !strings.HasPrefix(l, "rgba(0, 0, 0, 0)") { |
| 118 | out = append(out, l) |
| 119 | } |
| 120 | } |
| 121 | return out |
| 122 | } |
| 123 | |
| 124 | // paintsNothing is true when no layer of a box-shadow paints. |
| 125 | func paintsNothing(v string) bool { return painted(v) == nil } |
| 126 | |
| 127 | // TestTheTrackSitsInAndTheKnobLiftsOnHover (the switch rework, depth |
| 128 | // like the buttons'): in day the track is sunk by two inset layers, |
| 129 | // the contact shadow and an ambient hairline rim; the knob stands on |
| 130 | // the theme's knob shadow, two layers in day (a drop and a hairline |
| 131 | // ring), over its own contact shadow and under its highlight; and |
| 132 | // hovered it lifts: a longer contact shadow and the theme's hover knob |
| 133 | // shadow, deeper than the rest one. The toggle block's head lifts the |
| 134 | // same. A disabled switch hovered keeps its rest shadow. plain and |
| 135 | // signal declare no depth: their track paints no shadow, and their |
| 136 | // knob paints its own theme shadow, the same layers hovered as at |
| 137 | // rest. Frozen first, for forceState. |
| 138 | func TestTheTrackSitsInAndTheKnobLiftsOnHover(t *testing.T) { |
| 139 | rig := sizingRig(t, false, map[string]string{"/": sizingDoc("switch depth", daySwitchBody)}, harness.WithHover()) |
| 140 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 141 | defer cancel() |
| 142 | mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#tb", chromedp.ByQuery)) |
| 143 | still(t, ctx) |
| 144 | knob := func(sel string) string { return knobStyleOf(t, ctx, sel+" [rst-switch-track]", "box-shadow") } |
| 145 | track := func(sel string) string { return computed(t, ctx, sel+" [rst-switch-track]", "box-shadow") } |
| 146 | hovered := func(sel string, read func(string) string) string { |
| 147 | forceState(t, ctx, sel, "hover") |
| 148 | v := read(sel) |
| 149 | forceState(t, ctx, sel) |
| 150 | return v |
| 151 | } |
| 152 | for _, theme := range []string{"day", "plain", "signal"} { |
| 153 | swapTheme(t, ctx, theme) |
| 154 | if theme != "day" { |
| 155 | for _, sel := range []string{"#off", "#on", "#head"} { |
| 156 | if tr, k, hk := track(sel), painted(knob(sel)), painted(hovered(sel, knob)); !paintsNothing(tr) || len(k) == 0 || strings.Join(k, ", ") != strings.Join(hk, ", ") { |
| 157 | t.Errorf("%s: %s paints track %q, knob %q, hovered knob %q; a flat theme's track paints nothing and its knob paints its own shadow, hovered or not", theme, sel, tr, k, hk) |
| 158 | } |
| 159 | } |
| 160 | continue |
| 161 | } |
| 162 | if tr := layers(track("#off")); len(tr) != 2 || !strings.HasSuffix(tr[0], "0px 1px 2px 0px inset") || !strings.HasSuffix(tr[1], "0px 0px 0px 0.5px inset") || paintsNothing(track("#off")) { |
| 163 | t.Errorf("day: the track's box-shadow is %q; want two painted inset layers, a 1px 2px contact shadow and a 0.5px rim", track("#off")) |
| 164 | } |
| 165 | for _, sel := range []string{"#off", "#on", "#head"} { |
| 166 | rest, lift := layers(knob(sel)), layers(hovered(sel, knob)) |
| 167 | if len(rest) < 4 || !strings.HasSuffix(rest[0], "inset") || paintsNothing(knob(sel)) { |
| 168 | t.Errorf("day: %s's knob at rest has box-shadow %q; want the highlight inset first, its contact shadow, and the theme's two-layer knob shadow", sel, knob(sel)) |
| 169 | continue |
| 170 | } |
| 171 | if len(lift) != len(rest) || shadowBlur(t, lift[1]) <= shadowBlur(t, rest[1]) || shadowBlur(t, lift[2]) <= shadowBlur(t, rest[2]) { |
| 172 | t.Errorf("day: %s hovered has knob box-shadow %q, at rest %q; the knob should lift, with a longer contact shadow and a deeper knob shadow", sel, strings.Join(lift, ", "), strings.Join(rest, ", ")) |
| 173 | } |
| 174 | } |
| 175 | if rest, lift := knob("#dis"), hovered("#dis", knob); lift != rest { |
| 176 | t.Errorf("day: a disabled switch hovered has knob box-shadow %q, at rest %q; want no lift", lift, rest) |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // rgbOf parses an rgb() triple (canonColours' output) into channels. |
| 182 | func rgbOf(t *testing.T, v string) (r, g, b float64) { |
| 183 | t.Helper() |
| 184 | if _, err := fmt.Sscanf(canonColours(v), "rgb(%f, %f, %f)", &r, &g, &b); err != nil { |
| 185 | t.Fatalf("%q is not an rgb() colour", v) |
| 186 | } |
| 187 | return |
| 188 | } |
| 189 | |
| 190 | // mixed is color-mix(in srgb, on share, off): each channel the share |
| 191 | // of the way from off to on, which is what the track must paint when |
| 192 | // its knob is that share of the way across. |
| 193 | func mixed(t *testing.T, off, on string, share float64) string { |
| 194 | t.Helper() |
| 195 | r1, g1, b1 := rgbOf(t, off) |
| 196 | r2, g2, b2 := rgbOf(t, on) |
| 197 | return fmt.Sprintf("rgb(%.0f, %.0f, %.0f)", r1+(r2-r1)*share, g1+(g2-g1)*share, b1+(b2-b1)*share) |
| 198 | } |
| 199 | |
| 200 | // near is true when two rgb() colours are within tol per channel. |
| 201 | func near(t *testing.T, a, b string, tol float64) bool { |
| 202 | t.Helper() |
| 203 | r1, g1, b1 := rgbOf(t, a) |
| 204 | r2, g2, b2 := rgbOf(t, b) |
| 205 | return math.Abs(r1-r2) <= tol && math.Abs(g1-g2) <= tol && math.Abs(b1-b2) <= tol |
| 206 | } |
| 207 | |
| 208 | // TestTheTrackColourFollowsTheKnob (the switch rework): the on colour |
| 209 | // fades in exactly as far as the knob has travelled. Two registered |
| 210 | // numbers on the track, --rst-switch-p (the knob's share of its |
| 211 | // travel, which the knob's translate reads) and --rst-switch-on (the |
| 212 | // on colour's share of the track, which a color-mix() reads), set |
| 213 | // side by side by every rule and by the one script write, and eased |
| 214 | // together by one transition. Mid-drag, with a finger: the track is |
| 215 | // the share of the way from the off colour to the on colour that the |
| 216 | // knob is across; released past the middle, it is the on colour. Mid- |
| 217 | // animation after a click, the transitions paused part way: the same, |
| 218 | // for a switch going on and for a toggle block's head going off. At |
| 219 | // rest the numbers are 0 and 0% off, 1 and 100% on. In LTR and RTL. |
| 220 | func TestTheTrackColourFollowsTheKnob(t *testing.T) { |
| 221 | doc := sizingDoc("switch colour", daySwitchBody) |
| 222 | pages := map[string]string{"/": doc, "/rtl": strings.Replace(doc, `dir="ltr"`, `dir="rtl"`, 1)} |
| 223 | for _, path := range []string{"/", "/rtl"} { |
| 224 | t.Run(path, func(t *testing.T) { |
| 225 | rig := sizingRig(t, false, pages) |
| 226 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 227 | defer cancel() |
| 228 | mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#tb", chromedp.ByQuery), |
| 229 | chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise), |
| 230 | emulation.SetTouchEmulationEnabled(true)) |
| 231 | sign := 1.0 |
| 232 | if path == "/rtl" { |
| 233 | sign = -1 |
| 234 | } |
| 235 | off, on := read(t, ctx, "off"), read(t, ctx, "on") |
| 236 | offColour, onColour := canonColours(off.Bg), canonColours(on.Bg) |
| 237 | if offColour == onColour { |
| 238 | t.Fatalf("the off and on tracks are both %s; the drive would prove nothing", offColour) |
| 239 | } |
| 240 | if off.P != "0" || off.On != "0%" || on.P != "1" || on.On != "100%" { |
| 241 | t.Errorf("at rest the off track's numbers are %q and %q, the on track's %q and %q; want 0 and 0%% off, 1 and 100%% on", off.P, off.On, on.P, on.On) |
| 242 | } |
| 243 | if d := computed(t, ctx, "#off [rst-switch-track]", "transition"); !strings.Contains(d, "--rst-switch-p 0.24s cubic-bezier(0.22, 1, 0.36, 1)") || !strings.Contains(d, "--rst-switch-on 0.24s cubic-bezier(0.22, 1, 0.36, 1)") { |
| 244 | t.Errorf("the track's transition is %q; want both numbers eased by the same 0.24s cubic-bezier(0.22, 1, 0.36, 1), so the colour cannot drift from the knob", d) |
| 245 | } |
| 246 | |
| 247 | // A finger drags the off knob 60% of the way across in |
| 248 | // steps (the shim needs 4px before it takes the drag) and |
| 249 | // holds it there. |
| 250 | x := thumbAt(off, sign) |
| 251 | mustRun(t, ctx, input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: x, Y: off.Y}})) |
| 252 | for _, o := range []float64{0.2, 0.4, 0.6} { |
| 253 | mustRun(t, ctx, input.DispatchTouchEvent(input.TouchMove, []*input.TouchPoint{{X: x + sign*o*off.Run, Y: off.Y}})) |
| 254 | } |
| 255 | mustRun(t, ctx, chromedp.Sleep(100*time.Millisecond)) |
| 256 | mid := read(t, ctx, "off") |
| 257 | if !mid.Dragging { |
| 258 | t.Fatal("the drag did not take") |
| 259 | } |
| 260 | share := math.Abs(mid.X) / mid.Run |
| 261 | if share < 0.5 || share > 0.7 { |
| 262 | t.Errorf("mid-drag the knob is %.2f of the way across; want about 0.6", share) |
| 263 | } |
| 264 | if want := mixed(t, offColour, onColour, share); !near(t, mid.Bg, want, 2) { |
| 265 | t.Errorf("mid-drag, knob %.2f across, the track is %s; want %s, that share of the way from %s to %s", share, canonColours(mid.Bg), want, offColour, onColour) |
| 266 | } |
| 267 | mustRun(t, ctx, input.DispatchTouchEvent(input.TouchEnd, nil), chromedp.Sleep(400*time.Millisecond)) |
| 268 | if s := read(t, ctx, "off"); !s.Checked || canonColours(s.Bg) != onColour || math.Abs(s.X-sign*s.Run) > 0.5 { |
| 269 | t.Errorf("released at 60%%: checked %v, track %s, knob at %.1f; want on, %s, %.1f", s.Checked, canonColours(s.Bg), s.X, onColour, sign*s.Run) |
| 270 | } |
| 271 | |
| 272 | // A click, and the transitions paused 100ms in: the knob and |
| 273 | // the colour have come the same share of the way. #on goes |
| 274 | // off; #head, the toggle block's, goes off too. |
| 275 | var pausedProps string |
| 276 | for _, id := range []string{"on", "head"} { |
| 277 | before := read(t, ctx, id) |
| 278 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const l = document.getElementById(`+jsStr(id)+`), tr = l.querySelector("[rst-switch-track]"); l.querySelector("input").click(); |
| 279 | const as = document.getAnimations().filter(a => a.effect && a.effect.target === tr); as.forEach(a => { a.pause(); a.currentTime = 100; }); return as.map(a => a.transitionProperty).sort().join(" "); })()`, &pausedProps)) |
| 280 | if pausedProps != "--rst-switch-on --rst-switch-p" { |
| 281 | t.Errorf("#%s clicked: the track's running transitions are %q; want --rst-switch-on and --rst-switch-p", id, pausedProps) |
| 282 | } |
| 283 | s := read(t, ctx, id) |
| 284 | share := math.Abs(s.X) / s.Run |
| 285 | if s.Checked == before.Checked || share < 0.05 || share > 0.95 { |
| 286 | t.Errorf("#%s clicked and paused 100ms in: checked %v (was %v), knob %.2f across; want toggled and mid-way", id, s.Checked, before.Checked, share) |
| 287 | } |
| 288 | if want := mixed(t, offColour, onColour, share); !near(t, s.Bg, want, 2) { |
| 289 | t.Errorf("#%s paused 100ms into its toggle, knob %.2f across, the track is %s; want %s", id, share, canonColours(s.Bg), want) |
| 290 | } |
| 291 | mustRun(t, ctx, chromedp.Evaluate(`document.getAnimations().forEach(a => a.finish()), true`, nil)) |
| 292 | } |
| 293 | }) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | // TestAReleasedKnobSettlesWithAFirmCurve (the switch rework): a drag |
| 298 | // follows the pointer 1:1 across the whole track, with no transition |
| 299 | // on the track while it lasts; released, the knob goes to the nearer |
| 300 | // end, on from half way or further (0.5 is on), with the firm 0.18s |
| 301 | // cubic-bezier(0.3, 0.9, 0.4, 1) rather than a toggle's 0.24s: the |
| 302 | // shim marks the label data-rst-settle for the release and clears it |
| 303 | // after, and both numbers ride the one transition, so the colour |
| 304 | // follows the knob home. The checkbox is clicked only when the end is |
| 305 | // the other state. Under reduced motion the settle has no transition. |
| 306 | // In LTR and RTL. |
| 307 | func TestAReleasedKnobSettlesWithAFirmCurve(t *testing.T) { |
| 308 | doc := sizingDoc("switch release", daySwitchBody) |
| 309 | pages := map[string]string{"/": doc, "/rtl": strings.Replace(doc, `dir="ltr"`, `dir="rtl"`, 1)} |
| 310 | const firm = "0.18s cubic-bezier(0.3, 0.9, 0.4, 1)" |
| 311 | for _, leg := range []struct { |
| 312 | name, path string |
| 313 | reduce bool |
| 314 | }{{"ltr", "/", false}, {"rtl", "/rtl", false}, {"ltr reduced motion", "/", true}} { |
| 315 | t.Run(leg.name, func(t *testing.T) { |
| 316 | rig := sizingRig(t, false, pages) |
| 317 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 318 | defer cancel() |
| 319 | if leg.reduce { |
| 320 | mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}})) |
| 321 | } |
| 322 | mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+leg.path), chromedp.WaitVisible("#tb", chromedp.ByQuery), |
| 323 | chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) |
| 324 | sign := 1.0 |
| 325 | if leg.path == "/rtl" { |
| 326 | sign = -1 |
| 327 | } |
| 328 | mouse := func(typ input.MouseType, x, y float64) chromedp.Action { |
| 329 | return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1) |
| 330 | } |
| 331 | // drag presses the knob where it rests, moves it through the |
| 332 | // shares of the travel given (toward on), reads the switch at |
| 333 | // the last, releases, and reads it again at once. |
| 334 | drag := func(id string, shares ...float64) (mid, dropped switchReading) { |
| 335 | s := read(t, ctx, id) |
| 336 | x := thumbAt(s, sign) |
| 337 | if s.Checked { |
| 338 | x += sign * s.Run |
| 339 | } |
| 340 | mustRun(t, ctx, mouse(input.MousePressed, x, s.Y)) |
| 341 | for _, o := range shares { |
| 342 | mustRun(t, ctx, mouse(input.MouseMoved, thumbAt(s, sign)+sign*o*s.Run, s.Y)) |
| 343 | } |
| 344 | mid = read(t, ctx, id) |
| 345 | mustRun(t, ctx, mouse(input.MouseReleased, thumbAt(s, sign)+sign*shares[len(shares)-1]*s.Run, s.Y)) |
| 346 | dropped = read(t, ctx, id) |
| 347 | return |
| 348 | } |
| 349 | off, on := read(t, ctx, "off"), read(t, ctx, "on") |
| 350 | offColour, onColour := canonColours(off.Bg), canonColours(on.Bg) |
| 351 | |
| 352 | // Short of half way: back to off, nothing fired. |
| 353 | mid, dropped := drag("off", 0.2, 0.4) |
| 354 | if !mid.Dragging || mid.Duration != "0s" || math.Abs(math.Abs(mid.X)/mid.Run-0.4) > 0.03 { |
| 355 | t.Errorf("mid-drag: dragging %v, transition %s, knob %.2f across; want a drag with no transition, the knob 0.4 across under the pointer", mid.Dragging, mid.Duration, math.Abs(mid.X)/mid.Run) |
| 356 | } |
| 357 | if !dropped.Settling || dropped.Dragging { |
| 358 | t.Errorf("just released: settling %v, dragging %v; want the settle mark on and the drag mark off", dropped.Settling, dropped.Dragging) |
| 359 | } |
| 360 | if d := computed(t, ctx, "#off [rst-switch-track]", "transition"); leg.reduce && d != "none" || !leg.reduce && d != "--rst-switch-p "+firm+", --rst-switch-on "+firm { |
| 361 | t.Errorf("just released the track's transition is %q; want both numbers on the firm %s (reduced motion %v: none)", d, firm, leg.reduce) |
| 362 | } |
| 363 | mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) |
| 364 | if s := read(t, ctx, "off"); s.Checked || s.Changes != 0 || s.Settling || math.Abs(s.X) > 0.5 || canonColours(s.Bg) != offColour { |
| 365 | t.Errorf("released at 0.4, settled: checked %v, %d change events, settling %v, knob at %.1f, track %s; want off, none, the mark gone, the knob home and the off colour", s.Checked, s.Changes, s.Settling, s.X, canonColours(s.Bg)) |
| 366 | } |
| 367 | if s := read(t, ctx, "off"); !leg.reduce && !strings.Contains(s.Duration, "0.24s") || leg.reduce && s.Duration != "0s" { |
| 368 | t.Errorf("settled, the track's transition is %s; want a toggle's 0.24s back (reduced motion %v: none)", s.Duration, leg.reduce) |
| 369 | } |
| 370 | |
| 371 | // Half way exactly: on. Then from on, half way: still on; and |
| 372 | // short of it: off. |
| 373 | drag("off", 0.2, 0.5) |
| 374 | mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) |
| 375 | if s := read(t, ctx, "off"); !s.Checked || s.Changes != 1 || s.Inputs != 1 || math.Abs(s.X-sign*s.Run) > 0.5 || canonColours(s.Bg) != onColour { |
| 376 | t.Errorf("released at 0.5: checked %v, %d change and %d input events, knob at %.1f, track %s; want on, one of each, the knob at %.1f and the on colour", s.Checked, s.Changes, s.Inputs, s.X, canonColours(s.Bg), sign*s.Run) |
| 377 | } |
| 378 | drag("on", 0.8, 0.5) |
| 379 | mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) |
| 380 | if s := read(t, ctx, "on"); !s.Checked || s.Changes != 0 { |
| 381 | t.Errorf("from on, released at 0.5: checked %v, %d change events; want still on, none", s.Checked, s.Changes) |
| 382 | } |
| 383 | drag("on", 0.8, 0.45) |
| 384 | mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) |
| 385 | if s := read(t, ctx, "on"); s.Checked || s.Changes != 1 || math.Abs(s.X) > 0.5 || canonColours(s.Bg) != offColour { |
| 386 | t.Errorf("from on, released at 0.45: checked %v, %d change events, knob at %.1f, track %s; want off, once, the knob home and the off colour", s.Checked, s.Changes, s.X, canonColours(s.Bg)) |
| 387 | } |
| 388 | |
| 389 | // Mid-settle the colour is still the knob's share: release |
| 390 | // #off at 0.3 (it is on now, so this is from on) and pause |
| 391 | // the settle 60ms in. |
| 392 | if leg.reduce { |
| 393 | return |
| 394 | } |
| 395 | var props string |
| 396 | s := read(t, ctx, "off") |
| 397 | x := thumbAt(s, sign) + sign*s.Run |
| 398 | mustRun(t, ctx, mouse(input.MousePressed, x, s.Y), mouse(input.MouseMoved, thumbAt(s, sign)+sign*0.6*s.Run, s.Y), mouse(input.MouseMoved, thumbAt(s, sign)+sign*0.3*s.Run, s.Y)) |
| 399 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const tr = document.querySelector("#off [rst-switch-track]"); return new Promise(done => { document.addEventListener("pointerup", () => { requestAnimationFrame(() => { const as = document.getAnimations().filter(a => a.effect && a.effect.target === tr); as.forEach(a => { a.pause(); a.currentTime = 60; }); done(as.map(a => a.transitionProperty).sort().join(" ")); }); }, {once: true}); }); })()`, nil)) |
| 400 | mustRun(t, ctx, mouse(input.MouseReleased, thumbAt(s, sign)+sign*0.3*s.Run, s.Y)) |
| 401 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const tr = document.querySelector("#off [rst-switch-track]"); const as = document.getAnimations().filter(a => a.effect && a.effect.target === tr); as.forEach(a => { a.pause(); a.currentTime = 60; }); return as.map(a => a.transitionProperty).sort().join(" "); })()`, &props)) |
| 402 | if props != "--rst-switch-on --rst-switch-p" { |
| 403 | t.Errorf("released at 0.3 from on, the track's running transitions are %q; want --rst-switch-on and --rst-switch-p", props) |
| 404 | } |
| 405 | if s := read(t, ctx, "off"); s.Checked { |
| 406 | t.Errorf("released at 0.3 from on: still checked") |
| 407 | } else if share := math.Abs(s.X) / s.Run; share <= 0.02 || share >= 0.3 { |
| 408 | t.Errorf("paused 60ms into the settle the knob is %.2f across; want it on its way from 0.3 to 0", share) |
| 409 | } else if want := mixed(t, offColour, onColour, share); !near(t, s.Bg, want, 2) { |
| 410 | t.Errorf("paused 60ms into the settle, knob %.2f across, the track is %s; want %s", share, canonColours(s.Bg), want) |
| 411 | } |
| 412 | mustRun(t, ctx, chromedp.Evaluate(`document.getAnimations().forEach(a => a.finish()), true`, nil)) |
| 413 | }) |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | // TestAHoveredKnobPrintsNoShadow (switch review fix 1): the knob's |
| 418 | // hover lift is a box-shadow in a rule that outranks print's reset on |
| 419 | // the knob, so print has to flatten the two knob-shadow tokens as it |
| 420 | // flattens the depth tokens; otherwise a page printed with the pointer |
| 421 | // over a switch printed the lift. Hovered and at rest, the ordinary |
| 422 | // switch and the toggle block's head, in day, plain and signal, on a |
| 423 | // hover rig under print media: no painted shadow on the knob or the |
| 424 | // track. |
| 425 | func TestAHoveredKnobPrintsNoShadow(t *testing.T) { |
| 426 | rig := sizingRig(t, false, map[string]string{"/": sizingDoc("switch print", daySwitchBody)}, harness.WithHover()) |
| 427 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 428 | defer cancel() |
| 429 | mustRun(t, ctx, emulation.SetEmulatedMedia().WithMedia("print"), chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#tb", chromedp.ByQuery)) |
| 430 | still(t, ctx) |
| 431 | var hov, print bool |
| 432 | mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(hover: hover)").matches`, &hov), chromedp.Evaluate(`matchMedia("print").matches`, &print)) |
| 433 | if !hov || !print { |
| 434 | t.Fatalf("(hover: hover) %v, print %v; the hover rule would not apply and the drive would prove nothing", hov, print) |
| 435 | } |
| 436 | for _, theme := range []string{"day", "plain", "signal"} { |
| 437 | swapTheme(t, ctx, theme) |
| 438 | for _, sel := range []string{"#on", "#off", "#head"} { |
| 439 | for _, states := range [][]string{{}, {"hover"}} { |
| 440 | forceState(t, ctx, sel, states...) |
| 441 | if k, tr := knobStyleOf(t, ctx, sel+" [rst-switch-track]", "box-shadow"), computed(t, ctx, sel+" [rst-switch-track]", "box-shadow"); !noShadow(k) || !noShadow(tr) { |
| 442 | t.Errorf("%s: %s %v in print paints knob box-shadow %q, track %q; want nothing painted", theme, sel, states, k, tr) |
| 443 | } |
| 444 | forceState(t, ctx, sel) |
| 445 | } |
| 446 | } |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | // TestATblockHeadDragsLikeASwitch (switch review fix 3): the toggle |
| 451 | // block's head has the same track and must drag the same. The shim's |
| 452 | // drag found only [rst-switch] labels, so a head never took a drag; |
| 453 | // it finds the head too, reading the head's own input (the body is |
| 454 | // the head's sibling, not inside it), and the drag, sheen and settle |
| 455 | // rules name the head with #104 item 1's direct-child scoping. From |
| 456 | // on, the knob dragged 30% across follows the pointer with no |
| 457 | // transition while the track's colour follows it; released, the head |
| 458 | // settles with the firm curve, goes off once (the label's own click |
| 459 | // after a drag is cancelled, or it would toggle straight back), and |
| 460 | // the body hides; dragged back past the middle it goes on and the body |
| 461 | // shows; a tap still toggles it. In LTR and RTL. |
| 462 | func TestATblockHeadDragsLikeASwitch(t *testing.T) { |
| 463 | doc := sizingDoc("head drag", daySwitchBody) |
| 464 | pages := map[string]string{"/": doc, "/rtl": strings.Replace(doc, `dir="ltr"`, `dir="rtl"`, 1)} |
| 465 | for _, path := range []string{"/", "/rtl"} { |
| 466 | t.Run(path, func(t *testing.T) { |
| 467 | rig := sizingRig(t, false, pages) |
| 468 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 469 | defer cancel() |
| 470 | mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#tb", chromedp.ByQuery), |
| 471 | chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) |
| 472 | sign := 1.0 |
| 473 | if path == "/rtl" { |
| 474 | sign = -1 |
| 475 | } |
| 476 | mouse := func(typ input.MouseType, x, y float64) chromedp.Action { |
| 477 | return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1) |
| 478 | } |
| 479 | offColour, onColour := canonColours(read(t, ctx, "off").Bg), canonColours(read(t, ctx, "on").Bg) |
| 480 | body := func() string { return computed(t, ctx, "#tb > [rst-tblock-body]", "display") } |
| 481 | // drag presses the head's knob where it rests and moves it to |
| 482 | // each share of the travel, reads before the release and at |
| 483 | // once after it (with the track's transition then), and |
| 484 | // waits for the settle. |
| 485 | drag := func(shares ...float64) (mid, dropped switchReading, trans string) { |
| 486 | s := read(t, ctx, "head") |
| 487 | x := thumbAt(s, sign) |
| 488 | if s.Checked { |
| 489 | x += sign * s.Run |
| 490 | } |
| 491 | mustRun(t, ctx, mouse(input.MousePressed, x, s.Y)) |
| 492 | for _, o := range shares { |
| 493 | mustRun(t, ctx, mouse(input.MouseMoved, thumbAt(s, sign)+sign*o*s.Run, s.Y)) |
| 494 | } |
| 495 | mid = read(t, ctx, "head") |
| 496 | mustRun(t, ctx, mouse(input.MouseReleased, thumbAt(s, sign)+sign*shares[len(shares)-1]*s.Run, s.Y)) |
| 497 | dropped = read(t, ctx, "head") |
| 498 | trans = computed(t, ctx, "#head [rst-switch-track]", "transition") |
| 499 | mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) |
| 500 | return |
| 501 | } |
| 502 | if s := read(t, ctx, "head"); !s.Checked || body() != "block" { |
| 503 | t.Fatalf("the head starts checked %v with its body %s; want on and shown", s.Checked, body()) |
| 504 | } |
| 505 | mid, dropped, trans := drag(0.7, 0.3) |
| 506 | if !mid.Dragging || mid.Duration != "0s" || math.Abs(math.Abs(mid.X)/mid.Run-0.3) > 0.03 { |
| 507 | t.Errorf("%s: mid-drag the head is dragging %v, transition %s, knob %.2f across; want a drag with no transition and the knob 0.3 across under the pointer", path, mid.Dragging, mid.Duration, math.Abs(mid.X)/mid.Run) |
| 508 | } |
| 509 | if want := mixed(t, offColour, onColour, math.Abs(mid.X)/mid.Run); mid.Dragging && !near(t, mid.Bg, want, 2) { |
| 510 | t.Errorf("%s: mid-drag the head's track is %s; want %s, the knob's share of the way from off to on", path, canonColours(mid.Bg), want) |
| 511 | } |
| 512 | if !dropped.Settling || dropped.Dragging || !strings.Contains(trans, "0.18s cubic-bezier(0.3, 0.9, 0.4, 1)") { |
| 513 | t.Errorf("%s: just released the head is settling %v, dragging %v, transition %q; want the settle mark and the firm curve", path, dropped.Settling, dropped.Dragging, trans) |
| 514 | } |
| 515 | if s := read(t, ctx, "head"); s.Checked || s.Changes != 1 || s.Inputs != 1 || math.Abs(s.X) > 0.5 || canonColours(s.Bg) != offColour || body() != "none" { |
| 516 | t.Errorf("%s: released at 0.3: checked %v, %d change and %d input events, knob at %.1f, track %s, body %s; want off, one of each, the knob home, the off colour and the body hidden", path, s.Checked, s.Changes, s.Inputs, s.X, canonColours(s.Bg), body()) |
| 517 | } |
| 518 | drag(0.3, 0.8) |
| 519 | if s := read(t, ctx, "head"); !s.Checked || s.Changes != 2 || math.Abs(s.X-sign*s.Run) > 0.5 || body() != "block" { |
| 520 | t.Errorf("%s: dragged back to 0.8: checked %v, %d change events, knob at %.1f, body %s; want on, two, the knob at the end and the body shown", path, s.Checked, s.Changes, s.X, body()) |
| 521 | } |
| 522 | s := read(t, ctx, "head") |
| 523 | x := thumbAt(s, sign) + sign*s.Run |
| 524 | mustRun(t, ctx, mouse(input.MousePressed, x, s.Y), mouse(input.MouseReleased, x, s.Y), chromedp.Sleep(400*time.Millisecond)) |
| 525 | if s := read(t, ctx, "head"); s.Checked || s.Changes != 3 { |
| 526 | t.Errorf("%s: a tap on the head: checked %v, %d change events; want off, three", path, s.Checked, s.Changes) |
| 527 | } |
| 528 | }) |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | // TestAToggleDuringASettleTakesItsOwnCurve (switch review fix 4): the |
| 533 | // settle mark outlives the release (300ms for a 0.18s transition), so a |
| 534 | // click or Space inside that window took the firm release curve |
| 535 | // instead of a toggle's 0.24s. The mark now goes on the next change |
| 536 | // event inside the switch, bar the release's own checkbox click, which |
| 537 | // is the settle itself. A release at 0.4 then a click: the mark is |
| 538 | // gone at once and the toggle rides 0.24s; a release that commits |
| 539 | // keeps the mark and the firm curve; a release at 0.8 from on (no |
| 540 | // commit) then Space on the focused checkbox: the mark is gone. |
| 541 | func TestAToggleDuringASettleTakesItsOwnCurve(t *testing.T) { |
| 542 | rig := sizingRig(t, false, map[string]string{"/": sizingDoc("settle leak", daySwitchBody)}) |
| 543 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 544 | defer cancel() |
| 545 | mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#tb", chromedp.ByQuery), |
| 546 | chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) |
| 547 | const firm, toggle = "0.18s cubic-bezier(0.3, 0.9, 0.4, 1)", "0.24s cubic-bezier(0.22, 1, 0.36, 1)" |
| 548 | mouse := func(typ input.MouseType, x, y float64) chromedp.Action { |
| 549 | return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1) |
| 550 | } |
| 551 | trans := func(id string) string { return computed(t, ctx, "#"+id+" [rst-switch-track]", "transition") } |
| 552 | // release drags the knob to share and lets go, reading at once. |
| 553 | release := func(id string, share float64) switchReading { |
| 554 | s := read(t, ctx, id) |
| 555 | x := thumbAt(s, 1) |
| 556 | if s.Checked { |
| 557 | x += s.Run |
| 558 | } |
| 559 | mustRun(t, ctx, mouse(input.MousePressed, x, s.Y), mouse(input.MouseMoved, thumbAt(s, 1)+0.5*s.Run, s.Y), mouse(input.MouseMoved, thumbAt(s, 1)+share*s.Run, s.Y), |
| 560 | mouse(input.MouseReleased, thumbAt(s, 1)+share*s.Run, s.Y)) |
| 561 | return read(t, ctx, id) |
| 562 | } |
| 563 | |
| 564 | if s := release("off", 0.4); !s.Settling || s.Checked || !strings.Contains(trans("off"), firm) { |
| 565 | t.Fatalf("released at 0.4: settling %v, checked %v, transition %q; want the mark, off, and the firm curve: the drive's premise", s.Settling, s.Checked, trans("off")) |
| 566 | } |
| 567 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("#off input").click(), true`, nil)) |
| 568 | if s := read(t, ctx, "off"); s.Settling || !s.Checked || !strings.Contains(trans("off"), toggle) { |
| 569 | t.Errorf("clicked while settling: settling %v, checked %v, transition %q; want the mark gone, on, and a toggle's %s", s.Settling, s.Checked, trans("off"), toggle) |
| 570 | } |
| 571 | mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) |
| 572 | if s := read(t, ctx, "off"); !s.Checked || math.Abs(s.X-s.Run) > 0.5 || s.Changes != 1 { |
| 573 | t.Errorf("settled after the click: checked %v, knob at %.1f, %d change events; want on at %.1f, one", s.Checked, s.X, s.Changes, s.Run) |
| 574 | } |
| 575 | |
| 576 | // The release's own click is the settle, and keeps the mark. |
| 577 | if s := release("on", 0.2); !s.Settling || s.Checked || !strings.Contains(trans("on"), firm) { |
| 578 | t.Errorf("released at 0.2 from on, which commits: settling %v, checked %v, transition %q; want the mark kept, off, and the firm curve", s.Settling, s.Checked, trans("on")) |
| 579 | } |
| 580 | mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) |
| 581 | |
| 582 | // Space on the focused checkbox during a settle that did not commit. |
| 583 | if s := release("off", 0.8); !s.Settling || !s.Checked { |
| 584 | t.Fatalf("released at 0.8 from on: settling %v, checked %v; want the mark and still on", s.Settling, s.Checked) |
| 585 | } |
| 586 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("#off input").focus(), true`, nil), chromedp.KeyEvent(" ")) |
| 587 | if s := read(t, ctx, "off"); s.Settling || s.Checked || !strings.Contains(trans("off"), toggle) { |
| 588 | t.Errorf("Space while settling: settling %v, checked %v, transition %q; want the mark gone, off, and a toggle's %s", s.Settling, s.Checked, trans("off"), toggle) |
| 589 | } |
| 590 | } |
| 591 | |