624 lines
25 KiB
Python
624 lines
25 KiB
Python
"""tests/test_runner.py —— runner 状态机全路径(mock docker 执行器)。
|
||
|
||
覆盖(TC-BIO-RUN 系列):成功推进/失败/重跑/跳过/暂停/恢复/重启续跑/超时/
|
||
日志截断/并发上限/输入缺失等待/确认等待/自动连续/信号量释放/事件成对。
|
||
|
||
注意:runner 内部用 asyncio.create_task 派生任务,测试必须「单场景单 loop」——
|
||
每个用例在一个新建 event loop 内完成 create→run→断言前的所有异步步骤。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
import pytest
|
||
|
||
from conftest import FakeExecutor, FakeTimedOutExecutor, make_runner
|
||
from app.services.bioflow.runner import (
|
||
JOB_DONE,
|
||
JOB_FAILED,
|
||
JOB_PAUSED,
|
||
STAGE_DONE,
|
||
STAGE_FAILED,
|
||
STAGE_SKIPPED,
|
||
STAGE_WAITING_CONFIRM,
|
||
RunnerError,
|
||
)
|
||
|
||
pytestmark = pytest.mark.bioflow_unit
|
||
|
||
|
||
def _scenario(coro_factory):
|
||
"""单 loop 跑完整场景。"""
|
||
loop = asyncio.new_event_loop()
|
||
try:
|
||
return loop.run_until_complete(coro_factory())
|
||
finally:
|
||
loop.close()
|
||
|
||
|
||
async def _drain(runner, seconds: float = 5.0) -> None:
|
||
"""等待 runner 派生的后台任务全部结束。"""
|
||
while runner._tasks:
|
||
await asyncio.wait_for(asyncio.gather(*list(runner._tasks)), timeout=seconds)
|
||
await asyncio.sleep(0.01)
|
||
|
||
|
||
def _collected(events, job_id):
|
||
return [e for e in events.replay(job_id) if e["type"] != "job_recovered"]
|
||
|
||
|
||
def test_full_chain_success(db, events, catalog, fake_executor):
|
||
"""TC-BIO-RUN-001:全链成功推进(自动连续)。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "SPL 鉴定", ["identify", "phylogeny", "motif"],
|
||
auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record.status == JOB_DONE
|
||
ev_types = [e["type"] for e in _collected(events, record.id)]
|
||
assert ev_types.count("stage_started") == 3
|
||
assert ev_types.count("stage_done") == 3
|
||
assert ev_types[-1] == "job_done"
|
||
assert ev_types[0] == "job_created"
|
||
assert all(runner.stage_status(record, s) == STAGE_DONE for s in record.stage_chain)
|
||
|
||
|
||
def test_stage_failure_stops_chain(db, events, catalog):
|
||
"""TC-BIO-RUN-002:阶段失败转 failed,后续阶段不执行。"""
|
||
executor = FakeExecutor(exit_code=1, log="boom\n")
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny", "motif"],
|
||
auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record.status == JOB_FAILED
|
||
assert runner.stage_status(record, "identify") == STAGE_FAILED
|
||
assert runner.stage_status(record, "phylogeny") == "waiting"
|
||
ev_types = [e["type"] for e in _collected(events, record.id)]
|
||
assert "stage_failed" in ev_types
|
||
failed_ev = [e for e in _collected(events, record.id) if e["type"] == "stage_failed"][0]
|
||
assert failed_ev["data"]["stage"] == "identify"
|
||
assert failed_ev["data"]["reason"] == "exit_code"
|
||
assert [c["stage"] for c in executor.calls] == ["identify"] # 后续阶段未执行
|
||
|
||
|
||
def test_rerun_after_failure(db, events, catalog):
|
||
"""TC-BIO-RUN-003:失败后 rerun 成功,attempt=2,继续后续阶段。"""
|
||
executor = FakeExecutor(exit_code=1)
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
assert record.status == JOB_FAILED
|
||
executor.exit_code = 0
|
||
await runner.rerun_stage(record, "identify", None)
|
||
await _drain(runner)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert runner.stage_status(record, "identify") == STAGE_DONE
|
||
assert record._stage_states["identify"]["attempt"] == 2
|
||
assert record.status == JOB_DONE # 后续阶段自动推进
|
||
|
||
|
||
def test_skip_after_failure(db, events, catalog):
|
||
"""TC-BIO-RUN-004:失败后 skip → skipped,后续阶段可执行。"""
|
||
executor = FakeExecutor(exit_code=1)
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
assert runner.stage_status(record, "identify") == STAGE_FAILED
|
||
executor.exit_code = 0
|
||
await runner.skip_stage(record, "identify")
|
||
await _drain(runner)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert runner.stage_status(record, "identify") == STAGE_SKIPPED
|
||
ev_types = [e["type"] for e in _collected(events, record.id)]
|
||
assert "stage_skipped" in ev_types
|
||
assert record.status == JOB_DONE
|
||
assert runner.stage_status(record, "phylogeny") == STAGE_DONE
|
||
|
||
|
||
def test_rerun_with_params_goes_confirm(db, events, catalog):
|
||
"""TC-BIO-RUN-005:带参数重跑 → waiting_confirm 再确认;新参数进入执行。"""
|
||
executor = FakeExecutor(exit_code=1)
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
await runner.rerun_stage(record, "identify", {"cpu": "8"})
|
||
assert runner.stage_status(record, "identify") == STAGE_WAITING_CONFIRM
|
||
executor.exit_code = 0
|
||
await runner.confirm_stage(record, "identify", {"cpu": "8"})
|
||
await _drain(runner)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record._stage_states["identify"]["params"]["cpu"] == "8"
|
||
assert runner.stage_status(record, "identify") == STAGE_DONE
|
||
assert executor.calls[-1]["cpu"] == "8"
|
||
|
||
|
||
def test_rerun_non_failed_rejected(db, events, catalog, fake_executor):
|
||
"""TC-BIO-RUN-024:done 阶段 rerun/skip 拒绝。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
with pytest.raises(RunnerError, match="不可重跑"):
|
||
_scenario(lambda: runner.rerun_stage(record, "identify", None))
|
||
with pytest.raises(RunnerError, match="不可跳过"):
|
||
_scenario(lambda: runner.skip_stage(record, "identify"))
|
||
|
||
|
||
def test_pause_at_stage_boundary(db, events, catalog):
|
||
"""TC-BIO-RUN-006:暂停在阶段边界(当前阶段完成后停)。"""
|
||
executor = FakeExecutor(delay=0.05)
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny"], auto_continue=True, auto_start=False)
|
||
task = asyncio.create_task(runner.run_job(record))
|
||
await asyncio.sleep(0.02) # identify 已进入执行
|
||
await runner.pause_job(record)
|
||
await asyncio.wait_for(task, timeout=5)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record.status == JOB_PAUSED
|
||
assert runner.stage_status(record, "identify") == STAGE_DONE
|
||
assert all(c["stage"] != "phylogeny" for c in executor.calls)
|
||
|
||
|
||
def test_resume_continues_without_rerun(db, events, catalog):
|
||
"""TC-BIO-RUN-007:暂停后恢复续跑,不重跑已完成。"""
|
||
executor = FakeExecutor(delay=0.02)
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny"], auto_continue=True, auto_start=False)
|
||
task = asyncio.create_task(runner.run_job(record))
|
||
await asyncio.sleep(0.03)
|
||
await runner.pause_job(record)
|
||
await asyncio.wait_for(task, timeout=5)
|
||
assert record.status == JOB_PAUSED
|
||
await runner.resume_job(record)
|
||
await _drain(runner)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record.status == JOB_DONE
|
||
identify_calls = [c for c in executor.calls if c["stage"] == "identify"]
|
||
assert len(identify_calls) == 1 # 不重跑
|
||
|
||
|
||
def test_timeout_fails_stage(db, events, catalog):
|
||
"""TC-BIO-RUN-012:超时 → failed(reason=timeout)。"""
|
||
executor = FakeTimedOutExecutor()
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert runner.stage_status(record, "identify") == STAGE_FAILED
|
||
failed = [e for e in _collected(events, record.id) if e["type"] == "stage_failed"][0]
|
||
assert failed["data"]["reason"] == "timeout"
|
||
|
||
|
||
def test_log_tail_truncated(db, events, catalog):
|
||
"""TC-BIO-RUN-013:日志截断只留尾 80 行。"""
|
||
executor = FakeExecutor(log="\n".join(f"L{i}" for i in range(500)) + "\n")
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
tail = record._stage_states["identify"]["log_tail"]
|
||
assert tail.count("\n") + 1 == 80
|
||
assert tail.startswith("L420")
|
||
assert tail.rstrip().endswith("L499")
|
||
|
||
|
||
def test_exit_nonzero_judged_failed(db, events, catalog):
|
||
"""TC-BIO-RUN-014:退出码非 0 判失败,log_tail 含 stderr。"""
|
||
executor = FakeExecutor(exit_code=3, log="error output\n")
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert runner.stage_status(record, "identify") == STAGE_FAILED
|
||
assert "error output" in record._stage_states["identify"]["log_tail"]
|
||
|
||
|
||
def test_manifest_missing_judged_failed(db, events, catalog):
|
||
"""TC-BIO-RUN-015:退出码 0 但 manifest 缺 → failed(reason=no_manifest)。"""
|
||
executor = FakeExecutor(exit_code=0, manifest_status="missing")
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert runner.stage_status(record, "identify") == STAGE_FAILED
|
||
failed = [e for e in _collected(events, record.id) if e["type"] == "stage_failed"][0]
|
||
assert failed["data"]["reason"] == "no_manifest"
|
||
|
||
|
||
def test_manifest_failed_status(db, events, catalog):
|
||
"""TC-BIO-RUN-016:manifest status=failed 判败(不因退出码 0 误判成功)。"""
|
||
executor = FakeExecutor(exit_code=0, manifest_status="failed")
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert runner.stage_status(record, "identify") == STAGE_FAILED
|
||
failed = [e for e in _collected(events, record.id) if e["type"] == "stage_failed"][0]
|
||
assert failed["data"]["reason"] == "manifest_failed"
|
||
|
||
|
||
def test_await_files_when_missing(db, events, catalog):
|
||
"""TC-BIO-RUN-017:输入缺失等待文件(force_input_check=True)。"""
|
||
executor = FakeExecutor()
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog, check_inputs=True)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert runner.stage_status(record, "identify") == "await_files"
|
||
ev = [e for e in _collected(events, record.id) if e["type"] == "stage_await_files"][0]
|
||
assert ev["data"]["stage"] == "identify"
|
||
assert ev["data"]["required_files"]
|
||
assert executor.calls == [] # 不启动 docker
|
||
|
||
|
||
def test_await_files_then_resume(db, events, catalog):
|
||
"""TC-BIO-RUN-018:文件补齐自动推进。"""
|
||
executor = FakeExecutor()
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog, check_inputs=True)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
assert runner.stage_status(record, "identify") == "await_files"
|
||
(record.input_dir / "genome.pep.fa").write_text(">x\nACGT\n", encoding="utf-8")
|
||
record._stage_states["identify"]["status"] = "waiting"
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record.status == JOB_DONE
|
||
assert runner.stage_status(record, "identify") == STAGE_DONE
|
||
|
||
|
||
def test_await_confirm_manual_mode(db, events, catalog):
|
||
"""TC-BIO-RUN-019/020:手动模式等待确认;确认后执行。"""
|
||
executor = FakeExecutor()
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny"], auto_continue=False, auto_start=False)
|
||
await runner.run_job(record)
|
||
assert runner.stage_status(record, "identify") == STAGE_WAITING_CONFIRM
|
||
assert executor.calls == [] # 未确认不执行
|
||
await runner.confirm_stage(record, "identify", {})
|
||
await _drain(runner)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
ev = [e for e in _collected(events, record.id) if e["type"] == "stage_await_confirm"][0]
|
||
assert ev["data"]["stage"] == "identify"
|
||
assert ev["data"]["form_defaults"] # 表单默认值
|
||
assert runner.stage_status(record, "identify") == STAGE_DONE
|
||
assert runner.stage_status(record, "phylogeny") == STAGE_WAITING_CONFIRM
|
||
|
||
|
||
def test_confirm_unknown_param_rejected(db, events, catalog, fake_executor):
|
||
"""TC-BIO-API-058 对应服务层:confirm 拒绝非 schema 参数。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_start=False)
|
||
await runner.run_job(record)
|
||
with pytest.raises(RunnerError, match="未知参数"):
|
||
await runner.confirm_stage(record, "identify", {"magic": "x"})
|
||
return runner, record
|
||
|
||
_scenario(scenario)
|
||
|
||
|
||
def test_confirm_non_confirmable(db, events, catalog, fake_executor):
|
||
"""TC-BIO-API-059:重复 confirm 幂等拒绝。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
assert runner.stage_status(record, "identify") == STAGE_DONE
|
||
with pytest.raises(RunnerError, match="不可确认"):
|
||
await runner.confirm_stage(record, "identify", {})
|
||
return runner, record
|
||
|
||
_scenario(scenario)
|
||
|
||
|
||
def test_auto_continue_skips_confirm(db, events, catalog, fake_executor):
|
||
"""TC-BIO-RUN-021:自动连续跳过确认全程推进。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record.status == JOB_DONE
|
||
assert "stage_await_confirm" not in [e["type"] for e in _collected(events, record.id)]
|
||
|
||
|
||
def test_concurrency_limit(db, events, catalog):
|
||
"""TC-BIO-RUN-010:并发上限排队(4 任务并发,同时 running ≤3)。"""
|
||
executor = FakeExecutor(delay=0.1)
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
records = []
|
||
for i in range(4):
|
||
r = await runner.create_job(
|
||
f"s{i}", f"t{i}", ["identify"], auto_continue=True, auto_start=False)
|
||
records.append(r)
|
||
await asyncio.gather(*[runner.run_job(r) for r in records])
|
||
return runner, records
|
||
|
||
runner, records = _scenario(scenario)
|
||
assert executor.max_running <= 3
|
||
assert all(r.status == JOB_DONE for r in records)
|
||
|
||
|
||
def test_semaphore_released_on_failure(db, events, catalog):
|
||
"""TC-BIO-RUN-025:执行失败后信号量释放。"""
|
||
executor = FakeExecutor(exit_code=1)
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record.status == JOB_FAILED
|
||
assert not runner.semaphore.locked()
|
||
|
||
|
||
def test_restart_recovery_no_rerun(db, events, catalog, fake_executor):
|
||
"""TC-BIO-RUN-008:重启续跑不重跑已完成阶段(attempt 不变)。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny"], auto_continue=True, auto_start=False)
|
||
# 只完成第 1 阶段(模拟中断前状态)
|
||
await runner._run_stage(record, "identify", 0)
|
||
assert runner.stage_status(record, "identify") == STAGE_DONE
|
||
# 模拟重启:新 runner 实例(进程重启后内存清空)
|
||
runner2 = make_runner(db, events, fake_executor, catalog)
|
||
recovered = await runner2.recover()
|
||
await _drain(runner2)
|
||
return runner2, record, recovered
|
||
|
||
runner2, record, recovered = _scenario(scenario)
|
||
assert any(r.id == record.id for r in recovered)
|
||
from app.services.bioflow import store
|
||
st = _scenario(lambda: store.get_stage(db, record.id, "identify"))
|
||
assert st["status"] == STAGE_DONE
|
||
assert st["attempt"] == 1 # 不重跑
|
||
|
||
|
||
def test_restart_recovers_inflight_by_manifest(db, events, catalog, fake_executor):
|
||
"""TC-BIO-RUN-009:重启恢复 in-flight running 阶段按 manifest 判成败。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny"], auto_continue=True, auto_start=False)
|
||
await runner._run_stage(record, "identify", 0) # 第 1 阶段完成
|
||
# phylogeny 标记 running 且无 manifest(模拟崩溃瞬间)
|
||
record._stage_states["phylogeny"]["status"] = "running"
|
||
await runner._persist_stage(record, "phylogeny")
|
||
# 模拟重启
|
||
runner2 = make_runner(db, events, fake_executor, catalog)
|
||
await runner2.recover()
|
||
await _drain(runner2)
|
||
# 同一 loop 内校验(aiosqlite 连接绑定本 loop)
|
||
from app.services.bioflow import store
|
||
st = await store.get_stage(db, record.id, "phylogeny")
|
||
assert st["status"] == STAGE_DONE
|
||
assert st["attempt"] >= 1 # 无 manifest → 重跑;attempt 至少 1
|
||
return runner2, record
|
||
|
||
_scenario(scenario)
|
||
|
||
|
||
def test_restart_skips_archived(db, events, catalog, fake_executor):
|
||
"""TC-BIO-RUN-030:恢复时跳过已归档任务。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
# 归档(状态改为 archived)
|
||
await runner._persist_job(record)
|
||
from app.services.bioflow.store import update_job_status
|
||
await update_job_status(db, record.id, "archived")
|
||
runner2 = make_runner(db, events, fake_executor, catalog)
|
||
recovered = await runner2.recover()
|
||
return recovered
|
||
|
||
recovered = _scenario(scenario)
|
||
assert recovered == []
|
||
|
||
|
||
def test_empty_stages_done_immediately(db, events, catalog, fake_executor):
|
||
"""TC-BIO-RUN-039:空阶段任务直接 done,无 docker 调用。"""
|
||
# 注意:创建任务要求 stages 非空(pydantic min_length=1)由路由层保证;
|
||
# 服务层直接构造空链验证防御
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
record.stage_chain = []
|
||
record._stage_states = {}
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record.status == JOB_DONE
|
||
assert fake_executor.calls == []
|
||
assert record.progress() == 100
|
||
|
||
|
||
def test_progress_calculation(db, events, catalog, fake_executor):
|
||
"""TC-BIO-RUN-029:任务进度数值。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify", "phylogeny", "motif"],
|
||
auto_continue=True, auto_start=False)
|
||
assert record.progress() == 0
|
||
# 全链跑完 → 100(进度公式按完成阶段数/总数)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
assert record.progress() == 100
|
||
|
||
|
||
def test_unknown_stage_create_rejected(db, events, catalog, fake_executor):
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
with pytest.raises(RunnerError, match="未知阶段"):
|
||
await runner.create_job("s1", "t", ["ghost_stage"], auto_start=False)
|
||
return None
|
||
|
||
_scenario(scenario)
|
||
|
||
|
||
def test_prerequisite_autofill(db, events, catalog, fake_executor):
|
||
"""TC-BIO-RUN-034:前置补全后链完整(phylogeny 缺 identify → 自动补)。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job("s1", "t", ["phylogeny", "motif"], auto_start=False)
|
||
return record
|
||
|
||
record = _scenario(scenario)
|
||
assert record.stage_chain[0] == "identify"
|
||
assert set(record.stage_chain) == {"identify", "phylogeny", "motif"}
|
||
|
||
|
||
def test_multi_job_isolation(db, events, catalog):
|
||
"""TC-BIO-RUN-028:多任务互不串扰。"""
|
||
executor = FakeExecutor(log="job1 log\n")
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, executor, catalog)
|
||
r1 = await runner.create_job("s1", "t1", ["identify"], auto_continue=True, auto_start=False)
|
||
r2 = await runner.create_job("s2", "t2", ["identify"], auto_continue=True, auto_start=False)
|
||
await asyncio.gather(runner.run_job(r1), runner.run_job(r2))
|
||
return runner, r1, r2
|
||
|
||
runner, r1, r2 = _scenario(scenario)
|
||
assert r1.id != r2.id
|
||
assert r1.status == JOB_DONE and r2.status == JOB_DONE
|
||
assert r1._stage_states["identify"]["log_tail"] == "job1 log" # tail_lines 去尾换行
|
||
|
||
|
||
def test_stage_log_event(db, events, catalog, fake_executor):
|
||
"""TC-BIO-SSE-004:stage_log 事件随执行发布(log 非空)。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
log_events = [e for e in _collected(events, record.id) if e["type"] == "stage_log"]
|
||
assert log_events
|
||
assert all(e["data"]["log"] for e in log_events)
|
||
|
||
|
||
def test_event_seq_strictly_increasing(db, events, catalog, fake_executor):
|
||
"""TC-BIO-SSE-013:seq 严格递增。"""
|
||
|
||
async def scenario():
|
||
runner = make_runner(db, events, fake_executor, catalog)
|
||
record = await runner.create_job(
|
||
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
|
||
await runner.run_job(record)
|
||
return runner, record
|
||
|
||
runner, record = _scenario(scenario)
|
||
seqs = [e["seq"] for e in _collected(events, record.id)]
|
||
assert seqs == sorted(seqs)
|
||
assert len(set(seqs)) == len(seqs)
|