批量引擎改为「分块流水线」:视频按 WOV_BATCH_STAGE_GROUP_SIZE(默认 8)分组, 组内按 DAG 拓扑序跑完全部视频(全部 extract → 全部 ASR → 全部翻译 → 全部 ASS) 再进入下一组,本地模型每组只加载一次、卸载一次,而不是每个视频来回加载卸载; 产物仍按组增量落到视频旁。调度器新增 execute_run(run_id, stop_after=节点): 该节点完成后任务保持 RUNNING 不收尾,下一次调用从产物表跳过已完成节点继续, 用于实现阶段边界。 - nodes/llm.py:翻译节点结束释放本机 Ollama 显存(node 参数 unload_after > LLM_UNLOAD_AFTER > 本机 loopback 端点默认卸载,云端端点不卸载;卸载失败只告警), 新增 keep_model.flag 语义(阶段内保持常驻)与 release_local_model(); 新增节点内暂停(按批 20 行检查 paused.flag,抛 PauseRequested,调度器保持 PAUSED)。 - src/wov_app/batch.py:分组阶段执行与阶段末统一释放显存;失败视频只在它失败 节点的那个阶段重试(避免 LLM 已常驻时重跑 ASR 抢显存);任务没有明细时保持 QUEUED 等登记完成、仍有未完成视频时置回 QUEUED 自愈(原先留 RUNNING 会卡死: 引擎只拾取 QUEUED,任务停在“运行中但没人推进”);无失败视频时删除任务级空目录; 每个阶段开始前清理 paused.flag / keep_model.flag,避免强杀残留影响后续阶段。 - src/wov_app/config.py:新增 WOV_BATCH_STAGE_GROUP_SIZE(设为 1 即旧的每视频全链路)。 - 任务列表与批量页分工:GET /api/runs 默认排除 source=batch(一个批量任务会产生 N 条单视频 run,会把 20 条窗口占满;且任务管理页的暂停/重试/删除对批量 run 语义不成立),需要排查时用 include_batch=1;作为补偿批量页详情新增阶段列 (阶段 i/N · 中文标签,由该视频 run 的 current_node_id 在 DAG 拓扑序中的位置 推导,节点类型映射中文标签)。阶段只有节点边界粒度,句级进度不落库、只在日志。 - 顺带纳入此前未提交的批量僵尸状态恢复:recover_interrupted_batch_jobs 除 RUNNING 外也把「COMPLETED 但仍含未结束视频」的任务置回 QUEUED;fix_zombie_batch_jobs.py 改为按条件扫描并支持 --apply 预览;批量页明细只列本批真正处理过的视频。 测试新增/更新:分块流水线调用顺序(组内按节点跑完再下一组)、每组只释放一次模型、 阶段内保持常驻标志、翻译按批暂停、失败视频不跨阶段推进、任务无明细/中途登记视频时 置回 QUEUED、任务工作空间与残留信号清理、任务列表默认过滤批量 run、详情阶段字段、 前端阶段列渲染;全量 507 passed(唯一失败为既有素材缺失的 integration 用例)。
341 lines
13 KiB
Python
341 lines
13 KiB
Python
"""src/wov_app/routers/apps.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
|
|
|
被测模块:`src/wov_app/routers/apps.py`(用户端 API:应用列表、上传建任务、
|
|
进度查询、产物下载、重试/暂停/继续/删除),可独立调用。用例通过真实
|
|
FastAPI TestClient 走完整 HTTP 链路,数据库为临时 SQLite。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from wov_app import registry
|
|
from wov_app.db import Database
|
|
from wov_app.main import app as fastapi_app
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_registry():
|
|
"""保存/恢复注册表(模块自带隔离)。"""
|
|
snapshot = dict(registry._registry)
|
|
yield
|
|
registry._registry.clear()
|
|
registry._registry.update(snapshot)
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
|
"""构造隔离存储与库的真实 TestClient。
|
|
|
|
`wov_app.config` 在导入时锁定路径常量,因此这里同时替换 main 与 config
|
|
的 STORAGE_DIR(路由在函数内 `from wov_app.config import STORAGE_DIR`),
|
|
保证测试不写入真实 data/。
|
|
"""
|
|
storage = tmp_path / "storage"
|
|
monkeypatch.setattr("wov_app.config.STORAGE_DIR", storage)
|
|
monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db")
|
|
monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db")
|
|
monkeypatch.setattr("wov_app.main.STORAGE_DIR", storage)
|
|
monkeypatch.setenv("WOV_AUTO_SEED", "0")
|
|
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0")
|
|
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0")
|
|
monkeypatch.setenv("WOV_BATCH_ENABLED", "0")
|
|
# 生命周期用 main.DB_PATH 建库;库文件路径与断言用的库保持一致。
|
|
with TestClient(fastapi_app) as test_client:
|
|
yield test_client
|
|
|
|
|
|
def _publish_echo_app(client: TestClient, app_id: str = "echo-app") -> 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": app_id, "name": "Echo App", "description": "upload a file", "definition": definition,
|
|
})
|
|
client.post(f"/api/admin/workflows/{app_id}/publish")
|
|
return app_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 应用列表与创建任务
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_list_apps_returns_only_published(client: TestClient) -> None:
|
|
"""应用列表只返回已发布工作流(用户看不到草稿)。"""
|
|
# 数据:一个草稿 + 一个已发布。
|
|
client.post("/api/admin/workflows", json={
|
|
"id": "draft", "name": "草稿", "description": "",
|
|
"definition": {"name": "d", "version": 1, "nodes": [], "edges": []},
|
|
})
|
|
_publish_echo_app(client)
|
|
|
|
# 测试过程
|
|
response = client.get("/api/apps")
|
|
|
|
# 验证结果
|
|
assert response.status_code == 200
|
|
ids = {item["id"] for item in response.json()}
|
|
assert ids == {"echo-app"}
|
|
|
|
|
|
def test_create_run_accepts_upload_and_persists_params(client: TestClient) -> None:
|
|
"""上传视频创建任务:返回 run_id,参数覆盖被持久化。"""
|
|
# 数据:已发布应用 + 真实上传文件 + 裁剪参数覆盖。
|
|
app_id = _publish_echo_app(client)
|
|
|
|
# 测试过程
|
|
response = client.post(
|
|
f"/api/apps/{app_id}/runs",
|
|
files={"file": ("movie.mp4", b"fake video bytes", "video/mp4")},
|
|
data={"params": '{"step": {"interval_seconds": 3}}'},
|
|
)
|
|
|
|
# 验证结果:任务创建成功且参数被保存。
|
|
assert response.status_code == 200, response.text
|
|
run_id = response.json()["id"]
|
|
stored = client.get(f"/api/runs/{run_id}").json()
|
|
assert stored["param_overrides"] == {"step": {"interval_seconds": 3}}
|
|
assert stored["status"] in ("QUEUED", "RUNNING", "COMPLETED")
|
|
|
|
|
|
def test_create_run_rejects_unpublished_app(client: TestClient) -> None:
|
|
"""未发布应用不接单(404)。"""
|
|
# 数据:只创建不发布。
|
|
client.post("/api/admin/workflows", json={
|
|
"id": "draft-app", "name": "草稿", "description": "",
|
|
"definition": {"name": "d", "version": 1, "nodes": [], "edges": []},
|
|
})
|
|
|
|
# 测试过程
|
|
response = client.post(
|
|
"/api/apps/draft-app/runs",
|
|
files={"file": ("a.mp4", b"x", "video/mp4")},
|
|
)
|
|
|
|
# 验证结果
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_create_run_rejects_unknown_app(client: TestClient) -> None:
|
|
"""不存在的应用返回 404。"""
|
|
# 数据:未注册的应用 ID。
|
|
# 测试过程
|
|
response = client.post(
|
|
"/api/apps/nope/runs", files={"file": ("a.mp4", b"x", "video/mp4")},
|
|
)
|
|
|
|
# 验证结果
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_create_run_rejects_invalid_params_json(client: TestClient) -> None:
|
|
"""params 不是合法 JSON 时返回 422(参数错误不产生任务)。"""
|
|
# 数据:已发布应用 + 非法 params。
|
|
app_id = _publish_echo_app(client)
|
|
|
|
# 测试过程
|
|
response = client.post(
|
|
f"/api/apps/{app_id}/runs",
|
|
files={"file": ("a.mp4", b"x", "video/mp4")},
|
|
data={"params": "{not json"},
|
|
)
|
|
|
|
# 验证结果
|
|
assert response.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 任务查询、暂停、继续、重试、删除
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _create_run(client: TestClient) -> str:
|
|
"""创建一条任务并返回 run_id(测试辅助)。"""
|
|
app_id = _publish_echo_app(client)
|
|
response = client.post(
|
|
f"/api/apps/{app_id}/runs", files={"file": ("m.mp4", b"data", "video/mp4")},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
return response.json()["id"]
|
|
|
|
|
|
def test_list_and_get_runs(client: TestClient) -> None:
|
|
"""任务列表与详情可读(详情含状态与进度)。"""
|
|
# 数据:一条任务。
|
|
run_id = _create_run(client)
|
|
|
|
# 测试过程
|
|
listed = client.get("/api/runs").json()
|
|
detail = client.get(f"/api/runs/{run_id}")
|
|
|
|
# 验证结果
|
|
assert any(item["id"] == run_id for item in listed)
|
|
assert detail.status_code == 200
|
|
assert {"status", "progress", "id"} <= set(detail.json())
|
|
|
|
|
|
def test_get_missing_run_returns_404(client: TestClient) -> None:
|
|
"""不存在的任务返回 404。"""
|
|
# 数据:未创建的任务 ID。
|
|
# 测试过程与验证结果
|
|
assert client.get("/api/runs/nope").status_code == 404
|
|
|
|
|
|
def test_list_runs_excludes_batch_runs_by_default(client: TestClient, tmp_path: Path) -> None:
|
|
"""批量 run 不进任务管理默认列表(它们是批量任务明细,由批量页展示)。"""
|
|
# 数据:一条上传任务 + 一条同工作流的批量 run。
|
|
upload_id = _create_run(client)
|
|
db = Database(tmp_path / "wov.db")
|
|
db.create_run({
|
|
"id": "run_batch_listed", "workflow_id": "echo-app", "workflow_version": 1,
|
|
"status": "RUNNING", "current_node_id": "step", "progress": 0.0, "error": None,
|
|
"input_uri": "/videos/movie.mp4", "param_overrides": None, "source": "batch",
|
|
"created_at": "2026-09-09T00:00:00+00:00", "updated_at": "2026-09-09T00:00:00+00:00",
|
|
})
|
|
|
|
# 测试过程
|
|
default_ids = [item["id"] for item in client.get("/api/runs").json()]
|
|
all_ids = [item["id"] for item in client.get("/api/runs", params={"include_batch": 1}).json()]
|
|
|
|
# 验证结果:默认列表只有上传任务,显式请求时包含批量 run。
|
|
assert default_ids == [upload_id]
|
|
assert set(all_ids) == {upload_id, "run_batch_listed"}
|
|
|
|
|
|
def test_pause_and_resume_run(client: TestClient) -> None:
|
|
"""暂停置 PAUSED、继续置 QUEUED,并写入/清除暂停信号文件。"""
|
|
# 数据:一条任务。
|
|
run_id = _create_run(client)
|
|
|
|
# 测试过程
|
|
paused = client.post(f"/api/runs/{run_id}/pause")
|
|
paused_status = client.get(f"/api/runs/{run_id}").json()["status"]
|
|
resumed = client.post(f"/api/runs/{run_id}/resume")
|
|
resumed_status = client.get(f"/api/runs/{run_id}").json()["status"]
|
|
|
|
# 验证结果
|
|
assert paused.status_code == 200 and resumed.status_code == 200
|
|
assert paused_status == "PAUSED"
|
|
assert resumed_status == "QUEUED"
|
|
|
|
|
|
def test_resume_rejects_non_paused_run(client: TestClient) -> None:
|
|
"""非 PAUSED 状态不能继续(409/422 语义,避免误触发执行)。"""
|
|
# 数据:一条刚创建的任务(QUEUED)。
|
|
run_id = _create_run(client)
|
|
|
|
# 测试过程
|
|
response = client.post(f"/api/runs/{run_id}/resume")
|
|
|
|
# 验证结果:不是 200 成功。
|
|
assert response.status_code != 200
|
|
|
|
|
|
def test_retry_requeues_failed_run(client: TestClient) -> None:
|
|
"""FAILED 任务可重试:状态回到 QUEUED 并清空错误。"""
|
|
# 数据:手动把任务置为 FAILED。
|
|
run_id = _create_run(client)
|
|
db: Database = client.app.state.db
|
|
db.update_run(run_id, status="FAILED", error="boom", updated_at="2026-09-01T00:00:00+00:00")
|
|
|
|
# 测试过程
|
|
response = client.post(f"/api/runs/{run_id}/retry")
|
|
|
|
# 验证结果
|
|
assert response.status_code == 200
|
|
stored = client.get(f"/api/runs/{run_id}").json()
|
|
assert stored["status"] == "QUEUED"
|
|
assert stored["error"] is None
|
|
|
|
|
|
def test_delete_run_removes_record_and_files(client: TestClient) -> None:
|
|
"""删除任务清理记录与私有上传/产物目录(不动外部路径)。"""
|
|
# 数据:一条任务。
|
|
run_id = _create_run(client)
|
|
db: Database = client.app.state.db
|
|
|
|
# 测试过程
|
|
response = client.delete(f"/api/runs/{run_id}")
|
|
|
|
# 验证结果:记录消失,再次查询 404。
|
|
assert response.status_code == 200
|
|
assert db.get_run(run_id) is None
|
|
assert client.get(f"/api/runs/{run_id}").status_code == 404
|
|
|
|
|
|
def test_delete_rejects_batch_source_run(client: TestClient) -> None:
|
|
"""source=batch 的任务拒绝普通删除(R01:避免误删用户视频目录)。"""
|
|
# 数据:一条 batch 来源任务(source 不在 update_run 白名单,直连库改)。
|
|
run_id = _create_run(client)
|
|
db: Database = client.app.state.db
|
|
with db._connect() as conn:
|
|
conn.execute("UPDATE workflow_runs SET source='batch' WHERE id = ?", (run_id,))
|
|
|
|
# 测试过程
|
|
response = client.delete(f"/api/runs/{run_id}")
|
|
|
|
# 验证结果:被拒绝(422),记录仍存在。
|
|
assert response.status_code == 422
|
|
assert db.get_run(run_id) is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 产物查询与下载
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_list_artifacts_and_download(client: TestClient, tmp_path: Path) -> None:
|
|
"""产物列表可查,产物内容可按名下载。"""
|
|
# 数据:任务 + 真实产物文件 + 产物记录。
|
|
run_id = _create_run(client)
|
|
db: Database = client.app.state.db
|
|
payload = tmp_path / "out.srt"
|
|
payload.write_text("1\n00:00:01,000 --> 00:00:02,000\n你好\n", encoding="utf-8")
|
|
db.create_artifact({
|
|
"run_id": run_id, "node_id": "step", "name": "result",
|
|
"uri": str(payload), "mime_type": "application/x-subrip", "size": payload.stat().st_size,
|
|
})
|
|
|
|
# 测试过程
|
|
listed = client.get(f"/api/runs/{run_id}/artifacts")
|
|
downloaded = client.get(f"/api/runs/{run_id}/artifacts/result")
|
|
|
|
# 验证结果
|
|
assert listed.status_code == 200
|
|
assert any(item["name"] == "result" for item in listed.json())
|
|
assert downloaded.status_code == 200
|
|
assert "你好" in downloaded.content.decode("utf-8")
|
|
|
|
|
|
def test_download_missing_artifact_returns_404(client: TestClient) -> None:
|
|
"""下载不存在的产物返回 404。"""
|
|
# 数据:一条任务,无该产物。
|
|
run_id = _create_run(client)
|
|
|
|
# 测试过程与验证结果
|
|
assert client.get(f"/api/runs/{run_id}/artifacts/nope").status_code == 404
|
|
|
|
|
|
def test_download_missing_file_returns_404(client: TestClient) -> None:
|
|
"""产物记录存在但文件已被删除时返回 404(不返回残缺内容)。"""
|
|
# 数据:产物记录指向不存在的文件。
|
|
run_id = _create_run(client)
|
|
db: Database = client.app.state.db
|
|
db.create_artifact({
|
|
"run_id": run_id, "node_id": "step", "name": "gone",
|
|
"uri": "/nonexistent/file.srt", "mime_type": "application/x-subrip", "size": 0,
|
|
})
|
|
|
|
# 测试过程与验证结果
|
|
assert client.get(f"/api/runs/{run_id}/artifacts/gone").status_code == 404
|