diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 964784e..386ba0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: | total=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}') # Current enforced coverage floor. Codex PRs raise this incrementally toward 90%. - min=45.0 + min=50.0 awk -v t="$total" -v m="$min" 'BEGIN { if (t+0 < m+0) { printf "Coverage %.1f%% is below floor %.1f%%\n", t, m diff --git a/watch/events_debounce_test.go b/watch/events_debounce_test.go index f4934b6..9591b6f 100644 --- a/watch/events_debounce_test.go +++ b/watch/events_debounce_test.go @@ -76,3 +76,10 @@ func TestEventDebouncerPrunesStaleEntries(t *testing.T) { t.Fatal("expected recent path entry to be retained") } } + +func TestNewEventDebouncerHasMinimumPruneWindow(t *testing.T) { + debouncer := newEventDebouncer(10 * time.Millisecond) + if debouncer.pruneAfter != time.Second { + t.Fatalf("pruneAfter = %v, want %v", debouncer.pruneAfter, time.Second) + } +} diff --git a/watch/events_handle_test.go b/watch/events_handle_test.go index c8e5ca2..091eb52 100644 --- a/watch/events_handle_test.go +++ b/watch/events_handle_test.go @@ -1,9 +1,11 @@ package watch import ( + "os" "path/filepath" "reflect" "sort" + "strings" "testing" "time" @@ -157,3 +159,168 @@ func TestFindRelatedHot(t *testing.T) { }) } } + +func TestHandleEventWriteUpdatesTrackedState(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + + rel := "pkg/main.go" + abs := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + content := "package main\n\nfunc run() {}\n" + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + info, err := os.Stat(abs) + if err != nil { + t.Fatal(err) + } + + d := &Daemon{ + root: root, + eventLog: filepath.Join(root, ".codemap", "events.log"), + graph: &Graph{ + Files: map[string]*scanner.FileInfo{ + rel: {Path: rel, Size: 10, Ext: ".go"}, + }, + State: map[string]*FileState{ + rel: {Lines: 1, Size: 10}, + }, + }, + } + + d.handleEvent(fsnotify.Event{Name: abs, Op: fsnotify.Write}) + + d.graph.mu.RLock() + defer d.graph.mu.RUnlock() + + state, ok := d.graph.State[rel] + if !ok { + t.Fatalf("expected %q to remain tracked in graph state", rel) + } + if state.Lines != 3 { + t.Fatalf("expected updated line count 3, got %d", state.Lines) + } + if state.Size != info.Size() { + t.Fatalf("expected updated size %d, got %d", info.Size(), state.Size) + } + + file, ok := d.graph.Files[rel] + if !ok { + t.Fatalf("expected %q to remain tracked in graph files", rel) + } + if file.Ext != ".go" { + t.Fatalf("expected .go extension, got %q", file.Ext) + } + + if len(d.graph.Events) != 1 { + t.Fatalf("expected one event, got %d", len(d.graph.Events)) + } + event := d.graph.Events[0] + if event.Op != "WRITE" || event.Path != rel { + t.Fatalf("unexpected event: %+v", event) + } + if event.Delta != 2 { + t.Fatalf("expected line delta +2, got %d", event.Delta) + } + if event.SizeDelta != info.Size()-10 { + t.Fatalf("expected size delta %d, got %d", info.Size()-10, event.SizeDelta) + } + + logData, err := os.ReadFile(filepath.Join(root, ".codemap", "events.log")) + if err != nil { + t.Fatalf("expected log file to be written: %v", err) + } + if !strings.Contains(string(logData), "WRITE") || !strings.Contains(string(logData), rel) { + t.Fatalf("expected event log to contain write event for %q, got:\n%s", rel, string(logData)) + } +} + +func TestHandleEventRemoveDeletesTrackedState(t *testing.T) { + root := t.TempDir() + rel := "old.go" + + d := &Daemon{ + root: root, + graph: &Graph{ + Files: map[string]*scanner.FileInfo{ + rel: {Path: rel, Size: 40, Ext: ".go"}, + }, + State: map[string]*FileState{ + rel: {Lines: 4, Size: 40}, + }, + }, + } + + d.handleEvent(fsnotify.Event{Name: filepath.Join(root, rel), Op: fsnotify.Remove}) + + d.graph.mu.RLock() + defer d.graph.mu.RUnlock() + + if _, ok := d.graph.Files[rel]; ok { + t.Fatalf("expected %q to be removed from graph files", rel) + } + if _, ok := d.graph.State[rel]; ok { + t.Fatalf("expected %q to be removed from graph state", rel) + } + + if len(d.graph.Events) != 1 { + t.Fatalf("expected one event, got %d", len(d.graph.Events)) + } + event := d.graph.Events[0] + if event.Op != "REMOVE" || event.Path != rel { + t.Fatalf("unexpected event: %+v", event) + } + if event.Delta != -4 { + t.Fatalf("expected line delta -4, got %d", event.Delta) + } + if event.SizeDelta != -40 { + t.Fatalf("expected size delta -40, got %d", event.SizeDelta) + } +} + +func TestHandleEventSkipsGitignoredPath(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("ignored.go\n"), 0o644); err != nil { + t.Fatal(err) + } + + rel := "ignored.go" + abs := filepath.Join(root, rel) + if err := os.WriteFile(abs, []byte("package ignored\n"), 0o644); err != nil { + t.Fatal(err) + } + + gitCache := scanner.NewGitIgnoreCache(root) + gitCache.EnsureDir(root) + + d := &Daemon{ + root: root, + gitCache: gitCache, + eventLog: filepath.Join(root, ".codemap", "events.log"), + graph: &Graph{ + Files: map[string]*scanner.FileInfo{}, + State: map[string]*FileState{}, + Events: []Event{}, + }, + } + + d.handleEvent(fsnotify.Event{Name: abs, Op: fsnotify.Write}) + + d.graph.mu.RLock() + defer d.graph.mu.RUnlock() + + if len(d.graph.Events) != 0 { + t.Fatalf("expected gitignored event to be skipped, got %+v", d.graph.Events) + } + if len(d.graph.Files) != 0 || len(d.graph.State) != 0 { + t.Fatalf("expected gitignored event to avoid graph updates, files=%d state=%d", len(d.graph.Files), len(d.graph.State)) + } +} diff --git a/watch/events_limits_test.go b/watch/events_limits_test.go index 47560ec..d28b6df 100644 --- a/watch/events_limits_test.go +++ b/watch/events_limits_test.go @@ -67,3 +67,37 @@ func TestTrimEventLogToBytes(t *testing.T) { t.Fatalf("expected newest entry to be retained after trim") } } + +func TestTrimEventLogToBytesIgnoresInvalidLimits(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "events.log") + original := "line1\nline2\n" + if err := os.WriteFile(logPath, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + maxBytes int64 + keepBytes int64 + }{ + {name: "max bytes non-positive", maxBytes: 0, keepBytes: 10}, + {name: "keep bytes non-positive", maxBytes: 10, keepBytes: 0}, + {name: "keep bytes larger than max", maxBytes: 10, keepBytes: 11}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := trimEventLogToBytes(logPath, tt.maxBytes, tt.keepBytes); err != nil { + t.Fatalf("trimEventLogToBytes() error = %v", err) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if string(data) != original { + t.Fatalf("expected log content to remain unchanged, got %q", string(data)) + } + }) + } +}