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>
This commit is contained in:
371
python-backend/tests/test_selection.py
Normal file
371
python-backend/tests/test_selection.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user