gabriel / muse public
test_detect_refactor_supercharge.py python
352 lines 14.2 KB
Raw
1 """Supercharge tests for ``muse code detect-refactor`` — agent-usability gaps.
2
3 The existing TestDetectRefactorV2 in test_code_commands.py covers correctness,
4 JSON schema, event schema, rename detection, implementation classification,
5 reformatted-skip, truncation, --kind filter, invalid kind, and BFS
6 merge-parent-2 traversal.
7
8 This file targets only the gaps those tests leave open:
9
10 Coverage matrix
11 ---------------
12 - --json / -j: -j alias works identically to --json
13 - exit_code: JSON output includes exit_code = 0 on success
14 - duration_ms: JSON output includes non-negative float duration_ms
15 - TypedDicts: _RefactorOutputJson carries exit_code/duration_ms annotations
16 - Docstrings: run() docstring mentions exit_code and duration_ms
17 - ANSI: JSON output never contains terminal escape sequences
18 - Performance: duration_ms stays under 2000 ms for a small repo
19 """
20
21 from __future__ import annotations
22 from collections.abc import Mapping
23
24 import argparse
25
26 import json
27 import pathlib
28 import textwrap
29
30 import pytest
31
32 from tests.cli_test_helper import CliRunner, InvokeResult
33
34 runner = CliRunner()
35
36
37 # ---------------------------------------------------------------------------
38 # Helpers
39 # ---------------------------------------------------------------------------
40
41
42 def _env(root: pathlib.Path) -> Mapping[str, str]:
43 return {"MUSE_REPO_ROOT": str(root)}
44
45
46 def _run(root: pathlib.Path, *args: str) -> InvokeResult:
47 return runner.invoke(None, list(args), env=_env(root))
48
49
50 # ---------------------------------------------------------------------------
51 # Fixture — repo with a rename event and an implementation-change event
52 # ---------------------------------------------------------------------------
53
54
55 @pytest.fixture()
56 def refactor_repo(
57 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
58 ) -> pathlib.Path:
59 """Repo with two commits that produce detectable refactoring events.
60
61 Commit 1 — seed: billing.py with compute_total + validate_amount.
62 Commit 2 — refactor: compute_total renamed to calculate_total;
63 validate_amount body changed (implementation).
64
65 The same body hash under a new name triggers a rename event.
66 A body change under the same name triggers an implementation event.
67 """
68 monkeypatch.chdir(tmp_path)
69 r = _run(tmp_path, "init", "--domain", "code")
70 assert r.exit_code == 0, r.output
71
72 # commit 1 — seed
73 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
74 def compute_total(items):
75 return sum(items)
76
77 def validate_amount(amount):
78 return amount > 0
79 """))
80 r = _run(tmp_path, "code", "add", ".")
81 assert r.exit_code == 0, r.output
82 r = _run(tmp_path, "commit", "-m", "initial: add billing functions")
83 assert r.exit_code == 0, r.output
84
85 # commit 2 — rename compute_total → calculate_total; change validate_amount body
86 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
87 def calculate_total(items):
88 return sum(items)
89
90 def validate_amount(amount):
91 return amount >= 0
92 """))
93 r = _run(tmp_path, "code", "add", ".")
94 assert r.exit_code == 0, r.output
95 r = _run(tmp_path, "commit", "-m", "refactor: rename compute_total, tighten validate")
96 assert r.exit_code == 0, r.output
97
98 return tmp_path
99
100
101 # ---------------------------------------------------------------------------
102 # TestJsonAlias — -j works identically to --json
103 # ---------------------------------------------------------------------------
104
105
106 class TestJsonAlias:
107 """-j shorthand must behave identically to --json."""
108
109 def test_j_alias_exits_zero(self, refactor_repo: pathlib.Path) -> None:
110 r = _run(refactor_repo, "code", "detect-refactor", "-j")
111 assert r.exit_code == 0, r.output
112
113 def test_j_alias_valid_json(self, refactor_repo: pathlib.Path) -> None:
114 r = _run(refactor_repo, "code", "detect-refactor", "-j")
115 json.loads(r.output) # must not raise
116
117 def test_j_alias_has_events_key(self, refactor_repo: pathlib.Path) -> None:
118 r = _run(refactor_repo, "code", "detect-refactor", "-j")
119 assert "events" in json.loads(r.output)
120
121 def test_j_alias_has_commits_scanned_key(self, refactor_repo: pathlib.Path) -> None:
122 r = _run(refactor_repo, "code", "detect-refactor", "-j")
123 assert "commits_scanned" in json.loads(r.output)
124
125 def test_j_alias_has_total_key(self, refactor_repo: pathlib.Path) -> None:
126 r = _run(refactor_repo, "code", "detect-refactor", "-j")
127 assert "total" in json.loads(r.output)
128
129 def test_j_alias_same_top_level_keys_as_json_flag(
130 self, refactor_repo: pathlib.Path
131 ) -> None:
132 r1 = _run(refactor_repo, "code", "detect-refactor", "--json")
133 r2 = _run(refactor_repo, "code", "detect-refactor", "-j")
134 d1 = json.loads(r1.output)
135 d2 = json.loads(r2.output)
136 d1.pop("duration_ms", None)
137 d2.pop("duration_ms", None)
138 assert set(d1.keys()) == set(d2.keys())
139
140 def test_j_alias_event_count_matches_json_flag(
141 self, refactor_repo: pathlib.Path
142 ) -> None:
143 r1 = _run(refactor_repo, "code", "detect-refactor", "--json")
144 r2 = _run(refactor_repo, "code", "detect-refactor", "-j")
145 assert len(json.loads(r1.output)["events"]) == len(json.loads(r2.output)["events"])
146
147 def test_j_alias_with_kind_filter(self, refactor_repo: pathlib.Path) -> None:
148 r = _run(refactor_repo, "code", "detect-refactor", "-j", "--kind", "implementation")
149 assert r.exit_code == 0, r.output
150 data = json.loads(r.output)
151 for ev in data["events"]:
152 assert ev["kind"] == "implementation"
153
154 def test_j_alias_with_max_filter(self, refactor_repo: pathlib.Path) -> None:
155 r = _run(refactor_repo, "code", "detect-refactor", "-j", "--max", "10")
156 assert r.exit_code == 0, r.output
157 json.loads(r.output) # valid JSON
158
159
160 # ---------------------------------------------------------------------------
161 # TestDurationMs — JSON output must include duration_ms
162 # ---------------------------------------------------------------------------
163
164
165 class TestDurationMs:
166 """JSON output must include a non-negative float duration_ms."""
167
168 def test_json_has_duration_ms(self, refactor_repo: pathlib.Path) -> None:
169 r = _run(refactor_repo, "code", "detect-refactor", "--json")
170 assert "duration_ms" in json.loads(r.output)
171
172 def test_json_duration_ms_nonnegative(self, refactor_repo: pathlib.Path) -> None:
173 r = _run(refactor_repo, "code", "detect-refactor", "--json")
174 assert json.loads(r.output)["duration_ms"] >= 0
175
176 def test_json_duration_ms_is_float(self, refactor_repo: pathlib.Path) -> None:
177 r = _run(refactor_repo, "code", "detect-refactor", "--json")
178 assert isinstance(json.loads(r.output)["duration_ms"], float)
179
180 def test_j_alias_duration_ms_present(self, refactor_repo: pathlib.Path) -> None:
181 r = _run(refactor_repo, "code", "detect-refactor", "-j")
182 assert "duration_ms" in json.loads(r.output)
183
184 def test_duration_ms_with_kind_filter(self, refactor_repo: pathlib.Path) -> None:
185 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--kind", "implementation")
186 data = json.loads(r.output)
187 assert "duration_ms" in data
188 assert data["duration_ms"] >= 0
189
190 def test_duration_ms_with_max_filter(self, refactor_repo: pathlib.Path) -> None:
191 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--max", "1")
192 data = json.loads(r.output)
193 assert "duration_ms" in data
194 assert isinstance(data["duration_ms"], float)
195
196
197 # ---------------------------------------------------------------------------
198 # TestExitCode — JSON includes exit_code = 0 on success
199 # ---------------------------------------------------------------------------
200
201
202 class TestExitCode:
203 """JSON exit_code must be 0 on success."""
204
205 def test_json_has_exit_code(self, refactor_repo: pathlib.Path) -> None:
206 r = _run(refactor_repo, "code", "detect-refactor", "--json")
207 assert "exit_code" in json.loads(r.output)
208
209 def test_json_exit_code_zero(self, refactor_repo: pathlib.Path) -> None:
210 r = _run(refactor_repo, "code", "detect-refactor", "--json")
211 assert r.exit_code == 0
212 assert json.loads(r.output)["exit_code"] == 0
213
214 def test_json_exit_code_is_int(self, refactor_repo: pathlib.Path) -> None:
215 r = _run(refactor_repo, "code", "detect-refactor", "--json")
216 assert isinstance(json.loads(r.output)["exit_code"], int)
217
218 def test_j_alias_exit_code_present(self, refactor_repo: pathlib.Path) -> None:
219 r = _run(refactor_repo, "code", "detect-refactor", "-j")
220 assert "exit_code" in json.loads(r.output)
221
222 def test_exit_code_mirrors_process_exit(self, refactor_repo: pathlib.Path) -> None:
223 r = _run(refactor_repo, "code", "detect-refactor", "--json")
224 assert json.loads(r.output)["exit_code"] == r.exit_code
225
226 def test_exit_code_zero_with_kind_filter(self, refactor_repo: pathlib.Path) -> None:
227 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--kind", "implementation")
228 assert r.exit_code == 0
229 assert json.loads(r.output)["exit_code"] == 0
230
231 def test_exit_code_zero_with_max_filter(self, refactor_repo: pathlib.Path) -> None:
232 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--max", "5")
233 assert r.exit_code == 0
234 assert json.loads(r.output)["exit_code"] == 0
235
236 def test_exit_code_zero_with_from_to(self, refactor_repo: pathlib.Path) -> None:
237 r = _run(refactor_repo, "code", "detect-refactor", "--json",
238 "--from", "HEAD~1", "--to", "HEAD")
239 assert r.exit_code == 0
240 assert json.loads(r.output)["exit_code"] == 0
241
242
243 # ---------------------------------------------------------------------------
244 # TestTypedDicts — _RefactorOutputJson carries exit_code/duration_ms
245 # ---------------------------------------------------------------------------
246
247
248 class TestTypedDicts:
249 """_RefactorOutputJson must carry exit_code and duration_ms annotations."""
250
251 def test_refactor_output_json_typeddict_exists(self) -> None:
252 from muse.cli.commands.detect_refactor import _RefactorOutputJson # noqa: F401
253
254 def test_has_exit_code_annotation(self) -> None:
255 from muse.cli.commands.detect_refactor import _RefactorOutputJson
256 assert "exit_code" in _RefactorOutputJson.__annotations__
257
258 def test_has_duration_ms_annotation(self) -> None:
259 from muse.cli.commands.detect_refactor import _RefactorOutputJson
260 assert "duration_ms" in _RefactorOutputJson.__annotations__
261
262 def test_retains_schema_annotation(self) -> None:
263 from muse.cli.commands.detect_refactor import _RefactorOutputJson
264 assert "schema" in _RefactorOutputJson.__annotations__
265
266 def test_retains_events_annotation(self) -> None:
267 from muse.cli.commands.detect_refactor import _RefactorOutputJson
268 assert "events" in _RefactorOutputJson.__annotations__
269
270 def test_retains_commits_scanned_annotation(self) -> None:
271 from muse.cli.commands.detect_refactor import _RefactorOutputJson
272 assert "commits_scanned" in _RefactorOutputJson.__annotations__
273
274 def test_retains_truncated_annotation(self) -> None:
275 from muse.cli.commands.detect_refactor import _RefactorOutputJson
276 assert "truncated" in _RefactorOutputJson.__annotations__
277
278
279 # ---------------------------------------------------------------------------
280 # TestAnsiSanitization — no escape codes in JSON output
281 # ---------------------------------------------------------------------------
282
283
284 class TestAnsiSanitization:
285 """No ANSI escape sequences anywhere in the JSON output."""
286
287 def test_json_output_no_ansi(self, refactor_repo: pathlib.Path) -> None:
288 r = _run(refactor_repo, "code", "detect-refactor", "--json")
289 assert "\x1b" not in r.output
290
291 def test_j_alias_output_no_ansi(self, refactor_repo: pathlib.Path) -> None:
292 r = _run(refactor_repo, "code", "detect-refactor", "-j")
293 assert "\x1b" not in r.output
294
295 def test_json_output_no_ansi_with_kind_filter(
296 self, refactor_repo: pathlib.Path
297 ) -> None:
298 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--kind", "implementation")
299 assert "\x1b" not in r.output
300
301
302 # ---------------------------------------------------------------------------
303 # TestPerformance — duration_ms under 2000 ms for a small repo
304 # ---------------------------------------------------------------------------
305
306
307 class TestPerformance:
308 """duration_ms must stay under 2000 ms for small repos."""
309
310 def test_json_duration_under_2000ms(self, refactor_repo: pathlib.Path) -> None:
311 r = _run(refactor_repo, "code", "detect-refactor", "--json")
312 assert json.loads(r.output)["duration_ms"] < 2000
313
314 def test_j_alias_duration_under_2000ms(self, refactor_repo: pathlib.Path) -> None:
315 r = _run(refactor_repo, "code", "detect-refactor", "-j")
316 assert json.loads(r.output)["duration_ms"] < 2000
317
318 def test_duration_ms_is_float_not_int(self, refactor_repo: pathlib.Path) -> None:
319 r = _run(refactor_repo, "code", "detect-refactor", "--json")
320 assert isinstance(json.loads(r.output)["duration_ms"], float)
321
322
323 # ---------------------------------------------------------------------------
324 # TestRegisterFlags — --json / -j normalized at argparse level
325 # ---------------------------------------------------------------------------
326
327
328 class TestRegisterFlags:
329 """register() must expose --json with -j shorthand and dest=json_out."""
330
331 def _make_parser(self) -> argparse.ArgumentParser:
332 import argparse as ap
333 from muse.cli.commands.detect_refactor import register
334 root = ap.ArgumentParser()
335 subs = root.add_subparsers()
336 register(subs)
337 return root
338
339 def test_json_out_default_false(self) -> None:
340 p = self._make_parser()
341 ns = p.parse_args(['detect-refactor'])
342 assert ns.json_out is False
343
344 def test_json_out_true_with_json_flag(self) -> None:
345 p = self._make_parser()
346 ns = p.parse_args(['detect-refactor', '--json'])
347 assert ns.json_out is True
348
349 def test_json_out_true_with_j_flag(self) -> None:
350 p = self._make_parser()
351 ns = p.parse_args(['detect-refactor', '-j'])
352 assert ns.json_out is True
File History 1 commit