feat: VRSub 单体应用(WOV 单机版)初始提交

为视频生成 VR 双眼字幕的单体实现:FastAPI 后端、调度器与全部节点
(提音/转写/翻译/ASS/抽帧/OCR/LLM 过滤)在单进程内运行。

- 节点协议(wov_sdk 数据模型)与分布式版保持一致,预留回退桥梁
- 工作流即数据:DAG 存于 workflows/*.json,模型/链路改动只改数据
- 调度器:拓扑顺序执行、断点续跑(产物重建)、任务暂停/继续
- 抽帧按帧间隔(select 按帧号精确取帧),VLM OCR 与 LLM 过滤使用
  自适应线程池弹性并发,并打印数据处理速度进度日志
- 100% 行覆盖率(pytest --cov-fail-under=100)
This commit is contained in:
2026-08-16 23:58:25 +08:00
commit 4746e0363f
75 changed files with 10969 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
"""pytest 全局配置。
在测试进程启动时创建独立临时目录,并通过环境变量把应用的数据目录、数据库、
存储和后台服务全部指向测试环境,避免污染本地开发数据;同时隔离进程内节点
注册表,防止测试之间互相泄漏注册条目。
"""
import atexit
import os
import shutil
import tempfile
from pathlib import Path
import pytest
# 每个测试进程使用独立临时根目录,保证测试之间互不干扰。
TEST_ROOT = Path(tempfile.mkdtemp(prefix="vrsub-test-"))
os.environ["WOV_DATA_DIR"] = str(TEST_ROOT / "data")
os.environ["WOV_DB_PATH"] = str(TEST_ROOT / "data" / "wov.db")
os.environ["WOV_STORAGE_DIR"] = str(TEST_ROOT / "storage")
# 默认关闭自动种子和后台调度,测试显式控制执行时机。
os.environ["WOV_AUTO_SEED"] = "0"
os.environ["WOV_SCHEDULER_ENABLED"] = "0"
os.environ["WOV_CLEANUP_ENABLED"] = "0"
def _cleanup() -> None:
"""进程退出时清理临时测试目录。"""
shutil.rmtree(TEST_ROOT, ignore_errors=True)
atexit.register(_cleanup)
@pytest.fixture(autouse=True)
def _isolate_registry():
"""快照并恢复进程内节点注册表,避免测试之间互相污染。"""
from wov_app import registry
snapshot = dict(registry._registry)
yield
registry._registry.clear()
registry._registry.update(snapshot)
+148
View File
@@ -0,0 +1,148 @@
"""自适应线程池测试。
覆盖决策函数(增/减/保持/边界)、map 顺序返回、worker 异常隔离,
以及"10s 窗口内平均响应 < 0.3s 加线程 / > 1.0s 减线程"的弹性行为
(通过注入假时钟做确定性验证)。
"""
import time
from nodes.adaptive_pool import AdaptiveThreadPool, decide
class FakeClock:
"""可手动拨动的假时钟,用于确定性验证弹性窗口逻辑。"""
def __init__(self, now: float = 0.0) -> None:
self.now = now
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def test_decide_increase_when_fast() -> None:
"""平均响应低于 fast_threshold 且未达上限:线程数 +1。"""
assert decide(1, 0.1, 1, 16, 0.3, 1.0) == 2
def test_decide_decrease_when_slow() -> None:
"""平均响应高于 slow_threshold 且高于下限:线程数 -1。"""
assert decide(3, 2.0, 1, 16, 0.3, 1.0) == 2
def test_decide_keep_when_mid() -> None:
"""平均响应介于两阈值之间:保持不变。"""
assert decide(2, 0.5, 1, 16, 0.3, 1.0) == 2
def test_decide_bounds() -> None:
"""已达上限不再增、已达下限不再减。"""
assert decide(16, 0.1, 1, 16, 0.3, 1.0) == 16
assert decide(1, 2.0, 1, 16, 0.3, 1.0) == 1
def test_pool_map_ordered_results() -> None:
"""map 按输入顺序返回结果,worker 简单映射。"""
pool = AdaptiveThreadPool(worker=lambda item: item * 2)
assert pool.map([1, 2, 3, 4]) == [2, 4, 6, 8]
def test_pool_on_progress_callback() -> None:
"""进度回调:每次完成触发一次,携带已完成数/总数/速度。"""
progress: list[tuple[int, int, float]] = []
pool = AdaptiveThreadPool(
worker=lambda item: item,
on_progress=lambda done, total, rate: progress.append((done, total, rate)),
)
pool.map([10, 20, 30])
assert [item[0] for item in progress] == [1, 2, 3] # 已完成数递增。
assert all(item[1] == 3 for item in progress) # 总数固定。
assert all(item[2] > 0 for item in progress) # 速度为正值。
def test_pool_map_empty() -> None:
"""空输入:不启动任务,直接返回空列表。"""
pool = AdaptiveThreadPool(worker=lambda item: item)
assert pool.map([]) == []
def test_pool_worker_exception_isolated() -> None:
"""worker 抛异常时以异常对象作为结果,不拖垮整体。"""
def boom(item):
raise RuntimeError("boom")
pool = AdaptiveThreadPool(worker=boom)
results = pool.map([1, 2])
assert len(results) == 2
assert all(isinstance(result, RuntimeError) for result in results)
def test_pool_grows_when_fast() -> None:
"""10s 窗口内平均响应 < 0.3s:线程数从 1 增至 2(弹性扩容)。"""
clock = FakeClock()
pool = AdaptiveThreadPool(
worker=lambda item: item,
min_workers=1, max_workers=16,
window_seconds=10.0, fast_threshold=0.3,
clock=clock,
)
# 拨快时钟越过窗口:首个任务完成即触发评估 → 平均响应≈0 < 0.3 → +1 线程。
clock.advance(11)
pool.map(list(range(4)))
assert pool.max_concurrency == 2
def test_pool_shrink_when_slow() -> None:
"""窗口平均响应 > 1.0s:线程数从 2 减至 1(弹性退避)。"""
clock = FakeClock()
pool = AdaptiveThreadPool(
worker=lambda item: item,
min_workers=1, max_workers=16,
window_seconds=10.0, fast_threshold=0.3, slow_threshold=1.0,
clock=clock,
)
pool._resize(2) # 先扩到 2 个线程。
clock.advance(11)
pool._tick(2.0) # 窗口内平均 2.0 > 1.0 → 缩回 1。
deadline = time.monotonic() + 2
while len(pool._threads) > 1 and time.monotonic() < deadline:
time.sleep(0.01)
assert len(pool._threads) == 1
pool._stop.set()
def test_resize_shrink_idempotent() -> None:
"""回归:重复缩容到同一目标不会重复放哨兵(曾因并发缩容毒死全部线程而死锁)。"""
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=8)
pool._resize(3)
assert pool._target_workers == 3
pool._resize(2)
pool._resize(2) # 目标已是 2:幂等,不再放哨兵。
assert pool._target_workers == 2
# 只有 1 个线程被哨兵退出,最终存活 2 个。
deadline = time.monotonic() + 2
while len(pool._threads) > 2 and time.monotonic() < deadline:
time.sleep(0.01)
assert len(pool._threads) == 2
pool._stop.set()
def test_pool_survives_mixed_grow_shrink() -> None:
"""回归:扩容+缩容混合场景 map 必须完成且保序(修复前会死锁挂起)。"""
clock = FakeClock()
state = {"count": 0}
def worker(item):
state["count"] += 1
clock.advance(0.06 if state["count"] <= 20 else 0.6)
return item
pool = AdaptiveThreadPool(
worker=worker, min_workers=1, max_workers=4,
window_seconds=0.5, fast_threshold=0.2, slow_threshold=0.4,
clock=clock,
)
out = pool.map(list(range(60)))
assert out == list(range(60))
+34
View File
@@ -0,0 +1,34 @@
"""应用级 API 冒烟测试。
使用 FastAPI TestClient 验证健康检查、静态页面与 OpenAPI 文档可访问。
节点注册/实例管理 API 已随单体化移除,不再有对应路由。
"""
from fastapi.testclient import TestClient
from wov_app.main import app
def test_health_and_static() -> None:
"""验证静态首页、OpenAPI 文档与健康探针均可访问。"""
with TestClient(app) as client:
root = client.get("/", follow_redirects=False)
assert root.status_code == 200
assert "VRSub 字幕生成" in root.text
docs = client.get("/docs")
assert docs.status_code == 200
response = client.get("/health")
assert response.status_code == 200
assert response.json()["service"] == "wov-api"
assert response.json()["mode"] == "monolith"
def test_node_admin_routes_removed() -> None:
"""验证节点注册与实例管理路由在单体版中已移除(404/405)。"""
with TestClient(app) as client:
# GET 落到静态文件挂载后返回 404;POST 对静态挂载返回 405。
assert client.post("/api/admin/nodes", json={}).status_code == 405
assert client.get("/api/admin/nodes").status_code == 404
assert client.get("/api/admin/node-instances").status_code == 404
+313
View File
@@ -0,0 +1,313 @@
"""用户应用 API 测试。
覆盖已发布应用的上传建任务、进度查询、产物下载、失败重试以及
未发布/无版本工作流的拒绝逻辑。节点为内置注册,无需再手动注册。
"""
from pathlib import Path
from fastapi.testclient import TestClient
from wov_app.main import app
def _create_published_echo_workflow(client) -> str:
"""创建一个已发布的单节点 Echo 工作流(echo 为内置节点)。"""
definition = {
"name": "echo-flow",
"version": 1,
"nodes": [
{
"id": "step",
"node_type": "echo",
"inputs": {"file_uri": "input.video_uri"},
}
],
"edges": [],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "step.file_uri"},
}
client.post(
"/api/admin/workflows",
json={
"id": "echo-app",
"name": "Echo App",
"description": "upload a file",
"definition": definition,
},
)
client.post("/api/admin/workflows/echo-app/publish")
return "echo-app"
def test_upload_run_progress_and_download() -> None:
"""验证上传文件建任务、手动执行、查询产物与下载的完整流程。"""
with TestClient(app) as client:
workflow_id = _create_published_echo_workflow(client)
apps = client.get("/api/apps")
assert apps.status_code == 200
assert any(item["id"] == workflow_id for item in apps.json())
uploaded = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"hello from upload", "text/plain")},
)
assert uploaded.status_code == 200
run_id = uploaded.json()["id"]
assert uploaded.json()["status"] == "QUEUED"
run = client.get(f"/api/runs/{run_id}")
assert run.status_code == 200
assert run.json()["input_uri"].endswith("sample.txt")
assert run.json()["artifacts"] == []
scheduler = app.state.scheduler
scheduler.execute_run(run_id)
completed = client.get(f"/api/runs/{run_id}")
assert completed.status_code == 200
assert completed.json()["status"] == "COMPLETED"
artifact_names = [item["name"] for item in completed.json()["artifacts"]]
assert "result" in artifact_names
artifacts = client.get(f"/api/runs/{run_id}/artifacts")
assert artifacts.status_code == 200
assert len(artifacts.json()) >= 1
downloaded = client.get(f"/api/runs/{run_id}/artifacts/result")
assert downloaded.status_code == 200
assert b"hello from upload" in downloaded.content
assert client.get(f"/api/runs/{run_id}/artifacts/missing").status_code == 404
assert client.get("/api/runs/missing").status_code == 404
assert client.get("/api/runs/missing/artifacts").status_code == 404
db = app.state.db
db.create_artifact(
{
"run_id": run_id,
"node_id": "step",
"name": "missing-file",
"uri": str(Path(__file__).resolve().parent / "not-exists.bin"),
"mime_type": "text/plain",
"size": 0,
}
)
assert client.get(f"/api/runs/{run_id}/artifacts/missing-file").status_code == 404
runs = client.get("/api/runs")
assert runs.status_code == 200
assert any(item["id"] == run_id for item in runs.json())
def test_upload_rejects_unpublished_workflow() -> None:
"""验证草稿或不存在的工作流不能被用户发起任务。"""
with TestClient(app) as client:
client.post(
"/api/admin/workflows",
json={
"id": "draft",
"name": "Draft",
"definition": {
"name": "Draft",
"version": 1,
"nodes": [],
"edges": [],
},
},
)
response = client.post(
"/api/apps/draft/runs",
files={"file": ("x.txt", b"x", "text/plain")},
)
assert response.status_code == 404
response = client.post(
"/api/apps/missing/runs",
files={"file": ("x.txt", b"x", "text/plain")},
)
assert response.status_code == 404
def test_upload_rejects_workflow_without_version() -> None:
"""验证已发布但没有任何版本的工作流返回 422。"""
with TestClient(app) as client:
db = app.state.db
db.upsert_workflow(
{"id": "empty", "name": "Empty", "published": 1, "latest_version": 0}
)
response = client.post(
"/api/apps/empty/runs",
files={"file": ("x.txt", b"x", "text/plain")},
)
assert response.status_code == 422
def test_retry_failed_run_requeues_and_reruns() -> None:
"""验证失败任务重试会清空旧产物并重新执行成功。"""
with TestClient(app) as client:
workflow_id = _create_published_echo_workflow(client)
uploaded = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"hello retry", "text/plain")},
)
run_id = uploaded.json()["id"]
db = app.state.db
db.update_run(
run_id,
status="FAILED",
error="boom",
updated_at="2026-01-01T00:00:00+00:00",
)
db.create_artifact(
{
"run_id": run_id,
"node_id": "step",
"name": "stale",
"uri": "stale.txt",
"mime_type": "text/plain",
"size": 1,
}
)
response = client.post(f"/api/runs/{run_id}/retry")
assert response.status_code == 200
assert response.json() == {"id": run_id, "status": "QUEUED"}
run = client.get(f"/api/runs/{run_id}").json()
assert run["status"] == "QUEUED"
assert run["error"] is None
assert run["artifacts"] == []
app.state.scheduler.execute_run(run_id)
completed = client.get(f"/api/runs/{run_id}").json()
assert completed["status"] == "COMPLETED"
assert any(item["name"] == "result" for item in completed["artifacts"])
def test_pause_resume_run_api() -> None:
"""验证暂停/继续接口:QUEUED→PAUSED→QUEUED,状态非法时报 422。"""
with TestClient(app) as client:
workflow_id = _create_published_echo_workflow(client)
uploaded = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"hello pause", "text/plain")},
)
run_id = uploaded.json()["id"]
assert uploaded.json()["status"] == "QUEUED"
paused = client.post(f"/api/runs/{run_id}/pause")
assert paused.status_code == 200
assert paused.json() == {"id": run_id, "status": "PAUSED"}
assert client.get(f"/api/runs/{run_id}").json()["status"] == "PAUSED"
resumed = client.post(f"/api/runs/{run_id}/resume")
assert resumed.status_code == 200
assert resumed.json() == {"id": run_id, "status": "QUEUED"}
assert client.get(f"/api/runs/{run_id}").json()["status"] == "QUEUED"
# 非 PAUSED 任务不可继续。
assert client.post(f"/api/runs/{run_id}/resume").status_code == 422
# 不存在的任务 404。
assert client.post("/api/runs/missing/pause").status_code == 404
assert client.post("/api/runs/missing/resume").status_code == 404
def test_pause_rejects_terminal_states() -> None:
"""验证已完成任务不可暂停。"""
with TestClient(app) as client:
workflow_id = _create_published_echo_workflow(client)
uploaded = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"hello done", "text/plain")},
)
run_id = uploaded.json()["id"]
db = app.state.db
db.update_run(run_id, status="COMPLETED", progress=1.0, updated_at="2026-01-01T00:00:00+00:00")
assert client.post(f"/api/runs/{run_id}/pause").status_code == 422
def test_retry_rejects_non_failed_and_missing_runs() -> None:
"""验证只有 FAILED 状态且存在的任务才能重试。"""
with TestClient(app) as client:
workflow_id = _create_published_echo_workflow(client)
uploaded = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"x", "text/plain")},
)
run_id = uploaded.json()["id"]
assert client.post(f"/api/runs/{run_id}/retry").status_code == 422
assert client.post("/api/runs/missing/retry").status_code == 404
def test_delete_run_removes_record_and_files() -> None:
"""验证删除任务会清理数据库记录与磁盘上的上传/步骤文件。"""
import shutil
from wov_app.config import STORAGE_DIR
with TestClient(app) as client:
workflow_id = _create_published_echo_workflow(client)
uploaded = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"hello delete", "text/plain")},
)
run_id = uploaded.json()["id"]
# 执行任务以生成步骤产物目录。
app.state.scheduler.execute_run(run_id)
run = client.get(f"/api/runs/{run_id}").json()
steps_dir = STORAGE_DIR / "runs" / run_id
assert steps_dir.is_dir()
# 上传文件目录也应存在。
upload_dir = Path(run["input_uri"]).parent
assert upload_dir.is_dir()
deleted = client.delete(f"/api/runs/{run_id}")
assert deleted.status_code == 200
assert deleted.json() == {"deleted": run_id}
assert client.get(f"/api/runs/{run_id}").status_code == 404
assert not steps_dir.exists()
assert not upload_dir.exists()
# 删除不存在的任务返回 404。
assert client.delete(f"/api/runs/missing").status_code == 404
# 清理测试遗留的 runs 目录,避免跨用例残留。
shutil.rmtree(STORAGE_DIR / "runs", ignore_errors=True)
def test_create_run_with_param_overrides() -> None:
"""验证创建任务时可携带 params 覆盖(如前端框选的 crop),并持久化。"""
with TestClient(app) as client:
workflow_id = _create_published_echo_workflow(client)
uploaded = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"x", "text/plain")},
data={"params": '{"step": {"crop": [0, 0.82, 1, 0.18]}}'},
)
assert uploaded.status_code == 200
run_id = uploaded.json()["id"]
run = client.get(f"/api/runs/{run_id}").json()
assert run["param_overrides"] == {"step": {"crop": [0, 0.82, 1, 0.18]}}
# 非法 JSON 返回 422。
bad = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"x", "text/plain")},
data={"params": "not-json"},
)
assert bad.status_code == 422
def test_create_run_params_non_object_rejected() -> None:
"""params 为 JSON 数组时返回 422。"""
with TestClient(app) as client:
workflow_id = _create_published_echo_workflow(client)
response = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"x", "text/plain")},
data={"params": "[1,2,3]"},
)
assert response.status_code == 422
+32
View File
@@ -0,0 +1,32 @@
// crop 归一化纯函数单测:由 pytest 通过 node 执行(TDD 红阶段先失败)。
"use strict";
const assert = require("assert");
const { videoDisplayRect, rectToCrop, cropToRect } = require("../web/assets/crop.js");
// 1) 无留边(容器比例与视频一致):底部 20% 矩形 → crop [0, 0.8, 1, 0.2]
let d = videoDisplayRect(1280, 720, 1280, 720);
assert.deepStrictEqual(d, { x: 0, y: 0, w: 1280, h: 720 });
assert.deepStrictEqual(
rectToCrop({ x: 0, y: 576, w: 1280, h: 144 }, 1280, 720, 1280, 720),
[0, 0.8, 1, 0.2]
);
// 2) letterbox(容器比视频宽):视频显示在中间,矩形映射要考虑左右留边
d = videoDisplayRect(1280, 720, 1600, 720);
assert.deepStrictEqual(d, { x: 160, y: 0, w: 1280, h: 720 });
// 在渲染视频内框选右下 25% 区域
let crop = rectToCrop({ x: 160 + 640, y: 360, w: 640, h: 360 }, 1280, 720, 1600, 720);
assert.deepStrictEqual(crop, [0.5, 0.5, 0.5, 0.5]);
// 3) 回显一致性:crop → rect → crop 应还原(含 letterbox
let back = cropToRect(crop, 1280, 720, 1600, 720);
assert.deepStrictEqual(
rectToCrop(back, 1280, 720, 1600, 720),
crop
);
// 4) 越界钳制:矩形超出画面时 crop 值被限制在 0~1
crop = rectToCrop({ x: -100, y: -50, w: 2000, h: 900 }, 1280, 720, 1280, 720);
assert.ok(crop.every((v) => v >= 0 && v <= 1));
console.log("crop.js 全部断言通过");
+294
View File
@@ -0,0 +1,294 @@
"""数据库层单元测试。
直接对 Database 方法调用真实 SQLite 路径,覆盖工作流、版本、任务与产物的
增删改查。节点注册表已改为进程内内存态,不再落库。
"""
from pathlib import Path
from wov_app.db import Database
def test_workflow_crud(tmp_path) -> None:
"""验证工作流概要的插入、发布标记更新与删除。"""
db = Database(tmp_path / "wov.db")
workflow = {
"id": "demo",
"name": "Demo",
"description": "desc",
"published": 0,
"latest_version": 0,
}
db.upsert_workflow(workflow)
assert db.get_workflow("demo")["name"] == "Demo"
assert [item["id"] for item in db.list_workflows()] == ["demo"]
db.upsert_workflow({**workflow, "published": 1, "latest_version": 1})
assert db.get_workflow("demo")["published"] == 1
db.delete_workflow("demo")
assert db.get_workflow("demo") is None
def test_workflow_versions(tmp_path) -> None:
"""验证工作流版本的写入、最新版本查询与列表。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow(
{"id": "demo", "name": "Demo", "published": 1, "latest_version": 2}
)
definition = {"name": "Demo", "version": 1, "nodes": [], "edges": []}
db.create_workflow_version("demo", 1, definition)
db.create_workflow_version("demo", 2, {**definition, "version": 2})
latest = db.get_latest_workflow_version("demo")
assert latest["version"] == 2
assert latest["definition"]["version"] == 2
version = db.get_workflow_version("demo", 1)
assert version["version"] == 1
assert db.get_workflow_version("demo", 99) is None
assert len(db.list_workflow_versions("demo")) == 2
empty_db = Database(tmp_path / "empty.db")
assert empty_db.get_latest_workflow_version("missing") is None
def test_run_and_artifact_crud(tmp_path) -> None:
"""验证任务与产物的创建、查询、更新与删除。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_1",
"workflow_id": "demo",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": "in.txt",
"created_at": now,
"updated_at": now,
}
)
assert db.get_run("run_1")["status"] == "QUEUED"
assert db.next_queued_run()["id"] == "run_1"
db.update_run("run_1", status="RUNNING", progress=0.5, updated_at=now)
db.update_run("run_1")
assert db.get_run("run_1")["status"] == "RUNNING"
assert db.get_run("run_1")["progress"] == 0.5
assert db.next_queued_run() is None
assert len(db.list_runs()) == 1
db.create_artifact(
{
"run_id": "run_1",
"node_id": "echo",
"name": "result",
"uri": "out.txt",
"mime_type": "text/plain",
"size": 3,
}
)
assert db.get_artifact("run_1", "result")["uri"] == "out.txt"
assert db.get_artifact("run_1", "missing") is None
assert len(db.list_artifacts("run_1")) == 1
db.delete_run_artifacts("run_1")
assert db.list_artifacts("run_1") == []
def test_reset_run_clears_error_and_artifacts(tmp_path) -> None:
"""验证 reset_run 会把失败任务恢复到排队状态并清空旧产物。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_1",
"workflow_id": "demo",
"workflow_version": 1,
"status": "FAILED",
"progress": 0.75,
"current_node_id": "translate",
"error": "timed out",
"input_uri": "in.txt",
"created_at": now,
"updated_at": now,
}
)
db.create_artifact(
{
"run_id": "run_1",
"node_id": "asr",
"name": "asr.srt_uri",
"uri": "out.srt",
"mime_type": "application/x-subrip",
"size": 3,
}
)
db.reset_run("run_1", "2026-01-02T00:00:00+00:00")
run = db.get_run("run_1")
assert run["status"] == "QUEUED"
assert run["progress"] == 0
assert run["current_node_id"] is None
assert run["error"] is None
assert run["updated_at"] == "2026-01-02T00:00:00+00:00"
assert run["created_at"] == now
assert db.list_artifacts("run_1") == []
def test_delete_run(tmp_path) -> None:
"""验证 delete_run 会删除任务记录及其产物记录。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_1",
"workflow_id": "demo",
"workflow_version": 1,
"status": "COMPLETED",
"progress": 1,
"input_uri": "in.txt",
"created_at": now,
"updated_at": now,
}
)
db.create_artifact(
{
"run_id": "run_1",
"node_id": "asr",
"name": "asr.srt_uri",
"uri": "out.srt",
"mime_type": "application/x-subrip",
"size": 3,
}
)
db.delete_run("run_1")
assert db.get_run("run_1") is None
assert db.list_artifacts("run_1") == []
def test_list_run_ids(tmp_path) -> None:
"""验证 list_run_ids 返回全部任务 ID,供孤儿清理对照使用。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
assert db.list_run_ids() == []
for run_id in ("run_a", "run_b"):
db.create_run(
{
"id": run_id,
"workflow_id": "demo",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"created_at": now,
"updated_at": now,
}
)
assert set(db.list_run_ids()) == {"run_a", "run_b"}
def test_run_param_overrides_persist(tmp_path) -> None:
"""验证 param_overrides 随任务持久化并可读回。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": "demo", "name": "D", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_ov",
"workflow_id": "demo",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"param_overrides": {"extract": {"crop": [0, 0.5, 1, 0.5]}},
"created_at": now,
"updated_at": now,
}
)
run = db.get_run("run_ov")
assert run["param_overrides"] == {"extract": {"crop": [0, 0.5, 1, 0.5]}}
assert db.next_queued_run()["param_overrides"] == {"extract": {"crop": [0, 0.5, 1, 0.5]}}
def test_db_migration_adds_param_overrides(tmp_path) -> None:
"""旧库迁移:缺少 param_overrides 列的库打开后自动补列。"""
import sqlite3
db_path = tmp_path / "old.db"
conn = sqlite3.connect(db_path)
conn.execute(
"CREATE TABLE workflow_runs (id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL,"
" workflow_version INTEGER NOT NULL, status TEXT NOT NULL, current_node_id TEXT,"
" progress REAL NOT NULL DEFAULT 0, error TEXT, input_uri TEXT,"
" created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
)
conn.commit()
conn.close()
Database(db_path)
conn = sqlite3.connect(db_path)
columns = [row[1] for row in conn.execute("PRAGMA table_info(workflow_runs)")]
conn.close()
assert "param_overrides" in columns
def test_pause_resume_run(tmp_path) -> None:
"""验证 pause_run/resume_run 的状态流转与 PAUSED 任务可被调度器取到。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_p",
"workflow_id": "demo",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"created_at": now,
"updated_at": now,
}
)
db.pause_run("run_p", now)
assert db.get_run("run_p")["status"] == "PAUSED"
# PAUSED 任务会被 next_queued_run 取到(等待续跑)。
assert db.next_queued_run()["id"] == "run_p"
db.resume_run("run_p", now)
assert db.get_run("run_p")["status"] == "QUEUED"
assert db.next_queued_run()["id"] == "run_p"
def test_restore_run_outputs(tmp_path) -> None:
"""验证从产物重建节点输出(断点续跑的依据)。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_r",
"workflow_id": "demo",
"workflow_version": 1,
"status": "PAUSED",
"progress": 0,
"created_at": now,
"updated_at": now,
}
)
db.create_artifact(
{
"run_id": "run_r",
"node_id": "extract",
"name": "frames_manifest",
"uri": "frames.json",
"mime_type": "application/json",
"size": 1,
}
)
assert db.restore_run_outputs("run_r") == {
"extract": {"frames_manifest": "frames.json"}
}
assert db.restore_run_outputs("run_none") == {}
+28
View File
@@ -0,0 +1,28 @@
"""前端 crop 归一化纯函数测试。
通过 node 执行 tests/test_crop_js.js(真实 JS 断言),验证框选矩形与
crop 比例的互转(含 letterbox 与越界钳制)。
"""
import shutil
import subprocess
from pathlib import Path
import pytest
# 单体根目录:tests/ 的上一级。
WORKSPACE = Path(__file__).resolve().parent.parent
JS_TEST = WORKSPACE / "tests" / "test_crop_js.js"
def test_crop_js_normalization() -> None:
"""node 执行 crop 纯函数断言(TDD 红阶段先失败)。"""
if shutil.which("node") is None:
pytest.skip("环境无 node,跳过前端 crop 单测")
result = subprocess.run(
["node", str(JS_TEST)],
capture_output=True,
text=True,
cwd=str(WORKSPACE),
)
assert result.returncode == 0, result.stderr
+90
View File
@@ -0,0 +1,90 @@
"""字幕 OCR 整链真实集成测试。
使用 testdata/subtitle_10s.mp4(烧录 SUB 001@1-4s、SUB 002@6-9s)与真实
glm-ocr 模型:抽帧(frame-extract)→ 逐帧 OCRsubtitle-ocr)→ 汇总 SRT
断言烧录文字与时间轴对齐。Ollama 服务或资产缺失时自动跳过。
"""
import json
import urllib.request
from pathlib import Path
import pytest
from nodes.frame_extract import invoke as frame_invoke
from nodes.subtitle_ocr import invoke as ocr_invoke
from wov_sdk.models import InvokeRequest
OLLAMA_HOST = "http://192.168.123.70:11434"
MODEL = "glm-ocr:latest"
# 单体根目录:tests/ 的上一级。
WORKSPACE = Path(__file__).resolve().parent.parent
VIDEO = WORKSPACE / "testdata" / "subtitle_10s.mp4"
def _register_nodes() -> None:
"""注册全部内置节点,供 subtitle-ocr 内部调 vlm-ocr 使用。"""
from wov_app import registry
registry.register_all()
def _ollama_reachable() -> bool:
"""探测 Ollama 服务与目标模型是否可用。"""
try:
req = urllib.request.Request(
f"{OLLAMA_HOST}/api/show",
data=b'{"model": "%s"}' % MODEL.encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status == 200
except (urllib.error.URLError, OSError):
return False
@pytest.mark.integration
def test_subtitle_ocr_full_chain(tmp_path) -> None:
"""抽帧→OCR→汇总:SRT 应含 SUB 001/SUB 002 且时间轴落在各自区间。"""
if not _ollama_reachable():
pytest.skip("Ollama 服务或 glm-ocr 模型不可用,跳过真实模型集成测试")
if not VIDEO.is_file():
pytest.skip("缺少 testdata/subtitle_10s.mp4 测试资产,跳过集成测试")
_register_nodes()
# 抽帧:1s 间隔,字幕在底部,裁切下半 30% 区域(y=0.7,h=0.3)。
frames_resp = frame_invoke(
InvokeRequest(
run_id="chain_fx",
node_instance_id="",
inputs={"video_uri": str(VIDEO)},
params={"interval_seconds": 1, "crop": [0, 0.7, 1, 0.3]},
output_dir=str(tmp_path / "frames"),
)
)
assert frames_resp.status == "completed", frames_resp.error
manifest = json.loads(Path(frames_resp.outputs["frames_manifest"]).read_text(encoding="utf-8"))
assert len(manifest) >= 8
# OCR 汇总:真实 glm-ocr 逐帧识别。
ocr_resp = ocr_invoke(
InvokeRequest(
run_id="chain_ocr",
node_instance_id="",
inputs={"frames_manifest": str(frames_resp.outputs["frames_manifest"])},
params={"model": MODEL, "ollama_host": OLLAMA_HOST, "min_chars": 2},
output_dir=str(tmp_path / "out"),
)
)
assert ocr_resp.status == "completed", ocr_resp.error
srt = Path(ocr_resp.outputs["srt_uri"]).read_text(encoding="utf-8")
# 两条烧录字幕都应被识别(文字可能带噪声,但至少含关键片段)。
assert "SUB" in srt
# 时间轴:SUB 001 应在 1-4sSUB 002 应在 6-9s(允许模型/抽帧容差)。
first_line = next(line for line in srt.splitlines() if "-->" in line)
start = first_line.split(" --> ")[0].replace(",", ".")
hours, minutes, seconds = start.split(":")
total = int(hours) * 3600 + int(minutes) * 60 + float(seconds)
assert total < 5
+65
View File
@@ -0,0 +1,65 @@
"""VLM OCR 节点真实集成测试。
复用 testdata/test_real_hav_sub.png(真实视频字幕截图,一次性入库,避免
每次测试生成)。调用本地 Ollama 服务(192.168.123.70:11434)的真实
glm-ocr 模型做 OCR。Ollama 服务或测试资产缺失时自动跳过;可用时必须执行。
"""
import urllib.request
from pathlib import Path
import pytest
from nodes.vlm import invoke
from wov_sdk.models import InvokeRequest
OLLAMA_HOST = "http://192.168.123.70:11434"
MODEL = "glm-ocr:latest"
# 测试图片(真实视频字幕帧)上应识别出的字幕文本。
EXPECTED_TEXT = "还有没有什么困扰 或者奇怪的地方吗"
# 单体根目录:tests/ 的上一级。
WORKSPACE = Path(__file__).resolve().parent.parent
TEST_IMAGE = WORKSPACE / "testdata" / "test_real_hav_sub.png"
def _ollama_reachable() -> bool:
"""探测 Ollama 服务与目标模型是否可用。"""
try:
req = urllib.request.Request(
f"{OLLAMA_HOST}/api/show",
data=b'{"model": "%s"}' % MODEL.encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status == 200
except (urllib.error.URLError, OSError):
return False
@pytest.mark.integration
def test_vlm_ocr_real_model(tmp_path) -> None:
"""复用真实字幕截图 + 真实 glm-ocr:应识别出关键字幕文本并清洗围栏垃圾。"""
if not _ollama_reachable():
pytest.skip("Ollama 服务或 glm-ocr 模型不可用,跳过真实模型集成测试")
if not TEST_IMAGE.is_file():
pytest.skip("缺少 testdata/test_real_hav_sub.png 测试资产,跳过集成测试")
response = invoke(
InvokeRequest(
run_id="vlm_integration",
node_instance_id="",
inputs={"image_uri": str(TEST_IMAGE)},
params={"model": MODEL, "ollama_host": OLLAMA_HOST},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
text = response.outputs["text"]
print(text)
# 关键字幕文本应被识别出来。注意 glm-ocr 在此图上存在已知重复循环 bug:
# 识别出正确文本后可能继续循环输出,因此用"包含"断言而非全等,
# 下游 subtitle-ocr 的 max_result_chars 守卫会拦截超长输出。
assert EXPECTED_TEXT in text
# 围栏垃圾(```)不应出现在输出里。
assert "```" not in text
+52
View File
@@ -0,0 +1,52 @@
"""真实模型集成测试。
复用 testdata/speech_60s.wav(真实语音 WAV,一次性生成、入库,避免每次
测试从视频提取)。使用真实 faster-whisper 模型端到端验证 whisper 节点的
分块转写与 SRT 生成。本地缺少模型或测试资产时自动跳过;具备条件时必须
执行,作为对假模型单元测试的校准。
约定(见 AGENTS.md「测试与覆盖率」):单元测试允许在模型推理这一 I/O
边界使用返回真实结构的薄桩,但必须配套本集成测试验证真实行为。
"""
from pathlib import Path
import pytest
from nodes.whisper import invoke
from wov_sdk.models import InvokeRequest
# 单体根目录:tests/ 的上一级。
WORKSPACE = Path(__file__).resolve().parent.parent
MODEL_DIR = WORKSPACE / "model" / "faster-whisper-large-v3"
TEST_AUDIO = WORKSPACE / "testdata" / "speech_60s.wav"
@pytest.mark.integration
def test_whisper_real_model_chunked_transcription(tmp_path) -> None:
"""复用 testdata 语音 + 真实模型:分块转写产出真实 SRT,时间不越出素材范围。"""
if not (MODEL_DIR / "model.bin").is_file():
pytest.skip("本地无 faster-whisper-large-v3 模型,跳过真实模型集成测试")
if not TEST_AUDIO.is_file():
pytest.skip("缺少 testdata/speech_60s.wav 测试资产,跳过真实模型集成测试")
response = invoke(
InvokeRequest(
run_id="integration_1",
node_instance_id="",
inputs={"audio_uri": str(TEST_AUDIO)},
params={"language": "ja", "chunk_seconds": 60, "vad_filter": False},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
srt_path = Path(response.outputs["srt_uri"])
assert srt_path.is_file()
srt = srt_path.read_text(encoding="utf-8")
time_lines = [line for line in srt.splitlines() if "-->" in line]
# 60s 语音若含可识别内容,则应有字幕,且时间轴不越出素材时长(允许少量超窗)。
if time_lines:
last_end = time_lines[-1].split(" --> ")[1].replace(",", ".")
hours, minutes, seconds = last_end.split(":")
total = int(hours) * 3600 + int(minutes) * 60 + float(seconds)
assert total < 90
+257
View File
@@ -0,0 +1,257 @@
"""LLM 字幕过滤节点测试。
覆盖 SRT 解析/序列化、±N 上下文窗口组装(纯文本无时间戳、目标标记)、
LLM 调用(按 I/O 边界 mock urlopen)与删除判定、invoke 全链路与异常路径。
"""
import json
import urllib.error
from pathlib import Path
from nodes.llm_filter import invoke, parse_srt, serialize_srt
from wov_sdk.models import InvokeRequest
# 4 条字幕的 SRT:第 3 条为"答:"开头的无意义杂项,模拟 OCR 噪声。
_SRT = (
"1\n00:00:01,000 --> 00:00:04,000\n还有没有什么困扰\n\n"
"2\n00:00:05,000 --> 00:00:08,000\n或者奇怪的地方吗\n\n"
"3\n00:00:09,000 --> 00:00:12,000\n答:无意义杂项\n\n"
"4\n00:00:13,000 --> 00:00:16,000\n第二句正常字幕\n"
)
class FakeResponse:
"""模拟 urllib 响应:read() 返回 LLM 兼容接口的 JSON 载荷。"""
def __init__(self, payload: bytes) -> None:
self._payload = payload
def read(self) -> bytes:
return self._payload
def __enter__(self):
return self
def __exit__(self, *args) -> bool:
return False
class FakeLLM:
"""模拟 LLM 兼容接口:记录请求体,按策略返回"保留/删除"
支持两种策略:contents(按队列顺序,用于单次直调 _judge_target 的
确定性测试)或 decision_fn(按请求体内容决策,用于并发 invoke 测试,
保证任何线程执行顺序下判定结果都确定)。
"""
def __init__(self, contents: list[str] | None = None, decision_fn=None) -> None:
self._contents = list(contents) if contents is not None else None
self._decision_fn = decision_fn
self.bodies: list[dict] = []
self.headers: list[dict] = []
def __call__(self, request, timeout=None):
body = json.loads(request.data.decode("utf-8"))
self.bodies.append(body)
self.headers.append(dict(request.headers))
if self._decision_fn is not None:
content = self._decision_fn(body)
else:
content = self._contents.pop(0)
payload = json.dumps({"choices": [{"message": {"content": content}}]}).encode()
return FakeResponse(payload)
def _patch_llm(monkeypatch, contents: list[str] | None = None, decision_fn=None) -> FakeLLM:
"""替换 nodes.llm_filter 的 urlopen 为 FakeLLM 并返回实例。"""
fake = FakeLLM(contents=contents, decision_fn=decision_fn)
monkeypatch.setattr("nodes.llm_filter.urllib.request.urlopen", fake)
return fake
def _decision_by_target(body) -> str:
"""按目标字幕内容决策:含"答:"判为删除,其余保留(与 _SRT 的噪声对应)。"""
target = next(
line for line in body["messages"][1]["content"].splitlines()
if line.startswith("【目标】")
)
return "删除" if "答:" in target else "保留"
def test_parse_srt_multiline_and_last_block() -> None:
"""解析 SRT:多行文本与末条无空行结尾均能正确解析。"""
text = (
"1\n00:00:01,000 --> 00:00:04,000\n第一行\n第二行\n\n"
"2\n00:00:05,000 --> 00:00:08,000\n末条无空行结尾\n"
)
entries = parse_srt(text)
assert len(entries) == 2
assert entries[0]["start"] == "00:00:01,000"
assert entries[0]["end"] == "00:00:04,000"
assert entries[0]["text"] == "第一行\n第二行"
assert entries[1]["text"] == "末条无空行结尾"
def test_serialize_srt_renumbers() -> None:
"""序列化:序号从 1 重新编号,保留原始时间轴。"""
entries = [
{"start": "00:00:09,000", "end": "00:00:12,000", "text": "答:无意义杂项"},
{"start": "00:00:13,000", "end": "00:00:16,000", "text": "第二句正常字幕"},
]
out = serialize_srt(entries)
assert out == (
"1\n00:00:09,000 --> 00:00:12,000\n答:无意义杂项\n\n"
"2\n00:00:13,000 --> 00:00:16,000\n第二句正常字幕\n"
)
def test_judge_target_window_and_keep(monkeypatch) -> None:
"""窗口只含纯文本(无时间戳)、目标带标记;模型答"保留"则返回 False。"""
from nodes.llm_filter import _judge_target
entries = parse_srt(_SRT)
fake = _patch_llm(monkeypatch, ["保留"])
# context_size=1,目标为第 2 条(index=1):窗口 0..2 共 3 行,目标在中间。
assert _judge_target(entries, 1, context_size=1, params={}) is False
body = fake.bodies[0]
lines = body["messages"][1]["content"].splitlines()
assert len(lines) == 3
assert lines[0] == "还有没有什么困扰"
assert lines[1] == "【目标】或者奇怪的地方吗"
assert lines[2] == "答:无意义杂项"
# 不含时间戳。
assert "00:00" not in body["messages"][1]["content"]
assert body["enable_thinking"] is False
assert body["max_tokens"] == 16
def test_judge_target_delete(monkeypatch) -> None:
"""模型答"删除"时返回 True(判定该条无意义)。"""
from nodes.llm_filter import _judge_target
entries = parse_srt(_SRT)
_patch_llm(monkeypatch, ["删除"])
assert _judge_target(entries, 2, context_size=10, params={}) is True
def test_judge_target_model_and_auth(monkeypatch) -> None:
"""模型名从参数取;配置 API Key 时附带 Bearer 鉴权头。"""
from nodes.llm_filter import _judge_target
entries = parse_srt(_SRT)
monkeypatch.setenv("LLM_API_KEY", "sk-test")
fake = _patch_llm(monkeypatch, ["保留"])
assert _judge_target(entries, 0, context_size=10, params={"model": "m/1"}) is False
assert fake.bodies[0]["model"] == "m/1"
assert fake.headers[0]["Authorization"] == "Bearer sk-test"
def test_invoke_filters_and_renumbers(monkeypatch, tmp_path) -> None:
"""全链路(并发):按 LLM 判定删除无意义条,保留条重新编号输出。"""
srt = tmp_path / "in.srt"
srt.write_text(_SRT, encoding="utf-8")
# 内容决策:目标字幕含"答:"判删除,其余保留(任何线程顺序下结果确定)。
_patch_llm(monkeypatch, decision_fn=_decision_by_target)
response = invoke(
InvokeRequest(
run_id="r", node_instance_id="",
inputs={"srt_uri": str(srt)},
params={},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
assert response.outputs["kept"] == 3
assert response.outputs["removed"] == 1
out = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert out.count("-->") == 3
# 被删除的"答:无意义杂项"(第 3 条)时间轴不再出现。
assert "00:00:09,000" not in out
# 保留条重新编号且时间轴不变。
assert out.startswith("1\n00:00:01,000 --> 00:00:04,000\n还有没有什么困扰\n\n2\n")
assert "00:00:13,000 --> 00:00:16,000\n第二句正常字幕\n" in out
def test_invoke_context_size_param(monkeypatch, tmp_path) -> None:
"""context_size 参数生效:窗口大小=2×context_size+1(两端截断除外)。"""
srt = tmp_path / "in.srt"
srt.write_text(_SRT, encoding="utf-8")
fake = _patch_llm(monkeypatch, decision_fn=_decision_by_target)
response = invoke(
InvokeRequest(
run_id="r", node_instance_id="",
inputs={"srt_uri": str(srt)},
params={"context_size": 1},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
# 找到目标为第 2 条("或者奇怪的地方吗")的请求体:窗口应含 3 行。
body = next(
b for b in fake.bodies
if "【目标】或者奇怪的地方吗" in b["messages"][1]["content"]
)
window = body["messages"][1]["content"].splitlines()
assert len(window) == 3
def test_invoke_missing_input(tmp_path) -> None:
"""缺少 srt_uri 时返回失败。"""
response = invoke(
InvokeRequest(run_id="r", node_instance_id="", inputs={}, output_dir=str(tmp_path))
)
assert response.status == "failed"
assert "srt_uri" in response.error
def test_invoke_file_missing(tmp_path) -> None:
"""srt 文件不存在时返回失败。"""
response = invoke(
InvokeRequest(
run_id="r", node_instance_id="",
inputs={"srt_uri": str(tmp_path / "none.srt")},
output_dir=str(tmp_path),
)
)
assert response.status == "failed"
assert "not found" in response.error
def test_invoke_llm_error(monkeypatch, tmp_path) -> None:
"""LLM 调用失败(网络错误)时返回 failed,不静默输出未过滤结果。"""
srt = tmp_path / "in.srt"
srt.write_text(_SRT, encoding="utf-8")
def boom(request, timeout=None):
raise urllib.error.URLError("llm down")
monkeypatch.setattr("nodes.llm_filter.urllib.request.urlopen", boom)
response = invoke(
InvokeRequest(
run_id="r", node_instance_id="",
inputs={"srt_uri": str(srt)},
params={},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "failed"
assert "llm down" in response.error
def test_invoke_empty_srt(monkeypatch, tmp_path) -> None:
"""空 SRT(无条目)正常完成,输出空文件且不调用 LLM。"""
srt = tmp_path / "empty.srt"
srt.write_text("", encoding="utf-8")
fake = _patch_llm(monkeypatch, [])
response = invoke(
InvokeRequest(
run_id="r", node_instance_id="",
inputs={"srt_uri": str(srt)},
params={},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
assert response.outputs["kept"] == 0
assert response.outputs["removed"] == 0
assert fake.bodies == []
+165
View File
@@ -0,0 +1,165 @@
"""孤儿数据清理器测试。
覆盖 COMPLETED 无文件任务的删除、各类保留分支(有文件/失败/宽限期内)、
无任务记录的残留目录清理、清理线程启停以及防御性分支。
"""
from datetime import datetime, timezone
from pathlib import Path
from wov_app.db import Database
from wov_app.maintenance import OrphanCleaner
def _db(tmp_path) -> Database:
"""在临时目录创建独立数据库。"""
return Database(tmp_path / "wov.db")
def _make_run(db, run_id, status="COMPLETED", updated="2020-01-01T00:00:00+00:00", input_uri=None):
"""创建指定状态与更新时间的工作流任务记录。"""
db.upsert_workflow({"id": "flow", "name": "F", "published": 1, "latest_version": 1})
db.create_run(
{
"id": run_id,
"workflow_id": "flow",
"workflow_version": 1,
"status": status,
"progress": 1,
"input_uri": input_uri,
"created_at": updated,
"updated_at": updated,
}
)
def _now_iso() -> str:
"""返回当前 UTC 时间的 ISO 字符串。"""
return datetime.now(timezone.utc).isoformat()
def test_cleaner_removes_completed_orphan_run(tmp_path) -> None:
"""验证 COMPLETED 且无任何产物文件、超过宽限期的任务被整体清理。"""
db = _db(tmp_path)
upload_dir = tmp_path / "storage" / "uploads" / "run_orphan"
upload_dir.mkdir(parents=True)
upload_file = upload_dir / "in.mp4"
upload_file.write_bytes(b"x")
_make_run(db, "run_orphan", input_uri=str(upload_file))
db.create_artifact(
{
"run_id": "run_orphan",
"node_id": "asr",
"name": "asr.srt_uri",
"uri": str(tmp_path / "storage" / "runs" / "run_orphan" / "out.srt"),
"mime_type": "application/x-subrip",
"size": 1,
}
)
cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600)
assert cleaner.clean_once() == 1
assert db.get_run("run_orphan") is None
assert db.list_artifacts("run_orphan") == []
assert not upload_dir.exists()
def test_cleaner_keeps_completed_run_with_files(tmp_path) -> None:
"""验证仍有产物文件的 COMPLETED 任务不会被清理。"""
db = _db(tmp_path)
steps = tmp_path / "storage" / "runs" / "run_keep"
(steps / "asr").mkdir(parents=True)
(steps / "asr" / "out.srt").write_text("1\n00:00:00,000 --> 00:00:01,000\nok\n", encoding="utf-8")
_make_run(db, "run_keep")
cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600)
assert cleaner.clean_once() == 0
assert db.get_run("run_keep") is not None
def test_cleaner_keeps_failed_and_recent_runs(tmp_path) -> None:
"""验证 FAILED 任务与宽限期内的任务都不会被自动清理。"""
db = _db(tmp_path)
_make_run(db, "run_failed", status="FAILED")
_make_run(db, "run_recent", updated=_now_iso())
cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600)
assert cleaner.clean_once() == 0
assert db.get_run("run_failed") is not None
assert db.get_run("run_recent") is not None
def test_cleaner_removes_dangling_dirs_only(tmp_path) -> None:
"""验证无任务记录的残留目录被删除,已有任务的上传目录被保留。"""
db = _db(tmp_path)
ghost_upload = tmp_path / "storage" / "uploads" / "ghost"
ghost_upload.mkdir(parents=True)
ghost_steps = tmp_path / "storage" / "runs" / "ghost"
ghost_steps.mkdir(parents=True)
keep_upload = tmp_path / "storage" / "uploads" / "run_keep"
keep_upload.mkdir(parents=True)
(keep_upload / "in.mp4").write_bytes(b"x")
# run_keep 存在产物文件,不属于孤儿任务。
keep_steps = tmp_path / "storage" / "runs" / "run_keep" / "asr"
keep_steps.mkdir(parents=True)
(keep_steps / "out.srt").write_text("ok", encoding="utf-8")
_make_run(db, "run_keep")
cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600)
assert cleaner.clean_once() == 2
assert not ghost_upload.exists()
assert not ghost_steps.exists()
assert keep_upload.exists()
def test_cleaner_removes_run_with_empty_steps_dir(tmp_path) -> None:
"""验证步骤目录存在但为空(无文件)时仍视为孤儿清理。"""
db = _db(tmp_path)
steps = tmp_path / "storage" / "runs" / "run_empty"
(steps / "asr").mkdir(parents=True)
_make_run(db, "run_empty", input_uri="")
cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600)
assert cleaner.clean_once() == 1
assert db.get_run("run_empty") is None
assert not steps.exists()
def test_cleaner_default_config_and_defensive_branches(tmp_path, monkeypatch) -> None:
"""验证默认配置构造、缺失/非法时间与缺失任务记录的防御分支。"""
db = _db(tmp_path)
# 默认配置(interval/grace 走 config 默认值)。
cleaner = OrphanCleaner(db, tmp_path / "storage")
assert cleaner.interval_seconds > 0
assert cleaner.grace_seconds > 0
# 无更新时间 / 非法时间均保守视为未过期。
assert cleaner._expired(None) is False
assert cleaner._expired("not-a-date") is False
# 不存在的根目录直接返回 0。
assert cleaner._clean_dangling(tmp_path / "missing", set()) == 0
# list_run_ids 返回的 ID 在读取详情前已不存在时跳过。
monkeypatch.setattr(db, "list_run_ids", lambda: ["ghost"])
monkeypatch.setattr(db, "get_run", lambda run_id: None)
assert cleaner.clean_once() == 0
def test_cleaner_start_stop_loop(tmp_path) -> None:
"""验证清理线程可启动、周期执行并正常停止。"""
import time
db = _db(tmp_path)
cleaner = OrphanCleaner(db, tmp_path / "storage", interval_seconds=0.05, grace_seconds=3600)
cleaner.start()
try:
cleaner.start()
time.sleep(0.2)
finally:
cleaner.stop()
assert cleaner._thread is None
def test_lifespan_starts_cleaner(monkeypatch) -> None:
"""验证启用清理器时应用生命周期会启动清理线程并随退出停止。"""
from fastapi.testclient import TestClient
from wov_app.main import app
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "1")
with TestClient(app) as client:
assert client.get("/health").status_code == 200
assert app.state.cleaner._thread is not None
+174
View File
@@ -0,0 +1,174 @@
"""wov_sdk.models 的单元测试。
测试覆盖所有数据模型的 JSON 往返序列化、字段校验和 manifest 文件加载,
确保协议模型的稳定性。
"""
import json
import pytest
from wov_sdk.models import (
HealthResponse,
InvokeRequest,
InvokeResponse,
NodeManifest,
ProgressEvent,
WorkflowDefinition,
WorkflowEdge,
WorkflowNode,
)
def valid_manifest() -> NodeManifest:
"""构造一个覆盖全部字段的合法 NodeManifest,供测试复用。"""
return NodeManifest(
id="echo",
name="Echo",
version="1.0.0",
capability="echo",
command=["python", "-m", "echo"],
repo_dir="wov-node-echo",
env={"PORT": "0"},
input_schema={"text": "string"},
output_schema={"text": "string"},
max_concurrency=2,
idle_ttl_seconds=15,
health_timeout_seconds=5,
keep_warm=True,
)
def test_manifest_round_trip() -> None:
"""验证 manifest 经过 to_dict/from_dict 后保持原值。"""
manifest = valid_manifest()
restored = NodeManifest.from_dict(manifest.to_dict())
assert restored == manifest
@pytest.mark.parametrize(
("field", "value"),
[
("id", ""),
("name", ""),
("version", ""),
("capability", ""),
("repo_dir", ""),
("command", []),
("max_concurrency", 0),
("idle_ttl_seconds", -1),
("health_timeout_seconds", 0),
],
)
def test_manifest_validation(field: str, value: object) -> None:
"""验证必填字段为空或数值越界时抛出 ValueError。"""
manifest = valid_manifest()
setattr(manifest, field, value)
with pytest.raises(ValueError):
manifest.validate()
def test_manifest_load(tmp_path) -> None:
"""验证 NodeManifest.load 能从 JSON 文件读取并校验。"""
path = tmp_path / "node.manifest.json"
path.write_text(json.dumps(valid_manifest().to_dict()), encoding="utf-8")
loaded = NodeManifest.load(str(path))
assert loaded.id == "echo"
def test_invoke_request_round_trip() -> None:
"""验证 InvokeRequest 的 JSON 往返序列化。"""
request = InvokeRequest(
run_id="run_1",
node_instance_id="ni_1",
inputs={"text": "hello"},
params={"temperature": 0.2},
output_dir="out",
)
restored = InvokeRequest.from_dict(request.to_dict())
assert restored == request
def test_invoke_response_round_trip() -> None:
"""验证 InvokeResponse 的 JSON 往返序列化。"""
response = InvokeResponse(status="completed", outputs={"text": "hello"})
restored = InvokeResponse.from_dict(response.to_dict())
assert restored == response
def test_health_and_progress_serialization() -> None:
"""验证健康检查和进度事件模型的字典输出。"""
health = HealthResponse(status="ok", node_id="echo", version="1.0.0")
assert health.to_dict() == {
"status": "ok",
"node_id": "echo",
"version": "1.0.0",
}
progress = ProgressEvent(run_id="run_1", node_id="echo", progress=0.5, message="half")
assert progress.to_dict() == {
"run_id": "run_1",
"node_id": "echo",
"progress": 0.5,
"message": "half",
}
def test_workflow_node_and_edge_round_trip() -> None:
"""验证工作流节点与边的 JSON 往返序列化。"""
node = WorkflowNode(
id="asr",
node_type="faster-whisper",
params={"language": "ja"},
inputs={"audio_uri": "extract.audio_uri"},
)
edge = WorkflowEdge(from_node="extract", to_node="asr")
assert WorkflowNode.from_dict(node.to_dict()) == node
assert WorkflowEdge.from_dict(edge.to_dict()) == edge
assert edge.to_dict() == {"from": "extract", "to": "asr"}
assert node.to_dict()["inputs"] == {"audio_uri": "extract.audio_uri"}
def test_workflow_definition_round_trip_and_validation() -> None:
"""验证完整 DAG 定义可往返序列化并通过校验。"""
definition = WorkflowDefinition(
name="demo",
version=1,
nodes=[
WorkflowNode(id="extract", node_type="ffmpeg"),
WorkflowNode(id="asr", node_type="whisper"),
],
edges=[WorkflowEdge(from_node="extract", to_node="asr")],
entry_inputs={"video_uri": "file"},
final_outputs={"srt": "asr.srt_uri"},
)
restored = WorkflowDefinition.from_dict(definition.to_dict())
assert restored == definition
restored.validate()
def test_workflow_definition_invalid() -> None:
"""验证非法 DAG(空名、版本为 0、重复节点、未知边)被拒绝。"""
with pytest.raises(ValueError):
WorkflowDefinition(name="", version=1).validate()
with pytest.raises(ValueError):
WorkflowDefinition(name="demo", version=0).validate()
duplicate = WorkflowDefinition(
name="demo",
version=1,
nodes=[WorkflowNode(id="a", node_type="x"), WorkflowNode(id="a", node_type="y")],
)
with pytest.raises(ValueError):
duplicate.validate()
unknown_edge = WorkflowDefinition(
name="demo",
version=1,
nodes=[WorkflowNode(id="a", node_type="x")],
edges=[WorkflowEdge(from_node="a", to_node="missing")],
)
with pytest.raises(ValueError):
unknown_edge.validate()
+1295
View File
File diff suppressed because it is too large Load Diff
+401
View File
@@ -0,0 +1,401 @@
"""抽帧与字幕 OCR 节点单元测试。
frame-extract 用 testdata 真实视频抽帧+裁切+720p 压缩;subtitle-ocr 的 OCR
网络调用(vlm-ocr)按 I/O 边界 mock,但喂给它的帧图片是真实的 testdata 资产。
应用层不再加工模型输出文本,只做长度上限校验(超长报错跳过)。
"""
import json
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
from nodes.frame_extract import invoke as frame_invoke
from nodes.subtitle_ocr import _assemble_srt
from nodes.subtitle_ocr import invoke as ocr_invoke
# 单体根目录:tests/ 的上一级。
WORKSPACE = Path(__file__).resolve().parent.parent
TESTDATA = WORKSPACE / "testdata"
# 10s 测试视频:SUB 001 在 1-4s、SUB 002 在 6-9s。
VIDEO = TESTDATA / "subtitle_10s.mp4"
TEXT_IMG = TESTDATA / "ocr_text.png"
def _png_size(path: Path) -> tuple[int, int]:
"""从 PNG 头读取宽高(真实图片尺寸断言)。"""
data = path.read_bytes()
assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a real png"
width = int.from_bytes(data[16:20], "big")
height = int.from_bytes(data[20:24], "big")
return width, height
def _frame_request(tmp_path, video=VIDEO, **params) -> InvokeRequest:
"""构造 frame-extract 调用请求。"""
return InvokeRequest(
run_id="run_fx",
node_instance_id="",
inputs={"video_uri": str(video)},
params=params,
output_dir=str(tmp_path / "out"),
)
# ---------------------------------------------------------------------------
# frame-extract:抽帧 + 裁切 + 720p 压缩
# ---------------------------------------------------------------------------
def test_frame_extract_crop_and_manifest(tmp_path) -> None:
"""真实视频抽帧:裁切下半 50% 后帧尺寸为 1280x360,清单时间轴正确。"""
response = frame_invoke(
_frame_request(tmp_path, interval_seconds=1, crop=[0, 0.5, 1, 0.5])
)
assert response.status == "completed", response.error
manifest = json.loads(Path(response.outputs["frames_manifest"]).read_text(encoding="utf-8"))
assert len(manifest) >= 9
assert [round(item["time"], 3) for item in manifest] == [
round(i * 1.0, 3) for i in range(len(manifest))
]
first = Path(manifest[0]["image_uri"])
assert first.is_file()
# 1280x360 已在 720p 内,压缩不改变尺寸。
assert _png_size(first) == (1280, 360)
def test_frame_extract_default_params(tmp_path) -> None:
"""未指定参数时使用默认值:抽帧间隔 0.5 秒 + 默认底部裁切区域。"""
response = frame_invoke(_frame_request(tmp_path))
assert response.status == "completed", response.error
manifest = json.loads(Path(response.outputs["frames_manifest"]).read_text(encoding="utf-8"))
assert manifest
# 默认间隔 0.5s25fps 下 step=round(12.5)=12(银行家舍入),
# 帧时间按 step/fps=12/25=0.48s 步进(帧号精确,采样周期由帧量化决定)。
assert [round(item["time"], 3) for item in manifest] == [
round(i * 12 / 25, 3) for i in range(len(manifest))
]
def test_frame_extract_missing_video(tmp_path) -> None:
"""缺少 video_uri 时返回失败。"""
response = frame_invoke(
InvokeRequest(
run_id="r", node_instance_id="", inputs={}, output_dir=str(tmp_path)
)
)
assert response.status == "failed"
def test_frame_extract_bad_crop(tmp_path) -> None:
"""crop 比例越界(超出画面)时返回失败。"""
assert frame_invoke(_frame_request(tmp_path, crop=[0, 0.5, 1, 1.5])).status == "failed"
assert frame_invoke(_frame_request(tmp_path, crop=[-0.1, 0, 1, 0.5])).status == "failed"
assert frame_invoke(_frame_request(tmp_path, crop="abc")).status == "failed"
assert frame_invoke(_frame_request(tmp_path, crop=[0, 0.5, 1])).status == "failed"
# 各值域合法但 x+w 越出画面。
assert frame_invoke(_frame_request(tmp_path, crop=[0.6, 0, 0.5, 0.3])).status == "failed"
def test_frame_extract_video_missing_file(tmp_path) -> None:
"""video_uri 指向不存在的文件时返回失败。"""
response = frame_invoke(_frame_request(tmp_path, video=tmp_path / "none.mp4"))
assert response.status == "failed"
assert "not found" in response.error
def test_frame_extract_bad_interval(tmp_path) -> None:
"""间隔 <= 0 时返回失败。"""
response = frame_invoke(_frame_request(tmp_path, interval_seconds=0))
assert response.status == "failed"
def test_frame_extract_ffmpeg_fails(monkeypatch, tmp_path) -> None:
"""ffmpeg 抽帧失败时透传错误。"""
import subprocess as sp
monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: (1280, 720))
monkeypatch.setattr("nodes.frame_extract._video_fps", lambda *a, **k: 25.0)
monkeypatch.setattr("nodes.frame_extract._video_duration", lambda *a, **k: 10.0)
monkeypatch.setattr(
"nodes.frame_extract.subprocess.run",
lambda *a, **k: sp.CompletedProcess([], 1, stderr="boom"),
)
response = frame_invoke(_frame_request(tmp_path))
assert response.status == "failed"
assert "boom" in response.error
def test_video_size_unreadable(monkeypatch) -> None:
"""ffmpeg -i 输出不含视频流信息时返回 None。"""
import subprocess as sp
from nodes.frame_extract import _video_size
monkeypatch.setattr(
"nodes.frame_extract.subprocess.run",
lambda *a, **k: sp.CompletedProcess([], 0, stderr="no video stream"),
)
assert _video_size(Path("/tmp/x.mp4"), "ffmpeg") is None
def test_frame_extract_video_size_unknown(monkeypatch, tmp_path) -> None:
"""无法读取视频分辨率时返回失败。"""
monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: None)
response = frame_invoke(_frame_request(tmp_path))
assert response.status == "failed"
assert "video size" in response.error
def test_video_duration_unreadable(monkeypatch) -> None:
"""ffmpeg -i 输出缺少 Duration 时返回 None。"""
import subprocess as sp
from nodes.frame_extract import _video_duration
monkeypatch.setattr(
"nodes.frame_extract.subprocess.run",
lambda *a, **k: sp.CompletedProcess([], 0, stderr="no duration info"),
)
assert _video_duration(Path("/tmp/x.mp4"), "ffmpeg") is None
def test_frame_extract_duration_unknown(monkeypatch, tmp_path) -> None:
"""无法读取视频时长时返回失败。"""
monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: (1280, 720))
monkeypatch.setattr("nodes.frame_extract._video_duration", lambda *a, **k: None)
response = frame_invoke(_frame_request(tmp_path))
assert response.status == "failed"
assert "duration" in response.error
def test_frame_extract_fps_unknown(monkeypatch, tmp_path) -> None:
"""无法读取视频帧率时返回失败。"""
monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: (1280, 720))
monkeypatch.setattr("nodes.frame_extract._video_duration", lambda *a, **k: 10.0)
monkeypatch.setattr("nodes.frame_extract._video_fps", lambda *a, **k: None)
response = frame_invoke(_frame_request(tmp_path))
assert response.status == "failed"
assert "fps" in response.error
def test_frame_step_conversion() -> None:
"""帧间隔换算:step=round(间隔秒×fps),至少为 1。"""
from nodes.frame_extract import _frame_step
assert _frame_step(fps=25.0, interval=0.2) == 5
assert _frame_step(fps=25.0, interval=1.0) == 25
assert _frame_step(fps=29.97, interval=1.0) == 30
# fps 很低时 step 也不会小于 1(每帧都取)。
assert _frame_step(fps=1.0, interval=0.2) == 1
def test_video_fps_parse(monkeypatch) -> None:
"""帧率解析:支持小数(29.97)与有理数(30000/1001)。"""
import subprocess as sp
from nodes.frame_extract import _video_fps
monkeypatch.setattr(
"nodes.frame_extract.subprocess.run",
lambda *a, **k: sp.CompletedProcess(
[], 0, stderr="Stream #0:0: Video: h264, 1280x720, 30000/1001 fps, 30000/1001 tbr"
),
)
assert _video_fps(Path("/tmp/x.mp4"), "ffmpeg") == 30000 / 1001
monkeypatch.setattr(
"nodes.frame_extract.subprocess.run",
lambda *a, **k: sp.CompletedProcess(
[], 0, stderr="Stream #0:0: Video: h264, 1280x720, 25 fps, 25 tbr"
),
)
assert _video_fps(Path("/tmp/x.mp4"), "ffmpeg") == 25.0
monkeypatch.setattr(
"nodes.frame_extract.subprocess.run",
lambda *a, **k: sp.CompletedProcess([], 0, stderr="no video stream"),
)
assert _video_fps(Path("/tmp/x.mp4"), "ffmpeg") is None
def test_frame_extract_one_second_exact_frames(tmp_path) -> None:
"""真实视频 1s 间隔按秒 seek 精确抽帧:10s 视频应得 10 帧,时间 0..9。"""
response = frame_invoke(
_frame_request(tmp_path, interval_seconds=1, crop=[0, 0.7, 1, 0.3])
)
assert response.status == "completed", response.error
manifest = json.loads(Path(response.outputs["frames_manifest"]).read_text(encoding="utf-8"))
assert [round(item["time"], 3) for item in manifest] == [
round(i * 1.0, 3) for i in range(len(manifest))
]
assert len(manifest) == 10
# ---------------------------------------------------------------------------
# subtitle-ocrOCR 循环 + 长度上限 + 合并 + SRT 组装
# ---------------------------------------------------------------------------
def _frames_manifest(tmp_path, frame_specs) -> Path:
"""构造真实 frames.jsonframe_specs=[(time, image_path), ...]。"""
items = [{"time": time, "image_uri": str(image)} for time, image in frame_specs]
path = tmp_path / "frames.json"
path.write_text(json.dumps(items), encoding="utf-8")
return path
def test_assemble_srt_real_timeline() -> None:
"""SRT 组装:起始=帧时间,结束=最后可见帧时间+采样间隔。"""
lines = _assemble_srt([(0.0, 6.0, "A"), (6.0, 8.0, "B")], interval_seconds=2.0)
text = "\n".join(lines)
assert text.startswith("1\n")
# A 最后可见帧 6.0 + 间隔 2.0 = 8.0(而非下一条字幕的出现时间)。
assert "00:00:00,000 --> 00:00:08,000" in text
assert "00:00:06,000 --> 00:00:10,000" in text
def test_sampling_interval_from_manifest() -> None:
"""采样间隔从帧清单时间轴推导:均匀间隔取相邻差,退化清单回退默认值。"""
from nodes.subtitle_ocr import _sampling_interval
manifest = [{"time": i * 0.2, "image_uri": f"f{i}.png"} for i in range(10)]
assert _sampling_interval(manifest, 2.0) == 0.2
# 单帧(无法算差)与异常时间序:回退默认值。
assert _sampling_interval([{"time": 0.0, "image_uri": "f0.png"}], 2.0) == 2.0
assert _sampling_interval(
[{"time": 0.0}, {"time": 0.0}, {"time": 0.2}], 2.0
) == 0.2
def test_ocr_merges_consecutive_same_text(monkeypatch, tmp_path) -> None:
"""连续帧相同字幕合并为一条;消失时间=最后可见帧+间隔,空白段保留。"""
# SUB 001 在 0/2s4s 为空帧,SUB 002 在 6/8s。
frame_texts = [
(0.0, "SUB 001"), (2.0, "SUB 001"), (4.0, ""),
(6.0, "SUB 002"), (8.0, "SUB 002"),
]
frames = []
for index, (time, _text) in enumerate(frame_texts):
image = tmp_path / f"f{index}.png"
image.write_bytes(TEXT_IMG.read_bytes())
frames.append((time, image))
mapping = {str(image): text for (time, image), (_, text) in zip(frames, frame_texts)}
def fake_vlm(node_id, request):
return InvokeResponse(status="completed", outputs={"text": mapping[request.inputs["image_uri"]]})
monkeypatch.setattr("wov_app.registry.invoke", fake_vlm)
manifest = _frames_manifest(tmp_path, frames)
response = ocr_invoke(
InvokeRequest(
run_id="run_ocr",
node_instance_id="",
inputs={"frames_manifest": str(manifest)},
params={},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert srt.count("SUB 001") == 1
assert srt.count("SUB 002") == 1
# SUB 001 最后可见帧 2.0 + 间隔 2.0 = 4.0 消失(而非拖到 SUB 002 出现)。
assert "00:00:00,000 --> 00:00:04,000" in srt
assert "00:00:06,000 --> 00:00:10,000" in srt
def test_ocr_skips_failed_frames(monkeypatch, tmp_path) -> None:
"""个别帧 OCR 失败时跳过,不影响其余帧汇总。"""
frames = []
for index in range(3):
image = tmp_path / f"f{index}.png"
image.write_bytes(TEXT_IMG.read_bytes())
frames.append((index * 2.0, image))
def fake_vlm(node_id, request):
if "f1" in request.inputs["image_uri"]:
return InvokeResponse(status="failed", error="boom")
return InvokeResponse(status="completed", outputs={"text": "SUB 001"})
monkeypatch.setattr("wov_app.registry.invoke", fake_vlm)
manifest = _frames_manifest(tmp_path, frames)
response = ocr_invoke(
InvokeRequest(
run_id="run_ocr",
node_instance_id="",
inputs={"frames_manifest": str(manifest)},
params={},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "SUB 001" in srt
def test_ocr_missing_manifest(tmp_path) -> None:
"""缺少 frames_manifest 或清单文件不存在时返回失败。"""
response = ocr_invoke(
InvokeRequest(run_id="r", node_instance_id="", inputs={}, output_dir=str(tmp_path))
)
assert response.status == "failed"
response = ocr_invoke(
InvokeRequest(
run_id="r", node_instance_id="",
inputs={"frames_manifest": str(tmp_path / "none.json")},
output_dir=str(tmp_path),
)
)
assert response.status == "failed"
def test_ocr_skips_oversized_output(monkeypatch, tmp_path) -> None:
"""超长输出(模型重复循环等)直接报错跳过该帧,不进入 SRT。"""
frames = []
for index in range(3):
image = tmp_path / f"o{index}.png"
image.write_bytes(TEXT_IMG.read_bytes())
frames.append((index * 2.0, image))
def fake_vlm(node_id, request):
if "o0" in request.inputs["image_uri"]:
return InvokeResponse(status="completed", outputs={"text": "重复字幕\n" * 50})
if "o1" in request.inputs["image_uri"]:
# 空输出帧跳过。
return InvokeResponse(status="completed", outputs={"text": ""})
return InvokeResponse(status="completed", outputs={"text": "SUB 001"})
monkeypatch.setattr("wov_app.registry.invoke", fake_vlm)
manifest = _frames_manifest(tmp_path, frames)
response = ocr_invoke(
InvokeRequest(
run_id="run_ocr",
node_instance_id="",
inputs={"frames_manifest": str(manifest)},
params={"max_result_chars": 200},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "SUB 001" in srt
assert "重复字幕" not in srt
def test_ocr_passes_short_text_through(monkeypatch, tmp_path) -> None:
"""不超过上限的模型输出原样进入 SRT(不再做应用层过滤)。"""
image = tmp_path / "s0.png"
image.write_bytes(TEXT_IMG.read_bytes())
frames = [(0.0, image)]
def fake_vlm(node_id, request):
return InvokeResponse(status="completed", outputs={"text": " SUB 001 "})
monkeypatch.setattr("wov_app.registry.invoke", fake_vlm)
manifest = _frames_manifest(tmp_path, frames)
response = ocr_invoke(
InvokeRequest(
run_id="run_ocr", node_instance_id="",
inputs={"frames_manifest": str(manifest)},
params={},
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "SUB 001" in srt
+116
View File
@@ -0,0 +1,116 @@
"""进程内节点注册表测试。
覆盖节点注册、全量注册、查询、进程内调用以及未注册节点的报错路径,
验证注册表作为调度器唯一调用入口的正确性。
"""
import pytest
from wov_app import registry
from wov_sdk.models import InvokeRequest, InvokeResponse, NodeManifest
def _echo_manifest() -> NodeManifest:
"""构造最小合法 echo 节点清单。"""
return NodeManifest(
id="echo",
name="Echo",
version="1.0.0",
capability="echo",
command=["python", "-m", "echo"],
repo_dir="nodes",
)
def test_register_and_list() -> None:
"""验证注册后可查询与列出节点,且按 ID 排序。"""
registry.register(_echo_manifest(), lambda request: InvokeResponse(status="completed"))
registry.register(
NodeManifest(
id="z-node",
name="Z",
version="1",
capability="x",
command=["python", "-m", "z"],
repo_dir="nodes",
),
lambda request: InvokeResponse(status="completed"),
)
assert [node.id for node in registry.list_nodes()] == ["echo", "z-node"]
assert registry.get_node("echo").capability == "echo"
assert registry.get_node("missing") is None
def test_register_validation() -> None:
"""验证非法 manifest 注册会被协议校验拒绝。"""
invalid = _echo_manifest()
invalid.id = ""
with pytest.raises(ValueError):
registry.register(invalid, lambda request: InvokeResponse(status="completed"))
def test_register_all_loads_builtin_nodes() -> None:
"""验证 register_all 会加载 manifests/ 下全部内置节点。"""
registry.register_all()
ids = {node.id for node in registry.list_nodes()}
assert {
"echo",
"ffmpeg-extract",
"faster-whisper",
"llm-translate",
"vlm-ocr",
"srt-to-dual-eye-ass",
} <= ids
def test_invoke_calls_handler() -> None:
"""验证 invoke 会把请求转发给注册的进程内处理器。"""
captured = {}
def handler(request: InvokeRequest) -> InvokeResponse:
captured["run_id"] = request.run_id
return InvokeResponse(status="completed", outputs={"text": "ok"})
registry.register(_echo_manifest(), handler)
response = registry.invoke("echo", InvokeRequest(run_id="run_1", node_instance_id=""))
assert response.status == "completed"
assert response.outputs == {"text": "ok"}
assert captured["run_id"] == "run_1"
def test_invoke_unknown_node() -> None:
"""验证调用未注册节点时抛出 ValueError。"""
with pytest.raises(ValueError, match="not registered"):
registry.invoke("missing", InvokeRequest(run_id="run_1", node_instance_id=""))
def test_invoke_logs_node_lifecycle(caplog) -> None:
"""验证 invoke 会记录节点的开始/完成/耗时日志(主进程可见)。"""
registry.register(
_echo_manifest(), lambda request: InvokeResponse(status="completed", outputs={"text": "ok"})
)
with caplog.at_level("INFO", logger="vrsub.node"):
registry.invoke("echo", InvokeRequest(run_id="run_1", node_instance_id=""))
assert any("节点 echo 开始" in record.message for record in caplog.records)
assert any("节点 echo 完成" in record.message for record in caplog.records)
def test_invoke_logs_node_failure(caplog) -> None:
"""验证节点返回 failed 时记录失败日志。"""
registry.register(
_echo_manifest(), lambda request: InvokeResponse(status="failed", error="boom")
)
with caplog.at_level("INFO", logger="vrsub.node"):
registry.invoke("echo", InvokeRequest(run_id="run_1", node_instance_id=""))
assert any("节点 echo 失败" in record.message for record in caplog.records)
def test_get_logger_idempotent() -> None:
"""验证日志器重复获取不会重复附加控制台处理器。"""
from wov_app.logging import get_logger
logger = get_logger("idempotent")
handler_count = len(logger.handlers)
again = get_logger("idempotent")
assert again is logger
assert len(again.handlers) == handler_count
+655
View File
@@ -0,0 +1,655 @@
"""调度器单元测试。
覆盖拓扑排序、任务执行成功/失败分支、输入引用解析、MIME 推断以及
后台轮询线程的启动与停止。节点调用改为进程内注册表直接调用。
"""
import time
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 (
InvokeResponse,
NodeManifest,
WorkflowDefinition,
WorkflowEdge,
WorkflowNode,
)
# 单体根目录:tests/ 的上一级。
WORKSPACE = Path(__file__).resolve().parent.parent
def _register_echo() -> None:
"""把内置 echo 节点注册到进程内注册表。"""
from nodes.echo import invoke
registry.register(NodeManifest.load(str(WORKSPACE / "manifests" / "echo.json")), invoke)
def _db(tmp_path) -> Database:
"""在临时目录创建独立数据库。"""
return Database(tmp_path / "wov.db")
def _echo_definition() -> WorkflowDefinition:
"""构造引用 Echo 节点的单步骤工作流定义。"""
return WorkflowDefinition(
name="echo-flow",
version=1,
nodes=[
WorkflowNode(
id="step",
node_type="echo",
inputs={"file_uri": "input.video_uri"},
)
],
edges=[],
entry_inputs={"video_uri": "file"},
final_outputs={"result": "step.file_uri"},
)
def test_topological_sort() -> None:
"""验证 DAG 排序保持依赖顺序,并拒绝环与未知边。"""
definition = WorkflowDefinition(
name="dag",
version=1,
nodes=[
WorkflowNode(id="a", node_type="x"),
WorkflowNode(id="b", node_type="x"),
WorkflowNode(id="c", node_type="x"),
],
edges=[
WorkflowEdge(from_node="a", to_node="b"),
WorkflowEdge(from_node="a", to_node="c"),
],
)
order = topological_sort(definition)
assert order.index("a") < order.index("b")
assert order.index("a") < order.index("c")
cycle = WorkflowDefinition(
name="cycle",
version=1,
nodes=[
WorkflowNode(id="a", node_type="x"),
WorkflowNode(id="b", node_type="x"),
],
edges=[
WorkflowEdge(from_node="a", to_node="b"),
WorkflowEdge(from_node="b", to_node="a"),
],
)
with pytest.raises(ValueError, match="cycle"):
topological_sort(cycle)
with pytest.raises(ValueError, match="unknown edge"):
topological_sort(
WorkflowDefinition(
name="bad",
version=1,
nodes=[WorkflowNode(id="a", node_type="x")],
edges=[WorkflowEdge(from_node="a", to_node="missing")],
)
)
def test_execute_echo_workflow(tmp_path) -> None:
"""验证排队任务可被完整执行并登记全部产物。"""
db = _db(tmp_path)
input_file = tmp_path / "input.txt"
input_file.write_text("hello scheduler", encoding="utf-8")
_register_echo()
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, _echo_definition().to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_1",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(input_file),
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_1")
run = db.get_run("run_1")
assert run["status"] == "COMPLETED"
artifacts = db.list_artifacts("run_1")
assert {item["name"] for item in artifacts} == {"step.text", "step.file_uri", "result"}
def test_execute_run_missing_workflow(tmp_path, monkeypatch) -> None:
"""验证工作流记录缺失时任务被标记为失败。"""
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_missing",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"created_at": now,
"updated_at": now,
}
)
monkeypatch.setattr(db, "get_workflow", lambda workflow_id: None)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_missing")
assert db.get_run("run_missing")["status"] == "FAILED"
def test_execute_run_missing_version(tmp_path) -> None:
"""验证版本记录缺失时任务被标记为失败。"""
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_version",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_version")
assert db.get_run("run_version")["status"] == "FAILED"
def test_execute_run_missing_node(tmp_path) -> None:
"""验证未注册节点被调用时任务失败。"""
db = _db(tmp_path)
definition = WorkflowDefinition(
name="bad",
version=1,
nodes=[
WorkflowNode(
id="step",
node_type="missing-node",
inputs={"text": "input.video_uri"},
)
],
entry_inputs={"video_uri": "file"},
)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, definition.to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_node",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(tmp_path / "in.txt"),
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_node")
assert db.get_run("run_node")["status"] == "FAILED"
def test_resolve_ref_and_mime(tmp_path) -> None:
"""验证输入引用解析、MIME 推断与文件大小读取。"""
db = _db(tmp_path)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
assert scheduler._resolve_ref("input.video", "in.mp4", {}) == "in.mp4"
assert (
scheduler._resolve_ref(
"a.out", None, {"a": {"out": "result.txt"}}
)
== "result.txt"
)
assert scheduler._resolve_ref("a.out", None, {}) is None
assert scheduler._resolve_ref("nodot", "in.mp4", {}) is None
assert scheduler._mime_type("x.srt") == "application/x-subrip"
assert scheduler._mime_type("x.ass") == "text/plain"
assert scheduler._mime_type("x.wav") == "audio/wav"
assert scheduler._mime_type("x.mp4") == "video/mp4"
assert scheduler._mime_type("x.txt") == "text/plain"
assert scheduler._mime_type("x.bin") == "application/octet-stream"
existing = tmp_path / "existing.txt"
existing.write_text("x", encoding="utf-8")
assert scheduler._file_size(str(existing)) == 1
missing = tmp_path / "missing.bin"
assert scheduler._file_size(str(missing)) == 0
def test_execute_unknown_or_non_queued_run(tmp_path) -> None:
"""验证未知任务或非排队任务会被忽略。"""
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_done",
"workflow_id": "flow",
"workflow_version": 1,
"status": "COMPLETED",
"progress": 1,
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("missing")
scheduler.execute_run("run_done")
assert db.get_run("run_done")["status"] == "COMPLETED"
def test_execute_missing_input(tmp_path) -> None:
"""验证输入引用无法解析时任务失败。"""
db = _db(tmp_path)
definition = WorkflowDefinition(
name="missing-input",
version=1,
nodes=[
WorkflowNode(
id="step",
node_type="echo",
inputs={"text": "missing.output"},
)
],
entry_inputs={"video_uri": "file"},
)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, definition.to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_input",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(tmp_path / "in.txt"),
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_input")
assert db.get_run("run_input")["status"] == "FAILED"
def test_execute_node_failed_response(tmp_path) -> None:
"""验证节点返回 failed 时任务被标记为失败。"""
db = _db(tmp_path)
registry.register(
NodeManifest(
id="fail-node",
name="Fail",
version="1",
capability="echo",
repo_dir="nodes",
command=["python", "-m", "fail"],
),
lambda request: InvokeResponse(status="failed", error="boom"),
)
definition = WorkflowDefinition(
name="fail-flow",
version=1,
nodes=[
WorkflowNode(
id="step",
node_type="fail-node",
inputs={"text": "input.video_uri"},
)
],
entry_inputs={"video_uri": "file"},
)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, definition.to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_fail_node",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(tmp_path / "in.txt"),
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_fail_node")
assert db.get_run("run_fail_node")["status"] == "FAILED"
def test_scheduler_start_stop_loop(tmp_path) -> None:
"""验证调度线程可重复启动并正常停止。"""
db = _db(tmp_path)
scheduler = WorkflowScheduler(db, tmp_path / "storage", interval_seconds=0.05)
scheduler.start()
try:
scheduler.start()
time.sleep(0.15)
finally:
scheduler.stop()
assert scheduler._thread is None
def test_scheduler_background_executes_queued_run(tmp_path) -> None:
"""验证后台线程会自动执行排队中的任务。"""
db = _db(tmp_path)
input_file = tmp_path / "input.txt"
input_file.write_text("background", encoding="utf-8")
_register_echo()
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, _echo_definition().to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_bg",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(input_file),
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage", interval_seconds=0.05)
scheduler.start()
try:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if db.get_run("run_bg")["status"] in {"COMPLETED", "FAILED"}:
break
time.sleep(0.1)
finally:
scheduler.stop()
assert db.get_run("run_bg")["status"] == "COMPLETED"
def test_final_artifact_renamed_with_language_tag(tmp_path) -> None:
"""验证最终产物按 上传文件名.语言.时间戳 重命名并登记新 URI。"""
db = _db(tmp_path)
input_file = tmp_path / "movie01.mp4"
input_file.write_text("video", encoding="utf-8")
_register_echo()
definition = WorkflowDefinition(
name="lang-flow",
version=1,
nodes=[
WorkflowNode(
id="step",
node_type="echo",
params={"target_language": "zh-CN"},
inputs={"file_uri": "input.video_uri"},
)
],
edges=[],
entry_inputs={"video_uri": "file"},
final_outputs={"cn_srt": "step.file_uri"},
)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, definition.to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_1",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(input_file),
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_1")
artifacts = db.list_artifacts("run_1")
final = next(item for item in artifacts if item["name"] == "cn_srt")
filename = Path(final["uri"]).name
# 命名规则:movie01.zh-CN.<14位时间戳>.txt
assert filename.startswith("movie01.zh-CN.")
assert filename.endswith(".txt")
assert Path(final["uri"]).is_file()
# 原始未重命名文件不应残留。
step_artifacts = [item for item in artifacts if item["name"] == "step.file_uri"]
assert not Path(step_artifacts[0]["uri"]).exists()
def test_final_artifact_renamed_fallback_base_and_tag(tmp_path) -> None:
"""验证无上传文件时基础名回退 subtitle,无语言参数时标识回退别名。"""
db = _db(tmp_path)
_register_echo()
definition = WorkflowDefinition(
name="fallback-flow",
version=1,
nodes=[
# 空输入让 echo 走默认文本路径,避免 input_uri 缺失导致解析失败。
WorkflowNode(id="step", node_type="echo", inputs={})
],
entry_inputs={"video_uri": "file"},
final_outputs={"result": "step.file_uri"},
)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, definition.to_dict())
now = "2026-01-01T00:00:00+00:00"
# 故意不提供 input_uri,验证基础名回退。
db.create_run(
{
"id": "run_1",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": None,
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_1")
final = next(item for item in db.list_artifacts("run_1") if item["name"] == "result")
filename = Path(final["uri"]).name
# 基础名回退 subtitle、标识回退别名 result。
assert filename.startswith("subtitle.result.")
assert filename.endswith(".txt")
def test_execute_run_merges_param_overrides(tmp_path) -> None:
"""验证调度执行时把 param_overrides 合并进节点参数。"""
db = _db(tmp_path)
input_file = tmp_path / "input.txt"
input_file.write_text("x", encoding="utf-8")
captured = {}
def recording_handler(request):
captured["params"] = dict(request.params)
return InvokeResponse(status="completed", outputs={"text": "ok"})
registry.register(
NodeManifest(
id="record-node",
name="Record",
version="1",
capability="echo",
repo_dir="nodes",
command=["python", "-m", "record"],
),
recording_handler,
)
definition = WorkflowDefinition(
name="ov-flow",
version=1,
nodes=[WorkflowNode(id="step", node_type="record-node", params={"base": 1})],
entry_inputs={"video_uri": "file"},
)
db.upsert_workflow({"id": "flow", "name": "F", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, definition.to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_ov",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"param_overrides": {"step": {"crop": [0, 0.5, 1, 0.5]}},
"input_uri": str(input_file),
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_ov")
assert captured["params"] == {"base": 1, "crop": [0, 0.5, 1, 0.5]}
def _two_node_definition() -> WorkflowDefinition:
"""构造 a→b 两节点工作流:b 引用 a 的输出。"""
return WorkflowDefinition(
name="two-flow",
version=1,
nodes=[
WorkflowNode(id="a", node_type="x", inputs={"video_uri": "input.video_uri"}),
WorkflowNode(id="b", node_type="x", inputs={"data_uri": "a.data_uri"}),
],
edges=[WorkflowEdge(from_node="a", to_node="b")],
entry_inputs={"video_uri": "file"},
final_outputs={"result": "b.data_uri"},
)
def test_execute_pause_between_nodes_and_resume(tmp_path, monkeypatch) -> None:
"""验证运行中暂停:节点边界停下保持 PAUSED;续跑时跳过已完成节点。"""
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, _two_node_definition().to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_pause",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(tmp_path / "in.txt"),
"created_at": now,
"updated_at": now,
}
)
data_file = tmp_path / "data.bin"
data_file.write_bytes(b"x")
calls: list[str] = []
def fake_invoke(node_type, request):
# registry.invoke 首参是 node_type;用产物目录名(steps/<node_id>)识别节点。
node_id = Path(request.output_dir).name
calls.append(node_id)
# 第一个节点完成后立刻暂停任务,模拟用户在运行中点暂停。
if node_id == "a":
db.pause_run("run_pause", now)
return InvokeResponse(status="completed", outputs={"data_uri": str(data_file)})
monkeypatch.setattr(registry, "invoke", fake_invoke)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_pause")
assert db.get_run("run_pause")["status"] == "PAUSED"
assert calls == ["a"] # 节点 b 未执行。
# 继续:恢复排队并再次执行,节点 a 已产出结果应被跳过,只执行 b。
db.resume_run("run_pause", now)
scheduler.execute_run("run_pause")
assert db.get_run("run_pause")["status"] == "COMPLETED"
assert calls == ["a", "b"]
artifacts = db.list_artifacts("run_pause")
assert {item["name"] for item in artifacts} == {"a.data_uri", "b.data_uri", "result"}
def test_execute_paused_run_not_run(tmp_path, monkeypatch) -> None:
"""验证非可执行状态(如 RUNNING 之外的值)的任务不会被执行。"""
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, _two_node_definition().to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_done",
"workflow_id": "flow",
"workflow_version": 1,
"status": "COMPLETED",
"progress": 1.0,
"created_at": now,
"updated_at": now,
}
)
called = []
def fake_invoke(node_id, request):
called.append(node_id)
return InvokeResponse(status="completed", outputs={})
monkeypatch.setattr(registry, "invoke", fake_invoke)
WorkflowScheduler(db, tmp_path / "storage").execute_run("run_done")
assert called == []
def test_execute_pause_after_last_node_keeps_paused(tmp_path, monkeypatch) -> None:
"""验证全部节点完成但运行中被暂停时保持 PAUSED;续跑补做收尾后完成。"""
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, _two_node_definition().to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_tail",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(tmp_path / "in.txt"),
"created_at": now,
"updated_at": now,
}
)
data_file = tmp_path / "data.bin"
data_file.write_bytes(b"x")
calls: list[str] = []
def fake_invoke(node_type, request):
node_id = Path(request.output_dir).name
calls.append(node_id)
if node_id == "b": # 最后一个节点执行时暂停。
db.pause_run("run_tail", now)
return InvokeResponse(status="completed", outputs={"data_uri": str(data_file)})
monkeypatch.setattr(registry, "invoke", fake_invoke)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_tail")
# 全部节点已执行,但收尾前被暂停 → 保持 PAUSED 而不是 COMPLETED。
assert db.get_run("run_tail")["status"] == "PAUSED"
assert calls == ["a", "b"]
# 续跑:节点产物齐备全部跳过,补做收尾后完成。
db.resume_run("run_tail", now)
scheduler.execute_run("run_tail")
assert db.get_run("run_tail")["status"] == "COMPLETED"
assert calls == ["a", "b"]
+108
View File
@@ -0,0 +1,108 @@
"""种子数据测试。
验证从 workflows/*.json 数据文件加载默认工作流、幂等性,以及
开启种子与调度器后的应用生命周期。工作流定义来自数据文件而非代码。
"""
from pathlib import Path
from fastapi.testclient import TestClient
from wov_app.db import Database
from wov_app.main import app
from wov_app.seed import seed_default_workflows
# 单体根目录:tests/ 的上一级。
WORKSPACE = Path(__file__).resolve().parent.parent
def test_seed_default_workflows_idempotent(tmp_path) -> None:
"""验证从数据文件加载 demo/zh-direct 两个工作流且重复调用幂等。"""
db = Database(tmp_path / "wov.db")
created = seed_default_workflows(db)
assert created == 3
assert db.get_workflow("demo") is not None
assert db.get_workflow("zh-direct") is not None
# demoasr 显式声明 model_path 与长音频参数,模型选择完全数据化。
demo = db.get_latest_workflow_version("demo")["definition"]
assert demo["name"] == "视频字幕生成"
demo_asr = next(node for node in demo["nodes"] if node["id"] == "asr")
assert demo_asr["params"]["model_path"] == "faster-whisper-large-v3"
assert demo_asr["params"]["condition_on_previous_text"] is False
# zh-direct:使用中文直出模型并开启翻译任务。
zh = db.get_latest_workflow_version("zh-direct")["definition"]
assert zh["name"] == "中文直出字幕"
zh_asr = next(node for node in zh["nodes"] if node["id"] == "asr")
assert zh_asr["params"]["model_path"] == "whisper-large-v2-translate-zh-v0.2-st-ct2"
assert zh_asr["params"]["task"] == "translate"
# 再次调用不重复创建。
assert seed_default_workflows(db) == 0
assert len(db.list_workflow_versions("demo")) == 1
def test_seed_custom_dir_and_empty(tmp_path) -> None:
"""验证自定义数据目录的加载与空目录返回 0。"""
db = Database(tmp_path / "wov.db")
custom = tmp_path / "workflows"
custom.mkdir()
(custom / "a.json").write_text(
"""
{
"id": "flow-a",
"name": "Flow A",
"description": "custom",
"version": 1,
"definition": {
"name": "Flow A",
"version": 1,
"nodes": [{"id": "step", "node_type": "echo"}],
"edges": [],
"entry_inputs": {},
"final_outputs": {}
}
}
""",
encoding="utf-8",
)
assert seed_default_workflows(db, custom) == 1
assert db.get_workflow("flow-a") is not None
# 空目录返回 0。
empty = tmp_path / "empty"
empty.mkdir()
assert seed_default_workflows(db, empty) == 0
# 已存在的工作流被跳过。
assert seed_default_workflows(db, custom) == 0
def test_lifespan_with_seed_and_scheduler(monkeypatch) -> None:
"""验证启用自动种子与调度器后应用正常启动,demo 与中文直出应用均可见。"""
monkeypatch.setenv("WOV_AUTO_SEED", "1")
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "1")
with TestClient(app) as client:
apps = client.get("/api/apps")
assert apps.status_code == 200
assert any(item["id"] == "demo" for item in apps.json())
assert any(item["id"] == "zh-direct" for item in apps.json())
def test_seed_workflows_have_chunk_seconds(tmp_path) -> None:
"""验证内置工作流的 asr 节点均显式声明分块参数。"""
db = Database(tmp_path / "wov.db")
seed_default_workflows(db)
for workflow_id in ("demo", "zh-direct"):
definition = db.get_latest_workflow_version(workflow_id)["definition"]
asr = next(node for node in definition["nodes"] if node["id"] == "asr")
assert asr["params"]["chunk_seconds"] == 60
def test_seed_workflows_vad_filter_off(tmp_path) -> None:
"""验证内置工作流 asr 显式开启 VAD。"""
db = Database(tmp_path / "wov.db")
seed_default_workflows(db)
for workflow_id in ("demo", "zh-direct"):
definition = db.get_latest_workflow_version(workflow_id)["definition"]
asr = next(node for node in definition["nodes"] if node["id"] == "asr")
assert asr["params"]["vad_filter"] is True
+34
View File
@@ -0,0 +1,34 @@
"""uvicorn 冒烟测试。
用真实套接字启动 uvicorn 服务并请求 /health,验证应用能脱离 TestClient
在实际 Web 服务环境中正常工作。
"""
import threading
import time
import urllib.request
from uvicorn import Config, Server
from wov_app.main import app
def test_uvicorn_serves_app_over_real_socket() -> None:
"""验证 uvicorn 监听真实端口后健康检查可用。"""
config = Config(app=app, host="127.0.0.1", port=0, log_level="error")
server = Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
try:
deadline = time.monotonic() + 10
while not server.started and time.monotonic() < deadline:
time.sleep(0.05)
assert server.started
port = server.servers[0].sockets[0].getsockname()[1]
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as response:
assert response.status == 200
assert b'"wov-api"' in response.read()
finally:
server.should_exit = True
thread.join(timeout=10)
+117
View File
@@ -0,0 +1,117 @@
"""工作流管理 API 测试。
覆盖工作流的创建、查询、校验、发布、版本列表与删除等管理接口。
"""
from fastapi.testclient import TestClient
from wov_app.main import app
def definition() -> dict:
"""构造一个引用 Echo 节点的合法工作流定义。"""
return {
"name": "echo-flow",
"version": 1,
"nodes": [
{
"id": "step",
"node_type": "echo",
"inputs": {"file_uri": "input.video_uri"},
}
],
"edges": [],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "step.file_uri"},
}
def test_workflow_crud_and_publish() -> None:
"""验证工作流 CRUD、校验、发布与版本列表的完整流程。"""
with TestClient(app) as client:
created = client.post(
"/api/admin/workflows",
json={
"id": "echo-flow",
"name": "Echo Flow",
"description": "demo",
"definition": definition(),
},
)
assert created.status_code == 200
assert created.json()["id"] == "echo-flow"
assert client.get("/api/admin/workflows").status_code == 200
assert client.get("/api/admin/workflows/echo-flow").status_code == 200
assert client.get("/api/admin/workflows/missing").status_code == 404
validated = client.post(
"/api/admin/workflows/echo-flow/validate",
json=definition(),
)
assert validated.status_code == 200
assert validated.json()["valid"] is True
assert client.post(
"/api/admin/workflows/missing/validate",
json=definition(),
).status_code == 404
published = client.post("/api/admin/workflows/echo-flow/publish")
assert published.status_code == 200
assert published.json()["published"] == "echo-flow"
assert client.post("/api/admin/workflows/missing/publish").status_code == 404
versions = client.get("/api/admin/workflows/echo-flow/versions")
assert versions.status_code == 200
assert len(versions.json()) == 1
assert client.get("/api/admin/workflows/missing/versions").status_code == 404
assert client.delete("/api/admin/workflows/echo-flow").status_code == 200
assert client.delete("/api/admin/workflows/echo-flow").status_code == 404
def test_workflow_slug_without_id() -> None:
"""验证未提供 ID 时后端会从名称生成 slug。"""
with TestClient(app) as client:
created = client.post(
"/api/admin/workflows",
json={
"name": "Echo Flow",
"definition": definition(),
},
)
assert created.status_code == 200
assert created.json()["id"] == "echo-flow"
def test_workflow_validation_error() -> None:
"""验证重复节点 ID 的 DAG 会被拒绝。"""
with TestClient(app) as client:
response = client.post(
"/api/admin/workflows",
json={
"id": "bad",
"name": "Bad",
"definition": {
"name": "Bad",
"version": 1,
"nodes": [
{"id": "a", "node_type": "x"},
{"id": "a", "node_type": "y"},
],
"edges": [],
},
},
)
assert response.status_code == 422
def test_publish_workflow_without_version() -> None:
"""验证没有版本记录的工作流不能发布。"""
with TestClient(app) as client:
db = app.state.db
db.upsert_workflow(
{"id": "empty", "name": "Empty", "published": 0, "latest_version": 0}
)
response = client.post("/api/admin/workflows/empty/publish")
assert response.status_code == 422