Files

260 lines
8.6 KiB
Python

"""Tests for the Guntamatic sensor platform."""
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from guntamatic.heater import (
TRANSLATE_HC_PROGRAM,
TRANSLATE_PUMP_MODE,
NoSerialException,
)
import pytest
import requests
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.guntamatic.const import DOMAIN, SCAN_INTERVAL
from homeassistant.components.guntamatic.sensor import GUNTAMATIC_SENSORS
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.translation import async_get_translations
from homeassistant.util import dt as dt_util
from . import setup_integration
from .conftest import MOCK_PARSE_DATA
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("mock_heater")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test all entities."""
# The service date sensor computes now() + a day count; freeze the clock
# so the snapshot does not depend on the day the tests run on.
freezer.move_to("2030-01-01 12:00:00+00:00")
with patch(
"homeassistant.components.guntamatic._PLATFORMS",
[Platform.SENSOR],
):
await setup_integration(hass, mock_config_entry)
disabled_entries = [
entity_entry
for entity_entry in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
if entity_entry.disabled_by is not None
]
assert disabled_entries
assert all(
entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION
for entity_entry in disabled_entries
)
for entity_entry in disabled_entries:
entity_registry.async_update_entity(entity_entry.entity_id, disabled_by=None)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
@pytest.mark.parametrize(
"side_effect",
[
requests.exceptions.ConnectionError("Connection lost"),
NoSerialException,
Exception("Unknown error"),
],
)
async def test_state_unavailable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_heater: MagicMock,
side_effect: Exception,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sensors handle failures."""
await setup_integration(hass, mock_config_entry)
mock_heater.parse_data.side_effect = side_effect
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.mock_title_boiler_temperature")
assert state.state == STATE_UNAVAILABLE
# Recovery
mock_heater.parse_data.side_effect = None
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.mock_title_boiler_temperature")
assert state.state != STATE_UNAVAILABLE
@pytest.mark.parametrize(
("key_prefix", "translation_table"),
[
pytest.param("heating_circulation_program", TRANSLATE_HC_PROGRAM, id="program"),
pytest.param(
"heating_circulation_pump",
TRANSLATE_PUMP_MODE,
id="heating_circulation_pump",
),
pytest.param("auxiliary_pump", TRANSLATE_PUMP_MODE, id="auxiliary_pump"),
pytest.param("extra_dhw_boost", TRANSLATE_PUMP_MODE, id="extra_dhw_boost"),
],
)
def test_enum_options_match_library(
key_prefix: str,
translation_table: dict[str, str],
) -> None:
"""Test enum options mirror the canonical values emitted by the library."""
option_sets = {
tuple(description.options)
for description in GUNTAMATIC_SENSORS
if description.key.startswith(key_prefix)
and description.device_class is SensorDeviceClass.ENUM
}
assert len(option_sets) == 1, f"Inconsistent options across {key_prefix} sensors"
assert set(option_sets.pop()) == set(translation_table.values())
enum_descriptions = [
description
for description in GUNTAMATIC_SENSORS
if description.device_class is SensorDeviceClass.ENUM
]
@pytest.mark.parametrize(
"description",
enum_descriptions,
ids=lambda description: description.key,
)
async def test_enum_states_translated(
hass: HomeAssistant,
description: SensorEntityDescription,
) -> None:
"""Test every option of an enum sensor has a state translation."""
translations = await async_get_translations(hass, "en", "entity", [DOMAIN])
prefix = f"component.{DOMAIN}.entity.sensor.{description.translation_key}.state."
for option in description.options:
assert f"{prefix}{option}" in translations
async def test_enum_sensor_unmapped_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_heater: MagicMock,
) -> None:
"""Test an unmapped enum value is exposed as unknown instead of raising."""
return_value = MOCK_PARSE_DATA.copy()
return_value["heating_circulation_pump_1"] = ["BROKEN", ""]
mock_heater.parse_data.return_value = return_value
await setup_integration(hass, mock_config_entry)
await hass.async_block_till_done()
state = hass.states.get("sensor.heating_circuit_1_pump")
assert state is not None
assert state.state == STATE_UNKNOWN
_SERIAL = "959103"
@pytest.mark.usefixtures("mock_heater")
async def test_heating_circuit_devices(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test circuit sub-devices link to the main unit and absent slots create none."""
await setup_integration(hass, mock_config_entry)
main = device_registry.async_get_device_by_identifier(
(DOMAIN, _SERIAL), mock_config_entry.entry_id
)
circuit = device_registry.async_get_child_device_by_identifier(
(DOMAIN, f"{_SERIAL}_hc1"), mock_config_entry.entry_id
)
assert main is not None
assert circuit is not None
assert circuit.parent_device_id == main.id
assert (
device_registry.async_get_device_by_identifier(
(DOMAIN, f"{_SERIAL}_hc2"), mock_config_entry.entry_id
)
is None
)
async def test_key_disappearing_makes_entity_unavailable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_heater: MagicMock,
) -> None:
"""Test an individual sensor becoming unavailable when its key disappears."""
await setup_integration(hass, mock_config_entry)
entity_id = "sensor.mock_title_boiler_temperature"
assert hass.states.get(entity_id).state == "14.09"
# Simulate the heater no longer reporting this key
reduced = {k: v for k, v in MOCK_PARSE_DATA.items() if k != "boiler_temperature"}
mock_heater.parse_data.return_value = reduced
async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "unavailable"
# Restore the key and verify the entity recovers
mock_heater.parse_data.return_value = MOCK_PARSE_DATA.copy()
async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "14.09"
async def test_service_date_fractional_days(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_heater: MagicMock,
freezer: FrozenDateTimeFactory,
entity_registry: er.EntityRegistry,
) -> None:
"""Fractional service_days roll over correctly when time is not midnight."""
frozen = datetime(2026, 8, 26, 15, 00, tzinfo=dt_util.get_default_time_zone())
freezer.move_to(frozen)
data = MOCK_PARSE_DATA.copy()
data["service_days"] = ["1.5", "d"]
mock_heater.parse_data.return_value = data
await setup_integration(hass, mock_config_entry)
entity_registry.async_update_entity(
"sensor.mock_title_service_date", disabled_by=None
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("sensor.mock_title_service_date")
assert state is not None
assert state.state == (frozen + timedelta(days=1.5)).date().isoformat()