Files
AgentLab/backend/tests/test_scheduler_cron.py
ThearchyHelios b66eb955b5 定时任务小修:数字按 isdecimal 认,输入生成失败的原因不再说成模板写错
- 「²」这类字符 isdigit 为真、int() 却认不得:写进 cron 或每页条数时会冒出英文的 ValueError(被全局处理器
  回成 400)。改按 isdecimal 认,照常回中文的 422
- 库里的 cron 或时区后来坏了时,立即运行记下的原因以前写「输入模板无法渲染」,其实模板没错:改成照实说
  「无法生成这次运行的输入」,后面跟是哪一项
2026-10-05 08:50:57 +08:00

246 lines
11 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.
"""定时任务的 cron(app/scheduler/cron.py):自己写的五字段解析、下次触发、中文描述。
- 五个字段(分 时 日 月 周):*、列表、范围、步长、月份和星期的英文缩写;周日写 0 或 7
- 日和周都受限时取「或」,有一个以 * 开头就只看另一个(和 Linux 系统的 cron 一致)
- 下次触发按任务时区的墙上时间算:夏令时跳过的本地时间不触发,重复的本地时间只在第一次出现时触发一次
- 写错的表达式给中文说明:哪个字段、允许的范围;永远不会触发的(2 月 30 日)也拦下
- 描述:每天 09:00、每周一 09:00、每月 1 日 08:30、每 2 小时……说不清的写「按 cron 表达式 x」,后面带时区
America/New_York 2026 年 3 月 8 日 2:00 拨快到 3:00(2:00–2:59 不存在),11 月 1 日 2:00 拨回 1:00(1:00–1:59 出现两次)。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from app.scheduler import cron
UTC = timezone.utc
NY = "America/New_York"
def utc(*args: int) -> datetime:
return datetime(*args, tzinfo=UTC)
def nexts(expr: str, tz: str, after: datetime, n: int = 5) -> list[datetime]:
return cron.preview(expr, tz, n, after=after)
# --------------------------------------------------------------------------
# 解析:*、列表、范围、步长、缩写
# --------------------------------------------------------------------------
def test_star_list_range_step():
spec = cron.parse("*/15 9-17 * * 1-5")
assert spec.minutes == (0, 15, 30, 45)
assert spec.hours == tuple(range(9, 18))
assert spec.weekdays == frozenset({1, 2, 3, 4, 5})
assert spec.days == frozenset(range(1, 32)) and spec.months == frozenset(range(1, 13))
def test_range_with_step_and_value_with_step():
assert cron.parse("10-50/20 * * * *").minutes == (10, 30, 50)
# 「5/20」是 5 到最大值、每 20 一个(Vixie cron 的写法)
assert cron.parse("5/20 * * * *").minutes == (5, 25, 45)
assert cron.parse("0 0,12 1,15 * *").hours == (0, 12)
def test_month_and_weekday_abbreviations_any_case():
spec = cron.parse("0 9 * jan,Jul,DEC mon-fri")
assert spec.months == frozenset({1, 7, 12})
assert spec.weekdays == frozenset({1, 2, 3, 4, 5})
# 周日写 0、7、SUN 都行;MON-SUN 是周一到周日
assert cron.parse("0 9 * * 7").weekdays == frozenset({0})
assert cron.parse("0 9 * * SUN,7").weekdays == frozenset({0})
assert cron.parse("0 9 * * MON-SUN").weekdays == frozenset(range(7))
assert cron.parse("0 9 * * 5-7").weekdays == frozenset({5, 6, 0})
def test_expression_is_normalized():
assert cron.parse(" 0 9 * *\t1 ").expr == "0 9 * * 1"
# --------------------------------------------------------------------------
# 写错的表达式:中文说明,指出哪个字段、允许的范围
# --------------------------------------------------------------------------
@pytest.mark.parametrize("expr, fragments", [
("", ["不能为空"]),
("* * * *", ["5 个字段", "4 个"]),
("0 9 * * 1 2026", ["5 个字段", "6 个"]),
("61 * * * *", ["「分」", "61", "0–59"]),
("0 24 * * *", ["「时」", "24", "0–23"]),
("0 0 0 * *", ["「日」", "1–31"]),
("0 0 * 13 *", ["「月」", "1–12"]),
("0 0 * * 8", ["「周」", "0–7"]),
("*/0 * * * *", ["「分」", "步长"]),
("*/x * * * *", ["「分」", "步长"]),
("0 20-10 * * *", ["「时」", "20-10", "起点大于终点"]),
("0 JAN * * *", ["「时」", "JAN", "缩写"]),
("0 9 * * L", ["「周」", "L"]),
("0 9 1,,2 * *", ["「日」"]),
("@daily", ["5 个字段"]),
("² * * * *", ["「分」", "²"]),
("*/² * * * *", ["「分」", "步长"]),
])
def test_invalid_expressions_explain_in_chinese(expr, fragments):
with pytest.raises(cron.CronError) as caught:
cron.parse(expr)
message = str(caught.value)
for fragment in fragments:
assert fragment in message, message
def test_invalid_expression_names_the_field():
with pytest.raises(cron.CronError) as caught:
cron.parse("0 25 * * *")
assert caught.value.field == "hour"
@pytest.mark.parametrize("expr", ["0 0 30 2 *", "0 0 31 4,6,9,11 *", "0 0 31 FEB *"])
def test_dates_that_never_exist_are_rejected(expr):
with pytest.raises(cron.CronError) as caught:
cron.parse(expr)
assert "永远不会触发" in str(caught.value)
def test_leap_day_is_not_never():
"""2 月 29 日四年一次,不算永远不会触发。日、周都受限时周那一边总会碰上,也不算"""
cron.parse("0 0 29 2 *")
cron.parse("0 0 30 2 1")
# --------------------------------------------------------------------------
# 下次触发
# --------------------------------------------------------------------------
def test_next_is_strictly_after():
assert cron.next_fire("0 9 * * *", "UTC", utc(2026, 10, 5, 9, 0)) == utc(2026, 10, 6, 9, 0)
assert cron.next_fire("0 9 * * *", "UTC", utc(2026, 10, 5, 8, 59, 59)) == utc(2026, 10, 5, 9, 0)
def test_next_follows_the_task_timezone():
# 上海 09:00 = UTC 01:00
assert cron.next_fire("0 9 * * *", "Asia/Shanghai", utc(2026, 10, 5, 2, 0)) == utc(2026, 10, 6, 1, 0)
def test_weekly_and_monthly():
# 2026-10-05 是周一
assert nexts("0 9 * * 1", "UTC", utc(2026, 10, 5, 9, 0), 2) == [utc(2026, 10, 12, 9), utc(2026, 10, 19, 9)]
assert nexts("30 8 1 * *", "UTC", utc(2026, 10, 5), 3) == [
utc(2026, 11, 1, 8, 30), utc(2026, 12, 1, 8, 30), utc(2027, 1, 1, 8, 30)]
def test_day_and_weekday_both_restricted_means_either():
"""每月 1 日或者每周一:11 月 1 日是周日,也触发"""
assert nexts("0 9 1 * 1", "UTC", utc(2026, 10, 5, 10), 5) == [
utc(2026, 10, 12, 9), utc(2026, 10, 19, 9), utc(2026, 10, 26, 9), utc(2026, 11, 1, 9), utc(2026, 11, 2, 9)]
def test_star_on_one_side_means_only_the_other():
assert nexts("0 9 * * 1", "UTC", utc(2026, 10, 25), 2) == [utc(2026, 10, 26, 9), utc(2026, 11, 2, 9)]
assert nexts("0 9 1 * *", "UTC", utc(2026, 10, 25), 2) == [utc(2026, 11, 1, 9), utc(2026, 12, 1, 9)]
# 以 * 开头的步长也算「没限定」(和 Linux 系统的 cron 一致):单数日里的周一,不是单数日或周一
assert nexts("0 9 */2 * 1", "UTC", utc(2026, 10, 1), 3) == [
utc(2026, 10, 5, 9), utc(2026, 10, 19, 9), utc(2026, 11, 9, 9)]
def test_leap_day_every_four_years():
assert cron.next_fire("0 0 29 2 *", "UTC", utc(2026, 1, 1)) == utc(2028, 2, 29)
assert cron.next_fire("0 0 29 2 *", "UTC", utc(2028, 2, 29)) == utc(2032, 2, 29)
# 按任务时区:上海的 2 月 29 日零点是 UTC 2 月 28 日 16:00
assert cron.next_fire("0 0 29 2 *", "Asia/Shanghai", utc(2026, 1, 1)) == utc(2028, 2, 28, 16)
def test_spring_forward_skips_the_missing_local_time():
"""3 月 8 日没有 02:30:那天不触发,下一次是 3 月 9 日 02:30(夏令时,UTC 06:30)"""
assert nexts("30 2 * * *", NY, utc(2026, 3, 7, 8), 2) == [utc(2026, 3, 9, 6, 30), utc(2026, 3, 10, 6, 30)]
# 每半小时:01:30(冬令时)之后是 03:00(夏令时),02:00、02:30 不存在
assert nexts("*/30 * * * *", NY, utc(2026, 3, 8, 6, 15), 3) == [
utc(2026, 3, 8, 6, 30), utc(2026, 3, 8, 7, 0), utc(2026, 3, 8, 7, 30)]
# 拨快之后的 03:00 照常触发
assert cron.next_fire("0 3 * * *", NY, utc(2026, 3, 8, 5)) == utc(2026, 3, 8, 7)
def test_fall_back_fires_the_repeated_local_time_once():
"""11 月 1 日 01:30 出现两次(UTC 05:30 夏令时、06:30 冬令时):只在第一次触发"""
assert nexts("30 1 * * *", NY, utc(2026, 11, 1, 4), 2) == [utc(2026, 11, 1, 5, 30), utc(2026, 11, 2, 6, 30)]
# 每半小时:01:00、01:30 各一次,第二遍的 01:00、01:30 不再触发,下一次是 02:00(冬令时,UTC 07:00)
assert nexts("*/30 * * * *", NY, utc(2026, 11, 1, 4, 45), 4) == [
utc(2026, 11, 1, 5, 0), utc(2026, 11, 1, 5, 30), utc(2026, 11, 1, 7, 0), utc(2026, 11, 1, 7, 30)]
# 从第二遍的 01:10(UTC 06:10)往后找:01:30 已经在第一遍触发过
assert cron.next_fire("*/30 * * * *", NY, utc(2026, 11, 1, 6, 10)) == utc(2026, 11, 1, 7, 0)
def test_previous_fire():
assert cron.prev_fire("0 9 * * 1", "UTC", utc(2026, 10, 5, 9)) == utc(2026, 9, 28, 9)
assert cron.prev_fire("0 9 * * 1", "UTC", utc(2026, 10, 5, 9, 0, 1)) == utc(2026, 10, 5, 9)
# 第二遍的 01:40 之前最近的一次是第一遍的 01:30(UTC 05:30),它已经发生过
assert cron.prev_fire("30 1 * * *", NY, utc(2026, 11, 1, 6, 40)) == utc(2026, 11, 1, 5, 30)
assert cron.prev_fire("30 2 * * *", NY, utc(2026, 3, 9, 0)) == utc(2026, 3, 7, 7, 30)
def test_last_due_at_or_before():
assert cron.last_due("*/5 * * * *", "UTC", utc(2026, 10, 5, 9, 7, 30)) == utc(2026, 10, 5, 9, 5)
assert cron.last_due("*/5 * * * *", "UTC", utc(2026, 10, 5, 9, 5)) == utc(2026, 10, 5, 9, 5)
def test_preview_counts_and_defaults_to_now():
out = cron.preview("*/10 * * * *", "UTC", 3)
assert len(out) == 3 and all(b - a == timedelta(minutes=10) for a, b in zip(out, out[1:]))
assert out[0] > datetime.now(UTC) - timedelta(seconds=1)
# --------------------------------------------------------------------------
# 时区
# --------------------------------------------------------------------------
def test_timezone_names_are_canonical():
assert cron.timezone_name("Asia/Shanghai") == "Asia/Shanghai"
assert cron.timezone_name(" asia/shanghai ") == "Asia/Shanghai"
assert cron.timezone_name("UTC") == "UTC"
@pytest.mark.parametrize("name", ["", "Mars/Olympus", "../etc/passwd", "GMT+8 北京"])
def test_unknown_timezone_explains_in_chinese(name):
with pytest.raises(cron.TimezoneError) as caught:
cron.timezone_name(name)
assert "时区" in str(caught.value) and "Asia/Shanghai" in str(caught.value)
# --------------------------------------------------------------------------
# 中文描述
# --------------------------------------------------------------------------
@pytest.mark.parametrize("expr, text", [
("0 9 * * *", "每天 09:00"),
("0 9,18 * * *", "每天 09:00、18:00"),
("0 9 * * 1", "每周一 09:00"),
("0 9 * * MON", "每周一 09:00"),
("0 9 * * 1,3,5", "每周一、周三、周五 09:00"),
("0 9 * * 1-5", "每周一至周五 09:00"),
("0 10 * * 0", "每周日 10:00"),
("30 8 1 * *", "每月 1 日 08:30"),
("30 8 1,15 * *", "每月 1 日、15 日 08:30"),
("0 8 1 3 *", "每年 3 月 1 日 08:00"),
("0 0 29 2 *", "每逢闰年 2 月 29 日 00:00"),
("0 */2 * * *", "每 2 小时"),
("15 */6 * * *", "每 6 小时(第 15 分钟)"),
("0 * * * *", "每小时整点"),
("20 * * * *", "每小时第 20 分钟"),
("*/5 * * * *", "每 5 分钟"),
("* * * * *", "每分钟"),
("0 9 1 * 1", "按 cron 表达式 0 9 1 * 1"),
("*/7 * * * *", "按 cron 表达式 */7 * * * *"),
("0 9-17 * * 1-5", "按 cron 表达式 0 9-17 * * 1-5"),
])
def test_describe_in_chinese(expr, text):
assert cron.describe(expr, "Asia/Shanghai") == f"{text}(Asia/Shanghai)"