Files
musicmouse/python-backend/tests/test_web.py
Martin Bauer 747c390303 Add IR remote control (LIRC) with a number-key content mapping
Adds a TCP client for lircd's classic protocol: play/pause/next/prev/
volume/mute map to the same intents every other front-end already
emits, and number keys 0-9 play an assigned album/audiobook from the
start or a podcast show's newest episode, resolved fresh on every
press. The mapping is configured in config.yml and editable from the
frontend: a small "Taste zuweisen" button on the play screen (or the
A+digit keyboard shortcut) opens a 10-key picker to assign whatever is
currently playing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 08:31:28 +02:00

575 lines
20 KiB
Python

"""The web front-end, driven against the simulator.
Everything below the HTTP layer is production code: the same bus, the same reactions,
the same player interface - only the serial link and VLC are fake.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from collections.abc import AsyncIterator
from pathlib import Path
import httpx2
import pytest
from fastapi import FastAPI
from musicmouse.config import WebConfig, load_config
from musicmouse.library.analysis import BeatGrid, TrackCurves
from musicmouse.library.models import track_key
from musicmouse.services.web.service import build_app
from musicmouse.simulator.harness import Simulation, build_simulation
from tests.conftest import VALID_CONFIG, write_config
from tests.websocket_harness import WebSocketSession, websocket_connect
TRACK_SECONDS = 10.0
#: Shorthand: every test takes the same client type.
type Client = httpx2.AsyncClient
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
config = load_config(write_config(config_dir, VALID_CONFIG))
simulation = await build_simulation(config, track_duration=TRACK_SECONDS)
try:
yield simulation
finally:
await simulation.aclose()
@pytest.fixture
async def api(sim: Simulation, config_dir: Path) -> AsyncIterator[FastAPI]:
"""The real ASGI app, on the test's own event loop."""
application, hub = build_app(sim.app, WebConfig(), config_dir / "config.yml")
hub.start()
try:
yield application
finally:
hub.stop()
await application.state.ha_client.aclose()
@pytest.fixture
async def client(api: FastAPI) -> AsyncIterator[httpx2.AsyncClient]:
transport = httpx2.ASGITransport(app=api)
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
yield http
async def album_by_title(client: httpx2.AsyncClient, title: str) -> dict:
albums = (await client.get("/api/library")).json()["albums"]
return next(album for album in albums if album["title"] == title)
# ------------------------------------------------------------------------- library
async def test_library_lists_every_album_with_its_tracks(client: Client) -> None:
body = (await client.get("/api/library")).json()
titles = {album["title"] for album in body["albums"]}
assert titles == {
"Fuchs",
"Eule",
"Kinderparty Lieder",
"Conni in den Bergen",
"Alt",
"Neu",
}
album = await album_by_title(client, "Conni in den Bergen")
assert album["kind"] == "book"
assert album["category"] == "Conni"
assert len(album["colors"]) == 3
assert [track["title"] for track in album["tracks"]] == ["Teil 0", "Teil 1"]
async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None:
album = await album_by_title(client, "Fuchs")
assert album["has_cover"] is False
assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404
async def test_unanalyzed_tracks_report_no_analysis(client: Client) -> None:
album = await album_by_title(client, "Eule")
assert all(track["analysis"] is None for track in album["tracks"])
assert (await client.get(f"/api/tracks/{album['id']}/0/analysis")).status_code == 404
async def test_an_analyzed_track_reports_its_beats_and_curves(
client: Client, sim: Simulation
) -> None:
album = await album_by_title(client, "Eule")
library_album = sim.app.library.get(album["id"])
assert library_album is not None
key = track_key(library_album.tracks[0].path)
sim.app.library.cache.store_beats(key, BeatGrid((0.5, 1.0), (1.0, 0.5)))
sim.app.library.cache.store_curve(
key, TrackCurves(hop_seconds=1.0, energy=(0.4, 0.6), valence=(0.5, 0.5), drive=(0.3, 0.7))
)
response = await client.get(f"/api/tracks/{album['id']}/0/analysis")
assert response.status_code == 200
body = response.json()
assert body["times"] == [0.5, 1.0]
assert body["curve"]["energy"] == [0.4, 0.6]
assert body["curve"]["valence"] == [0.5, 0.5]
assert body["curve"]["drive"] == [0.3, 0.7]
async def test_a_track_with_a_curve_but_no_reliable_beat_still_reports_200(
client: Client, sim: Simulation
) -> None:
"""A free-tempo/spoken-word track has no usable beat grid, but energy/valence/
drive are computed regardless - the endpoint must not 404 just because `beats`
came back empty."""
album = await album_by_title(client, "Eule")
library_album = sim.app.library.get(album["id"])
assert library_album is not None
key = track_key(library_album.tracks[0].path)
sim.app.library.cache.store_curve(
key, TrackCurves(hop_seconds=1.0, energy=(0.5,), valence=(0.5,), drive=(0.5,))
)
response = await client.get(f"/api/tracks/{album['id']}/0/analysis")
assert response.status_code == 200
body = response.json()
assert body["times"] == []
assert body["curve"]["energy"] == [0.5]
# -------------------------------------------------------------------------- state
async def test_state_starts_idle(client: Client) -> None:
state = (await client.get("/api/state")).json()
assert state["playing"] is False
assert state["album_id"] is None
assert state["active_figure"] is None
assert state["connected"] == {"firmware": True, "mqtt": False, "lirc": False}
async def test_play_loads_the_album_and_starts_it(client: Client, sim: Simulation) -> None:
album = await album_by_title(client, "Kinderparty Lieder")
response = await client.post("/api/play", json={"album_id": album["id"]})
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.is_playing
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == album["id"]
assert (await client.get("/api/state")).json()["album_title"] == "Kinderparty Lieder"
async def test_track_titles_come_from_the_tags_not_the_filename(
client: Client, sim: Simulation
) -> None:
"""``01 - So ein schoener Tag.mp3`` is a filename, not a title."""
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"]})
await sim.bus.drain()
state = (await client.get("/api/state")).json()
assert state["track_title"] == "Track 0"
assert state["duration"] > 0
async def test_play_can_start_at_a_track(client: Client, sim: Simulation) -> None:
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"], "track_index": 2})
await sim.bus.drain()
assert sim.player.track_index == 2
assert (await client.get("/api/state")).json()["track_index"] == 2
async def test_playing_a_figure_album_reuses_the_figure_playlist(
client: Client, sim: Simulation
) -> None:
"""Identity matters: ``play_figure`` resumes on an ``is`` check."""
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"]})
await sim.bus.drain()
assert sim.player.playlist is sim.app.playlists["fuchs"]
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None:
response = await client.post("/api/play", json={"album_id": "nope"})
assert response.status_code == 404
async def test_transport_commands_reach_the_player(client: Client, sim: Simulation) -> None:
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"]})
await sim.bus.drain()
await client.post("/api/next")
await sim.bus.drain()
assert sim.player.track_index == 1
await client.post("/api/previous")
await sim.bus.drain()
assert sim.player.track_index == 0
await client.post("/api/pause")
await sim.bus.drain()
assert not sim.player.is_playing
await client.post("/api/resume")
await sim.bus.drain()
assert sim.player.is_playing
async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None:
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"]})
await sim.bus.drain()
response = await client.post("/api/seek", json={"position": 4.0})
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.position == pytest.approx(4.0, abs=0.1)
async def test_seeking_backwards_is_rejected(client: Client) -> None:
assert (await client.post("/api/seek", json={"position": -1})).status_code == 422
# ------------------------------------------------------------------------- volume
async def test_full_volume_means_the_configured_ceiling(
client: Client, sim: Simulation
) -> None:
"""The child sees 0..100; the config's max of 60 never crosses the API boundary."""
response = await client.post("/api/volume", json={"percent": 100})
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.volume == 60
assert (await client.get("/api/state")).json()["volume"] == 100
async def test_volume_scales_across_the_allowed_range(
client: Client, sim: Simulation
) -> None:
await client.post("/api/volume", json={"percent": 50})
await sim.bus.drain()
assert sim.player.volume == 30
assert (await client.get("/api/state")).json()["volume"] == 50
async def test_volume_steps_are_relative_to_the_percentage(
client: Client, sim: Simulation
) -> None:
await client.post("/api/volume", json={"percent": 50})
await sim.bus.drain()
await client.post("/api/volume", json={"delta_percent": 10})
await sim.bus.drain()
assert (await client.get("/api/state")).json()["volume"] == 60
assert sim.player.volume == 36
async def test_volume_steps_clamp_at_the_ends(client: Client, sim: Simulation) -> None:
await client.post("/api/volume", json={"percent": 95})
await sim.bus.drain()
await client.post("/api/volume", json={"delta_percent": 20})
await sim.bus.drain()
assert (await client.get("/api/state")).json()["volume"] == 100
async def test_the_device_volume_range_is_never_exposed(client: Client) -> None:
state = (await client.get("/api/state")).json()
assert "volume_min" not in state
assert "volume_max" not in state
async def test_volume_needs_one_of_the_two_fields(client: Client) -> None:
assert (await client.post("/api/volume", json={})).status_code == 422
# ---------------------------------------------------------------------- websocket
async def last_state(socket: WebSocketSession) -> dict:
"""Drain every currently queued frame and return the most recent state payload.
A single backend action can trigger more than one broadcast (e.g. a reaction to
``PlaybackChanged`` announcing its own state change); callers only care about
where things ended up, not how many frames it took to get there. Call only after
the bus has been drained, so every frame the action produced is already queued.
"""
state = None
while not socket.from_app.empty():
message = await socket.next_json()
if message["type"] == "state":
state = message["state"]
assert state is not None
return state
async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) -> None:
"""State events only fire on change, so a tab that connects mid-track needs this."""
async with websocket_connect(api, "/api/ws") as socket:
message = await socket.next_json()
assert message["type"] == "state"
assert message["state"]["playing"] is False
async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None:
async with websocket_connect(api, "/api/ws") as first, websocket_connect(
api, "/api/ws"
) as second:
await first.next_json()
await second.next_json()
await sim.driver.place("fuchs")
await sim.bus.drain()
for socket in (first, second):
message = await socket.next_json()
assert message["type"] == "state"
assert message["state"]["active_figure"] == "fuchs"
async def test_switching_albums_while_playing_reaches_every_client(
api: FastAPI, client: Client, sim: Simulation
) -> None:
"""Regression: playing album B from track 0 while album A was already playing used
to never broadcast, leaving PlayView stuck on album A until a reload."""
first = await album_by_title(client, "Fuchs")
second = await album_by_title(client, "Eule")
async with websocket_connect(api, "/api/ws") as socket:
await socket.next_json()
await client.post("/api/play", json={"album_id": first["id"]})
await sim.bus.drain()
assert (await last_state(socket))["album_title"] == "Fuchs"
await client.post("/api/play", json={"album_id": second["id"]})
await sim.bus.drain()
assert (await last_state(socket))["album_title"] == "Eule"
async def test_a_refresh_tells_the_clients_to_reload_the_library(
api: FastAPI, client: Client, sim: Simulation
) -> None:
async with websocket_connect(api, "/api/ws") as socket:
await socket.next_json()
response = await client.post("/api/library/refresh")
assert response.status_code == 202
message = await socket.next_json(timeout=5.0)
assert message["type"] == "library"
assert set(sim.app.playlists) == {"fuchs", "eule"}
# ------------------------------------------------------------------------ figures
async def test_the_web_ui_follows_a_figure_placed_on_the_reader(
client: Client, sim: Simulation
) -> None:
await sim.driver.place("fuchs")
await sim.bus.drain()
state = (await client.get("/api/state")).json()
assert state["active_figure"] == "fuchs"
assert state["playing"] is True
assert state["album_title"] == "Fuchs"
# ------------------------------------------------------------------------------- ha
HA_URL = "http://homeassistant.local:8123"
HA_TOKEN = "abc123"
VALID_CONFIG_WITH_HA: dict = {
**VALID_CONFIG,
"general": {
**VALID_CONFIG["general"],
"ha": {
"url": HA_URL,
"token": HA_TOKEN,
"devices": [
{"entity_id": "cover.rollo", "name": "Rollo"},
{"entity_id": "light.deckenlampe"},
],
"scenes": [{"entity_id": "scene.lesen", "name": "Lesen"}],
},
},
}
class FakeHomeAssistant:
"""Records every request the proxy makes and answers a couple of fixed routes."""
def __init__(self) -> None:
self.requests: list[httpx2.Request] = []
self.unreachable = False
def handler(self, request: httpx2.Request) -> httpx2.Response:
self.requests.append(request)
if self.unreachable:
raise httpx2.ConnectError("mock: connection refused")
if request.url.path == "/api/states/light.deckenlampe":
return httpx2.Response(200, json={"entity_id": "light.deckenlampe", "state": "on"})
if request.url.path == "/api/states/light.missing":
return httpx2.Response(404, json={"message": "Entity not found"})
if request.url.path == "/api/services/light/turn_on":
return httpx2.Response(200, json=[{"entity_id": "light.deckenlampe", "state": "on"}])
return httpx2.Response(404, json={"message": "unhandled in test"})
@pytest.fixture
async def fake_ha() -> FakeHomeAssistant:
return FakeHomeAssistant()
@pytest.fixture
async def client_with_ha(
config_dir: Path, fake_ha: FakeHomeAssistant
) -> AsyncIterator[httpx2.AsyncClient]:
"""A second client, built from a config with an ``ha:`` section and an ``ha_client``
pointed at ``fake_ha`` instead of the network, without touching the shared
``sim``/``api``/``client`` fixtures every other test in this file uses."""
config = load_config(write_config(config_dir, VALID_CONFIG_WITH_HA))
simulation = await build_simulation(config, track_duration=TRACK_SECONDS)
try:
ha_client = httpx2.AsyncClient(transport=httpx2.MockTransport(fake_ha.handler))
application, hub = build_app(
simulation.app, WebConfig(), config_dir / "config.yml", ha_client=ha_client
)
hub.start()
try:
transport = httpx2.ASGITransport(app=application)
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
yield http
finally:
hub.stop()
await ha_client.aclose()
finally:
await simulation.aclose()
async def test_ha_is_a_404_when_not_configured(client: Client) -> None:
assert (await client.get("/api/ha")).status_code == 404
assert (await client.get("/api/ha/states/light.x")).status_code == 404
assert (await client.post("/api/ha/services/light/turn_on", json={})).status_code == 404
async def test_ha_config_returns_ordered_lists_without_the_token(client_with_ha: Client) -> None:
body = (await client_with_ha.get("/api/ha")).json()
assert "url" not in body
assert "token" not in body
assert body["devices"] == [
{"entity_id": "cover.rollo", "name": "Rollo"},
{"entity_id": "light.deckenlampe", "name": None},
]
assert body["scenes"] == [{"entity_id": "scene.lesen", "name": "Lesen"}]
async def test_ha_states_proxies_to_home_assistant_with_the_token_attached(
client_with_ha: Client, fake_ha: FakeHomeAssistant
) -> None:
response = await client_with_ha.get("/api/ha/states/light.deckenlampe")
assert response.status_code == 200
assert response.json() == {"entity_id": "light.deckenlampe", "state": "on"}
assert len(fake_ha.requests) == 1
upstream = fake_ha.requests[0]
assert str(upstream.url) == f"{HA_URL}/api/states/light.deckenlampe"
assert upstream.headers["authorization"] == f"Bearer {HA_TOKEN}"
async def test_ha_states_passes_through_a_404_from_home_assistant(client_with_ha: Client) -> None:
response = await client_with_ha.get("/api/ha/states/light.missing")
assert response.status_code == 404
async def test_ha_services_proxies_the_body_with_the_token_attached(
client_with_ha: Client, fake_ha: FakeHomeAssistant
) -> None:
response = await client_with_ha.post(
"/api/ha/services/light/turn_on",
json={"entity_id": "light.deckenlampe", "brightness_pct": 80},
)
assert response.status_code == 200
upstream = fake_ha.requests[0]
assert str(upstream.url) == f"{HA_URL}/api/services/light/turn_on"
assert upstream.headers["authorization"] == f"Bearer {HA_TOKEN}"
assert json.loads(upstream.content) == {"entity_id": "light.deckenlampe", "brightness_pct": 80}
async def test_ha_proxy_reports_an_unreachable_home_assistant_as_a_502(
client_with_ha: Client, fake_ha: FakeHomeAssistant
) -> None:
fake_ha.unreachable = True
response = await client_with_ha.get("/api/ha/states/light.deckenlampe")
assert response.status_code == 502
# ------------------------------------------------- through a real uvicorn, not just ASGI
async def test_websockets_survive_a_real_server(sim: Simulation, config_dir: Path) -> None:
"""Boot the actual service and open an actual websocket.
Everything above drives the ASGI app directly, which cannot see whether uvicorn is
able to answer an upgrade at all - and it is not, unless a websocket implementation
is installed alongside it. That gap answered ``/api/ws`` with a 404 in production
while every other test passed.
"""
import json
import socket
import websockets
from musicmouse.services.web.service import WebService
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
service = WebService(
sim.app,
WebConfig(host="127.0.0.1", port=port),
config_dir / "config.yml",
)
server = asyncio.create_task(service.run(), name="web-service")
try:
await _wait_for_port(port)
async with websockets.connect(f"ws://127.0.0.1:{port}/api/ws") as client:
snapshot = json.loads(await asyncio.wait_for(client.recv(), 5.0))
assert snapshot["type"] == "state"
assert snapshot["state"]["playing"] is False
finally:
server.cancel()
with contextlib.suppress(asyncio.CancelledError):
await server
async def _wait_for_port(port: int, timeout: float = 5.0) -> None:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
try:
reader, writer = await asyncio.open_connection("127.0.0.1", port)
except OSError:
await asyncio.sleep(0.05)
continue
del reader
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
return
raise AssertionError(f"nothing listening on {port} after {timeout}s")