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