Files
core/tests/components/bitvis/conftest.py
T

234 lines
7.6 KiB
Python

"""Common fixtures for Bitvis Power Hub tests."""
from collections.abc import Callable, Generator, Iterator
from contextlib import ExitStack, contextmanager
from unittest.mock import AsyncMock, MagicMock, patch
from bitvis_protobuf.listener import Filter, FilterIp
from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample, parse_payload
from bitvis_protobuf.powerhub_pb2 import Payload
from bitvis_protobuf.utils import InvalidMacAddressError
import pytest
from homeassistant.components.bitvis.const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
TEST_DEVICE_MAC = "aa:bb:cc:dd:ee:ff"
SECOND_DEVICE_MAC = "11:22:33:44:55:66"
type ListenerCallback = Callable[
[PayloadSample | PayloadDiagnostic, tuple[str, int]], None
]
type ErrorCallback = Callable[[Exception, tuple[str, int]], None]
class FakeListener:
"""In-memory SharedListener stand-in for config-flow and coordinator tests."""
def __init__(self) -> None:
"""Initialize callback storage and async start/stop mocks."""
self._callbacks: dict[Filter, ListenerCallback] = {}
self._error_callbacks: list[ErrorCallback] = []
self.start = AsyncMock()
self.stop = AsyncMock()
self.register = MagicMock(side_effect=self._register)
self.unregister = MagicMock(side_effect=self._unregister)
self.register_error_callback = MagicMock(
side_effect=self._error_callbacks.append
)
self.unregister_error_callback = MagicMock(side_effect=self._unregister_error)
self.dispatch = MagicMock(side_effect=self._dispatch)
@property
def is_empty(self) -> bool:
"""Return True when no payload callbacks are registered."""
return not self._callbacks
def _register(self, filt: Filter, callback: ListenerCallback) -> None:
if filt in self._callbacks:
raise RuntimeError(f"Filter already registered: {filt}")
self._callbacks[filt] = callback
def _unregister(self, filt: Filter) -> None:
self._callbacks.pop(filt, None)
def _unregister_error(self, callback: ErrorCallback) -> None:
if callback in self._error_callbacks:
self._error_callbacks.remove(callback)
def _dispatch(self, data: bytes, addr: tuple[str, int]) -> None:
try:
payload = parse_payload(data)
except InvalidMacAddressError as err:
for callback in self._error_callbacks:
callback(err, addr)
return
if payload is None:
return
self.deliver(payload, addr)
def deliver(
self,
payload: PayloadSample | PayloadDiagnostic,
addr: tuple[str, int],
) -> None:
"""Deliver an already-parsed payload to matching callbacks."""
host = addr[0]
for filt, callback in self._callbacks.items():
if filt.match(payload, host):
callback(payload, addr)
@contextmanager
def patch_config_flow_connectivity(
resolved_host: str,
*,
mac_address: str = TEST_DEVICE_MAC,
deliver_mac: bool = True,
invalid_mac: bool = False,
port_bind_side_effect: BaseException | None = None,
discovery_timeout: bool = False,
register_side_effect: BaseException | None = None,
shared_listener: FakeListener | None = None,
) -> Iterator[AsyncMock]:
"""Patch library connectivity helpers used by the config flow."""
listener = shared_listener or FakeListener()
if register_side_effect is not None:
listener.register.side_effect = register_side_effect
else:
original_register = listener.register.side_effect
def _on_register(filt: Filter, callback: ListenerCallback) -> None:
original_register(filt, callback)
if not isinstance(filt, FilterIp):
return
if invalid_mac:
payload = Payload()
payload.sample.SetInParent()
listener.dispatch(payload.SerializeToString(), (resolved_host, 1234))
elif deliver_mac and not discovery_timeout:
callback(
PayloadSample(mac_address=mac_address, sample=MagicMock()),
(resolved_host, 1234),
)
listener.register.side_effect = _on_register
with ExitStack() as stack:
mock_verify = stack.enter_context(
patch(
"homeassistant.components.bitvis.config_flow.async_verify_udp_port_bindable",
new_callable=AsyncMock,
side_effect=port_bind_side_effect,
)
)
stack.enter_context(
patch(
"homeassistant.components.bitvis.config_flow.async_resolve_host",
new_callable=AsyncMock,
return_value={resolved_host},
)
)
stack.enter_context(
patch(
"homeassistant.components.bitvis.coordinator.SharedListener",
return_value=listener,
)
)
if discovery_timeout:
stack.enter_context(
patch(
"homeassistant.components.bitvis.config_flow.DISCOVERY_TIMEOUT",
0,
)
)
yield mock_verify
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100", CONF_PORT: DEFAULT_PORT},
unique_id=TEST_DEVICE_MAC,
title=DEFAULT_NAME,
)
@pytest.fixture
def mock_zeroconf_config_entry() -> MockConfigEntry:
"""Return a mocked config entry for zeroconf discovery host."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.200", CONF_PORT: DEFAULT_PORT},
unique_id=TEST_DEVICE_MAC,
title=DEFAULT_NAME,
)
@pytest.fixture
def mock_ipv6_config_entry() -> MockConfigEntry:
"""Return a mocked config entry with an IPv6 host."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "2001:db8::10", CONF_PORT: DEFAULT_PORT},
unique_id=SECOND_DEVICE_MAC,
title=DEFAULT_NAME,
)
@pytest.fixture
def mock_second_config_entry() -> MockConfigEntry:
"""Return a second mocked config entry on the same UDP port."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.101", CONF_PORT: DEFAULT_PORT},
unique_id=SECOND_DEVICE_MAC,
title=DEFAULT_NAME,
)
@pytest.fixture
def mock_shared_listener() -> FakeListener:
"""Return a fake bitvis_protobuf SharedListener."""
return FakeListener()
@pytest.fixture
def patch_shared_listener(
mock_shared_listener: FakeListener,
) -> Generator[FakeListener]:
"""Patch SharedListener to return a mocked instance."""
with patch(
"homeassistant.components.bitvis.coordinator.SharedListener",
return_value=mock_shared_listener,
):
yield mock_shared_listener
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.bitvis.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
patch_shared_listener: FakeListener,
) -> MockConfigEntry:
"""Set up the integration with a mocked UDP listener."""
await setup_integration(hass, mock_config_entry)
return mock_config_entry