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>
This commit is contained in:
2026-07-23 17:51:09 +08:00
parent d66f2c716c
commit 36085f107d
6 changed files with 232 additions and 0 deletions

57
tests/test_holiday.py Normal file
View File

@@ -0,0 +1,57 @@
"""Tests for daily.holiday."""
from __future__ import annotations
import unittest
from datetime import date
from daily.holiday import is_workday, workday_name
# 模拟 2026 节假日表1/1 元旦(休)1/4 元旦调休(补班)
HOLIDAYS = {
"2026-01-01": {"rest": True, "name": "元旦节"},
"2026-01-02": {"rest": True, "name": "元旦节"},
"2026-01-03": {"rest": True, "name": "元旦节"},
"2026-01-04": {"rest": False, "name": "元旦节调休"},
"2026-02-16": {"rest": True, "name": "春节"},
"2026-02-14": {"rest": False, "name": "春节调休"},
}
class IsWorkdayTests(unittest.TestCase):
def test_legal_holiday_is_rest(self):
# 2026-01-01 周四,元旦 → 休息
self.assertFalse(is_workday(date(2026, 1, 1), HOLIDAYS))
def test_tiaoxiu_makeup_day_is_workday(self):
# 2026-01-04 周日,调休补班 → 上班
self.assertEqual(date(2026, 1, 4).weekday(), 6)
self.assertTrue(is_workday(date(2026, 1, 4), HOLIDAYS))
def test_spring_festival_holiday(self):
self.assertFalse(is_workday(date(2026, 2, 16), HOLIDAYS))
def test_spring_festival_makeup_saturday(self):
# 2026-02-14 周六,调休 → 上班
self.assertEqual(date(2026, 2, 14).weekday(), 5)
self.assertTrue(is_workday(date(2026, 2, 14), HOLIDAYS))
def test_normal_weekday(self):
# 2026-07-23 周四,非节假日 → 上班
self.assertTrue(is_workday(date(2026, 7, 23), HOLIDAYS))
def test_normal_weekend(self):
# 2026-07-25 周六,非节假日 → 休息
self.assertFalse(is_workday(date(2026, 7, 25), HOLIDAYS))
class WorkdayNameTests(unittest.TestCase):
def test_holiday_name(self):
self.assertEqual(workday_name(date(2026, 1, 1), HOLIDAYS), "元旦节")
def test_normal_day_has_no_name(self):
self.assertIsNone(workday_name(date(2026, 7, 23), HOLIDAYS))
if __name__ == "__main__":
unittest.main()

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import unittest
from datetime import datetime
from unittest import mock
from zoneinfo import ZoneInfo
from daily.scheduler import (
@@ -12,6 +13,7 @@ from daily.scheduler import (
next_occurrence_after,
parse_hhmm,
plan_next_action,
tick_once,
)
@@ -116,3 +118,39 @@ class NextOccurrenceTests(unittest.TestCase):
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)