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

225 lines
8.3 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/srt.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/srt.py`SRT 条目解析与序列化),是 `llm-translate`、
`llm-filter` 等节点的公共依赖,可独立调用也可被组合调用,因此按规则
拥有独立的模块测试目录 `tests/nodes/test_srt/`。
结构约定:每个用例先准备输入数据,再调用真实生产代码 `parse_srt` /
`serialize_srt`,最后断言输出结果;数据为测试代码内常量或本目录 `data/`
下的真实字幕文件,不依赖全局 conftest 与其他测试的执行顺序。
"""
from __future__ import annotations
from pathlib import Path
import pytest
from nodes.srt import Cue, parse_srt, serialize_srt
# 模块专用数据目录:真实字幕文件放在测试代码所在目录内,与其他测试区分。
DATA_DIR = Path(__file__).resolve().parent / "data"
# 真实字幕参考文件(5 条,含 `---` 装饰正文与多条连续条目)。
SAMPLE_REFERENCE_SRT = DATA_DIR / "sample.reference.srt"
def test_parses_single_cue() -> None:
"""最基本的合法 SRT:一条字幕解析出时间戳与正文。"""
# 数据:标准格式的单条字幕。
text = "1\n00:00:01,000 --> 00:00:02,000\n你好\n"
# 测试过程:调用真实解析函数。
cues = parse_srt(text)
# 验证结果:条目数量、时间戳与正文完全一致。
assert cues == [Cue("00:00:01,000", "00:00:02,000", "你好")]
def test_accepts_bom_and_crlf() -> None:
"""兼容 Windows BOM 与 CRLF 换行(真实字幕文件常见编码形态)。"""
# 数据:带 BOM 且行尾为 \r\n 的 SRT。
text = "\ufeff1\r\n00:00:01,000 --> 00:00:02,000\r\n你好\r\n"
# 测试过程
cues = parse_srt(text)
# 验证结果:BOM 与 \r 不残留到正文。
assert cues == [Cue("00:00:01,000", "00:00:02,000", "你好")]
def test_keeps_multiline_body() -> None:
"""多行正文(含逗号类标点)必须完整保留内部换行。"""
# 数据:第二条以正文结尾(文件末尾无空行)。
text = (
"1\n00:00:01,000 --> 00:00:02,000\n第一行\n第二行\n\n"
"2\n00:00:03,000 --> 00:00:04,000\n末尾无空行"
)
# 测试过程
cues = parse_srt(text)
# 验证结果:两条条目、正文分别保留内部换行。
assert [cue.text for cue in cues] == ["第一行\n第二行", "末尾无空行"]
def test_keeps_empty_body_with_timeline() -> None:
"""空正文条目保留时间轴(翻译节点需要空 cue 占位对齐)。"""
# 数据:第一条正文为空,第二条有正文。
text = (
"1\n00:00:01,000 --> 00:00:02,000\n\n"
"2\n00:00:03,000 --> 00:00:04,000\n有词\n"
)
# 测试过程
cues = parse_srt(text)
# 验证结果:空正文解析为空字符串,但时间轴不被丢弃。
assert cues[0] == Cue("00:00:01,000", "00:00:02,000", "")
assert len(cues) == 2
def test_treats_whitespace_only_body_as_empty() -> None:
"""仅含空白的正文视为空正文,不作为正文内容写入。"""
# 数据:第一条正文是三个空格。
text = (
"1\n00:00:01,000 --> 00:00:02,000\n \n"
"2\n00:00:03,000 --> 00:00:04,000\nB\n"
)
# 测试过程
cues = parse_srt(text)
# 验证结果
assert [cue.text for cue in cues] == ["", "B"]
def test_accepts_extra_spaces_around_arrow() -> None:
"""时间戳箭头两侧多余空格不导致解析失败。"""
# 数据:箭头两侧各三个空格。
text = "1\n00:00:01,000 --> 00:00:02,000\nA\n"
# 测试过程与验证结果
assert parse_srt(text) == [Cue("00:00:01,000", "00:00:02,000", "A")]
def test_accepts_arbitrary_index_numbers() -> None:
"""序号只需是数字:真实字幕(如参考 SRT)序号可以不从 1 连续。"""
# 数据:序号为 7 与 16(真实参考字幕的形态)。
text = "7\n00:00:01,000 --> 00:00:02,000\nA\n\n16\n00:00:03,000 --> 00:00:04,000\nB\n"
# 测试过程
cues = parse_srt(text)
# 验证结果:序号不参与输出,只保留时间戳与正文。
assert cues == [Cue("00:00:01,000", "00:00:02,000", "A"), Cue("00:00:03,000", "00:00:04,000", "B")]
def test_accepts_hours_over_99() -> None:
"""超过两位的小时数(长视频)必须保留原样。"""
# 数据:小时为 100。
text = "1\n100:00:01,000 --> 100:00:02,000\nA\n"
# 测试过程与验证结果
assert parse_srt(text)[0].start == "100:00:01,000"
def test_empty_and_blank_input_return_no_cues() -> None:
"""空文件与仅含空行的文件都返回空列表,而不是报错。"""
# 数据:空字符串、纯空行两类输入。
# 测试过程与验证结果
assert parse_srt("") == []
assert parse_srt("\n\n\n") == []
def test_rejects_dot_millisecond_separator() -> None:
"""毫秒分隔符是逗号;点号(VTT 风格)必须明确报错而非静默错解析。"""
# 数据:时间戳使用点号。
text = "1\n00:00:01.000 --> 00:00:02.000\nA\n"
# 测试过程与验证结果:抛出 ValueError 并给出行号。
with pytest.raises(ValueError, match="timestamp"):
parse_srt(text)
def test_rejects_missing_index() -> None:
"""缺少序号行时明确报错(否则时间轴行会被当成序号)。"""
# 数据:直接以时间戳开头。
text = "00:00:01,000 --> 00:00:02,000\nA\n"
# 测试过程与验证结果
with pytest.raises(ValueError, match="index"):
parse_srt(text)
def test_rejects_truncated_last_entry() -> None:
"""末尾条目只有序号、没有时间戳时明确报错。"""
# 数据:最后一行是孤立的序号 2。
text = "1\n00:00:01,000 --> 00:00:02,000\nA\n\n2\n"
# 测试过程与验证结果
with pytest.raises(ValueError, match="index"):
parse_srt(text)
def test_rejects_negative_timestamp() -> None:
"""负时间戳非法,必须报错。"""
# 数据:起始时间为负数。
text = "1\n-00:00:01,000 --> 00:00:02,000\nA\n"
# 测试过程与验证结果
with pytest.raises(ValueError, match="timestamp"):
parse_srt(text)
def test_no_blank_line_between_cues_keeps_timeline_in_body() -> None:
"""缺少空行分隔时必须报错,而不是把下一条的时间轴吞进上一条正文。
真实形态:`...\\nA\\n2\\n00:00:03,000 --> 00:00:04,000\\nB\\n`。
当前实现会把 `2` 与时间轴行当作上一条正文,静默产出时间轴错位的字幕
(同 R05 类"静默错位"缺陷:解析不报错,但字幕时间与文本不对应)。
"""
# 数据:两条条目之间没有空行分隔。
text = "1\n00:00:01,000 --> 00:00:02,000\nA\n2\n00:00:03,000 --> 00:00:04,000\nB\n"
# 测试过程与验证结果:应明确报错,不能静默吞并。
with pytest.raises(ValueError):
parse_srt(text)
def test_serialize_renumbers_and_keeps_empty_cue() -> None:
"""序列化按顺序重排序号,空正文条目仍保留时间轴行。"""
# 数据:两条条目,第二条正文为空。
cues = [Cue("00:00:01,000", "00:00:02,000", "A"), Cue("00:00:03,000", "00:00:04,000", "")]
# 测试过程
text = serialize_srt(cues)
# 验证结果:序号连续、空正文条目保留时间轴与尾随空行。
assert text == (
"1\n00:00:01,000 --> 00:00:02,000\nA\n\n"
"2\n00:00:03,000 --> 00:00:04,000\n\n"
)
def test_serialize_empty_list_returns_empty_text() -> None:
"""空列表序列化为空字符串(供节点写出空字幕)。"""
# 数据:空条目列表。
# 测试过程与验证结果
assert serialize_srt([]) == ""
def test_round_trip_is_stable_on_real_reference_srt() -> None:
"""真实参考字幕:解析 → 序列化 → 再解析结果完全一致(时间轴无损)。"""
# 数据:本模块 data/ 下的真实参考字幕(5 条,含 `---` 正文)。
raw = SAMPLE_REFERENCE_SRT.read_text(encoding="utf-8")
# 测试过程:解析后序列化,再解析一次。
first = parse_srt(raw)
second = parse_srt(serialize_srt(first))
# 验证结果:条目数与内容不变,且时间轴行数与源文件一致。
assert first == second
assert len(first) == sum(1 for line in raw.splitlines() if "-->" in line)
assert first[2].text == "---"