Files
vrsub/tests/nodes/test_ass/test_ass.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

289 lines
9.7 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.
"""nodes/ass.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/ass.py`SRT → VR 双目 ASS,含统一样式出口),被
srt-to-dual-eye-ass 节点与历史字幕统脚本共用,可独立调用。
覆盖:SRT 解析(含多行与畸形行)、ASS 头与样式行、左右眼对照与零视差、
顶部安全区(an8 + MarginV=700)、透明度、margin_top 参数化、invoke 全流程。
"""
from __future__ import annotations
from pathlib import Path
from nodes.ass import (
DEFAULT_MARGIN_TOP,
_ass_header,
ass_header,
dialogue_line,
invoke,
parse_srt,
style_row,
write_ass,
)
from wov_sdk.models import InvokeRequest
# 模块专用测试数据:两条(第二条为多行)SRT。
SAMPLE_SRT = """1
00:00:01,000 --> 00:00:02,000
第一行
第二行
2
00:00:04,000 --> 00:00:06,000
第三行
"""
def _request(tmp_path: Path, srt_text: str, **params) -> InvokeRequest:
"""构造真实 InvokeRequest:把输入 SRT 落到临时目录,输出目录同目录下。"""
srt_path = tmp_path / "input.srt"
srt_path.write_text(srt_text, encoding="utf-8")
return InvokeRequest(
run_id="run-test",
node_instance_id="ass-1",
params=params,
inputs={"cn_srt_uri": str(srt_path)},
output_dir=str(tmp_path / "out"),
)
# ---------------------------------------------------------------------------
# SRT 解析
# ---------------------------------------------------------------------------
def test_parse_srt_converts_comma_to_dot_and_joins_multiline() -> None:
"""SRT 时间戳逗号转 ASS 点号;多行正文用 \\N 连接。"""
# 数据:两条,第一条两行正文。
text = SAMPLE_SRT
# 测试过程
entries = parse_srt(text)
# 验证结果:条数、时间戳格式与多行连接符。
assert len(entries) == 2
assert entries[0][0] == "00:00:01.000"
assert entries[0][1] == "00:00:02.000"
assert entries[0][2] == r"第一行\N第二行"
def test_parse_srt_skips_malformed_timeline() -> None:
"""缺少时间轴分隔符的畸形条目被跳过,不产生错误条目。"""
# 数据:一条畸形(无 -->)+ 一条正常。
text = "1\n00:00:01,000\n坏条目\n\n2\n00:00:04,000 --> 00:00:06,000\n正常\n"
# 测试过程
entries = parse_srt(text)
# 验证结果:只解析出正常条目。
assert len(entries) == 1
assert entries[0][2] == "正常"
def test_parse_srt_empty_text() -> None:
"""空文本解析为空列表。"""
# 数据:空字符串。
# 测试过程与验证结果
assert parse_srt("") == []
# ---------------------------------------------------------------------------
# 样式:单一事实来源
# ---------------------------------------------------------------------------
def test_ass_header_contains_resolution_and_styles() -> None:
"""ASS 头包含分辨率、左右眼两条样式与必需字段。"""
# 数据:VR 常用分辨率 3840x1920。
# 测试过程
header = ass_header(3840, 1920)
# 验证结果
assert "PlayResX: 3840" in header
assert "PlayResY: 1920" in header
assert "Style: LeftEye," in header
assert "Style: RightEye," in header
assert "[Script Info]" in header and "[V4+ Styles]" in header and "[Events]" in header
def test_style_row_top_aligned_with_margin() -> None:
"""样式行使用 an8 顶部对齐 + MarginV 顶部安全边距(默认 700)。"""
# 数据:左眼样式,宽 3840,默认边距。
# 测试过程
row = style_row("LeftEye", 3840)
# 验证结果:末尾字段为 Alignment=8、MarginL=50、MarginR=1920(右眼区)、MarginV=700。
assert row.endswith(",8,50,1920,700,1")
def test_style_row_uses_translucent_fill_and_outline() -> None:
"""文字填充约 70% 透明、描边半透明黑,降低对画面的遮挡。"""
# 数据:任一样式行。
# 测试过程
row = style_row("LeftEye", 3840)
# 验证结果
assert "&HB3FFFFFF" in row
assert "&H80000000" in row
assert "Arial" in row
def test_default_margin_top_is_700() -> None:
"""默认顶部安全边距常量是 700(2026-09 调整,历史 120 已废弃)。"""
# 数据:模块常量。
# 测试过程与验证结果
assert DEFAULT_MARGIN_TOP == 700
def test_dialogue_line_prefixes_top_alignment() -> None:
"""Dialogue 行固定前缀 {\\an8},保证每条字幕落在顶部安全区。"""
# 数据:一条字幕。
# 测试过程
line = dialogue_line("LeftEye", "0:00:01.00", "0:00:02.00", "你好")
# 验证结果
assert line == r"Dialogue: 0,0:00:01.00,0:00:02.00,LeftEye,,0,0,0,,{\an8}你好"
# ---------------------------------------------------------------------------
# 写出:左右眼与零视差
# ---------------------------------------------------------------------------
def test_write_ass_emits_both_eyes_with_identical_text(tmp_path: Path) -> None:
"""每个条目输出左右眼两行 Dialogue,文本与水平相对位置完全一致(零视差)。"""
# 数据:单条字幕。
entries = parse_srt("1\n00:00:01,000 --> 00:00:02,000\n台词\n")
output = tmp_path / "out.ass"
# 测试过程
write_ass(entries, output, "3840x1920")
content = output.read_text(encoding="utf-8")
# 验证结果:两条 DialogueLeftEye/RightEye),文本各出现一次。
dialogue = [line for line in content.splitlines() if line.startswith("Dialogue:")]
assert len(dialogue) == 2
assert "LeftEye" in dialogue[0] and "RightEye" in dialogue[1]
assert dialogue[0].split(",,")[-1] == dialogue[1].split(",,")[-1]
def test_write_ass_custom_margin_top_changes_style_row(tmp_path: Path) -> None:
"""margin_top 参数化到样式行(不同分辨率/内容可微调顶部边距)。"""
# 数据:自定义边距 300。
entries = parse_srt(SAMPLE_SRT)
output = tmp_path / "out.ass"
# 测试过程
write_ass(entries, output, "3840x1920", margin_top=300)
content = output.read_text(encoding="utf-8")
# 验证结果:样式行的 MarginV 为 300。
assert ",8,50,1920,300,1" in content
def test_write_ass_multiple_entries_keep_order(tmp_path: Path) -> None:
"""多条字幕按输入顺序输出(时间轴顺序不被打乱)。"""
# 数据:两条字幕。
entries = parse_srt(SAMPLE_SRT)
output = tmp_path / "out.ass"
# 测试过程
write_ass(entries, output, "3840x1920")
dialogue = [
line for line in output.read_text(encoding="utf-8").splitlines()
if line.startswith("Dialogue:")
]
# 验证结果:第 1、2 行属于第一条,第 3、4 行属于第二条。
assert "00:00:01.000" in dialogue[0] and "00:00:02.000" in dialogue[1]
assert "00:00:04.000" in dialogue[2] and "00:00:06.000" in dialogue[3]
def test_ass_header_compat_interface_matches_new_header() -> None:
"""兼容接口 _ass_header 与新版 ass_header 对同一分辨率输出一致(样式不漂移)。"""
# 数据:同一分辨率字符串与整数宽高。
# 测试过程与验证结果
assert _ass_header("3840x1920") == ass_header(3840, 1920)
# ---------------------------------------------------------------------------
# invoke 全流程
# ---------------------------------------------------------------------------
def test_invoke_writes_ass_and_returns_uri(tmp_path: Path) -> None:
"""invoke 读取 cn_srt_uri,写出 dual_eye.ass 并返回产物 URI。"""
# 数据:真实输入 SRT 文件 + 默认参数。
request = _request(tmp_path, SAMPLE_SRT)
# 测试过程
response = invoke(request)
# 验证结果:状态、产物存在、内容含左右眼。
assert response.status == "completed"
artifact = Path(response.outputs["ass_uri"])
assert artifact.is_file()
assert artifact.name == "dual_eye.ass"
assert "LeftEye" in artifact.read_text(encoding="utf-8")
def test_invoke_honors_resolution_and_margin_params(tmp_path: Path) -> None:
"""invoke 透传 resolution 与 margin_top 参数到产物。"""
# 数据:自定义分辨率 1920x1080 与边距 250。
request = _request(tmp_path, SAMPLE_SRT, resolution="1920x1080", margin_top=250)
# 测试过程
response = invoke(request)
# 验证结果
content = Path(response.outputs["ass_uri"]).read_text(encoding="utf-8")
assert "PlayResX: 1920" in content
assert "PlayResY: 1080" in content
assert "250,1" in content
def test_invoke_default_margin_top_is_700(tmp_path: Path) -> None:
"""未传 margin_top 时使用默认 700。"""
# 数据:不传 margin_top。
request = _request(tmp_path, SAMPLE_SRT)
# 测试过程
response = invoke(request)
# 验证结果
content = Path(response.outputs["ass_uri"]).read_text(encoding="utf-8")
assert ",8,50,1920,700,1" in content
def test_invoke_fails_without_input_uri(tmp_path: Path) -> None:
"""缺少 cn_srt_uri 输入时返回 failed 并说明原因。"""
# 数据:空输入。
request = InvokeRequest(
run_id="r", node_instance_id="n", params={}, inputs={}, output_dir=str(tmp_path)
)
# 测试过程
response = invoke(request)
# 验证结果
assert response.status == "failed"
assert "cn_srt_uri" in (response.error or "")
def test_invoke_fails_when_input_file_missing(tmp_path: Path) -> None:
"""输入指向不存在的文件时返回 failed。"""
# 数据:不存在的路径。
request = InvokeRequest(
run_id="r", node_instance_id="n", params={},
inputs={"cn_srt_uri": str(tmp_path / "missing.srt")},
output_dir=str(tmp_path / "out"),
)
# 测试过程
response = invoke(request)
# 验证结果
assert response.status == "failed"
assert "not found" in (response.error or "")