|
3 | 3 | # |
4 | 4 | # SPDX-License-Identifier: MIT OR Apache-2.0 |
5 | 5 |
|
6 | | -from pathlib import Path |
7 | | -from unittest.mock import patch |
| 6 | +"""Unit tests for response summary generation in response.py.""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +from unittest.mock import MagicMock |
8 | 11 |
|
9 | 12 | import pytest |
10 | 13 |
|
11 | | -from codeweaver.core import ( |
12 | | - ChunkKind, |
13 | | - CodeChunk, |
14 | | - ConfigLanguage, |
15 | | - DiscoveredFile, |
16 | | - ExtCategory, |
17 | | - SearchStrategy, |
18 | | - SemanticSearchLanguage, |
19 | | - Span, |
20 | | -) |
21 | | -from codeweaver.core.utils import uuid7 |
22 | 14 | from codeweaver.server.agent_api.search.intent import IntentType |
23 | | -from codeweaver.server.agent_api.search.response import ( |
24 | | - build_success_response, |
25 | | - calculate_token_count, |
26 | | - extract_languages, |
27 | | - generate_summary, |
28 | | -) |
29 | | -from codeweaver.server.agent_api.search.types import CodeMatch, CodeMatchType |
30 | | - |
31 | | - |
32 | | -@pytest.fixture |
33 | | -def mock_get_indexer_state_info(): |
34 | | - with patch( |
35 | | - "codeweaver.server.agent_api.search.response.get_indexer_state_info" |
36 | | - ) as mock: |
37 | | - mock.return_value = ("unknown", None) |
38 | | - yield mock |
39 | | - |
40 | | - |
41 | | -@pytest.mark.unit |
42 | | -@pytest.mark.parametrize( |
43 | | - "strategy,expected_mode,expected_status,expected_warning", |
44 | | - [ |
45 | | - (SearchStrategy.HYBRID_SEARCH, "hybrid", "success", None), |
46 | | - ( |
47 | | - SearchStrategy.DENSE_ONLY, |
48 | | - "dense_only", |
49 | | - "success", |
50 | | - "Sparse embeddings unavailable - using dense search only", |
51 | | - ), |
52 | | - ( |
53 | | - SearchStrategy.SPARSE_ONLY, |
54 | | - "sparse_only", |
55 | | - "partial", |
56 | | - "Dense embeddings unavailable - using sparse search only (degraded mode)", |
57 | | - ), |
58 | | - ( |
59 | | - SearchStrategy.KEYWORD_FALLBACK, |
60 | | - "sparse_only", |
61 | | - "partial", |
62 | | - "Dense embeddings unavailable - using sparse search only (degraded mode)", |
63 | | - ), |
64 | | - ], |
65 | | -) |
66 | | -def test_build_success_response_search_modes( |
67 | | - strategy, expected_mode, expected_status, expected_warning, mock_get_indexer_state_info |
68 | | -): |
69 | | - """Test successful response building with different search strategies and modes.""" |
70 | | - response = build_success_response( |
71 | | - code_matches=[], |
72 | | - query="test", |
73 | | - intent_type=IntentType.UNDERSTAND, |
74 | | - total_candidates=0, |
75 | | - token_limit=1000, |
76 | | - execution_time_ms=10.0, |
77 | | - strategies_used=[strategy], |
78 | | - ) |
79 | | - |
80 | | - assert response.search_mode == expected_mode |
81 | | - assert response.status == expected_status |
82 | | - if expected_warning: |
83 | | - assert expected_warning in response.warnings |
84 | | - else: |
85 | | - assert not response.warnings |
86 | | - |
87 | | - |
88 | | -@pytest.fixture |
89 | | -def sample_code_matches(): |
90 | | - chunk1_id = uuid7() |
91 | | - chunk2_id = uuid7() |
92 | | - chunk3_id = uuid7() |
93 | | - |
94 | | - return [ |
95 | | - CodeMatch( |
96 | | - file=DiscoveredFile( |
97 | | - path=Path("src/main.py"), |
98 | | - ext_category=ExtCategory( |
99 | | - language=SemanticSearchLanguage.PYTHON, kind=ChunkKind.CODE |
100 | | - ), |
101 | | - mime_type="text/x-python", |
102 | | - ), |
103 | | - content=CodeChunk( |
104 | | - chunk_id=chunk1_id, |
105 | | - chunk_name="main.py:hello_world", |
106 | | - file_path=Path("src/main.py"), |
107 | | - language="python", |
108 | | - content="def hello_world():\n print('Hello World!')", |
109 | | - line_range=Span(start=1, end=2, source_id=chunk1_id), |
110 | | - ), |
111 | | - span=Span(start=1, end=2, source_id=chunk1_id), |
112 | | - relevance_score=0.9, |
113 | | - match_type=CodeMatchType.SEMANTIC, |
114 | | - ), |
115 | | - CodeMatch( |
116 | | - file=DiscoveredFile( |
117 | | - path=Path("src/utils.py"), |
118 | | - ext_category=ExtCategory( |
119 | | - language=SemanticSearchLanguage.PYTHON, kind=ChunkKind.CODE |
120 | | - ), |
121 | | - mime_type="text/x-python", |
122 | | - ), |
123 | | - content=CodeChunk( |
124 | | - chunk_id=chunk2_id, |
125 | | - chunk_name="utils.py:helper", |
126 | | - file_path=Path("src/utils.py"), |
127 | | - language="python", |
128 | | - content="def helper():\n pass", |
129 | | - line_range=Span(start=1, end=2, source_id=chunk2_id), |
130 | | - ), |
131 | | - span=Span(start=1, end=2, source_id=chunk2_id), |
132 | | - relevance_score=0.8, |
133 | | - match_type=CodeMatchType.SEMANTIC, |
134 | | - ), |
135 | | - CodeMatch( |
136 | | - file=DiscoveredFile( |
137 | | - path=Path("config.json"), |
138 | | - ext_category=ExtCategory( |
139 | | - language=ConfigLanguage.JSON, kind=ChunkKind.CONFIG |
140 | | - ), |
141 | | - mime_type="application/json", |
142 | | - ), |
143 | | - content=CodeChunk( |
144 | | - chunk_id=chunk3_id, |
145 | | - chunk_name="config.json", |
146 | | - file_path=Path("config.json"), |
147 | | - language="json", |
148 | | - content='{"key": "value"}', |
149 | | - line_range=Span(start=1, end=1, source_id=chunk3_id), |
150 | | - ), |
151 | | - span=Span(start=1, end=1, source_id=chunk3_id), |
152 | | - relevance_score=0.5, |
153 | | - match_type=CodeMatchType.KEYWORD, |
154 | | - ), |
155 | | - ] |
156 | | - |
157 | | - |
158 | | -@pytest.mark.unit |
159 | | -def test_calculate_token_count(sample_code_matches): |
160 | | - """Test token count calculation.""" |
161 | | - # First item: 4 words * 1.3 ≈ 5.2, rounded/overhead -> 6 tokens |
162 | | - # Second item: 3 words * 1.3 = 3.9 -> 3 |
163 | | - # Third item: 2 words * 1.3 = 2.6 -> 2 |
164 | | - count = calculate_token_count(sample_code_matches, token_limit=1000) |
165 | | - assert count == 11 |
166 | | - |
167 | | - # Test limit |
168 | | - count_capped = calculate_token_count(sample_code_matches, token_limit=10) |
169 | | - assert count_capped == 10 |
170 | | - |
171 | | - |
172 | | -@pytest.mark.unit |
173 | | -def test_generate_summary_empty(): |
174 | | - """Test summary generation with empty matches.""" |
175 | | - summary = generate_summary([], IntentType.UNDERSTAND, "test query") |
176 | | - assert summary == "No matches found for query: 'test query'" |
177 | | - |
178 | | - |
179 | | -@pytest.mark.unit |
180 | | -def test_generate_summary_populated(sample_code_matches): |
181 | | - """Test summary generation with matches.""" |
182 | | - summary = generate_summary(sample_code_matches, IntentType.DEBUG, "fix bug") |
183 | | - assert "Found 3 relevant matches for debug query" in summary |
184 | | - assert "Top results in" in summary |
185 | | - assert "main.py" in summary |
186 | | - assert "utils.py" in summary |
187 | | - |
188 | | - |
189 | | -@pytest.mark.unit |
190 | | -def test_extract_languages(sample_code_matches): |
191 | | - """Test language extraction filters out config languages.""" |
192 | | - languages = extract_languages(sample_code_matches) |
193 | | - assert len(languages) == 1 |
194 | | - assert languages[0] == SemanticSearchLanguage.PYTHON |
195 | | - |
196 | | - |
197 | | -@pytest.mark.unit |
198 | | -@patch("codeweaver.server.agent_api.search.response.FindCodeResponseSummary") |
199 | | -def test_build_success_response_with_matches(mock_response_summary, mock_get_indexer_state_info, sample_code_matches): |
200 | | - """Test full build_success_response integration with CodeMatch objects.""" |
201 | | - # We patch FindCodeResponseSummary so we can just verify the values passed to it, bypassing pydantic validation of fake objects |
202 | | - mock_response_summary.return_value = "MockedResponse" |
203 | | - response = build_success_response( |
204 | | - code_matches=sample_code_matches, |
205 | | - query="test code", |
206 | | - intent_type=IntentType.IMPLEMENT, |
207 | | - total_candidates=10, |
208 | | - token_limit=1000, |
209 | | - execution_time_ms=15.5, |
210 | | - strategies_used=[SearchStrategy.HYBRID_SEARCH], |
211 | | - ) |
212 | | - |
213 | | - assert response == "MockedResponse" |
214 | | - mock_response_summary.assert_called_once() |
215 | | - kwargs = mock_response_summary.call_args[1] |
216 | | - |
217 | | - assert kwargs["search_mode"] == "hybrid" |
218 | | - assert kwargs["status"] == "success" |
219 | | - assert len(kwargs["matches"]) == 3 |
220 | | - assert kwargs["total_matches"] == 10 |
221 | | - assert kwargs["total_results"] == 3 |
222 | | - assert "Found 3 relevant matches for implement query" in kwargs["summary"] |
223 | | - assert kwargs["token_count"] == 11 |
224 | | - assert kwargs["execution_time_ms"] == 15.5 |
225 | | - assert len(kwargs["languages_found"]) == 1 |
226 | | - assert kwargs["languages_found"][0] == SemanticSearchLanguage.PYTHON |
| 15 | +from codeweaver.server.agent_api.search.response import generate_summary |
| 16 | +from codeweaver.server.agent_api.search.types import CodeMatch |
| 17 | + |
| 18 | + |
| 19 | +def create_mock_match(file_name: str) -> MagicMock: |
| 20 | + """Helper to create a mock CodeMatch with a nested file.path.name. |
| 21 | +
|
| 22 | + Uses spec=CodeMatch to ensure the mock mirrors the actual interface. |
| 23 | + """ |
| 24 | + mock_match = MagicMock(spec=CodeMatch) |
| 25 | + mock_file = MagicMock() |
| 26 | + mock_path = MagicMock() |
| 27 | + mock_path.name = file_name |
| 28 | + mock_file.path = mock_path |
| 29 | + mock_match.file = mock_file |
| 30 | + return mock_match |
| 31 | + |
| 32 | + |
| 33 | +class TestGenerateSummary: |
| 34 | + """Test suite for generate_summary function.""" |
| 35 | + |
| 36 | + @pytest.mark.unit |
| 37 | + def test_generate_summary_empty_results(self) -> None: |
| 38 | + """Test summary generation when no matches are found.""" |
| 39 | + query = "how to do something" |
| 40 | + summary = generate_summary([], IntentType.UNDERSTAND, query) |
| 41 | + assert summary == f"No matches found for query: '{query}'" |
| 42 | + |
| 43 | + @pytest.mark.unit |
| 44 | + def test_generate_summary_with_results(self) -> None: |
| 45 | + """Test summary generation with search results.""" |
| 46 | + mock_match1 = create_mock_match("auth.py") |
| 47 | + mock_match2 = create_mock_match("models.py") |
| 48 | + |
| 49 | + matches = [mock_match1, mock_match2] |
| 50 | + query = "find auth models" |
| 51 | + |
| 52 | + summary = generate_summary(matches, IntentType.UNDERSTAND, query) |
| 53 | + |
| 54 | + assert "Found 2 relevant matches" in summary |
| 55 | + assert "for understand query" in summary |
| 56 | + assert "Top results in: " in summary |
| 57 | + assert "auth.py" in summary |
| 58 | + assert "models.py" in summary |
| 59 | + |
| 60 | + @pytest.mark.unit |
| 61 | + def test_generate_summary_unique_files_limit(self) -> None: |
| 62 | + """Test that summary only includes up to 3 unique file names from top results.""" |
| 63 | + # Create 5 matches with different files |
| 64 | + matches = [create_mock_match(f"file_{i}.py") for i in range(5)] |
| 65 | + |
| 66 | + summary = generate_summary(matches, IntentType.IMPLEMENT, "test query") |
| 67 | + |
| 68 | + # Should mention "Found 5 relevant matches" |
| 69 | + assert "Found 5 relevant matches" in summary |
| 70 | + # Should only list first 3 files: file_0.py, file_1.py, file_2.py |
| 71 | + assert "file_0.py" in summary |
| 72 | + assert "file_1.py" in summary |
| 73 | + assert "file_2.py" in summary |
| 74 | + assert "file_3.py" not in summary |
| 75 | + |
| 76 | + @pytest.mark.unit |
| 77 | + def test_generate_summary_duplicate_files(self) -> None: |
| 78 | + """Test that summary handles duplicate file names gracefully.""" |
| 79 | + # 3 matches in the same file |
| 80 | + m1 = create_mock_match("shared.py") |
| 81 | + m2 = create_mock_match("shared.py") |
| 82 | + m3 = create_mock_match("other.py") |
| 83 | + |
| 84 | + matches = [m1, m2, m3] |
| 85 | + summary = generate_summary(matches, IntentType.DEBUG, "fix bug") |
| 86 | + |
| 87 | + assert "Found 3 relevant matches" in summary |
| 88 | + # Should only list "shared.py" once and "other.py" |
| 89 | + assert "shared.py" in summary |
| 90 | + assert "other.py" in summary |
| 91 | + # Verify it doesn't look like "shared.py, shared.py, other.py" |
| 92 | + assert summary.count("shared.py") == 1 |
| 93 | + |
| 94 | + @pytest.mark.unit |
| 95 | + def test_generate_summary_truncation(self) -> None: |
| 96 | + """Test that summary is truncated to 1000 characters.""" |
| 97 | + # Create a match with an extremely long file name to trigger truncation |
| 98 | + long_name = "a" * 2000 |
| 99 | + m = create_mock_match(long_name) |
| 100 | + |
| 101 | + summary = generate_summary([m], IntentType.OPTIMIZE, "fast") |
| 102 | + |
| 103 | + # Assert exact maximum length |
| 104 | + assert len(summary) == 1000 |
| 105 | + |
| 106 | + # Verify it starts with the expected prefix |
| 107 | + expected_prefix = "Found 1 relevant matches for optimize query. Top results in: " |
| 108 | + assert summary.startswith(expected_prefix) |
| 109 | + |
| 110 | + # Verify it ends with the truncated content of the long name |
| 111 | + remaining_length = 1000 - len(expected_prefix) |
| 112 | + expected_suffix = long_name[:remaining_length] |
| 113 | + assert summary.endswith(expected_suffix) |
0 commit comments