Files
pyflowx/tests/test_report.py
T

827 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""RunReport 测试."""
from __future__ import annotations
import contextlib
import csv
import io
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
import pyflowx as px
from pyflowx.task import TaskResult, TaskSpec, TaskStatus
def _fn() -> int:
return 1
def _make_result(
name: str = "a",
status: TaskStatus = TaskStatus.SUCCESS,
value: Any = 42,
error: BaseException | None = None,
duration: float = 0.5,
attempts: int = 1,
tags: tuple[str, ...] = (),
depends_on: tuple[str, ...] = (),
reason: str | None = None,
) -> TaskResult[Any]:
"""构造测试用 TaskResult 实例."""
spec: TaskSpec[Any] = TaskSpec[Any](name, _fn, tags=tags, depends_on=depends_on)
start = datetime(2024, 1, 1, 0, 0, 0)
# 用 timedelta 精确表达秒数,避免 int() 截断小数
end = start + timedelta(seconds=duration) if duration else None
return TaskResult[Any](
spec=spec,
status=status,
value=value,
error=error,
attempts=attempts,
started_at=start,
finished_at=end,
reason=reason,
)
class TestRunReportAccess:
"""测试 RunReport 的访问接口."""
def test_getitem_returns_value(self) -> None:
"""report[name] 应返回任务结果值."""
report = px.RunReport()
report.results["a"] = _make_result("a", value=7)
assert report["a"] == 7
def test_result_of_returns_full_result(self) -> None:
"""result_of 应返回完整的 TaskResult 对象."""
report = px.RunReport()
r = _make_result("a")
report.results["a"] = r
assert report.result_of("a") is r
def test_contains(self) -> None:
"""in 运算符应正确判断任务是否存在."""
report = px.RunReport()
report.results["a"] = _make_result("a")
assert "a" in report
assert "b" not in report
def test_iter_and_len(self) -> None:
"""应支持迭代任务名并返回任务数量."""
report = px.RunReport()
report.results["a"] = _make_result("a")
report.results["b"] = _make_result("b")
assert list(report) == ["a", "b"]
assert len(report) == 2
class TestRunReportSummary:
"""测试 RunReport 的 summary 方法."""
def test_summary_success(self) -> None:
"""应正确汇总成功和跳过的任务."""
report = px.RunReport()
report.results["a"] = _make_result("a", status=TaskStatus.SUCCESS, duration=1.0)
report.results["b"] = _make_result("b", status=TaskStatus.SKIPPED, duration=0.0)
s = report.summary()
assert s["success"] is True
assert s["total_tasks"] == 2
assert s["by_status"] == {"success": 1, "skipped": 1}
assert s["total_duration_seconds"] == 1.0
assert s["run_id"] == report.run_id
def test_summary_with_none_duration(self) -> None:
"""未开始/未结束的任务 duration 为 None,不应计入总时长."""
report = px.RunReport()
spec: TaskSpec[Any] = TaskSpec[Any]("a", _fn) # type: ignore[arg-type]
report.results["a"] = TaskResult(spec=spec, status=TaskStatus.FAILED)
s = report.summary()
assert s["total_duration_seconds"] == 0.0
def test_failed_tasks(self) -> None:
"""failed_tasks 应返回所有失败任务名."""
report = px.RunReport()
report.results["a"] = _make_result("a", status=TaskStatus.SUCCESS)
report.results["b"] = _make_result("b", status=TaskStatus.FAILED, error=ValueError("x"))
assert report.failed_tasks() == ["b"]
class TestRunReportDescribe:
"""测试 RunReport 的 describe 方法."""
def test_describe_success(self) -> None:
"""应正确描述成功状态和耗时."""
report = px.RunReport()
report.results["a"] = _make_result("a", status=TaskStatus.SUCCESS, duration=0.5)
desc = report.describe()
assert "success=True" in desc
assert "run_id=" in desc
assert "a: success" in desc
assert "0.500s" in desc
def test_describe_with_error(self) -> None:
"""应正确描述失败状态和错误信息."""
report = px.RunReport(success=False)
report.results["a"] = _make_result("a", status=TaskStatus.FAILED, error=ValueError("boom"), duration=0.1)
desc = report.describe()
assert "success=False" in desc
assert "error=ValueError" in desc
def test_describe_no_duration(self) -> None:
"""无耗时的任务应显示为 '-'."""
report = px.RunReport()
spec: TaskSpec[Any] = TaskSpec[Any]("a", _fn) # type: ignore[arg-type]
report.results["a"] = TaskResult[Any](spec=spec, status=TaskStatus.PENDING)
desc = report.describe()
assert "-" in desc # duration 显示为 "-"
class TestRunReportQueries:
"""测试 RunReport 的新查询 API."""
def test_succeeded_tasks(self) -> None:
"""succeeded_tasks 返回 SUCCESS 状态的任务名."""
report = px.RunReport()
report.results["a"] = _make_result("a", status=TaskStatus.SUCCESS)
report.results["b"] = _make_result("b", status=TaskStatus.FAILED)
report.results["c"] = _make_result("c", status=TaskStatus.SUCCESS)
assert report.succeeded_tasks() == ["a", "c"]
def test_skipped_tasks(self) -> None:
"""skipped_tasks 返回 SKIPPED 状态的任务名."""
report = px.RunReport()
report.results["a"] = _make_result("a", status=TaskStatus.SKIPPED)
report.results["b"] = _make_result("b", status=TaskStatus.SUCCESS)
assert report.skipped_tasks() == ["a"]
def test_tasks_by_status(self) -> None:
"""tasks_by_status 按指定状态过滤."""
report = px.RunReport()
report.results["a"] = _make_result("a", status=TaskStatus.FAILED)
report.results["b"] = _make_result("b", status=TaskStatus.FAILED)
report.results["c"] = _make_result("c", status=TaskStatus.SUCCESS)
assert report.tasks_by_status(TaskStatus.FAILED) == ["a", "b"]
assert report.tasks_by_status(TaskStatus.SUCCESS) == ["c"]
assert report.tasks_by_status(TaskStatus.SKIPPED) == []
def test_durations(self) -> None:
"""durations 返回任务名 -> 时长映射."""
report = px.RunReport()
report.results["a"] = _make_result("a", duration=1.5)
report.results["b"] = _make_result("b", duration=2.0)
durs = report.durations()
assert durs["a"] == 1.5
assert durs["b"] == 2.0
def test_durations_no_duration(self) -> None:
"""无时长的任务应返回 0.0."""
report = px.RunReport()
spec: TaskSpec[Any] = TaskSpec[Any]("a", _fn) # type: ignore[arg-type]
report.results["a"] = TaskResult[Any](spec=spec, status=TaskStatus.PENDING)
durs = report.durations()
assert durs["a"] == 0.0
# ---------------------------------------------------------------------- #
# 序列化:to_dict / to_json / to_csv / from_json
# ---------------------------------------------------------------------- #
class TestRunReportToDict:
"""测试 RunReport.to_dict."""
def test_to_dict_basic(self) -> None:
"""应输出包含 success/summary/results 的字典。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=42, duration=0.5, tags=("ingest",), depends_on=("x",))
d = report.to_dict()
assert d["success"] is True
assert d["summary"]["total_tasks"] == 1
assert len(d["results"]) == 1
entry = d["results"][0]
assert entry["name"] == "a"
assert entry["status"] == "success"
assert entry["value"] == 42
assert entry["error"] is None
assert entry["attempts"] == 1
assert entry["started_at"] == "2024-01-01T00:00:00"
assert entry["finished_at"] == "2024-01-01T00:00:00.500000"
assert entry["duration_seconds"] == 0.5
assert entry["reason"] is None
assert entry["tags"] == ["ingest"]
assert entry["depends_on"] == ["x"]
def test_to_dict_preserves_order(self) -> None:
"""results 列表应按插入顺序保留。"""
report = px.RunReport()
report.results["a"] = _make_result("a")
report.results["b"] = _make_result("b")
report.results["c"] = _make_result("c")
d = report.to_dict()
assert [r["name"] for r in d["results"]] == ["a", "b", "c"]
def test_to_dict_with_error(self) -> None:
"""error 应转为 repr 字符串。"""
report = px.RunReport(success=False)
report.results["a"] = _make_result("a", status=TaskStatus.FAILED, error=ValueError("boom"))
d = report.to_dict()
entry = d["results"][0]
assert entry["error"] == "ValueError('boom')"
assert entry["status"] == "failed"
def test_to_dict_none_value(self) -> None:
"""None 值应保持为 None。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=None)
d = report.to_dict()
assert d["results"][0]["value"] is None
def test_to_dict_non_serializable_value_falls_back_to_repr(self) -> None:
"""非 JSON 可序列化的值应回退到 repr。"""
report = px.RunReport()
# Path 对象不可 JSON 序列化
report.results["a"] = _make_result("a", value=Path("/tmp/x"))
d = report.to_dict()
assert d["results"][0]["value"] == "PosixPath('/tmp/x')"
def test_to_dict_with_custom_serializer(self) -> None:
"""自定义 value_serializer 应被调用。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=Path("/tmp/x"))
d = report.to_dict(value_serializer=str)
assert d["results"][0]["value"] == "/tmp/x"
def test_to_dict_no_timestamps(self) -> None:
"""未开始/未结束的任务 started_at/finished_at/duration 应为 None。"""
report = px.RunReport()
spec: TaskSpec[Any] = TaskSpec[Any]("a", _fn) # type: ignore[arg-type]
report.results["a"] = TaskResult[Any](spec=spec, status=TaskStatus.PENDING)
d = report.to_dict()
entry = d["results"][0]
assert entry["started_at"] is None
assert entry["finished_at"] is None
assert entry["duration_seconds"] is None
def test_to_dict_with_reason(self) -> None:
"""reason 字段应被保留。"""
report = px.RunReport()
report.results["a"] = _make_result("a", status=TaskStatus.SKIPPED, duration=0.0, reason="条件不满足")
d = report.to_dict()
assert d["results"][0]["reason"] == "条件不满足"
def test_to_dict_empty_report(self) -> None:
"""空报告也应可序列化。"""
report = px.RunReport()
d = report.to_dict()
assert d["success"] is True
assert d["results"] == []
assert d["summary"]["total_tasks"] == 0
class TestRunReportToJson:
"""测试 RunReport.to_json."""
def test_to_json_basic(self) -> None:
"""应输出合法 JSON 字符串。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=42)
s = report.to_json()
data = json.loads(s)
assert data["success"] is True
assert data["results"][0]["value"] == 42
def test_to_json_indent_zero_compact(self) -> None:
"""indent=0 应输出紧凑单行(无换行)。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=1)
s = report.to_json(indent=0)
assert "\n" not in s
def test_to_json_indent_default(self) -> None:
"""默认 indent=2 应输出多行格式。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=1)
s = report.to_json()
assert "\n" in s
def test_to_json_ensure_chinese(self) -> None:
"""中文字符不应被转义(ensure_ascii=False)。"""
report = px.RunReport()
report.results["a"] = _make_result("a", status=TaskStatus.SKIPPED, duration=0.0, reason="条件不满足")
s = report.to_json()
assert "条件不满足" in s
assert "\\u" not in s
def test_to_json_with_value_serializer(self) -> None:
"""to_json 应透传 value_serializer。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=Path("/tmp/x"))
s = report.to_json(value_serializer=str)
data = json.loads(s)
assert data["results"][0]["value"] == "/tmp/x"
class TestRunReportToCsv:
"""测试 RunReport.to_csv."""
def test_to_csv_basic(self) -> None:
"""应输出带表头与数据行的 CSV。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=42, duration=0.5)
s = report.to_csv()
reader = csv.reader(io.StringIO(s))
rows = list(reader)
assert rows[0] == [
"name",
"status",
"attempts",
"duration_seconds",
"started_at",
"finished_at",
"error",
"reason",
"value",
]
assert rows[1][0] == "a"
assert rows[1][1] == "success"
assert rows[1][2] == "1"
assert rows[1][3] == "0.500000"
assert rows[1][8] == "42"
def test_to_csv_without_value(self) -> None:
"""include_value=False 应省略 value 列。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=42)
s = report.to_csv(include_value=False)
reader = csv.reader(io.StringIO(s))
rows = list(reader)
assert rows[0] == [
"name",
"status",
"attempts",
"duration_seconds",
"started_at",
"finished_at",
"error",
"reason",
]
assert len(rows[1]) == 8
def test_to_csv_empty_value_quoted(self) -> None:
"""含逗号的值应被引号包裹(csv 模块自动处理)。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value="a,b,c")
s = report.to_csv()
# csv 模块会自动给含特殊字符的值加引号
assert '"a,b,c"' in s
def test_to_csv_empty_report(self) -> None:
"""空报告应只输出表头。"""
report = px.RunReport()
s = report.to_csv()
reader = csv.reader(io.StringIO(s))
rows = list(reader)
assert len(rows) == 1
assert rows[0][0] == "name"
def test_to_csv_non_serializable_value(self) -> None:
"""非 JSON 可序列化值应回退到 repr。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=Path("/tmp/x"))
s = report.to_csv()
assert "PosixPath('/tmp/x')" in s
class TestRunReportToHtml:
"""测试 RunReport.to_html."""
def test_to_html_basic_structure(self) -> None:
"""应输出完整 HTML5 文档结构。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=42)
s = report.to_html()
assert s.startswith("<!DOCTYPE html>")
assert s.endswith("</html>")
assert "<html" in s
assert "<head>" in s
assert "<body>" in s
def test_to_html_contains_run_id(self) -> None:
"""HTML 应在标题与正文包含 run_id。"""
report = px.RunReport(run_id="abcd1234")
report.results["a"] = _make_result("a")
s = report.to_html()
assert "abcd1234" in s
def test_to_html_contains_task_name(self) -> None:
"""HTML 表格应包含任务名。"""
report = px.RunReport()
report.results["extract"] = _make_result("extract", value=1)
s = report.to_html()
assert "extract" in s
assert "<td" in s
def test_to_html_status_badge_class(self) -> None:
"""不同状态应渲染对应 CSS 类。"""
report = px.RunReport()
report.results["ok"] = _make_result("ok", status=TaskStatus.SUCCESS)
report.results["bad"] = _make_result("bad", status=TaskStatus.FAILED, error=ValueError("x"))
report.results["skip"] = _make_result("skip", status=TaskStatus.SKIPPED, duration=0.0, reason="r")
s = report.to_html()
assert "status-success" in s
assert "status-failed" in s
assert "status-skipped" in s
def test_to_html_xss_escape_task_name(self) -> None:
"""任务名含 HTML 特殊字符应被转义。"""
report = px.RunReport()
malicious = "<script>evil()</script>"
spec: TaskSpec[Any] = TaskSpec[Any](malicious, _fn)
result: TaskResult[Any] = TaskResult(
spec=spec,
status=TaskStatus.SUCCESS,
value=1,
attempts=1,
)
report.results[malicious] = result
s = report.to_html()
assert "<script>" not in s
assert "&lt;script&gt;" in s
def test_to_html_xss_escape_error(self) -> None:
"""错误信息含 HTML 特殊字符应被转义。"""
report = px.RunReport()
err = ValueError("<img src=x onerror=alert(1)>")
report.results["a"] = _make_result("a", status=TaskStatus.FAILED, error=err)
s = report.to_html()
assert "<img" not in s
assert "&lt;img" in s
def test_to_html_xss_escape_reason(self) -> None:
"""跳过原因含 HTML 特殊字符应被转义。"""
report = px.RunReport()
report.results["a"] = _make_result(
"a",
status=TaskStatus.SKIPPED,
duration=0.0,
reason="<b>bold</b>",
)
s = report.to_html()
assert "<b>bold</b>" not in s
assert "&lt;b&gt;" in s
def test_to_html_without_value(self) -> None:
"""include_value=False 应省略返回值列。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value="secret")
s = report.to_html(include_value=False)
assert "secret" not in s
assert "返回值" not in s
def test_to_html_with_value(self) -> None:
"""include_value=True 应包含返回值(JSON 序列化形式)。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=[1, 2, 3])
s = report.to_html()
assert "[1, 2, 3]" in s
def test_to_html_with_value_serializer(self) -> None:
"""to_html 应透传 value_serializer。"""
report = px.RunReport()
report.results["a"] = _make_result("a", value=Path("/tmp/x"))
s = report.to_html(value_serializer=str)
assert "/tmp/x" in s
assert "PosixPath" not in s
def test_to_html_empty_report(self) -> None:
"""空报告应渲染提示文本而非空表格。"""
report = px.RunReport()
s = report.to_html()
assert "无任务结果" in s
assert "<table>" not in s
def test_to_html_failure_status_indicator(self) -> None:
"""失败报告应在汇总卡片显示失败样式。"""
report = px.RunReport(success=False)
report.results["a"] = _make_result("a", status=TaskStatus.FAILED, error=RuntimeError("x"))
s = report.to_html()
assert "card failure" in s
assert "失败" in s
def test_to_html_success_status_indicator(self) -> None:
"""成功报告应在汇总卡片显示成功样式。"""
report = px.RunReport(success=True)
report.results["a"] = _make_result("a", status=TaskStatus.SUCCESS)
s = report.to_html()
assert "card success" in s
assert "成功" in s
def test_to_html_summary_cards_content(self) -> None:
"""汇总卡片应包含任务总数/耗时/各状态计数。"""
report = px.RunReport()
report.results["a"] = _make_result("a", status=TaskStatus.SUCCESS, duration=0.5)
report.results["b"] = _make_result("b", status=TaskStatus.FAILED, error=ValueError("x"), duration=0.3)
s = report.to_html()
assert "任务总数" in s
assert "总耗时" in s
assert "成功" in s
assert "失败" in s
assert "跳过" in s
assert "2" in s # 任务总数
assert "0.800" in s # 0.5+0.3
def test_to_html_no_timestamps(self) -> None:
"""无时间戳时应渲染占位符。"""
report = px.RunReport()
# 构造无时间戳的结果
spec: TaskSpec[Any] = TaskSpec[Any]("a", _fn)
result: TaskResult[Any] = TaskResult(
spec=spec,
status=TaskStatus.SUCCESS,
value=1,
attempts=1,
started_at=None,
finished_at=None,
)
report.results["a"] = result
s = report.to_html()
# 无时间戳时表格单元格显示 "-"
assert s.count("<td>-</td>") >= 2
def test_to_html_css_inlined(self) -> None:
"""CSS 应内联在 <style> 标签中,无外部依赖。"""
report = px.RunReport()
s = report.to_html()
assert "<style>" in s
assert "</style>" in s
assert "stylesheet" not in s.lower() # 无外部样式表引用
assert "link rel" not in s.lower()
class TestRunReportFromJson:
"""测试 RunReport.from_json."""
def test_from_json_round_trip(self) -> None:
"""to_json → from_json 应能恢复所有可序列化字段。"""
report = px.RunReport()
report.results["a"] = _make_result(
"a",
value=42,
duration=0.5,
tags=("ingest",),
depends_on=("x",),
)
report.results["b"] = _make_result(
"b",
status=TaskStatus.SKIPPED,
duration=0.0,
reason="条件不满足",
)
text = report.to_json()
restored = px.RunReport.from_json(text)
assert restored.success == report.success
assert len(restored) == 2
assert restored["a"] == 42
assert restored.result_of("a").status == TaskStatus.SUCCESS
assert restored.result_of("a").attempts == 1
assert restored.result_of("a").duration == 0.5
assert restored.result_of("a").started_at == datetime(2024, 1, 1, 0, 0, 0)
assert restored.result_of("a").finished_at == datetime(2024, 1, 1, 0, 0, 0, 500000)
assert restored.result_of("a").spec.tags == ("ingest",)
assert restored.result_of("a").spec.depends_on == ("x",)
assert restored.result_of("b").status == TaskStatus.SKIPPED
assert restored.result_of("b").reason == "条件不满足"
def test_from_json_preserves_order(self) -> None:
"""from_json 应保持 results 顺序。"""
report = px.RunReport()
report.results["a"] = _make_result("a")
report.results["b"] = _make_result("b")
report.results["c"] = _make_result("c")
restored = px.RunReport.from_json(report.to_json())
assert list(restored) == ["a", "b", "c"]
def test_from_json_empty_report(self) -> None:
"""空报告应可正确反序列化。"""
report = px.RunReport()
restored = px.RunReport.from_json(report.to_json())
assert len(restored) == 0
assert restored.success is True
def test_from_json_invalid_status_raises(self) -> None:
"""非法 status 值应抛 ValueErrorTaskStatus 枚举校验)。"""
data: dict[str, Any] = {
"success": True,
"summary": {},
"results": [{"name": "a", "status": "unknown_status"}],
}
with pytest.raises(ValueError):
_ = px.RunReport.from_json(json.dumps(data))
def test_from_json_missing_optional_fields(self) -> None:
"""缺少可选字段(tags/depends_on/attempts)应使用默认值。"""
data = {
"success": False,
"results": [{"name": "a", "status": "success"}],
}
restored = px.RunReport.from_json(json.dumps(data))
assert restored.success is False
assert restored.result_of("a").attempts == 0
assert restored.result_of("a").spec.tags == ()
assert restored.result_of("a").spec.depends_on == ()
def test_from_json_invalid_json_raises(self) -> None:
"""非法 JSON 字符串应抛 JSONDecodeError。"""
with pytest.raises(json.JSONDecodeError):
_ = px.RunReport.from_json("not json{")
class TestRunReportSerializationIntegration:
"""序列化与实际执行流程的集成测试。"""
def test_real_run_serialization_round_trip(self) -> None:
"""实际 run() 产出的报告应可序列化/反序列化。"""
def extract() -> list[int]:
return [1, 2, 3]
def double(extract: list[int]) -> list[int]:
return [x * 2 for x in extract]
graph = px.Graph.from_specs(
[
px.TaskSpec("extract", extract, tags=("ingest",)),
px.TaskSpec("double", double, depends_on=("extract",), tags=("transform",)),
]
)
report = px.run(graph, strategy="sequential")
# 序列化 → 反序列化
text = report.to_json()
restored = px.RunReport.from_json(text)
assert restored.success is True
assert restored["double"] == [2, 4, 6]
assert restored.result_of("extract").spec.tags == ("ingest",)
assert restored.result_of("double").spec.depends_on == ("extract",)
def test_csv_export_from_real_run(self) -> None:
"""实际 run() 报告应可导出 CSV。"""
def extract() -> list[int]:
return [1, 2, 3]
graph = px.Graph.from_specs([px.TaskSpec("extract", extract)])
report = px.run(graph, strategy="sequential")
csv_text = report.to_csv()
assert "extract" in csv_text
assert "success" in csv_text
# 值应被 repr 化(列表用 repr)
assert "[1, 2, 3]" in csv_text
class TestRunId:
"""run_id 字段与日志追踪集成测试。"""
def test_default_run_id_is_8_char_hex(self) -> None:
"""默认 run_id 应为 8 字符十六进制字符串。"""
report = px.RunReport()
assert len(report.run_id) == 8
int(report.run_id, 16) # 合法 hex
def test_each_report_has_unique_run_id(self) -> None:
"""多次创建 RunReport 应得到不同 run_id(概率意义上)。"""
ids = {px.RunReport().run_id for _ in range(100)}
assert len(ids) >= 95 # 允许极少碰撞
def test_explicit_run_id(self) -> None:
"""构造时可显式指定 run_id。"""
report = px.RunReport(run_id="deadbeef")
assert report.run_id == "deadbeef"
def test_run_id_in_summary(self) -> None:
"""summary() 应包含 run_id。"""
report = px.RunReport()
assert report.summary()["run_id"] == report.run_id
def test_run_id_in_to_dict(self) -> None:
"""to_dict() 顶层应含 run_id。"""
report = px.RunReport()
report.results["a"] = _make_result("a")
data = report.to_dict()
assert data["run_id"] == report.run_id
def test_run_id_in_to_json(self) -> None:
"""to_json() 输出应包含 run_id。"""
report = px.RunReport(run_id="cafef00d")
text = report.to_json()
assert "cafef00d" in text
def test_from_json_preserves_run_id(self) -> None:
"""from_json 应保留原 run_id。"""
report = px.RunReport(run_id="abc12345")
report.results["a"] = _make_result("a")
restored = px.RunReport.from_json(report.to_json())
assert restored.run_id == "abc12345"
def test_from_json_generates_run_id_when_missing(self) -> None:
"""旧版 JSON(无 run_id 字段)反序列化时应自动生成。"""
data: dict[str, Any] = {"success": True, "results": []}
restored = px.RunReport.from_json(json.dumps(data))
assert len(restored.run_id) == 8
def test_real_run_report_has_run_id(self) -> None:
"""真实 run() 返回的报告应携带 run_id。"""
def task_a() -> int:
return 1
graph = px.Graph.from_specs([px.TaskSpec("a", task_a)])
report = px.run(graph, strategy="sequential")
assert len(report.run_id) == 8
def test_profile_returns_profile_report(self) -> None:
"""profile(graph) 应返回 ProfileReport 实例。"""
def task_a() -> int:
return 1
def task_b(a: int) -> int:
return a * 2
graph = px.Graph.from_specs(
[
px.TaskSpec("a", task_a),
px.TaskSpec("b", task_b, depends_on=("a",)),
]
)
report = px.run(graph, strategy="sequential")
profile = report.profile(graph)
assert profile.total_duration >= 0
assert any(t.name == "a" for t in profile.tasks)
assert any(t.name == "b" for t in profile.tasks)
class TestStructuredLogging:
"""结构化日志:run_id 应通过 extra 字段注入日志记录。"""
def test_run_emits_run_id_in_extra(self, caplog: pytest.LogCaptureFixture) -> None:
"""run() 应在日志中通过 extra 携带 run_id 字段。"""
def task_a() -> int:
return 1
graph = px.Graph.from_specs([px.TaskSpec("a", task_a)])
with caplog.at_level("INFO", logger="pyflowx.executors"):
report = px.run(graph, strategy="sequential")
# 找到带 run_id extra 的日志记录
records_with_run_id = [r for r in caplog.records if hasattr(r, "run_id")]
assert records_with_run_id, "应至少有一条日志携带 run_id extra 字段"
# 所有 run_id extra 应与 report.run_id 一致
assert all(r.run_id == report.run_id for r in records_with_run_id) # type: ignore[attr-defined]
def test_failed_task_emits_structured_extra(self, caplog: pytest.LogCaptureFixture) -> None:
"""任务失败时日志 extra 应包含 task_name/error_type/attempts。"""
def boom() -> None:
raise RuntimeError("kaboom")
graph = px.Graph.from_specs([px.TaskSpec("a", boom)])
with caplog.at_level("WARNING", logger="pyflowx.executors"), contextlib.suppress(px.TaskFailedError):
px.run(graph, strategy="sequential", verbose=False)
failure_records = [r for r in caplog.records if hasattr(r, "task_name") and r.task_name == "a"] # type: ignore[union-attr]
assert failure_records, "失败任务应发出携带 task_name 的结构化日志"
rec = failure_records[0]
assert rec.error_type == "RuntimeError" # type: ignore[attr-defined]
assert rec.attempts == 1 # type: ignore[attr-defined]
assert rec.status == "failed" # type: ignore[attr-defined]
def test_cached_task_emits_structured_extra(self, caplog: pytest.LogCaptureFixture, tmp_path: Path) -> None:
"""缓存命中日志应携带 run_id/task_name/status extra。"""
def task_a() -> int:
return 42
graph = px.Graph.from_specs([px.TaskSpec("a", task_a)])
backend = px.JSONBackend(str(tmp_path / "state.json"))
# 第一次运行写入缓存
px.run(graph, strategy="sequential", state=backend)
# 第二次运行应命中缓存
with caplog.at_level("INFO", logger="pyflowx.executors"):
report = px.run(graph, strategy="sequential", state=backend)
cached_records = [
r
for r in caplog.records
if hasattr(r, "task_name") and r.task_name == "a" and "cached" in r.getMessage() # type: ignore[union-attr]
]
assert cached_records, "缓存命中应有结构化日志"
rec = cached_records[0]
assert rec.run_id == report.run_id # type: ignore[attr-defined]
assert rec.status == "skipped" # type: ignore[attr-defined]