"""Unit tests for watcher module — events, FileWatcher debounce/dedup, IncrementalIndexer.""" from __future__ import annotations import time from pathlib import Path from unittest.mock import MagicMock, patch import pytest from codexlens_search.watcher.events import ChangeType, FileEvent, WatcherConfig from codexlens_search.watcher.incremental_indexer import BatchResult, IncrementalIndexer # --------------------------------------------------------------------------- # ChangeType enum # --------------------------------------------------------------------------- class TestChangeType: def test_values(self): assert ChangeType.CREATED.value == "created" assert ChangeType.MODIFIED.value == "modified" assert ChangeType.DELETED.value == "deleted" def test_all_members(self): assert len(ChangeType) == 3 # --------------------------------------------------------------------------- # FileEvent # --------------------------------------------------------------------------- class TestFileEvent: def test_creation(self): e = FileEvent(path=Path("a.py"), change_type=ChangeType.CREATED) assert e.path == Path("a.py") assert e.change_type == ChangeType.CREATED assert isinstance(e.timestamp, float) def test_custom_timestamp(self): e = FileEvent(path=Path("b.py"), change_type=ChangeType.DELETED, timestamp=42.0) assert e.timestamp == 42.0 # --------------------------------------------------------------------------- # WatcherConfig # --------------------------------------------------------------------------- class TestWatcherConfig: def test_defaults(self): cfg = WatcherConfig() assert cfg.debounce_ms == 500 assert ".git" in cfg.ignored_patterns assert "__pycache__" in cfg.ignored_patterns assert "node_modules" in cfg.ignored_patterns def test_custom(self): cfg = WatcherConfig(debounce_ms=1000, ignored_patterns={".custom"}) assert cfg.debounce_ms == 1000 assert cfg.ignored_patterns == {".custom"} # --------------------------------------------------------------------------- # BatchResult # --------------------------------------------------------------------------- class TestBatchResult: def test_defaults(self): r = BatchResult() assert r.files_indexed == 0 assert r.files_removed == 0 assert r.chunks_created == 0 assert r.errors == [] def test_total_processed(self): r = BatchResult(files_indexed=3, files_removed=2) assert r.total_processed == 5 def test_has_errors(self): r = BatchResult() assert r.has_errors is False r.errors.append("oops") assert r.has_errors is True # --------------------------------------------------------------------------- # IncrementalIndexer — event routing # --------------------------------------------------------------------------- class TestIncrementalIndexer: @pytest.fixture def mock_pipeline(self): pipeline = MagicMock() pipeline.index_file.return_value = MagicMock( files_processed=1, chunks_created=3 ) return pipeline def test_routes_created_to_index_file(self, mock_pipeline): indexer = IncrementalIndexer(mock_pipeline, root=Path("/project")) events = [ FileEvent(Path("/project/src/new.py"), ChangeType.CREATED), ] result = indexer.process_events(events) assert result.files_indexed == 1 mock_pipeline.index_file.assert_called_once() # CREATED should NOT use force=True call_kwargs = mock_pipeline.index_file.call_args assert call_kwargs.kwargs.get("force", call_kwargs[1].get("force")) is False def test_routes_modified_to_index_file_with_force(self, mock_pipeline): indexer = IncrementalIndexer(mock_pipeline, root=Path("/project")) events = [ FileEvent(Path("/project/src/changed.py"), ChangeType.MODIFIED), ] result = indexer.process_events(events) assert result.files_indexed == 1 call_kwargs = mock_pipeline.index_file.call_args assert call_kwargs.kwargs.get("force", call_kwargs[1].get("force")) is True def test_routes_deleted_to_remove_file(self, mock_pipeline, tmp_path): root = tmp_path / "project" root.mkdir() indexer = IncrementalIndexer(mock_pipeline, root=root) events = [ FileEvent(root / "src" / "old.py", ChangeType.DELETED), ] result = indexer.process_events(events) assert result.files_removed == 1 # On Windows relative_to produces backslashes, normalize actual_arg = mock_pipeline.remove_file.call_args[0][0] assert actual_arg.replace("\\", "/") == "src/old.py" def test_batch_with_mixed_events(self, mock_pipeline): indexer = IncrementalIndexer(mock_pipeline, root=Path("/project")) events = [ FileEvent(Path("/project/a.py"), ChangeType.CREATED), FileEvent(Path("/project/b.py"), ChangeType.MODIFIED), FileEvent(Path("/project/c.py"), ChangeType.DELETED), ] result = indexer.process_events(events) assert result.files_indexed == 2 assert result.files_removed == 1 assert result.total_processed == 3 def test_error_isolation(self, mock_pipeline): """One file failure should not stop processing of others.""" call_count = [0] def side_effect(*args, **kwargs): call_count[0] += 1 if call_count[0] == 1: raise RuntimeError("disk error") return MagicMock(files_processed=1, chunks_created=1) mock_pipeline.index_file.side_effect = side_effect indexer = IncrementalIndexer(mock_pipeline, root=Path("/project")) events = [ FileEvent(Path("/project/fail.py"), ChangeType.CREATED), FileEvent(Path("/project/ok.py"), ChangeType.CREATED), ] result = indexer.process_events(events) assert result.files_indexed == 1 # second succeeded assert len(result.errors) == 1 # first failed assert "disk error" in result.errors[0] def test_empty_events(self, mock_pipeline): indexer = IncrementalIndexer(mock_pipeline) result = indexer.process_events([]) assert result.total_processed == 0 mock_pipeline.index_file.assert_not_called() mock_pipeline.remove_file.assert_not_called() # --------------------------------------------------------------------------- # FileWatcher — debounce and dedup logic (unit-level, no actual FS) # --------------------------------------------------------------------------- class TestFileWatcherLogic: """Test FileWatcher internals without starting a real watchdog Observer.""" @pytest.fixture def watcher_parts(self): """Create a FileWatcher with mocked observer, capture callbacks.""" # Import here since watchdog is optional from codexlens_search.watcher.file_watcher import FileWatcher, _EVENT_PRIORITY collected = [] def on_changes(events): collected.extend(events) cfg = WatcherConfig(debounce_ms=100) watcher = FileWatcher(Path("."), cfg, on_changes) return watcher, collected, _EVENT_PRIORITY def test_event_priority_ordering(self, watcher_parts): _, _, priority = watcher_parts assert priority[ChangeType.DELETED] > priority[ChangeType.MODIFIED] assert priority[ChangeType.MODIFIED] > priority[ChangeType.CREATED] def test_dedup_keeps_higher_priority(self, watcher_parts, tmp_path): watcher, collected, _ = watcher_parts f = str(tmp_path / "a.py") watcher._on_raw_event(f, ChangeType.CREATED) watcher._on_raw_event(f, ChangeType.DELETED) watcher.flush_now() assert len(collected) == 1 assert collected[0].change_type == ChangeType.DELETED def test_dedup_does_not_downgrade(self, watcher_parts, tmp_path): watcher, collected, _ = watcher_parts f = str(tmp_path / "b.py") watcher._on_raw_event(f, ChangeType.DELETED) watcher._on_raw_event(f, ChangeType.CREATED) watcher.flush_now() assert len(collected) == 1 # CREATED (priority 1) < DELETED (priority 3), so DELETED stays assert collected[0].change_type == ChangeType.DELETED def test_multiple_files_kept(self, watcher_parts, tmp_path): watcher, collected, _ = watcher_parts watcher._on_raw_event(str(tmp_path / "a.py"), ChangeType.CREATED) watcher._on_raw_event(str(tmp_path / "b.py"), ChangeType.MODIFIED) watcher._on_raw_event(str(tmp_path / "c.py"), ChangeType.DELETED) watcher.flush_now() assert len(collected) == 3 paths = {str(e.path) for e in collected} assert len(paths) == 3 def test_flush_clears_pending(self, watcher_parts, tmp_path): watcher, collected, _ = watcher_parts watcher._on_raw_event(str(tmp_path / "a.py"), ChangeType.CREATED) watcher.flush_now() assert len(collected) == 1 collected.clear() watcher.flush_now() assert len(collected) == 0 def test_should_watch_filters_ignored(self, watcher_parts): watcher, _, _ = watcher_parts assert watcher._should_watch(Path("/project/src/main.py")) is True assert watcher._should_watch(Path("/project/.git/config")) is False assert watcher._should_watch(Path("/project/node_modules/foo.js")) is False assert watcher._should_watch(Path("/project/__pycache__/mod.pyc")) is False def test_jsonl_serialization(self): from codexlens_search.watcher.file_watcher import FileWatcher import json events = [ FileEvent(Path("/tmp/a.py"), ChangeType.CREATED, 1000.0), FileEvent(Path("/tmp/b.py"), ChangeType.DELETED, 2000.0), ] output = FileWatcher.events_to_jsonl(events) lines = output.strip().split("\n") assert len(lines) == 2 obj1 = json.loads(lines[0]) assert obj1["change_type"] == "created" assert obj1["timestamp"] == 1000.0 obj2 = json.loads(lines[1]) assert obj2["change_type"] == "deleted"