"""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"]