cgcardona / muse public
test_music_midi_merge.py python
589 lines 23.9 KB
9ee9c39c refactor: rename music→midi domain, strip all 5-dim backward compat Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """Tests for muse/plugins/midi/midi_merge.py — 21-dimension MIDI merge."""
2 from __future__ import annotations
3
4 import io
5
6 import mido
7 import pytest
8
9 from muse.core.attributes import AttributeRule
10 from muse.plugins.midi.midi_merge import (
11 INTERNAL_DIMS,
12 DIM_ALIAS,
13 DimensionSlice,
14 MidiDimensions,
15 NON_INDEPENDENT_DIMS,
16 _classify_event,
17 _hash_events,
18 dimension_conflict_detail,
19 extract_dimensions,
20 merge_midi_dimensions,
21 )
22
23
24 # ---------------------------------------------------------------------------
25 # MIDI builder helpers
26 # ---------------------------------------------------------------------------
27
28
29 def _make_midi(
30 *,
31 notes: list[tuple[int, int, int]] | None = None,
32 pitchwheel: list[tuple[int, int]] | None = None,
33 control_change: list[tuple[int, int, int]] | None = None,
34 channel_pressure: list[tuple[int, int]] | None = None,
35 poly_aftertouch: list[tuple[int, int, int]] | None = None,
36 program_change: list[tuple[int, int]] | None = None,
37 tempo: int = 500_000,
38 ticks_per_beat: int = 480,
39 ) -> bytes:
40 """Build a minimal type-0 MIDI file in memory.
41
42 Args:
43 notes: List of (abs_tick, note, velocity) note-on events.
44 pitchwheel: List of (abs_tick, pitch) pitchwheel events.
45 control_change: List of (abs_tick, control, value) CC events.
46 channel_pressure: List of (abs_tick, pressure) channel pressure events.
47 poly_aftertouch: List of (abs_tick, note, pressure) poly-pressure events.
48 program_change: List of (abs_tick, program) program-change events.
49 tempo: Microseconds per beat (default 120 BPM).
50 ticks_per_beat: MIDI resolution.
51 """
52 mid = mido.MidiFile(type=0, ticks_per_beat=ticks_per_beat)
53 track = mido.MidiTrack()
54
55 events: list[tuple[int, mido.Message]] = []
56 events.append((0, mido.MetaMessage("set_tempo", tempo=tempo, time=0)))
57
58 for abs_tick, note, vel in notes or []:
59 events.append((abs_tick, mido.Message("note_on", note=note, velocity=vel, time=0)))
60 events.append((abs_tick + 120, mido.Message("note_off", note=note, velocity=0, time=0)))
61
62 for abs_tick, pitch in pitchwheel or []:
63 events.append((abs_tick, mido.Message("pitchwheel", pitch=pitch, time=0)))
64
65 for abs_tick, ctrl, val in control_change or []:
66 events.append((abs_tick, mido.Message("control_change", control=ctrl, value=val, time=0)))
67
68 for abs_tick, pressure in channel_pressure or []:
69 events.append((abs_tick, mido.Message("aftertouch", value=pressure, time=0)))
70
71 for abs_tick, note, pressure in poly_aftertouch or []:
72 events.append((abs_tick, mido.Message("polytouch", note=note, value=pressure, time=0)))
73
74 for abs_tick, program in program_change or []:
75 events.append((abs_tick, mido.Message("program_change", program=program, time=0)))
76
77 events.sort(key=lambda x: (x[0], x[1].type))
78 prev = 0
79 for abs_tick, msg in events:
80 delta = abs_tick - prev
81 track.append(msg.copy(time=delta))
82 prev = abs_tick
83
84 track.append(mido.MetaMessage("end_of_track", time=0))
85 mid.tracks.append(track)
86
87 buf = io.BytesIO()
88 mid.save(file=buf)
89 return buf.getvalue()
90
91
92 def _midi_bytes_to_notes(midi_bytes: bytes) -> set[int]:
93 mid = mido.MidiFile(file=io.BytesIO(midi_bytes))
94 notes: set[int] = set()
95 for track in mid.tracks:
96 for msg in track:
97 if msg.type == "note_on" and msg.velocity > 0:
98 notes.add(msg.note)
99 return notes
100
101
102 def _midi_bytes_to_pitchwheels(midi_bytes: bytes) -> list[int]:
103 mid = mido.MidiFile(file=io.BytesIO(midi_bytes))
104 values: list[int] = []
105 for track in mid.tracks:
106 for msg in track:
107 if msg.type == "pitchwheel":
108 values.append(msg.pitch)
109 return values
110
111
112 def _midi_bytes_to_ccs(midi_bytes: bytes) -> list[tuple[int, int]]:
113 mid = mido.MidiFile(file=io.BytesIO(midi_bytes))
114 ccs: list[tuple[int, int]] = []
115 for track in mid.tracks:
116 for msg in track:
117 if msg.type == "control_change":
118 ccs.append((msg.control, msg.value))
119 return ccs
120
121
122 # ---------------------------------------------------------------------------
123 # INTERNAL_DIMS — verify all 21 dimensions declared
124 # ---------------------------------------------------------------------------
125
126
127 class TestInternalDims:
128 _EXPECTED_21 = [
129 "notes", "pitch_bend", "channel_pressure", "poly_pressure",
130 "cc_modulation", "cc_volume", "cc_pan", "cc_expression",
131 "cc_sustain", "cc_portamento", "cc_sostenuto", "cc_soft_pedal",
132 "cc_reverb", "cc_chorus", "cc_other",
133 "program_change", "tempo_map", "time_signatures",
134 "key_signatures", "markers", "track_structure",
135 ]
136
137 def test_exactly_21_dims(self) -> None:
138 assert len(INTERNAL_DIMS) == 21
139
140 def test_all_expected_names_present(self) -> None:
141 assert set(INTERNAL_DIMS) == set(self._EXPECTED_21)
142
143 def test_non_independent_dims(self) -> None:
144 assert NON_INDEPENDENT_DIMS == frozenset({"tempo_map", "time_signatures", "track_structure"})
145
146 def test_no_old_coarse_names_in_dims(self) -> None:
147 """Old coarse names (melodic, rhythmic, harmonic, dynamic, structural) must be gone."""
148 old_names = {"melodic", "rhythmic", "harmonic", "dynamic", "structural"}
149 assert old_names.isdisjoint(set(INTERNAL_DIMS))
150
151 def test_no_old_coarse_aliases_in_dim_alias(self) -> None:
152 """Old aliases removed from DIM_ALIAS — no backward-compat shims."""
153 old_aliases = {"melodic", "rhythmic", "harmonic", "dynamic", "structural"}
154 assert old_aliases.isdisjoint(set(DIM_ALIAS))
155
156
157 # ---------------------------------------------------------------------------
158 # _classify_event — fine-grained 21-dimension routing
159 # ---------------------------------------------------------------------------
160
161
162 class TestClassifyEvent:
163 # Note events → notes
164 def test_note_on(self) -> None:
165 assert _classify_event(mido.Message("note_on", note=60)) == "notes"
166
167 def test_note_off(self) -> None:
168 assert _classify_event(mido.Message("note_off", note=60)) == "notes"
169
170 # Pitch bend → pitch_bend
171 def test_pitchwheel(self) -> None:
172 assert _classify_event(mido.Message("pitchwheel", pitch=100)) == "pitch_bend"
173
174 # Channel pressure → channel_pressure
175 def test_channel_aftertouch(self) -> None:
176 assert _classify_event(mido.Message("aftertouch", value=64)) == "channel_pressure"
177
178 # Polyphonic aftertouch → poly_pressure
179 def test_poly_aftertouch(self) -> None:
180 assert _classify_event(mido.Message("polytouch", note=60, value=64)) == "poly_pressure"
181
182 # Named CC controllers
183 def test_cc_1_modulation(self) -> None:
184 assert _classify_event(mido.Message("control_change", control=1, value=64)) == "cc_modulation"
185
186 def test_cc_7_volume(self) -> None:
187 assert _classify_event(mido.Message("control_change", control=7, value=100)) == "cc_volume"
188
189 def test_cc_10_pan(self) -> None:
190 assert _classify_event(mido.Message("control_change", control=10, value=64)) == "cc_pan"
191
192 def test_cc_11_expression(self) -> None:
193 assert _classify_event(mido.Message("control_change", control=11, value=100)) == "cc_expression"
194
195 def test_cc_64_sustain(self) -> None:
196 assert _classify_event(mido.Message("control_change", control=64, value=127)) == "cc_sustain"
197
198 def test_cc_65_portamento(self) -> None:
199 assert _classify_event(mido.Message("control_change", control=65, value=0)) == "cc_portamento"
200
201 def test_cc_66_sostenuto(self) -> None:
202 assert _classify_event(mido.Message("control_change", control=66, value=127)) == "cc_sostenuto"
203
204 def test_cc_67_soft_pedal(self) -> None:
205 assert _classify_event(mido.Message("control_change", control=67, value=64)) == "cc_soft_pedal"
206
207 def test_cc_91_reverb(self) -> None:
208 assert _classify_event(mido.Message("control_change", control=91, value=40)) == "cc_reverb"
209
210 def test_cc_93_chorus(self) -> None:
211 assert _classify_event(mido.Message("control_change", control=93, value=20)) == "cc_chorus"
212
213 def test_cc_other_unlisted(self) -> None:
214 # CC 2 is not individually named → cc_other
215 assert _classify_event(mido.Message("control_change", control=2, value=50)) == "cc_other"
216
217 def test_cc_3_other(self) -> None:
218 assert _classify_event(mido.Message("control_change", control=3, value=50)) == "cc_other"
219
220 # Program change
221 def test_program_change(self) -> None:
222 assert _classify_event(mido.Message("program_change", program=40)) == "program_change"
223
224 # Tempo / time-sig → non-independent
225 def test_set_tempo(self) -> None:
226 assert _classify_event(mido.MetaMessage("set_tempo", tempo=500_000)) == "tempo_map"
227
228 def test_time_signature(self) -> None:
229 msg = mido.MetaMessage(
230 "time_signature", numerator=4, denominator=4,
231 clocks_per_click=24, notated_32nd_notes_per_beat=8,
232 )
233 assert _classify_event(msg) == "time_signatures"
234
235 # Key signature
236 def test_key_signature(self) -> None:
237 assert _classify_event(mido.MetaMessage("key_signature", key="C")) == "key_signatures"
238
239 # Markers
240 def test_marker(self) -> None:
241 assert _classify_event(mido.MetaMessage("marker", text="verse")) == "markers"
242
243 def test_text(self) -> None:
244 assert _classify_event(mido.MetaMessage("text", text="hello")) == "markers"
245
246 # Track structure
247 def test_track_name(self) -> None:
248 assert _classify_event(mido.MetaMessage("track_name", name="Piano")) == "track_structure"
249
250 def test_end_of_track_returns_none(self) -> None:
251 # end_of_track is reconstructed during MIDI assembly, not stored in any dim
252 assert _classify_event(mido.MetaMessage("end_of_track")) is None
253
254
255 # ---------------------------------------------------------------------------
256 # extract_dimensions
257 # ---------------------------------------------------------------------------
258
259
260 class TestExtractDimensions:
261 def test_empty_midi_has_all_21_dims(self) -> None:
262 midi = _make_midi()
263 dims = extract_dimensions(midi)
264 assert set(dims.slices.keys()) == set(INTERNAL_DIMS)
265
266 def test_notes_in_notes_bucket(self) -> None:
267 midi = _make_midi(notes=[(0, 60, 80), (480, 64, 80)])
268 dims = extract_dimensions(midi)
269 note_on = [msg for _, msg in dims.slices["notes"].events if msg.type == "note_on"]
270 assert len(note_on) == 2
271
272 def test_pitchwheel_in_pitch_bend(self) -> None:
273 midi = _make_midi(pitchwheel=[(100, 500), (200, -500)])
274 dims = extract_dimensions(midi)
275 assert len(dims.slices["pitch_bend"].events) == 2
276
277 def test_cc_volume_bucket(self) -> None:
278 midi = _make_midi(control_change=[(0, 7, 100)])
279 dims = extract_dimensions(midi)
280 assert len(dims.slices["cc_volume"].events) == 1
281
282 def test_cc_sustain_bucket(self) -> None:
283 midi = _make_midi(control_change=[(0, 64, 127)])
284 dims = extract_dimensions(midi)
285 assert len(dims.slices["cc_sustain"].events) == 1
286
287 def test_cc_modulation_bucket(self) -> None:
288 midi = _make_midi(control_change=[(0, 1, 90)])
289 dims = extract_dimensions(midi)
290 assert len(dims.slices["cc_modulation"].events) == 1
291
292 def test_cc_other_bucket(self) -> None:
293 midi = _make_midi(control_change=[(0, 2, 50)])
294 dims = extract_dimensions(midi)
295 assert len(dims.slices["cc_other"].events) == 1
296
297 def test_tempo_in_tempo_map(self) -> None:
298 midi = _make_midi(tempo=600_000)
299 dims = extract_dimensions(midi)
300 types = {msg.type for _, msg in dims.slices["tempo_map"].events}
301 assert "set_tempo" in types
302
303 def test_content_hash_is_deterministic(self) -> None:
304 midi = _make_midi(notes=[(0, 60, 80)])
305 d1 = extract_dimensions(midi)
306 d2 = extract_dimensions(midi)
307 assert d1.slices["notes"].content_hash == d2.slices["notes"].content_hash
308
309 def test_different_notes_give_different_hash(self) -> None:
310 da = extract_dimensions(_make_midi(notes=[(0, 60, 80)]))
311 db = extract_dimensions(_make_midi(notes=[(0, 62, 80)]))
312 assert da.slices["notes"].content_hash != db.slices["notes"].content_hash
313
314 def test_different_dimensions_independent_hashes(self) -> None:
315 """Changing notes must not affect pitch_bend hash."""
316 base = _make_midi(pitchwheel=[(0, 200)])
317 with_notes = _make_midi(notes=[(0, 60, 80)], pitchwheel=[(0, 200)])
318 da = extract_dimensions(base)
319 db = extract_dimensions(with_notes)
320 assert da.slices["pitch_bend"].content_hash == db.slices["pitch_bend"].content_hash
321 assert da.slices["notes"].content_hash != db.slices["notes"].content_hash
322
323 def test_ticks_per_beat_preserved(self) -> None:
324 midi = _make_midi(ticks_per_beat=960)
325 assert extract_dimensions(midi).ticks_per_beat == 960
326
327 def test_invalid_bytes_raises(self) -> None:
328 with pytest.raises(ValueError, match="Failed to parse"):
329 extract_dimensions(b"not a midi file")
330
331 def test_get_by_fine_alias(self) -> None:
332 midi = _make_midi(pitchwheel=[(0, 100)])
333 dims = extract_dimensions(midi)
334 assert dims.get("pitch_bend").name == "pitch_bend"
335 assert dims.get("sustain").name == "cc_sustain"
336 assert dims.get("volume").name == "cc_volume"
337
338 def test_get_unknown_alias_raises(self) -> None:
339 midi = _make_midi()
340 dims = extract_dimensions(midi)
341 with pytest.raises(KeyError):
342 dims.get("melodic") # old alias — removed
343
344
345 # ---------------------------------------------------------------------------
346 # dimension_conflict_detail
347 # ---------------------------------------------------------------------------
348
349
350 class TestDimensionConflictDetail:
351 def _dims_from(
352 self,
353 notes: list[tuple[int, int, int]] | None = None,
354 pitchwheel: list[tuple[int, int]] | None = None,
355 control_change: list[tuple[int, int, int]] | None = None,
356 tempo: int = 500_000,
357 ) -> MidiDimensions:
358 return extract_dimensions(_make_midi(
359 notes=notes, pitchwheel=pitchwheel,
360 control_change=control_change, tempo=tempo,
361 ))
362
363 def test_unchanged_when_all_same(self) -> None:
364 base = self._dims_from(notes=[(0, 60, 80)])
365 detail = dimension_conflict_detail(base, base, base)
366 assert all(v == "unchanged" for v in detail.values())
367
368 def test_notes_left_only(self) -> None:
369 base = self._dims_from()
370 left = self._dims_from(notes=[(0, 60, 80)])
371 detail = dimension_conflict_detail(base, left, base)
372 assert detail["notes"] == "left_only"
373 assert detail["pitch_bend"] == "unchanged"
374
375 def test_pitch_bend_right_only(self) -> None:
376 base = self._dims_from()
377 right = self._dims_from(pitchwheel=[(0, 100)])
378 detail = dimension_conflict_detail(base, base, right)
379 assert detail["pitch_bend"] == "right_only"
380
381 def test_both_sides_change_notes(self) -> None:
382 base = self._dims_from()
383 left = self._dims_from(notes=[(0, 60, 80)])
384 right = self._dims_from(notes=[(0, 64, 80)])
385 detail = dimension_conflict_detail(base, left, right)
386 assert detail["notes"] == "both"
387
388 def test_independent_changes_in_separate_dims(self) -> None:
389 base = self._dims_from()
390 left = self._dims_from(notes=[(0, 60, 80)])
391 right = self._dims_from(pitchwheel=[(0, 200)])
392 detail = dimension_conflict_detail(base, left, right)
393 assert detail["notes"] == "left_only"
394 assert detail["pitch_bend"] == "right_only"
395 assert detail["cc_volume"] == "unchanged"
396
397 def test_cc_volume_vs_cc_sustain_independent(self) -> None:
398 """Two different CC dims changed independently."""
399 base = self._dims_from()
400 left = self._dims_from(control_change=[(0, 7, 100)]) # cc_volume
401 right = self._dims_from(control_change=[(0, 64, 127)]) # cc_sustain
402 detail = dimension_conflict_detail(base, left, right)
403 assert detail["cc_volume"] == "left_only"
404 assert detail["cc_sustain"] == "right_only"
405
406
407 # ---------------------------------------------------------------------------
408 # merge_midi_dimensions
409 # ---------------------------------------------------------------------------
410
411
412 class TestMergeMidiDimensions:
413 def _midi(
414 self,
415 notes: list[tuple[int, int, int]] | None = None,
416 pitchwheel: list[tuple[int, int]] | None = None,
417 control_change: list[tuple[int, int, int]] | None = None,
418 tempo: int = 500_000,
419 ticks_per_beat: int = 480,
420 ) -> bytes:
421 return _make_midi(
422 notes=notes, pitchwheel=pitchwheel, control_change=control_change,
423 tempo=tempo, ticks_per_beat=ticks_per_beat,
424 )
425
426 def _rules(self, *rules: tuple[str, str, str]) -> list[AttributeRule]:
427 return [AttributeRule(p, d, s, i + 1) for i, (p, d, s) in enumerate(rules)]
428
429 # --- Clean auto-merge: independent dimensions ---------------------------
430
431 def test_independent_notes_and_pitch_bend(self) -> None:
432 """Left changed notes, right changed pitch_bend → clean auto-merge."""
433 base = self._midi()
434 left = self._midi(notes=[(0, 60, 80)])
435 right = self._midi(pitchwheel=[(0, 500)])
436 result = merge_midi_dimensions(base, left, right, [], "song.mid")
437 assert result is not None
438 merged, _ = result
439 assert _midi_bytes_to_notes(merged) == {60}
440 assert _midi_bytes_to_pitchwheels(merged) == [500]
441
442 def test_independent_two_cc_dims(self) -> None:
443 """Left changed cc_volume, right changed cc_sustain → clean auto-merge."""
444 base = self._midi()
445 left = self._midi(control_change=[(0, 7, 100)]) # cc_volume
446 right = self._midi(control_change=[(0, 64, 127)]) # cc_sustain
447 result = merge_midi_dimensions(base, left, right, [], "song.mid")
448 assert result is not None
449 merged, _ = result
450 ccs = dict(_midi_bytes_to_ccs(merged))
451 assert ccs.get(7) == 100
452 assert ccs.get(64) == 127
453
454 def test_one_side_only_changed_notes(self) -> None:
455 base = self._midi()
456 left = self._midi(notes=[(0, 64, 80)])
457 result = merge_midi_dimensions(base, left, self._midi(), [], "song.mid")
458 assert result is not None
459 merged, _ = result
460 assert _midi_bytes_to_notes(merged) == {64}
461
462 def test_unchanged_both_sides_preserved(self) -> None:
463 base = self._midi(notes=[(0, 60, 80)])
464 result = merge_midi_dimensions(base, base, base, [], "song.mid")
465 assert result is not None
466 merged, _ = result
467 assert _midi_bytes_to_notes(merged) == {60}
468
469 # --- Strategy override via AttributeRule --------------------------------
470
471 def test_notes_conflict_resolved_by_ours_rule(self) -> None:
472 base = self._midi()
473 left = self._midi(notes=[(0, 60, 80)])
474 right = self._midi(notes=[(0, 64, 80)])
475 rules = self._rules(("*", "notes", "ours"))
476 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
477 assert result is not None
478 merged, report = result
479 assert _midi_bytes_to_notes(merged) == {60}
480 assert "ours" in report["notes"]
481
482 def test_notes_conflict_resolved_by_theirs_rule(self) -> None:
483 base = self._midi()
484 left = self._midi(notes=[(0, 60, 80)])
485 right = self._midi(notes=[(0, 64, 80)])
486 rules = self._rules(("*", "notes", "theirs"))
487 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
488 assert result is not None
489 merged, _ = result
490 assert _midi_bytes_to_notes(merged) == {64}
491
492 def test_pitch_bend_conflict_resolved_by_theirs(self) -> None:
493 base = self._midi()
494 left = self._midi(pitchwheel=[(0, 200)])
495 right = self._midi(pitchwheel=[(0, -200)])
496 rules = self._rules(("*", "pitch_bend", "theirs"))
497 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
498 assert result is not None
499 merged, _ = result
500 assert _midi_bytes_to_pitchwheels(merged) == [-200]
501
502 def test_wildcard_dim_rule_resolves_all(self) -> None:
503 base = self._midi()
504 left = self._midi(notes=[(0, 60, 80)], pitchwheel=[(0, 200)])
505 right = self._midi(notes=[(0, 64, 80)], pitchwheel=[(0, -200)])
506 rules = self._rules(("*", "*", "ours"))
507 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
508 assert result is not None
509 merged, _ = result
510 assert _midi_bytes_to_notes(merged) == {60}
511 assert 200 in _midi_bytes_to_pitchwheels(merged)
512
513 def test_notes_conflict_no_rule_returns_none(self) -> None:
514 """Both sides changed notes, no matching rule → conflict → None."""
515 base = self._midi()
516 left = self._midi(notes=[(0, 60, 80)])
517 right = self._midi(notes=[(0, 64, 80)])
518 assert merge_midi_dimensions(base, left, right, [], "song.mid") is None
519
520 def test_manual_strategy_returns_none(self) -> None:
521 base = self._midi()
522 left = self._midi(notes=[(0, 60, 80)])
523 right = self._midi(notes=[(0, 64, 80)])
524 rules = self._rules(("*", "notes", "manual"))
525 assert merge_midi_dimensions(base, left, right, rules, "song.mid") is None
526
527 # --- Report content -----------------------------------------------------
528
529 def test_report_shows_left_right_labels(self) -> None:
530 base = self._midi()
531 left = self._midi(notes=[(0, 60, 80)])
532 right = self._midi(pitchwheel=[(0, 100)])
533 result = merge_midi_dimensions(base, left, right, [], "song.mid")
534 assert result is not None
535 _, report = result
536 assert report["notes"] == "left"
537 assert report["pitch_bend"] == "right"
538
539 # --- Output is valid MIDI -----------------------------------------------
540
541 def test_merged_bytes_parseable(self) -> None:
542 base = self._midi()
543 left = self._midi(notes=[(0, 60, 80)])
544 right = self._midi(pitchwheel=[(0, 100)])
545 result = merge_midi_dimensions(base, left, right, [], "song.mid")
546 assert result is not None
547 merged, _ = result
548 parsed = mido.MidiFile(file=io.BytesIO(merged))
549 assert parsed.ticks_per_beat == 480
550
551 def test_merged_bytes_preserve_ticks_per_beat(self) -> None:
552 base = _make_midi(ticks_per_beat=960)
553 left = _make_midi(notes=[(0, 60, 80)], ticks_per_beat=960)
554 right = _make_midi(pitchwheel=[(0, 100)], ticks_per_beat=960)
555 result = merge_midi_dimensions(base, left, right, [], "song.mid")
556 assert result is not None
557 merged, _ = result
558 assert mido.MidiFile(file=io.BytesIO(merged)).ticks_per_beat == 960
559
560 # --- Path-pattern matching in rules ------------------------------------
561
562 def test_path_specific_rule_respected(self) -> None:
563 base = self._midi()
564 left = self._midi(pitchwheel=[(0, 200)])
565 right = self._midi(pitchwheel=[(0, -200)])
566 rules = self._rules(("keys/*", "pitch_bend", "theirs"))
567
568 result_keys = merge_midi_dimensions(base, left, right, rules, "keys/piano.mid")
569 assert result_keys is not None
570 merged_keys, _ = result_keys
571 assert _midi_bytes_to_pitchwheels(merged_keys) == [-200]
572
573 result_other = merge_midi_dimensions(base, left, right, rules, "other/bass.mid")
574 assert result_other is None # rule doesn't match this path
575
576 def test_multi_rule_priority_order(self) -> None:
577 """Lower-priority rule does not override higher-priority one."""
578 base = self._midi()
579 left = self._midi(control_change=[(0, 7, 100)]) # cc_volume
580 right = self._midi(control_change=[(0, 7, 50)])
581 rules = self._rules(
582 ("*", "cc_volume", "ours"), # priority 1
583 ("*", "cc_volume", "theirs"), # priority 2 — should be ignored
584 )
585 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
586 assert result is not None
587 merged, _ = result
588 ccs = dict(_midi_bytes_to_ccs(merged))
589 assert ccs.get(7) == 100 # ours = left = 100