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
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 == ""
assert len(app.view) == len(app.stations)
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_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)