ACT-001: actuator-first sensor lifecycle
This commit is contained in:
135
tests/api/test_actuators.py
Normal file
135
tests/api/test_actuators.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.config import Settings
|
||||
from app.ha.discovery import DiscoveredEntity
|
||||
from app.ha.discovery import discover_entities
|
||||
from app.ha.history import EntityHistorySeries, NumericHistoryPoint
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
from app.main import app
|
||||
from app.ml.registry.model_registry import ModelRegistry
|
||||
|
||||
|
||||
class FakeHaReader(HaReader):
|
||||
def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None:
|
||||
self._entities = entities
|
||||
self._history = history
|
||||
|
||||
def read_entities(self) -> list[HaEntitySummary]:
|
||||
return list(self._entities)
|
||||
|
||||
def discover(
|
||||
self,
|
||||
domains: set[str] | None = None,
|
||||
learnable: bool | None = None,
|
||||
) -> list[DiscoveredEntity]:
|
||||
return discover_entities(self._entities, domains=domains, learnable=learnable)
|
||||
|
||||
def read_history(
|
||||
self,
|
||||
entity_ids: list[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[EntityHistorySeries]:
|
||||
base = start_time
|
||||
return [
|
||||
EntityHistorySeries(
|
||||
entity_id=entity_id,
|
||||
points=[
|
||||
NumericHistoryPoint(
|
||||
timestamp=base + timedelta(hours=index),
|
||||
value=value,
|
||||
)
|
||||
for index, value in enumerate(self._history.get(entity_id, []))
|
||||
],
|
||||
)
|
||||
for entity_id in entity_ids
|
||||
if entity_id in self._history
|
||||
]
|
||||
|
||||
|
||||
def _install_service(tmp_path: Path) -> None:
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
entity_id="light.abstellkammer",
|
||||
domain="light",
|
||||
friendly_name="Abstellkammer Licht",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.abstellkammer_illuminance",
|
||||
domain="sensor",
|
||||
device_class="illuminance",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="lx",
|
||||
friendly_name="Abstellkammer Helligkeit",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="binary_sensor.abstellkammer_motion",
|
||||
domain="binary_sensor",
|
||||
device_class="motion",
|
||||
friendly_name="Abstellkammer Bewegung",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
]
|
||||
settings = Settings(
|
||||
ha_url="http://ha.local",
|
||||
ha_token="token",
|
||||
model_store=str(tmp_path / "models"),
|
||||
automation_store=str(tmp_path / "automations"),
|
||||
actuator_store=str(tmp_path / "actuators"),
|
||||
history_days=14,
|
||||
min_training_points=5,
|
||||
retrain_stale_hours=24,
|
||||
reconcile_interval_seconds=900,
|
||||
)
|
||||
app.state.registry = ModelRegistry(tmp_path / "models")
|
||||
app.state.actuator_store = ActuatorStore(tmp_path / "actuators")
|
||||
app.state.ha_reader = FakeHaReader(
|
||||
entities,
|
||||
{"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]},
|
||||
)
|
||||
app.state.actuator_service = ActuatorReconciliationService(
|
||||
ha_reader=app.state.ha_reader,
|
||||
store=app.state.actuator_store,
|
||||
registry=app.state.registry,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
|
||||
created = client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||
assert created.status_code == 201
|
||||
assert created.json()["assignment"]["selected_numeric_entity_id"] == (
|
||||
"sensor.abstellkammer_illuminance"
|
||||
)
|
||||
|
||||
listed = client.get("/v1/actuators")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["lifecycle"]["status"] == "trained"
|
||||
|
||||
override = client.post(
|
||||
"/v1/actuators/light.abstellkammer/override",
|
||||
json={
|
||||
"numeric_entity_id": "sensor.abstellkammer_illuminance",
|
||||
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
|
||||
"note": "Explizit bestaetigt",
|
||||
},
|
||||
)
|
||||
assert override.status_code == 200
|
||||
assert override.json()["assignment"]["source"] == "manual"
|
||||
|
||||
reconciliation = client.post("/v1/actuators/reconciliation/run")
|
||||
assert reconciliation.status_code == 200
|
||||
assert reconciliation.json()["trained_models"] == 1
|
||||
Reference in New Issue
Block a user