gabriel / muse public
test_compare_supercharge.py python
433 lines 15.0 KB
Raw
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 21 days ago
1 """Supercharge tests for ``muse code compare`` — agent-usability gaps.
2
3 The existing TestCompare suite in test_code_commands.py covers correctness,
4 JSON schema, all filters (--kind, --file, --language), --stat, --semver, and
5 invalid-ref error paths. This file targets only the gaps those tests leave open:
6
7 Coverage matrix
8 ---------------
9 - --json / -j: -j alias works identically to --json
10 - exit_code: JSON output includes exit_code = 0 on success
11 - duration_ms: JSON output includes non-negative float duration_ms
12 - TypedDicts: _CompareJson gains exit_code/duration_ms annotations
13 - Docstrings: run() docstring mentions exit_code and duration_ms
14 - ANSI: JSON output never contains terminal escape sequences
15 - Performance: duration_ms stays under 2000 ms for a small repo
16 """
17
18 from __future__ import annotations
19 from collections.abc import Mapping
20
21 import json
22 import pathlib
23 import textwrap
24
25 import pytest
26
27 from tests.cli_test_helper import CliRunner, InvokeResult
28
29 runner = CliRunner()
30
31
32 # ---------------------------------------------------------------------------
33 # Helpers
34 # ---------------------------------------------------------------------------
35
36
37 def _env(root: pathlib.Path) -> Mapping[str, str]:
38 return {"MUSE_REPO_ROOT": str(root)}
39
40
41 def _run(root: pathlib.Path, *args: str) -> InvokeResult:
42 return runner.invoke(None, list(args), env=_env(root))
43
44
45 # ---------------------------------------------------------------------------
46 # Fixture — two-commit repo with a semantic change between them
47 # ---------------------------------------------------------------------------
48
49
50 @pytest.fixture()
51 def compare_repo(
52 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
53 ) -> tuple[pathlib.Path, str, str]:
54 """Repo with two commits.
55
56 Commit A — alpha.py defines alpha_fn().
57 Commit B — alpha.py also defines beta_fn() (added symbol).
58
59 Returns (path, commit_id_a, commit_id_b).
60 """
61 monkeypatch.chdir(tmp_path)
62 r = _run(tmp_path, "init", "--domain", "code")
63 assert r.exit_code == 0, r.output
64
65 (tmp_path / "alpha.py").write_text(textwrap.dedent("""\
66 def alpha_fn():
67 return 1
68 """))
69 r = _run(tmp_path, "code", "add", ".")
70 assert r.exit_code == 0, r.output
71 r = _run(tmp_path, "commit", "-m", "add alpha_fn")
72 assert r.exit_code == 0, r.output
73
74 from muse.core.refs import (
75 get_head_commit_id,
76 read_current_branch,
77 )
78 branch = read_current_branch(tmp_path)
79 commit_a = get_head_commit_id(tmp_path, branch)
80
81 (tmp_path / "alpha.py").write_text(textwrap.dedent("""\
82 def alpha_fn():
83 return 1
84
85 def beta_fn():
86 return 2
87 """))
88 r = _run(tmp_path, "code", "add", ".")
89 assert r.exit_code == 0, r.output
90 r = _run(tmp_path, "commit", "-m", "add beta_fn")
91 assert r.exit_code == 0, r.output
92
93 commit_b = get_head_commit_id(tmp_path, branch)
94 assert commit_a is not None
95 assert commit_b is not None
96 return tmp_path, commit_a, commit_b
97
98
99 # ---------------------------------------------------------------------------
100 # TestJsonAlias — -j works identically to --json
101 # ---------------------------------------------------------------------------
102
103
104 class TestJsonAlias:
105 """-j shorthand must behave identically to --json."""
106
107 def test_j_alias_exits_zero(
108 self, compare_repo: tuple[pathlib.Path, str, str]
109 ) -> None:
110 root, a, b = compare_repo
111 r = _run(root, "code", "compare", a, b, "-j")
112 assert r.exit_code == 0, r.output
113
114 def test_j_alias_valid_json(
115 self, compare_repo: tuple[pathlib.Path, str, str]
116 ) -> None:
117 root, a, b = compare_repo
118 r = _run(root, "code", "compare", a, b, "-j")
119 json.loads(r.output) # must not raise
120
121 def test_j_alias_has_from_key(
122 self, compare_repo: tuple[pathlib.Path, str, str]
123 ) -> None:
124 root, a, b = compare_repo
125 r = _run(root, "code", "compare", a, b, "-j")
126 data = json.loads(r.output)
127 assert "from" in data
128
129 def test_j_alias_has_ops_key(
130 self, compare_repo: tuple[pathlib.Path, str, str]
131 ) -> None:
132 root, a, b = compare_repo
133 r = _run(root, "code", "compare", a, b, "-j")
134 data = json.loads(r.output)
135 assert "ops" in data
136
137 def test_j_alias_same_top_level_keys_as_json_flag(
138 self, compare_repo: tuple[pathlib.Path, str, str]
139 ) -> None:
140 root, a, b = compare_repo
141 r1 = _run(root, "code", "compare", a, b, "--json")
142 r2 = _run(root, "code", "compare", a, b, "-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_op_count_matches_json_flag(
150 self, compare_repo: tuple[pathlib.Path, str, str]
151 ) -> None:
152 root, a, b = compare_repo
153 r1 = _run(root, "code", "compare", a, b, "--json")
154 r2 = _run(root, "code", "compare", a, b, "-j")
155 assert len(json.loads(r1.output)["ops"]) == len(json.loads(r2.output)["ops"])
156
157 def test_j_alias_same_ref_empty_ops(
158 self, compare_repo: tuple[pathlib.Path, str, str]
159 ) -> None:
160 root, a, _ = compare_repo
161 r = _run(root, "code", "compare", a, a, "-j")
162 assert r.exit_code == 0, r.output
163 assert json.loads(r.output)["ops"] == []
164
165 def test_j_alias_with_language_filter(
166 self, compare_repo: tuple[pathlib.Path, str, str]
167 ) -> None:
168 root, a, b = compare_repo
169 r = _run(root, "code", "compare", a, b, "-j", "--language", "Python")
170 assert r.exit_code == 0, r.output
171 data = json.loads(r.output)
172 assert data["filters"]["language"] == "Python"
173
174
175 # ---------------------------------------------------------------------------
176 # TestDurationMs — JSON output must include duration_ms
177 # ---------------------------------------------------------------------------
178
179
180 class TestDurationMs:
181 """JSON output must include a non-negative float duration_ms."""
182
183 def test_json_has_duration_ms(
184 self, compare_repo: tuple[pathlib.Path, str, str]
185 ) -> None:
186 root, a, b = compare_repo
187 r = _run(root, "code", "compare", a, b, "--json")
188 data = json.loads(r.output)
189 assert "duration_ms" in data
190
191 def test_json_duration_ms_nonnegative(
192 self, compare_repo: tuple[pathlib.Path, str, str]
193 ) -> None:
194 root, a, b = compare_repo
195 r = _run(root, "code", "compare", a, b, "--json")
196 assert json.loads(r.output)["duration_ms"] >= 0
197
198 def test_json_duration_ms_is_float(
199 self, compare_repo: tuple[pathlib.Path, str, str]
200 ) -> None:
201 root, a, b = compare_repo
202 r = _run(root, "code", "compare", a, b, "--json")
203 assert isinstance(json.loads(r.output)["duration_ms"], float)
204
205 def test_j_alias_duration_ms_present(
206 self, compare_repo: tuple[pathlib.Path, str, str]
207 ) -> None:
208 root, a, b = compare_repo
209 r = _run(root, "code", "compare", a, b, "-j")
210 assert "duration_ms" in json.loads(r.output)
211
212 def test_duration_ms_same_ref(
213 self, compare_repo: tuple[pathlib.Path, str, str]
214 ) -> None:
215 """duration_ms is present even when there are no changes."""
216 root, a, _ = compare_repo
217 r = _run(root, "code", "compare", a, a, "--json")
218 data = json.loads(r.output)
219 assert "duration_ms" in data
220 assert data["duration_ms"] >= 0
221
222 def test_duration_ms_with_kind_filter(
223 self, compare_repo: tuple[pathlib.Path, str, str]
224 ) -> None:
225 root, a, b = compare_repo
226 r = _run(root, "code", "compare", a, b, "--json", "--kind", "function")
227 data = json.loads(r.output)
228 assert "duration_ms" in data
229 assert data["duration_ms"] >= 0
230
231
232 # ---------------------------------------------------------------------------
233 # TestExitCode — JSON includes exit_code = 0 on success
234 # ---------------------------------------------------------------------------
235
236
237 class TestExitCode:
238 """JSON exit_code must be 0 on success."""
239
240 def test_json_has_exit_code(
241 self, compare_repo: tuple[pathlib.Path, str, str]
242 ) -> None:
243 root, a, b = compare_repo
244 r = _run(root, "code", "compare", a, b, "--json")
245 assert "exit_code" in json.loads(r.output)
246
247 def test_json_exit_code_zero_with_changes(
248 self, compare_repo: tuple[pathlib.Path, str, str]
249 ) -> None:
250 root, a, b = compare_repo
251 r = _run(root, "code", "compare", a, b, "--json")
252 assert r.exit_code == 0
253 assert json.loads(r.output)["exit_code"] == 0
254
255 def test_json_exit_code_zero_no_changes(
256 self, compare_repo: tuple[pathlib.Path, str, str]
257 ) -> None:
258 root, a, _ = compare_repo
259 r = _run(root, "code", "compare", a, a, "--json")
260 assert r.exit_code == 0
261 assert json.loads(r.output)["exit_code"] == 0
262
263 def test_json_exit_code_is_int(
264 self, compare_repo: tuple[pathlib.Path, str, str]
265 ) -> None:
266 root, a, b = compare_repo
267 r = _run(root, "code", "compare", a, b, "--json")
268 assert isinstance(json.loads(r.output)["exit_code"], int)
269
270 def test_j_alias_exit_code_present(
271 self, compare_repo: tuple[pathlib.Path, str, str]
272 ) -> None:
273 root, a, b = compare_repo
274 r = _run(root, "code", "compare", a, b, "-j")
275 assert "exit_code" in json.loads(r.output)
276
277 def test_exit_code_mirrors_process_exit(
278 self, compare_repo: tuple[pathlib.Path, str, str]
279 ) -> None:
280 root, a, b = compare_repo
281 r = _run(root, "code", "compare", a, b, "--json")
282 data = json.loads(r.output)
283 assert data["exit_code"] == r.exit_code
284
285 def test_exit_code_zero_with_filters(
286 self, compare_repo: tuple[pathlib.Path, str, str]
287 ) -> None:
288 root, a, b = compare_repo
289 r = _run(root, "code", "compare", a, b, "--json", "--kind", "function")
290 assert r.exit_code == 0
291 assert json.loads(r.output)["exit_code"] == 0
292
293
294 # ---------------------------------------------------------------------------
295 # TestTypedDicts — _CompareJson carries the new fields
296 # ---------------------------------------------------------------------------
297
298
299 class TestTypedDicts:
300 """_CompareJson must carry exit_code and duration_ms annotations."""
301
302 def test_compare_json_typeddict_exists(self) -> None:
303 from muse.cli.commands.compare import _CompareJson # noqa: F401
304
305 def test_has_exit_code_annotation(self) -> None:
306 from muse.cli.commands.compare import _CompareJson
307 assert "exit_code" in _CompareJson.__annotations__
308
309 def test_has_duration_ms_annotation(self) -> None:
310 from muse.cli.commands.compare import _CompareJson
311 assert "duration_ms" in _CompareJson.__annotations__
312
313 def test_retains_from_annotation(self) -> None:
314 from muse.cli.commands.compare import _CompareJson
315 assert "from" in _CompareJson.__annotations__
316
317 def test_retains_to_annotation(self) -> None:
318 from muse.cli.commands.compare import _CompareJson
319 assert "to" in _CompareJson.__annotations__
320
321 def test_retains_stat_annotation(self) -> None:
322 from muse.cli.commands.compare import _CompareJson
323 assert "stat" in _CompareJson.__annotations__
324
325 def test_retains_ops_annotation(self) -> None:
326 from muse.cli.commands.compare import _CompareJson
327 assert "ops" in _CompareJson.__annotations__
328
329 def test_retains_filters_annotation(self) -> None:
330 from muse.cli.commands.compare import _CompareJson
331 assert "filters" in _CompareJson.__annotations__
332
333
334 # ---------------------------------------------------------------------------
335 # TestDocstrings — run() docstring documents new fields
336 # ---------------------------------------------------------------------------
337
338
339 class TestDocstrings:
340 """run() must document exit_code."""
341
342 def test_run_docstring_documents_fields(self) -> None:
343 from muse.cli.commands.compare import run
344 assert "exit_code" in run.__doc__
345
346
347 # ---------------------------------------------------------------------------
348 # TestAnsiSanitization — no escape codes in JSON output
349 # ---------------------------------------------------------------------------
350
351
352 class TestAnsiSanitization:
353 """No ANSI escape sequences anywhere in the JSON output."""
354
355 def test_json_output_no_ansi_with_changes(
356 self, compare_repo: tuple[pathlib.Path, str, str]
357 ) -> None:
358 root, a, b = compare_repo
359 r = _run(root, "code", "compare", a, b, "--json")
360 assert "\x1b" not in r.output
361
362 def test_j_alias_output_no_ansi(
363 self, compare_repo: tuple[pathlib.Path, str, str]
364 ) -> None:
365 root, a, b = compare_repo
366 r = _run(root, "code", "compare", a, b, "-j")
367 assert "\x1b" not in r.output
368
369 def test_json_output_no_ansi_no_changes(
370 self, compare_repo: tuple[pathlib.Path, str, str]
371 ) -> None:
372 root, a, _ = compare_repo
373 r = _run(root, "code", "compare", a, a, "--json")
374 assert "\x1b" not in r.output
375
376
377 # ---------------------------------------------------------------------------
378 # TestPerformance — duration_ms under 2000 ms for a small repo
379 # ---------------------------------------------------------------------------
380
381
382 class TestPerformance:
383 """duration_ms must stay under 2000 ms for small repos."""
384
385 def test_json_duration_under_2000ms(
386 self, compare_repo: tuple[pathlib.Path, str, str]
387 ) -> None:
388 root, a, b = compare_repo
389 r = _run(root, "code", "compare", a, b, "--json")
390 assert json.loads(r.output)["duration_ms"] < 2000
391
392 def test_j_alias_duration_under_2000ms(
393 self, compare_repo: tuple[pathlib.Path, str, str]
394 ) -> None:
395 root, a, b = compare_repo
396 r = _run(root, "code", "compare", a, b, "-j")
397 assert json.loads(r.output)["duration_ms"] < 2000
398
399 def test_duration_ms_is_float_not_int(
400 self, compare_repo: tuple[pathlib.Path, str, str]
401 ) -> None:
402 root, a, b = compare_repo
403 r = _run(root, "code", "compare", a, b, "--json")
404 assert isinstance(json.loads(r.output)["duration_ms"], float)
405
406
407 # ---------------------------------------------------------------------------
408 # Flag registration tests
409 # ---------------------------------------------------------------------------
410
411 import argparse as _argparse
412 from muse.cli.commands.compare import register as _register_compare
413
414
415 def _parse_compare(*args: str) -> _argparse.Namespace:
416 root_p = _argparse.ArgumentParser()
417 subs = root_p.add_subparsers(dest="cmd")
418 _register_compare(subs)
419 return root_p.parse_args(["compare", *args])
420
421
422 class TestRegisterFlags:
423 def test_default_json_out_is_false(self) -> None:
424 ns = _parse_compare("HEAD~1", "HEAD")
425 assert ns.json_out is False
426
427 def test_json_flag_sets_json_out(self) -> None:
428 ns = _parse_compare("HEAD~1", "HEAD", "--json")
429 assert ns.json_out is True
430
431 def test_j_shorthand_sets_json_out(self) -> None:
432 ns = _parse_compare("HEAD~1", "HEAD", "-j")
433 assert ns.json_out is True
File History 1 commit
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 21 days ago