forked from admin/PlantAI-rag-new
- 生信进度:后端新增 stage_progress 事件帧+进度解析白名单(rnaseq_qc/sample_qc), kernel 日志流式落盘(20行/1s flush+fsync),logs 分页端点(offset/limit/eof), 事件 seq 跨重启扫描续增(2026-09-17 断流修复);前端 FlowProgressCard 确定性/ 不确定两态进度条+点击定位到生信任务 TAB,BioJobsPane DAG 行展开滚动日志区 (3s 轮询),log_tail 4000 截断,重跑 stage_started 清零进度 - 图片注入:figures 三源重构(标准DOI图库主路径/遗留体系兜底/PMC兜底), [[FIG:]] 宽松归一化识别 PMC/PMID/DOI 三种形态,引文兜底扩展至 [PMCID:]/[DOI:] 且标记未满配额继续注入,注入预算 90s/10篇(175 实测校准), PMC graphic 无 content-type 宽松正则,空结果不缓存(176 冷连接三连败修复) - QA 启动自愈:start.sh 启动前等 Qdrant readyz(30min)+Neo4j TCP(10min); backend_guard 标签校验改子集判定 - 样本配对:同 inode 去重优先真实文件,R1/R2 归一配对一对一认领 - gitignore 新增 *.bak-*/*.before-*/bioflow_service/bioflow.db 测试:bioflow_service pytest 358/358,前端 bioflow vitest 205/205
354 lines
14 KiB
Python
354 lines
14 KiB
Python
"""tests/conftest.py —— 生信服务测试共享夹具。
|
||
|
||
分层标记(TEST_PLAN §9-12 / TEST_CASES §6):
|
||
- bioflow_unit:服务层单元(runner 状态机/kernel 拼装/manifest/DB 迁移/意图分流)
|
||
- bioflow_api:端点用例(TestClient + mock docker/LLM/vstore)
|
||
|
||
全部 mock docker/LLM/vstore,不依赖生产服务(8090/8091/8094/8098 绝不探测)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
# 测试运行 cwd = bioflow_service/(任务书自测命令);repo 根在其上一级
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||
if str(REPO_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(REPO_ROOT))
|
||
|
||
from app.config import Settings # noqa: E402
|
||
from app.context import Context, build_context # noqa: E402
|
||
from app.db import Database # noqa: E402
|
||
from app.services.bioflow.catalog import StageCatalog, build_catalog # noqa: E402
|
||
from app.services.bioflow.events import EventBus # noqa: E402
|
||
from app.services.bioflow.kernel import ImageMap, Kernel, KernelConfig, UnknownStageError # noqa: E402
|
||
from app.services.bioflow.runner import Runner # noqa: E402
|
||
|
||
|
||
def _run(coro):
|
||
"""同步跑一个协程(新建 event loop;docgen 测试同款可靠模式)。"""
|
||
return asyncio.new_event_loop().run_until_complete(coro)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 静态映射表 fixture(不依赖真实 stage_image_map.json;测试确定性)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
STAGE_MAP_JSON = {
|
||
"version": "2.0.0",
|
||
"images": {
|
||
"bioflow-gf": {"python": "3.11.14", "source_env": "test4py311", "docker_tag": "bioflow-gf"},
|
||
"bioflow-rnaseq": {"python": "3.11.15", "source_env": "rnaseq", "docker_tag": "bioflow-rnaseq"},
|
||
"bioflow-rnaR": {"python": "3.14.3", "source_env": "rnaR", "docker_tag": "bioflow-rnar",
|
||
"auxiliary": True},
|
||
},
|
||
"stages": {
|
||
"identify": {"image": "bioflow-gf", "module": "cli.run_identify", "executable": True},
|
||
"genome_prepare": {"image": "bioflow-gf", "module": "cli.run_genome_prepare", "executable": True},
|
||
"phylogeny": {"image": "bioflow-gf", "module": "cli.run_phylogeny", "executable": True},
|
||
"motif": {"image": "bioflow-gf", "module": "cli.run_motif", "executable": True},
|
||
"expression": {
|
||
"image": "bioflow-rnaR", "module": "cli.run_expression", "executable": True,
|
||
"note": "跨环境: 主 rnaR; rnaseq_quant 矩阵子步骤拆 bioflow-rnaseq",
|
||
},
|
||
"rnaseq_quant": {"image": "bioflow-rnaseq", "module": "cli.run_rnaseq_quant", "executable": True},
|
||
"rnaseq_qc": {"image": "bioflow-rnaseq", "module": "cli.run_rnaseq_qc", "executable": True},
|
||
"synteny_plot_revise": {"image": "bioflow-gf", "module": None, "executable": True,
|
||
"note": "入口脚本缺失"},
|
||
},
|
||
"family_chain": ["genome_prepare", "identify", "phylogeny", "motif", "expression"],
|
||
}
|
||
|
||
|
||
@pytest.fixture()
|
||
def image_map_path(tmp_path):
|
||
p = tmp_path / "stage_image_map.json"
|
||
p.write_text(json.dumps(STAGE_MAP_JSON), encoding="utf-8")
|
||
return p
|
||
|
||
|
||
@pytest.fixture()
|
||
def image_map(image_map_path) -> ImageMap:
|
||
return Kernel.load(image_map_path, KernelConfig()).map
|
||
|
||
|
||
@pytest.fixture()
|
||
def catalog(image_map) -> StageCatalog:
|
||
return build_catalog(image_map)
|
||
|
||
|
||
@pytest.fixture()
|
||
def kcfg(tmp_path) -> KernelConfig:
|
||
return KernelConfig(
|
||
host_uid=1234,
|
||
docker_tag_suffix="dev",
|
||
data_root=str(tmp_path / "data"),
|
||
bba_env={"BBA_PFAM_DIR": "/bioflow-data/hmm"},
|
||
)
|
||
|
||
|
||
@pytest.fixture()
|
||
def kernel(image_map, kcfg) -> Kernel:
|
||
return Kernel(image_map, kcfg)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB(临时 SQLite;三表幂等迁移)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture()
|
||
def db(tmp_path):
|
||
database = Database(tmp_path / "bioflow" / "bioflow.db")
|
||
_run(database.connect())
|
||
yield database
|
||
_run(database.close())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# runner(mock docker 执行器)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class FakeExecutor:
|
||
"""mock docker:可编程退出码/超时/延迟/日志;记录全部调用。"""
|
||
|
||
def __init__(self, exit_code: int = 0, log: str = "", delay: float = 0.0,
|
||
manifest_status: str | None = None) -> None:
|
||
self.exit_code = exit_code
|
||
self.log = log or "stage running log line\n"
|
||
self.delay = delay
|
||
self.manifest_status = manifest_status # None=用参数决定;"success"/"failed"/"missing"
|
||
self.calls: list[dict[str, Any]] = []
|
||
self.running = 0
|
||
self.max_running = 0
|
||
|
||
def _write_manifest(self, workspace, stage: str, status: str, prefix: str = "Ft.SPL") -> None:
|
||
final = Path(workspace) / "final"
|
||
final.mkdir(parents=True, exist_ok=True)
|
||
manifest = {
|
||
"manifest_version": "1", "job_id": "t", "stage": stage,
|
||
"status": status, "prefix": prefix,
|
||
"artifacts": {"final_members": {"artifact": "final_members",
|
||
"full_path": str(final / f"{prefix}.identified_members.id"),
|
||
"type": "table", "size": 10}},
|
||
"parameters": {}, "metrics": {"final_members": 24},
|
||
}
|
||
(final / f"{prefix}.{stage}.manifest.json").write_text(
|
||
json.dumps(manifest), encoding="utf-8")
|
||
|
||
async def __call__(self, stage, input_dir, workspace, params=None,
|
||
log_path=None, **kwargs):
|
||
params = params or {}
|
||
if self.delay:
|
||
await asyncio.sleep(self.delay)
|
||
self.running += 1
|
||
self.max_running = max(self.max_running, self.running)
|
||
try:
|
||
log = str(self.log) if isinstance(self.log, str) else str(self.log)
|
||
status = self.manifest_status
|
||
if status is None:
|
||
status = "success" if self.exit_code == 0 else "failed"
|
||
if status != "missing" and self.exit_code == 0:
|
||
self._write_manifest(workspace, stage, status)
|
||
if log_path:
|
||
Path(log_path).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(log_path).write_text(log, encoding="utf-8")
|
||
cmd_hint = {"stage": stage, "cpu": params.get("cpu"), "memory": params.get("memory")}
|
||
self.calls.append(cmd_hint)
|
||
from app.services.bioflow.kernel import tail_lines
|
||
return {"exit_code": self.exit_code, "timed_out": False,
|
||
"log_tail": tail_lines(log, 80), "cmd": []}
|
||
finally:
|
||
self.running -= 1
|
||
|
||
|
||
class FakeTimedOutExecutor(FakeExecutor):
|
||
async def __call__(self, stage, input_dir, workspace, params=None,
|
||
log_path=None, **kwargs):
|
||
self.calls.append({"stage": stage, "timed_out": True})
|
||
return {"exit_code": 137, "timed_out": True, "log_tail": "timeout killed\n", "cmd": []}
|
||
|
||
|
||
@pytest.fixture()
|
||
def fake_executor() -> FakeExecutor:
|
||
return FakeExecutor()
|
||
|
||
|
||
def make_runner(db, events, executor, catalog, *, concurrency: int = 3,
|
||
check_inputs: bool = False, **kwargs) -> Runner:
|
||
"""Runner 工厂:默认 force_input_check=False(状态机用例不卡输入检查),
|
||
需要输入检查的用例传 check_inputs=True(见 test_await_files_when_missing)。"""
|
||
return Runner(
|
||
db, events, executor, catalog, asyncio.Semaphore(concurrency),
|
||
force_input_check=check_inputs, **kwargs,
|
||
)
|
||
|
||
|
||
@pytest.fixture()
|
||
def events(tmp_path) -> EventBus:
|
||
return EventBus(tmp_path / "events")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# context(端点用例用)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class FakeFlowStore:
|
||
"""mock vstore FlowStore(P6 接口形态)。"""
|
||
|
||
def __init__(self, items=None) -> None:
|
||
self.items = items or []
|
||
|
||
def search(self, query, top_k=6, **kwargs):
|
||
return {"mode": "hybrid", "items": self.items[:top_k]}
|
||
|
||
def get_chunk(self, chunk_key):
|
||
for it in self.items:
|
||
if str(it.get("chunk_key") or it.get("id")) == chunk_key:
|
||
return {"meta": it, "text": it.get("text", "")}
|
||
return None
|
||
|
||
|
||
class FakeLLM:
|
||
"""mock 规划 LLM:可编程候选/异常。"""
|
||
|
||
def __init__(self, candidates=None, error: str | None = None) -> None:
|
||
self.candidates = candidates or [
|
||
{"purpose": "SPL 家族标准鉴定流程", "stages": ["identify", "phylogeny", "motif"]},
|
||
]
|
||
self.error = error
|
||
|
||
async def __call__(self, system, user, **kwargs):
|
||
if self.error == "timeout":
|
||
from app.services.bioflow.llm import LLMTimeout
|
||
raise LLMTimeout("timeout")
|
||
if self.error == "unavailable":
|
||
from app.services.bioflow.llm import LLMUnavailable
|
||
raise LLMUnavailable("unavailable")
|
||
if self.error == "bad":
|
||
from app.services.bioflow.llm import LLMError
|
||
raise LLMError("bad")
|
||
return {"candidates": self.candidates}
|
||
|
||
|
||
@pytest.fixture()
|
||
def test_settings(tmp_path, monkeypatch):
|
||
"""测试 Settings(全部路径指向 tmp;零宿主路径)。"""
|
||
s = Settings(
|
||
_env_file=None,
|
||
data_root=str(tmp_path / "data"),
|
||
jobs_dir=str(tmp_path / "jobs"),
|
||
extensions_dir=str(tmp_path / "extensions"),
|
||
db_path=str(tmp_path / "bioflow.db"),
|
||
archives_dir=str(tmp_path / "archives"),
|
||
image_map_path=str(tmp_path / "stage_image_map.json"),
|
||
flow_docs_dir=str(tmp_path / "flow_docs"),
|
||
concurrency=3,
|
||
host_uid_env="BIOFLOW_TEST_UID",
|
||
service_url="http://127.0.0.1:9999",
|
||
)
|
||
Path(s.image_map_abs).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(s.image_map_abs).write_text(json.dumps(STAGE_MAP_JSON), encoding="utf-8")
|
||
for d in (s.data_root_abs, s.jobs_dir_abs, s.archives_dir_abs, s.flow_docs_dir_abs, s.extensions_dir_abs):
|
||
Path(d).mkdir(parents=True, exist_ok=True)
|
||
monkeypatch.setenv("BIOFLOW_TEST_UID", "1234")
|
||
|
||
import app.config as config_mod
|
||
monkeypatch.setattr(config_mod, "settings", s)
|
||
monkeypatch.setattr(config_mod, "get_settings", lambda: s)
|
||
|
||
import app.context as context_mod
|
||
monkeypatch.setattr(context_mod, "get_settings", lambda: s)
|
||
monkeypatch.setattr(context_mod, "context", Context())
|
||
return s
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _isolated_env(tmp_path, monkeypatch):
|
||
"""每个用例隔离:关键环境键清空(不污染生产键名)且 Settings 默认全部指向 tmp。"""
|
||
for key in ("BIOFLOW_DATA_ROOT", "BIOFLOW_JOBS_DIR", "BIOFLOW_DB_PATH",
|
||
"BIOFLOW_ARCHIVES_DIR", "BIOFLOW_IMAGE_MAP", "BIOFLOW_FLOW_DOCS_DIR"):
|
||
monkeypatch.delenv(key, raising=False)
|
||
s = Settings(
|
||
_env_file=None,
|
||
data_root=str(tmp_path / "data"),
|
||
jobs_dir=str(tmp_path / "jobs"),
|
||
extensions_dir=str(tmp_path / "extensions"),
|
||
db_path=str(tmp_path / "bioflow.db"),
|
||
archives_dir=str(tmp_path / "archives"),
|
||
image_map_path=str(tmp_path / "stage_image_map.json"),
|
||
flow_docs_dir=str(tmp_path / "flow_docs"),
|
||
)
|
||
import app.config as config_mod
|
||
monkeypatch.setattr(config_mod, "settings", s)
|
||
monkeypatch.setattr(config_mod, "get_settings", lambda: s)
|
||
import app.context as context_mod
|
||
monkeypatch.setattr(context_mod, "get_settings", lambda: s)
|
||
# 各模块经 from ..config import get_settings 持有原始函数引用,须逐模块替换。
|
||
# 仅替换模块顶层确有此名字的(kernel/runner 用 _default_kernel_config 惰性读 config)。
|
||
import app.db as db_mod
|
||
import app.routers.bioflow as r_flow
|
||
import app.routers.biojobs as r_jobs
|
||
import app.routers.bioarchives as r_arch
|
||
for mod in (db_mod, r_flow, r_jobs, r_arch):
|
||
if hasattr(mod, "get_settings"):
|
||
monkeypatch.setattr(mod, "get_settings", lambda: s)
|
||
return s
|
||
|
||
|
||
@pytest.fixture()
|
||
def api_context(db, tmp_path, monkeypatch, image_map, fake_executor, fake_llm):
|
||
"""端点用例 context:mock docker/LLM/vstore,注入到全部路由模块。
|
||
|
||
fake_executor/fake_llm 为模块级 fixture(可被用例覆盖行为后 re-patch)。
|
||
"""
|
||
events = EventBus(tmp_path / "jobs" / "events")
|
||
catalog = build_catalog(image_map)
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
from app.services.bioflow.planner import Planner
|
||
|
||
flow_store = FakeFlowStore([
|
||
{"chunk_key": "c1", "id": "c1", "source": "stage_contract",
|
||
"doc_kind": "contract", "stage_name": "identify",
|
||
"title": "Identify Stage Contract", "title_path": "Identify Stage Contract",
|
||
"path": "gfa_core/schemas/stages/identify_contract_v1.md",
|
||
"cite": {"anchor": "## 1", "line": 1}, "line_start": 1, "line_end": 10,
|
||
"text": "Identify 需要全蛋白 FASTA 输入。"},
|
||
])
|
||
planner = Planner(catalog, flow_store, archive_finder=None)
|
||
|
||
async def find_archive(name):
|
||
return None
|
||
planner.archive_finder = find_archive
|
||
|
||
ctx = Context(db=db, events=events, kernel=None, catalog=catalog,
|
||
runner=runner, semaphore=runner.semaphore, planner=planner,
|
||
flow_store=flow_store, executor=fake_executor)
|
||
|
||
# 路由模块以 from ..context import context 绑定,须逐模块替换
|
||
import app.routers.bioflow as r_flow
|
||
import app.routers.biojobs as r_jobs
|
||
import app.routers.bioarchives as r_arch
|
||
monkeypatch.setattr(r_flow, "context", ctx)
|
||
monkeypatch.setattr(r_jobs, "context", ctx)
|
||
monkeypatch.setattr(r_arch, "context", ctx)
|
||
import app.context as context_mod
|
||
monkeypatch.setattr(context_mod, "context", ctx)
|
||
|
||
# 规划 LLM mock(complete_json → fake_llm)
|
||
import app.services.bioflow.planner as planner_mod
|
||
monkeypatch.setattr(planner_mod, "complete_json", fake_llm)
|
||
return ctx
|
||
|
||
|
||
@pytest.fixture()
|
||
def fake_llm():
|
||
return FakeLLM()
|