Files
pyflowx/tests/test_progress.py
T

272 lines
8.8 KiB
Python

"""进度监控测试。"""
from __future__ import annotations
from typing import Any
import pytest
import pyflowx as px
from pyflowx.progress import ProgressCallback, RichProgressMonitor
from pyflowx.task import TaskEvent, TaskStatus
# ---------------------------------------------------------------------- #
# 测试用监控器
# ---------------------------------------------------------------------- #
class _RecordingMonitor:
"""记录所有事件的测试用监控器。"""
def __init__(self) -> None:
self.events: list[TaskEvent] = []
self.started_total: int | None = None
self.finished: bool = False
def start(self, total: int) -> None:
self.started_total = total
def on_event(self, event: TaskEvent) -> None:
self.events.append(event)
def finish(self) -> None:
self.finished = True
# ---------------------------------------------------------------------- #
# ProgressCallback Protocol
# ---------------------------------------------------------------------- #
def test_progress_callback_protocol_satisfied() -> None:
"""_RecordingMonitor 满足 ProgressCallback 协议。"""
monitor = _RecordingMonitor()
assert isinstance(monitor, ProgressCallback)
def test_rich_progress_monitor_satisfies_protocol() -> None:
"""RichProgressMonitor 满足 ProgressCallback 协议。"""
assert isinstance(RichProgressMonitor(), ProgressCallback)
# ---------------------------------------------------------------------- #
# RichProgressMonitor
# ---------------------------------------------------------------------- #
def test_rich_monitor_start_finish_lifecycle() -> None:
"""start/finish 生命周期正常。"""
monitor = RichProgressMonitor()
monitor.start(5)
assert monitor._total == 5
assert monitor._task_id is not None
monitor.finish()
def test_rich_monitor_on_event_running() -> None:
"""RUNNING 事件将任务加入 running 集合。"""
monitor = RichProgressMonitor()
monitor.start(3)
monitor.on_event(TaskEvent(task="a", status=TaskStatus.RUNNING))
assert "a" in monitor._running
monitor.finish()
def test_rich_monitor_on_event_success_advances() -> None:
"""SUCCESS 事件推进进度并从 running 移除。"""
monitor = RichProgressMonitor()
monitor.start(2)
monitor.on_event(TaskEvent(task="a", status=TaskStatus.RUNNING))
monitor.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=0.1))
assert "a" not in monitor._running
assert monitor._completed == 1
monitor.finish()
def test_rich_monitor_on_event_failed_advances() -> None:
"""FAILED 事件也推进进度。"""
monitor = RichProgressMonitor()
monitor.start(1)
monitor.on_event(TaskEvent(task="a", status=TaskStatus.FAILED, error="boom"))
assert monitor._completed == 1
monitor.finish()
def test_rich_monitor_on_event_skipped_advances() -> None:
"""SKIPPED 事件也推进进度。"""
monitor = RichProgressMonitor()
monitor.start(1)
monitor.on_event(TaskEvent(task="a", status=TaskStatus.SKIPPED, reason="缓存命中"))
assert monitor._completed == 1
monitor.finish()
def test_rich_monitor_context_manager() -> None:
"""__enter__/__exit__ 正常工作。"""
with RichProgressMonitor() as monitor:
monitor.start(1)
monitor.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS))
# 退出后不再报错
def test_rich_monitor_on_event_before_start() -> None:
"""start 前调用 on_event 不报错(task_id 为 None 时早返回)。"""
monitor = RichProgressMonitor()
monitor.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS))
# 未 start 时 advance 不执行,_update_description 早返回
assert monitor._completed == 1
assert monitor._task_id is None
def test_rich_monitor_pending_event_noop() -> None:
"""PENDING 事件不是 RUNNING 也不是终态,应被忽略。"""
monitor = RichProgressMonitor()
monitor.start(2)
monitor.on_event(TaskEvent(task="a", status=TaskStatus.PENDING))
assert monitor._completed == 0
assert len(monitor._running) == 0
monitor.finish()
def test_rich_monitor_update_description_running() -> None:
"""运行中时描述应包含任务名。"""
monitor = RichProgressMonitor()
monitor.start(5)
monitor.on_event(TaskEvent(task="task_a", status=TaskStatus.RUNNING))
monitor.on_event(TaskEvent(task="task_b", status=TaskStatus.RUNNING))
# 无法直接断言 rich 内部状态,但 _running 应包含两个任务
assert len(monitor._running) == 2
monitor.finish()
def test_rich_monitor_update_description_many_running_folds() -> None:
"""超过 3 个运行中任务时描述应折叠。"""
monitor = RichProgressMonitor()
monitor.start(10)
for i in range(5):
monitor.on_event(TaskEvent(task=f"task_{i}", status=TaskStatus.RUNNING))
assert len(monitor._running) == 5
# 不报错即可(描述更新逻辑内部处理折叠)
monitor.finish()
# ---------------------------------------------------------------------- #
# run() 集成测试
# ---------------------------------------------------------------------- #
def _make_simple_graph() -> px.Graph:
"""构建简单测试图。"""
def task_a() -> int:
return 1
def task_b(task_a: int) -> int:
return task_a + 1
return px.Graph.from_specs(
[
px.TaskSpec("task_a", task_a),
px.TaskSpec("task_b", task_b, depends_on=("task_a",)),
]
)
def test_run_with_progress_true() -> None:
"""progress=True 使用 RichProgressMonitor 不报错。"""
graph = _make_simple_graph()
report = px.run(graph, strategy="sequential", progress=True)
assert report.success
assert report["task_b"] == 2
def test_run_with_custom_monitor() -> None:
"""progress=自定义监控器,事件被正确捕获。"""
graph = _make_simple_graph()
monitor = _RecordingMonitor()
report = px.run(graph, strategy="sequential", progress=monitor)
assert report.success
assert monitor.started_total == 2
assert monitor.finished
# 至少有 RUNNING + SUCCESS 事件
statuses = [e.status for e in monitor.events]
assert TaskStatus.RUNNING in statuses
assert TaskStatus.SUCCESS in statuses
def test_run_with_monitor_and_on_event_coexist() -> None:
"""progress 监控器与 on_event 回调可共存。"""
graph = _make_simple_graph()
monitor = _RecordingMonitor()
on_event_events: list[TaskEvent] = []
def capture(event: TaskEvent) -> None:
on_event_events.append(event)
report = px.run(
graph,
strategy="sequential",
progress=monitor,
on_event=capture,
)
assert report.success
# 两者都应收到事件
assert len(monitor.events) > 0
assert len(on_event_events) > 0
assert len(monitor.events) == len(on_event_events)
def test_run_with_monitor_finish_called_on_failure() -> None:
"""任务失败时 monitor.finish 仍被调用。"""
def fail_task() -> Any:
raise RuntimeError("boom")
graph = px.Graph.from_specs([px.TaskSpec("fail", fail_task)])
monitor = _RecordingMonitor()
with pytest.raises(px.TaskFailedError):
px.run(graph, strategy="sequential", progress=monitor)
assert monitor.finished
assert monitor.started_total == 1
def test_run_progress_false_no_monitor() -> None:
"""progress=False 不创建监控器,行为不变。"""
graph = _make_simple_graph()
report = px.run(graph, strategy="sequential", progress=False)
assert report.success
def test_run_progress_with_verbose_coexist() -> None:
"""progress 与 verbose 可同时使用。"""
graph = _make_simple_graph()
monitor = _RecordingMonitor()
report = px.run(graph, strategy="sequential", progress=monitor, verbose=True)
assert report.success
assert monitor.finished
def test_run_progress_with_thread_strategy() -> None:
"""progress 在 thread 策略下正常工作。"""
graph = _make_simple_graph()
monitor = _RecordingMonitor()
report = px.run(graph, strategy="thread", progress=monitor)
assert report.success
assert monitor.finished
def test_run_progress_with_dependency_strategy() -> None:
"""progress 在 dependency 策略下正常工作。"""
graph = _make_simple_graph()
monitor = _RecordingMonitor()
report = px.run(graph, strategy="dependency", progress=monitor)
assert report.success
assert monitor.finished
def test_run_progress_with_async_strategy() -> None:
"""progress 在 async 策略下正常工作。"""
async def atask() -> int:
return 42
graph = px.Graph.from_specs([px.TaskSpec("atask", atask)])
monitor = _RecordingMonitor()
report = px.run(graph, strategy="async", progress=monitor)
assert report.success
assert monitor.finished
assert monitor.started_total == 1