gabriel / muse public
test_entangle_supercharge.py python
356 lines 14.1 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """Supercharge tests for ``muse code entangle`` — agent-usability gaps.
2
3 The existing TestEntangle in test_code_commands.py covers correctness,
4 JSON schema, pair schema, co-change rate, filters (--top, --min-co-changes,
5 --min-rate, --symbol, --since, --include-same-file), sorting, and validation.
6
7 This file targets only the gaps those tests leave open:
8
9 Coverage matrix
10 ---------------
11 - --json / -j: -j alias works identically to --json
12 - exit_code: JSON output includes exit_code = 0 on success
13 - duration_ms: JSON output includes non-negative float duration_ms
14 - TypedDicts: _EntangleOutputJson carries exit_code/duration_ms annotations
15 - Docstrings: run() docstring mentions exit_code and duration_ms
16 - ANSI: JSON output never contains terminal escape sequences
17 - Performance: duration_ms stays under 2000 ms for a small repo
18 """
19
20 from __future__ import annotations
21
22 import json
23 import pathlib
24 import textwrap
25
26 import pytest
27
28 from tests.cli_test_helper import CliRunner
29
30 runner = CliRunner()
31
32
33 # ---------------------------------------------------------------------------
34 # Helpers
35 # ---------------------------------------------------------------------------
36
37
38 def _env(root: pathlib.Path) -> dict[str, str]:
39 return {"MUSE_REPO_ROOT": str(root)}
40
41
42 def _run(root: pathlib.Path, *args: str):
43 return runner.invoke(None, list(args), env=_env(root))
44
45
46 # ---------------------------------------------------------------------------
47 # Fixture — two files that co-change with no import link
48 # ---------------------------------------------------------------------------
49
50
51 @pytest.fixture()
52 def entangle_repo(
53 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
54 ) -> pathlib.Path:
55 """Repo where billing.py::Invoice and serializers.py::to_json co-change
56 twice but share no import link — a textbook entanglement.
57
58 Commit 1 — seed both files.
59 Commit 2 — both symbols change together (co-change #1).
60 Commit 3 — both symbols change again (co-change #2).
61 """
62 monkeypatch.chdir(tmp_path)
63 r = _run(tmp_path, "init", "--domain", "code")
64 assert r.exit_code == 0, r.output
65
66 # commit 1 — seed
67 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
68 class Invoice:
69 def compute_total(self, items):
70 return sum(items)
71 """))
72 (tmp_path / "serializers.py").write_text(textwrap.dedent("""\
73 def to_json(obj):
74 return str(obj)
75 """))
76 r = _run(tmp_path, "code", "add", ".")
77 assert r.exit_code == 0, r.output
78 r = _run(tmp_path, "commit", "-m", "seed")
79 assert r.exit_code == 0, r.output
80
81 # commit 2 — co-change #1
82 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
83 class Invoice:
84 def compute_total(self, items):
85 return round(sum(items), 2)
86 """))
87 (tmp_path / "serializers.py").write_text(textwrap.dedent("""\
88 def to_json(obj):
89 import json
90 return json.dumps(obj)
91 """))
92 r = _run(tmp_path, "code", "add", ".")
93 assert r.exit_code == 0, r.output
94 r = _run(tmp_path, "commit", "-m", "co-change 1")
95 assert r.exit_code == 0, r.output
96
97 # commit 3 — co-change #2
98 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
99 class Invoice:
100 def compute_total(self, items):
101 return round(sum(items), 4)
102 """))
103 (tmp_path / "serializers.py").write_text(textwrap.dedent("""\
104 def to_json(obj):
105 import json
106 return json.dumps(obj, indent=2)
107 """))
108 r = _run(tmp_path, "code", "add", ".")
109 assert r.exit_code == 0, r.output
110 r = _run(tmp_path, "commit", "-m", "co-change 2")
111 assert r.exit_code == 0, r.output
112
113 return tmp_path
114
115
116 # ---------------------------------------------------------------------------
117 # TestJsonAlias — -j works identically to --json
118 # ---------------------------------------------------------------------------
119
120
121 class TestJsonAlias:
122 """-j shorthand must behave identically to --json."""
123
124 def test_j_alias_exits_zero(self, entangle_repo: pathlib.Path) -> None:
125 r = _run(entangle_repo, "code", "entangle", "-j")
126 assert r.exit_code == 0, r.output
127
128 def test_j_alias_valid_json(self, entangle_repo: pathlib.Path) -> None:
129 r = _run(entangle_repo, "code", "entangle", "-j")
130 json.loads(r.output) # must not raise
131
132 def test_j_alias_has_pairs_key(self, entangle_repo: pathlib.Path) -> None:
133 r = _run(entangle_repo, "code", "entangle", "-j")
134 assert "pairs" in json.loads(r.output)
135
136 def test_j_alias_has_commits_analysed_key(self, entangle_repo: pathlib.Path) -> None:
137 r = _run(entangle_repo, "code", "entangle", "-j")
138 assert "commits_analysed" in json.loads(r.output)
139
140 def test_j_alias_has_filters_key(self, entangle_repo: pathlib.Path) -> None:
141 r = _run(entangle_repo, "code", "entangle", "-j")
142 assert "filters" in json.loads(r.output)
143
144 def test_j_alias_same_top_level_keys_as_json_flag(
145 self, entangle_repo: pathlib.Path
146 ) -> None:
147 r1 = _run(entangle_repo, "code", "entangle", "--json")
148 r2 = _run(entangle_repo, "code", "entangle", "-j")
149 d1 = json.loads(r1.output)
150 d2 = json.loads(r2.output)
151 d1.pop("duration_ms", None)
152 d2.pop("duration_ms", None)
153 assert set(d1.keys()) == set(d2.keys())
154
155 def test_j_alias_pair_count_matches_json_flag(
156 self, entangle_repo: pathlib.Path
157 ) -> None:
158 r1 = _run(entangle_repo, "code", "entangle", "--json", "--min-co-changes", "1")
159 r2 = _run(entangle_repo, "code", "entangle", "-j", "--min-co-changes", "1")
160 assert len(json.loads(r1.output)["pairs"]) == len(json.loads(r2.output)["pairs"])
161
162 def test_j_alias_with_min_co_changes(self, entangle_repo: pathlib.Path) -> None:
163 r = _run(entangle_repo, "code", "entangle", "-j", "--min-co-changes", "1")
164 assert r.exit_code == 0, r.output
165 data = json.loads(r.output)
166 assert data["filters"]["min_co_changes"] == 1
167
168 def test_j_alias_with_top_filter(self, entangle_repo: pathlib.Path) -> None:
169 r = _run(entangle_repo, "code", "entangle", "-j", "--top", "5")
170 assert r.exit_code == 0, r.output
171 assert len(json.loads(r.output)["pairs"]) <= 5
172
173
174 # ---------------------------------------------------------------------------
175 # TestDurationMs — JSON output must include duration_ms
176 # ---------------------------------------------------------------------------
177
178
179 class TestDurationMs:
180 """JSON output must include a non-negative float duration_ms."""
181
182 def test_json_has_duration_ms(self, entangle_repo: pathlib.Path) -> None:
183 r = _run(entangle_repo, "code", "entangle", "--json")
184 assert "duration_ms" in json.loads(r.output)
185
186 def test_json_duration_ms_nonnegative(self, entangle_repo: pathlib.Path) -> None:
187 r = _run(entangle_repo, "code", "entangle", "--json")
188 assert json.loads(r.output)["duration_ms"] >= 0
189
190 def test_json_duration_ms_is_float(self, entangle_repo: pathlib.Path) -> None:
191 r = _run(entangle_repo, "code", "entangle", "--json")
192 assert isinstance(json.loads(r.output)["duration_ms"], float)
193
194 def test_j_alias_duration_ms_present(self, entangle_repo: pathlib.Path) -> None:
195 r = _run(entangle_repo, "code", "entangle", "-j")
196 assert "duration_ms" in json.loads(r.output)
197
198 def test_duration_ms_with_min_co_changes(self, entangle_repo: pathlib.Path) -> None:
199 r = _run(entangle_repo, "code", "entangle", "--json", "--min-co-changes", "1")
200 data = json.loads(r.output)
201 assert "duration_ms" in data
202 assert data["duration_ms"] >= 0
203
204 def test_duration_ms_with_min_rate(self, entangle_repo: pathlib.Path) -> None:
205 r = _run(entangle_repo, "code", "entangle", "--json", "--min-rate", "0.5",
206 "--min-co-changes", "1")
207 data = json.loads(r.output)
208 assert "duration_ms" in data
209 assert isinstance(data["duration_ms"], float)
210
211
212 # ---------------------------------------------------------------------------
213 # TestExitCode — JSON includes exit_code = 0 on success
214 # ---------------------------------------------------------------------------
215
216
217 class TestExitCode:
218 """JSON exit_code must be 0 on success."""
219
220 def test_json_has_exit_code(self, entangle_repo: pathlib.Path) -> None:
221 r = _run(entangle_repo, "code", "entangle", "--json")
222 assert "exit_code" in json.loads(r.output)
223
224 def test_json_exit_code_zero(self, entangle_repo: pathlib.Path) -> None:
225 r = _run(entangle_repo, "code", "entangle", "--json")
226 assert r.exit_code == 0
227 assert json.loads(r.output)["exit_code"] == 0
228
229 def test_json_exit_code_is_int(self, entangle_repo: pathlib.Path) -> None:
230 r = _run(entangle_repo, "code", "entangle", "--json")
231 assert isinstance(json.loads(r.output)["exit_code"], int)
232
233 def test_j_alias_exit_code_present(self, entangle_repo: pathlib.Path) -> None:
234 r = _run(entangle_repo, "code", "entangle", "-j")
235 assert "exit_code" in json.loads(r.output)
236
237 def test_exit_code_mirrors_process_exit(self, entangle_repo: pathlib.Path) -> None:
238 r = _run(entangle_repo, "code", "entangle", "--json")
239 assert json.loads(r.output)["exit_code"] == r.exit_code
240
241 def test_exit_code_zero_with_min_co_changes(
242 self, entangle_repo: pathlib.Path
243 ) -> None:
244 r = _run(entangle_repo, "code", "entangle", "--json", "--min-co-changes", "1")
245 assert r.exit_code == 0
246 assert json.loads(r.output)["exit_code"] == 0
247
248 def test_exit_code_zero_with_top_filter(self, entangle_repo: pathlib.Path) -> None:
249 r = _run(entangle_repo, "code", "entangle", "--json", "--top", "5")
250 assert r.exit_code == 0
251 assert json.loads(r.output)["exit_code"] == 0
252
253 def test_exit_code_zero_empty_result(self, entangle_repo: pathlib.Path) -> None:
254 """exit_code is 0 even when no pairs meet the threshold."""
255 r = _run(entangle_repo, "code", "entangle", "--json", "--min-co-changes", "999")
256 assert r.exit_code == 0
257 data = json.loads(r.output)
258 assert data["exit_code"] == 0
259 assert data["pairs"] == []
260
261
262 # ---------------------------------------------------------------------------
263 # TestTypedDicts — _EntangleOutputJson carries exit_code/duration_ms
264 # ---------------------------------------------------------------------------
265
266
267 class TestTypedDicts:
268 """_EntangleOutputJson must carry exit_code and duration_ms annotations."""
269
270 def test_entangle_output_json_typeddict_exists(self) -> None:
271 from muse.cli.commands.entangle import _EntangleOutputJson # noqa: F401
272
273 def test_has_exit_code_annotation(self) -> None:
274 from muse.cli.commands.entangle import _EntangleOutputJson
275 assert "exit_code" in _EntangleOutputJson.__annotations__
276
277 def test_has_duration_ms_annotation(self) -> None:
278 from muse.cli.commands.entangle import _EntangleOutputJson
279 assert "duration_ms" in _EntangleOutputJson.__annotations__
280
281 def test_retains_pairs_annotation(self) -> None:
282 from muse.cli.commands.entangle import _EntangleOutputJson
283 assert "pairs" in _EntangleOutputJson.__annotations__
284
285 def test_retains_commits_analysed_annotation(self) -> None:
286 from muse.cli.commands.entangle import _EntangleOutputJson
287 assert "commits_analysed" in _EntangleOutputJson.__annotations__
288
289 def test_retains_truncated_annotation(self) -> None:
290 from muse.cli.commands.entangle import _EntangleOutputJson
291 assert "truncated" in _EntangleOutputJson.__annotations__
292
293 def test_retains_filters_annotation(self) -> None:
294 from muse.cli.commands.entangle import _EntangleOutputJson
295 assert "filters" in _EntangleOutputJson.__annotations__
296
297
298 # ---------------------------------------------------------------------------
299 # TestDocstrings — run() docstring documents exit_code and duration_ms
300 # ---------------------------------------------------------------------------
301
302
303 class TestDocstrings:
304 """run() must document exit_code and duration_ms."""
305
306 def test_run_docstring_mentions_exit_code(self) -> None:
307 from muse.cli.commands.entangle import run
308 assert run.__doc__ is not None
309 assert "exit_code" in run.__doc__
310
311 def test_run_docstring_mentions_duration_ms(self) -> None:
312 from muse.cli.commands.entangle import run
313 assert run.__doc__ is not None
314 assert "duration_ms" in run.__doc__
315
316
317 # ---------------------------------------------------------------------------
318 # TestAnsiSanitization — no escape codes in JSON output
319 # ---------------------------------------------------------------------------
320
321
322 class TestAnsiSanitization:
323 """No ANSI escape sequences anywhere in the JSON output."""
324
325 def test_json_output_no_ansi(self, entangle_repo: pathlib.Path) -> None:
326 r = _run(entangle_repo, "code", "entangle", "--json")
327 assert "\x1b" not in r.output
328
329 def test_j_alias_output_no_ansi(self, entangle_repo: pathlib.Path) -> None:
330 r = _run(entangle_repo, "code", "entangle", "-j")
331 assert "\x1b" not in r.output
332
333 def test_json_output_no_ansi_with_results(self, entangle_repo: pathlib.Path) -> None:
334 r = _run(entangle_repo, "code", "entangle", "--json", "--min-co-changes", "1")
335 assert "\x1b" not in r.output
336
337
338 # ---------------------------------------------------------------------------
339 # TestPerformance — duration_ms under 2000 ms for a small repo
340 # ---------------------------------------------------------------------------
341
342
343 class TestPerformance:
344 """duration_ms must stay under 2000 ms for small repos."""
345
346 def test_json_duration_under_2000ms(self, entangle_repo: pathlib.Path) -> None:
347 r = _run(entangle_repo, "code", "entangle", "--json")
348 assert json.loads(r.output)["duration_ms"] < 2000
349
350 def test_j_alias_duration_under_2000ms(self, entangle_repo: pathlib.Path) -> None:
351 r = _run(entangle_repo, "code", "entangle", "-j")
352 assert json.loads(r.output)["duration_ms"] < 2000
353
354 def test_duration_ms_is_float_not_int(self, entangle_repo: pathlib.Path) -> None:
355 r = _run(entangle_repo, "code", "entangle", "--json")
356 assert isinstance(json.loads(r.output)["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago