Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
0
python-backend/tests/__init__.py
Normal file
0
python-backend/tests/__init__.py
Normal file
39
python-backend/tests/conftest.py
Normal file
39
python-backend/tests/conftest.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
VALID_CONFIG: dict[str, Any] = {
|
||||
"general": {
|
||||
"figure_folder": "music",
|
||||
"serial_port": "/dev/ttyUSB0",
|
||||
"min_volume": 0,
|
||||
"max_volume": 60,
|
||||
"initial_volume": 40,
|
||||
},
|
||||
"figures": {
|
||||
"fuchs": {"id": "04a1b2c3d4", "colors": ["#ff6600", "#ffcc00", "#331100", "wff"]},
|
||||
"eule": {"id": "04b2c3d4e5", "colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_dir(tmp_path: Path) -> Path:
|
||||
"""A directory with a valid ``music/`` tree and two figures' worth of tracks."""
|
||||
for figure, tracks in (("fuchs", 3), ("eule", 2)):
|
||||
folder = tmp_path / "music" / figure
|
||||
folder.mkdir(parents=True)
|
||||
for index in range(tracks):
|
||||
(folder / f"{index:02d} - track.mp3").write_bytes(b"")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def write_config(directory: Path, data: dict[str, Any], name: str = "config.yml") -> Path:
|
||||
path = directory / name
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
YAML(typ="safe").dump(data, handle)
|
||||
return path
|
||||
178
python-backend/tests/test_bus.py
Normal file
178
python-backend/tests/test_bus.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.events import (
|
||||
ButtonEvent,
|
||||
Event,
|
||||
InputEvent,
|
||||
NextTrackRequested,
|
||||
RfidTokenRead,
|
||||
VolumeChanged,
|
||||
)
|
||||
from musicmouse.hardware import Button, ButtonAction
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def bus() -> AsyncIterator[EventBus]:
|
||||
async with EventBus() as running_bus:
|
||||
yield running_bus
|
||||
|
||||
|
||||
def _rfid(tag: str = "04a1b2c3d4") -> RfidTokenRead:
|
||||
return RfidTokenRead(tag_id=bytes.fromhex(tag), figure="fuchs", source="device")
|
||||
|
||||
|
||||
async def test_handler_receives_its_event(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe(RfidTokenRead, seen.append)
|
||||
|
||||
event = _rfid()
|
||||
await bus.emit_and_wait(event)
|
||||
|
||||
assert seen == [event]
|
||||
|
||||
|
||||
async def test_unrelated_handlers_are_not_called(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe(NextTrackRequested, seen.append)
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
|
||||
assert seen == []
|
||||
|
||||
|
||||
async def test_subscribing_to_a_base_class_catches_subclasses(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe(InputEvent, seen.append)
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
await bus.emit_and_wait(VolumeChanged(volume=30))
|
||||
|
||||
assert [type(e) for e in seen] == [RfidTokenRead]
|
||||
|
||||
|
||||
async def test_subscribe_all_sees_everything(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe_all(seen.append)
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
await bus.emit_and_wait(VolumeChanged(volume=30))
|
||||
|
||||
assert [type(e) for e in seen] == [RfidTokenRead, VolumeChanged]
|
||||
|
||||
|
||||
async def test_async_handlers_are_awaited(bus: EventBus) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
async def slow(_: Event) -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
seen.append("slow")
|
||||
|
||||
bus.subscribe(RfidTokenRead, slow)
|
||||
bus.subscribe(RfidTokenRead, lambda _: seen.append("fast"))
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
|
||||
assert seen == ["slow", "fast"]
|
||||
|
||||
|
||||
async def test_events_are_dispatched_in_emission_order(bus: EventBus) -> None:
|
||||
"""Ordering is what makes 'last event wins' meaningful for LED arbitration."""
|
||||
seen: list[int] = []
|
||||
|
||||
async def record(event: VolumeChanged) -> None:
|
||||
await asyncio.sleep(0)
|
||||
seen.append(event.volume)
|
||||
|
||||
bus.subscribe(VolumeChanged, record)
|
||||
|
||||
for volume in range(5):
|
||||
bus.emit(VolumeChanged(volume=volume))
|
||||
await bus.drain()
|
||||
|
||||
assert seen == [0, 1, 2, 3, 4]
|
||||
|
||||
|
||||
async def test_events_emitted_from_a_handler_are_handled_before_drain_returns(
|
||||
bus: EventBus,
|
||||
) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
def on_button(_: ButtonEvent) -> None:
|
||||
seen.append("button")
|
||||
bus.emit(NextTrackRequested(source="device"))
|
||||
|
||||
bus.subscribe(ButtonEvent, on_button)
|
||||
bus.subscribe(NextTrackRequested, lambda _: seen.append("next"))
|
||||
|
||||
await bus.emit_and_wait(
|
||||
ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED, source="device")
|
||||
)
|
||||
|
||||
assert seen == ["button", "next"]
|
||||
|
||||
|
||||
async def test_a_raising_handler_does_not_stop_the_others(
|
||||
bus: EventBus, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
def boom(_: Event) -> None:
|
||||
raise RuntimeError("handler is broken")
|
||||
|
||||
bus.subscribe(RfidTokenRead, boom)
|
||||
bus.subscribe(RfidTokenRead, lambda _: seen.append("survivor"))
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await bus.emit_and_wait(_rfid())
|
||||
await bus.emit_and_wait(_rfid())
|
||||
|
||||
assert seen == ["survivor", "survivor"]
|
||||
assert "handler is broken" in caplog.text
|
||||
|
||||
|
||||
async def test_unsubscribe(bus: EventBus) -> None:
|
||||
seen: list[Event] = []
|
||||
unsubscribe = bus.subscribe(RfidTokenRead, seen.append)
|
||||
|
||||
await bus.emit_and_wait(_rfid())
|
||||
unsubscribe()
|
||||
await bus.emit_and_wait(_rfid())
|
||||
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
async def test_emit_is_safe_from_another_thread(bus: EventBus) -> None:
|
||||
"""libVLC fires its callbacks off-loop; this is the crossing that must be safe."""
|
||||
seen: list[VolumeChanged] = []
|
||||
done = asyncio.Event()
|
||||
|
||||
def record(event: VolumeChanged) -> None:
|
||||
seen.append(event)
|
||||
done.set()
|
||||
|
||||
bus.subscribe(VolumeChanged, record)
|
||||
|
||||
await asyncio.to_thread(bus.emit, VolumeChanged(volume=42, source="player"))
|
||||
async with asyncio.timeout(2):
|
||||
await done.wait()
|
||||
|
||||
assert [e.volume for e in seen] == [42]
|
||||
|
||||
|
||||
async def test_emit_before_start_is_an_error() -> None:
|
||||
with pytest.raises(RuntimeError, match="before start"):
|
||||
EventBus().emit(VolumeChanged(volume=1))
|
||||
|
||||
|
||||
async def test_stop_is_idempotent() -> None:
|
||||
stopped = EventBus()
|
||||
await stopped.start()
|
||||
await stopped.stop()
|
||||
await stopped.stop()
|
||||
108
python-backend/tests/test_clock.py
Normal file
108
python-backend/tests/test_clock.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from musicmouse.clock import FakeClock, RealClock
|
||||
|
||||
|
||||
async def test_fake_clock_starts_at_zero_and_advances() -> None:
|
||||
clock = FakeClock()
|
||||
assert clock.now() == 0.0
|
||||
|
||||
await clock.advance(2.5)
|
||||
|
||||
assert clock.now() == 2.5
|
||||
|
||||
|
||||
async def test_sleep_blocks_until_time_is_advanced_past_the_deadline() -> None:
|
||||
clock = FakeClock()
|
||||
woken = asyncio.Event()
|
||||
|
||||
async def sleeper() -> None:
|
||||
await clock.sleep(10)
|
||||
woken.set()
|
||||
|
||||
task = asyncio.create_task(sleeper())
|
||||
await clock.advance(9)
|
||||
assert not woken.is_set()
|
||||
|
||||
await clock.advance(2)
|
||||
assert woken.is_set()
|
||||
await task
|
||||
|
||||
|
||||
async def test_sleepers_wake_in_deadline_order_regardless_of_start_order() -> None:
|
||||
clock = FakeClock()
|
||||
woken: list[str] = []
|
||||
|
||||
async def sleeper(name: str, delay: float) -> None:
|
||||
await clock.sleep(delay)
|
||||
woken.append(name)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(sleeper("late", 30)),
|
||||
asyncio.create_task(sleeper("early", 10)),
|
||||
asyncio.create_task(sleeper("middle", 20)),
|
||||
]
|
||||
await clock.advance(60)
|
||||
|
||||
assert woken == ["early", "middle", "late"]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
async def test_time_at_wake_up_is_the_deadline_not_the_target() -> None:
|
||||
clock = FakeClock()
|
||||
observed: list[float] = []
|
||||
|
||||
async def sleeper() -> None:
|
||||
await clock.sleep(5)
|
||||
observed.append(clock.now())
|
||||
|
||||
task = asyncio.create_task(sleeper())
|
||||
await clock.advance(100)
|
||||
|
||||
assert observed == [5.0]
|
||||
assert clock.now() == 100.0
|
||||
await task
|
||||
|
||||
|
||||
async def test_idle_hook_runs_after_each_wake_up() -> None:
|
||||
calls: list[int] = []
|
||||
|
||||
async def idle() -> None:
|
||||
calls.append(1)
|
||||
|
||||
clock = FakeClock(idle=idle)
|
||||
|
||||
async def sleeper() -> None:
|
||||
await clock.sleep(1)
|
||||
|
||||
task = asyncio.create_task(sleeper())
|
||||
await clock.advance(2)
|
||||
|
||||
assert calls # the bus got a chance to drain between virtual ticks
|
||||
await task
|
||||
|
||||
|
||||
async def test_pending_timers_is_visible() -> None:
|
||||
clock = FakeClock()
|
||||
task = asyncio.create_task(clock.sleep(5))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert clock.pending_timers == 1
|
||||
await clock.advance(5)
|
||||
assert clock.pending_timers == 0
|
||||
await task
|
||||
|
||||
|
||||
async def test_zero_sleep_just_yields() -> None:
|
||||
clock = FakeClock()
|
||||
await clock.sleep(0)
|
||||
assert clock.now() == 0.0
|
||||
|
||||
|
||||
async def test_real_clock_measures_real_elapsed_time() -> None:
|
||||
clock = RealClock()
|
||||
before = clock.now()
|
||||
await clock.advance(0.01)
|
||||
assert clock.now() - before >= 0.005
|
||||
202
python-backend/tests/test_config.py
Normal file
202
python-backend/tests/test_config.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.color import ColorRGBW, parse_color
|
||||
from musicmouse.config import ConfigError, build_playlists, load_config
|
||||
from tests.conftest import VALID_CONFIG, write_config
|
||||
|
||||
|
||||
def _config(**general: Any) -> dict[str, Any]:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["general"].update(general)
|
||||
return data
|
||||
|
||||
|
||||
def test_loads_valid_config(config_dir: Path) -> None:
|
||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||
|
||||
assert set(config.figures) == {"fuchs", "eule"}
|
||||
assert config.figures["fuchs"].id == bytes.fromhex("04a1b2c3d4")
|
||||
assert config.figures["fuchs"].colors.primary == ColorRGBW(1.0, 0.4, 0.0, 0)
|
||||
assert config.figures["fuchs"].colors.accent == ColorRGBW(0, 0, 0, 1.0)
|
||||
assert config.general.max_volume == 60
|
||||
|
||||
|
||||
def test_figure_folder_resolves_relative_to_the_config_file(config_dir: Path) -> None:
|
||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||
assert config.general.figure_folder == (config_dir / "music").resolve()
|
||||
|
||||
|
||||
def test_tag_map_and_playlists(config_dir: Path) -> None:
|
||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||
|
||||
assert config.tag_map == {
|
||||
bytes.fromhex("04a1b2c3d4"): "fuchs",
|
||||
bytes.fromhex("04b2c3d4e5"): "eule",
|
||||
}
|
||||
playlists = build_playlists(config)
|
||||
assert [t.path.name for t in playlists["fuchs"].tracks] == [
|
||||
"00 - track.mp3",
|
||||
"01 - track.mp3",
|
||||
"02 - track.mp3",
|
||||
]
|
||||
assert len(playlists["eule"]) == 2
|
||||
|
||||
|
||||
def test_playlist_is_alphabetical_regardless_of_creation_order(config_dir: Path) -> None:
|
||||
folder = config_dir / "music" / "fuchs"
|
||||
for name in ("zz last.mp3", "aa first.mp3"):
|
||||
(folder / name).write_bytes(b"")
|
||||
|
||||
playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG)))
|
||||
names = [t.path.name for t in playlists["fuchs"].tracks]
|
||||
assert names == sorted(names)
|
||||
assert names[0] == "00 - track.mp3"
|
||||
|
||||
|
||||
def test_non_audio_files_are_ignored(config_dir: Path) -> None:
|
||||
(config_dir / "music" / "eule" / "cover.jpg").write_bytes(b"")
|
||||
(config_dir / "music" / "eule" / "notes.txt").write_bytes(b"")
|
||||
|
||||
playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG)))
|
||||
assert len(playlists["eule"]) == 2
|
||||
|
||||
|
||||
def test_missing_figure_folder_warns_but_does_not_fail(
|
||||
config_dir: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["figures"]["neu"] = {"id": "0400000001", "colors": ["#111111"] * 4}
|
||||
|
||||
config = load_config(write_config(config_dir, data))
|
||||
playlists = build_playlists(config)
|
||||
|
||||
assert len(playlists["neu"]) == 0
|
||||
assert "no media folder" in caplog.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- error paths
|
||||
|
||||
|
||||
def _error(directory: Path, data: dict[str, Any]) -> str:
|
||||
with pytest.raises(ConfigError) as excinfo:
|
||||
load_config(write_config(directory, data))
|
||||
return str(excinfo.value)
|
||||
|
||||
|
||||
def test_unknown_option_is_rejected_with_its_path(config_dir: Path) -> None:
|
||||
message = _error(config_dir, _config(buton_leds_brightness=0.5))
|
||||
assert "general.buton_leds_brightness" in message
|
||||
assert "unknown option" in message
|
||||
|
||||
|
||||
def test_bad_color_names_the_figure_and_the_position(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["figures"]["fuchs"]["colors"] = ["#ff6600", "not-a-color", "#331100", "wff"]
|
||||
|
||||
message = _error(config_dir, data)
|
||||
assert "figures.fuchs.colors.secondary" in message
|
||||
assert "'#rrggbb' or 'wNN'" in message
|
||||
|
||||
|
||||
def test_wrong_number_of_colors(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["figures"]["eule"]["colors"] = ["#ffffff", "#000000"]
|
||||
|
||||
message = _error(config_dir, data)
|
||||
assert "figures.eule.colors" in message
|
||||
assert "exactly 4 colors" in message
|
||||
|
||||
|
||||
def test_duplicate_tag_ids_are_rejected(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["figures"]["eule"]["id"] = data["figures"]["fuchs"]["id"]
|
||||
|
||||
message = _error(config_dir, data)
|
||||
assert "both use tag id 04a1b2c3d4" in message
|
||||
|
||||
|
||||
def test_tag_id_length_is_checked(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["figures"]["fuchs"]["id"] = "04a1b2"
|
||||
|
||||
message = _error(config_dir, data)
|
||||
assert "figures.fuchs.id" in message
|
||||
assert "expected 5 bytes" in message
|
||||
|
||||
|
||||
def test_all_zero_tag_id_is_reserved(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["figures"]["fuchs"]["id"] = "0000000000"
|
||||
|
||||
assert "reserved" in _error(config_dir, data)
|
||||
|
||||
|
||||
def test_volume_range_is_checked(config_dir: Path) -> None:
|
||||
message = _error(config_dir, _config(min_volume=50, max_volume=20, initial_volume=30))
|
||||
assert "must not exceed max_volume" in message
|
||||
|
||||
|
||||
def test_initial_volume_must_lie_in_range(config_dir: Path) -> None:
|
||||
message = _error(config_dir, _config(min_volume=10, max_volume=20, initial_volume=90))
|
||||
assert "must lie between" in message
|
||||
|
||||
|
||||
def test_missing_figure_folder_is_an_error(config_dir: Path) -> None:
|
||||
message = _error(config_dir, _config(figure_folder="does-not-exist"))
|
||||
assert "general.figure_folder" in message
|
||||
assert "no such directory" in message
|
||||
|
||||
|
||||
def test_every_problem_is_reported_at_once(config_dir: Path) -> None:
|
||||
data = _config(volume_increment=0)
|
||||
data["figures"]["fuchs"]["colors"] = ["#ff6600", "nope", "#331100", "wff"]
|
||||
|
||||
message = _error(config_dir, data)
|
||||
assert "2 problems" in message
|
||||
assert "general.volume_increment" in message
|
||||
assert "figures.fuchs.colors.secondary" in message
|
||||
|
||||
|
||||
def test_directory_instead_of_file_says_so(config_dir: Path) -> None:
|
||||
with pytest.raises(ConfigError, match="Pass the config file itself"):
|
||||
load_config(config_dir)
|
||||
|
||||
|
||||
def test_missing_file(tmp_path: Path) -> None:
|
||||
with pytest.raises(ConfigError, match="Cannot read config file"):
|
||||
load_config(tmp_path / "nope.yml")
|
||||
|
||||
|
||||
def test_malformed_yaml(tmp_path: Path) -> None:
|
||||
path = tmp_path / "config.yml"
|
||||
path.write_text("general: [unclosed\n", encoding="utf-8")
|
||||
with pytest.raises(ConfigError, match="not valid YAML"):
|
||||
load_config(path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- colors
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("#000000", ColorRGBW(0, 0, 0, 0)),
|
||||
("#ffffff", ColorRGBW(1, 1, 1, 0)),
|
||||
("w00", ColorRGBW(0, 0, 0, 0)),
|
||||
("wff", ColorRGBW(0, 0, 0, 1)),
|
||||
],
|
||||
)
|
||||
def test_parse_color(text: str, expected: ColorRGBW) -> None:
|
||||
assert parse_color(text) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["#fff", "#gggggg", "orange", "", "#ff66000"])
|
||||
def test_parse_color_rejects(text: str) -> None:
|
||||
with pytest.raises(ValueError, match="unrecognized color format"):
|
||||
parse_color(text)
|
||||
141
python-backend/tests/test_effects.py
Normal file
141
python-backend/tests/test_effects.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Golden-byte tests for the LED effect payloads.
|
||||
|
||||
These pin the exact layout the firmware reads back into its C++ structs. If one of
|
||||
them fails, either the firmware struct changed or an effect field was reordered - both
|
||||
would otherwise show up only as garbled LEDs on the real device.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.color import ColorHSV, ColorRGBW
|
||||
from musicmouse.effects import (
|
||||
EffectAlexaSwipeConfig,
|
||||
EffectCircularConfig,
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectReverseSwipe,
|
||||
EffectStaticConfig,
|
||||
EffectStaticDetailedConfig,
|
||||
EffectSwipeAndChange,
|
||||
LedEffect,
|
||||
)
|
||||
|
||||
|
||||
def test_color_rgbw_is_four_bytes() -> None:
|
||||
assert ColorRGBW(1.0, 0.5, 0.0, 0.25).as_bytes().hex() == "ff7f003f"
|
||||
|
||||
|
||||
def test_color_rgbw_rejects_out_of_range_channels() -> None:
|
||||
with pytest.raises(ValueError, match=r"within 0\.\.1"):
|
||||
ColorRGBW(1.5, 0, 0, 0).as_bytes()
|
||||
|
||||
|
||||
def test_color_hsv_is_three_floats() -> None:
|
||||
# h=180.0, s=1.0, v=0.5
|
||||
assert ColorHSV(180.0, 1.0, 0.5).as_bytes().hex() == "00003443" "0000803f" "0000003f"
|
||||
|
||||
|
||||
def test_color_hsv_from_rgb() -> None:
|
||||
assert ColorHSV.from_rgb(ColorRGBW(1, 0, 0, 0)) == ColorHSV(0.0, 1.0, 1.0)
|
||||
assert ColorHSV.from_rgb(ColorRGBW(0, 1, 0, 0)) == ColorHSV(120.0, 1.0, 1.0)
|
||||
|
||||
|
||||
def test_static() -> None:
|
||||
effect = EffectStaticConfig(ColorRGBW(1.0, 0.5, 0.0, 0.25), begin=3, end=45)
|
||||
assert effect.as_bytes().hex() == "ff7f003f" "0300" "2d00"
|
||||
|
||||
|
||||
def test_static_detailed() -> None:
|
||||
effect = EffectStaticDetailedConfig(
|
||||
ColorRGBW(0, 0, 0, 1.0), increment=4, begin=0.25, end=0.75, transition_time_in_ms=500
|
||||
)
|
||||
assert effect.as_bytes().hex() == (
|
||||
"000000ff" # color
|
||||
"0400" # increment (uint16)
|
||||
"0000803e" # begin 0.25f
|
||||
"0000403f" # end 0.75f
|
||||
"0000fa43" # transition 500.0f
|
||||
)
|
||||
|
||||
|
||||
def test_circular() -> None:
|
||||
effect = EffectCircularConfig(speed=360, width=180, color=ColorRGBW(0, 0, 1, 0))
|
||||
assert effect.as_bytes().hex() == "0000b443" "00003443" "0000ff00"
|
||||
|
||||
|
||||
def test_reverse_swipe() -> None:
|
||||
effect = EffectReverseSwipe(swipe_speed=720, bell_curve_width_in_leds=3, start_position=180)
|
||||
assert effect.as_bytes().hex() == "00003444" "00004040" "00003443"
|
||||
|
||||
|
||||
def test_alexa_swipe() -> None:
|
||||
effect = EffectAlexaSwipeConfig(
|
||||
primary_color_width=180,
|
||||
transition_width=180,
|
||||
swipe_speed=720,
|
||||
bell_curve_width_in_leds=3,
|
||||
start_position=180,
|
||||
forward=False,
|
||||
primary_color=ColorRGBW(1, 0, 0, 0),
|
||||
secondary_color=ColorRGBW(0, 1, 0, 0),
|
||||
)
|
||||
assert effect.as_bytes().hex() == (
|
||||
"00003443" "00003443" "00003444" "00004040" "00003443" # five floats
|
||||
"00" # forward = false
|
||||
"ff000000" # primary
|
||||
"00ff0000" # secondary
|
||||
)
|
||||
|
||||
|
||||
def test_random_two_color_interpolation() -> None:
|
||||
effect = EffectRandomTwoColorInterpolationConfig(
|
||||
cycle_durations_ms=1000,
|
||||
start_with_existing=True,
|
||||
num_segments=3,
|
||||
hue1_random=False,
|
||||
hue2_random=True,
|
||||
color1=ColorHSV(180.0, 1.0, 0.5),
|
||||
color2=ColorHSV(0.0, 0.0, 0.0),
|
||||
)
|
||||
assert effect.as_bytes().hex() == (
|
||||
"e8030000" # cycle_durations_ms int32
|
||||
"01" # start_with_existing
|
||||
"03000000" # num_segments int32
|
||||
"00" # hue1_random
|
||||
"01" # hue2_random
|
||||
"000034430000803f0000003f" # color1 hsv
|
||||
"000000000000000000000000" # color2 hsv
|
||||
)
|
||||
|
||||
|
||||
def test_rgb_colors_are_converted_to_hsv_on_the_wire() -> None:
|
||||
"""The firmware struct is HSV; reactions hand it RGBW figure colours."""
|
||||
as_rgb = EffectRandomTwoColorInterpolationConfig(
|
||||
color1=ColorRGBW(1, 0, 0, 0), color2=ColorRGBW(0, 1, 0, 0)
|
||||
)
|
||||
as_hsv = EffectRandomTwoColorInterpolationConfig(
|
||||
color1=ColorHSV(0.0, 1.0, 1.0), color2=ColorHSV(120.0, 1.0, 1.0)
|
||||
)
|
||||
assert as_rgb.as_bytes() == as_hsv.as_bytes()
|
||||
|
||||
|
||||
def test_swipe_and_change_is_the_two_payloads_concatenated() -> None:
|
||||
effect = EffectSwipeAndChange()
|
||||
assert effect.as_bytes() == effect.swipe.as_bytes() + effect.change.as_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("effect", "size"),
|
||||
[
|
||||
(EffectStaticConfig(ColorRGBW(0, 0, 0, 0)), 8),
|
||||
(EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)), 18),
|
||||
(EffectCircularConfig(), 12),
|
||||
(EffectAlexaSwipeConfig(), 29),
|
||||
(EffectRandomTwoColorInterpolationConfig(), 35),
|
||||
(EffectReverseSwipe(), 12),
|
||||
(EffectSwipeAndChange(), 64),
|
||||
],
|
||||
)
|
||||
def test_payload_sizes(effect: LedEffect, size: int) -> None:
|
||||
assert len(effect.as_bytes()) == size
|
||||
333
python-backend/tests/test_mouse.py
Normal file
333
python-backend/tests/test_mouse.py
Normal file
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
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
|
||||
|
||||
|
||||
def only[T: Event](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"),
|
||||
]
|
||||
535
python-backend/tests/test_mqtt.py
Normal file
535
python-backend/tests/test_mqtt.py
Normal file
@@ -0,0 +1,535 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.color import ColorRGBW
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.effects import (
|
||||
EffectCircularConfig,
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectStaticDetailedConfig,
|
||||
EffectSwipeAndChange,
|
||||
)
|
||||
from musicmouse.events import (
|
||||
ButtonEvent,
|
||||
Event,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
RfidTokenRead,
|
||||
SetVolumeRequested,
|
||||
TouchButtonPressed,
|
||||
)
|
||||
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
from musicmouse.services.mqtt.lights import (
|
||||
BLACK,
|
||||
LightEntity,
|
||||
effect_names,
|
||||
parse_positional_effect,
|
||||
)
|
||||
from musicmouse.services.mqtt.player import TransportButton, VolumeNumber
|
||||
from musicmouse.services.mqtt.service import build_entities
|
||||
from musicmouse.services.mqtt.triggers import ButtonTrigger, TagScanner, TouchTrigger
|
||||
from musicmouse.simulator.fake_player import FakePlayer
|
||||
from musicmouse.simulator.fake_transport import FakeTransport
|
||||
|
||||
TAG_MAP = {bytes.fromhex("04a1b2c3d4"): "fuchs"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingPublisher:
|
||||
"""Stands in for the broker connection."""
|
||||
|
||||
messages: list[tuple[str, str, bool]] = field(default_factory=list)
|
||||
|
||||
async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None:
|
||||
self.messages.append((topic, payload, retain))
|
||||
|
||||
def payloads_on(self, topic: str) -> list[str]:
|
||||
return [payload for sent_topic, payload, _ in self.messages if sent_topic == topic]
|
||||
|
||||
def last_json(self, topic: str) -> dict:
|
||||
return json.loads(self.payloads_on(topic)[-1])
|
||||
|
||||
def clear(self) -> None:
|
||||
self.messages.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def bus() -> AsyncIterator[EventBus]:
|
||||
async with EventBus() as running:
|
||||
yield running
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_config() -> MqttConfig:
|
||||
return MqttConfig(server="broker.local")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transport() -> FakeTransport:
|
||||
return FakeTransport()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mouse(bus: EventBus, transport: FakeTransport) -> MusicMouseDevice:
|
||||
device = MusicMouseDevice(bus, transport, TAG_MAP, port="simulated")
|
||||
transport.attach(device.feed)
|
||||
return device
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(bus: EventBus) -> FakePlayer:
|
||||
return FakePlayer(bus, initial_volume=40)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def publisher() -> RecordingPublisher:
|
||||
return RecordingPublisher()
|
||||
|
||||
|
||||
def attach(entity: Entity, publisher: RecordingPublisher) -> Entity:
|
||||
entity.attach(publisher)
|
||||
return entity
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shelf(
|
||||
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, publisher: RecordingPublisher
|
||||
) -> LightEntity:
|
||||
entity = LightEntity(bus, mqtt_config, mouse, LedZone.SHELF, "Shelf")
|
||||
entity.attach(publisher)
|
||||
return entity
|
||||
|
||||
|
||||
def command(**fields: object) -> str:
|
||||
return json.dumps(fields)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ effect names
|
||||
|
||||
|
||||
def test_the_old_effect_names_still_decode_the_same_way() -> None:
|
||||
"""Values taken from the hand-written elif chain in the old mqtt_json.py."""
|
||||
assert parse_positional_effect("side_0.2") == (0.9, 0.1, 1)
|
||||
assert parse_positional_effect("side_0.5") == (0.75, 0.25, 1)
|
||||
assert parse_positional_effect("top_0.2") == (0.4, 0.6, 1)
|
||||
assert parse_positional_effect("top_0.5") == (0.25, 0.75, 1)
|
||||
assert parse_positional_effect("side_0.2_inc4") == (0.9, 0.1, 4)
|
||||
assert parse_positional_effect("side_0.2_inc8") == (0.9, 0.1, 8)
|
||||
assert parse_positional_effect("top_0.5_inc4") == (0.25, 0.75, 4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["static", "circular", "sideways_0.2", "side", "top_", "side_x"])
|
||||
def test_non_positional_names_are_not_parsed(name: str) -> None:
|
||||
assert parse_positional_effect(name) is None
|
||||
|
||||
|
||||
def test_every_old_effect_name_is_still_offered() -> None:
|
||||
previously_offered = {
|
||||
"static", "circular", "wipeup", "twocolor", "twocolorrandom",
|
||||
"side_0.2", "side_0.5", "side_0.2_inc4", "side_0.2_inc8", "side_0.5_inc4",
|
||||
"top_0.2", "top_0.5", "top_0.2_inc4", "top_0.5_inc4",
|
||||
} # fmt: skip
|
||||
assert previously_offered <= set(effect_names())
|
||||
|
||||
|
||||
def test_every_offered_effect_name_can_be_built(
|
||||
shelf: LightEntity, mouse: MusicMouseDevice
|
||||
) -> None:
|
||||
for name in effect_names():
|
||||
shelf._state.update({"state": "ON", "effect": name})
|
||||
assert shelf._build_effect() is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- light commands
|
||||
|
||||
|
||||
async def test_turning_the_light_on_sets_a_static_effect(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
|
||||
) -> None:
|
||||
await shelf.handle(
|
||||
shelf.command_topic,
|
||||
command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=255),
|
||||
)
|
||||
await bus.drain()
|
||||
|
||||
effect = mouse.effect(LedZone.SHELF)
|
||||
assert isinstance(effect, EffectStaticDetailedConfig)
|
||||
assert effect.color == ColorRGBW(1.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
async def test_brightness_scales_the_colour(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
|
||||
) -> None:
|
||||
await shelf.handle(
|
||||
shelf.command_topic,
|
||||
command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=128),
|
||||
)
|
||||
await bus.drain()
|
||||
|
||||
effect = mouse.effect(LedZone.SHELF)
|
||||
assert isinstance(effect, EffectStaticDetailedConfig)
|
||||
assert effect.color.r == pytest.approx(128 / 255, abs=0.01)
|
||||
|
||||
|
||||
async def test_turning_the_light_off_sends_black(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
|
||||
) -> None:
|
||||
await shelf.handle(shelf.command_topic, command(state="OFF"))
|
||||
await bus.drain()
|
||||
|
||||
effect = mouse.effect(LedZone.SHELF)
|
||||
assert isinstance(effect, EffectStaticDetailedConfig)
|
||||
assert effect.color == BLACK
|
||||
|
||||
|
||||
async def test_a_positional_effect_becomes_a_detailed_static(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
|
||||
) -> None:
|
||||
await shelf.handle(shelf.command_topic, command(state="ON", effect="top_0.5_inc4"))
|
||||
await bus.drain()
|
||||
|
||||
effect = mouse.effect(LedZone.SHELF)
|
||||
assert isinstance(effect, EffectStaticDetailedConfig)
|
||||
assert (effect.begin, effect.end, effect.increment) == (0.25, 0.75, 4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("circular", EffectCircularConfig),
|
||||
("wipeup", EffectSwipeAndChange),
|
||||
("twocolor", EffectRandomTwoColorInterpolationConfig),
|
||||
("twocolorrandom", EffectRandomTwoColorInterpolationConfig),
|
||||
],
|
||||
)
|
||||
async def test_named_effects(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, name: str, expected: type
|
||||
) -> None:
|
||||
await shelf.handle(shelf.command_topic, command(state="ON", effect=name))
|
||||
await bus.drain()
|
||||
assert isinstance(mouse.effect(LedZone.SHELF), expected)
|
||||
|
||||
|
||||
async def test_twocolorrandom_randomises_both_hues(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
|
||||
) -> None:
|
||||
await shelf.handle(shelf.command_topic, command(state="ON", effect="twocolorrandom"))
|
||||
await bus.drain()
|
||||
|
||||
effect = mouse.effect(LedZone.SHELF)
|
||||
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
|
||||
assert effect.hue1_random and effect.hue2_random
|
||||
|
||||
|
||||
async def test_an_unknown_effect_turns_the_light_off_with_a_warning(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await shelf.handle(shelf.command_topic, command(state="ON", effect="disco"))
|
||||
await bus.drain()
|
||||
|
||||
effect = mouse.effect(LedZone.SHELF)
|
||||
assert isinstance(effect, EffectStaticDetailedConfig)
|
||||
assert effect.color == BLACK
|
||||
assert "Unknown effect 'disco'" in caplog.text
|
||||
|
||||
|
||||
async def test_malformed_json_is_ignored(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await shelf.handle(shelf.command_topic, "not json at all")
|
||||
await bus.drain()
|
||||
|
||||
assert mouse.effect(LedZone.SHELF) is None
|
||||
assert "non-JSON command" in caplog.text
|
||||
|
||||
|
||||
async def test_the_previous_colour_is_remembered_for_two_colour_effects(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
|
||||
) -> None:
|
||||
await shelf.handle(
|
||||
shelf.command_topic,
|
||||
command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=255),
|
||||
)
|
||||
await shelf.handle(
|
||||
shelf.command_topic,
|
||||
command(
|
||||
state="ON",
|
||||
color={"r": 0, "g": 0, "b": 255, "w": 0},
|
||||
brightness=255,
|
||||
effect="twocolor",
|
||||
),
|
||||
)
|
||||
await bus.drain()
|
||||
|
||||
effect = mouse.effect(LedZone.SHELF)
|
||||
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
|
||||
assert effect.color1 == ColorRGBW(0, 0, 1, 0)
|
||||
assert effect.color2 == ColorRGBW(1, 0, 0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- state reporting
|
||||
|
||||
|
||||
async def test_state_is_published_after_a_command(
|
||||
bus: EventBus, shelf: LightEntity, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
await shelf.handle(shelf.command_topic, command(state="ON", effect="static"))
|
||||
await bus.drain()
|
||||
|
||||
assert publisher.last_json(shelf.state_topic)["state"] == "ON"
|
||||
|
||||
|
||||
async def test_a_local_effect_updates_what_home_assistant_sees(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
"""The whole point of publishing from LedEffectChanged rather than the command echo."""
|
||||
await shelf.handle(shelf.command_topic, command(state="OFF"))
|
||||
await bus.drain()
|
||||
assert publisher.last_json(shelf.state_topic)["state"] == "OFF"
|
||||
|
||||
mouse.set_effect(
|
||||
LedZone.SHELF, EffectCircularConfig(color=ColorRGBW(0, 1, 0, 0)), origin="device"
|
||||
)
|
||||
await bus.drain()
|
||||
|
||||
reported = publisher.last_json(shelf.state_topic)
|
||||
assert reported["state"] == "ON"
|
||||
assert reported["color"] == {"r": 0, "g": 255, "b": 0, "w": 0}
|
||||
|
||||
|
||||
async def test_a_local_off_effect_is_reported_as_off(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
from musicmouse.effects import OFF
|
||||
|
||||
await shelf.handle(shelf.command_topic, command(state="ON", effect="static"))
|
||||
await bus.drain()
|
||||
|
||||
mouse.set_effect(LedZone.SHELF, OFF(), origin="device")
|
||||
await bus.drain()
|
||||
|
||||
assert publisher.last_json(shelf.state_topic)["state"] == "OFF"
|
||||
|
||||
|
||||
async def test_another_zone_does_not_affect_this_entity(
|
||||
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
publisher.clear()
|
||||
mouse.set_effect(LedZone.RING, EffectCircularConfig(), origin="device")
|
||||
await bus.drain()
|
||||
|
||||
assert publisher.payloads_on(shelf.state_topic) == []
|
||||
|
||||
|
||||
async def test_discovery_declares_a_json_rgbw_light_with_effects(shelf: LightEntity) -> None:
|
||||
payload = shelf.discovery_payload()
|
||||
|
||||
assert payload["schema"] == "json"
|
||||
assert payload["supported_color_modes"] == ["rgbw"]
|
||||
assert payload["command_topic"] == shelf.command_topic
|
||||
assert "top_0.5_inc4" in payload["effect_list"]
|
||||
assert payload["device"]["identifiers"] == ["musicmouse"]
|
||||
|
||||
|
||||
async def test_announce_publishes_retained_discovery_then_state(
|
||||
shelf: LightEntity, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
await shelf.announce()
|
||||
|
||||
topics = [topic for topic, _, _ in publisher.messages]
|
||||
assert topics == [shelf.discovery_topic, shelf.state_topic]
|
||||
assert publisher.messages[0][2] is True # discovery is retained
|
||||
|
||||
|
||||
async def test_nothing_is_published_while_offline(shelf: LightEntity) -> None:
|
||||
shelf.attach(None)
|
||||
await shelf.announce() # must not raise
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- player
|
||||
|
||||
|
||||
async def test_volume_command_emits_an_intent(
|
||||
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer
|
||||
) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe(SetVolumeRequested, seen.append)
|
||||
entity = VolumeNumber(bus, mqtt_config, player)
|
||||
|
||||
await entity.handle(entity.command_topic, "37")
|
||||
await bus.drain()
|
||||
|
||||
assert seen == [SetVolumeRequested(volume=37, source="mqtt")]
|
||||
|
||||
|
||||
async def test_a_non_numeric_volume_is_ignored(
|
||||
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe(SetVolumeRequested, seen.append)
|
||||
entity = VolumeNumber(bus, mqtt_config, player)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await entity.handle(entity.command_topic, "loud")
|
||||
await bus.drain()
|
||||
|
||||
assert seen == []
|
||||
assert "non-numeric volume" in caplog.text
|
||||
|
||||
|
||||
async def test_volume_state_follows_the_player(
|
||||
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
entity = VolumeNumber(bus, mqtt_config, player)
|
||||
entity.attach(publisher)
|
||||
|
||||
player.set_volume(22)
|
||||
await bus.drain()
|
||||
|
||||
assert publisher.payloads_on(entity.state_topic)[-1] == "22"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("action", "expected"),
|
||||
[("next", NextTrackRequested), ("pause", PauseRequested)],
|
||||
)
|
||||
async def test_transport_buttons_emit_intents(
|
||||
bus: EventBus, mqtt_config: MqttConfig, action: str, expected: type[Event]
|
||||
) -> None:
|
||||
seen: list[Event] = []
|
||||
bus.subscribe(expected, seen.append)
|
||||
entity = TransportButton(bus, mqtt_config, action, f"Music Mouse {action}")
|
||||
|
||||
await entity.handle(entity.command_topic, "PRESS")
|
||||
await bus.drain()
|
||||
|
||||
assert len(seen) == 1
|
||||
assert seen[0].source == "mqtt"
|
||||
|
||||
|
||||
async def test_the_player_sensor_reports_the_current_track(
|
||||
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.media import Playlist, Track
|
||||
from musicmouse.services.mqtt.player import PlayerSensor
|
||||
|
||||
entity = PlayerSensor(bus, mqtt_config, player)
|
||||
entity.attach(publisher)
|
||||
player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),)))
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
|
||||
assert publisher.payloads_on(entity.state_topic)[-1] == "playing"
|
||||
attributes = publisher.last_json(f"{entity.base_topic}/attributes")
|
||||
assert attributes["playlist"] == "fuchs"
|
||||
assert attributes["title"] == "01 - Song"
|
||||
assert attributes["volume"] == 40
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- triggers
|
||||
|
||||
|
||||
async def test_a_button_trigger_fires_only_for_its_own_event(
|
||||
bus: EventBus, mqtt_config: MqttConfig, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
trigger = ButtonTrigger(bus, mqtt_config, Button.ROTARY, ButtonAction.PRESSED)
|
||||
trigger.attach(publisher)
|
||||
|
||||
bus.emit(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED, source="device"))
|
||||
bus.emit(ButtonEvent(button=Button.ROTARY, action=ButtonAction.RELEASED, source="device"))
|
||||
bus.emit(ButtonEvent(button=Button.ROTARY, action=ButtonAction.PRESSED, source="device"))
|
||||
await bus.drain()
|
||||
|
||||
assert publisher.payloads_on(trigger.trigger_topic) == ["pressed"]
|
||||
|
||||
|
||||
async def test_button_trigger_discovery(bus: EventBus, mqtt_config: MqttConfig) -> None:
|
||||
trigger = ButtonTrigger(bus, mqtt_config, Button.ROTARY, ButtonAction.LONG_PRESSED)
|
||||
payload = trigger.discovery_payload()
|
||||
|
||||
assert trigger.discovery_topic == (
|
||||
"homeassistant/device_automation/musicmouse/rotary_long_pressed/config"
|
||||
)
|
||||
assert payload["automation_type"] == "trigger"
|
||||
assert payload["type"] == "button_long_press"
|
||||
assert payload["subtype"] == "rotary"
|
||||
|
||||
|
||||
async def test_a_touch_trigger_fires(
|
||||
bus: EventBus, mqtt_config: MqttConfig, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
trigger = TouchTrigger(bus, mqtt_config, TouchButton.LEFT_EAR, pressed=True)
|
||||
trigger.attach(publisher)
|
||||
|
||||
bus.emit(TouchButtonPressed(button=TouchButton.RIGHT_EAR, source="device"))
|
||||
bus.emit(TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device"))
|
||||
await bus.drain()
|
||||
|
||||
assert publisher.payloads_on(trigger.trigger_topic) == ["left_ear"]
|
||||
|
||||
|
||||
async def test_the_tag_scanner_publishes_every_read(
|
||||
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, publisher: RecordingPublisher
|
||||
) -> None:
|
||||
scanner = TagScanner(bus, mqtt_config)
|
||||
scanner.attach(publisher)
|
||||
|
||||
bus.emit(
|
||||
RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), figure="fuchs", source="device")
|
||||
)
|
||||
await bus.drain()
|
||||
|
||||
assert json.loads(publisher.payloads_on(scanner.scan_topic)[-1]) == {
|
||||
"tag_id": "04a1b2c3d4",
|
||||
"figure": "fuchs",
|
||||
"known": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_tag_scanner_discovery(bus: EventBus, mqtt_config: MqttConfig) -> None:
|
||||
scanner = TagScanner(bus, mqtt_config)
|
||||
assert scanner.discovery_topic == "homeassistant/tag/musicmouse/config"
|
||||
assert scanner.discovery_payload()["value_template"] == "{{ value_json.tag_id }}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- wiring
|
||||
|
||||
|
||||
def test_every_entity_has_a_unique_discovery_topic(
|
||||
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer
|
||||
) -> None:
|
||||
entities = build_entities(bus, mqtt_config, mouse, player)
|
||||
topics = [entity.discovery_topic for entity in entities]
|
||||
|
||||
assert len(topics) == len(set(topics))
|
||||
assert len(entities) > 20
|
||||
|
||||
|
||||
def test_command_topics_do_not_collide(
|
||||
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer
|
||||
) -> None:
|
||||
entities = build_entities(bus, mqtt_config, mouse, player)
|
||||
topics = [topic for entity in entities for topic in entity.command_topics()]
|
||||
|
||||
assert len(topics) == len(set(topics))
|
||||
|
||||
|
||||
def test_all_three_led_zones_are_exposed(
|
||||
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer
|
||||
) -> None:
|
||||
entities = build_entities(bus, mqtt_config, mouse, player)
|
||||
lights = [e for e in entities if isinstance(e, LightEntity)]
|
||||
assert {light.zone for light in lights} == set(LedZone)
|
||||
297
python-backend/tests/test_player.py
Normal file
297
python-backend/tests/test_player.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Tests for the shared player behaviour, exercised through FakePlayer.
|
||||
|
||||
VlcPlayer adds only the libVLC bindings on top of PlayerBase; it needs a real audio
|
||||
device and is covered by the on-device checklist, not here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import FakeClock
|
||||
from musicmouse.devices.player import Player, VlcPlayer
|
||||
from musicmouse.events import (
|
||||
Event,
|
||||
PlaybackChanged,
|
||||
PlaylistFinished,
|
||||
TrackChanged,
|
||||
VolumeChanged,
|
||||
)
|
||||
from musicmouse.media import Playlist, Track
|
||||
from musicmouse.simulator.fake_player import FakePlayer
|
||||
|
||||
|
||||
def playlist(name: str = "fuchs", count: int = 3) -> Playlist:
|
||||
tracks = tuple(Track(Path(f"/music/{name}/{i}.mp3")) for i in range(count))
|
||||
return Playlist(name=name, tracks=tracks)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def bus() -> AsyncIterator[EventBus]:
|
||||
async with EventBus() as running:
|
||||
yield running
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(bus: EventBus) -> FakeClock:
|
||||
return FakeClock(idle=bus.drain)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(bus: EventBus, clock: FakeClock) -> FakePlayer:
|
||||
return FakePlayer(bus, clock=clock, track_duration=10.0, initial_volume=50)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seen(bus: EventBus) -> list[Event]:
|
||||
events: list[Event] = []
|
||||
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)]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ volume
|
||||
|
||||
|
||||
async def test_volume_starts_at_the_configured_value(player: FakePlayer) -> None:
|
||||
assert player.volume == 50
|
||||
|
||||
|
||||
async def test_setting_volume_announces_it(
|
||||
bus: EventBus, player: FakePlayer, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_volume(30, source="mqtt")
|
||||
await bus.drain()
|
||||
|
||||
assert player.volume == 30
|
||||
assert only(seen, VolumeChanged) == [VolumeChanged(volume=30, source="mqtt")]
|
||||
|
||||
|
||||
async def test_setting_the_same_volume_is_not_announced(
|
||||
bus: EventBus, player: FakePlayer, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_volume(50)
|
||||
await bus.drain()
|
||||
assert only(seen, VolumeChanged) == []
|
||||
|
||||
|
||||
async def test_change_volume_is_relative(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.change_volume(-20)
|
||||
await bus.drain()
|
||||
assert player.volume == 30
|
||||
|
||||
|
||||
async def test_volume_is_clamped_to_the_configured_range(bus: EventBus) -> None:
|
||||
limited = FakePlayer(bus, min_volume=20, max_volume=60, initial_volume=40)
|
||||
|
||||
limited.set_volume(100)
|
||||
assert limited.volume == 60
|
||||
limited.set_volume(0)
|
||||
assert limited.volume == 20
|
||||
|
||||
|
||||
async def test_min_volume_of_zero_is_honoured(bus: EventBus) -> None:
|
||||
"""Regression: `if self.volume_min and ...` treated a configured 0 as unset."""
|
||||
limited = FakePlayer(bus, min_volume=0, max_volume=100, initial_volume=10)
|
||||
|
||||
limited.set_volume(-5)
|
||||
|
||||
assert limited.volume == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- playback
|
||||
|
||||
|
||||
async def test_play_from_start_starts_the_first_track(
|
||||
bus: EventBus, player: FakePlayer, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
|
||||
assert player.is_playing
|
||||
assert player.track_index == 0
|
||||
assert player.current_track is not None
|
||||
assert player.current_track.title == "0"
|
||||
assert only(seen, PlaybackChanged)[-1].playing is True
|
||||
|
||||
|
||||
async def test_playing_an_empty_playlist_does_nothing(
|
||||
bus: EventBus, player: FakePlayer, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
player.set_playlist(Playlist(name="leer", tracks=()))
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
|
||||
assert not player.is_playing
|
||||
assert "playlist is empty" in caplog.text
|
||||
|
||||
|
||||
async def test_pause_and_resume(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
player.pause()
|
||||
await bus.drain()
|
||||
assert not player.is_playing
|
||||
|
||||
player.play()
|
||||
await bus.drain()
|
||||
assert player.is_playing
|
||||
|
||||
|
||||
async def test_tracks_advance_as_time_passes(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_playlist(playlist(count=3))
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
|
||||
await clock.advance(10.0)
|
||||
assert player.track_index == 1
|
||||
|
||||
await clock.advance(10.0)
|
||||
assert player.track_index == 2
|
||||
|
||||
assert [e.index for e in only(seen, TrackChanged)] == [1, 2]
|
||||
|
||||
|
||||
async def test_playlist_end_stops_playback_and_is_announced(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_playlist(playlist(count=2))
|
||||
player.play_from_start()
|
||||
|
||||
await clock.advance(25.0)
|
||||
|
||||
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]
|
||||
assert not player.is_playing
|
||||
|
||||
|
||||
async def test_a_paused_player_does_not_advance(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock
|
||||
) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
player.pause()
|
||||
|
||||
await clock.advance(100.0)
|
||||
|
||||
assert player.track_index == 0
|
||||
assert not player.is_playing
|
||||
|
||||
|
||||
async def test_resuming_continues_the_remainder_of_the_track(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock
|
||||
) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
await clock.advance(7.0)
|
||||
player.pause()
|
||||
await clock.advance(100.0)
|
||||
player.play()
|
||||
|
||||
await clock.advance(2.0)
|
||||
assert player.track_index == 0 # 3s of the track were still left
|
||||
|
||||
await clock.advance(2.0)
|
||||
assert player.track_index == 1
|
||||
|
||||
|
||||
async def test_next_and_previous(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.set_playlist(playlist(count=3))
|
||||
player.play_from_start()
|
||||
await bus.drain()
|
||||
|
||||
player.next_track()
|
||||
player.next_track()
|
||||
await bus.drain()
|
||||
assert player.track_index == 2
|
||||
|
||||
player.previous_track()
|
||||
await bus.drain()
|
||||
assert player.track_index == 1
|
||||
|
||||
|
||||
async def test_previous_on_the_first_track_stays_there(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
player.previous_track()
|
||||
await bus.drain()
|
||||
|
||||
assert player.track_index == 0
|
||||
|
||||
|
||||
async def test_next_past_the_last_track_ends_the_playlist(
|
||||
bus: EventBus, player: FakePlayer, seen: list[Event]
|
||||
) -> None:
|
||||
player.set_playlist(playlist(count=2))
|
||||
player.play_from_start()
|
||||
player.next_track()
|
||||
player.next_track()
|
||||
await bus.drain()
|
||||
|
||||
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]
|
||||
assert not player.is_playing
|
||||
|
||||
|
||||
async def test_skipping_restarts_the_track_timer(
|
||||
bus: EventBus, player: FakePlayer, clock: FakeClock
|
||||
) -> None:
|
||||
player.set_playlist(playlist(count=3))
|
||||
player.play_from_start()
|
||||
await clock.advance(9.0)
|
||||
player.next_track()
|
||||
await bus.drain()
|
||||
|
||||
await clock.advance(9.0)
|
||||
assert player.track_index == 1 # a fresh 10s, not the 1s left over
|
||||
|
||||
await clock.advance(2.0)
|
||||
assert player.track_index == 2
|
||||
|
||||
|
||||
async def test_stop_resets_playback(bus: EventBus, player: FakePlayer, clock: FakeClock) -> None:
|
||||
player.set_playlist(playlist())
|
||||
player.play_from_start()
|
||||
player.stop()
|
||||
await bus.drain()
|
||||
|
||||
assert not player.is_playing
|
||||
await clock.advance(100.0)
|
||||
assert player.track_index == 0
|
||||
|
||||
|
||||
async def test_setting_a_new_playlist_resets_the_index(bus: EventBus, player: FakePlayer) -> None:
|
||||
player.set_playlist(playlist(count=3))
|
||||
player.play_from_start()
|
||||
player.next_track()
|
||||
await bus.drain()
|
||||
|
||||
player.set_playlist(playlist(name="eule", count=2))
|
||||
|
||||
assert player.track_index == 0
|
||||
assert player.playlist is not None
|
||||
assert player.playlist.name == "eule"
|
||||
|
||||
|
||||
async def test_current_track_is_none_without_a_playlist(player: FakePlayer) -> None:
|
||||
assert player.current_track is None
|
||||
assert player.playlist is None
|
||||
|
||||
|
||||
def test_fake_player_satisfies_the_player_protocol(player: FakePlayer) -> None:
|
||||
check: Player = player
|
||||
assert check.volume == player.volume
|
||||
|
||||
|
||||
def _vlc_player_satisfies_the_player_protocol(real: VlcPlayer) -> Player:
|
||||
"""Checked by mypy, not at runtime: VlcPlayer needs libVLC to instantiate."""
|
||||
return real
|
||||
369
python-backend/tests/test_reactions.py
Normal file
369
python-backend/tests/test_reactions.py
Normal 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)
|
||||
104
python-backend/tests/test_scenarios.py
Normal file
104
python-backend/tests/test_scenarios.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Run every file in ``scenarios/`` as a test.
|
||||
|
||||
Same driver, same verbs, same code path as the interactive simulator - only the clock
|
||||
differs, so a scenario that takes half a minute by hand runs in microseconds here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.config import load_config
|
||||
from musicmouse.simulator.driver import ExpectationError, ScriptError
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
from musicmouse.simulator.script import run_script_file
|
||||
from tests.conftest import VALID_CONFIG, write_config
|
||||
|
||||
SCENARIO_DIR = Path(__file__).resolve().parents[1] / "scenarios"
|
||||
SCENARIOS = sorted(SCENARIO_DIR.glob("*.txt"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||
simulation = await build_simulation(config)
|
||||
try:
|
||||
yield simulation
|
||||
finally:
|
||||
await simulation.aclose()
|
||||
|
||||
|
||||
def test_scenario_files_exist() -> None:
|
||||
assert SCENARIOS, f"no scenario files found in {SCENARIO_DIR}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", SCENARIOS, ids=lambda p: p.stem)
|
||||
async def test_scenario(sim: Simulation, scenario: Path) -> None:
|
||||
await run_script_file(sim, scenario)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the script parser
|
||||
|
||||
|
||||
async def test_comments_and_blank_lines_are_ignored(sim: Simulation) -> None:
|
||||
await sim.driver.run_script("# just a comment\n\n \nplace fuchs # trailing\n")
|
||||
sim.driver.check("figure", "fuchs")
|
||||
|
||||
|
||||
async def test_a_failing_expectation_names_the_line(sim: Simulation) -> None:
|
||||
with pytest.raises(ExpectationError) as excinfo:
|
||||
await sim.driver.run_script("place fuchs\nexpect track 7\n")
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "line 2" in message
|
||||
assert "expected track to be '7', but it is '0'" in message
|
||||
|
||||
|
||||
async def test_an_unknown_verb_is_reported_with_its_line(sim: Simulation) -> None:
|
||||
with pytest.raises(ScriptError, match="line 1"):
|
||||
await sim.driver.run_script("frobnicate the widget\n")
|
||||
|
||||
|
||||
async def test_an_unknown_figure_lists_the_configured_ones(sim: Simulation) -> None:
|
||||
with pytest.raises(ScriptError, match="configured: eule, fuchs"):
|
||||
await sim.driver.run_script("place giraffe\n")
|
||||
|
||||
|
||||
async def test_an_unknown_property_lists_the_valid_ones(sim: Simulation) -> None:
|
||||
with pytest.raises(ScriptError, match="unknown property"):
|
||||
await sim.driver.run_script("expect loudness 5\n")
|
||||
|
||||
|
||||
async def test_an_unknown_touch_button_lists_the_valid_ones(sim: Simulation) -> None:
|
||||
with pytest.raises(ScriptError, match="left_foot, right_foot"):
|
||||
await sim.driver.run_script("touch nose\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "seconds"),
|
||||
[("2", 2.0), ("2s", 2.0), ("500ms", 0.5), ("1m", 60.0), ("0.5s", 0.5)],
|
||||
)
|
||||
async def test_duration_formats(text: str, seconds: float) -> None:
|
||||
from musicmouse.simulator.driver import Duration
|
||||
|
||||
assert Duration.parse(text).seconds == pytest.approx(seconds)
|
||||
|
||||
|
||||
async def test_a_bad_duration_is_reported() -> None:
|
||||
from musicmouse.simulator.driver import Duration
|
||||
|
||||
with pytest.raises(ScriptError, match="is not a duration"):
|
||||
Duration.parse("soon")
|
||||
|
||||
|
||||
async def test_status_and_leds_produce_output(sim: Simulation) -> None:
|
||||
await sim.driver.place("fuchs")
|
||||
|
||||
status = await sim.driver.execute("status")
|
||||
leds = await sim.driver.execute("leds")
|
||||
|
||||
assert status is not None and "fuchs" in status and "playing" in status
|
||||
assert leds is not None and "ring" in leds and "shelf" in leds
|
||||
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