"""桃育种后端「单株评价 → 性状观测 → 统计分析」部署自测脚本(仅标准库 urllib)。 用途:一键验证 breeding 模块评价与统计全链路是否打通,可部署后回归自测。 链路:登录 → 建基础数据(基地/试验地/育种目标/杂交组合/单株) → 建评价(tree_evaluation) → 批量建观测(trait_observation) → 跑统计(describe / correlation / traits / selection-index) → 断言统计结果确实来自 trait_observation(均值精确回读) 特点: · 登录自适应验证码(dev 开启时自动 captcha/get → slider/complete → 带 key 登录)。 · 双单元:①精确校验单元(单组合单株, 固定值, 断言 describe 均值精确回读) ②批量链路单元(多组合多单株, 仅验证 HTTP 200 与计数)。 · 默认跑完自动清理,库不留脏数据(CLEANUP=True)。 · 任意非 200 或断言失败立即退出码 1;全过退出码 0。 用法: cd d:\\dpb\\dpb\\backend $env:ENVIRONMENT='dev' C:\\ai\\miniconda3\\envs\\dpb\\python.exe scripts/e2e_eval.py """ import datetime import json import sys import urllib.error import urllib.parse import urllib.request BASE = "http://localhost:5667" RUN = datetime.datetime.now().strftime("%Y%m%d%H%M%S") CLEANUP = True # 部署自测默认清理,不留脏数据 N_COMBOS = 3 # 批量单元:组合数 N_TREES = 4 # 批量单元:每组合单株数 CORE = ["max_fruit_weight", "avg_fruit_weight", "longitudinal_dia", "transverse_dia", "lateral_dia", "flesh_thickness", "ssc"] HEADERS = {"Content-Type": "application/json"} _COLLECT = {"obs": [], "eval": [], "tree": [], "combo": [], "plot": [], "site": [], "target": []} TOKEN = None def track(kind, obj): if isinstance(obj, list): _COLLECT[kind].extend(obj) else: _COLLECT[kind].append(obj) return obj def _req(method, url, *, data=None, headers=None, form=False): h = dict(HEADERS) if headers: h.update(headers) body = None if data is not None: if form: body = urllib.parse.urlencode(data).encode() h["Content-Type"] = "application/x-www-form-urlencoded" else: body = json.dumps(data).encode() req = urllib.request.Request(url, data=body, method=method, headers=h) try: with urllib.request.urlopen(req, timeout=30) as r: return r.status, json.loads(r.read().decode()) except urllib.error.HTTPError as e: try: return e.code, json.loads(e.read().decode()) except Exception: # noqa: BLE001 return e.code, {"raw": e.read().decode()[:400]} def step(name, status, body, *, expect=200): if status != expect: print(f"[FAIL] {name} -> HTTP {status}") print(" BODY:", str(body)[:600]) sys.exit(1) print(f"[OK ] {name} -> HTTP {status}") return body def assert_(cond, msg): if not cond: print(f"[FAIL] ASSERT: {msg}") sys.exit(1) print(f"[OK ] ASSERT: {msg}") def login(): data = {"username": "super", "password": "123456", "grant_type": "password", "login_type": "PC端"} st, b = _req("POST", f"{BASE}/api/v1/system/auth/login", data=data, form=True) if st == 200 and b.get("code") == 0: return b["data"]["access_token"] # dev 开启验证码:拿 key -> 标记 verified -> 带 key 登录 key = _req("GET", f"{BASE}/api/v1/system/auth/captcha/get")[1]["data"]["key"] _req("POST", f"{BASE}/api/v1/system/auth/captcha/slider/complete", data={"captcha_key": key}) data["captcha_key"] = key st, b = _req("POST", f"{BASE}/api/v1/system/auth/login", data=data, form=True) if st != 200 or b.get("code") != 0: print("LOGIN FAILED", st, b) sys.exit(1) return b["data"]["access_token"] def create(domain, payload): st, b = _req("POST", f"{BASE}/api/v1/bre/{domain}/create", data=payload, headers={"Authorization": f"Bearer {TOKEN}"}) return step(f"POST /bre/{domain}/create", st, b)["data"] def gget(domain, params=None): url = f"{BASE}/api/v1/bre/{domain}/list" if params: url += "?" + urllib.parse.urlencode(params) st, b = _req("GET", url, headers={"Authorization": f"Bearer {TOKEN}"}) return step(f"GET /bre/{domain}/list", st, b) def _lq(name, items): """构造 FastAPI list[str] 查询串:trait_codes=a&trait_codes=b(逗号串会被当单值)。""" return "&".join(f"{name}={urllib.parse.quote(str(i))}" for i in items) def gen_value(code, ci, ti): """批量单元确定性造数(落在合理量程,仅用于链路验证,非真实育种值)。""" bases = {"max_fruit_weight": 80, "avg_fruit_weight": 60, "longitudinal_dia": 50, "transverse_dia": 55, "lateral_dia": 52, "flesh_thickness": 8, "ssc": 8} off = (ci * 7 + ti * 1.7 + CORE.index(code) * 0.9) % 30 return round(bases[code] + off, 1) def precise_unit(core_traits, gid, pid): """精确均值校验:单组合单株 + 7 固定值观测,断言 describe 回读精确均值。""" print("\n--- 单元① 精确均值校验 (单组合/单株, 固定值) ---") site = create("site", {"site_name": f"E2E基地{RUN}-P", "remark": "自测自动创建"}, ) track("site", site["id"]) plot = create("plot", {"site_id": site["id"], "plot_code": f"P{RUN}-P"}) track("plot", plot["id"]) target = create("target", {"target_name": f"E2E目标{RUN}-P"}) track("target", target["id"]) combo = create("cross_combination", { "combination_code": f"E2E-CC-{RUN}-P", "bre_target_id": target["id"], "female_parent_id": gid, "male_parent_id": gid, "cross_method": "人工杂交", "cross_year": 2026, }) track("combo", combo["id"]) tree = create("tree", { "combination_id": combo["id"], "plot_id": plot["id"], "tree_no": f"T{RUN}-P", "row_no": 1, "col_no": 1, "planted_date": "2026-03-15", "status": "1", "bre_personnel_id": pid, }) track("tree", tree["id"]) ev = create("tree_evaluation", { "combination_id": combo["id"], "tree_id": tree["id"], "evaluate_date": "2026-07-29", "evaluate_year": 2026, "bre_personnel_id": pid, "remark": f"E2E自测{RUN}-P", }) track("eval", ev["id"]) values = {"max_fruit_weight": 120.5, "avg_fruit_weight": 98.2, "longitudinal_dia": 65.1, "transverse_dia": 70.3, "lateral_dia": 68.0, "flesh_thickness": 12.4, "ssc": 12.8} for t in core_traits: code = t["trait_code"] ob = create("trait_observation", { "evaluation_id": ev["id"], "tree_id": tree["id"], "combination_id": combo["id"], "trait_id": t["id"], "category": t.get("category"), "trait_code": code, "trait_name": t.get("trait_name"), "data_type": t.get("data_type"), "evaluate_year": 2026, "value_numeric": values.get(code), }) track("obs", ob["id"]) # describe?group_by=combination:该组合 stats 的 mean 应精确等于插入值 qs = _lq("trait_codes", CORE) st, b = _req("GET", f"{BASE}/api/v1/bre/statistics/describe?{qs}&group_by=combination", headers={"Authorization": f"Bearer {TOKEN}"}) de = step("GET /bre/statistics/describe(group_by=combination)", st, b) groups = de["data"].get("groups", []) grp = next((g for g in groups if g["group"] == combo["id"]), None) assert_(grp is not None, f"describe 回读包含本组合 {combo['id']}") stat_map = {s["trait"]: s for s in grp["stats"]} for code, val in values.items(): assert_(code in stat_map, f"describe 含性状 {code}") assert_(abs(stat_map[code]["mean"] - val) < 1e-6, f"{code} 均值精确回读={val} (got {stat_map[code]['mean']})") assert_(stat_map[code]["n"] == 1, f"{code} 覆盖单株数 n==1") assert_(de["data"]["total"] == 1, "describe total==1 (单株)") return 1 # 贡献的单株数 def batch_unit(n_combos, n_trees, core_traits, gid, pid): """批量链路单元:多组合多单株,仅验证接口不报错且计数正确。""" print(f"\n--- 单元② 批量链路 (组合×单株 = {n_combos}×{n_trees}) ---") site = create("site", {"site_name": f"E2E基地{RUN}-B", "remark": "自测自动创建"}) track("site", site["id"]) plot = create("plot", {"site_id": site["id"], "plot_code": f"P{RUN}-B"}) track("plot", plot["id"]) target = create("target", {"target_name": f"E2E目标{RUN}-B"}) track("target", target["id"]) total_trees = 0 for ci in range(n_combos): combo = create("cross_combination", { "combination_code": f"E2E-CC-{RUN}-B{ci}", "bre_target_id": target["id"], "female_parent_id": gid, "male_parent_id": gid, "cross_method": "人工杂交", "cross_year": 2026, }) track("combo", combo["id"]) for ti in range(n_trees): tree = create("tree", { "combination_id": combo["id"], "plot_id": plot["id"], "tree_no": f"T{RUN}-B{ci}-{ti}", "row_no": ti + 1, "col_no": 1, "planted_date": "2026-03-15", "status": "1", "bre_personnel_id": pid, }) track("tree", tree["id"]) ev = create("tree_evaluation", { "combination_id": combo["id"], "tree_id": tree["id"], "evaluate_date": "2026-07-29", "evaluate_year": 2026, "bre_personnel_id": pid, "remark": f"E2E自测{RUN}-B{ci}-{ti}", }) track("eval", ev["id"]) for t in core_traits: code = t["trait_code"] ob = create("trait_observation", { "evaluation_id": ev["id"], "tree_id": tree["id"], "combination_id": combo["id"], "trait_id": t["id"], "category": t.get("category"), "trait_code": code, "trait_name": t.get("trait_name"), "data_type": t.get("data_type"), "evaluate_year": 2026, "value_numeric": gen_value(code, ci, ti), }) track("obs", ob["id"]) total_trees += 1 return total_trees def stat_checks(precise_trees, batch_trees): print("\n--- 统计接口断言 ---") # traits 核心性状数 st, b = _req("GET", f"{BASE}/api/v1/bre/statistics/traits?core_only=1", headers={"Authorization": f"Bearer {TOKEN}"}) tr = step("GET /bre/statistics/traits?core_only=1", st, b) assert_(len(tr["data"]) == 7, f"核心性状数==7 (got {len(tr['data'])})") # describe 全局 total qs = _lq("trait_codes", CORE) st, b = _req("GET", f"{BASE}/api/v1/bre/statistics/describe?{qs}&group_by=combination", headers={"Authorization": f"Bearer {TOKEN}"}) de = step("GET /bre/statistics/describe", st, b) expected_total = precise_trees + batch_trees assert_(de["data"]["total"] == expected_total, f"describe total=={expected_total} (got {de['data']['total']})") # correlation 矩阵维度与对角 cqs = _lq("trait_codes", ["avg_fruit_weight", "ssc"]) st, b = _req("GET", f"{BASE}/api/v1/bre/statistics/correlation?{cqs}", headers={"Authorization": f"Bearer {TOKEN}"}) co = step("GET /bre/statistics/correlation", st, b) assert_(co["data"]["n"] >= 1 and len(co["data"]["matrix"]) == 2, "correlation 矩阵 2x2") assert_(co["data"]["matrix"][0][0] == 1.0 and co["data"]["matrix"][1][1] == 1.0, "correlation 对角线==1.0") # selection-index 返回排名 st, b = _req("POST", f"{BASE}/api/v1/bre/statistics/selection-index", data={"weights": {c: 1.0 for c in CORE}, "year": 2026, "top_n": 10}, headers={"Authorization": f"Bearer {TOKEN}"}) si = step("POST /bre/statistics/selection-index", st, b) top = si["data"].get("top") if isinstance(si.get("data"), dict) else None assert_(isinstance(top, list) and len(top) >= 1, f"selection-index 返回非空排名 (n={len(top) if isinstance(top, list) else '?'})") def cleanup(): if not CLEANUP: print("\n(CLEANUP=False,测试数据保留;再次运行自动用新 RUN 后缀避免冲突)") return print("\n清理测试数据...") h = {"Authorization": f"Bearer {TOKEN}"} order = [("trait_observation", "obs"), ("tree_evaluation", "eval"), ("tree", "tree"), ("cross_combination", "combo"), ("plot", "plot"), ("site", "site"), ("target", "target")] for domain, key in order: ids = _COLLECT[key] if not ids: continue st, _ = _req("DELETE", f"{BASE}/api/v1/bre/{domain}/delete", data=ids, headers=h) print(f" DELETE /bre/{domain}/delete {len(ids)} 条 -> HTTP {st}") def main(): global TOKEN TOKEN = login() print(f"登录成功,token 前缀: {TOKEN[:12]}...\n") tr = gget("trait", {"page": 1, "page_size": 100}) traits = tr["data"]["items"] core_traits = [t for t in traits if t["trait_code"] in set(CORE) and t.get("data_type") == "numeric"] assert_(len(core_traits) == 7, f"核心数值性状命中 7/7 (got {len(core_traits)})") print(f"核心数值性状: {[t['trait_code'] for t in core_traits]}\n") germ = gget("germplasm", {"page": 1, "page_size": 5}) gid = germ["data"]["items"][0]["id"] pers = gget("personnel", {"page": 1, "page_size": 5}) pid = pers["data"]["items"][0]["id"] try: p = precise_unit(core_traits, gid, pid) b = batch_unit(N_COMBOS, N_TREES, core_traits, gid, pid) stat_checks(p, b) print("\n=== 部署自测全部通过 ✅ ===") finally: cleanup() if __name__ == "__main__": main()