Files
vrsub/tests/web/test_batch/test_batch_js.py
T
cat-shark dcdc5e8604 feat: 批量分块流水线、本地模型显存让渡与任务列表分工
批量引擎改为「分块流水线」:视频按 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 用例)。
2026-09-18 10:31:52 +08:00

155 lines
5.1 KiB
Python

"""web/assets/batch.js 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`web/assets/batch.js`(批量处理页的渲染逻辑:任务进度、操作按钮、
视频明细表 HTML)。
实现方式:在 Node.js 子进程中加载真实 JS 文件并调用真实函数(不重写逻辑),
验证渲染出的 HTML 内容;环境无 node 时跳过(保持跨平台可运行)。
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
# 仓库根与被测脚本。
WORKSPACE = Path(__file__).resolve().parents[3]
APP_JS = WORKSPACE / "web" / "assets" / "app.js"
BATCH_JS = WORKSPACE / "web" / "assets" / "batch.js"
# 用真实 JS 引擎执行调用的封装:按页面加载顺序执行 app.js(提供
# escapeHtml/badge 等公共函数)与 batch.js,再调用后者的真实函数。
# 渲染不需要真实 DOM,只提供脚本顶层引用到的 document 桩。
_CALL_SCRIPT = """
const fs = require("fs");
const vm = require("vm");
const sandbox = {
module: { exports: {} },
document: { addEventListener: () => {}, getElementById: () => null },
setInterval: () => 0,
console,
};
vm.createContext(sandbox);
for (const path of [process.argv[1], process.argv[2]]) {
vm.runInContext(fs.readFileSync(path, "utf8"), sandbox);
}
const payload = JSON.parse(process.argv[3]);
const fn = sandbox.module.exports[payload.fn];
process.stdout.write(JSON.stringify(fn(...payload.args)));
"""
def _run(fn: str, *args) -> object:
"""在 node 中调用 batch.js 的真实函数并返回解析后的结果。"""
node = shutil.which("node")
if node is None:
pytest.skip("环境没有 node,跳过 JS 模块测试")
completed = subprocess.run(
[
node,
"-e",
_CALL_SCRIPT,
str(APP_JS),
str(BATCH_JS),
json.dumps({"fn": fn, "args": list(args)}),
],
capture_output=True,
text=True,
)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
# ---------------------------------------------------------------------------
# videoDetailTable:详情明细表
# ---------------------------------------------------------------------------
def _video(name: str, status: str) -> dict:
"""构造一条明细数据(字段与 GET /api/batch/jobs/{id} 返回的一致)。"""
return {
"id": f"bv_{name}",
"video_path": f"/videos/{name}",
"status": status,
"error": None,
"finals": {},
}
def test_detail_table_lists_processing_and_completed_videos() -> None:
"""正常明细:待处理与已完成的视频都出现在表格行里。"""
# 数据:一个待处理、一个已完成。
videos = [_video("a.mp4", "PENDING"), _video("c.mp4", "COMPLETED")]
# 测试过程
html = _run("videoDetailTable", "batch_1", videos)
# 验证结果
assert "a.mp4" in html
assert "c.mp4" in html
assert "PENDING" in html and "COMPLETED" in html
def test_detail_table_hides_skipped_videos() -> None:
"""详情列表不展示 SKIPPED(视频旁已有字幕、本次未处理)的视频行。"""
# 数据:待处理、跳过、完成各一个。
videos = [
_video("a.mp4", "PENDING"),
_video("b.mp4", "SKIPPED"),
_video("c.mp4", "COMPLETED"),
]
# 测试过程
html = _run("videoDetailTable", "batch_1", videos)
# 验证结果:跳过的那行完全不出现。
assert "a.mp4" in html
assert "c.mp4" in html
assert "b.mp4" not in html
assert "SKIPPED" not in html
def test_detail_table_with_only_skipped_shows_hint() -> None:
"""整批视频都已被跳过时给出提示,而不是渲染空表格。"""
# 数据:两个 SKIPPED。
videos = [_video("a.mp4", "SKIPPED"), _video("b.mp4", "SKIPPED")]
# 测试过程
html = _run("videoDetailTable", "batch_1", videos)
# 验证结果:无表格行,只提示无待处理视频。
assert "a.mp4" not in html and "b.mp4" not in html
assert "<table" not in html
assert "无待处理视频" in html
def test_detail_table_shows_stage_for_running_video() -> None:
"""处理中的视频显示阶段(第几阶段/共几阶段 + 中文标签)。"""
# 数据:一个处理到第二阶段的视频(字段与 GET /api/batch/jobs/{id} 一致)。
video = _video("a.mp4", "RUNNING")
video.update({"stage_label": "转写", "stage_index": 2, "stage_total": 4})
# 测试过程
html = _run("videoDetailTable", "batch_1", [video])
# 验证结果:表头与单元格都带阶段信息。
assert "<th>阶段</th>" in html
assert "阶段 2/4 · 转写" in html
def test_detail_table_shows_placeholder_without_stage() -> None:
"""未开始的视频阶段列显示占位符(不报错)。"""
# 数据:一个待处理视频(没有阶段字段)。
video = _video("a.mp4", "PENDING")
# 测试过程
html = _run("videoDetailTable", "batch_1", [video])
# 验证结果:阶段列为占位符。
assert "<th>阶段</th>" in html
assert "阶段 " not in html