390 lines
20 KiB
Python
390 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""孟德尔/亲本一致性检验正式 tc 套件:TestClient 走真实 API + 落库/隔离 golden 断言。
|
||
|
||
fixture(镜像 e2e_mendelian_persist):P1/P2 全 0/1 → T1、P3/P4 全 0/0 → T2(5 标记)。
|
||
T1 全 0/0 → rate=1.0 兼容;T2 全 1/1 → 每标记 alien → rate=0.0 → flag。
|
||
经真实 HTTP 端点 POST /api/v1/bre/genotype_call/mendelian/check/run 断言:
|
||
[1] 返回 check_id / isolated=1 / cleared=0 + 计数 2/1/0/0
|
||
[2] bre_mendelian_check 批次落库(阈值/计数/data_version/input_hash)
|
||
[3] 子表 2 行:T1 rate=1.0 flag=false / T2 rate=0.0 flag=true incompatible=5
|
||
[4] T2 进入隔离注册表 bre_pedigree_exclusion(source=mendelian / source_check_id)
|
||
[5] 阈值 0.0 重跑 → flagged=0 / cleared=1 + 隔离行清除(可逆)
|
||
[6] GET 只读预览零回归(无 check_id / 纯计算)
|
||
依赖: 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, TreePedigreeExclusionModel # noqa: E402
|
||
from app.api.v1.module_bre.trait_observation.model import TraitObservationModel # 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 ( # noqa: E402
|
||
GenotypeCallModel,
|
||
MendelianCheckModel,
|
||
MendelianCheckTreeModel,
|
||
)
|
||
from app.api.v1.module_bre.genotype_call.service import GENOTYPE_DATA_VERSION # noqa: E402
|
||
|
||
create_app = main.create_app
|
||
TOKEN = None
|
||
ok, fail = 0, 0
|
||
|
||
PREFIX = "TC_MEND"
|
||
tokens: dict[str, list[int]] = {
|
||
"germ": [], "tree": [], "combo": [], "target": [],
|
||
"marker": [], "dataset": [], "sample": [], "call": [],
|
||
"obs": [],
|
||
}
|
||
check_ids: list[int] = []
|
||
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)
|
||
trait = TraitModel(trait_code=f"tM_{PREFIX}", trait_name=f"果径{PREFIX}",
|
||
data_type="numeric", unit="mm", is_core="1", direction="desc",
|
||
into_ebv="1", default_h2=0.5, created_id=1)
|
||
db.add(trait)
|
||
await db.flush()
|
||
P1 = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-P1", can_be_female=True, created_id=1)
|
||
P2 = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-P2", can_be_male=True, created_id=1)
|
||
P3 = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-P3", can_be_female=True, created_id=1)
|
||
P4 = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-P4", can_be_male=True, created_id=1)
|
||
db.add_all([P1, P2, P3, P4])
|
||
await db.flush()
|
||
tokens["germ"] += [P1.id, P2.id, P3.id, P4.id]
|
||
C0 = CrossCombinationModel(combination_code=f"{PREFIX}-C0", bre_target_id=target.id,
|
||
female_parent_id=P1.id, male_parent_id=P2.id,
|
||
design_type="full_diallel", created_id=1)
|
||
C1 = CrossCombinationModel(combination_code=f"{PREFIX}-C1", bre_target_id=target.id,
|
||
female_parent_id=P3.id, male_parent_id=P4.id,
|
||
design_type="full_diallel", created_id=1)
|
||
db.add_all([C0, C1])
|
||
await db.flush()
|
||
tokens["combo"] += [C0.id, C1.id]
|
||
T1 = TreeModel(combination_id=C0.id, tree_no=f"{PREFIX}-T1", status="alive",
|
||
stage="evaluation", generation="F1", dam_id=P1.id, sire_id=P2.id, created_id=1)
|
||
T2 = TreeModel(combination_id=C1.id, tree_no=f"{PREFIX}-T2", status="alive",
|
||
stage="evaluation", generation="F1", dam_id=P3.id, sire_id=P4.id, created_id=1)
|
||
db.add_all([T1, T2])
|
||
await db.flush()
|
||
tokens["tree"] += [T1.id, T2.id]
|
||
FIX.update({"T1": T1.id, "T2": T2.id, "P1": P1.id, "P2": P2.id,
|
||
"P3": P3.id, "P4": P4.id})
|
||
o1 = TraitObservationModel(tree_id=T1.id, combination_id=C0.id, trait_id=trait.id,
|
||
value_numeric=25.0, evaluate_year=2025, created_id=1)
|
||
o2 = TraitObservationModel(tree_id=T2.id, combination_id=C1.id, trait_id=trait.id,
|
||
value_numeric=20.0, evaluate_year=2025, created_id=1)
|
||
db.add_all([o1, o2])
|
||
await db.flush()
|
||
tokens["obs"] += [o1.id, o2.id]
|
||
markers = []
|
||
for j in range(5):
|
||
m = MarkerModel(marker_name=f"{PREFIX}-M{j + 1}", marker_type="SNP",
|
||
chromosome=str(j), position=j * 100, created_id=1)
|
||
db.add(m)
|
||
markers.append(m)
|
||
await db.flush()
|
||
tokens["marker"] = [m.id for m in markers]
|
||
mids = [m.id for m in markers]
|
||
ds = GenotypingDatasetModel(dataset_name=f"{PREFIX}-DS", platform="SNP",
|
||
purpose="MD", created_id=1)
|
||
db.add(ds)
|
||
await db.flush()
|
||
tokens["dataset"].append(ds.id)
|
||
FIX["dataset_id"] = ds.id
|
||
|
||
def mk_sample(ds_id, no, source_type, source_id):
|
||
s = GenotypeSampleModel(sample_name=no, dataset_id=ds_id,
|
||
source_type=source_type, source_id=source_id, created_id=1)
|
||
db.add(s)
|
||
return s
|
||
|
||
samp = [
|
||
mk_sample(ds.id, f"{PREFIX}-P1-S", "germplasm", P1.id),
|
||
mk_sample(ds.id, f"{PREFIX}-P2-S", "germplasm", P2.id),
|
||
mk_sample(ds.id, f"{PREFIX}-P3-S", "germplasm", P3.id),
|
||
mk_sample(ds.id, f"{PREFIX}-P4-S", "germplasm", P4.id),
|
||
mk_sample(ds.id, f"{PREFIX}-T1-S", "tree", T1.id),
|
||
mk_sample(ds.id, f"{PREFIX}-T2-S", "tree", T2.id),
|
||
]
|
||
await db.flush()
|
||
tokens["sample"] = [s.id for s in samp]
|
||
calls = []
|
||
gt_map = {"P1": "0/1", "P2": "0/1", "P3": "0/0", "P4": "0/0", "T1": "0/0", "T2": "1/1"}
|
||
for name, s in zip(("P1", "P2", "P3", "P4", "T1", "T2"), samp):
|
||
for mid in mids:
|
||
calls.append(GenotypeCallModel(sample_id=s.id, marker_id=mid,
|
||
allele=gt_map[name], 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 _verify_and_cleanup() -> None:
|
||
engine, sf = create_async_engine_and_session()
|
||
try:
|
||
async with sf() as db:
|
||
r = FIX["run1"]
|
||
# ---- [2] 批次落库 ----
|
||
crow = await db.get(MendelianCheckModel, r["check_id"])
|
||
check("[2] bre_mendelian_check 落库(阈值/计数/data_version/input_hash)",
|
||
crow is not None and float(crow.compat_threshold) == 0.98
|
||
and crow.trees_checked == 2 and crow.trees_flagged == 1
|
||
and crow.data_version == GENOTYPE_DATA_VERSION and crow.input_hash,
|
||
f"v={crow.data_version if crow else None}")
|
||
# ---- [3] 子表 ----
|
||
childs = (await db.execute(select(MendelianCheckTreeModel).where(
|
||
MendelianCheckTreeModel.check_id == r["check_id"]))).scalars().all()
|
||
child_by_tree = {int(c.tree_id): c for c in childs}
|
||
check("[3] 子表 2 行:T1 rate=1.0 flag=false / T2 rate=0.0 flag=true incompatible=5",
|
||
len(childs) == 2
|
||
and float(child_by_tree[FIX["T1"]].compatibility_rate) == 1.0
|
||
and not child_by_tree[FIX["T1"]].flag
|
||
and float(child_by_tree[FIX["T2"]].compatibility_rate) == 0.0
|
||
and child_by_tree[FIX["T2"]].flag
|
||
and child_by_tree[FIX["T2"]].status == "checked"
|
||
and child_by_tree[FIX["T2"]].incompatible == 5,
|
||
f"T1={child_by_tree[FIX['T1']].compatibility_rate}/{child_by_tree[FIX['T1']].flag} "
|
||
f"T2={child_by_tree[FIX['T2']].compatibility_rate}/{child_by_tree[FIX['T2']].flag}")
|
||
# ---- [4] 隔离注册表在 main_ 中 run1 后立即验(run2 会清除),此处只验 [5] ----
|
||
# ---- [5] 可逆:隔离行清除 ----
|
||
if FIX.get("run2"):
|
||
excl2 = (await db.execute(select(TreePedigreeExclusionModel).where(
|
||
TreePedigreeExclusionModel.is_deleted.is_(False),
|
||
TreePedigreeExclusionModel.tree_id == FIX["T2"]))).scalars().all()
|
||
check("[5] 阈值 0.0 后 T2 隔离行已清除", len(excl2) == 0, f"n={len(excl2)}")
|
||
|
||
# ---- cleanup ----
|
||
if tokens["tree"]:
|
||
await db.execute(delete(TreePedigreeExclusionModel).where(
|
||
TreePedigreeExclusionModel.tree_id.in_(tokens["tree"])))
|
||
if check_ids:
|
||
await db.execute(delete(MendelianCheckTreeModel).where(
|
||
MendelianCheckTreeModel.check_id.in_(check_ids)))
|
||
await db.execute(delete(MendelianCheckModel).where(
|
||
MendelianCheckModel.id.in_(check_ids)))
|
||
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["marker"]:
|
||
await db.execute(delete(MarkerModel).where(MarkerModel.id.in_(tokens["marker"])))
|
||
if tokens["dataset"]:
|
||
await db.execute(delete(GenotypingDatasetModel).where(
|
||
GenotypingDatasetModel.id.in_(tokens["dataset"])))
|
||
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"])))
|
||
await db.execute(delete(TraitModel).where(TraitModel.trait_code == f"tM_{PREFIX}"))
|
||
if tokens["target"]:
|
||
await db.execute(delete(TargetModel).where(TargetModel.id.in_(tokens["target"])))
|
||
await db.commit()
|
||
print(f"[cleanup] 孟德尔 tc 数据已清(check={len(check_ids)})")
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
async def _check_isolated(check_id: int) -> None:
|
||
"""run1 落库后立即查隔离注册表(必须在 run2 清除之前验)。"""
|
||
engine, sf = create_async_engine_and_session()
|
||
try:
|
||
async with sf() as db:
|
||
excl = (await db.execute(select(TreePedigreeExclusionModel).where(
|
||
TreePedigreeExclusionModel.is_deleted.is_(False),
|
||
TreePedigreeExclusionModel.tree_id == FIX["T2"]))).scalars().all()
|
||
check("[4] T2 进入隔离注册表(source=mendelian / source_check_id)",
|
||
len(excl) == 1 and excl[0].source_type == "mendelian"
|
||
and excl[0].source_check_id == check_id, f"n={len(excl)}")
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
async def _wipe() -> None:
|
||
"""启动前清理本前缀残留(防上次进程被杀/建 fixture 中途失败留下的脏数据)。"""
|
||
engine, sf = create_async_engine_and_session()
|
||
try:
|
||
async with sf() as db:
|
||
ds_ids = list((await db.execute(select(GenotypingDatasetModel.id).where(
|
||
GenotypingDatasetModel.dataset_name.like(f"{PREFIX}-%")))).scalars())
|
||
tree_ids = list((await db.execute(select(TreeModel.id).where(
|
||
TreeModel.tree_no.like(f"{PREFIX}-%")))).scalars())
|
||
if tree_ids:
|
||
await db.execute(delete(TreePedigreeExclusionModel).where(
|
||
TreePedigreeExclusionModel.tree_id.in_(tree_ids)))
|
||
check_ids = list((await db.execute(select(MendelianCheckModel.id).where(
|
||
MendelianCheckModel.dataset_id.in_(ds_ids)))).scalars())
|
||
if check_ids:
|
||
await db.execute(delete(MendelianCheckTreeModel).where(
|
||
MendelianCheckTreeModel.check_id.in_(check_ids)))
|
||
await db.execute(delete(MendelianCheckModel).where(
|
||
MendelianCheckModel.id.in_(check_ids)))
|
||
if ds_ids:
|
||
sample_ids = list((await db.execute(select(GenotypeSampleModel.id).where(
|
||
GenotypeSampleModel.dataset_id.in_(ds_ids)))).scalars())
|
||
if sample_ids:
|
||
await db.execute(delete(GenotypeCallModel).where(
|
||
GenotypeCallModel.sample_id.in_(sample_ids)))
|
||
await db.execute(delete(GenotypeSampleModel).where(
|
||
GenotypeSampleModel.id.in_(sample_ids)))
|
||
await db.execute(delete(GenotypingDatasetModel).where(
|
||
GenotypingDatasetModel.id.in_(ds_ids)))
|
||
if tree_ids:
|
||
obs_ids = list((await db.execute(select(TraitObservationModel.id).where(
|
||
TraitObservationModel.tree_id.in_(tree_ids)))).scalars())
|
||
if obs_ids:
|
||
await db.execute(delete(TraitObservationModel).where(
|
||
TraitObservationModel.id.in_(obs_ids)))
|
||
await db.execute(delete(TreeModel).where(TreeModel.id.in_(tree_ids)))
|
||
marker_ids = list((await db.execute(select(MarkerModel.id).where(
|
||
MarkerModel.marker_name.like(f"{PREFIX}-%")))).scalars())
|
||
if marker_ids:
|
||
await db.execute(delete(MarkerModel).where(MarkerModel.id.in_(marker_ids)))
|
||
combo_ids = list((await db.execute(select(CrossCombinationModel.id).where(
|
||
CrossCombinationModel.combination_code.like(f"{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)))
|
||
await db.execute(delete(TraitModel).where(TraitModel.trait_code == f"tM_{PREFIX}"))
|
||
await db.execute(delete(TargetModel).where(
|
||
TargetModel.target_name.like(f"{PREFIX}-%")))
|
||
await db.commit()
|
||
print(f"[preclean] 孟德尔 tc 前缀残留已清(ds={len(ds_ids)} tree={len(tree_ids)})")
|
||
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()
|
||
# ---- [1] POST run ----
|
||
r = client.post("/api/v1/bre/genotype_call/mendelian/check/run",
|
||
json={"dataset_id": FIX["dataset_id"], "compat_threshold": 0.98},
|
||
headers=H)
|
||
check("[HTTP] mendelian/check/run 200", r.status_code == 200,
|
||
f"{r.status_code} {str(r.text)[:150]}")
|
||
b = r.json()
|
||
data = b.get("data") if b.get("code") == 0 else None
|
||
check("[1] 返回 check_id / isolated=1 / cleared=0", data is not None
|
||
and isinstance(data["check_id"], int) and data["isolated"] == 1
|
||
and data["cleared"] == 0, f"{data}")
|
||
if data:
|
||
FIX["run1"] = data
|
||
check_ids.append(data["check_id"])
|
||
check("[1] 汇总计数 checked=2 / flagged=1 / no_parents=0 / missing=0",
|
||
data["trees_checked"] == 2 and data["trees_flagged"] == 1
|
||
and data["trees_no_parents"] == 0
|
||
and data["trees_missing_parent_genotype"] == 0,
|
||
f"{data['trees_checked']}/{data['trees_flagged']}")
|
||
# ---- [4] 隔离注册表:run1 刚落库即验(run2 阈值 0.0 会清除)----
|
||
asyncio.run(_check_isolated(FIX["run1"]["check_id"]))
|
||
|
||
# ---- [5] 阈值 0.0 重跑(可逆)----
|
||
r2 = client.post("/api/v1/bre/genotype_call/mendelian/check/run",
|
||
json={"dataset_id": FIX["dataset_id"], "compat_threshold": 0.0},
|
||
headers=H)
|
||
b2 = r2.json()
|
||
d2 = b2.get("data") if r2.status_code == 200 and b2.get("code") == 0 else None
|
||
check("[5] 阈值 0.0:flagged=0 / isolated=0 / cleared=1",
|
||
d2 is not None and d2["trees_flagged"] == 0
|
||
and d2["isolated"] == 0 and d2["cleared"] == 1, f"{d2}")
|
||
if d2:
|
||
FIX["run2"] = d2
|
||
check_ids.append(d2["check_id"])
|
||
|
||
# ---- [6] GET 只读预览零回归 ----
|
||
g = client.get("/api/v1/bre/genotype_call/mendelian/check",
|
||
params={"dataset_id": FIX["dataset_id"], "compat_threshold": 0.98},
|
||
headers=H)
|
||
gb = g.json()
|
||
gd = gb.get("data") if g.status_code == 200 and gb.get("code") == 0 else None
|
||
check("[6] GET 只读预览原结构(无 check_id / 纯计算)",
|
||
gd is not None and "check_id" not in gd
|
||
and gd["trees_flagged"] == 1 and len(gd["trees"]) == 2
|
||
and gd["summary"]["incompatible_markers"] == 5,
|
||
f"flagged={gd and gd['trees_flagged']}")
|
||
finally:
|
||
asyncio.run(_verify_and_cleanup())
|
||
|
||
print(f"\n===== 孟德尔 tc 套件:ok={ok} fail={fail} =====")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main_()
|
||
sys.exit(1 if fail else 0)
|