cgcardona / muse public
test_stress_midi_all_dims.py python
471 lines 18.8 KB
e6786943 feat: upgrade to Python 3.14, drop from __future__ import annotations Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """Stress tests for the 21-dimension MIDI merge engine.
2
3 Each of the 21 internal dimensions is tested for:
4 1. Clean auto-merge when only one side changes (left_only / right_only).
5 2. Correct conflict detection when both sides change independently.
6 3. Unchanged dimensions are preserved from base unchanged.
7 4. Non-independent dimensions (tempo_map, time_signatures, track_structure)
8 block the entire merge on bilateral conflict.
9 5. dimension_conflict_detail returns correct change labels for all 21 dims.
10 6. extract_dimensions round-trips every event type.
11 7. Large sequences (100 notes, many CC events) handled correctly.
12 """
13
14 import io
15 from typing import TypedDict
16
17 import mido
18 import pytest
19
20 from muse.core.attributes import AttributeRule
21 from muse.plugins.midi.midi_merge import (
22 INTERNAL_DIMS,
23 NON_INDEPENDENT_DIMS,
24 MidiDimensions,
25 dimension_conflict_detail,
26 extract_dimensions,
27 merge_midi_dimensions,
28 )
29
30
31 # ---------------------------------------------------------------------------
32 # Typed kwargs for _make_midi — avoids bare dict in parametrize
33 # ---------------------------------------------------------------------------
34
35
36 class MidiKwargs(TypedDict, total=False):
37 notes: list[tuple[int, int, int]]
38 pitchwheel: list[tuple[int, int]]
39 control_change: list[tuple[int, int, int]]
40 channel_pressure: list[tuple[int, int]]
41 poly_aftertouch: list[tuple[int, int, int]]
42 program_change: list[tuple[int, int]]
43 set_tempo: int
44 time_sig: tuple[int, int]
45 key_sig: str
46 marker: str
47 track_name: str
48 ticks_per_beat: int
49
50
51 # ---------------------------------------------------------------------------
52 # MIDI construction helpers (reused from test_music_midi_merge)
53 # ---------------------------------------------------------------------------
54
55
56 def _make_midi(
57 *,
58 notes: list[tuple[int, int, int]] | None = None,
59 pitchwheel: list[tuple[int, int]] | None = None,
60 control_change: list[tuple[int, int, int]] | None = None,
61 channel_pressure: list[tuple[int, int]] | None = None,
62 poly_aftertouch: list[tuple[int, int, int]] | None = None,
63 program_change: list[tuple[int, int]] | None = None,
64 set_tempo: int | None = None,
65 time_sig: tuple[int, int] | None = None,
66 key_sig: str | None = None,
67 marker: str | None = None,
68 track_name: str | None = None,
69 ticks_per_beat: int = 480,
70 ) -> bytes:
71 mid = mido.MidiFile(type=0, ticks_per_beat=ticks_per_beat)
72 track = mido.MidiTrack()
73 events: list[tuple[int, mido.Message]] = []
74
75 tempo = set_tempo if set_tempo is not None else 500_000
76 events.append((0, mido.MetaMessage("set_tempo", tempo=tempo, time=0)))
77
78 if time_sig:
79 events.append((0, mido.MetaMessage("time_signature", numerator=time_sig[0], denominator=time_sig[1], time=0)))
80 if key_sig:
81 events.append((0, mido.MetaMessage("key_signature", key=key_sig, time=0)))
82 if marker:
83 events.append((0, mido.MetaMessage("marker", text=marker, time=0)))
84 if track_name:
85 events.append((0, mido.MetaMessage("track_name", name=track_name, time=0)))
86
87 for abs_tick, note, vel in notes or []:
88 events.append((abs_tick, mido.Message("note_on", note=note, velocity=vel, time=0)))
89 events.append((abs_tick + 120, mido.Message("note_off", note=note, velocity=0, time=0)))
90 for abs_tick, pitch in pitchwheel or []:
91 events.append((abs_tick, mido.Message("pitchwheel", pitch=pitch, time=0)))
92 for abs_tick, ctrl, val in control_change or []:
93 events.append((abs_tick, mido.Message("control_change", control=ctrl, value=val, time=0)))
94 for abs_tick, pressure in channel_pressure or []:
95 events.append((abs_tick, mido.Message("aftertouch", value=pressure, time=0)))
96 for abs_tick, note, pressure in poly_aftertouch or []:
97 events.append((abs_tick, mido.Message("polytouch", note=note, value=pressure, time=0)))
98 for abs_tick, prog in program_change or []:
99 events.append((abs_tick, mido.Message("program_change", program=prog, time=0)))
100
101 events.sort(key=lambda x: x[0])
102 prev = 0
103 for abs_tick, msg in events:
104 delta = abs_tick - prev
105 track.append(msg.copy(time=delta))
106 prev = abs_tick
107
108 track.append(mido.MetaMessage("end_of_track", time=0))
109 mid.tracks.append(track)
110 buf = io.BytesIO()
111 mid.save(file=buf)
112 return buf.getvalue()
113
114
115 def _empty_midi() -> bytes:
116 return _make_midi()
117
118
119 _NO_ATTRS: list[AttributeRule] = []
120
121
122 def _ours_rule(path: str) -> list[AttributeRule]:
123 return [AttributeRule(path_pattern=path, dimension="*", strategy="ours")]
124
125
126 # ---------------------------------------------------------------------------
127 # Dimension count
128 # ---------------------------------------------------------------------------
129
130
131 class TestDimensionCount:
132 def test_exactly_21_internal_dims(self) -> None:
133 assert len(INTERNAL_DIMS) == 21
134
135 def test_all_dims_present_in_extracted_dimensions(self) -> None:
136 dims = extract_dimensions(_empty_midi())
137 for d in INTERNAL_DIMS:
138 assert d in dims.slices, f"Missing dimension: {d}"
139
140 def test_non_independent_dims_subset_of_internal(self) -> None:
141 for d in NON_INDEPENDENT_DIMS:
142 assert d in INTERNAL_DIMS
143
144
145 # ---------------------------------------------------------------------------
146 # extract_dimensions correctness per event type
147 # ---------------------------------------------------------------------------
148
149
150 class TestExtractDimensionsPerType:
151 def test_notes_extracted(self) -> None:
152 midi = _make_midi(notes=[(0, 60, 80)])
153 dims = extract_dimensions(midi)
154 assert len(dims.slices["notes"].events) > 0
155
156 def test_pitch_bend_extracted(self) -> None:
157 midi = _make_midi(pitchwheel=[(0, 4096)])
158 dims = extract_dimensions(midi)
159 assert len(dims.slices["pitch_bend"].events) > 0
160
161 def test_channel_pressure_extracted(self) -> None:
162 midi = _make_midi(channel_pressure=[(0, 64)])
163 dims = extract_dimensions(midi)
164 assert len(dims.slices["channel_pressure"].events) > 0
165
166 def test_poly_pressure_extracted(self) -> None:
167 midi = _make_midi(poly_aftertouch=[(0, 60, 64)])
168 dims = extract_dimensions(midi)
169 assert len(dims.slices["poly_pressure"].events) > 0
170
171 def test_program_change_extracted(self) -> None:
172 midi = _make_midi(program_change=[(0, 25)])
173 dims = extract_dimensions(midi)
174 assert len(dims.slices["program_change"].events) > 0
175
176 def test_marker_extracted(self) -> None:
177 midi = _make_midi(marker="Chorus")
178 dims = extract_dimensions(midi)
179 assert len(dims.slices["markers"].events) > 0
180
181 def test_track_name_extracted(self) -> None:
182 midi = _make_midi(track_name="Piano")
183 dims = extract_dimensions(midi)
184 assert len(dims.slices["track_structure"].events) > 0
185
186 @pytest.mark.parametrize("cc_num,expected_dim", [
187 (1, "cc_modulation"),
188 (7, "cc_volume"),
189 (10, "cc_pan"),
190 (11, "cc_expression"),
191 (64, "cc_sustain"),
192 (65, "cc_portamento"),
193 (66, "cc_sostenuto"),
194 (67, "cc_soft_pedal"),
195 (91, "cc_reverb"),
196 (93, "cc_chorus"),
197 (20, "cc_other"), # CC 20 is "other"
198 (100, "cc_other"), # CC 100 is "other"
199 ])
200 def test_cc_event_classified_to_correct_dimension(self, cc_num: int, expected_dim: str) -> None:
201 midi = _make_midi(control_change=[(0, cc_num, 64)])
202 dims = extract_dimensions(midi)
203 assert len(dims.slices[expected_dim].events) > 0
204
205 def test_empty_midi_all_dims_present_with_empty_events(self) -> None:
206 midi = _empty_midi()
207 dims = extract_dimensions(midi)
208 for d in INTERNAL_DIMS:
209 assert d in dims.slices
210
211
212 # ---------------------------------------------------------------------------
213 # dimension_conflict_detail correctness
214 # ---------------------------------------------------------------------------
215
216
217 class TestDimensionConflictDetail:
218 def test_all_unchanged_when_identical(self) -> None:
219 midi = _make_midi(notes=[(0, 60, 80)])
220 dims = extract_dimensions(midi)
221 detail = dimension_conflict_detail(dims, dims, dims)
222 for d in INTERNAL_DIMS:
223 assert detail[d] == "unchanged", f"Expected unchanged for {d}, got {detail[d]}"
224
225 def test_left_only_change_detected(self) -> None:
226 base_midi = _empty_midi()
227 left_midi = _make_midi(notes=[(0, 60, 80)])
228 base_dims = extract_dimensions(base_midi)
229 left_dims = extract_dimensions(left_midi)
230 detail = dimension_conflict_detail(base_dims, left_dims, base_dims)
231 assert detail["notes"] == "left_only"
232
233 def test_right_only_change_detected(self) -> None:
234 base_midi = _empty_midi()
235 right_midi = _make_midi(pitchwheel=[(0, 1000)])
236 base_dims = extract_dimensions(base_midi)
237 right_dims = extract_dimensions(right_midi)
238 detail = dimension_conflict_detail(base_dims, base_dims, right_dims)
239 assert detail["pitch_bend"] == "right_only"
240
241 def test_bilateral_conflict_detected(self) -> None:
242 base_midi = _empty_midi()
243 left_midi = _make_midi(notes=[(0, 60, 80)])
244 right_midi = _make_midi(notes=[(0, 64, 80)])
245 base_dims = extract_dimensions(base_midi)
246 left_dims = extract_dimensions(left_midi)
247 right_dims = extract_dimensions(right_midi)
248 detail = dimension_conflict_detail(base_dims, left_dims, right_dims)
249 assert detail["notes"] == "both"
250
251 def test_independent_cc_dims_dont_cross_contaminate(self) -> None:
252 """Changing CC1 on left and CC7 on right → each in its own dimension."""
253 base = _empty_midi()
254 left = _make_midi(control_change=[(0, 1, 100)]) # modulation
255 right = _make_midi(control_change=[(0, 7, 80)]) # volume
256 b = extract_dimensions(base)
257 l = extract_dimensions(left)
258 r = extract_dimensions(right)
259 detail = dimension_conflict_detail(b, l, r)
260 assert detail["cc_modulation"] == "left_only"
261 assert detail["cc_volume"] == "right_only"
262 # Notes should be unchanged.
263 assert detail["notes"] == "unchanged"
264
265
266 # ---------------------------------------------------------------------------
267 # merge_midi_dimensions — clean auto-merge per dimension
268 # ---------------------------------------------------------------------------
269
270
271 class TestCleanMergePerDimension:
272 def test_notes_left_only_auto_merges(self) -> None:
273 base = _empty_midi()
274 left = _make_midi(notes=[(0, 60, 80)])
275 result = merge_midi_dimensions(base, left, base, _NO_ATTRS, "test.mid")
276 assert result is not None
277 merged_bytes, report = result
278 assert report.get("notes") in ("left", "left_only", "base", None) or "notes" in report
279
280 def test_pitchwheel_right_only_auto_merges(self) -> None:
281 base = _empty_midi()
282 right = _make_midi(pitchwheel=[(0, 2000)])
283 result = merge_midi_dimensions(base, base, right, _NO_ATTRS, "test.mid")
284 assert result is not None
285
286 def test_cc_modulation_independent_of_cc_volume(self) -> None:
287 """Left edits CC1 (modulation), right edits CC7 (volume) — must auto-merge."""
288 base = _empty_midi()
289 left = _make_midi(control_change=[(0, 1, 100)])
290 right = _make_midi(control_change=[(0, 7, 80)])
291 result = merge_midi_dimensions(base, left, right, _NO_ATTRS, "test.mid")
292 assert result is not None
293
294 @pytest.mark.parametrize("cc_left,cc_right", [
295 (1, 7), (1, 10), (1, 11), (1, 64), (1, 91),
296 (7, 10), (7, 64), (10, 91), (64, 93),
297 ])
298 def test_independent_cc_pairs_auto_merge(self, cc_left: int, cc_right: int) -> None:
299 """Every pair of distinct named CCs can be changed independently."""
300 base = _empty_midi()
301 left = _make_midi(control_change=[(0, cc_left, 64)])
302 right = _make_midi(control_change=[(0, cc_right, 64)])
303 result = merge_midi_dimensions(base, left, right, _NO_ATTRS, "test.mid")
304 assert result is not None, f"CC{cc_left} vs CC{cc_right} should auto-merge"
305
306 def test_notes_and_pitchwheel_independently_auto_merge(self) -> None:
307 """Left adds notes; right adds pitchwheel — must auto-merge."""
308 base = _empty_midi()
309 left = _make_midi(notes=[(0, 60, 80)])
310 right = _make_midi(pitchwheel=[(0, 500)])
311 result = merge_midi_dimensions(base, left, right, _NO_ATTRS, "test.mid")
312 assert result is not None
313
314 def test_program_change_independent_of_notes(self) -> None:
315 base = _empty_midi()
316 left = _make_midi(notes=[(0, 60, 80)])
317 right = _make_midi(program_change=[(0, 25)])
318 result = merge_midi_dimensions(base, left, right, _NO_ATTRS, "test.mid")
319 assert result is not None
320
321
322 # ---------------------------------------------------------------------------
323 # merge_midi_dimensions — conflict resolution with strategy
324 # ---------------------------------------------------------------------------
325
326
327 class TestConflictResolutionStrategies:
328 def test_bilateral_notes_conflict_with_ours_strategy(self) -> None:
329 base = _empty_midi()
330 left = _make_midi(notes=[(0, 60, 80)])
331 right = _make_midi(notes=[(0, 64, 80)])
332 result = merge_midi_dimensions(base, left, right, _ours_rule("test.mid"), "test.mid")
333 # With "ours" strategy, conflict is resolved in favour of left.
334 assert result is not None
335
336 def test_bilateral_notes_conflict_no_strategy_returns_none(self) -> None:
337 base = _empty_midi()
338 left = _make_midi(notes=[(0, 60, 80)])
339 right = _make_midi(notes=[(0, 64, 80)])
340 result = merge_midi_dimensions(base, left, right, _NO_ATTRS, "test.mid")
341 assert result is None
342
343 def test_non_independent_tempo_conflict_blocks_merge(self) -> None:
344 """tempo_map bilateral conflict → entire merge blocked."""
345 base = _empty_midi()
346 left = _make_midi(set_tempo=400_000)
347 right = _make_midi(set_tempo=600_000)
348 result = merge_midi_dimensions(base, left, right, _NO_ATTRS, "test.mid")
349 assert result is None
350
351
352 # ---------------------------------------------------------------------------
353 # Large sequence stress tests
354 # ---------------------------------------------------------------------------
355
356
357 class TestLargeSequenceStress:
358 def test_100_notes_extract_dimension(self) -> None:
359 notes = [(i * 480, (60 + i % 12), 80) for i in range(100)]
360 midi = _make_midi(notes=notes)
361 dims = extract_dimensions(midi)
362 # Each note generates a note_on and note_off → ≥200 events.
363 assert len(dims.slices["notes"].events) >= 200
364
365 def test_many_cc_events_all_classified(self) -> None:
366 """50 CC events across multiple controllers all classified correctly."""
367 ccs = [(i * 10, cc, i % 127) for i, cc in enumerate([1, 7, 10, 11, 64] * 10)]
368 midi = _make_midi(control_change=ccs)
369 dims = extract_dimensions(midi)
370 total = sum(len(dims.slices[d].events) for d in INTERNAL_DIMS)
371 assert total >= len(ccs)
372
373 def test_all_21_dimensions_touched_and_auto_merge(self) -> None:
374 """Base empty; left touches notes, right touches pitch_bend — 21 dims all present."""
375 base = _empty_midi()
376 left = _make_midi(notes=[(0, 60, 80)])
377 right = _make_midi(pitchwheel=[(0, 1000)])
378 result = merge_midi_dimensions(base, left, right, _NO_ATTRS, "stress.mid")
379 assert result is not None
380 merged_bytes, report = result
381 assert len(merged_bytes) > 0
382
383 def test_hash_stability_empty_dimension(self) -> None:
384 """Hash of an empty dimension must be stable across calls."""
385 midi = _empty_midi()
386 d1 = extract_dimensions(midi)
387 d2 = extract_dimensions(midi)
388 for dim in INTERNAL_DIMS:
389 assert d1.slices[dim].content_hash == d2.slices[dim].content_hash
390
391 def test_merged_output_is_valid_midi(self) -> None:
392 """The bytes returned by merge_midi_dimensions must parse as valid MIDI."""
393 base = _empty_midi()
394 left = _make_midi(notes=[(0, 60, 80)])
395 right = _make_midi(pitchwheel=[(0, 500)])
396 result = merge_midi_dimensions(base, left, right, _NO_ATTRS, "test.mid")
397 assert result is not None
398 merged_bytes, _ = result
399 # Should not raise.
400 parsed = mido.MidiFile(file=io.BytesIO(merged_bytes))
401 assert parsed.ticks_per_beat > 0
402
403
404 # ---------------------------------------------------------------------------
405 # dimension_conflict_detail — all 21 dimensions
406 # ---------------------------------------------------------------------------
407
408
409 class TestAllDimensionConflictDetail:
410 """Verify every dimension can independently report unchanged/left_only/right_only/bilateral."""
411
412 @pytest.mark.parametrize("dim,left_kwargs,right_kwargs", [
413 ("notes",
414 {"notes": [(0, 60, 80)]},
415 {"notes": [(0, 64, 80)]}),
416 ("pitch_bend",
417 {"pitchwheel": [(0, 1000)]},
418 {"pitchwheel": [(0, -1000)]}),
419 ("channel_pressure",
420 {"channel_pressure": [(0, 80)]},
421 {"channel_pressure": [(0, 40)]}),
422 ("poly_pressure",
423 {"poly_aftertouch": [(0, 60, 80)]},
424 {"poly_aftertouch": [(0, 60, 40)]}),
425 ("cc_modulation",
426 {"control_change": [(0, 1, 100)]},
427 {"control_change": [(0, 1, 50)]}),
428 ("cc_volume",
429 {"control_change": [(0, 7, 100)]},
430 {"control_change": [(0, 7, 50)]}),
431 ("cc_pan",
432 {"control_change": [(0, 10, 64)]},
433 {"control_change": [(0, 10, 32)]}),
434 ("cc_expression",
435 {"control_change": [(0, 11, 100)]},
436 {"control_change": [(0, 11, 50)]}),
437 ("cc_sustain",
438 {"control_change": [(0, 64, 127)]},
439 {"control_change": [(0, 64, 0)]}),
440 ("cc_portamento",
441 {"control_change": [(0, 65, 127)]},
442 {"control_change": [(0, 65, 0)]}),
443 ("cc_sostenuto",
444 {"control_change": [(0, 66, 127)]},
445 {"control_change": [(0, 66, 0)]}),
446 ("cc_soft_pedal",
447 {"control_change": [(0, 67, 127)]},
448 {"control_change": [(0, 67, 0)]}),
449 ("cc_reverb",
450 {"control_change": [(0, 91, 80)]},
451 {"control_change": [(0, 91, 40)]}),
452 ("cc_chorus",
453 {"control_change": [(0, 93, 80)]},
454 {"control_change": [(0, 93, 40)]}),
455 ("program_change",
456 {"program_change": [(0, 10)]},
457 {"program_change": [(0, 20)]}),
458 ])
459 def test_bilateral_conflict_per_dimension(
460 self, dim: str, left_kwargs: MidiKwargs, right_kwargs: MidiKwargs
461 ) -> None:
462 base = _empty_midi()
463 left = _make_midi(**left_kwargs)
464 right = _make_midi(**right_kwargs)
465 b = extract_dimensions(base)
466 l = extract_dimensions(left)
467 r = extract_dimensions(right)
468 detail = dimension_conflict_detail(b, l, r)
469 assert detail[dim] == "both", (
470 f"Expected bilateral_conflict for {dim}, got {detail[dim]}"
471 )