package seed import ( "testing" "github.com/urpi/go-bananas/internal/domain" "github.com/urpi/go-bananas/internal/tokenengine" ) func TestEveryBuiltInThemeValidates(t *testing.T) { for _, def := range All() { t.Run(def.Slug, func(t *testing.T) { theme := def.Theme() result := tokenengine.Validate(theme.Tokens) if !result.Valid { for _, issue := range result.Errors() { t.Errorf("%s %s: %s (%s)", issue.Severity, issue.Path, issue.Message, issue.Hint) } t.Fatal("built-in theme must be valid") } if result.TokenCount < 50 { t.Errorf("theme has only %d tokens; a built-in should be complete", result.TokenCount) } }) } } func TestEveryBuiltInThemeRendersBothAppearances(t *testing.T) { for _, def := range All() { t.Run(def.Slug, func(t *testing.T) { theme := def.Theme() for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} { css := tokenengine.Render(theme, tokenengine.RenderOptions{ Slug: theme.Slug, Appearance: appearance, Components: true, Derive: tokenengine.DefaultDerive(), }) if css == "" { t.Fatalf("%s/%s rendered an empty stylesheet", theme.Slug, appearance) } if !contains(css, "--bn-color-semantic-background") { t.Errorf("%s/%s is missing the background variable", theme.Slug, appearance) } if !contains(css, "--bn-color-semantic-text") { t.Errorf("%s/%s is missing the text variable", theme.Slug, appearance) } } }) } } func TestBuiltInThemesDeclareDistinctDarkOverrides(t *testing.T) { // A theme that declares a dark appearance but supplies no dark overrides // would render the light palette in dark mode, which is never intentional. for _, def := range All() { theme := def.Theme() light := tokenengine.Resolve(theme, domain.AppearanceLight) dark := tokenengine.Resolve(theme, domain.AppearanceDark) lightBG, _ := light.Lookup("color.semantic.background") darkBG, _ := dark.Lookup("color.semantic.background") if lightBG.CSS == darkBG.CSS { t.Errorf("%s: light and dark backgrounds are identical (%s)", def.Slug, lightBG.CSS) } } } func TestTextContrastMeetsAAForEveryBuiltIn(t *testing.T) { // This is the check that makes a built-in theme trustworthy: body text must // be readable on every surface it is likely to sit on. pairs := []struct { fgPath, bgPath string }{ {"color.semantic.text", "color.semantic.background"}, {"color.semantic.text", "color.semantic.surface"}, {"color.semantic.text", "color.semantic.surface-raised"}, {"color.semantic.text-muted", "color.semantic.background"}, {"color.on-primary", "color.semantic.primary"}, {"color.on-accent", "color.semantic.accent"}, {"color.on-danger", "color.semantic.danger"}, } for _, def := range All() { theme := def.Theme() for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} { doc := tokenengine.Resolve(theme, appearance) for _, pair := range pairs { fg, ok := doc.Lookup(pair.fgPath) if !ok { continue } bg, ok := doc.Lookup(pair.bgPath) if !ok { continue } fgColor, err := tokenengine.ParseColor(fg.CSS) if err != nil { continue } bgColor, err := tokenengine.ParseColor(bg.CSS) if err != nil { continue } ratio := tokenengine.ContrastRatio(fgColor, bgColor) minimum := tokenengine.ContrastAA // The high-contrast theme is the escape hatch and is held to the // stricter AAA bar instead. if def.Slug == "high-contrast" { minimum = tokenengine.ContrastAAA } if ratio < minimum { t.Errorf("%s/%s: %s (%s) on %s (%s) = %.2f:1, want at least %.1f:1", def.Slug, appearance, pair.fgPath, fg.CSS, pair.bgPath, bg.CSS, ratio, minimum) } } } } } func TestBuiltInSlugsAreValidAndReservedSafe(t *testing.T) { for _, def := range All() { if err := domain.ValidateSlug(def.Slug); err != nil { t.Errorf("slug %q is invalid: %v", def.Slug, err) } if _, reserved := domain.ReservedSlugs[def.Slug]; reserved { t.Errorf("slug %q collides with a reserved runtime path", def.Slug) } if def.Appearance == "" { t.Errorf("theme %q has no default appearance", def.Slug) } } } func TestBuiltInThemesHaveUniqueIDs(t *testing.T) { seen := make(map[string]string) for _, theme := range Themes() { if previous, dup := seen[theme.ID]; dup { t.Errorf("id %q is used by both %q and %q", theme.ID, previous, theme.Slug) } seen[theme.ID] = theme.Slug } } func contains(haystack, needle string) bool { return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0 } func indexOf(haystack, needle string) int { for i := 0; i+len(needle) <= len(haystack); i++ { if haystack[i:i+len(needle)] == needle { return i } } return -1 }