gabriel / muse public
test_coupling_supercharge.py python
369 lines 14.6 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """Supercharge tests for ``muse code coupling`` — agent-usability gaps.
2
3 The existing TestCoupling suite in test_code_commands.py covers correctness,
4 JSON schema, all filters (--min, --top, --file, --from, --to, --max-commits),
5 pair schema in both partner and pair modes, truncation, and error paths.
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
12 - exit_code: JSON output includes exit_code = 0 on success (both paths)
13 - duration_ms: JSON output includes non-negative float duration_ms (both paths)
14 - TypedDicts: _CouplingOutputJson gains exit_code/duration_ms annotations
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 - Both paths: early-return (file-not-found) path also carries exit_code/duration_ms
19 """
20
21 from __future__ import annotations
22 from collections.abc import Mapping
23
24 import argparse
25
26 import json
27 import pathlib
28 import textwrap
29
30 import pytest
31
32 from tests.cli_test_helper import CliRunner, InvokeResult
33
34 runner = CliRunner()
35
36
37 # ---------------------------------------------------------------------------
38 # Helpers
39 # ---------------------------------------------------------------------------
40
41
42 def _env(root: pathlib.Path) -> Mapping[str, str]:
43 return {"MUSE_REPO_ROOT": str(root)}
44
45
46 def _run(root: pathlib.Path, *args: str) -> InvokeResult:
47 return runner.invoke(None, list(args), env=_env(root))
48
49
50 # ---------------------------------------------------------------------------
51 # Fixture — repo where billing.py + models.py co-change twice
52 # ---------------------------------------------------------------------------
53
54
55 @pytest.fixture()
56 def coupling_repo(
57 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
58 ) -> pathlib.Path:
59 """Repo with 3 commits where billing.py + models.py co-change twice.
60
61 Commit 1 — billing.py only (seed).
62 Commit 2 — billing.py + models.py change together (co-change #1).
63 Commit 3 — billing.py + models.py change together again (co-change #2).
64 """
65 monkeypatch.chdir(tmp_path)
66 r = _run(tmp_path, "init", "--domain", "code")
67 assert r.exit_code == 0, r.output
68
69 # commit 1 — seed
70 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
71 def compute(items):
72 return sum(items)
73 """))
74 r = _run(tmp_path, "code", "add", ".")
75 assert r.exit_code == 0, r.output
76 r = _run(tmp_path, "commit", "-m", "seed billing")
77 assert r.exit_code == 0, r.output
78
79 # commit 2 — co-change #1
80 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
81 def compute(items, tax=0.0):
82 return sum(items) + tax
83 """))
84 (tmp_path / "models.py").write_text(textwrap.dedent("""\
85 class Order:
86 def total(self):
87 return 0
88 """))
89 r = _run(tmp_path, "code", "add", ".")
90 assert r.exit_code == 0, r.output
91 r = _run(tmp_path, "commit", "-m", "co-change 1: billing + models")
92 assert r.exit_code == 0, r.output
93
94 # commit 3 — co-change #2
95 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
96 def compute(items, tax=0.0, discount=0.0):
97 return sum(items) + tax - discount
98 """))
99 (tmp_path / "models.py").write_text(textwrap.dedent("""\
100 class Order:
101 def total(self):
102 return 42
103 def apply(self):
104 pass
105 """))
106 r = _run(tmp_path, "code", "add", ".")
107 assert r.exit_code == 0, r.output
108 r = _run(tmp_path, "commit", "-m", "co-change 2: billing + models again")
109 assert r.exit_code == 0, r.output
110
111 return tmp_path
112
113
114 # ---------------------------------------------------------------------------
115 # TestJsonAlias — -j works identically to --json
116 # ---------------------------------------------------------------------------
117
118
119 class TestJsonAlias:
120 """-j shorthand must behave identically to --json."""
121
122 def test_j_alias_exits_zero(self, coupling_repo: pathlib.Path) -> None:
123 r = _run(coupling_repo, "code", "coupling", "-j")
124 assert r.exit_code == 0, r.output
125
126 def test_j_alias_valid_json(self, coupling_repo: pathlib.Path) -> None:
127 r = _run(coupling_repo, "code", "coupling", "-j")
128 json.loads(r.output) # must not raise
129
130 def test_j_alias_has_pairs_key(self, coupling_repo: pathlib.Path) -> None:
131 r = _run(coupling_repo, "code", "coupling", "-j")
132 assert "pairs" in json.loads(r.output)
133
134 def test_j_alias_has_commits_analysed_key(self, coupling_repo: pathlib.Path) -> None:
135 r = _run(coupling_repo, "code", "coupling", "-j")
136 assert "commits_analysed" in json.loads(r.output)
137
138 def test_j_alias_same_top_level_keys_as_json_flag(
139 self, coupling_repo: pathlib.Path
140 ) -> None:
141 r1 = _run(coupling_repo, "code", "coupling", "--json")
142 r2 = _run(coupling_repo, "code", "coupling", "-j")
143 d1 = json.loads(r1.output)
144 d2 = json.loads(r2.output)
145 d1.pop("duration_ms", None)
146 d2.pop("duration_ms", None)
147 assert set(d1.keys()) == set(d2.keys())
148
149 def test_j_alias_pair_count_matches_json_flag(
150 self, coupling_repo: pathlib.Path
151 ) -> None:
152 r1 = _run(coupling_repo, "code", "coupling", "--json", "--min", "1")
153 r2 = _run(coupling_repo, "code", "coupling", "-j", "--min", "1")
154 assert len(json.loads(r1.output)["pairs"]) == len(json.loads(r2.output)["pairs"])
155
156 def test_j_alias_with_file_filter(self, coupling_repo: pathlib.Path) -> None:
157 r = _run(coupling_repo, "code", "coupling", "-j", "--file", "billing.py", "--min", "1")
158 assert r.exit_code == 0, r.output
159 data = json.loads(r.output)
160 assert data["filters"]["file"] == "billing.py"
161
162 def test_j_alias_with_min_filter(self, coupling_repo: pathlib.Path) -> None:
163 r = _run(coupling_repo, "code", "coupling", "-j", "--min", "2")
164 assert r.exit_code == 0, r.output
165 data = json.loads(r.output)
166 assert data["filters"]["min_count"] == 2
167
168
169 # ---------------------------------------------------------------------------
170 # TestDurationMs — JSON output must include duration_ms
171 # ---------------------------------------------------------------------------
172
173
174 class TestDurationMs:
175 """JSON output must include a non-negative float duration_ms."""
176
177 def test_json_has_duration_ms(self, coupling_repo: pathlib.Path) -> None:
178 r = _run(coupling_repo, "code", "coupling", "--json")
179 assert "duration_ms" in json.loads(r.output)
180
181 def test_json_duration_ms_nonnegative(self, coupling_repo: pathlib.Path) -> None:
182 r = _run(coupling_repo, "code", "coupling", "--json")
183 assert json.loads(r.output)["duration_ms"] >= 0
184
185 def test_json_duration_ms_is_float(self, coupling_repo: pathlib.Path) -> None:
186 r = _run(coupling_repo, "code", "coupling", "--json")
187 assert isinstance(json.loads(r.output)["duration_ms"], float)
188
189 def test_j_alias_duration_ms_present(self, coupling_repo: pathlib.Path) -> None:
190 r = _run(coupling_repo, "code", "coupling", "-j")
191 assert "duration_ms" in json.loads(r.output)
192
193 def test_duration_ms_on_file_not_found_path(
194 self, coupling_repo: pathlib.Path
195 ) -> None:
196 """Early-return (file-not-found) path must also carry duration_ms."""
197 r = _run(coupling_repo, "code", "coupling", "--json", "--file", "nonexistent_xyz.py")
198 assert r.exit_code == 0, r.output
199 data = json.loads(r.output)
200 assert "duration_ms" in data
201 assert data["duration_ms"] >= 0
202
203 def test_duration_ms_with_file_filter(self, coupling_repo: pathlib.Path) -> None:
204 r = _run(coupling_repo, "code", "coupling", "--json", "--file", "billing.py", "--min", "1")
205 data = json.loads(r.output)
206 assert "duration_ms" in data
207 assert isinstance(data["duration_ms"], float)
208
209
210 # ---------------------------------------------------------------------------
211 # TestExitCode — JSON includes exit_code = 0 on success
212 # ---------------------------------------------------------------------------
213
214
215 class TestExitCode:
216 """JSON exit_code must be 0 on success."""
217
218 def test_json_has_exit_code(self, coupling_repo: pathlib.Path) -> None:
219 r = _run(coupling_repo, "code", "coupling", "--json")
220 assert "exit_code" in json.loads(r.output)
221
222 def test_json_exit_code_zero(self, coupling_repo: pathlib.Path) -> None:
223 r = _run(coupling_repo, "code", "coupling", "--json")
224 assert r.exit_code == 0
225 assert json.loads(r.output)["exit_code"] == 0
226
227 def test_json_exit_code_is_int(self, coupling_repo: pathlib.Path) -> None:
228 r = _run(coupling_repo, "code", "coupling", "--json")
229 assert isinstance(json.loads(r.output)["exit_code"], int)
230
231 def test_j_alias_exit_code_present(self, coupling_repo: pathlib.Path) -> None:
232 r = _run(coupling_repo, "code", "coupling", "-j")
233 assert "exit_code" in json.loads(r.output)
234
235 def test_exit_code_mirrors_process_exit(self, coupling_repo: pathlib.Path) -> None:
236 r = _run(coupling_repo, "code", "coupling", "--json")
237 assert json.loads(r.output)["exit_code"] == r.exit_code
238
239 def test_exit_code_zero_on_file_not_found_path(
240 self, coupling_repo: pathlib.Path
241 ) -> None:
242 """Early-return (file-not-found) path must also carry exit_code = 0."""
243 r = _run(coupling_repo, "code", "coupling", "--json", "--file", "nonexistent_xyz.py")
244 assert r.exit_code == 0, r.output
245 data = json.loads(r.output)
246 assert "exit_code" in data
247 assert data["exit_code"] == 0
248
249 def test_exit_code_zero_with_filters(self, coupling_repo: pathlib.Path) -> None:
250 r = _run(coupling_repo, "code", "coupling", "--json", "--min", "2", "--top", "5")
251 assert r.exit_code == 0
252 assert json.loads(r.output)["exit_code"] == 0
253
254 def test_exit_code_zero_with_file_filter(self, coupling_repo: pathlib.Path) -> None:
255 r = _run(coupling_repo, "code", "coupling", "--json", "--file", "billing.py", "--min", "1")
256 assert r.exit_code == 0
257 assert json.loads(r.output)["exit_code"] == 0
258
259
260 # ---------------------------------------------------------------------------
261 # TestTypedDicts — _CouplingOutputJson carries the new fields
262 # ---------------------------------------------------------------------------
263
264
265 class TestTypedDicts:
266 """_CouplingOutputJson must carry exit_code and duration_ms annotations."""
267
268 def test_coupling_output_json_typeddict_exists(self) -> None:
269 from muse.cli.commands.coupling import _CouplingOutputJson # noqa: F401
270
271 def test_has_exit_code_annotation(self) -> None:
272 from muse.cli.commands.coupling import _CouplingOutputJson
273 assert "exit_code" in _CouplingOutputJson.__annotations__
274
275 def test_has_duration_ms_annotation(self) -> None:
276 from muse.cli.commands.coupling import _CouplingOutputJson
277 assert "duration_ms" in _CouplingOutputJson.__annotations__
278
279 def test_retains_pairs_annotation(self) -> None:
280 from muse.cli.commands.coupling import _CouplingOutputJson
281 assert "pairs" in _CouplingOutputJson.__annotations__
282
283 def test_retains_commits_analysed_annotation(self) -> None:
284 from muse.cli.commands.coupling import _CouplingOutputJson
285 assert "commits_analysed" in _CouplingOutputJson.__annotations__
286
287 def test_retains_truncated_annotation(self) -> None:
288 from muse.cli.commands.coupling import _CouplingOutputJson
289 assert "truncated" in _CouplingOutputJson.__annotations__
290
291 def test_retains_filters_annotation(self) -> None:
292 from muse.cli.commands.coupling import _CouplingOutputJson
293 assert "filters" in _CouplingOutputJson.__annotations__
294
295
296 # ---------------------------------------------------------------------------
297 # TestAnsiSanitization — no escape codes in JSON output
298 # ---------------------------------------------------------------------------
299
300
301 class TestAnsiSanitization:
302 """No ANSI escape sequences anywhere in the JSON output."""
303
304 def test_json_output_no_ansi(self, coupling_repo: pathlib.Path) -> None:
305 r = _run(coupling_repo, "code", "coupling", "--json")
306 assert "\x1b" not in r.output
307
308 def test_j_alias_output_no_ansi(self, coupling_repo: pathlib.Path) -> None:
309 r = _run(coupling_repo, "code", "coupling", "-j")
310 assert "\x1b" not in r.output
311
312 def test_json_output_no_ansi_with_file_filter(
313 self, coupling_repo: pathlib.Path
314 ) -> None:
315 r = _run(coupling_repo, "code", "coupling", "--json", "--file", "billing.py", "--min", "1")
316 assert "\x1b" not in r.output
317
318
319 # ---------------------------------------------------------------------------
320 # TestPerformance — duration_ms under 2000 ms for a small repo
321 # ---------------------------------------------------------------------------
322
323
324 class TestPerformance:
325 """duration_ms must stay under 2000 ms for small repos."""
326
327 def test_json_duration_under_2000ms(self, coupling_repo: pathlib.Path) -> None:
328 r = _run(coupling_repo, "code", "coupling", "--json")
329 assert json.loads(r.output)["duration_ms"] < 2000
330
331 def test_j_alias_duration_under_2000ms(self, coupling_repo: pathlib.Path) -> None:
332 r = _run(coupling_repo, "code", "coupling", "-j")
333 assert json.loads(r.output)["duration_ms"] < 2000
334
335 def test_duration_ms_is_float_not_int(self, coupling_repo: pathlib.Path) -> None:
336 r = _run(coupling_repo, "code", "coupling", "--json")
337 assert isinstance(json.loads(r.output)["duration_ms"], float)
338
339
340 # ---------------------------------------------------------------------------
341 # TestRegisterFlags — --json / -j normalized at argparse level
342 # ---------------------------------------------------------------------------
343
344
345 class TestRegisterFlags:
346 """register() must expose --json with -j shorthand and dest=json_out."""
347
348 def _make_parser(self) -> argparse.ArgumentParser:
349 import argparse as ap
350 from muse.cli.commands.coupling import register
351 root = ap.ArgumentParser()
352 subs = root.add_subparsers()
353 register(subs)
354 return root
355
356 def test_json_out_default_false(self) -> None:
357 p = self._make_parser()
358 ns = p.parse_args(['coupling'])
359 assert ns.json_out is False
360
361 def test_json_out_true_with_json_flag(self) -> None:
362 p = self._make_parser()
363 ns = p.parse_args(['coupling', '--json'])
364 assert ns.json_out is True
365
366 def test_json_out_true_with_j_flag(self) -> None:
367 p = self._make_parser()
368 ns = p.parse_args(['coupling', '-j'])
369 assert ns.json_out is True
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago