main 5041353

source_test.go RawHistoryFolder

1package noctalia
2
3import (
4	"context"
5	"os"
6	"path/filepath"
7	"runtime"
8	"testing"
9
10	"github.com/bnema/dumber/internal/domain/entity"
11)
12
13func TestParseColorsJSONValidActivePalette(t *testing.T) {
14	theme, err := ParseColorsJSON(noctaliaColorsJSON())
15	if err != nil {
16		t.Fatalf("ParseColorsJSON() error = %v", err)
17	}
18	if theme.Provider != "noctalia" {
19		t.Fatalf("Provider = %q, want noctalia", theme.Provider)
20	}
21	if theme.Name == "" {
22		t.Fatal("Name is empty, want source metadata")
23	}
24	want := entity.ColorPalette{
25		Background:     "#000000",
26		Surface:        "#0c0c0c",
27		SurfaceVariant: "#282828",
28		Text:           "#ffffff",
29		Muted:          "#a0a0a0",
30		Accent:         "#ffc799",
31		Border:         "#505050",
32	}
33	if theme.LightPalette == nil || *theme.LightPalette != want {
34		t.Fatalf("LightPalette = %+v, want %+v", theme.LightPalette, want)
35	}
36	if theme.DarkPalette == nil || *theme.DarkPalette != want {
37		t.Fatalf("DarkPalette = %+v, want active Noctalia palette %+v", theme.DarkPalette, want)
38	}
39}
40
41func TestParseColorsJSONDocsExample(t *testing.T) {
42	path := docsExamplePath(t, "noctalia-colors.json")
43	data, err := os.ReadFile(path)
44	if err != nil {
45		t.Fatalf("ReadFile(%q) error = %v", path, err)
46	}
47
48	theme, err := ParseColorsJSON(data)
49	if err != nil {
50		t.Fatalf("ParseColorsJSON(docs example) error = %v", err)
51	}
52	if theme == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" {
53		t.Fatalf("parsed docs example = %+v", theme)
54	}
55}
56
57func TestParseColorsJSONFallsBackWhenNuanceFieldsAreAbsent(t *testing.T) {
58	theme, err := ParseColorsJSON([]byte(`{
59		"mPrimary":"#ffc799",
60		"mSurface":"#0c0c0c",
61		"mOnSurface":"#ffffff",
62		"mSurfaceVariant":"#1c1c1c",
63		"mOnSurfaceVariant":"#a0a0a0",
64		"mOutline":"#505050"
65	}`))
66	if err != nil {
67		t.Fatalf("ParseColorsJSON() error = %v", err)
68	}
69	if theme.DarkPalette.Background != "#0c0c0c" {
70		t.Fatalf("Background = %q, want mSurface fallback", theme.DarkPalette.Background)
71	}
72	if theme.DarkPalette.SurfaceVariant != "#1c1c1c" {
73		t.Fatalf("SurfaceVariant = %q, want mSurfaceVariant fallback", theme.DarkPalette.SurfaceVariant)
74	}
75}
76
77func TestParseColorsJSONRequiresMappedFields(t *testing.T) {
78	cases := map[string]string{
79		"missing surface":         `{"mPrimary":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
80		"missing surface variant": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
81		"missing text":            `{"mPrimary":"#111111","mSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
82		"missing muted":           `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOutline":"#555555"}`,
83		"missing accent":          `{"mSurface":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
84		"missing border":          `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOnSurfaceVariant":"#555555"}`,
85	}
86	for name, input := range cases {
87		t.Run(name, func(t *testing.T) {
88			if _, err := ParseColorsJSON([]byte(input)); err == nil {
89				t.Fatal("ParseColorsJSON() error = nil, want missing field error")
90			}
91		})
92	}
93}
94
95func TestParseColorsJSONInvalidHexRejection(t *testing.T) {
96	if _, err := ParseColorsJSON([]byte(`{"mPrimary":"ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050"}`)); err == nil {
97		t.Fatal("ParseColorsJSON() error = nil, want invalid hex error")
98	}
99}
100
101func TestParseColorsJSONInvalidOptionalNuanceHexRejection(t *testing.T) {
102	cases := map[string]string{
103		"invalid shadow": `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mShadow":"000000"}`,
104		"invalid hover":  `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mHover":"282828"}`,
105	}
106	for name, input := range cases {
107		t.Run(name, func(t *testing.T) {
108			if _, err := ParseColorsJSON([]byte(input)); err == nil {
109				t.Fatal("ParseColorsJSON() error = nil, want optional hex validation error")
110			}
111		})
112	}
113}
114
115func TestParseColorsJSONRejectsTrailingTokens(t *testing.T) {
116	if _, err := ParseColorsJSON(append(noctaliaColorsJSON(), []byte(` {}`)...)); err == nil {
117		t.Fatal("ParseColorsJSON() error = nil, want trailing data error")
118	}
119}
120
121func TestParseDumberJSONValidFullPalette(t *testing.T) {
122	json := []byte(`{
123		"name":"Night Theme",
124		"source":"noctalia-template",
125		"mode":"dark",
126		"light":{"background":"#FFFFFF","surface":"#F0F0F0","surface_variant":"#E0E0E0","text":"#111111","muted":"#777777","accent":"#3366CC","border":"#CCCCCC"},
127		"dark":{"background":"#000000","surface":"#101010","surface_variant":"#202020","text":"#EEEEEE","muted":"#999999","accent":"#AABBCC","border":"#333333"}
128	}`)
129
130	theme, err := ParseDumberJSON(json)
131	if err != nil {
132		t.Fatalf("ParseDumberJSON() error = %v", err)
133	}
134	if theme.Provider != "noctalia" {
135		t.Fatalf("Provider = %q, want noctalia", theme.Provider)
136	}
137	if theme.Name != "Night Theme" {
138		t.Fatalf("Name = %q, want metadata name", theme.Name)
139	}
140	if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Accent != "#AABBCC" {
141		t.Fatalf("parsed palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
142	}
143}
144
145func TestParseDumberJSONDocsExample(t *testing.T) {
146	path := docsExamplePath(t, "noctalia-dumber-theme.json")
147	data, err := os.ReadFile(path)
148	if err != nil {
149		t.Fatalf("ReadFile(%q) error = %v", path, err)
150	}
151
152	theme, err := ParseDumberJSON(data)
153	if err != nil {
154		t.Fatalf("ParseDumberJSON(docs example) error = %v", err)
155	}
156	if theme == nil || theme.LightPalette == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" {
157		t.Fatalf("parsed docs example = %+v", theme)
158	}
159}
160
161func TestParseDumberJSONPartialPalette(t *testing.T) {
162	theme, err := ParseDumberJSON([]byte(`{
163		"light":{"background":"#FFFFFF","accent":"#112233"},
164		"dark":{"text":"#EEEEEE"}
165	}`))
166	if err != nil {
167		t.Fatalf("ParseDumberJSON() error = %v", err)
168	}
169	if theme.LightPalette.Surface != "" || theme.DarkPalette.Background != "" {
170		t.Fatalf("missing fields should remain empty: %+v / %+v", theme.LightPalette, theme.DarkPalette)
171	}
172}
173
174func TestParseDumberJSONRequiresLightAndDarkObjects(t *testing.T) {
175	cases := map[string]string{
176		"missing light": `{"dark":{}}`,
177		"missing dark":  `{"light":{}}`,
178	}
179	for name, input := range cases {
180		t.Run(name, func(t *testing.T) {
181			if _, err := ParseDumberJSON([]byte(input)); err == nil {
182				t.Fatal("ParseDumberJSON() error = nil, want error")
183			}
184		})
185	}
186}
187
188func TestParseDumberJSONMetadataNameFallsBackToSource(t *testing.T) {
189	theme, err := ParseDumberJSON([]byte(`{"source":"noctalia-template","light":{},"dark":{}}`))
190	if err != nil {
191		t.Fatalf("ParseDumberJSON() error = %v", err)
192	}
193	if theme.Name != "noctalia-template" {
194		t.Fatalf("Name = %q, want source fallback", theme.Name)
195	}
196}
197
198func TestParseDumberJSONValidHexAcceptance(t *testing.T) {
199	_, err := ParseDumberJSON([]byte(`{"light":{"accent":"#aBc123"},"dark":{"accent":"#ABC123"}}`))
200	if err != nil {
201		t.Fatalf("ParseDumberJSON() error = %v", err)
202	}
203}
204
205func TestParseDumberJSONInvalidHexRejection(t *testing.T) {
206	if _, err := ParseDumberJSON([]byte(`{"light":{"accent":"ABC123"},"dark":{}}`)); err == nil {
207		t.Fatal("ParseDumberJSON() error = nil, want invalid hex error")
208	}
209}
210
211func TestParseDumberJSONMalformedRejection(t *testing.T) {
212	if _, err := ParseDumberJSON([]byte(`{"light":`)); err == nil {
213		t.Fatal("ParseDumberJSON() error = nil, want malformed JSON error")
214	}
215}
216
217func TestParseDumberJSONRejectsTrailingTokens(t *testing.T) {
218	if _, err := ParseDumberJSON([]byte(`{"light":{},"dark":{}} {}`)); err == nil {
219		t.Fatal("ParseDumberJSON() error = nil, want trailing data error")
220	}
221}
222
223func TestExpandPath(t *testing.T) {
224	t.Setenv("NOCTALIA_THEME_TEST", "theme.json")
225	home := t.TempDir()
226	t.Setenv("HOME", home)
227
228	got, err := ExpandPath("~/$NOCTALIA_THEME_TEST")
229	if err != nil {
230		t.Fatalf("ExpandPath() error = %v", err)
231	}
232	want := filepath.Join(home, "theme.json")
233	if got != want {
234		t.Fatalf("ExpandPath() = %q, want %q", got, want)
235	}
236}
237
238func TestFileSourceIdentityUsesExpandedPath(t *testing.T) {
239	home := t.TempDir()
240	t.Setenv("HOME", home)
241
242	path := filepath.Join(home, ".config", "noctalia", "colors.json")
243	source := NewFileSourceFromConfig(entity.ExternalThemeConfig{
244		Enabled:  true,
245		Provider: "noctalia",
246		Format:   "colors-json",
247		Path:     path,
248	})
249	absoluteIdentity := source.ExternalThemeIdentity()
250
251	source.Configure(entity.ExternalThemeConfig{
252		Enabled:  true,
253		Provider: "noctalia",
254		Format:   "colors-json",
255		Path:     "~/.config/noctalia/colors.json",
256	})
257	if source.ExternalThemeIdentity() != absoluteIdentity {
258		t.Fatalf("ExternalThemeIdentity() = %q, want %q", source.ExternalThemeIdentity(), absoluteIdentity)
259	}
260}
261
262func TestFileSourceDisabledBehavior(t *testing.T) {
263	source := NewFileSource(false, "/does/not/exist")
264	if source.IsEnabled() {
265		t.Fatal("IsEnabled() = true, want false")
266	}
267	theme, err := source.Get(context.Background())
268	if err != nil {
269		t.Fatalf("Get() error = %v", err)
270	}
271	if theme != nil {
272		t.Fatalf("Get() theme = %+v, want nil", theme)
273	}
274}
275
276func TestFileSourceReadsColorsJSONByDefault(t *testing.T) {
277	path := filepath.Join(t.TempDir(), "colors.json")
278	if err := os.WriteFile(path, noctaliaColorsJSON(), 0o600); err != nil {
279		t.Fatalf("WriteFile() error = %v", err)
280	}
281
282	theme, err := NewFileSource(true, path).Get(context.Background())
283	if err != nil {
284		t.Fatalf("Get() error = %v", err)
285	}
286	if theme.LightPalette.Accent != "#ffc799" || theme.DarkPalette.Background != "#000000" || theme.DarkPalette.SurfaceVariant != "#282828" {
287		t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
288	}
289}
290
291func TestFileSourceReadsDumberJSONWhenConfigured(t *testing.T) {
292	path := filepath.Join(t.TempDir(), "theme.json")
293	if err := os.WriteFile(path, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil {
294		t.Fatalf("WriteFile() error = %v", err)
295	}
296
297	source := NewFileSourceFromConfig(entity.ExternalThemeConfig{
298		Enabled:  true,
299		Provider: "noctalia",
300		Format:   "dumber-json",
301		Path:     path,
302	})
303	theme, err := source.Get(context.Background())
304	if err != nil {
305		t.Fatalf("Get() error = %v", err)
306	}
307	if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Background != "#000000" {
308		t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
309	}
310}
311
312func TestFileSourceConfigureUpdatesSettings(t *testing.T) {
313	colorsPath := filepath.Join(t.TempDir(), "colors.json")
314	if err := os.WriteFile(colorsPath, noctaliaColorsJSON(), 0o600); err != nil {
315		t.Fatalf("WriteFile(colors) error = %v", err)
316	}
317	dumberPath := filepath.Join(t.TempDir(), "theme.json")
318	if err := os.WriteFile(dumberPath, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil {
319		t.Fatalf("WriteFile(dumber) error = %v", err)
320	}
321
322	source := NewFileSourceFromConfig(entity.ExternalThemeConfig{})
323	if source.IsEnabled() {
324		t.Fatal("IsEnabled() = true, want false")
325	}
326
327	source.Configure(entity.ExternalThemeConfig{
328		Enabled:  true,
329		Provider: " NOCTALIA ",
330		Format:   " COLORS-JSON ",
331		Path:     colorsPath,
332	})
333	if !source.IsEnabled() {
334		t.Fatal("IsEnabled() = false for colors-json, want true")
335	}
336	if _, err := source.Get(context.Background()); err != nil {
337		t.Fatalf("Get() colors-json after Configure error = %v", err)
338	}
339
340	source.Configure(entity.ExternalThemeConfig{
341		Enabled:  true,
342		Provider: "noctalia",
343		Format:   "dumber-json",
344		Path:     dumberPath,
345	})
346	if !source.IsEnabled() {
347		t.Fatal("IsEnabled() = false for dumber-json, want true")
348	}
349	if _, err := source.Get(context.Background()); err != nil {
350		t.Fatalf("Get() dumber-json after Configure error = %v", err)
351	}
352
353	source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "other", Format: "colors-json", Path: colorsPath})
354	if source.IsEnabled() {
355		t.Fatal("IsEnabled() = true for unsupported provider, want false")
356	}
357	source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "noctalia", Format: "toml", Path: colorsPath})
358	if source.IsEnabled() {
359		t.Fatal("IsEnabled() = true for unsupported format, want false")
360	}
361}
362
363func docsExamplePath(t *testing.T, name string) string {
364	t.Helper()
365	_, file, _, ok := runtime.Caller(0)
366	if !ok {
367		t.Fatal("runtime.Caller(0) failed")
368	}
369	return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "docs", "examples", name)
370}
371
372func noctaliaColorsJSON() []byte {
373	return []byte(`{
374		"mError": "#ff8080",
375		"mHover": "#282828",
376		"mOnError": "#000000",
377		"mOnHover": "#ffffff",
378		"mOnPrimary": "#000000",
379		"mOnSecondary": "#000000",
380		"mOnSurface": "#ffffff",
381		"mOnSurfaceVariant": "#a0a0a0",
382		"mOnTertiary": "#000000",
383		"mOutline": "#505050",
384		"mPrimary": "#ffc799",
385		"mSecondary": "#99ffe4",
386		"mShadow": "#000000",
387		"mSurface": "#0c0c0c",
388		"mSurfaceVariant": "#1c1c1c",
389		"mTertiary": "#fbadff"
390	}`)
391}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close