Files
PlantAI-rag-new/bioflow_service/tests/test_events.py
T
admin 0282cfeeeb feat(bioflow): 阶段进度条+滚动日志+点击定位全链;图片注入三修;QA启动就绪等待
- 生信进度:后端新增 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
2026-09-24 16:48:52 +08:00

195 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""tests/test_events.py —— 事件总线(发布/订阅/持久/快照/回放/seq)。"""
from __future__ import annotations
import asyncio
import json
import pytest
from app.services.bioflow.events import EVENT_TYPES, EventBus
pytestmark = pytest.mark.bioflow_unit
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def test_publish_replay_persistence(tmp_path):
bus = EventBus(tmp_path / "events")
e1 = bus.publish("job_created", {"job_id": "j1", "title": "t", "stages": []})
e2 = bus.publish("stage_started", {"job_id": "j1", "stage": "identify"})
assert e2["seq"] == e1["seq"] + 1
# 持久文件存在
f = tmp_path / "events" / "j1.jsonl"
assert f.exists()
lines = f.read_text(encoding="utf-8").splitlines()
assert len(lines) == 2
assert json.loads(lines[0])["type"] == "job_created"
# 回放
replay = bus.replay("j1")
assert [e["type"] for e in replay] == ["job_created", "stage_started"]
# after_seq
assert bus.replay("j1", after_seq=e1["seq"])[0]["type"] == "stage_started"
def test_unknown_event_type_rejected(tmp_path):
bus = EventBus(tmp_path / "events")
with pytest.raises(ValueError, match="未知事件类型"):
bus.publish("nope", {"job_id": "j"})
def test_event_types_complete():
"""TC-BIO-SSE-001:12 类事件全集。"""
expected = {
"job_created", "plan_ready", "stage_started", "stage_log", "stage_done",
"stage_failed", "stage_skipped", "stage_await_confirm", "stage_await_files",
"job_done", "job_paused", "job_archived",
}
assert expected <= set(EVENT_TYPES)
assert "stage_progress" in EVENT_TYPES
def test_snapshot_restores_progress(tmp_path):
"""TC-BIO-SSE-025:stage_progress 进快照推导(断线重连恢复进度条)。"""
bus = EventBus(tmp_path / "events")
bus.publish("job_created", {"job_id": "j1", "stages": [
{"stage": "rnaseq_qc", "status": "waiting"},
]})
bus.publish("stage_started", {"job_id": "j1", "stage": "rnaseq_qc"})
bus.publish("stage_progress", {"job_id": "j1", "stage": "rnaseq_qc",
"percent": 33, "message": "A"})
bus.publish("stage_progress", {"job_id": "j1", "stage": "rnaseq_qc",
"percent": 66, "message": "B"})
snap = bus.snapshot("j1")
st = {s["stage"]: s for s in snap["stages"]}["rnaseq_qc"]
assert st["progress"] == 66.0
assert st["progressMessage"] == "B"
def test_subscribe_broadcast(tmp_path):
async def scenario():
bus = EventBus(tmp_path / "events")
sub1 = await bus.subscribe(job_filter="j1")
sub2 = await bus.subscribe(job_filter="j1")
sub3 = await bus.subscribe(job_filter="j2")
bus.publish("stage_started", {"job_id": "j1", "stage": "identify"})
e1 = await asyncio.wait_for(sub1.queue.get(), timeout=2)
e2 = await asyncio.wait_for(sub2.queue.get(), timeout=2)
assert e1["type"] == "stage_started"
assert e2["seq"] == e1["seq"]
assert sub3.queue.empty() # job 过滤
await bus.unsubscribe(sub1)
await bus.unsubscribe(sub2)
await bus.unsubscribe(sub3)
return True
assert _run(scenario())
def test_snapshot(tmp_path):
bus = EventBus(tmp_path / "events")
bus.publish("job_created", {"job_id": "j1", "title": "t", "stages": ["identify", "phylogeny"]})
bus.publish("stage_started", {"job_id": "j1", "stage": "identify"})
bus.publish("stage_done", {"job_id": "j1", "stage": "identify", "exit_code": 0})
snap = bus.snapshot("j1")
assert snap["type"] == "snapshot"
assert snap["job"]["status"] == "created"
stages = {s["stage"]: s["status"] for s in snap["stages"]}
assert stages["identify"] == "done"
assert stages["phylogeny"] == "waiting"
assert snap["last_seq"] == 3
assert bus.snapshot("nope") is None
def test_snapshot_job_done(tmp_path):
bus = EventBus(tmp_path / "events")
bus.publish("job_created", {"job_id": "j1", "stages": []})
bus.publish("job_done", {"job_id": "j1"})
assert bus.snapshot("j1")["job"]["status"] == "done"
def test_snapshot_dict_stages_clean(tmp_path):
"""BUG-R1-04 回归锁定:job_created.stages 为 dict(runner 真实发布形态)时,
快照 stages 逐阶段必须是干净 {stage, status} 结构——不得出现 dict repr 串
阶段名(此前 str(s) 会把 {stage:..., status:...} 串当阶段名,前端进度卡乱串)。
"""
bus = EventBus(tmp_path / "events")
bus.publish("job_created", {
"job_id": "j1", "title": "t",
"stages": [
{"stage": "identify", "status": "waiting"},
{"stage": "phylogeny", "status": "waiting"},
],
})
bus.publish("stage_started", {"job_id": "j1", "stage": "identify"})
bus.publish("stage_done", {"job_id": "j1", "stage": "identify", "exit_code": 0})
snap = bus.snapshot("j1")
assert snap["type"] == "snapshot"
assert len(snap["stages"]) == 2
stages = {s["stage"]: s["status"] for s in snap["stages"]}
# 干净阶段名(非 dict repr 串)
assert stages["identify"] == "done"
assert stages["phylogeny"] == "waiting"
for s in snap["stages"]:
assert isinstance(s["stage"], str)
assert not s["stage"].startswith("{")
assert isinstance(s["status"], str)
def test_memory_replay_after_seq(tmp_path):
bus = EventBus(tmp_path / "events")
e1 = bus.publish("stage_started", {"job_id": "j1", "stage": "a"})
bus.publish("stage_done", {"job_id": "j1", "stage": "a"})
replay = bus.memory_replay(after_seq=e1["seq"])
assert [e["type"] for e in replay] == ["stage_done"]
def test_seq_persists_across_restart(tmp_path):
"""BUG-FIX 2026-09-17 回归锁定:重启后新事件 seq 从历史最大继续递增,
不允许低于已持久化事件的 seq(否则前端 last_seq 水位过滤吞掉关键帧)。"""
events_dir = tmp_path / "events"
bus1 = EventBus(events_dir)
e1 = bus1.publish("job_created", {"job_id": "j1", "stages": []})
e2 = bus1.publish("stage_await_files", {"job_id": "j1", "stage": "a"})
assert e2["seq"] == e1["seq"] + 1
# 模拟重启:同目录新建 EventBus
bus2 = EventBus(events_dir)
assert bus2._seq == e2["seq"]
e3 = bus2.publish("stage_started", {"job_id": "j1", "stage": "a"})
assert e3["seq"] == e2["seq"] + 1
def test_seq_scan_empty_and_bad_lines(tmp_path):
"""空目录 → 0;含坏行的 jsonl 只跳过坏行不中断扫描。"""
bus = EventBus(tmp_path / "empty")
assert bus._seq == 0
events_dir = tmp_path / "bad"
events_dir.mkdir()
(events_dir / "j1.jsonl").write_text(
json.dumps({"type": "job_created", "seq": 42, "data": {"job_id": "j1"}}) + "\n" +
"not-a-json-line\n" +
json.dumps({"type": "job_done", "seq": 41, "data": {"job_id": "j1"}}) + "\n",
encoding="utf-8",
)
bus2 = EventBus(events_dir)
assert bus2._seq == 42
def test_seq_scan_multiple_files_max(tmp_path):
"""多个 job jsonl 取全局最大 seq。"""
events_dir = tmp_path / "multi"
events_dir.mkdir()
(events_dir / "a.jsonl").write_text(
json.dumps({"type": "job_created", "seq": 7, "data": {"job_id": "a"}}) + "\n",
encoding="utf-8",
)
(events_dir / "b.jsonl").write_text(
json.dumps({"type": "job_created", "seq": 17, "data": {"job_id": "b"}}) + "\n",
encoding="utf-8",
)
bus = EventBus(events_dir)
assert bus._seq == 17