1482 lines
67 KiB
Python
1482 lines
67 KiB
Python
"""文件夹批量处理引擎与 API 测试。
|
||
|
||
覆盖:视频扫描与旁挂字幕判定、创建任务时一次性定位(已有字幕 → SKIPPED)、
|
||
运行时只消费已定位明细、产物复制到视频旁与过程文件清理、暂停/继续断点续跑、
|
||
失败视频不中断、批量 API 全端点(创建/列表/详情/暂停/继续/删除/下载/目录树)
|
||
与 404/422 分支。
|
||
"""
|
||
|
||
import json
|
||
import time
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from wov_app import batch as batch_engine
|
||
from wov_app.batch import (
|
||
BATCH_WORK_ROOT,
|
||
MARKER_NAME,
|
||
PAUSE_FLAG,
|
||
BatchWorker,
|
||
create_job,
|
||
list_sidecar_subtitles,
|
||
load_marker,
|
||
remove_job_workspace,
|
||
scan_videos,
|
||
)
|
||
from wov_app.config import STORAGE_DIR
|
||
from wov_app.db import Database
|
||
from wov_app.main import app
|
||
from wov_sdk.models import WorkflowDefinition
|
||
|
||
|
||
def _now_iso() -> str:
|
||
"""返回当前 UTC 时间的 ISO 格式字符串。"""
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def _db(tmp_path) -> Database:
|
||
"""在临时目录创建独立数据库。"""
|
||
return Database(tmp_path / "wov.db")
|
||
|
||
|
||
def _seed_echo_workflow(db: Database, workflow_id: str = "echo-app", published: bool = True) -> None:
|
||
"""创建引用 echo 节点的单节点工作流(真实数据流:输入文件复制为产物)。
|
||
|
||
幂等:先删除同 ID 的旧工作流(含版本与任务),再重新创建。
|
||
"""
|
||
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"},
|
||
}
|
||
# 批量测试会真实执行 echo 节点,注册表由 conftest 每测试隔离,需显式注册。
|
||
_register_echo()
|
||
if db.get_workflow(workflow_id) is not None:
|
||
db.delete_workflow(workflow_id)
|
||
db.upsert_workflow(
|
||
{
|
||
"id": workflow_id,
|
||
"name": "Echo",
|
||
"description": "",
|
||
"published": 1 if published else 0,
|
||
"latest_version": 1,
|
||
}
|
||
)
|
||
db.create_workflow_version(workflow_id, 1, definition)
|
||
|
||
|
||
def _register_echo() -> None:
|
||
"""把内置 echo 节点注册到进程内注册表(conftest 每个测试隔离注册表)。"""
|
||
from wov_app import registry
|
||
from nodes.echo import invoke
|
||
from wov_sdk.models import NodeManifest
|
||
|
||
root = Path(__file__).resolve().parent.parent
|
||
registry.register(NodeManifest.load(str(root / "manifests" / "echo.json")), invoke)
|
||
|
||
|
||
def _video_folder(tmp_path, names=("a.mp4", "b.mp4")) -> Path:
|
||
"""创建含视频文件的文件夹:内容为真实文本(echo 节点按文本读入)。"""
|
||
folder = tmp_path / "videos"
|
||
folder.mkdir()
|
||
for index, name in enumerate(names, start=1):
|
||
(folder / name).write_text(f"视频 {name} 的测试内容 {index}\n", encoding="utf-8")
|
||
return folder
|
||
|
||
|
||
def _make_job(db: Database, folder: Path, workflow_id: str = "echo-app", recursive: bool = True) -> dict:
|
||
"""通过 create_job 创建批量任务并返回任务记录。"""
|
||
job_id = create_job(db, str(folder), workflow_id, recursive)
|
||
return db.get_batch_job(job_id)
|
||
|
||
|
||
def _create_run(
|
||
db: Database,
|
||
run_id: str,
|
||
folder: Path,
|
||
workflow_id: str = "echo-app",
|
||
status: str = "QUEUED",
|
||
name: str = "a",
|
||
) -> None:
|
||
"""创建一条 source=batch 的运行记录(input 指向 folder 下的视频文件)。"""
|
||
db.create_run(
|
||
{
|
||
"id": run_id,
|
||
"workflow_id": workflow_id,
|
||
"workflow_version": 1,
|
||
"status": status,
|
||
"progress": 0,
|
||
"input_uri": str(folder / f"{name}.mp4"),
|
||
"param_overrides": None,
|
||
"source": "batch",
|
||
"created_at": _now_iso(),
|
||
"updated_at": _now_iso(),
|
||
}
|
||
)
|
||
|
||
|
||
def _find_product(video_dir: Path, stem: str, alias: str) -> Path:
|
||
"""在同名输出位置查找最终产物文件(文件名以 主名.别名 开头)。"""
|
||
matches = list(video_dir.glob(f"{stem}.{alias}.*"))
|
||
assert matches, f"未找到产物 {stem}.{alias}.* in {video_dir}"
|
||
return matches[0]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 扫描 / 旁挂字幕判定 / 完成标记读取
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_scan_videos_recursive_and_top_level(tmp_path) -> None:
|
||
"""递归/非递归扫描只返回视频文件,隐藏目录与普通文件不参与。"""
|
||
folder = tmp_path / "media"
|
||
(folder / "sub").mkdir(parents=True)
|
||
(folder / "a.mp4").write_text("a", encoding="utf-8")
|
||
(folder / "b.MKV").write_text("b", encoding="utf-8")
|
||
(folder / "readme.txt").write_text("c", encoding="utf-8")
|
||
(folder / "sub" / "c.avi").write_text("d", encoding="utf-8")
|
||
(folder / "sub" / "notes.md").write_text("e", encoding="utf-8")
|
||
|
||
recursive = scan_videos(folder, recursive=True)
|
||
assert [p.name for p in recursive] == ["a.mp4", "b.MKV", "c.avi"]
|
||
flat = scan_videos(folder, recursive=False)
|
||
assert [p.name for p in flat] == ["a.mp4", "b.MKV"]
|
||
|
||
|
||
def test_list_sidecar_subtitles_matches(tmp_path) -> None:
|
||
"""视频旁"文件名含视频名"的字幕文件全部命中;不相关/非字幕/别的目录不算。"""
|
||
folder = tmp_path / "media"
|
||
(folder / "sub").mkdir(parents=True)
|
||
video = folder / "movie.mp4"
|
||
video.write_text("v", encoding="utf-8")
|
||
# 命中:同目录、字幕扩展名、文件名含视频主名(大小写不敏感)。
|
||
(folder / "movie.srt").write_text("s", encoding="utf-8")
|
||
(folder / "movie.CN.srt").write_text("s", encoding="utf-8")
|
||
(folder / "MOVIE.CN_dual_eye.ass").write_text("s", encoding="utf-8")
|
||
(folder / "movie.zh-CN.20260819120000.srt").write_text("s", encoding="utf-8")
|
||
# 不命中:不含视频主名、非字幕扩展名、子目录里的字幕。
|
||
(folder / "other.srt").write_text("s", encoding="utf-8")
|
||
(folder / "movie.jpg").write_text("s", encoding="utf-8")
|
||
(folder / "sub" / "movie.srt").write_text("s", encoding="utf-8")
|
||
|
||
names = [p.name for p in list_sidecar_subtitles(video)]
|
||
assert names == [
|
||
"MOVIE.CN_dual_eye.ass",
|
||
"movie.CN.srt",
|
||
"movie.srt",
|
||
"movie.zh-CN.20260819120000.srt",
|
||
]
|
||
|
||
|
||
def test_list_sidecar_subtitles_short_stem_and_unrelated(tmp_path) -> None:
|
||
"""单字符视频主名只接受"主名."前缀,避免 a.mp4 误配 apple.srt。"""
|
||
folder = tmp_path / "media"
|
||
folder.mkdir()
|
||
video = folder / "a.mp4"
|
||
video.write_text("v", encoding="utf-8")
|
||
(folder / "apple.srt").write_text("s", encoding="utf-8")
|
||
(folder / "b.srt").write_text("s", encoding="utf-8")
|
||
assert list_sidecar_subtitles(video) == []
|
||
(folder / "a.srt").write_text("s", encoding="utf-8")
|
||
assert [p.name for p in list_sidecar_subtitles(video)] == ["a.srt"]
|
||
|
||
|
||
def test_list_sidecar_subtitles_handles_unreadable_entries(tmp_path, monkeypatch) -> None:
|
||
"""目录不可读返回空列表;单个子项不可读时跳过该项不影响其余匹配。"""
|
||
from pathlib import Path as RealPath
|
||
|
||
# 整目录不可读(iterdir 抛 OSError)→ 保守返回空。
|
||
class _Denied(RealPath):
|
||
"""iterdir 恒抛权限错误的子类,模拟不可读的视频目录。"""
|
||
|
||
def iterdir(self):
|
||
raise OSError("denied")
|
||
|
||
denied = _Denied(str(tmp_path / "denied"))
|
||
assert list_sidecar_subtitles(denied / "a.mp4") == []
|
||
|
||
# 单个子项不可读(is_file 抛 OSError)→ 跳过该项,其余正常返回。
|
||
folder = tmp_path / "media"
|
||
folder.mkdir()
|
||
video = folder / "a.mp4"
|
||
video.write_text("v", encoding="utf-8")
|
||
(folder / "a.srt").write_text("s", encoding="utf-8")
|
||
|
||
class _Poison(RealPath):
|
||
"""is_file 恒抛权限错误的子类,模拟不可读的目录项。"""
|
||
|
||
def is_file(self):
|
||
raise OSError("denied")
|
||
|
||
real_iterdir = RealPath.iterdir
|
||
|
||
def mixed_iterdir(path):
|
||
return list(real_iterdir(path)) + [_Poison(str(folder / "secret"))]
|
||
|
||
monkeypatch.setattr(RealPath, "iterdir", mixed_iterdir)
|
||
assert [p.name for p in list_sidecar_subtitles(video)] == ["a.srt"]
|
||
|
||
|
||
def test_load_marker_variants(tmp_path, monkeypatch) -> None:
|
||
"""完成标记缺失/损坏/非字典/读取异常时返回 None。"""
|
||
work = tmp_path / "movie"
|
||
work.mkdir()
|
||
assert load_marker(work) is None
|
||
(work / MARKER_NAME).write_text("{broken json", encoding="utf-8")
|
||
assert load_marker(work) is None
|
||
(work / MARKER_NAME).write_text("[1,2]", encoding="utf-8")
|
||
assert load_marker(work) is None
|
||
(work / MARKER_NAME).write_text('{"workflow_id": "w", "finals": {"r": "m.txt"}}', encoding="utf-8")
|
||
assert load_marker(work)["finals"]["r"] == "m.txt"
|
||
|
||
# 读取抛 OSError(权限等)时同样保守视为无标记。
|
||
from pathlib import Path as RealPath
|
||
|
||
def broken_read_text(path, **kwargs):
|
||
raise OSError("denied")
|
||
|
||
monkeypatch.setattr(RealPath, "read_text", broken_read_text)
|
||
assert load_marker(work) is None
|
||
|
||
|
||
def test_remove_job_workspace(tmp_path) -> None:
|
||
"""删除任务级私有工作空间;目录不存在时静默无副作用。"""
|
||
job_dir = BATCH_WORK_ROOT / "batch_del"
|
||
(job_dir / "runs" / "run_x").mkdir(parents=True)
|
||
remove_job_workspace("batch_del")
|
||
assert not job_dir.exists()
|
||
remove_job_workspace("batch_missing")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# create_job:校验 + 一次性定位(已有字幕 → SKIPPED)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_job_validation_errors(tmp_path) -> None:
|
||
"""文件夹不存在、未发布工作流、无版本、无视频都拒绝创建。"""
|
||
db = _db(tmp_path)
|
||
folder = _video_folder(tmp_path)
|
||
with pytest.raises(ValueError, match="folder not found"):
|
||
create_job(db, str(tmp_path / "missing"), "echo-app")
|
||
with pytest.raises(ValueError, match="published workflow not found"):
|
||
create_job(db, str(folder), "ghost-flow")
|
||
_seed_echo_workflow(db, published=False)
|
||
with pytest.raises(ValueError, match="published workflow not found"):
|
||
create_job(db, str(folder), "echo-app")
|
||
# 有版本但文件夹里没有视频。
|
||
_seed_echo_workflow(db, published=True)
|
||
empty = tmp_path / "empty"
|
||
empty.mkdir()
|
||
with pytest.raises(ValueError, match="no videos found in folder"):
|
||
create_job(db, str(empty), "echo-app")
|
||
# 已发布但没有版本的工作流。
|
||
db.delete_workflow("echo-app")
|
||
db.upsert_workflow({"id": "echo-app", "name": "E", "description": "", "published": 1, "latest_version": 0})
|
||
with pytest.raises(ValueError, match="workflow has no version"):
|
||
create_job(db, str(folder), "echo-app")
|
||
|
||
|
||
def test_create_job_locates_all_videos_once(tmp_path) -> None:
|
||
"""创建任务时一次性定位:全部视频登记明细、递归标志落库、可被引擎拾起。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
(tmp_path / "videos" / "sub").mkdir(parents=True)
|
||
(tmp_path / "videos" / "a.mp4").write_text("a", encoding="utf-8")
|
||
(tmp_path / "videos" / "sub" / "b.mkv").write_text("b", encoding="utf-8")
|
||
job = _make_job(db, tmp_path / "videos", recursive=True)
|
||
assert job["status"] == "QUEUED"
|
||
videos = db.list_batch_videos(job["id"])
|
||
assert {Path(v["video_path"]).name for v in videos} == {"a.mp4", "b.mkv"}
|
||
assert all(v["status"] == "PENDING" for v in videos)
|
||
# 工作空间位于应用私有存储目录(与媒体库隔离),且每个视频独立目录。
|
||
assert all(Path(v["work_dir"]).is_relative_to(BATCH_WORK_ROOT / job["id"]) for v in videos)
|
||
assert db.next_queued_batch_job()["id"] == job["id"]
|
||
|
||
|
||
def test_create_job_skips_videos_with_existing_subtitles(tmp_path) -> None:
|
||
"""视频旁已有对应字幕文件的直接记 SKIPPED,其余 PENDING(不触发流水线)。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
# a 已处理过(视频旁有中文双目字幕),b 未处理。
|
||
(folder / "a.CN_dual_eye.ass").write_text("已处理", encoding="utf-8")
|
||
|
||
job = _make_job(db, folder)
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["a.mp4"]["status"] == "SKIPPED"
|
||
assert videos["a.mp4"]["run_id"] is None
|
||
assert videos["b.mp4"]["status"] == "PENDING"
|
||
|
||
|
||
def test_create_job_all_videos_skipped_still_created(tmp_path) -> None:
|
||
"""文件夹里全部视频都已有字幕时任务直接视为完成(无字幕视频数为 0)。
|
||
|
||
新口径(2026-09):total = 扫描出的无字幕视频数,全被 SKIPPED 时
|
||
total=0 且没有可处理项,创建即置 COMPLETED,不排队空跑。
|
||
"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
(folder / "a.CN.srt").write_text("x", encoding="utf-8")
|
||
(folder / "b.CN_dual_eye.ass").write_text("x", encoding="utf-8")
|
||
job = _make_job(db, folder)
|
||
assert job["status"] == "COMPLETED"
|
||
assert job["total"] == 0 and job["done"] == 0
|
||
assert all(v["status"] == "SKIPPED" for v in db.list_batch_videos(job["id"]))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 引擎:完整处理 / 跳过 / 失败 / 暂停续跑 / 收尾清理
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_batch_worker_processes_all_videos_and_cleans_up(tmp_path) -> None:
|
||
"""批量引擎逐个处理视频:产物复制到视频旁、run 记录与过程工作空间清理。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path)
|
||
job = _make_job(db, folder)
|
||
worker = BatchWorker(db, interval_seconds=0.05)
|
||
worker._process_job(job)
|
||
|
||
job = db.get_batch_job(job["id"])
|
||
assert job["status"] == "COMPLETED"
|
||
assert job["done"] == 2 and job["failed"] == 0
|
||
for name in ("a", "b"):
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
row = videos[f"{name}.mp4"]
|
||
# 最终产物复制到视频旁(与 .mp4 同目录),内容与输入一致(真实数据流)。
|
||
product = _find_product(folder, name, "result")
|
||
assert product.parent == folder
|
||
assert product.read_text(encoding="utf-8") == (folder / f"{name}.mp4").read_text(encoding="utf-8")
|
||
# 视频状态 COMPLETED 且 run 引用清空;不再写完成标记。
|
||
assert row["status"] == "COMPLETED"
|
||
assert row["run_id"] is None
|
||
assert not (Path(row["work_dir"]) / MARKER_NAME).exists()
|
||
# 过程工作空间(含 runs/steps/音频分块等)已被整体清理,不在媒体库残留。
|
||
assert not Path(row["work_dir"]).exists()
|
||
# run 记录已删除(产物已放视频旁,不再依赖原文件)。
|
||
assert db.list_runs() == []
|
||
|
||
|
||
def test_batch_worker_skips_videos_with_existing_subtitles(tmp_path) -> None:
|
||
"""已有字幕的视频在创建时记 SKIPPED,不进入 total/done,只统计真正处理的。
|
||
|
||
新口径(2026-09):total=无字幕视频数(不含 SKIPPED),done=实际完成数。
|
||
"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
(folder / "a.CN_dual_eye.ass").write_text("已处理", encoding="utf-8")
|
||
job = _make_job(db, folder)
|
||
# 创建时 total 已固定为无字幕数(b 一个),SKIPPED 不计入。
|
||
assert db.get_batch_job(job["id"])["total"] == 1
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(job)
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["a.mp4"]["status"] == "SKIPPED"
|
||
assert videos["a.mp4"]["run_id"] is None
|
||
assert videos["b.mp4"]["status"] == "COMPLETED"
|
||
job = db.get_batch_job(job["id"])
|
||
# done 只计实际完成的 b(1 个),不再把 SKIPPED 的 a 计入。
|
||
assert job["done"] == 1 and job["total"] == 1
|
||
|
||
|
||
def test_batch_worker_all_skipped_job_completes(tmp_path) -> None:
|
||
"""全部视频都有字幕时创建即视为完成(total=0),不创建任何 run。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
(folder / "a.srt").write_text("x", encoding="utf-8")
|
||
(folder / "b.CN.srt").write_text("x", encoding="utf-8")
|
||
job = _make_job(db, folder)
|
||
# 新口径:无字幕视频数为 0 → 创建即 COMPLETED,引擎不需要再跑。
|
||
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
|
||
assert db.get_batch_job(job["id"])["total"] == 0
|
||
assert db.get_batch_job(job["id"])["done"] == 0
|
||
assert db.list_runs() == []
|
||
|
||
|
||
def test_batch_worker_failed_video_continues(tmp_path) -> None:
|
||
"""缺失视频文件记为 FAILED,任务继续处理后续视频。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("gone.mp4", "b.mp4"))
|
||
job = _make_job(db, folder)
|
||
# 任务创建后、处理前删除第一个视频(模拟外部移除),第二个正常处理。
|
||
(folder / "gone.mp4").unlink()
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(job)
|
||
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["gone.mp4"]["status"] == "FAILED"
|
||
assert videos["gone.mp4"]["error"] == "video file not found"
|
||
assert videos["b.mp4"]["status"] == "COMPLETED"
|
||
job = db.get_batch_job(job["id"])
|
||
assert job["status"] == "COMPLETED"
|
||
assert job["done"] == 1 and job["failed"] == 1
|
||
|
||
|
||
def test_batch_worker_process_exception_marks_video_failed(tmp_path, monkeypatch) -> None:
|
||
"""单视频执行抛异常:该视频 FAILED 带错误信息,任务继续处理后续视频。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
job = _make_job(db, folder)
|
||
|
||
class _BoomScheduler:
|
||
"""execute_run 直接抛异常的假调度器,触发单视频兜底分支。"""
|
||
|
||
def __init__(self, db: Database, work_dir: Path) -> None:
|
||
self.db = db
|
||
|
||
def execute_run(self, run_id: str) -> None:
|
||
raise RuntimeError("boom")
|
||
|
||
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _BoomScheduler)
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(job)
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["a.mp4"]["status"] == "FAILED"
|
||
assert "boom" in videos["a.mp4"]["error"]
|
||
assert videos["b.mp4"]["status"] == "FAILED"
|
||
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
|
||
|
||
|
||
class _PausingScheduler:
|
||
"""把 execute_run 模拟为"被暂停"的假调度器:置 run 为 PAUSED。"""
|
||
|
||
def __init__(self, db: Database, work_dir: Path) -> None:
|
||
self.db = db
|
||
self.work_dir = work_dir
|
||
|
||
def execute_run(self, run_id: str) -> None:
|
||
self.db.update_run(run_id, status="PAUSED", updated_at=_now_iso())
|
||
|
||
|
||
def test_batch_worker_pause_then_resume_continues(tmp_path, monkeypatch) -> None:
|
||
"""暂停后重新开始:PAUSED 视频从断点续跑,未开始的视频接着处理。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
job = _make_job(db, folder)
|
||
worker = BatchWorker(db, interval_seconds=0.05)
|
||
|
||
# 第一次执行:第一个视频处理中被暂停(假调度器把 run 置为 PAUSED)。
|
||
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _PausingScheduler)
|
||
worker._process_job(job)
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["a.mp4"]["status"] == "PAUSED"
|
||
assert videos["b.mp4"]["status"] == "PENDING"
|
||
assert db.get_batch_job(job["id"])["status"] == "PAUSED"
|
||
|
||
# 恢复真实调度器并继续:a 从断点完成(收尾清理),b 接着处理,任务 COMPLETED。
|
||
monkeypatch.undo()
|
||
worker.resume_job(job["id"])
|
||
worker._process_job(db.get_batch_job(job["id"]))
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["a.mp4"]["status"] == "COMPLETED"
|
||
assert videos["b.mp4"]["status"] == "COMPLETED"
|
||
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
|
||
|
||
# 再次处理(已完成视频在循环里直接 continue):结果不变,幂等。
|
||
worker._process_job(db.get_batch_job(job["id"]))
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["a.mp4"]["status"] == "COMPLETED"
|
||
assert videos["b.mp4"]["status"] == "COMPLETED"
|
||
assert db.get_batch_job(job["id"])["done"] == 2
|
||
|
||
|
||
def test_batch_worker_paused_job_does_not_start_new_video(tmp_path, monkeypatch) -> None:
|
||
"""任务在视频之间被暂停:后续视频不开始,不创建 run。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
job = _make_job(db, folder)
|
||
job_id = job["id"]
|
||
from wov_app.scheduler import WorkflowScheduler
|
||
|
||
class _PauseAfterFirstScheduler:
|
||
"""真实执行第一个视频后把批量任务置为 PAUSED(模拟用户处理中暂停)。"""
|
||
|
||
def __init__(self, db: Database, work_dir: Path) -> None:
|
||
self.db = db
|
||
self.work_dir = work_dir
|
||
|
||
def execute_run(self, run_id: str) -> None:
|
||
WorkflowScheduler(self.db, self.work_dir).execute_run(run_id)
|
||
self.db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
|
||
|
||
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _PauseAfterFirstScheduler)
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(db.get_batch_job(job_id))
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job_id)}
|
||
# 第一个视频真实完成;第二个视频在开始前因任务已暂停而不处理。
|
||
assert videos["a.mp4"]["status"] == "COMPLETED"
|
||
assert videos["b.mp4"]["status"] == "PENDING"
|
||
assert videos["b.mp4"]["run_id"] is None
|
||
assert db.get_batch_job(job_id)["status"] == "PAUSED"
|
||
|
||
|
||
def test_batch_worker_failed_and_running_runs_resume(tmp_path) -> None:
|
||
"""已失败的 run 重跑、上次进程残留的 RUNNING run 恢复后继续(收尾清理)。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
job = _make_job(db, folder)
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
for name, status in (("a", "FAILED"), ("b", "RUNNING")):
|
||
run_id = f"run_{name}"
|
||
_create_run(db, run_id, folder, status=status, name=name)
|
||
db.update_batch_video(videos[f"{name}.mp4"]["id"], run_id=run_id, updated_at=_now_iso())
|
||
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(db.get_batch_job(job["id"]))
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["a.mp4"]["status"] == "COMPLETED"
|
||
assert videos["b.mp4"]["status"] == "COMPLETED"
|
||
|
||
|
||
def test_batch_worker_retry_failed_keeps_completed_node_artifacts(tmp_path) -> None:
|
||
"""批量重试 FAILED 视频**保留已完成节点产物**:只重跑失败节点。
|
||
|
||
回归:此前 FAILED 走 reset_run 清空全部产物记录,重跑时 extract/ocr 等
|
||
长耗时节点从头重做(run_e2b74e89e232 的 22222 帧 OCR 被白白丢弃)。
|
||
通过改写 step1 产物内容验证:若 step1 被重跑,最终产物会恢复为源视频
|
||
文本;保留产物则最终产物内容为改写后的内容。
|
||
"""
|
||
db = _db(tmp_path)
|
||
_register_echo()
|
||
# 双节点串联工作流:step1 成功、step2 失败的场景下验证只重跑 step2。
|
||
definition = {
|
||
"name": "two-step",
|
||
"version": 1,
|
||
"nodes": [
|
||
{"id": "s1", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
|
||
{"id": "s2", "node_type": "echo", "inputs": {"file_uri": "s1.file_uri"}},
|
||
],
|
||
"edges": [{"from": "s1", "to": "s2"}],
|
||
"entry_inputs": {"video_uri": "file"},
|
||
"final_outputs": {"result": "s2.file_uri"},
|
||
}
|
||
db.upsert_workflow({"id": "echo-app", "name": "Echo", "description": "", "published": 1, "latest_version": 1})
|
||
db.create_workflow_version("echo-app", 1, definition)
|
||
folder = _video_folder(tmp_path, names=("a.mp4",))
|
||
job = _make_job(db, folder)
|
||
video = db.list_batch_videos(job["id"])[0]
|
||
run_id = "run_retry"
|
||
_create_run(db, run_id, folder, status="FAILED", name="a")
|
||
db.update_batch_video(video["id"], run_id=run_id, updated_at=_now_iso())
|
||
|
||
# 模拟 step1 已完成并登记产物,把其内容改写为与源视频不同(step2 会引用)。
|
||
s1_out = Path(video["work_dir"]) / "runs" / run_id / "steps" / "s1" / "echo.txt"
|
||
s1_out.parent.mkdir(parents=True)
|
||
s1_out.write_text("step1 已保留产物", encoding="utf-8")
|
||
db.create_artifact(
|
||
{
|
||
"run_id": run_id,
|
||
"node_id": "s1",
|
||
"name": "s1.file_uri",
|
||
"uri": str(s1_out),
|
||
"mime_type": "text/plain",
|
||
"size": s1_out.stat().st_size,
|
||
}
|
||
)
|
||
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(db.get_batch_job(job["id"]))
|
||
video = db.list_batch_videos(job["id"])[0]
|
||
assert video["status"] == "COMPLETED"
|
||
# step1 未重跑:最终产物内容等于保留的 step1 产物,而不是源视频文本。
|
||
product = _find_product(folder, "a", "result")
|
||
assert product.read_text(encoding="utf-8") == "step1 已保留产物"
|
||
|
||
|
||
def test_batch_worker_stale_run_id_recreated(tmp_path) -> None:
|
||
"""明细里的 run_id 指向已不存在的 run 时按全新视频处理并正常完成。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4",))
|
||
job = _make_job(db, folder)
|
||
video = db.list_batch_videos(job["id"])[0]
|
||
db.update_batch_video(video["id"], run_id="run_dead", updated_at=_now_iso())
|
||
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(job)
|
||
video = db.list_batch_videos(job["id"])[0]
|
||
assert video["status"] == "COMPLETED"
|
||
assert db.get_run("run_dead") is None
|
||
assert _find_product(folder, "a", "result").is_file()
|
||
|
||
|
||
def test_batch_worker_run_deleted_by_scheduler_returns(tmp_path, monkeypatch) -> None:
|
||
"""execute_run 期间 run 记录被删除(极端情况)时直接返回,不误标完成。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4",))
|
||
job = _make_job(db, folder)
|
||
|
||
class _DeletingScheduler:
|
||
"""execute_run 直接删除 run 记录的假调度器。"""
|
||
|
||
def __init__(self, db: Database, work_dir: Path) -> None:
|
||
self.db = db
|
||
|
||
def execute_run(self, run_id: str) -> None:
|
||
self.db.delete_run(run_id)
|
||
|
||
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _DeletingScheduler)
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(job)
|
||
video = db.list_batch_videos(job["id"])[0]
|
||
# 不标 COMPLETED/FAILED,保持 PENDING,等待下一次处理重试。
|
||
assert video["status"] == "PENDING"
|
||
|
||
|
||
def test_batch_worker_pending_leftover_never_marks_completed(tmp_path, monkeypatch) -> None:
|
||
"""残留未处理 PENDING 视频时,任务**不得**置 COMPLETED(状态机缺陷回归)。
|
||
|
||
真实事故(batch_969fabe74b83 等 3 个任务):引擎串行处理到 9.9GB 大视频时
|
||
execute_run 异常中断,_process_video 返回但该视频未被标 FAILED/COMPLETED
|
||
(保持 PENDING),_run_job 循环照常走完剩余 SKIPPED 后**无条件**收尾置
|
||
COMPLETED——留下"10 个待处理却已完成"的僵尸状态。
|
||
|
||
本测试用假调度器复现:第一个视频 execute_run 抛异常且不标任何状态
|
||
(run 被删除的极端情形同路径),第二个视频正常;断言任务必须保持
|
||
RUNNING(而非 COMPLETED),等待引擎下一次拾起重跑剩余 PENDING。
|
||
"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
# a.mp4 待处理(处理中崩溃);b.mp4 旁放好字幕 → 创建即 SKIPPED,不参与处理。
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
(folder / "b.CN.srt").write_text("1\n00:00:00,000 --> 00:00:01,000\nb\n", encoding="utf-8")
|
||
job = _make_job(db, folder)
|
||
videos0 = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos0["b.mp4"]["status"] == "SKIPPED" # 前置:旁挂字幕命中跳过
|
||
|
||
class _CrashScheduler:
|
||
"""模拟进程在第一个(唯一待处理)视频处理中被杀:run 消失、视频保持 PENDING。"""
|
||
|
||
def __init__(self, db: Database, work_dir: Path) -> None:
|
||
self.db = db
|
||
|
||
def execute_run(self, run_id: str) -> None:
|
||
# 等同进程崩溃后 run 记录残留被清/丢失,视频仍是 PENDING。
|
||
self.db.delete_run(run_id)
|
||
|
||
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _CrashScheduler)
|
||
worker = BatchWorker(db, interval_seconds=0.05)
|
||
# 第一轮:a 崩溃残留 PENDING → 任务必须仍 RUNNING(允许续跑),不得 COMPLETED。
|
||
worker._process_job(job)
|
||
job = db.get_batch_job(job["id"])
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
# 缺陷行为:job 被置 COMPLETED(无条件收尾);期望保持 RUNNING。
|
||
assert videos["a.mp4"]["status"] == "PENDING"
|
||
assert videos["b.mp4"]["status"] == "SKIPPED"
|
||
assert job["status"] == "RUNNING", (
|
||
f"残留 PENDING 时任务被误置为 {job['status']}(缺陷:无条件收尾置 COMPLETED)"
|
||
)
|
||
|
||
# 第二轮(引擎重启后再次拾起 RUNNING 任务):a 这次正常完成 → 全部完成才 COMPLETED。
|
||
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _CrashScheduler)
|
||
# 放开假调度器的崩溃:第二次调用时不再删 run,让真实调度器跑通。
|
||
class _RecoveringScheduler:
|
||
"""第二轮:真实执行工作流并产生成品,由引擎收尾放置。"""
|
||
|
||
def __init__(self, db: Database, work_dir: Path) -> None:
|
||
self.db = db
|
||
|
||
def execute_run(self, run_id: str) -> None:
|
||
from wov_app.scheduler import WorkflowScheduler
|
||
|
||
video = self.db.list_batch_videos(job["id"])[0]
|
||
WorkflowScheduler(self.db, Path(video["work_dir"])).execute_run(run_id)
|
||
|
||
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _RecoveringScheduler)
|
||
worker._process_job(job)
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["a.mp4"]["status"] == "COMPLETED"
|
||
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
|
||
|
||
|
||
def test_batch_worker_already_completed_runs_finalized(tmp_path) -> None:
|
||
"""run 已完成但视频未标记(收尾前中断):直接放置产物、清理并标记完成。
|
||
|
||
覆盖 _place_products 各分支:产物齐全(复制)、产物记录存在但文件丢失
|
||
(报错)、无产物记录(报错)——只有成品齐全才清理工作空间与 run 记录。
|
||
"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4", "c.mp4"))
|
||
job = _make_job(db, folder)
|
||
now = _now_iso()
|
||
videos = {Path(v["video_path"]).stem: v for v in db.list_batch_videos(job["id"])}
|
||
|
||
def complete_run(run_id: str, name: str, with_file: bool, with_artifact: bool) -> None:
|
||
"""创建 COMPLETED run;可选产物文件与产物记录。"""
|
||
_create_run(db, run_id, folder, status="COMPLETED", name=name)
|
||
work_dir = Path(videos[name]["work_dir"])
|
||
work_dir.mkdir(parents=True)
|
||
if with_artifact:
|
||
path = work_dir / "runs" / run_id / "steps" / "step" / f"{name}.result.txt"
|
||
db.create_artifact(
|
||
{
|
||
"run_id": run_id,
|
||
"node_id": "step",
|
||
"name": "result",
|
||
"uri": str(path),
|
||
"mime_type": "text/plain",
|
||
"size": 6,
|
||
}
|
||
)
|
||
if with_file:
|
||
path = work_dir / "runs" / run_id / "steps" / "step" / f"{name}.result.txt"
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(f"产物 {name}", encoding="utf-8")
|
||
db.update_batch_video(videos[name]["id"], run_id=run_id, updated_at=now)
|
||
|
||
# a:产物齐全;b:产物记录存在但文件丢失;c:没有任何产物记录。
|
||
complete_run("run_done_a", "a", with_file=True, with_artifact=True)
|
||
complete_run("run_done_b", "b", with_file=False, with_artifact=True)
|
||
complete_run("run_done_c", "c", with_file=False, with_artifact=False)
|
||
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(db.get_batch_job(job["id"]))
|
||
videos = {Path(v["video_path"]).stem: v for v in db.list_batch_videos(job["id"])}
|
||
assert videos["a"]["status"] == "COMPLETED"
|
||
assert all(videos[name]["status"] == "FAILED" for name in ("b", "c"))
|
||
# a 的产物复制到视频旁;b/c 无产物可复制。
|
||
product = _find_product(folder, "a", "result")
|
||
assert product.read_text(encoding="utf-8") == "产物 a"
|
||
assert not list(folder.glob("b.result.*")) and not list(folder.glob("c.result.*"))
|
||
# a 清理完成;b/c 必须保留工作空间、关联 run 与错误供修复后重试。
|
||
assert not Path(videos["a"]["work_dir"]).exists()
|
||
assert db.get_run("run_done_a") is None
|
||
for name in ("b", "c"):
|
||
assert Path(videos[name]["work_dir"]).exists()
|
||
assert db.get_run(f"run_done_{name}") is not None
|
||
assert videos[name]["run_id"] == f"run_done_{name}"
|
||
assert "result" in videos[name]["error"]
|
||
|
||
|
||
def test_batch_worker_place_products_branches(tmp_path) -> None:
|
||
"""_place_products:无产物记录/文件丢失报错;.srt/.ass 按库内约定命名覆盖;
|
||
其他扩展名保留原文件名复制。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4",))
|
||
video = folder / "a.mp4"
|
||
run_id = "run_place"
|
||
_create_run(db, run_id, folder, name="a")
|
||
source_dir = tmp_path / "src"
|
||
source_dir.mkdir()
|
||
worker = BatchWorker(db, interval_seconds=0.05)
|
||
definition = {
|
||
"name": "multi-final",
|
||
"version": 1,
|
||
"nodes": [],
|
||
"edges": [],
|
||
"entry_inputs": {"video_uri": "file"},
|
||
# 无产物记录 / 文件丢失 / srt / ass / txt 五种 final 场景。
|
||
"final_outputs": {
|
||
"alias_none": "x.none",
|
||
"alias_lost": "x.lost",
|
||
"alias_srt": "step.srt",
|
||
"alias_ass": "step.ass",
|
||
"alias_txt": "step.txt",
|
||
},
|
||
}
|
||
# alias_lost:产物记录存在但指向的文件已丢失 → 跳过不放置。
|
||
db.create_artifact(
|
||
{"run_id": run_id, "node_id": "step", "name": "alias_lost", "uri": str(source_dir / "lost.srt"), "mime_type": "text/plain", "size": 0}
|
||
)
|
||
# alias_srt:中文 srt → <视频名>.CN.srt;目标已存在(旧内容)→ 覆盖。
|
||
src_srt = source_dir / "raw_any_name.srt"
|
||
src_srt.write_text("1\n00:00:00,000 --> 00:00:01,000\n新中文字幕\n", encoding="utf-8")
|
||
db.create_artifact(
|
||
{"run_id": run_id, "node_id": "step", "name": "alias_srt", "uri": str(src_srt), "mime_type": "text/plain", "size": src_srt.stat().st_size}
|
||
)
|
||
target_srt = video.parent / "a.CN.srt"
|
||
target_srt.write_text("旧字幕内容", encoding="utf-8")
|
||
# alias_ass:双目 ass → <视频名>.CN_dual_eye.ass;覆盖同名旧文件。
|
||
src_ass = source_dir / "whatever.ass"
|
||
src_ass.write_text("[Script Info]\n新双目字幕\n", encoding="utf-8")
|
||
db.create_artifact(
|
||
{"run_id": run_id, "node_id": "step", "name": "alias_ass", "uri": str(src_ass), "mime_type": "text/plain", "size": src_ass.stat().st_size}
|
||
)
|
||
target_ass = video.parent / "a.CN_dual_eye.ass"
|
||
target_ass.write_text("旧 ass", encoding="utf-8")
|
||
# alias_txt:其他扩展名保留原文件名(含时间戳命名)复制。
|
||
src_txt = source_dir / "a.other.20260902120000.txt"
|
||
src_txt.write_text("其余产物内容", encoding="utf-8")
|
||
db.create_artifact(
|
||
{"run_id": run_id, "node_id": "step", "name": "alias_txt", "uri": str(src_txt), "mime_type": "text/plain", "size": src_txt.stat().st_size}
|
||
)
|
||
|
||
# 所有必需成品预检通过前不开始覆盖,缺失原因中包含具体别名。
|
||
with pytest.raises(ValueError, match="alias_none"):
|
||
worker._place_products(run_id, video, WorkflowDefinition.from_dict(definition))
|
||
del definition["final_outputs"]["alias_none"]
|
||
with pytest.raises(ValueError, match="alias_lost"):
|
||
worker._place_products(run_id, video, WorkflowDefinition.from_dict(definition))
|
||
assert target_srt.read_text(encoding="utf-8") == "旧字幕内容"
|
||
del definition["final_outputs"]["alias_lost"]
|
||
placed = worker._place_products(run_id, video, WorkflowDefinition.from_dict(definition))
|
||
assert placed == ["a.CN.srt", "a.CN_dual_eye.ass", "a.other.20260902120000.txt"]
|
||
# srt/ass 被改名为标准名且覆盖旧文件;txt 保留原名。
|
||
assert target_srt.read_text(encoding="utf-8") == "1\n00:00:00,000 --> 00:00:01,000\n新中文字幕\n"
|
||
assert target_ass.read_text(encoding="utf-8") == "[Script Info]\n新双目字幕\n"
|
||
assert (video.parent / "a.other.20260902120000.txt").read_text(encoding="utf-8") == "其余产物内容"
|
||
# alias_none(无记录)与 alias_lost(文件丢失)未放置。
|
||
assert not (video.parent / "lost.srt").exists()
|
||
def test_batch_worker_job_validation_failures(tmp_path) -> None:
|
||
"""文件夹缺失/工作流未发布/无版本时任务置为 FAILED 并记录错误。"""
|
||
db = _db(tmp_path)
|
||
now = _now_iso()
|
||
|
||
def add_job(job_id, folder, workflow_id="echo-app"):
|
||
db.create_batch_job(
|
||
{
|
||
"id": job_id, "folder_path": folder, "workflow_id": workflow_id,
|
||
"recursive": 1, "status": "QUEUED", "progress": 0, "total": 0,
|
||
"done": 0, "failed": 0, "error": None,
|
||
"created_at": now, "updated_at": now,
|
||
}
|
||
)
|
||
|
||
worker = BatchWorker(db, interval_seconds=0.05)
|
||
add_job("job_nofolder", str(tmp_path / "missing"))
|
||
worker._process_job(db.get_batch_job("job_nofolder"))
|
||
assert db.get_batch_job("job_nofolder")["status"] == "FAILED"
|
||
assert "folder not found" in db.get_batch_job("job_nofolder")["error"]
|
||
|
||
folder = _video_folder(tmp_path)
|
||
_seed_echo_workflow(db, published=False)
|
||
add_job("job_unpublished", str(folder))
|
||
worker._process_job(db.get_batch_job("job_unpublished"))
|
||
assert db.get_batch_job("job_unpublished")["status"] == "FAILED"
|
||
assert "not found or unpublished" in db.get_batch_job("job_unpublished")["error"]
|
||
|
||
# 已发布但没有任何版本。
|
||
_seed_echo_workflow(db, published=True)
|
||
db.delete_workflow("echo-app")
|
||
db.upsert_workflow({"id": "echo-app", "name": "E", "description": "", "published": 1, "latest_version": 0})
|
||
add_job("job_noversion", str(folder))
|
||
worker._process_job(db.get_batch_job("job_noversion"))
|
||
assert db.get_batch_job("job_noversion")["status"] == "FAILED"
|
||
assert "has no version" in db.get_batch_job("job_noversion")["error"]
|
||
|
||
|
||
def test_batch_worker_catches_unexpected_job_error(tmp_path) -> None:
|
||
"""任务级兜底:DAG 解析失败(缺 name)时任务 FAILED 而不是卡死在 RUNNING。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4",))
|
||
# 覆盖为缺失 name 的非法定义:from_dict 抛 KeyError。
|
||
bad_definition = {"version": 1, "nodes": [], "edges": []}
|
||
db.create_workflow_version("echo-app", 2, bad_definition)
|
||
db.upsert_workflow({"id": "echo-app", "name": "E", "description": "", "published": 1, "latest_version": 2})
|
||
job = _make_job(db, folder)
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(job)
|
||
assert db.get_batch_job(job["id"])["status"] == "FAILED"
|
||
assert "name" in db.get_batch_job(job["id"])["error"]
|
||
|
||
|
||
def test_batch_worker_cycle_fails_video_not_job(tmp_path) -> None:
|
||
"""DAG 环在执行期抛错:单个视频 FAILED 记录错误,批量任务继续并完成。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4",))
|
||
# 覆盖为带环的定义:validate 通过、调度器拓扑排序时抛"contains a cycle"。
|
||
cycle_definition = {
|
||
"name": "bad",
|
||
"version": 2,
|
||
"nodes": [
|
||
{"id": "x", "node_type": "echo", "inputs": {"file_uri": "y.file_uri"}},
|
||
{"id": "y", "node_type": "echo", "inputs": {"file_uri": "x.file_uri"}},
|
||
],
|
||
"edges": [{"from": "x", "to": "y"}, {"from": "y", "to": "x"}],
|
||
"entry_inputs": {"video_uri": "file"},
|
||
"final_outputs": {"result": "x.file_uri"},
|
||
}
|
||
db.create_workflow_version("echo-app", 2, cycle_definition)
|
||
db.upsert_workflow({"id": "echo-app", "name": "E", "description": "", "published": 1, "latest_version": 2})
|
||
job = _make_job(db, folder)
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(job)
|
||
video = db.list_batch_videos(job["id"])[0]
|
||
assert video["status"] == "FAILED"
|
||
assert "cycle" in video["error"]
|
||
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
|
||
assert db.get_batch_job(job["id"])["failed"] == 1
|
||
|
||
|
||
def test_batch_worker_loop_processes_and_survives_exceptions(tmp_path, monkeypatch) -> None:
|
||
"""轮询线程:处理排队任务;轮询异常不杀死线程;重复启动/幽灵任务无害。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4",))
|
||
job = _make_job(db, folder)
|
||
# 不存在的任务 ID:_run_job 直接返回,不报错。
|
||
BatchWorker(db, interval_seconds=0.05)._process_job({"id": "ghost_job"})
|
||
# 第一次轮询抛异常(模拟数据库抖动),后续正常。
|
||
calls = {"n": 0}
|
||
real_next = db.next_queued_batch_job
|
||
|
||
def flaky_next():
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
raise RuntimeError("transient error")
|
||
return real_next()
|
||
|
||
monkeypatch.setattr(db, "next_queued_batch_job", flaky_next)
|
||
worker = BatchWorker(db, interval_seconds=0.05)
|
||
worker.start()
|
||
# 重复启动无副作用:线程已存在时直接返回。
|
||
worker.start()
|
||
try:
|
||
deadline = time.monotonic() + 10
|
||
while time.monotonic() < deadline:
|
||
if db.get_batch_job(job["id"])["status"] in {"COMPLETED", "FAILED"}:
|
||
break
|
||
time.sleep(0.1)
|
||
finally:
|
||
worker.stop()
|
||
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
|
||
|
||
|
||
def test_batch_worker_pause_job_writes_flag_and_pauses_run(tmp_path) -> None:
|
||
"""pause_job:任务置 PAUSED、排队/运行中的 run 暂停并写 paused.flag。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4", "c.mp4"))
|
||
job = _make_job(db, folder)
|
||
videos = {Path(v["video_path"]).stem: v for v in db.list_batch_videos(job["id"])}
|
||
|
||
# a:QUEUED 运行(应被暂停并写 flag);b:run 记录不存在;c:已完成的 run。
|
||
_create_run(db, "run_pause_me", folder, status="QUEUED", name="a")
|
||
_create_run(db, "run_done_c", folder, status="COMPLETED", name="c")
|
||
db.update_batch_video(videos["a"]["id"], run_id="run_pause_me", updated_at=_now_iso())
|
||
db.update_batch_video(videos["b"]["id"], run_id="run_ghost", updated_at=_now_iso())
|
||
db.update_batch_video(videos["c"]["id"], run_id="run_done_c", updated_at=_now_iso())
|
||
|
||
worker = BatchWorker(db, interval_seconds=0.05)
|
||
worker.pause_job(job["id"])
|
||
assert db.get_batch_job(job["id"])["status"] == "PAUSED"
|
||
assert db.get_run("run_pause_me")["status"] == "PAUSED"
|
||
work_dir_a = Path(videos["a"]["work_dir"])
|
||
assert (work_dir_a / "runs" / "run_pause_me" / PAUSE_FLAG).is_file()
|
||
# b 的 run 不存在、c 的 run 已完成:都被跳过,不写 flag。
|
||
assert not (Path(videos["b"]["work_dir"]) / "runs" / "run_ghost" / PAUSE_FLAG).exists()
|
||
assert not (Path(videos["c"]["work_dir"]) / "runs" / "run_done_c" / PAUSE_FLAG).exists()
|
||
|
||
# 继续:任务回到 QUEUED,由引擎从断点续跑。
|
||
worker.resume_job(job["id"])
|
||
assert db.get_batch_job(job["id"])["status"] == "QUEUED"
|
||
|
||
|
||
|
||
def test_batch_progress_sync_recounts_done_failed(tmp_path) -> None:
|
||
"""sync_batch_job_progress 按明细实时重算 done(=完成+跳过) 与 failed。
|
||
|
||
回归:此前 batch_jobs.done 只在任务收尾一次性汇总,处理中途(尤其暂停)
|
||
恒为 0——前端显示 0/431 0%,与实际已处理数量严重不符(真实任务
|
||
batch_fee668175444 已处理 15 个仍显示 0/431)。本测试要求无论任务处于
|
||
哪种状态,汇总字段都与明细实时一致。
|
||
"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4", "c.mp4", "d.mp4"))
|
||
job = _make_job(db, folder)
|
||
job_id = job["id"]
|
||
videos = {Path(v["video_path"]).stem: v for v in db.list_batch_videos(job_id)}
|
||
|
||
# 手工构造典型中途态:a 已完成、b 处理中被暂停、c 失败、d 仍排队。
|
||
db.update_batch_video(videos["a"]["id"], status="COMPLETED", updated_at=_now_iso())
|
||
db.update_batch_video(videos["b"]["id"], status="PAUSED", updated_at=_now_iso())
|
||
db.update_batch_video(videos["c"]["id"], status="FAILED", error="boom", updated_at=_now_iso())
|
||
# 任务保持 PAUSED(引擎在视频间停下时的真实状态)。
|
||
db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
|
||
|
||
db.sync_batch_job_progress(job_id)
|
||
synced = db.get_batch_job(job_id)
|
||
# done 计入已完成 + 已跳过(与收尾口径一致);失败单独计;进行中不计。
|
||
assert synced["done"] == 1
|
||
assert synced["failed"] == 1
|
||
|
||
# 幂等:重复对齐结果不变(引擎在多个边界可能重复调用)。
|
||
db.sync_batch_job_progress(job_id)
|
||
assert db.get_batch_job(job_id)["done"] == 1
|
||
|
||
|
||
def test_batch_progress_sync_counts_skipped(tmp_path) -> None:
|
||
"""SKIPPED 视频不计入 total 也不计入 done(它不需要本批处理)。
|
||
|
||
新口径(2026-09):进度只反映"本批无字幕待处理"的视频。
|
||
"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4",))
|
||
job = _make_job(db, folder)
|
||
# 把 a 改为 SKIPPED(等同创建时旁挂字幕被跳过的语义)。
|
||
db.update_batch_video(db.list_batch_videos(job["id"])[0]["id"], status="SKIPPED", updated_at=_now_iso())
|
||
db.sync_batch_job_progress(job["id"])
|
||
job = db.get_batch_job(job["id"])
|
||
assert job["done"] == 0
|
||
assert job["total"] == 0
|
||
|
||
|
||
def test_batch_progress_sync_returns_refreshed_job(tmp_path) -> None:
|
||
"""refresh_batch_job 返回对齐后的最新任务记录(供 router 读取侧使用)。"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
job = _make_job(db, folder)
|
||
job_id = job["id"]
|
||
db.update_batch_video(db.list_batch_videos(job_id)[0]["id"], status="COMPLETED", updated_at=_now_iso())
|
||
refreshed = db.refresh_batch_job(job_id)
|
||
assert refreshed is not None
|
||
assert refreshed["done"] == 1
|
||
# 幽灵任务:返回 None,不报错。
|
||
assert db.refresh_batch_job("batch_ghost") is None
|
||
|
||
|
||
def test_batch_worker_paused_job_reports_real_done(tmp_path, monkeypatch) -> None:
|
||
"""引擎在视频之间暂停后,job 汇总实时反映已完成数量(不等任务收尾)。
|
||
|
||
回归:真实任务 batch_fee668175444 手动暂停后前端仍显示 0/431 0%,
|
||
因为 done 只在任务整体 COMPLETED 时汇总一次。修复后引擎每次停下都要
|
||
用明细实时对齐 done/failed,暂停中前端即可看到真实进度。
|
||
"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
job = _make_job(db, folder)
|
||
job_id = job["id"]
|
||
from wov_app.scheduler import WorkflowScheduler
|
||
|
||
class _PauseAfterFirstScheduler:
|
||
"""真实执行第一个视频后把批量任务置为 PAUSED(模拟用户处理中暂停)。"""
|
||
|
||
def __init__(self, db: Database, work_dir: Path) -> None:
|
||
self.db = db
|
||
self.work_dir = work_dir
|
||
|
||
def execute_run(self, run_id: str) -> None:
|
||
WorkflowScheduler(self.db, self.work_dir).execute_run(run_id)
|
||
self.db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
|
||
|
||
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _PauseAfterFirstScheduler)
|
||
BatchWorker(db, interval_seconds=0.05)._process_job(db.get_batch_job(job_id))
|
||
|
||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job_id)}
|
||
assert videos["a.mp4"]["status"] == "COMPLETED"
|
||
assert db.get_batch_job(job_id)["status"] == "PAUSED"
|
||
# 关键断言:暂停瞬间 done 已是 1(a 完成),而不是停留在创建时的 0。
|
||
assert db.get_batch_job(job_id)["done"] == 1
|
||
|
||
|
||
def test_batch_worker_skipped_continue_syncs_done(tmp_path) -> None:
|
||
"""循环里遇到 SKIPPED/COMPLETED 的 continue 分支不把它们计入 done。
|
||
|
||
新口径:total=无字幕视频数(本例 a 有字幕 SKIPPED → total=1 即 b),
|
||
done 只计实际完成(b=COMPLETED → done=1)。SKIPPED 不占分母也不占分子。
|
||
"""
|
||
db = _db(tmp_path)
|
||
_seed_echo_workflow(db)
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
(folder / "a.CN.srt").write_text("x", encoding="utf-8") # a 创建即 SKIPPED
|
||
job = _make_job(db, folder)
|
||
job_id = job["id"]
|
||
# 创建即固定:total=无字幕数(仅 b=1)。
|
||
assert db.get_batch_job(job_id)["total"] == 1
|
||
# b 手工置为 COMPLETED(模拟此前已完成);a 在循环里走 continue 分支。
|
||
db.update_batch_video(
|
||
[v for v in db.list_batch_videos(job_id) if v["video_path"].endswith("b.mp4")][0]["id"],
|
||
status="COMPLETED", updated_at=_now_iso(),
|
||
)
|
||
db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
|
||
BatchWorker(db, interval_seconds=0.05)._run_job(job_id)
|
||
job = db.get_batch_job(job_id)
|
||
# done 只计实际完成的 b(1),SKIPPED 的 a 不计;total 保持 1。
|
||
assert job["done"] == 1 and job["total"] == 1
|
||
|
||
def test_batch_worker_ghost_job_id_returns(tmp_path) -> None:
|
||
"""_run_job 在任务不存在时直接返回(幽灵任务处理无副作用)。"""
|
||
db = _db(tmp_path)
|
||
BatchWorker(db, interval_seconds=0.05)._run_job("batch_ghost")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 批量 API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _client_with_echo_workflow(tmp_path):
|
||
"""返回 TestClient 与包含真实视频文件的文件夹(echo 工作流已发布)。"""
|
||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||
client = TestClient(app)
|
||
client.__enter__()
|
||
client.post(
|
||
"/api/admin/workflows",
|
||
json={
|
||
"id": "echo-app",
|
||
"name": "Echo App",
|
||
"description": "batch test",
|
||
"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/echo-app/publish")
|
||
return client, folder
|
||
|
||
|
||
def test_batch_api_create_list_detail(tmp_path) -> None:
|
||
"""批量 API:创建任务、列表、详情(含 SKIPPED 状态与视频旁产物清单)。"""
|
||
client, folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
# a 视频旁已有字幕(创建即 SKIPPED),b 待处理。
|
||
(folder / "a.CN_dual_eye.ass").write_text("已有字幕", encoding="utf-8")
|
||
response = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(folder), "workflow_id": "echo-app", "recursive": True},
|
||
)
|
||
assert response.status_code == 200
|
||
job = response.json()
|
||
assert job["status"] == "QUEUED"
|
||
by_name = {Path(v["video_path"]).name: v for v in job["videos"]}
|
||
assert by_name["a.mp4"]["status"] == "SKIPPED"
|
||
assert by_name["a.mp4"]["finals"] == {"a.CN_dual_eye.ass": "a.CN_dual_eye.ass"}
|
||
assert by_name["b.mp4"]["status"] == "PENDING"
|
||
assert by_name["b.mp4"]["finals"] == {}
|
||
|
||
listed = client.get("/api/batch/jobs").json()
|
||
assert any(item["id"] == job["id"] for item in listed)
|
||
|
||
detail = client.get(f"/api/batch/jobs/{job['id']}").json()
|
||
assert detail["workflow_id"] == "echo-app"
|
||
assert len(detail["videos"]) == 2
|
||
# 新口径:total = 无字幕需处理数(b 一个),SKIPPED 的 a 不占分母。
|
||
assert detail["total"] == 1 and detail["done"] == 0
|
||
|
||
missing = client.get("/api/batch/jobs/ghost")
|
||
assert missing.status_code == 404
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
|
||
|
||
def test_batch_api_all_skipped_created_completed(tmp_path) -> None:
|
||
"""全部视频已有字幕时,创建响应直接为 COMPLETED,total=0。"""
|
||
client, folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
# 两个视频都已有旁挂字幕 → 无任何无字幕项。
|
||
(folder / "a.CN.srt").write_text("x", encoding="utf-8")
|
||
(folder / "b.CN_dual_eye.ass").write_text("x", encoding="utf-8")
|
||
job = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(folder), "workflow_id": "echo-app"},
|
||
).json()
|
||
assert job["status"] == "COMPLETED"
|
||
assert job["total"] == 0 and job["done"] == 0
|
||
assert all(v["status"] == "SKIPPED" for v in job["videos"])
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
def test_batch_api_creation_errors(tmp_path) -> None:
|
||
"""批量 API 拒绝:文件夹不存在、无视频、未发布工作流。"""
|
||
client, folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
bad_folder = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(tmp_path / "missing"), "workflow_id": "echo-app"},
|
||
)
|
||
assert bad_folder.status_code == 422
|
||
empty = tmp_path / "empty"
|
||
empty.mkdir()
|
||
no_videos = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(empty), "workflow_id": "echo-app"},
|
||
)
|
||
assert no_videos.status_code == 422
|
||
not_published = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(folder), "workflow_id": "ghost"},
|
||
)
|
||
assert not_published.status_code == 422
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
def test_batch_api_list_detail_syncs_real_progress(tmp_path) -> None:
|
||
"""列表/详情读取前自动对齐 done/failed:暂停中的任务也显示真实进度。
|
||
|
||
回归:服务运行中任务被暂停(batch_fee668175444),前端轮询列表看到
|
||
0/431 0%——因为 done 只在收尾时汇总、暂停后无人再写。读取侧兜底对齐
|
||
保证前端拿到明细真实状态,即使引擎不在运行(进程被杀/暂停中)。
|
||
"""
|
||
client, folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
job = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(folder), "workflow_id": "echo-app"},
|
||
).json()
|
||
job_id = job["id"]
|
||
db = app.state.db
|
||
|
||
# 模拟暂停中已处理 1 个(COMPLETED)+ 1 个失败:明细变了,但 job.done 仍是 0。
|
||
videos = db.list_batch_videos(job_id)
|
||
db.update_batch_video(videos[0]["id"], status="COMPLETED", updated_at=_now_iso())
|
||
db.update_batch_video(videos[1]["id"], status="FAILED", error="x", updated_at=_now_iso())
|
||
db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
|
||
assert db.get_batch_job(job_id)["done"] == 0 # 修复前:旧值
|
||
|
||
# 读取详情:返回的 job 已完成实时对齐。
|
||
detail = client.get(f"/api/batch/jobs/{job_id}").json()
|
||
assert detail["done"] == 1 and detail["failed"] == 1
|
||
# 数据库里的汇总也一并修正(读取副作用:后续列表/引擎都看到正确值)。
|
||
assert db.get_batch_job(job_id)["done"] == 1
|
||
|
||
# 列表接口同样实时对齐。
|
||
listed = client.get("/api/batch/jobs").json()
|
||
mine = next(item for item in listed if item["id"] == job_id)
|
||
assert mine["done"] == 1 and mine["failed"] == 1
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
def test_batch_api_pause_resume_delete(tmp_path) -> None:
|
||
"""批量 API:暂停/继续切换任务状态,删除清理 DB 记录与私有工作空间。"""
|
||
client, folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
job = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(folder), "workflow_id": "echo-app"},
|
||
).json()
|
||
|
||
paused = client.post(f"/api/batch/jobs/{job['id']}/pause")
|
||
assert paused.status_code == 200
|
||
assert client.get(f"/api/batch/jobs/{job['id']}").json()["status"] == "PAUSED"
|
||
|
||
resumed = client.post(f"/api/batch/jobs/{job['id']}/resume")
|
||
assert resumed.status_code == 200
|
||
assert client.get(f"/api/batch/jobs/{job['id']}").json()["status"] == "QUEUED"
|
||
|
||
# 给第一个视频挂一个 run,验证删除任务时级联删除 run 记录。
|
||
db = app.state.db
|
||
video = job["videos"][0]
|
||
_create_run(db, "run_del", folder, name=Path(video["video_path"]).stem)
|
||
db.update_batch_video(video["id"], run_id="run_del", updated_at=_now_iso())
|
||
assert db.get_run("run_del") is not None
|
||
# 模拟任务遗留的私有工作空间:删除任务时一并清理。
|
||
workspace = BATCH_WORK_ROOT / job["id"]
|
||
(workspace / "runs" / "run_del").mkdir(parents=True)
|
||
(workspace / "runs" / "run_del" / "paused.flag").write_text("", encoding="utf-8")
|
||
|
||
deleted = client.delete(f"/api/batch/jobs/{job['id']}")
|
||
assert deleted.status_code == 200
|
||
assert client.get(f"/api/batch/jobs/{job['id']}").status_code == 404
|
||
assert db.get_run("run_del") is None
|
||
assert not workspace.exists()
|
||
|
||
assert client.post("/api/batch/jobs/ghost/pause").status_code == 404
|
||
assert client.post("/api/batch/jobs/ghost/resume").status_code == 404
|
||
assert client.delete("/api/batch/jobs/ghost").status_code == 404
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
def test_batch_api_worker_unavailable(tmp_path, monkeypatch) -> None:
|
||
"""批量引擎不可用时,暂停/继续接口返回 503。"""
|
||
client, folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
job = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(folder), "workflow_id": "echo-app"},
|
||
).json()
|
||
monkeypatch.setattr("wov_app.routers.batch._get_worker", lambda: None)
|
||
assert client.post(f"/api/batch/jobs/{job['id']}/pause").status_code == 503
|
||
assert client.post(f"/api/batch/jobs/{job['id']}/resume").status_code == 503
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
def test_batch_api_download_sidecar_subtitle(tmp_path) -> None:
|
||
"""批量 API:下载视频旁的字幕文件,缺失别名/文件返回 404。"""
|
||
client, folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
(folder / "a.CN_dual_eye.ass").write_text("已有字幕内容", encoding="utf-8")
|
||
job = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(folder), "workflow_id": "echo-app"},
|
||
).json()
|
||
video = next(v for v in job["videos"] if Path(v["video_path"]).name == "a.mp4")
|
||
|
||
ok = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=a.CN_dual_eye.ass")
|
||
assert ok.status_code == 200
|
||
assert ok.content == "已有字幕内容".encode("utf-8")
|
||
|
||
bad_alias = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=nope")
|
||
assert bad_alias.status_code == 404
|
||
bad_video = client.get(f"/api/batch/jobs/{job['id']}/videos/bv_ghost/download?alias=a.CN_dual_eye.ass")
|
||
assert bad_video.status_code == 404
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
def test_batch_api_download_sidecar_file_missing(tmp_path, monkeypatch) -> None:
|
||
"""下载时旁挂字幕文件已被外部移除(列表与下载之间的竞态)→ 404。"""
|
||
client, folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
(folder / "a.srt").write_text("x", encoding="utf-8")
|
||
job = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(folder), "workflow_id": "echo-app"},
|
||
).json()
|
||
video = next(v for v in job["videos"] if Path(v["video_path"]).name == "a.mp4")
|
||
|
||
# 模拟列表后文件被删:让旁挂字幕扫描返回一个磁盘上已不存在的路径。
|
||
gone = folder / "a.srt"
|
||
gone.unlink()
|
||
monkeypatch.setattr(batch_engine, "list_sidecar_subtitles", lambda video_path: [gone])
|
||
missing = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=a.srt")
|
||
assert missing.status_code == 404
|
||
assert "file missing" in missing.json()["detail"]
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
def test_batch_api_download_legacy_marker(tmp_path) -> None:
|
||
"""批量 API:旧版完成标记(batch.done.json)里的语义别名仍可下载。"""
|
||
client, folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
job = client.post(
|
||
"/api/batch/jobs",
|
||
json={"folder": str(folder), "workflow_id": "echo-app"},
|
||
).json()
|
||
db = app.state.db
|
||
video = job["videos"][0]
|
||
# 旧版布局:work_dir 是视频的同名文件夹,里面写 batch.done.json + 产物。
|
||
work = Path(video["work_dir"])
|
||
work.mkdir(parents=True)
|
||
marker = {"workflow_id": "echo-app", "workflow_version": 1, "run_id": "run_old", "finals": {"result": "a.result.20260819000000.txt"}}
|
||
(work / MARKER_NAME).write_text(json.dumps(marker), encoding="utf-8")
|
||
(work / "a.result.20260819000000.txt").write_text("下载内容", encoding="utf-8")
|
||
|
||
# 详情页产物清单合并旧版完成标记里的语义别名。
|
||
detail = client.get(f"/api/batch/jobs/{job['id']}").json()
|
||
video_detail = next(v for v in detail["videos"] if v["id"] == video["id"])
|
||
assert video_detail["finals"]["result"] == "a.result.20260819000000.txt"
|
||
|
||
ok = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=result")
|
||
assert ok.status_code == 200
|
||
assert ok.content == "下载内容".encode("utf-8")
|
||
# 标记里别名对应的产物文件缺失 → 404(不会落到旁挂字幕解析)。
|
||
(work / "a.result.20260819000000.txt").unlink()
|
||
gone = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=result")
|
||
assert gone.status_code == 404
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
def test_batch_api_roots(tmp_path, monkeypatch) -> None:
|
||
"""目录树选择器:返回可浏览根目录(含根/家目录与 Windows 盘符分支)。"""
|
||
client, _folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
roots = client.get("/api/batch/roots").json()
|
||
assert isinstance(roots, list) and len(roots) >= 1
|
||
# POSIX 必有 /;Windows 必有盘符;两者都有家目录。
|
||
assert any(item["path"] in ("/", str(Path.home())) for item in roots)
|
||
assert all(item["name"] for item in roots)
|
||
|
||
# Windows 分支:模拟 os.name=nt,存在盘符时返回该驱动器。
|
||
import os
|
||
|
||
real_exists = Path.exists
|
||
|
||
def fake_exists(path):
|
||
# 盘符形式(如 C:\)视为存在,其余走真实判断。
|
||
return str(path).endswith(":\\") or real_exists(path)
|
||
|
||
monkeypatch.setattr(os, "name", "nt")
|
||
monkeypatch.setattr(Path, "exists", fake_exists)
|
||
nt_roots = client.get("/api/batch/roots").json()
|
||
assert any(str(item["path"]).endswith(":\\") for item in nt_roots)
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
def test_batch_api_dirs(tmp_path, monkeypatch) -> None:
|
||
"""目录树选择器:列出子目录、隐藏目录过滤、不存在/不可读返回空。"""
|
||
client, _folder = _client_with_echo_workflow(tmp_path)
|
||
try:
|
||
# 真实目录结构:普通子目录、隐藏目录、文件。
|
||
target = tmp_path / "media"
|
||
(target / "movies").mkdir(parents=True)
|
||
(target / "series").mkdir(parents=True)
|
||
(target / ".hidden").mkdir(parents=True)
|
||
(target / "note.txt").write_text("x", encoding="utf-8")
|
||
|
||
data = client.get(f"/api/batch/dirs?path={target}").json()
|
||
assert [item["name"] for item in data["dirs"]] == ["movies", "series"]
|
||
assert data["path"] == str(target)
|
||
|
||
# 路径指向文件 → 空列表。
|
||
file_data = client.get(f"/api/batch/dirs?path={target / 'note.txt'}").json()
|
||
assert file_data["dirs"] == []
|
||
# 目录不存在 → 空列表。
|
||
missing = client.get(f"/api/batch/dirs?path={tmp_path / 'ghost'}").json()
|
||
assert missing["dirs"] == []
|
||
|
||
from pathlib import Path as RealPath
|
||
|
||
# 单个子项不可读(is_dir 抛 OSError)→ 跳过该项,其余目录正常返回。
|
||
class _PoisonPath(RealPath):
|
||
"""is_dir 恒抛权限错误的子类,模拟不可读的子目录。"""
|
||
|
||
def is_dir(self):
|
||
raise OSError("denied")
|
||
|
||
real_iterdir = RealPath.iterdir
|
||
|
||
def mixed_iterdir(path):
|
||
return list(real_iterdir(path)) + [_PoisonPath(str(tmp_path / "poison"))]
|
||
|
||
monkeypatch.setattr(RealPath, "iterdir", mixed_iterdir)
|
||
mixed = client.get(f"/api/batch/dirs?path={target}").json()
|
||
assert [item["name"] for item in mixed["dirs"]] == ["movies", "series"]
|
||
|
||
# 整个目录不可读(iterdir 抛 OSError)→ 空列表而不是 500。
|
||
def deny(path):
|
||
raise OSError("denied")
|
||
|
||
monkeypatch.setattr(RealPath, "iterdir", deny)
|
||
denied = client.get(f"/api/batch/dirs?path={target}").json()
|
||
assert denied["dirs"] == []
|
||
finally:
|
||
client.__exit__(None, None, None)
|
||
|
||
|
||
def test_main_starts_batch_worker_when_enabled(monkeypatch) -> None:
|
||
"""WOV_BATCH_ENABLED=1 时应用生命周期启动批量引擎后台线程(退出时回收)。"""
|
||
monkeypatch.setenv("WOV_BATCH_ENABLED", "1")
|
||
client = TestClient(app)
|
||
with client:
|
||
worker = app.state.batch
|
||
assert worker is not None
|
||
assert worker._thread is not None
|
||
assert worker._thread.name == "wov-batch-worker"
|
||
# 退出应用后批量引擎线程已停止,后续测试不会被后台线程打扰。
|
||
assert worker._thread is None
|
||
|
||
|
||
# STORAGE_DIR 引用仅供静态检查使用(ensure batch 根相对应用存储目录)。
|
||
assert BATCH_WORK_ROOT == STORAGE_DIR / "batch"
|