- event bus systen - all components are independent - preparation for web frontend
370 lines
11 KiB
Python
370 lines
11 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, 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)
|