test_stress_midi_all_dims.py
python
| 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 | ) |