test(02-04): add failing tests for ChoreographyScheduler
- 8 tests covering: dispatch timing, payload format, pause/resume, seek, stop, on_event callback, position tracking - All tests fail (RED) — scheduler module not yet implemented
This commit is contained in:
183
tests/test_scheduler.py
Normal file
183
tests/test_scheduler.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Tests for ChoreographyScheduler — TDD RED phase.
|
||||
|
||||
Verifies:
|
||||
- 3 events fire at correct timestamps (within 20ms drift per D-08)
|
||||
- Pause suppresses dispatch; resume continues
|
||||
- Seek skips events before seek position
|
||||
- Payload format: {zone, animation, params} — no "v":1 (transport injects that)
|
||||
- stop() resets state cleanly
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from led_sync.models import ChoreoEvent
|
||||
from led_sync.scheduler import ChoreographyScheduler
|
||||
|
||||
|
||||
class MockTransport:
|
||||
"""Minimal stand-in for ESP32Transport."""
|
||||
|
||||
connected = True
|
||||
last_status = None
|
||||
|
||||
def __init__(self):
|
||||
self.sent: list[dict] = []
|
||||
self.sent_times: list[float] = []
|
||||
|
||||
def send_command(self, cmd: dict) -> None:
|
||||
self.sent.append(cmd)
|
||||
self.sent_times.append(time.monotonic())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transport():
|
||||
return MockTransport()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler(transport):
|
||||
return ChoreographyScheduler(transport)
|
||||
|
||||
|
||||
def make_events() -> list[ChoreoEvent]:
|
||||
return [
|
||||
ChoreoEvent(timestamp=0.0, zone="wand", animation="chase", params={"speed": 0.5}),
|
||||
ChoreoEvent(timestamp=0.05, zone="schrank", animation="pulse", params={}),
|
||||
ChoreoEvent(timestamp=0.1, zone="all", animation="rainbow", params={}),
|
||||
]
|
||||
|
||||
|
||||
def make_two_events() -> list[ChoreoEvent]:
|
||||
return [
|
||||
ChoreoEvent(timestamp=0.0, zone="wand", animation="breathe", params={}),
|
||||
ChoreoEvent(timestamp=0.15, zone="wand", animation="sparkle", params={}),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_three_events_dispatched(scheduler, transport):
|
||||
"""All 3 events dispatch, in order, within 20ms drift each."""
|
||||
events = make_events()
|
||||
t0 = time.monotonic()
|
||||
scheduler.play(events)
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
assert len(transport.sent) == 3, f"Expected 3 commands, got {len(transport.sent)}"
|
||||
for i, (cmd, fired_at) in enumerate(zip(transport.sent, transport.sent_times)):
|
||||
drift = abs((fired_at - t0) - events[i].timestamp)
|
||||
assert drift < 0.020, f"Event {i}: drift {drift * 1000:.1f}ms > 20ms"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_format(scheduler, transport):
|
||||
"""Command dict contains zone, animation, params — no 'v':1 (transport adds that)."""
|
||||
events = [ChoreoEvent(timestamp=0.0, zone="wand", animation="chase", params={"speed": 0.5})]
|
||||
scheduler.play(events)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert len(transport.sent) == 1
|
||||
cmd = transport.sent[0]
|
||||
assert cmd["zone"] == "wand"
|
||||
assert cmd["animation"] == "chase"
|
||||
assert cmd["params"] == {"speed": 0.5}
|
||||
assert "v" not in cmd, "Transport injects v:1, scheduler must not include it"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_suppresses_dispatch(scheduler, transport):
|
||||
"""No events fire while paused."""
|
||||
events = make_two_events()
|
||||
scheduler.play(events)
|
||||
await asyncio.sleep(0.05)
|
||||
scheduler.pause()
|
||||
count_at_pause = len(transport.sent)
|
||||
await asyncio.sleep(0.25) # second event would have fired by now if not paused
|
||||
assert len(transport.sent) == count_at_pause, "Events fired while paused!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_fires_remaining_events(scheduler, transport):
|
||||
"""After resume, remaining events fire; no double-firing."""
|
||||
events = make_two_events()
|
||||
scheduler.play(events)
|
||||
await asyncio.sleep(0.05)
|
||||
scheduler.pause()
|
||||
await asyncio.sleep(0.1)
|
||||
scheduler.resume()
|
||||
await asyncio.sleep(0.3)
|
||||
assert len(transport.sent) == 2, f"Expected 2 total, got {len(transport.sent)}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seek_skips_past_events(scheduler, transport):
|
||||
"""seek(0.1) skips first event (ts=0.0), fires only events >= 0.1."""
|
||||
events = make_two_events() # ts=0.0 and ts=0.15
|
||||
scheduler.play(events, seek_seconds=0.1)
|
||||
await asyncio.sleep(0.25)
|
||||
assert len(transport.sent) == 1, f"Seek should skip first event; got {len(transport.sent)}"
|
||||
assert transport.sent[0]["animation"] == "sparkle"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_resets_state(scheduler, transport):
|
||||
"""stop() cancels running task, resets state."""
|
||||
events = make_events()
|
||||
scheduler.play(events)
|
||||
assert scheduler.is_running
|
||||
scheduler.stop()
|
||||
await asyncio.sleep(0.01)
|
||||
assert not scheduler.is_running
|
||||
assert scheduler.start_time is None
|
||||
assert scheduler.pause_offset == 0.0
|
||||
assert scheduler.paused_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_event_callback(transport):
|
||||
"""on_event callback is called once per dispatched event."""
|
||||
fired: list[ChoreoEvent] = []
|
||||
scheduler = ChoreographyScheduler(transport, on_event=fired.append)
|
||||
events = make_events()
|
||||
scheduler.play(events)
|
||||
await asyncio.sleep(0.25)
|
||||
assert len(fired) == 3
|
||||
assert fired[0].animation == "chase"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_position_during_play(scheduler):
|
||||
"""current_position advances monotonically during playback."""
|
||||
events = make_events()
|
||||
scheduler.play(events)
|
||||
await asyncio.sleep(0.05)
|
||||
pos = scheduler.current_position
|
||||
assert 0.04 <= pos <= 0.12, f"current_position={pos:.3f} out of expected range"
|
||||
scheduler.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_position_frozen_when_paused(scheduler, transport):
|
||||
"""current_position is frozen while paused."""
|
||||
events = make_two_events()
|
||||
scheduler.play(events)
|
||||
await asyncio.sleep(0.05)
|
||||
scheduler.pause()
|
||||
pos1 = scheduler.current_position
|
||||
await asyncio.sleep(0.1)
|
||||
pos2 = scheduler.current_position
|
||||
assert abs(pos1 - pos2) < 0.002, f"Position changed during pause: {pos1} -> {pos2}"
|
||||
scheduler.stop()
|
||||
Reference in New Issue
Block a user