gabriel / muse public
test_detect_refactor_supercharge.py python
336 lines 13.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 144 days ago
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
23 import json
24 import pathlib
25 import textwrap
26
27 import pytest
28
29 from tests.cli_test_helper import CliRunner
30
31 runner = CliRunner()
32
33
34 # ---------------------------------------------------------------------------
35 # Helpers
36 # ---------------------------------------------------------------------------
37
38
39 def _env(root: pathlib.Path) -> dict[str, str]:
40 return {"MUSE_REPO_ROOT": str(root)}
41
42
43 def _run(root: pathlib.Path, *args: str):
44 return runner.invoke(None, list(args), env=_env(root))
45
46
47 # ---------------------------------------------------------------------------
48 # Fixture — repo with a rename event and an implementation-change event
49 # ---------------------------------------------------------------------------
50
51
52 @pytest.fixture()
53 def refactor_repo(
54 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
55 ) -> pathlib.Path:
56 """Repo with two commits that produce detectable refactoring events.
57
58 Commit 1 — seed: billing.py with compute_total + validate_amount.
59 Commit 2 — refactor: compute_total renamed to calculate_total;
60 validate_amount body changed (implementation).
61
62 The same body hash under a new name triggers a rename event.
63 A body change under the same name triggers an implementation event.
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 def compute_total(items):
72 return sum(items)
73
74 def validate_amount(amount):
75 return amount > 0
76 """))
77 r = _run(tmp_path, "code", "add", ".")
78 assert r.exit_code == 0, r.output
79 r = _run(tmp_path, "commit", "-m", "initial: add billing functions")
80 assert r.exit_code == 0, r.output
81
82 # commit 2 — rename compute_total → calculate_total; change validate_amount body
83 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
84 def calculate_total(items):
85 return sum(items)
86
87 def validate_amount(amount):
88 return amount >= 0
89 """))
90 r = _run(tmp_path, "code", "add", ".")
91 assert r.exit_code == 0, r.output
92 r = _run(tmp_path, "commit", "-m", "refactor: rename compute_total, tighten validate")
93 assert r.exit_code == 0, r.output
94
95 return tmp_path
96
97
98 # ---------------------------------------------------------------------------
99 # TestJsonAlias — -j works identically to --json
100 # ---------------------------------------------------------------------------
101
102
103 class TestJsonAlias:
104 """-j shorthand must behave identically to --json."""
105
106 def test_j_alias_exits_zero(self, refactor_repo: pathlib.Path) -> None:
107 r = _run(refactor_repo, "code", "detect-refactor", "-j")
108 assert r.exit_code == 0, r.output
109
110 def test_j_alias_valid_json(self, refactor_repo: pathlib.Path) -> None:
111 r = _run(refactor_repo, "code", "detect-refactor", "-j")
112 json.loads(r.output) # must not raise
113
114 def test_j_alias_has_events_key(self, refactor_repo: pathlib.Path) -> None:
115 r = _run(refactor_repo, "code", "detect-refactor", "-j")
116 assert "events" in json.loads(r.output)
117
118 def test_j_alias_has_commits_scanned_key(self, refactor_repo: pathlib.Path) -> None:
119 r = _run(refactor_repo, "code", "detect-refactor", "-j")
120 assert "commits_scanned" in json.loads(r.output)
121
122 def test_j_alias_has_total_key(self, refactor_repo: pathlib.Path) -> None:
123 r = _run(refactor_repo, "code", "detect-refactor", "-j")
124 assert "total" in json.loads(r.output)
125
126 def test_j_alias_same_top_level_keys_as_json_flag(
127 self, refactor_repo: pathlib.Path
128 ) -> None:
129 r1 = _run(refactor_repo, "code", "detect-refactor", "--json")
130 r2 = _run(refactor_repo, "code", "detect-refactor", "-j")
131 d1 = json.loads(r1.output)
132 d2 = json.loads(r2.output)
133 d1.pop("duration_ms", None)
134 d2.pop("duration_ms", None)
135 assert set(d1.keys()) == set(d2.keys())
136
137 def test_j_alias_event_count_matches_json_flag(
138 self, refactor_repo: pathlib.Path
139 ) -> None:
140 r1 = _run(refactor_repo, "code", "detect-refactor", "--json")
141 r2 = _run(refactor_repo, "code", "detect-refactor", "-j")
142 assert len(json.loads(r1.output)["events"]) == len(json.loads(r2.output)["events"])
143
144 def test_j_alias_with_kind_filter(self, refactor_repo: pathlib.Path) -> None:
145 r = _run(refactor_repo, "code", "detect-refactor", "-j", "--kind", "implementation")
146 assert r.exit_code == 0, r.output
147 data = json.loads(r.output)
148 for ev in data["events"]:
149 assert ev["kind"] == "implementation"
150
151 def test_j_alias_with_max_filter(self, refactor_repo: pathlib.Path) -> None:
152 r = _run(refactor_repo, "code", "detect-refactor", "-j", "--max", "10")
153 assert r.exit_code == 0, r.output
154 json.loads(r.output) # valid JSON
155
156
157 # ---------------------------------------------------------------------------
158 # TestDurationMs — JSON output must include duration_ms
159 # ---------------------------------------------------------------------------
160
161
162 class TestDurationMs:
163 """JSON output must include a non-negative float duration_ms."""
164
165 def test_json_has_duration_ms(self, refactor_repo: pathlib.Path) -> None:
166 r = _run(refactor_repo, "code", "detect-refactor", "--json")
167 assert "duration_ms" in json.loads(r.output)
168
169 def test_json_duration_ms_nonnegative(self, refactor_repo: pathlib.Path) -> None:
170 r = _run(refactor_repo, "code", "detect-refactor", "--json")
171 assert json.loads(r.output)["duration_ms"] >= 0
172
173 def test_json_duration_ms_is_float(self, refactor_repo: pathlib.Path) -> None:
174 r = _run(refactor_repo, "code", "detect-refactor", "--json")
175 assert isinstance(json.loads(r.output)["duration_ms"], float)
176
177 def test_j_alias_duration_ms_present(self, refactor_repo: pathlib.Path) -> None:
178 r = _run(refactor_repo, "code", "detect-refactor", "-j")
179 assert "duration_ms" in json.loads(r.output)
180
181 def test_duration_ms_with_kind_filter(self, refactor_repo: pathlib.Path) -> None:
182 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--kind", "implementation")
183 data = json.loads(r.output)
184 assert "duration_ms" in data
185 assert data["duration_ms"] >= 0
186
187 def test_duration_ms_with_max_filter(self, refactor_repo: pathlib.Path) -> None:
188 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--max", "1")
189 data = json.loads(r.output)
190 assert "duration_ms" in data
191 assert isinstance(data["duration_ms"], float)
192
193
194 # ---------------------------------------------------------------------------
195 # TestExitCode — JSON includes exit_code = 0 on success
196 # ---------------------------------------------------------------------------
197
198
199 class TestExitCode:
200 """JSON exit_code must be 0 on success."""
201
202 def test_json_has_exit_code(self, refactor_repo: pathlib.Path) -> None:
203 r = _run(refactor_repo, "code", "detect-refactor", "--json")
204 assert "exit_code" in json.loads(r.output)
205
206 def test_json_exit_code_zero(self, refactor_repo: pathlib.Path) -> None:
207 r = _run(refactor_repo, "code", "detect-refactor", "--json")
208 assert r.exit_code == 0
209 assert json.loads(r.output)["exit_code"] == 0
210
211 def test_json_exit_code_is_int(self, refactor_repo: pathlib.Path) -> None:
212 r = _run(refactor_repo, "code", "detect-refactor", "--json")
213 assert isinstance(json.loads(r.output)["exit_code"], int)
214
215 def test_j_alias_exit_code_present(self, refactor_repo: pathlib.Path) -> None:
216 r = _run(refactor_repo, "code", "detect-refactor", "-j")
217 assert "exit_code" in json.loads(r.output)
218
219 def test_exit_code_mirrors_process_exit(self, refactor_repo: pathlib.Path) -> None:
220 r = _run(refactor_repo, "code", "detect-refactor", "--json")
221 assert json.loads(r.output)["exit_code"] == r.exit_code
222
223 def test_exit_code_zero_with_kind_filter(self, refactor_repo: pathlib.Path) -> None:
224 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--kind", "implementation")
225 assert r.exit_code == 0
226 assert json.loads(r.output)["exit_code"] == 0
227
228 def test_exit_code_zero_with_max_filter(self, refactor_repo: pathlib.Path) -> None:
229 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--max", "5")
230 assert r.exit_code == 0
231 assert json.loads(r.output)["exit_code"] == 0
232
233 def test_exit_code_zero_with_from_to(self, refactor_repo: pathlib.Path) -> None:
234 r = _run(refactor_repo, "code", "detect-refactor", "--json",
235 "--from", "HEAD~1", "--to", "HEAD")
236 assert r.exit_code == 0
237 assert json.loads(r.output)["exit_code"] == 0
238
239
240 # ---------------------------------------------------------------------------
241 # TestTypedDicts — _RefactorOutputJson carries exit_code/duration_ms
242 # ---------------------------------------------------------------------------
243
244
245 class TestTypedDicts:
246 """_RefactorOutputJson must carry exit_code and duration_ms annotations."""
247
248 def test_refactor_output_json_typeddict_exists(self) -> None:
249 from muse.cli.commands.detect_refactor import _RefactorOutputJson # noqa: F401
250
251 def test_has_exit_code_annotation(self) -> None:
252 from muse.cli.commands.detect_refactor import _RefactorOutputJson
253 assert "exit_code" in _RefactorOutputJson.__annotations__
254
255 def test_has_duration_ms_annotation(self) -> None:
256 from muse.cli.commands.detect_refactor import _RefactorOutputJson
257 assert "duration_ms" in _RefactorOutputJson.__annotations__
258
259 def test_retains_schema_version_annotation(self) -> None:
260 from muse.cli.commands.detect_refactor import _RefactorOutputJson
261 assert "schema_version" in _RefactorOutputJson.__annotations__
262
263 def test_retains_events_annotation(self) -> None:
264 from muse.cli.commands.detect_refactor import _RefactorOutputJson
265 assert "events" in _RefactorOutputJson.__annotations__
266
267 def test_retains_commits_scanned_annotation(self) -> None:
268 from muse.cli.commands.detect_refactor import _RefactorOutputJson
269 assert "commits_scanned" in _RefactorOutputJson.__annotations__
270
271 def test_retains_truncated_annotation(self) -> None:
272 from muse.cli.commands.detect_refactor import _RefactorOutputJson
273 assert "truncated" in _RefactorOutputJson.__annotations__
274
275
276 # ---------------------------------------------------------------------------
277 # TestDocstrings — run() docstring documents exit_code and duration_ms
278 # ---------------------------------------------------------------------------
279
280
281 class TestDocstrings:
282 """run() must document exit_code and duration_ms."""
283
284 def test_run_docstring_mentions_exit_code(self) -> None:
285 from muse.cli.commands.detect_refactor import run
286 assert run.__doc__ is not None
287 assert "exit_code" in run.__doc__
288
289 def test_run_docstring_mentions_duration_ms(self) -> None:
290 from muse.cli.commands.detect_refactor import run
291 assert run.__doc__ is not None
292 assert "duration_ms" in run.__doc__
293
294
295 # ---------------------------------------------------------------------------
296 # TestAnsiSanitization — no escape codes in JSON output
297 # ---------------------------------------------------------------------------
298
299
300 class TestAnsiSanitization:
301 """No ANSI escape sequences anywhere in the JSON output."""
302
303 def test_json_output_no_ansi(self, refactor_repo: pathlib.Path) -> None:
304 r = _run(refactor_repo, "code", "detect-refactor", "--json")
305 assert "\x1b" not in r.output
306
307 def test_j_alias_output_no_ansi(self, refactor_repo: pathlib.Path) -> None:
308 r = _run(refactor_repo, "code", "detect-refactor", "-j")
309 assert "\x1b" not in r.output
310
311 def test_json_output_no_ansi_with_kind_filter(
312 self, refactor_repo: pathlib.Path
313 ) -> None:
314 r = _run(refactor_repo, "code", "detect-refactor", "--json", "--kind", "implementation")
315 assert "\x1b" not in r.output
316
317
318 # ---------------------------------------------------------------------------
319 # TestPerformance — duration_ms under 2000 ms for a small repo
320 # ---------------------------------------------------------------------------
321
322
323 class TestPerformance:
324 """duration_ms must stay under 2000 ms for small repos."""
325
326 def test_json_duration_under_2000ms(self, refactor_repo: pathlib.Path) -> None:
327 r = _run(refactor_repo, "code", "detect-refactor", "--json")
328 assert json.loads(r.output)["duration_ms"] < 2000
329
330 def test_j_alias_duration_under_2000ms(self, refactor_repo: pathlib.Path) -> None:
331 r = _run(refactor_repo, "code", "detect-refactor", "-j")
332 assert json.loads(r.output)["duration_ms"] < 2000
333
334 def test_duration_ms_is_float_not_int(self, refactor_repo: pathlib.Path) -> None:
335 r = _run(refactor_repo, "code", "detect-refactor", "--json")
336 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 144 days ago