//go:build browser package check import ( "context" "fmt" "math" "strconv" "strings" "testing" "time" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/input" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/harness" ) // The day switch (the switch rework, 2026-10-24): its size comes from // theme tokens with a fine-pointer set and a touch set; the track sits // in and the knob stands on a drop shadow that deepens on hover; the // on colour follows the knob, dragged or clicked; a released knob // settles at the nearer end with a firm curve. plain and signal keep // today's switch. // swapTheme replaces the page's theme stylesheet with the named // theme's CSS, inline, so one page can be read under every theme. func swapTheme(t *testing.T, ctx context.Context, name string) { t.Helper() css, ok := fiesta.ThemeCSS(name) if !ok { t.Fatalf("no theme %q", name) } 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)) } // daySwitchBody is a page with an on switch, an off switch and a // toggle block with an on head, and the counters switchJS reads. const daySwitchBody = `

` + `

` + `

` + `
body
` + `` // TestTheSwitchTakesItsSizeFromTheTheme: the track's width and height // and the knob's size are theme tokens in two sets, one for a fine // pointer and one for a coarse one, and the knob's travel follows from // them. day is 40x23 with a 19px knob under a mouse and 51x31 with a // 27px knob on a touch screen, the toggle block's head the same; plain // and signal are 34x19 with a 15px knob under both. The label's // minimum block size, the target floor, is not the switch's to change. func TestTheSwitchTakesItsSizeFromTheTheme(t *testing.T) { type size struct{ W, H, K, Run float64 } want := map[bool]map[string]size{ false: {"day": {40, 23, 19, 17}, "plain": {34, 19, 15, 15}, "signal": {34, 19, 15, 15}}, true: {"day": {51, 31, 27, 20}, "plain": {34, 19, 15, 15}, "signal": {34, 19, 15, 15}}, } for _, coarse := range []bool{false, true} { name := "fine" if coarse { name = "coarse" } t.Run(name, func(t *testing.T) { rig := sizingRig(t, coarse, map[string]string{"/": sizingDoc("switch size", daySwitchBody)}) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#tb", chromedp.ByQuery)) requirePointer(t, ctx, coarse) still(t, ctx) floor := computed(t, ctx, "#on", "min-block-size") for _, theme := range []string{"day", "plain", "signal"} { swapTheme(t, ctx, theme) w := want[coarse][theme] for _, id := range []string{"on", "off", "head"} { s := read(t, ctx, id) if s.W != w.W || s.H != w.H || s.K != w.K { 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) } if on := id != "off"; on && math.Abs(s.X-w.Run) > 0.5 || !on && math.Abs(s.X) > 0.5 { 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) } } if got := computed(t, ctx, "#on", "min-block-size"); got != floor { 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) } } }) } } // shadowBlur reads one box-shadow layer's blur radius, the third // length after its colour ("rgba(…) 0px 1px 2px 0px inset" is 2). func shadowBlur(t *testing.T, layer string) float64 { t.Helper() i := strings.LastIndex(layer, ")") f := strings.Fields(layer[i+1:]) if len(f) < 4 { t.Fatalf("box-shadow layer %q: want a colour and four lengths", layer) } v, err := strconv.ParseFloat(strings.TrimSuffix(f[2], "px"), 64) if err != nil { t.Fatal(err) } return v } // painted is the layers of a box-shadow that paint: the ones whose // colour is not transparent, in order. A transparent layer's geometry // is nothing on screen, so two shadows with the same painted layers // are the same pixels. func painted(v string) []string { var out []string for _, l := range layers(v) { if !strings.HasPrefix(l, "rgba(0, 0, 0, 0)") { out = append(out, l) } } return out } // paintsNothing is true when no layer of a box-shadow paints. func paintsNothing(v string) bool { return painted(v) == nil } // TestTheTrackSitsInAndTheKnobLiftsOnHover (the switch rework, depth // like the buttons'): in day the track is sunk by two inset layers, // the contact shadow and an ambient hairline rim; the knob stands on // the theme's knob shadow, two layers in day (a drop and a hairline // ring), over its own contact shadow and under its highlight; and // hovered it lifts: a longer contact shadow and the theme's hover knob // shadow, deeper than the rest one. The toggle block's head lifts the // same. A disabled switch hovered keeps its rest shadow. plain and // signal declare no depth: their track paints no shadow, and their // knob paints its own theme shadow, the same layers hovered as at // rest. Frozen first, for forceState. func TestTheTrackSitsInAndTheKnobLiftsOnHover(t *testing.T) { rig := sizingRig(t, false, map[string]string{"/": sizingDoc("switch depth", daySwitchBody)}, harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#tb", chromedp.ByQuery)) still(t, ctx) knob := func(sel string) string { return knobStyleOf(t, ctx, sel+" [rst-switch-track]", "box-shadow") } track := func(sel string) string { return computed(t, ctx, sel+" [rst-switch-track]", "box-shadow") } hovered := func(sel string, read func(string) string) string { forceState(t, ctx, sel, "hover") v := read(sel) forceState(t, ctx, sel) return v } for _, theme := range []string{"day", "plain", "signal"} { swapTheme(t, ctx, theme) if theme != "day" { for _, sel := range []string{"#off", "#on", "#head"} { if tr, k, hk := track(sel), painted(knob(sel)), painted(hovered(sel, knob)); !paintsNothing(tr) || len(k) == 0 || strings.Join(k, ", ") != strings.Join(hk, ", ") { 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) } } continue } 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")) { 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")) } for _, sel := range []string{"#off", "#on", "#head"} { rest, lift := layers(knob(sel)), layers(hovered(sel, knob)) if len(rest) < 4 || !strings.HasSuffix(rest[0], "inset") || paintsNothing(knob(sel)) { 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)) continue } if len(lift) != len(rest) || shadowBlur(t, lift[1]) <= shadowBlur(t, rest[1]) || shadowBlur(t, lift[2]) <= shadowBlur(t, rest[2]) { 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, ", ")) } } if rest, lift := knob("#dis"), hovered("#dis", knob); lift != rest { t.Errorf("day: a disabled switch hovered has knob box-shadow %q, at rest %q; want no lift", lift, rest) } } } // rgbOf parses an rgb() triple (canonColours' output) into channels. func rgbOf(t *testing.T, v string) (r, g, b float64) { t.Helper() if _, err := fmt.Sscanf(canonColours(v), "rgb(%f, %f, %f)", &r, &g, &b); err != nil { t.Fatalf("%q is not an rgb() colour", v) } return } // mixed is color-mix(in srgb, on share, off): each channel the share // of the way from off to on, which is what the track must paint when // its knob is that share of the way across. func mixed(t *testing.T, off, on string, share float64) string { t.Helper() r1, g1, b1 := rgbOf(t, off) r2, g2, b2 := rgbOf(t, on) return fmt.Sprintf("rgb(%.0f, %.0f, %.0f)", r1+(r2-r1)*share, g1+(g2-g1)*share, b1+(b2-b1)*share) } // near is true when two rgb() colours are within tol per channel. func near(t *testing.T, a, b string, tol float64) bool { t.Helper() r1, g1, b1 := rgbOf(t, a) r2, g2, b2 := rgbOf(t, b) return math.Abs(r1-r2) <= tol && math.Abs(g1-g2) <= tol && math.Abs(b1-b2) <= tol } // TestTheTrackColourFollowsTheKnob (the switch rework): the on colour // fades in exactly as far as the knob has travelled. Two registered // numbers on the track, --rst-switch-p (the knob's share of its // travel, which the knob's translate reads) and --rst-switch-on (the // on colour's share of the track, which a color-mix() reads), set // side by side by every rule and by the one script write, and eased // together by one transition. Mid-drag, with a finger: the track is // the share of the way from the off colour to the on colour that the // knob is across; released past the middle, it is the on colour. Mid- // animation after a click, the transitions paused part way: the same, // for a switch going on and for a toggle block's head going off. At // rest the numbers are 0 and 0% off, 1 and 100% on. In LTR and RTL. func TestTheTrackColourFollowsTheKnob(t *testing.T) { doc := sizingDoc("switch colour", daySwitchBody) pages := map[string]string{"/": doc, "/rtl": strings.Replace(doc, `dir="ltr"`, `dir="rtl"`, 1)} for _, path := range []string{"/", "/rtl"} { t.Run(path, func(t *testing.T) { rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#tb", chromedp.ByQuery), chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise), emulation.SetTouchEmulationEnabled(true)) sign := 1.0 if path == "/rtl" { sign = -1 } off, on := read(t, ctx, "off"), read(t, ctx, "on") offColour, onColour := canonColours(off.Bg), canonColours(on.Bg) if offColour == onColour { t.Fatalf("the off and on tracks are both %s; the drive would prove nothing", offColour) } if off.P != "0" || off.On != "0%" || on.P != "1" || on.On != "100%" { 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) } 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)") { 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) } // A finger drags the off knob 60% of the way across in // steps (the shim needs 4px before it takes the drag) and // holds it there. x := thumbAt(off, sign) mustRun(t, ctx, input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: x, Y: off.Y}})) for _, o := range []float64{0.2, 0.4, 0.6} { mustRun(t, ctx, input.DispatchTouchEvent(input.TouchMove, []*input.TouchPoint{{X: x + sign*o*off.Run, Y: off.Y}})) } mustRun(t, ctx, chromedp.Sleep(100*time.Millisecond)) mid := read(t, ctx, "off") if !mid.Dragging { t.Fatal("the drag did not take") } share := math.Abs(mid.X) / mid.Run if share < 0.5 || share > 0.7 { t.Errorf("mid-drag the knob is %.2f of the way across; want about 0.6", share) } if want := mixed(t, offColour, onColour, share); !near(t, mid.Bg, want, 2) { 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) } mustRun(t, ctx, input.DispatchTouchEvent(input.TouchEnd, nil), chromedp.Sleep(400*time.Millisecond)) if s := read(t, ctx, "off"); !s.Checked || canonColours(s.Bg) != onColour || math.Abs(s.X-sign*s.Run) > 0.5 { 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) } // A click, and the transitions paused 100ms in: the knob and // the colour have come the same share of the way. #on goes // off; #head, the toggle block's, goes off too. var pausedProps string for _, id := range []string{"on", "head"} { before := read(t, ctx, id) mustRun(t, ctx, chromedp.Evaluate(`(() => { const l = document.getElementById(`+jsStr(id)+`), tr = l.querySelector("[rst-switch-track]"); l.querySelector("input").click(); 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)) if pausedProps != "--rst-switch-on --rst-switch-p" { t.Errorf("#%s clicked: the track's running transitions are %q; want --rst-switch-on and --rst-switch-p", id, pausedProps) } s := read(t, ctx, id) share := math.Abs(s.X) / s.Run if s.Checked == before.Checked || share < 0.05 || share > 0.95 { 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) } if want := mixed(t, offColour, onColour, share); !near(t, s.Bg, want, 2) { t.Errorf("#%s paused 100ms into its toggle, knob %.2f across, the track is %s; want %s", id, share, canonColours(s.Bg), want) } mustRun(t, ctx, chromedp.Evaluate(`document.getAnimations().forEach(a => a.finish()), true`, nil)) } }) } } // TestAReleasedKnobSettlesWithAFirmCurve (the switch rework): a drag // follows the pointer 1:1 across the whole track, with no transition // on the track while it lasts; released, the knob goes to the nearer // end, on from half way or further (0.5 is on), with the firm 0.18s // cubic-bezier(0.3, 0.9, 0.4, 1) rather than a toggle's 0.24s: the // shim marks the label data-rst-settle for the release and clears it // after, and both numbers ride the one transition, so the colour // follows the knob home. The checkbox is clicked only when the end is // the other state. Under reduced motion the settle has no transition. // In LTR and RTL. func TestAReleasedKnobSettlesWithAFirmCurve(t *testing.T) { doc := sizingDoc("switch release", daySwitchBody) pages := map[string]string{"/": doc, "/rtl": strings.Replace(doc, `dir="ltr"`, `dir="rtl"`, 1)} const firm = "0.18s cubic-bezier(0.3, 0.9, 0.4, 1)" for _, leg := range []struct { name, path string reduce bool }{{"ltr", "/", false}, {"rtl", "/rtl", false}, {"ltr reduced motion", "/", true}} { t.Run(leg.name, func(t *testing.T) { rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() if leg.reduce { mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}})) } mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+leg.path), chromedp.WaitVisible("#tb", chromedp.ByQuery), chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) sign := 1.0 if leg.path == "/rtl" { sign = -1 } mouse := func(typ input.MouseType, x, y float64) chromedp.Action { return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1) } // drag presses the knob where it rests, moves it through the // shares of the travel given (toward on), reads the switch at // the last, releases, and reads it again at once. drag := func(id string, shares ...float64) (mid, dropped switchReading) { s := read(t, ctx, id) x := thumbAt(s, sign) if s.Checked { x += sign * s.Run } mustRun(t, ctx, mouse(input.MousePressed, x, s.Y)) for _, o := range shares { mustRun(t, ctx, mouse(input.MouseMoved, thumbAt(s, sign)+sign*o*s.Run, s.Y)) } mid = read(t, ctx, id) mustRun(t, ctx, mouse(input.MouseReleased, thumbAt(s, sign)+sign*shares[len(shares)-1]*s.Run, s.Y)) dropped = read(t, ctx, id) return } off, on := read(t, ctx, "off"), read(t, ctx, "on") offColour, onColour := canonColours(off.Bg), canonColours(on.Bg) // Short of half way: back to off, nothing fired. mid, dropped := drag("off", 0.2, 0.4) if !mid.Dragging || mid.Duration != "0s" || math.Abs(math.Abs(mid.X)/mid.Run-0.4) > 0.03 { 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) } if !dropped.Settling || dropped.Dragging { t.Errorf("just released: settling %v, dragging %v; want the settle mark on and the drag mark off", dropped.Settling, dropped.Dragging) } 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 { 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) } mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) if s := read(t, ctx, "off"); s.Checked || s.Changes != 0 || s.Settling || math.Abs(s.X) > 0.5 || canonColours(s.Bg) != offColour { 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)) } if s := read(t, ctx, "off"); !leg.reduce && !strings.Contains(s.Duration, "0.24s") || leg.reduce && s.Duration != "0s" { t.Errorf("settled, the track's transition is %s; want a toggle's 0.24s back (reduced motion %v: none)", s.Duration, leg.reduce) } // Half way exactly: on. Then from on, half way: still on; and // short of it: off. drag("off", 0.2, 0.5) mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) 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 { 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) } drag("on", 0.8, 0.5) mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) if s := read(t, ctx, "on"); !s.Checked || s.Changes != 0 { t.Errorf("from on, released at 0.5: checked %v, %d change events; want still on, none", s.Checked, s.Changes) } drag("on", 0.8, 0.45) mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) if s := read(t, ctx, "on"); s.Checked || s.Changes != 1 || math.Abs(s.X) > 0.5 || canonColours(s.Bg) != offColour { 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)) } // Mid-settle the colour is still the knob's share: release // #off at 0.3 (it is on now, so this is from on) and pause // the settle 60ms in. if leg.reduce { return } var props string s := read(t, ctx, "off") x := thumbAt(s, sign) + sign*s.Run 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)) 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)) mustRun(t, ctx, mouse(input.MouseReleased, thumbAt(s, sign)+sign*0.3*s.Run, s.Y)) 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)) if props != "--rst-switch-on --rst-switch-p" { t.Errorf("released at 0.3 from on, the track's running transitions are %q; want --rst-switch-on and --rst-switch-p", props) } if s := read(t, ctx, "off"); s.Checked { t.Errorf("released at 0.3 from on: still checked") } else if share := math.Abs(s.X) / s.Run; share <= 0.02 || share >= 0.3 { t.Errorf("paused 60ms into the settle the knob is %.2f across; want it on its way from 0.3 to 0", share) } else if want := mixed(t, offColour, onColour, share); !near(t, s.Bg, want, 2) { t.Errorf("paused 60ms into the settle, knob %.2f across, the track is %s; want %s", share, canonColours(s.Bg), want) } mustRun(t, ctx, chromedp.Evaluate(`document.getAnimations().forEach(a => a.finish()), true`, nil)) }) } } // TestAHoveredKnobPrintsNoShadow (switch review fix 1): the knob's // hover lift is a box-shadow in a rule that outranks print's reset on // the knob, so print has to flatten the two knob-shadow tokens as it // flattens the depth tokens; otherwise a page printed with the pointer // over a switch printed the lift. Hovered and at rest, the ordinary // switch and the toggle block's head, in day, plain and signal, on a // hover rig under print media: no painted shadow on the knob or the // track. func TestAHoveredKnobPrintsNoShadow(t *testing.T) { rig := sizingRig(t, false, map[string]string{"/": sizingDoc("switch print", daySwitchBody)}, harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, emulation.SetEmulatedMedia().WithMedia("print"), chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#tb", chromedp.ByQuery)) still(t, ctx) var hov, print bool mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(hover: hover)").matches`, &hov), chromedp.Evaluate(`matchMedia("print").matches`, &print)) if !hov || !print { t.Fatalf("(hover: hover) %v, print %v; the hover rule would not apply and the drive would prove nothing", hov, print) } for _, theme := range []string{"day", "plain", "signal"} { swapTheme(t, ctx, theme) for _, sel := range []string{"#on", "#off", "#head"} { for _, states := range [][]string{{}, {"hover"}} { forceState(t, ctx, sel, states...) 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) { t.Errorf("%s: %s %v in print paints knob box-shadow %q, track %q; want nothing painted", theme, sel, states, k, tr) } forceState(t, ctx, sel) } } } } // TestATblockHeadDragsLikeASwitch (switch review fix 3): the toggle // block's head has the same track and must drag the same. The shim's // drag found only [rst-switch] labels, so a head never took a drag; // it finds the head too, reading the head's own input (the body is // the head's sibling, not inside it), and the drag, sheen and settle // rules name the head with #104 item 1's direct-child scoping. From // on, the knob dragged 30% across follows the pointer with no // transition while the track's colour follows it; released, the head // settles with the firm curve, goes off once (the label's own click // after a drag is cancelled, or it would toggle straight back), and // the body hides; dragged back past the middle it goes on and the body // shows; a tap still toggles it. In LTR and RTL. func TestATblockHeadDragsLikeASwitch(t *testing.T) { doc := sizingDoc("head drag", daySwitchBody) pages := map[string]string{"/": doc, "/rtl": strings.Replace(doc, `dir="ltr"`, `dir="rtl"`, 1)} for _, path := range []string{"/", "/rtl"} { t.Run(path, func(t *testing.T) { rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#tb", chromedp.ByQuery), chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) sign := 1.0 if path == "/rtl" { sign = -1 } mouse := func(typ input.MouseType, x, y float64) chromedp.Action { return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1) } offColour, onColour := canonColours(read(t, ctx, "off").Bg), canonColours(read(t, ctx, "on").Bg) body := func() string { return computed(t, ctx, "#tb > [rst-tblock-body]", "display") } // drag presses the head's knob where it rests and moves it to // each share of the travel, reads before the release and at // once after it (with the track's transition then), and // waits for the settle. drag := func(shares ...float64) (mid, dropped switchReading, trans string) { s := read(t, ctx, "head") x := thumbAt(s, sign) if s.Checked { x += sign * s.Run } mustRun(t, ctx, mouse(input.MousePressed, x, s.Y)) for _, o := range shares { mustRun(t, ctx, mouse(input.MouseMoved, thumbAt(s, sign)+sign*o*s.Run, s.Y)) } mid = read(t, ctx, "head") mustRun(t, ctx, mouse(input.MouseReleased, thumbAt(s, sign)+sign*shares[len(shares)-1]*s.Run, s.Y)) dropped = read(t, ctx, "head") trans = computed(t, ctx, "#head [rst-switch-track]", "transition") mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) return } if s := read(t, ctx, "head"); !s.Checked || body() != "block" { t.Fatalf("the head starts checked %v with its body %s; want on and shown", s.Checked, body()) } mid, dropped, trans := drag(0.7, 0.3) if !mid.Dragging || mid.Duration != "0s" || math.Abs(math.Abs(mid.X)/mid.Run-0.3) > 0.03 { 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) } if want := mixed(t, offColour, onColour, math.Abs(mid.X)/mid.Run); mid.Dragging && !near(t, mid.Bg, want, 2) { 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) } if !dropped.Settling || dropped.Dragging || !strings.Contains(trans, "0.18s cubic-bezier(0.3, 0.9, 0.4, 1)") { 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) } 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" { 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()) } drag(0.3, 0.8) if s := read(t, ctx, "head"); !s.Checked || s.Changes != 2 || math.Abs(s.X-sign*s.Run) > 0.5 || body() != "block" { 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()) } s := read(t, ctx, "head") x := thumbAt(s, sign) + sign*s.Run mustRun(t, ctx, mouse(input.MousePressed, x, s.Y), mouse(input.MouseReleased, x, s.Y), chromedp.Sleep(400*time.Millisecond)) if s := read(t, ctx, "head"); s.Checked || s.Changes != 3 { t.Errorf("%s: a tap on the head: checked %v, %d change events; want off, three", path, s.Checked, s.Changes) } }) } } // TestAToggleDuringASettleTakesItsOwnCurve (switch review fix 4): the // settle mark outlives the release (300ms for a 0.18s transition), so a // click or Space inside that window took the firm release curve // instead of a toggle's 0.24s. The mark now goes on the next change // event inside the switch, bar the release's own checkbox click, which // is the settle itself. A release at 0.4 then a click: the mark is // gone at once and the toggle rides 0.24s; a release that commits // keeps the mark and the firm curve; a release at 0.8 from on (no // commit) then Space on the focused checkbox: the mark is gone. func TestAToggleDuringASettleTakesItsOwnCurve(t *testing.T) { rig := sizingRig(t, false, map[string]string{"/": sizingDoc("settle leak", daySwitchBody)}) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(800, 600), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#tb", chromedp.ByQuery), chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) const firm, toggle = "0.18s cubic-bezier(0.3, 0.9, 0.4, 1)", "0.24s cubic-bezier(0.22, 1, 0.36, 1)" mouse := func(typ input.MouseType, x, y float64) chromedp.Action { return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1) } trans := func(id string) string { return computed(t, ctx, "#"+id+" [rst-switch-track]", "transition") } // release drags the knob to share and lets go, reading at once. release := func(id string, share float64) switchReading { s := read(t, ctx, id) x := thumbAt(s, 1) if s.Checked { x += s.Run } 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), mouse(input.MouseReleased, thumbAt(s, 1)+share*s.Run, s.Y)) return read(t, ctx, id) } if s := release("off", 0.4); !s.Settling || s.Checked || !strings.Contains(trans("off"), firm) { 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")) } mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("#off input").click(), true`, nil)) if s := read(t, ctx, "off"); s.Settling || !s.Checked || !strings.Contains(trans("off"), toggle) { 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) } mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) if s := read(t, ctx, "off"); !s.Checked || math.Abs(s.X-s.Run) > 0.5 || s.Changes != 1 { 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) } // The release's own click is the settle, and keeps the mark. if s := release("on", 0.2); !s.Settling || s.Checked || !strings.Contains(trans("on"), firm) { 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")) } mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) // Space on the focused checkbox during a settle that did not commit. if s := release("off", 0.8); !s.Settling || !s.Checked { t.Fatalf("released at 0.8 from on: settling %v, checked %v; want the mark and still on", s.Settling, s.Checked) } mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("#off input").focus(), true`, nil), chromedp.KeyEvent(" ")) if s := read(t, ctx, "off"); s.Settling || s.Checked || !strings.Contains(trans("off"), toggle) { 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) } }