Files
localthings/tests/localthings/conftest.py
T
Tim Schindler 8dadea6796 test: address Copilot review on observe fixture typing and comment
- Parameterize FakeObserveSession.notify_on_subscribe as dict[str, Any] | None
  to match the surrounding fully-typed attributes (the dict is delivered as
  the OBSERVE rep to on_notification); add the typing.Any import.
- Reword the test_coordinator comment: subscribe() delivers a notification
  for every href it subscribes to (when notify_on_subscribe is set), not one.
2026-07-22 08:22:45 +02:00

219 lines
7.9 KiB
Python

"""Shared fixtures for localthings component tests."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
import cbor2
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
# Point HA's config dir at the repo root so the loader mounts custom_components/
# from the project into sys.path — otherwise IntegrationNotFound is raised.
PROJECT_ROOT = str(Path(__file__).resolve().parents[2])
@pytest.fixture
def hass_config_dir() -> str:
"""Override to let HA's loader find custom_components/localthings."""
return PROJECT_ROOT
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
"""Automatically enable custom integrations for all localthings tests.
pytest-homeassistant-custom-component caches an empty custom-component
dict during HA startup. This autouse fixture calls the upstream
enable_custom_integrations fixture which pops that cache entry, forcing
a re-discovery that finds custom_components/localthings.
"""
return enable_custom_integrations
from custom_components.localthings.const import (
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
@pytest.fixture(autouse=True)
def _fast_coordinator_timers():
"""Shrink the coordinator's real-time delays for every localthings test.
`_run_subpolls` and `_attempt_observe_mode` are, by design, blocking/
real-time operations in production (a real sub-poll cadence and a real
CoAP OBSERVE grace period). Tests that drive them through a full
`hass.config_entries.async_setup(...)` + `hass.async_block_till_done()`
would otherwise burn tens of real seconds per test waiting them out.
This patches only the *class* attributes coordinator.py exposes for
this purpose (`_SUBPOLL_STEP_S`, `_OBSERVE_GRACE_PERIOD_S`) — the
production defaults they're computed from (`SUMMARY_INTERVAL_S`,
`GRACE_PERIOD_S`) are untouched. Tests that need to exercise the real
grace-period race (e.g. test_observe.py's own `ObserveManager` tests)
still pass `grace_period_s=` explicitly and are unaffected.
"""
with (
patch.object(LocalThingsCoordinator, '_SUBPOLL_STEP_S', 0.001),
patch.object(LocalThingsCoordinator, '_OBSERVE_GRACE_PERIOD_S', 0.02),
patch.object(LocalThingsCoordinator, '_RECONNECT_PAUSE_S', 0.01),
):
yield
FIXTURES = Path(__file__).resolve().parent.parent / 'fixtures'
MOCK_HOST = '10.0.0.254'
MOCK_PORT = 49154
MOCK_SERIAL = 'TEST-SERIAL-001'
MOCK_CA_CERT_PEM = '-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----'
MOCK_CA_KEY_PEM = '-----BEGIN PRIVATE KEY-----\nTEST-CA-KEY\n-----END PRIVATE KEY-----'
MOCK_LEAF_CERT_PEM = '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----'
MOCK_LEAF_KEY_PEM = '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----'
ENTRY_DATA = {
CONF_HOST: MOCK_HOST,
CONF_PORT: MOCK_PORT,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
CONF_LEAF_CERT_PEM: MOCK_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
}
def _load_fridge_resources() -> dict:
from custom_components.localthings.registry.batch import parse_device0_batch
data = json.loads((FIXTURES / 'refrigerator_device.json').read_text())
return parse_device0_batch(data['device0'])
@pytest.fixture
def fridge_resources():
return _load_fridge_resources()
@pytest.fixture
def mock_probe():
"""Patch _probe_and_validate to succeed (recognized type) without a real DTLS connection."""
with patch(
'custom_components.localthings.config_flow._probe_and_validate',
return_value={
'port': MOCK_PORT,
'serial': MOCK_SERIAL,
'leaf_cert_pem': MOCK_LEAF_CERT_PEM,
'leaf_key_pem': MOCK_LEAF_KEY_PEM,
'one_ui_version': '7.0 Refrigerator',
'device_type_recognized': True,
},
) as m:
yield m
@pytest.fixture
def mock_probe_unknown_type():
"""Patch _probe_and_validate to succeed, but with an unrecognized device type."""
with patch(
'custom_components.localthings.config_flow._probe_and_validate',
return_value={
'port': MOCK_PORT,
'serial': MOCK_SERIAL,
'leaf_cert_pem': MOCK_LEAF_CERT_PEM,
'leaf_key_pem': MOCK_LEAF_KEY_PEM,
'one_ui_version': '9.0 Space Heater',
'device_type_recognized': False,
},
) as m:
yield m
@pytest.fixture
def mock_coordinator_session(fridge_resources):
"""Patch coordinator's blocking session methods so no real DTLS is needed."""
with (
patch('custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session'),
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
return_value=fridge_resources,
),
patch('custom_components.localthings.coordinator.LocalThingsCoordinator._close_session'),
):
yield
class FakeObserveSession:
"""Stand-in for DtlsCoapSession that supports subscribe()/on_notification
for coordinator-level observe tests, without a real DTLS connection."""
def __init__(self, on_notification=None):
self.on_notification = on_notification
self.subscribed: list[str] = []
self.fail_hrefs: set[str] = set()
self.closed = False
# When set to a rep dict, subscribe() immediately delivers that rep
# as an OBSERVE notification for the href — what a real device does
# when it answers a subscription with the current representation.
# `try_enter_observe_mode` clears its notified set before it
# subscribes, so this is the only way to deliver a notify that
# reliably counts: it lands synchronously via on_notification,
# after that clear and before the post-sleep fraction check — a
# notify raced in from another thread can be wiped by the clear
# (or arrive after the check) depending on scheduling. Set to
# None to model a device that answers subscriptions but never
# notifies.
self.notify_on_subscribe: dict[str, Any] | None = None
def subscribe(self, path_segs):
href = '/' + '/'.join(path_segs)
if href in self.fail_hrefs:
raise ConnectionError("subscribe failed")
self.subscribed.append(href)
if self.notify_on_subscribe is not None and self.on_notification is not None:
self.on_notification(href, cbor2.dumps(self.notify_on_subscribe))
return b'\x01'
def refresh_observes(self, paths):
return None
def close(self):
self.closed = True
@pytest.fixture
def mock_coordinator_observe_session(fridge_resources):
"""Like mock_coordinator_session, but _connect_session installs a
FakeObserveSession on coordinator._session so subscribe()/on_notification
wiring can be exercised."""
fake = FakeObserveSession()
def _connect(self):
fake.on_notification = self._observe.on_notification
self._session = fake
with (
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session',
_connect,
),
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
return_value=fridge_resources,
),
patch('custom_components.localthings.coordinator.LocalThingsCoordinator._close_session'),
):
yield fake
@pytest.fixture
def mock_entry(hass):
"""A MockConfigEntry added to hass, ready for async_setup."""
entry = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id=f'localthings_{MOCK_SERIAL}',
)
entry.add_to_hass(hass)
return entry