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