package main import "testing" // newTestResolver compiles the kjol theme layer (no app brand) against a fixed token // set. baseDir "." is fine — nothing in the entry @imports a local file. func newTestResolver(t *testing.T, tokens ...string) *Resolver { t.Helper() r, err := NewResolver("", ".", tokens) if err != nil { t.Fatalf("NewResolver: %v", err) } return r } func TestResolverSemanticTokens(t *testing.T) { r := newTestResolver(t, "bg-surface", "text-ink", "text-white", "bg-red-500") // Semantic surface: white in light, near-black in dark. if c, ok := r.resolveToken("bg-surface", light); !ok || hexOf(c) != "#ffffff" { t.Errorf("bg-surface light = %v (%s), want #ffffff", ok, hexOf(c)) } if c, ok := r.resolveToken("bg-surface", dark); !ok || hexOf(c) != "#101013" { t.Errorf("bg-surface dark = %v (%s), want #101013", ok, hexOf(c)) } // text-white chases var(--color-white) → #fff. if c, ok := r.resolveToken("text-white", light); !ok || hexOf(c) != "#ffffff" { t.Errorf("text-white = %v (%s), want #ffffff", ok, hexOf(c)) } // The palette OKLCH resolves to Tailwind's published hex. if c, ok := r.resolveToken("bg-red-500", light); !ok || hexOf(c) != "#fb2c36" { t.Errorf("bg-red-500 = %v (%s), want #fb2c36", ok, hexOf(c)) } } func TestResolverSideClassification(t *testing.T) { r := newTestResolver(t, "bg-red-500", "text-ink", "flex", "text-sm", "p-4") cases := map[string]string{ "bg-red-500": "bg", "text-ink": "fg", "flex": "", // not a colour utility "text-sm": "", // font-size, not a colour "p-4": "", } for tok, want := range cases { if got := r.side(tok); got != want { t.Errorf("side(%q) = %q, want %q", tok, got, want) } } } func TestResolverOpacityModifier(t *testing.T) { r := newTestResolver(t, "bg-white/50") c, ok := r.resolveToken("bg-white/50", light) if !ok { t.Fatal("bg-white/50 did not resolve") } approx(t, "bg-white/50 alpha", c.A, 0.5, 0.02) } func TestResolverArbitraryValue(t *testing.T) { r := newTestResolver(t, "bg-[#123456]") c, ok := r.resolveToken("bg-[#123456]", light) if !ok || hexOf(c) != "#123456" { t.Errorf("bg-[#123456] = %v (%s), want #123456", ok, hexOf(c)) } } func TestResolverDarkVariantToken(t *testing.T) { r := newTestResolver(t, "dark:bg-surface") // A dark: token resolves against the dark environment. if c, ok := r.resolveToken("dark:bg-surface", dark); !ok || hexOf(c) != "#101013" { t.Errorf("dark:bg-surface (dark) = %v (%s), want #101013", ok, hexOf(c)) } } // End to end: white-on-white fails; the semantic surface/ink pair passes in both // themes (the two tokens move together, so dark mode stays legible). func TestCheckGroupContrast(t *testing.T) { r := newTestResolver(t, "bg-white", "text-white", "bg-surface", "text-ink") // White on white: identical in both themes, so it collapses to one finding. fail := checkGroup(ClassGroup{ File: "x.tsx", Line: 1, Tokens: []string{"bg-white", "text-white"}, }, r, Options{Level: "AA"}) if len(fail) != 1 || fail[0].Pass { t.Fatalf("white-on-white should fail once, got %+v", fail) } // Surface/ink: light and dark are both checked (colours differ per theme) and // both must pass. pass := checkGroup(ClassGroup{ File: "x.tsx", Line: 2, Tokens: []string{"bg-surface", "text-ink"}, }, r, Options{Level: "AA"}) if len(pass) == 0 { t.Fatal("surface/ink produced no findings") } for _, f := range pass { if !f.Pass { t.Errorf("surface/ink should pass in %s, got %.2f:1", f.ThemeName(), f.Ratio) } } } // A fixed palette background paired with an inverting semantic text token is a real // dark-mode trap: bg-white stays white while text-ink climbs to near-white. func TestCheckGroupFixedVsSemanticDarkTrap(t *testing.T) { r := newTestResolver(t, "bg-white", "text-ink") got := checkGroup(ClassGroup{ File: "x.tsx", Line: 1, Tokens: []string{"bg-white", "text-ink"}, }, r, Options{Level: "AA"}) var lightPass, darkFail bool for _, f := range got { if f.Theme == light && f.Pass { lightPass = true } if f.Theme == dark && !f.Pass { darkFail = true } } if !lightPass || !darkFail { t.Errorf("expected light pass + dark fail, got %+v", got) } } // Scanner: a comment apostrophe ("panel's") must not open a string literal and // swallow the class constants below it — the desync that produced dozens of bogus // cross-paired findings. func TestLexerSkipsCommentApostrophe(t *testing.T) { src := []byte("// ModalSize selects the panel's max width.\n" + "const a = \"text-white\"\n" + "const b = \"bg-surface\"\n") lits := extractLiterals(src) if len(lits) != 2 { t.Fatalf("expected 2 literals, got %d: %+v", len(lits), lits) } if lits[0].content != "text-white" || lits[1].content != "bg-surface" { t.Errorf("unexpected literal contents: %+v", lits) } } func TestLexerSingleQuoteExpressionPosition(t *testing.T) { // Apostrophe in JSX text is not a string; a single-quoted attribute value is. src := []byte("
don't click
\nx\n") lits := extractLiterals(src) found := false for _, l := range lits { if l.content == "bg-red-500 text-white" { found = true } } if !found { t.Errorf("single-quoted class attribute not extracted: %+v", lits) } } func TestSplitVariants(t *testing.T) { cases := []struct { token string base string nvar int }{ {"bg-red-500", "bg-red-500", 0}, {"dark:bg-surface", "bg-surface", 1}, {"dark:hover:text-ink", "text-ink", 2}, {"text-[color:red]", "text-[color:red]", 0}, // ':' inside [] is not a variant sep } for _, c := range cases { v, base := splitVariants(c.token) if base != c.base || len(v) != c.nvar { t.Errorf("splitVariants(%q) = %v,%q; want %d variants, base %q", c.token, v, base, c.nvar, c.base) } } }