feat(automations): weekday + timezone scheduling for time-of-day rule
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.)
This commit is contained in:
@@ -52,6 +52,8 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
|||||||
"time_of_day": lambda: TimeOfDayRule(
|
"time_of_day": lambda: TimeOfDayRule(
|
||||||
start_time=s.start_time or "00:00",
|
start_time=s.start_time or "00:00",
|
||||||
end_time=s.end_time or "23:59",
|
end_time=s.end_time or "23:59",
|
||||||
|
days_of_week=s.days_of_week or [],
|
||||||
|
timezone=s.timezone or "",
|
||||||
),
|
),
|
||||||
"system_idle": lambda: SystemIdleRule(
|
"system_idle": lambda: SystemIdleRule(
|
||||||
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ class RuleSchema(BaseModel):
|
|||||||
# Time-of-day rule fields
|
# Time-of-day rule fields
|
||||||
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
||||||
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
|
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
|
||||||
|
days_of_week: list[int] | None = Field(
|
||||||
|
None,
|
||||||
|
description="Active weekdays for time_of_day rule (0=Mon..6=Sun). Empty/null = every day.",
|
||||||
|
)
|
||||||
|
timezone: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="IANA timezone for time_of_day rule (e.g. 'Europe/Berlin'). Empty = server local.",
|
||||||
|
)
|
||||||
# System idle rule fields
|
# System idle rule fields
|
||||||
idle_minutes: int | None = Field(
|
idle_minutes: int | None = Field(
|
||||||
None, description="Idle timeout in minutes (for system_idle rule)"
|
None, description="Idle timeout in minutes (for system_idle rule)"
|
||||||
|
|||||||
@@ -26,6 +26,33 @@ from ledgrab.utils import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Cache resolved IANA timezones (and remember invalid names) so the ~1 Hz
|
||||||
|
# automation tick neither re-parses tzdata nor log-spams on a bad name.
|
||||||
|
_TZ_CACHE: Dict[str, object] = {}
|
||||||
|
_TZ_WARNED: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_in_tz(tz_name: str) -> datetime:
|
||||||
|
"""Current local time, in ``tz_name`` (IANA) if given, else the server's."""
|
||||||
|
if not tz_name:
|
||||||
|
return datetime.now()
|
||||||
|
tz = _TZ_CACHE.get(tz_name)
|
||||||
|
if tz is None:
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
_TZ_CACHE[tz_name] = tz
|
||||||
|
except Exception:
|
||||||
|
if tz_name not in _TZ_WARNED:
|
||||||
|
_TZ_WARNED.add(tz_name)
|
||||||
|
logger.warning(
|
||||||
|
"Invalid timezone %r for time-of-day rule; using server local time",
|
||||||
|
tz_name,
|
||||||
|
)
|
||||||
|
return datetime.now()
|
||||||
|
return datetime.now(tz)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _RuleEvalContext:
|
class _RuleEvalContext:
|
||||||
@@ -519,16 +546,26 @@ class AutomationEngine:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
||||||
now = datetime.now()
|
now = _now_in_tz(rule.timezone)
|
||||||
current = now.hour * 60 + now.minute
|
current = now.hour * 60 + now.minute
|
||||||
parts_s = rule.start_time.split(":")
|
parts_s = rule.start_time.split(":")
|
||||||
parts_e = rule.end_time.split(":")
|
parts_e = rule.end_time.split(":")
|
||||||
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
||||||
end = int(parts_e[0]) * 60 + int(parts_e[1])
|
end = int(parts_e[0]) * 60 + int(parts_e[1])
|
||||||
|
days = rule.days_of_week
|
||||||
|
|
||||||
if start <= end:
|
if start <= end:
|
||||||
return start <= current <= end
|
if not (start <= current <= end):
|
||||||
# Overnight range (e.g. 22:00 → 06:00)
|
return False
|
||||||
return current >= start or current <= end
|
return not days or now.weekday() in days
|
||||||
|
|
||||||
|
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
|
||||||
|
# START day, so the after-midnight tail is matched against yesterday.
|
||||||
|
if current >= start: # evening portion — today's window
|
||||||
|
return not days or now.weekday() in days
|
||||||
|
if current <= end: # early-morning portion — yesterday's window
|
||||||
|
return not days or ((now.weekday() - 1) % 7) in days
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
||||||
|
|||||||
@@ -152,6 +152,50 @@
|
|||||||
border-left: 1px solid var(--border-color);
|
border-left: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Weekday + timezone scheduling (time_of_day rule) */
|
||||||
|
.rule-weekday-block,
|
||||||
|
.rule-tz-block {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.rule-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.weekday-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.weekday-chip {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.weekday-chip:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.weekday-chip.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.rule-tz-block input.rule-timezone {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.time-range-label {
|
.time-range-label {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -340,11 +340,15 @@ const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
|
|||||||
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
|
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
|
||||||
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
|
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
|
||||||
},
|
},
|
||||||
time_of_day: (c) => ({
|
time_of_day: (c) => {
|
||||||
icon: ICON_CLOCK,
|
const days: number[] = Array.isArray(c.days_of_week) ? c.days_of_week : [];
|
||||||
text: `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`,
|
let text = `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`;
|
||||||
title: t('automations.rule.time_of_day'),
|
if (days.length && days.length < 7) {
|
||||||
}),
|
text += ` · ${[...days].sort((a, b) => a - b).map((d) => t('weekday.short.' + d)).join(' ')}`;
|
||||||
|
}
|
||||||
|
if (c.timezone) text += ` · ${c.timezone}`;
|
||||||
|
return { icon: ICON_CLOCK, text, title: t('automations.rule.time_of_day') };
|
||||||
|
},
|
||||||
system_idle: (c) => {
|
system_idle: (c) => {
|
||||||
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
|
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
|
||||||
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
|
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
|
||||||
@@ -878,6 +882,11 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
|
|||||||
const [sh, sm] = startTime.split(':').map(Number);
|
const [sh, sm] = startTime.split(':').map(Number);
|
||||||
const [eh, em] = endTime.split(':').map(Number);
|
const [eh, em] = endTime.split(':').map(Number);
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const days: number[] = Array.isArray(data.days_of_week) ? data.days_of_week : [];
|
||||||
|
const tz: string = data.timezone || '';
|
||||||
|
const dayChips = [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
.map((d) => `<button type="button" class="weekday-chip${days.includes(d) ? ' active' : ''}" data-day="${d}">${t('weekday.short.' + d)}</button>`)
|
||||||
|
.join('');
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="rule-fields">
|
<div class="rule-fields">
|
||||||
<input type="hidden" class="rule-start-time" value="${startTime}">
|
<input type="hidden" class="rule-start-time" value="${startTime}">
|
||||||
@@ -901,9 +910,21 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rule-weekday-block">
|
||||||
|
<span class="rule-field-label">${t('automations.rule.time_of_day.days')}</span>
|
||||||
|
<div class="weekday-chips">${dayChips}</div>
|
||||||
|
<small class="rule-hint-desc">${t('automations.rule.time_of_day.days_hint')}</small>
|
||||||
|
</div>
|
||||||
|
<div class="rule-tz-block">
|
||||||
|
<label class="rule-field-label">${t('automations.rule.time_of_day.timezone')}</label>
|
||||||
|
<input type="text" class="rule-timezone" placeholder="${t('automations.rule.time_of_day.timezone.placeholder')}" value="${tz}">
|
||||||
|
</div>
|
||||||
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
|
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
|
||||||
</div>`;
|
</div>`;
|
||||||
_wireTimeRangePicker(container);
|
_wireTimeRangePicker(container);
|
||||||
|
container.querySelectorAll('.weekday-chip').forEach((chip) => {
|
||||||
|
chip.addEventListener('click', () => chip.classList.toggle('active'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
|
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
|
||||||
@@ -1314,6 +1335,9 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
|
|||||||
rule_type: 'time_of_day',
|
rule_type: 'time_of_day',
|
||||||
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
|
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
|
||||||
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
|
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
|
||||||
|
days_of_week: Array.from(row.querySelectorAll('.weekday-chip.active'))
|
||||||
|
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
|
||||||
|
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(),
|
||||||
}),
|
}),
|
||||||
system_idle: (row) => ({
|
system_idle: (row) => ({
|
||||||
rule_type: 'system_idle',
|
rule_type: 'system_idle',
|
||||||
|
|||||||
@@ -1235,6 +1235,17 @@
|
|||||||
"automations.rule.time_of_day.start_time": "Start Time:",
|
"automations.rule.time_of_day.start_time": "Start Time:",
|
||||||
"automations.rule.time_of_day.end_time": "End Time:",
|
"automations.rule.time_of_day.end_time": "End Time:",
|
||||||
"automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.",
|
"automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.",
|
||||||
|
"automations.rule.time_of_day.days": "Active days",
|
||||||
|
"automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.",
|
||||||
|
"automations.rule.time_of_day.timezone": "Timezone",
|
||||||
|
"automations.rule.time_of_day.timezone.placeholder": "Server local (e.g. Europe/Berlin)",
|
||||||
|
"weekday.short.0": "Mon",
|
||||||
|
"weekday.short.1": "Tue",
|
||||||
|
"weekday.short.2": "Wed",
|
||||||
|
"weekday.short.3": "Thu",
|
||||||
|
"weekday.short.4": "Fri",
|
||||||
|
"weekday.short.5": "Sat",
|
||||||
|
"weekday.short.6": "Sun",
|
||||||
"automations.rule.system_idle": "System Idle",
|
"automations.rule.system_idle": "System Idle",
|
||||||
"automations.rule.system_idle.desc": "User idle/active",
|
"automations.rule.system_idle.desc": "User idle/active",
|
||||||
"automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):",
|
"automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):",
|
||||||
|
|||||||
@@ -1269,6 +1269,17 @@
|
|||||||
"automations.rule.time_of_day.start_time": "Время начала:",
|
"automations.rule.time_of_day.start_time": "Время начала:",
|
||||||
"automations.rule.time_of_day.end_time": "Время окончания:",
|
"automations.rule.time_of_day.end_time": "Время окончания:",
|
||||||
"automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
|
"automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
|
||||||
|
"automations.rule.time_of_day.days": "Активные дни",
|
||||||
|
"automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.",
|
||||||
|
"automations.rule.time_of_day.timezone": "Часовой пояс",
|
||||||
|
"automations.rule.time_of_day.timezone.placeholder": "Локальное время сервера (напр. Europe/Berlin)",
|
||||||
|
"weekday.short.0": "Пн",
|
||||||
|
"weekday.short.1": "Вт",
|
||||||
|
"weekday.short.2": "Ср",
|
||||||
|
"weekday.short.3": "Чт",
|
||||||
|
"weekday.short.4": "Пт",
|
||||||
|
"weekday.short.5": "Сб",
|
||||||
|
"weekday.short.6": "Вс",
|
||||||
"automations.rule.system_idle": "Бездействие системы",
|
"automations.rule.system_idle": "Бездействие системы",
|
||||||
"automations.rule.system_idle.desc": "Бездействие/активность",
|
"automations.rule.system_idle.desc": "Бездействие/активность",
|
||||||
"automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
|
"automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
|
||||||
|
|||||||
@@ -1265,6 +1265,17 @@
|
|||||||
"automations.rule.time_of_day.start_time": "开始时间:",
|
"automations.rule.time_of_day.start_time": "开始时间:",
|
||||||
"automations.rule.time_of_day.end_time": "结束时间:",
|
"automations.rule.time_of_day.end_time": "结束时间:",
|
||||||
"automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
|
"automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
|
||||||
|
"automations.rule.time_of_day.days": "生效日期",
|
||||||
|
"automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。",
|
||||||
|
"automations.rule.time_of_day.timezone": "时区",
|
||||||
|
"automations.rule.time_of_day.timezone.placeholder": "服务器本地时间(如 Europe/Berlin)",
|
||||||
|
"weekday.short.0": "周一",
|
||||||
|
"weekday.short.1": "周二",
|
||||||
|
"weekday.short.2": "周三",
|
||||||
|
"weekday.short.3": "周四",
|
||||||
|
"weekday.short.4": "周五",
|
||||||
|
"weekday.short.5": "周六",
|
||||||
|
"weekday.short.6": "周日",
|
||||||
"automations.rule.system_idle": "系统空闲",
|
"automations.rule.system_idle": "系统空闲",
|
||||||
"automations.rule.system_idle.desc": "空闲/活跃",
|
"automations.rule.system_idle.desc": "空闲/活跃",
|
||||||
"automations.rule.system_idle.idle_minutes": "空闲超时(分钟):",
|
"automations.rule.system_idle.idle_minutes": "空闲超时(分钟):",
|
||||||
|
|||||||
@@ -65,27 +65,40 @@ class ApplicationRule(Rule):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TimeOfDayRule(Rule):
|
class TimeOfDayRule(Rule):
|
||||||
"""Activate during a specific time range (server local time).
|
"""Activate during a specific time range.
|
||||||
|
|
||||||
Supports overnight ranges: if start_time > end_time, the range wraps
|
Supports overnight ranges: if start_time > end_time, the range wraps
|
||||||
around midnight (e.g. 22:00 → 06:00).
|
around midnight (e.g. 22:00 → 06:00) — an overnight window belongs to the
|
||||||
|
day it *starts* on. ``days_of_week`` (0=Mon .. 6=Sun, empty = every day)
|
||||||
|
restricts which days the window is active. ``timezone`` is an IANA name
|
||||||
|
(e.g. "Europe/Berlin"); empty = the server's local time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
rule_type: str = "time_of_day"
|
rule_type: str = "time_of_day"
|
||||||
start_time: str = "00:00" # HH:MM
|
start_time: str = "00:00" # HH:MM
|
||||||
end_time: str = "23:59" # HH:MM
|
end_time: str = "23:59" # HH:MM
|
||||||
|
days_of_week: List[int] = field(default_factory=list) # 0=Mon..6=Sun; empty=all days
|
||||||
|
timezone: str = "" # IANA tz name; empty = server local time
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["start_time"] = self.start_time
|
d["start_time"] = self.start_time
|
||||||
d["end_time"] = self.end_time
|
d["end_time"] = self.end_time
|
||||||
|
d["days_of_week"] = self.days_of_week
|
||||||
|
d["timezone"] = self.timezone
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "TimeOfDayRule":
|
def from_dict(cls, data: dict) -> "TimeOfDayRule":
|
||||||
|
raw_days = data.get("days_of_week") or []
|
||||||
|
days = sorted(
|
||||||
|
{int(d) for d in raw_days if isinstance(d, (int, float)) and 0 <= int(d) <= 6}
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
start_time=data.get("start_time", "00:00"),
|
start_time=data.get("start_time", "00:00"),
|
||||||
end_time=data.get("end_time", "23:59"),
|
end_time=data.get("end_time", "23:59"),
|
||||||
|
days_of_week=days,
|
||||||
|
timezone=data.get("timezone", "") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user