gabriel / muse public
test_age_supercharge.py python
391 lines 15.3 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Supercharge tests for ``muse code age`` — agent-usability gaps.
2
3 Coverage matrix
4 ---------------
5 - --json / -j: -j alias works identically to --json for list 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: _AgeListJson, _ExplainJson, _NoSymbolsJson annotations exist
9 - Docstrings: run() docstring mentions exit_code and duration_ms
10 - ANSI: address / string fields in JSON never contain escape sequences
11 - Performance: duration_ms stays < 1000 ms for normal operations
12 """
13
14 from __future__ import annotations
15
16 import json
17 import pathlib
18 import textwrap
19
20 import pytest
21
22 from tests.cli_test_helper import CliRunner
23
24 runner = CliRunner()
25
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31
32 def _env(root: pathlib.Path) -> dict[str, str]:
33 return {"MUSE_REPO_ROOT": str(root)}
34
35
36 def _run(root: pathlib.Path, *args: str): # type: ignore[return]
37 return runner.invoke(None, list(args), env=_env(root))
38
39
40 def _first_symbol_address(root: pathlib.Path) -> str:
41 """Return the address of the first symbol in the age JSON output."""
42 r = _run(root, "code", "age", "--json")
43 assert r.exit_code == 0, r.output
44 data = json.loads(r.output)
45 syms = data["symbols"]
46 assert syms, "age_repo should always have at least one symbol with history"
47 return syms[0]["address"]
48
49
50 # ---------------------------------------------------------------------------
51 # Fixture — repo with commit history so symbols have age data
52 # ---------------------------------------------------------------------------
53
54
55 @pytest.fixture()
56 def age_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
57 """Repo with three commits that give billing.py symbols measurable age.
58
59 Commit 1: create billing.py (Invoice class + compute_total + stable_fn)
60 Commit 2: modify compute_total body → 1 impl change
61 Commit 3: modify compute_total body → 2 impl changes
62 """
63 monkeypatch.chdir(tmp_path)
64
65 r = _run(tmp_path, "init", "--domain", "code")
66 assert r.exit_code == 0, r.output
67
68 # Commit 1 — create the file.
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 stable_fn():
75 return 42
76 """))
77 r1 = _run(tmp_path, "code", "add", "billing.py")
78 assert r1.exit_code == 0, r1.output
79 r2 = _run(tmp_path, "commit", "-m", "initial billing")
80 assert r2.exit_code == 0, r2.output
81
82 # Commit 2 — impl change to compute_total.
83 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
84 class Invoice:
85 def compute_total(self, items):
86 return round(sum(items), 2)
87
88 def stable_fn():
89 return 42
90 """))
91 r3 = _run(tmp_path, "code", "add", "billing.py")
92 assert r3.exit_code == 0, r3.output
93 r4 = _run(tmp_path, "commit", "-m", "round result")
94 assert r4.exit_code == 0, r4.output
95
96 # Commit 3 — second impl change to compute_total.
97 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
98 class Invoice:
99 def compute_total(self, items):
100 total = sum(items)
101 return round(total, 4)
102
103 def stable_fn():
104 return 42
105 """))
106 r5 = _run(tmp_path, "code", "add", "billing.py")
107 assert r5.exit_code == 0, r5.output
108 r6 = _run(tmp_path, "commit", "-m", "higher precision")
109 assert r6.exit_code == 0, r6.output
110
111 return tmp_path
112
113
114 # ---------------------------------------------------------------------------
115 # TestJsonAlias — -j works identically to --json
116 # ---------------------------------------------------------------------------
117
118
119 class TestJsonAlias:
120 """The -j shorthand must behave identically to --json."""
121
122 def test_j_alias_main_exits_zero(self, age_repo: pathlib.Path) -> None:
123 r = _run(age_repo, "code", "age", "-j")
124 assert r.exit_code == 0, r.output
125
126 def test_j_alias_main_valid_json(self, age_repo: pathlib.Path) -> None:
127 r = _run(age_repo, "code", "age", "-j")
128 assert r.exit_code == 0, r.output
129 json.loads(r.output) # must not raise
130
131 def test_j_alias_main_has_symbols_key(self, age_repo: pathlib.Path) -> None:
132 r = _run(age_repo, "code", "age", "-j")
133 data = json.loads(r.output)
134 assert "symbols" in data
135
136 def test_j_alias_explain_exits_zero(self, age_repo: pathlib.Path) -> None:
137 addr = _first_symbol_address(age_repo)
138 r = _run(age_repo, "code", "age", "--explain", addr, "-j")
139 assert r.exit_code == 0, r.output
140
141 def test_j_alias_explain_valid_json(self, age_repo: pathlib.Path) -> None:
142 addr = _first_symbol_address(age_repo)
143 r = _run(age_repo, "code", "age", "--explain", addr, "-j")
144 assert r.exit_code == 0, r.output
145 json.loads(r.output) # must not raise
146
147 def test_j_alias_same_top_level_keys_as_json_flag(self, age_repo: pathlib.Path) -> None:
148 r1 = _run(age_repo, "code", "age", "--json")
149 r2 = _run(age_repo, "code", "age", "-j")
150 d1 = json.loads(r1.output)
151 d2 = json.loads(r2.output)
152 d1.pop("duration_ms", None)
153 d2.pop("duration_ms", None)
154 assert set(d1.keys()) == set(d2.keys())
155
156 def test_j_alias_explain_same_keys_as_json_flag(self, age_repo: pathlib.Path) -> None:
157 addr = _first_symbol_address(age_repo)
158 r1 = _run(age_repo, "code", "age", "--explain", addr, "--json")
159 r2 = _run(age_repo, "code", "age", "--explain", addr, "-j")
160 d1 = json.loads(r1.output)
161 d2 = json.loads(r2.output)
162 d1.pop("duration_ms", None)
163 d2.pop("duration_ms", None)
164 assert set(d1.keys()) == set(d2.keys())
165
166
167 # ---------------------------------------------------------------------------
168 # TestDurationMs — every JSON path emits duration_ms
169 # ---------------------------------------------------------------------------
170
171
172 class TestDurationMs:
173 """Every JSON output path must include a non-negative float duration_ms."""
174
175 def test_main_json_has_duration_ms(self, age_repo: pathlib.Path) -> None:
176 r = _run(age_repo, "code", "age", "--json")
177 data = json.loads(r.output)
178 assert "duration_ms" in data
179
180 def test_main_json_duration_ms_nonnegative(self, age_repo: pathlib.Path) -> None:
181 r = _run(age_repo, "code", "age", "--json")
182 data = json.loads(r.output)
183 assert data["duration_ms"] >= 0
184
185 def test_main_json_duration_ms_is_float(self, age_repo: pathlib.Path) -> None:
186 r = _run(age_repo, "code", "age", "--json")
187 data = json.loads(r.output)
188 assert isinstance(data["duration_ms"], float)
189
190 def test_explain_json_has_duration_ms(self, age_repo: pathlib.Path) -> None:
191 addr = _first_symbol_address(age_repo)
192 r = _run(age_repo, "code", "age", "--explain", addr, "--json")
193 data = json.loads(r.output)
194 assert "duration_ms" in data
195
196 def test_explain_json_duration_ms_nonnegative(self, age_repo: pathlib.Path) -> None:
197 addr = _first_symbol_address(age_repo)
198 r = _run(age_repo, "code", "age", "--explain", addr, "--json")
199 data = json.loads(r.output)
200 assert data["duration_ms"] >= 0
201
202 def test_explain_json_duration_ms_is_float(self, age_repo: pathlib.Path) -> None:
203 addr = _first_symbol_address(age_repo)
204 r = _run(age_repo, "code", "age", "--explain", addr, "--json")
205 data = json.loads(r.output)
206 assert isinstance(data["duration_ms"], float)
207
208 def test_j_alias_duration_ms_present(self, age_repo: pathlib.Path) -> None:
209 r = _run(age_repo, "code", "age", "-j")
210 data = json.loads(r.output)
211 assert "duration_ms" in data
212
213
214 # ---------------------------------------------------------------------------
215 # TestExitCode — every JSON path emits exit_code
216 # ---------------------------------------------------------------------------
217
218
219 class TestExitCode:
220 """Every JSON output path must include exit_code; 0 on success."""
221
222 def test_main_json_has_exit_code(self, age_repo: pathlib.Path) -> None:
223 r = _run(age_repo, "code", "age", "--json")
224 data = json.loads(r.output)
225 assert "exit_code" in data
226
227 def test_main_json_exit_code_zero_on_success(self, age_repo: pathlib.Path) -> None:
228 r = _run(age_repo, "code", "age", "--json")
229 assert r.exit_code == 0
230 data = json.loads(r.output)
231 assert data["exit_code"] == 0
232
233 def test_main_json_exit_code_is_int(self, age_repo: pathlib.Path) -> None:
234 r = _run(age_repo, "code", "age", "--json")
235 data = json.loads(r.output)
236 assert isinstance(data["exit_code"], int)
237
238 def test_explain_json_has_exit_code(self, age_repo: pathlib.Path) -> None:
239 addr = _first_symbol_address(age_repo)
240 r = _run(age_repo, "code", "age", "--explain", addr, "--json")
241 data = json.loads(r.output)
242 assert "exit_code" in data
243
244 def test_explain_json_exit_code_zero_on_success(self, age_repo: pathlib.Path) -> None:
245 addr = _first_symbol_address(age_repo)
246 r = _run(age_repo, "code", "age", "--explain", addr, "--json")
247 assert r.exit_code == 0
248 data = json.loads(r.output)
249 assert data["exit_code"] == 0
250
251 def test_explain_json_exit_code_is_int(self, age_repo: pathlib.Path) -> None:
252 addr = _first_symbol_address(age_repo)
253 r = _run(age_repo, "code", "age", "--explain", addr, "--json")
254 data = json.loads(r.output)
255 assert isinstance(data["exit_code"], int)
256
257 def test_j_alias_exit_code_present(self, age_repo: pathlib.Path) -> None:
258 r = _run(age_repo, "code", "age", "-j")
259 data = json.loads(r.output)
260 assert "exit_code" in data
261
262 def test_exit_code_mirrors_process_exit(self, age_repo: pathlib.Path) -> None:
263 r = _run(age_repo, "code", "age", "--json")
264 data = json.loads(r.output)
265 assert data["exit_code"] == r.exit_code
266
267
268 # ---------------------------------------------------------------------------
269 # TestTypedDicts — envelope TypedDicts exist with the required fields
270 # ---------------------------------------------------------------------------
271
272
273 class TestTypedDicts:
274 """_AgeListJson, _ExplainJson TypedDicts must exist and include new fields."""
275
276 def test_age_list_json_typed_dict_exists(self) -> None:
277 from muse.cli.commands.age import _AgeListJson # noqa: F401
278
279 def test_age_list_json_has_exit_code_annotation(self) -> None:
280 from muse.cli.commands.age import _AgeListJson
281 assert "exit_code" in _AgeListJson.__annotations__
282
283 def test_age_list_json_has_duration_ms_annotation(self) -> None:
284 from muse.cli.commands.age import _AgeListJson
285 assert "duration_ms" in _AgeListJson.__annotations__
286
287 def test_age_list_json_has_symbols_annotation(self) -> None:
288 from muse.cli.commands.age import _AgeListJson
289 assert "symbols" in _AgeListJson.__annotations__
290
291 def test_explain_json_typed_dict_exists(self) -> None:
292 from muse.cli.commands.age import _ExplainJson # noqa: F401
293
294 def test_explain_json_has_exit_code_annotation(self) -> None:
295 from muse.cli.commands.age import _ExplainJson
296 assert "exit_code" in _ExplainJson.__annotations__
297
298 def test_explain_json_has_duration_ms_annotation(self) -> None:
299 from muse.cli.commands.age import _ExplainJson
300 assert "duration_ms" in _ExplainJson.__annotations__
301
302 def test_explain_json_has_events_annotation(self) -> None:
303 from muse.cli.commands.age import _ExplainJson
304 assert "events" in _ExplainJson.__annotations__
305
306 def test_age_record_typed_dict_exists(self) -> None:
307 from muse.cli.commands.age import _AgeRecord # noqa: F401
308
309 def test_age_record_has_est_survival_pct(self) -> None:
310 from muse.cli.commands.age import _AgeRecord
311 assert "est_survival_pct" in _AgeRecord.__annotations__
312
313
314 # ---------------------------------------------------------------------------
315 # TestDocstrings — run() docstring documents new fields
316 # ---------------------------------------------------------------------------
317
318
319 class TestDocstrings:
320 """run() must document exit_code and duration_ms in its docstring."""
321
322 def test_run_docstring_mentions_exit_code(self) -> None:
323 from muse.cli.commands.age import run
324 assert run.__doc__ is not None
325 assert "exit_code" in run.__doc__
326
327 def test_run_docstring_mentions_duration_ms(self) -> None:
328 from muse.cli.commands.age import run
329 assert run.__doc__ is not None
330 assert "duration_ms" in run.__doc__
331
332
333 # ---------------------------------------------------------------------------
334 # TestAnsiSanitization — JSON fields must not contain terminal escape codes
335 # ---------------------------------------------------------------------------
336
337
338 class TestAnsiSanitization:
339 """No ANSI escape sequences in JSON string fields."""
340
341 def test_main_json_no_ansi_in_output(self, age_repo: pathlib.Path) -> None:
342 r = _run(age_repo, "code", "age", "--json")
343 assert "\x1b" not in r.output
344
345 def test_explain_json_no_ansi_in_output(self, age_repo: pathlib.Path) -> None:
346 addr = _first_symbol_address(age_repo)
347 r = _run(age_repo, "code", "age", "--explain", addr, "--json")
348 assert "\x1b" not in r.output
349
350 def test_main_json_addresses_no_ansi(self, age_repo: pathlib.Path) -> None:
351 r = _run(age_repo, "code", "age", "--json")
352 data = json.loads(r.output)
353 for sym in data["symbols"]:
354 assert "\x1b" not in sym["address"]
355
356 def test_explain_json_address_no_ansi(self, age_repo: pathlib.Path) -> None:
357 addr = _first_symbol_address(age_repo)
358 r = _run(age_repo, "code", "age", "--explain", addr, "--json")
359 data = json.loads(r.output)
360 assert "\x1b" not in data["address"]
361
362
363 # ---------------------------------------------------------------------------
364 # TestPerformance — duration_ms stays in a reasonable range
365 # ---------------------------------------------------------------------------
366
367
368 class TestPerformance:
369 """duration_ms must be non-negative and under 1000 ms for small repos."""
370
371 def test_main_json_duration_under_1000ms(self, age_repo: pathlib.Path) -> None:
372 r = _run(age_repo, "code", "age", "--json")
373 data = json.loads(r.output)
374 assert data["duration_ms"] < 1000
375
376 def test_explain_json_duration_under_1000ms(self, age_repo: pathlib.Path) -> None:
377 addr = _first_symbol_address(age_repo)
378 r = _run(age_repo, "code", "age", "--explain", addr, "--json")
379 data = json.loads(r.output)
380 assert data["duration_ms"] < 1000
381
382 def test_duration_ms_type_is_float_not_int(self, age_repo: pathlib.Path) -> None:
383 """duration_ms must always be float, never int — even if value is 0."""
384 r = _run(age_repo, "code", "age", "--json")
385 data = json.loads(r.output)
386 # JSON floats with no decimal part can parse as int — check the raw string.
387 import re
388 m = re.search(r'"duration_ms"\s*:\s*([0-9.e+\-]+)', r.output)
389 assert m is not None, "duration_ms not found in output"
390 # Should contain a decimal point (e.g. "12.3", not "12").
391 assert isinstance(data["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago