M8-T15: add thermal meter cost engine

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 567ddb9779
commit 489e5b596a
5 changed files with 909 additions and 16 deletions
+180 -1
View File
@@ -32,7 +32,7 @@ from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, select
from sqlalchemy import create_engine, event, select
from sqlalchemy.orm import Session
from app.integrations.pricing.strategies import PeriodDeltas # noqa: F401
@@ -57,6 +57,28 @@ from app.services.energy_cost import (
from app.services import timezone as tz_module
def test_scheduler_isolates_electricity_and_thermal_cost_scopes(monkeypatch: pytest.MonkeyPatch) -> None:
"""A thermal/electricity failure must not suppress the other ledger or HA publish."""
from app import main
calls: list[str] = []
class DummySession:
def rollback(self) -> None:
calls.append("rollback")
def close(self) -> None:
calls.append("close")
monkeypatch.setattr(main, "get_session_local", lambda: lambda: DummySession())
monkeypatch.setattr(main, "compute_closed_periods", lambda _session: (_ for _ in ()).throw(ValueError()))
monkeypatch.setattr(main, "compute_closed_meter_cost_periods", lambda _session: calls.append("thermal"))
from app.services import ha_discovery
monkeypatch.setattr(ha_discovery, "publish_states", lambda _session: calls.append("publish"))
main._run_scheduled_energy_cost()
assert calls == ["rollback", "close", "thermal", "close", "publish", "close"]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -82,6 +104,163 @@ def energy_db(tmp_path: Path):
engine.dispose()
@pytest.mark.parametrize("failing_scope", ["electricity", "thermal"])
def test_scheduler_uses_clean_sessions_after_real_flush_failure(
energy_db: Session, monkeypatch: pytest.MonkeyPatch, failing_scope: str
) -> None:
"""A DB failed-transaction in either ledger cannot poison the next scope or HA."""
from app import main
from app.services import ha_discovery
assert energy_db.bind is not None
sessions: list[Session] = []
calls: list[str] = []
def factory() -> Session:
session = Session(energy_db.bind)
sessions.append(session)
return session
def flush_failure(session: Session) -> None:
session.add(EnergyContract())
session.flush()
def successful(name: str):
def operation(session: Session) -> None:
session.execute(select(EnergyContract)).all()
calls.append(name)
return operation
monkeypatch.setattr(main, "get_session_local", lambda: factory)
monkeypatch.setattr(
main, "compute_closed_periods",
flush_failure if failing_scope == "electricity" else successful("electricity"),
)
monkeypatch.setattr(
main, "compute_closed_meter_cost_periods",
flush_failure if failing_scope == "thermal" else successful("thermal"),
)
monkeypatch.setattr(ha_discovery, "publish_states", lambda session: successful("ha")(session))
main._run_scheduled_energy_cost()
assert calls == (["thermal", "ha"] if failing_scope == "electricity" else ["electricity", "ha"])
assert len({id(session) for session in sessions}) == 3
@pytest.mark.parametrize("failing_scope", ["electricity", "thermal"])
def test_scheduler_isolates_real_commit_failure_and_rolls_back_write(
energy_db: Session, monkeypatch: pytest.MonkeyPatch, failing_scope: str
) -> None:
"""A commit exception leaves no partial write and the later scopes still run."""
from app import main
from app.services import ha_discovery
assert energy_db.bind is not None
sessions: list[Session] = []
calls: list[str] = []
def factory() -> Session:
session = Session(energy_db.bind)
sessions.append(session)
if len(sessions) == (1 if failing_scope == "electricity" else 2):
event.listen(session, "before_commit", lambda _session: (_ for _ in ()).throw(RuntimeError("commit")))
return session
def write(name: str):
def operation(session: Session) -> None:
now = datetime.now(UTC)
session.add(EnergyContract(
name=name, kind="manual", scope="electricity", active=False, currency="EUR",
created_at=now, updated_at=now,
))
session.commit()
calls.append(name)
return operation
monkeypatch.setattr(main, "get_session_local", lambda: factory)
monkeypatch.setattr(main, "compute_closed_periods", write("electricity"))
monkeypatch.setattr(main, "compute_closed_meter_cost_periods", write("thermal"))
monkeypatch.setattr(ha_discovery, "publish_states", lambda _session: calls.append("ha"))
main._run_scheduled_energy_cost()
assert calls == (["thermal", "ha"] if failing_scope == "electricity" else ["electricity", "ha"])
persisted = Session(energy_db.bind)
try:
assert persisted.scalars(select(EnergyContract.name)).all() == (
["thermal"] if failing_scope == "electricity" else ["electricity"]
)
finally:
persisted.close()
@pytest.mark.parametrize("failure", ["factory", "rollback", "close"])
def test_scheduler_lifecycle_failures_are_non_fatal_and_keep_later_scopes_independent(
monkeypatch: pytest.MonkeyPatch, failure: str
) -> None:
"""Factory and cleanup errors are logged locally, never allowed to escape."""
from app import main
from app.services import ha_discovery
calls: list[str] = []
created = 0
class DummySession:
def rollback(self) -> None:
calls.append("rollback")
if failure == "rollback":
raise RuntimeError("rollback")
def close(self) -> None:
calls.append("close")
if failure == "close":
raise RuntimeError("close")
def factory() -> DummySession:
nonlocal created
created += 1
if failure == "factory" and created == 1:
raise RuntimeError("factory")
return DummySession()
def electricity(_session: DummySession) -> None:
if failure in {"rollback", "close"}:
raise RuntimeError("operation")
calls.append("electricity")
monkeypatch.setattr(main, "get_session_local", lambda: factory)
monkeypatch.setattr(main, "compute_closed_periods", electricity)
monkeypatch.setattr(main, "compute_closed_meter_cost_periods", lambda _session: calls.append("thermal"))
monkeypatch.setattr(ha_discovery, "publish_states", lambda _session: calls.append("ha"))
main._run_scheduled_energy_cost()
assert "thermal" in calls and "ha" in calls
assert created == 3
def test_scheduler_swallows_ha_failure_after_two_successful_scopes(monkeypatch: pytest.MonkeyPatch) -> None:
from app import main
from app.services import ha_discovery
calls: list[str] = []
class DummySession:
def rollback(self) -> None:
calls.append("rollback")
def close(self) -> None:
calls.append("close")
monkeypatch.setattr(main, "get_session_local", lambda: lambda: DummySession())
monkeypatch.setattr(main, "compute_closed_periods", lambda _session: calls.append("electricity"))
monkeypatch.setattr(main, "compute_closed_meter_cost_periods", lambda _session: calls.append("thermal"))
monkeypatch.setattr(ha_discovery, "publish_states", lambda _session: (_ for _ in ()).throw(RuntimeError("ha")))
main._run_scheduled_energy_cost()
assert calls == ["electricity", "close", "thermal", "close", "rollback", "close"]
# ---------------------------------------------------------------------------
# Data-builder helpers
# ---------------------------------------------------------------------------