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
179 lines
6.4 KiB
Python
179 lines
6.4 KiB
Python
"""tests/test_manifests.py —— manifest 解析/校验/跨阶段输入定位。"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from app.services.bioflow.manifests import (
|
||
Manifest,
|
||
ManifestError,
|
||
artifact_paths,
|
||
input_resolution,
|
||
locate_manifest,
|
||
parse_manifest,
|
||
)
|
||
|
||
pytestmark = pytest.mark.bioflow_unit
|
||
|
||
|
||
def _write_manifest(workspace, stage, status="success", prefix="Ft.SPL", extra=None):
|
||
final = workspace / "final"
|
||
final.mkdir(parents=True, exist_ok=True)
|
||
raw = {
|
||
"manifest_version": "1", "job_id": "j1", "stage": stage, "status": status,
|
||
"prefix": prefix,
|
||
"artifacts": {
|
||
"final_members": {"artifact": "final_members",
|
||
"full_path": str(final / f"{prefix}.final_members.id"),
|
||
"type": "table", "size": 100},
|
||
},
|
||
"parameters": {"cpu": 8}, "metrics": {"final_members": 24},
|
||
}
|
||
raw.update(extra or {})
|
||
path = final / f"{prefix}.{stage}.manifest.json"
|
||
path.write_text(json.dumps(raw), encoding="utf-8")
|
||
return path
|
||
|
||
|
||
def test_parse_success(tmp_path):
|
||
_write_manifest(tmp_path, "identify")
|
||
m = parse_manifest(tmp_path, "identify")
|
||
assert isinstance(m, Manifest)
|
||
assert m.status == "success"
|
||
assert m.artifacts["final_members"]["type"] == "table"
|
||
assert m.metrics["final_members"] == 24
|
||
assert m.prefix == "Ft.SPL"
|
||
|
||
|
||
def test_missing_manifest(tmp_path):
|
||
with pytest.raises(ManifestError, match="缺失"):
|
||
parse_manifest(tmp_path, "identify")
|
||
|
||
|
||
def test_invalid_json(tmp_path):
|
||
final = tmp_path / "final"
|
||
final.mkdir(parents=True)
|
||
(final / "Ft.SPL.identify.manifest.json").write_text("{broken", encoding="utf-8")
|
||
with pytest.raises(ManifestError, match="非法 JSON"):
|
||
parse_manifest(tmp_path, "identify")
|
||
|
||
|
||
def test_missing_required_keys(tmp_path):
|
||
final = tmp_path / "final"
|
||
final.mkdir(parents=True)
|
||
(final / "Ft.SPL.identify.manifest.json").write_text('{"stage": "identify"}', encoding="utf-8")
|
||
with pytest.raises(ManifestError, match="缺必需字段"):
|
||
parse_manifest(tmp_path, "identify")
|
||
|
||
|
||
def test_invalid_status(tmp_path):
|
||
_write_manifest(tmp_path, "identify", status="weird")
|
||
with pytest.raises(ManifestError, match="status 非法"):
|
||
parse_manifest(tmp_path, "identify")
|
||
|
||
|
||
def test_failed_status_valid(tmp_path):
|
||
_write_manifest(tmp_path, "identify", status="failed")
|
||
m = parse_manifest(tmp_path, "identify")
|
||
assert m.status == "failed"
|
||
|
||
|
||
def test_locate_manifest_strict_no_fallback(tmp_path):
|
||
# 无 prefix 匹配时绝不兜底其他阶段 manifest(跨阶段误配会导致误判成败)
|
||
path = _write_manifest(tmp_path, "identify")
|
||
assert locate_manifest(tmp_path, "phylogeny", prefix="OTHER") is None
|
||
assert locate_manifest(tmp_path, "identify") == path
|
||
|
||
|
||
def test_locate_manifest_no_infix_name(tmp_path):
|
||
"""BUG-FIX 2026-09-18:无中缀名兜底 final/{stage}.manifest.json
|
||
(rnaseq 系 CLI 空 prefix 形态,如 rnaseq_qc.manifest.json)。"""
|
||
final = tmp_path / "final"
|
||
final.mkdir(parents=True, exist_ok=True)
|
||
raw = {
|
||
"manifest_version": "1.0", "job_id": "unknown", "stage": "rnaseq_qc",
|
||
"status": "success", "prefix": "",
|
||
"artifacts": {"clean": {"full_path": str(final / "x.clean.fastq.gz")}},
|
||
"parameters": {}, "metrics": {},
|
||
}
|
||
path = final / "rnaseq_qc.manifest.json"
|
||
path.write_text(json.dumps(raw), encoding="utf-8")
|
||
|
||
assert locate_manifest(tmp_path, "rnaseq_qc") == path
|
||
assert locate_manifest(tmp_path, "rnaseq_qc", prefix="") == path
|
||
m = parse_manifest(tmp_path, "rnaseq_qc")
|
||
assert m.status == "success"
|
||
assert m.stage == "rnaseq_qc"
|
||
|
||
|
||
def test_locate_manifest_no_cross_stage_misattribution(tmp_path):
|
||
"""无中缀兜底不跨阶段误配:stage=rnaseq 不命中 rnaseq_qc.manifest.json;
|
||
stage=rnaseq_qc 也不命中他阶段带 prefix 的 manifest。"""
|
||
final = tmp_path / "final"
|
||
final.mkdir(parents=True, exist_ok=True)
|
||
_write_manifest(tmp_path, "rnaseq_qc") # Ft.SPL.rnaseq_qc.manifest.json
|
||
# 加一个裸名 rnaseq_qc.manifest.json 模拟 rnaseq 系实际产出
|
||
raw = {
|
||
"manifest_version": "1.0", "job_id": "j1", "stage": "rnaseq_qc",
|
||
"status": "success", "prefix": "",
|
||
"artifacts": {}, "parameters": {}, "metrics": {},
|
||
}
|
||
(final / "rnaseq_qc.manifest.json").write_text(json.dumps(raw), encoding="utf-8")
|
||
|
||
# stage=rnaseq 绝不命中 rnaseq_qc.manifest.json(精确等名,无前缀混淆)
|
||
assert locate_manifest(tmp_path, "rnaseq") is None
|
||
# 中缀匹配优先于裸名兜底(有 prefix 中缀文件时先命中)
|
||
qc = locate_manifest(tmp_path, "rnaseq_qc")
|
||
assert qc is not None
|
||
assert qc.name == "Ft.SPL.rnaseq_qc.manifest.json"
|
||
|
||
|
||
def test_locate_manifest_bare_name_priority(tmp_path):
|
||
"""只有裸名文件时兜底命中;同目录存在其他阶段 manifest 不干扰。"""
|
||
final = tmp_path / "final"
|
||
final.mkdir(parents=True, exist_ok=True)
|
||
_write_manifest(tmp_path, "identify") # Ft.SPL.identify.manifest.json
|
||
raw = {
|
||
"manifest_version": "1.0", "job_id": "j1", "stage": "rnaseq_qc",
|
||
"status": "success", "prefix": "",
|
||
"artifacts": {}, "parameters": {}, "metrics": {},
|
||
}
|
||
bare = final / "rnaseq_qc.manifest.json"
|
||
bare.write_text(json.dumps(raw), encoding="utf-8")
|
||
|
||
assert locate_manifest(tmp_path, "rnaseq_qc") == bare
|
||
# identify 照常中缀命中,不受裸名文件影响
|
||
assert locate_manifest(tmp_path, "identify").name == "Ft.SPL.identify.manifest.json"
|
||
|
||
|
||
def test_artifact_paths():
|
||
artifacts = {
|
||
"a": {"full_path": "/x/a.id", "type": "table"},
|
||
"b": "/y/b.fa",
|
||
"c": {"path": "/z/c.txt"},
|
||
"d": {"relpath_or_hint": "final/d.txt"},
|
||
}
|
||
paths = artifact_paths(artifacts)
|
||
assert "/x/a.id" in paths
|
||
assert "/y/b.fa" in paths
|
||
assert "/z/c.txt" in paths
|
||
assert "final/d.txt" in paths
|
||
|
||
|
||
def test_input_resolution(tmp_path):
|
||
final = tmp_path / "final"
|
||
final.mkdir(parents=True, exist_ok=True)
|
||
(final / "Ft.SPL.final_members.id").write_text("gene1\ngene2\n", encoding="utf-8")
|
||
_write_manifest(tmp_path, "identify")
|
||
res = input_resolution(tmp_path, "identify")
|
||
assert res["resolved"] is True
|
||
assert res["manifest_path"]
|
||
assert res["inputs"]
|
||
|
||
|
||
def test_input_resolution_missing(tmp_path):
|
||
res = input_resolution(tmp_path, "identify")
|
||
assert res["resolved"] is False
|
||
assert "缺失" in res["error"]
|