The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.
A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
7.7 KiB
Python
236 lines
7.7 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)
|
|
|
|
|
|
# ------------------------------------------------------------------------------ lirc
|
|
|
|
|
|
async def test_lirc_is_a_404_when_unconfigured(client: httpx2.AsyncClient) -> None:
|
|
assert (await client.get("/api/lirc")).status_code == 404
|
|
|
|
|
|
# --------------------------------------------------------------------------- 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
|