Web frontend

This commit is contained in:
2026-08-27 12:32:20 +02:00
parent d44c24ec97
commit edb6e5e027
97 changed files with 9535 additions and 195 deletions

View File

@@ -0,0 +1,357 @@
"""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
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
import httpx2
import pytest
from fastapi import FastAPI
from musicmouse.config import WebConfig, load_config
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 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
def api(sim: Simulation, config_dir: Path) -> Iterator[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()
@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",
"Wissen macht Ah",
}
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
# -------------------------------------------------------------------------- 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}
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 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_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"
# ------------------------------------------------- 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")