"""进度监控测试。""" 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