624 lines
20 KiB
Python
624 lines
20 KiB
Python
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)
|