# -*- 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 路径)。 - 标记 pg(pyproject 已注册),`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}"