cgcardona / muse public
test_find.py python
408 lines 13.8 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse find`` — search commit history by musical properties.
2
3 All async tests call ``_find_async`` directly with an in-memory SQLite
4 session and a ``tmp_path`` repo root — no real Postgres or running
5 process required. Commits are seeded via ``_commit_async`` so the two
6 commands are tested as an integrated pair.
7
8 Naming convention: test_muse_find_<behavior>_<scenario>
9 """
10 from __future__ import annotations
11
12 import json
13 import pathlib
14 import uuid
15 from datetime import datetime, timezone
16
17 import pytest
18 from sqlalchemy.ext.asyncio import AsyncSession
19
20 from maestro.muse_cli.commands.commit import _commit_async
21 from maestro.muse_cli.commands.find import _find_async
22 from maestro.muse_cli.errors import ExitCode
23 from maestro.services.muse_find import (
24 MuseFindQuery,
25 MuseFindResults,
26 _matches_property,
27 _parse_property_filter,
28 search_commits,
29 )
30
31
32 # ---------------------------------------------------------------------------
33 # Helpers
34 # ---------------------------------------------------------------------------
35
36
37 def _init_muse_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
38 rid = repo_id or str(uuid.uuid4())
39 muse = root / ".muse"
40 (muse / "refs" / "heads").mkdir(parents=True)
41 (muse / "repo.json").write_text(
42 json.dumps({"repo_id": rid, "schema_version": "1"})
43 )
44 (muse / "HEAD").write_text("refs/heads/main")
45 (muse / "refs" / "heads" / "main").write_text("")
46 return rid
47
48
49 def _write_workdir(root: pathlib.Path, files: dict[str, bytes]) -> None:
50 workdir = root / "muse-work"
51 workdir.mkdir(exist_ok=True)
52 for name, content in files.items():
53 (workdir / name).write_bytes(content)
54
55
56 async def _make_commits(
57 root: pathlib.Path,
58 session: AsyncSession,
59 messages: list[str],
60 file_seed: int = 0,
61 ) -> list[str]:
62 """Create N commits with unique file content."""
63 commit_ids: list[str] = []
64 for i, msg in enumerate(messages):
65 _write_workdir(root, {f"track_{file_seed + i}.mid": f"MIDI-{file_seed + i}".encode()})
66 cid = await _commit_async(message=msg, root=root, session=session)
67 commit_ids.append(cid)
68 return commit_ids
69
70
71 # ---------------------------------------------------------------------------
72 # Unit tests for helpers
73 # ---------------------------------------------------------------------------
74
75
76 def test_muse_find_parse_range_filter_returns_triple() -> None:
77 """``tempo=120-130`` parses to (tempo, 120.0, 130.0)."""
78 result = _parse_property_filter("tempo=120-130")
79 assert result == ("tempo", 120.0, 130.0)
80
81
82 def test_muse_find_parse_range_filter_returns_none_for_text() -> None:
83 """``key=Eb`` is not a range and returns None."""
84 assert _parse_property_filter("key=Eb") is None
85
86
87 def test_muse_find_parse_range_filter_returns_none_for_no_equals() -> None:
88 """A string without ``=`` is not a range."""
89 assert _parse_property_filter("melancholic") is None
90
91
92 def test_muse_find_matches_property_plain_text_case_insensitive() -> None:
93 """Plain text filter matches case-insensitively."""
94 assert _matches_property("key=Eb major, mode=major", "key=Eb") is True
95 assert _matches_property("key=Eb major, mode=major", "KEY=EB") is True
96 assert _matches_property("key=Eb major, mode=major", "mode=minor") is False
97
98
99 def test_muse_find_matches_property_range_in_bounds() -> None:
100 """Range filter matches when message tempo is within range."""
101 assert _matches_property("tempo=125 bpm, swing=0.6", "tempo=120-130") is True
102
103
104 def test_muse_find_matches_property_range_out_of_bounds() -> None:
105 """Range filter rejects message tempo outside the range."""
106 assert _matches_property("tempo=115 bpm", "tempo=120-130") is False
107
108
109 def test_muse_find_matches_property_range_at_boundary() -> None:
110 """Range filter matches when tempo equals boundary value exactly."""
111 assert _matches_property("tempo=120", "tempo=120-130") is True
112 assert _matches_property("tempo=130", "tempo=120-130") is True
113
114
115 def test_muse_find_matches_property_missing_key() -> None:
116 """Range filter returns False when key is absent from the message."""
117 assert _matches_property("mode=minor, has=bridge", "tempo=120-130") is False
118
119
120 # ---------------------------------------------------------------------------
121 # Regression: test_muse_find_harmony_key_returns_matching_commits
122 # ---------------------------------------------------------------------------
123
124
125 @pytest.mark.anyio
126 async def test_muse_find_harmony_key_returns_matching_commits(
127 tmp_path: pathlib.Path,
128 muse_cli_db_session: AsyncSession,
129 ) -> None:
130 """``--harmony 'key=F minor'`` lists all commits where the key was F minor."""
131 _init_muse_repo(tmp_path)
132 await _make_commits(
133 tmp_path,
134 muse_cli_db_session,
135 [
136 "ambient sketch, key=F minor, tempo=90 bpm",
137 "jazz take, key=Eb major, tempo=140 bpm",
138 "brooding outro, key=F minor, tempo=72 bpm",
139 ],
140 )
141
142 query = MuseFindQuery(harmony="key=F minor", limit=20)
143 results = await search_commits(muse_cli_db_session, _get_repo_id(tmp_path), query)
144
145 assert results.total_scanned == 2 # ILIKE applied at SQL level
146 assert len(results.matches) == 2
147 for match in results.matches:
148 assert "key=F minor" in match.message
149
150
151 def _get_repo_id(root: pathlib.Path) -> str:
152 data: dict[str, str] = json.loads((root / ".muse" / "repo.json").read_text())
153 return data["repo_id"]
154
155
156 # ---------------------------------------------------------------------------
157 # test_muse_find_rhythm_tempo_range_filter
158 # ---------------------------------------------------------------------------
159
160
161 @pytest.mark.anyio
162 async def test_muse_find_rhythm_tempo_range_filter(
163 tmp_path: pathlib.Path,
164 muse_cli_db_session: AsyncSession,
165 ) -> None:
166 """``--rhythm 'tempo=120-130'`` finds only commits with tempo in range."""
167 _init_muse_repo(tmp_path)
168 await _make_commits(
169 tmp_path,
170 muse_cli_db_session,
171 [
172 "slow groove, tempo=95 bpm",
173 "medium vibe, tempo=125 bpm",
174 "fast run, tempo=160 bpm",
175 "another mid, tempo=120 bpm",
176 ],
177 )
178
179 query = MuseFindQuery(rhythm="tempo=120-130", limit=20)
180 results = await search_commits(muse_cli_db_session, _get_repo_id(tmp_path), query)
181
182 assert len(results.matches) == 2
183 messages = {m.message for m in results.matches}
184 assert "medium vibe, tempo=125 bpm" in messages
185 assert "another mid, tempo=120 bpm" in messages
186
187
188 # ---------------------------------------------------------------------------
189 # test_muse_find_multiple_flags_combine_with_and_logic
190 # ---------------------------------------------------------------------------
191
192
193 @pytest.mark.anyio
194 async def test_muse_find_multiple_flags_combine_with_and_logic(
195 tmp_path: pathlib.Path,
196 muse_cli_db_session: AsyncSession,
197 ) -> None:
198 """Multiple filter flags combine with AND — commit must satisfy all."""
199 _init_muse_repo(tmp_path)
200 await _make_commits(
201 tmp_path,
202 muse_cli_db_session,
203 [
204 "melancholic bridge, key=F minor, has=bridge",
205 "melancholic verse, key=F minor", # no bridge
206 "bright bridge, key=C major, has=bridge", # wrong key
207 ],
208 )
209
210 query = MuseFindQuery(emotion="melancholic", structure="has=bridge", limit=20)
211 results = await search_commits(muse_cli_db_session, _get_repo_id(tmp_path), query)
212
213 assert len(results.matches) == 1
214 assert "melancholic bridge" in results.matches[0].message
215
216
217 # ---------------------------------------------------------------------------
218 # test_muse_find_json_output
219 # ---------------------------------------------------------------------------
220
221
222 @pytest.mark.anyio
223 async def test_muse_find_json_output(
224 tmp_path: pathlib.Path,
225 muse_cli_db_session: AsyncSession,
226 capsys: pytest.CaptureFixture[str],
227 ) -> None:
228 """``--json`` output is valid JSON with correct commit_id fields."""
229 _init_muse_repo(tmp_path)
230 cids = await _make_commits(
231 tmp_path,
232 muse_cli_db_session,
233 ["jazz chord, key=Cm7"],
234 )
235
236 capsys.readouterr()
237 query = MuseFindQuery(harmony="key=Cm7", limit=20)
238 await _find_async(
239 root=tmp_path,
240 session=muse_cli_db_session,
241 query=query,
242 output_json=True,
243 )
244
245 captured = capsys.readouterr().out
246 payload = json.loads(captured)
247
248 assert isinstance(payload, list)
249 assert len(payload) == 1
250 assert payload[0]["commit_id"] == cids[0]
251 assert payload[0]["message"] == "jazz chord, key=Cm7"
252
253
254 # ---------------------------------------------------------------------------
255 # test_muse_find_since_until_date_filter
256 # ---------------------------------------------------------------------------
257
258
259 @pytest.mark.anyio
260 async def test_muse_find_since_until_date_filter(
261 tmp_path: pathlib.Path,
262 muse_cli_db_session: AsyncSession,
263 ) -> None:
264 """``--since`` / ``--until`` restrict results to the given date window."""
265 from maestro.muse_cli.models import MuseCliCommit
266 from maestro.muse_cli.snapshot import compute_commit_id, compute_snapshot_id
267 from maestro.muse_cli.db import upsert_object, upsert_snapshot, insert_commit
268
269 _init_muse_repo(tmp_path)
270 repo_id = _get_repo_id(tmp_path)
271
272 # Manually insert commits with specific timestamps
273 old_ts = datetime(2025, 6, 1, tzinfo=timezone.utc)
274 new_ts = datetime(2026, 2, 1, tzinfo=timezone.utc)
275
276 for i, (msg, ts) in enumerate([("old take", old_ts), ("new take", new_ts)]):
277 manifest = {f"track_{i}.mid": f"abcdef{i:02x}" * 4}
278 snapshot_id = compute_snapshot_id(manifest)
279 await upsert_object(muse_cli_db_session, object_id=f"abcdef{i:02x}" * 4, size_bytes=8)
280 await upsert_snapshot(muse_cli_db_session, manifest=manifest, snapshot_id=snapshot_id)
281 await muse_cli_db_session.flush()
282 commit_id = compute_commit_id(
283 parent_ids=[],
284 snapshot_id=snapshot_id,
285 message=msg,
286 committed_at_iso=ts.isoformat(),
287 )
288 commit = MuseCliCommit(
289 commit_id=commit_id,
290 repo_id=repo_id,
291 branch="main",
292 parent_commit_id=None,
293 snapshot_id=snapshot_id,
294 message=msg,
295 author="",
296 committed_at=ts,
297 )
298 await insert_commit(muse_cli_db_session, commit)
299
300 cutoff = datetime(2026, 1, 1, tzinfo=timezone.utc)
301 query = MuseFindQuery(since=cutoff, limit=20)
302 results = await search_commits(muse_cli_db_session, repo_id, query)
303
304 assert len(results.matches) == 1
305 assert results.matches[0].message == "new take"
306
307
308 # ---------------------------------------------------------------------------
309 # test_muse_find_no_matches_returns_empty
310 # ---------------------------------------------------------------------------
311
312
313 @pytest.mark.anyio
314 async def test_muse_find_no_matches_returns_empty(
315 tmp_path: pathlib.Path,
316 muse_cli_db_session: AsyncSession,
317 ) -> None:
318 """No-match query returns empty results without error."""
319 _init_muse_repo(tmp_path)
320 await _make_commits(tmp_path, muse_cli_db_session, ["take 1", "take 2"])
321
322 query = MuseFindQuery(emotion="epic", limit=20)
323 results = await search_commits(muse_cli_db_session, _get_repo_id(tmp_path), query)
324
325 assert len(results.matches) == 0
326 assert results.total_scanned == 0 # SQL ILIKE filters row out entirely
327
328
329 # ---------------------------------------------------------------------------
330 # test_muse_find_limit_caps_results
331 # ---------------------------------------------------------------------------
332
333
334 @pytest.mark.anyio
335 async def test_muse_find_limit_caps_results(
336 tmp_path: pathlib.Path,
337 muse_cli_db_session: AsyncSession,
338 ) -> None:
339 """``--limit N`` caps the result set even when more matches exist."""
340 _init_muse_repo(tmp_path)
341 await _make_commits(
342 tmp_path,
343 muse_cli_db_session,
344 [f"minor key take {i}, key=F minor" for i in range(5)],
345 )
346
347 query = MuseFindQuery(harmony="key=F minor", limit=3)
348 results = await search_commits(muse_cli_db_session, _get_repo_id(tmp_path), query)
349
350 assert len(results.matches) == 3
351 assert results.total_scanned == 5
352
353
354 # ---------------------------------------------------------------------------
355 # test_muse_find_results_are_newest_first
356 # ---------------------------------------------------------------------------
357
358
359 @pytest.mark.anyio
360 async def test_muse_find_results_are_newest_first(
361 tmp_path: pathlib.Path,
362 muse_cli_db_session: AsyncSession,
363 ) -> None:
364 """Results are ordered newest-first."""
365 _init_muse_repo(tmp_path)
366 cids = await _make_commits(
367 tmp_path,
368 muse_cli_db_session,
369 [
370 "first minor, key=F minor",
371 "second minor, key=F minor",
372 "third minor, key=F minor",
373 ],
374 )
375
376 query = MuseFindQuery(harmony="key=F minor", limit=20)
377 results = await search_commits(muse_cli_db_session, _get_repo_id(tmp_path), query)
378
379 # Newest (cids[2]) should be first
380 assert results.matches[0].commit_id == cids[2]
381 assert results.matches[-1].commit_id == cids[0]
382
383
384 # ---------------------------------------------------------------------------
385 # test_muse_find_no_filters_exits_user_error (CLI skeleton)
386 # ---------------------------------------------------------------------------
387
388
389 def test_muse_find_no_filters_exits_user_error(tmp_path: pathlib.Path) -> None:
390 """``muse find`` with no flags exits with USER_ERROR."""
391 import os
392 from typer.testing import CliRunner
393 from maestro.muse_cli.app import cli
394
395 muse = tmp_path / ".muse"
396 (muse / "refs" / "heads").mkdir(parents=True)
397 (muse / "repo.json").write_text(json.dumps({"repo_id": "test", "schema_version": "1"}))
398 (muse / "HEAD").write_text("refs/heads/main")
399
400 runner = CliRunner()
401 prev = os.getcwd()
402 try:
403 os.chdir(tmp_path)
404 result = runner.invoke(cli, ["find"], catch_exceptions=False)
405 finally:
406 os.chdir(prev)
407
408 assert result.exit_code == ExitCode.USER_ERROR