Spaces:
Running
Running
| """Tests for engines.py — MIDI processing engines (no model required).""" | |
| import pytest | |
| from engines import EngineRegistry, ParrotEngine, ReverseParrotEngine | |
| # --------------------------------------------------------------------------- | |
| # EngineRegistry | |
| # --------------------------------------------------------------------------- | |
| class TestEngineRegistry: | |
| def test_list_engines_nonempty(self): | |
| engines = EngineRegistry.list_engines() | |
| assert len(engines) > 0 | |
| def test_known_engines_present(self): | |
| engines = EngineRegistry.list_engines() | |
| assert "parrot" in engines | |
| assert "reverse_parrot" in engines | |
| assert "godzilla_continue" in engines | |
| def test_get_engine_returns_instance(self): | |
| engine = EngineRegistry.get_engine("parrot") | |
| assert hasattr(engine, "process") | |
| def test_get_unknown_engine_raises(self): | |
| with pytest.raises(ValueError, match="Unknown engine"): | |
| EngineRegistry.get_engine("nonexistent_engine") | |
| def test_get_engine_info(self): | |
| info = EngineRegistry.get_engine_info("parrot") | |
| assert info["id"] == "parrot" | |
| assert "name" in info | |
| # --------------------------------------------------------------------------- | |
| # ParrotEngine | |
| # --------------------------------------------------------------------------- | |
| class TestParrotEngine: | |
| def test_returns_same_notes(self, melody_events): | |
| engine = ParrotEngine() | |
| result = engine.process(melody_events) | |
| assert len(result) == len(melody_events) | |
| for orig, out in zip(melody_events, result): | |
| assert out["note"] == orig["note"] | |
| assert out["type"] == orig["type"] | |
| assert out["time"] == orig["time"] | |
| assert out["velocity"] == orig["velocity"] | |
| def test_empty_input(self): | |
| engine = ParrotEngine() | |
| assert engine.process([]) == [] | |
| def test_preserves_channel(self): | |
| events = [ | |
| {"type": "note_on", "note": 60, "velocity": 100, "time": 0.0, "channel": 5}, | |
| {"type": "note_off", "note": 60, "velocity": 0, "time": 0.5, "channel": 5}, | |
| ] | |
| engine = ParrotEngine() | |
| result = engine.process(events) | |
| for r in result: | |
| assert r["channel"] == 5 | |
| def test_default_channel_is_zero(self): | |
| events = [ | |
| {"type": "note_on", "note": 60, "velocity": 100, "time": 0.0}, | |
| ] | |
| engine = ParrotEngine() | |
| result = engine.process(events) | |
| assert result[0]["channel"] == 0 | |
| # --------------------------------------------------------------------------- | |
| # ReverseParrotEngine | |
| # --------------------------------------------------------------------------- | |
| class TestReverseParrotEngine: | |
| def test_reverses_note_sequence(self, melody_events): | |
| engine = ReverseParrotEngine() | |
| result = engine.process(melody_events) | |
| orig_on_notes = [e["note"] for e in melody_events if e["type"] == "note_on"] | |
| result_on_notes = [e["note"] for e in result if e["type"] == "note_on"] | |
| assert result_on_notes == list(reversed(orig_on_notes)) | |
| def test_preserves_timing(self, melody_events): | |
| engine = ReverseParrotEngine() | |
| result = engine.process(melody_events) | |
| orig_on_times = [e["time"] for e in melody_events if e["type"] == "note_on"] | |
| result_on_times = [e["time"] for e in result if e["type"] == "note_on"] | |
| assert result_on_times == orig_on_times | |
| def test_preserves_event_types_order(self, melody_events): | |
| engine = ReverseParrotEngine() | |
| result = engine.process(melody_events) | |
| orig_types = [e["type"] for e in melody_events] | |
| result_types = [e["type"] for e in result] | |
| assert result_types == orig_types | |
| def test_empty_input(self): | |
| engine = ReverseParrotEngine() | |
| assert engine.process([]) == [] | |
| def test_single_note_unchanged(self, single_note_events): | |
| engine = ReverseParrotEngine() | |
| result = engine.process(single_note_events) | |
| assert len(result) == 2 | |
| assert result[0]["note"] == single_note_events[0]["note"] | |
| def test_reversal_is_involution(self, melody_events): | |
| """Reversing twice should give back the original notes.""" | |
| engine = ReverseParrotEngine() | |
| once = engine.process(melody_events) | |
| twice = engine.process(once) | |
| orig_on_notes = [e["note"] for e in melody_events if e["type"] == "note_on"] | |
| twice_on_notes = [e["note"] for e in twice if e["type"] == "note_on"] | |
| assert twice_on_notes == orig_on_notes | |
| def test_note_on_off_pairing_matches(self, melody_events): | |
| """Each note_on must have a matching note_off with the same note value.""" | |
| engine = ReverseParrotEngine() | |
| result = engine.process(melody_events) | |
| pending = {} | |
| for e in result: | |
| if e["type"] == "note_on": | |
| pending.setdefault(e["note"], 0) | |
| pending[e["note"]] += 1 | |
| elif e["type"] == "note_off": | |
| assert e["note"] in pending and pending[e["note"]] > 0, ( | |
| f"note_off for {e['note']} without prior note_on" | |
| ) | |
| pending[e["note"]] -= 1 | |
| if pending[e["note"]] == 0: | |
| del pending[e["note"]] | |
| assert pending == {}, f"Unmatched note_on events: {pending}" | |
| def test_overlapping_notes_correct_pairing(self): | |
| """Overlapping (legato) notes where note_off order differs from note_on order. | |
| This was the original bug: independent reversal of note_on and note_off | |
| lists produced note_off events before their corresponding note_on. | |
| """ | |
| events = [ | |
| {"type": "note_on", "note": 60, "velocity": 80, "time": 0.0, "channel": 0}, | |
| {"type": "note_on", "note": 64, "velocity": 80, "time": 0.2, "channel": 0}, | |
| {"type": "note_off", "note": 64, "velocity": 0, "time": 0.5, "channel": 0}, | |
| {"type": "note_off", "note": 60, "velocity": 0, "time": 0.6, "channel": 0}, | |
| {"type": "note_on", "note": 67, "velocity": 80, "time": 0.8, "channel": 0}, | |
| {"type": "note_off", "note": 67, "velocity": 0, "time": 1.1, "channel": 0}, | |
| ] | |
| engine = ReverseParrotEngine() | |
| result = engine.process(events) | |
| # Note order should be reversed: 60,64,67 -> 67,64,60 | |
| result_on_notes = [e["note"] for e in result if e["type"] == "note_on"] | |
| assert result_on_notes == [67, 64, 60] | |
| # Every note_off must come after its note_on | |
| seen_on = set() | |
| for e in result: | |
| if e["type"] == "note_on": | |
| seen_on.add(e["note"]) | |
| elif e["type"] == "note_off": | |
| assert e["note"] in seen_on, ( | |
| f"note_off for {e['note']} at t={e['time']} before its note_on" | |
| ) | |
| def test_orphaned_note_off_is_dropped(self): | |
| """A note_off with no preceding note_on should be silently dropped.""" | |
| events = [ | |
| {"type": "note_off", "note": 60, "velocity": 0, "time": 0.0, "channel": 0}, | |
| {"type": "note_on", "note": 62, "velocity": 80, "time": 0.1, "channel": 0}, | |
| {"type": "note_off", "note": 62, "velocity": 0, "time": 0.4, "channel": 0}, | |
| ] | |
| engine = ReverseParrotEngine() | |
| result = engine.process(events) | |
| result_on_notes = [e["note"] for e in result if e["type"] == "note_on"] | |
| assert result_on_notes == [62], f"Expected [62], got {result_on_notes}" | |
| assert len(result) == 2, f"Expected 2 events, got {len(result)}" | |
| def test_preserves_note_durations(self, melody_events): | |
| """Each reversed note should keep its original duration.""" | |
| engine = ReverseParrotEngine() | |
| result = engine.process(melody_events) | |
| def get_durations(events): | |
| pairs = {} | |
| for e in events: | |
| if e["type"] == "note_on": | |
| pairs[e["note"]] = e["time"] | |
| elif e["type"] == "note_off" and e["note"] in pairs: | |
| yield e["time"] - pairs.pop(e["note"]) | |
| orig_durations = list(get_durations(melody_events)) | |
| result_durations = list(get_durations(result)) | |
| # Durations should be the same set (reversed order) | |
| assert sorted(result_durations) == sorted(orig_durations) | |