按"测试规则"重写 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。
225 lines
8.3 KiB
Python
225 lines
8.3 KiB
Python
"""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 == "---"
|