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