"""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 from typing import TypeVar 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 T = TypeVar("T", bound=Event) def only(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