cgcardona / muse public
test_blame.py python
435 lines 14.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse blame``.
2
3 All async tests call ``_blame_async`` directly with an in-memory SQLite
4 session and a ``tmp_path`` repo root — no real Postgres or running process
5 required. Commits are seeded via ``_commit_async`` so blame and commit
6 are tested as an integrated pair.
7
8 Covered scenarios:
9
10 - ``test_blame_returns_last_commit_per_path`` (regression)
11 - ``test_blame_path_filter_restricts_output``
12 - ``test_blame_track_filter_glob``
13 - ``test_blame_section_filter``
14 - ``test_blame_json_output``
15 - ``test_blame_no_commits_exits_zero``
16 - ``test_blame_outside_repo_exits_2``
17 - ``test_blame_single_commit_all_added``
18 - ``test_blame_unmodified_file_attributes_oldest_commit``
19 """
20 from __future__ import annotations
21
22 import json
23 import os
24 import pathlib
25 import uuid
26
27 import pytest
28 import typer
29 from sqlalchemy.ext.asyncio import AsyncSession
30 from typer.testing import CliRunner
31
32 from maestro.muse_cli.app import cli
33 from maestro.muse_cli.commands.blame import _blame_async, _render_blame
34 from maestro.muse_cli.commands.commit import _commit_async
35 from maestro.muse_cli.errors import ExitCode
36
37 runner = CliRunner()
38
39
40 # ---------------------------------------------------------------------------
41 # Helpers
42 # ---------------------------------------------------------------------------
43
44
45 def _init_muse_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
46 rid = repo_id or str(uuid.uuid4())
47 muse = root / ".muse"
48 (muse / "refs" / "heads").mkdir(parents=True)
49 (muse / "repo.json").write_text(
50 json.dumps({"repo_id": rid, "schema_version": "1"})
51 )
52 (muse / "HEAD").write_text("refs/heads/main")
53 (muse / "refs" / "heads" / "main").write_text("")
54 return rid
55
56
57 def _write_file(root: pathlib.Path, rel_path: str, content: bytes) -> None:
58 """Write a file inside muse-work/ at the given relative path."""
59 target = root / "muse-work" / rel_path
60 target.parent.mkdir(parents=True, exist_ok=True)
61 target.write_bytes(content)
62
63
64 # ---------------------------------------------------------------------------
65 # test_blame_returns_last_commit_per_path (regression)
66 # ---------------------------------------------------------------------------
67
68
69 @pytest.mark.anyio
70 async def test_blame_returns_last_commit_per_path(
71 tmp_path: pathlib.Path,
72 muse_cli_db_session: AsyncSession,
73 ) -> None:
74 """Blame returns the most-recent commit that changed each path.
75
76 Regression: ``muse blame <path>`` must walk the commit
77 graph and return the correct last-change commit, not simply the HEAD
78 commit for all paths.
79 """
80 _init_muse_repo(tmp_path)
81
82 # Commit 1: add two files
83 _write_file(tmp_path, "bass/bassline.mid", b"bass-v1")
84 _write_file(tmp_path, "keys/melody.mid", b"keys-v1")
85 cid1 = await _commit_async(message="initial take", root=tmp_path, session=muse_cli_db_session)
86
87 # Commit 2: modify only bassline
88 _write_file(tmp_path, "bass/bassline.mid", b"bass-v2")
89 cid2 = await _commit_async(message="update bass groove", root=tmp_path, session=muse_cli_db_session)
90
91 result = await _blame_async(
92 root=tmp_path,
93 session=muse_cli_db_session,
94 path_filter=None,
95 track_filter=None,
96 section_filter=None,
97 line_range=None,
98 )
99
100 entries = {e["path"].split("muse-work/")[-1]: e for e in result["entries"]}
101
102 # bass/bassline.mid was changed in commit 2
103 assert "bass/bassline.mid" in entries
104 assert entries["bass/bassline.mid"]["commit_short"] == cid2[:8]
105 assert entries["bass/bassline.mid"]["change_type"] == "modified"
106
107 # keys/melody.mid was only in commit 1 and not changed in commit 2
108 assert "keys/melody.mid" in entries
109 assert entries["keys/melody.mid"]["commit_short"] == cid1[:8]
110 assert entries["keys/melody.mid"]["change_type"] == "added"
111
112
113 # ---------------------------------------------------------------------------
114 # test_blame_path_filter_restricts_output
115 # ---------------------------------------------------------------------------
116
117
118 @pytest.mark.anyio
119 async def test_blame_path_filter_restricts_output(
120 tmp_path: pathlib.Path,
121 muse_cli_db_session: AsyncSession,
122 ) -> None:
123 """Positional path filter returns only matching entries."""
124 _init_muse_repo(tmp_path)
125
126 _write_file(tmp_path, "bass/bassline.mid", b"bass")
127 _write_file(tmp_path, "keys/melody.mid", b"keys")
128 await _commit_async(message="init", root=tmp_path, session=muse_cli_db_session)
129
130 result = await _blame_async(
131 root=tmp_path,
132 session=muse_cli_db_session,
133 path_filter="bassline.mid",
134 track_filter=None,
135 section_filter=None,
136 line_range=None,
137 )
138
139 assert len(result["entries"]) == 1
140 assert "bassline.mid" in result["entries"][0]["path"]
141
142
143 # ---------------------------------------------------------------------------
144 # test_blame_track_filter_glob
145 # ---------------------------------------------------------------------------
146
147
148 @pytest.mark.anyio
149 async def test_blame_track_filter_glob(
150 tmp_path: pathlib.Path,
151 muse_cli_db_session: AsyncSession,
152 ) -> None:
153 """``--track`` filters by basename glob pattern."""
154 _init_muse_repo(tmp_path)
155
156 _write_file(tmp_path, "bass/bassline.mid", b"bass")
157 _write_file(tmp_path, "drums/kick.wav", b"kick")
158 _write_file(tmp_path, "keys/piano.mid", b"piano")
159 await _commit_async(message="init", root=tmp_path, session=muse_cli_db_session)
160
161 result = await _blame_async(
162 root=tmp_path,
163 session=muse_cli_db_session,
164 path_filter=None,
165 track_filter="*.mid",
166 section_filter=None,
167 line_range=None,
168 )
169
170 # Only .mid files should appear
171 for entry in result["entries"]:
172 assert entry["path"].endswith(".mid"), f"Non-.mid file leaked: {entry['path']}"
173 # Both MIDI files should be present
174 paths = [e["path"] for e in result["entries"]]
175 assert any("bassline.mid" in p for p in paths)
176 assert any("piano.mid" in p for p in paths)
177 assert not any("kick.wav" in p for p in paths)
178
179
180 # ---------------------------------------------------------------------------
181 # test_blame_section_filter
182 # ---------------------------------------------------------------------------
183
184
185 @pytest.mark.anyio
186 async def test_blame_section_filter(
187 tmp_path: pathlib.Path,
188 muse_cli_db_session: AsyncSession,
189 ) -> None:
190 """``--section`` filters to files inside the named section directory."""
191 _init_muse_repo(tmp_path)
192
193 _write_file(tmp_path, "chorus/lead.mid", b"chorus-lead")
194 _write_file(tmp_path, "verse/rhythm.mid", b"verse-rhythm")
195 _write_file(tmp_path, "chorus/bass.mid", b"chorus-bass")
196 await _commit_async(message="init", root=tmp_path, session=muse_cli_db_session)
197
198 result = await _blame_async(
199 root=tmp_path,
200 session=muse_cli_db_session,
201 path_filter=None,
202 track_filter=None,
203 section_filter="chorus",
204 line_range=None,
205 )
206
207 paths = [e["path"] for e in result["entries"]]
208 assert all("chorus" in p for p in paths)
209 assert not any("verse" in p for p in paths)
210 assert len(result["entries"]) == 2
211
212
213 # ---------------------------------------------------------------------------
214 # test_blame_json_output
215 # ---------------------------------------------------------------------------
216
217
218 @pytest.mark.anyio
219 async def test_blame_json_output(
220 tmp_path: pathlib.Path,
221 muse_cli_db_session: AsyncSession,
222 capsys: pytest.CaptureFixture[str],
223 ) -> None:
224 """``--json`` flag emits parseable JSON with the correct keys."""
225 _init_muse_repo(tmp_path)
226
227 _write_file(tmp_path, "drums/beat.mid", b"drums")
228 await _commit_async(message="beat commit", root=tmp_path, session=muse_cli_db_session)
229
230 result = await _blame_async(
231 root=tmp_path,
232 session=muse_cli_db_session,
233 path_filter=None,
234 track_filter=None,
235 section_filter=None,
236 line_range=None,
237 )
238
239 output = json.dumps(dict(result), indent=2)
240 parsed: dict[str, object] = json.loads(output)
241
242 assert "entries" in parsed
243 assert isinstance(parsed["entries"], list)
244 assert len(parsed["entries"]) == 1
245
246 entry = parsed["entries"][0]
247 assert isinstance(entry, dict)
248 for key in ("path", "commit_id", "commit_short", "author", "committed_at", "message", "change_type"):
249 assert key in entry, f"Missing key in BlameEntry: {key}"
250
251
252 # ---------------------------------------------------------------------------
253 # test_blame_no_commits_exits_zero
254 # ---------------------------------------------------------------------------
255
256
257 @pytest.mark.anyio
258 async def test_blame_no_commits_exits_zero(
259 tmp_path: pathlib.Path,
260 muse_cli_db_session: AsyncSession,
261 capsys: pytest.CaptureFixture[str],
262 ) -> None:
263 """``muse blame`` on a repo with no commits exits 0 with a friendly message."""
264 _init_muse_repo(tmp_path)
265
266 with pytest.raises(typer.Exit) as exc_info:
267 await _blame_async(
268 root=tmp_path,
269 session=muse_cli_db_session,
270 path_filter=None,
271 track_filter=None,
272 section_filter=None,
273 line_range=None,
274 )
275
276 assert exc_info.value.exit_code == ExitCode.SUCCESS
277 out = capsys.readouterr().out
278 assert "No commits yet" in out
279
280
281 # ---------------------------------------------------------------------------
282 # test_blame_outside_repo_exits_2
283 # ---------------------------------------------------------------------------
284
285
286 def test_blame_outside_repo_exits_2(tmp_path: pathlib.Path) -> None:
287 """``muse blame`` outside a .muse/ directory exits with code 2."""
288 prev = os.getcwd()
289 try:
290 os.chdir(tmp_path)
291 result = runner.invoke(cli, ["blame"], catch_exceptions=False)
292 finally:
293 os.chdir(prev)
294
295 assert result.exit_code == ExitCode.REPO_NOT_FOUND
296
297
298 # ---------------------------------------------------------------------------
299 # test_blame_single_commit_all_added
300 # ---------------------------------------------------------------------------
301
302
303 @pytest.mark.anyio
304 async def test_blame_single_commit_all_added(
305 tmp_path: pathlib.Path,
306 muse_cli_db_session: AsyncSession,
307 ) -> None:
308 """All files in a single-commit repo are attributed to that commit as 'added'."""
309 _init_muse_repo(tmp_path)
310
311 _write_file(tmp_path, "bass.mid", b"bass")
312 _write_file(tmp_path, "drums.mid", b"drums")
313 cid = await _commit_async(message="first commit", root=tmp_path, session=muse_cli_db_session)
314
315 result = await _blame_async(
316 root=tmp_path,
317 session=muse_cli_db_session,
318 path_filter=None,
319 track_filter=None,
320 section_filter=None,
321 line_range=None,
322 )
323
324 assert len(result["entries"]) == 2
325 for entry in result["entries"]:
326 assert entry["commit_short"] == cid[:8]
327 assert entry["change_type"] == "added"
328
329
330 # ---------------------------------------------------------------------------
331 # test_blame_unmodified_file_attributes_oldest_commit
332 # ---------------------------------------------------------------------------
333
334
335 @pytest.mark.anyio
336 async def test_blame_unmodified_file_attributes_oldest_commit(
337 tmp_path: pathlib.Path,
338 muse_cli_db_session: AsyncSession,
339 ) -> None:
340 """A file that never changes across three commits is attributed to the first commit."""
341 _init_muse_repo(tmp_path)
342
343 # Commit 1: add stable.mid and volatile.mid
344 _write_file(tmp_path, "stable.mid", b"stable-content-never-changes")
345 _write_file(tmp_path, "volatile.mid", b"volatile-v1")
346 cid1 = await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
347
348 # Commit 2: modify only volatile
349 _write_file(tmp_path, "volatile.mid", b"volatile-v2")
350 await _commit_async(message="update volatile", root=tmp_path, session=muse_cli_db_session)
351
352 # Commit 3: modify only volatile again
353 _write_file(tmp_path, "volatile.mid", b"volatile-v3")
354 cid3 = await _commit_async(message="another volatile update", root=tmp_path, session=muse_cli_db_session)
355
356 result = await _blame_async(
357 root=tmp_path,
358 session=muse_cli_db_session,
359 path_filter=None,
360 track_filter=None,
361 section_filter=None,
362 line_range=None,
363 )
364
365 entries = {e["path"].split("muse-work/")[-1]: e for e in result["entries"]}
366
367 # stable.mid was never changed — must point to cid1
368 assert "stable.mid" in entries
369 assert entries["stable.mid"]["commit_short"] == cid1[:8]
370
371 # volatile.mid was last changed in cid3
372 assert "volatile.mid" in entries
373 assert entries["volatile.mid"]["commit_short"] == cid3[:8]
374
375
376 # ---------------------------------------------------------------------------
377 # test_blame_render_human_readable
378 # ---------------------------------------------------------------------------
379
380
381 @pytest.mark.anyio
382 async def test_blame_render_human_readable(
383 tmp_path: pathlib.Path,
384 muse_cli_db_session: AsyncSession,
385 ) -> None:
386 """Human-readable output contains the short commit ID and file path."""
387 _init_muse_repo(tmp_path)
388
389 _write_file(tmp_path, "bass/groove.mid", b"groove")
390 cid = await _commit_async(message="add groove", root=tmp_path, session=muse_cli_db_session)
391
392 result = await _blame_async(
393 root=tmp_path,
394 session=muse_cli_db_session,
395 path_filter=None,
396 track_filter=None,
397 section_filter=None,
398 line_range=None,
399 )
400
401 rendered = _render_blame(result)
402 assert cid[:8] in rendered
403 assert "groove.mid" in rendered
404 assert "add groove" in rendered
405
406
407 # ---------------------------------------------------------------------------
408 # test_blame_line_range_recorded_in_output
409 # ---------------------------------------------------------------------------
410
411
412 @pytest.mark.anyio
413 async def test_blame_line_range_recorded_in_output(
414 tmp_path: pathlib.Path,
415 muse_cli_db_session: AsyncSession,
416 ) -> None:
417 """``--line-range`` is recorded in the result and shown in human-readable output."""
418 _init_muse_repo(tmp_path)
419
420 _write_file(tmp_path, "score.mxl", b"<musicxml/>")
421 await _commit_async(message="add score", root=tmp_path, session=muse_cli_db_session)
422
423 result = await _blame_async(
424 root=tmp_path,
425 session=muse_cli_db_session,
426 path_filter=None,
427 track_filter=None,
428 section_filter=None,
429 line_range="10,20",
430 )
431
432 assert result["line_range"] == "10,20"
433 rendered = _render_blame(result)
434 assert "line-range" in rendered
435 assert "10,20" in rendered