cgcardona / muse public
test_context.py python
454 lines 15.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse context``.
2
3 All async tests call ``_context_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 the full pipeline
6 (commit → context) is exercised as an integrated pair.
7 """
8 from __future__ import annotations
9
10 import json
11 import pathlib
12 import uuid
13
14 import pytest
15 from sqlalchemy.ext.asyncio import AsyncSession
16
17 from maestro.muse_cli.commands.commit import _commit_async
18 from maestro.muse_cli.commands.context import OutputFormat, _context_async
19 from maestro.muse_cli.errors import ExitCode
20 from maestro.services.muse_context import (
21 MuseContextResult,
22 MuseHeadCommitInfo,
23 MuseHistoryEntry,
24 MuseMusicalState,
25 build_muse_context,
26 _extract_track_names,
27 )
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34
35 def _init_muse_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
36 """Initialise a minimal .muse/ repo structure under *root*."""
37 rid = repo_id or str(uuid.uuid4())
38 muse = root / ".muse"
39 (muse / "refs" / "heads").mkdir(parents=True)
40 (muse / "repo.json").write_text(
41 json.dumps({"repo_id": rid, "schema_version": "1"})
42 )
43 (muse / "HEAD").write_text("refs/heads/main")
44 (muse / "refs" / "heads" / "main").write_text("")
45 return rid
46
47
48 def _write_workdir(root: pathlib.Path, files: dict[str, bytes]) -> None:
49 workdir = root / "muse-work"
50 workdir.mkdir(exist_ok=True)
51 for name, content in files.items():
52 (workdir / name).write_bytes(content)
53
54
55 async def _make_commits(
56 root: pathlib.Path,
57 session: AsyncSession,
58 messages: list[str],
59 file_seed: int = 0,
60 ) -> list[str]:
61 """Create N commits on the repo with different file content per commit."""
62 commit_ids: list[str] = []
63 for i, msg in enumerate(messages):
64 _write_workdir(
65 root,
66 {f"track_{file_seed + i}.mid": f"MIDI-{file_seed + i}".encode()},
67 )
68 cid = await _commit_async(message=msg, root=root, session=session)
69 commit_ids.append(cid)
70 return commit_ids
71
72
73 # ---------------------------------------------------------------------------
74 # Unit tests for _extract_track_names
75 # ---------------------------------------------------------------------------
76
77
78 def test_extract_track_names_midi_files() -> None:
79 """MIDI file stems become track names, sorted and de-duplicated."""
80 manifest = {"drums.mid": "aaa", "bass.mid": "bbb", "piano.midi": "ccc"}
81 result = _extract_track_names(manifest)
82 assert result == ["bass", "drums", "piano"]
83
84
85 def test_extract_track_names_ignores_non_music_files() -> None:
86 """Non-music files (JSON, txt, png) are not treated as tracks."""
87 manifest = {
88 "drums.mid": "aaa",
89 "README.txt": "bbb",
90 "cover.png": "ccc",
91 "meta.json": "ddd",
92 }
93 result = _extract_track_names(manifest)
94 assert result == ["drums"]
95
96
97 def test_extract_track_names_ignores_hash_stems() -> None:
98 """64-char hex stems that look like SHA-256 hashes are filtered out."""
99 sha = "a" * 64
100 manifest = {f"{sha}.mid": "abc", "bass.mid": "def"}
101 result = _extract_track_names(manifest)
102 assert result == ["bass"]
103
104
105 def test_extract_track_names_case_insensitive() -> None:
106 """File extensions are matched case-insensitively."""
107 manifest = {"Drums.MID": "aaa", "Bass.Mp3": "bbb"}
108 result = _extract_track_names(manifest)
109 assert result == ["bass", "drums"]
110
111
112 def test_extract_track_names_empty_manifest() -> None:
113 """An empty manifest returns an empty list."""
114 assert _extract_track_names({}) == []
115
116
117 # ---------------------------------------------------------------------------
118 # test_muse_context_returns_full_musical_state_at_head
119 # ---------------------------------------------------------------------------
120
121
122 @pytest.mark.anyio
123 async def test_muse_context_returns_full_musical_state_at_head(
124 tmp_path: pathlib.Path,
125 muse_cli_db_session: AsyncSession,
126 ) -> None:
127 """All top-level keys are present in the result at HEAD."""
128 _init_muse_repo(tmp_path)
129 _write_workdir(tmp_path, {"drums.mid": b"MIDI-drums", "bass.mid": b"MIDI-bass"})
130 await _commit_async(message="initial commit", root=tmp_path, session=muse_cli_db_session)
131
132 result = await build_muse_context(
133 muse_cli_db_session, root=tmp_path, depth=5
134 )
135
136 assert isinstance(result, MuseContextResult)
137 assert isinstance(result.head_commit, MuseHeadCommitInfo)
138 assert isinstance(result.musical_state, MuseMusicalState)
139 assert isinstance(result.history, list)
140 assert isinstance(result.missing_elements, list)
141 assert isinstance(result.suggestions, dict)
142 assert result.current_branch == "main"
143 assert result.head_commit.message == "initial commit"
144
145
146 # ---------------------------------------------------------------------------
147 # test_muse_context_active_tracks_from_manifest
148 # ---------------------------------------------------------------------------
149
150
151 @pytest.mark.anyio
152 async def test_muse_context_active_tracks_from_manifest(
153 tmp_path: pathlib.Path,
154 muse_cli_db_session: AsyncSession,
155 ) -> None:
156 """active_tracks is derived from MIDI file names in the snapshot manifest."""
157 _init_muse_repo(tmp_path)
158 _write_workdir(
159 tmp_path,
160 {"drums.mid": b"d", "bass.mid": b"b", "piano.mid": b"p"},
161 )
162 await _commit_async(message="three tracks", root=tmp_path, session=muse_cli_db_session)
163
164 result = await build_muse_context(muse_cli_db_session, root=tmp_path)
165
166 assert sorted(result.musical_state.active_tracks) == ["bass", "drums", "piano"]
167
168
169 # ---------------------------------------------------------------------------
170 # test_muse_context_depth_limits_history_length
171 # ---------------------------------------------------------------------------
172
173
174 @pytest.mark.anyio
175 async def test_muse_context_depth_limits_history_length(
176 tmp_path: pathlib.Path,
177 muse_cli_db_session: AsyncSession,
178 ) -> None:
179 """--depth 3 returns exactly 3 history entries for a chain of 5 commits."""
180 _init_muse_repo(tmp_path)
181 await _make_commits(
182 tmp_path,
183 muse_cli_db_session,
184 ["c1", "c2", "c3", "c4", "c5"],
185 )
186
187 result = await build_muse_context(muse_cli_db_session, root=tmp_path, depth=3)
188
189 assert len(result.history) == 3
190
191
192 # ---------------------------------------------------------------------------
193 # test_muse_context_depth_zero_returns_empty_history
194 # ---------------------------------------------------------------------------
195
196
197 @pytest.mark.anyio
198 async def test_muse_context_depth_zero_returns_empty_history(
199 tmp_path: pathlib.Path,
200 muse_cli_db_session: AsyncSession,
201 ) -> None:
202 """depth=0 omits history entirely."""
203 _init_muse_repo(tmp_path)
204 await _make_commits(tmp_path, muse_cli_db_session, ["a", "b", "c"])
205
206 result = await build_muse_context(muse_cli_db_session, root=tmp_path, depth=0)
207
208 assert result.history == []
209
210
211 # ---------------------------------------------------------------------------
212 # test_muse_context_sections_flag_expands_section_detail
213 # ---------------------------------------------------------------------------
214
215
216 @pytest.mark.anyio
217 async def test_muse_context_sections_flag_expands_section_detail(
218 tmp_path: pathlib.Path,
219 muse_cli_db_session: AsyncSession,
220 ) -> None:
221 """--sections populates musical_state.sections with track names."""
222 _init_muse_repo(tmp_path)
223 _write_workdir(tmp_path, {"drums.mid": b"d", "bass.mid": b"b"})
224 await _commit_async(
225 message="with sections", root=tmp_path, session=muse_cli_db_session
226 )
227
228 result = await build_muse_context(
229 muse_cli_db_session, root=tmp_path, include_sections=True
230 )
231
232 assert result.musical_state.sections is not None
233 assert "main" in result.musical_state.sections
234 section = result.musical_state.sections["main"]
235 assert sorted(section.tracks) == ["bass", "drums"]
236
237
238 # ---------------------------------------------------------------------------
239 # test_muse_context_sections_flag_false_omits_sections
240 # ---------------------------------------------------------------------------
241
242
243 @pytest.mark.anyio
244 async def test_muse_context_sections_flag_false_omits_sections(
245 tmp_path: pathlib.Path,
246 muse_cli_db_session: AsyncSession,
247 ) -> None:
248 """Without --sections, musical_state.sections is None."""
249 _init_muse_repo(tmp_path)
250 _write_workdir(tmp_path, {"drums.mid": b"d"})
251 await _commit_async(message="no sections", root=tmp_path, session=muse_cli_db_session)
252
253 result = await build_muse_context(muse_cli_db_session, root=tmp_path)
254
255 assert result.musical_state.sections is None
256
257
258 # ---------------------------------------------------------------------------
259 # test_muse_context_tracks_flag_adds_per_track_breakdown
260 # ---------------------------------------------------------------------------
261
262
263 @pytest.mark.anyio
264 async def test_muse_context_tracks_flag_adds_per_track_breakdown(
265 tmp_path: pathlib.Path,
266 muse_cli_db_session: AsyncSession,
267 ) -> None:
268 """--tracks populates musical_state.tracks with one entry per active track."""
269 _init_muse_repo(tmp_path)
270 _write_workdir(tmp_path, {"drums.mid": b"d", "bass.mid": b"b"})
271 await _commit_async(message="tracks test", root=tmp_path, session=muse_cli_db_session)
272
273 result = await build_muse_context(
274 muse_cli_db_session, root=tmp_path, include_tracks=True
275 )
276
277 assert result.musical_state.tracks is not None
278 track_names = sorted(t.track_name for t in result.musical_state.tracks)
279 assert track_names == ["bass", "drums"]
280
281
282 # ---------------------------------------------------------------------------
283 # test_muse_context_specific_commit_resolves_correctly
284 # ---------------------------------------------------------------------------
285
286
287 @pytest.mark.anyio
288 async def test_muse_context_specific_commit_resolves_correctly(
289 tmp_path: pathlib.Path,
290 muse_cli_db_session: AsyncSession,
291 ) -> None:
292 """Passing an explicit commit_id returns context for that commit, not HEAD."""
293 _init_muse_repo(tmp_path)
294 cids = await _make_commits(
295 tmp_path, muse_cli_db_session, ["first commit", "second commit"]
296 )
297 first_commit_id = cids[0]
298
299 result = await build_muse_context(
300 muse_cli_db_session, root=tmp_path, commit_id=first_commit_id
301 )
302
303 assert result.head_commit.commit_id == first_commit_id
304 assert result.head_commit.message == "first commit"
305
306
307 # ---------------------------------------------------------------------------
308 # test_muse_context_no_commits_raises_runtime_error
309 # ---------------------------------------------------------------------------
310
311
312 @pytest.mark.anyio
313 async def test_muse_context_no_commits_raises_runtime_error(
314 tmp_path: pathlib.Path,
315 muse_cli_db_session: AsyncSession,
316 ) -> None:
317 """build_muse_context raises RuntimeError when the repo has no commits."""
318 _init_muse_repo(tmp_path)
319
320 with pytest.raises(RuntimeError, match="no commits yet"):
321 await build_muse_context(muse_cli_db_session, root=tmp_path)
322
323
324 # ---------------------------------------------------------------------------
325 # test_muse_context_unknown_commit_raises_value_error
326 # ---------------------------------------------------------------------------
327
328
329 @pytest.mark.anyio
330 async def test_muse_context_unknown_commit_raises_value_error(
331 tmp_path: pathlib.Path,
332 muse_cli_db_session: AsyncSession,
333 ) -> None:
334 """build_muse_context raises ValueError for an unknown commit_id."""
335 _init_muse_repo(tmp_path)
336 await _make_commits(tmp_path, muse_cli_db_session, ["one commit"])
337
338 with pytest.raises(ValueError, match="not found in DB"):
339 await build_muse_context(
340 muse_cli_db_session,
341 root=tmp_path,
342 commit_id="nonexistent" * 5,
343 )
344
345
346 # ---------------------------------------------------------------------------
347 # test_muse_context_to_dict_is_serialisable
348 # ---------------------------------------------------------------------------
349
350
351 @pytest.mark.anyio
352 async def test_muse_context_to_dict_is_serialisable(
353 tmp_path: pathlib.Path,
354 muse_cli_db_session: AsyncSession,
355 ) -> None:
356 """to_dict() produces a dict that round-trips cleanly through json.dumps."""
357 _init_muse_repo(tmp_path)
358 _write_workdir(tmp_path, {"drums.mid": b"d"})
359 await _commit_async(message="json test", root=tmp_path, session=muse_cli_db_session)
360
361 result = await build_muse_context(muse_cli_db_session, root=tmp_path)
362
363 d = result.to_dict()
364 serialised = json.dumps(d, default=str)
365 parsed = json.loads(serialised)
366
367 assert parsed["current_branch"] == "main"
368 assert "musical_state" in parsed
369 assert "head_commit" in parsed
370
371
372 # ---------------------------------------------------------------------------
373 # test_muse_context_format_json_outputs_valid_json (via _context_async)
374 # ---------------------------------------------------------------------------
375
376
377 @pytest.mark.anyio
378 async def test_muse_context_format_json_outputs_valid_json(
379 tmp_path: pathlib.Path,
380 muse_cli_db_session: AsyncSession,
381 capsys: pytest.CaptureFixture[str],
382 ) -> None:
383 """_context_async with json format emits valid JSON to stdout."""
384 _init_muse_repo(tmp_path)
385 _write_workdir(tmp_path, {"drums.mid": b"d"})
386 await _commit_async(message="json format", root=tmp_path, session=muse_cli_db_session)
387
388 capsys.readouterr()
389 await _context_async(
390 root=tmp_path,
391 session=muse_cli_db_session,
392 commit_id=None,
393 depth=5,
394 sections=False,
395 tracks=False,
396 include_history=False,
397 fmt=OutputFormat.json,
398 )
399 out = capsys.readouterr().out
400
401 parsed = json.loads(out)
402 assert "repo_id" in parsed
403 assert "musical_state" in parsed
404 assert "head_commit" in parsed
405 assert "history" in parsed
406
407
408 # ---------------------------------------------------------------------------
409 # test_muse_context_history_entries_are_newest_first
410 # ---------------------------------------------------------------------------
411
412
413 @pytest.mark.anyio
414 async def test_muse_context_history_entries_are_newest_first(
415 tmp_path: pathlib.Path,
416 muse_cli_db_session: AsyncSession,
417 ) -> None:
418 """History entries appear newest-first (most recent ancestor at index 0)."""
419 _init_muse_repo(tmp_path)
420 cids = await _make_commits(
421 tmp_path, muse_cli_db_session, ["oldest", "middle", "head"]
422 )
423
424 result = await build_muse_context(
425 muse_cli_db_session, root=tmp_path, depth=5
426 )
427
428 assert len(result.history) == 2
429 assert result.history[0].commit_id == cids[1] # middle
430 assert result.history[1].commit_id == cids[0] # oldest
431
432
433 # ---------------------------------------------------------------------------
434 # test_muse_context_outside_repo_exits_2 (CLI integration)
435 # ---------------------------------------------------------------------------
436
437
438 def test_muse_context_outside_repo_exits_2(tmp_path: pathlib.Path) -> None:
439 """muse context outside a .muse/ directory exits with code 2."""
440 import os
441
442 from typer.testing import CliRunner
443
444 from maestro.muse_cli.app import cli
445
446 runner = CliRunner()
447 prev = os.getcwd()
448 try:
449 os.chdir(tmp_path)
450 result = runner.invoke(cli, ["context"], catch_exceptions=False)
451 finally:
452 os.chdir(prev)
453
454 assert result.exit_code == ExitCode.REPO_NOT_FOUND