watcher_test.go
1 package watcher 2 3 import ( 4 "context" 5 "os" 6 "path/filepath" 7 "strings" 8 "sync" 9 "testing" 10 "time" 11 12 "github.com/fsnotify/fsnotify" 13 ) 14 15 func TestGlobMatch(t *testing.T) { 16 tests := []struct { 17 glob string 18 filename string 19 want bool 20 }{ 21 {"", "anything.txt", true}, 22 {"", "", true}, 23 {"*.txt", "readme.txt", true}, 24 {"*.txt", "readme.md", false}, 25 {"*.go", "main.go", true}, 26 {"*.go", "main.go.bak", false}, 27 {"data.*", "data.json", true}, 28 {"data.*", "mydata.json", false}, 29 {"config.yaml", "config.yaml", true}, 30 {"config.yaml", "config.yml", false}, 31 {"[abc].txt", "a.txt", true}, 32 {"[abc].txt", "d.txt", false}, 33 } 34 for _, tt := range tests { 35 got := MatchGlob(tt.glob, tt.filename) 36 if got != tt.want { 37 t.Errorf("MatchGlob(%q, %q) = %v, want %v", tt.glob, tt.filename, got, tt.want) 38 } 39 } 40 } 41 42 func TestMapEventType(t *testing.T) { 43 tests := []struct { 44 op fsnotify.Op 45 want string 46 }{ 47 {fsnotify.Create, "created"}, 48 {fsnotify.Write, "modified"}, 49 {fsnotify.Remove, "deleted"}, 50 {fsnotify.Rename, "renamed"}, 51 {fsnotify.Chmod, ""}, 52 } 53 for _, tt := range tests { 54 got := MapEventType(tt.op) 55 if got != tt.want { 56 t.Errorf("MapEventType(%v) = %q, want %q", tt.op, got, tt.want) 57 } 58 } 59 } 60 61 func TestFormatPrompt(t *testing.T) { 62 events := []fileEvent{ 63 {Path: "/b/file2.txt", Type: "created"}, 64 {Path: "/a/file1.go", Type: "modified"}, 65 } 66 got := FormatPrompt(events) 67 want := "File changes detected:\n- modified: /a/file1.go\n- created: /b/file2.txt" 68 if got != want { 69 t.Errorf("FormatPrompt() =\n%s\nwant:\n%s", got, want) 70 } 71 } 72 73 func TestDedupEvents(t *testing.T) { 74 // Simulate bursty writes: multiple events for the same path. 75 // Last event type wins via sequential map assignment. 76 batch := make(map[string]string) 77 batch["/tmp/file.txt"] = "created" 78 batch["/tmp/file.txt"] = "modified" 79 batch["/tmp/file.txt"] = "modified" 80 81 if batch["/tmp/file.txt"] != "modified" { 82 t.Errorf("expected last event type 'modified', got %q", batch["/tmp/file.txt"]) 83 } 84 if len(batch) != 1 { 85 t.Errorf("expected 1 deduped entry, got %d", len(batch)) 86 } 87 } 88 89 func TestActiveHoursWindow(t *testing.T) { 90 tests := []struct { 91 name string 92 window string 93 hour int 94 min int 95 want bool 96 }{ 97 // Empty = always active 98 {"empty", "", 12, 0, true}, 99 // Normal daytime window: 09:00-17:00 100 {"normal-inside", "09:00-17:00", 12, 0, true}, 101 {"normal-start", "09:00-17:00", 9, 0, true}, 102 {"normal-end-exclusive", "09:00-17:00", 17, 0, false}, 103 {"normal-before", "09:00-17:00", 8, 59, false}, 104 {"normal-after", "09:00-17:00", 17, 1, false}, 105 // Overnight window: 22:00-02:00 106 {"overnight-before-midnight", "22:00-02:00", 23, 0, true}, 107 {"overnight-start", "22:00-02:00", 22, 0, true}, 108 {"overnight-after-midnight", "22:00-02:00", 1, 30, true}, 109 {"overnight-end-exclusive", "22:00-02:00", 2, 0, false}, 110 {"overnight-outside-day", "22:00-02:00", 12, 0, false}, 111 {"overnight-just-before", "22:00-02:00", 21, 59, false}, 112 // Edge: midnight boundary 113 {"midnight-span", "23:00-01:00", 0, 0, true}, 114 {"midnight-span-outside", "23:00-01:00", 12, 0, false}, 115 } 116 for _, tt := range tests { 117 t.Run(tt.name, func(t *testing.T) { 118 now := time.Date(2026, 3, 17, tt.hour, tt.min, 0, 0, time.UTC) 119 got := InActiveHours(tt.window, now) 120 if got != tt.want { 121 t.Errorf("InActiveHours(%q, %02d:%02d) = %v, want %v", 122 tt.window, tt.hour, tt.min, got, tt.want) 123 } 124 }) 125 } 126 } 127 128 func TestActiveHoursInvalidFormat(t *testing.T) { 129 // Invalid formats should return true (always active). 130 invalids := []string{"not-a-time", "25:00-12:00", "09:00", "09:70-17:00"} 131 now := time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC) 132 for _, w := range invalids { 133 if !InActiveHours(w, now) { 134 t.Errorf("InActiveHours(%q) should return true for invalid format", w) 135 } 136 } 137 } 138 139 func TestExpandPath(t *testing.T) { 140 home, _ := os.UserHomeDir() 141 got := ExpandPath("~/test") 142 want := filepath.Join(home, "test") 143 if got != want { 144 t.Errorf("ExpandPath(~/test) = %q, want %q", got, want) 145 } 146 147 // Env var expansion 148 os.Setenv("SHAN_TEST_DIR", "/tmp/shantest") 149 defer os.Unsetenv("SHAN_TEST_DIR") 150 got = ExpandPath("$SHAN_TEST_DIR/sub") 151 if got != "/tmp/shantest/sub" { 152 t.Errorf("ExpandPath($SHAN_TEST_DIR/sub) = %q, want /tmp/shantest/sub", got) 153 } 154 } 155 156 func TestWatcher_Integration(t *testing.T) { 157 dir := t.TempDir() 158 159 var mu sync.Mutex 160 var calls []struct { 161 Agent string 162 Prompt string 163 } 164 165 runFn := func(ctx context.Context, agent, prompt string) { 166 mu.Lock() 167 defer mu.Unlock() 168 calls = append(calls, struct { 169 Agent string 170 Prompt string 171 }{agent, prompt}) 172 } 173 174 w, err := New(map[string][]WatchEntry{ 175 "test-agent": {{Path: dir, Glob: "*.txt"}}, 176 }, runFn) 177 if err != nil { 178 t.Fatalf("New: %v", err) 179 } 180 w.Debounce = 100 * time.Millisecond 181 182 ctx, cancel := context.WithCancel(context.Background()) 183 defer cancel() 184 w.Start(ctx) 185 defer w.Close() 186 187 // Create a matching file. 188 testFile := filepath.Join(dir, "hello.txt") 189 if err := os.WriteFile(testFile, []byte("content"), 0644); err != nil { 190 t.Fatalf("write file: %v", err) 191 } 192 193 // Wait for debounce + processing. 194 time.Sleep(500 * time.Millisecond) 195 196 mu.Lock() 197 defer mu.Unlock() 198 if len(calls) == 0 { 199 t.Fatal("expected RunFunc to be called, got 0 calls") 200 } 201 202 call := calls[0] 203 if call.Agent != "test-agent" { 204 t.Errorf("agent = %q, want %q", call.Agent, "test-agent") 205 } 206 if !strings.Contains(call.Prompt, "hello.txt") { 207 t.Errorf("prompt should contain filename, got: %s", call.Prompt) 208 } 209 if !strings.Contains(call.Prompt, "File changes detected:") { 210 t.Errorf("prompt should start with header, got: %s", call.Prompt) 211 } 212 } 213 214 func TestWatcher_GlobFilter(t *testing.T) { 215 dir := t.TempDir() 216 217 var mu sync.Mutex 218 called := false 219 220 runFn := func(ctx context.Context, agent, prompt string) { 221 mu.Lock() 222 defer mu.Unlock() 223 called = true 224 } 225 226 w, err := New(map[string][]WatchEntry{ 227 "test-agent": {{Path: dir, Glob: "*.txt"}}, 228 }, runFn) 229 if err != nil { 230 t.Fatalf("New: %v", err) 231 } 232 w.Debounce = 100 * time.Millisecond 233 234 ctx, cancel := context.WithCancel(context.Background()) 235 defer cancel() 236 w.Start(ctx) 237 defer w.Close() 238 239 // Create a NON-matching file. 240 nonMatch := filepath.Join(dir, "readme.md") 241 if err := os.WriteFile(nonMatch, []byte("# readme"), 0644); err != nil { 242 t.Fatalf("write file: %v", err) 243 } 244 245 // Wait for debounce + processing. 246 time.Sleep(500 * time.Millisecond) 247 248 mu.Lock() 249 defer mu.Unlock() 250 if called { 251 t.Error("RunFunc should NOT have been called for non-matching glob") 252 } 253 } 254 255 func TestWatcher_MultipleAgents(t *testing.T) { 256 dir := t.TempDir() 257 258 var mu sync.Mutex 259 agentCalls := make(map[string]int) 260 261 runFn := func(ctx context.Context, agent, prompt string) { 262 mu.Lock() 263 defer mu.Unlock() 264 agentCalls[agent]++ 265 } 266 267 // Two agents watching same directory with different globs. 268 w, err := New(map[string][]WatchEntry{ 269 "go-agent": {{Path: dir, Glob: "*.go"}}, 270 "md-agent": {{Path: dir, Glob: "*.md"}}, 271 }, runFn) 272 if err != nil { 273 t.Fatalf("New: %v", err) 274 } 275 w.Debounce = 100 * time.Millisecond 276 277 ctx, cancel := context.WithCancel(context.Background()) 278 defer cancel() 279 w.Start(ctx) 280 defer w.Close() 281 282 // Create a .go file — should only trigger go-agent. 283 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0644); err != nil { 284 t.Fatalf("write file: %v", err) 285 } 286 287 time.Sleep(500 * time.Millisecond) 288 289 mu.Lock() 290 if agentCalls["go-agent"] == 0 { 291 t.Error("go-agent should have been called") 292 } 293 if agentCalls["md-agent"] != 0 { 294 t.Error("md-agent should NOT have been called for .go file") 295 } 296 mu.Unlock() 297 } 298 299 func TestWatcher_RecursiveSubdir(t *testing.T) { 300 dir := t.TempDir() 301 subdir := filepath.Join(dir, "sub") 302 if err := os.MkdirAll(subdir, 0755); err != nil { 303 t.Fatalf("mkdir: %v", err) 304 } 305 306 var mu sync.Mutex 307 var prompts []string 308 309 runFn := func(ctx context.Context, agent, prompt string) { 310 mu.Lock() 311 defer mu.Unlock() 312 prompts = append(prompts, prompt) 313 } 314 315 w, err := New(map[string][]WatchEntry{ 316 "agent": {{Path: dir, Glob: "*.txt"}}, 317 }, runFn) 318 if err != nil { 319 t.Fatalf("New: %v", err) 320 } 321 w.Debounce = 100 * time.Millisecond 322 323 ctx, cancel := context.WithCancel(context.Background()) 324 defer cancel() 325 w.Start(ctx) 326 defer w.Close() 327 328 // Write file in subdirectory. 329 if err := os.WriteFile(filepath.Join(subdir, "nested.txt"), []byte("nested"), 0644); err != nil { 330 t.Fatalf("write: %v", err) 331 } 332 333 time.Sleep(500 * time.Millisecond) 334 335 mu.Lock() 336 defer mu.Unlock() 337 if len(prompts) == 0 { 338 t.Fatal("expected callback for file in subdirectory") 339 } 340 if !strings.Contains(prompts[0], "nested.txt") { 341 t.Errorf("prompt should contain nested.txt, got: %s", prompts[0]) 342 } 343 } 344 345 func TestFormatPrompt_Sorted(t *testing.T) { 346 events := []fileEvent{ 347 {Path: "/z/last.go", Type: "deleted"}, 348 {Path: "/a/first.go", Type: "created"}, 349 {Path: "/m/mid.go", Type: "modified"}, 350 } 351 got := FormatPrompt(events) 352 lines := strings.Split(got, "\n") 353 if len(lines) != 4 { 354 t.Fatalf("expected 4 lines (header + 3 events), got %d", len(lines)) 355 } 356 357 // Verify events are sorted by path (ascending). 358 want := []string{ 359 "- created: /a/first.go", 360 "- modified: /m/mid.go", 361 "- deleted: /z/last.go", 362 } 363 eventLines := lines[1:] 364 for i, line := range eventLines { 365 if line != want[i] { 366 t.Errorf("line %d = %q, want %q", i, line, want[i]) 367 } 368 } 369 }