358 lines
12 KiB
Python
358 lines
12 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
|
|
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")
|