1package noctalia
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/domain/entity"
11)
12
13func TestFileWatcherStartStopAndContextCancellation(t *testing.T) {
14 path := writeThemeFile(t, t.TempDir(), "theme.json")
15 watcher := newFastWatcher()
16
17 ctx, cancel := context.WithCancel(context.Background())
18 if err := watcher.Start(ctx, watcherConfig(path), func() {}); err != nil {
19 t.Fatalf("Start() error = %v", err)
20 }
21 if !watcher.isRunningForTest() {
22 t.Fatal("watcher is not running after Start")
23 }
24 if err := watcher.Stop(); err != nil {
25 t.Fatalf("Stop() error = %v", err)
26 }
27 if err := watcher.Stop(); err != nil {
28 t.Fatalf("second Stop() error = %v", err)
29 }
30
31 if err := watcher.Start(ctx, watcherConfig(path), func() {}); err != nil {
32 t.Fatalf("Start() after Stop error = %v", err)
33 }
34 cancel()
35 waitUntil(t, func() bool { return !watcher.isRunningForTest() })
36}
37
38func TestFileWatcherStartSamePathDoesNotDuplicateWatcher(t *testing.T) {
39 dir := t.TempDir()
40 path := writeThemeFile(t, dir, "theme.json")
41 watcher := newFastWatcher()
42 changes := make(chan struct{}, 4)
43
44 if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
45 t.Fatalf("Start() error = %v", err)
46 }
47 if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
48 t.Fatalf("second Start() same path error = %v", err)
49 }
50
51 writeThemeFile(t, dir, "theme.json")
52 waitForChange(t, changes)
53 assertNoChange(t, changes, 60*time.Millisecond)
54}
55
56func TestFileWatcherPathChangeRestartsAndDisabledStops(t *testing.T) {
57 dir := t.TempDir()
58 first := writeThemeFile(t, dir, "first.json")
59 second := writeThemeFile(t, dir, "second.json")
60 watcher := newFastWatcher()
61 changes := make(chan string, 8)
62
63 if err := watcher.Start(context.Background(), watcherConfig(first), func() { changes <- "first" }); err != nil {
64 t.Fatalf("Start(first) error = %v", err)
65 }
66 if err := watcher.Start(context.Background(), watcherConfig(second), func() { changes <- "second" }); err != nil {
67 t.Fatalf("Start(second) error = %v", err)
68 }
69
70 writeThemeFile(t, dir, "first.json")
71 assertNoStringChange(t, changes, 80*time.Millisecond)
72 writeThemeFile(t, dir, "second.json")
73 if got := waitForStringChange(t, changes); got != "second" {
74 t.Fatalf("callback = %q, want second", got)
75 }
76
77 cfg := watcherConfig(second)
78 cfg.Enabled = false
79 if err := watcher.Start(context.Background(), cfg, func() { changes <- "disabled" }); err != nil {
80 t.Fatalf("Start(disabled) error = %v", err)
81 }
82 if watcher.isRunningForTest() {
83 t.Fatal("watcher running after disabled config")
84 }
85 writeThemeFile(t, dir, "second.json")
86 assertNoStringChange(t, changes, 80*time.Millisecond)
87}
88
89func TestFileWatcherDebouncesBurstToOneCallback(t *testing.T) {
90 dir := t.TempDir()
91 path := writeThemeFile(t, dir, "theme.json")
92 watcher := NewFileWatcher()
93 watcher.delay = 100 * time.Millisecond
94 changes := make(chan struct{}, 8)
95
96 if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
97 t.Fatalf("Start() error = %v", err)
98 }
99 for range 5 {
100 writeThemeFile(t, dir, "theme.json")
101 }
102
103 waitForChange(t, changes)
104 assertNoChange(t, changes, 150*time.Millisecond)
105}
106
107func TestFileWatcherAtomicRenameWriteEmitsCallback(t *testing.T) {
108 dir := t.TempDir()
109 path := writeThemeFile(t, dir, "theme.json")
110 watcher := newFastWatcher()
111 changes := make(chan struct{}, 4)
112
113 if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
114 t.Fatalf("Start() error = %v", err)
115 }
116 tmp := filepath.Join(dir, "theme.json.tmp")
117 if err := os.WriteFile(tmp, []byte(`{"light":{},"dark":{}}`), 0o600); err != nil {
118 t.Fatalf("WriteFile(tmp) error = %v", err)
119 }
120 if err := os.Rename(tmp, path); err != nil {
121 t.Fatalf("Rename() error = %v", err)
122 }
123
124 waitForChange(t, changes)
125}
126
127func TestFileWatcherInvalidOrEmptyPathBehavior(t *testing.T) {
128 watcher := newFastWatcher()
129 if err := watcher.Start(context.Background(), watcherConfig(""), nil); err != nil {
130 t.Fatalf("Start(empty path) error = %v", err)
131 }
132 if watcher.isRunningForTest() {
133 t.Fatal("watcher running for empty path")
134 }
135 missingParent := filepath.Join(t.TempDir(), "missing", "theme.json")
136 if err := watcher.Start(context.Background(), watcherConfig(missingParent), func() {}); err == nil {
137 t.Fatal("Start(missing parent) error = nil, want error")
138 }
139}
140
141func TestWatchPathFromConfigSupportsNoctaliaFormats(t *testing.T) {
142 path := filepath.Join(t.TempDir(), "theme.json")
143 for _, format := range []string{"colors-json", "dumber-json"} {
144 t.Run(format, func(t *testing.T) {
145 got, enabled, err := watchPathFromConfig(entity.ExternalThemeConfig{
146 Enabled: true,
147 Provider: " NOCTALIA ",
148 Format: " " + format + " ",
149 Path: path,
150 })
151 if err != nil {
152 t.Fatalf("watchPathFromConfig() error = %v", err)
153 }
154 if !enabled {
155 t.Fatal("enabled = false, want true")
156 }
157 if got != path {
158 t.Fatalf("path = %q, want %q", got, path)
159 }
160 })
161 }
162
163 _, enabled, err := watchPathFromConfig(entity.ExternalThemeConfig{
164 Enabled: true,
165 Provider: "noctalia",
166 Format: "toml",
167 Path: path,
168 })
169 if err != nil {
170 t.Fatalf("watchPathFromConfig(unsupported) error = %v", err)
171 }
172 if enabled {
173 t.Fatal("enabled = true for unsupported format, want false")
174 }
175}
176
177func TestFileWatcherInvalidPathStopsPreviousWatcher(t *testing.T) {
178 dir := t.TempDir()
179 path := writeThemeFile(t, dir, "theme.json")
180 watcher := newFastWatcher()
181 changes := make(chan struct{}, 4)
182
183 if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
184 t.Fatalf("Start(valid) error = %v", err)
185 }
186 missingParent := filepath.Join(dir, "missing", "theme.json")
187 if err := watcher.Start(context.Background(), watcherConfig(missingParent), func() { changes <- struct{}{} }); err == nil {
188 t.Fatal("Start(missing parent) error = nil, want error")
189 }
190 if watcher.isRunningForTest() {
191 t.Fatal("watcher running after invalid path")
192 }
193 writeThemeFile(t, dir, "theme.json")
194 assertNoChange(t, changes, 80*time.Millisecond)
195}
196
197func newFastWatcher() *FileWatcher {
198 watcher := NewFileWatcher()
199 watcher.delay = 20 * time.Millisecond
200 return watcher
201}
202
203func watcherConfig(path string) entity.ExternalThemeConfig {
204 return entity.ExternalThemeConfig{Enabled: true, Provider: providerName, Format: formatName, Path: path}
205}
206
207func writeThemeFile(t *testing.T, dir, name string) string {
208 t.Helper()
209 path := filepath.Join(dir, name)
210 if err := os.WriteFile(path, []byte(`{"light":{},"dark":{}}`), 0o600); err != nil {
211 t.Fatalf("WriteFile(%s) error = %v", name, err)
212 }
213 return path
214}
215
216func waitForChange(t *testing.T, ch <-chan struct{}) {
217 t.Helper()
218 select {
219 case <-ch:
220 case <-time.After(500 * time.Millisecond):
221 t.Fatal("timed out waiting for callback")
222 }
223}
224
225func assertNoChange(t *testing.T, ch <-chan struct{}, d time.Duration) {
226 t.Helper()
227 select {
228 case <-ch:
229 t.Fatal("unexpected callback")
230 case <-time.After(d):
231 }
232}
233
234func waitForStringChange(t *testing.T, ch <-chan string) string {
235 t.Helper()
236 select {
237 case got := <-ch:
238 return got
239 case <-time.After(500 * time.Millisecond):
240 t.Fatal("timed out waiting for callback")
241 }
242 panic("unreachable")
243}
244
245func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) {
246 t.Helper()
247 select {
248 case got := <-ch:
249 t.Fatalf("unexpected callback %q", got)
250 case <-time.After(d):
251 }
252}
253
254func waitUntil(t *testing.T, condition func() bool) {
255 t.Helper()
256 deadline := time.Now().Add(500 * time.Millisecond)
257 for time.Now().Before(deadline) {
258 if condition() {
259 return
260 }
261 time.Sleep(5 * time.Millisecond)
262 }
263 t.Fatal("condition not met before timeout")
264}