"""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()