package web 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) } } }