824 lines
31 KiB
Python
824 lines
31 KiB
Python
"""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( # noqa: PLR0913
|
||
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(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(
|
||
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("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("a", _fn) # type: ignore[arg-type]
|
||
report.results["a"] = TaskResult(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("a", _fn) # type: ignore[arg-type]
|
||
report.results["a"] = TaskResult(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("a", _fn) # type: ignore[arg-type]
|
||
report.results["a"] = TaskResult(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(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 "<script>" 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 "<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 "<b>" 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()
|
||
# orjson 紧凑输出无空格([1,2,3]),标准库带空格([1, 2, 3]),两者均接受
|
||
assert "[1,2,3]" in s or "[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("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 值应抛 ValueError(TaskStatus 枚举校验)。"""
|
||
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]
|