Files
vrsub/tests/app/test_routers/test_batch_api.py
T
cat-shark 8a715a8064 test: 按模块重写测试代码,删除旧平铺结构
按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、
不保留全局 conftest.py、测试过程只调用真实生产代码。

结构(73 个文件、30 个模块目录、477 用例):
- tests/nodes/  15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/
  subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/
  proper_nouns/adaptive_pool/vad_profiler/echo);
- tests/app/    11 个模块目录(db/scheduler/batch/maintenance/registry/seed/
  storage/config/logging/main/routers 三组 API);
- tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。

测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore
的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。

顺带发现并修复三个真实缺陷:
- nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位),
  改为正文行遇时间戳行即报错;
- src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI
  抛 ValueError 导致任务误判失败,改为同时捕获;
- nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT,
  改用生产模块 nodes/srt.py。

真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py
(运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py
(无 Key / 余额 / 限流转跳过)。全量 477 passed。
2026-09-13 15:40:56 +08:00

311 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""src/wov_app/routers/batch.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`src/wov_app/routers/batch.py`(批量处理 API:目录浏览、任务创建
与列表、暂停/继续、删除、产物下载),可独立调用。用例通过真实 TestClient
文件夹为临时目录下的真实视频文件。
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from wov_app import registry
from wov_app.main import app as fastapi_app
from wov_sdk.models import WorkflowDefinition
@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。"""
monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db")
monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage")
monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db")
monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "storage")
# 批量工作空间也指向临时目录(模块常量在导入时绑定)。
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
monkeypatch.setenv("WOV_AUTO_SEED", "0")
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0")
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0")
monkeypatch.setenv("WOV_BATCH_ENABLED", "0")
with TestClient(fastapi_app) as test_client:
yield test_client
def _make_video(path: Path) -> Path:
"""创建真实可读的视频文件。"""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 32)
return path
def _publish_workflow(client: TestClient, workflow_id: str = "wf") -> None:
"""创建并发布单 echo 节点工作流。"""
definition = WorkflowDefinition.from_dict({
"name": "批量流程", "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"},
}).to_dict()
client.post("/api/admin/workflows", json={
"id": workflow_id, "name": "批量流程", "description": "", "definition": definition,
})
client.post(f"/api/admin/workflows/{workflow_id}/publish")
# ---------------------------------------------------------------------------
# 目录浏览(本地后端提供,浏览器拿不到绝对路径)
# ---------------------------------------------------------------------------
def test_list_roots_returns_browsable_roots(client: TestClient) -> None:
"""根目录列表返回可浏览位置(POSIX 返回 / 与家目录)。"""
# 数据:无(真实文件系统)。
# 测试过程
response = client.get("/api/batch/roots")
# 验证结果:非空且每项含 path/name。
assert response.status_code == 200
roots = response.json()
assert roots, "至少应返回一个可浏览根目录"
assert all({"path", "name"} <= set(item) for item in roots)
def test_list_dirs_returns_only_directories(client: TestClient, tmp_path: Path) -> None:
"""列目录只返回直接子目录,且过滤隐藏目录。"""
# 数据:两个子目录 + 一个隐藏目录 + 一个文件。
folder = tmp_path / "root"
(folder / "sub1").mkdir(parents=True)
(folder / "sub2").mkdir()
(folder / ".hidden").mkdir()
(folder / "file.txt").write_text("x", encoding="utf-8")
# 测试过程
body = client.get("/api/batch/dirs", params={"path": str(folder)}).json()
# 验证结果
names = {item["name"] for item in body["dirs"]}
assert names == {"sub1", "sub2"}
def test_list_dirs_returns_empty_for_missing_path(client: TestClient, tmp_path: Path) -> None:
"""目录不存在时返回空列表而非 500(前端树保持可用)。"""
# 数据:不存在的路径。
# 测试过程
response = client.get("/api/batch/dirs", params={"path": str(tmp_path / "nope")})
# 验证结果
assert response.status_code == 200
assert response.json()["dirs"] == []
# ---------------------------------------------------------------------------
# 创建与查询批量任务
# ---------------------------------------------------------------------------
def test_create_job_returns_job_with_videos(client: TestClient, tmp_path: Path) -> None:
"""创建批量任务:返回任务 ID、总数与明细列表(含跳过项)。"""
# 数据:两个视频,其中一个已有旁挂字幕。
folder = tmp_path / "videos"
_make_video(folder / "a.mp4")
_make_video(folder / "b.mp4")
(folder / "b.CN.srt").write_text("1\n", encoding="utf-8")
_publish_workflow(client)
# 测试过程
response = client.post("/api/batch/jobs", json={
"folder": str(folder), "workflow_id": "wf", "recursive": False,
})
# 验证结果
assert response.status_code == 200, response.text
body = response.json()
assert body["total"] == 1
assert len(body["videos"]) == 2
def test_create_job_rejects_missing_folder(client: TestClient, tmp_path: Path) -> None:
"""文件夹不存在返回 422。"""
# 数据:未发布的目录路径。
_publish_workflow(client)
# 测试过程
response = client.post("/api/batch/jobs", json={
"folder": str(tmp_path / "nope"), "workflow_id": "wf", "recursive": False,
})
# 验证结果
assert response.status_code == 422
def test_create_job_rejects_unpublished_workflow(client: TestClient, tmp_path: Path) -> None:
"""工作流未发布返回 422。"""
# 数据:有视频但工作流未发布。
folder = tmp_path / "videos"
_make_video(folder / "a.mp4")
# 测试过程
response = client.post("/api/batch/jobs", json={
"folder": str(folder), "workflow_id": "wf", "recursive": False,
})
# 验证结果
assert response.status_code == 422
def test_create_job_rejects_folder_without_videos(client: TestClient, tmp_path: Path) -> None:
"""文件夹内无视频返回 422。"""
# 数据:空文件夹 + 已发布工作流。
folder = tmp_path / "videos"
folder.mkdir()
_publish_workflow(client)
# 测试过程
response = client.post("/api/batch/jobs", json={
"folder": str(folder), "workflow_id": "wf", "recursive": False,
})
# 验证结果
assert response.status_code == 422
def test_list_and_get_job(client: TestClient, tmp_path: Path) -> None:
"""任务列表与详情可读,详情含视频明细。"""
# 数据:一条批量任务。
folder = tmp_path / "videos"
_make_video(folder / "a.mp4")
_publish_workflow(client)
job_id = client.post("/api/batch/jobs", json={
"folder": str(folder), "workflow_id": "wf", "recursive": False,
}).json()["id"]
# 测试过程
listed = client.get("/api/batch/jobs").json()
detail = client.get(f"/api/batch/jobs/{job_id}")
# 验证结果
assert any(item["id"] == job_id for item in listed)
assert detail.status_code == 200
assert detail.json()["videos"][0]["video_path"].endswith("a.mp4")
def test_get_missing_job_returns_404(client: TestClient) -> None:
"""不存在的批量任务返回 404。"""
# 数据:未创建。
# 测试过程与验证结果
assert client.get("/api/batch/jobs/nope").status_code == 404
# ---------------------------------------------------------------------------
# 暂停 / 继续 / 删除
# ---------------------------------------------------------------------------
def test_pause_and_resume_job(client: TestClient, tmp_path: Path) -> None:
"""暂停置 PAUSED、继续置 QUEUED。"""
# 数据:一条待处理批量任务。
folder = tmp_path / "videos"
_make_video(folder / "a.mp4")
_publish_workflow(client)
job_id = client.post("/api/batch/jobs", json={
"folder": str(folder), "workflow_id": "wf", "recursive": False,
}).json()["id"]
# 测试过程
paused = client.post(f"/api/batch/jobs/{job_id}/pause")
after_pause = client.get(f"/api/batch/jobs/{job_id}").json()["status"]
resumed = client.post(f"/api/batch/jobs/{job_id}/resume")
after_resume = client.get(f"/api/batch/jobs/{job_id}").json()["status"]
# 验证结果
assert paused.status_code == 200 and resumed.status_code == 200
assert after_pause == "PAUSED"
assert after_resume == "QUEUED"
def test_delete_job_removes_records_and_keeps_media(client: TestClient, tmp_path: Path) -> None:
"""删除任务清理记录与私有工作空间,保留用户视频。"""
# 数据:一条批量任务。
folder = tmp_path / "videos"
video = _make_video(folder / "a.mp4")
_publish_workflow(client)
job_id = client.post("/api/batch/jobs", json={
"folder": str(folder), "workflow_id": "wf", "recursive": False,
}).json()["id"]
# 测试过程
response = client.delete(f"/api/batch/jobs/{job_id}")
# 验证结果:任务消失但视频保留。
assert response.status_code == 200
assert client.get(f"/api/batch/jobs/{job_id}").status_code == 404
assert video.is_file()
def test_delete_missing_job_returns_404(client: TestClient) -> None:
"""删除不存在的任务返回 404。"""
# 数据:未创建。
# 测试过程与验证结果
assert client.delete("/api/batch/jobs/nope").status_code == 404
# ---------------------------------------------------------------------------
# 产物下载
# ---------------------------------------------------------------------------
def test_download_sidecar_product(client: TestClient, tmp_path: Path) -> None:
"""下载接口返回视频旁的产物文件内容。"""
# 数据:一条批量任务 + 视频旁的成品字幕。
folder = tmp_path / "videos"
_make_video(folder / "movie.mp4")
_publish_workflow(client)
body = client.post("/api/batch/jobs", json={
"folder": str(folder), "workflow_id": "wf", "recursive": False,
}).json()
job_id = body["id"]
video_id = body["videos"][0]["id"]
(folder / "movie.CN.srt").write_text("1\n00:00:01,000 --> 00:00:02,000\n你好\n", encoding="utf-8")
# 测试过程
response = client.get(
f"/api/batch/jobs/{job_id}/videos/{video_id}/download",
params={"alias": "movie.CN.srt"},
)
# 验证结果
assert response.status_code == 200
assert "你好" in response.content.decode("utf-8")
def test_download_missing_product_returns_404(client: TestClient, tmp_path: Path) -> None:
"""产物不存在时返回 404。"""
# 数据:一条批量任务(无产物)。
folder = tmp_path / "videos"
_make_video(folder / "movie.mp4")
_publish_workflow(client)
body = client.post("/api/batch/jobs", json={
"folder": str(folder), "workflow_id": "wf", "recursive": False,
}).json()
# 测试过程
response = client.get(
f"/api/batch/jobs/{body['id']}/videos/{body['videos'][0]['id']}/download",
params={"alias": "movie.CN.srt"},
)
# 验证结果
assert response.status_code == 404