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