"""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 from typing import TypeVar 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 T = TypeVar("T", bound=Event) def only(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, pause/seek and the volume/playing poll.""" def __init__(self) -> None: self.media: _FakeMedia | None = None self.paused = False self.time_ms = 0 self.length_ms = 0 self.volume = 50 def get_media(self) -> _FakeMedia | None: return self.media def set_pause(self, flag: int) -> None: self.paused = bool(flag) def set_time(self, ms: int) -> None: self.time_ms = ms def get_time(self) -> int: return self.time_ms def get_length(self) -> int: return self.length_ms def audio_set_volume(self, volume: int) -> None: self.volume = volume def audio_get_volume(self) -> int: return self.volume class _FakeListPlayer: """Just enough of libVLC's list player to exercise play/stop and the is-playing half of the poll.""" def __init__(self) -> None: self.playing = False self.play_calls = 0 self.stop_calls = 0 self.played_index: int | None = None def play(self) -> None: self.play_calls += 1 self.playing = True def stop(self) -> None: self.stop_calls += 1 self.playing = False def is_playing(self) -> bool: return self.playing def play_item_at_index(self, index: int) -> None: self.played_index = index self.playing = True def _vlc_like( bus: EventBus, playlist: Playlist, *, media_player: _FakeMediaPlayer | None = None, list_player: _FakeListPlayer | None = None, ) -> VlcPlayer: """A VlcPlayer with its libVLC parts stubbed out, without calling __init__.""" player = object.__new__(VlcPlayer) PlayerBase.__init__(player, bus) player._media_player = media_player or _FakeMediaPlayer() player._list_player = list_player or _FakeListPlayer() 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") 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 player._on_next_item(object()) player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3") player._on_next_item(object()) await bus.drain() assert player.track_index == 0 # ------------------------------------------------------ VlcPlayer actions, stubbed async def test_vlc_play_starts_playback_when_idle(bus: EventBus) -> None: list_player = _FakeListPlayer() player = _vlc_like(bus, playlist(), list_player=list_player) player.play() assert list_player.play_calls == 1 async def test_vlc_play_is_a_no_op_once_already_playing(bus: EventBus) -> None: """Mirrors `FakePlayer.play()`'s own guard, so the two `Player` implementations agree on what a repeated play() does instead of leaving it to libVLC.""" list_player = _FakeListPlayer() player = _vlc_like(bus, playlist(), list_player=list_player) player._set_playing(True) player.play() assert list_player.play_calls == 0 async def test_vlc_pause_pauses_when_playing(bus: EventBus) -> None: media_player = _FakeMediaPlayer() player = _vlc_like(bus, playlist(), media_player=media_player) player._set_playing(True) player.pause() assert media_player.paused is True async def test_vlc_pause_is_a_no_op_when_not_playing(bus: EventBus) -> None: media_player = _FakeMediaPlayer() player = _vlc_like(bus, playlist(), media_player=media_player) player.pause() assert media_player.paused is False async def test_vlc_play_track_clamps_to_the_playlist_bounds(bus: EventBus) -> None: list_player = _FakeListPlayer() player = _vlc_like(bus, playlist(count=3), list_player=list_player) player.play_track(99) assert list_player.played_index == 2 async def test_vlc_play_track_on_an_empty_playlist_does_nothing( bus: EventBus, caplog: pytest.LogCaptureFixture ) -> None: list_player = _FakeListPlayer() player = _vlc_like(bus, Playlist(name="leer", tracks=()), list_player=list_player) with caplog.at_level("WARNING"): player.play_track(0) assert list_player.played_index is None assert "playlist is empty" in caplog.text async def test_vlc_seek_sets_the_time_in_milliseconds(bus: EventBus) -> None: media_player = _FakeMediaPlayer() player = _vlc_like(bus, playlist(), media_player=media_player) player.seek(12.5) assert media_player.time_ms == 12500 async def test_vlc_seek_clamps_negative_positions_to_zero(bus: EventBus) -> None: media_player = _FakeMediaPlayer() player = _vlc_like(bus, playlist(), media_player=media_player) player.seek(-5.0) assert media_player.time_ms == 0 async def test_vlc_position_and_duration_read_the_media_player(bus: EventBus) -> None: media_player = _FakeMediaPlayer() media_player.time_ms = 4_000 media_player.length_ms = 180_000 player = _vlc_like(bus, playlist(), media_player=media_player) assert player.position == 4.0 assert player.duration == 180.0 async def test_vlc_position_floors_libvlcs_minus_one_at_zero(bus: EventBus) -> None: """libVLC reports -1 for both until a media is actually opened.""" media_player = _FakeMediaPlayer() media_player.time_ms = -1 media_player.length_ms = -1 player = _vlc_like(bus, playlist(), media_player=media_player) assert player.position == 0.0 assert player.duration == 0.0 async def test_vlc_apply_volume_pushes_to_the_media_player(bus: EventBus) -> None: media_player = _FakeMediaPlayer() player = _vlc_like(bus, playlist(), media_player=media_player) player.set_volume(77) assert media_player.volume == 77 async def test_vlc_poll_announces_a_volume_change_made_outside_the_app( bus: EventBus, seen: list[Event] ) -> None: """The physical volume can move without going through `set_volume` - a hardware knob on the amp, or another process - so the poll is what catches libVLC drifting from what `PlayerBase` last announced.""" media_player = _FakeMediaPlayer() media_player.volume = 50 player = _vlc_like(bus, playlist(), media_player=media_player) media_player.volume = 65 player._poll() await bus.drain() assert player.volume == 65 assert only(seen, VolumeChanged) == [VolumeChanged(volume=65, source="player")] async def test_vlc_poll_ignores_a_negative_volume_reading(bus: EventBus) -> None: media_player = _FakeMediaPlayer() player = _vlc_like(bus, playlist(), media_player=media_player) media_player.volume = -1 player._poll() assert player.volume == 50 async def test_vlc_poll_syncs_is_playing_from_the_list_player( bus: EventBus, seen: list[Event] ) -> None: list_player = _FakeListPlayer() player = _vlc_like(bus, playlist(), list_player=list_player) list_player.playing = True player._poll() await bus.drain() assert player.is_playing assert only(seen, PlaybackChanged)[-1].playing is True async def test_vlc_on_playing_and_on_stopped_announce_playback_changed( bus: EventBus, seen: list[Event] ) -> None: player = _vlc_like(bus, playlist()) player._on_playing(object()) await bus.drain() assert player.is_playing player._on_stopped(object()) await bus.drain() assert not player.is_playing assert [e.playing for e in only(seen, PlaybackChanged)] == [True, False] async def test_vlc_on_playlist_end_stops_and_announces_playlist_finished( bus: EventBus, seen: list[Event] ) -> None: player = _vlc_like(bus, playlist()) player._set_playing(True) player._on_playlist_end(object()) await bus.drain() assert not player.is_playing assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]