Files
musicmouse/python-backend/tests/test_reactions.py
Martin Bauer 747c390303 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>
2026-09-11 08:31:28 +02:00

391 lines
12 KiB
Python

"""The behaviour of the mouse, driven end to end through the simulator.
Everything between the tag being read and the LED bytes being written is production
code here - only the serial link and VLC are substituted.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from musicmouse.config import load_config
from musicmouse.effects import (
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectStaticConfig,
EffectSwipeAndChange,
)
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
from tests.conftest import VALID_CONFIG, write_config
TRACK_SECONDS = 10.0
@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 mouse(sim: Simulation) -> SimulatorDriver:
return sim.driver
@pytest.fixture
def seen(sim: Simulation) -> list[Event]:
events: list[Event] = []
sim.bus.subscribe_all(events.append)
return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]
# ------------------------------------------------------------------ figure on/off
async def test_placing_a_figure_starts_its_playlist(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
mouse.check("figure", "fuchs")
mouse.check("playing", "true")
mouse.check("playlist", "fuchs")
mouse.check("track", "0")
async def test_placing_a_figure_lights_the_leds(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
for zone in LedZone:
assert isinstance(sim.app.mouse.effect(zone), EffectSwipeAndChange)
assert sim.app.mouse.button_led_brightness == pytest.approx(0.5)
async def test_the_figures_colours_are_used(mouse: SimulatorDriver, sim: Simulation) -> None:
await mouse.place("fuchs")
effect = sim.app.mouse.effect(LedZone.RING)
assert isinstance(effect, EffectSwipeAndChange)
assert effect.swipe.primary_color == sim.app.colors("fuchs").primary
assert effect.swipe.secondary_color == sim.app.colors("fuchs").secondary
async def test_the_mouse_swipe_is_offset_from_the_ring(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
ring = sim.app.mouse.effect(LedZone.RING)
body = sim.app.mouse.effect(LedZone.MOUSE)
assert isinstance(ring, EffectSwipeAndChange)
assert isinstance(body, EffectSwipeAndChange)
assert body.swipe.start_position != ring.swipe.start_position
async def test_removing_a_figure_pauses_and_runs_the_off_animation(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.remove()
mouse.check("figure", "none")
mouse.check("playing", "false")
assert isinstance(sim.app.mouse.effect(LedZone.RING), EffectReverseSwipe)
assert sim.app.mouse.button_led_brightness == pytest.approx(0.0)
async def test_putting_the_same_figure_back_resumes_where_it_left_off(
mouse: SimulatorDriver,
) -> None:
await mouse.place("fuchs")
await mouse.wait(TRACK_SECONDS + 1)
mouse.check("track", "1")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("playing", "true")
mouse.check("track", "1")
async def test_a_different_figure_starts_from_the_top(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.wait(TRACK_SECONDS + 1)
await mouse.remove()
await mouse.place("eule")
mouse.check("playlist", "eule")
mouse.check("track", "0")
async def test_swapping_figures_without_removing_first(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.place("eule")
mouse.check("figure", "eule")
mouse.check("playlist", "eule")
mouse.check("playing", "true")
async def test_replacing_a_figure_after_its_playlist_finished_starts_over(
mouse: SimulatorDriver,
) -> None:
await mouse.place("fuchs")
await mouse.wait(TRACK_SECONDS * 5) # fuchs has 3 tracks
mouse.check("playing", "false")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("track", "0")
mouse.check("playing", "true")
async def test_an_unknown_tag_changes_nothing(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.tag(bytes.fromhex("0999999999"))
mouse.check("figure", "fuchs")
mouse.check("playing", "true")
# ------------------------------------------------------------------- playlist end
async def test_the_playlist_ending_runs_the_off_animation(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("eule") # two tracks
await mouse.wait(TRACK_SECONDS * 2 + 1)
mouse.check("playing", "false")
assert isinstance(sim.app.mouse.effect(LedZone.RING), EffectReverseSwipe)
# ----------------------------------------------------------------------- buttons
async def test_the_right_button_skips_forward(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.press("right")
mouse.check("track", "1")
async def test_the_left_button_skips_back(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.press("right")
await mouse.press("left")
mouse.check("track", "0")
async def test_buttons_do_nothing_while_paused(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.remove()
await mouse.press("right")
mouse.check("track", "0")
async def test_only_the_press_acts_not_the_release(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.press("right", "released")
await mouse.press("right", "clicked")
mouse.check("track", "0")
async def test_the_rotary_press_does_not_touch_playback(mouse: SimulatorDriver) -> None:
"""It is published to Home Assistant instead; the backend has no opinion."""
await mouse.place("fuchs")
await mouse.press("rotary")
mouse.check("playing", "true")
mouse.check("track", "0")
# ------------------------------------------------------------------------ volume
async def test_turning_the_encoder_up_raises_the_volume(
mouse: SimulatorDriver, seen: list[Event]
) -> None:
await mouse.turn(1)
mouse.check("volume", "45") # 40 initial + 5 increment
assert only(seen, VolumeChanged)[-1].volume == 45
async def test_turning_the_encoder_down_lowers_the_volume(mouse: SimulatorDriver) -> None:
await mouse.turn(-1)
mouse.check("volume", "35")
async def test_several_clicks_in_one_event_scale_the_step(mouse: SimulatorDriver) -> None:
await mouse.turn(3)
mouse.check("volume", "55")
async def test_volume_stops_at_the_configured_maximum(mouse: SimulatorDriver) -> None:
for _ in range(20):
await mouse.turn(1)
mouse.check("volume", "60")
async def test_volume_stops_at_the_configured_minimum(mouse: SimulatorDriver) -> None:
for _ in range(20):
await mouse.turn(-1)
mouse.check("volume", "0")
async def test_setting_the_volume_directly(mouse: SimulatorDriver) -> None:
await mouse.set_volume(25)
mouse.check("volume", "25")
# ------------------------------------------------------------------ touch buttons
@pytest.mark.parametrize("button", list(TouchButton))
async def test_touching_lights_that_body_part_in_the_accent_colour(
mouse: SimulatorDriver, sim: Simulation, button: TouchButton
) -> None:
await mouse.place("fuchs")
await mouse.touch(button.slug)
effect = sim.app.mouse.effect(LedZone.MOUSE)
assert isinstance(effect, EffectStaticConfig)
assert effect.color == sim.app.colors("fuchs").accent
assert (effect.begin, effect.end) == MOUSE_LED_RANGES[button]
async def test_releasing_restores_the_body_effect(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.touch("left_ear")
await mouse.release("left_ear")
assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectRandomTwoColorInterpolationConfig)
async def test_releasing_clears_the_area_before_restoring(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.touch("left_ear")
sim.transport.clear()
await mouse.release("left_ear")
written = sim.transport.effects_for(LedZone.MOUSE)
assert isinstance(written[0], EffectStaticConfig)
assert written[0].color == sim.app.colors("fuchs").primary
assert isinstance(written[1], EffectRandomTwoColorInterpolationConfig)
async def test_touching_with_no_figure_does_nothing(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.touch("left_ear")
assert sim.app.mouse.effect(LedZone.MOUSE) is None
async def test_touching_while_paused_does_nothing(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.remove()
sim.transport.clear()
await mouse.touch("left_ear")
assert sim.transport.effects_for(LedZone.MOUSE) == []
# ------------------------------------------------------- light arbitration & reconnect
async def test_an_mqtt_command_wins_until_the_next_figure_animation(
mouse: SimulatorDriver, sim: Simulation
) -> None:
"""Last write wins, whichever side it came from."""
from musicmouse.color import ColorRGBW
from_mqtt = EffectStaticConfig(ColorRGBW(0, 0, 1, 0))
sim.app.mouse.set_effect(LedZone.SHELF, from_mqtt, origin="mqtt")
await mouse.settle()
assert sim.app.mouse.effect(LedZone.SHELF) == from_mqtt
await mouse.place("fuchs")
assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange)
sim.app.mouse.set_effect(LedZone.SHELF, from_mqtt, origin="mqtt")
await mouse.settle()
assert sim.app.mouse.effect(LedZone.SHELF) == from_mqtt
async def test_every_led_write_is_announced_with_its_origin(
mouse: SimulatorDriver, seen: list[Event]
) -> None:
await mouse.place("fuchs")
origins = {e.origin for e in only(seen, LedEffectChanged)}
assert origins == {"device"}
assert {e.zone for e in only(seen, LedEffectChanged)} == set(LedZone)
async def test_reconnecting_restores_the_leds(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
before = sim.app.mouse.effect(LedZone.RING)
assert before is not None
await mouse.disconnect()
sim.transport.clear()
await mouse.reconnect()
restored = sim.transport.effect(LedZone.RING)
assert restored is not None
# Compared as bytes: two-colour effects travel as HSV, so the decoded object holds
# 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