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