158 lines
4.7 KiB
Go
158 lines
4.7 KiB
Go
|
|
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
|
||
|
|
}
|