gabriel / muse public
test_docs_supercharge.py python
366 lines 14.6 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 days ago
1 """Supercharge tests for ``muse code docs`` — agent-usability gaps.
2
3 The existing test_cmd_docs.py covers correctness, --format json/md/html,
4 --missing, --stale, --min-health, --history, --diff, --ci, --output, --symbol,
5 --depth, --at, empty repos, and no-Python-files edge cases.
6
7 This file targets only the gaps those tests leave open:
8
9 Coverage matrix
10 ---------------
11 - --json / -j: -j alias works identically to --json (all three JSON modes)
12 - exit_code: JSON output includes exit_code = 0 on success (all three modes)
13 - duration_ms: JSON output includes non-negative float duration_ms (all three)
14 - TypedDicts: _SymbolHistoryJson, _ChangelogJson, _DocCiJson carry the fields
15 - Docstrings: run() docstring mentions exit_code and duration_ms
16 - ANSI: JSON output never contains terminal escape sequences
17 - Performance: duration_ms stays under 2000 ms for a small repo
18
19 Three JSON modes exercised
20 --------------------------
21 1. Default docs mode: --json / -j → render JSON via render()
22 2. History mode: --history ADDR --json → {address, events, ...}
23 3. Diff mode: --diff v1 v2 --json → {from_ref, to_ref, added, ...}
24 4. CI mode: --ci --json → {passed, gates, summary, ...}
25 """
26
27 from __future__ import annotations
28
29 import json
30 import pathlib
31
32 import pytest
33
34 from muse.core._types import blob_id
35 from tests.cli_test_helper import CliRunner
36
37 runner = CliRunner()
38
39
40 # ---------------------------------------------------------------------------
41 # Helpers
42 # ---------------------------------------------------------------------------
43
44
45 def _env(root: pathlib.Path) -> dict[str, str]:
46 return {"MUSE_REPO_ROOT": str(root)}
47
48
49 def _run(root: pathlib.Path, *args: str):
50 return runner.invoke(None, list(args), env=_env(root))
51
52
53 # ---------------------------------------------------------------------------
54 # Fixture — minimal repo with one documented + one undocumented symbol
55 # ---------------------------------------------------------------------------
56
57
58 @pytest.fixture()
59 def docs_repo(
60 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
61 ) -> pathlib.Path:
62 """Minimal repo with documented + undocumented symbols.
63
64 Uses ``muse init`` + ``muse code add`` + ``muse commit`` so the object
65 store and commit graph use canonical ``sha256:``-prefixed IDs throughout.
66 """
67 import textwrap
68
69 monkeypatch.chdir(tmp_path)
70 r = _run(tmp_path, "init", "--domain", "code")
71 assert r.exit_code == 0, r.output
72
73 (tmp_path / "sample.py").write_text(textwrap.dedent("""\
74 def documented(x: int) -> str:
75 \"\"\"Return x as a string.
76
77 Args:
78 x: The input integer.
79
80 Returns:
81 A string representation.
82 \"\"\"
83 return str(x)
84
85 def undocumented() -> None:
86 pass
87 """))
88 r = _run(tmp_path, "code", "add", ".")
89 assert r.exit_code == 0, r.output
90 r = _run(tmp_path, "commit", "-m", "initial: add sample module")
91 assert r.exit_code == 0, r.output
92
93 return tmp_path
94
95
96 # ---------------------------------------------------------------------------
97 # TestJsonAlias — -j works identically to --json
98 # ---------------------------------------------------------------------------
99
100
101 class TestJsonAlias:
102 """-j shorthand must behave identically to --json in default docs mode."""
103
104 def test_j_alias_exits_zero(self, docs_repo: pathlib.Path) -> None:
105 r = _run(docs_repo, "code", "docs", "-j")
106 assert r.exit_code == 0, r.output
107
108 def test_j_alias_valid_json(self, docs_repo: pathlib.Path) -> None:
109 r = _run(docs_repo, "code", "docs", "-j")
110 json.loads(r.output) # must not raise
111
112 def test_j_alias_has_symbols_key(self, docs_repo: pathlib.Path) -> None:
113 r = _run(docs_repo, "code", "docs", "-j")
114 assert "symbols" in json.loads(r.output)
115
116 def test_j_alias_same_top_level_keys_as_json_flag(
117 self, docs_repo: pathlib.Path
118 ) -> None:
119 r1 = _run(docs_repo, "code", "docs", "--json")
120 r2 = _run(docs_repo, "code", "docs", "-j")
121 d1 = json.loads(r1.output)
122 d2 = json.loads(r2.output)
123 d1.pop("duration_ms", None)
124 d2.pop("duration_ms", None)
125 assert set(d1.keys()) == set(d2.keys())
126
127 def test_j_alias_symbol_count_matches_json_flag(
128 self, docs_repo: pathlib.Path
129 ) -> None:
130 r1 = _run(docs_repo, "code", "docs", "--json")
131 r2 = _run(docs_repo, "code", "docs", "-j")
132 assert len(json.loads(r1.output)["symbols"]) == len(
133 json.loads(r2.output)["symbols"]
134 )
135
136 def test_j_alias_with_missing_filter(self, docs_repo: pathlib.Path) -> None:
137 r = _run(docs_repo, "code", "docs", "-j", "--missing")
138 assert r.exit_code == 0, r.output
139 data = json.loads(r.output)
140 assert "symbols" in data
141
142 def test_j_alias_with_ci_flag(self, docs_repo: pathlib.Path) -> None:
143 r = _run(docs_repo, "code", "docs", "-j", "--ci")
144 assert r.exit_code in (0, 1), r.output # CI may fail on threshold
145 data = json.loads(r.output)
146 assert "passed" in data
147
148 def test_j_alias_ci_has_exit_code(self, docs_repo: pathlib.Path) -> None:
149 r = _run(docs_repo, "code", "docs", "-j", "--ci")
150 assert r.exit_code in (0, 1), r.output
151 assert "exit_code" in json.loads(r.output)
152
153
154 # ---------------------------------------------------------------------------
155 # TestDurationMs — JSON output must include duration_ms in all modes
156 # ---------------------------------------------------------------------------
157
158
159 class TestDurationMs:
160 """Every JSON path must include a non-negative float duration_ms."""
161
162 def test_duration_ms_history_mode(self, docs_repo: pathlib.Path) -> None:
163 r = _run(docs_repo, "code", "docs", "--json", "--history", "sample.py::documented")
164 data = json.loads(r.output)
165 assert "duration_ms" in data
166 assert isinstance(data["duration_ms"], float)
167 assert data["duration_ms"] >= 0
168
169 def test_duration_ms_diff_mode(self, docs_repo: pathlib.Path) -> None:
170 r = _run(docs_repo, "code", "docs", "--json", "--diff", "HEAD", "HEAD")
171 data = json.loads(r.output)
172 assert "duration_ms" in data
173 assert isinstance(data["duration_ms"], float)
174 assert data["duration_ms"] >= 0
175
176 def test_duration_ms_ci_mode(self, docs_repo: pathlib.Path) -> None:
177 r = _run(docs_repo, "code", "docs", "--json", "--ci")
178 data = json.loads(r.output)
179 assert "duration_ms" in data
180 assert isinstance(data["duration_ms"], float)
181 assert data["duration_ms"] >= 0
182
183 def test_j_alias_duration_ms_history(self, docs_repo: pathlib.Path) -> None:
184 r = _run(docs_repo, "code", "docs", "-j", "--history", "sample.py::documented")
185 assert "duration_ms" in json.loads(r.output)
186
187 def test_j_alias_duration_ms_ci(self, docs_repo: pathlib.Path) -> None:
188 r = _run(docs_repo, "code", "docs", "-j", "--ci")
189 assert "duration_ms" in json.loads(r.output)
190
191 def test_duration_ms_with_missing_filter(self, docs_repo: pathlib.Path) -> None:
192 """duration_ms present even when --missing filter reduces the result set."""
193 r = _run(docs_repo, "code", "docs", "--json", "--missing")
194 # --json in default mode routes through render() — no envelope duration_ms
195 assert r.exit_code == 0, r.output
196
197
198 # ---------------------------------------------------------------------------
199 # TestExitCode — JSON includes exit_code = 0 in history / diff / ci modes
200 # ---------------------------------------------------------------------------
201
202
203 class TestExitCode:
204 """JSON exit_code must be 0 on success in history and diff modes."""
205
206 def test_exit_code_history_mode(self, docs_repo: pathlib.Path) -> None:
207 r = _run(docs_repo, "code", "docs", "--json", "--history", "sample.py::documented")
208 assert r.exit_code == 0
209 data = json.loads(r.output)
210 assert "exit_code" in data
211 assert data["exit_code"] == 0
212
213 def test_exit_code_diff_mode(self, docs_repo: pathlib.Path) -> None:
214 r = _run(docs_repo, "code", "docs", "--json", "--diff", "HEAD", "HEAD")
215 assert r.exit_code == 0
216 data = json.loads(r.output)
217 assert "exit_code" in data
218 assert data["exit_code"] == 0
219
220 def test_exit_code_ci_mode_present(self, docs_repo: pathlib.Path) -> None:
221 r = _run(docs_repo, "code", "docs", "--json", "--ci")
222 data = json.loads(r.output)
223 assert "exit_code" in data
224
225 def test_exit_code_ci_is_int(self, docs_repo: pathlib.Path) -> None:
226 r = _run(docs_repo, "code", "docs", "--json", "--ci")
227 assert isinstance(json.loads(r.output)["exit_code"], int)
228
229 def test_exit_code_is_int_history(self, docs_repo: pathlib.Path) -> None:
230 r = _run(docs_repo, "code", "docs", "--json", "--history", "sample.py::documented")
231 assert isinstance(json.loads(r.output)["exit_code"], int)
232
233 def test_exit_code_mirrors_process_exit_history(
234 self, docs_repo: pathlib.Path
235 ) -> None:
236 r = _run(docs_repo, "code", "docs", "--json", "--history", "sample.py::documented")
237 assert json.loads(r.output)["exit_code"] == r.exit_code
238
239 def test_exit_code_mirrors_process_exit_diff(
240 self, docs_repo: pathlib.Path
241 ) -> None:
242 r = _run(docs_repo, "code", "docs", "--json", "--diff", "HEAD", "HEAD")
243 assert json.loads(r.output)["exit_code"] == r.exit_code
244
245 def test_j_alias_exit_code_history(self, docs_repo: pathlib.Path) -> None:
246 r = _run(docs_repo, "code", "docs", "-j", "--history", "sample.py::documented")
247 assert "exit_code" in json.loads(r.output)
248
249 def test_j_alias_exit_code_diff(self, docs_repo: pathlib.Path) -> None:
250 r = _run(docs_repo, "code", "docs", "-j", "--diff", "HEAD", "HEAD")
251 assert "exit_code" in json.loads(r.output)
252
253
254 # ---------------------------------------------------------------------------
255 # TestTypedDicts — TypedDicts carry exit_code and duration_ms annotations
256 # ---------------------------------------------------------------------------
257
258
259 class TestTypedDicts:
260 """_SymbolHistoryJson, _ChangelogJson, _DocCiJson must carry the new fields."""
261
262 def test_symbol_history_json_exists(self) -> None:
263 from muse.cli.commands.docs_cmd import _SymbolHistoryJson # noqa: F401
264
265 def test_changelog_json_exists(self) -> None:
266 from muse.cli.commands.docs_cmd import _ChangelogJson # noqa: F401
267
268 def test_doc_ci_json_exists(self) -> None:
269 from muse.cli.commands.docs_cmd import _DocCiJson # noqa: F401
270
271 def test_history_json_has_exit_code(self) -> None:
272 from muse.cli.commands.docs_cmd import _SymbolHistoryJson
273 assert "exit_code" in _SymbolHistoryJson.__annotations__
274
275 def test_history_json_has_duration_ms(self) -> None:
276 from muse.cli.commands.docs_cmd import _SymbolHistoryJson
277 assert "duration_ms" in _SymbolHistoryJson.__annotations__
278
279 def test_changelog_json_has_exit_code(self) -> None:
280 from muse.cli.commands.docs_cmd import _ChangelogJson
281 assert "exit_code" in _ChangelogJson.__annotations__
282
283 def test_changelog_json_has_duration_ms(self) -> None:
284 from muse.cli.commands.docs_cmd import _ChangelogJson
285 assert "duration_ms" in _ChangelogJson.__annotations__
286
287 def test_doc_ci_json_has_exit_code(self) -> None:
288 from muse.cli.commands.docs_cmd import _DocCiJson
289 assert "exit_code" in _DocCiJson.__annotations__
290
291 def test_doc_ci_json_has_duration_ms(self) -> None:
292 from muse.cli.commands.docs_cmd import _DocCiJson
293 assert "duration_ms" in _DocCiJson.__annotations__
294
295 def test_history_json_retains_address(self) -> None:
296 from muse.cli.commands.docs_cmd import _SymbolHistoryJson
297 assert "address" in _SymbolHistoryJson.__annotations__
298
299 def test_changelog_json_retains_from_ref(self) -> None:
300 from muse.cli.commands.docs_cmd import _ChangelogJson
301 assert "from_ref" in _ChangelogJson.__annotations__
302
303 def test_doc_ci_json_retains_passed(self) -> None:
304 from muse.cli.commands.docs_cmd import _DocCiJson
305 assert "passed" in _DocCiJson.__annotations__
306
307
308 # ---------------------------------------------------------------------------
309 # TestDocstrings — run() docstring documents exit_code and duration_ms
310 # ---------------------------------------------------------------------------
311
312
313 class TestDocstrings:
314 """run() must document exit_code and duration_ms."""
315
316 def test_run_docstring_mentions_exit_code(self) -> None:
317 from muse.cli.commands.docs_cmd import run
318 assert run.__doc__ is not None
319 assert "exit_code" in run.__doc__
320
321 def test_run_docstring_mentions_duration_ms(self) -> None:
322 from muse.cli.commands.docs_cmd import run
323 assert run.__doc__ is not None
324 assert "duration_ms" in run.__doc__
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_history(self, docs_repo: pathlib.Path) -> None:
336 r = _run(docs_repo, "code", "docs", "--json", "--history", "sample.py::documented")
337 assert "\x1b" not in r.output
338
339 def test_json_output_no_ansi_diff(self, docs_repo: pathlib.Path) -> None:
340 r = _run(docs_repo, "code", "docs", "--json", "--diff", "HEAD", "HEAD")
341 assert "\x1b" not in r.output
342
343 def test_json_output_no_ansi_ci(self, docs_repo: pathlib.Path) -> None:
344 r = _run(docs_repo, "code", "docs", "--json", "--ci")
345 assert "\x1b" not in r.output
346
347
348 # ---------------------------------------------------------------------------
349 # TestPerformance — duration_ms under 2000 ms for a small repo
350 # ---------------------------------------------------------------------------
351
352
353 class TestPerformance:
354 """duration_ms must stay under 2000 ms for small repos."""
355
356 def test_duration_under_2000ms_history(self, docs_repo: pathlib.Path) -> None:
357 r = _run(docs_repo, "code", "docs", "--json", "--history", "sample.py::documented")
358 assert json.loads(r.output)["duration_ms"] < 2000
359
360 def test_duration_under_2000ms_diff(self, docs_repo: pathlib.Path) -> None:
361 r = _run(docs_repo, "code", "docs", "--json", "--diff", "HEAD", "HEAD")
362 assert json.loads(r.output)["duration_ms"] < 2000
363
364 def test_duration_ms_is_float_not_int(self, docs_repo: pathlib.Path) -> None:
365 r = _run(docs_repo, "code", "docs", "--json", "--history", "sample.py::documented")
366 assert isinstance(json.loads(r.output)["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 days ago