Files
daily-robots/tests/test_scheduler.py
yumao 36085f107d feat: 新增节假日缓存,仅工作日生成与推送
- 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>
2026-07-23 17:51:09 +08:00

157 lines
5.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)