176 lines
5.7 KiB
Go
176 lines
5.7 KiB
Go
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("<p>don't click</p>\n<a class='bg-red-500 text-white'>x</a>\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)
|
|
}
|
|
}
|
|
}
|