watcher_test.go (6379B)
1 package watcher 2 3 import ( 4 "os" 5 "path/filepath" 6 "sync/atomic" 7 "testing" 8 "time" 9 ) 10 11 // --------------------------------------------------------------------------- 12 // 1. TestDebouncerBatchesEvents — rapid events produce exactly one callback 13 // --------------------------------------------------------------------------- 14 func TestDebouncerBatchesEvents(t *testing.T) { 15 var count atomic.Int64 16 17 d := NewDebouncer(100*time.Millisecond, func() { 18 count.Add(1) 19 }) 20 defer d.Stop() 21 22 // Fire 5 events rapidly, 10ms apart 23 for i := 0; i < 5; i++ { 24 d.Trigger() 25 time.Sleep(10 * time.Millisecond) 26 } 27 28 // Wait for debounce window to expire plus margin 29 time.Sleep(200 * time.Millisecond) 30 31 got := count.Load() 32 if got != 1 { 33 t.Errorf("callback fired %d times, want 1", got) 34 } 35 } 36 37 // --------------------------------------------------------------------------- 38 // 2. TestDebouncerSeparateEvents — two events separated by more than the 39 // debounce interval should fire the callback twice 40 // --------------------------------------------------------------------------- 41 func TestDebouncerSeparateEvents(t *testing.T) { 42 var count atomic.Int64 43 44 d := NewDebouncer(50*time.Millisecond, func() { 45 count.Add(1) 46 }) 47 defer d.Stop() 48 49 // First event 50 d.Trigger() 51 // Wait for the debounce to fire 52 time.Sleep(150 * time.Millisecond) 53 54 // Second event 55 d.Trigger() 56 // Wait for the debounce to fire 57 time.Sleep(150 * time.Millisecond) 58 59 got := count.Load() 60 if got != 2 { 61 t.Errorf("callback fired %d times, want 2", got) 62 } 63 } 64 65 // --------------------------------------------------------------------------- 66 // 3. TestDebouncerStopCancelsPending — Stop prevents a pending callback 67 // --------------------------------------------------------------------------- 68 func TestDebouncerStopCancelsPending(t *testing.T) { 69 var count atomic.Int64 70 71 d := NewDebouncer(100*time.Millisecond, func() { 72 count.Add(1) 73 }) 74 75 d.Trigger() 76 // Stop before the debounce interval elapses 77 time.Sleep(20 * time.Millisecond) 78 d.Stop() 79 80 // Wait past the debounce interval 81 time.Sleep(200 * time.Millisecond) 82 83 got := count.Load() 84 if got != 0 { 85 t.Errorf("callback fired %d times after Stop, want 0", got) 86 } 87 } 88 89 // --------------------------------------------------------------------------- 90 // 4. TestShouldIgnore — verify ignore pattern matching 91 // --------------------------------------------------------------------------- 92 func TestShouldIgnore(t *testing.T) { 93 w := &Watcher{ 94 ignores: []string{".git", "*.tmp", "node_modules"}, 95 } 96 97 tests := []struct { 98 path string 99 expect bool 100 }{ 101 {"/project/.git", true}, 102 {"/project/foo.tmp", true}, 103 {"/project/node_modules", true}, 104 {"/project/main.go", false}, 105 {"/project/src/app.go", false}, 106 } 107 108 for _, tt := range tests { 109 got := w.shouldIgnore(tt.path) 110 if got != tt.expect { 111 t.Errorf("shouldIgnore(%q) = %v, want %v", tt.path, got, tt.expect) 112 } 113 } 114 } 115 116 // --------------------------------------------------------------------------- 117 // 5. TestShouldInclude — verify include prefix matching 118 // --------------------------------------------------------------------------- 119 func TestShouldInclude(t *testing.T) { 120 w := &Watcher{ 121 rootPath: "/project", 122 includes: []string{"src", "docs/api"}, 123 } 124 125 tests := []struct { 126 path string 127 expect bool 128 }{ 129 {"/project/src/main.go", true}, 130 {"/project/src/pkg/util.go", true}, 131 {"/project/docs/api/readme.md", true}, 132 {"/project/docs", true}, // ancestor of docs/api 133 {"/project/tmp/cache.bin", false}, 134 {"/project/build/out.o", false}, 135 {"/project", true}, // root always included 136 } 137 138 for _, tt := range tests { 139 got := w.shouldInclude(tt.path) 140 if got != tt.expect { 141 t.Errorf("shouldInclude(%q) = %v, want %v", tt.path, got, tt.expect) 142 } 143 } 144 } 145 146 // --------------------------------------------------------------------------- 147 // 6. TestShouldIncludeEmptyMeansAll — empty includes means include everything 148 // --------------------------------------------------------------------------- 149 func TestShouldIncludeEmptyMeansAll(t *testing.T) { 150 w := &Watcher{ 151 rootPath: "/project", 152 includes: nil, 153 } 154 155 tests := []struct { 156 path string 157 expect bool 158 }{ 159 {"/project/anything/at/all.go", true}, 160 {"/project/tmp/cache.bin", true}, 161 } 162 163 for _, tt := range tests { 164 got := w.shouldInclude(tt.path) 165 if got != tt.expect { 166 t.Errorf("shouldInclude(%q) = %v, want %v", tt.path, got, tt.expect) 167 } 168 } 169 } 170 171 // --------------------------------------------------------------------------- 172 // 7. TestFindBrokenSymlinks — detects broken symlinks in a directory 173 // --------------------------------------------------------------------------- 174 func TestFindBrokenSymlinks(t *testing.T) { 175 dir := t.TempDir() 176 177 // Create a valid file 178 os.WriteFile(filepath.Join(dir, "good.txt"), []byte("ok"), 0644) 179 180 // Create a broken symlink 181 os.Symlink("/nonexistent/target", filepath.Join(dir, "bad.txt")) 182 183 // Create a valid symlink 184 os.Symlink(filepath.Join(dir, "good.txt"), filepath.Join(dir, "also-good.txt")) 185 186 broken := findBrokenSymlinks(dir) 187 188 if len(broken) != 1 { 189 t.Fatalf("findBrokenSymlinks found %d, want 1", len(broken)) 190 } 191 if broken[0].Target != "/nonexistent/target" { 192 t.Errorf("target = %q, want %q", broken[0].Target, "/nonexistent/target") 193 } 194 if filepath.Base(broken[0].Path) != "bad.txt" { 195 t.Errorf("path base = %q, want %q", filepath.Base(broken[0].Path), "bad.txt") 196 } 197 } 198 199 // --------------------------------------------------------------------------- 200 // 8. TestAddRecursiveSkipsBrokenSymlinks — watcher starts despite broken symlinks 201 // --------------------------------------------------------------------------- 202 func TestAddRecursiveSkipsBrokenSymlinks(t *testing.T) { 203 dir := t.TempDir() 204 sub := filepath.Join(dir, "subdir") 205 os.Mkdir(sub, 0755) 206 207 // Create a broken symlink inside subdir 208 os.Symlink("/nonexistent/target", filepath.Join(sub, "broken.csv")) 209 210 w, err := New(dir, 100, nil, nil, func() {}) 211 if err != nil { 212 t.Fatalf("New: %v", err) 213 } 214 defer w.Stop() 215 216 // Start should succeed despite broken symlinks 217 if err := w.Start(); err != nil { 218 t.Fatalf("Start failed: %v", err) 219 } 220 221 if len(w.BrokenSymlinks) != 1 { 222 t.Fatalf("BrokenSymlinks = %d, want 1", len(w.BrokenSymlinks)) 223 } 224 if w.BrokenSymlinks[0].Target != "/nonexistent/target" { 225 t.Errorf("target = %q, want %q", w.BrokenSymlinks[0].Target, "/nonexistent/target") 226 } 227 }