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