Skip to content
quarry Artifact [5ff94a847e]
Login

Artifact [5ff94a847e]

Artifact 5ff94a847e2cc5a406ab86acd98e312bf6531334000c9fa1a8494fc7b6949979:


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,
	"--pk-w": true, // a diagram's drawn width, set by markup.Figure
}

// 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)
	}
}

// themeIndependent are the colours the RULES may state outright,
// because they do not belong to a theme. Each is a decision, and the
// reason is the point of the list:
//
//	#000 in a mask — a mask reads alpha only, so the colour is
//	  arbitrary and a token would imply it matters.
//	#1d2021 and #f0ece4 on a media tile — a video with no poster
//	  frame is drawn as a dark frame with a light play mark, the same
//	  on both grounds, the way a screen is dark in a lit room.
//	#000000c4 on the lightbox backdrop — a scrim dims the page
//	  towards black whichever theme the page is in.
var themeIndependent = map[string]bool{
	"#000": true, "#1d2021": true, "#f0ece4": true, "#000000c4": true,
}

// A colour in the RULES has to be a token or an argued exception.
//
// paletteCSS is where a theme-dependent colour lives, and the contrast
// floor only inspects paletteCSS — so a hex written into a rule is
// outside the one check that would catch it being unreadable. Ten of
// them had accumulated there, most as var() fallbacks for tokens that
// did not exist; those are gone. What is left is the short list above,
// and this test is what makes the next one an argument rather than an
// accident.
func TestRuleColoursAreTokensOrArguedExceptions(t *testing.T) {
	rules := sheetLiteral
	for _, h := range regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b`).FindAllString(rules, -1) {
		if !themeIndependent[strings.ToLower(h)] {
			t.Errorf("FAIL: the rules state the colour %s outright. A theme-dependent "+
				"colour belongs in paletteCSS, which is the only part the contrast floor "+
				"inspects; if it genuinely belongs to no theme, add it to "+
				"themeIndependent with the reason.", h)
		}
	}
}

// And the palette is where the themed colours are, so it should hold
// most of them. A rule that quietly grows its own palette is the drift
// this pair of tests exists to stop.
func TestThePaletteHoldsTheColours(t *testing.T) {
	hex := regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b`)
	inPalette := len(hex.FindAllString(paletteCSS, -1))
	inRules := len(hex.FindAllString(sheetLiteral, -1))
	if inPalette < 25 {
		t.Errorf("FAIL: only %d colours in the palette; it moved or shrank", inPalette)
	}
	if inRules > len(themeIndependent)+1 {
		t.Errorf("FAIL: %d colours stated in the rules against %d argued exceptions",
			inRules, len(themeIndependent))
	}
}