feat(02-01): implement ChoreoEvent and ChoreoFile Pydantic v2 models

- ChoreoEvent: validated zone (schrank/wand/all), animation (8 names), timestamp (>= 0)
- ChoreoFile: save/load .choreo.json roundtrip, events always sorted by timestamp
- add_event() inserts and re-sorts by timestamp (D-02)
- Exports VALID_ZONES and VALID_ANIMATIONS constants
- 17 tests pass: validators, roundtrip, sorting, constants
This commit is contained in:
Claude
2026-04-03 14:22:41 +02:00
parent 09c1ac1321
commit 6b507e9605
2 changed files with 308 additions and 0 deletions

212
tests/test_models.py Normal file
View File

@@ -0,0 +1,212 @@
"""TDD: Tests for ChoreoEvent and ChoreoFile Pydantic v2 models.
Tests cover:
- Field validation (zone, animation, timestamp)
- File save/load roundtrip
- Event sorting by timestamp
- add_event() re-sorts
"""
import json
import tempfile
from pathlib import Path
import pytest
from pydantic import ValidationError
# --- RED: these imports will fail until models.py exists ---
def import_models():
from led_sync.models import ChoreoEvent, ChoreoFile, VALID_ZONES, VALID_ANIMATIONS
return ChoreoEvent, ChoreoFile, VALID_ZONES, VALID_ANIMATIONS
class TestChoreoEventValidation:
def test_valid_event_creation(self):
ChoreoEvent, _, _, _ = import_models()
e = ChoreoEvent(
timestamp=1.5,
zone="wand",
animation="chase",
params={"speed": 0.5},
)
assert e.timestamp == 1.5
assert e.zone == "wand"
assert e.animation == "chase"
assert e.params == {"speed": 0.5}
assert e.loop is None
assert e.duration is None
def test_all_valid_zones_accepted(self):
ChoreoEvent, _, VALID_ZONES, _ = import_models()
for zone in VALID_ZONES:
e = ChoreoEvent(timestamp=0, zone=zone, animation="pulse", params={})
assert e.zone == zone
def test_invalid_zone_rejected(self):
ChoreoEvent, _, _, _ = import_models()
with pytest.raises(ValidationError) as exc_info:
ChoreoEvent(timestamp=1.0, zone="bedroom", animation="chase", params={})
assert "Invalid zone" in str(exc_info.value)
def test_all_valid_animations_accepted(self):
ChoreoEvent, _, _, VALID_ANIMATIONS = import_models()
for anim in VALID_ANIMATIONS:
e = ChoreoEvent(timestamp=0, zone="wand", animation=anim, params={})
assert e.animation == anim
def test_invalid_animation_rejected(self):
ChoreoEvent, _, _, _ = import_models()
with pytest.raises(ValidationError) as exc_info:
ChoreoEvent(timestamp=1.0, zone="wand", animation="disco", params={})
assert "Unknown animation" in str(exc_info.value)
def test_negative_timestamp_rejected(self):
ChoreoEvent, _, _, _ = import_models()
with pytest.raises(ValidationError):
ChoreoEvent(timestamp=-1.0, zone="wand", animation="chase", params={})
def test_zero_timestamp_accepted(self):
ChoreoEvent, _, _, _ = import_models()
e = ChoreoEvent(timestamp=0.0, zone="schrank", animation="breathe", params={})
assert e.timestamp == 0.0
def test_loop_and_duration_optional(self):
ChoreoEvent, _, _, _ = import_models()
e = ChoreoEvent(
timestamp=2.0,
zone="all",
animation="rainbow",
params={},
loop=3,
duration=5.0,
)
assert e.loop == 3
assert e.duration == 5.0
class TestChoreoFileBasics:
def test_empty_choreofile_creation(self):
_, ChoreoFile, _, _ = import_models()
cf = ChoreoFile(song_path="test.mp3")
assert cf.song_path == "test.mp3"
assert cf.bpm is None
assert cf.events == []
def test_choreofile_with_bpm(self):
_, ChoreoFile, _, _ = import_models()
cf = ChoreoFile(song_path="test.mp3", bpm=120.0)
assert cf.bpm == 120.0
class TestChoreoFileSortingAndAdd:
def test_events_sorted_on_load(self):
ChoreoEvent, ChoreoFile, _, _ = import_models()
e1 = ChoreoEvent(timestamp=3.0, zone="wand", animation="chase", params={})
e2 = ChoreoEvent(timestamp=1.0, zone="wand", animation="pulse", params={})
e3 = ChoreoEvent(timestamp=2.0, zone="schrank", animation="rainbow", params={})
cf = ChoreoFile(song_path="test.mp3", events=[e1, e2, e3])
timestamps = [e.timestamp for e in cf.events]
assert timestamps == sorted(timestamps), "Events must be sorted by timestamp"
def test_add_event_maintains_sort(self):
ChoreoEvent, ChoreoFile, _, _ = import_models()
cf = ChoreoFile(song_path="test.mp3")
cf.add_event(ChoreoEvent(timestamp=5.0, zone="wand", animation="strobe", params={}))
cf.add_event(ChoreoEvent(timestamp=1.0, zone="wand", animation="chase", params={}))
cf.add_event(ChoreoEvent(timestamp=3.0, zone="all", animation="pulse", params={}))
timestamps = [e.timestamp for e in cf.events]
assert timestamps == [1.0, 3.0, 5.0]
class TestChoreoFileRoundtrip:
def test_save_creates_json_file(self):
ChoreoEvent, ChoreoFile, _, _ = import_models()
cf = ChoreoFile(song_path="my_song.mp3", bpm=128.0)
e = ChoreoEvent(timestamp=1.5, zone="wand", animation="chase", params={"speed": 0.5})
cf.add_event(e)
with tempfile.NamedTemporaryFile(suffix=".choreo.json", delete=False) as f:
tmp = Path(f.name)
try:
cf.save(tmp)
assert tmp.exists()
data = json.loads(tmp.read_text())
assert data["song_path"] == "my_song.mp3"
assert data["bpm"] == 128.0
assert len(data["events"]) == 1
assert data["events"][0]["timestamp"] == 1.5
finally:
tmp.unlink(missing_ok=True)
def test_load_roundtrip_preserves_all_fields(self):
ChoreoEvent, ChoreoFile, _, _ = import_models()
e1 = ChoreoEvent(timestamp=1.5, zone="wand", animation="chase", params={"speed": 0.5})
e2 = ChoreoEvent(
timestamp=0.5,
zone="schrank",
animation="strobe",
params={"intensity": 0.8},
loop=2,
duration=3.0,
)
e3 = ChoreoEvent(timestamp=3.0, zone="all", animation="rainbow", params={})
original = ChoreoFile(song_path="test.mp3", bpm=120.0, events=[e1, e2, e3])
with tempfile.NamedTemporaryFile(suffix=".choreo.json", delete=False) as f:
tmp = Path(f.name)
try:
original.save(tmp)
loaded = ChoreoFile.load(tmp)
assert loaded.song_path == original.song_path
assert loaded.bpm == original.bpm
assert len(loaded.events) == 3
# Events should be sorted: 0.5, 1.5, 3.0
assert loaded.events[0].timestamp == 0.5
assert loaded.events[1].timestamp == 1.5
assert loaded.events[2].timestamp == 3.0
assert loaded.events[0].loop == 2
assert loaded.events[0].duration == 3.0
finally:
tmp.unlink(missing_ok=True)
def test_load_sorts_events_by_timestamp(self):
"""Loading a file with unsorted events should produce sorted output."""
_, ChoreoFile, _, _ = import_models()
# Write raw JSON with unsorted events
raw = {
"song_path": "test.mp3",
"bpm": None,
"events": [
{"timestamp": 3.0, "zone": "wand", "animation": "chase", "params": {}, "loop": None, "duration": None},
{"timestamp": 1.0, "zone": "wand", "animation": "pulse", "params": {}, "loop": None, "duration": None},
{"timestamp": 2.0, "zone": "all", "animation": "rainbow", "params": {}, "loop": None, "duration": None},
],
}
with tempfile.NamedTemporaryFile(
suffix=".choreo.json", mode="w", delete=False
) as f:
json.dump(raw, f)
tmp = Path(f.name)
try:
loaded = ChoreoFile.load(tmp)
timestamps = [e.timestamp for e in loaded.events]
assert timestamps == [1.0, 2.0, 3.0]
finally:
tmp.unlink(missing_ok=True)
class TestExportedConstants:
def test_valid_zones_exported(self):
_, _, VALID_ZONES, _ = import_models()
assert "schrank" in VALID_ZONES
assert "wand" in VALID_ZONES
assert "all" in VALID_ZONES
assert len(VALID_ZONES) == 3
def test_valid_animations_exported(self):
_, _, _, VALID_ANIMATIONS = import_models()
expected = {"chase", "pulse", "rainbow", "strobe", "color_wash", "breathe", "sparkle", "gradient_sweep"}
assert VALID_ANIMATIONS == expected