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