Compare commits
2
Commits
d4acfc438a
..
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a03e7fd6 | ||
|
|
d3fc90b320 |
@@ -27,6 +27,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -369,6 +370,10 @@ register_provider(_modbus_provider)
|
||||
# Energy Cost provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# *_today 当日窗口翻天的宽限:本地午夜后 5 秒才切到新的一天,避免在 00:00:0x 把
|
||||
# 归零后的值发出去、被慢几秒的 HA 钟记成前一天的 23:59:59(归错小时桶)。
|
||||
_TODAY_RESET_GRACE = timedelta(seconds=5)
|
||||
|
||||
|
||||
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
||||
@@ -735,7 +740,11 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
return None
|
||||
|
||||
# Today's window in UTC, using monkeypatch-safe module attribute calls.
|
||||
today_local = _tz_mod.local_now().date()
|
||||
# Grace: subtract _TODAY_RESET_GRACE so that in the first 5 seconds after
|
||||
# local midnight the getter still returns yesterday's window. This prevents
|
||||
# a "归零后的值" from being published while HA's clock (which may lag a few
|
||||
# seconds) would stamp it as 23:59:59 of the previous day.
|
||||
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
|
||||
tomorrow_local = today_local + _td(days=1)
|
||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||
@@ -772,7 +781,8 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
if not versions:
|
||||
return None
|
||||
|
||||
today_local = _tz_mod.local_now().date()
|
||||
# Grace: same logic as import_cost_today — see that getter's comment.
|
||||
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
|
||||
tomorrow_local = today_local + _td(days=1)
|
||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||
|
||||
+27
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -36,6 +37,7 @@ from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SE
|
||||
from app.services.ha_discovery import publish_discovery, publish_states
|
||||
from app.services.tibber_prices import refresh_prices
|
||||
from app.services.energy_cost import compute_closed_periods
|
||||
from app.services.timezone import local_tz
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -159,6 +161,20 @@ def _run_scheduled_ha_state_publish() -> None:
|
||||
session.close()
|
||||
|
||||
|
||||
def _run_midnight_state_publish() -> None:
|
||||
"""本地午夜后不久专门发布一次状态,让 *_today 的每日归零稳稳落在午夜之后
|
||||
(对 HA 钟慢几秒鲁棒)。best-effort:失败仅记日志,不影响调度器。"""
|
||||
session_local = get_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
from app.services.ha_discovery import publish_states
|
||||
publish_states(session)
|
||||
except Exception:
|
||||
logger.exception("_run_midnight_state_publish: failed (non-fatal)")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def ensure_auth_db_ready() -> None:
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
@@ -233,6 +249,17 @@ async def lifespan(_: FastAPI):
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
# Dedicated midnight publish: fire at local 00:00:10 so *_today grace (5 s) has
|
||||
# already elapsed and the day-rolled value is pushed to HA immediately, rather
|
||||
# than waiting for the next 60-second ha-state-publish sweep.
|
||||
scheduler.add_job(
|
||||
_run_midnight_state_publish,
|
||||
trigger=CronTrigger(hour=0, minute=0, second=10, timezone=local_tz()),
|
||||
id="midnight-today-publish",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
||||
|
||||
@@ -123,6 +123,10 @@ _READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
|
||||
# degraded to prevent negative costs or grossly inflated charges.
|
||||
_MAX_DELTA_KWH = Decimal("100")
|
||||
|
||||
# 每日固定费/税补在"本地午夜后多久"才结算入账。延后到 01:05 是为了让累计成本的
|
||||
# 整天阶跃落在新一天、且避开 01:00 整点(HA 长期统计的小时桶边界)。
|
||||
_SETTLEMENT_OFFSET = timedelta(hours=1, minutes=5)
|
||||
|
||||
# DSMR payload register keys (cumulative kWh, JSON string values).
|
||||
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
|
||||
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
|
||||
@@ -764,7 +768,8 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
||||
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
||||
|
||||
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
|
||||
today_local: _date = local_now().date()
|
||||
_now_local = local_now()
|
||||
today_local: _date = _now_local.date()
|
||||
|
||||
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
|
||||
#
|
||||
@@ -811,8 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
||||
else:
|
||||
last_counted = local_end_date - _td(days=1)
|
||||
|
||||
# Cap: only elapsed or today's days.
|
||||
last_counted = min(last_counted, today_local)
|
||||
# Settlement cap: "today" is only counted once the local clock has passed the
|
||||
# settlement offset since midnight (01:05). This defers the daily standing-charge
|
||||
# step from 00:00 to 01:05, ensuring the cumulative-cost adiabatic jump lands
|
||||
# inside the new calendar day and avoids the HA 01:00 hourly-bucket boundary.
|
||||
_today_midnight_utc = _lmu(today_local)
|
||||
if _now_local >= _today_midnight_utc + _SETTLEMENT_OFFSET:
|
||||
settled_cap = today_local
|
||||
else:
|
||||
settled_cap = today_local - _td(days=1)
|
||||
last_counted = min(last_counted, settled_cap)
|
||||
|
||||
# If the range is empty (first_counted > last_counted), no days are counted.
|
||||
|
||||
|
||||
+317
-16
@@ -1199,10 +1199,16 @@ def _ams_midnight(year: int, month: int, day: int) -> datetime:
|
||||
|
||||
|
||||
class TestSummarizePrincipleC:
|
||||
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone."""
|
||||
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone.
|
||||
|
||||
Tests that assert a specific "today" (e.g. June 25) must also pin
|
||||
``local_now`` via *pinned_now* to remain deterministic as wall-clock
|
||||
time advances. Tests that only care about windows entirely in the past
|
||||
or entirely in the future do not need to pin ``local_now``.
|
||||
"""
|
||||
|
||||
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
|
||||
# Today local = June 25 CEST (since today is 2026-06-25).
|
||||
# Reference "today" pinned in individual tests = June 25 CEST.
|
||||
|
||||
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
|
||||
"""Create an active manual contract with a single version."""
|
||||
@@ -1210,10 +1216,27 @@ class TestSummarizePrincipleC:
|
||||
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
|
||||
session.commit()
|
||||
|
||||
def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict:
|
||||
"""Run summarize with Europe/Amsterdam local_tz monkeypatched."""
|
||||
def _run_summarize_ams(
|
||||
self,
|
||||
session: Session,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
*,
|
||||
pinned_now: datetime | None = None,
|
||||
) -> dict:
|
||||
"""Run summarize with Europe/Amsterdam local_tz monkeypatched.
|
||||
|
||||
If *pinned_now* is given, also patches ``app.services.energy_cost.local_now``
|
||||
to that fixed value, making settlement-offset logic deterministic
|
||||
regardless of wall-clock time.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
import app.services.energy_cost as _ec
|
||||
|
||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||
if pinned_now is not None:
|
||||
with patch.object(_ec, "local_now", return_value=pinned_now):
|
||||
return summarize(session, start_utc, end_utc)
|
||||
return summarize(session, start_utc, end_utc)
|
||||
|
||||
def test_today_window_counts_1_day(self, energy_db: Session) -> None:
|
||||
@@ -1259,7 +1282,10 @@ class TestSummarizePrincipleC:
|
||||
def test_this_month_window_counts_25_days(self, energy_db: Session) -> None:
|
||||
"""This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25).
|
||||
|
||||
Today = June 25 local, so June 26–30 are not yet elapsed.
|
||||
Pins ``local_now`` to June 25 noon AMS so the test is deterministic
|
||||
regardless of the actual wall-clock date. June 25 is well past the
|
||||
01:05 settlement offset, so ``settled_cap = June 25``.
|
||||
Today = June 25 local, so June 26–30 are not yet elapsed → 25 days.
|
||||
Matches table row: 6/1→7/1 (This Month) → 25 days.
|
||||
"""
|
||||
eff_utc = _ams_midnight(2026, 6, 1)
|
||||
@@ -1267,7 +1293,9 @@ class TestSummarizePrincipleC:
|
||||
|
||||
start = _ams_midnight(2026, 6, 1)
|
||||
end = _ams_midnight(2026, 7, 1)
|
||||
result = self._run_summarize_ams(energy_db, start, end)
|
||||
# Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
|
||||
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
@@ -1304,8 +1332,9 @@ class TestSummarizePrincipleC:
|
||||
def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None:
|
||||
"""A window partially in the future caps at today (only elapsed days counted).
|
||||
|
||||
Pins ``local_now`` to June 25 noon AMS so the test is deterministic.
|
||||
Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
|
||||
Today = June 25 → only 1 day elapsed (June 25).
|
||||
Today = June 25 → settled_cap = June 25 → only 1 day elapsed (June 25).
|
||||
Matches table row: 6/25→6/28 → 1 day.
|
||||
"""
|
||||
eff_utc = _ams_midnight(2026, 6, 1)
|
||||
@@ -1313,7 +1342,9 @@ class TestSummarizePrincipleC:
|
||||
|
||||
start = _ams_midnight(2026, 6, 25)
|
||||
end = _ams_midnight(2026, 6, 28)
|
||||
result = self._run_summarize_ams(energy_db, start, end)
|
||||
# Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
|
||||
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
@@ -1366,8 +1397,12 @@ class TestSummarizePrincipleC:
|
||||
start = _ams_midnight(2026, 6, 1)
|
||||
end = _ams_midnight(2026, 7, 1)
|
||||
from unittest.mock import patch
|
||||
import app.services.energy_cost as _ec
|
||||
# Pin local_now to June 25 2026 noon AMS: today=June 25, settled_cap=June 25.
|
||||
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||
result = summarize(energy_db, start, end)
|
||||
with patch.object(_ec, "local_now", return_value=pinned_now):
|
||||
result = summarize(energy_db, start, end)
|
||||
|
||||
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
|
||||
expected_fixed = 24 * 12 / 30 + 1 * 24 / 30
|
||||
@@ -1408,12 +1443,16 @@ class TestSummarizePrincipleC:
|
||||
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
|
||||
energy_db.commit()
|
||||
|
||||
# Window = June 25 only (today, 1 day elapsed at V2 rate)
|
||||
# Window = June 25 only (today, 1 day elapsed at V2 rate).
|
||||
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
|
||||
start = _ams_midnight(2026, 6, 25)
|
||||
end = _ams_midnight(2026, 7, 1)
|
||||
from unittest.mock import patch
|
||||
import app.services.energy_cost as _ec
|
||||
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||
result = summarize(energy_db, start, end)
|
||||
with patch.object(_ec, "local_now", return_value=pinned_now):
|
||||
result = summarize(energy_db, start, end)
|
||||
|
||||
# Only 1 day at V2 rate
|
||||
expected_fixed = 1 * 24 / 30
|
||||
@@ -1434,13 +1473,11 @@ class TestSummarizePrincipleC:
|
||||
within the day start_utc falls. June 24 is the anchor day → its charge is counted.
|
||||
|
||||
Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)).
|
||||
Today local = June 25 (2026-06-25).
|
||||
Pins local_now to June 25 noon AMS → today=June 25, settled_cap=June 25.
|
||||
first_counted = June 24 (anchor day, previously dropped).
|
||||
last_counted = min(June 26, June 25) = June 25.
|
||||
n_days = June 25 - June 24 + 1 = 2.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
eff_utc = _ams_midnight(2026, 6, 1)
|
||||
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||
|
||||
@@ -1449,8 +1486,9 @@ class TestSummarizePrincipleC:
|
||||
# end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped)
|
||||
end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC)
|
||||
|
||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc)
|
||||
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
|
||||
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc, pinned_now=pinned_now)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
@@ -2409,3 +2447,266 @@ class TestMeterAwareComputePeriod:
|
||||
assert row.degraded is False, "All-zero deltas must not trigger the D6 guard"
|
||||
assert row.import_cost == 0.0
|
||||
assert row.net_cost == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FUE-T08. summarize — settlement offset (local 01:05)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSummarizeSettlementOffset:
|
||||
"""FUE-T08: daily fixed-fee/heffingskorting settled after local 01:05.
|
||||
|
||||
Reference date: June 26, 2026 AMS (CEST = UTC+2).
|
||||
- AMS midnight June 26 = June 25 22:00 UTC
|
||||
- Settlement threshold = June 25 22:00 + 01:05 = June 25 23:05 UTC
|
||||
= June 26 01:05 AMS
|
||||
- "before offset" now = June 26 00:30 AMS = June 25 22:30 UTC
|
||||
- "after offset" now = June 26 01:10 AMS = June 25 23:10 UTC
|
||||
|
||||
All tests monkeypatch both local_tz (Europe/Amsterdam) and
|
||||
``app.services.energy_cost.local_now`` to be fully deterministic.
|
||||
"""
|
||||
|
||||
# UTC instants used as "now":
|
||||
# June 25 22:30 UTC = June 26 00:30 AMS (before settlement threshold 23:05 UTC)
|
||||
_NOW_BEFORE = datetime(2026, 6, 25, 22, 30, 0, tzinfo=UTC)
|
||||
# June 25 23:10 UTC = June 26 01:10 AMS (after settlement threshold 23:05 UTC)
|
||||
_NOW_AFTER = datetime(2026, 6, 25, 23, 10, 0, tzinfo=UTC)
|
||||
|
||||
def _setup_single_version_contract(self, session: Session) -> None:
|
||||
"""Insert a manual contract effective Jan 1 2026 (covers all test dates)."""
|
||||
c = _make_contract(session, kind="manual", active=True)
|
||||
_make_version(session, c, _MANUAL_VALUES, effective_from=_ams_midnight(2026, 1, 1))
|
||||
session.commit()
|
||||
|
||||
def _run(
|
||||
self,
|
||||
session: Session,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
*,
|
||||
now_utc: datetime,
|
||||
) -> dict:
|
||||
"""Run summarize with AMS timezone and pinned local_now.
|
||||
|
||||
*now_utc* is converted to AMS before being used as the ``local_now``
|
||||
return value, so ``.date()`` yields the correct AMS local date.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
import app.services.energy_cost as _ec
|
||||
|
||||
now_ams = now_utc.astimezone(_ams())
|
||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||
with patch.object(_ec, "local_now", return_value=now_ams):
|
||||
return summarize(session, start_utc, end_utc)
|
||||
|
||||
# --- AC1: before 01:05 → today not settled → fixed_costs/credits == 0 ---
|
||||
|
||||
def test_today_window_before_offset_yields_zero(self, energy_db: Session) -> None:
|
||||
"""AC1: At local 00:30 (before 01:05), today's window → credits == 0 and fixed_costs == 0.
|
||||
|
||||
Window: [June 26 AMS midnight, June 27 AMS midnight).
|
||||
now = June 26 00:30 AMS (before 01:05) → settled_cap = June 25.
|
||||
first_counted = June 26 > last_counted = June 25 → 0 days.
|
||||
"""
|
||||
self._setup_single_version_contract(energy_db)
|
||||
start = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
|
||||
end = _ams_midnight(2026, 6, 27) # June 26 22:00 UTC
|
||||
|
||||
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
|
||||
|
||||
assert result["fixed_costs"] == 0.0, (
|
||||
"AC1: before settlement offset, today's fixed_costs must be 0; "
|
||||
f"got {result['fixed_costs']}"
|
||||
)
|
||||
assert result["credits"] == 0.0, (
|
||||
"AC1: before settlement offset, today's credits must be 0; "
|
||||
f"got {result['credits']}"
|
||||
)
|
||||
|
||||
# --- AC2: >= 01:05 → today settled → 1 day fixed/credits ---
|
||||
|
||||
def test_today_window_after_offset_yields_one_day(self, energy_db: Session) -> None:
|
||||
"""AC2: At local 01:10 (>= 01:05), today's window → 1 day of fixed/credits.
|
||||
|
||||
Window: [June 26 AMS midnight, June 27 AMS midnight).
|
||||
now = June 26 01:10 AMS (after 01:05) → settled_cap = June 26.
|
||||
first_counted = June 26 = last_counted → 1 day.
|
||||
"""
|
||||
self._setup_single_version_contract(energy_db)
|
||||
start = _ams_midnight(2026, 6, 26)
|
||||
end = _ams_midnight(2026, 6, 27)
|
||||
|
||||
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
assert abs(result["fixed_costs"] - daily_fixed) < 1e-9, (
|
||||
"AC2: after settlement offset, today's fixed_costs must equal 1 day; "
|
||||
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
|
||||
)
|
||||
assert abs(result["credits"] - daily_credit) < 1e-9, (
|
||||
"AC2: after settlement offset, today's credits must equal 1 day; "
|
||||
f"expected {daily_credit:.6f}, got {result['credits']}"
|
||||
)
|
||||
|
||||
# --- AC3a: cumulative window, before offset → today (June 26) not counted ---
|
||||
|
||||
def test_cumulative_before_offset_excludes_today(self, energy_db: Session) -> None:
|
||||
"""AC3: Cumulative window at 00:30 (before offset) excludes today from count.
|
||||
|
||||
Window: [June 24 AMS midnight, June 26 00:30 AMS).
|
||||
first_counted = June 24.
|
||||
last_counted (from window) = June 26 (lmu(June 26) = June 25 22:00 < 22:30).
|
||||
settled_cap = June 25 (before offset) → min(June 26, June 25) = June 25.
|
||||
Counted: June 24 + June 25 = 2 days (today June 26 excluded).
|
||||
"""
|
||||
self._setup_single_version_contract(energy_db)
|
||||
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
||||
end = self._NOW_BEFORE # June 25 22:30 UTC = June 26 00:30 AMS
|
||||
|
||||
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
|
||||
"AC3 before offset: today (June 26) must not be counted; expected 2 days; "
|
||||
f"got {result['fixed_costs']}"
|
||||
)
|
||||
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
|
||||
"AC3 before offset: credits must be 2 days; "
|
||||
f"got {result['credits']}"
|
||||
)
|
||||
|
||||
# --- AC3b: cumulative window, after offset → today (June 26) counted ---
|
||||
|
||||
def test_cumulative_after_offset_includes_today(self, energy_db: Session) -> None:
|
||||
"""AC3: Cumulative window at 01:10 (after offset) includes today.
|
||||
|
||||
Window: [June 24 AMS midnight, June 26 01:10 AMS).
|
||||
first_counted = June 24.
|
||||
last_counted (from window) = June 26 (lmu(June 26) < 23:10 UTC).
|
||||
settled_cap = June 26 (after offset) → last_counted = June 26.
|
||||
Counted: June 24 + June 25 + June 26 = 3 days.
|
||||
"""
|
||||
self._setup_single_version_contract(energy_db)
|
||||
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
||||
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
|
||||
|
||||
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
assert abs(result["fixed_costs"] - daily_fixed * 3) < 1e-9, (
|
||||
"AC3 after offset: today (June 26) must be counted; expected 3 days; "
|
||||
f"got {result['fixed_costs']}"
|
||||
)
|
||||
assert abs(result["credits"] - daily_credit * 3) < 1e-9, (
|
||||
"AC3 after offset: credits must be 3 days; "
|
||||
f"got {result['credits']}"
|
||||
)
|
||||
|
||||
# --- AC4: past days always fully counted regardless of offset ---
|
||||
|
||||
def test_past_days_always_counted_regardless_of_offset(self, energy_db: Session) -> None:
|
||||
"""AC4: Past days (all before today) are fully counted even before 01:05.
|
||||
|
||||
Window: [June 20 AMS midnight, June 26 AMS midnight) — all before today.
|
||||
now = June 26 00:30 AMS (before offset) → settled_cap = June 25.
|
||||
last_counted (from window) = June 25 (lmu(June 26) = end_utc, not <).
|
||||
min(June 25, June 25) = June 25 → 6 days (June 20-25), all past.
|
||||
"""
|
||||
self._setup_single_version_contract(energy_db)
|
||||
start = _ams_midnight(2026, 6, 20) # June 19 22:00 UTC
|
||||
end = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
|
||||
|
||||
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
# June 20-25 = 6 days, all past — unaffected by settlement offset.
|
||||
assert abs(result["fixed_costs"] - daily_fixed * 6) < 1e-9, (
|
||||
"AC4: past days must be fully counted regardless of settlement offset; "
|
||||
f"expected 6 days = {daily_fixed * 6:.6f}, got {result['fixed_costs']}"
|
||||
)
|
||||
assert abs(result["credits"] - daily_credit * 6) < 1e-9, (
|
||||
"AC4: past credits must be 6 days; "
|
||||
f"got {result['credits']}"
|
||||
)
|
||||
|
||||
# --- AC5: cross-version segments still correct with settlement offset ---
|
||||
|
||||
def test_cross_version_with_settlement_offset(self, energy_db: Session) -> None:
|
||||
"""AC5: Cross-version day split is correct when settlement offset is active.
|
||||
|
||||
V1 covers June 24; V2 covers June 25+.
|
||||
Window: [June 24 AMS midnight, June 26 01:10 AMS).
|
||||
After offset (01:10) → settled_cap = June 26 → 3 days: V1=1, V2=2.
|
||||
|
||||
V1: network_fee=6, management_fee=6 → daily_fixed = 12/30
|
||||
heffingskorting=300 → daily_credit = 300/365
|
||||
V2: network_fee=12, management_fee=12 → daily_fixed = 24/30
|
||||
heffingskorting=600 → daily_credit = 600/365
|
||||
|
||||
Expected:
|
||||
fixed_costs = 1×(12/30) + 2×(24/30) = 0.4 + 1.6 = 2.0
|
||||
credits = 1×(300/365) + 2×(600/365) = 1500/365
|
||||
"""
|
||||
_VALUES_V1 = {
|
||||
"energy": {"buy": {"normal": 0.10, "dal": 0.10},
|
||||
"sell": {"normal": 0.05, "dal": 0.05},
|
||||
"energy_tax": 0.0, "ode": 0.0},
|
||||
"standing": {"network_fee": 6.0, "management_fee": 6.0},
|
||||
"credits": {"heffingskorting": 300.0},
|
||||
}
|
||||
_VALUES_V2 = {
|
||||
"energy": {"buy": {"normal": 0.20, "dal": 0.20},
|
||||
"sell": {"normal": 0.08, "dal": 0.08},
|
||||
"energy_tax": 0.0, "ode": 0.0},
|
||||
"standing": {"network_fee": 12.0, "management_fee": 12.0},
|
||||
"credits": {"heffingskorting": 600.0},
|
||||
}
|
||||
v1_from = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
||||
v2_from = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
|
||||
|
||||
c = _make_contract(energy_db, kind="manual", active=True)
|
||||
_make_version(energy_db, c, _VALUES_V1, effective_from=v1_from, effective_to=v2_from)
|
||||
_make_version(energy_db, c, _VALUES_V2, effective_from=v2_from)
|
||||
energy_db.commit()
|
||||
|
||||
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
||||
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
|
||||
|
||||
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
||||
|
||||
expected_fixed = 1 * 12 / 30 + 2 * 24 / 30 # V1: 1 day, V2: 2 days
|
||||
expected_credits = 1 * 300 / 365 + 2 * 600 / 365
|
||||
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
|
||||
f"AC5: cross-version fixed_costs wrong; expected {expected_fixed}, "
|
||||
f"got {result['fixed_costs']}"
|
||||
)
|
||||
assert abs(result["credits"] - expected_credits) < 1e-9, (
|
||||
f"AC5: cross-version credits wrong; expected {expected_credits}, "
|
||||
f"got {result['credits']}"
|
||||
)
|
||||
|
||||
# --- AC6: return dict key-set unchanged ---
|
||||
|
||||
def test_return_dict_keys_unchanged(self, energy_db: Session) -> None:
|
||||
"""AC6: summarize() return dict keys are unchanged by the settlement offset."""
|
||||
self._setup_single_version_contract(energy_db)
|
||||
start = _ams_midnight(2026, 6, 26)
|
||||
end = _ams_midnight(2026, 6, 27)
|
||||
|
||||
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
||||
|
||||
expected_keys = {
|
||||
"currency", "metered_import", "metered_export", "metered_net",
|
||||
"fixed_costs", "credits", "total_payable", "period_count",
|
||||
"degraded_count", "days",
|
||||
}
|
||||
assert set(result.keys()) == expected_keys, (
|
||||
f"AC6: summarize() key set changed; expected {expected_keys}, "
|
||||
f"got {set(result.keys())}"
|
||||
)
|
||||
|
||||
+236
-19
@@ -1729,10 +1729,13 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
|
||||
"""FU11: import_cost_today getter returns value for today's local window.
|
||||
|
||||
Setup: period at local 'today' (within today's UTC window), active contract.
|
||||
Timezone pinned to UTC. Verifies: value = metered_import + fixed_costs for 1 day
|
||||
Timezone pinned to UTC, local_now pinned to 2026-06-26 12:00:00 UTC (deterministic midday).
|
||||
Verifies: value = metered_import + fixed_costs for 1 day
|
||||
(Principle C: today counts as elapsed if we're past local midnight — which UTC is).
|
||||
|
||||
FUE-T05: provider requires an active electricity meter.
|
||||
FUE-T09: local_now pinned (not real time) to eliminate the ~5s flaky window introduced
|
||||
by the grace period: (local_now()-5s).date() is unpredictable near UTC 00:00:00–00:00:04.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
@@ -1740,17 +1743,16 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
|
||||
from app.integrations.expose import build_catalog
|
||||
from app.services import timezone as _tz_mod
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
# Place a period in today's window (1 hour ago, well within today UTC midnight→tomorrow)
|
||||
# When TZ=UTC, local today = UTC today, so a period 1h ago is in today's window.
|
||||
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
# Ensure t0 is still today UTC (handle edge case where now is < 1h past UTC midnight)
|
||||
if t0.date() < now_utc.date():
|
||||
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
# Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always.
|
||||
fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
|
||||
today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight).
|
||||
t0 = fake_now.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Contract starting well before today
|
||||
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
||||
meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
||||
effective_from = today_midnight - timedelta(days=30)
|
||||
meter_start = today_midnight - timedelta(days=30)
|
||||
|
||||
import_cost_today = 1.23
|
||||
|
||||
@@ -1766,8 +1768,12 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
# Pin timezone to UTC so today_local = UTC date (deterministic on any CI host).
|
||||
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||
# Pin both local_tz (UTC) and local_now (deterministic midday) so the today window
|
||||
# is always [2026-06-26 00:00:00 UTC, 2026-06-27 00:00:00 UTC), regardless of CI clock.
|
||||
with (
|
||||
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
|
||||
patch.object(_tz_mod, "local_now", return_value=fake_now),
|
||||
):
|
||||
catalog = build_catalog(session)
|
||||
today_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
||||
@@ -1789,6 +1795,8 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None:
|
||||
"""FU11: export_revenue_today getter returns value for today's local window.
|
||||
|
||||
FUE-T05: provider requires an active electricity meter.
|
||||
FUE-T09: local_now pinned (not real time) to eliminate the ~5s flaky window introduced
|
||||
by the grace period: (local_now()-5s).date() is unpredictable near UTC 00:00:00–00:00:04.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
@@ -1796,13 +1804,15 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None:
|
||||
from app.integrations.expose import build_catalog
|
||||
from app.services import timezone as _tz_mod
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
if t0.date() < now_utc.date():
|
||||
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
# Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always.
|
||||
fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
|
||||
today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
||||
meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
||||
# Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight).
|
||||
t0 = fake_now.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||
|
||||
effective_from = today_midnight - timedelta(days=30)
|
||||
meter_start = today_midnight - timedelta(days=30)
|
||||
export_today = 0.75
|
||||
|
||||
with Session(energy_db) as session:
|
||||
@@ -1817,7 +1827,12 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None:
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||
# Pin both local_tz (UTC) and local_now (deterministic midday) so the today window
|
||||
# is always [2026-06-26 00:00:00 UTC, 2026-06-27 00:00:00 UTC), regardless of CI clock.
|
||||
with (
|
||||
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
|
||||
patch.object(_tz_mod, "local_now", return_value=fake_now),
|
||||
):
|
||||
catalog = build_catalog(session)
|
||||
today_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.export_revenue_today"
|
||||
@@ -2468,3 +2483,205 @@ def test_energy_cost_identifiers_change_after_meter_swap(energy_db) -> None:
|
||||
assert entity.device.identifiers[1] != old_uuid, (
|
||||
f"After swap, old uuid {old_uuid!r} must not appear in identifiers"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FUE-T09: *_today grace — window selection at / around local midnight
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Strategy: patch local_now + local_tz (to UTC) and spy on summarize to capture
|
||||
# the start/end arguments. Verifies that the 5-second grace means:
|
||||
# - 00:00:03 → today_local = yesterday (grace not yet elapsed)
|
||||
# - 00:00:12 → today_local = today (grace elapsed)
|
||||
# - 12:00:00 → today_local = today (midday, unaffected)
|
||||
|
||||
|
||||
def _spy_summarize_factory(captured: dict):
|
||||
"""Return a summarize spy that records start/end and returns a zero-value dict."""
|
||||
def _spy(sess, start, end):
|
||||
captured["start"] = start
|
||||
captured["end"] = end
|
||||
return {"metered_import": 0.0, "fixed_costs": 0.0, "metered_export": 0.0, "credits": 0.0}
|
||||
return _spy
|
||||
|
||||
|
||||
def _setup_grace_db(energy_db) -> None:
|
||||
"""Insert active meter + active contract + one non-degraded period for grace tests."""
|
||||
anchor = datetime(2026, 6, 25, 0, 0, 0, tzinfo=timezone.utc)
|
||||
with Session(energy_db) as session:
|
||||
_make_active_meter(session, started_at=anchor, label="Grace Test Meter")
|
||||
_make_contract_with_version(
|
||||
session,
|
||||
values=_STANDING_VALUES,
|
||||
effective_from=anchor,
|
||||
active=True,
|
||||
)
|
||||
_make_period(session, period_start=anchor, import_cost=1.0, export_revenue=0.5, degraded=False)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_today_getter_grace_uses_yesterday_window_at_00_00_03(energy_db) -> None:
|
||||
"""FUE-T09 AC1a: at local 00:00:03 (< grace=5s), both today getters use YESTERDAY's window.
|
||||
|
||||
With grace=5s, (local_now() - 5s) = 2026-06-25 23:59:58, so today_local = 2026-06-25
|
||||
(yesterday). summarize must be called with start = 2026-06-25 00:00:00 UTC.
|
||||
Verifies both import and export today getters.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
from app.integrations.expose import build_catalog
|
||||
from app.services import timezone as _tz_mod
|
||||
|
||||
_setup_grace_db(energy_db)
|
||||
|
||||
# 2026-06-26 00:00:03 UTC — 3 seconds past midnight, still within 5-second grace.
|
||||
fake_now = datetime(2026, 6, 26, 0, 0, 3, tzinfo=timezone.utc)
|
||||
|
||||
captured_import: dict = {}
|
||||
captured_export: dict = {}
|
||||
|
||||
with Session(energy_db) as session:
|
||||
with (
|
||||
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
|
||||
patch.object(_tz_mod, "local_now", return_value=fake_now),
|
||||
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_import)),
|
||||
):
|
||||
catalog = build_catalog(session)
|
||||
import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_today")
|
||||
import_entry.entity.value_getter(session)
|
||||
|
||||
# Reset and test export getter (separate patch context so we get a fresh spy dict)
|
||||
captured_export = {}
|
||||
with Session(energy_db) as session:
|
||||
with (
|
||||
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
|
||||
patch.object(_tz_mod, "local_now", return_value=fake_now),
|
||||
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_export)),
|
||||
):
|
||||
catalog = build_catalog(session)
|
||||
export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_today")
|
||||
export_entry.entity.value_getter(session)
|
||||
|
||||
# Grace not elapsed: today_local = 2026-06-25 (yesterday), so window starts there.
|
||||
expected_start = datetime(2026, 6, 25, 0, 0, 0, tzinfo=timezone.utc)
|
||||
assert captured_import.get("start") == expected_start, (
|
||||
f"import_cost_today at 00:00:03: window start must be yesterday ({expected_start}), "
|
||||
f"got {captured_import.get('start')}"
|
||||
)
|
||||
assert captured_export.get("start") == expected_start, (
|
||||
f"export_revenue_today at 00:00:03: window start must be yesterday ({expected_start}), "
|
||||
f"got {captured_export.get('start')}"
|
||||
)
|
||||
|
||||
|
||||
def test_today_getter_grace_uses_today_window_at_00_00_12(energy_db) -> None:
|
||||
"""FUE-T09 AC1b: at local 00:00:12 (> grace=5s), both today getters use TODAY's window.
|
||||
|
||||
With grace=5s, (local_now() - 5s) = 2026-06-26 00:00:07, so today_local = 2026-06-26
|
||||
(today). summarize must be called with start = 2026-06-26 00:00:00 UTC.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
from app.integrations.expose import build_catalog
|
||||
from app.services import timezone as _tz_mod
|
||||
|
||||
_setup_grace_db(energy_db)
|
||||
|
||||
# 2026-06-26 00:00:12 UTC — 12 seconds past midnight, beyond the 5-second grace.
|
||||
fake_now = datetime(2026, 6, 26, 0, 0, 12, tzinfo=timezone.utc)
|
||||
|
||||
captured_import: dict = {}
|
||||
captured_export: dict = {}
|
||||
|
||||
with Session(energy_db) as session:
|
||||
with (
|
||||
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
|
||||
patch.object(_tz_mod, "local_now", return_value=fake_now),
|
||||
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_import)),
|
||||
):
|
||||
catalog = build_catalog(session)
|
||||
import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_today")
|
||||
import_entry.entity.value_getter(session)
|
||||
|
||||
captured_export = {}
|
||||
with Session(energy_db) as session:
|
||||
with (
|
||||
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
|
||||
patch.object(_tz_mod, "local_now", return_value=fake_now),
|
||||
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_export)),
|
||||
):
|
||||
catalog = build_catalog(session)
|
||||
export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_today")
|
||||
export_entry.entity.value_getter(session)
|
||||
|
||||
# Grace elapsed: today_local = 2026-06-26 (today), window starts there.
|
||||
expected_start = datetime(2026, 6, 26, 0, 0, 0, tzinfo=timezone.utc)
|
||||
assert captured_import.get("start") == expected_start, (
|
||||
f"import_cost_today at 00:00:12: window start must be today ({expected_start}), "
|
||||
f"got {captured_import.get('start')}"
|
||||
)
|
||||
assert captured_export.get("start") == expected_start, (
|
||||
f"export_revenue_today at 00:00:12: window start must be today ({expected_start}), "
|
||||
f"got {captured_export.get('start')}"
|
||||
)
|
||||
|
||||
|
||||
def test_today_getter_grace_unaffected_at_midday(energy_db) -> None:
|
||||
"""FUE-T09 AC2: at 12:00, grace doesn't shift the window — today_local still = today.
|
||||
|
||||
Well past midnight, (local_now() - 5s).date() == local_now().date() always.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
from app.integrations.expose import build_catalog
|
||||
from app.services import timezone as _tz_mod
|
||||
|
||||
_setup_grace_db(energy_db)
|
||||
|
||||
# 2026-06-26 12:00:00 UTC — midday.
|
||||
fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
with Session(energy_db) as session:
|
||||
with (
|
||||
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
|
||||
patch.object(_tz_mod, "local_now", return_value=fake_now),
|
||||
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured)),
|
||||
):
|
||||
catalog = build_catalog(session)
|
||||
import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_today")
|
||||
import_entry.entity.value_getter(session)
|
||||
|
||||
# At 12:00 the grace (5s) does not push the date back: today_local = 2026-06-26.
|
||||
expected_start = datetime(2026, 6, 26, 0, 0, 0, tzinfo=timezone.utc)
|
||||
assert captured.get("start") == expected_start, (
|
||||
f"import_cost_today at 12:00: window start must be today ({expected_start}), "
|
||||
f"got {captured.get('start')}"
|
||||
)
|
||||
|
||||
|
||||
def test_midnight_state_publish_no_raise_when_mqtt_disabled() -> None:
|
||||
"""FUE-T09 AC3: _run_midnight_state_publish is safe when MQTT is disabled.
|
||||
|
||||
_run_midnight_state_publish delegates to publish_states, which is internally
|
||||
guarded (_should_publish returns False when MQTT is disabled or not connected).
|
||||
This test verifies publish_states itself doesn't raise under those conditions —
|
||||
the job's try/except swallows any other exception as well.
|
||||
"""
|
||||
settings = _make_settings(mqtt_enabled=False)
|
||||
mock_mgr = _make_mock_manager(is_connected=False)
|
||||
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
|
||||
):
|
||||
from app.services.ha_discovery import publish_states
|
||||
from sqlalchemy import create_engine as _ce
|
||||
from sqlalchemy.orm import Session as _S
|
||||
|
||||
eng = _ce("sqlite:///:memory:")
|
||||
with _S(eng) as sess:
|
||||
publish_states(sess) # must not raise
|
||||
|
||||
mock_mgr.publish.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user