Files
vrsub/tests/app/test_db/test_database.py
T
cat-shark 8a715a8064 test: 按模块重写测试代码,删除旧平铺结构
按"测试规则"重写 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。
2026-09-13 15:40:56 +08:00

333 lines
12 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.
"""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 → QUEUEDPAUSED 保持不变。"""
# 数据: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"]