gabriel / muse public
test_blame_supercharge.py python
341 lines 13.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Supercharge tests for ``muse code blame`` — agent-usability gaps.
2
3 The three existing test files (test_cmd_blame.py, test_cmd_blame_hardening.py,
4 test_core_blame.py) already cover correctness, security, rename tracking, kind/
5 author filters, E2E, and stress. This file targets only the gaps those files
6 leave open:
7
8 Coverage matrix
9 ---------------
10 - --json / -j: -j alias works identically to --json
11 - exit_code: JSON output includes exit_code = 0 on success
12 - duration_ms: JSON output includes non-negative float duration_ms
13 - TypedDicts: _BlameResultJson gains exit_code/duration_ms annotations
14 - Docstrings: run() docstring mentions exit_code and duration_ms
15 - ANSI: JSON output never contains terminal escape sequences
16 - Performance: duration_ms stays under 1000 ms for a small repo
17 """
18
19 from __future__ import annotations
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) -> dict[str, str]:
38 return {"MUSE_REPO_ROOT": str(root)}
39
40
41 def _run(root: pathlib.Path, *args: str): # type: ignore[return]
42 return runner.invoke(None, list(args), env=_env(root))
43
44
45 # ---------------------------------------------------------------------------
46 # Fixture — repo with a committed symbol so blame returns real events
47 # ---------------------------------------------------------------------------
48
49
50 @pytest.fixture()
51 def blame_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
52 """Code-domain repo with two commits containing structured deltas.
53
54 Commit 1: create billing.py with Invoice.compute_total + process_order
55 Commit 2: modify compute_total body → blame sees a 'modified' event
56 """
57 monkeypatch.chdir(tmp_path)
58
59 r = _run(tmp_path, "init", "--domain", "code")
60 assert r.exit_code == 0, r.output
61
62 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
63 class Invoice:
64 def compute_total(self, items):
65 return sum(items)
66
67 def process_order(invoice, items):
68 return invoice.compute_total(items)
69 """))
70 r1 = _run(tmp_path, "code", "add", "billing.py")
71 assert r1.exit_code == 0, r1.output
72 r2 = _run(tmp_path, "commit", "-m", "initial billing")
73 assert r2.exit_code == 0, r2.output
74
75 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
76 class Invoice:
77 def compute_total(self, items):
78 return round(sum(items), 2)
79
80 def process_order(invoice, items):
81 return invoice.compute_total(items)
82 """))
83 r3 = _run(tmp_path, "code", "add", "billing.py")
84 assert r3.exit_code == 0, r3.output
85 r4 = _run(tmp_path, "commit", "-m", "round result")
86 assert r4.exit_code == 0, r4.output
87
88 return tmp_path
89
90
91 def _blame_address(root: pathlib.Path) -> str:
92 """Return a symbol address that blame can find in blame_repo."""
93 return "billing.py::Invoice.compute_total"
94
95
96 # ---------------------------------------------------------------------------
97 # TestJsonAlias — -j works identically to --json
98 # ---------------------------------------------------------------------------
99
100
101 class TestJsonAlias:
102 """The -j shorthand must behave identically to --json."""
103
104 def test_j_alias_exits_zero(self, blame_repo: pathlib.Path) -> None:
105 addr = _blame_address(blame_repo)
106 r = _run(blame_repo, "code", "blame", addr, "-j")
107 assert r.exit_code == 0, r.output
108
109 def test_j_alias_valid_json(self, blame_repo: pathlib.Path) -> None:
110 addr = _blame_address(blame_repo)
111 r = _run(blame_repo, "code", "blame", addr, "-j")
112 json.loads(r.output) # must not raise
113
114 def test_j_alias_has_events_key(self, blame_repo: pathlib.Path) -> None:
115 addr = _blame_address(blame_repo)
116 r = _run(blame_repo, "code", "blame", addr, "-j")
117 data = json.loads(r.output)
118 assert "events" in data
119
120 def test_j_alias_has_address_key(self, blame_repo: pathlib.Path) -> None:
121 addr = _blame_address(blame_repo)
122 r = _run(blame_repo, "code", "blame", addr, "-j")
123 data = json.loads(r.output)
124 assert data["address"] == addr
125
126 def test_j_alias_same_top_level_keys_as_json_flag(self, blame_repo: pathlib.Path) -> None:
127 addr = _blame_address(blame_repo)
128 r1 = _run(blame_repo, "code", "blame", addr, "--json")
129 r2 = _run(blame_repo, "code", "blame", addr, "-j")
130 d1 = json.loads(r1.output)
131 d2 = json.loads(r2.output)
132 d1.pop("duration_ms", None)
133 d2.pop("duration_ms", None)
134 assert set(d1.keys()) == set(d2.keys())
135
136 def test_j_alias_address_matches(self, blame_repo: pathlib.Path) -> None:
137 addr = _blame_address(blame_repo)
138 r1 = _run(blame_repo, "code", "blame", addr, "--json")
139 r2 = _run(blame_repo, "code", "blame", addr, "-j")
140 assert json.loads(r1.output)["address"] == json.loads(r2.output)["address"]
141
142
143 # ---------------------------------------------------------------------------
144 # TestDurationMs — JSON output must include duration_ms
145 # ---------------------------------------------------------------------------
146
147
148 class TestDurationMs:
149 """JSON output must include a non-negative float duration_ms."""
150
151 def test_json_has_duration_ms(self, blame_repo: pathlib.Path) -> None:
152 addr = _blame_address(blame_repo)
153 r = _run(blame_repo, "code", "blame", addr, "--json")
154 data = json.loads(r.output)
155 assert "duration_ms" in data
156
157 def test_json_duration_ms_nonnegative(self, blame_repo: pathlib.Path) -> None:
158 addr = _blame_address(blame_repo)
159 r = _run(blame_repo, "code", "blame", addr, "--json")
160 data = json.loads(r.output)
161 assert data["duration_ms"] >= 0
162
163 def test_json_duration_ms_is_float(self, blame_repo: pathlib.Path) -> None:
164 addr = _blame_address(blame_repo)
165 r = _run(blame_repo, "code", "blame", addr, "--json")
166 data = json.loads(r.output)
167 assert isinstance(data["duration_ms"], float)
168
169 def test_j_alias_duration_ms_present(self, blame_repo: pathlib.Path) -> None:
170 addr = _blame_address(blame_repo)
171 r = _run(blame_repo, "code", "blame", addr, "-j")
172 data = json.loads(r.output)
173 assert "duration_ms" in data
174
175 def test_duration_ms_with_kind_filter(self, blame_repo: pathlib.Path) -> None:
176 addr = _blame_address(blame_repo)
177 r = _run(blame_repo, "code", "blame", addr, "--json", "--kind", "created")
178 data = json.loads(r.output)
179 assert "duration_ms" in data
180 assert data["duration_ms"] >= 0
181
182 def test_duration_ms_with_all_flag(self, blame_repo: pathlib.Path) -> None:
183 addr = _blame_address(blame_repo)
184 r = _run(blame_repo, "code", "blame", addr, "--json", "--all")
185 data = json.loads(r.output)
186 assert "duration_ms" in data
187 assert data["duration_ms"] >= 0
188
189
190 # ---------------------------------------------------------------------------
191 # TestExitCode — JSON output must include exit_code
192 # ---------------------------------------------------------------------------
193
194
195 class TestExitCode:
196 """JSON output must include exit_code = 0 on success."""
197
198 def test_json_has_exit_code(self, blame_repo: pathlib.Path) -> None:
199 addr = _blame_address(blame_repo)
200 r = _run(blame_repo, "code", "blame", addr, "--json")
201 data = json.loads(r.output)
202 assert "exit_code" in data
203
204 def test_json_exit_code_zero_on_success(self, blame_repo: pathlib.Path) -> None:
205 addr = _blame_address(blame_repo)
206 r = _run(blame_repo, "code", "blame", addr, "--json")
207 assert r.exit_code == 0
208 data = json.loads(r.output)
209 assert data["exit_code"] == 0
210
211 def test_json_exit_code_is_int(self, blame_repo: pathlib.Path) -> None:
212 addr = _blame_address(blame_repo)
213 r = _run(blame_repo, "code", "blame", addr, "--json")
214 data = json.loads(r.output)
215 assert isinstance(data["exit_code"], int)
216
217 def test_j_alias_exit_code_present(self, blame_repo: pathlib.Path) -> None:
218 addr = _blame_address(blame_repo)
219 r = _run(blame_repo, "code", "blame", addr, "-j")
220 data = json.loads(r.output)
221 assert "exit_code" in data
222
223 def test_exit_code_mirrors_process_exit(self, blame_repo: pathlib.Path) -> None:
224 addr = _blame_address(blame_repo)
225 r = _run(blame_repo, "code", "blame", addr, "--json")
226 data = json.loads(r.output)
227 assert data["exit_code"] == r.exit_code
228
229 def test_exit_code_zero_with_no_events_found(self, blame_repo: pathlib.Path) -> None:
230 """Blame on a symbol with no recorded history exits 0 with empty events."""
231 r = _run(blame_repo, "code", "blame", "billing.py::nonexistent_fn", "--json")
232 assert r.exit_code == 0
233 data = json.loads(r.output)
234 assert data["exit_code"] == 0
235 assert data["events"] == []
236
237
238 # ---------------------------------------------------------------------------
239 # TestTypedDicts — _BlameResultJson carries the new fields
240 # ---------------------------------------------------------------------------
241
242
243 class TestTypedDicts:
244 """_BlameResultJson must gain exit_code/duration_ms annotations."""
245
246 def test_blame_result_json_exists(self) -> None:
247 from muse.cli.commands.blame import _BlameResultJson # noqa: F401
248
249 def test_blame_result_json_has_exit_code_annotation(self) -> None:
250 from muse.cli.commands.blame import _BlameResultJson
251 assert "exit_code" in _BlameResultJson.__annotations__
252
253 def test_blame_result_json_has_duration_ms_annotation(self) -> None:
254 from muse.cli.commands.blame import _BlameResultJson
255 assert "duration_ms" in _BlameResultJson.__annotations__
256
257 def test_blame_result_json_retains_events_annotation(self) -> None:
258 from muse.cli.commands.blame import _BlameResultJson
259 assert "events" in _BlameResultJson.__annotations__
260
261 def test_blame_result_json_retains_address_annotation(self) -> None:
262 from muse.cli.commands.blame import _BlameResultJson
263 assert "address" in _BlameResultJson.__annotations__
264
265 def test_blame_event_json_exists(self) -> None:
266 from muse.cli.commands.blame import _BlameEventJson # noqa: F401
267
268 def test_blame_event_json_has_commit_id(self) -> None:
269 from muse.cli.commands.blame import _BlameEventJson
270 assert "commit_id" in _BlameEventJson.__annotations__
271
272
273 # ---------------------------------------------------------------------------
274 # TestDocstrings — run() docstring documents new fields
275 # ---------------------------------------------------------------------------
276
277
278 class TestDocstrings:
279 """run() must document exit_code and duration_ms."""
280
281 def test_run_docstring_mentions_exit_code(self) -> None:
282 from muse.cli.commands.blame import run
283 assert run.__doc__ is not None
284 assert "exit_code" in run.__doc__
285
286 def test_run_docstring_mentions_duration_ms(self) -> None:
287 from muse.cli.commands.blame import run
288 assert run.__doc__ is not None
289 assert "duration_ms" in run.__doc__
290
291
292 # ---------------------------------------------------------------------------
293 # TestAnsiSanitization — no escape codes in JSON output
294 # ---------------------------------------------------------------------------
295
296
297 class TestAnsiSanitization:
298 """No ANSI escape sequences anywhere in the JSON output."""
299
300 def test_json_output_no_ansi(self, blame_repo: pathlib.Path) -> None:
301 addr = _blame_address(blame_repo)
302 r = _run(blame_repo, "code", "blame", addr, "--json")
303 assert "\x1b" not in r.output
304
305 def test_j_alias_output_no_ansi(self, blame_repo: pathlib.Path) -> None:
306 addr = _blame_address(blame_repo)
307 r = _run(blame_repo, "code", "blame", addr, "-j")
308 assert "\x1b" not in r.output
309
310 def test_json_address_field_no_ansi(self, blame_repo: pathlib.Path) -> None:
311 addr = _blame_address(blame_repo)
312 r = _run(blame_repo, "code", "blame", addr, "--json")
313 data = json.loads(r.output)
314 assert "\x1b" not in data["address"]
315
316
317 # ---------------------------------------------------------------------------
318 # TestPerformance — duration_ms under 1000 ms for a small repo
319 # ---------------------------------------------------------------------------
320
321
322 class TestPerformance:
323 """duration_ms must be non-negative and under 1000 ms for small repos."""
324
325 def test_json_duration_under_1000ms(self, blame_repo: pathlib.Path) -> None:
326 addr = _blame_address(blame_repo)
327 r = _run(blame_repo, "code", "blame", addr, "--json")
328 data = json.loads(r.output)
329 assert data["duration_ms"] < 1000
330
331 def test_j_alias_duration_under_1000ms(self, blame_repo: pathlib.Path) -> None:
332 addr = _blame_address(blame_repo)
333 r = _run(blame_repo, "code", "blame", addr, "-j")
334 data = json.loads(r.output)
335 assert data["duration_ms"] < 1000
336
337 def test_duration_ms_is_float_not_int(self, blame_repo: pathlib.Path) -> None:
338 addr = _blame_address(blame_repo)
339 r = _run(blame_repo, "code", "blame", addr, "--json")
340 data = json.loads(r.output)
341 assert isinstance(data["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago