"""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")