summaryrefslogtreecommitdiff
path: root/vrsite/themes_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'vrsite/themes_test.go')
-rw-r--r--vrsite/themes_test.go294
1 files changed, 294 insertions, 0 deletions
diff --git a/vrsite/themes_test.go b/vrsite/themes_test.go
new file mode 100644
index 0000000..4ef1cca
--- /dev/null
+++ b/vrsite/themes_test.go
@@ -0,0 +1,294 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// everything a helix theme reader has to survive: quoted scope keys, a
+// palette, one of the terminal color names, modifiers, and an inline table
+// broken over lines the way a formatter leaves it
+const helixSrc = `
+# a comment
+"ui.background" = { bg = "base" }
+"ui.text" = { fg = "text" }
+"ui.linenr" = { fg = "muted" }
+"ui.statusline" = { fg = "text", bg = "surface" }
+"ui.cursorline.primary" = { bg = "highlight" }
+"ui.menu.selected" = { fg = "base", bg = "rose" }
+"ui.window" = { fg = "muted" }
+comment = { fg = "muted", modifiers = ["italic"] }
+keyword = { fg = "pine", modifiers = [
+ "bold",
+] }
+string = "gold"
+function = { fg = "rose" }
+"constant.numeric" = { fg = "iris" }
+"variable.other.member" = { fg = "foam" }
+"punctuation" = { fg = "muted" }
+warning = { fg = "gold" }
+type = "white"
+
+[palette]
+base = "#191724"
+text = "#e0def4"
+muted = "#6e6a86"
+surface = "#1f1d2e"
+highlight = "#21202e"
+pine = "#31748f"
+gold = "#f6c177"
+rose = "#ebbcba"
+iris = "#c4a7e7"
+foam = "#9ccfd8"
+`
+
+func writeTheme(t *testing.T, dir, name, body string) string {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, name)
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestHelixTheme(t *testing.T) {
+ dir := t.TempDir()
+ path := writeTheme(t, dir, "rose_pine.toml", helixSrc)
+ th := loadHelixTheme(path, []string{dir})
+ if th == nil {
+ t.Fatal("theme did not load")
+ }
+ for _, tc := range []struct{ what, got, want string }{
+ {"id", th.ID, "helix/rose-pine"},
+ {"source", th.Source, "helix"},
+ {"name", th.Name, "rose_pine"},
+ {"appearance", th.Appearance, "dark"},
+ {"bg", th.Bg, "#191724"},
+ {"fg", th.Fg, "#e0def4"},
+ {"panel", th.Panel, "#1f1d2e"},
+ {"line numbers", th.LineNo, "#6e6a86"},
+ {"border", th.Border, "#6e6a86"},
+ {"accent", th.Accent, "#ebbcba"}, // function
+ {"note", th.Note, "#f6c177"}, // warning
+ {"hover", th.Hover, "#21202e"}, // cursorline, not the loud menu row
+ {"muted", th.Muted, "#6e6a86"}, // comment
+ } {
+ if tc.got != tc.want {
+ t.Errorf("%s = %q, want %q", tc.what, tc.got, tc.want)
+ }
+ }
+ // syntax keys the site paints with, spelled the way helix spells them
+ for _, tc := range []struct{ key, want string }{
+ {"comment", "#6e6a86"},
+ {"keyword", "#31748f"},
+ {"string", "#f6c177"},
+ {"type", "#e5e5e5"}, // a terminal color name, not a palette one
+ {"number", "#c4a7e7"}, // zed's name for constant.numeric
+ {"property", "#9ccfd8"}, // zed's name for variable.other.member
+ {"punctuation.bracket", "#6e6a86"}, // falls back along its dots
+ } {
+ if got := th.syn(tc.key); got != tc.want {
+ t.Errorf("syntax %s = %q, want %q", tc.key, got, tc.want)
+ }
+ }
+ if s := th.Syntax["comment"]; s.FontStyle == nil || *s.FontStyle != "italic" {
+ t.Errorf("comment is not italic: %+v", s)
+ }
+ if s := th.Syntax["keyword"]; s.FontWeight == nil || *s.FontWeight != 700 {
+ t.Errorf("keyword is not bold: %+v", s)
+ }
+ // agent colors: from the code's own colors, and none of them invisible
+ if len(th.Players) < 3 {
+ t.Fatalf("agent colors = %v, want at least 3", th.Players)
+ }
+ for _, p := range th.Players {
+ if colorDist(p, hex6(th.Bg)) <= 60 || colorDist(p, hex6(th.Fg)) <= 60 {
+ t.Errorf("agent color %s vanishes against the theme", p)
+ }
+ }
+}
+
+// A child theme restating only the palette is how helix ships most of its
+// families: the parent's scopes have to resolve against the child's colors.
+func TestHelixInherits(t *testing.T) {
+ dir := t.TempDir()
+ writeTheme(t, dir, "parent.toml", helixSrc)
+ path := writeTheme(t, dir, "child.toml", `
+inherits = "parent"
+comment = { fg = "muted" }
+
+[palette]
+base = "#eff1f5"
+text = "#4c4f69"
+muted = "#7c7f93"
+surface = "#e6e9ef"
+highlight = "#dce0e8"
+pine = "#1e66f5"
+gold = "#df8e1d"
+rose = "#ea76cb"
+iris = "#8839ef"
+foam = "#04a5e5"
+`)
+ th := loadHelixTheme(path, []string{dir})
+ if th == nil {
+ t.Fatal("child theme did not load")
+ }
+ if th.Bg != "#eff1f5" || th.Fg != "#4c4f69" {
+ t.Errorf("inherited scopes kept the parent's palette: bg=%s fg=%s", th.Bg, th.Fg)
+ }
+ if th.Appearance != "light" {
+ t.Errorf("appearance = %q, want light for a pale background", th.Appearance)
+ }
+ if got := th.syn("keyword"); got != "#1e66f5" {
+ t.Errorf("inherited keyword = %q, want the child's pine", got)
+ }
+ // the child's own entry wins over the parent's, modifiers and all
+ if s := th.Syntax["comment"]; s.FontStyle != nil {
+ t.Errorf("child comment kept the parent's italics: %+v", s)
+ }
+}
+
+// A theme that names almost nothing still has to dress a whole page.
+func TestFillClosesTheGaps(t *testing.T) {
+ th := &Theme{Bg: "#101010", Fg: "#f0f0f0", Syntax: map[string]SyntaxStyle{
+ "function": {Color: "#88c0d0"},
+ }}
+ th.fill()
+ for _, tc := range []struct{ what, got string }{
+ {"panel", th.Panel}, {"border", th.Border}, {"line numbers", th.LineNo},
+ {"muted", th.Muted}, {"accent", th.Accent}, {"note", th.Note}, {"hover", th.Hover},
+ } {
+ if okColor(tc.got) == "" {
+ t.Errorf("%s = %q, want a color", tc.what, tc.got)
+ }
+ }
+ if th.Accent != "#88c0d0" {
+ t.Errorf("accent = %q, want the theme's own function color", th.Accent)
+ }
+ if th.Panel == th.Bg || th.Hover == th.Bg {
+ t.Errorf("panel %q and hover %q must lift off the background %q", th.Panel, th.Hover, th.Bg)
+ }
+ // a panel the text would be lost in is not kept
+ lost := &Theme{Bg: "#f7f3ee", Fg: "#605a52", Panel: "#605a52"}
+ lost.fill()
+ if lost.Panel == "#605a52" {
+ t.Error("kept a panel the foreground disappears into")
+ }
+}
+
+func TestZedFamily(t *testing.T) {
+ fam, err := parseZedFamily([]byte(`{"name":"Fake","themes":[
+ {"name":"Fake Dark","appearance":"dark","style":{
+ "editor.background":"#101010","editor.foreground":"#e0e0e0",
+ "text.accent":"0f4b6e","warning":"#e0af68",
+ "players":[{"cursor":"#7aa2f7"},{"cursor":"#9ece6a"}],
+ "syntax":{"keyword":{"color":"#bb9af7","font_style":"italic"},
+ "string":{"color":"9ece6a"}}}},
+ {"name":"Fake Light","appearance":"light","style":{
+ "editor.background":"#fafafa","editor.foreground":"#383a42"}}]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(fam) != 2 {
+ t.Fatalf("themes = %d, want 2", len(fam))
+ }
+ dark, light := fam[0], fam[1]
+ if dark.ID != "zed/fake-dark" || light.ID != "zed/fake-light" {
+ t.Errorf("ids = %q, %q", dark.ID, light.ID)
+ }
+ // a theme in the wild does drop the odd "#": what is not a color is
+ // treated as unsaid, and filled in rather than emitted as nonsense
+ if dark.Accent == "0f4b6e" {
+ t.Error("kept a color with no #")
+ }
+ if _, ok := dark.Syntax["string"]; ok {
+ t.Error("kept a syntax color with no #")
+ }
+ if dark.syn("keyword") != "#bb9af7" {
+ t.Errorf("keyword = %q", dark.syn("keyword"))
+ }
+ if len(dark.Players) < 2 || dark.Players[0] != "#7aa2f7" {
+ t.Errorf("agent colors = %v, want the player palette first", dark.Players)
+ }
+ // nothing to work from but bg and fg, and still a whole theme
+ if okColor(light.Muted) == "" || okColor(light.Hover) == "" {
+ t.Errorf("light theme left gaps: %+v", light)
+ }
+}
+
+// The picker's two files: a block per theme, and a menu that names them.
+func TestThemeFiles(t *testing.T) {
+ dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light")
+ helix := loadHelixTheme(writeTheme(t, t.TempDir(), "rose_pine.toml", helixSrc), nil)
+ ts := themesFrom([]*Theme{dark, light, helix}, dark, light)
+
+ css := string(ts.CSS)
+ for _, th := range ts.List {
+ if !strings.Contains(css, `:root[data-theme="`+th.ID+`"]`) {
+ t.Errorf("no css block for %s", th.ID)
+ }
+ if !strings.Contains(string(ts.JS), th.ID) {
+ t.Errorf("%s is not in the menu", th.ID)
+ }
+ }
+ for _, want := range []string{"--bg:", "--fg:", "--ag0:", "color-scheme: dark", "color-scheme: light"} {
+ if !strings.Contains(css, want) {
+ t.Errorf("themes.css never says %q", want)
+ }
+ }
+ // every declaration has to be worth something to the browser
+ for _, line := range strings.Split(css, "\n") {
+ if l := strings.TrimSpace(line); strings.HasPrefix(l, "--") && strings.HasSuffix(l, ": ;") {
+ t.Errorf("empty declaration: %s", l)
+ }
+ }
+ if n := strings.Count(string(ts.JS), `"id":`); n != len(ts.List) {
+ t.Errorf("menu has %d themes, want %d", n, len(ts.List))
+ }
+}
+
+// The two editors' theme directories, as they are on a machine that has them.
+func TestDiscoverFindsBothEditors(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, "data"))
+ writeTheme(t, filepath.Join(home, "config", "zed", "themes"), "mine.json",
+ `{"name":"Mine","themes":[{"name":"Mine Dark","appearance":"dark","style":{
+ "editor.background":"#101010","editor.foreground":"#e0e0e0"}}]}`)
+ writeTheme(t, filepath.Join(home, "data", "zed", "extensions", "installed", "ext", "themes"),
+ "ext.json", `{"name":"Ext","themes":[{"name":"Ext Light","appearance":"light","style":{
+ "editor.background":"#fafafa","editor.foreground":"#383a42"}}]}`)
+ writeTheme(t, filepath.Join(home, "config", "helix", "themes"), "mine_helix.toml", helixSrc)
+
+ found := map[string]*Theme{}
+ for _, th := range discoverThemes() {
+ found[th.ID] = th
+ }
+ for _, want := range []string{"zed/mine-dark", "zed/ext-light", "helix/mine-helix"} {
+ if found[want] == nil {
+ t.Errorf("did not find %s", want)
+ }
+ }
+}
+
+// Coverage colors are mixed in the browser so every theme can supply them.
+func TestMaskMix(t *testing.T) {
+ s := &Site{Agents: []*Agent{{Bit: 1}, {Bit: 2}, {Bit: 4}}}
+ for _, tc := range []struct {
+ mask uint8
+ want string
+ }{
+ {1, "var(--ag0)"},
+ {5, "color-mix(in srgb, var(--ag0) 50.000%, var(--ag2))"},
+ {7, "color-mix(in srgb, var(--ag0) 33.333%, color-mix(in srgb, var(--ag1) 50.000%, var(--ag2)))"},
+ } {
+ if got := s.maskMix(tc.mask); got != tc.want {
+ t.Errorf("mask %d = %q, want %q", tc.mask, got, tc.want)
+ }
+ }
+}