Files
deepagent/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_autocomplete.py
HyunjunJeon af5fbfabec 문서 추가: Context Engineering 문서 추가 및 deepagents_sourcecode 한국어 번역
- Context_Engineering.md: 에이전트 컨텍스트 엔지니어링 개념 정리 문서 추가
- Context_Engineering_Research.ipynb: 연구 노트북 업데이트
- deepagents_sourcecode/: docstring과 주석을 한국어로 번역
2026-01-11 17:55:52 +09:00

346 lines
13 KiB
Python

"""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