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