"""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