diff options
Diffstat (limited to 'vrsite/themes_test.go')
| -rw-r--r-- | vrsite/themes_test.go | 294 |
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) + } + } +} |
