- 生信进度:后端新增 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
195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
"""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
|