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