Files
musicmouse/python-backend/tests/test_remote_api.py
Martin Bauer 751637d2f1 Remove dead code
- wire.with_figure: the device uses dataclasses.replace directly.
- DeviceConnected/DeviceDisconnected: emitted, never subscribed to, and a duplicate
  of ConnectionChanged(target="firmware").
- ScreenService.toggle(): never called.
- GET /api/lirc (LircConfigOut, api.lircConfig): no front-end reads it.
- POST /api/volume's delta_percent (api.nudgeVolume): no front-end sends it.
- LibraryCache.shrink_stored_covers: a one-off migration that re-read every cover
  header on every scan. _INDEX_VERSION 7 rebuilds any cache that could still need it.
- Firmware: the MFRC522 library and its unused globals (lib/nfc/rc522.c is the
  driver in use), and mfrc_version.h, a reader test sketch nothing included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:27 +02:00

229 lines
7.4 KiB
Python

"""The IR remote's number-key mapping, over the REST API."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import httpx2
import pytest
from musicmouse.config import RemoteSlotConfig, 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
#: A config file with comments, to prove saving does not flatten them.
COMMENTED = """\
# The MusicMouse config.
general:
library:
root: music
cache: .cache
serial_port: "/dev/ttyUSB0"
alsa_device: simulate
min_volume: 0
max_volume: 60
initial_volume: 40
figures:
fuchs:
id: "04a1b2c3d4"
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
eule:
id: "04b2c3d4e5"
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
"""
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
config = load_config(write_config(config_dir, VALID_CONFIG))
simulation = await build_simulation(config)
try:
yield simulation
finally:
await simulation.aclose()
@pytest.fixture
async def client(sim: Simulation, config_dir: Path) -> AsyncIterator[httpx2.AsyncClient]:
path = config_dir / "config.yml"
path.write_text(COMMENTED, encoding="utf-8")
api, hub = build_app(sim.app, WebConfig(), path)
hub.start()
transport = httpx2.ASGITransport(app=api)
try:
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
yield http
finally:
hub.stop()
await api.state.ha_client.aclose()
async def album_id(sim: Simulation, title: str) -> str:
return next(a.id for a in sim.app.library.albums if a.title == title)
# --------------------------------------------------------------------------- reading
async def test_the_mapping_is_empty_by_default(client: httpx2.AsyncClient) -> None:
body = (await client.get("/api/remote/mapping")).json()
assert body["slots"] == []
# --------------------------------------------------------------------------- writing
async def test_saving_an_album_slot_round_trips(
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping", json={"slots": {"3": {"target_kind": "album", "target": target}}}
)
assert response.status_code == 200
slots = response.json()["slots"]
assert slots == [
{
"digit": "3",
"target_kind": "album",
"target": target,
"resolved_album_id": target,
"track_index": 0,
}
]
text = (config_dir / "config.yml").read_text(encoding="utf-8")
assert "# The MusicMouse config." in text # comments survive
reloaded = load_config(config_dir / "config.yml")
assert reloaded.remote["3"].target == target
assert "track_index" not in text # a whole-album slot reads as it always did
async def test_saving_a_song_slot_round_trips(
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping",
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 1}}},
)
assert response.status_code == 200
assert response.json()["slots"][0]["track_index"] == 1
assert (await client.get("/api/remote/mapping")).json()["slots"][0]["track_index"] == 1
assert sim.app.config.remote["4"].track_index == 1
reloaded = load_config(config_dir / "config.yml")
assert reloaded.remote["4"].track_index == 1
async def test_a_song_past_the_end_of_the_album_is_rejected(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping",
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 2}}},
)
assert response.status_code == 422
assert "no track 2" in response.json()["detail"]
async def test_a_song_of_a_series_is_rejected(client: httpx2.AsyncClient) -> None:
response = await client.put(
"/api/remote/mapping",
json={
"slots": {
"7": {"target_kind": "series", "target": "Wissen macht Ah", "track_index": 1}
}
},
)
assert response.status_code == 422
# --------------------------------------------------------------------------- pressing
async def test_pressing_a_key_plays_its_song_and_then_the_rest(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
sim.app.config.remote = {
"4": RemoteSlotConfig(target_kind="album", target=target, track_index=1)
}
response = await client.post("/api/remote/press/4")
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == target
assert len(sim.player.playlist) == 2 # the whole album, not just the one song
assert sim.player.track_index == 1
async def test_pressing_an_unassigned_key_does_nothing(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
assert (await client.post("/api/remote/press/5")).status_code == 204
await sim.bus.drain()
assert sim.player.playlist is None
async def test_pressing_a_non_digit_is_rejected(client: httpx2.AsyncClient) -> None:
assert (await client.post("/api/remote/press/x")).status_code == 422
async def test_saving_a_series_slot_resolves_to_the_latest_episode(
client: httpx2.AsyncClient,
) -> None:
response = await client.put(
"/api/remote/mapping",
json={"slots": {"7": {"target_kind": "series", "target": "Wissen macht Ah"}}},
)
assert response.status_code == 200
slot = response.json()["slots"][0]
assert slot["target_kind"] == "series"
assert slot["target"] == "Wissen macht Ah"
assert slot["resolved_album_id"] is not None
async def test_saving_clears_digits_left_out(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
await client.put(
"/api/remote/mapping", json={"slots": {"1": {"target_kind": "album", "target": target}}}
)
response = await client.put("/api/remote/mapping", json={"slots": {}})
assert response.json()["slots"] == []
async def test_an_unresolvable_album_target_is_rejected(client: httpx2.AsyncClient) -> None:
response = await client.put(
"/api/remote/mapping",
json={"slots": {"1": {"target_kind": "album", "target": "no-such-album"}}},
)
assert response.status_code == 422
assert "1" in response.json()["detail"]
async def test_an_unresolvable_series_target_is_rejected(client: httpx2.AsyncClient) -> None:
response = await client.put(
"/api/remote/mapping",
json={"slots": {"2": {"target_kind": "series", "target": "no such show"}}},
)
assert response.status_code == 422
async def test_a_rejected_save_leaves_the_file_alone(
client: httpx2.AsyncClient, config_dir: Path
) -> None:
before = (config_dir / "config.yml").read_text(encoding="utf-8")
await client.put(
"/api/remote/mapping",
json={"slots": {"1": {"target_kind": "album", "target": "no-such-album"}}},
)
assert (config_dir / "config.yml").read_text(encoding="utf-8") == before