gabriel / musehub public
test_musehub_notation.py python
231 lines 9.1 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for the MuseHub notation service — MIDI-to-notation conversion.
2
3 Covers acceptance criteria (score/notation renderer):
4 - test_notation_convert_ref_returns_result — convert_ref_to_notation returns NotationResult
5 - test_notation_result_has_tracks — result contains at least one track
6 - test_notation_result_tracks_have_required_fields — each track has clef, key_signature, etc.
7 - test_notation_notes_have_required_fields — each note has pitch_name, octave, duration, etc.
8 - test_notation_deterministic — same ref always returns same result
9 - test_notation_different_refs_differ — different refs produce different keys/tempos
10 - test_notation_num_tracks_clamped — num_tracks=0 is clamped to 1
11 - test_notation_num_bars_clamped — num_bars=0 is clamped to 1
12 - test_notation_to_dict_camel_case — serialized dict uses camelCase timeSig
13 - test_notation_to_dict_has_all_keys — serialized dict has tracks/tempo/key/timeSig
14 - test_notation_clef_for_bass — bass role gets bass clef
15 - test_notation_clef_for_piano — piano role gets treble clef
16 - test_notation_start_beat_non_negative — all notes have start_beat >= 0
17 - test_notation_velocity_in_range — velocity is in [0, 127]
18 - test_notation_duration_valid — duration is a recognized fraction string
19 """
20 from __future__ import annotations
21
22 import pytest
23
24 from musehub.services.musehub_notation import (
25 NotationResult,
26 convert_ref_to_notation,
27 notation_result_to_dict,
28 )
29
30 # ---------------------------------------------------------------------------
31 # Constants
32 # ---------------------------------------------------------------------------
33
34 _VALID_DURATIONS = {"1/1", "1/2", "1/4", "1/8", "1/16"}
35
36
37 # ---------------------------------------------------------------------------
38 # Basic API
39 # ---------------------------------------------------------------------------
40
41
42 def test_notation_convert_ref_returns_result() -> None:
43 """convert_ref_to_notation returns a NotationResult named tuple."""
44 result = convert_ref_to_notation("abc1234")
45 assert isinstance(result, NotationResult)
46
47
48 def test_notation_result_has_tracks() -> None:
49 """Result always contains at least one track."""
50 result = convert_ref_to_notation("abc1234", num_tracks=1)
51 assert len(result.tracks) >= 1
52
53
54 def test_notation_result_tracks_have_required_fields() -> None:
55 """Each NotationTrack dict contains all required metadata fields."""
56 result = convert_ref_to_notation("abc1234", num_tracks=2)
57 for track in result.tracks:
58 assert "track_id" in track
59 assert "clef" in track
60 assert "key_signature" in track
61 assert "time_signature" in track
62 assert "instrument" in track
63 assert "notes" in track
64 assert isinstance(track["notes"], list)
65
66
67 def test_notation_notes_have_required_fields() -> None:
68 """Each NotationNote dict in a track has all required fields."""
69 result = convert_ref_to_notation("main", num_tracks=1, num_bars=4)
70 for track in result.tracks:
71 for note in track["notes"]:
72 assert "pitch_name" in note
73 assert "octave" in note
74 assert "duration" in note
75 assert "start_beat" in note
76 assert "velocity" in note
77 assert "track_id" in note
78
79
80 # ---------------------------------------------------------------------------
81 # Determinism
82 # ---------------------------------------------------------------------------
83
84
85 def test_notation_deterministic() -> None:
86 """The same ref always produces identical results (deterministic stub)."""
87 r1 = convert_ref_to_notation("deadbeef", num_tracks=2, num_bars=4)
88 r2 = convert_ref_to_notation("deadbeef", num_tracks=2, num_bars=4)
89 assert r1.key == r2.key
90 assert r1.tempo == r2.tempo
91 assert r1.time_sig == r2.time_sig
92 assert len(r1.tracks) == len(r2.tracks)
93 for t1, t2 in zip(r1.tracks, r2.tracks):
94 assert len(t1["notes"]) == len(t2["notes"])
95
96
97 def test_notation_different_refs_differ() -> None:
98 """Different refs produce at least one differing field (key, tempo, or timeSig)."""
99 r1 = convert_ref_to_notation("aaaaaaa", num_tracks=1)
100 r2 = convert_ref_to_notation("bbbbbbb", num_tracks=1)
101 # At least one of key, tempo, or time_sig must differ across distinct refs
102 differs = (r1.key != r2.key) or (r1.tempo != r2.tempo) or (r1.time_sig != r2.time_sig)
103 assert differs, "Distinct refs should produce different notation parameters"
104
105
106 # ---------------------------------------------------------------------------
107 # Clamping
108 # ---------------------------------------------------------------------------
109
110
111 def test_notation_num_tracks_clamped() -> None:
112 """num_tracks=0 is clamped to 1 — result always has at least one track."""
113 result = convert_ref_to_notation("ref", num_tracks=0)
114 assert len(result.tracks) == 1
115
116
117 def test_notation_num_tracks_max_clamped() -> None:
118 """num_tracks=100 is clamped to 8 — result has at most 8 tracks."""
119 result = convert_ref_to_notation("ref", num_tracks=100)
120 assert len(result.tracks) == 8
121
122
123 def test_notation_num_bars_clamped() -> None:
124 """num_bars=0 is clamped to 1 — at least one bar is generated."""
125 result = convert_ref_to_notation("ref", num_tracks=1, num_bars=0)
126 assert len(result.tracks) == 1
127
128
129 # ---------------------------------------------------------------------------
130 # Serialization
131 # ---------------------------------------------------------------------------
132
133
134 def test_notation_to_dict_has_all_keys() -> None:
135 """notation_result_to_dict returns a dict with tracks, tempo, key, timeSig."""
136 result = convert_ref_to_notation("abc")
137 d = notation_result_to_dict(result)
138 assert "tracks" in d
139 assert "tempo" in d
140 assert "key" in d
141 assert "timeSig" in d
142
143
144 def test_notation_to_dict_camel_case() -> None:
145 """Serialized dict uses camelCase 'timeSig' not snake_case 'time_sig'."""
146 result = convert_ref_to_notation("abc")
147 d = notation_result_to_dict(result)
148 assert "timeSig" in d
149 assert "time_sig" not in d
150
151
152 def test_notation_to_dict_tempo_is_int() -> None:
153 """Serialized tempo is a positive integer."""
154 result = convert_ref_to_notation("abc")
155 d = notation_result_to_dict(result)
156 assert isinstance(d["tempo"], int)
157 assert d["tempo"] > 0
158
159
160 # ---------------------------------------------------------------------------
161 # Clef assignment
162 # ---------------------------------------------------------------------------
163
164
165 def test_notation_clef_for_bass() -> None:
166 """First track assigned role 'bass' should receive bass clef."""
167 result = convert_ref_to_notation("abc", num_tracks=5)
168 # Track index 1 maps to 'bass' role (see _ROLE_NAMES order)
169 bass_tracks = [t for t in result.tracks if t["instrument"] == "bass"]
170 for t in bass_tracks:
171 assert t["clef"] == "bass", f"bass instrument should have bass clef, got {t['clef']}"
172
173
174 def test_notation_clef_for_piano() -> None:
175 """Track with 'piano' role should receive treble clef."""
176 result = convert_ref_to_notation("abc", num_tracks=1)
177 piano_tracks = [t for t in result.tracks if t["instrument"] == "piano"]
178 for t in piano_tracks:
179 assert t["clef"] == "treble"
180
181
182 # ---------------------------------------------------------------------------
183 # Note value constraints
184 # ---------------------------------------------------------------------------
185
186
187 def test_notation_start_beat_non_negative() -> None:
188 """All notes have start_beat >= 0."""
189 result = convert_ref_to_notation("main", num_tracks=3, num_bars=4)
190 for track in result.tracks:
191 for note in track["notes"]:
192 assert note["start_beat"] >= 0, f"Negative start_beat: {note}"
193
194
195 def test_notation_velocity_in_range() -> None:
196 """All note velocities are in [0, 127]."""
197 result = convert_ref_to_notation("main", num_tracks=3, num_bars=4)
198 for track in result.tracks:
199 for note in track["notes"]:
200 vel = note["velocity"]
201 assert 0 <= vel <= 127, f"Velocity out of range: {vel}"
202
203
204 def test_notation_duration_valid() -> None:
205 """All note durations are recognized fraction strings."""
206 result = convert_ref_to_notation("main", num_tracks=3, num_bars=4)
207 for track in result.tracks:
208 for note in track["notes"]:
209 dur = note["duration"]
210 assert dur in _VALID_DURATIONS, f"Unrecognized duration: {dur}"
211
212
213 def test_notation_octave_in_range() -> None:
214 """All note octaves are in a playable range [1, 7]."""
215 result = convert_ref_to_notation("main", num_tracks=3, num_bars=4)
216 for track in result.tracks:
217 for note in track["notes"]:
218 oct_ = note["octave"]
219 assert 1 <= oct_ <= 7, f"Octave out of expected range: {oct_}"
220
221
222 def test_notation_pitch_name_valid() -> None:
223 """All note pitch_name values are valid note names (A-G with optional # or b)."""
224 valid_names = {
225 "C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#",
226 "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B",
227 }
228 result = convert_ref_to_notation("main", num_tracks=2, num_bars=4)
229 for track in result.tracks:
230 for note in track["notes"]:
231 assert note["pitch_name"] in valid_names, f"Invalid pitch name: {note['pitch_name']}"