Full rearchitecture using Claude

- event bus systen
- all components are independent
- preparation for web frontend
This commit is contained in:
2026-08-26 13:22:28 +02:00
parent 55bcc9448c
commit d44c24ec97
70 changed files with 7591 additions and 1011 deletions

View File

@@ -0,0 +1,369 @@
"""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)