536 lines
20 KiB
Python
536 lines
20 KiB
Python
"""失败诊断模块测试。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
import pyflowx as px
|
||
from pyflowx.diagnostics import DiagnosticReport, diagnose
|
||
from pyflowx.task import TaskResult, TaskSpec, TaskStatus
|
||
|
||
|
||
def _fn() -> None:
|
||
return None
|
||
|
||
|
||
def _make_result(
|
||
name: str,
|
||
status: TaskStatus = TaskStatus.SUCCESS,
|
||
value: Any = None,
|
||
error: BaseException | None = None,
|
||
depends_on: tuple[str, ...] = (),
|
||
soft_depends_on: tuple[str, ...] = (),
|
||
reason: str | None = None,
|
||
attempts: int = 1,
|
||
) -> TaskResult[Any]:
|
||
"""构造测试用 TaskResult。"""
|
||
spec: TaskSpec[Any] = TaskSpec[Any](name, _fn, depends_on=depends_on, soft_depends_on=soft_depends_on)
|
||
start = datetime(2024, 1, 1, 0, 0, 0)
|
||
end = datetime(2024, 1, 1, 0, 0, 1) if status != TaskStatus.PENDING else None
|
||
return TaskResult[Any](
|
||
spec=spec,
|
||
status=status,
|
||
value=value,
|
||
error=error,
|
||
attempts=attempts,
|
||
started_at=start,
|
||
finished_at=end,
|
||
reason=reason,
|
||
)
|
||
|
||
|
||
def _make_failed_report(
|
||
failures: dict[str, BaseException],
|
||
deps: dict[str, tuple[str, ...]] | None = None,
|
||
extra_results: dict[str, TaskResult[Any]] | None = None,
|
||
) -> px.RunReport:
|
||
"""构造包含指定失败任务的 RunReport。
|
||
|
||
参数
|
||
----
|
||
failures:
|
||
任务名 -> 异常对象。这些任务状态为 FAILED。
|
||
deps:
|
||
任务名 -> 硬依赖列表。仅对失败任务和额外结果中未指定的任务生效。
|
||
extra_results:
|
||
额外的 TaskResult(如 SUCCESS/SKIPPED 任务)。
|
||
"""
|
||
deps = deps or {}
|
||
extra_results = extra_results or {}
|
||
report = px.RunReport(success=False)
|
||
for name, err in failures.items():
|
||
report.results[name] = _make_result(
|
||
name,
|
||
status=TaskStatus.FAILED,
|
||
error=err,
|
||
depends_on=deps.get(name, ()),
|
||
)
|
||
for name, result in extra_results.items():
|
||
if name not in report.results:
|
||
report.results[name] = result
|
||
return report
|
||
|
||
|
||
# ---------------------------------------------------------------------- #
|
||
# diagnose 基础行为
|
||
# ---------------------------------------------------------------------- #
|
||
class TestDiagnoseBasic:
|
||
"""diagnose() 基础行为测试。"""
|
||
|
||
def test_diagnose_success_returns_none(self) -> None:
|
||
"""成功报告应返回 None。"""
|
||
report = px.RunReport(success=True)
|
||
assert diagnose(report) is None
|
||
|
||
def test_diagnose_no_failures_returns_report(self) -> None:
|
||
"""success=False 但无 FAILED 任务时仍返回报告(带提示)。"""
|
||
report = px.RunReport(success=False)
|
||
report.results["a"] = _make_result("a", status=TaskStatus.SKIPPED, reason="手动跳过")
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.failed_tasks == []
|
||
assert any("无失败任务" in h for h in diag.hints)
|
||
|
||
def test_diagnose_single_failure(self) -> None:
|
||
"""单个失败任务:根因即自身,链长度为 1。"""
|
||
report = _make_failed_report({"a": ValueError("bad value")})
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.failed_tasks == ["a"]
|
||
assert diag.root_causes == ["a"]
|
||
assert len(diag.dependency_chains) == 1
|
||
assert diag.dependency_chains[0].failed_task == "a"
|
||
assert diag.dependency_chains[0].root_cause == "a"
|
||
assert diag.dependency_chains[0].chain == ["a"]
|
||
assert diag.dependency_chains[0].broken_at == "a"
|
||
|
||
def test_diagnose_returns_diagnostic_report_type(self) -> None:
|
||
"""返回值应为 DiagnosticReport 类型。"""
|
||
report = _make_failed_report({"a": ValueError("x")})
|
||
diag = diagnose(report)
|
||
assert isinstance(diag, DiagnosticReport)
|
||
|
||
|
||
# ---------------------------------------------------------------------- #
|
||
# 依赖链回溯
|
||
# ---------------------------------------------------------------------- #
|
||
class TestDependencyChainTracing:
|
||
"""依赖链回溯测试。"""
|
||
|
||
def test_chain_single_dependency(self) -> None:
|
||
"""a -> b 链:a 失败导致 b 失败时,根因为 a。"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": FileNotFoundError("/tmp/missing.txt"),
|
||
"b": RuntimeError("a failed"),
|
||
},
|
||
deps={"b": ("a",)},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.root_causes == ["a"]
|
||
# b 的链应为 a -> b
|
||
b_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "b")
|
||
assert b_chain.root_cause == "a"
|
||
assert b_chain.chain == ["a", "b"]
|
||
assert b_chain.broken_at == "a"
|
||
|
||
def test_chain_diamond_dependency(self) -> None:
|
||
"""菱形依赖:a -> b, a -> c, b -> d, c -> d;a 失败导致 b 失败,进而导致 d 失败。"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": ImportError("missing module"),
|
||
"b": RuntimeError("a failed"),
|
||
"d": RuntimeError("b failed"),
|
||
},
|
||
deps={"b": ("a",), "c": ("a",), "d": ("b", "c")},
|
||
extra_results={
|
||
"c": _make_result("c", status=TaskStatus.SUCCESS, depends_on=("a",)),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.root_causes == ["a"]
|
||
# d 的链应从 a 开始
|
||
d_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "d")
|
||
assert d_chain.root_cause == "a"
|
||
assert d_chain.chain[0] == "a"
|
||
assert d_chain.chain[-1] == "d"
|
||
assert d_chain.broken_at == "a"
|
||
|
||
def test_chain_multiple_root_causes(self) -> None:
|
||
"""多个独立根因:a 和 x 都失败但无依赖关系。"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": ValueError("a error"),
|
||
"x": KeyError("x error"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert set(diag.root_causes) == {"a", "x"}
|
||
|
||
def test_chain_cascade_failure(self) -> None:
|
||
"""级联失败:a -> b -> c,a 失败导致 b、c 都失败。"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": ValueError("root"),
|
||
"b": RuntimeError("a failed"),
|
||
"c": RuntimeError("b failed"),
|
||
},
|
||
deps={"b": ("a",), "c": ("b",)},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.root_causes == ["a"]
|
||
# c 的链应为 a -> b -> c
|
||
c_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "c")
|
||
assert c_chain.chain == ["a", "b", "c"]
|
||
assert c_chain.root_cause == "a"
|
||
|
||
def test_chain_with_successful_dependency(self) -> None:
|
||
"""失败任务的依赖若成功,则失败任务自身为根因。"""
|
||
report = _make_failed_report(
|
||
failures={"b": ValueError("b error")},
|
||
deps={"b": ("a",)},
|
||
extra_results={
|
||
"a": _make_result("a", status=TaskStatus.SUCCESS),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.root_causes == ["b"]
|
||
b_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "b")
|
||
assert b_chain.root_cause == "b"
|
||
assert b_chain.chain == ["b"]
|
||
|
||
def test_chain_with_missing_dependency_in_results(self) -> None:
|
||
"""失败任务依赖不在 results 中(如 only= 过滤)时,自身为根因。"""
|
||
report = _make_failed_report(
|
||
failures={"b": ValueError("b error")},
|
||
deps={"b": ("a",)}, # a 不在 results 中
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.root_causes == ["b"]
|
||
b_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "b")
|
||
assert b_chain.root_cause == "b"
|
||
|
||
def test_chain_with_skipped_dependency(self) -> None:
|
||
"""失败任务的依赖为 SKIPPED 时,自身为根因(SKIPPED 非 FAILED)。"""
|
||
report = _make_failed_report(
|
||
failures={"b": ValueError("b error")},
|
||
deps={"b": ("a",)},
|
||
extra_results={
|
||
"a": _make_result("a", status=TaskStatus.SKIPPED, reason="条件不满足"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.root_causes == ["b"]
|
||
|
||
def test_chain_circular_dependency(self) -> None:
|
||
"""循环依赖:a -> b -> a,diagnose 不依赖 Graph 校验应安全处理。
|
||
|
||
构造带环的 results(绕过 Graph 校验),验证 _trace 的 visited 防御
|
||
防止无限递归,并以其中一个任务作为断点返回。
|
||
"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": ValueError("a error"),
|
||
"b": RuntimeError("b error"),
|
||
},
|
||
deps={"a": ("b",), "b": ("a",)}, # 环:a 依赖 b,b 依赖 a
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
# 两者都有 failed_deps,都不满足根因条件(FAILED 且无 failed_deps)
|
||
assert diag.root_causes == []
|
||
# 依赖链仍应返回(递归遇到 visited 后以当前任务作为断点)
|
||
assert len(diag.dependency_chains) == 2
|
||
|
||
|
||
# ---------------------------------------------------------------------- #
|
||
# 相似失败聚类
|
||
# ---------------------------------------------------------------------- #
|
||
class TestFailureClusters:
|
||
"""相似失败聚类测试。"""
|
||
|
||
def test_cluster_same_exception_type(self) -> None:
|
||
"""相同异常类型 + 消息应聚类。"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": ValueError("invalid input"),
|
||
"b": ValueError("invalid input"),
|
||
"c": ValueError("invalid input"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert len(diag.failure_clusters) == 1
|
||
cluster = diag.failure_clusters[0]
|
||
assert cluster.pattern.startswith("ValueError")
|
||
assert set(cluster.tasks) == {"a", "b", "c"}
|
||
|
||
def test_cluster_different_messages_separate(self) -> None:
|
||
"""相同类型但不同消息应分属不同聚类。"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": ValueError("error one"),
|
||
"b": ValueError("error two"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert len(diag.failure_clusters) == 2
|
||
|
||
def test_cluster_sorted_by_size(self) -> None:
|
||
"""聚类按任务数降序排列。"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": KeyError("k1"),
|
||
"b": KeyError("k1"),
|
||
"c": ValueError("v1"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
# KeyError 聚类(2 个任务)应在 ValueError 聚类(1 个任务)之前
|
||
assert len(diag.failure_clusters[0].tasks) >= len(diag.failure_clusters[1].tasks)
|
||
|
||
def test_cluster_sample_error_populated(self) -> None:
|
||
"""聚类应包含代表性错误消息。"""
|
||
report = _make_failed_report(
|
||
failures={"a": ValueError("test message")},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.failure_clusters[0].sample_error != ""
|
||
|
||
|
||
# ---------------------------------------------------------------------- #
|
||
# 根因提示
|
||
# ---------------------------------------------------------------------- #
|
||
class TestHints:
|
||
"""根因提示测试。"""
|
||
|
||
@pytest.mark.parametrize(
|
||
("exception", "expected_keyword"),
|
||
[
|
||
(FileNotFoundError("/tmp/missing.txt"), "路径"),
|
||
(ImportError("missing_module"), "依赖"),
|
||
(ModuleNotFoundError("missing_module"), "模块"),
|
||
(TimeoutError(), "超时"),
|
||
(ConnectionError("refused"), "网络"),
|
||
(PermissionError("denied"), "权限"),
|
||
(KeyError("missing_key"), "上下文"),
|
||
(MemoryError(), "内存"),
|
||
],
|
||
)
|
||
def test_hint_for_common_exceptions(
|
||
self,
|
||
exception: BaseException,
|
||
expected_keyword: str,
|
||
) -> None:
|
||
"""常见异常应生成包含关键词的提示。"""
|
||
report = _make_failed_report({"a": exception})
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert any(expected_keyword in h for h in diag.hints)
|
||
|
||
def test_hint_unknown_exception_no_hint(self) -> None:
|
||
"""未知异常类型不应生成提示。"""
|
||
report = _make_failed_report({"a": RuntimeError("custom error")})
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert diag.hints == []
|
||
|
||
def test_hint_deduplicated_by_type(self) -> None:
|
||
"""相同异常类型的多个根因只生成一条提示。"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": FileNotFoundError("/a"),
|
||
"b": FileNotFoundError("/b"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
file_hints = [h for h in diag.hints if "路径" in h]
|
||
assert len(file_hints) == 1
|
||
|
||
def test_hint_includes_task_name(self) -> None:
|
||
"""提示应包含根因任务名。"""
|
||
report = _make_failed_report({"my_task": FileNotFoundError("/x")})
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert any("my_task" in h for h in diag.hints)
|
||
|
||
def test_hint_parent_class_matching(self) -> None:
|
||
"""异常的父类在 _HINT_MAP 中时应匹配父类提示。"""
|
||
# BlockingIOError 是 OSError 的子类
|
||
report = _make_failed_report({"a": BlockingIOError("resource busy")})
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert any("系统错误" in h for h in diag.hints)
|
||
|
||
|
||
# ---------------------------------------------------------------------- #
|
||
# 跳过的任务识别
|
||
# ---------------------------------------------------------------------- #
|
||
class TestSkippedDueToFailure:
|
||
"""因失败而跳过的任务识别测试。"""
|
||
|
||
def test_skipped_with_failure_reason(self) -> None:
|
||
"""reason 含"失败"的 SKIPPED 任务应被识别。"""
|
||
report = _make_failed_report(
|
||
failures={"a": ValueError("x")},
|
||
extra_results={
|
||
"b": _make_result("b", status=TaskStatus.SKIPPED, reason="上游任务失败"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert "b" in diag.skipped_due_to_failure
|
||
|
||
def test_skipped_with_cancel_reason(self) -> None:
|
||
"""reason 含"取消"的 SKIPPED 任务应被识别。"""
|
||
report = _make_failed_report(
|
||
failures={"a": ValueError("x")},
|
||
extra_results={
|
||
"b": _make_result("b", status=TaskStatus.SKIPPED, reason="执行被取消"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert "b" in diag.skipped_due_to_failure
|
||
|
||
def test_skipped_with_unrelated_reason_excluded(self) -> None:
|
||
"""reason 不含失败/取消关键词的 SKIPPED 任务不应被识别。"""
|
||
report = _make_failed_report(
|
||
failures={"a": ValueError("x")},
|
||
extra_results={
|
||
"b": _make_result("b", status=TaskStatus.SKIPPED, reason="条件不满足"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
assert "b" not in diag.skipped_due_to_failure
|
||
|
||
|
||
# ---------------------------------------------------------------------- #
|
||
# DiagnosticReport.describe()
|
||
# ---------------------------------------------------------------------- #
|
||
class TestDiagnosticReportDescribe:
|
||
"""DiagnosticReport.describe() 测试。"""
|
||
|
||
def test_describe_includes_sections(self) -> None:
|
||
"""describe() 应包含根因、依赖链、聚类、跳过、建议等节。"""
|
||
report = _make_failed_report(
|
||
failures={
|
||
"a": FileNotFoundError("/x"),
|
||
"b": RuntimeError("upstream"),
|
||
},
|
||
deps={"b": ("a",)},
|
||
extra_results={
|
||
"c": _make_result("c", status=TaskStatus.SKIPPED, reason="上游任务失败"),
|
||
},
|
||
)
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
text = diag.describe()
|
||
assert "失败诊断报告" in text
|
||
assert "根因任务" in text
|
||
assert "依赖链" in text
|
||
assert "相似失败聚类" in text
|
||
assert "因失败而跳过的任务" in text
|
||
assert "建议" in text
|
||
assert "路径" in text # FileNotFoundError 提示
|
||
|
||
def test_describe_no_root_causes(self) -> None:
|
||
"""无根因时应显示"无法识别"。"""
|
||
report = px.RunReport(success=False)
|
||
report.results["a"] = _make_result("a", status=TaskStatus.SKIPPED, reason="手动跳过")
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
text = diag.describe()
|
||
assert "无法识别" in text
|
||
|
||
def test_describe_empty_hints(self) -> None:
|
||
"""无提示时节标题应省略。"""
|
||
report = _make_failed_report({"a": RuntimeError("custom")})
|
||
diag = diagnose(report)
|
||
assert diag is not None
|
||
text = diag.describe()
|
||
assert "建议" not in text
|
||
|
||
|
||
# ---------------------------------------------------------------------- #
|
||
# RunReport 集成
|
||
# ---------------------------------------------------------------------- #
|
||
class TestRunReportIntegration:
|
||
"""RunReport.diagnose() 集成测试。"""
|
||
|
||
def test_report_diagnose_method(self) -> None:
|
||
"""RunReport.diagnose() 应与模块级 diagnose() 等价。"""
|
||
report = _make_failed_report({"a": ValueError("x")})
|
||
via_method = report.diagnose()
|
||
via_func = diagnose(report)
|
||
assert via_method is not None
|
||
assert via_func is not None
|
||
assert via_method.failed_tasks == via_func.failed_tasks
|
||
assert via_method.root_causes == via_func.root_causes
|
||
|
||
def test_report_describe_includes_diagnostics_on_failure(self) -> None:
|
||
"""失败时 describe() 应附诊断摘要。"""
|
||
report = _make_failed_report({"a": FileNotFoundError("/x")})
|
||
text = report.describe()
|
||
assert "失败诊断报告" in text
|
||
assert "路径" in text # 提示关键词
|
||
|
||
def test_report_describe_no_diagnostics_on_success(self) -> None:
|
||
"""成功时 describe() 不应包含诊断。"""
|
||
report = px.RunReport(success=True)
|
||
report.results["a"] = _make_result("a")
|
||
text = report.describe()
|
||
assert "失败诊断报告" not in text
|
||
|
||
def test_real_run_failure_diagnosis(self) -> None:
|
||
"""实际 run() 失败应可生成诊断报告。"""
|
||
|
||
def boom() -> None:
|
||
raise FileNotFoundError("/tmp/missing.txt")
|
||
|
||
def downstream(_boom: None) -> int:
|
||
return 1
|
||
|
||
# continue_on_error=True 使失败不抛异常而写入报告(success 保持 True)
|
||
graph = px.Graph.from_specs(
|
||
[
|
||
px.TaskSpec("boom", boom, continue_on_error=True),
|
||
px.TaskSpec("downstream", downstream, depends_on=("boom",)),
|
||
]
|
||
)
|
||
report = px.run(graph, strategy="sequential")
|
||
# continue_on_error 不影响 success 标志,手动标记以触发诊断
|
||
report.success = False
|
||
diag = report.diagnose()
|
||
assert diag is not None
|
||
assert "boom" in diag.root_causes
|
||
assert any("路径" in h for h in diag.hints)
|
||
|
||
def test_diagnose_works_with_from_json(self) -> None:
|
||
"""反序列化后的报告也应可诊断(不依赖 Graph)。"""
|
||
report = _make_failed_report(
|
||
failures={"a": ValueError("x"), "b": RuntimeError("upstream")},
|
||
deps={"b": ("a",)},
|
||
)
|
||
text = report.to_json()
|
||
restored = px.RunReport.from_json(text)
|
||
restored.success = False # from_json 默认 success 来自 JSON
|
||
diag = restored.diagnose()
|
||
assert diag is not None
|
||
assert "a" in diag.root_causes
|