package web
import (
"os"
"regexp"
"sort"
"strings"
"testing"
)
// inlineVars are set per element by a style attribute, not by the
// palette: the identicon's hue and chroma, a column index, a measured
// height. They are declared where they are used and have nothing to do
// with theming.
var inlineVars = map[string]bool{
"--uh": true, "--uc": true, "--c": true, "--column": true,
"--footer-h": true, "--gutter": true, "--measure": true,
"--page": true, "--stamp-node": true, "--ink": true, "--mono": true,
}
// A custom property the sheet READS must be one the sheet DECLARES.
//
// --warn, --danger and --ok were read by thirteen rules between them
// and declared nowhere. Every one of those rules rendered its var()
// fallback instead, which meant: the same severity in two different
// colours (--warn fell through to #b60 in six places and #b3261e in
// two), and a form-error colour of 3.55:1 against the cream ground —
// below AA. None of it was visible to the contrast floor, because the
// fallbacks sat outside paletteCSS where TestEveryPaletteTokenIs-
// ContrastChecked cannot look.
//
// That is the shape of the bug: a token that does not exist does not
// fail loudly, it silently becomes whatever was typed after the comma.
func TestEveryTokenTheSheetReadsIsDeclared(t *testing.T) {
src := styleSource
declared := map[string]bool{}
for _, m := range regexp.MustCompile(`(--[a-z0-9-]+)\s*:`).FindAllStringSubmatch(src, -1) {
declared[m[1]] = true
}
var missing []string
for _, m := range regexp.MustCompile(`var\((--[a-z0-9-]+)`).FindAllStringSubmatch(src, -1) {
if !declared[m[1]] && !inlineVars[m[1]] {
missing = append(missing, m[1])
}
}
if len(missing) > 0 {
sort.Strings(missing)
uniq := missing[:0]
var last string
for _, m := range missing {
if m != last {
uniq = append(uniq, m)
last = m
}
}
t.Errorf("FAIL: the sheet reads %v, which it never declares. Each renders its "+
"var() fallback — an untuned colour outside the contrast floor — or nothing "+
"at all. Declare it in paletteCSS, or add it to inlineVars if a style "+
"attribute sets it.", uniq)
}
}
// And no var() carries a colour fallback.
//
// A fallback is where an undeclared token hides: it makes the rule
// look fine, keeps the page rendering, and puts a colour nobody tuned
// on the screen in both themes at once. If the token exists the
// fallback is dead; if it does not, the fallback is the bug.
func TestNoVarFallbackSmugglesInAColour(t *testing.T) {
bad := regexp.MustCompile(`var\(--[a-z0-9-]+,\s*#[0-9a-fA-F]{3,8}\s*\)`).FindAllString(styleSource, -1)
if len(bad) > 0 {
t.Errorf("FAIL: %d var() fallback colour(s) in the sheet: %s. A colour belongs in "+
"paletteCSS where the contrast floor can see it.", len(bad), strings.Join(bad, " "))
}
}
// A token declared in one theme must be declared in the other.
//
// Declared-somewhere is not enough: a colour that exists only in
// darkTokens renders NOTHING on the light ground, and one that exists
// only in the light :root renders the light value on a near-black
// page. The first version of this guard checked only that a token was
// declared at all, and it passed while --warn was missing from light —
// because dark declared it. This is the version that fails.
//
// themeFree lists what is deliberately one value for both: the
// severity aliases, which resolve through --add and --del and move
// with them, and the translucent wash that tints whatever it lands on.
func TestEveryThemedTokenIsDeclaredInBothThemes(t *testing.T) {
themeFree := map[string]bool{"--ok": true, "--danger": true, "--surface": true}
set := func(css string) map[string]bool {
out := map[string]bool{}
for _, m := range regexp.MustCompile(`(--[a-z0-9-]+)\s*:\s*#`).FindAllStringSubmatch(css, -1) {
out[m[1]] = true
}
return out
}
lightRoot := paletteCSS[:strings.Index(paletteCSS, "@media")]
light, dark := set(lightRoot), set(darkTokens)
for tok := range light {
if !dark[tok] && !themeFree[tok] {
t.Errorf("FAIL: %s is declared for light and not for dark, so explicit dark "+
"renders the light value on a near-black page", tok)
}
}
for tok := range dark {
if !light[tok] && !themeFree[tok] {
t.Errorf("FAIL: %s is declared for dark and not for light, so it renders "+
"nothing at all on the cream ground", tok)
}
}
}
// Both dark selectors carry the same tokens.
//
// The media query had the full set and the explicit data-theme stamp
// had nine of them, so a reader who picked dark from the menu — rather
// than running a dark OS — got every diff colour at its LIGHT value:
// pale green and pink rows on a near-black page. Two hand-maintained
// copies is how that happened, so there is one list now and this test
// is what keeps it one.
func TestBothDarkSelectorsUseTheSameTokenList(t *testing.T) {
if n := strings.Count(paletteCSS, darkTokens); n != 2 {
t.Errorf("FAIL: darkTokens appears %d time(s) in the palette, want 2 — the OS "+
"media query and the explicit data-theme=dark stamp must not drift apart", n)
}
for _, tok := range []string{"--del", "--add", "--mark", "--add-row", "--del-row", "--warn"} {
if !strings.Contains(darkTokens, tok) {
t.Errorf("FAIL: darkTokens omits %s, so explicit dark falls back to the light value", tok)
}
}
}
// Every status paragraph announces itself, and the severity decides how
// loudly.
//
// 20 of the 33 carried no role at all, so a screen reader was told
// nothing when a save was refused or a setting took effect. The split
// matters: an error INTERRUPTS, which is role="alert" and assertive by
// default; a result is polite, which is role="status" plus aria-live,
// announced at the next pause instead of cutting across what is being
// read. Marking everything alert is the same mistake as marking
// everything danger.
func webSource(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("web.go")
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestEveryStatusParagraphAnnouncesItself(t *testing.T) {
tag := regexp.MustCompile(`<[a-z]+ class="status([^"]*)"([^>]*)>`)
var errs, notes int
for _, m := range tag.FindAllStringSubmatch(webSource(t), -1) {
cls, attrs := m[1], m[2]
isErr := strings.Contains(cls, "err")
switch {
case isErr && !strings.Contains(attrs, `role="alert"`):
t.Errorf("FAIL: an error is not announced assertively: <… class=\"status%s\"%s>", cls, attrs)
case !isErr && !strings.Contains(attrs, `role="status"`):
t.Errorf("FAIL: a result is not announced at all: <… class=\"status%s\"%s>", cls, attrs)
case !isErr && !strings.Contains(attrs, "aria-live"):
t.Errorf("FAIL: a result has role=status but no aria-live, so it is announced "+
"only if focus happens to land there: <… class=\"status%s\"%s>", cls, attrs)
}
if isErr {
errs++
} else {
notes++
}
}
if errs == 0 || notes == 0 {
t.Fatalf("FAIL: found %d errors and %d results; this test is not reading the markup", errs, notes)
}
}