turbo-editors/turbo-corepublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

theme_test.go · 600 lines · 17.2 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 17h ago1package theme
2
3import (
4 "errors"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/gdamore/tcell/v2"
11
12 "github.com/BurntSushi/toml"
13)
14
15func TestParseReadsNameAndDescription(t *testing.T) {
16 th := mustParse(t, `
17 name = "Example"
18 description = "A theme for the tests"
19 [colors]
20 default = { fg = "white", bg = "black" }
21 `)
22
23 if got := th.Name(); got != "Example" {
24 t.Errorf("Name() = %q, want %q", got, "Example")
25 }
26 if got := th.Description(); got != "A theme for the tests" {
27 t.Errorf("Description() = %q", got)
28 }
29}
30
31func TestStyleReadsTheColoursThatWereSet(t *testing.T) {
32 th := mustParse(t, `
33 [colors]
34 default = { fg = "white", bg = "black" }
35 "syntax.keyword" = { fg = "lime", bg = "navy", bold = true }
36 `)
37
38 fg, bg, attrs := th.Style(KeySyntaxKeyword).Decompose()
39 if fg != tcell.ColorLime {
40 t.Errorf("foreground = %v, want lime", fg)
41 }
42 if bg != tcell.ColorNavy {
43 t.Errorf("background = %v, want navy", bg)
44 }
45 if attrs&tcell.AttrBold == 0 {
46 t.Error("the bold attribute was not applied")
47 }
48}
49
50func TestStyleAcceptsHexColours(t *testing.T) {
51 th := mustParse(t, `
52 [colors]
53 default = { fg = "#ff8800", bg = "#001122" }
54 `)
55
56 fg, bg, _ := th.Style(KeyDefault).Decompose()
57 if got := fg.Hex(); got != 0xff8800 {
58 t.Errorf("foreground = %#06x, want 0xff8800", got)
59 }
60 if got := bg.Hex(); got != 0x001122 {
61 t.Errorf("background = %#06x, want 0x001122", got)
62 }
63}
64
65func TestStyleFallsBackAlongTheDots(t *testing.T) {
66 th := mustParse(t, `
67 [colors]
68 default = { fg = "white", bg = "black" }
69 syntax = { fg = "aqua" }
70 `)
71
72 tests := []struct {
73 key string
74 want tcell.Color
75 }{
76 {"syntax.keyword", tcell.ColorAqua}, // falls back to "syntax"
77 {"syntax.string.raw", tcell.ColorAqua}, // two levels up
78 {"completion.item", tcell.ColorWhite}, // nothing matches, so "default"
79 {"no.such.key.at.all", tcell.ColorWhite}, //
80 }
81
82 for _, tc := range tests {
83 fg, _, _ := th.Style(tc.key).Decompose()
84 if fg != tc.want {
85 t.Errorf("Style(%q) foreground = %v, want %v", tc.key, fg, tc.want)
86 }
87 }
88}
89
90func TestAnEntryInheritsTheHalfItLeavesOut(t *testing.T) {
91 th := mustParse(t, `
92 [colors]
93 default = { fg = "white", bg = "navy" }
94 "syntax.comment" = { fg = "gray" }
95 `)
96
97 fg, bg, _ := th.Style(KeySyntaxComment).Decompose()
98 if fg != tcell.ColorGray {
99 t.Errorf("foreground = %v, want gray", fg)
100 }
101 if bg != tcell.ColorNavy {
102 t.Errorf("background = %v, want the navy inherited from default", bg)
103 }
104}
105
106func TestShallowerKeysAreResolvedFirst(t *testing.T) {
107 // "syntax.keyword" must be able to inherit its background from "syntax",
108 // whatever order the entries happen to come out of the TOML map in.
109 th := mustParse(t, `
110 [colors]
111 "syntax.keyword" = { bold = true }
112 syntax = { fg = "lime", bg = "purple" }
113 default = { fg = "white", bg = "black" }
114 `)
115
116 fg, bg, attrs := th.Style(KeySyntaxKeyword).Decompose()
117 if fg != tcell.ColorLime || bg != tcell.ColorPurple {
118 t.Errorf("Style(syntax.keyword) = %v on %v, want lime on purple", fg, bg)
119 }
120 if attrs&tcell.AttrBold == 0 {
121 t.Error("the bold attribute of the deeper key was lost")
122 }
123}
124
125func TestAttributesAreAllSupported(t *testing.T) {
126 th := mustParse(t, `
127 [colors]
128 default = { fg = "white", bg = "black", bold = true, underline = true,
129 italic = true, reverse = true, dim = true, blink = true }
130 `)
131
132 _, _, attrs := th.Style(KeyDefault).Decompose()
133 for name, want := range map[string]tcell.AttrMask{
134 "bold": tcell.AttrBold,
135 "underline": tcell.AttrUnderline,
136 "italic": tcell.AttrItalic,
137 "reverse": tcell.AttrReverse,
138 "dim": tcell.AttrDim,
139 "blink": tcell.AttrBlink,
140 } {
141 if attrs&want == 0 {
142 t.Errorf("the %s attribute was not applied", name)
143 }
144 }
145}
146
147func TestDefaultColourNamesLeaveItToTheTerminal(t *testing.T) {
148 for _, name := range []string{"default", "-", ""} {
149 th := mustParse(t, `[colors]`+"\n"+`default = { fg = "`+name+`", bg = "red" }`)
150
151 fg, bg, _ := th.Style(KeyDefault).Decompose()
152 if fg != tcell.ColorWhite {
153 t.Errorf("fg = %q left the foreground at %v, want the inherited white", name, fg)
154 }
155 if bg != tcell.ColorRed {
156 t.Errorf("fg = %q disturbed the background: %v", name, bg)
157 }
158 }
159}
160
161func TestParseRejectsAnUnknownColour(t *testing.T) {
162 _, err := parseTheme([]byte(`
163 [colors]
164 default = { fg = "not-a-colour" }
165 `))
166
167 if err == nil {
168 t.Fatal("parseTheme() error = nil, want a failure naming the bad colour")
169 }
170 if !strings.Contains(err.Error(), "not-a-colour") {
171 t.Errorf("parseTheme() error = %v, want it to name the offending colour", err)
172 }
173}
174
175func TestParseRejectsInvalidTOML(t *testing.T) {
176 if _, err := parseTheme([]byte(`this is not = = toml`)); err == nil {
177 t.Fatal("parseTheme() error = nil, want a failure")
178 }
179}
180
181func TestKeysAndDefines(t *testing.T) {
182 th := mustParse(t, `
183 [colors]
184 default = { fg = "white" }
185 syntax = { fg = "lime" }
186 `)
187
188 if !th.Defines("syntax") {
189 t.Error(`Defines("syntax") = false, want true`)
190 }
191 if th.Defines(KeySyntaxKeyword) {
192 t.Error(`Defines("syntax.keyword") = true, want false — it is only reachable by fallback`)
193 }
194
195 keys := th.Keys()
196 if len(keys) != 2 || keys[0] != "default" || keys[1] != "syntax" {
197 t.Errorf("Keys() = %v, want [default syntax] sorted", keys)
198 }
199}
200
201// mustParse parses a theme in a test, failing on error.
202func mustParse(t *testing.T, content string) *Theme {
203 t.Helper()
204 th, err := parseTheme([]byte(content))
205 if err != nil {
206 t.Fatalf("parseTheme() error = %v", err)
207 }
208 return th
209}
210
211// userDir is the directory the wrappers below read the user's own themes from.
212// It is empty unless a test called useThemeDir, so a test that does not ask for
213// a theme directory gets the embedded themes alone — which is deterministic in
214// a way reading the real one never was.
215var userDir string
216
217// useThemeDir gives the test a theme directory of its own, and points the
218// wrappers at it for the length of the test.
219func useThemeDir(t *testing.T) string {
220 t.Helper()
221 dir := t.TempDir()
222 userDir = dir
223 t.Cleanup(func() { userDir = "" })
224 return dir
225}
226
227// load, available and parseTheme are the package's own functions with the test's
228// theme directory filled in, so that the tests below read as they did when the
229// directory came from the environment.
230func load(name string) (*Theme, error) { return Load(name, userDir) }
231
232func available() []string { return Available(userDir) }
233
234func parseTheme(data []byte) (*Theme, error) { return Parse(data, userDir) }
235
236// writeTheme creates a theme file in dir.
237func writeTheme(t *testing.T, dir, name, content string) string {
238 t.Helper()
239 path := filepath.Join(dir, name+".toml")
240 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
241 t.Fatalf("writing %s: %v", path, err)
242 }
243 return path
244}
245
246func TestLoadFindsEmbeddedThemes(t *testing.T) {
247 useThemeDir(t) // an empty user directory, so only embedded themes exist
248
249 th, err := load(DefaultName)
250 if err != nil {
251 t.Fatalf("load(%q) error = %v", DefaultName, err)
252 }
253 if th.Name() != "Turbo Classic" {
254 t.Errorf("Name() = %q, want %q", th.Name(), "Turbo Classic")
255 }
256}
257
258func TestEveryEmbeddedThemeParsesAndCoversEveryKey(t *testing.T) {
259 useThemeDir(t)
260
261 names := embeddedNames()
262 if len(names) == 0 {
263 t.Fatal("no themes are embedded in the binary")
264 }
265
266 for _, name := range names {
267 t.Run(name, func(t *testing.T) {
268 th, err := load(name)
269 if err != nil {
270 t.Fatalf("load() error = %v", err)
271 }
272 if th.Name() == "" {
273 t.Error("the theme has no name")
274 }
275 for _, key := range allStyleKeys() {
276 if !th.Defines(key) {
277 t.Errorf("key %q is not set, so it can only be reached by fallback", key)
278 }
279 }
280 })
281 }
282}
283
284func TestAUserThemeWinsOverAnEmbeddedOneOfTheSameName(t *testing.T) {
285 dir := useThemeDir(t)
286 writeTheme(t, dir, DefaultName, `
287 name = "Mine"
288 [colors]
289 default = { fg = "red", bg = "black" }
290 `)
291
292 th, err := load(DefaultName)
293 if err != nil {
294 t.Fatalf("load() error = %v", err)
295 }
296 if th.Name() != "Mine" {
297 t.Errorf("Name() = %q, want the user's theme to win", th.Name())
298 }
299}
300
301func TestLoadOfAnUnknownThemeReportsErrNotFound(t *testing.T) {
302 useThemeDir(t)
303
304 _, err := load("no-such-theme")
305
306 if !errors.Is(err, ErrNotFound) {
307 t.Errorf("load() error = %v, want ErrNotFound", err)
308 }
309}
310
311func TestLoadRejectsNamesThatWouldEscapeTheThemeDirectories(t *testing.T) {
312 useThemeDir(t)
313
314 for _, name := range []string{"", "../secret", "sub/theme", `..\other`} {
315 if _, err := load(name); !errors.Is(err, ErrNotFound) {
316 t.Errorf("load(%q) error = %v, want ErrNotFound", name, err)
317 }
318 }
319}
320
321func TestInheritsFillsInTheMissingKeys(t *testing.T) {
322 dir := useThemeDir(t)
323 writeTheme(t, dir, "child", `
324 name = "Child"
325 inherits = "turbo-classic"
326 [colors]
327 "syntax.keyword" = { fg = "red" }
328 `)
329
330 th, err := load("child")
331 if err != nil {
332 t.Fatalf("load() error = %v", err)
333 }
334
335 fg, _, _ := th.Style(KeySyntaxKeyword).Decompose()
336 if fg != tcell.ColorRed {
337 t.Errorf("the child's own key was lost: foreground = %v", fg)
338 }
339 if !th.Defines(KeyMenuSelected) {
340 t.Error("a key from the inherited theme is missing")
341 }
342}
343
344func TestInheritsDetectsALoop(t *testing.T) {
345 dir := useThemeDir(t)
346 writeTheme(t, dir, "a", `inherits = "b"`+"\n[colors]")
347 writeTheme(t, dir, "b", `inherits = "a"`+"\n[colors]")
348
349 _, err := load("a")
350
351 if err == nil {
352 t.Fatal("load() error = nil, want a failure on the inheritance loop")
353 }
354 if !strings.Contains(err.Error(), "loop") {
355 t.Errorf("load() error = %v, want it to mention the loop", err)
356 }
357}
358
359func TestInheritsFromAnUnknownThemeFails(t *testing.T) {
360 dir := useThemeDir(t)
361 writeTheme(t, dir, "orphan", `inherits = "nowhere"`+"\n[colors]")
362
363 if _, err := load("orphan"); err == nil {
364 t.Fatal("load() error = nil, want a failure")
365 }
366}
367
368func TestLoadFileNamesTheThemeAfterItsFile(t *testing.T) {
369 dir := t.TempDir()
370 path := writeTheme(t, dir, "unnamed", "[colors]\ndefault = { fg = \"white\" }")
371
372 th, err := LoadFile(path, userDir)
373 if err != nil {
374 t.Fatalf("LoadFile(, userDir) error = %v", err)
375 }
376 if th.Name() != "unnamed" {
377 t.Errorf("Name() = %q, want the file's base name", th.Name())
378 }
379}
380
381func TestLoadFileOfAMissingFileFails(t *testing.T) {
382 if _, err := LoadFile(filepath.Join(t.TempDir(), "absent.toml"), userDir); err == nil {
383 t.Fatal("LoadFile() error = nil, want a failure")
384 }
385}
386
387func TestAvailableMergesUserAndEmbeddedThemes(t *testing.T) {
388 dir := useThemeDir(t)
389 writeTheme(t, dir, "mine", "[colors]")
390 writeTheme(t, dir, DefaultName, "[colors]") // shadows an embedded one
391 if err := os.WriteFile(filepath.Join(dir, "notes.txt"), nil, 0o644); err != nil {
392 t.Fatalf("writing a decoy file: %v", err)
393 }
394
395 names := available()
396
397 if !contains(names, "mine") {
398 t.Errorf("available() = %v, want it to include the user's theme", names)
399 }
400 if !contains(names, "turbo-dark") {
401 t.Errorf("available() = %v, want it to include the embedded themes", names)
402 }
403 if countOf(names, DefaultName) != 1 {
404 t.Errorf("available() = %v, want %q listed once", names, DefaultName)
405 }
406 if contains(names, "notes") {
407 t.Errorf("available() = %v, want non-TOML files ignored", names)
408 }
409 if !isSorted(names) {
410 t.Errorf("available() = %v, want it sorted", names)
411 }
412}
413
414func TestDefaultAlwaysReturnsAUsableTheme(t *testing.T) {
415 useThemeDir(t)
416
417 th := Default(userDir)
418
419 if th == nil {
420 t.Fatal("Default(userDir) = nil")
421 }
422 if _, bg, _ := th.Style(KeyEditorText).Decompose(); bg == tcell.ColorDefault {
423 t.Error("the default theme leaves the editor background to the terminal")
424 }
425}
426
427// allStyleKeys is every key the editor asks for. An embedded theme is expected
428// to set them all, so that adding a widget without theming it is caught here.
429func allStyleKeys() []string {
430 return []string{
431 KeyDefault, KeyDesktop, KeyShadow,
432 KeyMenuBar, KeyMenuItem, KeyMenuSelected, KeyMenuShortcut, KeyMenuDisabled,
433 KeyWindowFrameActive, KeyWindowFrameInactive,
434 KeyWindowTitleActive, KeyWindowTitleInactive, KeyWindowBody,
435 KeyStatusBar, KeyStatusBarKey, KeyStatusBarHint,
436 KeyScrollBar, KeyScrollBarThumb,
437 KeyDialogFrame, KeyDialogBody, KeyDialogTitle, KeyDialogLabel,
438 KeyButton, KeyButtonFocused, KeyButtonShortcut,
439 KeyInput, KeyInputFocused, KeyInputSelection,
440 KeyList, KeyListSelected, KeyListUnfocused,
441 KeyCheckbox, KeyCheckboxFocused,
442 KeyEditorText, KeyEditorSelection, KeyEditorLineNumber, KeyEditorCurrent, KeyEditorCursor,
443 KeyTerminalText, KeyTerminalCursor,
444 KeyTreeText,
445 KeyTreeDirectory,
446 KeyTreeSelected,
447 KeyTreeUnfocused,
448 KeySyntaxKeyword, KeySyntaxType, KeySyntaxBuiltin, KeySyntaxConstant,
449 KeySyntaxFunction, KeySyntaxString, KeySyntaxChar, KeySyntaxNumber,
450 KeySyntaxComment, KeySyntaxOperator, KeySyntaxPunctuation, KeySyntaxIdentifier,
451 KeySyntaxHeading,
452 KeySyntaxTag,
453 KeySyntaxAttribute,
454 KeySyntaxEmphasis,
455 KeySyntaxLink,
456 KeyCompletionFrame, KeyCompletionItem, KeyCompletionSelected, KeyCompletionDetail,
457 KeyDiagnosticError, KeyDiagnosticWarning, KeyDiagnosticInfo,
458 }
459}
460
461func contains(list []string, want string) bool {
462 return countOf(list, want) > 0
463}
464
465func countOf(list []string, want string) int {
466 n := 0
467 for _, item := range list {
468 if item == want {
469 n++
470 }
471 }
472 return n
473}
474
475func isSorted(list []string) bool {
476 for i := 1; i < len(list); i++ {
477 if list[i-1] > list[i] {
478 return false
479 }
480 }
481 return true
482}
483
484func TestEveryEmbeddedThemeSetsEveryKeyItself(t *testing.T) {
485 // Defines() is satisfied by inheritance, so a theme that omits a key still
486 // passes TestEveryEmbeddedThemeParsesAndCoversEveryKey — while silently
487 // showing a colour Turbo Classic chose for its blue background. On cream
488 // or on espresso that colour can be unreadable, and nothing says so.
489 //
490 // A theme of your own may still inherit; that is what `inherits` is for.
491 // The rule is narrower: a theme shipped inside the binary is one we are
492 // answerable for, so it states its whole palette.
493 useThemeDir(t)
494
495 for _, name := range embeddedNames() {
496 t.Run(name, func(t *testing.T) {
497 body, err := embeddedThemes.ReadFile(embeddedDir + "/" + name + ".toml")
498 if err != nil {
499 t.Fatalf("reading the embedded theme: %v", err)
500 }
501
502 var own file
503 if _, err := toml.Decode(string(body), &own); err != nil {
504 t.Fatalf("parsing the embedded theme: %v", err)
505 }
506 for _, key := range allStyleKeys() {
507 if _, set := own.Colors[key]; !set {
508 t.Errorf("%q is left to inheritance, so this theme shows a colour chosen for another one", key)
509 }
510 }
511 })
512 }
513}
514
515// --- names a theme used to answer to ----------------------------------------
516
517// A theme's name is what somebody wrote in a settings file their whole team
518// shares. Renaming "monochrome" to "monochrome-dark" so the light one could
519// join it as a pair would have broken every such file; the alias is what stops
520// that, and these four tests are what stop the alias from quietly rotting.
521
522func TestARetiredThemeNameStillLoads(t *testing.T) {
523 old, err := Load("monochrome", "")
524 if err != nil {
525 t.Fatalf(`Load("monochrome") error = %v; a name that used to work must go on working`, err)
526 }
527 current, err := Load("monochrome-dark", "")
528 if err != nil {
529 t.Fatalf(`Load("monochrome-dark") error = %v`, err)
530 }
531
532 if old.Name() != current.Name() {
533 t.Errorf("the retired name loads %q, want the same theme as monochrome-dark, %q", old.Name(), current.Name())
534 }
535 if got, want := old.Style(KeyEditorText), current.Style(KeyEditorText); got != want {
536 t.Errorf("the retired name loads a different editor.text style: %v, want %v", got, want)
537 }
538}
539
540func TestARetiredNameIsNotOfferedAsAThemeOfItsOwn(t *testing.T) {
541 // Otherwise the theme dialog and -list-themes would show one theme twice,
542 // under two names, and a reader would reasonably expect two themes.
543 for _, name := range Available("") {
544 if name == "monochrome" {
545 t.Error(`Available() offers "monochrome", which is a retired name rather than a theme`)
546 }
547 }
548}
549
550func TestAUserThemeWinsOverARetiredName(t *testing.T) {
551 // The rule for every other name is that a file in the user's directory
552 // beats the embedded theme. A retired name must not be the exception, or
553 // somebody's own monochrome.toml would stop being read the day the alias
554 // was added.
555 dir := t.TempDir()
556 own := `name = "Mine"
557[colors]
558default = { fg = "#ff0000", bg = "#00ff00" }
559`
560 if err := os.WriteFile(filepath.Join(dir, "monochrome.toml"), []byte(own), 0o644); err != nil {
561 t.Fatal(err)
562 }
563
564 th, err := Load("monochrome", dir)
565 if err != nil {
566 t.Fatalf("Load() error = %v", err)
567 }
568 if th.Name() != "Mine" {
569 t.Errorf("Load(%q) = %q, want the user's own file", "monochrome", th.Name())
570 }
571}
572
573func TestEveryRetiredNamePointsAtAThemeWeShip(t *testing.T) {
574 // An alias whose target is not shipped is an alias that has stopped
575 // meaning anything, and it fails at the worst moment: when a user with an
576 // old settings file starts the editor.
577 //
578 // The list is read back through Load rather than from the map, because the
579 // map is unexported — which is the point: a caller cannot add one.
580 for _, retired := range []string{"monochrome"} {
581 if _, err := Load(retired, ""); err != nil {
582 t.Errorf("the retired name %q no longer loads: %v", retired, err)
583 }
584 }
585}
586
587func TestBothMonochromesAreShippedAndAreDifferentThemes(t *testing.T) {
588 dark, err := Load("monochrome-dark", "")
589 if err != nil {
590 t.Fatalf("Load(monochrome-dark) error = %v", err)
591 }
592 light, err := Load("monochrome-light", "")
593 if err != nil {
594 t.Fatalf("Load(monochrome-light) error = %v", err)
595 }
596
597 if dark.Style(KeyEditorText) == light.Style(KeyEditorText) {
598 t.Error("the two monochromes draw the page identically; one of them is not doing its job")
599 }
600}