gabriel / muse public
test_core_test_selection.py python
316 lines 11.6 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """Tests for muse.core.test_selection — symbol-graph–driven test selection.
2
3 Coverage:
4 - Unit tests for every internal helper (_is_test_file, _is_test_function,
5 _confidence, _build_coverage_map).
6 - Integration tests for select_tests using a synthetic in-memory snapshot.
7 - Integration tests for changed_symbols_from_diff.
8 - Edge cases: empty diff, no test files, depth=0, fallback heuristics.
9 """
10
11 from __future__ import annotations
12
13 import pathlib
14 import tempfile
15
16 import pytest
17
18
19 from muse.core.types import Manifest
20 from muse.core.paths import muse_dir
21 from muse.core.test_selection import (
22 ChangedSymbol,
23 SelectionResult,
24 SelectionTarget,
25 _confidence,
26 _is_test_file,
27 _is_test_function,
28 changed_symbols_from_diff,
29 select_tests,
30 )
31
32
33 # ---------------------------------------------------------------------------
34 # Unit tests — _is_test_file
35 # ---------------------------------------------------------------------------
36
37
38 class TestIsTestFile:
39 def test_test_prefix(self) -> None:
40 assert _is_test_file("tests/test_foo.py") is True
41
42 def test_test_suffix(self) -> None:
43 assert _is_test_file("src/foo_test.py") is True
44
45 def test_conftest(self) -> None:
46 assert _is_test_file("tests/conftest.py") is True
47
48 def test_production_file(self) -> None:
49 assert _is_test_file("muse/core/store.py") is False
50
51 def test_nested_production_file(self) -> None:
52 # muse/cli/commands/test_cmd.py has stem "test_cmd" which starts
53 # with "test_" — the heuristic correctly treats it as a test file.
54 assert _is_test_file("muse/cli/commands/test_cmd.py") is True
55
56 def test_non_test_stem_in_nested_path(self) -> None:
57 assert _is_test_file("muse/cli/commands/commit.py") is False
58
59 def test_empty_string(self) -> None:
60 assert _is_test_file("") is False
61
62
63 # ---------------------------------------------------------------------------
64 # Unit tests — _is_test_function
65 # ---------------------------------------------------------------------------
66
67
68 class TestIsTestFunction:
69 def test_test_function(self) -> None:
70 assert _is_test_function("tests/test_foo.py::test_bar", "function") is True
71
72 def test_test_method(self) -> None:
73 assert _is_test_function("tests/test_foo.py::TestFoo::test_bar", "method") is True
74
75 def test_non_test_function(self) -> None:
76 assert _is_test_function("muse/core/store.py::read_commit", "function") is False
77
78 def test_wrong_kind(self) -> None:
79 assert _is_test_function("tests/test_foo.py::test_bar", "class") is False
80
81 def test_no_double_colon(self) -> None:
82 assert _is_test_function("tests/test_foo.py", "function") is False
83
84 def test_async_function(self) -> None:
85 assert _is_test_function("tests/test_foo.py::test_async", "async_function") is True
86
87 def test_async_method(self) -> None:
88 assert _is_test_function("tests/test_foo.py::TestClass::test_async", "async_method") is True
89
90
91 # ---------------------------------------------------------------------------
92 # Unit tests — _confidence
93 # ---------------------------------------------------------------------------
94
95
96 class TestConfidence:
97 def test_depth_0(self) -> None:
98 assert _confidence(0) == 1.0
99
100 def test_depth_1(self) -> None:
101 assert _confidence(1) == 1.0
102
103 def test_depth_2(self) -> None:
104 assert _confidence(2) == 0.9
105
106 def test_depth_3(self) -> None:
107 assert _confidence(3) == 0.7
108
109 def test_depth_5(self) -> None:
110 assert _confidence(5) == 0.7
111
112
113 # ---------------------------------------------------------------------------
114 # Integration tests — select_tests (with real repo structure)
115 # ---------------------------------------------------------------------------
116
117
118
119
120 class TestSelectTests:
121 def test_empty_changed(self, tmp_path: pathlib.Path) -> None:
122 """Empty changed list returns an empty result with 100% coverage."""
123 root, manifest = _make_minimal_repo(tmp_path)
124 result = select_tests(root, [], manifest)
125 assert result["changed_addresses"] == []
126 assert result["test_targets"] == []
127 assert result["coverage_fraction"] == 1.0
128 assert result["fallback_used"] is False
129
130 def test_unknown_symbol_uses_fallback(self, tmp_path: pathlib.Path) -> None:
131 """A changed symbol with no call-graph coverage falls back to file name."""
132 root, manifest = _make_minimal_repo(tmp_path)
133 changed: list[ChangedSymbol] = [
134 ChangedSymbol(
135 address="prod.py::some_unknown_function",
136 change_kind="modified",
137 )
138 ]
139 result = select_tests(root, changed, manifest)
140 # Should still try the file-name heuristic:
141 # "prod.py" stem "prod" → test file "tests/test_prod.py"
142 assert result["fallback_used"] is True
143 assert len(result["uncovered_addresses"]) == 0 or len(result["test_targets"]) >= 0
144 # Coverage fraction should be 0 or 1 (heuristic filled it)
145 assert 0.0 <= result["coverage_fraction"] <= 1.0
146
147 def test_selection_result_structure(self, tmp_path: pathlib.Path) -> None:
148 """SelectionResult has all required fields with correct types."""
149 root, manifest = _make_minimal_repo(tmp_path)
150 changed: list[ChangedSymbol] = [
151 ChangedSymbol(address="prod.py::compute", change_kind="modified")
152 ]
153 result = select_tests(root, changed, manifest)
154
155 assert isinstance(result["changed_addresses"], list)
156 assert isinstance(result["test_targets"], list)
157 assert isinstance(result["covered_addresses"], list)
158 assert isinstance(result["uncovered_addresses"], list)
159 assert isinstance(result["coverage_fraction"], float)
160 assert isinstance(result["fallback_used"], bool)
161 assert 0.0 <= result["coverage_fraction"] <= 1.0
162
163 def test_targets_have_confidence(self, tmp_path: pathlib.Path) -> None:
164 """Every SelectionTarget has a confidence in [0.0, 1.0]."""
165 root, manifest = _make_minimal_repo(tmp_path)
166 changed: list[ChangedSymbol] = [
167 ChangedSymbol(address="prod.py::compute", change_kind="modified")
168 ]
169 result = select_tests(root, changed, manifest)
170 for target in result["test_targets"]:
171 assert 0.0 <= target["confidence"] <= 1.0
172 assert isinstance(target["node_id"], str)
173 assert isinstance(target["file"], str)
174 assert isinstance(target["reason"], str)
175
176 def test_added_symbol_is_uncovered_when_no_test(
177 self, tmp_path: pathlib.Path
178 ) -> None:
179 """A newly added symbol with no test shows in uncovered_addresses."""
180 root, manifest = _make_minimal_repo(tmp_path)
181 changed: list[ChangedSymbol] = [
182 ChangedSymbol(
183 address="prod.py::brand_new_function",
184 change_kind="added",
185 )
186 ]
187 result = select_tests(root, changed, manifest)
188 # The symbol has no callers → it may be uncovered OR covered by fallback
189 assert "prod.py::brand_new_function" in result["changed_addresses"]
190
191 def test_deleted_symbol_appears_in_changed(
192 self, tmp_path: pathlib.Path
193 ) -> None:
194 """A deleted symbol still appears in changed_addresses."""
195 root, manifest = _make_minimal_repo(tmp_path)
196 changed: list[ChangedSymbol] = [
197 ChangedSymbol(address="prod.py::compute", change_kind="deleted")
198 ]
199 result = select_tests(root, changed, manifest)
200 assert "prod.py::compute" in result["changed_addresses"]
201
202 def test_depth_cap(self, tmp_path: pathlib.Path) -> None:
203 """depth > 10 is capped to 10 (does not raise)."""
204 root, manifest = _make_minimal_repo(tmp_path)
205 changed: list[ChangedSymbol] = [
206 ChangedSymbol(address="prod.py::compute", change_kind="modified")
207 ]
208 result = select_tests(root, changed, manifest, depth=50)
209 assert isinstance(result, dict)
210
211
212 # ---------------------------------------------------------------------------
213 # Integration tests — changed_symbols_from_diff
214 # ---------------------------------------------------------------------------
215
216
217 class TestChangedSymbolsFromDiff:
218 def test_no_change_when_workdir_matches(self, tmp_path: pathlib.Path) -> None:
219 """When working tree matches HEAD, no changed symbols are returned."""
220 root, manifest = _make_minimal_repo(tmp_path)
221 result = changed_symbols_from_diff(root, manifest)
222 # Working tree files are the same as in the object store → no changes.
223 # (Since _make_minimal_repo writes the files both as objects and to disk)
224 assert isinstance(result, list)
225
226 def test_returns_list_of_changed_symbols(self, tmp_path: pathlib.Path) -> None:
227 """Return type is always list[ChangedSymbol]."""
228 root, manifest = _make_minimal_repo(tmp_path)
229 result = changed_symbols_from_diff(root, manifest)
230 for item in result:
231 assert "address" in item
232 assert "change_kind" in item
233 assert item["change_kind"] in {"modified", "added", "deleted"}
234
235 def test_modified_file_produces_changed(self, tmp_path: pathlib.Path) -> None:
236 """Editing a file on disk produces 'modified' entries in the diff."""
237 root, manifest = _make_minimal_repo(tmp_path)
238
239 # Write a different version of prod.py to disk.
240 new_src = b"""\
241 def compute(x: int) -> int:
242 # Extra line to make this body different
243 return x * 2 + 0
244
245 def helper() -> int:
246 return 42
247 """
248 (root / "prod.py").write_bytes(new_src)
249
250 result = changed_symbols_from_diff(root, manifest)
251 addresses = [c["address"] for c in result]
252 # "compute" has a different body now → should appear
253 modified_addrs = [
254 c["address"] for c in result if c["change_kind"] == "modified"
255 ]
256 assert any("compute" in addr for addr in modified_addrs)
257
258 def test_new_file_produces_added(self, tmp_path: pathlib.Path) -> None:
259 """A new file on disk (not in manifest) is not in result (manifest-based)."""
260 root, manifest = _make_minimal_repo(tmp_path)
261
262 # Write a new file that isn't in the manifest — should be invisible.
263 (root / "new_file.py").write_bytes(b"def new_func() -> None: pass\n")
264
265 result = changed_symbols_from_diff(root, manifest)
266 addresses = [c["address"] for c in result]
267 assert not any("new_file.py" in addr for addr in addresses)
268
269
270 # ---------------------------------------------------------------------------
271 # Helpers
272 # ---------------------------------------------------------------------------
273
274
275 def _make_minimal_repo(
276 tmp_path: pathlib.Path,
277 ) -> tuple[pathlib.Path, dict[str, str]]:
278 """Create a minimal .muse repo with prod.py and tests/test_prod.py."""
279 from muse.core.object_store import write_object
280
281 dot_muse = muse_dir(tmp_path)
282 dot_muse.mkdir(exist_ok=True)
283
284 prod_src = b"""\
285 def compute(x: int) -> int:
286 return x * 2
287
288 def helper() -> int:
289 return 42
290 """
291 test_src = b"""\
292 from prod import compute
293
294 def test_compute() -> None:
295 assert compute(2) == 4
296 """
297
298 from muse.core.types import blob_id
299
300 prod_oid = blob_id(prod_src)
301 test_oid = blob_id(test_src)
302
303 write_object(tmp_path, prod_oid, prod_src)
304 write_object(tmp_path, test_oid, test_src)
305
306 # Also write to disk so working-tree diff can read them.
307 (tmp_path / "prod.py").write_bytes(prod_src)
308 tests_dir = tmp_path / "tests"
309 tests_dir.mkdir(exist_ok=True)
310 (tests_dir / "test_prod.py").write_bytes(test_src)
311
312 manifest: Manifest = {
313 "prod.py": prod_oid,
314 "tests/test_prod.py": test_oid,
315 }
316 return tmp_path, manifest
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago