merge: T15 报告编排骨架与失败退款(T-P3-07/09)
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""report 流水线编排的纯逻辑单测(不连库)。
|
||||
|
||||
覆盖任务文档 §4 的两条:assemble_stages 缺失即 CONFIG_ERROR 且列出缺失项;
|
||||
run_pipeline 顺序执行、产物按键写入、第二阶段抛错 → failed 且已完成阶段
|
||||
产物仍在。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.report.pipeline import (
|
||||
ReportContext,
|
||||
StageFailureError,
|
||||
assemble_stages,
|
||||
run_pipeline,
|
||||
)
|
||||
from packages.shared.config import Settings
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.models import RpInput, RpReport
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""只满足 flush/commit/rollback 的最小会话——单测不连库。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.flush_count = 0
|
||||
self.commit_count = 0
|
||||
|
||||
async def flush(self) -> None:
|
||||
self.flush_count += 1
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commit_count += 1
|
||||
|
||||
async def rollback(self) -> None: ...
|
||||
|
||||
|
||||
def _stage_settings(*paths: str) -> Settings:
|
||||
"""按别名构造阶段配置——字段名带 alias 且未开 populate_by_name,
|
||||
用字段名构造会被静默忽略、落回默认值(踩过坑,写下来防再踩)。"""
|
||||
return Settings(REPORT_PIPELINE_STAGES_JSON=json.dumps(list(paths)))
|
||||
|
||||
|
||||
def _make_ctx() -> tuple[ReportContext, FakeSession]:
|
||||
# 瞬态 ORM 对象:default=dict 只在 INSERT 时生效,这里必须显式给 JSONB 列初值。
|
||||
report = RpReport(
|
||||
report_id=101,
|
||||
user_id=1,
|
||||
status="running",
|
||||
result={},
|
||||
model_info={},
|
||||
)
|
||||
report_input = RpInput(
|
||||
input_id=11,
|
||||
report_id=101,
|
||||
questionnaire={},
|
||||
photo_meta={},
|
||||
derived_features={},
|
||||
)
|
||||
session = FakeSession()
|
||||
ctx = ReportContext(
|
||||
report=report,
|
||||
report_input=report_input,
|
||||
session=session, # type: ignore[arg-type]
|
||||
settings=Settings(),
|
||||
)
|
||||
return ctx, session
|
||||
|
||||
|
||||
class AlphaStage:
|
||||
name = "alpha"
|
||||
|
||||
async def run(self, ctx: ReportContext) -> None:
|
||||
ctx.report.result["alpha"] = {"value": 1}
|
||||
ctx.report_input.derived_features["pose"] = {"kps": 3}
|
||||
|
||||
|
||||
class BetaStage:
|
||||
name = "beta"
|
||||
|
||||
async def run(self, ctx: ReportContext) -> None:
|
||||
# 顺序契约:后一阶段能读到前一阶段的产物。
|
||||
assert ctx.report.result.get("alpha") == {"value": 1}
|
||||
ctx.report.result["beta"] = {"value": ctx.report.result["alpha"]["value"] + 1}
|
||||
|
||||
|
||||
class BoomStage:
|
||||
name = "boom"
|
||||
|
||||
async def run(self, ctx: ReportContext) -> None:
|
||||
raise RuntimeError("爆了:模拟阶段失败")
|
||||
|
||||
|
||||
async def test_run_pipeline_executes_stages_in_order_and_flushes_each() -> None:
|
||||
ctx, session = _make_ctx()
|
||||
|
||||
await run_pipeline(ctx, [AlphaStage(), BetaStage()])
|
||||
|
||||
assert ctx.report.result == {"alpha": {"value": 1}, "beta": {"value": 2}}
|
||||
assert ctx.report_input.derived_features == {"pose": {"kps": 3}}
|
||||
# 每个阶段一次 flush:中间态逐段落库(T-P3-09)的结构保证。
|
||||
assert session.flush_count == 2
|
||||
|
||||
|
||||
async def test_run_pipeline_failure_keeps_completed_stage_and_marks_failed() -> None:
|
||||
ctx, session = _make_ctx()
|
||||
|
||||
with pytest.raises(StageFailureError) as excinfo:
|
||||
await run_pipeline(ctx, [AlphaStage(), BoomStage()])
|
||||
|
||||
assert excinfo.value.stage_name == "boom"
|
||||
assert ctx.report.status == "failed"
|
||||
# 已完成阶段的产物与视觉中间态都还在——重试/重生成不重跑的结构基础。
|
||||
assert ctx.report.result["alpha"] == {"value": 1}
|
||||
assert ctx.report_input.derived_features == {"pose": {"kps": 3}}
|
||||
# 错误摘要写进 result,且失败结案在事务里先行提交(退款事务纪律)。
|
||||
assert ctx.report.result["error"]["stage"] == "boom"
|
||||
assert ctx.report.result["error"]["type"] == "RuntimeError"
|
||||
assert session.commit_count == 1
|
||||
|
||||
|
||||
def test_assemble_stages_lists_all_missing_paths() -> None:
|
||||
settings = _stage_settings(
|
||||
"packages.report.no_such_module:Nope",
|
||||
"packages.report.pipeline:NotAClass",
|
||||
)
|
||||
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
assemble_stages(settings)
|
||||
|
||||
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
|
||||
assert excinfo.value.data == {
|
||||
"missing": [
|
||||
"packages.report.no_such_module:Nope",
|
||||
"packages.report.pipeline:NotAClass",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_assemble_stages_rejects_empty_config() -> None:
|
||||
settings = _stage_settings()
|
||||
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
assemble_stages(settings)
|
||||
|
||||
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
|
||||
|
||||
|
||||
class _NotAStage:
|
||||
"""存在但不满足 Stage 协议(没有 run)——必须被当成配置错误拦下。"""
|
||||
|
||||
name = "broken"
|
||||
|
||||
|
||||
def test_assemble_stages_rejects_protocol_violations() -> None:
|
||||
settings = _stage_settings(
|
||||
"tests.unit.test_report_pipeline:AlphaStage",
|
||||
"tests.unit.test_report_pipeline:_NotAStage",
|
||||
)
|
||||
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
assemble_stages(settings)
|
||||
|
||||
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
|
||||
assert excinfo.value.data == {"missing": ["tests.unit.test_report_pipeline:_NotAStage"]}
|
||||
|
||||
|
||||
def test_assemble_stages_imports_from_any_module_path() -> None:
|
||||
"""dotted path 装配对本模块的 Fake 阶段同样生效(importlib 动态导入路径)。"""
|
||||
settings = _stage_settings("tests.unit.test_report_pipeline:AlphaStage")
|
||||
stages = assemble_stages(settings)
|
||||
assert [s.name for s in stages] == ["alpha"]
|
||||
Reference in New Issue
Block a user