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。
This commit is contained in:
2026-09-13 15:40:56 +08:00
parent 966f3e6b4b
commit 8a715a8064
139 changed files with 20810 additions and 10733 deletions
+494
View File
@@ -0,0 +1,494 @@
"""src/wov_app/scheduler.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`src/wov_app/scheduler.py`DAG 拓扑调度、断点续跑、暂停语义),
可独立调用。用例使用真实 SQLite、真实存储目录与真实节点(echo/echo 派生),
仅对需要外部服务的节点不做测试。
"""
from __future__ import annotations
from pathlib import Path
import pytest
from wov_app import registry
from wov_app.db import Database
from wov_app.scheduler import WorkflowScheduler, topological_sort
from wov_sdk.models import WorkflowDefinition
@pytest.fixture(autouse=True)
def _isolate_registry():
"""用例前清空注册表、用例后恢复快照:保证用例看到的是干净基线,
不受其他模块(如 main 生命周期 register_all)的注册结果影响。"""
snapshot = dict(registry._registry)
registry._registry.clear()
yield
registry._registry.clear()
registry._registry.update(snapshot)
def _definition(nodes: list[dict], edges: list[dict], **extra) -> WorkflowDefinition:
"""构造真实 WorkflowDefinition(节点 ID/类型/输入与边)。"""
payload = {
"name": "测试流程",
"version": 1,
"nodes": nodes,
"edges": edges,
"entry_inputs": {"video_uri": "file"},
"final_outputs": extra.pop("final_outputs", {}),
**extra,
}
return WorkflowDefinition.from_dict(payload)
def _db_with_workflow(tmp_path: Path, definition: WorkflowDefinition, workflow_id: str = "wf") -> Database:
"""建好工作流 + 版本记录的临时库(任务表有外键约束)。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": workflow_id, "name": "流程", "description": ""})
db.create_workflow_version(workflow_id, 1, definition.to_dict())
return db
def _run(run_id: str, input_uri: str, **overrides) -> dict:
"""构造真实任务记录。"""
record = {
"id": run_id, "workflow_id": "wf", "workflow_version": 1, "status": "QUEUED",
"current_node_id": None, "progress": 0.0, "error": None, "input_uri": input_uri,
"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 _scheduler(db: Database, storage: Path) -> WorkflowScheduler:
"""构造调度器(不启动后台线程,直接调用 execute_run)。"""
return WorkflowScheduler(db, storage, interval_seconds=999)
# ---------------------------------------------------------------------------
# 拓扑排序
# ---------------------------------------------------------------------------
def test_topological_sort_linear_chain() -> None:
"""线性链按依赖顺序返回。"""
# 数据:a → b → c。
definition = _definition(
nodes=[
{"id": "a", "node_type": "echo", "inputs": {}},
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
{"id": "c", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
],
edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "c"}],
)
# 测试过程
order = topological_sort(definition)
# 验证结果
assert order == ["a", "b", "c"]
def test_topological_sort_diamond() -> None:
"""菱形依赖中,汇合节点排在其全部前驱之后。"""
# 数据:a → (b, c) → d。
definition = _definition(
nodes=[
{"id": "a", "node_type": "echo", "inputs": {}},
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
{"id": "c", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
{"id": "d", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
],
edges=[
{"from": "a", "to": "b"}, {"from": "a", "to": "c"},
{"from": "b", "to": "d"}, {"from": "c", "to": "d"},
],
)
# 测试过程
order = topological_sort(definition)
# 验证结果:a 最先、d 最后,b/c 在中间。
assert order[0] == "a"
assert order[-1] == "d"
assert set(order[1:3]) == {"b", "c"}
# ---------------------------------------------------------------------------
# 执行:成功路径
# ---------------------------------------------------------------------------
def test_execute_run_completes_and_records_artifacts(tmp_path: Path) -> None:
"""单节点任务执行成功:状态 COMPLETED、产物登记、进度到位。"""
# 数据:echo 单节点工作流 + 真实输入文件。
definition = _definition(
nodes=[{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}],
edges=[],
final_outputs={"result": "step.file_uri"},
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
source = tmp_path / "input.txt"
source.write_text("输入内容", encoding="utf-8")
db.create_run(_run("run-1", str(source)))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-1")
# 验证结果:任务完成、进度 1.0、节点产物与最终别名都已登记。
stored = db.get_run("run-1")
assert stored["status"] == "COMPLETED"
assert stored["progress"] == 1.0
names = {a["name"] for a in db.list_artifacts("run-1")}
assert "step.file_uri" in names
assert "result" in names
def test_execute_run_multi_node_chain_passes_artifacts(tmp_path: Path) -> None:
"""多节点链:后序节点通过 URI 拿到前序产物(节点间只经产物交换数据)。"""
# 数据:step1 → step2 两节点链。
definition = _definition(
nodes=[
{"id": "step1", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
{"id": "step2", "node_type": "echo", "inputs": {"file_uri": "step1.file_uri"}},
],
edges=[{"from": "step1", "to": "step2"}],
final_outputs={"out": "step2.file_uri"},
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
source = tmp_path / "input.txt"
source.write_text("链式内容", encoding="utf-8")
db.create_run(_run("run-2", str(source)))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-2")
# 验证结果:两节点产物都存在,且 step2 的产物内容来自 step1(传递一致)。
assert db.get_run("run-2")["status"] == "COMPLETED"
artifacts = {a["name"]: a["uri"] for a in db.list_artifacts("run-2")}
assert "step1.file_uri" in artifacts and "step2.file_uri" in artifacts
assert Path(artifacts["step2.file_uri"]).read_text(encoding="utf-8") == "链式内容"
assert Path(artifacts["step2.file_uri"]).parent != Path(artifacts["step1.file_uri"]).parent
def test_execute_run_creates_final_alias_with_stable_name(tmp_path: Path) -> None:
"""最终产物按 上传文件名.标识.时间戳 生成别名,并保留节点原始文件。"""
# 数据:单节点 + target_language 参数(决定别名标识)。
definition = _definition(
nodes=[{
"id": "step", "node_type": "echo",
"inputs": {"file_uri": "input.video_uri"},
"params": {"target_language": "zh-CN"},
}],
edges=[],
final_outputs={"cn_srt_uri": "step.file_uri"},
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
source = tmp_path / "test01.mp4"
source.write_text("数据", encoding="utf-8")
db.create_run(_run("run-3", str(source)))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-3")
# 验证结果:别名指向 finals 下的稳定路径,含 zh-CN 标识;节点原文件仍在。
artifacts = {a["name"]: a["uri"] for a in db.list_artifacts("run-3")}
final = Path(artifacts["cn_srt_uri"])
assert final.is_file()
assert "zh-CN" in final.name
assert "finals" in final.parts
assert Path(artifacts["step.file_uri"]).is_file()
# ---------------------------------------------------------------------------
# 失败与无效 DAG
# ---------------------------------------------------------------------------
def test_execute_run_fails_when_workflow_version_missing(tmp_path: Path) -> None:
"""工作流版本记录丢失时任务失败(不留 QUEUED 堵塞队列)。"""
# 数据:工作流存在但没有 v1 版本记录(外键仍满足)。
db = Database(tmp_path / "wov.db")
storage = tmp_path / "storage"
db.upsert_workflow({"id": "wf-no-version", "name": "流程", "description": ""})
db.create_run(_run("run-x", "input", workflow_id="wf-no-version"))
# 测试过程
_scheduler(db, storage).execute_run("run-x")
# 验证结果
stored = db.get_run("run-x")
assert stored["status"] == "FAILED"
assert "workflow version not found" in stored["error"]
def test_execute_run_marks_failed_on_cycle_and_does_not_block_queue(tmp_path: Path) -> None:
"""环形 DAG(历史无效版本)立即失败且不堵塞后续任务(R04 回归)。"""
# 数据:A→B→A 的环形定义。
definition = _definition(
nodes=[
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
],
edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-cycle", "input"))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-cycle")
# 验证结果:任务 FAILED,且队首前移(不再返回该任务)。
assert db.get_run("run-cycle")["status"] == "FAILED"
assert db.next_queued_run() is None
def test_execute_run_fails_when_input_reference_missing(tmp_path: Path) -> None:
"""输入引用无法解析(前序产物缺失)时任务失败并记录原因。"""
# 数据:节点引用不存在的产物。
definition = _definition(
nodes=[{"id": "step", "node_type": "echo", "inputs": {"file_uri": "ghost.file_uri"}}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-4", "input"))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-4")
# 验证结果
stored = db.get_run("run-4")
assert stored["status"] == "FAILED"
assert "missing input" in stored["error"]
def test_execute_run_fails_when_node_returns_failed(tmp_path: Path) -> None:
"""节点返回 failed 时任务失败并保留错误信息。"""
# 数据:注册一个总是失败的节点。
from wov_sdk.models import InvokeResponse, NodeManifest
definition = _definition(
nodes=[{"id": "step", "node_type": "boom", "inputs": {}}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-5", "input"))
registry.register(
NodeManifest(id="boom", name="失败节点", version="1", capability="c", command=["python"]),
lambda request: InvokeResponse(status="failed", error="节点内部错误"),
)
# 测试过程
_scheduler(db, storage).execute_run("run-5")
# 验证结果
stored = db.get_run("run-5")
assert stored["status"] == "FAILED"
assert "节点内部错误" in stored["error"]
# ---------------------------------------------------------------------------
# 暂停语义
# ---------------------------------------------------------------------------
def test_execute_run_leaves_paused_run_untouched(tmp_path: Path) -> None:
"""以 PAUSED 进入时直接返回保持暂停(修复"点击暂停反而开始任务")。"""
# 数据:PAUSED 状态的任务。
definition = _definition(
nodes=[{"id": "step", "node_type": "echo", "inputs": {}}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-6", "input", status="PAUSED"))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-6")
# 验证结果:仍为 PAUSED,且未产生任何产物。
assert db.get_run("run-6")["status"] == "PAUSED"
assert db.list_artifacts("run-6") == []
def test_execute_run_stops_at_node_boundary_when_paused(tmp_path: Path) -> None:
"""运行中被暂停:在当前节点边界停下保持 PAUSED,不标 FAILED。"""
# 数据:两节点链;第一个节点执行时把任务置 PAUSED。
from wov_sdk.models import InvokeResponse, NodeManifest
definition = _definition(
nodes=[
{"id": "step1", "node_type": "pause-me", "inputs": {}},
{"id": "step2", "node_type": "echo", "inputs": {}},
],
edges=[{"from": "step1", "to": "step2"}],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-7", "input"))
registry.register_all()
def pause_handler(request):
"""模拟用户在该节点执行期间点击暂停。"""
db.pause_run("run-7", "t2")
return InvokeResponse(status="completed", outputs={"file_uri": "/tmp/x"})
registry.register(
NodeManifest(id="pause-me", name="暂停节点", version="1", capability="c", command=["python"]),
pause_handler,
)
# 测试过程
_scheduler(db, storage).execute_run("run-7")
# 验证结果:保持 PAUSED,step2 未执行。
assert db.get_run("run-7")["status"] == "PAUSED"
names = {a["name"] for a in db.list_artifacts("run-7")}
assert not any(name.startswith("step2") for name in names)
def test_execute_run_clears_stale_pause_flag(tmp_path: Path) -> None:
"""执行前清理残留的 paused.flag(避免误触发节点内暂停)。"""
# 数据:单节点任务 + 已存在的 paused.flag。
definition = _definition(
nodes=[{"id": "step", "node_type": "echo", "inputs": {}}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-8", "input"))
registry.register_all()
run_root = storage / "runs" / "run-8"
run_root.mkdir(parents=True)
(run_root / "paused.flag").write_text("", encoding="utf-8")
# 测试过程
_scheduler(db, storage).execute_run("run-8")
# 验证结果:标志被清除,任务正常完成。
assert not (run_root / "paused.flag").exists()
assert db.get_run("run-8")["status"] == "COMPLETED"
# ---------------------------------------------------------------------------
# 断点续跑与参数覆盖
# ---------------------------------------------------------------------------
def test_execute_run_resumes_from_existing_artifacts(tmp_path: Path) -> None:
"""断点续跑:已有产物的节点被跳过,只执行剩余节点。"""
# 数据:两节点链,step1 产物已登记。
calls: list[str] = []
def tracking_handler(node_id: str):
def handler(request):
from wov_sdk.models import InvokeResponse
calls.append(node_id)
return InvokeResponse(status="completed", outputs={"file_uri": f"/tmp/{node_id}"})
return handler
definition = _definition(
nodes=[
{"id": "step1", "node_type": "track1", "inputs": {}},
{"id": "step2", "node_type": "track2", "inputs": {}},
],
edges=[{"from": "step1", "to": "step2"}],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-9", "input"))
db.create_artifact({
"run_id": "run-9", "node_id": "step1", "name": "step1.file_uri",
"uri": "/tmp/step1", "kind": "file",
})
from wov_sdk.models import NodeManifest
registry.register(
NodeManifest(id="track1", name="t1", version="1", capability="c", command=["python"]),
tracking_handler("step1"),
)
registry.register(
NodeManifest(id="track2", name="t2", version="1", capability="c", command=["python"]),
tracking_handler("step2"),
)
# 测试过程
_scheduler(db, storage).execute_run("run-9")
# 验证结果:只调用了 step2。
assert calls == ["step2"]
assert db.get_run("run-9")["status"] == "COMPLETED"
def test_execute_run_applies_param_overrides(tmp_path: Path) -> None:
"""param_overrides 按节点 ID 合并进节点参数(前端框选 crop 的通道)。"""
# 数据:节点参数与覆盖值同时存在。
received: list[dict] = []
def handler(request):
from wov_sdk.models import InvokeResponse
received.append(dict(request.params))
return InvokeResponse(status="completed", outputs={"file_uri": "/tmp/x"})
definition = _definition(
nodes=[{
"id": "step", "node_type": "param-node",
"inputs": {}, "params": {"interval_seconds": 0.5, "crop": [0, 0, 1, 1]},
}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-10", "input", param_overrides={"step": {"crop": [0, 0.75, 1, 0.25]}}))
from wov_sdk.models import NodeManifest
registry.register(
NodeManifest(id="param-node", name="p", version="1", capability="c", command=["python"]),
handler,
)
# 测试过程
_scheduler(db, storage).execute_run("run-10")
# 验证结果:覆盖值生效,未覆盖的参数保持原样。
assert received == [{"interval_seconds": 0.5, "crop": [0, 0.75, 1, 0.25]}]
# ---------------------------------------------------------------------------
# 线程生命周期
# ---------------------------------------------------------------------------
def test_start_and_stop_are_idempotent(tmp_path: Path) -> None:
"""start 重复调用不产生多余线程;stop 正常结束。"""
# 数据:空库 + 调度器。
db = Database(tmp_path / "wov.db")
scheduler = WorkflowScheduler(db, tmp_path / "storage", interval_seconds=999)
# 测试过程
scheduler.start()
first = scheduler._thread
scheduler.start()
second = scheduler._thread
scheduler.stop()
# 验证结果
assert first is second
assert scheduler._thread is None