Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
297
python-backend/tests/test_player.py
Normal file
297
python-backend/tests/test_player.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Tests for the shared player behaviour, exercised through FakePlayer.
|
||||
|
||||
VlcPlayer adds only the libVLC bindings on top of PlayerBase; it needs a real audio
|
||||
device and is covered by the on-device checklist, not here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import FakeClock
|
||||
from musicmouse.devices.player import Player, VlcPlayer
|
||||
from musicmouse.events import (
|
||||
Event,
|
||||
PlaybackChanged,
|
||||
PlaylistFinished,
|
||||
TrackChanged,
|
||||
VolumeChanged,
|
||||
)
|
||||
from musicmouse.media import Playlist, Track
|
||||
from musicmouse.simulator.fake_player import FakePlayer
|
||||
|
||||
|
||||
def playlist(name: str = "fuchs", count: int = 3) -> Playlist:
|
||||
tracks = tuple(Track(Path(f"/music/{name}/{i}.mp3")) for i in range(count))
|
||||
return Playlist(name=name, tracks=tracks)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def bus() -> AsyncIterator[EventBus]:
|
||||
async with EventBus() as running:
|
||||
yield running
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(bus: EventBus) -> FakeClock:
|
||||
return FakeClock(idle=bus.drain)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(bus: EventBus, clock: FakeClock) -> FakePlayer:
|
||||
return FakePlayer(bus, clock=clock, track_duration=10.0, initial_volume=50)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seen(bus: EventBus) -> list[Event]:
|
||||
events: list[Event] = []
|
||||
bus.subscribe_all(events.append)
|
||||
return events
|
||||
|
||||
|
||||
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
|
||||
return [e for e in events if isinstance(e, event_type)]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ volume
|
||||
|
||||
|
||||
async def test_volume_starts_at_the_configured_value(player: FakePlayer) -> None:
|
||||
assert player.volume == 50
|
||||
|
||||
|
||||
async def test_setting_volume_announces_it(
|
||||
bus: EventBus, player: FakePlayer, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_volume(30, source="mqtt")
|
||||
await bus.drain()
|
||||
|
||||
assert player.volume == 30
|
||||
assert only(seen, VolumeChanged) == [VolumeChanged(volume=30, source="mqtt")]
|
||||
|
||||
|
||||
async def test_setting_the_same_volume_is_not_announced(
|
||||
bus: EventBus, player: FakePlayer, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_volume(50)
|
||||
await bus.drain()
|
||||
assert only(seen, VolumeChanged) == []
|
||||
|
||||
|
||||
async def test_change_volume_is_relative(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.change_volume(-20)
|
||||
await bus.drain()
|
||||
assert player.volume == 30
|
||||
|
||||
|
||||
async def test_volume_is_clamped_to_the_configured_range(bus: EventBus) -> None:
|
||||
limited = FakePlayer(bus, min_volume=20, max_volume=60, initial_volume=40)
|
||||
|
||||
limited.set_volume(100)
|
||||
assert limited.volume == 60
|
||||
limited.set_volume(0)
|
||||
assert limited.volume == 20
|
||||
|
||||
|
||||
async def test_min_volume_of_zero_is_honoured(bus: EventBus) -> None:
|
||||
"""Regression: `if self.volume_min and ...` treated a configured 0 as unset."""
|
||||
limited = FakePlayer(bus, min_volume=0, max_volume=100, initial_volume=10)
|
||||
|
||||
limited.set_volume(-5)
|
||||
|
||||
assert limited.volume == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- playback
|
||||
|
||||
|
||||
async def test_play_from_start_starts_the_first_track(
|
||||
bus: EventBus, player: FakePlayer, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
|
||||
assert player.is_playing
|
||||
assert player.track_index == 0
|
||||
assert player.current_track is not None
|
||||
assert player.current_track.title == "0"
|
||||
assert only(seen, PlaybackChanged)[-1].playing is True
|
||||
|
||||
|
||||
async def test_playing_an_empty_playlist_does_nothing(
|
||||
bus: EventBus, player: FakePlayer, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
player.set_playlist(Playlist(name="leer", tracks=()))
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
|
||||
assert not player.is_playing
|
||||
assert "playlist is empty" in caplog.text
|
||||
|
||||
|
||||
async def test_pause_and_resume(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
player.pause()
|
||||
await bus.drain()
|
||||
assert not player.is_playing
|
||||
|
||||
player.play()
|
||||
await bus.drain()
|
||||
assert player.is_playing
|
||||
|
||||
|
||||
async def test_tracks_advance_as_time_passes(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_playlist(playlist(count=3))
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
|
||||
await clock.advance(10.0)
|
||||
assert player.track_index == 1
|
||||
|
||||
await clock.advance(10.0)
|
||||
assert player.track_index == 2
|
||||
|
||||
assert [e.index for e in only(seen, TrackChanged)] == [1, 2]
|
||||
|
||||
|
||||
async def test_playlist_end_stops_playback_and_is_announced(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_playlist(playlist(count=2))
|
||||
player.play_from_start()
|
||||
|
||||
await clock.advance(25.0)
|
||||
|
||||
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]
|
||||
assert not player.is_playing
|
||||
|
||||
|
||||
async def test_a_paused_player_does_not_advance(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock
|
||||
) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
player.pause()
|
||||
|
||||
await clock.advance(100.0)
|
||||
|
||||
assert player.track_index == 0
|
||||
assert not player.is_playing
|
||||
|
||||
|
||||
async def test_resuming_continues_the_remainder_of_the_track(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock
|
||||
) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
await clock.advance(7.0)
|
||||
player.pause()
|
||||
await clock.advance(100.0)
|
||||
player.play()
|
||||
|
||||
await clock.advance(2.0)
|
||||
assert player.track_index == 0 # 3s of the track were still left
|
||||
|
||||
await clock.advance(2.0)
|
||||
assert player.track_index == 1
|
||||
|
||||
|
||||
async def test_next_and_previous(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.set_playlist(playlist(count=3))
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
|
||||
player.next_track()
|
||||
player.next_track()
|
||||
await bus.drain()
|
||||
assert player.track_index == 2
|
||||
|
||||
player.previous_track()
|
||||
await bus.drain()
|
||||
assert player.track_index == 1
|
||||
|
||||
|
||||
async def test_previous_on_the_first_track_stays_there(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
player.previous_track()
|
||||
await bus.drain()
|
||||
|
||||
assert player.track_index == 0
|
||||
|
||||
|
||||
async def test_next_past_the_last_track_ends_the_playlist(
|
||||
bus: EventBus, player: FakePlayer, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_playlist(playlist(count=2))
|
||||
player.play_from_start()
|
||||
player.next_track()
|
||||
player.next_track()
|
||||
await bus.drain()
|
||||
|
||||
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]
|
||||
assert not player.is_playing
|
||||
|
||||
|
||||
async def test_skipping_restarts_the_track_timer(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock
|
||||
) -> None:
|
||||
player.set_playlist(playlist(count=3))
|
||||
player.play_from_start()
|
||||
await clock.advance(9.0)
|
||||
player.next_track()
|
||||
await bus.drain()
|
||||
|
||||
await clock.advance(9.0)
|
||||
assert player.track_index == 1 # a fresh 10s, not the 1s left over
|
||||
|
||||
await clock.advance(2.0)
|
||||
assert player.track_index == 2
|
||||
|
||||
|
||||
async def test_stop_resets_playback(bus: EventBus, player: FakePlayer, clock: FakeClock) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
player.stop()
|
||||
await bus.drain()
|
||||
|
||||
assert not player.is_playing
|
||||
await clock.advance(100.0)
|
||||
assert player.track_index == 0
|
||||
|
||||
|
||||
async def test_setting_a_new_playlist_resets_the_index(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.set_playlist(playlist(count=3))
|
||||
player.play_from_start()
|
||||
player.next_track()
|
||||
await bus.drain()
|
||||
|
||||
player.set_playlist(playlist(name="eule", count=2))
|
||||
|
||||
assert player.track_index == 0
|
||||
assert player.playlist is not None
|
||||
assert player.playlist.name == "eule"
|
||||
|
||||
|
||||
async def test_current_track_is_none_without_a_playlist(player: FakePlayer) -> None:
|
||||
assert player.current_track is None
|
||||
assert player.playlist is None
|
||||
|
||||
|
||||
def test_fake_player_satisfies_the_player_protocol(player: FakePlayer) -> None:
|
||||
check: Player = player
|
||||
assert check.volume == player.volume
|
||||
|
||||
|
||||
def _vlc_player_satisfies_the_player_protocol(real: VlcPlayer) -> Player:
|
||||
"""Checked by mypy, not at runtime: VlcPlayer needs libVLC to instantiate."""
|
||||
return real
|
||||
Reference in New Issue
Block a user