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