"""失败诊断模块测试。""" 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