M8-T15: add thermal meter cost engine
This commit is contained in:
+180
-1
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user