Files
dpb/backend/tests/test_stat_analysis_tc.py
T

175 lines
6.8 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""统计引擎正式 tc 套件(ssGBLUP/OCS/MABC/孟德尔 + 补齐 5 套)的 pytest 包装层。
每套 tc = 一个独立脚本(backend/scripts/test_bre_analysis_*_tc.py),走真实 API
TestClient + 真实 PostgreSQL 16 + Redis),脚本自带 fixture 清理并 sys.exit(0/1)。
本包装层以 subprocess 顺序调用这些脚本,断言 rc==0 —— 与 run_regression.py 的执行契约一致。
关键点:
- 全部基于真实 PG16 + Redis,仅当探测到 PG/Redis 可达才跑,否则 skipif 跳过。
- 不 import 任何 app 模块(测试库生命周期由 conftest 管理,与统计引擎 tc 独立);subprocess env 显式构建:
清空可能残留的 DATABASE_*/REDIS_*/POOL_*/CAPTCHA_ENABLE 等连接变量,
置 ENVIRONMENT=dev / DATABASE_TYPE=postgres / PYTHONPATH=backend / PYTHONUTF8=1
再并入从 backend/env/.env.dev 解析的真实连接值(可被 TC_CI_* 覆盖,用于验证 skipif 路径)。
- 标记 pgpyproject 已注册),`pytest -m pg` 只跑本组;run_ci.py 的验收门即此命令。
运行:cd backend && C:/ai/miniconda3/envs/dpb/python.exe -X utf8 -m pytest -m pg -v
"""
import asyncio
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
BACKEND = Path(__file__).resolve().parents[1]
SCRIPTS = BACKEND / "scripts"
ENV_FILE = BACKEND / "env" / ".env.dev"
PY = os.environ.get("DPB_PYTHON", sys.executable)
# (套件名, 脚本文件名) —— 10 套:4 套既有 + 5 套补齐 + O2–O4 方法学广度
TC_SCRIPTS = [
("ssgblup", "test_bre_analysis_ssgblup_tc.py"),
("ocs", "test_bre_analysis_ocs_tc.py"),
("mabc", "test_bre_analysis_mabc_tc.py"),
("mendelian", "test_bre_analysis_mendelian_tc.py"),
("genetic_corr", "test_bre_analysis_genetic_corr_tc.py"),
("type_b_heredity", "test_bre_analysis_type_b_tc.py"),
("inbreeding_depression", "test_bre_analysis_inbreeding_tc.py"),
("trial_design", "test_bre_analysis_trial_design_tc.py"),
("stability", "test_bre_analysis_stability_tc.py"),
("o234", "test_bre_analysis_o234_tc.py"),
]
_TC_OVERRIDES = {
"db_host": "TC_CI_DB_HOST", "db_port": "TC_CI_DB_PORT", "db_user": "TC_CI_DB_USER",
"db_password": "TC_CI_DB_PASSWORD", "db_name": "TC_CI_DB_NAME",
"redis_host": "TC_CI_REDIS_HOST", "redis_port": "TC_CI_REDIS_PORT",
"redis_db": "TC_CI_REDIS_DB",
}
def _env_cfg() -> dict:
"""解析 backend/env/.env.dev(简单 key=value),返回真实连接配置,可被 TC_CI_* 覆盖。"""
cfg = {}
if ENV_FILE.exists():
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
cfg[k.strip()] = v.strip().strip("'\"")
for key, envvar in _TC_OVERRIDES.items():
if envvar in os.environ and os.environ[envvar] != "":
cfg[key] = os.environ[envvar]
return {
"db_host": cfg.get("DATABASE_HOST", "localhost"),
"db_port": int(cfg.get("DATABASE_PORT", "5432")),
"db_user": cfg.get("DATABASE_USER", "dpb"),
"db_password": cfg.get("DATABASE_PASSWORD", ""),
"db_name": cfg.get("DATABASE_NAME", "dpb"),
"redis_host": cfg.get("REDIS_HOST", "localhost"),
"redis_port": int(cfg.get("REDIS_PORT", "6379")),
"redis_db": int(cfg.get("REDIS_DB_NAME", "1")),
}
async def _probe() -> tuple[bool, bool, str]:
import asyncpg
import redis.asyncio as aioredis
cfg = _env_cfg()
db_ok, redis_ok = False, False
db_err, redis_err = "", ""
try:
conn = await asyncpg.connect(
host=cfg["db_host"], port=cfg["db_port"], user=cfg["db_user"],
password=cfg["db_password"] or None, database=cfg["db_name"],
timeout=5)
await conn.close()
db_ok = True
except Exception as exc: # noqa: BLE001
db_err = str(exc)[:100]
try:
# protocol=2 必须与 app.core.database.redis_connect 一致:本机 WSL redis-server 版本较老,
# 不支持 redis-py 7.x 默认 RESP3,需走 RESP2。
r = aioredis.Redis(host=cfg["redis_host"], port=cfg["redis_port"],
db=cfg["redis_db"], protocol=2, socket_connect_timeout=5)
await r.ping()
await r.aclose()
redis_ok = True
except Exception as exc: # noqa: BLE001
redis_err = str(exc)[:100]
detail = (f"PG={'ok' if db_ok else db_err} Redis={'ok' if redis_ok else redis_err}")
return db_ok, redis_ok, detail
_PG: tuple | None = None
def pg_ready() -> tuple[bool, bool, str]:
global _PG
if _PG is None:
_PG = asyncio.run(_probe())
return _PG
def _sub_env() -> dict:
"""显式构建子进程环境:清空可能残留的连接变量,置真实 PG/Redis 连接。"""
env = dict(os.environ)
for k in ("DATABASE_TYPE", "DATABASE_NAME", "POOL_SIZE", "MAX_OVERFLOW",
"CAPTCHA_ENABLE", "ENVIRONMENT", "PYTHONPATH", "PYTHONIOENCODING"):
env.pop(k, None)
cfg = _env_cfg()
env["ENVIRONMENT"] = "dev"
env["DATABASE_TYPE"] = "postgres"
env["PYTHONPATH"] = str(BACKEND)
env["PYTHONUTF8"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
env["DATABASE_HOST"] = cfg["db_host"]
env["DATABASE_PORT"] = str(cfg["db_port"])
env["DATABASE_USER"] = cfg["db_user"]
env["DATABASE_PASSWORD"] = cfg["db_password"]
env["DATABASE_NAME"] = cfg["db_name"]
env["REDIS_HOST"] = cfg["redis_host"]
env["REDIS_PORT"] = str(cfg["redis_port"])
env["REDIS_DB_NAME"] = str(cfg["redis_db"])
return env
def _run_tc(name: str, script: Path) -> str:
"""顺序串跑单个 tc 脚本;返回 '' 表示通过,否则返回失败摘要(尾部日志)。"""
try:
proc = subprocess.run(
[PY, "-X", "utf8", str(script)],
cwd=str(SCRIPTS), env=_sub_env(), capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=1800)
except subprocess.TimeoutExpired:
return f"[{name}] 超时(>30min"
out = proc.stdout or ""
summary = ""
for line in out.splitlines():
if "===== " in line and "tc 套件" in line:
summary = line.strip()
break
if proc.returncode == 0:
print(f" -> {name}: EXIT=0 {summary}")
return ""
tail = "\n".join(out.splitlines()[-25:])
return f"[{name}] EXIT={proc.returncode} {summary}\n{tail}"
@pytest.mark.pg
@pytest.mark.parametrize("name,script", TC_SCRIPTS)
def test_analysis_tc(name: str, script: str):
db_ok, redis_ok, detail = pg_ready()
if not (db_ok and redis_ok):
pytest.skip(f"需要真实 PostgreSQL16 + Redis{detail}")
script_path = SCRIPTS / script
assert script_path.exists(), f"脚本缺失: {script_path}"
err = _run_tc(name, script_path)
assert err == "", f"\n{err}"