go-bananas/internal/seed/seed_test.go

158 lines
4.7 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
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
}