Files
butubb 8f4e5586e9
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(schedule): 任务支持「每天定时 / 每周定时」,用选择器而不是手写 cron
- schedule.py: 新增调度计算模块(纯函数,便于单测)。三种模式:interval(每 N 分钟)/ daily(选钟点)/ weekly(选星期 + 钟点)
- 钟点模式是「固定时刻」而非「固定延迟」——从日历重算,所以某轮跑晚了不会把之后每一轮都拖晚。interval 保持原语义:从上一轮开始计时
- 抖动只加给 interval。给「每天 9:00」也加抖动就成了 9:00–9:01 随机触发,操作者选的时间被悄悄改掉,只会像 bug
- 模型 / schema / service: 新增 schedule_mode / schedule_hours / schedule_days / schedule_minute。时钟字段存逗号分隔文本——几个小整数、永远整体读写,单开一张表只会换来 join。interval_minutes 保留且仍是默认值,已有任务不受影响
- service: 改动任何调度字段都按合并后的状态重算 next_run_at。重新启用也算改动,否则停用一个月再打开会带着一个月前的 next_run_at,一保存就立即触发
- 前端: 运行方式三选一 + 小时/星期胶囊多选 + 分钟下拉,并实时预览结果句子。任务卡片改显示后端拼好的 schedule_label,避免列表和编辑器对同一计划给出两种说法
- 校验: 钟点模式至少选一个时间,按周至少选一个星期

tests/test_schedule.py 新增 24 个用例,含「恰好等于当前时刻的档位归属下一天」这个会让调度器自循环的边界。
2026-10-07 15:33:17 +08:00

195 lines
5.3 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for monitor task schedule arithmetic.
Everything here is timezone-local, matching the implementation: the container is
pinned to the operator's zone via TZ, so the tests build their expectations from
naive local datetimes too and stay correct wherever they run.
"""
from datetime import datetime, time, timedelta
import pytest
from api.monitor import schedule
def _ms(moment: datetime) -> int:
return int(moment.timestamp() * 1000)
def test_interval_is_now_plus_the_interval():
after = _ms(datetime(2026, 10, 7, 9, 0))
nxt = schedule.next_occurrence(
mode=schedule.MODE_INTERVAL,
interval_minutes=120,
hours=[],
days=[],
minute=0,
after_ms=after,
)
assert nxt == after + 120 * 60_000
def test_daily_takes_the_soonest_remaining_time_today():
after = _ms(datetime(2026, 10, 7, 8, 0))
nxt = schedule.next_occurrence(
mode=schedule.MODE_DAILY,
interval_minutes=60,
hours=[18, 9], # deliberately unsorted
days=[],
minute=30,
after_ms=after,
)
assert nxt == _ms(datetime(2026, 10, 7, 9, 30))
def test_daily_rolls_over_to_tomorrow_once_every_time_has_passed():
after = _ms(datetime(2026, 10, 7, 20, 0))
nxt = schedule.next_occurrence(
mode=schedule.MODE_DAILY,
interval_minutes=60,
hours=[9, 18],
days=[],
minute=30,
after_ms=after,
)
assert nxt == _ms(datetime(2026, 10, 8, 9, 30))
def test_a_slot_exactly_now_belongs_to_the_next_day():
"""Strictly-after, so the run that just fired does not fire again.
The scheduler advances with ``after_ms`` set to the moment the run started,
which is at or just past the slot -- if the comparison were inclusive it would
pick the same slot back up and loop.
"""
after = _ms(datetime(2026, 10, 7, 9, 30))
nxt = schedule.next_occurrence(
mode=schedule.MODE_DAILY,
interval_minutes=60,
hours=[9],
days=[],
minute=30,
after_ms=after,
)
assert nxt == _ms(datetime(2026, 10, 8, 9, 30))
def test_weekly_jumps_to_the_next_selected_weekday():
after_dt = datetime(2026, 10, 7, 8, 0)
target = (after_dt.weekday() + 2) % 7
nxt = schedule.next_occurrence(
mode=schedule.MODE_WEEKLY,
interval_minutes=60,
hours=[10],
days=[target],
minute=0,
after_ms=_ms(after_dt),
)
expected = datetime.combine((after_dt + timedelta(days=2)).date(), time(10, 0))
assert nxt == _ms(expected)
def test_weekly_can_fire_later_the_same_day():
after_dt = datetime(2026, 10, 7, 8, 0)
nxt = schedule.next_occurrence(
mode=schedule.MODE_WEEKLY,
interval_minutes=60,
hours=[21],
days=[after_dt.weekday()],
minute=15,
after_ms=_ms(after_dt),
)
assert nxt == _ms(datetime.combine(after_dt.date(), time(21, 15)))
def test_weekly_without_weekdays_means_every_day():
"""Otherwise an empty day selection would match nothing and never fire."""
after = _ms(datetime(2026, 10, 7, 8, 0))
nxt = schedule.next_occurrence(
mode=schedule.MODE_WEEKLY,
interval_minutes=60,
hours=[9],
days=[],
minute=0,
after_ms=after,
)
assert nxt == _ms(datetime(2026, 10, 7, 9, 0))
def test_a_clock_schedule_with_no_times_can_never_fire():
"""Returned as None so the caller can park the task instead of leaving it due."""
nxt = schedule.next_occurrence(
mode=schedule.MODE_DAILY,
interval_minutes=60,
hours=[],
days=[],
minute=0,
after_ms=_ms(datetime(2026, 10, 7, 8, 0)),
)
assert nxt is None
@pytest.mark.parametrize(
"raw, expected",
[
("9,18", [9, 18]),
("18,9", [9, 18]), # stored order is not guaranteed
("9,9,9", [9]),
("", []),
(None, []),
("9, 18 ", [9, 18]),
("9,99,-1,abc,", [9]), # junk is dropped, never raised
],
)
def test_parse_hours_is_forgiving(raw, expected):
assert schedule.parse_hours(raw) == expected
def test_parse_days_accepts_the_whole_week():
assert schedule.parse_days("0,1,2,3,4,5,6") == [0, 1, 2, 3, 4, 5, 6]
assert schedule.parse_days("7,-1") == []
@pytest.mark.parametrize(
"mode, interval, hours, days, minute, expected",
[
(schedule.MODE_INTERVAL, 360, [], [], 0, "每 6 小时"),
(schedule.MODE_INTERVAL, 1440, [], [], 0, "每 1 天"),
(schedule.MODE_INTERVAL, 45, [], [], 0, "每 45 分钟"),
(schedule.MODE_DAILY, 60, [9, 18], [], 30, "每天 09:30、18:30"),
(schedule.MODE_WEEKLY, 60, [10], [0, 1, 2, 3, 4], 0, "周一、周二、周三、周四、周五 10:00"),
(schedule.MODE_WEEKLY, 60, [10], [], 0, "每天 10:00"),
(schedule.MODE_DAILY, 60, [], [], 0, "未设置时间"),
],
)
def test_describe(mode, interval, hours, days, minute, expected):
assert (
schedule.describe(
mode=mode, interval_minutes=interval, hours=hours, days=days, minute=minute
)
== expected
)
def test_format_round_trips_through_parse():
hours = [9, 12, 18]
days = [0, 4]
assert schedule.parse_hours(schedule.format_hours(hours)) == hours
assert schedule.parse_days(schedule.format_days(days)) == days