- daily/holiday.py: 抓取 xiaoai.me 全年节假日并缓存到 .cache/holidays-<year>.json, is_workday() 判定(法定节假日/周末休息,调休补班日上班),联网失败回退本地缓存 - scheduler: tick_once 前置工作日闸门,非工作日标记完成并跳过,判定失败降级为正常工作日避免漏跑 - config/.env.example: 新增 DAILY_WORKDAY_ONLY 开关(默认开) - tests: test_holiday 8 例 + scheduler 非工作日闸门 2 例 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
5.3 KiB
Python
157 lines
5.3 KiB
Python
"""Tests for daily.scheduler."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
from datetime import datetime
|
||
from unittest import mock
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from daily.scheduler import (
|
||
ClockTime,
|
||
SchedulerState,
|
||
next_occurrence_after,
|
||
parse_hhmm,
|
||
plan_next_action,
|
||
tick_once,
|
||
)
|
||
|
||
|
||
class ParseHhmmTests(unittest.TestCase):
|
||
def test_parse(self):
|
||
t = parse_hhmm("08:50")
|
||
self.assertEqual((t.hour, t.minute), (8, 50))
|
||
|
||
def test_invalid(self):
|
||
with self.assertRaises(ValueError):
|
||
parse_hhmm("25:00")
|
||
|
||
|
||
class PlanNextActionTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self.tz = ZoneInfo("Asia/Shanghai")
|
||
self.gen = ClockTime(8, 50)
|
||
self.push = ClockTime(9, 0)
|
||
|
||
def test_before_generate_waits_for_generate(self):
|
||
now = datetime(2026, 7, 9, 8, 30, tzinfo=self.tz)
|
||
run_at, action = plan_next_action(
|
||
now=now,
|
||
tz=self.tz,
|
||
state=SchedulerState(),
|
||
generate_at=self.gen,
|
||
push_at=self.push,
|
||
)
|
||
self.assertEqual(action, "generate")
|
||
self.assertEqual(run_at.hour, 8)
|
||
self.assertEqual(run_at.minute, 50)
|
||
|
||
def test_after_generate_before_push_waits_for_push(self):
|
||
now = datetime(2026, 7, 9, 8, 55, tzinfo=self.tz)
|
||
state = SchedulerState(last_generate_date="2026-07-09")
|
||
run_at, action = plan_next_action(
|
||
now=now,
|
||
tz=self.tz,
|
||
state=state,
|
||
generate_at=self.gen,
|
||
push_at=self.push,
|
||
)
|
||
self.assertEqual(action, "push")
|
||
self.assertEqual(run_at.hour, 9)
|
||
|
||
def test_catch_up_generate_when_started_late(self):
|
||
now = datetime(2026, 7, 9, 8, 55, tzinfo=self.tz)
|
||
run_at, action = plan_next_action(
|
||
now=now,
|
||
tz=self.tz,
|
||
state=SchedulerState(),
|
||
generate_at=self.gen,
|
||
push_at=self.push,
|
||
)
|
||
self.assertEqual(action, "generate")
|
||
self.assertEqual(run_at, now)
|
||
|
||
def test_next_day_after_both_done(self):
|
||
now = datetime(2026, 7, 9, 10, 0, tzinfo=self.tz)
|
||
state = SchedulerState(last_generate_date="2026-07-09", last_push_date="2026-07-09")
|
||
run_at, action = plan_next_action(
|
||
now=now,
|
||
tz=self.tz,
|
||
state=state,
|
||
generate_at=self.gen,
|
||
push_at=self.push,
|
||
)
|
||
self.assertEqual(action, "generate")
|
||
self.assertEqual(run_at.date().isoformat(), "2026-07-10")
|
||
|
||
def test_evening_start_waits_for_tomorrow_generate(self):
|
||
now = datetime(2026, 7, 9, 20, 35, tzinfo=self.tz)
|
||
run_at, action = plan_next_action(
|
||
now=now,
|
||
tz=self.tz,
|
||
state=SchedulerState(),
|
||
generate_at=self.gen,
|
||
push_at=self.push,
|
||
)
|
||
self.assertEqual(action, "generate")
|
||
self.assertEqual(run_at.date().isoformat(), "2026-07-10")
|
||
self.assertEqual((run_at.hour, run_at.minute), (8, 50))
|
||
|
||
def test_catch_up_push_when_generate_done(self):
|
||
now = datetime(2026, 7, 9, 20, 35, tzinfo=self.tz)
|
||
state = SchedulerState(last_generate_date="2026-07-09")
|
||
run_at, action = plan_next_action(
|
||
now=now,
|
||
tz=self.tz,
|
||
state=state,
|
||
generate_at=self.gen,
|
||
push_at=self.push,
|
||
)
|
||
self.assertEqual(action, "push")
|
||
self.assertEqual(run_at, now)
|
||
|
||
|
||
class NextOccurrenceTests(unittest.TestCase):
|
||
def test_tomorrow_when_past(self):
|
||
tz = ZoneInfo("Asia/Shanghai")
|
||
now = datetime(2026, 7, 9, 10, 0, tzinfo=tz)
|
||
nxt = next_occurrence_after(ClockTime(8, 50), tz, now)
|
||
self.assertEqual(nxt.date().isoformat(), "2026-07-10")
|
||
self.assertEqual((nxt.hour, nxt.minute), (8, 50))
|
||
|
||
|
||
class WorkdayGateTests(unittest.TestCase):
|
||
"""非工作日 tick_once 直接标记完成并跳过,不触发 generate。"""
|
||
|
||
def setUp(self) -> None:
|
||
self.tz = ZoneInfo("Asia/Shanghai")
|
||
self.gen = ClockTime(8, 50)
|
||
self.push = ClockTime(9, 0)
|
||
|
||
def _tick(self, day, workday):
|
||
now = datetime(day.year, day.month, day.day, 8, 30, tzinfo=self.tz)
|
||
holidays = {} if workday else {day.isoformat(): {"rest": True, "name": "测试假"}}
|
||
with mock.patch("daily.scheduler.load_holidays", return_value=holidays), \
|
||
mock.patch("daily.scheduler.run_scheduled_action") as run:
|
||
state = tick_once(
|
||
now=now, tz=self.tz, state=SchedulerState(),
|
||
generate_at=self.gen, push_at=self.push, dry_run=True,
|
||
)
|
||
return state, run
|
||
|
||
def test_holiday_skips_and_marks_done(self):
|
||
# 2026-07-25 是周六;强制为非工作日
|
||
from datetime import date
|
||
day = date(2026, 7, 25)
|
||
state, run = self._tick(day, workday=False)
|
||
self.assertFalse(run.called)
|
||
self.assertEqual(state.last_generate_date, "2026-07-25")
|
||
self.assertEqual(state.last_push_date, "2026-07-25")
|
||
|
||
def test_workday_proceeds(self):
|
||
from datetime import date
|
||
day = date(2026, 7, 23) # 周四,工作日
|
||
state, run = self._tick(day, workday=True)
|
||
# 工作日不提前标记完成(dry-run 不执行动作,故 last_generate_date 仍为 None)
|
||
self.assertIsNone(state.last_generate_date)
|