341 lines
17 KiB
Python
341 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""MABC 标记辅助回交进度正式 tc 套件:TestClient 走真实 API + golden 数值断言(纯计算端点)。
|
|||
|
|
|
|||
|
|
fixture(镜像 e2e_mabc):轮回亲本 rp_tree 背景面板全纯合 1/1;候选 BC 分离株 6 棵
|
|||
|
|
(前景 F1/F2 + 背景 M1..M4,min_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=5(C1/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)
|