cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / ui / theme / scheme_test.go
scheme_test.go 9.2K
1package theme23import (4 "fmt"5 "image/color"6 "os"7 "path/filepath"8 "strings"9 "testing"1011 "charm.land/lipgloss/v2"12 "github.com/charmbracelet/x/ansi"13)1415func TestBuiltins(t *testing.T) {16 bs := Builtins()17 if len(bs) < 300 {18 t.Fatalf("only %d built-in themes", len(bs))19 }20 if bs[0].Name != ClassicName {21 t.Errorf("first is %q", bs[0].Name)22 }23 for _, name := range []string{"Dracula", "Catppuccin Mocha", "Builtin Solarized Light", "nord", "GruvboxDark", "TokyoNight"} {24 if _, err := Lookup(name, ""); err != nil {25 t.Error(err)26 }27 }28 p, err := Lookup("tokyo-night", "")29 if err != nil || p.Name != "TokyoNight" {30 t.Errorf("loose match: %q %v", p.Name, err)31 }32 if p, err := Lookup("tokyonight", ""); err != nil || p.Name != "tokyonight" {33 t.Errorf("exact name: %q %v", p.Name, err)34 }35 if _, err := Lookup("No Such Theme", ""); err == nil || !strings.Contains(err.Error(), "012 config themes") {36 t.Errorf("unknown: %v", err)37 }38}3940// requireContrast fails when fg on bg is below min.41func requireContrast(t *testing.T, theme, what string, fg, bg color.Color, min float64) {42 t.Helper()43 if isNoColor(fg) || isNoColor(bg) {44 t.Errorf("%s: %s has no color (fg %v, bg %v)", theme, what, fg, bg)45 return46 }47 if r := contrast(fg, bg); r < min-0.01 {48 t.Errorf("%s: %s contrast %.2f < %.1f", theme, what, r, min)49 }50}5152// Every built-in scheme, run through the role derivation, keeps text53// readable: on cells, on the bars, in the selection and the headers, and54// every other role at its minimum. The high-contrast schemes are held to55// WCAG AAA.56func TestEveryThemeReadable(t *testing.T) {57 for _, p := range Builtins() {58 requireReadable(t, p.Name, FromPalette(p))59 }60}6162// requireReadable checks a scheme's roles against its minimums.63func requireReadable(t *testing.T, name string, th Theme) {64 t.Helper()65 lv := th.levels66 screenBg, text := th.Screen.GetBackground(), th.Screen.GetForeground()67 requireContrast(t, name, "cell text", text, screenBg, lv.text)68 for _, bar := range []struct {69 name string70 s lipgloss.Style71 }{{"menu bar", th.MenuBarRow}, {"status bar", th.StatusBarRow}, {"column header row", th.ColumnHeaderRow}} {72 requireContrast(t, name, bar.name+" text", bar.s.GetForeground(), bar.s.GetBackground(), lv.text)73 if c := contrast(bar.s.GetBackground(), screenBg); c < minDistinct-0.01 {74 t.Errorf("%s: %s doesn't stand out from the screen (%.2f)", name, bar.name, c)75 }76 }77 for _, r := range []struct {78 name string79 s lipgloss.Style80 }{81 {"selection", th.Selection}, {"header", th.Header}, {"row header", th.RowHeader},82 {"active header", th.HeaderActive}, {"selected header", th.HeaderSel}, {"pointer", th.Pointer},83 {"key chip", th.KeyChip}, {"menu selection", th.MenuSelected}, {"indicator", th.Indicator},84 } {85 requireContrast(t, name, r.name, r.s.GetForeground(), r.s.GetBackground(), lv.text)86 }87 // Muted text and hints on the bars, where the status line shows them.88 requireContrast(t, name, "muted text", th.Muted.GetForeground(), screenBg, lv.secondary)89 eachRole(&th, func(role string, s *lipgloss.Style) {90 fg, bg := s.GetForeground(), s.GetBackground()91 if role == "SeriesBg" || isNoColor(fg) {92 return93 }94 if isNoColor(bg) {95 bg = screenBg96 }97 requireContrast(t, name, role, fg, bg, minContrast(role, lv))98 })99}100101// The high-contrast theme follows the terminal's background, and its102// schemes are AAA: 7:1 for every text role, hints included.103func TestHighContrast(t *testing.T) {104 for _, dark := range []bool{true, false} {105 th, err := Resolve("high-contrast", dark, "")106 if err != nil || th.Name != HighContrast || th.Palette == nil || th.Palette.Dark != dark || th.levels != aaa {107 t.Fatalf("dark %v: %q %v", dark, th.Name, err)108 }109 requireReadable(t, th.Palette.Name, th)110 for _, role := range []string{"Hint", "Muted", "Warning", "Error", "ErrorCell", "Link", "Spilled", "NoteMark"} {111 if minContrast(role, th.levels) < 7 {112 t.Errorf("%s held to %.1f", role, minContrast(role, th.levels))113 }114 }115 }116 for _, name := range []string{"High Contrast Dark", "high-contrast-light", "HIGH-CONTRAST"} {117 if _, err := Resolve(name, true, ""); err != nil {118 t.Error(err)119 }120 }121 if l := List(""); l[1].Name != HighContrast {122 t.Errorf("list: %v", l[:3])123 }124}125126func TestSchemeUsesPaletteColors(t *testing.T) {127 p, _ := Lookup("Dracula", "")128 th := FromPalette(p)129 if !th.Palette.Dark || th.Name != "Dracula" {130 t.Errorf("%+v", th.Name)131 }132 // Every role is in true color: no ANSI index is left to the terminal.133 eachRole(&th, func(role string, s *lipgloss.Style) {134 for _, c := range []color.Color{s.GetForeground(), s.GetBackground(), s.GetUnderlineColor()} {135 switch c.(type) {136 case ansi.BasicColor, ansi.IndexedColor:137 t.Errorf("%s keeps ANSI color %v", role, c)138 }139 }140 })141 // The selection is in reverse video: its background is its foreground.142 if bg := th.Selection.GetForeground(); fmt.Sprint(bg) != fmt.Sprint(p.Selection) || !th.Selection.GetReverse() {143 t.Errorf("selection %v, scheme's %v", bg, p.Selection)144 }145 // The terminal theme keeps ANSI colors and no bands.146 term := New(true)147 if _, ok := term.Pointer.GetBackground().(ansi.BasicColor); !ok {148 t.Error("terminal theme lost its ANSI colors")149 }150 if !isNoColor(term.MenuBarRow.GetBackground()) || !isNoColor(term.Screen.GetBackground()) {151 t.Error("terminal theme has bands")152 }153}154155func TestReadThemeFiles(t *testing.T) {156 dir := t.TempDir()157 ghostty := "# Ghostty\npalette = 0=#1d1f21\npalette = 4=81a2be\npalette = 200=#ffffff\nbackground = #fafafa\nforeground = #333\ncursor-color = #000000\nselection-background = #cccccc\nfont-family = x\n"158 kitty := "# kitty\ncolor0 #000000\ncolor12 #5c5cff\nbackground #101010\nforeground #e0e0e0\nselection_background #444444\ncursor_text_color background\n"159 vhs := `{"name": "Mine", "black": "#000000", "red": "#ff0000", "green": "#00ff00", "yellow": "#ffff00", "blue": "#0000ff",160"purple": "#ff00ff", "cyan": "#00ffff", "white": "#eeeeee", "brightBlack": "#555555", "brightRed": "#ff5555",161"brightGreen": "#55ff55", "brightYellow": "#ffff55", "brightBlue": "#5555ff", "brightPurple": "#ff55ff",162"brightCyan": "#55ffff", "brightWhite": "#ffffff", "background": "#202020", "foreground": "#dddddd",163"selectionBackground": "#333333", "meta": {"isDark": true}}`164 os.WriteFile(filepath.Join(dir, "My Light"), []byte(ghostty), 0o644)165 os.WriteFile(filepath.Join(dir, "kitty.conf"), []byte(kitty), 0o644)166 os.WriteFile(filepath.Join(dir, "mine.json"), []byte(vhs), 0o644)167 os.WriteFile(filepath.Join(dir, "broken"), []byte("palette = 1=#ff0000\n"), 0o644)168 os.WriteFile(filepath.Join(dir, "Dracula"), []byte(ghostty), 0o644)169170 p, err := Lookup("My Light", dir)171 if err != nil || p.Dark || fmt.Sprint(p.ANSI[4]) != fmt.Sprint(color.RGBA{0x81, 0xa2, 0xbe, 0xff}) ||172 fmt.Sprint(p.Foreground) != fmt.Sprint(color.RGBA{0x33, 0x33, 0x33, 0xff}) || p.Selection == nil {173 t.Errorf("ghostty: %+v %v", p, err)174 }175 if fmt.Sprint(p.ANSI[1]) != fmt.Sprint(xterm[1]) {176 t.Error("slots left out should be xterm's")177 }178 p, err = Lookup("kitty", dir)179 if err != nil || !p.Dark || fmt.Sprint(p.ANSI[12]) != fmt.Sprint(color.RGBA{0x5c, 0x5c, 0xff, 0xff}) {180 t.Errorf("kitty: %+v %v", p, err)181 }182 p, err = Lookup("mine", dir)183 if err != nil || p.Name != "Mine" || fmt.Sprint(p.ANSI[5]) != fmt.Sprint(color.RGBA{0xff, 0, 0xff, 0xff}) || p.Selection == nil {184 t.Errorf("json: %+v %v", p, err)185 }186 if _, err := Lookup("broken", dir); err == nil || !strings.Contains(err.Error(), "background and foreground") {187 t.Errorf("broken: %v", err)188 }189 p, _ = Lookup("dracula", dir)190 if p.File == "" {191 t.Error("a user file should hide the built-in of the same name")192 }193 names := map[string]Entry{}194 list := List(dir)195 for _, e := range list {196 names[e.Name] = e197 }198 if list[0].Name != Terminal || !names["My Light"].User || names["My Light"].Dark || !names["Mine"].User || !names["Nord"].Dark && names["nord"].Name == "" {199 t.Errorf("list: %v", list[:6])200 }201 if _, ok := names["broken"]; ok {202 t.Error("a file that doesn't parse is listed")203 }204 if _, err := Lookup("../etc", dir); err == nil {205 t.Error("path in a name")206 }207}208209func TestFill(t *testing.T) {210 band := lipgloss.NewStyle().Background(lipgloss.Color("#102030")).Foreground(lipgloss.Color("#eeeeee"))211 chip := lipgloss.NewStyle().Background(lipgloss.Color("#ff0000")).Render("F1")212 line := "File " + chip + " x" + lipgloss.NewStyle().Foreground(lipgloss.Red).Render("warn") + " end"213 got := Fill(line, 30, band)214 if w := ansi.StringWidth(got); w != 30 {215 t.Errorf("width %d", w)216 }217 if ansi.Strip(got) != ansi.Strip(line)+strings.Repeat(" ", 30-ansi.StringWidth(line)) {218 t.Errorf("text changed: %q", ansi.Strip(got))219 }220 // The band's background comes back after the chip's reset.221 after := got[strings.Index(got, "F1"):]222 if !strings.Contains(after, "48;2;16;32;48") {223 t.Errorf("band not restored after a reset: %q", got)224 }225 if Fill(line, 30, lipgloss.NewStyle()) != line {226 t.Error("an empty band changed the line")227 }228 // Params of 38;2;r;g;b aren't mistaken for resets.229 bg, fg := sgrResets(params(t, "\x1b[38;2;0;39;49m"))230 if bg || fg {231 t.Errorf("true color read as reset: bg %v fg %v", bg, fg)232 }233 bg, fg = sgrResets(params(t, "\x1b[0;31m"))234 if !bg || fg {235 t.Errorf("0;31: bg %v fg %v", bg, fg)236 }237}238239func params(t *testing.T, seq string) ansi.Params {240 p := ansi.NewParser()241 _, _, _, _ = ansi.DecodeSequence(seq, 0, p)242 return append(ansi.Params(nil), p.Params()...)243}