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