package tokenengine import ( "math" "testing" ) func TestParseColorFormats(t *testing.T) { cases := []struct { name string input string want Color }{ {"hex6", "#1a2b3c", Color{RGB: RGB{R: 0x1a / 255.0, G: 0x2b / 255.0, B: 0x3c / 255.0, A: 1}, Raw: "#1a2b3c", Numeric: true}}, {"hex3", "#abc", Color{RGB: RGB{R: 0xaa / 255.0, G: 0xbb / 255.0, B: 0xcc / 255.0, A: 1}, Raw: "#abc", Numeric: true}}, {"hex8 alpha", "#00000080", Color{RGB: RGB{R: 0, G: 0, B: 0, A: 128 / 255.0}, Raw: "#00000080", Numeric: true}}, {"rgb legacy comma", "rgb(255, 0, 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255, 0, 0)", Numeric: true}}, {"rgb modern space", "rgb(255 0 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255 0 0)", Numeric: true}}, {"rgba alpha", "rgba(0, 0, 0, 0.5)", Color{RGB: RGB{A: 0.5}, Raw: "rgba(0, 0, 0, 0.5)", Numeric: true}}, {"hsl", "hsl(0, 100%, 50%)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "hsl(0, 100%, 50%)", Numeric: true}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := ParseColor(tc.input) if err != nil { t.Fatalf("ParseColor(%q) error: %v", tc.input, err) } if !got.Numeric { t.Fatalf("ParseColor(%q) returned a non-numeric colour", tc.input) } assertClose(t, got.R, tc.want.R, 0.002) assertClose(t, got.G, tc.want.G, 0.002) assertClose(t, got.B, tc.want.B, 0.002) assertClose(t, got.A, tc.want.A, 0.002) }) } } func TestParseColorRejectsGarbage(t *testing.T) { for _, bad := range []string{"", "#", "#12", "#1234567", "rgb(1,2)", "#gggggg"} { if _, err := ParseColor(bad); err == nil { t.Errorf("ParseColor(%q) should have failed", bad) } } } func TestParseColorPassesThroughModernSyntax(t *testing.T) { // These are valid CSS the engine cannot reason about numerically, but they // must round-trip rather than be rejected. for _, ok := range []string{"oklch(0.7 0.1 200)", "color(display-p3 1 0 0)", "light-dark(#fff, #000)", "currentColor", "transparent"} { got, err := ParseColor(ok) if err != nil { t.Errorf("ParseColor(%q) error: %v", ok, err) } if got.Numeric { t.Errorf("ParseColor(%q) should not be numeric", ok) } if got.Raw != ok { t.Errorf("ParseColor(%q).Raw = %q, want the original", ok, got.Raw) } } } func TestContrastRatioMatchesWCAGReferenceValues(t *testing.T) { // Reference values from the WCAG 2.1 relative-luminance definition. cases := []struct { a, b string want float64 }{ {"#ffffff", "#000000", 21}, {"#ffffff", "#ffffff", 1}, {"#000000", "#000000", 1}, {"#777777", "#ffffff", 4.48}, {"#767676", "#ffffff", 4.54}, // the canonical AA boundary grey } for _, tc := range cases { a, err := ParseColor(tc.a) if err != nil { t.Fatalf("%v", err) } b, err := ParseColor(tc.b) if err != nil { t.Fatalf("%v", err) } got := ContrastRatio(a, b) if math.Abs(got-tc.want) > 0.05 { t.Errorf("ContrastRatio(%s, %s) = %.3f, want %.3f", tc.a, tc.b, got, tc.want) } } } func TestBestForegroundPicksHigherContrast(t *testing.T) { light := Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true} dark := Color{RGB: RGB{A: 1}, Numeric: true} if got := BestForeground(Color{RGB: RGB{A: 1}, Numeric: true}); got.R != light.R { t.Errorf("foreground for black should be white, got %s", got.Hex()) } if got := BestForeground(Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}); got.R != dark.R { t.Errorf("foreground for white should be black, got %s", got.Hex()) } } func TestHexRoundTrip(t *testing.T) { for _, hex := range []string{"#000000", "#ffffff", "#4f46e5", "#4f46e580"} { c, err := ParseColor(hex) if err != nil { t.Fatalf("%v", err) } if got := c.Hex(); got != hex { t.Errorf("round trip of %q produced %q", hex, got) } } } func TestMixProducesIntermediateColour(t *testing.T) { black, _ := ParseColor("#000000") white, _ := ParseColor("#ffffff") mid := black.Mix(white, 0.5) if got := mid.Hex(); got != "#808080" && got != "#7f7f7f" { t.Errorf("50%% black/white mix = %s, want roughly #808080", got) } if got := black.Mix(white, 0).Hex(); got != "#000000" { t.Errorf("0%% mix = %s, want #000000", got) } if got := black.Mix(white, 1).Hex(); got != "#ffffff" { t.Errorf("100%% mix = %s, want #ffffff", got) } } func TestLightenDarkInvertsDirectionForDarkSchemes(t *testing.T) { base, _ := ParseColor("#4f46e5") light := base.LightenDark(0.1, false) dark := base.LightenDark(0.1, true) if light.RelativeLuminance() >= base.RelativeLuminance() { t.Error("light scheme emphasis should reduce luminance") } if dark.RelativeLuminance() <= base.RelativeLuminance() { t.Error("dark scheme emphasis should increase luminance") } } func TestGenerateRampIsOrderedAndComplete(t *testing.T) { ramp := GenerateRamp(DefaultRampOptions()) shades := StandardPaletteShades() if len(ramp) != len(shades) { t.Fatalf("ramp has %d steps, want %d", len(ramp), len(shades)) } previous := math.Inf(1) for _, shade := range shades { leaf, ok := ramp[shade].(map[string]any) if !ok { t.Fatalf("shade %s is not a token leaf", shade) } value, ok := leaf[ValueKey].(string) if !ok { t.Fatalf("shade %s has no string value", shade) } col, err := ParseColor(value) if err != nil { t.Fatalf("shade %s value %q is not a colour: %v", shade, value, err) } luminance := col.RelativeLuminance() if luminance > previous { t.Errorf("shade %s is lighter than the preceding shade", shade) } previous = luminance } } func TestGenerateRampNormalisesOutOfRangeInput(t *testing.T) { opts := RampOptions{Hue: 400, Saturation: -20, Lightness: 999, HueSpread: 1000}.Normalize() if opts.Hue < 0 || opts.Hue >= 360 { t.Errorf("hue = %f, want it wrapped into 0..360", opts.Hue) } if opts.Saturation != 0 { t.Errorf("saturation = %f, want it clamped to 0", opts.Saturation) } if opts.Lightness != 80 { t.Errorf("lightness = %f, want it clamped to 80", opts.Lightness) } if opts.HueSpread != 40 { t.Errorf("hueSpread = %f, want it clamped to 40", opts.HueSpread) } } func TestFormatNumberIsStable(t *testing.T) { cases := map[float64]string{ 0: "0", 1: "1", 1.5: "1.5", 0.1250001: "0.125", -0.5: "-0.5", 100: "100", } for in, want := range cases { if got := FormatNumber(in); got != want { t.Errorf("FormatNumber(%v) = %q, want %q", in, got, want) } } } // assertClose fails the test when want and got differ by more than tolerance. func assertClose(t *testing.T, got, want, tolerance float64) { t.Helper() if math.Abs(got-want) > tolerance { t.Errorf("got %v, want %v (tolerance %v)", got, want, tolerance) } }