按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、 不保留全局 conftest.py、测试过程只调用真实生产代码。 结构(73 个文件、30 个模块目录、477 用例): - tests/nodes/ 15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/ subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/ proper_nouns/adaptive_pool/vad_profiler/echo); - tests/app/ 11 个模块目录(db/scheduler/batch/maintenance/registry/seed/ storage/config/logging/main/routers 三组 API); - tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。 测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore 的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。 顺带发现并修复三个真实缺陷: - nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位), 改为正文行遇时间戳行即报错; - src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI 抛 ValueError 导致任务误判失败,改为同时捕获; - nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT, 改用生产模块 nodes/srt.py。 真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py (运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py (无 Key / 余额 / 限流转跳过)。全量 477 passed。
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""src/wov_app/db.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||
|
||
被测模块:`src/wov_app/db.py`(SQLite Repository:工作流/版本/任务/产物/
|
||
批量任务),可独立调用。每个用例在临时目录创建独立数据库文件(真实 SQLite),
|
||
不依赖全局 conftest。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from wov_app.db import Database
|
||
|
||
|
||
@pytest.fixture()
|
||
def db(tmp_path: Path) -> Database:
|
||
"""每个用例一个独立 SQLite 库(真实文件,非内存桩)。"""
|
||
return Database(tmp_path / "wov.db")
|
||
|
||
|
||
@pytest.fixture()
|
||
def db_with_workflow(db: Database) -> Database:
|
||
"""已建好工作流(含 v1 版本)的库:任务表对 workflow_id 有外键约束。"""
|
||
db.upsert_workflow(_workflow("wf"))
|
||
db.create_workflow_version("wf", 1, {"nodes": []})
|
||
return db
|
||
|
||
|
||
def _workflow(workflow_id: str = "wf", name: str = "流程") -> dict:
|
||
"""构造真实工作流记录字段。"""
|
||
return {"id": workflow_id, "name": name, "description": ""}
|
||
|
||
|
||
def _run(run_id: str = "run-1", **overrides) -> dict:
|
||
"""构造真实任务记录字段(默认 upload 来源、QUEUED 状态)。"""
|
||
record = {
|
||
"id": run_id,
|
||
"workflow_id": "wf",
|
||
"workflow_version": 1,
|
||
"status": "QUEUED",
|
||
"current_node_id": None,
|
||
"progress": 0.0,
|
||
"error": None,
|
||
"input_uri": None,
|
||
"param_overrides": None,
|
||
"source": "upload",
|
||
"created_at": "2026-09-01T00:00:00+00:00",
|
||
"updated_at": "2026-09-01T00:00:00+00:00",
|
||
}
|
||
record.update(overrides)
|
||
return record
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工作流与版本
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_upsert_and_get_workflow(db: Database) -> None:
|
||
"""工作流写入后可读回,重复写入同 ID 覆盖而不报错。"""
|
||
# 数据:一条工作流记录。
|
||
db.upsert_workflow(_workflow("wf-1", "初版"))
|
||
|
||
# 测试过程
|
||
stored = db.get_workflow("wf-1")
|
||
db.upsert_workflow(_workflow("wf-1", "改名"))
|
||
renamed = db.get_workflow("wf-1")
|
||
|
||
# 验证结果
|
||
assert stored["name"] == "初版"
|
||
assert renamed["name"] == "改名"
|
||
|
||
|
||
def test_list_and_delete_workflow(db: Database) -> None:
|
||
"""列出全部工作流;删除后不再出现在列表与查询中。"""
|
||
# 数据:两条工作流。
|
||
db.upsert_workflow(_workflow("wf-1"))
|
||
db.upsert_workflow(_workflow("wf-2"))
|
||
|
||
# 测试过程
|
||
before = {w["id"] for w in db.list_workflows()}
|
||
db.delete_workflow("wf-1")
|
||
after = {w["id"] for w in db.list_workflows()}
|
||
|
||
# 验证结果
|
||
assert before == {"wf-1", "wf-2"}
|
||
assert after == {"wf-2"}
|
||
assert db.get_workflow("wf-1") is None
|
||
|
||
|
||
def test_workflow_versions_and_latest(db: Database) -> None:
|
||
"""版本按序保存,latest 返回最高版本,可按版本号精确读取。"""
|
||
# 数据:同一工作流的 v1 与 v2 定义。
|
||
db.upsert_workflow(_workflow("wf-1"))
|
||
db.create_workflow_version("wf-1", 1, {"nodes": [{"id": "a"}]})
|
||
db.create_workflow_version("wf-1", 2, {"nodes": [{"id": "a"}, {"id": "b"}]})
|
||
|
||
# 测试过程
|
||
latest = db.get_latest_workflow_version("wf-1")
|
||
first = db.get_workflow_version("wf-1", 1)
|
||
versions = db.list_workflow_versions("wf-1")
|
||
|
||
# 验证结果
|
||
assert latest["version"] == 2
|
||
assert len(latest["definition"]["nodes"]) == 2
|
||
assert first["definition"]["nodes"] == [{"id": "a"}]
|
||
assert [v["version"] for v in versions] == [2, 1]
|
||
|
||
|
||
def test_workflow_version_missing_returns_none(db: Database) -> None:
|
||
"""不存在的工作流/版本返回 None(不做隐式创建)。"""
|
||
# 数据:空库。
|
||
# 测试过程与验证结果
|
||
assert db.get_latest_workflow_version("nope") is None
|
||
assert db.get_workflow_version("nope", 1) is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 任务:创建、读写、param_overrides
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_and_get_run_with_overrides(db_with_workflow: Database) -> None:
|
||
"""任务创建后可读回,param_overrides 以 JSON 存储并解析回字典。"""
|
||
# 数据:带参数覆盖的 upload 任务。
|
||
db_with_workflow.create_run(_run("r1", param_overrides={"frame-extract": {"crop": [0, 0.75, 1, 0.25]}}))
|
||
|
||
# 测试过程
|
||
stored = db_with_workflow.get_run("r1")
|
||
|
||
# 验证结果
|
||
assert stored["status"] == "QUEUED"
|
||
assert stored["source"] == "upload"
|
||
assert stored["param_overrides"] == {"frame-extract": {"crop": [0, 0.75, 1, 0.25]}}
|
||
|
||
|
||
def test_update_run_fields(db_with_workflow: Database) -> None:
|
||
"""update_run 按字段更新(状态/进度/错误)。"""
|
||
# 数据:一条任务。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
|
||
# 测试过程
|
||
db_with_workflow.update_run("r1", status="RUNNING", progress=0.5, error=None, updated_at="t2")
|
||
stored = db_with_workflow.get_run("r1")
|
||
|
||
# 验证结果
|
||
assert stored["status"] == "RUNNING"
|
||
assert stored["progress"] == 0.5
|
||
|
||
|
||
def test_list_runs_orders_by_created_at_desc(db_with_workflow: Database) -> None:
|
||
"""任务列表按创建时间倒序返回(新的在前)。"""
|
||
# 数据:三条不同创建时间的任务。
|
||
db_with_workflow.create_run(_run("old", created_at="2026-09-01T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("mid", created_at="2026-09-02T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("new", created_at="2026-09-03T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
ids = [r["id"] for r in db_with_workflow.list_runs()]
|
||
|
||
# 验证结果
|
||
assert ids == ["new", "mid", "old"]
|
||
|
||
|
||
def test_delete_run_removes_record_and_artifacts(db_with_workflow: Database) -> None:
|
||
"""删除任务同时清理其产物记录。"""
|
||
# 数据:任务 + 一条产物。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_artifact({
|
||
"run_id": "r1", "node_id": "a", "name": "a.data_uri", "uri": "/tmp/x", "kind": "file",
|
||
})
|
||
|
||
# 测试过程
|
||
db_with_workflow.delete_run("r1")
|
||
|
||
# 验证结果
|
||
assert db_with_workflow.get_run("r1") is None
|
||
assert db_with_workflow.list_artifacts("r1") == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 调度用查询:只有 QUEUED 会被拾起
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_next_queued_run_returns_oldest_queued(db_with_workflow: Database) -> None:
|
||
"""按创建时间返回最早的 QUEUED 任务。"""
|
||
# 数据:一条较早的 QUEUED。
|
||
db_with_workflow.create_run(_run("a", created_at="2026-09-01T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
picked = db_with_workflow.next_queued_run()
|
||
|
||
# 验证结果
|
||
assert picked["id"] == "a"
|
||
|
||
|
||
def test_next_queued_run_skips_paused(db_with_workflow: Database) -> None:
|
||
"""PAUSED 不被拾起(必须显式 resume;修复"点击暂停反而开始任务"回归)。"""
|
||
# 数据:一条 PAUSED(更早)+ 一条 QUEUED(更晚)。
|
||
db_with_workflow.create_run(_run("paused", status="PAUSED", created_at="2026-09-01T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("queued", created_at="2026-09-02T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
picked = db_with_workflow.next_queued_run()
|
||
|
||
# 验证结果:取的是 QUEUED 那条。
|
||
assert picked["id"] == "queued"
|
||
|
||
|
||
def test_next_queued_run_skips_batch_source(db_with_workflow: Database) -> None:
|
||
"""source=batch 的任务由批量引擎执行,主调度器不拾起(存储目录不同)。"""
|
||
# 数据:一条 batch 来源的 QUEUED。
|
||
db_with_workflow.create_run(_run("batch-1", source="batch"))
|
||
|
||
# 测试过程与验证结果
|
||
assert db_with_workflow.next_queued_run() is None
|
||
|
||
|
||
def test_pause_and_resume_run(db_with_workflow: Database) -> None:
|
||
"""暂停置 PAUSED,继续置回 QUEUED(等待调度器断点续跑)。"""
|
||
# 数据:一条 QUEUED。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
|
||
# 测试过程
|
||
db_with_workflow.pause_run("r1", "t2")
|
||
paused = db_with_workflow.get_run("r1")["status"]
|
||
db_with_workflow.resume_run("r1", "t3")
|
||
resumed = db_with_workflow.get_run("r1")["status"]
|
||
|
||
# 验证结果
|
||
assert paused == "PAUSED"
|
||
assert resumed == "QUEUED"
|
||
|
||
|
||
def test_recover_interrupted_runs_requeues_running_only(db_with_workflow: Database) -> None:
|
||
"""重启恢复:RUNNING → QUEUED,PAUSED 保持不变。"""
|
||
# 数据:RUNNING 与 PAUSED 各一条。
|
||
db_with_workflow.create_run(_run("running", status="RUNNING"))
|
||
db_with_workflow.create_run(_run("paused", status="PAUSED"))
|
||
|
||
# 测试过程
|
||
count = db_with_workflow.recover_interrupted_runs("t2")
|
||
|
||
# 验证结果:只恢复 1 条,PAUSED 不变。
|
||
assert count == 1
|
||
assert db_with_workflow.get_run("running")["status"] == "QUEUED"
|
||
assert db_with_workflow.get_run("paused")["status"] == "PAUSED"
|
||
|
||
|
||
def test_recover_interrupted_batch_jobs_requeues_running(db_with_workflow: Database) -> None:
|
||
"""重启恢复:RUNNING 的批量任务 → QUEUED(否则永久无人拾起)。"""
|
||
# 数据:一条 RUNNING 批量任务(批量明细表对 run 有外键,先建任务记录)。
|
||
db_with_workflow.create_run(_run("bv-run-1"))
|
||
db_with_workflow.create_batch_job({
|
||
"id": "job-1", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||
"status": "RUNNING", "created_at": "t1", "updated_at": "t1",
|
||
})
|
||
|
||
# 测试过程
|
||
count = db_with_workflow.recover_interrupted_batch_jobs("t2")
|
||
|
||
# 验证结果
|
||
assert count == 1
|
||
assert db_with_workflow.get_batch_job("job-1")["status"] == "QUEUED"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 产物与断点恢复
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_and_get_artifact(db_with_workflow: Database) -> None:
|
||
"""产物按 run + name 记录,可按名精确读取。"""
|
||
# 数据:一条任务 + 一条产物(产物对 run 有外键)。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_artifact({
|
||
"run_id": "r1", "node_id": "ocr", "name": "ocr.srt_uri",
|
||
"uri": "/tmp/out/subtitle.srt", "kind": "file",
|
||
})
|
||
|
||
# 测试过程
|
||
stored = db_with_workflow.get_artifact("r1", "ocr.srt_uri")
|
||
|
||
# 验证结果
|
||
assert stored["uri"] == "/tmp/out/subtitle.srt"
|
||
assert db_with_workflow.get_artifact("r1", "missing") is None
|
||
|
||
|
||
def test_restore_run_outputs_strips_node_prefix(db_with_workflow: Database) -> None:
|
||
"""恢复产物时剥去"节点ID."前缀,还原为 {输出名: URI}(断点续跑依赖)。"""
|
||
# 数据:两个节点的产物。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "extract", "name": "extract.audio_uri", "uri": "/a.wav", "kind": "file"})
|
||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "asr", "name": "asr.srt_uri", "uri": "/a.srt", "kind": "file"})
|
||
|
||
# 测试过程
|
||
outputs = db_with_workflow.restore_run_outputs("r1")
|
||
|
||
# 验证结果
|
||
assert outputs == {
|
||
"extract": {"audio_uri": "/a.wav"},
|
||
"asr": {"srt_uri": "/a.srt"},
|
||
}
|
||
|
||
|
||
def test_reset_run_clears_state_and_artifacts(db_with_workflow: Database) -> None:
|
||
"""reset_run 清空产物与错误、回到 QUEUED(供失败任务重跑)。"""
|
||
# 数据:一条 FAILED 任务带产物与错误。
|
||
db_with_workflow.create_run(_run("r1", status="FAILED", error="boom"))
|
||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "a", "name": "a.x", "uri": "/x", "kind": "file"})
|
||
|
||
# 测试过程
|
||
db_with_workflow.reset_run("r1", "t2")
|
||
stored = db_with_workflow.get_run("r1")
|
||
|
||
# 验证结果
|
||
assert stored["status"] == "QUEUED"
|
||
assert stored["error"] is None
|
||
assert db_with_workflow.list_artifacts("r1") == []
|
||
|
||
|
||
def test_list_run_ids(db_with_workflow: Database) -> None:
|
||
"""列出全部任务 ID(供孤儿清理比对文件系统)。"""
|
||
# 数据:两条任务。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_run(_run("r2"))
|
||
|
||
# 测试过程与验证结果
|
||
assert sorted(db_with_workflow.list_run_ids()) == ["r1", "r2"]
|