/ internal / watcher / watcher_test.go
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  }