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

249 lines
8.9 KiB
Python

"""nodes/subtitle_cleanup.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/subtitle_cleanup.py`(幻觉整条删除 + 短呻吟过滤),被
whisper(日语链路)与 llm-translate(中文链路)复用,纯函数可独立调用。
每个用例构造真实 SRT 文本,调用真实清理函数,并解析输出验证时间轴与序号。
"""
from __future__ import annotations
from nodes.subtitle_cleanup import (
DEFAULT_MOAN_MAX_CHARS,
HALLUCINATION_TOKENS,
JAPANESE_HALLUCINATION_TOKENS,
clean_japanese_hallucinations,
clean_srt_text,
remove_hallucination_entries,
remove_short_moan_entries,
)
from tests.shared.srt_entries import parse_srt_entries
def _srt(*cues: tuple[str, str, str]) -> str:
"""把 (起始, 结束, 文本) 列表拼成标准 SRT 文本,供各用例作为输入数据。"""
blocks = [
f"{i}\n{start} --> {end}\n{text}\n"
for i, (start, end, text) in enumerate(cues, 1)
]
return "\n".join(blocks)
# ---------------------------------------------------------------------------
# 幻觉整条删除(中文词表 / 日语词表)
# ---------------------------------------------------------------------------
def test_removes_long_hallucination_cue_entirely() -> None:
"""展示时长达到阈值的寒暄幻觉整条删除(时间轴不残留空 cue)。"""
# 数据:一条 20s 的"谢谢观看"(超过默认 15s 阈值)。
text = _srt(
("00:00:01,000", "00:00:02,000", "真实的对话"),
("00:00:03,000", "00:00:23,000", "谢谢观看"),
)
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果:只剩真实对话,序号重排为 1,幻想的行完全消失。
entries = parse_srt_entries(cleaned)
assert [e["text"] for e in entries] == ["真实的对话"]
assert cleaned.startswith("1\n")
assert "谢谢观看" not in cleaned
def test_keeps_short_hallucination_when_inside_threshold() -> None:
"""展示时长低于阈值的相同词可能是剧情真实内容,必须保留。"""
# 数据:一条 2s 的"晚安"(剧情中真实互道晚安)。
text = _srt(("00:00:01,000", "00:00:03,000", "晚安"))
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果:保留。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["晚安"]
def test_keeps_non_hallucination_long_cue() -> None:
"""长时但不是幻觉词的内容必须保留(只按词表删除)。"""
# 数据:一条 30s 的正常长台词。
text = _srt(("00:00:01,000", "00:00:31,000", "这是一段很长的真实独白内容"))
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果
assert "这是一段很长的真实独白内容" in cleaned
def test_threshold_boundary_is_inclusive() -> None:
"""阈值边界按"≥ 阈值"删除(严格等于阈值即删除)。"""
# 数据:恰好 15s 的幻觉条目与 14.999s 的同类条目。
text = _srt(
("00:00:00,000", "00:00:15,000", "谢谢观看"),
("00:00:16,000", "00:00:30,999", "感谢观看"),
)
# 测试过程
cleaned = clean_srt_text(text, threshold_seconds=15.0)
# 验证结果:15s 的被删除,14.999s 的保留。
kept = [e["text"] for e in parse_srt_entries(cleaned)]
assert kept == ["感谢观看"]
def test_resequences_after_middle_removal() -> None:
"""删除中间条目后剩余条目从 1 连续编号,保持合法 SRT。"""
# 数据:三条,中间一条是长时幻觉。
text = _srt(
("00:00:01,000", "00:00:02,000", "第一条"),
("00:00:03,000", "00:00:25,000", "谢谢观看"),
("00:00:26,000", "00:00:27,000", "第三条"),
)
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果:序号连续且内容为第一、三条。
assert cleaned.splitlines()[0] == "1"
assert "\n2\n" in cleaned
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["第一条", "第三条"]
def test_japanese_hallucination_removed_and_short_kept() -> None:
"""日语词表:长时"おやすみなさい"删除,短时保留。"""
# 数据:一条 30s 日语幻觉 + 一条 3s 同词。
text = _srt(
("00:01:00,000", "00:01:30,000", "おやすみなさい"),
("00:02:00,000", "00:02:03,000", "おやすみなさい"),
)
# 测试过程
cleaned = clean_japanese_hallucinations(text)
# 验证结果:只保留短的那条。
entries = parse_srt_entries(cleaned)
assert len(entries) == 1
assert entries[0]["start"] == 120.0
def test_custom_token_list_is_honored() -> None:
"""自定义词表生效:只删除传入词命中的条目。"""
# 数据:两个不同的长时条目。
text = _srt(
("00:00:01,000", "00:00:20,000", "自定义幻觉词"),
("00:00:21,000", "00:00:40,000", "谢谢观看"),
)
# 测试过程:只传"自定义幻觉词"。
cleaned = remove_hallucination_entries(text, ("自定义幻觉词",))
# 验证结果:只删除自定义词,"谢谢观看"保留。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["谢谢观看"]
def test_default_tables_are_not_empty() -> None:
"""两张默认词表都非空(防止重构时误清空导致清理失效)。"""
# 数据:模块导出的词表常量。
# 测试过程与验证结果
assert len(HALLUCINATION_TOKENS) > 0
assert len(JAPANESE_HALLUCINATION_TOKENS) > 0
# ---------------------------------------------------------------------------
# 短呻吟过滤(decode_full 去噪)
# ---------------------------------------------------------------------------
def test_removes_pure_moan_fragments() -> None:
"""纯呻吟碎片(あ…/ん?/はぁ…)整条删除。"""
# 数据:三条纯呻吟与一条真实短对话。
text = _srt(
("00:00:01,000", "00:00:02,000", "あ…"),
("00:00:03,000", "00:00:04,000", "ん?"),
("00:00:05,000", "00:00:06,000", "はぁ…"),
("00:00:07,000", "00:00:09,000", "そこ、だめ"),
)
# 测试过程
cleaned = remove_short_moan_entries(text)
# 验证结果:只剩真实短对话。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["そこ、だめ"]
def test_keeps_real_short_dialogue() -> None:
"""含真实假名(そ/や/ね)的短对话不命中判据,必须保留。"""
# 数据:四条真实短对话。
dialog = ["そこ", "やばい", "ねえ", "やだ"]
# 测试过程
cleaned = remove_short_moan_entries(_srt(*[
(f"00:00:0{i},000", f"00:00:0{i + 1},000", text)
for i, text in enumerate(dialog, 1)
]))
# 验证结果:全部保留。
assert [e["text"] for e in parse_srt_entries(cleaned)] == dialog
def test_moan_threshold_boundary() -> None:
"""有效假名数超过阈值(默认 3)的纯呻吟串保留,等于阈值的删除。"""
# 数据:3 个假名(删除)与 4 个假名(保留)。
text = _srt(
("00:00:01,000", "00:00:02,000", "あんあ"),
("00:00:03,000", "00:00:04,000", "あんあん"),
)
# 测试过程
cleaned = remove_short_moan_entries(text, max_chars=3)
# 验证结果
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["あんあん"]
def test_moan_default_max_chars_constant() -> None:
"""默认阈值常量为 3(与文档约定一致,改动需同步文档)。"""
# 数据:模块常量。
# 测试过程与验证结果
assert DEFAULT_MOAN_MAX_CHARS == 3
def test_moan_filter_can_be_disabled() -> None:
"""max_chars=0 时关闭过滤,输入原样返回。"""
# 数据:一条纯呻吟。
text = _srt(("00:00:01,000", "00:00:02,000", "あ…"))
# 测试过程与验证结果
assert remove_short_moan_entries(text, max_chars=0) == text
def test_moan_removal_in_middle_resequences() -> None:
"""删除中间呻吟后剩余条目序号连续。"""
# 数据:真实对话、呻吟、真实对话。
text = _srt(
("00:00:01,000", "00:00:02,000", "行くよ"),
("00:00:03,000", "00:00:04,000", "ん…"),
("00:00:05,000", "00:00:06,000", "だめ"),
)
# 测试过程
cleaned = remove_short_moan_entries(text)
# 验证结果
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["行くよ", "だめ"]
assert "\n2\n" in cleaned
def test_multiline_moan_entry_removed_as_one_cue() -> None:
"""多行呻吟条目整体作为一条 cue 删除(不残留半条)。"""
# 数据:两行纯呻吟组成一条 cue。
text = "1\n00:00:01,000 --> 00:00:03,000\nあ…\nん…\n\n2\n00:00:04,000 --> 00:00:05,000\nそこ\n"
# 测试过程
cleaned = remove_short_moan_entries(text)
# 验证结果:只剩第二条并重编号。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["そこ"]
assert cleaned.startswith("1\n")