bosch_shc: exercise via_device_id test through config entry setup (#178102)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas
2026-08-03 23:25:45 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent eab1a0a0ce
commit 390d419087
2 changed files with 166 additions and 55 deletions
+105 -1
View File
@@ -1,10 +1,114 @@
"""bosch_shc session fixtures."""
from unittest.mock import MagicMock
from collections.abc import Generator
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, create_autospec, patch
from boschshcpy import BatteryLevelService, SHCBatteryDevice
import pytest
from homeassistant.components.bosch_shc.const import (
CONF_SSL_CERTIFICATE,
CONF_SSL_KEY,
DOMAIN,
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def bosch_shc_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None:
"""Auto mock zeroconf."""
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock bosch_shc config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "1.1.1.1",
CONF_SSL_CERTIFICATE: "cert",
CONF_SSL_KEY: "key",
},
unique_id="test-mac",
)
# Keep in sync with binary_sensor.py's device_helper buckets — a bucket
# missing here breaks the mock_session fixture.
_EMPTY_DEVICE_BUCKETS: dict[str, list[Any]] = {
bucket: []
for bucket in (
"motion_detectors",
"shutter_contacts",
"shutter_contacts2",
"smoke_detectors",
"thermostats",
"twinguards",
"universal_switches",
"wallthermostats",
"water_leakage_detectors",
)
}
@pytest.fixture
def device_buckets(request: pytest.FixtureRequest) -> dict[str, list[Any]]:
"""device_helper buckets for the mock session.
Empty by default; a test overrides specific buckets via
``@pytest.mark.parametrize("device_buckets", [{...}], indirect=True)``.
"""
overrides: dict[str, list[Any]] = getattr(request, "param", {})
return {**_EMPTY_DEVICE_BUCKETS, **overrides}
@pytest.fixture
def mock_session(device_buckets: dict[str, list[Any]]) -> Generator[MagicMock]:
"""Mock SHCSession, patched in for the duration of the test."""
session = MagicMock()
session.information.unique_id = "test-mac"
session.information.updateState.name = "UP_TO_DATE"
session.information.version = "2.0"
session.device_helper = SimpleNamespace(**device_buckets)
with patch("homeassistant.components.bosch_shc.SHCSession", return_value=session):
yield session
async def setup_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up the bosch_shc integration for testing."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
def battery_only_device(
device_id: str = "hdm:HomeMaticIP:motion1",
name: str = "Motion",
device_services: list[Any] | None = None,
) -> SHCBatteryDevice:
"""Build a minimal device double for the motion_detectors bucket.
motion_detectors only ever backs a single BatterySensor entity (unlike
shutter_contacts, which backs both a ShutterContactSensor and a
BatterySensor for the same device) — the single-entity shape keeps these
entity.py tests free of a second entity's subscribe/unsubscribe calls.
"""
device = create_autospec(SHCBatteryDevice, instance=True, spec_set=True)
device.name = name
device.id = device_id
device.root_device_id = "test-mac"
device.serial = f"serial-{device_id}"
device.batterylevel = BatteryLevelService.State.OK
device.device_services = device_services or []
device.manufacturer = "Bosch"
device.device_model = "MD"
device.status = "AVAILABLE"
device.deleted = False
return device
+61 -54
View File
@@ -1,78 +1,85 @@
"""Tests for the Bosch SHC entity base classes."""
from unittest.mock import MagicMock
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from boschshcpy import SHCDevice
import pytest
from homeassistant.components.bosch_shc.const import DOMAIN
from homeassistant.components.bosch_shc.entity import SHCEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import battery_only_device, setup_integration
from tests.common import MockConfigEntry
HUB_IDENTIFIER = (DOMAIN, "test-mac")
@pytest.fixture(autouse=True)
def platforms() -> Generator[None]:
"""Restrict bosch_shc setup to the binary_sensor platform."""
with patch(
"homeassistant.components.bosch_shc.PLATFORMS", [Platform.BINARY_SENSOR]
):
yield
@pytest.fixture
def motion_device(mock_session: MagicMock) -> MagicMock:
"""The mock device backing the motion detector's battery sensor."""
return mock_session.device_helper.motion_detectors[0]
@pytest.mark.parametrize(
"device_buckets",
[{"motion_detectors": [battery_only_device()]}],
indirect=True,
)
@pytest.mark.usefixtures("mock_session")
async def test_shc_entity_via_device_id(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
motion_device: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test SHCEntity links its device to the SHC hub via via_device_id."""
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
"""SHCEntity links its device to the SHC hub via via_device_id."""
await setup_integration(hass, mock_config_entry)
hub_device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "root-serial")},
manufacturer="Bosch",
name="Bosch SHC",
model="SmartHomeController",
hub_device = device_registry.async_get_device(identifiers={HUB_IDENTIFIER})
assert hub_device is not None
child_device = device_registry.async_get_device(
identifiers={(DOMAIN, motion_device.id)}
)
device = MagicMock(spec=SHCDevice)
device.serial = "child-serial"
device.manufacturer = "Bosch"
device.device_model = "SWD"
device.name = "Shutter Contact"
device.id = "child-id"
device.root_device_id = "root-serial"
entity = SHCEntity(
hass=hass, device=device, parent_id="root-serial", entry_id=entry.entry_id
)
assert entity.device_info is not None
assert entity.device_info["via_device_id"] == hub_device.id
assert child_device is not None
assert child_device.via_device_id == hub_device.id
@pytest.mark.parametrize(
"device_buckets",
[{"motion_detectors": [battery_only_device()]}],
indirect=True,
)
@pytest.mark.usefixtures("mock_session")
async def test_shc_entity_via_device_id_mismatch(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
motion_device: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test SHCEntity sets up without a link when the hub identifier does not match.
"""Setup does not crash and skips the link when the hub identifier does not match.
boschshcpy may render the hub identifier and a device's root_device_id
differently, so the lookup can miss; setup must not crash in that case.
differently, so the lookup can miss.
"""
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
motion_device.root_device_id = "root-serial-mismatch"
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "hub-serial")},
manufacturer="Bosch",
name="Bosch SHC",
model="SmartHomeController",
await setup_integration(hass, mock_config_entry)
child_device = device_registry.async_get_device(
identifiers={(DOMAIN, motion_device.id)}
)
device = MagicMock(spec=SHCDevice)
device.serial = "child-serial"
device.manufacturer = "Bosch"
device.device_model = "SWD"
device.name = "Shutter Contact"
device.id = "child-id"
device.root_device_id = "root-serial-mismatch"
entity = SHCEntity(
hass=hass, device=device, parent_id="hub-serial", entry_id=entry.entry_id
)
assert entity.device_info is not None
assert "via_device_id" not in entity.device_info
assert child_device is not None
assert child_device.via_device_id is None