go-bananas/internal/tokenengine/tokens_test.go

252 lines
7.7 KiB
Go
Raw Normal View History

2026-10-07 15:11:35 +02:00
package tokenengine
import (
"strings"
"testing"
"github.com/urpi/go-bananas/internal/domain"
)
// testTheme builds a minimal but complete theme for engine tests.
func testTheme() *domain.Theme {
return &domain.Theme{
Slug: "acme",
Name: "Acme",
Status: domain.StatusPublished,
DefaultAppearance: domain.AppearanceLight,
Version: 3,
Tokens: domain.TokenDoc{
"color": map[string]any{
"palette": map[string]any{
"brand": map[string]any{
"500": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"},
"600": map[string]any{TypeKey: string(KindColor), ValueKey: "#4338ca"},
},
},
"semantic": map[string]any{
"background": map[string]any{TypeKey: string(KindColor), ValueKey: "#ffffff"},
"surface": map[string]any{TypeKey: string(KindColor), ValueKey: "#f8fafc"},
"text": map[string]any{TypeKey: string(KindColor), ValueKey: "#0f172a"},
"primary": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"},
},
},
"font": map[string]any{
"family": map[string]any{
"sans": map[string]any{TypeKey: string(KindFamilyAlias()), ValueKey: "Inter, sans-serif"},
},
},
"space": map[string]any{
"4": map[string]any{TypeKey: string(KindDimension), ValueKey: 1},
},
},
}
}
// KindFamilyAlias exists only so the fixture reads clearly; it resolves to the
// canonical font-family kind.
func KindFamilyAlias() Kind { return KindFontFamily }
func TestResolveFlattensNestedTokens(t *testing.T) {
doc := Resolve(testTheme(), domain.AppearanceLight)
if got, ok := doc.Lookup("color.semantic.background"); !ok {
t.Fatalf("expected color.semantic.background to resolve, got %d tokens", len(doc.Tokens))
} else if got.CSS != "#ffffff" {
t.Errorf("background = %q, want #ffffff", got.CSS)
}
space, ok := doc.Lookup("space.4")
if !ok {
t.Fatal("expected space.4 to resolve")
}
// A bare number in a dimension group must pick up the px suffix.
if space.CSS != "1px" {
t.Errorf("space.4 = %q, want 1px", space.CSS)
}
}
func TestResolveIsDeterministic(t *testing.T) {
first := Resolve(testTheme(), domain.AppearanceLight)
second := Resolve(testTheme(), domain.AppearanceLight)
if len(first.Tokens) != len(second.Tokens) {
t.Fatalf("token count differs: %d vs %d", len(first.Tokens), len(second.Tokens))
}
for i := range first.Tokens {
if first.Tokens[i].Path != second.Tokens[i].Path {
t.Fatalf("token %d out of order: %q vs %q", i, first.Tokens[i].Path, second.Tokens[i].Path)
}
}
}
func TestAppearanceOverridesWinOverBase(t *testing.T) {
theme := testTheme()
theme.Tokens[AppearancesKey] = map[string]any{
"dark": map[string]any{
"color": map[string]any{
"semantic": map[string]any{
"background": map[string]any{TypeKey: string(KindColor), ValueKey: "#0b1220"},
},
},
},
}
light := Resolve(theme, domain.AppearanceLight)
dark := Resolve(theme, domain.AppearanceDark)
if got, _ := light.Lookup("color.semantic.background"); got.CSS != "#ffffff" {
t.Errorf("light background = %q, want the base value #ffffff", got.CSS)
}
if got, _ := dark.Lookup("color.semantic.background"); got.CSS != "#0b1220" {
t.Errorf("dark background = %q, want the override #0b1220", got.CSS)
}
// Tokens not overridden must fall through to the base value.
if got, _ := dark.Lookup("color.semantic.primary"); got.CSS != "#4f46e5" {
t.Errorf("dark primary = %q, want the base value #4f46e5", got.CSS)
}
}
func TestRenderEmitsScopedSelectorsAndDerivedTokens(t *testing.T) {
css := Render(testTheme(), RenderOptions{
Slug: "acme",
Appearance: domain.AppearanceLight,
Derive: DefaultDerive(),
Components: true,
})
for _, want := range []string{
`[data-bananas-theme="acme"]`,
`[data-bananas-theme="acme"][data-bananas-appearance=dark]`,
"--bn-color-semantic-background: #ffffff;",
// Derived foreground must pick white on a dark primary.
"--bn-color-on-semantic-primary: #ffffff;",
"--bn-color-semantic-primary-hover:",
"--bn-space-4: 1px;",
"@layer bananas",
".bn-btn",
`.dark [data-bananas-theme="acme"]`,
} {
if !strings.Contains(css, want) {
t.Errorf("rendered CSS missing %q\n---\n%s", want, css)
}
}
}
func TestRenderMinifiedOmitsCommentsAndWhitespace(t *testing.T) {
css := Render(testTheme(), RenderOptions{Slug: "acme", Minify: true, Derive: DefaultDerive()})
if strings.Contains(css, "/*") {
t.Error("minified CSS must not contain comments")
}
if strings.Contains(css, "\n\n") {
t.Error("minified CSS must not contain blank lines")
}
}
func TestValidateAcceptsACompleteTheme(t *testing.T) {
result := Validate(testTheme().Tokens)
for _, issue := range result.Errors() {
// The fixture is intentionally sparse; it must only trip the "unknown
// group" rule if the schema drifted, never on the tokens it defines.
if strings.Contains(issue.Message, "unknown token group") {
t.Errorf("unexpected schema error: %+v", issue)
}
}
if result.TokenCount != 8 {
t.Errorf("TokenCount = %d, want 8", result.TokenCount)
}
}
func TestValidateRejectsMalformedColour(t *testing.T) {
doc := testTheme().Tokens
doc["color"].(map[string]any)["semantic"].(map[string]any)["primary"] = map[string]any{
TypeKey: string(KindColor), ValueKey: "#nothex",
}
result := Validate(doc)
if result.Valid {
t.Fatal("expected validation to fail on a malformed colour")
}
found := false
for _, issue := range result.Issues {
if issue.Path == "color.semantic.primary" {
found = true
}
}
if !found {
t.Errorf("expected an issue on color.semantic.primary, got %+v", result.Issues)
}
}
func TestValidateRejectsUnknownGroup(t *testing.T) {
doc := testTheme().Tokens
doc["colour"] = map[string]any{"typo": map[string]any{ValueKey: "#fff"}}
result := Validate(doc)
if result.Valid {
t.Fatal("expected validation to reject an unknown top-level group")
}
}
func TestVariableNameNormalisation(t *testing.T) {
cases := map[string]string{
"color.semantic.background": "--bn-color-semantic-background",
"space.4": "--bn-space-4",
"font.lineHeight.tight": "--bn-font-line-height-tight",
"font.line height": "--bn-font-line-height",
"weird.Name_Here": "--bn-weird-name-here",
}
for path, want := range cases {
if got := VariableName(path); got != want {
t.Errorf("VariableName(%q) = %q, want %q", path, got, want)
}
}
}
func TestSetAndDeleteToken(t *testing.T) {
doc := testTheme().Tokens
if err := SetIn(doc, "space.6", 1.5, KindDimension, ""); err != nil {
t.Fatalf("SetIn: %v", err)
}
if got, err := LookupIn(doc, "space.6"); err != nil {
t.Fatalf("LookupIn: %v", err)
} else if got[ValueKey] != 1.5 {
t.Errorf("space.6 = %v, want 1.5", got[ValueKey])
}
deleted, err := DeleteIn(doc, "space.6")
if err != nil || !deleted {
t.Fatalf("DeleteIn = %v, %v; want true, nil", deleted, err)
}
if _, err := LookupIn(doc, "space.6"); err == nil {
t.Error("token should be gone after DeleteIn")
}
}
func TestSetInRejectsUnknownGroup(t *testing.T) {
doc := domain.TokenDoc{}
err := SetIn(doc, "colour.typo", "#fff", KindColor, "")
if err == nil {
t.Fatal("expected SetIn to reject an unknown group")
}
}
func TestJSONPayloadCarriesFlatAndNestedForms(t *testing.T) {
payload := JSONPayload(testTheme(), domain.AppearanceDark)
if payload.Theme.Slug != "acme" {
t.Errorf("slug = %q, want acme", payload.Theme.Slug)
}
if payload.Variable["--bn-color-semantic-primary"] != "#4f46e5" {
t.Errorf("flat map missing primary: %v", payload.Variable)
}
semantic, ok := payload.Tokens["color"].(map[string]any)
if !ok {
t.Fatal("nested tokens missing the color group")
}
if _, ok := semantic["semantic"]; !ok {
t.Error("nested tokens missing the semantic group")
}
}