gabriel / muse public
test_languages_supercharge.py python
445 lines 18.7 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 languages`` — agent-usability gaps.
2
3 The existing test_code_language_config.py covers the language detection
4 machinery (AST parser, CodeConfig, adapter routing) but has zero CLI tests.
5
6 This file covers the CLI command end-to-end:
7
8 Coverage matrix
9 ---------------
10 - --json / -j: -j alias (was missing — caused argparse error)
11 - exit_code: both JSON paths (snapshot + diff) include exit_code = 0
12 - duration_ms: both JSON paths include non-negative float duration_ms
13 - TypedDicts: _SnapshotOutputJson and _DiffOutputJson carry all envelope fields
14 - Docstrings: run() docstring mentions exit_code and duration_ms
15 - Snapshot JSON: shape, required keys, language entry structure
16 - Diff JSON: shape, required keys, diff entry structure
17 - --sort: name / files / symbols all accepted; output ordering correct
18 - --include-imports: import pseudo-symbols added to counts
19 - --commit: historical snapshot accepted; bad ref exits cleanly
20 - --diff: diff mode activates; bad ref exits cleanly
21 - ANSI: no escape codes in JSON output
22 - Performance: duration_ms < 5000 ms on a small repo
23 """
24
25 from __future__ import annotations
26
27 import json
28 import pathlib
29 import textwrap
30
31 import pytest
32
33 from tests.cli_test_helper import CliRunner
34
35 runner = CliRunner()
36
37
38 # ---------------------------------------------------------------------------
39 # Helpers
40 # ---------------------------------------------------------------------------
41
42
43 def _env(root: pathlib.Path) -> dict[str, str]:
44 return {"MUSE_REPO_ROOT": str(root)}
45
46
47 def _run(root: pathlib.Path, *args: str):
48 return runner.invoke(None, list(args), env=_env(root))
49
50
51 # ---------------------------------------------------------------------------
52 # Fixture — two-commit repo with Python + Markdown files
53 # ---------------------------------------------------------------------------
54
55
56 @pytest.fixture()
57 def lang_repo(
58 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
59 ) -> pathlib.Path:
60 """Repo with Python and Markdown files across two commits.
61
62 Commit 1: billing.py (Invoice class + validate_amount fn)
63 Commit 2: auth.py (AuthError class + verify_token fn) + README.md
64 """
65 monkeypatch.chdir(tmp_path)
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 import decimal
71
72 class Invoice:
73 def compute_total(self, items):
74 return sum(items)
75
76 def validate_amount(amount):
77 return amount > 0
78 """))
79 r = _run(tmp_path, "code", "add", ".")
80 assert r.exit_code == 0, r.output
81 r = _run(tmp_path, "commit", "-m", "first commit")
82 assert r.exit_code == 0, r.output
83
84 (tmp_path / "auth.py").write_text(textwrap.dedent("""\
85 class AuthError(Exception):
86 pass
87
88 def verify_token(token):
89 return bool(token)
90 """))
91 (tmp_path / "README.md").write_text("# Test Repo\n\nA test repo.\n")
92 r = _run(tmp_path, "code", "add", ".")
93 assert r.exit_code == 0, r.output
94 r = _run(tmp_path, "commit", "-m", "second commit")
95 assert r.exit_code == 0, r.output
96
97 return tmp_path
98
99
100 # ---------------------------------------------------------------------------
101 # TestJsonAlias — -j alias must work
102 # ---------------------------------------------------------------------------
103
104
105 class TestJsonAlias:
106 """-j must be accepted and produce identical output to --json."""
107
108 def test_j_alias_exits_zero(self, lang_repo: pathlib.Path) -> None:
109 r = _run(lang_repo, "code", "languages", "-j")
110 assert r.exit_code == 0, r.output
111
112 def test_j_alias_valid_json(self, lang_repo: pathlib.Path) -> None:
113 r = _run(lang_repo, "code", "languages", "-j")
114 json.loads(r.output)
115
116 def test_j_alias_same_keys_as_json_flag(self, lang_repo: pathlib.Path) -> None:
117 r1 = _run(lang_repo, "code", "languages", "--json")
118 r2 = _run(lang_repo, "code", "languages", "-j")
119 d1, d2 = json.loads(r1.output), json.loads(r2.output)
120 d1.pop("duration_ms", None)
121 d2.pop("duration_ms", None)
122 assert set(d1.keys()) == set(d2.keys())
123
124 def test_j_alias_diff_mode(self, lang_repo: pathlib.Path) -> None:
125 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "-j")
126 assert r.exit_code == 0, r.output
127 json.loads(r.output)
128
129 def test_j_alias_no_ansi(self, lang_repo: pathlib.Path) -> None:
130 r = _run(lang_repo, "code", "languages", "-j")
131 assert "\x1b" not in r.output
132
133
134 # ---------------------------------------------------------------------------
135 # TestSnapshotJson — snapshot mode JSON envelope
136 # ---------------------------------------------------------------------------
137
138
139 class TestSnapshotJson:
140 """Snapshot mode JSON must include exit_code, duration_ms, and correct shape."""
141
142 def test_has_exit_code(self, lang_repo: pathlib.Path) -> None:
143 r = _run(lang_repo, "code", "languages", "--json")
144 assert "exit_code" in json.loads(r.output)
145
146 def test_exit_code_zero(self, lang_repo: pathlib.Path) -> None:
147 r = _run(lang_repo, "code", "languages", "--json")
148 assert r.exit_code == 0
149 assert json.loads(r.output)["exit_code"] == 0
150
151 def test_exit_code_mirrors_process_exit(self, lang_repo: pathlib.Path) -> None:
152 r = _run(lang_repo, "code", "languages", "--json")
153 assert json.loads(r.output)["exit_code"] == r.exit_code
154
155 def test_has_duration_ms(self, lang_repo: pathlib.Path) -> None:
156 r = _run(lang_repo, "code", "languages", "--json")
157 assert "duration_ms" in json.loads(r.output)
158
159 def test_duration_ms_is_float(self, lang_repo: pathlib.Path) -> None:
160 r = _run(lang_repo, "code", "languages", "--json")
161 assert isinstance(json.loads(r.output)["duration_ms"], float)
162
163 def test_duration_ms_nonnegative(self, lang_repo: pathlib.Path) -> None:
164 r = _run(lang_repo, "code", "languages", "--json")
165 assert json.loads(r.output)["duration_ms"] >= 0
166
167 def test_has_languages_key(self, lang_repo: pathlib.Path) -> None:
168 r = _run(lang_repo, "code", "languages", "--json")
169 assert "languages" in json.loads(r.output)
170
171 def test_languages_is_list(self, lang_repo: pathlib.Path) -> None:
172 r = _run(lang_repo, "code", "languages", "--json")
173 assert isinstance(json.loads(r.output)["languages"], list)
174
175 def test_has_commit_key(self, lang_repo: pathlib.Path) -> None:
176 r = _run(lang_repo, "code", "languages", "--json")
177 assert "commit" in json.loads(r.output)
178
179 def test_has_include_imports_key(self, lang_repo: pathlib.Path) -> None:
180 r = _run(lang_repo, "code", "languages", "--json")
181 assert "include_imports" in json.loads(r.output)
182
183 def test_include_imports_false_by_default(self, lang_repo: pathlib.Path) -> None:
184 r = _run(lang_repo, "code", "languages", "--json")
185 assert json.loads(r.output)["include_imports"] is False
186
187 def test_python_present_in_languages(self, lang_repo: pathlib.Path) -> None:
188 r = _run(lang_repo, "code", "languages", "--json")
189 langs = {e["language"] for e in json.loads(r.output)["languages"]}
190 assert "Python" in langs
191
192 def test_language_entry_has_required_keys(self, lang_repo: pathlib.Path) -> None:
193 r = _run(lang_repo, "code", "languages", "--json")
194 for entry in json.loads(r.output)["languages"]:
195 assert "language" in entry
196 assert "files" in entry
197 assert "symbols" in entry
198 assert "kinds" in entry
199
200 def test_files_and_symbols_are_ints(self, lang_repo: pathlib.Path) -> None:
201 r = _run(lang_repo, "code", "languages", "--json")
202 for entry in json.loads(r.output)["languages"]:
203 assert isinstance(entry["files"], int)
204 assert isinstance(entry["symbols"], int)
205
206 def test_no_ansi_in_json(self, lang_repo: pathlib.Path) -> None:
207 r = _run(lang_repo, "code", "languages", "--json")
208 assert "\x1b" not in r.output
209
210
211 # ---------------------------------------------------------------------------
212 # TestDiffJson — diff mode JSON envelope
213 # ---------------------------------------------------------------------------
214
215
216 class TestDiffJson:
217 """Diff mode JSON must include exit_code, duration_ms, and correct shape."""
218
219 def test_diff_has_exit_code(self, lang_repo: pathlib.Path) -> None:
220 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
221 assert r.exit_code == 0, r.output
222 assert "exit_code" in json.loads(r.output)
223
224 def test_diff_exit_code_zero(self, lang_repo: pathlib.Path) -> None:
225 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
226 assert json.loads(r.output)["exit_code"] == 0
227
228 def test_diff_has_duration_ms(self, lang_repo: pathlib.Path) -> None:
229 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
230 assert "duration_ms" in json.loads(r.output)
231
232 def test_diff_duration_ms_is_float(self, lang_repo: pathlib.Path) -> None:
233 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
234 assert isinstance(json.loads(r.output)["duration_ms"], float)
235
236 def test_diff_has_from_and_to(self, lang_repo: pathlib.Path) -> None:
237 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
238 d = json.loads(r.output)
239 assert "from" in d
240 assert "to" in d
241
242 def test_diff_has_diff_key(self, lang_repo: pathlib.Path) -> None:
243 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
244 assert "diff" in json.loads(r.output)
245
246 def test_diff_entries_have_required_keys(self, lang_repo: pathlib.Path) -> None:
247 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
248 for entry in json.loads(r.output)["diff"]:
249 assert "language" in entry
250 assert "delta_files" in entry
251 assert "delta_symbols" in entry
252 assert "status" in entry
253
254 def test_diff_python_added_symbols(self, lang_repo: pathlib.Path) -> None:
255 """Second commit added auth.py — Python should show positive delta."""
256 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
257 entries = {e["language"]: e for e in json.loads(r.output)["diff"]}
258 assert "Python" in entries
259 assert entries["Python"]["delta_files"] >= 0
260 assert entries["Python"]["delta_symbols"] >= 0
261
262 def test_diff_status_values_valid(self, lang_repo: pathlib.Path) -> None:
263 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
264 valid = {"added", "removed", "changed", "unchanged"}
265 for entry in json.loads(r.output)["diff"]:
266 assert entry["status"] in valid
267
268 def test_diff_no_ansi(self, lang_repo: pathlib.Path) -> None:
269 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
270 assert "\x1b" not in r.output
271
272
273 # ---------------------------------------------------------------------------
274 # TestSortFlag — --sort ordering
275 # ---------------------------------------------------------------------------
276
277
278 class TestSortFlag:
279 """--sort name / files / symbols must produce correctly ordered output."""
280
281 def test_sort_name_alphabetical(self, lang_repo: pathlib.Path) -> None:
282 r = _run(lang_repo, "code", "languages", "--sort", "name", "--json")
283 langs = [e["language"] for e in json.loads(r.output)["languages"]]
284 assert langs == sorted(langs)
285
286 def test_sort_files_descending(self, lang_repo: pathlib.Path) -> None:
287 r = _run(lang_repo, "code", "languages", "--sort", "files", "--json")
288 counts = [e["files"] for e in json.loads(r.output)["languages"]]
289 assert counts == sorted(counts, reverse=True)
290
291 def test_sort_symbols_descending(self, lang_repo: pathlib.Path) -> None:
292 r = _run(lang_repo, "code", "languages", "--sort", "symbols", "--json")
293 counts = [e["symbols"] for e in json.loads(r.output)["languages"]]
294 assert counts == sorted(counts, reverse=True)
295
296 def test_invalid_sort_exits_nonzero(self, lang_repo: pathlib.Path) -> None:
297 r = _run(lang_repo, "code", "languages", "--sort", "bogus", "--json")
298 assert r.exit_code != 0
299
300
301 # ---------------------------------------------------------------------------
302 # TestIncludeImports — import pseudo-symbols
303 # ---------------------------------------------------------------------------
304
305
306 class TestIncludeImports:
307 """--include-imports must add import pseudo-symbols to counts."""
308
309 def test_include_imports_flag_in_json(self, lang_repo: pathlib.Path) -> None:
310 r = _run(lang_repo, "code", "languages", "--include-imports", "--json")
311 assert json.loads(r.output)["include_imports"] is True
312
313 def test_symbols_higher_with_imports(self, lang_repo: pathlib.Path) -> None:
314 r_no = _run(lang_repo, "code", "languages", "--json")
315 r_yes = _run(lang_repo, "code", "languages", "--include-imports", "--json")
316 py_no = next(e for e in json.loads(r_no.output)["languages"] if e["language"] == "Python")
317 py_yes = next(e for e in json.loads(r_yes.output)["languages"] if e["language"] == "Python")
318 assert py_yes["symbols"] >= py_no["symbols"]
319
320 def test_import_kind_present_with_flag(self, lang_repo: pathlib.Path) -> None:
321 r = _run(lang_repo, "code", "languages", "--include-imports", "--json")
322 py = next(e for e in json.loads(r.output)["languages"] if e["language"] == "Python")
323 assert "import" in py["kinds"]
324
325 def test_import_kind_absent_without_flag(self, lang_repo: pathlib.Path) -> None:
326 r = _run(lang_repo, "code", "languages", "--json")
327 py = next(e for e in json.loads(r.output)["languages"] if e["language"] == "Python")
328 assert "import" not in py["kinds"]
329
330
331 # ---------------------------------------------------------------------------
332 # TestHistoricalCommit — --commit flag
333 # ---------------------------------------------------------------------------
334
335
336 class TestHistoricalCommit:
337 """--commit must accept branch names and commit IDs; bad refs exit cleanly."""
338
339 def test_commit_head_tilde_1_accepted(self, lang_repo: pathlib.Path) -> None:
340 r = _run(lang_repo, "code", "languages", "--commit", "HEAD~1", "--json")
341 assert r.exit_code == 0, r.output
342
343 def test_commit_head_tilde_1_has_envelope(self, lang_repo: pathlib.Path) -> None:
344 r = _run(lang_repo, "code", "languages", "--commit", "HEAD~1", "--json")
345 d = json.loads(r.output)
346 assert "exit_code" in d
347 assert "duration_ms" in d
348
349 def test_commit_head_tilde_1_fewer_languages(self, lang_repo: pathlib.Path) -> None:
350 """First commit has no README.md, so Markdown absent or zero."""
351 r_old = _run(lang_repo, "code", "languages", "--commit", "HEAD~1", "--json")
352 r_new = _run(lang_repo, "code", "languages", "--json")
353 old_langs = {e["language"] for e in json.loads(r_old.output)["languages"] if e["files"] > 0}
354 new_langs = {e["language"] for e in json.loads(r_new.output)["languages"] if e["files"] > 0}
355 # HEAD~1 should have fewer or equal active languages
356 assert len(old_langs) <= len(new_langs)
357
358 def test_bad_commit_ref_exits_nonzero(self, lang_repo: pathlib.Path) -> None:
359 r = _run(lang_repo, "code", "languages", "--commit", "nonexistent_branch", "--json")
360 assert r.exit_code != 0
361
362 def test_bad_commit_no_json_on_error(self, lang_repo: pathlib.Path) -> None:
363 r = _run(lang_repo, "code", "languages", "--commit", "nonexistent_branch", "--json")
364 assert r.exit_code != 0
365 # Should not emit JSON on error
366 with pytest.raises(Exception):
367 json.loads(r.output)
368
369
370 # ---------------------------------------------------------------------------
371 # TestTypedDicts — TypedDicts carry envelope fields
372 # ---------------------------------------------------------------------------
373
374
375 class TestTypedDicts:
376 """_SnapshotOutputJson and _DiffOutputJson must carry exit_code and duration_ms."""
377
378 def test_snapshot_typeddict_exists(self) -> None:
379 from muse.cli.commands.languages import _SnapshotOutputJson # noqa: F401
380
381 def test_snapshot_has_exit_code_annotation(self) -> None:
382 from muse.cli.commands.languages import _SnapshotOutputJson
383 assert "exit_code" in _SnapshotOutputJson.__annotations__
384
385 def test_snapshot_has_duration_ms_annotation(self) -> None:
386 from muse.cli.commands.languages import _SnapshotOutputJson
387 assert "duration_ms" in _SnapshotOutputJson.__annotations__
388
389 def test_snapshot_has_languages_annotation(self) -> None:
390 from muse.cli.commands.languages import _SnapshotOutputJson
391 assert "languages" in _SnapshotOutputJson.__annotations__
392
393 def test_diff_typeddict_exists(self) -> None:
394 from muse.cli.commands.languages import _DiffOutputJson # noqa: F401
395
396 def test_diff_has_exit_code_annotation(self) -> None:
397 from muse.cli.commands.languages import _DiffOutputJson
398 assert "exit_code" in _DiffOutputJson.__annotations__
399
400 def test_diff_has_duration_ms_annotation(self) -> None:
401 from muse.cli.commands.languages import _DiffOutputJson
402 assert "duration_ms" in _DiffOutputJson.__annotations__
403
404 def test_diff_has_diff_annotation(self) -> None:
405 from muse.cli.commands.languages import _DiffOutputJson
406 assert "diff" in _DiffOutputJson.__annotations__
407
408
409 # ---------------------------------------------------------------------------
410 # TestDocstrings
411 # ---------------------------------------------------------------------------
412
413
414 class TestDocstrings:
415 """run() must document exit_code and duration_ms."""
416
417 def test_run_mentions_exit_code(self) -> None:
418 from muse.cli.commands.languages import run
419 assert run.__doc__ is not None
420 assert "exit_code" in run.__doc__
421
422 def test_run_mentions_duration_ms(self) -> None:
423 from muse.cli.commands.languages import run
424 assert "duration_ms" in run.__doc__
425
426
427 # ---------------------------------------------------------------------------
428 # TestPerformance
429 # ---------------------------------------------------------------------------
430
431
432 class TestPerformance:
433 """duration_ms must be present and reasonable on a small repo."""
434
435 def test_snapshot_duration_under_5000ms(self, lang_repo: pathlib.Path) -> None:
436 r = _run(lang_repo, "code", "languages", "--json")
437 assert json.loads(r.output)["duration_ms"] < 5000
438
439 def test_diff_duration_under_5000ms(self, lang_repo: pathlib.Path) -> None:
440 r = _run(lang_repo, "code", "languages", "--diff", "HEAD~1", "--json")
441 assert json.loads(r.output)["duration_ms"] < 5000
442
443 def test_duration_ms_is_float_not_int(self, lang_repo: pathlib.Path) -> None:
444 r = _run(lang_repo, "code", "languages", "--json")
445 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 138 days ago