"""Tests for autocomplete fuzzy search functionality.""" from pathlib import Path from unittest.mock import MagicMock import pytest from deepagents_cli.widgets.autocomplete import ( SLASH_COMMANDS, FuzzyFileController, MultiCompletionManager, SlashCommandController, _find_project_root, _fuzzy_score, _fuzzy_search, _is_dotpath, _path_depth, ) SampleFiles = list[str] class TestFuzzyScore: """Tests for the _fuzzy_score function.""" def test_exact_filename_match_at_start(self): """Exact match at start of filename gets highest score.""" score = _fuzzy_score("main", "src/main.py") assert score > 140 # Should be ~150 def test_exact_filename_match_anywhere(self): """Exact match anywhere in filename.""" score = _fuzzy_score("test", "src/my_test_file.py") assert score > 90 # Should be ~100 def test_word_boundary_match(self): """Match at word boundary (after _, -, .) gets bonus.""" score_boundary = _fuzzy_score("test", "src/my_test.py") score_middle = _fuzzy_score("est", "src/mytest.py") assert score_boundary > score_middle def test_path_match_lower_than_filename(self): """Match in path scores lower than filename match.""" filename_score = _fuzzy_score("utils", "utils.py") path_score = _fuzzy_score("utils", "src/utils/helper.py") assert filename_score > path_score def test_no_match_returns_low_score(self): """Completely unrelated strings get very low scores.""" score = _fuzzy_score("xyz", "abc.py") assert score < 15 # Below MIN_FUZZY_SCORE threshold def test_case_insensitive(self): """Matching is case insensitive.""" score_lower = _fuzzy_score("main", "Main.py") score_upper = _fuzzy_score("MAIN", "main.py") assert score_lower > 100 assert score_upper > 100 def test_shorter_paths_preferred(self): """Shorter paths get slightly higher scores for same match.""" short_score = _fuzzy_score("test", "test.py") long_score = _fuzzy_score("test", "very/long/path/to/test.py") assert short_score > long_score class TestFuzzySearch: """Tests for the _fuzzy_search function.""" @pytest.fixture def sample_files(self) -> SampleFiles: """Sample file list for testing.""" return [ "README.md", "setup.py", "src/main.py", "src/utils.py", "src/helpers/string_utils.py", "tests/test_main.py", "tests/test_utils.py", ".github/workflows/ci.yml", ".gitignore", "docs/api.md", ] def test_empty_query_returns_root_files_first(self, sample_files: SampleFiles) -> None: """Empty query returns files sorted by depth, then name.""" results = _fuzzy_search("", sample_files, limit=5) # Root level files should come first assert results[0] in ["README.md", "setup.py"] assert all("/" not in r for r in results[:2]) # First items are root level def test_exact_match_ranked_first(self, sample_files: SampleFiles) -> None: """Exact filename matches are ranked first.""" results = _fuzzy_search("main", sample_files, limit=5) assert "src/main.py" in results[:2] def test_filters_dotfiles_by_default(self, sample_files: SampleFiles) -> None: """Dotfiles are filtered out by default.""" results = _fuzzy_search("git", sample_files, limit=10) assert not any(".git" in r for r in results) def test_includes_dotfiles_when_query_starts_with_dot(self, sample_files: SampleFiles) -> None: """Dotfiles included when query starts with '.'.""" results = _fuzzy_search(".git", sample_files, limit=10, include_dotfiles=True) assert any(".git" in r for r in results) def test_respects_limit(self, sample_files: SampleFiles) -> None: """Results respect the limit parameter.""" results = _fuzzy_search("", sample_files, limit=3) assert len(results) <= 3 def test_filters_low_score_matches(self, sample_files: SampleFiles) -> None: """Low score matches are filtered out.""" results = _fuzzy_search("xyznonexistent", sample_files, limit=10) assert len(results) == 0 def test_utils_matches_multiple_files(self, sample_files: SampleFiles) -> None: """Query matching multiple files returns all matches.""" results = _fuzzy_search("utils", sample_files, limit=10) assert len(results) >= 2 assert any("utils.py" in r for r in results) class TestHelperFunctions: """Tests for helper functions.""" def test_is_dotpath_detects_dotfiles(self): """_is_dotpath correctly identifies dotfiles.""" assert _is_dotpath(".gitignore") is True assert _is_dotpath(".github/workflows/ci.yml") is True assert _is_dotpath("src/.hidden/file.py") is True def test_is_dotpath_allows_normal_files(self): """_is_dotpath returns False for normal files.""" assert _is_dotpath("src/main.py") is False assert _is_dotpath("README.md") is False assert _is_dotpath("tests/test_main.py") is False def test_path_depth_counts_slashes(self): """_path_depth correctly counts directory depth.""" assert _path_depth("file.py") == 0 assert _path_depth("src/file.py") == 1 assert _path_depth("src/utils/file.py") == 2 assert _path_depth("a/b/c/d/file.py") == 4 class TestFindProjectRoot: """Tests for _find_project_root function.""" def test_finds_git_root(self, tmp_path: Path) -> None: """Finds .git directory and returns its parent.""" # Create nested structure with .git at root git_dir = tmp_path / ".git" git_dir.mkdir() nested = tmp_path / "src" / "deep" / "nested" nested.mkdir(parents=True) result = _find_project_root(nested) assert result == tmp_path def test_returns_start_path_when_no_git(self, tmp_path: Path) -> None: """Returns start path when no .git found.""" nested = tmp_path / "some" / "path" nested.mkdir(parents=True) result = _find_project_root(nested) # Should return the path itself (or a parent) since no .git exists assert result == nested or nested.is_relative_to(result) def test_handles_root_level_git(self, tmp_path: Path) -> None: """Handles .git at the start path itself.""" git_dir = tmp_path / ".git" git_dir.mkdir() result = _find_project_root(tmp_path) assert result == tmp_path class TestSlashCommandController: """Tests for SlashCommandController.""" @pytest.fixture def mock_view(self) -> MagicMock: """Create a mock CompletionView.""" return MagicMock() @pytest.fixture def controller(self, mock_view: MagicMock) -> SlashCommandController: """Create a SlashCommandController with mock view.""" return SlashCommandController(SLASH_COMMANDS, mock_view) def test_can_handle_slash_prefix(self, controller: SlashCommandController) -> None: """Handles text starting with /.""" assert controller.can_handle("/", 1) is True assert controller.can_handle("/hel", 4) is True assert controller.can_handle("/help", 5) is True def test_cannot_handle_non_slash(self, controller: SlashCommandController) -> None: """Does not handle text not starting with /.""" assert controller.can_handle("hello", 5) is False assert controller.can_handle("", 0) is False assert controller.can_handle("test /cmd", 9) is False def test_filters_commands_by_prefix( self, controller: SlashCommandController, mock_view: MagicMock, ) -> None: """Filters commands based on typed prefix.""" controller.on_text_changed("/hel", 4) # Should have called render with /help suggestion mock_view.render_completion_suggestions.assert_called() suggestions = mock_view.render_completion_suggestions.call_args[0][0] assert any("/help" in s[0] for s in suggestions) def test_shows_all_commands_on_slash_only( self, controller: SlashCommandController, mock_view: MagicMock, ) -> None: """Shows all commands when just / is typed.""" controller.on_text_changed("/", 1) mock_view.render_completion_suggestions.assert_called() suggestions = mock_view.render_completion_suggestions.call_args[0][0] assert len(suggestions) == len(SLASH_COMMANDS) def test_clears_on_no_match( self, controller: SlashCommandController, mock_view: MagicMock, ) -> None: """Clears suggestions when no commands match after having suggestions.""" # First get some suggestions controller.on_text_changed("/h", 2) mock_view.render_completion_suggestions.assert_called() # Now type something that doesn't match - should clear controller.on_text_changed("/xyz", 4) mock_view.clear_completion_suggestions.assert_called() def test_reset_clears_state( self, controller: SlashCommandController, mock_view: MagicMock, ) -> None: """Reset clears suggestions and state.""" controller.on_text_changed("/h", 2) controller.reset() mock_view.clear_completion_suggestions.assert_called() class TestFuzzyFileControllerCanHandle: """Tests for FuzzyFileController.can_handle method.""" @pytest.fixture def mock_view(self) -> MagicMock: """Create a mock CompletionView.""" return MagicMock() @pytest.fixture def controller(self, mock_view: MagicMock, tmp_path: Path) -> FuzzyFileController: """Create a FuzzyFileController.""" return FuzzyFileController(mock_view, cwd=tmp_path) def test_handles_at_symbol(self, controller: FuzzyFileController) -> None: """Handles text with @ symbol.""" assert controller.can_handle("@", 1) is True assert controller.can_handle("@file", 5) is True assert controller.can_handle("look at @src/main.py", 20) is True def test_handles_at_mid_text(self, controller: FuzzyFileController) -> None: """Handles @ in middle of text.""" assert controller.can_handle("check @file", 11) is True assert controller.can_handle("see @", 5) is True def test_no_handle_without_at(self, controller: FuzzyFileController) -> None: """Does not handle text without @.""" assert controller.can_handle("hello", 5) is False assert controller.can_handle("", 0) is False def test_no_handle_at_after_cursor(self, controller: FuzzyFileController) -> None: """Does not handle @ that's after cursor position.""" assert controller.can_handle("hello @file", 5) is False def test_no_handle_space_after_at(self, controller: FuzzyFileController) -> None: """Does not handle @ followed by space before cursor.""" assert controller.can_handle("@ file", 6) is False assert controller.can_handle("@file name", 10) is False def test_invalid_cursor_positions(self, controller: FuzzyFileController) -> None: """Handles invalid cursor positions gracefully.""" assert controller.can_handle("@file", 0) is False assert controller.can_handle("@file", -1) is False assert controller.can_handle("@file", 100) is False class TestMultiCompletionManager: """Tests for MultiCompletionManager.""" @pytest.fixture def mock_view(self) -> MagicMock: """Create a mock CompletionView.""" return MagicMock() @pytest.fixture def manager(self, mock_view: MagicMock, tmp_path: Path) -> MultiCompletionManager: """Create a MultiCompletionManager with both controllers.""" slash_ctrl = SlashCommandController(SLASH_COMMANDS, mock_view) file_ctrl = FuzzyFileController(mock_view, cwd=tmp_path) return MultiCompletionManager([slash_ctrl, file_ctrl]) def test_activates_slash_controller_for_slash(self, manager: MultiCompletionManager) -> None: """Activates slash controller for / prefix.""" manager.on_text_changed("/help", 5) assert manager._active is not None assert isinstance(manager._active, SlashCommandController) def test_activates_file_controller_for_at(self, manager: MultiCompletionManager) -> None: """Activates file controller for @ prefix.""" manager.on_text_changed("@file", 5) assert manager._active is not None assert isinstance(manager._active, FuzzyFileController) def test_no_active_for_plain_text(self, manager: MultiCompletionManager) -> None: """No controller active for plain text.""" manager.on_text_changed("hello world", 11) assert manager._active is None def test_switches_controllers(self, manager: MultiCompletionManager) -> None: """Switches between controllers as input changes.""" manager.on_text_changed("/cmd", 4) assert isinstance(manager._active, SlashCommandController) manager.on_text_changed("@file", 5) assert isinstance(manager._active, FuzzyFileController) def test_reset_clears_active(self, manager: MultiCompletionManager) -> None: """Reset clears active controller.""" manager.on_text_changed("/cmd", 4) manager.reset() assert manager._active is None