gabriel / muse public
test_compare_supercharge.py python
430 lines 15.0 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 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
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):
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.store import get_head_commit_id, read_current_branch
75 branch = read_current_branch(tmp_path)
76 commit_a = get_head_commit_id(tmp_path, branch)
77
78 (tmp_path / "alpha.py").write_text(textwrap.dedent("""\
79 def alpha_fn():
80 return 1
81
82 def beta_fn():
83 return 2
84 """))
85 r = _run(tmp_path, "code", "add", ".")
86 assert r.exit_code == 0, r.output
87 r = _run(tmp_path, "commit", "-m", "add beta_fn")
88 assert r.exit_code == 0, r.output
89
90 commit_b = get_head_commit_id(tmp_path, branch)
91 assert commit_a is not None
92 assert commit_b is not None
93 return tmp_path, commit_a, commit_b
94
95
96 # ---------------------------------------------------------------------------
97 # TestJsonAlias — -j works identically to --json
98 # ---------------------------------------------------------------------------
99
100
101 class TestJsonAlias:
102 """-j shorthand must behave identically to --json."""
103
104 def test_j_alias_exits_zero(
105 self, compare_repo: tuple[pathlib.Path, str, str]
106 ) -> None:
107 root, a, b = compare_repo
108 r = _run(root, "code", "compare", a, b, "-j")
109 assert r.exit_code == 0, r.output
110
111 def test_j_alias_valid_json(
112 self, compare_repo: tuple[pathlib.Path, str, str]
113 ) -> None:
114 root, a, b = compare_repo
115 r = _run(root, "code", "compare", a, b, "-j")
116 json.loads(r.output) # must not raise
117
118 def test_j_alias_has_from_key(
119 self, compare_repo: tuple[pathlib.Path, str, str]
120 ) -> None:
121 root, a, b = compare_repo
122 r = _run(root, "code", "compare", a, b, "-j")
123 data = json.loads(r.output)
124 assert "from" in data
125
126 def test_j_alias_has_ops_key(
127 self, compare_repo: tuple[pathlib.Path, str, str]
128 ) -> None:
129 root, a, b = compare_repo
130 r = _run(root, "code", "compare", a, b, "-j")
131 data = json.loads(r.output)
132 assert "ops" in data
133
134 def test_j_alias_same_top_level_keys_as_json_flag(
135 self, compare_repo: tuple[pathlib.Path, str, str]
136 ) -> None:
137 root, a, b = compare_repo
138 r1 = _run(root, "code", "compare", a, b, "--json")
139 r2 = _run(root, "code", "compare", a, b, "-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_op_count_matches_json_flag(
147 self, compare_repo: tuple[pathlib.Path, str, str]
148 ) -> None:
149 root, a, b = compare_repo
150 r1 = _run(root, "code", "compare", a, b, "--json")
151 r2 = _run(root, "code", "compare", a, b, "-j")
152 assert len(json.loads(r1.output)["ops"]) == len(json.loads(r2.output)["ops"])
153
154 def test_j_alias_same_ref_empty_ops(
155 self, compare_repo: tuple[pathlib.Path, str, str]
156 ) -> None:
157 root, a, _ = compare_repo
158 r = _run(root, "code", "compare", a, a, "-j")
159 assert r.exit_code == 0, r.output
160 assert json.loads(r.output)["ops"] == []
161
162 def test_j_alias_with_language_filter(
163 self, compare_repo: tuple[pathlib.Path, str, str]
164 ) -> None:
165 root, a, b = compare_repo
166 r = _run(root, "code", "compare", a, b, "-j", "--language", "Python")
167 assert r.exit_code == 0, r.output
168 data = json.loads(r.output)
169 assert data["filters"]["language"] == "Python"
170
171
172 # ---------------------------------------------------------------------------
173 # TestDurationMs — JSON output must include duration_ms
174 # ---------------------------------------------------------------------------
175
176
177 class TestDurationMs:
178 """JSON output must include a non-negative float duration_ms."""
179
180 def test_json_has_duration_ms(
181 self, compare_repo: tuple[pathlib.Path, str, str]
182 ) -> None:
183 root, a, b = compare_repo
184 r = _run(root, "code", "compare", a, b, "--json")
185 data = json.loads(r.output)
186 assert "duration_ms" in data
187
188 def test_json_duration_ms_nonnegative(
189 self, compare_repo: tuple[pathlib.Path, str, str]
190 ) -> None:
191 root, a, b = compare_repo
192 r = _run(root, "code", "compare", a, b, "--json")
193 assert json.loads(r.output)["duration_ms"] >= 0
194
195 def test_json_duration_ms_is_float(
196 self, compare_repo: tuple[pathlib.Path, str, str]
197 ) -> None:
198 root, a, b = compare_repo
199 r = _run(root, "code", "compare", a, b, "--json")
200 assert isinstance(json.loads(r.output)["duration_ms"], float)
201
202 def test_j_alias_duration_ms_present(
203 self, compare_repo: tuple[pathlib.Path, str, str]
204 ) -> None:
205 root, a, b = compare_repo
206 r = _run(root, "code", "compare", a, b, "-j")
207 assert "duration_ms" in json.loads(r.output)
208
209 def test_duration_ms_same_ref(
210 self, compare_repo: tuple[pathlib.Path, str, str]
211 ) -> None:
212 """duration_ms is present even when there are no changes."""
213 root, a, _ = compare_repo
214 r = _run(root, "code", "compare", a, a, "--json")
215 data = json.loads(r.output)
216 assert "duration_ms" in data
217 assert data["duration_ms"] >= 0
218
219 def test_duration_ms_with_kind_filter(
220 self, compare_repo: tuple[pathlib.Path, str, str]
221 ) -> None:
222 root, a, b = compare_repo
223 r = _run(root, "code", "compare", a, b, "--json", "--kind", "function")
224 data = json.loads(r.output)
225 assert "duration_ms" in data
226 assert data["duration_ms"] >= 0
227
228
229 # ---------------------------------------------------------------------------
230 # TestExitCode — JSON includes exit_code = 0 on success
231 # ---------------------------------------------------------------------------
232
233
234 class TestExitCode:
235 """JSON exit_code must be 0 on success."""
236
237 def test_json_has_exit_code(
238 self, compare_repo: tuple[pathlib.Path, str, str]
239 ) -> None:
240 root, a, b = compare_repo
241 r = _run(root, "code", "compare", a, b, "--json")
242 assert "exit_code" in json.loads(r.output)
243
244 def test_json_exit_code_zero_with_changes(
245 self, compare_repo: tuple[pathlib.Path, str, str]
246 ) -> None:
247 root, a, b = compare_repo
248 r = _run(root, "code", "compare", a, b, "--json")
249 assert r.exit_code == 0
250 assert json.loads(r.output)["exit_code"] == 0
251
252 def test_json_exit_code_zero_no_changes(
253 self, compare_repo: tuple[pathlib.Path, str, str]
254 ) -> None:
255 root, a, _ = compare_repo
256 r = _run(root, "code", "compare", a, a, "--json")
257 assert r.exit_code == 0
258 assert json.loads(r.output)["exit_code"] == 0
259
260 def test_json_exit_code_is_int(
261 self, compare_repo: tuple[pathlib.Path, str, str]
262 ) -> None:
263 root, a, b = compare_repo
264 r = _run(root, "code", "compare", a, b, "--json")
265 assert isinstance(json.loads(r.output)["exit_code"], int)
266
267 def test_j_alias_exit_code_present(
268 self, compare_repo: tuple[pathlib.Path, str, str]
269 ) -> None:
270 root, a, b = compare_repo
271 r = _run(root, "code", "compare", a, b, "-j")
272 assert "exit_code" in json.loads(r.output)
273
274 def test_exit_code_mirrors_process_exit(
275 self, compare_repo: tuple[pathlib.Path, str, str]
276 ) -> None:
277 root, a, b = compare_repo
278 r = _run(root, "code", "compare", a, b, "--json")
279 data = json.loads(r.output)
280 assert data["exit_code"] == r.exit_code
281
282 def test_exit_code_zero_with_filters(
283 self, compare_repo: tuple[pathlib.Path, str, str]
284 ) -> None:
285 root, a, b = compare_repo
286 r = _run(root, "code", "compare", a, b, "--json", "--kind", "function")
287 assert r.exit_code == 0
288 assert json.loads(r.output)["exit_code"] == 0
289
290
291 # ---------------------------------------------------------------------------
292 # TestTypedDicts — _CompareJson carries the new fields
293 # ---------------------------------------------------------------------------
294
295
296 class TestTypedDicts:
297 """_CompareJson must carry exit_code and duration_ms annotations."""
298
299 def test_compare_json_typeddict_exists(self) -> None:
300 from muse.cli.commands.compare import _CompareJson # noqa: F401
301
302 def test_has_exit_code_annotation(self) -> None:
303 from muse.cli.commands.compare import _CompareJson
304 assert "exit_code" in _CompareJson.__annotations__
305
306 def test_has_duration_ms_annotation(self) -> None:
307 from muse.cli.commands.compare import _CompareJson
308 assert "duration_ms" in _CompareJson.__annotations__
309
310 def test_retains_from_annotation(self) -> None:
311 from muse.cli.commands.compare import _CompareJson
312 assert "from" in _CompareJson.__annotations__
313
314 def test_retains_to_annotation(self) -> None:
315 from muse.cli.commands.compare import _CompareJson
316 assert "to" in _CompareJson.__annotations__
317
318 def test_retains_stat_annotation(self) -> None:
319 from muse.cli.commands.compare import _CompareJson
320 assert "stat" in _CompareJson.__annotations__
321
322 def test_retains_ops_annotation(self) -> None:
323 from muse.cli.commands.compare import _CompareJson
324 assert "ops" in _CompareJson.__annotations__
325
326 def test_retains_filters_annotation(self) -> None:
327 from muse.cli.commands.compare import _CompareJson
328 assert "filters" in _CompareJson.__annotations__
329
330
331 # ---------------------------------------------------------------------------
332 # TestDocstrings — run() docstring documents new fields
333 # ---------------------------------------------------------------------------
334
335
336 class TestDocstrings:
337 """run() must document exit_code."""
338
339 def test_run_docstring_documents_fields(self) -> None:
340 from muse.cli.commands.compare import run
341 assert "exit_code" in run.__doc__
342
343
344 # ---------------------------------------------------------------------------
345 # TestAnsiSanitization — no escape codes in JSON output
346 # ---------------------------------------------------------------------------
347
348
349 class TestAnsiSanitization:
350 """No ANSI escape sequences anywhere in the JSON output."""
351
352 def test_json_output_no_ansi_with_changes(
353 self, compare_repo: tuple[pathlib.Path, str, str]
354 ) -> None:
355 root, a, b = compare_repo
356 r = _run(root, "code", "compare", a, b, "--json")
357 assert "\x1b" not in r.output
358
359 def test_j_alias_output_no_ansi(
360 self, compare_repo: tuple[pathlib.Path, str, str]
361 ) -> None:
362 root, a, b = compare_repo
363 r = _run(root, "code", "compare", a, b, "-j")
364 assert "\x1b" not in r.output
365
366 def test_json_output_no_ansi_no_changes(
367 self, compare_repo: tuple[pathlib.Path, str, str]
368 ) -> None:
369 root, a, _ = compare_repo
370 r = _run(root, "code", "compare", a, a, "--json")
371 assert "\x1b" not in r.output
372
373
374 # ---------------------------------------------------------------------------
375 # TestPerformance — duration_ms under 2000 ms for a small repo
376 # ---------------------------------------------------------------------------
377
378
379 class TestPerformance:
380 """duration_ms must stay under 2000 ms for small repos."""
381
382 def test_json_duration_under_2000ms(
383 self, compare_repo: tuple[pathlib.Path, str, str]
384 ) -> None:
385 root, a, b = compare_repo
386 r = _run(root, "code", "compare", a, b, "--json")
387 assert json.loads(r.output)["duration_ms"] < 2000
388
389 def test_j_alias_duration_under_2000ms(
390 self, compare_repo: tuple[pathlib.Path, str, str]
391 ) -> None:
392 root, a, b = compare_repo
393 r = _run(root, "code", "compare", a, b, "-j")
394 assert json.loads(r.output)["duration_ms"] < 2000
395
396 def test_duration_ms_is_float_not_int(
397 self, compare_repo: tuple[pathlib.Path, str, str]
398 ) -> None:
399 root, a, b = compare_repo
400 r = _run(root, "code", "compare", a, b, "--json")
401 assert isinstance(json.loads(r.output)["duration_ms"], float)
402
403
404 # ---------------------------------------------------------------------------
405 # Flag registration tests
406 # ---------------------------------------------------------------------------
407
408 import argparse as _argparse
409 from muse.cli.commands.compare import register as _register_compare
410
411
412 def _parse_compare(*args: str) -> _argparse.Namespace:
413 root_p = _argparse.ArgumentParser()
414 subs = root_p.add_subparsers(dest="cmd")
415 _register_compare(subs)
416 return root_p.parse_args(["compare", *args])
417
418
419 class TestRegisterFlags:
420 def test_default_json_out_is_false(self) -> None:
421 ns = _parse_compare("HEAD~1", "HEAD")
422 assert ns.json_out is False
423
424 def test_json_flag_sets_json_out(self) -> None:
425 ns = _parse_compare("HEAD~1", "HEAD", "--json")
426 assert ns.json_out is True
427
428 def test_j_shorthand_sets_json_out(self) -> None:
429 ns = _parse_compare("HEAD~1", "HEAD", "-j")
430 assert ns.json_out is True
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 138 days ago