167 lines
5.7 KiB
Python
167 lines
5.7 KiB
Python
"""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
|