Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
178
python-backend/tests/test_bus.py
Normal file
178
python-backend/tests/test_bus.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.events import (
|
||||
ButtonEvent,
|
||||
Event,
|
||||
InputEvent,
|
||||
NextTrackRequested,
|
||||
RfidTokenRead,
|
||||
VolumeChanged,
|
||||
)
|
||||
from musicmouse.hardware import Button, ButtonAction
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def bus() -> AsyncIterator[EventBus]:
|
||||
async with EventBus() as running_bus:
|
||||
yield running_bus
|
||||
|
||||
|
||||
def _rfid(tag: str = "04a1b2c3d4") -> RfidTokenRead:
|
||||
return RfidTokenRead(tag_id=bytes.fromhex(tag), figure="fuchs", source="device")
|
||||
|
||||
|
||||
async def test_handler_receives_its_event(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe(RfidTokenRead, seen.append)
|
||||
|
||||
event = _rfid()
|
||||
await bus.emit_and_wait(event)
|
||||
|
||||
assert seen == [event]
|
||||
|
||||
|
||||
async def test_unrelated_handlers_are_not_called(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe(NextTrackRequested, seen.append)
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
|
||||
assert seen == []
|
||||
|
||||
|
||||
async def test_subscribing_to_a_base_class_catches_subclasses(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe(InputEvent, seen.append)
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
await bus.emit_and_wait(VolumeChanged(volume=30))
|
||||
|
||||
assert [type(e) for e in seen] == [RfidTokenRead]
|
||||
|
||||
|
||||
async def test_subscribe_all_sees_everything(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe_all(seen.append)
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
await bus.emit_and_wait(VolumeChanged(volume=30))
|
||||
|
||||
assert [type(e) for e in seen] == [RfidTokenRead, VolumeChanged]
|
||||
|
||||
|
||||
async def test_async_handlers_are_awaited(bus: EventBus) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
async def slow(_: Event) -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
seen.append("slow")
|
||||
|
||||
bus.subscribe(RfidTokenRead, slow)
|
||||
bus.subscribe(RfidTokenRead, lambda _: seen.append("fast"))
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
|
||||
assert seen == ["slow", "fast"]
|
||||
|
||||
|
||||
async def test_events_are_dispatched_in_emission_order(bus: EventBus) -> None:
|
||||
"""Ordering is what makes 'last event wins' meaningful for LED arbitration."""
|
||||
seen: list[int] = []
|
||||
|
||||
async def record(event: VolumeChanged) -> None:
|
||||
await asyncio.sleep(0)
|
||||
seen.append(event.volume)
|
||||
|
||||
bus.subscribe(VolumeChanged, record)
|
||||
|
||||
for volume in range(5):
|
||||
bus.emit(VolumeChanged(volume=volume))
|
||||
await bus.drain()
|
||||
|
||||
assert seen == [0, 1, 2, 3, 4]
|
||||
|
||||
|
||||
async def test_events_emitted_from_a_handler_are_handled_before_drain_returns(
|
||||
bus: EventBus,
|
||||
) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
def on_button(_: ButtonEvent) -> None:
|
||||
seen.append("button")
|
||||
bus.emit(NextTrackRequested(source="device"))
|
||||
|
||||
bus.subscribe(ButtonEvent, on_button)
|
||||
bus.subscribe(NextTrackRequested, lambda _: seen.append("next"))
|
||||
|
||||
await bus.emit_and_wait(
|
||||
ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED, source="device")
|
||||
)
|
||||
|
||||
assert seen == ["button", "next"]
|
||||
|
||||
|
||||
async def test_a_raising_handler_does_not_stop_the_others(
|
||||
bus: EventBus, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
def boom(_: Event) -> None:
|
||||
raise RuntimeError("handler is broken")
|
||||
|
||||
bus.subscribe(RfidTokenRead, boom)
|
||||
bus.subscribe(RfidTokenRead, lambda _: seen.append("survivor"))
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await bus.emit_and_wait(_rfid())
|
||||
await bus.emit_and_wait(_rfid())
|
||||
|
||||
assert seen == ["survivor", "survivor"]
|
||||
assert "handler is broken" in caplog.text
|
||||
|
||||
|
||||
async def test_unsubscribe(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
unsubscribe = bus.subscribe(RfidTokenRead, seen.append)
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
unsubscribe()
|
||||
await bus.emit_and_wait(_rfid())
|
||||
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
async def test_emit_is_safe_from_another_thread(bus: EventBus) -> None:
|
||||
"""libVLC fires its callbacks off-loop; this is the crossing that must be safe."""
|
||||
seen: list[VolumeChanged] = []
|
||||
done = asyncio.Event()
|
||||
|
||||
def record(event: VolumeChanged) -> None:
|
||||
seen.append(event)
|
||||
done.set()
|
||||
|
||||
bus.subscribe(VolumeChanged, record)
|
||||
|
||||
await asyncio.to_thread(bus.emit, VolumeChanged(volume=42, source="player"))
|
||||
async with asyncio.timeout(2):
|
||||
await done.wait()
|
||||
|
||||
assert [e.volume for e in seen] == [42]
|
||||
|
||||
|
||||
async def test_emit_before_start_is_an_error() -> None:
|
||||
with pytest.raises(RuntimeError, match="before start"):
|
||||
EventBus().emit(VolumeChanged(volume=1))
|
||||
|
||||
|
||||
async def test_stop_is_idempotent() -> None:
|
||||
stopped = EventBus()
|
||||
await stopped.start()
|
||||
await stopped.stop()
|
||||
await stopped.stop()
|
||||
Reference in New Issue
Block a user