返回 DeepSeek-Reasonix
theme_test.go
根目录 / internal / cli / theme_test.go
1 package cli
2
3 import (
4 "reflect"
5 "strings"
6 "testing"
7
8 "github.com/charmbracelet/colorprofile"
9
10 "reasonix/internal/control"
11
12 "charm.land/bubbles/v2/textarea"
13 tea "charm.land/bubbletea/v2"
14 "charm.land/lipgloss/v2"
15 )
16
17 func TestConfigureCLIThemeSwitchesModeAndDefaultStyle(t *testing.T) {
18 t.Setenv("REASONIX_THEME", "")
19 t.Setenv("REASONIX_THEME_STYLE", "")
20 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
21 activeColorProfile = colorprofile.ANSI256
22
23 configureCLITheme("light")
24 if activeCLITheme.name != "light" || activeCLITheme.style != "sandstone" {
25 t.Fatalf("light theme = %s/%s, want light/sandstone", activeCLITheme.name, activeCLITheme.style)
26 }
27 if got := accent("x"); !strings.HasPrefix(got, "\033[38;5;173m") {
28 t.Fatalf("light default accent = %q, want sandstone xterm 173", got)
29 }
30
31 configureCLITheme("dark")
32 if activeCLITheme.name != "dark" || activeCLITheme.style != "graphite" {
33 t.Fatalf("dark theme = %s/%s, want dark/graphite", activeCLITheme.name, activeCLITheme.style)
34 }
35 if got := accent("x"); !strings.HasPrefix(got, ansiAccent) {
36 t.Fatalf("dark accent = %q, want %q", got, ansiAccent)
37 }
38 }
39
40 func TestConfigureCLIThemeStyleOverride(t *testing.T) {
41 t.Setenv("REASONIX_THEME", "")
42 t.Setenv("REASONIX_THEME_STYLE", "")
43 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
44 activeColorProfile = colorprofile.ANSI256
45
46 configureCLIThemeWithStyle("dark", "aurora")
47 if activeCLITheme.name != "dark" || activeCLITheme.style != "aurora" {
48 t.Fatalf("theme = %s/%s, want dark/aurora", activeCLITheme.name, activeCLITheme.style)
49 }
50 if got := accent("x"); !strings.HasPrefix(got, "\033[38;5;79m") {
51 t.Fatalf("aurora accent = %q, want xterm 79", got)
52 }
53
54 configureCLITheme("glacier")
55 if activeCLITheme.name != "light" || activeCLITheme.style != "glacier" {
56 t.Fatalf("theme style command resolved %s/%s, want light/glacier", activeCLITheme.name, activeCLITheme.style)
57 }
58 }
59
60 func TestConfigureCLIThemeHonorsEnvOverride(t *testing.T) {
61 t.Setenv("REASONIX_THEME", "ember")
62 t.Setenv("REASONIX_THEME_STYLE", "")
63 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
64 activeColorProfile = colorprofile.ANSI256
65
66 configureCLIThemeWithStyle("light", "glacier")
67 if activeCLITheme.name != "dark" || activeCLITheme.style != "ember" {
68 t.Fatalf("REASONIX_THEME override resolved %s/%s, want dark/ember", activeCLITheme.name, activeCLITheme.style)
69 }
70 }
71
72 func TestThemeRendersAtProfileFidelity(t *testing.T) {
73 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
74 configureCLIThemeWithStyle("dark", "graphite")
75
76 activeColorProfile = colorprofile.TrueColor
77 if got := accent("x"); !strings.HasPrefix(got, "\033[38;2;217;119;87m") {
78 t.Fatalf("truecolor accent = %q, want 24-bit #d97757", got)
79 }
80
81 activeColorProfile = colorprofile.ANSI256
82 if got := accent("x"); !strings.HasPrefix(got, ansiAccent) {
83 t.Fatalf("256-colour accent = %q, want %q", got, ansiAccent)
84 }
85
86 activeColorProfile = colorprofile.NoTTY
87 if got := accent("x"); got != "x" {
88 t.Fatalf("no-tty accent = %q, want unstyled text", got)
89 }
90 }
91
92 func TestThemeArgCompletion(t *testing.T) {
93 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
94 activeColorProfile = colorprofile.ANSI256
95 configureCLIThemeWithStyle("dark", "graphite")
96
97 m := newTestChatTUI()
98 items, _, ok := m.slashArgItems("/theme ")
99 if !ok || len(items) == 0 {
100 t.Fatalf("/theme arg completion should offer themes, ok=%v n=%d", ok, len(items))
101 }
102 if !hasLabel(items, "auto") || !hasLabel(items, "graphite") || !hasLabel(items, "aurora") {
103 t.Fatalf("/theme completion missing expected themes: %v", labels(items))
104 }
105 }
106
107 func TestRunThemeSubcommandSwitchesAccentAndTextarea(t *testing.T) {
108 t.Setenv("REASONIX_THEME", "")
109 t.Setenv("REASONIX_THEME_STYLE", "")
110 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
111 activeColorProfile = colorprofile.ANSI256
112 configureCLIThemeWithStyle("dark", "graphite")
113
114 m := newTestChatTUI()
115 m.ctrl = control.New(control.Options{})
116 if cmd := m.runThemeSubcommand("/theme aurora"); cmd == nil {
117 t.Fatal("a real theme change should start the sweep")
118 }
119 if activeCLITheme.name != "dark" || activeCLITheme.style != "aurora" {
120 t.Fatalf("current theme = %s/%s, want dark/aurora", activeCLITheme.name, activeCLITheme.style)
121 }
122 if got := accent("x"); !strings.HasPrefix(got, "\033[38;5;79m") {
123 t.Fatalf("accent = %q, want aurora xterm color", got)
124 }
125 if m.input.Styles().Cursor.Color == nil {
126 t.Fatal("textarea cursor color was not refreshed")
127 }
128 }
129
130 func TestParseOSC11Response(t *testing.T) {
131 for _, tt := range []struct {
132 name string
133 in string
134 want terminalRGB
135 light bool
136 }{
137 {
138 name: "black-rgb",
139 in: "\x1b]11;rgb:0000/0000/0000\a",
140 want: terminalRGB{0, 0, 0},
141 light: false,
142 },
143 {
144 name: "white-rgb",
145 in: "\x1b]11;rgb:ffff/ffff/ffff\x1b\\",
146 want: terminalRGB{255, 255, 255},
147 light: true,
148 },
149 {
150 name: "hex",
151 in: "\x1b]11;#f8f8f8\a",
152 want: terminalRGB{248, 248, 248},
153 light: true,
154 },
155 } {
156 t.Run(tt.name, func(t *testing.T) {
157 got, ok := parseOSC11Response(tt.in)
158 if !ok {
159 t.Fatalf("parseOSC11Response returned !ok")
160 }
161 if got != tt.want {
162 t.Fatalf("rgb = %+v, want %+v", got, tt.want)
163 }
164 if got.looksLight() != tt.light {
165 t.Fatalf("looksLight = %v, want %v", got.looksLight(), tt.light)
166 }
167 })
168 }
169 }
170
171 func TestAutoThemeFallsBackToColorFGBG(t *testing.T) {
172 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
173 activeColorProfile = colorprofile.NoTTY
174
175 t.Setenv("COLORFGBG", "0;15")
176 if got := resolveCLITheme("auto").name; got != "light" {
177 t.Fatalf("COLORFGBG light fallback resolved %q, want light", got)
178 }
179
180 t.Setenv("COLORFGBG", "15;0")
181 if got := resolveCLITheme("auto").name; got != "dark" {
182 t.Fatalf("COLORFGBG dark fallback resolved %q, want dark", got)
183 }
184 }
185
186 func TestApplyTextareaThemeClearsCursorLineBackground(t *testing.T) {
187 t.Setenv("REASONIX_THEME", "")
188 t.Setenv("REASONIX_THEME_STYLE", "")
189 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
190 activeColorProfile = colorprofile.ANSI256
191
192 for _, mode := range []string{"dark", "light", "auto"} {
193 t.Run(mode, func(t *testing.T) {
194 if mode == "auto" {
195 t.Setenv("COLORFGBG", "0;15")
196 } else {
197 t.Setenv("COLORFGBG", "")
198 }
199 configureCLITheme(mode)
200
201 ti := textarea.New()
202 applyTextareaTheme(&ti)
203 styles := ti.Styles()
204 emptyBG := lipgloss.NewStyle().GetBackground()
205
206 if bg := styles.Focused.CursorLine.GetBackground(); !reflect.DeepEqual(bg, emptyBG) {
207 t.Fatalf("focused cursor line background = %v, want empty", bg)
208 }
209 if bg := styles.Blurred.CursorLine.GetBackground(); !reflect.DeepEqual(bg, emptyBG) {
210 t.Fatalf("blurred cursor line background = %v, want empty", bg)
211 }
212 if bg := styles.Focused.EndOfBuffer.GetBackground(); !reflect.DeepEqual(bg, emptyBG) {
213 t.Fatalf("end-of-buffer background = %v, want empty", bg)
214 }
215 if styles.Cursor.Color == nil {
216 t.Fatal("cursor color is nil with color enabled")
217 }
218 })
219 }
220 }
221
222 func TestApplyTextareaThemeHonorsCursorShape(t *testing.T) {
223 t.Setenv("REASONIX_THEME", "")
224 t.Setenv("REASONIX_THEME_STYLE", "")
225 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
226 prevShape := cliCursorShape
227 defer func() { cliCursorShape = prevShape }()
228 activeColorProfile = colorprofile.ANSI256
229 configureCLITheme("dark")
230
231 for _, tt := range []struct {
232 name string
233 in string
234 want tea.CursorShape
235 }{
236 {name: "default", in: "", want: tea.CursorBar},
237 {name: "underline", in: "underline", want: tea.CursorUnderline},
238 {name: "block", in: "block", want: tea.CursorBlock},
239 {name: "bar", in: "bar", want: tea.CursorBar},
240 {name: "unknown", in: "unknown", want: tea.CursorBar},
241 } {
242 t.Run(tt.name, func(t *testing.T) {
243 cliCursorShape = tt.in
244 ti := textarea.New()
245 applyTextareaTheme(&ti)
246 if got := ti.Styles().Cursor.Shape; got != tt.want {
247 t.Fatalf("cursor shape = %v, want %v", got, tt.want)
248 }
249 })
250 }
251 }
252
253 func TestComposerBorderAndCursorTrackThemeAccent(t *testing.T) {
254 t.Setenv("REASONIX_THEME", "")
255 t.Setenv("REASONIX_THEME_STYLE", "")
256 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
257 activeColorProfile = colorprofile.ANSI256
258
259 for _, theme := range cliThemeStyles {
260 t.Run(theme.name, func(t *testing.T) {
261 configureCLITheme(theme.name)
262 want := themeLipColor(activeCLITheme.accent)
263 if got := inputBoxStyle.GetBorderTopForeground(); !reflect.DeepEqual(got, want) {
264 t.Fatalf("composer top border color = %v, want theme accent %v", got, want)
265 }
266 if got := inputBoxStyle.GetBorderBottomForeground(); !reflect.DeepEqual(got, want) {
267 t.Fatalf("composer bottom border color = %v, want theme accent %v", got, want)
268 }
269
270 ti := textarea.New()
271 applyTextareaTheme(&ti)
272 if got := ti.Styles().Cursor.Color; !reflect.DeepEqual(got, want) {
273 t.Fatalf("composer cursor color = %v, want theme accent %v", got, want)
274 }
275 })
276 }
277
278 activeColorProfile = colorprofile.NoTTY
279 configureCLITheme("dark")
280 empty := lipgloss.NewStyle().GetBorderTopForeground()
281 if got := inputBoxStyle.GetBorderTopForeground(); !reflect.DeepEqual(got, empty) {
282 t.Fatalf("NO_COLOR composer top border color = %v, want no color", got)
283 }
284 if got := inputBoxStyle.GetBorderBottomForeground(); !reflect.DeepEqual(got, empty) {
285 t.Fatalf("NO_COLOR composer bottom border color = %v, want no color", got)
286 }
287 }
288
289 // TestRuntimeAutoThemeDoesNotProbeStdin guards the fix for a runtime `/theme auto`
290 // that live-probed the terminal (raw-mode stdin read) while the TUI owned stdin,
291 // racing bubbletea's input reader. The switch must resolve via the COLORFGBG
292 // fallback instead, never invoking the probe.
293 func TestRuntimeAutoThemeDoesNotProbeStdin(t *testing.T) {
294 t.Setenv("REASONIX_THEME", "")
295 t.Setenv("REASONIX_THEME_STYLE", "")
296 t.Setenv("COLORFGBG", "15;0")
297 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
298 activeColorProfile = colorprofile.ANSI256
299
300 probed := false
301 defer func(prev func() (terminalRGB, bool)) { terminalProbe = prev }(terminalProbe)
302 terminalProbe = func() (terminalRGB, bool) {
303 probed = true
304 return terminalRGB{255, 255, 255}, true
305 }
306
307 if got := setCLIThemeMode("auto").name; got != "dark" {
308 t.Fatalf("auto with COLORFGBG=15;0 resolved %q, want dark", got)
309 }
310 if probed {
311 t.Fatal("runtime /theme auto probed the terminal while the TUI owns stdin")
312 }
313
314 withTerminalProbe(func() {
315 if got := resolveCLITheme("auto").name; got != "light" {
316 t.Fatalf("opted-in probe resolved %q, want light", got)
317 }
318 })
319 if !probed {
320 t.Fatal("withTerminalProbe should be the one path that reaches the terminal")
321 }
322 }
323
324 func restoreThemeForTest(prevColor colorprofile.Profile, prevTheme cliPalette) {
325 activeColorProfile = prevColor
326 activeCLITheme = prevTheme
327 refreshCLIStyles()
328 }
329
329 lines GO