Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
299
python-backend/tests/test_wire.py
Normal file
299
python-backend/tests/test_wire.py
Normal file
@@ -0,0 +1,299 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.color import ColorRGBW
|
||||
from musicmouse.devices.wire import (
|
||||
MAGIC_FW_TO_HOST,
|
||||
FirmwareLog,
|
||||
FrameDecoder,
|
||||
MessageFwToHost,
|
||||
MessageHostToFw,
|
||||
ProtocolError,
|
||||
UnsupportedEffectError,
|
||||
encode_button_brightness,
|
||||
encode_effect,
|
||||
encode_input_event,
|
||||
)
|
||||
from musicmouse.effects import (
|
||||
EffectAlexaSwipeConfig,
|
||||
EffectStaticConfig,
|
||||
EffectStaticDetailedConfig,
|
||||
)
|
||||
from musicmouse.events import (
|
||||
ButtonEvent,
|
||||
InputEvent,
|
||||
RfidTokenRead,
|
||||
RotaryTurned,
|
||||
TouchButtonPressed,
|
||||
TouchButtonReleased,
|
||||
)
|
||||
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||
|
||||
MESSAGES_H = Path(__file__).resolve().parents[2] / "esp-firmware" / "src" / "Messages.h"
|
||||
|
||||
|
||||
def frame(msg_type: int, payload: bytes) -> bytes:
|
||||
return struct.pack("<IBH", MAGIC_FW_TO_HOST, msg_type, len(payload)) + payload
|
||||
|
||||
|
||||
# ------------------------------------------------------------ firmware contract
|
||||
|
||||
|
||||
def _parse_cpp_enum(source: str, name: str) -> dict[str, int]:
|
||||
body = re.search(rf"enum class {name}\s*:\s*uint8_t\s*\{{(.*?)\}}", source, re.DOTALL)
|
||||
assert body is not None, f"{name} not found in Messages.h"
|
||||
return {
|
||||
match["name"]: int(match["value"])
|
||||
for match in re.finditer(r"(?P<name>[A-Z_0-9]+)\s*=\s*(?P<value>\d+)", body.group(1))
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not MESSAGES_H.exists(), reason="firmware sources not available")
|
||||
@pytest.mark.parametrize("enum", [MessageFwToHost, MessageHostToFw])
|
||||
def test_message_ids_match_the_firmware(enum: type[MessageFwToHost | MessageHostToFw]) -> None:
|
||||
"""The contract is hand-duplicated in two languages; this is what catches drift.
|
||||
|
||||
A missing member here is exactly the bug the old host_driver.py shipped with:
|
||||
its MessageFwToHost enum never had BUTTON_EVENT = 4.
|
||||
"""
|
||||
source = MESSAGES_H.read_text(encoding="utf-8", errors="replace")
|
||||
assert _parse_cpp_enum(source, enum.__name__) == {m.name: m.value for m in enum}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not MESSAGES_H.exists(), reason="firmware sources not available")
|
||||
def test_magic_tokens_match_the_firmware() -> None:
|
||||
source = MESSAGES_H.read_text(encoding="utf-8", errors="replace")
|
||||
found = dict(re.findall(r"MAGIC_TOKEN_(\w+)\s*=\s*(0x[0-9a-fA-F]+)", source))
|
||||
assert int(found["FW_TO_HOST"], 16) == MAGIC_FW_TO_HOST
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- encoding
|
||||
|
||||
|
||||
def test_effect_frame_has_magic_type_and_length() -> None:
|
||||
effect = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
|
||||
encoded = encode_effect(LedZone.RING, effect)
|
||||
|
||||
magic, msg_type, size = struct.unpack("<IBH", encoded[:7])
|
||||
assert magic == 0x1D6379E3
|
||||
assert msg_type == MessageHostToFw.LED_WHEEL_EFFECT_STATIC
|
||||
assert size == len(effect.as_bytes())
|
||||
assert encoded[7:] == effect.as_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("zone", "expected"),
|
||||
[
|
||||
(LedZone.RING, MessageHostToFw.LED_WHEEL_EFFECT_STATIC),
|
||||
(LedZone.MOUSE, MessageHostToFw.MOUSE_LED_EFFECT_STATIC),
|
||||
(LedZone.SHELF, MessageHostToFw.SHELF_LED_EFFECT_STATIC),
|
||||
],
|
||||
)
|
||||
def test_the_same_effect_gets_a_different_id_per_zone(
|
||||
zone: LedZone, expected: MessageHostToFw
|
||||
) -> None:
|
||||
encoded = encode_effect(zone, EffectStaticConfig(ColorRGBW(0, 0, 0, 0)))
|
||||
assert encoded[4] == expected
|
||||
|
||||
|
||||
def test_alexa_swipe_is_only_supported_on_the_ring() -> None:
|
||||
encode_effect(LedZone.RING, EffectAlexaSwipeConfig())
|
||||
with pytest.raises(UnsupportedEffectError, match="mouse LEDs"):
|
||||
encode_effect(LedZone.MOUSE, EffectAlexaSwipeConfig())
|
||||
|
||||
|
||||
def test_static_detailed_is_only_supported_on_the_shelf() -> None:
|
||||
encode_effect(LedZone.SHELF, EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)))
|
||||
with pytest.raises(UnsupportedEffectError, match="supported there"):
|
||||
encode_effect(LedZone.RING, EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("button", "message"),
|
||||
[
|
||||
(Button.LEFT, MessageHostToFw.PREV_BUTTON_LED),
|
||||
(Button.RIGHT, MessageHostToFw.NEXT_BUTTON_LED),
|
||||
],
|
||||
)
|
||||
def test_button_brightness(button: Button, message: MessageHostToFw) -> None:
|
||||
encoded = encode_button_brightness(button, 0.5)
|
||||
assert encoded[4] == message
|
||||
assert struct.unpack("<f", encoded[7:]) == (0.5,)
|
||||
|
||||
|
||||
def test_button_brightness_range_is_checked() -> None:
|
||||
with pytest.raises(ValueError, match=r"within 0\.\.1"):
|
||||
encode_button_brightness(Button.LEFT, 1.5)
|
||||
|
||||
|
||||
def test_rotary_button_has_no_backlight() -> None:
|
||||
with pytest.raises(ValueError, match="no backlight"):
|
||||
encode_button_brightness(Button.ROTARY, 0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- decoding
|
||||
|
||||
|
||||
def collect(decoder: FrameDecoder, data: bytes) -> list[object]:
|
||||
"""Push ``data`` and drain everything the decoder can produce."""
|
||||
decoder.push(data)
|
||||
items: list[object] = []
|
||||
while (item := decoder.take()) is not None:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def decode_one(data: bytes) -> object:
|
||||
items = collect(FrameDecoder(), data)
|
||||
assert len(items) == 1, f"expected exactly one item, got {items}"
|
||||
return items[0]
|
||||
|
||||
|
||||
def test_decode_rfid() -> None:
|
||||
event = decode_one(frame(MessageFwToHost.RFID_TOKEN_READ, bytes.fromhex("04a1b2c3d4")))
|
||||
assert event == RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device")
|
||||
|
||||
|
||||
def test_decode_rotary() -> None:
|
||||
payload = struct.pack("<iiB", -17, 2, RotaryDirection.UP)
|
||||
event = decode_one(frame(MessageFwToHost.ROTARY_ENCODER, payload))
|
||||
assert event == RotaryTurned(
|
||||
position=-17, increment=2, direction=RotaryDirection.UP, source="device"
|
||||
)
|
||||
|
||||
|
||||
def test_decode_touch_press_and_release() -> None:
|
||||
pressed = decode_one(frame(MessageFwToHost.TOUCH_BUTTON_PRESS, bytes([TouchButton.LEFT_EAR])))
|
||||
released = decode_one(
|
||||
frame(MessageFwToHost.TOUCH_BUTTON_RELEASE, bytes([TouchButton.RIGHT_FOOT]))
|
||||
)
|
||||
assert pressed == TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device")
|
||||
assert released == TouchButtonReleased(button=TouchButton.RIGHT_FOOT, source="device")
|
||||
|
||||
|
||||
def test_decode_button_event() -> None:
|
||||
payload = struct.pack("<BB", Button.ROTARY, ButtonAction.DOUBLE_CLICKED)
|
||||
event = decode_one(frame(MessageFwToHost.BUTTON_EVENT, payload))
|
||||
assert event == ButtonEvent(
|
||||
button=Button.ROTARY, action=ButtonAction.DOUBLE_CLICKED, source="device"
|
||||
)
|
||||
|
||||
|
||||
def test_two_frames_in_one_chunk_are_both_decoded() -> None:
|
||||
"""Regression: the old parser handled at most one frame per read."""
|
||||
data = frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x00") + frame(
|
||||
MessageFwToHost.TOUCH_BUTTON_RELEASE, b"\x00"
|
||||
)
|
||||
items = collect(FrameDecoder(), data)
|
||||
assert [type(item) for item in items] == [TouchButtonPressed, TouchButtonReleased]
|
||||
|
||||
|
||||
def test_a_frame_split_across_chunks_is_reassembled() -> None:
|
||||
data = frame(MessageFwToHost.RFID_TOKEN_READ, bytes.fromhex("04a1b2c3d4"))
|
||||
decoder = FrameDecoder()
|
||||
|
||||
for index in range(len(data) - 1):
|
||||
assert collect(decoder, data[index : index + 1]) == []
|
||||
items = collect(decoder, data[-1:])
|
||||
|
||||
assert items == [RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device")]
|
||||
|
||||
|
||||
def test_log_text_is_yielded_separately() -> None:
|
||||
assert decode_one(b"RFID reader ready\n") == FirmwareLog("RFID reader ready")
|
||||
|
||||
|
||||
def test_log_text_interleaved_with_frames() -> None:
|
||||
data = (
|
||||
b"booting\n"
|
||||
+ frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x02")
|
||||
+ b"touched\r\n"
|
||||
+ frame(MessageFwToHost.TOUCH_BUTTON_RELEASE, b"\x02")
|
||||
)
|
||||
items = collect(FrameDecoder(), data)
|
||||
|
||||
assert items == [
|
||||
FirmwareLog("booting"),
|
||||
TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device"),
|
||||
FirmwareLog("touched"),
|
||||
TouchButtonReleased(button=TouchButton.LEFT_EAR, source="device"),
|
||||
]
|
||||
|
||||
|
||||
def test_unterminated_log_text_resyncs_on_the_next_frame() -> None:
|
||||
data = b"half a line" + frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x01")
|
||||
items = collect(FrameDecoder(), data)
|
||||
|
||||
assert items == [
|
||||
FirmwareLog("half a line"),
|
||||
TouchButtonPressed(button=TouchButton.RIGHT_FOOT, source="device"),
|
||||
]
|
||||
|
||||
|
||||
def test_partial_log_text_is_held_until_more_arrives() -> None:
|
||||
decoder = FrameDecoder()
|
||||
assert collect(decoder, b"partial") == []
|
||||
assert collect(decoder, b" line\n") == [FirmwareLog("partial line")]
|
||||
|
||||
|
||||
def test_unknown_message_type_raises_but_leaves_the_decoder_usable() -> None:
|
||||
decoder = FrameDecoder()
|
||||
data = frame(99, b"\x00") + frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x01")
|
||||
|
||||
decoder.push(data)
|
||||
with pytest.raises(ProtocolError, match="unknown message type 99"):
|
||||
decoder.take()
|
||||
assert collect(decoder, b"") == [
|
||||
TouchButtonPressed(button=TouchButton.RIGHT_FOOT, source="device")
|
||||
]
|
||||
|
||||
|
||||
def test_out_of_range_enum_value_raises() -> None:
|
||||
decoder = FrameDecoder()
|
||||
decoder.push(frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x09"))
|
||||
with pytest.raises(ProtocolError, match="bad value"):
|
||||
decoder.take()
|
||||
|
||||
|
||||
def test_wrong_length_rfid_payload_raises() -> None:
|
||||
decoder = FrameDecoder()
|
||||
decoder.push(frame(MessageFwToHost.RFID_TOKEN_READ, b"\x01\x02"))
|
||||
with pytest.raises(ProtocolError, match="must be 5 bytes"):
|
||||
decoder.take()
|
||||
|
||||
|
||||
def test_garbage_does_not_grow_the_buffer_without_bound() -> None:
|
||||
decoder = FrameDecoder()
|
||||
for _ in range(100):
|
||||
collect(decoder, b"\x00" * 1000)
|
||||
assert decoder.buffered < 8192 + 1000
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- round trip
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
[
|
||||
RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device"),
|
||||
RotaryTurned(position=5, increment=-1, direction=RotaryDirection.DOWN, source="device"),
|
||||
TouchButtonPressed(button=TouchButton.RIGHT_EAR, source="device"),
|
||||
TouchButtonReleased(button=TouchButton.LEFT_FOOT, source="device"),
|
||||
ButtonEvent(button=Button.LEFT, action=ButtonAction.LONG_PRESSED, source="device"),
|
||||
],
|
||||
)
|
||||
def test_encode_decode_round_trip(event: InputEvent) -> None:
|
||||
"""The simulator injects events through this path, so it exercises the real codec."""
|
||||
assert decode_one(encode_input_event(event)) == event
|
||||
|
||||
|
||||
def test_encode_input_event_rejects_non_firmware_events() -> None:
|
||||
from musicmouse.events import PlaylistFinished
|
||||
|
||||
with pytest.raises(ValueError, match="not a firmware message"):
|
||||
encode_input_event(PlaylistFinished())
|
||||
Reference in New Issue
Block a user