Web frontend
This commit is contained in:
357
python-backend/tests/test_web.py
Normal file
357
python-backend/tests/test_web.py
Normal 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")
|
||||
Reference in New Issue
Block a user