| 1 | //go:build browser |
| 2 | |
| 3 | // The browser drive for the tinted hairline (design doc §6-v2.2). |
| 4 | // |
| 5 | // The rake line — a 2.5rem accent stroke drawn over the page header's |
| 6 | // rule by [rst-page-header]::after — is retired. The header rule moved |
| 7 | // onto the theme axis as one DERIVED token, --rst-header-rule, and the |
| 8 | // pseudo-element's content is now none. Three claims need proving, and |
| 9 | // none of them can be proved by reading CSS: |
| 10 | // |
| 11 | // 1. the ::after draws nothing, in every theme and in both schemes; |
| 12 | // 2. the rule resolves to what the theme's token says — which for |
| 13 | // signal means an oklab mix a stylesheet cannot be asked to |
| 14 | // evaluate for us; |
| 15 | // 3. each theme's rule is the one it ruled: plain and day the line |
| 16 | // itself (day's is neutral since the depth design, C15), signal |
| 17 | // visibly tinted, all from one structural rule in tokens.css. |
| 18 | // |
| 19 | // Claim 2 is why this file computes oklab in Go. Reading the rule and |
| 20 | // comparing it to a probe painted with var(--rst-header-rule) would |
| 21 | // only prove the header names the token — it would agree with any |
| 22 | // value the token happened to hold, including a wrong one. So the |
| 23 | // expected colour is derived independently, from the theme's own |
| 24 | // --rst-accent and --rst-line as the browser resolves them, mixed here |
| 25 | // at the percentage Paul ruled, and compared with what Chromium |
| 26 | // painted. Two implementations of the same arithmetic, one of them |
| 27 | // ours. |
| 28 | // |
| 29 | // Colours come back through a 1×1 canvas rather than as text. |
| 30 | // getComputedStyle serialises a color-mix() result differently across |
| 31 | // engine versions (rgb(), color(srgb …), oklab()), and a drive that |
| 32 | // parses a serialisation is a drive that breaks on a Chromium upgrade |
| 33 | // while the pixels are fine. fillStyle accepts whatever the engine |
| 34 | // parses and getImageData hands back the eight-bit sRGB triple that was |
| 35 | // actually painted. |
| 36 | // |
| 37 | // ── The control ────────────────────────────────────────────────────── |
| 38 | // |
| 39 | // §7-v2: a gate nobody has watched fail is a gate nobody has tested. |
| 40 | // Set RST_HEADER_RULE_FALSIFY to "<theme>=<css colour>" and the served |
| 41 | // theme's --rst-header-rule declaration is rewritten before the browser |
| 42 | // sees it, so a drive that finds every rule correct can be watched |
| 43 | // finding a wrong one: |
| 44 | // |
| 45 | // RST_HEADER_RULE_FALSIFY='day=#ff0000' go test -tags browser ./check/ \ |
| 46 | // -run TestTheHeaderRuleIsTheThemesTintedHairline -v |
| 47 | // |
| 48 | // Unset — which is how CI runs it — it does nothing at all. |
| 49 | // |
| 50 | // The pixel strip has a control of its own, RST_RAKE_LINE_CONTROL=1, |
| 51 | // which appends the deleted flourish back onto the served tokens.css. |
| 52 | // It is the variable rastrillo's gallery sweep uses as well, and it |
| 53 | // exists here for a narrower reason: the strip's claim is that the |
| 54 | // decoration is uniform across the header, and the only way to know a |
| 55 | // uniformity check works is to watch it meet something that is not |
| 56 | // uniform. |
| 57 | package check |
| 58 | |
| 59 | import ( |
| 60 | "bytes" |
| 61 | "context" |
| 62 | "encoding/json" |
| 63 | "fmt" |
| 64 | "image/png" |
| 65 | "math" |
| 66 | "net/http" |
| 67 | "os" |
| 68 | "regexp" |
| 69 | "strings" |
| 70 | "testing" |
| 71 | "time" |
| 72 | |
| 73 | cdppage "github.com/chromedp/cdproto/page" |
| 74 | "github.com/chromedp/chromedp" |
| 75 | |
| 76 | "amadan.net/rastrillo/fiesta" |
| 77 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 78 | ) |
| 79 | |
| 80 | // headerRuleMix is the design decision, transcribed: how much of the |
| 81 | // accent each theme folds into its line. plain has no accent hue to |
| 82 | // fold, so its token is the line itself and its share is zero. |
| 83 | // |
| 84 | // This table and the themes' declarations are meant to stay in |
| 85 | // lockstep, the same way contrast_test.go's pair table and the |
| 86 | // themes' published ratios do. It is written here rather than parsed |
| 87 | // out of the CSS on purpose: a parser would read whatever the file |
| 88 | // says and agree with it, which is not a check. |
| 89 | var headerRuleMix = map[string]float64{ |
| 90 | "day": 0.00, // day: neutral since the depth design (C15); its rule is the line itself. |
| 91 | "plain": 0.00, |
| 92 | "signal": 0.45, |
| 93 | } |
| 94 | |
| 95 | // headerRuleFalsify reads the control's environment variable and |
| 96 | // returns the theme to corrupt and what to corrupt it to. |
| 97 | func headerRuleFalsify(t *testing.T) (theme, value string) { |
| 98 | t.Helper() |
| 99 | raw := os.Getenv("RST_HEADER_RULE_FALSIFY") |
| 100 | if raw == "" { |
| 101 | return "", "" |
| 102 | } |
| 103 | name, v, ok := strings.Cut(raw, "=") |
| 104 | if !ok || name == "" || v == "" { |
| 105 | t.Fatalf("RST_HEADER_RULE_FALSIFY=%q: want <theme>=<css colour>", raw) |
| 106 | } |
| 107 | if _, known := headerRuleMix[name]; !known { |
| 108 | t.Fatalf("RST_HEADER_RULE_FALSIFY names theme %q, which is not shipped", name) |
| 109 | } |
| 110 | t.Logf("CONTROL: serving themes/%s.css with --rst-header-rule rewritten to %q; this run is EXPECTED to fail on %s", name, v, name) |
| 111 | return name, v |
| 112 | } |
| 113 | |
| 114 | var headerRuleDecl = regexp.MustCompile(`--rst-header-rule:[^;]*;`) |
| 115 | |
| 116 | // headerRulePage serves tokens.css, one theme, and one page header, at |
| 117 | // /<theme>/<scheme>/<dir>/. Everything the page needs is on the page: |
| 118 | // no script, no fonts, no network. |
| 119 | func headerRulePage(t *testing.T) http.Handler { |
| 120 | t.Helper() |
| 121 | badTheme, badValue := headerRuleFalsify(t) |
| 122 | |
| 123 | mux := http.NewServeMux() |
| 124 | mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, r *http.Request) { |
| 125 | w.Header().Set("Content-Type", "text/css") |
| 126 | w.Write(fiesta.TokensCSS()) |
| 127 | w.Write(rakeLineControl(t)) |
| 128 | }) |
| 129 | mux.HandleFunc("GET /theme/{name}", func(w http.ResponseWriter, r *http.Request) { |
| 130 | name := strings.TrimSuffix(r.PathValue("name"), ".css") |
| 131 | css, ok := fiesta.ThemeCSS(name) |
| 132 | if !ok { |
| 133 | http.Error(w, "no theme", http.StatusNotFound) |
| 134 | return |
| 135 | } |
| 136 | if name == badTheme { |
| 137 | css = headerRuleDecl.ReplaceAll(css, []byte("--rst-header-rule: "+badValue+";")) |
| 138 | } |
| 139 | w.Header().Set("Content-Type", "text/css") |
| 140 | w.Write(css) |
| 141 | }) |
| 142 | mux.HandleFunc("GET /{theme}/{scheme}/{dir}/", func(w http.ResponseWriter, r *http.Request) { |
| 143 | theme, scheme, dir := r.PathValue("theme"), r.PathValue("scheme"), r.PathValue("dir") |
| 144 | lang := "en" |
| 145 | if dir == "rtl" { |
| 146 | lang = "ar" |
| 147 | } |
| 148 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 149 | fmt.Fprintf(w, `<!doctype html><html lang=%q dir=%q data-theme=%q><head><meta charset="utf-8">`+ |
| 150 | `<title>header rule</title>`+ |
| 151 | `<link rel="stylesheet" href="/tokens.css">`+ |
| 152 | `<link rel="stylesheet" href="/theme/%s.css">`+ |
| 153 | `</head><body><div rst-page>`+ |
| 154 | `<header rst-page-header id="h">`+ |
| 155 | `<div rst-page-header-titles><h1>Posts</h1>`+ |
| 156 | `<p rst-page-header-sub>Everything you have written, newest first.</p></div>`+ |
| 157 | `</header></div>`+ |
| 158 | `<span id="accent" style="background: var(--rst-accent)"></span>`+ |
| 159 | `<span id="line" style="background: var(--rst-line)"></span>`+ |
| 160 | `</body></html>`, lang, dir, scheme, theme) |
| 161 | }) |
| 162 | return mux |
| 163 | } |
| 164 | |
| 165 | // headerRuleReading is one measurement of one header. The four |
| 166 | // geometry fields are the clip for the pixel strip below, in CSS px. |
| 167 | type headerRuleReading struct { |
| 168 | Rule, Accent, Line []int // eight-bit sRGB, as painted |
| 169 | AfterContent string // getComputedStyle(h, "::after").content |
| 170 | AfterWidth int // the ::after's painted width, in px |
| 171 | BorderWidth string |
| 172 | BorderStyle string |
| 173 | Left, Bottom float64 |
| 174 | HeaderWidth float64 |
| 175 | Dir string |
| 176 | } |
| 177 | |
| 178 | // headerRuleMeasure paints each colour into a 1×1 canvas and reads the |
| 179 | // pixel back, so the drive never has to parse a CSS serialisation. See |
| 180 | // the file comment. |
| 181 | const headerRuleMeasure = `(() => { |
| 182 | const canvas = document.createElement("canvas"); |
| 183 | canvas.width = canvas.height = 1; |
| 184 | const g = canvas.getContext("2d", { willReadFrequently: true }); |
| 185 | const px = css => { |
| 186 | g.clearRect(0, 0, 1, 1); |
| 187 | g.fillStyle = "#000000"; |
| 188 | g.fillStyle = css; |
| 189 | g.fillRect(0, 0, 1, 1); |
| 190 | const d = g.getImageData(0, 0, 1, 1).data; |
| 191 | return [d[0], d[1], d[2]]; |
| 192 | }; |
| 193 | const h = document.getElementById("h"); |
| 194 | const s = getComputedStyle(h); |
| 195 | const after = getComputedStyle(h, "::after"); |
| 196 | const box = h.getBoundingClientRect(); |
| 197 | return JSON.stringify({ |
| 198 | Rule: px(s.borderBottomColor), |
| 199 | Accent: px(getComputedStyle(document.getElementById("accent")).backgroundColor), |
| 200 | Line: px(getComputedStyle(document.getElementById("line")).backgroundColor), |
| 201 | AfterContent: after.content, |
| 202 | // With content:none there is no box to lay out, so the pseudo-element |
| 203 | // has no width. A stroke that came back would have one. |
| 204 | AfterWidth: Math.round(parseFloat(after.width) || 0), |
| 205 | BorderWidth: s.borderBottomWidth, |
| 206 | BorderStyle: s.borderBottomStyle, |
| 207 | // The clip for the pixel strip. Geometry only — this side of the |
| 208 | // drive deliberately makes no claim about what the rule looks like |
| 209 | // across its width; that is the screenshot's job. |
| 210 | Left: box.left, |
| 211 | Bottom: box.bottom, |
| 212 | HeaderWidth: box.width, |
| 213 | Dir: getComputedStyle(document.documentElement).direction, |
| 214 | }); |
| 215 | })()` |
| 216 | |
| 217 | // srgbToLinearChannel and the two matrices below are Ottosson's oklab, |
| 218 | // the space CSS names in "in oklab". Written out rather than pulled in |
| 219 | // so this file owes nothing to a dependency for the one number it |
| 220 | // exists to check. |
| 221 | func srgbToLinearChannel(c float64) float64 { |
| 222 | if c <= 0.04045 { |
| 223 | return c / 12.92 |
| 224 | } |
| 225 | return math.Pow((c+0.055)/1.055, 2.4) |
| 226 | } |
| 227 | |
| 228 | func linearToSRGBChannel(c float64) float64 { |
| 229 | if c <= 0.0031308 { |
| 230 | return c * 12.92 |
| 231 | } |
| 232 | return 1.055*math.Pow(c, 1/2.4) - 0.055 |
| 233 | } |
| 234 | |
| 235 | // toOklab converts an eight-bit sRGB triple to oklab. |
| 236 | func toOklab(rgb []int) [3]float64 { |
| 237 | r := srgbToLinearChannel(float64(rgb[0]) / 255) |
| 238 | g := srgbToLinearChannel(float64(rgb[1]) / 255) |
| 239 | b := srgbToLinearChannel(float64(rgb[2]) / 255) |
| 240 | l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b) |
| 241 | m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b) |
| 242 | s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b) |
| 243 | return [3]float64{ |
| 244 | 0.2104542553*l + 0.7936177850*m - 0.0040720468*s, |
| 245 | 1.9779984951*l - 2.4285922050*m + 0.4505937099*s, |
| 246 | 0.0259040371*l + 0.7827717662*m - 0.8086757660*s, |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // fromOklab converts back, rounding to eight bits the way a painted |
| 251 | // pixel is. Values are clamped: every mix under test sits between two |
| 252 | // in-gamut colours, so a clamp here is a guard, not gamut mapping. |
| 253 | func fromOklab(lab [3]float64) []int { |
| 254 | l := lab[0] + 0.3963377774*lab[1] + 0.2158037573*lab[2] |
| 255 | m := lab[0] - 0.1055613458*lab[1] - 0.0638541728*lab[2] |
| 256 | s := lab[0] - 0.0894841775*lab[1] - 1.2914855480*lab[2] |
| 257 | l, m, s = l*l*l, m*m*m, s*s*s |
| 258 | lin := [3]float64{ |
| 259 | +4.0767416621*l - 3.3077115913*m + 0.2309699292*s, |
| 260 | -1.2684380046*l + 2.6097574011*m - 0.3413193965*s, |
| 261 | -0.0041960863*l - 0.7034186147*m + 1.7076147010*s, |
| 262 | } |
| 263 | out := make([]int, 3) |
| 264 | for i, v := range lin { |
| 265 | c := math.Round(linearToSRGBChannel(v) * 255) |
| 266 | out[i] = int(math.Min(255, math.Max(0, c))) |
| 267 | } |
| 268 | return out |
| 269 | } |
| 270 | |
| 271 | // mixOklab is CSS's color-mix(in oklab, a <share>, b): the two colours |
| 272 | // converted to oklab, interpolated, converted back. Both are opaque, so |
| 273 | // there is no premultiplication to do. |
| 274 | func mixOklab(a, b []int, share float64) []int { |
| 275 | la, lb := toOklab(a), toOklab(b) |
| 276 | var out [3]float64 |
| 277 | for i := range out { |
| 278 | out[i] = la[i]*share + lb[i]*(1-share) |
| 279 | } |
| 280 | return fromOklab(out) |
| 281 | } |
| 282 | |
| 283 | func rgbClose(got, want []int, tol int) bool { |
| 284 | if len(got) != 3 || len(want) != 3 { |
| 285 | return false |
| 286 | } |
| 287 | for i := range got { |
| 288 | if d := got[i] - want[i]; d > tol || d < -tol { |
| 289 | return false |
| 290 | } |
| 291 | } |
| 292 | return true |
| 293 | } |
| 294 | |
| 295 | // rgbDistance is a plain euclidean sRGB distance, used only to order the |
| 296 | // three themes by how tinted their rule is. It is not a perceptual |
| 297 | // metric and does not need to be: the question is "is signal's further |
| 298 | // from grey than day's", and every channel here moves the same way. |
| 299 | func rgbDistance(a, b []int) float64 { |
| 300 | var sum float64 |
| 301 | for i := range a { |
| 302 | d := float64(a[i] - b[i]) |
| 303 | sum += d * d |
| 304 | } |
| 305 | return math.Sqrt(sum) |
| 306 | } |
| 307 | |
| 308 | func rgbString(c []int) string { |
| 309 | if len(c) != 3 { |
| 310 | return "?" |
| 311 | } |
| 312 | return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) |
| 313 | } |
| 314 | |
| 315 | // TestTheHeaderRuleIsTheThemesTintedHairline is the drive. |
| 316 | // |
| 317 | // Bug classes it exists to catch — each of which leaves a page that |
| 318 | // renders and says nothing wrong: |
| 319 | // |
| 320 | // - the ::after survives somewhere, so the retired flourish is still |
| 321 | // on screen (and still reads as a progress bar at 12%); |
| 322 | // - the border still names --rst-line, so all three themes draw the |
| 323 | // same grey and the theme axis gained a token nothing uses; |
| 324 | // - a theme's percentage drifts from the decision — 45% typed as 4.5%, |
| 325 | // or a tint creeping back into day's neutral rule — which is |
| 326 | // invisible against a grey line and exactly the kind of thing a |
| 327 | // source-reading test would confirm rather than catch; |
| 328 | // - a theme declares the token but derives it from the wrong pair, so |
| 329 | // changing the accent leaves the rule behind, which is the drift |
| 330 | // the derivation exists to prevent; |
| 331 | // - the rule is right in light and wrong in dark, or the other way |
| 332 | // round, because light-dark() crept back into a derived value. |
| 333 | func TestTheHeaderRuleIsTheThemesTintedHairline(t *testing.T) { |
| 334 | rig := harness.New(t, func(string) http.Handler { return headerRulePage(t) }) |
| 335 | |
| 336 | // Wall-clock against a real browser, and the same budget and |
| 337 | // reasoning as every other drive in this package: the deadline |
| 338 | // exists so a hang fails as itself, not to race a busy runner. |
| 339 | ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| 340 | defer cancel() |
| 341 | |
| 342 | // tint[theme][scheme] is how far that rule sits from that theme's own |
| 343 | // line colour. Filled as the sweep runs, checked for ordering after. |
| 344 | tint := map[string]map[string]float64{} |
| 345 | |
| 346 | for _, theme := range fiesta.ThemeNames() { |
| 347 | share, ruled := headerRuleMix[theme] |
| 348 | if !ruled { |
| 349 | t.Errorf("theme %q is shipped but headerRuleMix does not say how much accent its rule carries; add it, with the number the design settled on", theme) |
| 350 | continue |
| 351 | } |
| 352 | tint[theme] = map[string]float64{} |
| 353 | for _, scheme := range []string{"light", "dark"} { |
| 354 | for _, dir := range []string{"ltr", "rtl"} { |
| 355 | name := theme + "/" + scheme + "/" + dir |
| 356 | var raw string |
| 357 | if err := chromedp.Run(ctx, |
| 358 | chromedp.EmulateViewport(1280, 900), |
| 359 | chromedp.Navigate(rig.Origin+"/"+theme+"/"+scheme+"/"+dir+"/"), |
| 360 | chromedp.WaitVisible(`#h`, chromedp.ByQuery), |
| 361 | chromedp.Evaluate(headerRuleMeasure, &raw), |
| 362 | ); err != nil { |
| 363 | t.Fatalf("%s: driving the header: %v", name, err) |
| 364 | } |
| 365 | var got headerRuleReading |
| 366 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 367 | t.Fatalf("%s: reading the measurement (%q): %v", name, raw, err) |
| 368 | } |
| 369 | |
| 370 | // 1. The rake line is gone. |
| 371 | if got.AfterContent != "none" { |
| 372 | t.Errorf("%s: [rst-page-header]::after has content %q, want \"none\" — the rake line is retired (design doc §6-v2.2)", name, got.AfterContent) |
| 373 | } |
| 374 | if got.AfterWidth != 0 { |
| 375 | t.Errorf("%s: [rst-page-header]::after still lays out a %dpx box; a retired pseudo-element has none", name, got.AfterWidth) |
| 376 | } |
| 377 | |
| 378 | // 2. The rule is the theme's derived token, measured. |
| 379 | want := mixOklab(got.Accent, got.Line, share) |
| 380 | if !rgbClose(got.Rule, want, 2) { |
| 381 | t.Errorf("%s: the header rule painted %s; --rst-accent %s mixed into --rst-line %s at %.0f%% in oklab is %s", |
| 382 | name, rgbString(got.Rule), rgbString(got.Accent), rgbString(got.Line), share*100, rgbString(want)) |
| 383 | } |
| 384 | if got.BorderWidth != "1px" || got.BorderStyle != "solid" { |
| 385 | t.Errorf("%s: the header rule is %s %s, want 1px solid", name, got.BorderWidth, got.BorderStyle) |
| 386 | } |
| 387 | |
| 388 | // 3. RTL. The rule spans the header in both directions — |
| 389 | // which is the whole difference from the stroke it |
| 390 | // replaced, and the reason this is checked in ar rather |
| 391 | // than assumed from "it is a border". |
| 392 | wantDir := dir |
| 393 | if got.Dir != wantDir { |
| 394 | t.Errorf("%s: the document resolved direction %q, want %q — the RTL half of this drive measured an LTR page", name, got.Dir, wantDir) |
| 395 | } |
| 396 | if got.HeaderWidth < 100 { |
| 397 | t.Errorf("%s: the header measured %.0fpx wide; nothing useful can be read off a strip that narrow", name, got.HeaderWidth) |
| 398 | } else { |
| 399 | headerRuleStrip(ctx, t, name, got, want) |
| 400 | } |
| 401 | |
| 402 | if dir == "ltr" { |
| 403 | tint[theme][scheme] = rgbDistance(got.Rule, got.Line) |
| 404 | t.Logf("%s: rule %s line %s accent %s (tint distance %.1f)", |
| 405 | name, rgbString(got.Rule), rgbString(got.Line), rgbString(got.Accent), tint[theme][scheme]) |
| 406 | } |
| 407 | } |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | // Three results from one structural rule. plain and day are the |
| 412 | // line exactly: plain has no hue to fold in and day chose a neutral |
| 413 | // rule (C15); signal folds in 45% and must read as tinted. |
| 414 | for _, scheme := range []string{"light", "dark"} { |
| 415 | plain, day, signal := tint["plain"][scheme], tint["day"][scheme], tint["signal"][scheme] |
| 416 | if plain != 0 { |
| 417 | t.Errorf("%s: plain's rule sits %.1f from its own line; plain has no accent hue to fold in, so it should be the line exactly", scheme, plain) |
| 418 | } |
| 419 | if day != 0 { |
| 420 | t.Errorf("%s: day's rule sits %.1f from its line; day's rule is neutral (C15)", scheme, day) |
| 421 | } |
| 422 | if signal <= 0 { |
| 423 | t.Errorf("%s: signal's rule is its line exactly; the 45%% tint did not survive to the pixel", scheme) |
| 424 | } |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | // headerRuleStrip is the measurement that replaced a tautology. |
| 429 | // |
| 430 | // The drive used to assert that the rule spanned the header by |
| 431 | // comparing two Go fields filled from the same JavaScript expression. |
| 432 | // That could never fail, and the RTL claim — that the decoration is |
| 433 | // symmetric now, where the retired stroke was anchored to the inline |
| 434 | // start — rested on it. This reads pixels instead. |
| 435 | // |
| 436 | // It captures a strip the full width of the header, from three CSS px |
| 437 | // above its bottom edge to three below, and requires every row in it to |
| 438 | // be one colour across. Nothing about a border-bottom guarantees that: |
| 439 | // the retired stroke sat at inset-block-end: -1px, BELOW the border |
| 440 | // box, which is why the strip deliberately extends past it rather than |
| 441 | // clipping to the element. A 2.5rem stroke over a 1200px header leaves |
| 442 | // one row that is accent for forty pixels and line colour for the rest, |
| 443 | // and a row like that is what this fails on. |
| 444 | // |
| 445 | // It also requires the strip to actually contain the rule, so a run |
| 446 | // that clipped the wrong six rows reports that rather than passing on a |
| 447 | // uniformly empty picture. The tolerance there is loose (6/255) because |
| 448 | // a fractional bottom edge blends the border row with the surface |
| 449 | // behind it; the exact colour is the canvas reading's job, and that one |
| 450 | // is held to 2. |
| 451 | func headerRuleStrip(ctx context.Context, t *testing.T, name string, got headerRuleReading, wantRule []int) { |
| 452 | t.Helper() |
| 453 | const pad = 3 |
| 454 | clip := &cdppage.Viewport{ |
| 455 | X: math.Round(got.Left), |
| 456 | Y: math.Round(got.Bottom) - pad, |
| 457 | Width: math.Round(got.HeaderWidth), |
| 458 | Height: 2 * pad, |
| 459 | Scale: 1, |
| 460 | } |
| 461 | var buf []byte |
| 462 | if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| 463 | var err error |
| 464 | buf, err = cdppage.CaptureScreenshot(). |
| 465 | WithFormat(cdppage.CaptureScreenshotFormatPng). |
| 466 | WithFromSurface(true). |
| 467 | WithCaptureBeyondViewport(true). |
| 468 | WithClip(clip). |
| 469 | Do(ctx) |
| 470 | return err |
| 471 | })); err != nil { |
| 472 | t.Errorf("%s: capturing the rule strip: %v", name, err) |
| 473 | return |
| 474 | } |
| 475 | img, err := png.Decode(bytes.NewReader(buf)) |
| 476 | if err != nil { |
| 477 | t.Errorf("%s: decoding the rule strip: %v", name, err) |
| 478 | return |
| 479 | } |
| 480 | b := img.Bounds() |
| 481 | if b.Dx() < 100 || b.Dy() < 2*pad { |
| 482 | t.Errorf("%s: the rule strip came back %dx%d; expected roughly %.0fx%d", name, b.Dx(), b.Dy(), clip.Width, 2*pad) |
| 483 | return |
| 484 | } |
| 485 | |
| 486 | at := func(x, y int) []int { |
| 487 | r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA() |
| 488 | return []int{int(r >> 8), int(g >> 8), int(bl >> 8)} |
| 489 | } |
| 490 | sawRule := false |
| 491 | for y := 0; y < b.Dy(); y++ { |
| 492 | first := at(0, y) |
| 493 | for x := 1; x < b.Dx(); x++ { |
| 494 | if px := at(x, y); !rgbClose(px, first, 2) { |
| 495 | t.Errorf("%s: the rule strip is not one colour across. Row %d of %d starts %s and is %s at x=%d of %d. The header's decoration is a border that spans it; a row that changes colour partway is a stroke laid over part of it, which is what §6-v2.2 retired", |
| 496 | name, y, b.Dy(), rgbString(first), rgbString(px), x, b.Dx()) |
| 497 | return |
| 498 | } |
| 499 | } |
| 500 | if rgbClose(first, wantRule, 6) { |
| 501 | sawRule = true |
| 502 | } |
| 503 | } |
| 504 | if !sawRule { |
| 505 | t.Errorf("%s: no row of the %d-row strip around the header's bottom edge is the rule colour %s; the strip was clipped somewhere else and its uniformity proves nothing", name, b.Dy(), rgbString(wantRule)) |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | // rakeLineControl returns the retired flourish, byte for byte as this |
| 510 | // branch deleted it, when RST_RAKE_LINE_CONTROL is set — and nothing |
| 511 | // otherwise. Appended to the served tokens.css it wins on cascade |
| 512 | // order, so the control exercises the real deletion rather than a |
| 513 | // stand-in for it. |
| 514 | // |
| 515 | // It is what the pixel strip is measured against. A 2.5rem stroke over |
| 516 | // a 1200px header leaves one row that is accent for forty pixels and |
| 517 | // line colour for the rest, which is exactly the shape a uniformity |
| 518 | // check has to catch and exactly the shape a border can never make. |
| 519 | func rakeLineControl(t *testing.T) []byte { |
| 520 | t.Helper() |
| 521 | if os.Getenv("RST_RAKE_LINE_CONTROL") == "" { |
| 522 | return nil |
| 523 | } |
| 524 | t.Logf("CONTROL: serving tokens.css with the rake line appended; this run is EXPECTED to fail") |
| 525 | return []byte(` |
| 526 | [rst-page-header]::after { |
| 527 | background: var(--rst-accent); |
| 528 | block-size: 2px; |
| 529 | content: ""; |
| 530 | inline-size: 2.5rem; |
| 531 | inset-block-end: -1px; |
| 532 | inset-inline-start: 0; |
| 533 | position: absolute; |
| 534 | } |
| 535 | `) |
| 536 | } |
| 537 | |