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