Files
Profeto/tests/test_scheduler_registration.py
T
shangfangjian 8c87b9d024 fix(critical): 修复调度器静默失效、数据源缺失与前端类型错误
C1 定时任务静默失效(4 层缺陷):
  - scheduler.py: croniter(expr, datetime.now) 未调用 now(),
    导致 TypeError 被吞、next_run 恒为 None(最深一层)
  - scheduler.py: _calc_next 非法 cron 静默吞异常 -> 改为构造期抛 ValueError
  - scheduler.py: _run_loop 一次异常即永久停摆 -> 增加异常隔离
  - scheduler.py: sleep(min(wait,60)) 使运行期 cron 变更最长 60s 才生效
    -> 改为固定 SLEEP_TICK 轮询
  - app.py / schedules.py: 3 处 register() 第 2 参误传 task 类型名

C3 EvalPage.tsx: 引用未导入的 EmptyState -> 改为已导入的 EmptyText
  (tsc --noEmit 由 1 error 变为 0 error)

新增 tests/test_scheduler_registration.py: 9 项行为回归测试
2026-09-21 17:04:04 +08:00

191 lines
7.4 KiB
Python

"""C1 回归测试: 定时任务调度器「静默失效」缺陷。
原始缺陷(全量审查 C1):
1. `Scheduler.register()` 的第 2 参是 cron 表达式,但 app.py:52 /
schedules.py:95,119 三处调用传的都是任务类型字符串("events" 等)。
`croniter("events")` 抛异常被 `_calc_next` 的 except 吞掉 →
next_run 永远为 None → 所有定时任务从不触发且无任何报错。
2. 运行期通过 API 新建的任务只进了 _tasks 字典,从未
asyncio.create_task 启动 _run_loop(只有 Scheduler.start() 会建循环)。
这些用例不依赖数据库,直接对调度器类做行为断言。
"""
from __future__ import annotations
import asyncio
import pytest
from src.core.scheduler import ScheduledTask, Scheduler
class TestCronValidation:
"""非法 cron 必须显式报错,不能静默变成永不触发。"""
def test_nonevent_cron_raises(self):
"""把任务类型字符串当 cron 传(原缺陷) → 构造时立即抛 ValueError。"""
async def _noop() -> None: ...
with pytest.raises(ValueError, match="cron"):
ScheduledTask("daily-events", "events", _noop)
def test_valid_cron_accepted(self):
"""合法 cron 正常构造,且 next_run 被计算出来(非 None)。"""
async def _noop() -> None: ...
task = ScheduledTask("daily-events", "0 8 * * *", _noop)
assert task.next_run is not None
def test_next_run_is_in_future(self):
"""next_run 必须落在未来,否则 _run_loop 会立刻误触发。"""
from datetime import datetime
async def _noop() -> None: ...
task = ScheduledTask("t", "*/30 * * * *", _noop)
assert task.next_run > datetime.now()
def test_croniter_receives_called_datetime(self):
"""回归: `croniter(expr, datetime.now)`(未调用)会抛
TypeError: 'builtin_function_or_method' object cannot be interpreted
as an integer —— 这是比「传错参数」更深一层的静默失效根源。
断言 next_run 是真实 datetime,而非 None。"""
from datetime import datetime
async def _noop() -> None: ...
task = ScheduledTask("t", "0 8 * * *", _noop)
assert isinstance(task.next_run, datetime), (
f"next_run 应为 datetime,实际 {task.next_run!r} —— "
"croniter 的 start_time 未正确传入"
)
class TestRunLoopActuallyFires:
"""注册后任务必须真的在到期时被执行(端到端行为,不 mock 内部)。"""
async def test_register_then_start_runs_task(self):
"""cron 到点后任务函数被调用一次。用 1 秒粒度的 * * * * * 加速验证。"""
calls: list[str] = []
async def _job() -> None:
calls.append("ran")
sched = Scheduler()
sched.register("t1", "* * * * *", _job, enabled=True)
await sched.start()
try:
# _run_loop 用 min(wait, 60) 分段睡;下一分钟边界最多 60 秒。
# 为让测试可跑,直接把 next_run 拨到过去,触发一次执行。
task = sched.get("t1")
from datetime import datetime, timedelta
task.next_run = datetime.now() - timedelta(seconds=1)
for _ in range(40):
if calls:
break
await asyncio.sleep(0.05)
finally:
await sched.stop()
assert calls == ["ran"], "注册并 start 后,到期任务未被执行"
async def test_register_after_start_also_runs(self):
"""运行期(已 start 之后)新 register 的任务也必须被启动(原缺陷 2)。"""
calls: list[str] = []
async def _job() -> None:
calls.append("late")
sched = Scheduler()
await sched.start()
try:
sched.register("late-task", "* * * * *", _job, enabled=True)
from datetime import datetime, timedelta
sched.get("late-task").next_run = datetime.now() - timedelta(seconds=1)
for _ in range(40):
if calls:
break
await asyncio.sleep(0.05)
finally:
await sched.stop()
assert calls == ["late"], "start 之后注册的任务未被启动循环"
async def test_disabled_task_does_not_run(self):
"""enabled=False 的任务不执行。"""
calls: list[str] = []
async def _job() -> None:
calls.append("nope")
sched = Scheduler()
sched.register("off", "* * * * *", _job, enabled=False)
await sched.start()
try:
from datetime import datetime, timedelta
sched.get("off").next_run = datetime.now() - timedelta(seconds=1)
await asyncio.sleep(0.3)
finally:
await sched.stop()
assert calls == []
class TestTaskFailureIsolation:
"""单个任务抛异常不能杀掉循环(否则一次失败永久停摆)。"""
async def test_exception_does_not_kill_loop(self):
"""任务抛异常后,循环仍存活并能在下一次到期时继续执行。"""
runs: list[int] = []
async def _boom() -> None:
runs.append(1)
if len(runs) == 1:
raise RuntimeError("boom")
sched = Scheduler()
sched.register("flaky", "* * * * *", _boom, enabled=True)
await sched.start()
try:
from datetime import datetime, timedelta
task = sched.get("flaky")
for _ in range(40):
if len(runs) >= 2:
break
task.next_run = datetime.now() - timedelta(seconds=1)
await asyncio.sleep(0.05)
assert len(runs) >= 2, "任务首次抛异常后循环未继续"
assert task._task is not None and not task._task.done(), (
"循环在任务抛异常后已终止"
)
finally:
await sched.stop()
async def test_next_run_change_takes_effect_promptly(self):
"""运行期把 next_run 改到过去,循环须在 SLEEP_TICK 内感知并触发。
回归: 原实现 sleep(min(wait_seconds, 60)),当 wait_seconds<60 时
会一次性睡满 wait_seconds,导致运行期通过 API 更新 cron 后
最长 60s 不生效。
"""
calls: list[str] = []
async def _job() -> None:
calls.append("ran")
import src.core.scheduler as sched_mod
old_tick = sched_mod.ScheduledTask.SLEEP_TICK
sched_mod.ScheduledTask.SLEEP_TICK = 0.05
try:
sched = Scheduler()
sched.register("tick", "* * * * *", _job, enabled=True)
await sched.start()
try:
from datetime import datetime, timedelta
# 先等循环进入 sleep(wait 接近 60s 的最坏情况)
await asyncio.sleep(0.15)
sched.get("tick").next_run = datetime.now() - timedelta(seconds=1)
# 短 tick 应在 ~0.15s 内感知;给 1s 容差
for _ in range(40):
if calls:
break
await asyncio.sleep(0.05)
finally:
await sched.stop()
finally:
sched_mod.ScheduledTask.SLEEP_TICK = old_tick
assert calls == ["ran"], (
"next_run 变更后循环未在 tick 内响应(疑似一次性睡满 wait_seconds)"
)