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。
This commit is contained in:
2026-09-13 15:40:56 +08:00
parent 966f3e6b4b
commit 8a715a8064
139 changed files with 20810 additions and 10733 deletions
View File
@@ -0,0 +1,19 @@
1
00:00:00,000 --> 00:00:01,440
---
2
00:00:01,440 --> 00:00:04,320
SUB 001
3
00:00:05,760 --> 00:00:06,240
---
4
00:00:06,240 --> 00:00:09,120
SUB 002
5
00:00:09,600 --> 00:00:10,080
---
+224
View File
@@ -0,0 +1,224 @@
"""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 == "---"