gabriel / muse public
test_hotspots_supercharge.py python
449 lines 18.1 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Supercharge tests for ``muse code hotspots`` — agent-usability gaps.
2
3 There are NO existing hotspot tests (confirmed: no test_cmd_hotspots.py,
4 no hotspot entries in the collected test suite).
5
6 This file covers both correctness and agent-usability gaps:
7
8 Coverage matrix
9 ---------------
10 - --json / -j: -j alias works identically to --json
11 - exit_code: JSON output includes exit_code = 0 on success
12 - duration_ms: JSON output includes non-negative float duration_ms
13 - TypedDicts: _HotspotsOutputJson carries all fields including exit_code/duration_ms
14 - Docstrings: run() docstring mentions exit_code and duration_ms
15 - ANSI: JSON output never contains terminal escape sequences
16 - Performance: duration_ms stays under 2000 ms for a small repo
17 - Schema: JSON has required top-level keys (from_ref, to_ref,
18 commits_analysed, truncated, filters, hotspots)
19 - Filters: filters dict carries kind, language, include_imports, min_changes
20 - Hotspot items: each item has address and changes keys
21 - --min filter: filters before ranking
22 - --top filter: bounds result count
23 """
24
25 from __future__ import annotations
26
27 import json
28 import pathlib
29 import textwrap
30
31 import pytest
32
33 from tests.cli_test_helper import CliRunner
34
35 runner = CliRunner()
36
37
38 # ---------------------------------------------------------------------------
39 # Helpers
40 # ---------------------------------------------------------------------------
41
42
43 def _env(root: pathlib.Path) -> dict[str, str]:
44 return {"MUSE_REPO_ROOT": str(root)}
45
46
47 def _run(root: pathlib.Path, *args: str):
48 return runner.invoke(None, list(args), env=_env(root))
49
50
51 # ---------------------------------------------------------------------------
52 # Fixture — repo with repeated symbol changes to generate churn
53 # ---------------------------------------------------------------------------
54
55
56 @pytest.fixture()
57 def hotspots_repo(
58 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
59 ) -> pathlib.Path:
60 """Repo where billing.py::compute_total changes 3 times, creating churn.
61
62 Commit 1 — seed: billing.py + helpers.py
63 Commit 2 — modify compute_total (churn #1)
64 Commit 3 — modify compute_total again (churn #2)
65 Commit 4 — modify helpers.py::format_currency once
66
67 Churn ranking after 4 commits:
68 billing.py::compute_total → 3 changes (introduced + 2 modifications)
69 helpers.py::format_currency → 2 changes (introduced + 1 modification)
70 """
71 monkeypatch.chdir(tmp_path)
72 r = _run(tmp_path, "init", "--domain", "code")
73 assert r.exit_code == 0, r.output
74
75 # commit 1 — seed
76 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
77 def compute_total(items):
78 return sum(items)
79
80 class Invoice:
81 pass
82 """))
83 (tmp_path / "helpers.py").write_text(textwrap.dedent("""\
84 def format_currency(amount):
85 return f"${amount:.2f}"
86 """))
87 r = _run(tmp_path, "code", "add", ".")
88 assert r.exit_code == 0, r.output
89 r = _run(tmp_path, "commit", "-m", "seed")
90 assert r.exit_code == 0, r.output
91
92 # commit 2 — modify compute_total
93 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
94 def compute_total(items):
95 return round(sum(items), 2)
96
97 class Invoice:
98 pass
99 """))
100 r = _run(tmp_path, "code", "add", ".")
101 assert r.exit_code == 0, r.output
102 r = _run(tmp_path, "commit", "-m", "round total")
103 assert r.exit_code == 0, r.output
104
105 # commit 3 — modify compute_total again
106 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
107 def compute_total(items, tax=0.0):
108 return round(sum(items) * (1 + tax), 2)
109
110 class Invoice:
111 pass
112 """))
113 r = _run(tmp_path, "code", "add", ".")
114 assert r.exit_code == 0, r.output
115 r = _run(tmp_path, "commit", "-m", "add tax parameter")
116 assert r.exit_code == 0, r.output
117
118 # commit 4 — modify format_currency
119 (tmp_path / "helpers.py").write_text(textwrap.dedent("""\
120 def format_currency(amount, symbol="$"):
121 return f"{symbol}{amount:.2f}"
122 """))
123 r = _run(tmp_path, "code", "add", ".")
124 assert r.exit_code == 0, r.output
125 r = _run(tmp_path, "commit", "-m", "parameterise symbol")
126 assert r.exit_code == 0, r.output
127
128 return tmp_path
129
130
131 # ---------------------------------------------------------------------------
132 # TestJsonAlias — -j works identically to --json
133 # ---------------------------------------------------------------------------
134
135
136 class TestJsonAlias:
137 """-j shorthand must behave identically to --json."""
138
139 def test_j_alias_exits_zero(self, hotspots_repo: pathlib.Path) -> None:
140 r = _run(hotspots_repo, "code", "hotspots", "-j")
141 assert r.exit_code == 0, r.output
142
143 def test_j_alias_valid_json(self, hotspots_repo: pathlib.Path) -> None:
144 r = _run(hotspots_repo, "code", "hotspots", "-j")
145 json.loads(r.output) # must not raise
146
147 def test_j_alias_has_hotspots_key(self, hotspots_repo: pathlib.Path) -> None:
148 r = _run(hotspots_repo, "code", "hotspots", "-j")
149 assert "hotspots" in json.loads(r.output)
150
151 def test_j_alias_has_commits_analysed_key(self, hotspots_repo: pathlib.Path) -> None:
152 r = _run(hotspots_repo, "code", "hotspots", "-j")
153 assert "commits_analysed" in json.loads(r.output)
154
155 def test_j_alias_has_filters_key(self, hotspots_repo: pathlib.Path) -> None:
156 r = _run(hotspots_repo, "code", "hotspots", "-j")
157 assert "filters" in json.loads(r.output)
158
159 def test_j_alias_same_top_level_keys_as_json_flag(
160 self, hotspots_repo: pathlib.Path
161 ) -> None:
162 r1 = _run(hotspots_repo, "code", "hotspots", "--json")
163 r2 = _run(hotspots_repo, "code", "hotspots", "-j")
164 d1 = json.loads(r1.output)
165 d2 = json.loads(r2.output)
166 d1.pop("duration_ms", None)
167 d2.pop("duration_ms", None)
168 assert set(d1.keys()) == set(d2.keys())
169
170 def test_j_alias_hotspot_count_matches_json_flag(
171 self, hotspots_repo: pathlib.Path
172 ) -> None:
173 r1 = _run(hotspots_repo, "code", "hotspots", "--json")
174 r2 = _run(hotspots_repo, "code", "hotspots", "-j")
175 assert len(json.loads(r1.output)["hotspots"]) == len(
176 json.loads(r2.output)["hotspots"]
177 )
178
179 def test_j_alias_with_top_filter(self, hotspots_repo: pathlib.Path) -> None:
180 r = _run(hotspots_repo, "code", "hotspots", "-j", "--top", "1")
181 assert r.exit_code == 0, r.output
182 assert len(json.loads(r.output)["hotspots"]) <= 1
183
184 def test_j_alias_with_kind_filter(self, hotspots_repo: pathlib.Path) -> None:
185 r = _run(hotspots_repo, "code", "hotspots", "-j", "--kind", "function")
186 assert r.exit_code == 0, r.output
187 data = json.loads(r.output)
188 assert data["filters"]["kind"] == "function"
189
190
191 # ---------------------------------------------------------------------------
192 # TestDurationMs — JSON output must include duration_ms
193 # ---------------------------------------------------------------------------
194
195
196 class TestDurationMs:
197 """JSON output must include a non-negative float duration_ms."""
198
199 def test_json_has_duration_ms(self, hotspots_repo: pathlib.Path) -> None:
200 r = _run(hotspots_repo, "code", "hotspots", "--json")
201 assert "duration_ms" in json.loads(r.output)
202
203 def test_json_duration_ms_nonnegative(self, hotspots_repo: pathlib.Path) -> None:
204 r = _run(hotspots_repo, "code", "hotspots", "--json")
205 assert json.loads(r.output)["duration_ms"] >= 0
206
207 def test_json_duration_ms_is_float(self, hotspots_repo: pathlib.Path) -> None:
208 r = _run(hotspots_repo, "code", "hotspots", "--json")
209 assert isinstance(json.loads(r.output)["duration_ms"], float)
210
211 def test_j_alias_duration_ms_present(self, hotspots_repo: pathlib.Path) -> None:
212 r = _run(hotspots_repo, "code", "hotspots", "-j")
213 assert "duration_ms" in json.loads(r.output)
214
215 def test_duration_ms_with_min_filter(self, hotspots_repo: pathlib.Path) -> None:
216 r = _run(hotspots_repo, "code", "hotspots", "--json", "--min", "2")
217 data = json.loads(r.output)
218 assert "duration_ms" in data
219 assert data["duration_ms"] >= 0
220
221 def test_duration_ms_with_kind_filter(self, hotspots_repo: pathlib.Path) -> None:
222 r = _run(hotspots_repo, "code", "hotspots", "--json", "--kind", "function")
223 data = json.loads(r.output)
224 assert "duration_ms" in data
225 assert isinstance(data["duration_ms"], float)
226
227 def test_duration_ms_with_top_filter(self, hotspots_repo: pathlib.Path) -> None:
228 r = _run(hotspots_repo, "code", "hotspots", "--json", "--top", "1")
229 data = json.loads(r.output)
230 assert "duration_ms" in data
231 assert data["duration_ms"] >= 0
232
233
234 # ---------------------------------------------------------------------------
235 # TestExitCode — JSON includes exit_code = 0 on success
236 # ---------------------------------------------------------------------------
237
238
239 class TestExitCode:
240 """JSON exit_code must be 0 on success."""
241
242 def test_json_has_exit_code(self, hotspots_repo: pathlib.Path) -> None:
243 r = _run(hotspots_repo, "code", "hotspots", "--json")
244 assert "exit_code" in json.loads(r.output)
245
246 def test_json_exit_code_zero(self, hotspots_repo: pathlib.Path) -> None:
247 r = _run(hotspots_repo, "code", "hotspots", "--json")
248 assert r.exit_code == 0
249 assert json.loads(r.output)["exit_code"] == 0
250
251 def test_json_exit_code_is_int(self, hotspots_repo: pathlib.Path) -> None:
252 r = _run(hotspots_repo, "code", "hotspots", "--json")
253 assert isinstance(json.loads(r.output)["exit_code"], int)
254
255 def test_j_alias_exit_code_present(self, hotspots_repo: pathlib.Path) -> None:
256 r = _run(hotspots_repo, "code", "hotspots", "-j")
257 assert "exit_code" in json.loads(r.output)
258
259 def test_exit_code_mirrors_process_exit(self, hotspots_repo: pathlib.Path) -> None:
260 r = _run(hotspots_repo, "code", "hotspots", "--json")
261 assert json.loads(r.output)["exit_code"] == r.exit_code
262
263 def test_exit_code_zero_with_min_filter(self, hotspots_repo: pathlib.Path) -> None:
264 """exit_code is 0 even when --min filters out all results."""
265 r = _run(hotspots_repo, "code", "hotspots", "--json", "--min", "999")
266 assert r.exit_code == 0
267 data = json.loads(r.output)
268 assert data["exit_code"] == 0
269 assert data["hotspots"] == []
270
271 def test_exit_code_zero_with_kind_filter(self, hotspots_repo: pathlib.Path) -> None:
272 r = _run(hotspots_repo, "code", "hotspots", "--json", "--kind", "function")
273 assert r.exit_code == 0
274 assert json.loads(r.output)["exit_code"] == 0
275
276 def test_exit_code_zero_with_top_filter(self, hotspots_repo: pathlib.Path) -> None:
277 r = _run(hotspots_repo, "code", "hotspots", "--json", "--top", "1")
278 assert r.exit_code == 0
279 assert json.loads(r.output)["exit_code"] == 0
280
281
282 # ---------------------------------------------------------------------------
283 # TestTypedDicts — _HotspotsOutputJson carries all fields
284 # ---------------------------------------------------------------------------
285
286
287 class TestTypedDicts:
288 """_HotspotsOutputJson must carry exit_code and duration_ms annotations."""
289
290 def test_hotspots_output_json_typeddict_exists(self) -> None:
291 from muse.cli.commands.hotspots import _HotspotsOutputJson # noqa: F401
292
293 def test_has_exit_code_annotation(self) -> None:
294 from muse.cli.commands.hotspots import _HotspotsOutputJson
295 assert "exit_code" in _HotspotsOutputJson.__annotations__
296
297 def test_has_duration_ms_annotation(self) -> None:
298 from muse.cli.commands.hotspots import _HotspotsOutputJson
299 assert "duration_ms" in _HotspotsOutputJson.__annotations__
300
301 def test_retains_hotspots_annotation(self) -> None:
302 from muse.cli.commands.hotspots import _HotspotsOutputJson
303 assert "hotspots" in _HotspotsOutputJson.__annotations__
304
305 def test_retains_commits_analysed_annotation(self) -> None:
306 from muse.cli.commands.hotspots import _HotspotsOutputJson
307 assert "commits_analysed" in _HotspotsOutputJson.__annotations__
308
309 def test_retains_truncated_annotation(self) -> None:
310 from muse.cli.commands.hotspots import _HotspotsOutputJson
311 assert "truncated" in _HotspotsOutputJson.__annotations__
312
313 def test_retains_filters_annotation(self) -> None:
314 from muse.cli.commands.hotspots import _HotspotsOutputJson
315 assert "filters" in _HotspotsOutputJson.__annotations__
316
317 def test_retains_from_ref_annotation(self) -> None:
318 from muse.cli.commands.hotspots import _HotspotsOutputJson
319 assert "from_ref" in _HotspotsOutputJson.__annotations__
320
321 def test_retains_to_ref_annotation(self) -> None:
322 from muse.cli.commands.hotspots import _HotspotsOutputJson
323 assert "to_ref" in _HotspotsOutputJson.__annotations__
324
325
326 # ---------------------------------------------------------------------------
327 # TestDocstrings — run() docstring documents exit_code and duration_ms
328 # ---------------------------------------------------------------------------
329
330
331 class TestDocstrings:
332 """run() must document exit_code and duration_ms."""
333
334 def test_run_docstring_mentions_exit_code(self) -> None:
335 from muse.cli.commands.hotspots import run
336 assert run.__doc__ is not None
337 assert "exit_code" in run.__doc__
338
339 def test_run_docstring_mentions_duration_ms(self) -> None:
340 from muse.cli.commands.hotspots import run
341 assert run.__doc__ is not None
342 assert "duration_ms" in run.__doc__
343
344
345 # ---------------------------------------------------------------------------
346 # TestAnsiSanitization — no escape codes in JSON output
347 # ---------------------------------------------------------------------------
348
349
350 class TestAnsiSanitization:
351 """No ANSI escape sequences anywhere in the JSON output."""
352
353 def test_json_output_no_ansi(self, hotspots_repo: pathlib.Path) -> None:
354 r = _run(hotspots_repo, "code", "hotspots", "--json")
355 assert "\x1b" not in r.output
356
357 def test_j_alias_output_no_ansi(self, hotspots_repo: pathlib.Path) -> None:
358 r = _run(hotspots_repo, "code", "hotspots", "-j")
359 assert "\x1b" not in r.output
360
361 def test_json_no_ansi_with_results(self, hotspots_repo: pathlib.Path) -> None:
362 r = _run(hotspots_repo, "code", "hotspots", "--json", "--min", "1")
363 assert "\x1b" not in r.output
364
365
366 # ---------------------------------------------------------------------------
367 # TestSchema — JSON shape correctness
368 # ---------------------------------------------------------------------------
369
370
371 class TestSchema:
372 """JSON envelope must carry all documented top-level keys."""
373
374 def test_has_from_ref(self, hotspots_repo: pathlib.Path) -> None:
375 r = _run(hotspots_repo, "code", "hotspots", "--json")
376 assert "from_ref" in json.loads(r.output)
377
378 def test_has_to_ref(self, hotspots_repo: pathlib.Path) -> None:
379 r = _run(hotspots_repo, "code", "hotspots", "--json")
380 assert "to_ref" in json.loads(r.output)
381
382 def test_has_truncated(self, hotspots_repo: pathlib.Path) -> None:
383 r = _run(hotspots_repo, "code", "hotspots", "--json")
384 assert "truncated" in json.loads(r.output)
385
386 def test_truncated_is_bool(self, hotspots_repo: pathlib.Path) -> None:
387 r = _run(hotspots_repo, "code", "hotspots", "--json")
388 assert isinstance(json.loads(r.output)["truncated"], bool)
389
390 def test_commits_analysed_positive(self, hotspots_repo: pathlib.Path) -> None:
391 r = _run(hotspots_repo, "code", "hotspots", "--json")
392 assert json.loads(r.output)["commits_analysed"] > 0
393
394 def test_hotspot_items_have_address(self, hotspots_repo: pathlib.Path) -> None:
395 r = _run(hotspots_repo, "code", "hotspots", "--json")
396 data = json.loads(r.output)
397 for item in data["hotspots"]:
398 assert "address" in item
399
400 def test_hotspot_items_have_changes(self, hotspots_repo: pathlib.Path) -> None:
401 r = _run(hotspots_repo, "code", "hotspots", "--json")
402 data = json.loads(r.output)
403 for item in data["hotspots"]:
404 assert "changes" in item
405 assert item["changes"] >= 1
406
407 def test_filters_has_kind(self, hotspots_repo: pathlib.Path) -> None:
408 r = _run(hotspots_repo, "code", "hotspots", "--json")
409 data = json.loads(r.output)
410 assert "kind" in data["filters"]
411
412 def test_filters_has_min_changes(self, hotspots_repo: pathlib.Path) -> None:
413 r = _run(hotspots_repo, "code", "hotspots", "--json")
414 data = json.loads(r.output)
415 assert "min_changes" in data["filters"]
416
417 def test_filters_has_include_imports(self, hotspots_repo: pathlib.Path) -> None:
418 r = _run(hotspots_repo, "code", "hotspots", "--json")
419 data = json.loads(r.output)
420 assert "include_imports" in data["filters"]
421
422 def test_top_bounds_hotspot_count(self, hotspots_repo: pathlib.Path) -> None:
423 r = _run(hotspots_repo, "code", "hotspots", "--json", "--top", "1")
424 assert len(json.loads(r.output)["hotspots"]) <= 1
425
426 def test_min_filter_removes_low_churn(self, hotspots_repo: pathlib.Path) -> None:
427 r = _run(hotspots_repo, "code", "hotspots", "--json", "--min", "999")
428 assert json.loads(r.output)["hotspots"] == []
429
430
431 # ---------------------------------------------------------------------------
432 # TestPerformance — duration_ms under 2000 ms for a small repo
433 # ---------------------------------------------------------------------------
434
435
436 class TestPerformance:
437 """duration_ms must stay under 2000 ms for small repos."""
438
439 def test_json_duration_under_2000ms(self, hotspots_repo: pathlib.Path) -> None:
440 r = _run(hotspots_repo, "code", "hotspots", "--json")
441 assert json.loads(r.output)["duration_ms"] < 2000
442
443 def test_j_alias_duration_under_2000ms(self, hotspots_repo: pathlib.Path) -> None:
444 r = _run(hotspots_repo, "code", "hotspots", "-j")
445 assert json.loads(r.output)["duration_ms"] < 2000
446
447 def test_duration_ms_is_float_not_int(self, hotspots_repo: pathlib.Path) -> None:
448 r = _run(hotspots_repo, "code", "hotspots", "--json")
449 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 139 days ago