cat / 012
clone

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

cat/module-path
012 / internal / ui / prefs_test.go
prefs_test.go 7.9K
1package ui23import (4 "context"5 "errors"6 "os"7 "path/filepath"8 "strings"9 "testing"1011 tea "charm.land/bubbletea/v2"12 uv "github.com/charmbracelet/ultraviolet"13 "github.com/charmbracelet/x/ansi"1415 "src.rest/cat/012/internal/config"16 "src.rest/cat/012/internal/jev"17 "src.rest/cat/012/internal/keyring"18)1920// configured returns a model with settings from a config file in a21// temporary directory and a credential store in memory.22func configured(t *testing.T, file string) (*Model, string, *keyring.Memory) {23 t.Helper()24 path := filepath.Join(t.TempDir(), "config")25 if file != "" {26 os.WriteFile(path, []byte(file), 0o600)27 }28 load := func() *config.Config { return config.Load(path, func(string) string { return "" }, nil) }29 store := &keyring.Memory{}30 m := newModel()31 m.Configure(Settings{Config: load(), Reload: load, Keys: store,32 Connect: func(key string) (jev.Client, error) { return &fakeJEV{}, nil }})33 return m, path, store34}3536func TestThemeFromConfig(t *testing.T) {37 m, _, _ := configured(t, "theme = Dracula\nbogus = 1\n")38 if m.th.Name != "Dracula" {39 t.Errorf("theme %q", m.th.Name)40 }41 m, _, _ = configured(t, "theme = light:Builtin Solarized Light,dark:Dracula\n")42 if m.th.Name != "Dracula" {43 t.Errorf("dark terminal: %q", m.th.Name)44 }45 send(m, tea.BackgroundColorMsg{Color: ansi.White})46 if m.th.Name != "Builtin Solarized Light" {47 t.Errorf("light terminal: %q", m.th.Name)48 }49 m, _, _ = configured(t, "theme = No Such Theme\n")50 if m.th.Name != "terminal" || !strings.Contains(line(m, contextLine), `no theme "No Such Theme"`) {51 t.Errorf("unknown theme: %q, %q", m.th.Name, line(m, contextLine))52 }53}5455// With a color scheme every line is drawn out to the terminal's edge,56// at odd widths too, and with an overlay open.57func TestBarsFillTheWidth(t *testing.T) {58 m, _, _ := configured(t, "theme = 1-2-3 Classic\n")59 for _, w := range []int{61, 80, 97} {60 send(m, tea.WindowSizeMsg{Width: w, Height: 17})61 checkFilled(t, m, "no overlay")62 press(t, m, "<alt+f>")63 checkFilled(t, m, "menu open")64 press(t, m, "<esc>")65 }66}6768// checkFilled fails unless every line is the terminal's width and every69// cell has a background: the bars' or the scheme's.70func checkFilled(t *testing.T, m *Model, when string) {71 t.Helper()72 content := m.View().Content73 lines := strings.Split(content, "\n")74 if len(lines) != m.height {75 t.Fatalf("%s: %d lines, height %d", when, len(lines), m.height)76 }77 for i, l := range lines {78 if got := ansi.StringWidth(l); got != m.width {79 t.Errorf("%s, width %d: line %d is %d wide", when, m.width, i, got)80 }81 }82 scr := uv.NewScreenBuffer(m.width, m.height)83 uv.NewStyledString(content).Draw(scr, scr.Bounds())84 for y := range m.height {85 for x := range m.width {86 if c := scr.CellAt(x, y); c != nil && c.Width > 0 && c.Style.Bg == nil {87 t.Fatalf("%s, width %d: no background at %d,%d", when, m.width, x, y)88 }89 }90 }91}9293func TestTerminalThemeUnchanged(t *testing.T) {94 m, _, _ := configured(t, "")95 if strings.Contains(m.View().Content, "48;2;") {96 t.Error("the terminal theme uses true color")97 }98}99100func TestThemePicker(t *testing.T) {101 m, path, _ := configured(t, "# mine\ntheme = Nord\n")102 press(t, m, "<alt+f>")103 openSettingsItem(t, m, "Theme")104 if m.th.Name != "nord" || !strings.Contains(screen(m), "│ nord") {105 t.Fatalf("picker opens on the current theme: %q\n%s", m.th.Name, screen(m))106 }107 press(t, m, "Dracula")108 if m.th.Name != "Dracula" {109 t.Errorf("preview: %q", m.th.Name)110 }111 press(t, m, "<esc>")112 if m.th.Name != "nord" || m.overlay != nil {113 t.Errorf("Esc restores: %q", m.th.Name)114 }115 press(t, m, "<alt+f>")116 openSettingsItem(t, m, "Theme")117 press(t, m, "1-2-3 Classic", "<enter>")118 if m.th.Name != "1-2-3 Classic" || !strings.Contains(line(m, contextLine), "saved in") {119 t.Errorf("Enter keeps it: %q, %q", m.th.Name, line(m, contextLine))120 }121 data, _ := os.ReadFile(path)122 if string(data) != "# mine\ntheme = 1-2-3 Classic\n" {123 t.Errorf("config file: %q", data)124 }125}126127// openSettingsItem opens File > Settings and runs the item called title.128func openSettingsItem(t *testing.T, m *Model, title string) {129 t.Helper()130 for range 30 {131 if selectedTitle(m) == "Settings" {132 break133 }134 press(t, m, "<down>")135 }136 press(t, m, "<right>")137 for range 10 {138 if selectedTitle(m) == title {139 press(t, m, "<enter>")140 return141 }142 press(t, m, "<down>")143 }144 t.Fatalf("no %s in Settings:\n%s", title, screen(m))145}146147// selectedTitle is the highlighted item of the open menu.148func selectedTitle(m *Model) string {149 mo, ok := m.overlay.(*menuOverlay)150 if !ok {151 return ""152 }153 l := mo.top()154 if l.sel < 0 {155 return ""156 }157 return l.items[l.sel].label()158}159160func TestAPIKeyPrompt(t *testing.T) {161 const key = "ts-secret-9876"162 m, _, store := configured(t, "")163 m.runCommand("settings.jev_key")164 press(t, m, key)165 ctx := line(m, contextLine)166 if strings.Contains(screen(m), key) || !strings.Contains(ctx, "TypeSafe API key: "+strings.Repeat("•", len(key))) {167 t.Fatalf("key shown: %q", ctx)168 }169 if x, y, _ := m.cursorPos(); y != contextLine || x != len("TypeSafe API key: ")+len(key) {170 t.Errorf("cursor at %d,%d", x, y)171 }172 press(t, m, "<enter>")173 if got, _ := store.Get(context.Background()); got != key {174 t.Errorf("stored %q", got)175 }176 if m.jev == nil || !strings.Contains(line(m, contextLine), "Key saved and checked; it's in the test store") {177 t.Errorf("JEV not on: %q", line(m, contextLine))178 }179 if strings.Contains(screen(m), key) || m.line.Text() != "" {180 t.Error("the key is still around")181 }182183 store.Err = errors.New("keychain locked")184 m.runCommand("settings.jev_key")185 press(t, m, "x", "<enter>")186 if !strings.Contains(screen(m), "Couldn't save the API key: keychain locked") {187 t.Errorf("failure not shown:\n%s", screen(m))188 }189}190191func TestKeyNeedsAStore(t *testing.T) {192 m := newModel()193 if commands["settings.jev_key"].available(m) || commands["settings.config_reload"].available(m) || commands["settings.config_edit"].available(m) {194 t.Error("settings that need a config or store are available without one")195 }196}197198func TestReloadConfig(t *testing.T) {199 m, path, _ := configured(t, "theme = nord\n")200 os.WriteFile(path, []byte("theme = Dracula\nchart-images = false\nnotifications = false\n"), 0o600)201 m.runCommand("settings.config_reload")202 if m.th.Name != "Dracula" || !m.term.noImages || !m.term.noNotify || !strings.Contains(line(m, contextLine), "Config reloaded") {203 t.Errorf("reload: %q images off %v, %q", m.th.Name, m.term.noImages, line(m, contextLine))204 }205 os.WriteFile(path, []byte("wat = 1\n"), 0o600)206 m.runCommand("settings.config_reload")207 if !strings.Contains(line(m, contextLine), "problems") {208 t.Errorf("warnings: %q", line(m, contextLine))209 }210 if m.th.Name != "terminal" {211 t.Errorf("theme back to the default: %q", m.th.Name)212 }213}214215func TestChartImagesOff(t *testing.T) {216 m, _, _ := configured(t, "chart-images = false\n")217 m.term.kitty = true218 if m.term.images() || m.term.chartOptions().Image {219 t.Error("images drawn although chart-images = false")220 }221}222223// The screen stays blank until the terminal says its background, so a224// light terminal never shows a frame in the dark theme; a terminal that225// doesn't say it answers the device attributes asked after it, and one226// that answers nothing is waited for only so long.227func TestScreenWaitsForTheBackground(t *testing.T) {228 blank := func(m *Model) bool { return strings.TrimSpace(screen(m)) == "" }229 m, _, _ := configured(t, "theme = light:Builtin Solarized Light,dark:Dracula\n")230 m.term.awaitBackground()231 if !blank(m) {232 t.Fatalf("drawn before the background is known:\n%s", screen(m))233 }234 send(m, tea.BackgroundColorMsg{Color: ansi.White})235 if blank(m) || m.th.Name != "Builtin Solarized Light" {236 t.Errorf("after a light background: theme %q, screen\n%s", m.th.Name, screen(m))237 }238239 m = newModel()240 m.term.awaitBackground()241 send(m, uv.PrimaryDeviceAttributesEvent{62, 22})242 if blank(m) {243 t.Error("still blank after the device attributes")244 }245 m = newModel()246 m.term.awaitBackground()247 send(m, bgWaitedMsg{})248 if blank(m) {249 t.Error("still blank after waiting")250 }251}