Add HA lifecycle tests and CI
Tests / tests (push) Canceled after 0s

This commit is contained in:
buzera committed 2026-10-26 18:21:18 +02:00
1 parent cea79b6b48
commit c771bc360a
12 files changed
+378 -4

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
name: Tests
on:
push:
pull_request:
workflow_dispatch:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-python@v5
with:
python-version: '3.13'
cache: pip
- name: Install test dependencies
run: python -m pip install -r requirements-test.txt
- name: Run checks
run: sh scripts/check.sh
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.pytest_cache/
.coverage
htmlcov/
.venv/
+18
View File
@@ -106,3 +106,21 @@ validation remain open. See README for compatibility and safe trial details.
The suite now has 42 passing offline tests. Compilation with the temporary cache The suite now has 42 passing offline tests. Compilation with the temporary cache
and `git diff --check` pass. The API basis is HA's and `git diff --check` pass. The API basis is HA's
[state timestamp documentation](https://www.home-assistant.io/docs/configuration/state_object/). [state timestamp documentation](https://www.home-assistant.io/docs/configuration/state_object/).
## Follow-up: HA Core harness and CI
Added a separate `tests/ha` pytest suite using the real Home Assistant 2025.12.0
package and matching fixtures, pinned for Python 3.13. All 14 tests pass locally,
alongside the existing 42 offline tests. The full `scripts/check.sh` checks and
`pip check` passed. The suite exercises the actual config manager, config/options
schemas, registry, scheduler and Store API, with mocked disk I/O and simulated
sensor/reference entities. A service-call guard covers every test. No actuator
calls were observed. Tests include cold setup with stored data/manual off, but not
a full HA process restart or HAOS hardware validation.
Added a Forgejo/Gitea Actions workflow and lightweight package/translation checks.
Remote runner execution, a modern-HA version matrix, dedicated lint/type checks,
full process restart, partial platform failure and real disk-failure testing remain
open. The test harness needed a pycares 4.11.0 pin for compatibility with HA's DNS
dependency. Integration runtime dependencies and behavior are unchanged.
+36
View File
@@ -126,3 +126,39 @@ history remain future work. These checks run on the existing five-minute poll.
After backing up and upgrading, check report ages in Status, adjust limits in After backing up and upgrading, check report ages in Status, adjust limits in
Options to match your sensors, and verify fallback/recovery with an unavailable Options to match your sensors, and verify fallback/recovery with an unavailable
source. Generic Thermostat remains the sole controller. No HAOS trial was performed. source. Generic Thermostat remains the sole controller. No HAOS trial was performed.
## Developer checks and Forgejo/Gitea Actions
The HA Core test suite targets **Home Assistant 2025.12.0 on Python 3.13**,
matching the minimum version in `hacs.json`. Run in a dedicated development
virtual environment; these test dependencies are not installed in the integration
and do not change its local, standard-library model inference.
```bash
python3.13 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements-test.txt
sh scripts/check.sh
```
The check script runs dependency consistency checks, 42 offline tests, 14 tests
using the real HA Core/fixture package, compilation, lightweight package metadata
and translation checks, and diff whitespace checks. A compatible DNS dependency
is pinned because pycares 5 breaks the older HA dependency's import. The script
also disables a transitive HA test dependency's optional metadata download.
HA tests cover setup, config/options forms, migration, reload and entity cleanup,
scheduled sampling and poll cancellation, stored model restoration/deletion,
missing inputs, manual off/presets and optimizer failure. A fixture rejects all
HA service calls throughout each test. HA Core runs locally with simulated input
entities and mocked storage I/O; this does not validate HAOS, physical devices,
disk failures or full process restarts. The original standalone unittest command
still works without installing HA.
`.gitea/workflows/tests.yml` runs the same checks for pushes and pull requests on
an `ubuntu-latest` Actions runner. Enable repository Actions and configure a runner
with that label. Dependencies and action downloads need internet access in CI;
model inference does not. Remote CI execution has not yet been verified. Local
validation used macOS and Python 3.13; Linux runner validation remains necessary.
No runtime integration behavior or manifest version changed in this test commit.
+7 -4
View File
@@ -40,7 +40,7 @@ Current behavior: **shadow mode only**. `generic_thermostat` remains the one and
**Objective:** Make the existing observer trustworthy before extending it. **Objective:** Make the existing observer trustworthy before extending it.
- [x] Perform a source audit of v0.2.1: pure model, optimizer, config/options, coordinator update timing, persistent model and entity lifecycle. - [x] Perform a source audit of v0.2.1: pure model, optimizer, config/options, coordinator update timing, persistent model and entity lifecycle.
- [ ] Add Home Assistant test harness coverage for initial setup, update, restart/reload, options changes, missing sensors, `heat/off`, target changes, teardown and uninstall. - [x] Add Home Assistant test harness coverage for initial setup, update, restart/reload, options changes, missing sensors, `heat/off`, target changes, teardown and uninstall.
- [ ] Verify that **zero physical-device service calls** occur in observation mode, including restart and failed update cases. - [ ] Verify that **zero physical-device service calls** occur in observation mode, including restart and failed update cases.
- [ ] Establish deterministic handling of 5-minute transitions, polling jitter, timestamp discontinuities, missing data and real switch transitions between samples. - [ ] Establish deterministic handling of 5-minute transitions, polling jitter, timestamp discontinuities, missing data and real switch transitions between samples.
- [x] Add input freshness thresholds and diagnostics that identify exactly which source is missing/stale/invalid. - [x] Add input freshness thresholds and diagnostics that identify exactly which source is missing/stale/invalid.
@@ -57,14 +57,17 @@ Current behavior: **shadow mode only**. `generic_thermostat` remains the one and
See [AUDIT.md](AUDIT.md). The source audit and focused repairs are complete; See [AUDIT.md](AUDIT.md). The source audit and focused repairs are complete;
Milestone 0 acceptance is **not** complete. Offline boundary doubles cover setup, Milestone 0 acceptance is **not** complete. Offline boundary doubles cover setup,
reload/unload, migration, removal, corrupt storage, missing inputs and manual off. reload/unload, migration, removal, corrupt storage, missing inputs and manual off.
They are not a full HA test harness. Per-room accepted ON/OFF counts, additive A follow-up now adds 14 real HA Core 2025.12.0 tests on Python 3.13, including
config/options flows, scheduling, registry cleanup and Store API calls. Disk I/O
and source entities are mocked; HAOS and full-process restart tests remain open. Per-room accepted ON/OFF counts, additive
payload schema v2, conservative relay-transition rejection, jitter normalization, payload schema v2, conservative relay-transition rejection, jitter normalization,
Fahrenheit targets and forecast input-change guards are implemented. Fahrenheit targets and forecast input-change guards are implemented.
Configurable room/weather report-age limits and per-source quality diagnostics Configurable room/weather report-age limits and per-source quality diagnostics
are now implemented with offline coverage. Sensor alignment remains open. are now implemented with offline coverage. Sensor alignment remains open.
Next implement a supported HA test harness/CI. Then improve event-based sampling and identity/persistence The HA Core harness and Forgejo/Gitea workflow are now added and pass locally;
migration tests. Only after those reliability checks proceed to Milestone 1's remote runner execution remains unverified. Next improve event-based sampling and
identity/persistence migration tests, then extend the HA version matrix. Only after those reliability checks proceed to Milestone 1's
recorded forecasts and walk-forward evaluation. Live control remains unauthorized. recorded forecasts and walk-forward evaluation. Live control remains unauthorized.
## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0) ## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0)
+4
View File
@@ -0,0 +1,4 @@
[pytest]
testpaths = tests/ha
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
+4
View File
@@ -0,0 +1,4 @@
# Pins Home Assistant 2025.12.0 and its matching fixtures; requires Python 3.13.
pytest-homeassistant-custom-component==0.13.298
# aiodns 3.5 (HA's pin) uses APIs removed in pycares 5.
pycares==4.11.0
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Run with Python 3.13 and requirements-test.txt installed.
set -eu
# A transitive HA test dependency otherwise fetches metadata on import.
export LITELLM_LOCAL_MODEL_COST_MAP=True
python -m pip check
python -m unittest discover -s tests -v
python -m pytest tests/ha -q
python -m compileall -q custom_components/heating_mpc
python scripts/validate_package.py
git diff --check
+28
View File
@@ -0,0 +1,28 @@
"""Small local packaging and translation checks; not a replacement for hassfest."""
import json
from pathlib import Path
import subprocess
root = Path(__file__).resolve().parents[1]
component = root / "custom_components" / "heating_mpc"
manifest = json.loads((component / "manifest.json").read_text())
assert manifest["domain"] == "heating_mpc"
assert manifest["config_flow"] is True
assert manifest["iot_class"] == "local_polling"
assert not manifest["requirements"], "Model inference should remain dependency-free"
json.loads((root / "hacs.json").read_text())
def keys(value, prefix=()):
if not isinstance(value, dict):
return {prefix}
return set().union(*(keys(child, (*prefix, key)) for key, child in value.items()))
strings = json.loads((component / "strings.json").read_text())
for language in ("en", "ro"):
translated = json.loads((component / "translations" / f"{language}.json").read_text())
assert keys(strings) == keys(translated), f"Translation keys differ: {language}"
tracked = subprocess.check_output(["git", "ls-files", "custom_components"], cwd=root, text=True).splitlines()
assert not any("__pycache__" in path or path.endswith((".pyc", ".pyo")) for path in tracked)
print("Package metadata, translations and tracked component files passed")
+40
View File
@@ -0,0 +1,40 @@
"""Real Home Assistant fixtures, isolated from the offline boundary doubles."""
from unittest.mock import patch
import pytest
from homeassistant.core import ServiceRegistry
from pytest_homeassistant_custom_component.common import MockConfigEntry
@pytest.fixture(autouse=True)
def custom_integrations(enable_custom_integrations):
"""Let HA discover custom_components/heating_mpc in this checkout."""
@pytest.fixture(autouse=True)
def no_actuator_calls():
"""Reject every HA service call during all tested observer lifecycle paths."""
with patch.object(ServiceRegistry, "async_call", side_effect=AssertionError(
"The shadow integration must never call HA services"
)) as calls:
yield calls
calls.assert_not_called()
@pytest.fixture
def entry():
return MockConfigEntry(
domain="heating_mpc", version=2, unique_id="home_heating",
data={"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
"room_entities": ["sensor.room"],
"outside_temperature_sources": ["sensor.outside"],
"weights": {"sensor.room": 1.0}, "horizon_minutes": 60},
)
@pytest.fixture
def inputs(hass):
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C"})
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"})
hass.states.async_set("switch.boiler", "off")
hass.states.async_set("climate.reference", "heat", {"temperature": 22, "preset_mode": "comfort"})
+58
View File
@@ -0,0 +1,58 @@
"""Validate actual HA form schemas, options and entry migration."""
from homeassistant.config_entries import SOURCE_USER
from homeassistant.data_entry_flow import FlowResultType
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.heating_mpc.const import DOMAIN
async def test_config_flow(hass, inputs):
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
assert result["type"] is FlowResultType.FORM
main = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
"room_entities": ["sensor.room"]}
validated = result["data_schema"](main)
assert validated["room_max_age_minutes"] == 30
assert validated["weather_max_age_minutes"] == 120
result = await hass.config_entries.flow.async_configure(result["flow_id"], validated)
assert result["step_id"] == "weather"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
assert result["step_id"] == "weather_extra"
result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({}))
assert result["step_id"] == "weights"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {"weight__sensor.room": 2})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"]["weights"] == {"sensor.room": 2}
await hass.async_block_till_done()
async def test_options_flow_keeps_settings(hass, entry, inputs):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
main = result["data_schema"]({"climate_entity": "climate.reference",
"heater_entity": "switch.boiler", "room_entities": ["sensor.room"],
"room_max_age_minutes": 45})
result = await hass.config_entries.options.async_configure(result["flow_id"], main)
result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"]({}))
result = await hass.config_entries.options.async_configure(result["flow_id"], {"weight__sensor.room": 3})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
assert entry.options["room_max_age_minutes"] == 45
assert hass.data[DOMAIN][entry.entry_id].config["room_max_age_minutes"] == 45
async def test_v1_entry_migrates_on_setup(hass, inputs):
entry = MockConfigEntry(domain=DOMAIN, version=1, data={
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
"room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 2
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
assert "outside_entity" not in entry.data
+148
View File
@@ -0,0 +1,148 @@
"""Run the observer through real HA lifecycle and storage APIs."""
from datetime import timedelta
from unittest.mock import patch
import pytest
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import async_fire_time_changed
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from custom_components.heating_mpc.const import DOMAIN
async def setup(hass, entry):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return hass.data[DOMAIN][entry.entry_id]
async def test_setup_reload_unload(hass, entry, inputs):
coordinator = await setup(hass, entry)
registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(registry, entry.entry_id)
assert len(entities) == 5
ids = {e.unique_id: e.entity_id for e in entities}
assert coordinator.data["status"] == "learning"
proposal_id = registry.async_get_entity_id("binary_sensor", DOMAIN, f"{entry.entry_id}_proposal")
assert hass.states.get(proposal_id).state == "unavailable"
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id] is not coordinator
assert ids == {e.unique_id: e.entity_id for e in er.async_entries_for_config_entry(registry, entry.entry_id)}
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.entry_id not in hass.data[DOMAIN]
assert not coordinator._listeners
assert coordinator._unsub_refresh is None
async def test_options_reload_removes_only_old_room(hass, entry, inputs):
await setup(hass, entry)
registry = er.async_get(hass)
old = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]})
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"]
assert registry.async_get(old) is None
assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min")
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 5
async def test_storage_restore_and_remove(hass, entry, inputs):
coordinator = await setup(hass, entry)
model = coordinator.learners["sensor.room"]
model.count, model.on_count, model.off_count = 250, 125, 125
await coordinator._store.async_save({"schema_version": 2, "heater_entity": "switch.boiler",
"learners": {"sensor.room": model.as_dict()}})
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
restored = hass.data[DOMAIN][entry.entry_id]
assert restored.learners["sensor.room"].count == 250
assert restored.data["status"] == "ready"
assert await hass.config_entries.async_remove(entry.entry_id)
await hass.async_block_till_done()
assert await Store(hass, 1, f"{DOMAIN}.{entry.entry_id}").async_load() is None
assert not er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
async def test_missing_sensor_manual_off_and_target(hass, entry, inputs):
coordinator = await setup(hass, entry)
hass.states.async_set("sensor.room", "unavailable")
await coordinator.async_refresh()
assert coordinator.data["status"] == "waiting_for_sensors"
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C"})
hass.states.async_set("climate.reference", "off", {"temperature": 19, "preset_mode": "away"})
await coordinator.async_refresh()
assert coordinator.data["status"] == "paused"
assert coordinator.data["proposal"] is None
assert hass.states.get("climate.reference").attributes["preset_mode"] == "away"
hass.states.async_set("climate.reference", "heat", {"temperature": 21, "preset_mode": "sleep"})
await coordinator.async_refresh()
assert coordinator.data["target"] == 21
assert hass.states.get("switch.boiler").state == "off"
async def test_optimizer_failure_is_unavailable(hass, entry, inputs):
coordinator = await setup(hass, entry)
model = coordinator.learners["sensor.room"]
model.count, model.on_count, model.off_count = 250, 125, 125
coordinator._update_counts()
with patch("custom_components.heating_mpc.coordinator.optimize", side_effect=RuntimeError("test")):
await coordinator.async_refresh()
assert coordinator.data["status"] == "prediction_error"
assert coordinator.data["proposal"] is None
async def test_scheduled_sample_saves_and_unload_stops_polling(hass, entry, inputs, freezer, hass_storage):
coordinator = await setup(hass, entry)
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert coordinator.learners["sensor.room"].count == 1
payload = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"]
assert payload["learners"]["sensor.room"]["off_count"] == 1
assert await hass.config_entries.async_unload(entry.entry_id)
future += timedelta(minutes=5)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert coordinator.learners["sensor.room"].count == 1
@pytest.mark.parametrize("payload", ["broken", {"learners": []}, {"learners": {"sensor.room": {"theta": []}}}])
async def test_corrupt_saved_payload_starts_learning(hass, entry, inputs, hass_storage, payload):
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": payload}
coordinator = await setup(hass, entry)
assert coordinator.data["status"] == "learning"
assert coordinator.data["samples"] == 0
async def test_startup_preserves_manual_off_with_trained_model(hass, entry, inputs, hass_storage):
from custom_components.heating_mpc.physics import Learner
model = Learner(count=250, on_count=125, off_count=125)
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": {
"schema_version": 2, "heater_entity": "switch.boiler",
"learners": {"sensor.room": model.as_dict()}}}
hass.states.async_set("climate.reference", "off", {"temperature": 19, "preset_mode": "away"})
coordinator = await setup(hass, entry)
assert coordinator.data["status"] == "paused"
assert coordinator.data["proposal"] is None
assert hass.states.get("climate.reference").state == "off"
assert hass.states.get("climate.reference").attributes["temperature"] == 19
async def test_startup_missing_inputs_then_recovery(hass, entry):
coordinator = await setup(hass, entry)
assert coordinator.data["status"] == "waiting_for_sensors"
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C"})
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"})
hass.states.async_set("switch.boiler", "off")
hass.states.async_set("climate.reference", "heat", {"temperature": 22})
await coordinator.async_refresh()
assert coordinator.data["status"] == "learning"
assert coordinator.data["samples"] == 0