# -*- coding: utf-8 -*- """近交衰退正式 tc 套件:TestClient 走真实 API + 方向感知/深层系谱判别(纯计算不落库)。 fixture(镜像 e2e_inbreeding_depression):6 基源种质(双作),3 组合;12 树 (前 6 非自交 dam≠sire → F=0;后 6 自交 dam==sire → F=0.5)。观测: - tD 衰退型 y=100−60F+ε(desc) - tN 无衰退型 y≈100 常数 - tA 病指型 y=10+40F(asc:F↑→指标↑=恶化,方向盲目的 slope<0 判据会误报「未检出」) 经真实 HTTP 端点 POST /api/v1/bre/statistics/inbreeding-depression 断言: [1] 衰退:slope<-40、t<-3、r²>0.9、has_depression=true、depression_rate<0、 max_F≈0.5、mean_F≈0.25、direction=desc、high_inbreeding 前6=自交(F=0.5)后4=F=0 [2] 无衰退:slope≈0、has_depression=false、文案「未检出」 [3] 样本不足:min_n=20 → sufficient=false + reason 含样本量/阈值、无回归 [4] asc 方向:slope>30、t>3、has_depression=true、文案含「上升」(方向感知) [5] 深层系谱(P→G7/G8 升谱,候选树 dam=G7 sire=G8 半同胞近交 F=0.125 不低估) [6] 性状不存在 → 409 依赖: 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, select # 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.trait.model import TraitModel # 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.trait_observation.model import TraitObservationModel # noqa: E402 create_app = main.create_app TOKEN = None ok, fail = 0, 0 PREFIX = "TCIBRD" tokens: dict[str, list[int]] = { "trait": [], "tree": [], "combo": [], "germ": [], "target": [], "obs": [], } 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 _wipe() -> None: """启动前清理本前缀残留(防上次进程被杀/建 fixture 中途失败留下的脏数据)。""" engine, sf = create_async_engine_and_session() try: async with sf() as db: trait_ids = list((await db.execute(select(TraitModel.id).where( TraitModel.trait_code.like(f"%_{PREFIX}")))).scalars()) if trait_ids: obs = list((await db.execute(select(TraitObservationModel.id).where( TraitObservationModel.trait_id.in_(trait_ids)))).scalars()) if obs: await db.execute(delete(TraitObservationModel).where( TraitObservationModel.id.in_(obs))) tree_ids = list((await db.execute(select(TreeModel.id).where( TreeModel.tree_no.like(f"{PREFIX}-%")))).scalars()) if tree_ids: await db.execute(delete(TreeModel).where(TreeModel.id.in_(tree_ids))) combo_ids = list((await db.execute(select(CrossCombinationModel.id).where( CrossCombinationModel.combination_code.like(f"C%_{PREFIX}")))).scalars()) if combo_ids: await db.execute(delete(CrossCombinationModel).where( CrossCombinationModel.id.in_(combo_ids))) germ_ids = list((await db.execute(select(BreedingGermplasmModel.id).where( BreedingGermplasmModel.cultivar_name.like(f"%_{PREFIX}")))).scalars()) if germ_ids: await db.execute(delete(BreedingGermplasmModel).where( BreedingGermplasmModel.id.in_(germ_ids))) if trait_ids: await db.execute(delete(TraitModel).where(TraitModel.id.in_(trait_ids))) await db.execute(delete(TargetModel).where(TargetModel.target_name == f"目标{PREFIX}")) await db.commit() print(f"[preclean] inbreeding tc 前缀残留已清(trait={len(trait_ids)} tree={len(tree_ids)})") finally: await engine.dispose() async def _build_fixture() -> None: engine, sf = create_async_engine_and_session() try: async with sf() as db: tD = TraitModel(trait_code=f"dD_{PREFIX}", trait_name=f"衰退型{PREFIX}", data_type="numeric", unit="g", is_core="1", direction="desc", into_ebv="1", default_h2=0.5, stage="evaluation", created_id=1) tN = TraitModel(trait_code=f"dN_{PREFIX}", trait_name=f"无衰退型{PREFIX}", data_type="numeric", unit="g", is_core="1", direction="desc", into_ebv="1", default_h2=0.5, stage="evaluation", created_id=1) tA = TraitModel(trait_code=f"dA_{PREFIX}", trait_name=f"病指型{PREFIX}", data_type="numeric", unit="%", is_core="1", direction="asc", into_ebv="1", default_h2=0.5, stage="evaluation", created_id=1) db.add_all([tD, tN, tA]) await db.flush() tokens["trait"] += [tD.id, tN.id, tA.id] FIX["D"], FIX["N"], FIX["A"] = tD, tN, tA target = TargetModel(target_name=f"目标{PREFIX}", created_id=1) db.add(target) await db.flush() tokens["target"].append(target.id) germs = [] for i in range(6): g = BreedingGermplasmModel(cultivar_name=f"G{i}_{PREFIX}", can_be_female=True, can_be_male=True, created_id=1) db.add(g) germs.append(g) await db.flush() tokens["germ"] += [g.id for g in germs] combos = [] for i in range(3): c = CrossCombinationModel( combination_code=f"C{i}_{PREFIX}", bre_target_id=target.id, female_parent_id=germs[2 * i].id, male_parent_id=germs[2 * i + 1].id, design_type="full_diallel", created_id=1, ) db.add(c) combos.append(c) await db.flush() tokens["combo"] += [c.id for c in combos] trees: list[TreeModel] = [] for i in range(6): gA, gB = germs[2 * (i % 3)].id, germs[2 * (i % 3) + 1].id t = TreeModel(combination_id=combos[i % 3].id, tree_no=f"{PREFIX}-N{i:02d}", status="alive", stage="evaluation", generation="F1", dam_id=gA, sire_id=gB, created_id=1) db.add(t) trees.append(t) for i in range(6): g = germs[i % 3].id # dam==sire → 自交,F=0.5 t = TreeModel(combination_id=combos[i % 3].id, tree_no=f"{PREFIX}-S{i:02d}", status="alive", stage="evaluation", generation="F1", dam_id=g, sire_id=g, created_id=1) db.add(t) trees.append(t) await db.flush() tokens["tree"] += [t.id for t in trees] obs_rows = [] for i, t in enumerate(trees): f = 0.5 if i >= 6 else 0.0 oD = TraitObservationModel(tree_id=t.id, combination_id=combos[i % 3].id, trait_id=tD.id, value_numeric=100.0 - 60.0 * f + (i % 5) * 0.3, evaluate_year=2025, created_id=1) oN = TraitObservationModel(tree_id=t.id, combination_id=combos[i % 3].id, trait_id=tN.id, value_numeric=100.0 + (i % 3) * 0.1, evaluate_year=2025, created_id=1) oA = TraitObservationModel(tree_id=t.id, combination_id=combos[i % 3].id, trait_id=tA.id, value_numeric=10.0 + 40.0 * f, evaluate_year=2025, created_id=1) db.add_all([oD, oN, oA]) obs_rows += [oD, oN, oA] await db.flush() tokens["obs"] += [o.id for o in obs_rows] # ---- [5] 深层系谱:P→G7/G8(tree FK 升谱),候选树 dam=G7 sire=G8 半同胞近交 F=0.125 ---- gP = BreedingGermplasmModel(cultivar_name=f"GP_{PREFIX}", can_be_female=True, can_be_male=True, created_id=1) g7 = BreedingGermplasmModel(cultivar_name=f"G7_{PREFIX}", can_be_female=True, can_be_male=True, created_id=1) g8 = BreedingGermplasmModel(cultivar_name=f"G8_{PREFIX}", can_be_female=True, can_be_male=True, created_id=1) db.add_all([gP, g7, g8]) await db.flush() tokens["germ"] += [gP.id, g7.id, g8.id] eX = TreeModel(combination_id=combos[0].id, tree_no=f"{PREFIX}-X01", status="alive", stage="evaluation", generation="F1", germplasm_id=g7.id, dam_id=gP.id, created_id=1) eY = TreeModel(combination_id=combos[0].id, tree_no=f"{PREFIX}-X02", status="alive", stage="evaluation", generation="F1", germplasm_id=g8.id, dam_id=gP.id, created_id=1) tD2 = TraitModel(trait_code=f"dD2_{PREFIX}", trait_name=f"深层系谱衰退型{PREFIX}", data_type="numeric", unit="g", is_core="1", direction="desc", into_ebv="1", default_h2=0.5, stage="evaluation", created_id=1) db.add_all([eX, eY, tD2]) await db.flush() tokens["tree"] += [eX.id, eY.id] tokens["trait"].append(tD2.id) FIX["D2"] = tD2 deep_trees: list[TreeModel] = [] for i in range(6): t = TreeModel(combination_id=combos[0].id, tree_no=f"{PREFIX}-H{i:02d}", status="alive", stage="evaluation", generation="F1", dam_id=g7.id, sire_id=g8.id, created_id=1) db.add(t) deep_trees.append(t) for i in range(6): t = TreeModel(combination_id=combos[0].id, tree_no=f"{PREFIX}-C{i:02d}", status="alive", stage="evaluation", generation="F1", dam_id=germs[0].id, sire_id=germs[1].id, created_id=1) db.add(t) deep_trees.append(t) await db.flush() tokens["tree"] += [t.id for t in deep_trees] obs2 = [] for i, t in enumerate(deep_trees): f = 0.125 if i < 6 else 0.0 o = TraitObservationModel(tree_id=t.id, trait_id=tD2.id, value_numeric=100.0 - 60.0 * f + (i % 4) * 0.2, evaluate_year=2025, created_id=1) db.add(o) obs2.append(o) await db.flush() tokens["obs"] += [o.id for o in obs2] await db.commit() finally: await engine.dispose() async def _verify_and_cleanup() -> None: engine, sf = create_async_engine_and_session() try: async with sf() as db: if tokens["obs"]: await db.execute(delete(TraitObservationModel).where( TraitObservationModel.id.in_(tokens["obs"]))) 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["trait"]: await db.execute(delete(TraitModel).where(TraitModel.id.in_(tokens["trait"]))) if tokens["target"]: await db.execute(delete(TargetModel).where(TargetModel.id.in_(tokens["target"]))) await db.commit() print(f"[cleanup] inbreeding tc 数据已清(trait={len(tokens['trait'])} tree={len(tokens['tree'])} " f"obs={len(tokens['obs'])})") finally: await engine.dispose() def main_() -> None: asyncio.run(_wipe()) asyncio.run(_build_fixture()) try: with TestClient(create_app()) as client: login(client) H = auth() code = {k: FIX[k].trait_code for k in ("D", "N", "A", "D2")} def run(tid, tcode, min_n=10): r = client.post("/api/v1/bre/statistics/inbreeding-depression", json={"trait_id": tid, "trait_code": tcode, "year": None, "trial_study_id": None, "min_n": min_n}, headers=H) check("[HTTP] inbreeding-depression 200", r.status_code == 200, f"{r.status_code} {str(r.text)[:120]}") b = r.json() return b.get("data") if b.get("code") == 0 else None # ---- [1] 衰退性状 ---- rD = run(FIX["D"].id, code["D"]) check("[1] sufficient=True", rD is not None and rD.get("sufficient") is True, f"{rD and rD.get('reason')}") if rD: reg = rD["regression"] check("[1] n_obs=12", rD.get("n_obs") == 12, f"{rD.get('n_obs')}") check("[1] slope<-40(≈−60)", reg["slope"] < -40, f"slope={reg['slope']:.2f}") check("[1] r²>0.9", reg["r2"] > 0.9, f"r²={reg['r2']:.3f}") check("[1] has_depression=true", reg["has_depression"] is True) check("[1] t_slope<-3", reg["t_slope"] < -3, f"t={reg['t_slope']:.2f}") check("[1] 衰退率<0", reg.get("depression_rate") is not None and reg["depression_rate"] < 0, f"{reg.get('depression_rate')}") check("[1] max_F≈0.5", abs(rD["max_F"] - 0.5) < 0.001, f"max_F={rD['max_F']}") check("[1] mean_F≈0.25", 0.2 < rD["mean_F"] < 0.3, f"mean_F={rD['mean_F']}") check("[1] direction=desc", rD["direction"] == "desc", f"{rD['direction']}") check("[1] 文案含近交衰退", "近交衰退" in reg["interpretation"], f"{reg['interpretation']}") hi = rD["high_inbreeding"] check("[1] high_inbreeding 10 条", len(hi) == 10, f"{len(hi)}") check("[1] 前6=自交(F=0.5)+tree_no", all(abs(x["F"] - 0.5) < 0.001 for x in hi[:6]) and all(x.get("tree_no", "").startswith(f"{PREFIX}-S") for x in hi[:6]), f"{[x.get('F') for x in hi[:6]]}") check("[1] 后4=F=0", all(abs(x["F"]) < 1e-6 for x in hi[6:])) # ---- [2] 无衰退性状 ---- rN = run(FIX["N"].id, code["N"]) check("[2] sufficient=True", rN is not None and rN.get("sufficient") is True) if rN: regN = rN["regression"] check("[2] slope≈0", abs(regN["slope"]) < 5, f"slope={regN['slope']:.2f}") check("[2] has_depression=false", regN["has_depression"] is False) check("[2] t>-1.645", regN["t_slope"] > -1.645, f"t={regN['t_slope']:.2f}") check("[2] 文案含未检出", "未检出" in regN["interpretation"], f"{regN['interpretation']}") # ---- [3] 样本不足 ---- rLow = run(FIX["D"].id, code["D"], min_n=20) check("[3] sufficient=false", rLow is not None and rLow.get("sufficient") is False) if rLow: check("[3] reason 含样本量+阈值", "12 棵" in rLow["reason"] and "≥20" in rLow["reason"], f"{rLow['reason']}") check("[3] 无回归/清单", rLow.get("regression") is None and rLow.get("high_inbreeding") == []) # ---- [4] asc 方向(病指,越低越好):F↑→指标↑=恶化 ---- rA = run(FIX["A"].id, code["A"]) check("[4] sufficient=True", rA is not None and rA.get("sufficient") is True) if rA: regA = rA["regression"] check("[4] direction=asc", rA["direction"] == "asc", f"{rA['direction']}") check("[4] slope>30(≈+40)", regA["slope"] > 30, f"slope={regA['slope']:.2f}") check("[4] t>3", regA["t_slope"] > 3, f"t={regA['t_slope']:.2f}") check("[4] has_depression=true(方向感知)", regA["has_depression"] is True) check("[4] 文案含上升", "上升" in regA["interpretation"], f"{regA['interpretation']}") check("[4] max_F>0.4", rA["max_F"] > 0.4, f"max_F={rA['max_F']}") check("[4] 前6=F=0.5", all(abs(x["F"] - 0.5) < 0.001 for x in rA["high_inbreeding"][:6])) # ---- [5] 深层系谱近交系数(多世代不低估 F) ---- rD2 = run(FIX["D2"].id, code["D2"]) check("[5] sufficient=True", rD2 is not None and rD2.get("sufficient") is True) if rD2: check("[5] n_obs=12", rD2.get("n_obs") == 12, f"{rD2.get('n_obs')}") check("[5] max_F≈0.125(半同胞近交,浅层=0 低估)", abs(rD2["max_F"] - 0.125) < 0.001, f"max_F={rD2['max_F']}") check("[5] mean_F≈0.0625", abs(rD2["mean_F"] - 0.0625) < 0.001, f"mean_F={rD2['mean_F']}") check("[5] slope<-40", rD2["regression"]["slope"] < -40, f"slope={rD2['regression']['slope']:.2f}") check("[5] has_depression=true", rD2["regression"]["has_depression"] is True) hi2 = rD2["high_inbreeding"] check("[5] 前6=F=0.125+H株", all(abs(x["F"] - 0.125) < 0.001 for x in hi2[:6]) and all(x.get("tree_no", "").startswith(f"{PREFIX}-H") for x in hi2[:6]), f"{[x.get('F') for x in hi2[:6]]}") # ---- [6] 校验 409 ---- r = client.post("/api/v1/bre/statistics/inbreeding-depression", json={"trait_id": 99999999, "trait_code": f"dZ_{PREFIX}", "year": None, "trial_study_id": None, "min_n": 10}, headers=H) check("[6] 性状不存在 → 409", r.status_code == 409, f"{r.status_code}") r = client.post("/api/v1/bre/statistics/inbreeding-depression", json={"trait_id": FIX["D"].id, "trait_code": code["D"], "year": None, "trial_study_id": None, "min_n": 1}, headers=H) check("[6] min_n<2 → 422", r.status_code == 422, f"{r.status_code}") finally: asyncio.run(_verify_and_cleanup()) print(f"\n===== inbreeding tc 套件:ok={ok} fail={fail} =====") if __name__ == "__main__": main_() sys.exit(1 if fail else 0)