""" 验证后台服务执行进度、状态与结果记录。 """ import asyncio import threading from collections.abc import Generator from uuid import uuid4 import pytest from app.runtime.loop import main_loop_registry from app.runtime.progress import AsyncProgressHelper from app.scheduler.facade import Scheduler from app.scheduler.registry import ExecutionRegistry @pytest.fixture(autouse=True) def _restore_main_loop_registry() -> Generator[None, None, None]: """ 隔离并恢复主循环登记,避免前序兼容层假循环改变投递路径。 """ previous = main_loop_registry.current main_loop_registry.replace_compat(None) try: yield finally: main_loop_registry.replace_compat(previous) def _build_scheduler(job_id, func): """ 构造不启动 APScheduler 的定时服务测试对象。 """ scheduler = object.__new__(Scheduler) scheduler._scheduler = None scheduler._event = threading.Event() scheduler._lock = threading.RLock() scheduler._jobs = { job_id: { "name": "测试定时服务", "provider_name": "测试", "func": func, "running": False, } } scheduler._lifecycle_state = "running" scheduler._registry = ExecutionRegistry(scheduler._lock) return scheduler def test_scheduler_records_live_and_completed_progress(): """ 定时服务应在执行中更新进度,并在成功后收敛为 100%。 """ job_id = f"test-success-{uuid4()}" snapshots = [] def task(progress_callback): """ 上报一次中间进度。 """ progress_callback(value=42, text="正在处理", data={"finished": 2}) snapshots.append(scheduler.get_progress(job_id)) scheduler = _build_scheduler(job_id, task) scheduler.start(job_id) assert snapshots[0].enable is True assert snapshots[0].value == 42 assert snapshots[0].status == "running" assert snapshots[0].data["finished"] == 2 progress = scheduler.get_progress(job_id) assert progress.enable is False assert progress.value == 100 assert progress.status == "success" assert progress.success is True assert progress.started_at assert progress.finished_at def test_scheduler_failure_preserves_last_progress(monkeypatch): """ 定时服务异常时应保留失败位置,而不是伪装成 100% 完成。 """ job_id = f"test-failure-{uuid4()}" def task(progress_callback): """ 上报进度后抛出异常。 """ progress_callback(value=37, text="处理失败") raise RuntimeError("预期失败") scheduler = _build_scheduler(job_id, task) monkeypatch.setattr( scheduler, "_handle_job_error", lambda **kwargs: None, ) scheduler.start(job_id) progress = scheduler.get_progress(job_id) assert progress.enable is False assert progress.value == 37 assert progress.status == "failed" assert progress.success is False assert progress.error == "预期失败" def test_scheduler_treats_standard_failure_result_as_failed(): """ 返回 `(False, message)` 的定时服务应记录为业务失败。 """ job_id = f"test-result-failure-{uuid4()}" def task(progress_callback): """ 返回标准失败结果。 """ progress_callback(value=55, text="业务校验失败") return False, "业务失败" scheduler = _build_scheduler(job_id, task) scheduler.start(job_id) progress = scheduler.get_progress(job_id) assert progress.value == 55 assert progress.status == "failed" assert progress.error == "业务失败" def test_scheduler_runs_async_job_without_running_global_loop(monkeypatch): """ 全局事件循环未运行时,异步定时服务仍应正常执行并收敛进度。 """ job_id = f"test-async-{uuid4()}" async def task(progress_callback): """ 上报异步任务进度。 """ progress_callback(value=65, text="异步处理中") scheduler = _build_scheduler(job_id, task) target_loop = asyncio.new_event_loop() main_loop_registry.replace_compat(target_loop) try: scheduler.start(job_id) finally: target_loop.close() progress = scheduler.get_progress(job_id) assert progress.enable is False assert progress.value == 100 assert progress.status == "success" def test_scheduler_runs_async_job_from_current_event_loop(monkeypatch): """ 在异步入口中手动触发定时服务时,不应嵌套调用 `asyncio.run`。 """ job_id = f"test-current-loop-{uuid4()}" async def task(progress_callback): """ 在当前事件循环中上报进度。 """ progress_callback(value=75, text="当前循环处理中") async def run_task(): """ 从已运行的事件循环启动定时服务。 """ scheduler.start(job_id) async def wait_until_finished() -> None: """ 等待任务及其异步进度句柄全部收敛。 """ while ( scheduler._registry.handles() or scheduler._registry.is_active(job_id) ): await asyncio.sleep(0) await asyncio.wait_for(wait_until_finished(), timeout=1) scheduler = _build_scheduler(job_id, task) target_loop = asyncio.new_event_loop() main_loop_registry.replace_compat(target_loop) try: asyncio.run(run_task()) finally: target_loop.close() progress = scheduler.get_progress(job_id) assert progress.enable is False assert progress.value == 100 assert progress.status == "success" def test_scheduler_records_cancelled_async_job_as_failed(): """ 协程任务取消时运行时进度不得收敛为成功。 """ job_id = f"test-cancelled-{uuid4()}" async def task(): """ 模拟传播到调度器外壳的取消。 """ raise asyncio.CancelledError async def run_task(): """ 等待取消传播到调度外壳并完成失败终态收口。 """ job = scheduler._prepare_job(job_id) with pytest.raises(asyncio.CancelledError): await scheduler._run_coro_job(task, job_id, job) scheduler = _build_scheduler(job_id, task) asyncio.run(run_task()) progress = scheduler.get_progress(job_id) assert progress.enable is False assert progress.status == "failed" assert progress.success is False assert progress.error == "任务已取消" def test_scheduler_stop_async_cancels_owned_async_jobs(): """ Scheduler 关停应取消并等待自身登记的异步作业。 """ job_id = f"test-owned-task-{uuid4()}" started = asyncio.Event() cancelled = asyncio.Event() async def task(): """ 等待关停信号,验证任务确实由 Scheduler 持有。 """ started.set() try: await asyncio.Event().wait() except asyncio.CancelledError: cancelled.set() raise scheduler = _build_scheduler(job_id, task) async def run_task(): """ 在当前事件循环启动并收口异步作业。 """ scheduler.start(job_id) await started.wait() assert len(scheduler._registry.handles()) == 1 await scheduler.stop_async() asyncio.run(run_task()) assert cancelled.is_set() assert scheduler._registry.handles() == () def test_scheduler_returns_none_for_unknown_job(): """ 未注册且无历史进度的定时服务应返回空。 """ job_id = f"test-unknown-{uuid4()}" scheduler = object.__new__(Scheduler) scheduler._lock = threading.RLock() scheduler._jobs = {} scheduler._lifecycle_state = "running" scheduler._registry = ExecutionRegistry(scheduler._lock) assert scheduler.get_progress(job_id) is None def test_async_progress_end_rebuilds_terminal_snapshot_after_read_failure(): """ 进度快照读取失败时,终态写入仍应生成可展示的完成状态。 """ class BrokenProgress: """ 模拟读取失败但仍可写入的进度后端。 """ def __init__(self): """ 初始化用于观察终态写入的缓存槽位。 """ self.value = None async def get(self, _key): """ 模拟 Redis 读取失败后的空结果。 """ return None async def set(self, _key, value): """ 记录进度终态写入。 """ self.value = value async def run_end(): """ 执行一次终态写入。 """ progress = AsyncProgressHelper("scheduler:read-failure") backend = BrokenProgress() progress._progress = backend await progress.end( text="任务执行完成", data={"status": "success"}, value=100, ) return backend.value detail = asyncio.run(run_end()) assert detail == { "enable": False, "value": 100.0, "text": "任务执行完成", "data": {"status": "success"}, }