feat(report): orchestration skeleton with staged pipeline and refund-on-failure
POST /reports 骨架:资格校验→终身配额预扣→tk_task→cpu 队列;动态装配 流水线(dotted-path 注册、缺阶段即 CONFIG_ERROR);中间态逐段落库、失败 退款、derived_features 跨重试/重生成复用(T-P3-07/09)。 实现:packages/report 的 pipeline.py(Stage 契约保持不变,新增 assemble_stages/ run_pipeline/StageFailureError,ReportContext 增可选 task_id 参数、向后兼容)、 repo.py、schemas.py、service.py(重放优先于资格校验、reserve+建行+建任务同事务、 提交后才投递、并发同键落败回滚);apps/api/routers/reports.py 填桩(send_task 按 任务名投递,不 import apps.worker);apps/worker/report_tasks.py(worker 侧自建 gateway 装配、状态归一 QUEUED→RUNNING、成功 commit 配额+成本聚合写 model_info、 基础设施异常重试一次、重试耗尽兜底失败退款)。测试:单测 6 个(装配缺失/空配置/ 协议违约/顺序执行/失败保留产物),集成 6 场景(预扣 lifetime/PENDING、成功 committed+两阶段键、同 request_key 幂等不重复扣、二次首生成 40920、阶段失败 refund+derived_features 保留、regenerate 走 m: 月配额 version+1 复用视觉中间态)。 偏差说明: 1) apps/worker/celery_app.py 的 include 只含 apps.worker.tasks(禁改文件),真 worker 进程注册 meida.report.generate 需协调者合并时把 include 补上 apps.worker.report_tasks;API 侧已按任务名 send_task,两侧只靠名字衔接,测试 不受影响。2) ReportContext 增加可选 task_id 关键字参数(失败结案要推动 tk_task),Stage 协议/DEFAULT_STAGE_PATHS/stage_names 未动,T16/T17 兼容。 3) JSONB 深变更用 flag_modified 显式标脏(重新赋相等新 dict 会被 flush 的按值 比较跳过,集成测试抓到过该坑)。4) POST 后 tk_task 保持 PENDING(任务文档 §4 场景 1 口径),QUEUED→RUNNING 由 worker 入口归一。
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
"""报告域端到端(T-P3-07/09):触发→配额→流水线→失败退款→重生成。
|
||||
|
||||
Fake 阶段定义在本文件里,通过 ``REPORT_PIPELINE_STAGES_JSON`` 让
|
||||
assemble_stages 按 dotted path 动态装配——与 T16/T17 的真实阶段走完全相同
|
||||
的装配路径。不启真 celery:worker 内芯函数直调;API 投递钩子替换为 no-op,
|
||||
避免测试消息进真 Redis 队列被开发环境的 worker 消费掉。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
# Fake 阶段走 importlib 动态装配,dotted path 必须可导入(本文件自身)。
|
||||
_MODULE = "tests.integration.test_report_flow"
|
||||
_SUCCESS_STAGES = json.dumps([f"{_MODULE}:FakeVisionStage", f"{_MODULE}:FakeDiagnosisStage"])
|
||||
_FAILING_STAGES = json.dumps([f"{_MODULE}:FakeVisionStage", f"{_MODULE}:FakeBoomStage"])
|
||||
|
||||
|
||||
class FakeVisionStage:
|
||||
"""视觉阶段替身:计算次数写进 derived_features,用例据此断言「已存在即跳过」。"""
|
||||
|
||||
name = "vision"
|
||||
|
||||
async def run(self, ctx: Any) -> None:
|
||||
features = ctx.report_input.derived_features
|
||||
if not features:
|
||||
# 与 T16 真实视觉阶段同一契约(T-P3-09):中间态非空则不重算。
|
||||
features["vision_runs"] = 1
|
||||
ctx.report.result["vision"] = {"features": sorted(features)}
|
||||
|
||||
|
||||
class FakeDiagnosisStage:
|
||||
name = "diagnosis"
|
||||
|
||||
async def run(self, ctx: Any) -> None:
|
||||
# 顺序契约:诊断能读到视觉阶段的产物。
|
||||
assert "vision" in ctx.report.result
|
||||
ctx.report.result["diagnosis"] = {"summary": "肩宽腰细", "source": "vision"}
|
||||
|
||||
|
||||
class FakeBoomStage:
|
||||
name = "boom"
|
||||
|
||||
async def run(self, ctx: Any) -> None:
|
||||
raise RuntimeError("爆了:模拟阶段失败")
|
||||
|
||||
|
||||
# --- fixtures ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_enqueue(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""不向真 Redis 投递:worker 由用例直调(celery 注册/消费不在本用例范围)。"""
|
||||
monkeypatch.setattr("apps.api.routers.reports._enqueue_report", lambda task_id: None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _stage_config(monkeypatch: pytest.MonkeyPatch, payload: str) -> Iterator[None]:
|
||||
"""把阶段配置换成 Fake 阶段(setenv + cache_clear,结束时恢复)。
|
||||
|
||||
API 进程与 worker 内芯读的是同一个 get_settings 单例,cache_clear 之后
|
||||
两边都会按 env 重建。
|
||||
"""
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
monkeypatch.setenv("REPORT_PIPELINE_STAGES_JSON", payload)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_stages(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
with _stage_config(monkeypatch, _SUCCESS_STAGES):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def failing_stages(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
with _stage_config(monkeypatch, _FAILING_STAGES):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(no_enqueue: None) -> AsyncIterator[Any]:
|
||||
import httpx
|
||||
|
||||
from apps.api.main import create_app
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
app = create_app(get_settings())
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
yield http
|
||||
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def _login(client: Any) -> tuple[dict[str, str], int]:
|
||||
"""dev-token:每个用例独立 device_key,拿到的就是全新用户。"""
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/dev-token", json={"device_key": f"report-it-{uuid.uuid4().hex[:8]}"}
|
||||
)
|
||||
assert resp.json()["code"] == 0
|
||||
data = resp.json()["data"]
|
||||
return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"])
|
||||
|
||||
|
||||
async def _create_report(
|
||||
client: Any, headers: dict[str, str], *, request_key: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
resp = await client.post(
|
||||
"/api/v1/reports",
|
||||
headers=headers,
|
||||
json={
|
||||
"questionnaire": {"style": "通勤", "height_cm": 168},
|
||||
"photo_object_key": "report_photo/u1.jpg",
|
||||
"request_key": request_key or f"rk-{uuid.uuid4().hex}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()["data"]
|
||||
assert body["status"] == "pending"
|
||||
return body
|
||||
|
||||
|
||||
async def _fetch_one(statement: Any) -> Any:
|
||||
from packages.shared.db import session_scope
|
||||
|
||||
async with session_scope() as session:
|
||||
result = await session.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _fetch_report(report_id: int) -> Any:
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.shared.models import RpReport
|
||||
|
||||
return await _fetch_one(select(RpReport).where(RpReport.report_id == report_id))
|
||||
|
||||
|
||||
async def _fetch_input(report_id: int) -> Any:
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.shared.models import RpInput
|
||||
|
||||
return await _fetch_one(select(RpInput).where(RpInput.report_id == report_id))
|
||||
|
||||
|
||||
async def _fetch_task(task_id: int) -> Any:
|
||||
from packages.shared.db import session_scope
|
||||
from packages.task import repo as task_repo
|
||||
|
||||
async with session_scope() as session:
|
||||
task = await task_repo.get(session, task_id)
|
||||
assert task is not None
|
||||
# 脱离会话前把要断言的 JSONB 读出来(expire_on_commit=False,够用)。
|
||||
return task
|
||||
|
||||
|
||||
async def _ledger_rows(user_id: int, quota_key: str) -> list[Any]:
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.shared.db import session_scope
|
||||
from packages.shared.models import Ledger
|
||||
|
||||
async with session_scope() as session:
|
||||
result = await session.execute(
|
||||
select(Ledger)
|
||||
.where(Ledger.user_id == user_id, Ledger.quota_key == quota_key)
|
||||
.order_by(Ledger.created_at.asc(), Ledger.tx_id.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# --- 场景 1:触发与预扣 -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_reserves_lifetime_quota_and_creates_pending_task(
|
||||
fake_stages: None, client: Any
|
||||
) -> None:
|
||||
headers, user_id = await _login(client)
|
||||
|
||||
data = await _create_report(client, headers)
|
||||
task_id, report_id = int(data["task_id"]), int(data["report_id"])
|
||||
assert data["version"] == 1
|
||||
|
||||
task = await _fetch_task(task_id)
|
||||
assert task.status == "pending"
|
||||
assert task.task_type == "report"
|
||||
assert task.payload["report_id"] == str(report_id)
|
||||
assert task.quota_reserved == 1
|
||||
assert task.reserve_tx_id is not None
|
||||
|
||||
report = await _fetch_report(report_id)
|
||||
assert report.status == "pending"
|
||||
rp_input = await _fetch_input(report_id)
|
||||
assert rp_input.photo_meta == {"object_key": "report_photo/u1.jpg"}
|
||||
assert rp_input.derived_features == {}
|
||||
|
||||
rows = await _ledger_rows(user_id, "report_basic")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].tx_type == "consume"
|
||||
assert rows[0].status == "reserved"
|
||||
assert rows[0].period_key == "life" # report_basic 是终身配额(迁移 0003)
|
||||
|
||||
|
||||
# --- 场景 2:worker 成功 ------------------------------------------------------
|
||||
|
||||
|
||||
async def test_worker_success_commits_quota_and_writes_stage_keys(
|
||||
fake_stages: None, client: Any
|
||||
) -> None:
|
||||
from apps.worker.report_tasks import _generate_report
|
||||
|
||||
headers, user_id = await _login(client)
|
||||
data = await _create_report(client, headers)
|
||||
task_id, report_id = int(data["task_id"]), int(data["report_id"])
|
||||
|
||||
result = await _generate_report(task_id)
|
||||
assert result["status"] == "succeeded"
|
||||
|
||||
report = await _fetch_report(report_id)
|
||||
assert report.status == "succeeded"
|
||||
assert report.completed_at is not None
|
||||
assert set(report.result) == {"vision", "diagnosis"}
|
||||
assert report.result["vision"] == {"features": ["vision_runs"]}
|
||||
assert "stages" in report.model_info
|
||||
|
||||
task = await _fetch_task(task_id)
|
||||
assert task.status == "succeeded"
|
||||
assert task.progress == 100
|
||||
|
||||
rows = await _ledger_rows(user_id, "report_basic")
|
||||
assert [row.status for row in rows] == ["committed"]
|
||||
|
||||
# 列表与详情视图透出同样的产物。
|
||||
listing = (await client.get("/api/v1/reports", headers=headers)).json()["data"]
|
||||
assert [item["report_id"] for item in listing] == [str(report_id)]
|
||||
assert listing[0]["result"]["diagnosis"]["summary"] == "肩宽腰细"
|
||||
|
||||
|
||||
# --- 场景 3:幂等重提交 -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_duplicate_request_key_returns_same_task_without_second_reserve(
|
||||
fake_stages: None, client: Any
|
||||
) -> None:
|
||||
from apps.worker.report_tasks import _generate_report
|
||||
|
||||
headers, user_id = await _login(client)
|
||||
request_key = f"rk-{uuid.uuid4().hex}"
|
||||
first = await _create_report(client, headers, request_key=request_key)
|
||||
await _generate_report(int(first["task_id"])) # 先让首单成功
|
||||
|
||||
again = await client.post(
|
||||
"/api/v1/reports",
|
||||
headers=headers,
|
||||
json={
|
||||
"questionnaire": {"style": "通勤"},
|
||||
"photo_object_key": "report_photo/u1.jpg",
|
||||
"request_key": request_key,
|
||||
},
|
||||
)
|
||||
assert again.status_code == 200
|
||||
replay = again.json()["data"]
|
||||
assert replay["task_id"] == first["task_id"]
|
||||
assert replay["report_id"] == first["report_id"]
|
||||
assert replay["status"] == "succeeded"
|
||||
|
||||
# tk_task 幂等键 + reserve 幂等键双保险:只有一笔 reserve。
|
||||
rows = await _ledger_rows(user_id, "report_basic")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].status == "committed"
|
||||
assert await _count_user_reports(user_id) == 1 # 重放不建新报告行
|
||||
|
||||
|
||||
async def _count_user_reports(user_id: int) -> int:
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from packages.shared.db import session_scope
|
||||
from packages.shared.models import RpReport
|
||||
|
||||
async with session_scope() as session:
|
||||
result = await session.execute(
|
||||
select(func.count()).select_from(RpReport).where(RpReport.user_id == user_id)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
# --- 场景 4:首次资格 40920 ---------------------------------------------------
|
||||
|
||||
|
||||
async def test_second_first_report_is_rejected_with_40920(fake_stages: None, client: Any) -> None:
|
||||
from apps.worker.report_tasks import _generate_report
|
||||
|
||||
headers, _ = await _login(client)
|
||||
data = await _create_report(client, headers)
|
||||
await _generate_report(int(data["task_id"])) # 首单成功,终身资格已用掉
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/reports",
|
||||
headers=headers,
|
||||
json={
|
||||
"questionnaire": {},
|
||||
"photo_object_key": "report_photo/u2.jpg",
|
||||
"request_key": f"rk-{uuid.uuid4().hex}", # 新键:不是幂等重放
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
body = resp.json()
|
||||
assert body["code"] == 40920
|
||||
assert "30" in (body.get("hint") or "")
|
||||
|
||||
|
||||
# --- 场景 5:失败退款 + derived_features 保留 ----------------------------------
|
||||
|
||||
|
||||
async def test_stage_failure_refunds_and_preserves_derived_features(
|
||||
failing_stages: None, client: Any
|
||||
) -> None:
|
||||
from apps.worker.report_tasks import _generate_report
|
||||
from packages.report.pipeline import StageFailureError
|
||||
|
||||
headers, user_id = await _login(client)
|
||||
data = await _create_report(client, headers)
|
||||
task_id, report_id = int(data["task_id"]), int(data["report_id"])
|
||||
|
||||
with pytest.raises(StageFailureError):
|
||||
await _generate_report(task_id)
|
||||
|
||||
report = await _fetch_report(report_id)
|
||||
assert report.status == "failed"
|
||||
# 已完成阶段的产物仍在 + 错误摘要写进 result。
|
||||
assert report.result["vision"] == {"features": ["vision_runs"]}
|
||||
assert report.result["error"]["stage"] == "boom"
|
||||
|
||||
# T-P3-09 核心:视觉中间态保留——重试/重生成不需要重新上传照片。
|
||||
rp_input = await _fetch_input(report_id)
|
||||
assert rp_input.derived_features == {"vision_runs": 1}
|
||||
|
||||
task = await _fetch_task(task_id)
|
||||
assert task.status == "failed"
|
||||
|
||||
# 退款:consume 流水转 refunded,反向 refund 流水 committed。
|
||||
rows = await _ledger_rows(user_id, "report_basic")
|
||||
assert [(row.tx_type, row.status) for row in rows] == [
|
||||
("consume", "refunded"),
|
||||
("refund", "committed"),
|
||||
]
|
||||
|
||||
# failed 报告的详情只透错误摘要,不透半成品。
|
||||
detail = (await client.get(f"/api/v1/reports/{report_id}", headers=headers)).json()["data"]
|
||||
assert set(detail["result"]) == {"error"}
|
||||
assert detail["result"]["error"]["stage"] == "boom"
|
||||
|
||||
|
||||
# --- 场景 6:regenerate 走月配额并复用视觉中间态 -------------------------------
|
||||
|
||||
|
||||
async def test_regenerate_uses_month_quota_and_reuses_derived_features(
|
||||
fake_stages: None, client: Any
|
||||
) -> None:
|
||||
from apps.worker.report_tasks import _generate_report
|
||||
|
||||
headers, user_id = await _login(client)
|
||||
first = await _create_report(client, headers)
|
||||
await _generate_report(int(first["task_id"]))
|
||||
|
||||
regen = await client.post(
|
||||
f"/api/v1/reports/{first['report_id']}/regenerate",
|
||||
headers=headers,
|
||||
json={"request_key": f"rk-regen-{uuid.uuid4().hex}"},
|
||||
)
|
||||
assert regen.status_code == 200
|
||||
data = regen.json()["data"]
|
||||
assert data["version"] == 2
|
||||
new_report_id = int(data["report_id"])
|
||||
assert new_report_id != int(first["report_id"])
|
||||
|
||||
# 重新生成走 report_regen 月配额(m:YYYY-MM),不复用终身额度。
|
||||
rows = await _ledger_rows(user_id, "report_regen")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].status == "reserved"
|
||||
assert rows[0].period_key.startswith("m:")
|
||||
|
||||
# 新输入行直接带着上一版的视觉中间态(无需重新上传照片)。
|
||||
new_input = await _fetch_input(new_report_id)
|
||||
assert new_input.derived_features == {"vision_runs": 1}
|
||||
|
||||
await _generate_report(int(data["task_id"]))
|
||||
report_v2 = await _fetch_report(new_report_id)
|
||||
assert report_v2.status == "succeeded"
|
||||
assert report_v2.version == 2
|
||||
# 成功后视觉仍未重算:vision_runs 计数保持 1(Fake 阶段非空即跳过)。
|
||||
input_after = await _fetch_input(new_report_id)
|
||||
assert input_after.derived_features == {"vision_runs": 1}
|
||||
@@ -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