378 lines
19 KiB
Python
378 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""试验设计正式 tc 套件:TestClient 走真实 API + 落库 block_no 结构断言(增广/α-格子)。
|
||
|
||
fixture(镜像 e2e_trial_design_ext):16 种质池 G01..G16 + mk_study helper
|
||
(TrialStudyModel block_count + TrialStudyEntryModel entry_number/germplasm_id)。
|
||
经真实 HTTP 端点 POST /api/v1/bre/statistics/trial-design 断言:
|
||
[1] augmented:12 条目(9 新品系 + 3 对照)× 3 区组 → 新品系每区组 3 条不重复、对照每区组
|
||
重复(check_reps=3)且对照 block_no=None(不落库)、新品系 block_no∈{1,2,3}
|
||
[2] alpha 平方 v=9=k²(k=3 素数):r=4 完全格子 → 4 rep×3 块×3 条、每 rep 覆盖全部 9、
|
||
任一对至多同块一次(λ≤1)、balance_warning=False、block_no=rep*100+block 落库
|
||
[3] alpha 部分 r=3(同 v=9):仍可分解平衡、λ≤1
|
||
[4] alpha 平方 v=16=k²(k=4 非素数):r=5 → balance_warning=True(可分解)
|
||
[5] alpha 一般 v=12(非平方,k=3 显式):s=4 贪心可分解、r 缺省=s=4、block_no 落库
|
||
[6] 校验 409:对照非条目子集 / 无对照 / v 不被 k 整除 / 非平方缺 block_size
|
||
DB 读回统一放在 TestClient 退出后的单一 asyncio.run(避免嵌套事件循环)。
|
||
依赖: 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.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.trial.model import TrialModel # noqa: E402
|
||
from app.api.v1.module_bre.trial_study.model import TrialStudyModel # noqa: E402
|
||
from app.api.v1.module_bre.trial_study_entry.model import TrialStudyEntryModel # noqa: E402
|
||
|
||
create_app = main.create_app
|
||
TOKEN = None
|
||
ok, fail = 0, 0
|
||
|
||
PREFIX = "TCTDES"
|
||
tokens: dict[str, list[int]] = {
|
||
"target": [], "germ": [], "combo": [], "trial": [], "study": [], "entry": [],
|
||
}
|
||
FIX: dict = {}
|
||
RES: 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 pairs_of(blk_entries):
|
||
ps = []
|
||
for i in range(len(blk_entries)):
|
||
for j in range(i + 1, len(blk_entries)):
|
||
ps.append(tuple(sorted((blk_entries[i], blk_entries[j]))))
|
||
return ps
|
||
|
||
|
||
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:
|
||
study_ids = list((await db.execute(select(TrialStudyModel.id).where(
|
||
TrialStudyModel.study_name.like(f"{PREFIX}-%")))).scalars())
|
||
if study_ids:
|
||
entries = list((await db.execute(select(TrialStudyEntryModel.id).where(
|
||
TrialStudyEntryModel.trial_study_id.in_(study_ids)))).scalars())
|
||
if entries:
|
||
await db.execute(delete(TrialStudyEntryModel).where(
|
||
TrialStudyEntryModel.id.in_(entries)))
|
||
await db.execute(delete(TrialStudyModel).where(TrialStudyModel.id.in_(study_ids)))
|
||
trial_ids = list((await db.execute(select(TrialModel.id).where(
|
||
TrialModel.trial_name == f"试验{PREFIX}"))).scalars())
|
||
if trial_ids:
|
||
await db.execute(delete(TrialModel).where(TrialModel.id.in_(trial_ids)))
|
||
combo_ids = list((await db.execute(select(CrossCombinationModel.id).where(
|
||
CrossCombinationModel.combination_code == f"{PREFIX}-C"))).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}-G%")))).scalars())
|
||
if germ_ids:
|
||
await db.execute(delete(BreedingGermplasmModel).where(
|
||
BreedingGermplasmModel.id.in_(germ_ids)))
|
||
await db.execute(delete(TargetModel).where(TargetModel.target_name == f"{PREFIX}-T"))
|
||
await db.commit()
|
||
print(f"[preclean] trial_design tc 前缀残留已清(study={len(study_ids)} germ={len(germ_ids)})")
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
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}-C", 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)
|
||
|
||
germs = []
|
||
for j in range(1, 17):
|
||
g = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-G{j:02d}",
|
||
can_be_female=(j % 2 == 0), can_be_male=True, created_id=1)
|
||
db.add(g)
|
||
germs.append(g)
|
||
await db.flush()
|
||
tokens["germ"] = [g.id for g in germs]
|
||
gid = [g.id for g in germs]
|
||
|
||
trial = TrialModel(trial_name=f"试验{PREFIX}", design_type="rcbd", created_id=1)
|
||
db.add(trial)
|
||
await db.flush()
|
||
tokens["trial"].append(trial.id)
|
||
|
||
async def mk_study(name, block_count, n_entries, germ_subset):
|
||
st = TrialStudyModel(trial_id=trial.id, study_name=f"{PREFIX}-{name}",
|
||
block_count=block_count, design_type="rcbd", year=2025, created_id=1)
|
||
db.add(st)
|
||
await db.flush()
|
||
ents = []
|
||
for e in range(1, n_entries + 1):
|
||
ent = TrialStudyEntryModel(trial_study_id=st.id, entry_number=e,
|
||
germplasm_id=germ_subset[e - 1], created_id=1)
|
||
db.add(ent)
|
||
ents.append(ent)
|
||
await db.flush()
|
||
return st, ents
|
||
|
||
s1, ents1 = await mk_study("AUG", 3, 12, gid[0:12])
|
||
s3, ents3 = await mk_study("ALPHA9", 3, 9, gid[0:9])
|
||
s2, ents2 = await mk_study("ALPHA16", 4, 16, gid[0:16])
|
||
s4, ents4 = await mk_study("ALPHA12", 4, 12, gid[0:12])
|
||
tokens["study"] += [s1.id, s3.id, s2.id, s4.id]
|
||
tokens["entry"] += [e.id for e in ents1 + ents3 + ents2 + ents4]
|
||
FIX["studies"] = {"AUG": s1.id, "ALPHA9": s3.id, "ALPHA16": s2.id, "ALPHA12": s4.id}
|
||
FIX["gid"] = gid
|
||
await db.commit()
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
async def _block_map(db, sid: int) -> dict:
|
||
rows = (await db.execute(
|
||
select(TrialStudyEntryModel.entry_number, TrialStudyEntryModel.block_no)
|
||
.where(TrialStudyEntryModel.trial_study_id == sid))).all()
|
||
return {r.entry_number: r.block_no for r in rows}
|
||
|
||
|
||
async def _readback_and_cleanup() -> None:
|
||
"""TestClient 退出后:读回 block_no 断言落库结构,然后清理。"""
|
||
engine, sf = create_async_engine_and_session()
|
||
try:
|
||
async with sf() as db:
|
||
# ===== AUG 读回:对照不落库、新品系 block_no∈{1,2,3} =====
|
||
aug_bn = await _block_map(db, FIX["studies"]["AUG"])
|
||
check("[1] 对照(entry 1..3) block_no=None 不落库",
|
||
all(aug_bn.get(e) is None for e in (1, 2, 3)),
|
||
f"{ {e: aug_bn.get(e) for e in (1, 2, 3)} }")
|
||
check("[1] 新品系(entry 4..12) block_no∈{1,2,3}",
|
||
all(aug_bn.get(e) in (1, 2, 3) for e in range(4, 13)),
|
||
f"{ {e: aug_bn.get(e) for e in (4, 13)} }")
|
||
|
||
# ===== ALPHA9 / ALPHA12 读回:复合编码 rep*100+block =====
|
||
a9_bn = await _block_map(db, FIX["studies"]["ALPHA9"])
|
||
exp3 = {e: int(r) * 100 + int(b) for r, bm in RES["a9"]["blocks"].items()
|
||
for b, vs in bm.items() for e in vs}
|
||
check("[2] α9 block_no=rep*100+block 落库",
|
||
all(a9_bn.get(e) == exp3[e] for e in exp3) and len(a9_bn) == 9,
|
||
f"{dict(list(a9_bn.items())[:4])}…")
|
||
|
||
a12_bn = await _block_map(db, FIX["studies"]["ALPHA12"])
|
||
exp4 = {e: int(r) * 100 + int(b) for r, bm in RES["a12"]["blocks"].items()
|
||
for b, vs in bm.items() for e in vs}
|
||
check("[5] α12(一般 v) block_no=rep*100+block 落库",
|
||
all(a12_bn.get(e) == exp4[e] for e in exp4) and len(a12_bn) == 12)
|
||
|
||
# ===== 清理 =====
|
||
if tokens["entry"]:
|
||
await db.execute(delete(TrialStudyEntryModel).where(
|
||
TrialStudyEntryModel.id.in_(tokens["entry"])))
|
||
if tokens["study"]:
|
||
await db.execute(delete(TrialStudyModel).where(TrialStudyModel.id.in_(tokens["study"])))
|
||
if tokens["trial"]:
|
||
await db.execute(delete(TrialModel).where(TrialModel.id.in_(tokens["trial"])))
|
||
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(f"[cleanup] trial_design tc 数据已清(entry={len(tokens['entry'])} study={len(tokens['study'])})")
|
||
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()
|
||
sid = FIX["studies"]
|
||
gid = FIX["gid"]
|
||
|
||
def run(study_id, design_type, **kw):
|
||
payload = {"trial_study_id": study_id, "design_type": design_type, **kw}
|
||
r = client.post("/api/v1/bre/statistics/trial-design", json=payload, headers=H)
|
||
check(f"[HTTP] trial-design {design_type} 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] 增广设计 =================
|
||
checks = gid[0:3]
|
||
aug = run(sid["AUG"], "augmented", seed=7, check_germplasm_ids=checks)
|
||
RES["aug"] = aug
|
||
check("[1] 输出结构 n_new=9/n_checks=3/check_reps=3/block=3",
|
||
aug is not None and aug["design_type"] == "augmented" and aug["n_new_entries"] == 9
|
||
and aug["n_checks"] == 3 and aug["check_reps"] == 3
|
||
and aug["block_count"] == 3 and aug["n_entries"] == 12,
|
||
f"{aug and (aug['n_new_entries'], aug['n_checks'], aug['check_reps'])}")
|
||
if aug:
|
||
check("[1] 新品系均分 3 区组×3 条、恰为 {4..12} 不重复",
|
||
set(aug["blocks"].keys()) == {"1", "2", "3"}
|
||
and all(len(v) == 3 for v in aug["blocks"].values())
|
||
and set(e for v in aug["blocks"].values() for e in v) == set(range(4, 13)),
|
||
f"{aug['blocks']}")
|
||
check("[1] checks 恰 3 对照 {entry_number,germplasm_id}",
|
||
len(aug["checks"]) == 3 and {c["germplasm_id"] for c in aug["checks"]} == set(checks)
|
||
and all(set(c.keys()) == {"entry_number", "germplasm_id"} for c in aug["checks"]),
|
||
f"{aug['checks']}")
|
||
|
||
# ================= [3] α-格子部分 r=3(同 v=9,先跑:r=4 会整表覆写 block_no) =================
|
||
full9 = list(range(1, 10))
|
||
a9p = run(sid["ALPHA9"], "alpha", seed=11, reps=3)
|
||
if a9p:
|
||
b9p = a9p["blocks"]
|
||
cnt3 = {}
|
||
for bm in b9p.values():
|
||
for v in bm.values():
|
||
for p in pairs_of(v):
|
||
cnt3[p] = cnt3.get(p, 0) + 1
|
||
check("[3] 部分 r=3:3 rep×3 块×3 条、可分解、仍平衡 λ≤1",
|
||
a9p["reps"] == 3 and set(b9p.keys()) == {"1", "2", "3"}
|
||
and all(sorted(e for v in bm.values() for e in v) == full9 for bm in b9p.values())
|
||
and all(c <= 1 for c in cnt3.values()),
|
||
f"reps={a9p['reps']} maxλ={max(cnt3.values()) if cnt3 else 0}")
|
||
|
||
# ================= [2] α-格子平方 v=9 k=3(素数,完全格子 r=4,λ≤1,最后跑确保落库=全量) =================
|
||
a9 = run(sid["ALPHA9"], "alpha", seed=11)
|
||
RES["a9"] = a9
|
||
if a9:
|
||
b9 = a9["blocks"]
|
||
check("[2] v=9 自动 k=3/r=4/s=3/warn=False",
|
||
a9["block_size"] == 3 and a9["reps"] == 4 and a9["block_count"] == 3
|
||
and a9["balance_warning"] is False and a9["n_entries"] == 9,
|
||
f"k={a9['block_size']} r={a9['reps']} s={a9['block_count']} warn={a9['balance_warning']}")
|
||
check("[2] 4 rep×3 块×3 条",
|
||
set(b9.keys()) == {"1", "2", "3", "4"}
|
||
and all(set(bm.keys()) == {"1", "2", "3"} for bm in b9.values())
|
||
and all(len(v) == 3 for bm in b9.values() for v in bm.values()))
|
||
check("[2] 每 rep 可分解(覆盖全部 9)",
|
||
all(sorted(e for v in bm.values() for e in v) == full9 for bm in b9.values()))
|
||
cnt = {}
|
||
for bm in b9.values():
|
||
for v in bm.values():
|
||
for p in pairs_of(v):
|
||
cnt[p] = cnt.get(p, 0) + 1
|
||
check("[2] 任一对至多同块一次(λ≤1)", all(c <= 1 for c in cnt.values()) and len(cnt) == 36,
|
||
f"maxλ={max(cnt.values()) if cnt else 0}")
|
||
|
||
# ================= [4] α-格子平方 v=16 k=4(非素数 → 平衡保守告警) =================
|
||
a16 = run(sid["ALPHA16"], "alpha", seed=13)
|
||
full16 = list(range(1, 17))
|
||
if a16:
|
||
b16 = a16["blocks"]
|
||
check("[4] v=16 自动 k=4/r=5/s=4/warn=True(k 非素数)",
|
||
a16["block_size"] == 4 and a16["reps"] == 5 and a16["block_count"] == 4
|
||
and a16["balance_warning"] is True,
|
||
f"k={a16['block_size']} r={a16['reps']} warn={a16['balance_warning']}")
|
||
check("[4] 5 rep×4 块×4 条 + 每 rep 可分解",
|
||
set(b16.keys()) == {"1", "2", "3", "4", "5"}
|
||
and all(set(bm.keys()) == {"1", "2", "3", "4"} for bm in b16.values())
|
||
and all(len(v) == 4 for bm in b16.values() for v in bm.values())
|
||
and all(sorted(e for v in bm.values() for e in v) == full16 for bm in b16.values()))
|
||
|
||
# ================= [5] α-格子一般 v=12(非平方,k=3 显式,贪心可分解) =================
|
||
a12 = run(sid["ALPHA12"], "alpha", seed=17, block_size=3)
|
||
RES["a12"] = a12
|
||
full12 = list(range(1, 13))
|
||
if a12:
|
||
b12 = a12["blocks"]
|
||
check("[5] v=12 显式 k=3/s=4/r 缺省=4",
|
||
a12["block_size"] == 3 and a12["block_count"] == 4 and a12["reps"] == 4
|
||
and a12["n_entries"] == 12,
|
||
f"k={a12['block_size']} s={a12['block_count']} r={a12['reps']}")
|
||
check("[5] 4 rep×4 块×3 条 + 每 rep 可分解(贪心未破坏分解性)",
|
||
set(b12.keys()) == {"1", "2", "3", "4"}
|
||
and all(set(bm.keys()) == {"1", "2", "3", "4"} for bm in b12.values())
|
||
and all(len(v) == 3 for bm in b12.values() for v in bm.values())
|
||
and all(sorted(e for v in bm.values() for e in v) == full12 for bm in b12.values()),
|
||
f"warn={a12['balance_warning']}")
|
||
|
||
# ================= [6] 校验 409 =================
|
||
r = client.post("/api/v1/bre/statistics/trial-design",
|
||
json={"trial_study_id": sid["AUG"], "design_type": "augmented",
|
||
"seed": 1, "check_germplasm_ids": [gid[14]]}, headers=H)
|
||
check("[6] augmented 对照非条目子集 → 409", r.status_code == 409, f"{r.status_code}")
|
||
|
||
r = client.post("/api/v1/bre/statistics/trial-design",
|
||
json={"trial_study_id": sid["AUG"], "design_type": "augmented",
|
||
"seed": 1, "check_germplasm_ids": []}, headers=H)
|
||
check("[6] augmented 无对照 → 409", r.status_code == 409, f"{r.status_code}")
|
||
|
||
r = client.post("/api/v1/bre/statistics/trial-design",
|
||
json={"trial_study_id": sid["ALPHA16"], "design_type": "alpha",
|
||
"seed": 1, "block_size": 5}, headers=H)
|
||
check("[6] alpha v 不被 k 整除 → 409", r.status_code == 409, f"{r.status_code}")
|
||
|
||
r = client.post("/api/v1/bre/statistics/trial-design",
|
||
json={"trial_study_id": sid["ALPHA12"], "design_type": "alpha", "seed": 1},
|
||
headers=H)
|
||
check("[6] alpha 非平方缺 block_size → 409", r.status_code == 409, f"{r.status_code}")
|
||
finally:
|
||
asyncio.run(_readback_and_cleanup())
|
||
|
||
print(f"\n===== trial_design tc 套件:ok={ok} fail={fail} =====")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main_()
|
||
sys.exit(1 if fail else 0)
|