feat(ui): add catalogue picker, schedule pane and per-provider hero
This commit is contained in:
1 parent
058d336a2c
commit
b31ad3f6fb
16 files changed
+1211
-65
No files matched your search
+51
-4
@@ -6,6 +6,8 @@ import types
|
||||
import pytest
|
||||
from rich.cells import cell_len
|
||||
|
||||
from beefy_player.providers.somafm import SomaFMProvider
|
||||
from beefy_player.providers.tilderadio import TilderadioProvider
|
||||
from beefy_player.widgets.header import HeaderBar
|
||||
|
||||
TIME = re.compile(r"\d\d:\d\d:\d\d$")
|
||||
@@ -13,11 +15,17 @@ DATE = re.compile(r"[A-Z][a-z]{2} \d\d [A-Z][a-z]{2}$")
|
||||
VOLUME = re.compile(r"vol \d+%$")
|
||||
|
||||
|
||||
def render_header(width: int, height: int = 3) -> list[str]:
|
||||
def render_header(
|
||||
width: int, height: int = 3, provider=None
|
||||
) -> list[str]:
|
||||
"""Render the header without a running app, as plain text per line."""
|
||||
fake = type("Fake", (), {})()
|
||||
fake.app = types.SimpleNamespace(
|
||||
playing_state="playing", volume=70, current=None, notice=None
|
||||
playing_state="playing",
|
||||
volume=70,
|
||||
current=None,
|
||||
notice=None,
|
||||
provider=provider or SomaFMProvider(),
|
||||
)
|
||||
fake.size = types.SimpleNamespace(width=width, height=height)
|
||||
return [line.plain.rstrip() for line in HeaderBar.render(fake).renderables]
|
||||
@@ -34,8 +42,8 @@ def test_right_block_is_flush_right(width: int) -> None:
|
||||
|
||||
|
||||
def test_ragged_middle_does_not_shift_the_right_block() -> None:
|
||||
""""SOMA FM RADIO" and "SAN FRANCISCO" are shorter than
|
||||
"LISTENER SUPPORTED"; the right block must not follow their length."""
|
||||
""""TILDERADIO" is shorter than "STREAMED BY / FOR USERS OF THE"; the
|
||||
right block must not follow the length of the middle lines."""
|
||||
clock, date, volume = render_header(143)
|
||||
assert cell_len(clock) == cell_len(date) == cell_len(volume) == 143
|
||||
|
||||
@@ -44,3 +52,42 @@ def test_right_block_hidden_on_narrow_terminals() -> None:
|
||||
"""Below the threshold the right block is dropped, not squashed."""
|
||||
lines = render_header(30)
|
||||
assert all(":" not in line for line in lines)
|
||||
|
||||
|
||||
def test_somafm_hero_is_the_wordmark_and_its_taglines() -> None:
|
||||
lines = render_header(143)
|
||||
assert lines[0].startswith("▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄")
|
||||
assert "SOMA FM RADIO" in lines[0]
|
||||
assert "LISTENER SUPPORTED" in lines[1]
|
||||
assert "SAN FRANCISCO" in lines[2]
|
||||
|
||||
|
||||
def test_tilderadio_hero_is_its_own_art_and_motto() -> None:
|
||||
provider = TilderadioProvider()
|
||||
lines = render_header(143, height=len(provider.hero), provider=provider)
|
||||
assert len(lines) == len(provider.hero)
|
||||
# the caption block is centred against the seven-row radio icon
|
||||
assert "TILDERADIO" in lines[2]
|
||||
assert "STREAMED BY / FOR USERS OF THE" in lines[3]
|
||||
assert "TILDEVERSE" in lines[4]
|
||||
# the radio icon's signature green wave survives the conversion
|
||||
styles = [
|
||||
str(span.style)
|
||||
for line in HeaderBar.render(_fake(provider)).renderables
|
||||
for span in line.spans
|
||||
]
|
||||
assert any("green" in style for style in styles)
|
||||
assert all("SOMA" not in line for line in lines)
|
||||
|
||||
|
||||
def _fake(provider):
|
||||
fake = type("Fake", (), {})()
|
||||
fake.app = types.SimpleNamespace(
|
||||
playing_state="playing",
|
||||
volume=70,
|
||||
current=None,
|
||||
notice=None,
|
||||
provider=provider,
|
||||
)
|
||||
fake.size = types.SimpleNamespace(width=143, height=len(provider.hero) or 3)
|
||||
return fake
|
||||
+106
-1
@@ -11,6 +11,8 @@ from beefy_player.models import Song, Station
|
||||
from beefy_player.providers import PROVIDERS, available_providers, get_provider
|
||||
from beefy_player.providers.base import RadioProvider
|
||||
from beefy_player.providers.somafm import SomaFMProvider
|
||||
from beefy_player.providers.tilderadio import TilderadioProvider
|
||||
from beefy_player.widgets.header import HeaderBar
|
||||
|
||||
|
||||
def rendered_text(app) -> str:
|
||||
@@ -19,9 +21,10 @@ def rendered_text(app) -> str:
|
||||
|
||||
|
||||
def test_registry_lists_somafm_as_the_default() -> None:
|
||||
assert available_providers() == ("somafm",)
|
||||
assert available_providers() == ("somafm", "tilderadio")
|
||||
assert isinstance(get_provider(), SomaFMProvider)
|
||||
assert get_provider("somafm") is get_provider()
|
||||
assert isinstance(get_provider("tilderadio"), TilderadioProvider)
|
||||
# a stale slug in the config file must not stop the app from starting
|
||||
assert get_provider("no-such-catalogue") is get_provider()
|
||||
|
||||
@@ -128,3 +131,105 @@ def test_environment_variables_prefer_the_beefy_prefix(monkeypatch) -> None:
|
||||
|
||||
monkeypatch.delenv("SOMAFM_IMAGES")
|
||||
assert config.env_var("IMAGES") == ""
|
||||
|
||||
|
||||
def test_tilderadio_replaces_the_station_list_with_a_schedule() -> None:
|
||||
provider = TilderadioProvider()
|
||||
assert provider.has_schedule is True
|
||||
# the SomaFM wordmark's slot now holds tilderadio's own art and motto
|
||||
assert provider.hero and not provider.hero_gradient
|
||||
assert provider.hero_lines == (
|
||||
"TILDERADIO",
|
||||
"STREAMED BY / FOR USERS OF THE",
|
||||
"TILDEVERSE",
|
||||
)
|
||||
assert SomaFMProvider().has_schedule is False
|
||||
|
||||
|
||||
class _CatalogueApp:
|
||||
"""A headless app on a fixed catalogue, for the picker tests."""
|
||||
|
||||
def __init__(self, provider: str = "somafm") -> None:
|
||||
from beefy_player.app import BeefyPlayerApp
|
||||
|
||||
self.app = BeefyPlayerApp(
|
||||
provider=provider, autoplay=False, offline=True, audio=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p_opens_the_picker_and_switches_catalogue() -> None:
|
||||
from beefy_player.widgets.providers import ProviderScreen
|
||||
from beefy_player.widgets.schedule import SchedulePanel
|
||||
from beefy_player.widgets.station_list import StationList
|
||||
|
||||
app = _CatalogueApp().app
|
||||
async with app.run_test(size=(110, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
assert app.provider.id == "somafm"
|
||||
assert app.query_one(StationList).display is True
|
||||
assert app.query_one(SchedulePanel).display is False
|
||||
assert app.query_one(HeaderBar).styles.height.value == 4
|
||||
|
||||
await pilot.press("p")
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, ProviderScreen)
|
||||
|
||||
await pilot.press("2") # jump straight to tilderadio
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
assert app.provider.id == "tilderadio"
|
||||
assert app.config.provider == "tilderadio"
|
||||
assert [station.id for station in app.stations] == ["tilderadio"]
|
||||
# the left pane becomes the schedule, and the hero grows
|
||||
assert app.query_one(StationList).display is False
|
||||
assert app.query_one(SchedulePanel).display is True
|
||||
assert app.query_one(HeaderBar).styles.height.value == 8
|
||||
assert "tilderadio" in app.title
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escape_closes_the_picker_without_switching() -> None:
|
||||
app = _CatalogueApp().app
|
||||
async with app.run_test(size=(110, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("p")
|
||||
await pilot.pause()
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.provider.id == "somafm"
|
||||
assert app.config.provider != "tilderadio"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_names_both_catalogues_and_the_switch_key() -> None:
|
||||
app = _CatalogueApp().app
|
||||
async with app.run_test(size=(120, 60)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("question_mark")
|
||||
await pilot.pause()
|
||||
text = rendered_text(app)
|
||||
assert "CATALOGUES" in text
|
||||
assert "somafm" in text and "tilderadio" in text
|
||||
assert "switch station catalogue" in text
|
||||
|
||||
|
||||
def test_config_remembers_a_station_per_catalogue() -> None:
|
||||
config.Config(last_stations={"somafm": "defcon", "tilderadio": "tilderadio"}).save()
|
||||
loaded = config.Config.load()
|
||||
assert loaded.last_station_for("somafm") == "defcon"
|
||||
assert loaded.last_station_for("tilderadio") == "tilderadio"
|
||||
# a catalogue nobody has tuned yet falls back to the legacy single field
|
||||
assert loaded.last_station_for("no-such-catalogue") == loaded.last_station
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_terminals_crop_the_hero_but_keep_the_ticker() -> None:
|
||||
app = _CatalogueApp("tilderadio").app
|
||||
async with app.run_test(size=(90, 24)) as pilot:
|
||||
await pilot.pause()
|
||||
header = app.query_one(HeaderBar)
|
||||
assert header.styles.height.value <= 6
|
||||
lines = [line.plain for line in header.render().renderables]
|
||||
assert any("STOPPED" in line or "PLAYING" in line for line in lines)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""tilderadio's schedule: parsing, the live slot, caching and the pane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from beefy_player.app import BeefyPlayerApp
|
||||
from beefy_player.models import Broadcast
|
||||
from beefy_player.providers.tilderadio import api
|
||||
from beefy_player.widgets.schedule import SchedulePanel, _upcoming_rows
|
||||
|
||||
|
||||
def iso(moment: datetime) -> str:
|
||||
return moment.isoformat()
|
||||
|
||||
|
||||
def test_parse_schedule_reads_the_documented_feed() -> None:
|
||||
payload = {
|
||||
"schedule": [
|
||||
{
|
||||
"name": "controlfreak",
|
||||
"slug": "controlfreak",
|
||||
"start": "2026-11-04T02:00:00+00:00",
|
||||
"end": "2026-11-04T03:00:00+00:00",
|
||||
"start_ts": 1793757600,
|
||||
"end_ts": 1793761200,
|
||||
},
|
||||
{
|
||||
"name": "pa2",
|
||||
"slug": "pa2",
|
||||
"start": "2026-11-04T14:00:00+00:00",
|
||||
"end": "2026-11-04T15:00:00+00:00",
|
||||
"start_ts": 1793800800,
|
||||
"end_ts": 1793804400,
|
||||
},
|
||||
]
|
||||
}
|
||||
shows = api.parse_schedule(payload)
|
||||
assert [show.name for show in shows] == ["controlfreak", "pa2"]
|
||||
assert shows[0].start == datetime(2026, 11, 4, 2, tzinfo=timezone.utc)
|
||||
assert shows[0].end == datetime(2026, 11, 4, 3, tzinfo=timezone.utc)
|
||||
assert not shows[0].live
|
||||
# junk is dropped rather than crashing the pane
|
||||
assert api.parse_schedule({"schedule": [None, {}, {"name": "no start"}]}) == []
|
||||
assert api.parse_schedule(None) == []
|
||||
|
||||
|
||||
def test_live_broadcast_only_when_someone_is_on_air() -> None:
|
||||
payload = {
|
||||
"is_live": False,
|
||||
"dj": None,
|
||||
"broadcast_start": None,
|
||||
}
|
||||
assert api.live_broadcast(payload) is None
|
||||
|
||||
payload = {
|
||||
"is_live": True,
|
||||
"dj": "dokuja",
|
||||
"broadcast_start": 1793599228,
|
||||
}
|
||||
live = api.live_broadcast(payload)
|
||||
assert live is not None
|
||||
assert (live.name, live.dj, live.live) == ("dokuja", "dokuja", True)
|
||||
assert live.start is not None
|
||||
|
||||
|
||||
def client_for(now_payload, schedule_payload, status: int = 200) -> httpx.AsyncClient:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if str(request.url).endswith("/api/now/"):
|
||||
return httpx.Response(200, json=now_payload)
|
||||
if str(request.url).endswith("/api/schedule/"):
|
||||
return httpx.Response(status, json=schedule_payload)
|
||||
return httpx.Response(404, json={})
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_schedule_puts_the_live_show_first_and_drops_the_past() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_payload = {
|
||||
"is_live": True,
|
||||
"dj": "dokuja",
|
||||
"broadcast_start": iso(now - timedelta(minutes=25)),
|
||||
}
|
||||
schedule_payload = {
|
||||
"schedule": [
|
||||
{
|
||||
"name": "yesterday",
|
||||
"start": iso(now - timedelta(days=1)),
|
||||
"end": iso(now - timedelta(days=1) + timedelta(hours=1)),
|
||||
},
|
||||
{
|
||||
"name": "controlfreak",
|
||||
"start": iso(now + timedelta(hours=2)),
|
||||
"end": iso(now + timedelta(hours=3)),
|
||||
},
|
||||
]
|
||||
}
|
||||
async with client_for(now_payload, schedule_payload) as client:
|
||||
broadcasts = await api.fetch_schedule(client)
|
||||
|
||||
assert broadcasts[0].live and broadcasts[0].name == "dokuja"
|
||||
assert [item.name for item in broadcasts[1:]] == ["controlfreak"]
|
||||
|
||||
# ... and the schedule survives a dead endpoint
|
||||
async with client_for({"is_live": False}, {}, status=500) as client:
|
||||
offline = await api.fetch_schedule(client)
|
||||
assert [item.name for item in offline] == ["controlfreak"]
|
||||
|
||||
|
||||
def broadcast(name: str, minutes: int, *, live: bool = False) -> Broadcast:
|
||||
start = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
return Broadcast(
|
||||
name=name, dj=name, start=start, end=start + timedelta(hours=1), live=live
|
||||
)
|
||||
|
||||
|
||||
async def panel_lines(broadcasts: list[Broadcast], size=(100, 40)) -> list[str]:
|
||||
app = BeefyPlayerApp(provider="tilderadio", offline=True, audio=False)
|
||||
async with app.run_test(size=size) as pilot:
|
||||
await pilot.pause()
|
||||
app.schedule = broadcasts
|
||||
app.schedule_loaded = True
|
||||
panel = app.query_one(SchedulePanel)
|
||||
return [line.plain for line in panel.render().renderables]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_panel_shows_the_transmitter_and_what_is_next() -> None:
|
||||
lines = await panel_lines(
|
||||
[broadcast("dokuja", -20, live=True), broadcast("controlfreak", 90)]
|
||||
)
|
||||
text = "\n".join(lines)
|
||||
assert "ON AIR" in text and "dokuja" in text
|
||||
assert "since " in text and "until " in text
|
||||
assert "UPCOMING" in text
|
||||
assert "controlfreak" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_panel_says_auto_dj_when_nothing_is_live() -> None:
|
||||
lines = await panel_lines([broadcast("pa2", 120)])
|
||||
text = "\n".join(lines)
|
||||
assert "AUTO DJ" in text
|
||||
assert "library rotation" in text
|
||||
assert "ON AIR" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_panel_handles_an_empty_schedule() -> None:
|
||||
assert "nothing scheduled" in "\n".join(await panel_lines([]))
|
||||
|
||||
|
||||
def test_upcoming_rows_count_the_shows_they_had_to_hide() -> None:
|
||||
many = [broadcast(f"show {index}", 60 * (index + 1)) for index in range(30)]
|
||||
lines = [line.plain for line in _upcoming_rows(many, 34, 10)]
|
||||
assert len(lines) <= 10
|
||||
assert any("more" in line for line in lines)
|
||||
# every show is either listed or counted in the "… +N more" line
|
||||
listed = sum(1 for line in lines if "show " in line)
|
||||
hidden = int(lines[-1].split("+")[1].split()[0])
|
||||
assert listed + hidden == 30
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from beefy_player.app import BeefyPlayerApp
|
||||
from beefy_player.widgets.header import HeaderBar
|
||||
from beefy_player.widgets.schedule import SchedulePanel
|
||||
from beefy_player.widgets.search import SearchInput
|
||||
from beefy_player.widgets.spectrum import SpectrumPanel
|
||||
from beefy_player.widgets.stage import StagePanel
|
||||
@@ -42,6 +43,7 @@ async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
|
||||
HeaderBar,
|
||||
SearchInput,
|
||||
StationList,
|
||||
SchedulePanel,
|
||||
StagePanel,
|
||||
SpectrumPanel,
|
||||
StatusBar,
|
||||
|
||||
Reference in new issue
Block a user