按"测试规则"重写 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。
249 lines
8.9 KiB
Python
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")
|