Files

341 lines
17 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""MABC 标记辅助回交进度正式 tc 套件:TestClient 走真实 API + golden 数值断言(纯计算端点)。
fixture(镜像 e2e_mabc):轮回亲本 rp_tree 背景面板全纯合 1/1;候选 BC 分离株 6 棵
(前景 F1/F2 + 背景 M1..M4min_hits=2):C1(全 1/1→恢复100%)、C2(背景 M2 杂合→75%)、
C3(前景 1 命中 fail→75%)、C4(全 1/1→100%)、C5(背景全杂合→0%)、C6(童期→100%)。
经真实 HTTP 端点 POST /api/v1/bre/statistics/mabc-progress 断言:
[1] 前景命中与通过(C1 pass/C3 fail、n_pass=5
[2] 背景恢复率复算(100/75/75/0+ n_background_compared=4
[3] 回交代建议(C1 晋级 BC2 / C2 再回交 / C3 前景未通过维持)reason 语义
[4] recommended 恰为前景通过子集 + 恢复率降序
[5] 童期 C6 前景通过 + 晋级(stage 无关)
[6] 轮回亲本无基因型 → warning + 恢复率 None
[7] 校验 409:空候选/空前景面板/空背景面板/非法世代/target 越界/轮回亲本不存在
依赖: Redis + PG 正常(TestClient 走真实 lifespan)。运行后自动清理。
"""
import os
os.environ["ENVIRONMENT"] = "dev"
os.environ["PYTHONUTF8"] = "1"
import sys, asyncio # noqa: E402
sys.path.insert(0, r"d:\dpb\dpb\backend")
import main # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import delete # noqa: E402
from app.core.database import create_async_engine_and_session # noqa: E402
from app.api.v1.module_system.user.model import UserModel # noqa: E402 (注册 mapper)
from app.api.v1.module_bre.target.model import TargetModel # noqa: E402
from app.api.v1.module_bre.germplasm.model import BreedingGermplasmModel # noqa: E402
from app.api.v1.module_bre.cross_combination.model import CrossCombinationModel # noqa: E402
from app.api.v1.module_bre.tree.model import TreeModel # noqa: E402
from app.api.v1.module_bre.marker.model import MarkerModel # noqa: E402
from app.api.v1.module_bre.genotype_dataset.model import GenotypingDatasetModel # noqa: E402
from app.api.v1.module_bre.genotype_sample.model import GenotypeSampleModel # noqa: E402
from app.api.v1.module_bre.genotype_call.model import GenotypeCallModel # noqa: E402
from app.api.v1.module_bre.statistics.model import MasPanelModel, MasPanelMarkerModel # noqa: E402
create_app = main.create_app
TOKEN = None
ok, fail = 0, 0
PREFIX = "TC_MABC"
tokens: dict[str, list[int]] = {
"germ": [], "tree": [], "combo": [], "target": [],
"marker": [], "dataset": [], "sample": [], "call": [],
"panel": [], "panelmarker": [],
}
FIX: dict = {}
def check(name, cond, detail=""):
global ok, fail
if cond:
ok += 1
print(f" [ok] {name} {detail}")
else:
fail += 1
print(f" [FAIL] {name} {detail}")
def login(client):
global TOKEN
d = {"username": "super", "password": "123456", "grant_type": "password", "login_type": "PC端"}
r = client.post("/api/v1/system/auth/login", data=d)
b = r.json()
if r.status_code == 200 and b.get("code") == 0:
TOKEN = b["data"]["access_token"]
return
key = client.get("/api/v1/system/auth/captcha/get").json()["data"]["key"]
client.post("/api/v1/system/auth/captcha/slider/complete", json={"captcha_key": key})
d["captcha_key"] = key
r = client.post("/api/v1/system/auth/login", data=d)
b = r.json()
assert r.status_code == 200 and b.get("code") == 0, f"LOGIN FAIL {r.status_code} {b}"
TOKEN = b["data"]["access_token"]
def auth():
return {"Authorization": f"Bearer {TOKEN}"}
async def _build_fixture() -> None:
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
target = TargetModel(target_name=f"{PREFIX}-T", created_id=1)
db.add(target)
await db.flush()
tokens["target"].append(target.id)
combo = CrossCombinationModel(
combination_code=f"{PREFIX}-C1", bre_target_id=target.id,
female_parent_id=None, male_parent_id=None, design_type="full_diallel", created_id=1)
db.add(combo)
await db.flush()
tokens["combo"].append(combo.id)
rp_g = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-RP", can_be_female=True,
can_be_male=True, created_id=1)
db.add(rp_g)
await db.flush()
tokens["germ"].append(rp_g.id)
def mk_tree(no, stage="line", generation="BC1"):
t = TreeModel(combination_id=combo.id, tree_no=no, status="alive", stage=stage,
generation=generation, planted_date="2025-03-10", created_id=1)
db.add(t)
return t
rp_tree = mk_tree(f"{PREFIX}-RP-T")
cands = {nm: mk_tree(f"{PREFIX}-{nm}", stage="juvenile" if nm == "C6" else "line")
for nm in ("C1", "C2", "C3", "C4", "C5", "C6")}
rp_nogeno = mk_tree(f"{PREFIX}-RPNG-T")
await db.flush()
tokens["tree"] = [t.id for t in ([rp_tree, rp_nogeno] + list(cands.values()))]
FIX.update({"rp": rp_tree.id, "rp_nogeno": rp_nogeno.id,
"cand": {nm: t.id for nm, t in cands.items()}})
fg_markers, bg_markers = [], []
for j, nm in enumerate(["F1", "F2"]):
m = MarkerModel(marker_name=f"{PREFIX}-{nm}", marker_type="SNP",
chromosome="5", position=100 + j, created_id=1)
db.add(m)
fg_markers.append(m)
for j in range(4):
m = MarkerModel(marker_name=f"{PREFIX}-M{j + 1}", marker_type="SNP",
chromosome=str(j), position=j * 100, created_id=1)
db.add(m)
bg_markers.append(m)
await db.flush()
tokens["marker"] = [m.id for m in fg_markers + bg_markers]
FIX["fg_markers"] = fg_markers
FIX["bg_markers"] = bg_markers
fg_panel = MasPanelModel(panel_name=f"{PREFIX}-FG", n_markers=2, created_id=1)
bg_panel = MasPanelModel(panel_name=f"{PREFIX}-BG", n_markers=4, created_id=1)
db.add_all([fg_panel, bg_panel])
await db.flush()
tokens["panel"] = [fg_panel.id, bg_panel.id]
FIX["fg_panel"] = fg_panel.id
FIX["bg_panel"] = bg_panel.id
for m in fg_markers:
db.add(MasPanelMarkerModel(panel_id=fg_panel.id, marker_id=m.id,
favorable_dose=1, direction="high",
mode="additive", created_id=1))
tokens["panelmarker"].append(0)
for m in bg_markers:
db.add(MasPanelMarkerModel(panel_id=bg_panel.id, marker_id=m.id, created_id=1))
tokens["panelmarker"].append(0)
await db.flush()
ds = GenotypingDatasetModel(dataset_name=f"{PREFIX}-DS", platform="SNP",
purpose="GS", created_id=1)
db.add(ds)
await db.flush()
tokens["dataset"].append(ds.id)
all_markers = fg_markers + bg_markers
def mk_sample(tree, no):
s = GenotypeSampleModel(sample_name=no, dataset_id=ds.id,
source_type="tree", source_id=tree.id, created_id=1)
db.add(s)
return s
rp_s = mk_sample(rp_tree, f"{PREFIX}-RP-S")
samp_by_cand = {nm: mk_sample(t, f"{PREFIX}-{nm}-S") for nm, t in cands.items()}
await db.flush()
tokens["sample"] = [rp_s.id] + [s.id for s in samp_by_cand.values()]
GENO = {
"C1": ("1/1", "1/1", ["1/1", "1/1", "1/1", "1/1"]),
"C2": ("0/1", "1/1", ["1/1", "0/1", "1/1", "1/1"]),
"C3": ("0/0", "1/1", ["1/1", "1/1", "1/1", "0/0"]),
"C4": ("1/1", "0/1", ["1/1", "1/1", "1/1", "1/1"]),
"C5": ("1/1", "1/1", ["0/1", "0/1", "0/1", "0/1"]),
"C6": ("1/1", "1/1", ["1/1", "1/1", "1/1", "1/1"]),
}
calls = []
for j, m in enumerate(all_markers):
calls.append(GenotypeCallModel(sample_id=rp_s.id, marker_id=m.id,
allele="1/1", created_id=1))
for nm, (f1, f2, bg) in GENO.items():
s = samp_by_cand[nm]
calls.append(GenotypeCallModel(sample_id=s.id, marker_id=fg_markers[0].id,
allele=f1, created_id=1))
calls.append(GenotypeCallModel(sample_id=s.id, marker_id=fg_markers[1].id,
allele=f2, created_id=1))
for j, m in enumerate(bg_markers):
calls.append(GenotypeCallModel(sample_id=s.id, marker_id=m.id,
allele=bg[j], created_id=1))
db.add_all(calls)
await db.flush()
tokens["call"] = [c.id for c in calls]
await db.commit()
finally:
await engine.dispose()
async def _cleanup() -> None:
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
if tokens["call"]:
await db.execute(delete(GenotypeCallModel).where(
GenotypeCallModel.id.in_(tokens["call"])))
if tokens["sample"]:
await db.execute(delete(GenotypeSampleModel).where(
GenotypeSampleModel.id.in_(tokens["sample"])))
if tokens["dataset"]:
await db.execute(delete(GenotypingDatasetModel).where(
GenotypingDatasetModel.id.in_(tokens["dataset"])))
if tokens["panelmarker"]:
await db.execute(delete(MasPanelMarkerModel).where(
MasPanelMarkerModel.panel_id.in_(tokens["panel"])))
if tokens["panel"]:
await db.execute(delete(MasPanelModel).where(MasPanelModel.id.in_(tokens["panel"])))
if tokens["marker"]:
await db.execute(delete(MarkerModel).where(MarkerModel.id.in_(tokens["marker"])))
if tokens["tree"]:
await db.execute(delete(TreeModel).where(TreeModel.id.in_(tokens["tree"])))
if tokens["combo"]:
await db.execute(delete(CrossCombinationModel).where(
CrossCombinationModel.id.in_(tokens["combo"])))
if tokens["germ"]:
await db.execute(delete(BreedingGermplasmModel).where(
BreedingGermplasmModel.id.in_(tokens["germ"])))
if tokens["target"]:
await db.execute(delete(TargetModel).where(TargetModel.id.in_(tokens["target"])))
await db.commit()
print("[cleanup] MABC tc 数据已清")
finally:
await engine.dispose()
def main_() -> None:
asyncio.run(_build_fixture())
try:
with TestClient(create_app()) as client:
login(client)
c = FIX["cand"]
cand_ids = [c[nm] for nm in ("C1", "C2", "C3", "C4", "C5", "C6")]
def post_mabc(cands_, fg, bg, rp_, generation="BC1", background_target=90.0, min_hits=2):
r = client.post("/api/v1/bre/statistics/mabc-progress", json={
"candidate_tree_ids": cands_, "foreground_panel_ids": fg,
"background_panel_ids": bg, "recurrent_parent_tree_id": rp_,
"foreground_min_hits": min_hits, "generation": generation,
"background_target": background_target}, headers=auth())
if r.status_code != 200:
return None, r.status_code
b = r.json()
return (b.get("data") if b.get("code") == 0 else None), r.status_code
m, sc = post_mabc(cand_ids, [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp"])
check("[HTTP] mabc-progress 200", sc == 200, f"{sc}")
if m is None:
return
row = {r["tree_id"]: r for r in m["per_tree"]}
# ---- [1] 前景命中与通过 ----
check("[1] C1 前景 pass (2 命中)", row[c["C1"]]["foreground_pass"]
and row[c["C1"]]["foreground_hits"] == 2,
f"{row[c['C1']]['foreground_hits_by_panel']}")
check("[1] C3 前景 fail (1 命中)", not row[c["C3"]]["foreground_pass"]
and row[c["C3"]]["foreground_hits"] == 1, f"{row[c['C3']]['foreground_hits']}")
check("[1] n_pass=5C1/C2/C4/C5/C6", m["summary"]["n_pass"] == 5,
f"{m['summary']['n_pass']}")
# ---- [2] 背景恢复率复算 ----
check("[2] C1 恢复 100%", row[c["C1"]]["background_recovery"] == 100.0)
check("[2] C2 恢复 75%", row[c["C2"]]["background_recovery"] == 75.0,
f"{row[c['C2']]['background_recovery']}")
check("[2] C3 恢复 75%(前景 fail 也评估背景)",
row[c["C3"]]["background_recovery"] == 75.0)
check("[2] C5 恢复 0%(全杂合)", row[c["C5"]]["background_recovery"] == 0.0)
check("[2] 背景对比标记数=4", all(r["n_background_compared"] == 4 for r in row.values()),
f"{[r['n_background_compared'] for r in row.values()]}")
# ---- [3] 回交代建议 ----
check("[3] C1 晋级 BC2", row[c["C1"]]["bc_generation"] == "BC2"
and "晋级" in row[c["C1"]]["reason"], row[c["C1"]]["reason"])
check("[3] C2 再回交 BC1", row[c["C2"]]["bc_generation"] == "BC1"
and "再回交" in row[c["C2"]]["reason"], row[c["C2"]]["reason"])
check("[3] C3 维持 BC1(前景未通过)", row[c["C3"]]["bc_generation"] == "BC1"
and "前景未通过" in row[c["C3"]]["reason"], row[c["C3"]]["reason"])
# ---- [4] recommended 排序 ----
rec_ids = [r["tree_id"] for r in m["recommended"]]
pass_set = {c[nm] for nm in ("C1", "C2", "C4", "C5", "C6")}
check("[4] recommended 恰为前景通过子集", set(rec_ids) == pass_set, f"{rec_ids}")
rec_recovery = [r["background_recovery"] for r in m["recommended"]]
check("[4] 恢复率降序", rec_recovery == [100.0, 100.0, 100.0, 75.0, 0.0],
f"{rec_recovery}")
# ---- [5] 童期候选被前景选中 ----
check("[5] 童期 C6 前景通过 + 晋级", row[c["C6"]]["foreground_pass"]
and row[c["C6"]]["background_recovery"] == 100.0
and row[c["C6"]]["bc_generation"] == "BC2", row[c["C6"]])
# ---- [6] 轮回亲本无基因型 → warning + 恢复率 None ----
m6, sc6 = post_mabc([c["C1"]], [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp_nogeno"])
check("[6] mabc 200", sc6 == 200, f"{sc6}")
if m6:
check("[6] warning 提示轮回亲本无背景基因型",
m6["warning"] and "轮回亲本" in m6["warning"], m6["warning"])
check("[6] 恢复率 None + 建议维持世代",
m6["per_tree"][0]["background_recovery"] is None
and m6["per_tree"][0]["bc_generation"] == "BC1"
and "无法评估" in m6["per_tree"][0]["reason"],
m6["per_tree"][0]["reason"])
# ---- [7] 校验 409 ----
bad = [
([], [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp"]),
([c["C1"]], [], [FIX["bg_panel"]], FIX["rp"]),
([c["C1"]], [FIX["fg_panel"]], [], FIX["rp"]),
([c["C1"]], [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp"]),
([c["C1"]], [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp"]),
([c["C1"]], [FIX["fg_panel"]], [FIX["bg_panel"]], 999999999),
]
tags = ["空候选", "空前景面板", "空背景面板", "非法世代", "target 越界", "轮回亲本不存在"]
for (cands_, fg, bg, rp_), tag in zip(bad, tags):
kw = {}
if tag == "非法世代":
kw["generation"] = "BC9"
if tag == "target 越界":
kw["background_target"] = 120.0
_, scx = post_mabc(cands_, fg, bg, rp_, **kw)
# target 越界在 schema 层 Pydantic 校验(le=100)即拦截 → 422;其余走服务层 409
expect = 422 if tag == "target 越界" else 409
check(f"[7] {tag}{expect}", scx == expect, f"{scx}")
finally:
asyncio.run(_cleanup())
print(f"\n===== MABC tc 套件:ok={ok} fail={fail} =====")
if __name__ == "__main__":
main_()
sys.exit(1 if fail else 0)