cgcardona / muse public
test_manifest.py python
226 lines 8.4 KB
6d8ca4ac feat: god-tier MIDI dimension expansion + full supercharge architecture Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """Tests for muse.plugins.music.manifest — BarChunk, TrackManifest, MusicManifest."""
2 from __future__ import annotations
3
4 import io
5 import pathlib
6
7 import mido
8 import pytest
9
10 from muse.plugins.music._query import NoteInfo
11 from muse.plugins.music.manifest import (
12 BarChunk,
13 MusicManifest,
14 TrackManifest,
15 build_bar_chunk,
16 build_music_manifest,
17 build_track_manifest,
18 diff_manifests_by_bar,
19 read_music_manifest,
20 write_music_manifest,
21 )
22 from muse.plugins.music.midi_diff import NoteKey
23
24
25 def _note(pitch: int, start_tick: int = 0, duration_ticks: int = 480,
26 velocity: int = 80, channel: int = 0) -> NoteInfo:
27 return NoteInfo.from_note_key(
28 NoteKey(pitch=pitch, velocity=velocity, start_tick=start_tick,
29 duration_ticks=duration_ticks, channel=channel),
30 ticks_per_beat=480,
31 )
32
33
34 def _build_midi_bytes(notes: list[tuple[int, int, int]], ticks_per_beat: int = 480) -> bytes:
35 """Build a minimal MIDI file from (pitch, start_tick, duration_ticks) tuples."""
36 events: list[tuple[int, mido.Message]] = []
37 for pitch, start, dur in notes:
38 events.append((start, mido.Message("note_on", note=pitch, velocity=80, channel=0, time=0)))
39 events.append((start + dur, mido.Message("note_off", note=pitch, velocity=0, channel=0, time=0)))
40 events.sort(key=lambda e: (e[0], e[1].type))
41
42 track = mido.MidiTrack()
43 prev = 0
44 for abs_tick, msg in events:
45 track.append(msg.copy(time=abs_tick - prev))
46 prev = abs_tick
47 track.append(mido.MetaMessage("end_of_track", time=0))
48
49 mid = mido.MidiFile(type=0, ticks_per_beat=ticks_per_beat)
50 mid.tracks.append(track)
51 buf = io.BytesIO()
52 mid.save(file=buf)
53 return buf.getvalue()
54
55
56 # ---------------------------------------------------------------------------
57 # build_bar_chunk
58 # ---------------------------------------------------------------------------
59
60
61 class TestBuildBarChunk:
62 def test_fields_populated(self) -> None:
63 notes = [_note(60), _note(64), _note(67)]
64 chunk = build_bar_chunk(1, notes)
65 assert chunk["bar"] == 1
66 assert chunk["note_count"] == 3
67 assert isinstance(chunk["chunk_hash"], str)
68 assert len(chunk["chunk_hash"]) == 64
69
70 def test_pitch_range_correct(self) -> None:
71 notes = [_note(60), _note(72), _note(55)]
72 chunk = build_bar_chunk(1, notes)
73 assert chunk["pitch_range"] == [55, 72]
74
75 def test_same_notes_same_hash(self) -> None:
76 notes = [_note(60), _note(64)]
77 c1 = build_bar_chunk(1, notes)
78 c2 = build_bar_chunk(1, notes)
79 assert c1["chunk_hash"] == c2["chunk_hash"]
80
81 def test_different_notes_different_hash(self) -> None:
82 c1 = build_bar_chunk(1, [_note(60)])
83 c2 = build_bar_chunk(1, [_note(62)])
84 assert c1["chunk_hash"] != c2["chunk_hash"]
85
86 def test_empty_bar_has_pitch_range_zero(self) -> None:
87 chunk = build_bar_chunk(1, [])
88 assert chunk["pitch_range"] == [0, 0]
89 assert chunk["note_count"] == 0
90
91
92 # ---------------------------------------------------------------------------
93 # build_track_manifest
94 # ---------------------------------------------------------------------------
95
96
97 class TestBuildTrackManifest:
98 def test_basic_fields(self) -> None:
99 notes = [_note(60), _note(64), _note(67)]
100 tm = build_track_manifest(notes, "piano.mid", "abc123", 480)
101 assert tm["file_path"] == "piano.mid"
102 assert tm["content_hash"] == "abc123"
103 assert tm["ticks_per_beat"] == 480
104 assert tm["note_count"] == 3
105 assert isinstance(tm["track_id"], str)
106
107 def test_bars_dict_has_string_keys(self) -> None:
108 notes = [_note(60)]
109 tm = build_track_manifest(notes, "t.mid", "h1", 480)
110 for key in tm["bars"].keys():
111 assert isinstance(key, str)
112
113 def test_bar_count_matches_unique_bars(self) -> None:
114 # Two notes in bar 1, two in bar 2.
115 tpb = 480
116 bar_ticks = tpb * 4
117 notes = [
118 _note(60, start_tick=0),
119 _note(64, start_tick=tpb),
120 _note(60, start_tick=bar_ticks),
121 _note(67, start_tick=bar_ticks + tpb),
122 ]
123 tm = build_track_manifest(notes, "t.mid", "h1", 480)
124 assert tm["bar_count"] == 2
125
126 def test_key_guess_is_string(self) -> None:
127 notes = [_note(60), _note(62), _note(64), _note(65), _note(67)]
128 tm = build_track_manifest(notes, "t.mid", "h1", 480)
129 assert isinstance(tm["key_guess"], str)
130 assert len(tm["key_guess"]) > 0
131
132
133 # ---------------------------------------------------------------------------
134 # MusicManifest I/O
135 # ---------------------------------------------------------------------------
136
137
138 class TestMusicManifestIO:
139 def _make_manifest(self, tmp_path: pathlib.Path) -> MusicManifest:
140 notes = [_note(60), _note(64)]
141 track_manifest = build_track_manifest(notes, "t.mid", "fakehash123", 480)
142 return MusicManifest(
143 domain="music",
144 schema_version=2,
145 snapshot_id="snap-abc123",
146 files={"t.mid": "fakehash123"},
147 tracks={"t.mid": track_manifest},
148 )
149
150 def test_write_and_read_roundtrip(self, tmp_path: pathlib.Path) -> None:
151 manifest = self._make_manifest(tmp_path)
152 write_music_manifest(tmp_path, manifest)
153 recovered = read_music_manifest(tmp_path, "snap-abc123")
154 assert recovered is not None
155 assert recovered["snapshot_id"] == "snap-abc123"
156 assert "t.mid" in recovered["tracks"]
157
158 def test_read_missing_returns_none(self, tmp_path: pathlib.Path) -> None:
159 result = read_music_manifest(tmp_path, "nonexistent-snap")
160 assert result is None
161
162 def test_write_requires_snapshot_id(self, tmp_path: pathlib.Path) -> None:
163 manifest = MusicManifest(
164 domain="music",
165 schema_version=2,
166 snapshot_id="",
167 files={},
168 tracks={},
169 )
170 with pytest.raises(ValueError, match="snapshot_id"):
171 write_music_manifest(tmp_path, manifest)
172
173
174 # ---------------------------------------------------------------------------
175 # diff_manifests_by_bar
176 # ---------------------------------------------------------------------------
177
178
179 class TestDiffManifestsByBar:
180 def _make_pair_manifests(self) -> tuple[MusicManifest, MusicManifest]:
181 tpb = 480
182 bar_ticks = tpb * 4
183 notes1 = [_note(60, start_tick=0), _note(64, start_tick=bar_ticks)]
184 notes2 = [_note(60, start_tick=0), _note(67, start_tick=bar_ticks)] # bar 2 differs
185
186 tm1 = build_track_manifest(notes1, "t.mid", "hash1", tpb)
187 tm2 = build_track_manifest(notes2, "t.mid", "hash2", tpb)
188
189 base = MusicManifest(domain="music", schema_version=2, snapshot_id="s1",
190 files={"t.mid": "hash1"}, tracks={"t.mid": tm1})
191 target = MusicManifest(domain="music", schema_version=2, snapshot_id="s2",
192 files={"t.mid": "hash2"}, tracks={"t.mid": tm2})
193 return base, target
194
195 def test_no_change_produces_empty_result(self) -> None:
196 notes = [_note(60)]
197 tm = build_track_manifest(notes, "t.mid", "hash1", 480)
198 base = MusicManifest(domain="music", schema_version=2, snapshot_id="s1",
199 files={"t.mid": "hash1"}, tracks={"t.mid": tm})
200 changed = diff_manifests_by_bar(base, base)
201 assert changed == {}
202
203 def test_changed_bar_detected(self) -> None:
204 base, target = self._make_pair_manifests()
205 changed = diff_manifests_by_bar(base, target)
206 assert "t.mid" in changed
207 # Bar 2 changed.
208 assert 2 in changed["t.mid"]
209
210 def test_unchanged_bar_not_in_changed(self) -> None:
211 base, target = self._make_pair_manifests()
212 changed = diff_manifests_by_bar(base, target)
213 # Bar 1 is unchanged.
214 if "t.mid" in changed:
215 assert 1 not in changed["t.mid"]
216
217 def test_added_track_reported_with_sentinel(self) -> None:
218 notes = [_note(60)]
219 tm = build_track_manifest(notes, "new.mid", "hashN", 480)
220 base = MusicManifest(domain="music", schema_version=2, snapshot_id="s1",
221 files={}, tracks={})
222 target = MusicManifest(domain="music", schema_version=2, snapshot_id="s2",
223 files={"new.mid": "hashN"}, tracks={"new.mid": tm})
224 changed = diff_manifests_by_bar(base, target)
225 assert "new.mid" in changed
226 assert changed["new.mid"] == [-1]