342 lines
14 KiB
Python
342 lines
14 KiB
Python
"""End-to-end proof that the rename does not cost a single measurement.
|
|
|
|
`test_predecessor_adoption.py` proves the registry side of the move: the `entity_id`
|
|
does not change. This file closes the loop the user actually cares about by putting a
|
|
real recorder behind it - short-term history *and* long-term statistics are written for
|
|
the predecessor's entity, the new integration is then set up exactly as a user would set
|
|
it up (config flow options, real HTTP upload from the station), and the same rows are
|
|
read back afterwards through the same API the History and Statistics panels use.
|
|
|
|
The scenario mirrors the documented upgrade procedure:
|
|
|
|
1. The old integration ran and recorded measurements.
|
|
2. Its repository was swapped in HACS, so its files are gone and its entities sit at
|
|
`unavailable` with `restored: true` while its config entry stays behind.
|
|
3. The user adds the renamed integration, typing only the credentials.
|
|
|
|
Set ``SWS_MIGRATION_REPORT`` to a file path to dump the before/after transcript.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
from pytest_homeassistant_custom_component.components.recorder.common import async_wait_recording_done
|
|
|
|
from custom_components.sws12500.const import (
|
|
API_ID,
|
|
API_KEY,
|
|
DOMAIN,
|
|
OUTSIDE_HUMIDITY,
|
|
OUTSIDE_TEMP,
|
|
SENSORS_TO_LOAD,
|
|
WSLINK,
|
|
WSLINK_URL,
|
|
)
|
|
from homeassistant.components.recorder.history import get_significant_states
|
|
from homeassistant.components.recorder.models import StatisticMeanType
|
|
from homeassistant.components.recorder.statistics import async_import_statistics, statistics_during_period
|
|
from homeassistant.components.recorder.util import get_instance
|
|
from homeassistant.const import ATTR_RESTORED, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import entity_registry as er
|
|
from homeassistant.setup import async_setup_component
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
OLD_DOMAIN = "sws12500_legacy"
|
|
|
|
STATION_ID = "migration-station"
|
|
STATION_PW = "migration-secret"
|
|
|
|
# The object id the old integration handed out. Deliberately unlike anything the current
|
|
# naming scheme produces, so a recreated entity is impossible to confuse with a moved one.
|
|
OLD_OBJECT_ID = "sws12500_outside_temp"
|
|
OLD_ENTITY_ID = f"sensor.{OLD_OBJECT_ID}"
|
|
|
|
# Three readings recorded by the old integration, before anything was renamed.
|
|
HISTORIC_READINGS = ("18.6", "19.4", "20.2")
|
|
|
|
# What the station uploads once the renamed integration is running.
|
|
NEW_READING = "21.7"
|
|
|
|
ATTRS: dict[str, Any] = {
|
|
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
|
"device_class": "temperature",
|
|
"state_class": "measurement",
|
|
"friendly_name": "Outside temperature",
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _recorder(recorder_mock) -> None:
|
|
"""Every test here needs a real recorder behind the registries."""
|
|
|
|
|
|
def _upload(**overrides: str) -> dict[str, str]:
|
|
"""A minimal WSLink upload carrying the outside temperature and humidity."""
|
|
return {
|
|
"wsid": STATION_ID,
|
|
"wspw": STATION_PW,
|
|
"apiver": "0.6",
|
|
"t1tem": NEW_READING,
|
|
"t1hum": "62",
|
|
**overrides,
|
|
}
|
|
|
|
|
|
async def _record_predecessor_history(hass: HomeAssistant) -> MockConfigEntry:
|
|
"""Run the old integration's lifetime: entities, measurements, then uninstalled."""
|
|
old = MockConfigEntry(
|
|
domain=OLD_DOMAIN,
|
|
title="Sencor SWS 12500",
|
|
options={
|
|
API_ID: STATION_ID,
|
|
API_KEY: STATION_PW,
|
|
WSLINK: True,
|
|
SENSORS_TO_LOAD: [OUTSIDE_TEMP, OUTSIDE_HUMIDITY],
|
|
},
|
|
)
|
|
old.add_to_hass(hass)
|
|
|
|
registry = er.async_get(hass)
|
|
registered = registry.async_get_or_create(
|
|
"sensor",
|
|
OLD_DOMAIN,
|
|
OUTSIDE_TEMP,
|
|
config_entry=old,
|
|
suggested_object_id=OLD_OBJECT_ID,
|
|
original_name="Outside temperature",
|
|
)
|
|
assert registered.entity_id == OLD_ENTITY_ID
|
|
|
|
for reading in HISTORIC_READINGS:
|
|
hass.states.async_set(OLD_ENTITY_ID, reading, ATTRS)
|
|
await hass.async_block_till_done()
|
|
await async_wait_recording_done(hass)
|
|
|
|
# Long-term statistics, the rows behind the "Statistics" graph. Keyed on the same
|
|
# entity_id, which is the whole point of the exercise.
|
|
start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=2)
|
|
async_import_statistics(
|
|
hass,
|
|
{
|
|
"mean_type": StatisticMeanType.ARITHMETIC,
|
|
"has_sum": False,
|
|
"name": None,
|
|
"source": "recorder",
|
|
"statistic_id": OLD_ENTITY_ID,
|
|
"unit_class": "temperature",
|
|
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
|
},
|
|
[
|
|
{"start": start, "mean": 18.6, "min": 18.0, "max": 19.0},
|
|
{"start": start + timedelta(hours=1), "mean": 19.8, "min": 19.4, "max": 20.2},
|
|
],
|
|
)
|
|
await async_wait_recording_done(hass)
|
|
|
|
# HACS swap done, files gone: this is the placeholder state Home Assistant writes for
|
|
# a registered entity nothing is driving any more.
|
|
hass.states.async_set(OLD_ENTITY_ID, STATE_UNAVAILABLE, {**ATTRS, ATTR_RESTORED: True})
|
|
await hass.async_block_till_done()
|
|
|
|
return old
|
|
|
|
|
|
async def _history_for(hass: HomeAssistant, entity_id: str, since) -> list[str]:
|
|
"""Read back the recorded states the History panel would draw."""
|
|
states = await get_instance(hass).async_add_executor_job(
|
|
get_significant_states, hass, since, None, [entity_id]
|
|
)
|
|
return [state.state for state in states.get(entity_id, [])]
|
|
|
|
|
|
async def _statistics_for(hass: HomeAssistant, entity_id: str, since) -> list[float]:
|
|
"""Read back the long-term statistics the Statistics panel would draw."""
|
|
rows = await get_instance(hass).async_add_executor_job(
|
|
statistics_during_period, hass, since, None, {entity_id}, "hour", None, {"mean"}
|
|
)
|
|
return [row["mean"] for row in rows.get(entity_id, [])]
|
|
|
|
|
|
@pytest.fixture
|
|
async def migrated(
|
|
hass: HomeAssistant,
|
|
enable_custom_integrations,
|
|
hass_client_no_auth,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""The full move: recorded predecessor, renamed integration set up over the top."""
|
|
assert await async_setup_component(hass, "http", {"http": {}})
|
|
since = dt_util.utcnow() - timedelta(hours=3)
|
|
|
|
old = await _record_predecessor_history(hass)
|
|
before = await _history_for(hass, OLD_ENTITY_ID, since)
|
|
statistics_before = await _statistics_for(hass, OLD_ENTITY_ID, since)
|
|
|
|
# The rename itself. In the new repository this is a source change; here it is the
|
|
# one thing that has to be simulated, because both domains are still `sws12500`.
|
|
monkeypatch.setattr("custom_components.sws12500.predecessor.PREDECESSOR_DOMAIN", OLD_DOMAIN)
|
|
|
|
# What the user types into the new integration's config flow: the credentials, and
|
|
# nothing else. The protocol flag and the sensor list are inherited.
|
|
new = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
title="Sencor SWS 12500",
|
|
options={API_ID: STATION_ID, API_KEY: STATION_PW},
|
|
)
|
|
new.add_to_hass(hass)
|
|
assert await hass.config_entries.async_setup(new.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# The station uploads again, to the endpoint the inherited WSLINK flag opened.
|
|
client = await hass_client_no_auth()
|
|
response = await client.get(WSLINK_URL, params=_upload())
|
|
assert response.status == 200
|
|
assert await response.text() == "OK"
|
|
await hass.async_block_till_done()
|
|
await async_wait_recording_done(hass)
|
|
|
|
return {
|
|
"old_entry": old,
|
|
"new_entry": new,
|
|
"since": since,
|
|
"history_before": before,
|
|
"history_after": await _history_for(hass, OLD_ENTITY_ID, since),
|
|
"statistics_before": statistics_before,
|
|
"statistics_after": await _statistics_for(hass, OLD_ENTITY_ID, since),
|
|
}
|
|
|
|
|
|
async def test_the_measurements_survive_the_move(hass: HomeAssistant, migrated) -> None:
|
|
"""One series, one entity_id, spanning both integrations."""
|
|
assert migrated["history_before"] == [*HISTORIC_READINGS, STATE_UNAVAILABLE]
|
|
|
|
# Everything recorded before the move is still there, and the reading the renamed
|
|
# integration just took continues the same series.
|
|
after = migrated["history_after"]
|
|
assert after[: len(HISTORIC_READINGS)] == list(HISTORIC_READINGS)
|
|
assert after[-1] == NEW_READING
|
|
|
|
# Long-term statistics hang off the same entity_id and are untouched.
|
|
assert migrated["statistics_after"] == migrated["statistics_before"] == [18.6, 19.8]
|
|
|
|
|
|
async def test_the_entity_is_the_same_row_now_owned_by_the_new_integration(
|
|
hass: HomeAssistant, migrated, entity_registry: er.EntityRegistry
|
|
) -> None:
|
|
"""Moved, not recreated: same entity_id, new platform, no duplicate left behind."""
|
|
moved = entity_registry.async_get(OLD_ENTITY_ID)
|
|
assert moved is not None
|
|
assert moved.platform == DOMAIN
|
|
assert moved.config_entry_id == migrated["new_entry"].entry_id
|
|
|
|
# Nothing was created under the current naming scheme for the same reading, which is
|
|
# what an unmigrated install would have produced.
|
|
duplicates = [
|
|
entry.entity_id
|
|
for entry in er.async_entries_for_config_entry(entity_registry, migrated["new_entry"].entry_id)
|
|
if entry.unique_id == OUTSIDE_TEMP and entry.entity_id != OLD_ENTITY_ID
|
|
]
|
|
assert duplicates == []
|
|
|
|
# The live state is the new upload, on the old entity_id.
|
|
state = hass.states.get(OLD_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == NEW_READING
|
|
|
|
# The predecessor's config entry is gone, so Settings is clean.
|
|
assert hass.config_entries.async_entries(OLD_DOMAIN) == []
|
|
|
|
|
|
async def test_options_the_user_never_retyped_came_across(hass: HomeAssistant, migrated) -> None:
|
|
"""The upload above only worked because WSLINK was inherited before the routes."""
|
|
options = migrated["new_entry"].options
|
|
assert options[WSLINK] is True
|
|
assert OUTSIDE_TEMP in options[SENSORS_TO_LOAD]
|
|
# And the credentials the user did retype are the ones in force.
|
|
assert options[API_ID] == STATION_ID
|
|
|
|
|
|
async def test_without_the_move_the_history_is_orphaned(
|
|
hass: HomeAssistant,
|
|
enable_custom_integrations,
|
|
hass_client_no_auth,
|
|
entity_registry: er.EntityRegistry,
|
|
) -> None:
|
|
"""The counterfactual, and the shipped default: `PREDECESSOR_DOMAIN == DOMAIN`.
|
|
|
|
Nothing is adopted, so the new integration mints its own entity under the current
|
|
naming scheme and the recorded series stops at `unavailable`. This is what the
|
|
migration exists to prevent - and, until the domain actually changes, also proof that
|
|
the adoption pass is inert rather than quietly rewriting a live installation.
|
|
"""
|
|
assert await async_setup_component(hass, "http", {"http": {}})
|
|
since = dt_util.utcnow() - timedelta(hours=3)
|
|
|
|
old = await _record_predecessor_history(hass)
|
|
|
|
new = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
title="Sencor SWS 12500",
|
|
options={API_ID: STATION_ID, API_KEY: STATION_PW, WSLINK: True},
|
|
)
|
|
new.add_to_hass(hass)
|
|
assert await hass.config_entries.async_setup(new.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_client_no_auth()
|
|
assert (await client.get(WSLINK_URL, params=_upload())).status == 200
|
|
await hass.async_block_till_done()
|
|
await async_wait_recording_done(hass)
|
|
|
|
# A second entity id for the same reading; the old series ends where it ended.
|
|
fresh = entity_registry.async_get_entity_id("sensor", DOMAIN, OUTSIDE_TEMP)
|
|
assert fresh is not None
|
|
assert fresh != OLD_ENTITY_ID
|
|
assert await _history_for(hass, OLD_ENTITY_ID, since) == [*HISTORIC_READINGS, STATE_UNAVAILABLE]
|
|
# The new entity starts from `unknown`, as any freshly created one does, and knows
|
|
# nothing of what came before.
|
|
assert await _history_for(hass, fresh, since) == [STATE_UNKNOWN, NEW_READING]
|
|
|
|
# The predecessor's entry is untouched, exactly as before this change.
|
|
assert hass.config_entries.async_entries(OLD_DOMAIN) == [old]
|
|
|
|
|
|
async def test_write_migration_report(hass: HomeAssistant, migrated, entity_registry: er.EntityRegistry) -> None:
|
|
"""Dump the before/after transcript when SWS_MIGRATION_REPORT names a file."""
|
|
target = os.environ.get("SWS_MIGRATION_REPORT")
|
|
if not target:
|
|
pytest.skip("SWS_MIGRATION_REPORT not set")
|
|
|
|
moved = entity_registry.async_get(OLD_ENTITY_ID)
|
|
assert moved is not None
|
|
lines = [
|
|
f"Domain move: {OLD_DOMAIN} -> {DOMAIN}",
|
|
"",
|
|
"BEFORE (old integration recorded, then uninstalled)",
|
|
f" entity_id : {OLD_ENTITY_ID}",
|
|
f" platform : {OLD_DOMAIN}",
|
|
f" recorded states : {', '.join(migrated['history_before'])}",
|
|
f" long-term statistics : {migrated['statistics_before']}",
|
|
"",
|
|
f"AFTER (renamed integration set up, station uploaded {NEW_READING} C)",
|
|
f" entity_id : {moved.entity_id}",
|
|
f" platform : {moved.platform}",
|
|
f" config entry : {moved.config_entry_id} ({migrated['new_entry'].domain})",
|
|
f" recorded states : {', '.join(migrated['history_after'])}",
|
|
f" long-term statistics : {migrated['statistics_after']}",
|
|
f" live state : {hass.states.get(OLD_ENTITY_ID).state} C",
|
|
f" inherited options : {WSLINK}={migrated['new_entry'].options[WSLINK]}, "
|
|
f"{SENSORS_TO_LOAD}={migrated['new_entry'].options[SENSORS_TO_LOAD]}",
|
|
f" predecessor entries : {hass.config_entries.async_entries(OLD_DOMAIN)}",
|
|
"",
|
|
"entity_id unchanged, history and statistics continue in one series.",
|
|
]
|
|
Path(target).write_text("\n".join(lines) + "\n", encoding="utf-8")
|