package theme import ( "errors" "os" "path/filepath" "strings" "testing" "github.com/gdamore/tcell/v2" "github.com/BurntSushi/toml" ) func TestParseReadsNameAndDescription(t *testing.T) { th := mustParse(t, ` name = "Example" description = "A theme for the tests" [colors] default = { fg = "white", bg = "black" } `) if got := th.Name(); got != "Example" { t.Errorf("Name() = %q, want %q", got, "Example") } if got := th.Description(); got != "A theme for the tests" { t.Errorf("Description() = %q", got) } } func TestStyleReadsTheColoursThatWereSet(t *testing.T) { th := mustParse(t, ` [colors] default = { fg = "white", bg = "black" } "syntax.keyword" = { fg = "lime", bg = "navy", bold = true } `) fg, bg, attrs := th.Style(KeySyntaxKeyword).Decompose() if fg != tcell.ColorLime { t.Errorf("foreground = %v, want lime", fg) } if bg != tcell.ColorNavy { t.Errorf("background = %v, want navy", bg) } if attrs&tcell.AttrBold == 0 { t.Error("the bold attribute was not applied") } } func TestStyleAcceptsHexColours(t *testing.T) { th := mustParse(t, ` [colors] default = { fg = "#ff8800", bg = "#001122" } `) fg, bg, _ := th.Style(KeyDefault).Decompose() if got := fg.Hex(); got != 0xff8800 { t.Errorf("foreground = %#06x, want 0xff8800", got) } if got := bg.Hex(); got != 0x001122 { t.Errorf("background = %#06x, want 0x001122", got) } } func TestStyleFallsBackAlongTheDots(t *testing.T) { th := mustParse(t, ` [colors] default = { fg = "white", bg = "black" } syntax = { fg = "aqua" } `) tests := []struct { key string want tcell.Color }{ {"syntax.keyword", tcell.ColorAqua}, // falls back to "syntax" {"syntax.string.raw", tcell.ColorAqua}, // two levels up {"completion.item", tcell.ColorWhite}, // nothing matches, so "default" {"no.such.key.at.all", tcell.ColorWhite}, // } for _, tc := range tests { fg, _, _ := th.Style(tc.key).Decompose() if fg != tc.want { t.Errorf("Style(%q) foreground = %v, want %v", tc.key, fg, tc.want) } } } func TestAnEntryInheritsTheHalfItLeavesOut(t *testing.T) { th := mustParse(t, ` [colors] default = { fg = "white", bg = "navy" } "syntax.comment" = { fg = "gray" } `) fg, bg, _ := th.Style(KeySyntaxComment).Decompose() if fg != tcell.ColorGray { t.Errorf("foreground = %v, want gray", fg) } if bg != tcell.ColorNavy { t.Errorf("background = %v, want the navy inherited from default", bg) } } func TestShallowerKeysAreResolvedFirst(t *testing.T) { // "syntax.keyword" must be able to inherit its background from "syntax", // whatever order the entries happen to come out of the TOML map in. th := mustParse(t, ` [colors] "syntax.keyword" = { bold = true } syntax = { fg = "lime", bg = "purple" } default = { fg = "white", bg = "black" } `) fg, bg, attrs := th.Style(KeySyntaxKeyword).Decompose() if fg != tcell.ColorLime || bg != tcell.ColorPurple { t.Errorf("Style(syntax.keyword) = %v on %v, want lime on purple", fg, bg) } if attrs&tcell.AttrBold == 0 { t.Error("the bold attribute of the deeper key was lost") } } func TestAttributesAreAllSupported(t *testing.T) { th := mustParse(t, ` [colors] default = { fg = "white", bg = "black", bold = true, underline = true, italic = true, reverse = true, dim = true, blink = true } `) _, _, attrs := th.Style(KeyDefault).Decompose() for name, want := range map[string]tcell.AttrMask{ "bold": tcell.AttrBold, "underline": tcell.AttrUnderline, "italic": tcell.AttrItalic, "reverse": tcell.AttrReverse, "dim": tcell.AttrDim, "blink": tcell.AttrBlink, } { if attrs&want == 0 { t.Errorf("the %s attribute was not applied", name) } } } func TestDefaultColourNamesLeaveItToTheTerminal(t *testing.T) { for _, name := range []string{"default", "-", ""} { th := mustParse(t, `[colors]`+"\n"+`default = { fg = "`+name+`", bg = "red" }`) fg, bg, _ := th.Style(KeyDefault).Decompose() if fg != tcell.ColorWhite { t.Errorf("fg = %q left the foreground at %v, want the inherited white", name, fg) } if bg != tcell.ColorRed { t.Errorf("fg = %q disturbed the background: %v", name, bg) } } } func TestParseRejectsAnUnknownColour(t *testing.T) { _, err := parseTheme([]byte(` [colors] default = { fg = "not-a-colour" } `)) if err == nil { t.Fatal("parseTheme() error = nil, want a failure naming the bad colour") } if !strings.Contains(err.Error(), "not-a-colour") { t.Errorf("parseTheme() error = %v, want it to name the offending colour", err) } } func TestParseRejectsInvalidTOML(t *testing.T) { if _, err := parseTheme([]byte(`this is not = = toml`)); err == nil { t.Fatal("parseTheme() error = nil, want a failure") } } func TestKeysAndDefines(t *testing.T) { th := mustParse(t, ` [colors] default = { fg = "white" } syntax = { fg = "lime" } `) if !th.Defines("syntax") { t.Error(`Defines("syntax") = false, want true`) } if th.Defines(KeySyntaxKeyword) { t.Error(`Defines("syntax.keyword") = true, want false — it is only reachable by fallback`) } keys := th.Keys() if len(keys) != 2 || keys[0] != "default" || keys[1] != "syntax" { t.Errorf("Keys() = %v, want [default syntax] sorted", keys) } } // mustParse parses a theme in a test, failing on error. func mustParse(t *testing.T, content string) *Theme { t.Helper() th, err := parseTheme([]byte(content)) if err != nil { t.Fatalf("parseTheme() error = %v", err) } return th } // userDir is the directory the wrappers below read the user's own themes from. // It is empty unless a test called useThemeDir, so a test that does not ask for // a theme directory gets the embedded themes alone — which is deterministic in // a way reading the real one never was. var userDir string // useThemeDir gives the test a theme directory of its own, and points the // wrappers at it for the length of the test. func useThemeDir(t *testing.T) string { t.Helper() dir := t.TempDir() userDir = dir t.Cleanup(func() { userDir = "" }) return dir } // load, available and parseTheme are the package's own functions with the test's // theme directory filled in, so that the tests below read as they did when the // directory came from the environment. func load(name string) (*Theme, error) { return Load(name, userDir) } func available() []string { return Available(userDir) } func parseTheme(data []byte) (*Theme, error) { return Parse(data, userDir) } // writeTheme creates a theme file in dir. func writeTheme(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name+".toml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("writing %s: %v", path, err) } return path } func TestLoadFindsEmbeddedThemes(t *testing.T) { useThemeDir(t) // an empty user directory, so only embedded themes exist th, err := load(DefaultName) if err != nil { t.Fatalf("load(%q) error = %v", DefaultName, err) } if th.Name() != "Turbo Classic" { t.Errorf("Name() = %q, want %q", th.Name(), "Turbo Classic") } } func TestEveryEmbeddedThemeParsesAndCoversEveryKey(t *testing.T) { useThemeDir(t) names := embeddedNames() if len(names) == 0 { t.Fatal("no themes are embedded in the binary") } for _, name := range names { t.Run(name, func(t *testing.T) { th, err := load(name) if err != nil { t.Fatalf("load() error = %v", err) } if th.Name() == "" { t.Error("the theme has no name") } for _, key := range allStyleKeys() { if !th.Defines(key) { t.Errorf("key %q is not set, so it can only be reached by fallback", key) } } }) } } func TestAUserThemeWinsOverAnEmbeddedOneOfTheSameName(t *testing.T) { dir := useThemeDir(t) writeTheme(t, dir, DefaultName, ` name = "Mine" [colors] default = { fg = "red", bg = "black" } `) th, err := load(DefaultName) if err != nil { t.Fatalf("load() error = %v", err) } if th.Name() != "Mine" { t.Errorf("Name() = %q, want the user's theme to win", th.Name()) } } func TestLoadOfAnUnknownThemeReportsErrNotFound(t *testing.T) { useThemeDir(t) _, err := load("no-such-theme") if !errors.Is(err, ErrNotFound) { t.Errorf("load() error = %v, want ErrNotFound", err) } } func TestLoadRejectsNamesThatWouldEscapeTheThemeDirectories(t *testing.T) { useThemeDir(t) for _, name := range []string{"", "../secret", "sub/theme", `..\other`} { if _, err := load(name); !errors.Is(err, ErrNotFound) { t.Errorf("load(%q) error = %v, want ErrNotFound", name, err) } } } func TestInheritsFillsInTheMissingKeys(t *testing.T) { dir := useThemeDir(t) writeTheme(t, dir, "child", ` name = "Child" inherits = "turbo-classic" [colors] "syntax.keyword" = { fg = "red" } `) th, err := load("child") if err != nil { t.Fatalf("load() error = %v", err) } fg, _, _ := th.Style(KeySyntaxKeyword).Decompose() if fg != tcell.ColorRed { t.Errorf("the child's own key was lost: foreground = %v", fg) } if !th.Defines(KeyMenuSelected) { t.Error("a key from the inherited theme is missing") } } func TestInheritsDetectsALoop(t *testing.T) { dir := useThemeDir(t) writeTheme(t, dir, "a", `inherits = "b"`+"\n[colors]") writeTheme(t, dir, "b", `inherits = "a"`+"\n[colors]") _, err := load("a") if err == nil { t.Fatal("load() error = nil, want a failure on the inheritance loop") } if !strings.Contains(err.Error(), "loop") { t.Errorf("load() error = %v, want it to mention the loop", err) } } func TestInheritsFromAnUnknownThemeFails(t *testing.T) { dir := useThemeDir(t) writeTheme(t, dir, "orphan", `inherits = "nowhere"`+"\n[colors]") if _, err := load("orphan"); err == nil { t.Fatal("load() error = nil, want a failure") } } func TestLoadFileNamesTheThemeAfterItsFile(t *testing.T) { dir := t.TempDir() path := writeTheme(t, dir, "unnamed", "[colors]\ndefault = { fg = \"white\" }") th, err := LoadFile(path, userDir) if err != nil { t.Fatalf("LoadFile(, userDir) error = %v", err) } if th.Name() != "unnamed" { t.Errorf("Name() = %q, want the file's base name", th.Name()) } } func TestLoadFileOfAMissingFileFails(t *testing.T) { if _, err := LoadFile(filepath.Join(t.TempDir(), "absent.toml"), userDir); err == nil { t.Fatal("LoadFile() error = nil, want a failure") } } func TestAvailableMergesUserAndEmbeddedThemes(t *testing.T) { dir := useThemeDir(t) writeTheme(t, dir, "mine", "[colors]") writeTheme(t, dir, DefaultName, "[colors]") // shadows an embedded one if err := os.WriteFile(filepath.Join(dir, "notes.txt"), nil, 0o644); err != nil { t.Fatalf("writing a decoy file: %v", err) } names := available() if !contains(names, "mine") { t.Errorf("available() = %v, want it to include the user's theme", names) } if !contains(names, "turbo-dark") { t.Errorf("available() = %v, want it to include the embedded themes", names) } if countOf(names, DefaultName) != 1 { t.Errorf("available() = %v, want %q listed once", names, DefaultName) } if contains(names, "notes") { t.Errorf("available() = %v, want non-TOML files ignored", names) } if !isSorted(names) { t.Errorf("available() = %v, want it sorted", names) } } func TestDefaultAlwaysReturnsAUsableTheme(t *testing.T) { useThemeDir(t) th := Default(userDir) if th == nil { t.Fatal("Default(userDir) = nil") } if _, bg, _ := th.Style(KeyEditorText).Decompose(); bg == tcell.ColorDefault { t.Error("the default theme leaves the editor background to the terminal") } } // allStyleKeys is every key the editor asks for. An embedded theme is expected // to set them all, so that adding a widget without theming it is caught here. func allStyleKeys() []string { return []string{ KeyDefault, KeyDesktop, KeyShadow, KeyMenuBar, KeyMenuItem, KeyMenuSelected, KeyMenuShortcut, KeyMenuDisabled, KeyWindowFrameActive, KeyWindowFrameInactive, KeyWindowTitleActive, KeyWindowTitleInactive, KeyWindowBody, KeyStatusBar, KeyStatusBarKey, KeyStatusBarHint, KeyScrollBar, KeyScrollBarThumb, KeyDialogFrame, KeyDialogBody, KeyDialogTitle, KeyDialogLabel, KeyButton, KeyButtonFocused, KeyButtonShortcut, KeyInput, KeyInputFocused, KeyInputSelection, KeyList, KeyListSelected, KeyListUnfocused, KeyCheckbox, KeyCheckboxFocused, KeyEditorText, KeyEditorSelection, KeyEditorLineNumber, KeyEditorCurrent, KeyEditorCursor, KeyTerminalText, KeyTerminalCursor, KeyTreeText, KeyTreeDirectory, KeyTreeSelected, KeyTreeUnfocused, KeySyntaxKeyword, KeySyntaxType, KeySyntaxBuiltin, KeySyntaxConstant, KeySyntaxFunction, KeySyntaxString, KeySyntaxChar, KeySyntaxNumber, KeySyntaxComment, KeySyntaxOperator, KeySyntaxPunctuation, KeySyntaxIdentifier, KeySyntaxHeading, KeySyntaxTag, KeySyntaxAttribute, KeySyntaxEmphasis, KeySyntaxLink, KeyCompletionFrame, KeyCompletionItem, KeyCompletionSelected, KeyCompletionDetail, KeyDiagnosticError, KeyDiagnosticWarning, KeyDiagnosticInfo, } } func contains(list []string, want string) bool { return countOf(list, want) > 0 } func countOf(list []string, want string) int { n := 0 for _, item := range list { if item == want { n++ } } return n } func isSorted(list []string) bool { for i := 1; i < len(list); i++ { if list[i-1] > list[i] { return false } } return true } func TestEveryEmbeddedThemeSetsEveryKeyItself(t *testing.T) { // Defines() is satisfied by inheritance, so a theme that omits a key still // passes TestEveryEmbeddedThemeParsesAndCoversEveryKey — while silently // showing a colour Turbo Classic chose for its blue background. On cream // or on espresso that colour can be unreadable, and nothing says so. // // A theme of your own may still inherit; that is what `inherits` is for. // The rule is narrower: a theme shipped inside the binary is one we are // answerable for, so it states its whole palette. useThemeDir(t) for _, name := range embeddedNames() { t.Run(name, func(t *testing.T) { body, err := embeddedThemes.ReadFile(embeddedDir + "/" + name + ".toml") if err != nil { t.Fatalf("reading the embedded theme: %v", err) } var own file if _, err := toml.Decode(string(body), &own); err != nil { t.Fatalf("parsing the embedded theme: %v", err) } for _, key := range allStyleKeys() { if _, set := own.Colors[key]; !set { t.Errorf("%q is left to inheritance, so this theme shows a colour chosen for another one", key) } } }) } } // --- names a theme used to answer to ---------------------------------------- // A theme's name is what somebody wrote in a settings file their whole team // shares. Renaming "monochrome" to "monochrome-dark" so the light one could // join it as a pair would have broken every such file; the alias is what stops // that, and these four tests are what stop the alias from quietly rotting. func TestARetiredThemeNameStillLoads(t *testing.T) { old, err := Load("monochrome", "") if err != nil { t.Fatalf(`Load("monochrome") error = %v; a name that used to work must go on working`, err) } current, err := Load("monochrome-dark", "") if err != nil { t.Fatalf(`Load("monochrome-dark") error = %v`, err) } if old.Name() != current.Name() { t.Errorf("the retired name loads %q, want the same theme as monochrome-dark, %q", old.Name(), current.Name()) } if got, want := old.Style(KeyEditorText), current.Style(KeyEditorText); got != want { t.Errorf("the retired name loads a different editor.text style: %v, want %v", got, want) } } func TestARetiredNameIsNotOfferedAsAThemeOfItsOwn(t *testing.T) { // Otherwise the theme dialog and -list-themes would show one theme twice, // under two names, and a reader would reasonably expect two themes. for _, name := range Available("") { if name == "monochrome" { t.Error(`Available() offers "monochrome", which is a retired name rather than a theme`) } } } func TestAUserThemeWinsOverARetiredName(t *testing.T) { // The rule for every other name is that a file in the user's directory // beats the embedded theme. A retired name must not be the exception, or // somebody's own monochrome.toml would stop being read the day the alias // was added. dir := t.TempDir() own := `name = "Mine" [colors] default = { fg = "#ff0000", bg = "#00ff00" } ` if err := os.WriteFile(filepath.Join(dir, "monochrome.toml"), []byte(own), 0o644); err != nil { t.Fatal(err) } th, err := Load("monochrome", dir) if err != nil { t.Fatalf("Load() error = %v", err) } if th.Name() != "Mine" { t.Errorf("Load(%q) = %q, want the user's own file", "monochrome", th.Name()) } } func TestEveryRetiredNamePointsAtAThemeWeShip(t *testing.T) { // An alias whose target is not shipped is an alias that has stopped // meaning anything, and it fails at the worst moment: when a user with an // old settings file starts the editor. // // The list is read back through Load rather than from the map, because the // map is unexported — which is the point: a caller cannot add one. for _, retired := range []string{"monochrome"} { if _, err := Load(retired, ""); err != nil { t.Errorf("the retired name %q no longer loads: %v", retired, err) } } } func TestBothMonochromesAreShippedAndAreDifferentThemes(t *testing.T) { dark, err := Load("monochrome-dark", "") if err != nil { t.Fatalf("Load(monochrome-dark) error = %v", err) } light, err := Load("monochrome-light", "") if err != nil { t.Fatalf("Load(monochrome-light) error = %v", err) } if dark.Style(KeyEditorText) == light.Style(KeyEditorText) { t.Error("the two monochromes draw the page identically; one of them is not doing its job") } }