"""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")