gabriel / muse public
test_gravity_supercharge.py python
487 lines 19.8 KB
Raw
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 15 hours ago
1 """Supercharge tests for ``muse code gravity`` — agent-usability gaps.
2
3 There are NO existing gravity tests (confirmed: test_cmd_gravity.py is empty,
4 no other gravity test files exist).
5
6 This file targets both correctness gaps and agent-usability gaps:
7
8 Coverage matrix
9 ---------------
10 - --json / -j: -j alias works identically to --json (both modes)
11 - exit_code: JSON output includes exit_code = 0 on success (both modes)
12 - duration_ms: JSON output includes non-negative float duration_ms (both)
13 - TypedDicts: _JsonOut and _GravityExplainJson carry 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 5000 ms for a small repo
17 - Schema: leaderboard JSON has required top-level keys
18 - Explain schema: --explain JSON has required fields
19 - args.as_json: --json flag uses dest="as_json" (idiomatic)
20
21 Two JSON modes exercised
22 ------------------------
23 1. Leaderboard mode: --json / -j → _JsonOut envelope
24 2. Explain mode: --explain ADDR --json → _GravityExplainJson envelope
25 """
26
27 from __future__ import annotations
28 from collections.abc import Mapping
29
30 import argparse
31 import json
32 import pathlib
33 import textwrap
34
35 import pytest
36
37 from tests.cli_test_helper import CliRunner, InvokeResult
38
39 runner = CliRunner()
40
41
42 # ---------------------------------------------------------------------------
43 # Helpers
44 # ---------------------------------------------------------------------------
45
46
47 def _env(root: pathlib.Path) -> Mapping[str, str]:
48 return {"MUSE_REPO_ROOT": str(root)}
49
50
51 def _run(root: pathlib.Path, *args: str) -> InvokeResult:
52 return runner.invoke(None, list(args), env=_env(root))
53
54
55 # ---------------------------------------------------------------------------
56 # Fixture — small Python repo with call-graph structure
57 # ---------------------------------------------------------------------------
58
59
60 @pytest.fixture()
61 def gravity_repo(
62 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
63 ) -> pathlib.Path:
64 """Repo with a simple Python call graph for gravity analysis.
65
66 core.py:
67 def read_object() ← foundation, called by everything
68 def validate(x) ← helper, called by process
69
70 service.py:
71 def process(x) ← calls validate, read_object
72 def publish(x) ← calls process
73
74 api.py:
75 def handle(req) ← calls publish, read_object
76
77 This gives:
78 read_object: high gravity (called by process, publish, handle)
79 validate: medium gravity (called by process → publish → handle)
80 process: medium gravity (called by publish → handle)
81 publish: lower gravity (called by handle)
82 handle: zero gravity (nobody calls it)
83 """
84 monkeypatch.chdir(tmp_path)
85 r = _run(tmp_path, "init", "--domain", "code")
86 assert r.exit_code == 0, r.output
87
88 (tmp_path / "core.py").write_text(textwrap.dedent("""\
89 def read_object(obj_id):
90 \"\"\"Load an object by ID.\"\"\"
91 return {"id": obj_id}
92
93 def validate(x):
94 \"\"\"Validate input.\"\"\"
95 if x is None:
96 raise ValueError("x must not be None")
97 return x
98 """))
99 (tmp_path / "service.py").write_text(textwrap.dedent("""\
100 from core import read_object, validate
101
102 def process(x):
103 \"\"\"Process with validation.\"\"\"
104 v = validate(x)
105 obj = read_object(v)
106 return obj
107
108 def publish(x):
109 \"\"\"Publish a processed result.\"\"\"
110 return process(x)
111 """))
112 (tmp_path / "api.py").write_text(textwrap.dedent("""\
113 from service import publish
114 from core import read_object
115
116 def handle(req):
117 \"\"\"Handle an incoming request.\"\"\"
118 read_object(req)
119 return publish(req)
120 """))
121 r = _run(tmp_path, "code", "add", ".")
122 assert r.exit_code == 0, r.output
123 r = _run(tmp_path, "commit", "-m", "seed gravity repo")
124 assert r.exit_code == 0, r.output
125
126 return tmp_path
127
128
129 # ---------------------------------------------------------------------------
130 # TestJsonAlias — -j works identically to --json (leaderboard mode)
131 # ---------------------------------------------------------------------------
132
133
134 class TestJsonAlias:
135 """-j shorthand must behave identically to --json in leaderboard mode."""
136
137 def test_j_alias_exits_zero(self, gravity_repo: pathlib.Path) -> None:
138 r = _run(gravity_repo, "code", "gravity", "-j")
139 assert r.exit_code == 0, r.output
140
141 def test_j_alias_valid_json(self, gravity_repo: pathlib.Path) -> None:
142 r = _run(gravity_repo, "code", "gravity", "-j")
143 json.loads(r.output) # must not raise
144
145 def test_j_alias_has_symbols_key(self, gravity_repo: pathlib.Path) -> None:
146 r = _run(gravity_repo, "code", "gravity", "-j")
147 assert "symbols" in json.loads(r.output)
148
149 def test_j_alias_has_ref_key(self, gravity_repo: pathlib.Path) -> None:
150 r = _run(gravity_repo, "code", "gravity", "-j")
151 assert "ref" in json.loads(r.output)
152
153 def test_j_alias_has_filters_key(self, gravity_repo: pathlib.Path) -> None:
154 r = _run(gravity_repo, "code", "gravity", "-j")
155 assert "filters" in json.loads(r.output)
156
157 def test_j_alias_same_top_level_keys_as_json_flag(
158 self, gravity_repo: pathlib.Path
159 ) -> None:
160 r1 = _run(gravity_repo, "code", "gravity", "--json")
161 r2 = _run(gravity_repo, "code", "gravity", "-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 def test_j_alias_symbol_count_matches_json_flag(
169 self, gravity_repo: pathlib.Path
170 ) -> None:
171 r1 = _run(gravity_repo, "code", "gravity", "--json")
172 r2 = _run(gravity_repo, "code", "gravity", "-j")
173 assert len(json.loads(r1.output)["symbols"]) == len(
174 json.loads(r2.output)["symbols"]
175 )
176
177 def test_j_alias_with_top_filter(self, gravity_repo: pathlib.Path) -> None:
178 r = _run(gravity_repo, "code", "gravity", "-j", "--top", "2")
179 assert r.exit_code == 0, r.output
180 assert len(json.loads(r.output)["symbols"]) <= 2
181
182 def test_j_alias_with_min_gravity(self, gravity_repo: pathlib.Path) -> None:
183 r = _run(gravity_repo, "code", "gravity", "-j", "--min-gravity", "0")
184 assert r.exit_code == 0, r.output
185 data = json.loads(r.output)
186 assert "symbols" in data
187
188
189 # ---------------------------------------------------------------------------
190 # TestDurationMs — JSON output must include duration_ms in both modes
191 # ---------------------------------------------------------------------------
192
193
194 class TestDurationMs:
195 """Every JSON path must include a non-negative float duration_ms."""
196
197 def test_json_has_duration_ms_leaderboard(self, gravity_repo: pathlib.Path) -> None:
198 r = _run(gravity_repo, "code", "gravity", "--json")
199 assert "duration_ms" in json.loads(r.output)
200
201 def test_json_duration_ms_nonnegative(self, gravity_repo: pathlib.Path) -> None:
202 r = _run(gravity_repo, "code", "gravity", "--json")
203 assert json.loads(r.output)["duration_ms"] >= 0
204
205 def test_json_duration_ms_is_float(self, gravity_repo: pathlib.Path) -> None:
206 r = _run(gravity_repo, "code", "gravity", "--json")
207 assert isinstance(json.loads(r.output)["duration_ms"], float)
208
209 def test_j_alias_duration_ms_present(self, gravity_repo: pathlib.Path) -> None:
210 r = _run(gravity_repo, "code", "gravity", "-j")
211 assert "duration_ms" in json.loads(r.output)
212
213 def test_duration_ms_with_top_filter(self, gravity_repo: pathlib.Path) -> None:
214 r = _run(gravity_repo, "code", "gravity", "--json", "--top", "2")
215 data = json.loads(r.output)
216 assert "duration_ms" in data
217 assert data["duration_ms"] >= 0
218
219 def test_duration_ms_explain_mode(self, gravity_repo: pathlib.Path) -> None:
220 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
221 assert r.exit_code == 0, r.output
222 data = json.loads(r.output)
223 assert "duration_ms" in data
224 assert isinstance(data["duration_ms"], float)
225 assert data["duration_ms"] >= 0
226
227 def test_j_alias_duration_ms_explain(self, gravity_repo: pathlib.Path) -> None:
228 r = _run(gravity_repo, "code", "gravity", "-j", "--explain", "core.py::read_object")
229 assert r.exit_code == 0, r.output
230 assert "duration_ms" in json.loads(r.output)
231
232
233 # ---------------------------------------------------------------------------
234 # TestExitCode — JSON includes exit_code = 0 on success (both modes)
235 # ---------------------------------------------------------------------------
236
237
238 class TestExitCode:
239 """JSON exit_code must be 0 on success in both leaderboard and explain modes."""
240
241 def test_json_has_exit_code_leaderboard(self, gravity_repo: pathlib.Path) -> None:
242 r = _run(gravity_repo, "code", "gravity", "--json")
243 assert "exit_code" in json.loads(r.output)
244
245 def test_json_exit_code_zero_leaderboard(self, gravity_repo: pathlib.Path) -> None:
246 r = _run(gravity_repo, "code", "gravity", "--json")
247 assert r.exit_code == 0
248 assert json.loads(r.output)["exit_code"] == 0
249
250 def test_json_exit_code_is_int_leaderboard(self, gravity_repo: pathlib.Path) -> None:
251 r = _run(gravity_repo, "code", "gravity", "--json")
252 assert isinstance(json.loads(r.output)["exit_code"], int)
253
254 def test_j_alias_exit_code_present(self, gravity_repo: pathlib.Path) -> None:
255 r = _run(gravity_repo, "code", "gravity", "-j")
256 assert "exit_code" in json.loads(r.output)
257
258 def test_exit_code_mirrors_process_exit(self, gravity_repo: pathlib.Path) -> None:
259 r = _run(gravity_repo, "code", "gravity", "--json")
260 assert json.loads(r.output)["exit_code"] == r.exit_code
261
262 def test_json_has_exit_code_explain(self, gravity_repo: pathlib.Path) -> None:
263 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
264 assert r.exit_code == 0, r.output
265 assert "exit_code" in json.loads(r.output)
266
267 def test_json_exit_code_zero_explain(self, gravity_repo: pathlib.Path) -> None:
268 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
269 assert r.exit_code == 0
270 assert json.loads(r.output)["exit_code"] == 0
271
272 def test_exit_code_is_int_explain(self, gravity_repo: pathlib.Path) -> None:
273 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
274 assert isinstance(json.loads(r.output)["exit_code"], int)
275
276 def test_exit_code_mirrors_process_exit_explain(
277 self, gravity_repo: pathlib.Path
278 ) -> None:
279 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
280 assert json.loads(r.output)["exit_code"] == r.exit_code
281
282
283 # ---------------------------------------------------------------------------
284 # TestTypedDicts — TypedDicts carry exit_code and duration_ms
285 # ---------------------------------------------------------------------------
286
287
288 class TestTypedDicts:
289 """_JsonOut and _GravityExplainJson must carry exit_code and duration_ms."""
290
291 def test_json_out_typeddict_exists(self) -> None:
292 from muse.cli.commands.gravity import _JsonOut # noqa: F401
293
294 def test_json_out_has_exit_code_annotation(self) -> None:
295 from muse.cli.commands.gravity import _JsonOut
296 assert "exit_code" in _JsonOut.__annotations__
297
298 def test_json_out_has_duration_ms_annotation(self) -> None:
299 from muse.cli.commands.gravity import _JsonOut
300 assert "duration_ms" in _JsonOut.__annotations__
301
302 def test_json_out_retains_symbols_annotation(self) -> None:
303 from muse.cli.commands.gravity import _JsonOut
304 assert "symbols" in _JsonOut.__annotations__
305
306 def test_json_out_retains_filters_annotation(self) -> None:
307 from muse.cli.commands.gravity import _JsonOut
308 assert "filters" in _JsonOut.__annotations__
309
310 def test_gravity_explain_json_exists(self) -> None:
311 from muse.cli.commands.gravity import _GravityExplainJson # noqa: F401
312
313 def test_gravity_explain_json_has_exit_code(self) -> None:
314 from muse.cli.commands.gravity import _GravityExplainJson
315 assert "exit_code" in _GravityExplainJson.__annotations__
316
317 def test_gravity_explain_json_has_duration_ms(self) -> None:
318 from muse.cli.commands.gravity import _GravityExplainJson
319 assert "duration_ms" in _GravityExplainJson.__annotations__
320
321 def test_gravity_explain_json_has_address(self) -> None:
322 from muse.cli.commands.gravity import _GravityExplainJson
323 assert "address" in _GravityExplainJson.__annotations__
324
325 def test_gravity_explain_json_has_gravity_pct(self) -> None:
326 from muse.cli.commands.gravity import _GravityExplainJson
327 assert "gravity_pct" in _GravityExplainJson.__annotations__
328
329
330 # ---------------------------------------------------------------------------
331 # TestAnsiSanitization — no escape codes in JSON output
332 # ---------------------------------------------------------------------------
333
334
335 class TestAnsiSanitization:
336 """No ANSI escape sequences anywhere in the JSON output."""
337
338 def test_json_output_no_ansi_leaderboard(self, gravity_repo: pathlib.Path) -> None:
339 r = _run(gravity_repo, "code", "gravity", "--json")
340 assert "\x1b" not in r.output
341
342 def test_j_alias_output_no_ansi(self, gravity_repo: pathlib.Path) -> None:
343 r = _run(gravity_repo, "code", "gravity", "-j")
344 assert "\x1b" not in r.output
345
346 def test_json_output_no_ansi_explain(self, gravity_repo: pathlib.Path) -> None:
347 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
348 assert "\x1b" not in r.output
349
350
351 # ---------------------------------------------------------------------------
352 # TestLeaderboardSchema — JSON shape for leaderboard mode
353 # ---------------------------------------------------------------------------
354
355
356 class TestLeaderboardSchema:
357 """Leaderboard JSON must carry the documented top-level keys."""
358
359 def test_has_ref_key(self, gravity_repo: pathlib.Path) -> None:
360 r = _run(gravity_repo, "code", "gravity", "--json")
361 assert "ref" in json.loads(r.output)
362
363 def test_has_snapshot_id_key(self, gravity_repo: pathlib.Path) -> None:
364 r = _run(gravity_repo, "code", "gravity", "--json")
365 assert "snapshot_id" in json.loads(r.output)
366
367 def test_has_total_production_symbols_key(self, gravity_repo: pathlib.Path) -> None:
368 r = _run(gravity_repo, "code", "gravity", "--json")
369 assert "total_production_symbols" in json.loads(r.output)
370
371 def test_has_include_tests_key(self, gravity_repo: pathlib.Path) -> None:
372 r = _run(gravity_repo, "code", "gravity", "--json")
373 assert "include_tests" in json.loads(r.output)
374
375 def test_include_tests_is_false_by_default(self, gravity_repo: pathlib.Path) -> None:
376 r = _run(gravity_repo, "code", "gravity", "--json")
377 assert json.loads(r.output)["include_tests"] is False
378
379 def test_symbols_is_list(self, gravity_repo: pathlib.Path) -> None:
380 r = _run(gravity_repo, "code", "gravity", "--json")
381 assert isinstance(json.loads(r.output)["symbols"], list)
382
383 def test_symbol_entries_have_gravity_pct(self, gravity_repo: pathlib.Path) -> None:
384 r = _run(gravity_repo, "code", "gravity", "--json")
385 data = json.loads(r.output)
386 for sym in data["symbols"]:
387 assert "gravity_pct" in sym
388
389 def test_symbol_entries_have_address(self, gravity_repo: pathlib.Path) -> None:
390 r = _run(gravity_repo, "code", "gravity", "--json")
391 data = json.loads(r.output)
392 for sym in data["symbols"]:
393 assert "address" in sym
394
395 def test_top_filter_bounds_symbols(self, gravity_repo: pathlib.Path) -> None:
396 r = _run(gravity_repo, "code", "gravity", "--json", "--top", "2")
397 data = json.loads(r.output)
398 assert len(data["symbols"]) <= 2
399
400
401 # ---------------------------------------------------------------------------
402 # TestExplainSchema — JSON shape for --explain mode
403 # ---------------------------------------------------------------------------
404
405
406 class TestExplainSchema:
407 """Explain JSON must carry the documented fields."""
408
409 def test_explain_has_address(self, gravity_repo: pathlib.Path) -> None:
410 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
411 assert r.exit_code == 0, r.output
412 assert "address" in json.loads(r.output)
413
414 def test_explain_has_gravity_pct(self, gravity_repo: pathlib.Path) -> None:
415 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
416 data = json.loads(r.output)
417 assert "gravity_pct" in data
418 assert isinstance(data["gravity_pct"], float)
419
420 def test_explain_has_direct_dependents(self, gravity_repo: pathlib.Path) -> None:
421 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
422 assert "direct_dependents" in json.loads(r.output)
423
424 def test_explain_has_depth_distribution(self, gravity_repo: pathlib.Path) -> None:
425 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
426 assert "depth_distribution" in json.loads(r.output)
427
428 def test_explain_address_matches_flag(self, gravity_repo: pathlib.Path) -> None:
429 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::validate")
430 assert r.exit_code == 0, r.output
431 data = json.loads(r.output)
432 assert data["address"] == "core.py::validate"
433
434
435 # ---------------------------------------------------------------------------
436 # TestPerformance — duration_ms under 5000 ms for a small repo
437 # ---------------------------------------------------------------------------
438
439
440 class TestPerformance:
441 """duration_ms must stay under 5000 ms for small repos (AST parse overhead)."""
442
443 def test_leaderboard_duration_under_5000ms(self, gravity_repo: pathlib.Path) -> None:
444 r = _run(gravity_repo, "code", "gravity", "--json")
445 assert json.loads(r.output)["duration_ms"] < 5000
446
447 def test_explain_duration_under_5000ms(self, gravity_repo: pathlib.Path) -> None:
448 r = _run(gravity_repo, "code", "gravity", "--json", "--explain", "core.py::read_object")
449 assert json.loads(r.output)["duration_ms"] < 5000
450
451 def test_duration_ms_is_float_not_int(self, gravity_repo: pathlib.Path) -> None:
452 r = _run(gravity_repo, "code", "gravity", "--json")
453 assert isinstance(json.loads(r.output)["duration_ms"], float)
454
455
456 # ---------------------------------------------------------------------------
457 # TestRegisterFlags — argparse-level verification
458 # ---------------------------------------------------------------------------
459
460
461 class TestRegisterFlags:
462 """Verify that register() wires --json / -j correctly."""
463
464 def _make_parser(self) -> "argparse.ArgumentParser":
465 import argparse
466 from muse.cli.commands.gravity import register
467 ap = argparse.ArgumentParser()
468 subs = ap.add_subparsers()
469 register(subs)
470 return ap
471
472 def test_json_flag_long(self) -> None:
473 ns = self._make_parser().parse_args(["gravity", "--json"])
474 assert ns.json_out is True
475
476 def test_j_alias(self) -> None:
477 ns = self._make_parser().parse_args(["gravity", "-j"])
478 assert ns.json_out is True
479
480 def test_default_is_text(self) -> None:
481 ns = self._make_parser().parse_args(["gravity"])
482 assert ns.json_out is False
483
484 def test_dest_is_json_out(self) -> None:
485 ns = self._make_parser().parse_args(["gravity", "-j"])
486 assert hasattr(ns, "json_out")
487 assert not hasattr(ns, "fmt")
File History 1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 15 hours ago