from __future__ import annotations import html import re from datetime import datetime, timedelta, timezone import pytest from beefy_player import config from beefy_player.app import BeefyPlayerApp from beefy_player.models import Song, Station from beefy_player.providers.base import StationPage from beefy_player.widgets.art import ArtPane from beefy_player.widgets.help import HelpScreen from beefy_player.widgets.stage import StagePanel from beefy_player.widgets.station_list import StationList def _history_method(func): """Adapt a ``(client, station_id)`` fake to the provider method signature.""" async def method(self, client, station_id): return await func(client, station_id) return method def rendered_text(app: BeefyPlayerApp) -> str: svg = html.unescape(app.export_screenshot()).replace("\xa0", " ") return re.sub(r"<[^>]+>", " ", svg) @pytest.mark.asyncio async def test_boot_navigation_filter_favourite_visualizer_help() -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() assert len(app.stations) >= 40 assert app.current is not None assert app.current.id == "groovesalad" assert app.focused is None assert len(app.view) == len(app.stations) start = app.index await pilot.press("down") await pilot.pause() assert app.index == min(start + 1, len(app.view) - 1) await pilot.press("g") await pilot.pause() assert app.index == 0 await pilot.press("shift+g") await pilot.pause() assert app.index == len(app.view) - 1 await pilot.press("slash") await pilot.pause() search = app.query_one("#search") assert search.has_class("visible") assert app.focused is search for char in "defcon": await pilot.press(char) await pilot.pause() assert app.query == "defcon" assert [station.id for station in app.view] == ["defcon"] await pilot.press("enter") await pilot.pause() assert app.current is not None assert app.current.id == "defcon" assert app.query == "defcon" assert [station.id for station in app.view] == ["defcon"] assert not search.has_class("visible") assert app.focused is None assert app.playing_state in ("stopped", "error") app.action_favorite() await pilot.pause() assert "defcon" in app.config.favorites assert app.view[0].id == "defcon" assert app.index == 0 modes = set() for _ in range(len(config.VISUALIZERS)): await pilot.press("v") await pilot.pause() modes.add(app.visualizer) assert modes == set(config.VISUALIZERS) await pilot.press("question_mark") await pilot.pause() assert isinstance(app.screen, HelpScreen) await pilot.press("x") await pilot.pause() assert not isinstance(app.screen, HelpScreen) text = rendered_text(app) for needle in ("SOMA FM RADIO", "STATIONS", "ON AIR", "SPECTRUM"): assert needle in text assert "DEF CON" in text.upper() @pytest.mark.asyncio async def test_escape_leaves_the_filter_sticky_and_a_second_escape_clears_it() -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() await pilot.press("slash") for char in "defcon": await pilot.press(char) await pilot.pause() search = app.query_one("#search") assert search.has_class("visible") assert app.query == "defcon" assert [station.id for station in app.view] == ["defcon"] await pilot.press("escape") await pilot.pause() assert app.query == "defcon" assert search.value == "defcon" assert not search.has_class("visible") assert app.focused is None assert [station.id for station in app.view] == ["defcon"] assert "defcon" in app.query_one(StationList).border_title app.action_favorite() await pilot.pause() assert "defcon" in app.config.favorites assert app.query == "defcon" assert [station.id for station in app.view] == ["defcon"] await pilot.press("escape") await pilot.pause() assert app.query == "" assert search.value == "" assert len(app.view) == len(app.stations) @pytest.mark.asyncio async def test_enter_keeps_the_filter_sticky_like_the_first_escape() -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() await pilot.press("slash") for char in "defcon": await pilot.press(char) await pilot.pause() await pilot.press("enter") await pilot.pause() search = app.query_one("#search") assert app.current is not None assert app.current.id == "defcon" assert app.query == "defcon" assert [station.id for station in app.view] == ["defcon"] assert not search.has_class("visible") await pilot.press("escape") await pilot.pause() assert app.query == "" assert len(app.view) == len(app.stations) @pytest.mark.asyncio async def test_directory_search_uses_the_server_not_the_loaded_list(monkeypatch) -> None: app = BeefyPlayerApp( provider="radiobrowser", autoplay=False, offline=True, audio=False ) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() app.offline = False found = Station( id="deep-jazz", title="Deep Jazz", description="France ยท french", genre="jazz", provider="radiobrowser", ) queries: list[str] = [] async def fake_search(self, client, query): queries.append(query) return [found] monkeypatch.setattr(type(app.provider), "search_stations", fake_search) app.query = "jazz" app._rebuild_view() assert not any(station.id == "deep-jazz" for station in app.view) worker = app._search_directory("jazz") await worker.wait() await pilot.pause() assert queries == ["jazz"] assert [station.id for station in app.view] == ["deep-jazz"] assert app._find_station("deep-jazz") is found app._tune(found) assert any(station.id == "deep-jazz" for station in app.stations) app._set_stations( [Station(id="other", title="Other", provider="radiobrowser")] ) assert app.current.id == "deep-jazz" assert any(station.id == "deep-jazz" for station in app.stations) app.query = "" app.search_results = None app._search_results_query = "" app._rebuild_view() assert len(app.view) == len(app.stations) @pytest.mark.asyncio async def test_a_refresh_replaces_a_fallback_station() -> None: app = BeefyPlayerApp( provider="radiobrowser", autoplay=False, offline=True, audio=False ) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() assert app.current is not None assert app.current.id.startswith("fallback-") app._set_stations( [Station(id="real", title="Real Station", provider="radiobrowser")] ) assert app.current.id == "real" assert [station.id for station in app.stations] == ["real"] @pytest.mark.asyncio async def test_favoriting_a_loaded_station_from_search_updates_it_in_place() -> None: app = BeefyPlayerApp( provider="radiobrowser", autoplay=False, offline=True, audio=False ) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() existing = app.stations[0] search_copy = Station( id=existing.id, title=existing.title, provider="radiobrowser" ) app.query = "jazz" app._set_search_results("jazz", [search_copy]) assert app.view[0] is existing app.index = 0 app.action_favorite() await pilot.pause() assert existing.favorite is True assert app.config.is_favorite(existing.id) app._close_search(clear=True) await pilot.pause() assert app.view[0].id == existing.id @pytest.mark.asyncio async def test_favoriting_a_directory_result_keeps_it_after_clearing_the_filter( monkeypatch, ) -> None: app = BeefyPlayerApp( provider="radiobrowser", autoplay=False, offline=True, audio=False ) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() app.offline = False found = Station( id="deep-jazz", title="Deep Jazz", provider="radiobrowser" ) async def fake_search(self, client, query): return [found] monkeypatch.setattr(type(app.provider), "search_stations", fake_search) await pilot.press("slash") for char in "jazz": await pilot.press(char) await pilot.pause(0.4) assert [station.id for station in app.view] == ["deep-jazz"] await pilot.press("escape") await pilot.pause() app.action_favorite() await pilot.pause() assert found.favorite is True assert "deep-jazz" in app.config.favorites await pilot.press("escape") await pilot.pause() assert app.query == "" assert app.view[0].id == "deep-jazz" @pytest.mark.asyncio async def test_missing_favorites_load_with_the_first_directory_page( monkeypatch, ) -> None: app = BeefyPlayerApp( provider="radiobrowser", autoplay=False, offline=True, audio=False ) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() app.offline = False favourite = Station( id="12345678-1234-1234-1234-123456789abc", title="Favourite", provider="radiobrowser", ) app.config.favorites = [favourite.id] page = StationPage( stations=[ Station(id="page-0", title="Page Zero", provider="radiobrowser") ], next_offset=100, ) async def fake_page(self, client, offset=0): return page async def fake_by_ids(self, client, station_ids): assert station_ids == [favourite.id] return [favourite] monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page) monkeypatch.setattr( type(app.provider), "fetch_stations_by_ids", fake_by_ids ) worker = app._refresh_stations() await worker.wait() await pilot.pause() assert any( station.id == favourite.id and station.favorite for station in app.stations ) assert app.view[0].id == favourite.id @pytest.mark.asyncio async def test_a_paginated_catalogue_loads_the_next_page_near_the_end( monkeypatch, ) -> None: app = BeefyPlayerApp( provider="radiobrowser", autoplay=False, offline=True, audio=False ) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() app.offline = False first = [ Station( id=f"s{index}", title=f"Station {index}", provider="radiobrowser" ) for index in range(100) ] app._set_stations(first) app._reset_directory_paging(first, has_more=True) assert app._directory_offset == 100 assert app._directory_exhausted is False app.index = 0 app._maybe_load_more_stations() assert app._loading_more is False seen: list[int] = [] async def fake_page(self, client, offset=0): seen.append(offset) return StationPage( stations=[ Station(id="extra", title="Extra", provider="radiobrowser") ], next_offset=None, ) monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page) app.index = 99 worker = app._load_more_stations() await worker.wait() await pilot.pause() assert seen == [100] assert any(station.id == "extra" for station in app.stations) assert app._directory_exhausted is True assert app.view[app.index].id == "s99" @pytest.mark.asyncio async def test_a_popularity_ordered_catalogue_keeps_its_order() -> None: app = BeefyPlayerApp( provider="radiobrowser", autoplay=False, offline=True, audio=False ) async with app.run_test(size=(110, 36)) as pilot: await pilot.pause() app._set_stations( [ Station(id="zulu", title="Zulu", provider="radiobrowser"), Station(id="alpha", title="Alpha", provider="radiobrowser"), ] ) assert [station.title for station in app.view] == ["Zulu", "Alpha"] @pytest.mark.asyncio async def test_stage_renders_history_and_listener_count() -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(110, 40)) as pilot: await pilot.pause() station = app.current assert station is not None station.listeners = 1234 now = datetime.now(timezone.utc) station.now_playing = Song( title="Rauschebart", artist="Jens Buchert", album="Gravity", played_at=now - timedelta(seconds=26), ) station.history = [ station.now_playing, Song( title="Room 505", artist="Alex Cortiz", album="The Chill Out Room", played_at=now - timedelta(minutes=5), ), ] app.query_one(StagePanel).refresh_stage() app.query_one(StationList).refresh() await pilot.pause() text = rendered_text(app) for needle in ("LAST PLAYED", "Jens Buchert", "Gravity", "1.2k listeners"): assert needle in text @pytest.mark.asyncio async def test_splash_names_the_provider() -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(100, 32)) as pilot: await pilot.pause() app.current = None app.query_one(StagePanel).refresh_stage() await pilot.pause() text = rendered_text(app) assert "SOMA FM" in text assert "somafm.com/channels.json" in text @pytest.mark.asyncio async def test_narrow_layout() -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(64, 20)) as pilot: await pilot.pause() app.query_one(StagePanel).refresh_stage() app.query_one(StationList).refresh() await pilot.pause() text = rendered_text(app) assert "STATIONS" in text assert "ON AIR" in text @pytest.mark.asyncio async def test_stage_shows_bandcamp_link_only_when_present() -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(110, 40)) as pilot: await pilot.pause() station = app.current assert station is not None song = Song( title="Rauschebart", artist="Jens Buchert", album="Gravity", bandcamp_url="https://jensbuchert.bandcamp.com/track/rauschebart", ) station.now_playing = song app.query_one(StagePanel).refresh_stage() await pilot.pause() assert "Bandcamp" in rendered_text(app) song.bandcamp_url = "" app.query_one(StagePanel).refresh_stage() await pilot.pause() assert "Bandcamp" not in rendered_text(app) @pytest.mark.asyncio async def test_bandcamp_link_survives_history_refresh(monkeypatch) -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(110, 40)) as pilot: await pilot.pause() station = app.current assert station is not None url = "https://jensbuchert.bandcamp.com/track/rauschebart" old_song = Song( title="Rauschebart", artist="Jens Buchert", album="Gravity", bandcamp_url=url, ) station.now_playing = old_song station.history = [old_song] async def fake_fetch_history(client, station_id): assert station_id == station.id return [ Song( title="Rauschebart", artist="Jens Buchert", album="Gravity", ) ] monkeypatch.setattr( type(app.provider), "fetch_history", _history_method(fake_fetch_history) ) worker = app._fetch_history(station.id) await worker.wait() await pilot.pause() assert station.now_playing is not None assert station.now_playing is not old_song assert station.now_playing.bandcamp_url == url assert "Bandcamp" in rendered_text(app) @pytest.mark.asyncio async def test_cached_bandcamp_link_is_reapplied_to_new_song() -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(110, 40)) as pilot: await pilot.pause() station = app.current assert station is not None url = "https://jensbuchert.bandcamp.com/track/rauschebart" song = Song(title="Rauschebart", artist="Jens Buchert", album="Gravity") app._bandcamp_urls[f"{station.id}|{song.key()}"] = url station.now_playing = song app._update_bandcamp() await pilot.pause() assert song.bandcamp_url == url assert "Bandcamp" in rendered_text(app) @pytest.mark.asyncio async def test_media_session_commands_drive_the_app() -> None: app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(100, 30)) as pilot: await pilot.pause() # The status bar names whichever backend this platform uses. assert app.media.label in {"mpris", "nowplaying"} assert app.media.status_label == "off" # audio disabled # The ticker drains the queue, so give it a couple of ticks. before = app.current.id app.media_actions.put("next") await pilot.pause(0.2) assert app.current.id != before app.media_actions.put("volume:25") await pilot.pause(0.2) assert app.volume == 25 @pytest.mark.asyncio async def test_menus_take_the_inline_artwork_down_and_put_it_back(monkeypatch) -> None: """Terminal images sit over a modal unless the image is deleted first.""" calls: list[str] = [] monkeypatch.setattr(ArtPane, "suspend", lambda self: calls.append("suspend")) monkeypatch.setattr(ArtPane, "resume", lambda self: calls.append("resume")) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(100, 30)) as pilot: await pilot.pause() await pilot.press("question_mark") await pilot.pause() assert calls == ["suspend"] await pilot.press("x") await pilot.pause() assert calls == ["suspend", "resume"] await pilot.press("p") await pilot.pause() assert calls[-1] == "suspend" await pilot.press("escape") await pilot.pause() assert calls[-1] == "resume" @pytest.mark.asyncio async def test_the_mosaic_gives_way_to_a_real_image(monkeypatch) -> None: """The pattern must not peek out around a letterboxed picture.""" from beefy_player.widgets import art as art_module monkeypatch.setattr(art_module.artwork, "supported", lambda: True) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(110, 34)) as pilot: await pilot.pause() pane = app.query_one(ArtPane) assert pane.display, "the artwork pane should be on screen at this size" app.artwork_bytes = None patterned = [line.plain for line in pane.render().renderables] assert patterned and any(line.strip() for line in patterned) app.artwork_bytes = b"pretend this is a png" blank = [line.plain for line in pane.render().renderables] assert len(blank) == len(patterned) assert len(blank[0]) == len(patterned[0]) assert all(not line.strip() for line in blank)