# -*- 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)