go-bananas/internal/tokenengine/color_test.go
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

216 lines
6.5 KiB
Go

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