gabriel / muse public
test_blast_risk_supercharge.py python
381 lines 15.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """Supercharge tests for ``muse code blast-risk`` — agent-usability gaps.
2
3 Coverage matrix
4 ---------------
5 - --json / -j: -j alias works identically to --json for table and explain modes
6 - exit_code: every JSON output path includes it (0 on success)
7 - duration_ms: every JSON output path includes it; non-negative float
8 - TypedDicts: _BlastRiskOutput gains exit_code/duration_ms; _ExplainJson added
9 - Docstrings: run() docstring mentions exit_code and duration_ms
10 - ANSI: address fields in JSON never contain escape sequences
11 - Performance: duration_ms stays < 2000 ms for small repos (blast-risk is heavier)
12 - Schema: risk 0-100, weight sum ≈ 1.0, scores 0-100
13 """
14
15 from __future__ import annotations
16
17 import json
18 import pathlib
19 import textwrap
20
21 import pytest
22
23 from tests.cli_test_helper import CliRunner
24
25 runner = CliRunner()
26
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32
33 def _env(root: pathlib.Path) -> dict[str, str]:
34 return {"MUSE_REPO_ROOT": str(root)}
35
36
37 def _run(root: pathlib.Path, *args: str): # type: ignore[return]
38 return runner.invoke(None, list(args), env=_env(root))
39
40
41 def _first_symbol_address(root: pathlib.Path) -> str:
42 """Return the highest-risk symbol's address from the JSON output."""
43 r = _run(root, "code", "blast-risk", "--json")
44 assert r.exit_code == 0, r.output
45 data = json.loads(r.output)
46 syms = data["symbols"]
47 assert syms, "blast_repo should always have at least one scored symbol"
48 return syms[0]["address"]
49
50
51 # ---------------------------------------------------------------------------
52 # Fixture — repo with commits giving blast-risk meaningful data
53 # ---------------------------------------------------------------------------
54
55
56 @pytest.fixture()
57 def blast_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
58 """Code-domain repo with two commits.
59
60 billing.py defines Invoice.compute_total and process_order.
61 test_billing.py imports both — so they have at least one test caller.
62 A second commit modifies compute_total so churn > 0.
63 """
64 monkeypatch.chdir(tmp_path)
65
66 r = _run(tmp_path, "init", "--domain", "code")
67 assert r.exit_code == 0, r.output
68
69 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
70 class Invoice:
71 def compute_total(self, items):
72 return sum(items)
73
74 def apply_discount(self, total, pct):
75 return total * (1 - pct)
76
77 def process_order(invoice, items):
78 return invoice.compute_total(items)
79 """))
80 (tmp_path / "test_billing.py").write_text(textwrap.dedent("""\
81 from billing import Invoice, process_order
82
83 def test_compute_total():
84 inv = Invoice()
85 assert inv.compute_total([1, 2, 3]) == 6
86
87 def test_process_order():
88 inv = Invoice()
89 assert process_order(inv, [10]) == 10
90 """))
91 r1 = _run(tmp_path, "code", "add", "billing.py")
92 assert r1.exit_code == 0, r1.output
93 r2 = _run(tmp_path, "code", "add", "test_billing.py")
94 assert r2.exit_code == 0, r2.output
95 r3 = _run(tmp_path, "commit", "-m", "Add billing module and tests")
96 assert r3.exit_code == 0, r3.output
97
98 # Second commit: modify compute_total → churn > 0.
99 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
100 class Invoice:
101 def compute_total(self, items):
102 return round(sum(items), 2)
103
104 def apply_discount(self, total, pct):
105 return total * (1 - pct)
106
107 def process_order(invoice, items):
108 return invoice.compute_total(items)
109 """))
110 r4 = _run(tmp_path, "code", "add", "billing.py")
111 assert r4.exit_code == 0, r4.output
112 r5 = _run(tmp_path, "commit", "-m", "Round compute_total result")
113 assert r5.exit_code == 0, r5.output
114
115 return tmp_path
116
117
118 # ---------------------------------------------------------------------------
119 # TestJsonAlias — -j works identically to --json
120 # ---------------------------------------------------------------------------
121
122
123 class TestJsonAlias:
124 """The -j shorthand must behave identically to --json."""
125
126 def test_j_alias_table_exits_zero(self, blast_repo: pathlib.Path) -> None:
127 r = _run(blast_repo, "code", "blast-risk", "-j")
128 assert r.exit_code == 0, r.output
129
130 def test_j_alias_table_valid_json(self, blast_repo: pathlib.Path) -> None:
131 r = _run(blast_repo, "code", "blast-risk", "-j")
132 json.loads(r.output) # must not raise
133
134 def test_j_alias_table_has_symbols_key(self, blast_repo: pathlib.Path) -> None:
135 r = _run(blast_repo, "code", "blast-risk", "-j")
136 data = json.loads(r.output)
137 assert "symbols" in data
138
139 def test_j_alias_explain_exits_zero(self, blast_repo: pathlib.Path) -> None:
140 addr = _first_symbol_address(blast_repo)
141 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "-j")
142 assert r.exit_code == 0, r.output
143
144 def test_j_alias_explain_valid_json(self, blast_repo: pathlib.Path) -> None:
145 addr = _first_symbol_address(blast_repo)
146 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "-j")
147 json.loads(r.output) # must not raise
148
149 def test_j_alias_table_same_keys_as_json_flag(self, blast_repo: pathlib.Path) -> None:
150 r1 = _run(blast_repo, "code", "blast-risk", "--json")
151 r2 = _run(blast_repo, "code", "blast-risk", "-j")
152 d1 = json.loads(r1.output)
153 d2 = json.loads(r2.output)
154 d1.pop("duration_ms", None)
155 d2.pop("duration_ms", None)
156 assert set(d1.keys()) == set(d2.keys())
157
158 def test_j_alias_explain_same_keys_as_json_flag(self, blast_repo: pathlib.Path) -> None:
159 addr = _first_symbol_address(blast_repo)
160 r1 = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
161 r2 = _run(blast_repo, "code", "blast-risk", "--explain", addr, "-j")
162 d1 = json.loads(r1.output)
163 d2 = json.loads(r2.output)
164 d1.pop("duration_ms", None)
165 d2.pop("duration_ms", None)
166 assert set(d1.keys()) == set(d2.keys())
167
168
169 # ---------------------------------------------------------------------------
170 # TestDurationMs — every JSON path emits duration_ms
171 # ---------------------------------------------------------------------------
172
173
174 class TestDurationMs:
175 """Every JSON output path must include a non-negative float duration_ms."""
176
177 def test_table_json_has_duration_ms(self, blast_repo: pathlib.Path) -> None:
178 r = _run(blast_repo, "code", "blast-risk", "--json")
179 data = json.loads(r.output)
180 assert "duration_ms" in data
181
182 def test_table_json_duration_ms_nonnegative(self, blast_repo: pathlib.Path) -> None:
183 r = _run(blast_repo, "code", "blast-risk", "--json")
184 data = json.loads(r.output)
185 assert data["duration_ms"] >= 0
186
187 def test_table_json_duration_ms_is_float(self, blast_repo: pathlib.Path) -> None:
188 r = _run(blast_repo, "code", "blast-risk", "--json")
189 data = json.loads(r.output)
190 assert isinstance(data["duration_ms"], float)
191
192 def test_explain_json_has_duration_ms(self, blast_repo: pathlib.Path) -> None:
193 addr = _first_symbol_address(blast_repo)
194 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
195 data = json.loads(r.output)
196 assert "duration_ms" in data
197
198 def test_explain_json_duration_ms_nonnegative(self, blast_repo: pathlib.Path) -> None:
199 addr = _first_symbol_address(blast_repo)
200 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
201 data = json.loads(r.output)
202 assert data["duration_ms"] >= 0
203
204 def test_explain_json_duration_ms_is_float(self, blast_repo: pathlib.Path) -> None:
205 addr = _first_symbol_address(blast_repo)
206 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
207 data = json.loads(r.output)
208 assert isinstance(data["duration_ms"], float)
209
210 def test_j_alias_duration_ms_present(self, blast_repo: pathlib.Path) -> None:
211 r = _run(blast_repo, "code", "blast-risk", "-j")
212 data = json.loads(r.output)
213 assert "duration_ms" in data
214
215
216 # ---------------------------------------------------------------------------
217 # TestExitCode — every JSON path emits exit_code
218 # ---------------------------------------------------------------------------
219
220
221 class TestExitCode:
222 """Every JSON output path must include exit_code; 0 on success."""
223
224 def test_table_json_has_exit_code(self, blast_repo: pathlib.Path) -> None:
225 r = _run(blast_repo, "code", "blast-risk", "--json")
226 data = json.loads(r.output)
227 assert "exit_code" in data
228
229 def test_table_json_exit_code_zero_on_success(self, blast_repo: pathlib.Path) -> None:
230 r = _run(blast_repo, "code", "blast-risk", "--json")
231 assert r.exit_code == 0
232 data = json.loads(r.output)
233 assert data["exit_code"] == 0
234
235 def test_table_json_exit_code_is_int(self, blast_repo: pathlib.Path) -> None:
236 r = _run(blast_repo, "code", "blast-risk", "--json")
237 data = json.loads(r.output)
238 assert isinstance(data["exit_code"], int)
239
240 def test_explain_json_has_exit_code(self, blast_repo: pathlib.Path) -> None:
241 addr = _first_symbol_address(blast_repo)
242 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
243 data = json.loads(r.output)
244 assert "exit_code" in data
245
246 def test_explain_json_exit_code_zero_on_success(self, blast_repo: pathlib.Path) -> None:
247 addr = _first_symbol_address(blast_repo)
248 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
249 assert r.exit_code == 0
250 data = json.loads(r.output)
251 assert data["exit_code"] == 0
252
253 def test_explain_json_exit_code_is_int(self, blast_repo: pathlib.Path) -> None:
254 addr = _first_symbol_address(blast_repo)
255 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
256 data = json.loads(r.output)
257 assert isinstance(data["exit_code"], int)
258
259 def test_table_exit_code_mirrors_process_exit(self, blast_repo: pathlib.Path) -> None:
260 r = _run(blast_repo, "code", "blast-risk", "--json")
261 data = json.loads(r.output)
262 assert data["exit_code"] == r.exit_code
263
264 def test_j_alias_exit_code_present(self, blast_repo: pathlib.Path) -> None:
265 r = _run(blast_repo, "code", "blast-risk", "-j")
266 data = json.loads(r.output)
267 assert "exit_code" in data
268
269
270 # ---------------------------------------------------------------------------
271 # TestTypedDicts — envelope TypedDicts carry the new fields
272 # ---------------------------------------------------------------------------
273
274
275 class TestTypedDicts:
276 """_BlastRiskOutput must gain exit_code/duration_ms; _ExplainJson added."""
277
278 def test_blast_risk_output_typed_dict_exists(self) -> None:
279 from muse.cli.commands.blast_risk import _BlastRiskOutput # noqa: F401
280
281 def test_blast_risk_output_has_exit_code_annotation(self) -> None:
282 from muse.cli.commands.blast_risk import _BlastRiskOutput
283 assert "exit_code" in _BlastRiskOutput.__annotations__
284
285 def test_blast_risk_output_has_duration_ms_annotation(self) -> None:
286 from muse.cli.commands.blast_risk import _BlastRiskOutput
287 assert "duration_ms" in _BlastRiskOutput.__annotations__
288
289 def test_blast_risk_output_has_symbols_annotation(self) -> None:
290 from muse.cli.commands.blast_risk import _BlastRiskOutput
291 assert "symbols" in _BlastRiskOutput.__annotations__
292
293 def test_explain_json_typed_dict_exists(self) -> None:
294 from muse.cli.commands.blast_risk import _ExplainJson # noqa: F401
295
296 def test_explain_json_has_exit_code_annotation(self) -> None:
297 from muse.cli.commands.blast_risk import _ExplainJson
298 assert "exit_code" in _ExplainJson.__annotations__
299
300 def test_explain_json_has_duration_ms_annotation(self) -> None:
301 from muse.cli.commands.blast_risk import _ExplainJson
302 assert "duration_ms" in _ExplainJson.__annotations__
303
304 def test_explain_json_has_risk_annotation(self) -> None:
305 from muse.cli.commands.blast_risk import _ExplainJson
306 assert "risk" in _ExplainJson.__annotations__
307
308 def test_symbol_risk_json_typed_dict_exists(self) -> None:
309 from muse.cli.commands.blast_risk import _SymbolRiskJson # noqa: F401
310
311
312 # ---------------------------------------------------------------------------
313 # TestDocstrings — run() docstring documents new fields
314 # ---------------------------------------------------------------------------
315
316
317 class TestDocstrings:
318 """run() must document exit_code and duration_ms in its docstring."""
319
320 def test_run_docstring_mentions_exit_code(self) -> None:
321 from muse.cli.commands.blast_risk import run
322 assert run.__doc__ is not None
323 assert "exit_code" in run.__doc__
324
325 def test_run_docstring_mentions_duration_ms(self) -> None:
326 from muse.cli.commands.blast_risk import run
327 assert run.__doc__ is not None
328 assert "duration_ms" in run.__doc__
329
330
331 # ---------------------------------------------------------------------------
332 # TestAnsiSanitization — JSON fields must not contain terminal escape codes
333 # ---------------------------------------------------------------------------
334
335
336 class TestAnsiSanitization:
337 """No ANSI escape sequences in JSON string fields."""
338
339 def test_table_json_no_ansi_in_output(self, blast_repo: pathlib.Path) -> None:
340 r = _run(blast_repo, "code", "blast-risk", "--json")
341 assert "\x1b" not in r.output
342
343 def test_explain_json_no_ansi_in_output(self, blast_repo: pathlib.Path) -> None:
344 addr = _first_symbol_address(blast_repo)
345 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
346 assert "\x1b" not in r.output
347
348 def test_table_json_addresses_no_ansi(self, blast_repo: pathlib.Path) -> None:
349 r = _run(blast_repo, "code", "blast-risk", "--json")
350 data = json.loads(r.output)
351 for sym in data["symbols"]:
352 assert "\x1b" not in sym["address"]
353
354
355 # ---------------------------------------------------------------------------
356 # TestPerformance — duration_ms stays in a reasonable range
357 # ---------------------------------------------------------------------------
358
359
360 class TestPerformance:
361 """duration_ms must be non-negative and under 2000 ms for small repos.
362
363 blast-risk does AST scanning + BFS commit walk so the budget is 2x larger
364 than simpler commands.
365 """
366
367 def test_table_json_duration_under_2000ms(self, blast_repo: pathlib.Path) -> None:
368 r = _run(blast_repo, "code", "blast-risk", "--json")
369 data = json.loads(r.output)
370 assert data["duration_ms"] < 2000
371
372 def test_explain_json_duration_under_2000ms(self, blast_repo: pathlib.Path) -> None:
373 addr = _first_symbol_address(blast_repo)
374 r = _run(blast_repo, "code", "blast-risk", "--explain", addr, "--json")
375 data = json.loads(r.output)
376 assert data["duration_ms"] < 2000
377
378 def test_duration_ms_is_float_not_int(self, blast_repo: pathlib.Path) -> None:
379 r = _run(blast_repo, "code", "blast-risk", "--json")
380 data = json.loads(r.output)
381 assert isinstance(data["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago