1ada5ac334
Extend the time-of-day condition from a bare server-local HH:MM window to a real schedule: pick which weekdays it is active (0=Mon..6=Sun, empty = every day) and an optional IANA timezone (empty = server local). Closes the parity gap where even a $5 WLED chip has weekday timers. - Overnight windows (start > end) count toward the day they START on, so the after-midnight tail is matched against the previous weekday. - Timezones are resolved via zoneinfo, cached, and fall back to server-local with a one-time warning on an invalid name (the ~1Hz tick never log-spams). - Backward compatible: new fields default to all-days / server-local, so existing automations are unchanged (no migration). - Frontend: weekday chips + timezone input on the rule editor, day/timezone in the rule summary, styles + i18n (en/ru/zh). 10 unit tests (weekday filter, overnight start-day semantics, tz fallback, round-trip, invalid-day filtering); full suite green (1936 passed). (Geographic sunrise/sunset triggers are a natural follow-up — the daylight value source already has the solar math to reuse.)
79 lines
2.8 KiB
Python
79 lines
2.8 KiB
Python
"""Tests for time-of-day automation scheduling (weekday + timezone + overnight)."""
|
|
|
|
import datetime as dt
|
|
|
|
from ledgrab.core.automations import automation_engine as ae
|
|
from ledgrab.core.automations.automation_engine import AutomationEngine, _now_in_tz
|
|
from ledgrab.storage.automation import TimeOfDayRule
|
|
|
|
_eval = AutomationEngine._evaluate_time_of_day
|
|
|
|
|
|
def _patch_now(monkeypatch, fixed: dt.datetime) -> None:
|
|
monkeypatch.setattr(ae, "_now_in_tz", lambda tz: fixed)
|
|
|
|
|
|
def test_within_window_every_day(monkeypatch):
|
|
_patch_now(monkeypatch, dt.datetime(2026, 6, 3, 20, 0))
|
|
assert _eval(TimeOfDayRule(start_time="18:00", end_time="23:00")) is True
|
|
|
|
|
|
def test_outside_window(monkeypatch):
|
|
_patch_now(monkeypatch, dt.datetime(2026, 6, 3, 12, 0))
|
|
assert _eval(TimeOfDayRule(start_time="18:00", end_time="23:00")) is False
|
|
|
|
|
|
def test_weekday_filter(monkeypatch):
|
|
fixed = dt.datetime(2026, 6, 3, 20, 0)
|
|
wd = fixed.weekday()
|
|
_patch_now(monkeypatch, fixed)
|
|
assert _eval(TimeOfDayRule("time_of_day", "18:00", "23:00", days_of_week=[wd])) is True
|
|
assert (
|
|
_eval(TimeOfDayRule("time_of_day", "18:00", "23:00", days_of_week=[(wd + 1) % 7])) is False
|
|
)
|
|
|
|
|
|
def test_overnight_evening_uses_today(monkeypatch):
|
|
fixed = dt.datetime(2026, 6, 3, 23, 0) # evening tail of a 22:00->06:00 window
|
|
wd = fixed.weekday()
|
|
_patch_now(monkeypatch, fixed)
|
|
assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[wd])) is True
|
|
assert (
|
|
_eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[(wd + 1) % 7])) is False
|
|
)
|
|
|
|
|
|
def test_overnight_morning_uses_yesterday(monkeypatch):
|
|
fixed = dt.datetime(2026, 6, 3, 3, 0) # morning tail belongs to yesterday's window
|
|
today = fixed.weekday()
|
|
yesterday = (today - 1) % 7
|
|
_patch_now(monkeypatch, fixed)
|
|
assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[yesterday])) is True
|
|
assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[today])) is False
|
|
|
|
|
|
def test_from_dict_filters_invalid_days():
|
|
rule = TimeOfDayRule.from_dict({"days_of_week": [0, 7, -1, 3, 3, "x", 2.0]})
|
|
assert rule.days_of_week == [0, 2, 3]
|
|
|
|
|
|
def test_to_dict_round_trips_new_fields():
|
|
rule = TimeOfDayRule("time_of_day", "08:00", "20:00", days_of_week=[1, 2], timezone="UTC")
|
|
d = rule.to_dict()
|
|
assert d["days_of_week"] == [1, 2]
|
|
assert d["timezone"] == "UTC"
|
|
again = TimeOfDayRule.from_dict(d)
|
|
assert again.days_of_week == [1, 2] and again.timezone == "UTC"
|
|
|
|
|
|
def test_now_in_tz_invalid_falls_back_to_local():
|
|
assert _now_in_tz("Not/AZone").tzinfo is None
|
|
|
|
|
|
def test_now_in_tz_valid_is_aware():
|
|
assert _now_in_tz("UTC").tzinfo is not None
|
|
|
|
|
|
def test_now_in_tz_empty_is_local():
|
|
assert _now_in_tz("").tzinfo is None
|