"""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 - except for how it works out which track is playing, which is stubbed out at the bottom of this file because getting it wrong is invisible until a front-end displays it. """ 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, PlayerBase, 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_switching_albums_while_playing_announces_the_new_track( bus: EventBus, player: FakePlayer, seen: list[Event] ) -> None: """Regression: starting a new playlist from track 0 while already playing track 0 of the previous one used to look like a no-op index change, so no ``TrackChanged`` went out and front-ends never learned the album had switched.""" player.set_playlist(playlist(name="fuchs")) player.play_from_start() await bus.drain() player.set_playlist(playlist(name="eule")) player.play_from_start() await bus.drain() assert player.is_playing assert player.playlist is not None assert player.playlist.name == "eule" changes = only(seen, TrackChanged) assert changes[-1].index == 0 assert changes[-1].track is not None assert changes[-1].track.path.parent.name == "eule" 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 # --------------------------------------------------------- track index without libVLC class _FakeMedia: def __init__(self, mrl: str) -> None: self._mrl = mrl def get_mrl(self) -> str: return self._mrl class _FakeMediaPlayer: """Just enough of libVLC's media player to exercise index syncing.""" def __init__(self) -> None: self.media: _FakeMedia | None = None def get_media(self) -> _FakeMedia | None: return self.media def _vlc_like(bus: EventBus, playlist: Playlist) -> VlcPlayer: """A VlcPlayer with its libVLC parts stubbed out, without calling __init__.""" player = object.__new__(VlcPlayer) PlayerBase.__init__(player, bus) player._media_player = _FakeMediaPlayer() # type: ignore[assignment] player._playlist = playlist player._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)} return player async def test_the_track_index_is_read_back_off_the_player(bus: EventBus) -> None: """Not taken from the event payload. ``MediaListPlayerNextItemSet`` carries a bare int rather than a Media on some libVLC builds, and trusting it left the index pinned at zero: the UI showed track 1 of an album that was audibly on track 3. """ playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3))) player = _vlc_like(bus, playlist) events: list[Event] = [] bus.subscribe(TrackChanged, events.append) player._media_player.media = _FakeMedia("file:///music/2.mp3") # type: ignore[attr-defined] player._on_next_item(object()) await bus.drain() assert player.track_index == 2 assert player.current_track is not None assert player.current_track.path.name == "2.mp3" assert len(events) == 1 async def test_an_unknown_media_leaves_the_index_alone(bus: EventBus) -> None: playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3))) player = _vlc_like(bus, playlist) player._media_player.media = None # type: ignore[attr-defined] player._on_next_item(object()) player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3") # type: ignore[attr-defined] player._on_next_item(object()) await bus.drain() assert player.track_index == 0