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:
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 全部断言通过");
|
||||
@@ -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") == {}
|
||||
@@ -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
|
||||
@@ -0,0 +1,90 @@
|
||||
"""字幕 OCR 整链真实集成测试。
|
||||
|
||||
使用 testdata/subtitle_10s.mp4(烧录 SUB 001@1-4s、SUB 002@6-9s)与真实
|
||||
glm-ocr 模型:抽帧(frame-extract)→ 逐帧 OCR(subtitle-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-4s,SUB 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
Executable
+174
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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.5s:25fps 下 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-ocr:OCR 循环 + 长度上限 + 合并 + SRT 组装
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _frames_manifest(tmp_path, frame_specs) -> Path:
|
||||
"""构造真实 frames.json;frame_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/2s,4s 为空帧,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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
# demo:asr 显式声明 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user