Add IR remote control (LIRC) with a number-key content mapping
Adds a TCP client for lircd's classic protocol: play/pause/next/prev/ volume/mute map to the same intents every other front-end already emits, and number keys 0-9 play an assigned album/audiobook from the start or a podcast show's newest episode, resolved fresh on every press. The mapping is configured in config.yml and editable from the frontend: a small "Taste zuweisen" button on the play screen (or the A+digit keyboard shortcut) opens a 10-key picker to assign whatever is currently playing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,46 @@ def test_ha_section_is_optional(config_dir: Path) -> None:
|
||||
assert load_config(write_config(config_dir, VALID_CONFIG)).general.ha is None
|
||||
|
||||
|
||||
def test_lirc_section_is_optional(config_dir: Path) -> None:
|
||||
assert load_config(write_config(config_dir, VALID_CONFIG)).general.lirc is None
|
||||
|
||||
data = _config(lirc={"host": "musicmouse-pi.local"})
|
||||
config = load_config(write_config(config_dir, data))
|
||||
assert config.general.lirc is not None
|
||||
assert config.general.lirc.port == 2222
|
||||
assert config.general.lirc.remote_name == "Hauppauge"
|
||||
|
||||
|
||||
def test_remote_mapping_is_optional_and_empty_by_default(config_dir: Path) -> None:
|
||||
assert load_config(write_config(config_dir, VALID_CONFIG)).remote == {}
|
||||
|
||||
|
||||
def test_remote_mapping_loads_album_and_series_slots(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["remote"] = {
|
||||
"1": {"target_kind": "album", "target": "abc123"},
|
||||
"7": {"target_kind": "series", "target": "Wissen macht Ah"},
|
||||
}
|
||||
config = load_config(write_config(config_dir, data))
|
||||
assert config.remote["1"].target_kind == "album"
|
||||
assert config.remote["1"].target == "abc123"
|
||||
assert config.remote["7"].target_kind == "series"
|
||||
|
||||
|
||||
def test_remote_mapping_rejects_an_out_of_range_digit(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["remote"] = {"10": {"target_kind": "album", "target": "abc123"}}
|
||||
message = _error(config_dir, data)
|
||||
assert "remote" in message
|
||||
|
||||
|
||||
def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["remote"] = {"1": {"target_kind": "playlist", "target": "abc123"}}
|
||||
message = _error(config_dir, data)
|
||||
assert "remote" in message
|
||||
|
||||
|
||||
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
||||
data = _config(
|
||||
ha={
|
||||
|
||||
@@ -128,6 +128,28 @@ async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None:
|
||||
assert [a.title for a in episodes] == ["Neu", "Alt"]
|
||||
|
||||
|
||||
async def test_latest_episode_finds_the_newest_episode_of_a_show(config_dir: Path) -> None:
|
||||
library = await build(config_dir)
|
||||
latest = library.latest_episode("Wissen macht Ah")
|
||||
assert latest is not None
|
||||
assert latest.title == "Neu"
|
||||
|
||||
|
||||
async def test_latest_episode_is_none_for_an_unknown_show(config_dir: Path) -> None:
|
||||
library = await build(config_dir)
|
||||
assert library.latest_episode("no such show") is None
|
||||
|
||||
|
||||
async def test_latest_episode_ignores_series_that_are_not_episode_unit(config_dir: Path) -> None:
|
||||
"""A music/book section groups by `series` too (an audiobook's character), but it
|
||||
is a folder-unit shelf, not an episode-unit one - `latest_episode` must not treat
|
||||
an audiobook as if it had "episodes"."""
|
||||
library = await build(config_dir)
|
||||
audiobook = album_named(library, "Conni in den Bergen")
|
||||
assert audiobook.series is not None
|
||||
assert library.latest_episode(audiobook.series) is None
|
||||
|
||||
|
||||
async def test_other_sections_keep_filename_order(config_dir: Path) -> None:
|
||||
album = album_named(await build(config_dir), "Kinderparty Lieder")
|
||||
assert [track.path.name for track in album.tracks] == [
|
||||
|
||||
342
python-backend/tests/test_lirc.py
Normal file
342
python-backend/tests/test_lirc.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""The IR remote: line parsing, and the service against a scripted lircd."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.clock import FakeClock
|
||||
from musicmouse.config import LircConfig, RemoteSlotConfig, load_config
|
||||
from musicmouse.events import (
|
||||
ConnectionChanged,
|
||||
Event,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.services.lirc import LircService
|
||||
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
from tests.conftest import VALID_CONFIG, write_config
|
||||
|
||||
# ---------------------------------------------------------------------- parse_line
|
||||
|
||||
|
||||
def test_a_valid_line_is_parsed() -> None:
|
||||
assert parse_line("0000000000001781 00 BTN_1 Hauppauge") == LircButtonEvent(
|
||||
code="0000000000001781", repeat=0, button="BTN_1", remote="Hauppauge"
|
||||
)
|
||||
|
||||
|
||||
def test_a_repeat_field_is_hexadecimal() -> None:
|
||||
event = parse_line("0000000000001781 0a BTN_1 Hauppauge")
|
||||
assert event is not None
|
||||
assert event.repeat == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"",
|
||||
"0000000000001781 00 BTN_1", # missing remote
|
||||
"0000000000001781 00 BTN_1 Hauppauge extra", # extra token
|
||||
"0000000000001781 zz BTN_1 Hauppauge", # non-hex repeat
|
||||
],
|
||||
)
|
||||
def test_malformed_lines_are_rejected(line: str) -> None:
|
||||
assert parse_line(line) is None
|
||||
|
||||
|
||||
def test_surrounding_whitespace_is_ignored() -> None:
|
||||
assert parse_line(" 0000000000001781 00 BTN_1 Hauppauge \n") is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- service
|
||||
|
||||
|
||||
class ScriptedLircd:
|
||||
"""A tiny stand-in for lircd: accepts one connection at a time and lets the test
|
||||
push lines to whoever is currently connected."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._connected = asyncio.Event()
|
||||
self.connection_count = 0
|
||||
|
||||
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
self._writer = writer
|
||||
self.connection_count += 1
|
||||
self._connected.set()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await asyncio.Event().wait() # held open until the test drops it
|
||||
|
||||
async def wait_connected(self) -> None:
|
||||
await self._connected.wait()
|
||||
|
||||
async def send(self, line: str) -> None:
|
||||
await self._connected.wait()
|
||||
assert self._writer is not None
|
||||
self._writer.write(f"{line}\n".encode())
|
||||
await self._writer.drain()
|
||||
# Real socket I/O, not the fake clock: give the reader a moment to actually
|
||||
# see the bytes before the caller checks what happened.
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
def drop(self) -> None:
|
||||
"""Simulate the link dying: close the socket, and wait for a new connect."""
|
||||
assert self._writer is not None
|
||||
self._writer.close()
|
||||
self._writer = None
|
||||
self._connected = asyncio.Event()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def lircd() -> AsyncIterator[tuple[ScriptedLircd, asyncio.base_events.Server]]:
|
||||
station = ScriptedLircd()
|
||||
server = await asyncio.start_server(station._handle, "127.0.0.1", 0)
|
||||
try:
|
||||
yield station, server
|
||||
finally:
|
||||
server.close()
|
||||
# The scripted connection handler holds its connection open forever (it does
|
||||
# not know the test is done), so `wait_closed()` alone would hang here.
|
||||
server.close_clients()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||
# Real time for the socket I/O itself, virtual time for the reconnect sleep - tests
|
||||
# drive that explicitly via `sim.clock.advance()` and drain the bus after.
|
||||
simulation = await build_simulation(config, clock=FakeClock())
|
||||
try:
|
||||
yield simulation
|
||||
finally:
|
||||
await simulation.aclose()
|
||||
|
||||
|
||||
def _lirc_config(port: int, *, reconnect_interval: float = 5.0) -> LircConfig:
|
||||
return LircConfig(host="127.0.0.1", port=port, reconnect_interval=reconnect_interval)
|
||||
|
||||
|
||||
async def _run_service(sim: Simulation, config: LircConfig) -> asyncio.Task[None]:
|
||||
service = LircService(sim.app, config, clock=sim.clock)
|
||||
return asyncio.create_task(service.run())
|
||||
|
||||
|
||||
async def test_transport_and_volume_buttons_emit_intents(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[Event] = []
|
||||
sim.bus.subscribe_all(seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 KEY_PLAY Hauppauge")
|
||||
await station.send("0 00 KEY_PAUSE Hauppauge")
|
||||
await station.send("0 00 KEY_STOP Hauppauge")
|
||||
await station.send("0 00 KEY_PREVIOUS Hauppauge")
|
||||
await station.send("0 00 KEY_FORWARD Hauppauge")
|
||||
await station.send("0 00 KEY_VOLUMEUP Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
kinds = [type(event) for event in seen if isinstance(event, IntentEvent)]
|
||||
assert kinds == [
|
||||
PlayRequested,
|
||||
PauseRequested,
|
||||
PauseRequested,
|
||||
PrevTrackRequested,
|
||||
NextTrackRequested,
|
||||
VolumeChangeRequested,
|
||||
]
|
||||
|
||||
|
||||
async def test_events_from_another_remote_are_ignored(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[Event] = []
|
||||
sim.bus.subscribe_all(seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 KEY_PLAY small_led_remote")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert not any(isinstance(event, IntentEvent) for event in seen)
|
||||
|
||||
|
||||
async def test_transport_buttons_only_act_on_the_first_press(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[Event] = []
|
||||
sim.bus.subscribe(NextTrackRequested, seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 KEY_FORWARD Hauppauge")
|
||||
await station.send("0 01 KEY_FORWARD Hauppauge")
|
||||
await station.send("0 02 KEY_FORWARD Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
async def test_volume_buttons_act_on_every_repeat(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[VolumeChangeRequested] = []
|
||||
sim.bus.subscribe(VolumeChangeRequested, seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 KEY_VOLUMEDOWN Hauppauge")
|
||||
await station.send("0 01 KEY_VOLUMEDOWN Hauppauge")
|
||||
await station.send("0 02 KEY_VOLUMEDOWN Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert len(seen) == 3
|
||||
assert all(event.delta < 0 for event in seen)
|
||||
|
||||
|
||||
async def test_mute_toggles_and_restores(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
sim.player.set_volume(37)
|
||||
await sim.bus.drain()
|
||||
|
||||
await station.send("0 00 KEY_MUTE Hauppauge")
|
||||
await sim.bus.drain()
|
||||
assert sim.player.volume == 0
|
||||
|
||||
await station.send("0 00 KEY_MUTE Hauppauge")
|
||||
await sim.bus.drain()
|
||||
assert sim.player.volume == 37
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
|
||||
async def test_a_digit_with_no_assignment_does_nothing(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[Event] = []
|
||||
sim.bus.subscribe(PlayAlbumRequested, seen.append)
|
||||
sim.bus.subscribe(PlaySeriesLatestRequested, seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 BTN_5 Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert seen == []
|
||||
|
||||
|
||||
async def test_an_album_digit_plays_from_the_beginning(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
album = next(a for a in sim.app.library.albums if a.title == "Kinderparty Lieder")
|
||||
sim.app.config.remote = {"3": RemoteSlotConfig(target_kind="album", target=album.id)}
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 BTN_3 Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert sim.player.playlist is not None
|
||||
assert sim.player.playlist.album_id == album.id
|
||||
assert sim.player.track_index == 0
|
||||
|
||||
|
||||
async def test_a_series_digit_plays_the_latest_episode(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
sim.app.config.remote = {"7": RemoteSlotConfig(target_kind="series", target="Wissen macht Ah")}
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 BTN_7 Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert sim.player.playlist is not None
|
||||
played = sim.app.library.get(sim.player.playlist.album_id)
|
||||
assert played is not None
|
||||
assert played.title == "Neu"
|
||||
|
||||
|
||||
async def test_a_dropped_link_reconnects(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[ConnectionChanged] = []
|
||||
sim.bus.subscribe(ConnectionChanged, seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port, reconnect_interval=10.0))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await sim.bus.drain()
|
||||
assert station.connection_count == 1
|
||||
|
||||
station.drop()
|
||||
await asyncio.sleep(0.05) # let the client notice EOF
|
||||
await sim.bus.drain()
|
||||
|
||||
assert isinstance(sim.clock, FakeClock)
|
||||
await sim.clock.advance(10.0)
|
||||
await station.wait_connected()
|
||||
await sim.bus.drain()
|
||||
|
||||
assert station.connection_count == 2
|
||||
assert [event.connected for event in seen] == [True, False, True]
|
||||
finally:
|
||||
task.cancel()
|
||||
@@ -18,7 +18,7 @@ from musicmouse.effects import (
|
||||
EffectStaticConfig,
|
||||
EffectSwipeAndChange,
|
||||
)
|
||||
from musicmouse.events import Event, LedEffectChanged, VolumeChanged
|
||||
from musicmouse.events import Event, LedEffectChanged, PlaySeriesLatestRequested, VolumeChanged
|
||||
from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton
|
||||
from musicmouse.simulator.driver import SimulatorDriver
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
@@ -367,3 +367,24 @@ async def test_reconnecting_restores_the_leds(
|
||||
# equivalent-but-not-equal colours.
|
||||
assert restored.as_bytes() == before.as_bytes()
|
||||
assert sim.transport.brightness() == pytest.approx(0.5)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- podcast shows
|
||||
|
||||
|
||||
async def test_playing_a_series_starts_its_newest_episode(sim: Simulation) -> None:
|
||||
await sim.bus.emit_and_wait(PlaySeriesLatestRequested(series="Wissen macht Ah", source="web"))
|
||||
|
||||
playlist = sim.player.playlist
|
||||
assert playlist is not None
|
||||
played = sim.app.library.get(playlist.album_id)
|
||||
assert played is not None
|
||||
assert played.title == "Neu"
|
||||
assert sim.player.is_playing
|
||||
|
||||
|
||||
async def test_playing_an_unknown_series_does_nothing(sim: Simulation) -> None:
|
||||
await sim.bus.emit_and_wait(PlaySeriesLatestRequested(series="no such show", source="web"))
|
||||
|
||||
assert sim.player.playlist is None
|
||||
assert not sim.player.is_playing
|
||||
|
||||
155
python-backend/tests/test_remote_api.py
Normal file
155
python-backend/tests/test_remote_api.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""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 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}
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
@@ -150,7 +150,7 @@ async def test_state_starts_idle(client: Client) -> None:
|
||||
assert state["playing"] is False
|
||||
assert state["album_id"] is None
|
||||
assert state["active_figure"] is None
|
||||
assert state["connected"] == {"firmware": True, "mqtt": False}
|
||||
assert state["connected"] == {"firmware": True, "mqtt": False, "lirc": False}
|
||||
|
||||
|
||||
async def test_play_loads_the_album_and_starts_it(client: Client, sim: Simulation) -> None:
|
||||
|
||||
Reference in New Issue
Block a user