test: 按模块重写测试代码,删除旧平铺结构

按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、
不保留全局 conftest.py、测试过程只调用真实生产代码。

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

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

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

真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py
(运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py
(无 Key / 余额 / 限流转跳过)。全量 477 passed。
This commit is contained in:
2026-09-13 15:40:56 +08:00
parent 966f3e6b4b
commit 8a715a8064
139 changed files with 20810 additions and 10733 deletions
+310
View File
@@ -0,0 +1,310 @@
"""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