Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user