Files
musicmouse/python-backend/tests/test_selection.py
Martin Bauer d465a74cde Figures: several albums per figure, picked on the LED ring
A figure folder that holds album folders instead of audio can play any of them,
up to six, in folder order (depth-first, alphabetical). Each is its own album,
titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays
the album it played last, or the first, straight away; another figure placed in
between forgets the position in that album, not the album itself.

Meanwhile the ring offers all of them: one section per album from a fixed palette,
edges shifting towards the neighbours' hue, the selected one bright and wobbling.
The knob moves the selection one section per detent and wraps; pressing plays the
selected album, and left alone for 8 s the picker fades into the figure's colours
and the music carries on. While open, the device hands the knob to the picker
(`capture_knob`): turns do not change the volume and the press is not published to
Home Assistant. A tick per step and a confirm "pling" go with it.

The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23),
re-sent on every detent with the section to glide from. The look is tunable through
a temporary `general.figure_selection` config section until the right values
become constants.

Every UI sound - the screen's blubb, the parents' timer, the picker - now follows
the music volume with a floor, times a new parent-mode "Effekt-Lautstärke"
(`general.effects_volume`).

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

372 lines
11 KiB
Python

"""Picking one of a figure's albums on the LED ring, driven through the simulator."""
from __future__ import annotations
from collections.abc import AsyncIterator
from copy import deepcopy
from pathlib import Path
from typing import Any, TypeVar
import pytest
from musicmouse.blubb import confirm_wav, tick_wav
from musicmouse.config import load_config
from musicmouse.effects import (
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectSegmentSelect,
EffectSwipeAndChange,
)
from musicmouse.events import ButtonEvent, Event, FigureSelectionChanged, RotaryTurned
from musicmouse.hardware import LedZone
from musicmouse.reactions import selection
from musicmouse.reactions.selection import CONFIRM_GAIN, DEFAULTS, PALETTE, TICK_GAIN
from musicmouse.simulator.driver import SimulatorDriver
from musicmouse.simulator.harness import Simulation, build_simulation
from musicmouse.sound import ui_level
from tests.conftest import VALID_CONFIG, write_config, write_track
TRACK_SECONDS = 10.0
TIMEOUT = DEFAULTS.timeout
T = TypeVar("T", bound=Event)
def _albums(config_dir: Path) -> None:
"""Give the fox three albums of two tracks each; the owl keeps its one."""
fuchs = config_dir / "music" / "Figuren" / "fuchs"
for track in fuchs.glob("*.mp3"):
track.unlink()
for folder in ("A", "B", "C"):
for index in range(2):
write_track(fuchs / folder / f"{index:02d}.mp3", title=f"{folder}{index}")
async def _simulation(config_dir: Path, config: dict[str, Any]) -> Simulation:
_albums(config_dir)
return await build_simulation(
load_config(write_config(config_dir, config)), track_duration=TRACK_SECONDS
)
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
simulation = await _simulation(config_dir, VALID_CONFIG)
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(events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]
def ring(sim: Simulation) -> object:
return sim.app.mouse.effect(LedZone.RING)
def picker(sim: Simulation) -> EffectSegmentSelect:
effect = ring(sim)
assert isinstance(effect, EffectSegmentSelect)
return effect
# ------------------------------------------------------------------------- opening
async def test_a_figure_with_several_albums_plays_the_first_and_offers_all(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
mouse.check("playing", "true")
mouse.check("album", "Fuchs - A")
effect = picker(sim)
assert effect.colors == PALETTE[:3]
assert effect.selected == 0
# Only the ring is the picker; the body and shelf run the usual start animation.
assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectSwipeAndChange)
assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange)
async def test_a_figure_with_one_album_gets_no_picker(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("eule")
assert isinstance(ring(sim), EffectSwipeAndChange)
assert sim.app.state.selector is None
assert not sim.app.mouse.knob_captured
async def test_the_config_can_override_how_the_picker_looks(config_dir: Path) -> None:
config = deepcopy(VALID_CONFIG)
config["general"]["figure_selection"] = {"offset_degrees": 90, "wobble_amplitude": 3}
sim = await _simulation(config_dir, config)
try:
await sim.driver.place("fuchs")
effect = picker(sim)
assert effect.offset_degrees == 90
assert effect.wobble_amplitude == 3
assert effect.slide_ms == DEFAULTS.slide_ms
finally:
await sim.aclose()
# --------------------------------------------------------------------------- knob
async def test_turning_moves_the_selection_instead_of_the_volume(
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
) -> None:
await mouse.place("fuchs")
volume = sim.player.volume
await mouse.turn(1)
effect = picker(sim)
assert (effect.previous, effect.selected) == (0, 1)
assert sim.player.volume == volume
assert not only(seen, RotaryTurned)
assert only(seen, FigureSelectionChanged)[-1].selected == 1
# Turning only moves the highlight; what plays changes on a press.
mouse.check("album", "Fuchs - A")
async def test_turning_wraps_round_the_ring(mouse: SimulatorDriver, sim: Simulation) -> None:
await mouse.place("fuchs")
await mouse.turn(-1)
assert picker(sim).selected == 2
await mouse.turn(2)
assert picker(sim).selected == 1
async def test_pressing_plays_the_selected_album_and_gives_the_knob_back(
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
) -> None:
await mouse.place("fuchs")
await mouse.turn(2)
await mouse.press("rotary")
mouse.check("album", "Fuchs - C")
mouse.check("track", "0")
mouse.check("playing", "true")
effect = ring(sim)
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
assert effect.start_with_existing # a fade from the sections, not a jump
# The press was the picker's: Home Assistant never hears of it.
assert not only(seen, ButtonEvent)
assert only(seen, FigureSelectionChanged)[-1].open is False
volume = sim.player.volume
await mouse.turn(-1)
assert sim.player.volume < volume
async def test_pressing_on_the_album_already_playing_does_not_restart_it(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.wait(3)
await mouse.press("rotary")
mouse.check("album", "Fuchs - A")
assert sim.player.position == pytest.approx(3.0)
assert sim.app.state.selector is None
async def test_presses_reach_home_assistant_again_once_the_picker_is_closed(
mouse: SimulatorDriver, seen: list[Event]
) -> None:
await mouse.place("fuchs")
await mouse.press("rotary")
await mouse.press("rotary")
assert len(only(seen, ButtonEvent)) == 1
# ------------------------------------------------------------------------ timeout
async def test_left_alone_the_picker_closes_and_the_music_carries_on(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.wait(TIMEOUT + 0.1)
assert sim.app.state.selector is None
assert not sim.app.mouse.knob_captured
assert isinstance(ring(sim), EffectRandomTwoColorInterpolationConfig)
mouse.check("album", "Fuchs - A")
mouse.check("playing", "true")
async def test_every_turn_starts_the_timeout_over(mouse: SimulatorDriver, sim: Simulation) -> None:
await mouse.place("fuchs")
await mouse.wait(TIMEOUT - 2)
await mouse.turn(1)
await mouse.wait(TIMEOUT - 1)
assert sim.app.state.selector is not None
await mouse.wait(1.1)
assert sim.app.state.selector is None
async def test_lifting_the_figure_closes_the_picker(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.remove()
assert sim.app.state.selector is None
assert not sim.app.mouse.knob_captured
assert isinstance(ring(sim), EffectReverseSwipe)
# ------------------------------------------------------------------------- memory
async def test_putting_the_figure_back_resumes_the_picked_album(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.press("rotary")
await mouse.wait(3)
await mouse.remove()
await mouse.place("fuchs")
mouse.check("album", "Fuchs - B")
assert sim.player.position == pytest.approx(3.0)
assert picker(sim).selected == 1
async def test_another_figure_in_between_keeps_the_album_but_not_the_place(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.press("rotary")
await mouse.wait(TRACK_SECONDS + 3)
await mouse.remove()
await mouse.place("eule")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("album", "Fuchs - B")
mouse.check("track", "0")
assert sim.player.position == pytest.approx(0.0)
assert picker(sim).selected == 1
async def test_an_album_played_to_the_end_is_still_the_one_picked_next_time(
mouse: SimulatorDriver,
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.press("rotary")
await mouse.wait(2 * TRACK_SECONDS + 1)
mouse.check("playing", "false")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("album", "Fuchs - B")
mouse.check("track", "0")
async def test_an_album_started_from_the_web_becomes_the_figures_pick(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.play_album("Fuchs - C")
await mouse.wait(2)
await mouse.place("fuchs")
mouse.check("album", "Fuchs - C")
assert picker(sim).selected == 2
# -------------------------------------------------------------------------- sound
@pytest.fixture
def sounds(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, bytes]]:
played: list[tuple[str, bytes]] = []
monkeypatch.setattr(
selection, "play_wav_soon", lambda device, wav: played.append((device, wav))
)
return played
async def _audible(config_dir: Path, effects_volume: int = 100) -> Simulation:
config = deepcopy(VALID_CONFIG)
config["general"]["alsa_device"] = "softvol_effects"
config["general"]["effects_volume"] = effects_volume
return await _simulation(config_dir, config)
async def test_each_step_ticks_but_fast_turns_do_not_pile_up(
config_dir: Path, sounds: list[tuple[str, bytes]]
) -> None:
sim = await _audible(config_dir)
try:
await sim.driver.place("fuchs")
await sim.driver.turn(1)
await sim.driver.turn(1) # no time passed: one tick for both
await sim.driver.wait(0.1)
await sim.driver.turn(1)
level = ui_level(sim.player.volume / 100, 1.0)
assert sounds == [("softvol_effects", tick_wav(gain=TICK_GAIN * level))] * 2
finally:
await sim.aclose()
async def test_picking_an_album_plays_the_confirm_sound(
config_dir: Path, sounds: list[tuple[str, bytes]]
) -> None:
sim = await _audible(config_dir, effects_volume=50)
try:
await sim.driver.place("fuchs")
await sim.driver.press("rotary")
level = ui_level(sim.player.volume / 100, 0.5)
assert sounds == [("softvol_effects", confirm_wav(gain=CONFIRM_GAIN * level))]
finally:
await sim.aclose()
async def test_no_picker_sounds_with_effects_turned_off(
config_dir: Path, sounds: list[tuple[str, bytes]]
) -> None:
sim = await _audible(config_dir, effects_volume=0)
try:
await sim.driver.place("fuchs")
await sim.driver.turn(1)
await sim.driver.press("rotary")
assert sounds == []
finally:
await sim.aclose()