| 1 | //go:build browser |
| 2 | |
| 3 | // A seg-tabs track made of buttons, for a choice that changes the page |
| 4 | // in place: each button must be the tab a link is, or a page that |
| 5 | // mixes the two, or moves from one to the other, shows two controls. |
| 6 | package check |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "fmt" |
| 11 | "net/http" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/chromedp/cdproto/emulation" |
| 17 | "github.com/chromedp/chromedp" |
| 18 | |
| 19 | "amadan.net/rastrillo/fiesta" |
| 20 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 21 | ) |
| 22 | |
| 23 | // segTabsTrack is one track holding the current link, a plain link, |
| 24 | // the pressed button, an unpressed one and a disabled one, so each |
| 25 | // button is read |
| 26 | // beside the link it must look like, under the same rules. |
| 27 | const segTabsTrack = `<main rst-page><div rst-seg-tabs role="group" aria-label="View">` + |
| 28 | `<a id="current" href="#list" aria-current="page">List</a><a id="link" href="#grid">Grid</a>` + |
| 29 | `<button id="pressed" type="button" aria-pressed="true">List</button><button id="unpressed" type="button" aria-pressed="false">Grid</button>` + |
| 30 | `<button id="disabled" type="button" aria-pressed="false" disabled>Map</button>` + |
| 31 | `</div></main>` |
| 32 | |
| 33 | // segTabsPages serves the track at /<theme>/<scheme>/ under tokens.css |
| 34 | // and that theme, with no script, so nothing but the stylesheets |
| 35 | // decides what each tab looks like. |
| 36 | func segTabsPages(t *testing.T) http.Handler { |
| 37 | t.Helper() |
| 38 | mux := http.NewServeMux() |
| 39 | mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, _ *http.Request) { |
| 40 | w.Header().Set("Content-Type", "text/css") |
| 41 | w.Write(fiesta.TokensCSS()) |
| 42 | }) |
| 43 | mux.HandleFunc("GET /theme/{name}", func(w http.ResponseWriter, r *http.Request) { |
| 44 | css, ok := fiesta.ThemeCSS(strings.TrimSuffix(r.PathValue("name"), ".css")) |
| 45 | if !ok { |
| 46 | http.NotFound(w, r) |
| 47 | return |
| 48 | } |
| 49 | w.Header().Set("Content-Type", "text/css") |
| 50 | w.Write(css) |
| 51 | }) |
| 52 | mux.HandleFunc("GET /{theme}/{scheme}/", func(w http.ResponseWriter, r *http.Request) { |
| 53 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 54 | fmt.Fprintf(w, `<!doctype html><html lang="en" dir="ltr" data-theme=%q><head><meta charset="utf-8">`+ |
| 55 | `<meta name="viewport" content="width=device-width"><title>seg-tabs</title>`+ |
| 56 | `<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/theme/%s.css">`+ |
| 57 | `</head><body>%s</body></html>`, r.PathValue("scheme"), r.PathValue("theme"), segTabsTrack) |
| 58 | }) |
| 59 | return mux |
| 60 | } |
| 61 | |
| 62 | // segTabFace is what a reader sees of a tab: its fill, its text and |
| 63 | // its shape, the box a finger or a pointer has to hit, and the display |
| 64 | // that centres its label in that box on a phone. |
| 65 | var segTabFace = []string{"background-color", "color", "font-family", "font-size", "font-weight", "line-height", "padding", "border-radius", "box-shadow", "block-size", "min-block-size", "min-inline-size", "display", "align-items", "justify-content", "cursor"} |
| 66 | |
| 67 | // sameFace reports each property of segTabFace in which button differs |
| 68 | // from link. |
| 69 | func sameFace(t *testing.T, ctx context.Context, where, button, link string) { |
| 70 | t.Helper() |
| 71 | for _, prop := range segTabFace { |
| 72 | if b, l := computed(t, ctx, button, prop), computed(t, ctx, link, prop); b != l { |
| 73 | t.Errorf("%s: %s's %s is %q, %s's is %q; a button in a track must be the tab a link is", where, button, prop, b, link, l) |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // TestASegTabButtonLooksLikeASegTabLink: in every theme and scheme, |
| 79 | // with a mouse and on a touch screen, the pressed button is the thumb |
| 80 | // the current link is, and an unpressed button is a plain tab, with |
| 81 | // no border and no fill of the browser's own; hovered, it turns the |
| 82 | // colour a hovered link does. Under forced colours and in print the |
| 83 | // pressed button keeps the current link's system colours and loses |
| 84 | // its lift the same way, or the choice made would vanish there. |
| 85 | func TestASegTabButtonLooksLikeASegTabLink(t *testing.T) { |
| 86 | for _, coarse := range []bool{false, true} { |
| 87 | // A mouse on a desktop meets the desktop floor, a finger on a |
| 88 | // phone the touch floor and its centring: each is a rule the |
| 89 | // buttons must share with the links. |
| 90 | pointer, width := "mouse at 1280", int64(1280) |
| 91 | var opts []harness.Option |
| 92 | if coarse { |
| 93 | pointer, width = "touch at 390", 390 |
| 94 | opts = append(opts, harness.WithCoarsePointer()) |
| 95 | } |
| 96 | rig := harness.New(t, func(string) http.Handler { return segTabsPages(t) }, opts...) |
| 97 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 98 | mustRun(t, ctx, chromedp.EmulateViewport(width, 844)) |
| 99 | for _, theme := range fiesta.ThemeNames() { |
| 100 | for _, scheme := range []string{"light", "dark"} { |
| 101 | where := fmt.Sprintf("%s %s, %s", theme, scheme, pointer) |
| 102 | mustRun(t, ctx, chromedp.Navigate(fmt.Sprintf("%s/%s/%s/", rig.Origin, theme, scheme)), chromedp.WaitVisible("#unpressed", chromedp.ByQuery)) |
| 103 | requirePointer(t, ctx, coarse) |
| 104 | still(t, ctx) |
| 105 | sameFace(t, ctx, where, "#pressed", "#current") |
| 106 | sameFace(t, ctx, where, "#unpressed", "#link") |
| 107 | if w := computed(t, ctx, "#unpressed", "border-width"); w != "0px" { |
| 108 | t.Errorf("%s: the unpressed button's border is %s wide; the browser's button edge must go", where, w) |
| 109 | } |
| 110 | if bg := computed(t, ctx, "#unpressed", "background-color"); bg != "rgba(0, 0, 0, 0)" { |
| 111 | t.Errorf("%s: the unpressed button is filled %s; only the pressed one wears the thumb", where, bg) |
| 112 | } |
| 113 | rest := computed(t, ctx, "#link", "color") |
| 114 | forceState(t, ctx, "#link", "hover") |
| 115 | forceState(t, ctx, "#unpressed", "hover") |
| 116 | hovered := computed(t, ctx, "#link", "color") |
| 117 | if hovered == rest { |
| 118 | t.Errorf("%s: CONTROL: a hovered link is %s, the colour it rests in; the hover below cannot be told from rest", where, rest) |
| 119 | } |
| 120 | if b := computed(t, ctx, "#unpressed", "color"); b != hovered { |
| 121 | t.Errorf("%s: a hovered unpressed button is %s, a hovered link %s", where, b, hovered) |
| 122 | } |
| 123 | forceState(t, ctx, "#link") |
| 124 | forceState(t, ctx, "#unpressed") |
| 125 | disabledLooksIdle(t, ctx, where, rest) |
| 126 | } |
| 127 | } |
| 128 | cancel() |
| 129 | } |
| 130 | |
| 131 | // Forced colours and print, in Day light, where the axis is |
| 132 | // flattened and the current tab is drawn by rules of its own. |
| 133 | rig := harness.New(t, func(string) http.Handler { return segTabsPages(t) }) |
| 134 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 135 | defer cancel() |
| 136 | for _, medium := range []struct { |
| 137 | name, query string |
| 138 | emulate *emulation.SetEmulatedMediaParams |
| 139 | }{ |
| 140 | {"forced colours", "(forced-colors: active)", emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "forced-colors", Value: "active"}})}, |
| 141 | {"print", "print", emulation.SetEmulatedMedia().WithMedia("print")}, |
| 142 | } { |
| 143 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), medium.emulate, chromedp.Navigate(rig.Origin+"/day/light/"), chromedp.WaitVisible("#pressed", chromedp.ByQuery)) |
| 144 | var on bool |
| 145 | at(t, ctx, `JSON.stringify(matchMedia(`+jsStr(medium.query)+`).matches)`, &on) |
| 146 | if !on { |
| 147 | t.Fatalf("%s: matchMedia(%q) is false; this leg is not the medium it claims", medium.name, medium.query) |
| 148 | } |
| 149 | still(t, ctx) |
| 150 | for _, prop := range []string{"background-color", "color", "box-shadow"} { |
| 151 | if b, l := computed(t, ctx, "#pressed", prop), computed(t, ctx, "#current", prop); b != l { |
| 152 | t.Errorf("%s: the pressed button's %s is %q, the current link's %q", medium.name, prop, b, l) |
| 153 | } |
| 154 | } |
| 155 | mustRun(t, ctx, emulation.SetEmulatedMedia()) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // disabledLooksIdle: a disabled button says it is not listening, as |
| 160 | // Fiesta's other disabled controls do: faint text, the default cursor, |
| 161 | // and no answer to a hover. With the enabled tab's colour, pointer and |
| 162 | // hover it read as a choice that would work, and a click did nothing. |
| 163 | // rest is a plain link's resting colour, which the faint text must not |
| 164 | // be, or the check below could not tell the two apart. |
| 165 | func disabledLooksIdle(t *testing.T, ctx context.Context, where, rest string) { |
| 166 | t.Helper() |
| 167 | var faint string |
| 168 | at(t, ctx, `(() => { const p = document.createElement("span"); p.style.color = "var(--rst-text-faint)"; document.body.append(p); const c = getComputedStyle(p).color; p.remove(); return JSON.stringify(c); })()`, &faint) |
| 169 | faint = canonColours(faint) |
| 170 | if faint == rest { |
| 171 | t.Errorf("%s: CONTROL: the faint text colour is %s, a plain tab's own; a disabled button cannot be told from an enabled one by colour", where, faint) |
| 172 | } |
| 173 | if c := computed(t, ctx, "#disabled", "color"); c != faint { |
| 174 | t.Errorf("%s: a disabled button's text is %s, want the faint text colour %s", where, c, faint) |
| 175 | } |
| 176 | if c := computed(t, ctx, "#disabled", "cursor"); c != "default" { |
| 177 | t.Errorf("%s: a disabled button's cursor is %q, want \"default\": a pointer promises a click that does nothing", where, c) |
| 178 | } |
| 179 | forceState(t, ctx, "#disabled", "hover", "active") |
| 180 | if c := computed(t, ctx, "#disabled", "color"); c != faint { |
| 181 | t.Errorf("%s: a hovered, pressed disabled button turns %s; it must stay %s", where, c, faint) |
| 182 | } |
| 183 | forceState(t, ctx, "#disabled") |
| 184 | } |
| 185 | |