virtual_keyboard / tests /test_engines.py
github-actions[bot]
Deploy to HF Spaces
2e0c2a7
"""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)