Files
localthings/tests/test_statistics_migration_end_to_end.py
T
Marc Billow eeece4906c Type air_monitor's particulates and migrate the recorded statistics
Adding a unit to a sensor that recorded long-term statistics without one
is not cosmetic: Home Assistant raises a units_changed repair and then
*suppresses statistics generation* for that entity until a human resolves
it (sensor/recorder.py's compile path hits `continue`). Shipping the PM
device classes on their own would therefore have silently frozen the very
history the labels were meant to describe.

A v2 -> v3 config-entry migration relabels the statistics metadata first.
It rewrites only the metadata row, never the recorded values -- these
readings were always µg/m³ and only the label was missing, so nothing
needs converting, which is why this uses async_update_statistics_metadata
and not change_statistics_unit. It reads entity_ids back off the entity
registry rather than rebuilding them from descriptor keys, since a renamed
entity's statistic_id no longer follows from its key, and it is scoped by
device family: range_hood and airconditioner still declare no unit for
their identically-named sensors, so relabelling theirs would create the
exact mismatch this exists to prevent.

With the migration in place there is no longer a reason to hold the labels
back on air_monitor, so it takes them too. That board is one of the two
whose fixtures pin the grade bands the mapping rests on -- typing the
purifier and not the monitor was an inconsistency, not caution. It still
declines the shared state_class column, unchanged.

Recorder coupling is guarded: after_dependencies pulls it in when
configured, the import is local to the migration, and a setup without it
is a no-op. Freshly created entries mint v3 directly, having no history to
relabel.

Tested at both levels. The unit-level tests patch the recorder and assert
which entities are relabelled with which arguments, covering the renamed
entity, subdevice-prefixed and instanced keys, the near-miss keys
(dustbag_/dustbin_), the skipped families and the recorder-absent path.
Because a mock can only prove the call is made, not that it does what the
migration needs, a second suite drives a real in-memory recorder end to
end: statistics seeded unitless, migration run, metadata confirmed to read
µg/m³ / concentration, recorded means confirmed byte-identical, and the
units_changed issue confirmed present before and absent after.
2026-08-15 01:01:49 +00:00

162 lines
6.2 KiB
Python

"""The v2 -> v3 statistics relabel against a real recorder, not a mock.
tests/localthings/test_statistics_migration.py proves the migration calls
HA's API with the right arguments for the right entities. It cannot prove
that call does what the migration needs, because the recorder is patched
out. This drives an in-memory recorder end to end: statistics recorded
unitless, migration run, metadata inspected -- and, most importantly, the
recorded *values* checked to be untouched, which is the claim that makes
doing this automatically safe rather than something to ask each user about.
Lives here rather than under tests/localthings/ on purpose: that package's
autouse `enable_custom_integrations` fixture depends on `hass`, which
starts Home Assistant before `recorder_mock` can claim its database URL.
Nothing here loads the integration -- `async_migrate_entry` is called
directly -- so the entry only needs the one key the v2 -> v3 step reads.
"""
from __future__ import annotations
from datetime import timedelta
from functools import partial
from typing import cast
import pytest
from homeassistant.components.recorder.models import StatisticMeanType, StatisticMetaData
from homeassistant.components.recorder.statistics import (
async_import_statistics,
get_metadata,
statistics_during_period,
)
from homeassistant.components.recorder.util import get_instance
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.components.recorder.common import (
async_wait_recording_done,
)
from custom_components.localthings import async_migrate_entry
from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN
from custom_components.localthings.registry.entities import SensorDesc
SERIAL = "TEST-SERIAL-0000"
RECORDED = [11.0, 9.0, 14.0]
async def _seed_unitless_statistics(hass: HomeAssistant, entity_id: str) -> None:
"""Record hourly statistics the way these sensors always have: numeric
means, no unit of measurement at all."""
start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=4)
async_import_statistics(
hass,
{
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
"statistic_id": entity_id,
"unit_class": None,
"unit_of_measurement": None,
},
[
{"start": start + timedelta(hours=i), "mean": value, "min": value, "max": value}
for i, value in enumerate(RECORDED)
],
)
await async_wait_recording_done(hass)
async def _metadata(hass: HomeAssistant, entity_id: str) -> StatisticMetaData:
result = await get_instance(hass).async_add_executor_job(
partial(get_metadata, hass, statistic_ids={entity_id})
)
return result[entity_id][1]
async def _means(hass: HomeAssistant, entity_id: str) -> list[float]:
rows = await get_instance(hass).async_add_executor_job(
statistics_during_period,
hass,
dt_util.utcnow() - timedelta(days=1),
None,
{entity_id},
"hour",
None,
{"mean"},
)
return [cast(float, row["mean"]) for row in rows.get(entity_id, [])]
@pytest.fixture
def purifier_entry(hass: HomeAssistant) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_DEVICE_TYPE: "air_purifier"},
unique_id=f"{DOMAIN}_{SERIAL}",
version=2,
)
entry.add_to_hass(hass)
return entry
def _dust_entity(hass: HomeAssistant, entry: MockConfigEntry):
return er.async_get(hass).async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_{SERIAL}_dust", config_entry=entry
)
async def test_relabels_metadata_without_touching_recorded_values(
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
) -> None:
dust = _dust_entity(hass, purifier_entry)
await _seed_unitless_statistics(hass, dust.entity_id)
before = await _metadata(hass, dust.entity_id)
assert before["unit_of_measurement"] is None
assert await _means(hass, dust.entity_id) == RECORDED
assert await async_migrate_entry(hass, purifier_entry) is True
await async_wait_recording_done(hass)
after = await _metadata(hass, dust.entity_id)
assert after["unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
assert after["unit_class"] == "concentration"
# The readings were always µg/m³; only the label was missing. Nothing is
# converted, so the recorded history still says exactly what it said.
assert await _means(hass, dust.entity_id) == RECORDED
async def test_recorded_unit_ends_up_matching_what_the_descriptor_declares(
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
) -> None:
"""The invariant the migration exists to establish, stated directly.
HA raises units_changed -- and suppresses statistics generation -- when
an entity's unit disagrees with the unit recorded against its
statistic_id. Rather than drive HA's validation to observe that, this
asserts the condition that validation reads: after migrating, the
recorded metadata says exactly what the descriptor says. Asserting our
own invariant instead of Home Assistant's reaction to it keeps the test
off internals that change between versions (both `_update_issues` and
`validate_statistics` have gained parameters), and tests this repo
rather than that one."""
from custom_components.localthings.registry.capabilities import air_purifier
dust = _dust_entity(hass, purifier_entry)
await _seed_unitless_statistics(hass, dust.entity_id)
desc = next(
d
for d in air_purifier.AIR_QUALITY.entities
if d.key == "dust" and isinstance(d, SensorDesc)
)
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] != desc.unit
assert await async_migrate_entry(hass, purifier_entry) is True
await async_wait_recording_done(hass)
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] == desc.unit