Files
musicmouse/python-backend/tests/test_mouse.py
Martin Bauer a7fb56c9fe Target the Python that Raspberry Pi OS ships
Requiring 3.13 meant the device needed an interpreter the distribution does not
have, which is what dragged uv in, and uv then had to be matched to the Pi's
32-bit userland by hand and to build Pillow from source because no armv7 wheel
exists for a 3.13 ABI. Dropping to 3.11 removes all of that: apt provides the
interpreter and piwheels has prebuilt armhf wheels for the native dependencies.

The 3.13-only syntax was shallow - PEP 695 throughout, which converts back
mechanically:

  type X = Y               ->  X: TypeAlias = Y
  type Handler[E: Event]   ->  E = TypeVar("E", bound=Event) plus a plain alias,
                               which is generic anyway because it carries a TypeVar
  def f[T: Bound](...)     ->  a module-level TypeVar

Also drop the one @override (3.12, and static-only), and stop the lirc test
harness calling Server.close_clients(), which is 3.13: the scripted handler now
releases its connection when asked, which is what that call was there to force.

Verified on 3.11.14 - 485 passed, mypy strict clean - and still 496 passed on
the 3.14 dev venv, which additionally has the analysis extra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:20:59 +02:00

338 lines
10 KiB
Python

from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import TypeVar
import pytest
from musicmouse.bus import EventBus
from musicmouse.color import ColorRGBW
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.wire import encode_input_event
from musicmouse.effects import (
OFF,
EffectAlexaSwipeConfig,
EffectCircularConfig,
EffectStaticConfig,
)
from musicmouse.events import (
ActiveFigureChanged,
ButtonEvent,
ConnectionChanged,
Event,
LedEffectChanged,
RfidTokenRead,
TouchButtonPressed,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
from musicmouse.simulator import FakeTransport
FUCHS = bytes.fromhex("04a1b2c3d4")
EULE = bytes.fromhex("04b2c3d4e5")
UNKNOWN = bytes.fromhex("0999999999")
TAG_MAP = {FUCHS: "fuchs", EULE: "eule"}
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as running:
yield running
@pytest.fixture
def transport() -> FakeTransport:
return FakeTransport()
@pytest.fixture
def device(bus: EventBus, transport: FakeTransport) -> MusicMouseDevice:
mouse = MusicMouseDevice(bus, transport, TAG_MAP, port="/dev/fake")
transport.attach(mouse.feed)
return mouse
@pytest.fixture
def seen(bus: EventBus) -> list[Event]:
events: list[Event] = []
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)]
# ----------------------------------------------------------------- incoming events
async def test_button_press_reaches_the_bus(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(
encode_input_event(ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED))
)
await bus.drain()
assert only(seen, ButtonEvent) == [
ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED, source="device")
]
async def test_touch_press_reaches_the_bus(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(TouchButtonPressed(button=TouchButton.LEFT_EAR)))
await bus.drain()
assert only(seen, TouchButtonPressed)[0].button == TouchButton.LEFT_EAR
async def test_known_tag_resolves_to_a_figure(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
assert only(seen, RfidTokenRead)[0].figure == "fuchs"
assert only(seen, ActiveFigureChanged) == [
ActiveFigureChanged(figure="fuchs", previous=None, source="device")
]
assert device.active_figure == "fuchs"
async def test_all_zero_tag_means_the_figure_was_removed(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
seen.clear()
device.feed(encode_input_event(RfidTokenRead(tag_id=bytes(5))))
await bus.drain()
assert only(seen, ActiveFigureChanged) == [
ActiveFigureChanged(figure=None, previous="fuchs", source="device")
]
assert device.active_figure is None
async def test_swapping_figures_reports_the_previous_one(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
seen.clear()
device.feed(encode_input_event(RfidTokenRead(tag_id=EULE)))
await bus.drain()
assert only(seen, ActiveFigureChanged) == [
ActiveFigureChanged(figure="eule", previous="fuchs", source="device")
]
async def test_rereading_the_same_tag_is_not_a_change(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
for _ in range(3):
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
assert len(only(seen, RfidTokenRead)) == 3
assert len(only(seen, ActiveFigureChanged)) == 1
async def test_unknown_tag_is_reported_but_does_not_change_the_active_figure(
bus: EventBus, device: MusicMouseDevice, seen: list[Event], caplog: pytest.LogCaptureFixture
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
seen.clear()
with caplog.at_level(logging.WARNING):
device.feed(encode_input_event(RfidTokenRead(tag_id=UNKNOWN)))
await bus.drain()
read = only(seen, RfidTokenRead)[0]
assert read.known is False
assert read.figure is None
assert only(seen, ActiveFigureChanged) == []
assert device.active_figure == "fuchs"
assert "Unknown RFID tag 0999999999" in caplog.text
async def test_firmware_log_lines_are_logged_not_published(
bus: EventBus, device: MusicMouseDevice, seen: list[Event], caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.INFO):
device.feed(b"RFID reader ready\n")
await bus.drain()
assert seen == []
assert "[firmware] RFID reader ready" in caplog.text
async def test_a_bad_frame_is_dropped_and_the_next_one_still_arrives(
bus: EventBus, device: MusicMouseDevice, seen: list[Event], caplog: pytest.LogCaptureFixture
) -> None:
import struct
from musicmouse.devices.wire import MAGIC_FW_TO_HOST
bad = struct.pack("<IBH", MAGIC_FW_TO_HOST, 99, 1) + b"\x00"
good = encode_input_event(TouchButtonPressed(button=TouchButton.RIGHT_EAR))
with caplog.at_level(logging.WARNING):
device.feed(bad + good)
await bus.drain()
assert only(seen, TouchButtonPressed)[0].button == TouchButton.RIGHT_EAR
assert "Discarding bad frame" in caplog.text
# ------------------------------------------------------------------------ actions
async def test_setting_an_effect_writes_it_and_announces_it(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport, seen: list[Event]
) -> None:
effect = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
device.set_effect(LedZone.RING, effect, origin="mqtt")
await bus.drain()
assert transport.effect(LedZone.RING) == effect
assert only(seen, LedEffectChanged) == [
LedEffectChanged(zone=LedZone.RING, effect=effect, origin="mqtt", source="device")
]
assert device.effect(LedZone.RING) == effect
async def test_last_write_wins_per_zone(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
"""A figure animation and an MQTT command fight over the shelf; the later one wins."""
from_mqtt = EffectStaticConfig(ColorRGBW(0, 0, 1, 0))
from_figure = EffectCircularConfig(color=ColorRGBW(1, 0, 0, 0))
device.set_effect(LedZone.SHELF, from_mqtt, origin="mqtt")
device.set_effect(LedZone.SHELF, from_figure, origin="device")
await bus.drain()
assert transport.effect(LedZone.SHELF) == from_figure
assert device.effect(LedZone.SHELF) == from_figure
async def test_zones_are_independent(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
ring = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
shelf = EffectStaticConfig(ColorRGBW(0, 1, 0, 0))
device.set_effect(LedZone.RING, ring)
device.set_effect(LedZone.SHELF, shelf)
await bus.drain()
assert transport.effect(LedZone.RING) == ring
assert transport.effect(LedZone.SHELF) == shelf
assert transport.effect(LedZone.MOUSE) is None
async def test_an_effect_a_zone_does_not_support_is_reported_not_sent(
bus: EventBus,
device: MusicMouseDevice,
transport: FakeTransport,
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.ERROR):
device.set_effect(LedZone.MOUSE, EffectAlexaSwipeConfig())
await bus.drain()
assert transport.effect(LedZone.MOUSE) is None
assert device.effect(LedZone.MOUSE) is None
assert "cannot be sent to the mouse LEDs" in caplog.text
async def test_button_brightness_sets_both_backlights(
device: MusicMouseDevice, transport: FakeTransport
) -> None:
device.set_button_brightness(0.25)
assert transport.brightness(Button.LEFT) == pytest.approx(0.25)
assert transport.brightness(Button.RIGHT) == pytest.approx(0.25)
assert device.button_led_brightness == pytest.approx(0.25)
@pytest.mark.parametrize(("given", "expected"), [(-1.0, 0.0), (5.0, 1.0)])
async def test_button_brightness_is_clamped(
device: MusicMouseDevice, transport: FakeTransport, given: float, expected: float
) -> None:
device.set_button_brightness(given)
assert transport.brightness() == pytest.approx(expected)
async def test_all_leds_off(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
device.set_button_brightness(1.0)
device.all_leds_off()
await bus.drain()
for zone in LedZone:
assert transport.effect(zone) == OFF()
assert transport.brightness() == pytest.approx(0.0)
# --------------------------------------------------------------------- reconnect
async def test_writes_while_disconnected_are_dropped(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
transport.disconnect()
device.set_effect(LedZone.RING, EffectStaticConfig(ColorRGBW(1, 0, 0, 0)))
await bus.drain()
assert transport.effect(LedZone.RING) is None
assert transport.dropped_bytes > 0
async def test_reconnect_restores_the_memorized_led_state(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
"""The point of memorizing state: a pulled USB cable should be invisible."""
ring = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
shelf = EffectCircularConfig(color=ColorRGBW(0, 0, 1, 0))
device.set_effect(LedZone.RING, ring)
device.set_effect(LedZone.SHELF, shelf)
device.set_button_brightness(0.5)
await bus.drain()
transport.disconnect()
device.on_disconnected("port closed")
await bus.drain()
transport.clear()
transport.reconnect()
device.on_connected()
await bus.drain()
assert transport.effect(LedZone.RING) == ring
assert transport.effect(LedZone.SHELF) == shelf
assert transport.brightness() == pytest.approx(0.5)
async def test_connection_changes_are_announced(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.on_connected()
device.on_disconnected("cable pulled")
await bus.drain()
assert only(seen, ConnectionChanged) == [
ConnectionChanged(target="firmware", connected=True, source="device"),
ConnectionChanged(target="firmware", connected=False, source="device"),
]