gabriel / muse public
test_invariants_supercharge.py python
372 lines 14.7 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Supercharge tests for ``muse code invariants`` — agent-usability gaps.
2
3 Existing tests (test_cmd_invariants.py) cover rule types, violation detection,
4 --strict, --rule filter, no-rules file default, JSON schema basics.
5
6 This file targets only the gaps those tests leave open:
7
8 Coverage matrix
9 ---------------
10 - --json / -j: -j alias works identically to --json
11 - exit_code: JSON output includes exit_code reflecting violation status
12 (0 = all pass / warnings only; 1 = errors or strict+warnings)
13 - duration_ms: JSON output includes non-negative float duration_ms
14 - TypedDicts: _InvariantsOutputJson carries exit_code and duration_ms
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 - Early-exit paths: no-match --rule filter emits exit_code and duration_ms
19 - HEAD~N ref syntax: --commit HEAD~1 must not crash (was raising ValueError)
20 """
21
22 from __future__ import annotations
23 from collections.abc import Mapping
24
25 import json
26 import pathlib
27 import textwrap
28
29 import pytest
30
31 from tests.cli_test_helper import CliRunner
32
33 runner = CliRunner()
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _env(root: pathlib.Path) -> Mapping[str, str]:
42 return {"MUSE_REPO_ROOT": str(root)}
43
44
45 def _run(root: pathlib.Path, *args: str):
46 return runner.invoke(None, list(args), env=_env(root))
47
48
49 # ---------------------------------------------------------------------------
50 # Fixture — minimal Python repo (no invariants violations)
51 # ---------------------------------------------------------------------------
52
53
54 @pytest.fixture()
55 def inv_repo(
56 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
57 ) -> pathlib.Path:
58 """Minimal repo — clean Python files, no circular imports."""
59 monkeypatch.chdir(tmp_path)
60 r = _run(tmp_path, "init", "--domain", "code")
61 assert r.exit_code == 0, r.output
62
63 (tmp_path / "core.py").write_text(textwrap.dedent("""\
64 def compute(x):
65 return x * 2
66 """))
67 (tmp_path / "service.py").write_text(textwrap.dedent("""\
68 from core import compute
69
70 def process(x):
71 return compute(x)
72 """))
73 r = _run(tmp_path, "code", "add", ".")
74 assert r.exit_code == 0, r.output
75 r = _run(tmp_path, "commit", "-m", "seed invariants repo")
76 assert r.exit_code == 0, r.output
77
78 return tmp_path
79
80
81 # ---------------------------------------------------------------------------
82 # TestJsonAlias — -j works identically to --json
83 # ---------------------------------------------------------------------------
84
85
86 class TestJsonAlias:
87 """-j shorthand must behave identically to --json."""
88
89 def test_j_alias_exits_zero(self, inv_repo: pathlib.Path) -> None:
90 r = _run(inv_repo, "code", "invariants", "-j")
91 assert r.exit_code == 0, r.output
92
93 def test_j_alias_valid_json(self, inv_repo: pathlib.Path) -> None:
94 r = _run(inv_repo, "code", "invariants", "-j")
95 json.loads(r.output) # must not raise
96
97 def test_j_alias_has_violations_key(self, inv_repo: pathlib.Path) -> None:
98 r = _run(inv_repo, "code", "invariants", "-j")
99 assert "violations" in json.loads(r.output)
100
101 def test_j_alias_has_errors_key(self, inv_repo: pathlib.Path) -> None:
102 r = _run(inv_repo, "code", "invariants", "-j")
103 assert "errors" in json.loads(r.output)
104
105 def test_j_alias_same_top_level_keys_as_json_flag(
106 self, inv_repo: pathlib.Path
107 ) -> None:
108 r1 = _run(inv_repo, "code", "invariants", "--json")
109 r2 = _run(inv_repo, "code", "invariants", "-j")
110 d1 = json.loads(r1.output)
111 d2 = json.loads(r2.output)
112 d1.pop("duration_ms", None)
113 d2.pop("duration_ms", None)
114 assert set(d1.keys()) == set(d2.keys())
115
116 def test_j_alias_violations_is_list(self, inv_repo: pathlib.Path) -> None:
117 r = _run(inv_repo, "code", "invariants", "-j")
118 data = json.loads(r.output)
119 assert isinstance(data["violations"], list)
120
121 def test_j_alias_rules_checked_positive(self, inv_repo: pathlib.Path) -> None:
122 r = _run(inv_repo, "code", "invariants", "-j")
123 data = json.loads(r.output)
124 assert data["rules_checked"] >= 0
125
126
127 # ---------------------------------------------------------------------------
128 # TestDurationMs — JSON output must include duration_ms
129 # ---------------------------------------------------------------------------
130
131
132 class TestDurationMs:
133 """JSON output must include a non-negative float duration_ms."""
134
135 def test_json_has_duration_ms(self, inv_repo: pathlib.Path) -> None:
136 r = _run(inv_repo, "code", "invariants", "--json")
137 assert "duration_ms" in json.loads(r.output)
138
139 def test_json_duration_ms_nonnegative(self, inv_repo: pathlib.Path) -> None:
140 r = _run(inv_repo, "code", "invariants", "--json")
141 assert json.loads(r.output)["duration_ms"] >= 0
142
143 def test_json_duration_ms_is_float(self, inv_repo: pathlib.Path) -> None:
144 r = _run(inv_repo, "code", "invariants", "--json")
145 assert isinstance(json.loads(r.output)["duration_ms"], float)
146
147 def test_j_alias_duration_ms_present(self, inv_repo: pathlib.Path) -> None:
148 r = _run(inv_repo, "code", "invariants", "-j")
149 assert "duration_ms" in json.loads(r.output)
150
151 def test_duration_ms_under_2000ms(self, inv_repo: pathlib.Path) -> None:
152 r = _run(inv_repo, "code", "invariants", "--json")
153 assert json.loads(r.output)["duration_ms"] < 2000
154
155
156 # ---------------------------------------------------------------------------
157 # TestExitCode — JSON includes exit_code reflecting violation status
158 # ---------------------------------------------------------------------------
159
160
161 class TestExitCode:
162 """JSON exit_code must mirror the process exit code."""
163
164 def test_json_has_exit_code(self, inv_repo: pathlib.Path) -> None:
165 r = _run(inv_repo, "code", "invariants", "--json")
166 assert "exit_code" in json.loads(r.output)
167
168 def test_json_exit_code_zero_clean_repo(self, inv_repo: pathlib.Path) -> None:
169 r = _run(inv_repo, "code", "invariants", "--json")
170 assert r.exit_code == 0
171 assert json.loads(r.output)["exit_code"] == 0
172
173 def test_json_exit_code_is_int(self, inv_repo: pathlib.Path) -> None:
174 r = _run(inv_repo, "code", "invariants", "--json")
175 assert isinstance(json.loads(r.output)["exit_code"], int)
176
177 def test_j_alias_exit_code_present(self, inv_repo: pathlib.Path) -> None:
178 r = _run(inv_repo, "code", "invariants", "-j")
179 assert "exit_code" in json.loads(r.output)
180
181 def test_exit_code_mirrors_process_exit(self, inv_repo: pathlib.Path) -> None:
182 r = _run(inv_repo, "code", "invariants", "--json")
183 assert json.loads(r.output)["exit_code"] == r.exit_code
184
185
186 # ---------------------------------------------------------------------------
187 # TestTypedDicts — _InvariantsOutputJson carries exit_code and duration_ms
188 # ---------------------------------------------------------------------------
189
190
191 class TestTypedDicts:
192 """_InvariantsOutputJson must carry exit_code and duration_ms annotations."""
193
194 def test_invariants_output_json_typeddict_exists(self) -> None:
195 from muse.cli.commands.invariants import _InvariantsOutputJson # noqa: F401
196
197 def test_has_exit_code_annotation(self) -> None:
198 from muse.cli.commands.invariants import _InvariantsOutputJson
199 assert "exit_code" in _InvariantsOutputJson.__annotations__
200
201 def test_has_duration_ms_annotation(self) -> None:
202 from muse.cli.commands.invariants import _InvariantsOutputJson
203 assert "duration_ms" in _InvariantsOutputJson.__annotations__
204
205 def test_has_violations_annotation(self) -> None:
206 from muse.cli.commands.invariants import _InvariantsOutputJson
207 assert "violations" in _InvariantsOutputJson.__annotations__
208
209 def test_has_errors_annotation(self) -> None:
210 from muse.cli.commands.invariants import _InvariantsOutputJson
211 assert "errors" in _InvariantsOutputJson.__annotations__
212
213 def test_has_warnings_annotation(self) -> None:
214 from muse.cli.commands.invariants import _InvariantsOutputJson
215 assert "warnings" in _InvariantsOutputJson.__annotations__
216
217
218 # ---------------------------------------------------------------------------
219 # TestDocstrings — run() docstring documents exit_code and duration_ms
220 # ---------------------------------------------------------------------------
221
222
223 # ---------------------------------------------------------------------------
224 # TestAnsiSanitization — no escape codes in JSON output
225 # ---------------------------------------------------------------------------
226
227
228 class TestAnsiSanitization:
229 """No ANSI escape sequences anywhere in the JSON output."""
230
231 def test_json_output_no_ansi(self, inv_repo: pathlib.Path) -> None:
232 r = _run(inv_repo, "code", "invariants", "--json")
233 assert "\x1b" not in r.output
234
235 def test_j_alias_output_no_ansi(self, inv_repo: pathlib.Path) -> None:
236 r = _run(inv_repo, "code", "invariants", "-j")
237 assert "\x1b" not in r.output
238
239
240 # ---------------------------------------------------------------------------
241 # TestPerformance — duration_ms under 2000 ms for small repo
242 # ---------------------------------------------------------------------------
243
244
245 class TestPerformance:
246 """duration_ms must stay under 2000 ms for small repos."""
247
248 def test_json_duration_under_2000ms(self, inv_repo: pathlib.Path) -> None:
249 r = _run(inv_repo, "code", "invariants", "--json")
250 assert json.loads(r.output)["duration_ms"] < 2000
251
252 def test_duration_ms_is_float_not_int(self, inv_repo: pathlib.Path) -> None:
253 r = _run(inv_repo, "code", "invariants", "--json")
254 assert isinstance(json.loads(r.output)["duration_ms"], float)
255
256
257 # ---------------------------------------------------------------------------
258 # TestEarlyExitPaths — no-match and no-rules paths must include envelope fields
259 # ---------------------------------------------------------------------------
260
261
262 class TestEarlyExitPaths:
263 """Every JSON-emitting code path must include exit_code and duration_ms."""
264
265 def test_no_match_rule_filter_has_exit_code(self, inv_repo: pathlib.Path) -> None:
266 r = _run(inv_repo, "code", "invariants", "--rule", "zzz_no_such_rule", "-j")
267 assert r.exit_code == 0, r.output
268 data = json.loads(r.output)
269 assert "exit_code" in data
270 assert data["exit_code"] == 0
271
272 def test_no_match_rule_filter_has_duration_ms(self, inv_repo: pathlib.Path) -> None:
273 r = _run(inv_repo, "code", "invariants", "--rule", "zzz_no_such_rule", "-j")
274 data = json.loads(r.output)
275 assert "duration_ms" in data
276 assert isinstance(data["duration_ms"], float)
277 assert data["duration_ms"] >= 0
278
279 def test_no_match_rule_filter_has_error_field(self, inv_repo: pathlib.Path) -> None:
280 r = _run(inv_repo, "code", "invariants", "--rule", "zzz_no_such_rule", "-j")
281 data = json.loads(r.output)
282 assert data.get("error") == "no_matching_rules"
283
284 def test_no_match_rule_filter_no_ansi(self, inv_repo: pathlib.Path) -> None:
285 r = _run(inv_repo, "code", "invariants", "--rule", "zzz_no_such_rule", "-j")
286 assert "\x1b" not in r.output
287
288
289 # ---------------------------------------------------------------------------
290 # TestRelativeRefSyntax — HEAD~N must not crash
291 # ---------------------------------------------------------------------------
292
293
294 class TestRelativeRefSyntax:
295 """--commit HEAD~N and similar relative refs must not raise ValueError."""
296
297 @pytest.fixture()
298 def two_commit_repo(
299 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
300 ) -> pathlib.Path:
301 """Repo with two commits so HEAD~1 resolves to a real commit."""
302 monkeypatch.chdir(tmp_path)
303 r = _run(tmp_path, "init", "--domain", "code")
304 assert r.exit_code == 0, r.output
305
306 (tmp_path / "core.py").write_text("def compute(x):\n return x * 2\n")
307 r = _run(tmp_path, "code", "add", ".")
308 assert r.exit_code == 0, r.output
309 r = _run(tmp_path, "commit", "-m", "first")
310 assert r.exit_code == 0, r.output
311
312 (tmp_path / "service.py").write_text("def process(x):\n return x\n")
313 r = _run(tmp_path, "code", "add", ".")
314 assert r.exit_code == 0, r.output
315 r = _run(tmp_path, "commit", "-m", "second")
316 assert r.exit_code == 0, r.output
317
318 return tmp_path
319
320 def test_head_tilde_1_does_not_crash(self, two_commit_repo: pathlib.Path) -> None:
321 r = _run(two_commit_repo, "code", "invariants", "--commit", "HEAD~1", "-j")
322 # Must not crash with ValueError — exit 0 or 1 (depending on violations)
323 assert r.exit_code in (0, 1), r.output
324
325 def test_head_tilde_1_emits_valid_json(self, two_commit_repo: pathlib.Path) -> None:
326 r = _run(two_commit_repo, "code", "invariants", "--commit", "HEAD~1", "-j")
327 assert r.exit_code in (0, 1), r.output
328 json.loads(r.output) # must not raise
329
330 def test_head_tilde_1_has_exit_code(self, two_commit_repo: pathlib.Path) -> None:
331 r = _run(two_commit_repo, "code", "invariants", "--commit", "HEAD~1", "-j")
332 assert "exit_code" in json.loads(r.output)
333
334 def test_head_tilde_1_has_duration_ms(self, two_commit_repo: pathlib.Path) -> None:
335 r = _run(two_commit_repo, "code", "invariants", "--commit", "HEAD~1", "-j")
336 data = json.loads(r.output)
337 assert "duration_ms" in data
338 assert isinstance(data["duration_ms"], float)
339
340
341 # ---------------------------------------------------------------------------
342 # TestRegisterFlags — argparse-level verification
343 # ---------------------------------------------------------------------------
344
345
346 class TestRegisterFlags:
347 """Verify that register() wires --json / -j correctly."""
348
349 def _make_parser(self):
350 import argparse
351 from muse.cli.commands.invariants import register
352 ap = argparse.ArgumentParser()
353 subs = ap.add_subparsers()
354 register(subs)
355 return ap
356
357 def test_json_flag_long(self):
358 ns = self._make_parser().parse_args(["invariants", "--json"])
359 assert ns.json_out is True
360
361 def test_j_alias(self):
362 ns = self._make_parser().parse_args(["invariants", "-j"])
363 assert ns.json_out is True
364
365 def test_default_is_text(self):
366 ns = self._make_parser().parse_args(["invariants"])
367 assert ns.json_out is False
368
369 def test_dest_is_json_out(self):
370 ns = self._make_parser().parse_args(["invariants", "-j"])
371 assert hasattr(ns, "json_out")
372 assert not hasattr(ns, "fmt")
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago