"""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