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