FUE-T09: grace-shift *_today daily reset past local midnight + dedicated 00:00:10 publish
This commit is contained in:
@@ -27,6 +27,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Any, Callable, Optional, Protocol
|
from typing import Any, Callable, Optional, Protocol
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -369,6 +370,10 @@ register_provider(_modbus_provider)
|
|||||||
# Energy Cost 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]:
|
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||||
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
||||||
@@ -735,7 +740,11 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Today's window in UTC, using monkeypatch-safe module attribute calls.
|
# 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)
|
tomorrow_local = today_local + _td(days=1)
|
||||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_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:
|
if not versions:
|
||||||
return None
|
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)
|
tomorrow_local = today_local + _td(days=1)
|
||||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_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.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from sqlalchemy.orm import Session
|
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.ha_discovery import publish_discovery, publish_states
|
||||||
from app.services.tibber_prices import refresh_prices
|
from app.services.tibber_prices import refresh_prices
|
||||||
from app.services.energy_cost import compute_closed_periods
|
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
|
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -159,6 +161,20 @@ def _run_scheduled_ha_state_publish() -> None:
|
|||||||
session.close()
|
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:
|
def ensure_auth_db_ready() -> None:
|
||||||
session_local = get_session_local()
|
session_local = get_session_local()
|
||||||
session: Session = session_local()
|
session: Session = session_local()
|
||||||
@@ -233,6 +249,17 @@ async def lifespan(_: FastAPI):
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
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()
|
scheduler.start()
|
||||||
|
|
||||||
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
||||||
|
|||||||
+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.
|
"""FU11: import_cost_today getter returns value for today's local window.
|
||||||
|
|
||||||
Setup: period at local 'today' (within today's UTC window), active contract.
|
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).
|
(Principle C: today counts as elapsed if we're past local midnight — which UTC is).
|
||||||
|
|
||||||
FUE-T05: provider requires an active electricity meter.
|
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 datetime import timedelta
|
||||||
from unittest.mock import patch
|
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.integrations.expose import build_catalog
|
||||||
from app.services import timezone as _tz_mod
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
# Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always.
|
||||||
# Place a period in today's window (1 hour ago, well within today UTC midnight→tomorrow)
|
fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
# When TZ=UTC, local today = UTC today, so a period 1h ago is in today's window.
|
today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
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)
|
# Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight).
|
||||||
if t0.date() < now_utc.date():
|
t0 = fake_now.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||||
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
# Contract starting well before today
|
# Contract starting well before today
|
||||||
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
effective_from = today_midnight - timedelta(days=30)
|
||||||
meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
meter_start = today_midnight - timedelta(days=30)
|
||||||
|
|
||||||
import_cost_today = 1.23
|
import_cost_today = 1.23
|
||||||
|
|
||||||
@@ -1766,8 +1768,12 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
# Pin timezone to UTC so today_local = UTC date (deterministic on any CI host).
|
# Pin both local_tz (UTC) and local_now (deterministic midday) so the today window
|
||||||
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
# 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)
|
catalog = build_catalog(session)
|
||||||
today_entry = next(
|
today_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
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.
|
"""FU11: export_revenue_today getter returns value for today's local window.
|
||||||
|
|
||||||
FUE-T05: provider requires an active electricity meter.
|
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 datetime import timedelta
|
||||||
from unittest.mock import patch
|
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.integrations.expose import build_catalog
|
||||||
from app.services import timezone as _tz_mod
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
# Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always.
|
||||||
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
if t0.date() < now_utc.date():
|
today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
t0 = now_utc.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)
|
# Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight).
|
||||||
meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
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
|
export_today = 0.75
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
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()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
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)
|
catalog = build_catalog(session)
|
||||||
today_entry = next(
|
today_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.export_revenue_today"
|
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, (
|
assert entity.device.identifiers[1] != old_uuid, (
|
||||||
f"After swap, old uuid {old_uuid!r} must not appear in identifiers"
|
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