"""nodes/ass.py 的模块级测试(数据 → 测试过程 → 验证结果)。 被测模块:`nodes/ass.py`(SRT → VR 双目 ASS,含统一样式出口),被 srt-to-dual-eye-ass 节点与历史字幕统脚本共用,可独立调用。 覆盖:SRT 解析(含多行与畸形行)、ASS 头与样式行、左右眼对照与零视差、 顶部安全区(an8 + MarginV=700)、透明度、margin_top 参数化、invoke 全流程。 """ from __future__ import annotations from pathlib import Path from nodes.ass import ( DEFAULT_MARGIN_TOP, _ass_header, ass_header, dialogue_line, invoke, parse_srt, style_row, write_ass, ) from wov_sdk.models import InvokeRequest # 模块专用测试数据:两条(第二条为多行)SRT。 SAMPLE_SRT = """1 00:00:01,000 --> 00:00:02,000 第一行 第二行 2 00:00:04,000 --> 00:00:06,000 第三行 """ def _request(tmp_path: Path, srt_text: str, **params) -> InvokeRequest: """构造真实 InvokeRequest:把输入 SRT 落到临时目录,输出目录同目录下。""" srt_path = tmp_path / "input.srt" srt_path.write_text(srt_text, encoding="utf-8") return InvokeRequest( run_id="run-test", node_instance_id="ass-1", params=params, inputs={"cn_srt_uri": str(srt_path)}, output_dir=str(tmp_path / "out"), ) # --------------------------------------------------------------------------- # SRT 解析 # --------------------------------------------------------------------------- def test_parse_srt_converts_comma_to_dot_and_joins_multiline() -> None: """SRT 时间戳逗号转 ASS 点号;多行正文用 \\N 连接。""" # 数据:两条,第一条两行正文。 text = SAMPLE_SRT # 测试过程 entries = parse_srt(text) # 验证结果:条数、时间戳格式与多行连接符。 assert len(entries) == 2 assert entries[0][0] == "00:00:01.000" assert entries[0][1] == "00:00:02.000" assert entries[0][2] == r"第一行\N第二行" def test_parse_srt_skips_malformed_timeline() -> None: """缺少时间轴分隔符的畸形条目被跳过,不产生错误条目。""" # 数据:一条畸形(无 -->)+ 一条正常。 text = "1\n00:00:01,000\n坏条目\n\n2\n00:00:04,000 --> 00:00:06,000\n正常\n" # 测试过程 entries = parse_srt(text) # 验证结果:只解析出正常条目。 assert len(entries) == 1 assert entries[0][2] == "正常" def test_parse_srt_empty_text() -> None: """空文本解析为空列表。""" # 数据:空字符串。 # 测试过程与验证结果 assert parse_srt("") == [] # --------------------------------------------------------------------------- # 样式:单一事实来源 # --------------------------------------------------------------------------- def test_ass_header_contains_resolution_and_styles() -> None: """ASS 头包含分辨率、左右眼两条样式与必需字段。""" # 数据:VR 常用分辨率 3840x1920。 # 测试过程 header = ass_header(3840, 1920) # 验证结果 assert "PlayResX: 3840" in header assert "PlayResY: 1920" in header assert "Style: LeftEye," in header assert "Style: RightEye," in header assert "[Script Info]" in header and "[V4+ Styles]" in header and "[Events]" in header def test_style_row_top_aligned_with_margin() -> None: """样式行使用 an8 顶部对齐 + MarginV 顶部安全边距(默认 700)。""" # 数据:左眼样式,宽 3840,默认边距。 # 测试过程 row = style_row("LeftEye", 3840) # 验证结果:末尾字段为 Alignment=8、MarginL=50、MarginR=1920(右眼区)、MarginV=700。 assert row.endswith(",8,50,1920,700,1") def test_style_row_uses_translucent_fill_and_outline() -> None: """文字填充约 70% 透明、描边半透明黑,降低对画面的遮挡。""" # 数据:任一样式行。 # 测试过程 row = style_row("LeftEye", 3840) # 验证结果 assert "&HB3FFFFFF" in row assert "&H80000000" in row assert "Arial" in row def test_default_margin_top_is_700() -> None: """默认顶部安全边距常量是 700(2026-09 调整,历史 120 已废弃)。""" # 数据:模块常量。 # 测试过程与验证结果 assert DEFAULT_MARGIN_TOP == 700 def test_dialogue_line_prefixes_top_alignment() -> None: """Dialogue 行固定前缀 {\\an8},保证每条字幕落在顶部安全区。""" # 数据:一条字幕。 # 测试过程 line = dialogue_line("LeftEye", "0:00:01.00", "0:00:02.00", "你好") # 验证结果 assert line == r"Dialogue: 0,0:00:01.00,0:00:02.00,LeftEye,,0,0,0,,{\an8}你好" # --------------------------------------------------------------------------- # 写出:左右眼与零视差 # --------------------------------------------------------------------------- def test_write_ass_emits_both_eyes_with_identical_text(tmp_path: Path) -> None: """每个条目输出左右眼两行 Dialogue,文本与水平相对位置完全一致(零视差)。""" # 数据:单条字幕。 entries = parse_srt("1\n00:00:01,000 --> 00:00:02,000\n台词\n") output = tmp_path / "out.ass" # 测试过程 write_ass(entries, output, "3840x1920") content = output.read_text(encoding="utf-8") # 验证结果:两条 Dialogue(LeftEye/RightEye),文本各出现一次。 dialogue = [line for line in content.splitlines() if line.startswith("Dialogue:")] assert len(dialogue) == 2 assert "LeftEye" in dialogue[0] and "RightEye" in dialogue[1] assert dialogue[0].split(",,")[-1] == dialogue[1].split(",,")[-1] def test_write_ass_custom_margin_top_changes_style_row(tmp_path: Path) -> None: """margin_top 参数化到样式行(不同分辨率/内容可微调顶部边距)。""" # 数据:自定义边距 300。 entries = parse_srt(SAMPLE_SRT) output = tmp_path / "out.ass" # 测试过程 write_ass(entries, output, "3840x1920", margin_top=300) content = output.read_text(encoding="utf-8") # 验证结果:样式行的 MarginV 为 300。 assert ",8,50,1920,300,1" in content def test_write_ass_multiple_entries_keep_order(tmp_path: Path) -> None: """多条字幕按输入顺序输出(时间轴顺序不被打乱)。""" # 数据:两条字幕。 entries = parse_srt(SAMPLE_SRT) output = tmp_path / "out.ass" # 测试过程 write_ass(entries, output, "3840x1920") dialogue = [ line for line in output.read_text(encoding="utf-8").splitlines() if line.startswith("Dialogue:") ] # 验证结果:第 1、2 行属于第一条,第 3、4 行属于第二条。 assert "00:00:01.000" in dialogue[0] and "00:00:02.000" in dialogue[1] assert "00:00:04.000" in dialogue[2] and "00:00:06.000" in dialogue[3] def test_ass_header_compat_interface_matches_new_header() -> None: """兼容接口 _ass_header 与新版 ass_header 对同一分辨率输出一致(样式不漂移)。""" # 数据:同一分辨率字符串与整数宽高。 # 测试过程与验证结果 assert _ass_header("3840x1920") == ass_header(3840, 1920) # --------------------------------------------------------------------------- # invoke 全流程 # --------------------------------------------------------------------------- def test_invoke_writes_ass_and_returns_uri(tmp_path: Path) -> None: """invoke 读取 cn_srt_uri,写出 dual_eye.ass 并返回产物 URI。""" # 数据:真实输入 SRT 文件 + 默认参数。 request = _request(tmp_path, SAMPLE_SRT) # 测试过程 response = invoke(request) # 验证结果:状态、产物存在、内容含左右眼。 assert response.status == "completed" artifact = Path(response.outputs["ass_uri"]) assert artifact.is_file() assert artifact.name == "dual_eye.ass" assert "LeftEye" in artifact.read_text(encoding="utf-8") def test_invoke_honors_resolution_and_margin_params(tmp_path: Path) -> None: """invoke 透传 resolution 与 margin_top 参数到产物。""" # 数据:自定义分辨率 1920x1080 与边距 250。 request = _request(tmp_path, SAMPLE_SRT, resolution="1920x1080", margin_top=250) # 测试过程 response = invoke(request) # 验证结果 content = Path(response.outputs["ass_uri"]).read_text(encoding="utf-8") assert "PlayResX: 1920" in content assert "PlayResY: 1080" in content assert "250,1" in content def test_invoke_default_margin_top_is_700(tmp_path: Path) -> None: """未传 margin_top 时使用默认 700。""" # 数据:不传 margin_top。 request = _request(tmp_path, SAMPLE_SRT) # 测试过程 response = invoke(request) # 验证结果 content = Path(response.outputs["ass_uri"]).read_text(encoding="utf-8") assert ",8,50,1920,700,1" in content def test_invoke_fails_without_input_uri(tmp_path: Path) -> None: """缺少 cn_srt_uri 输入时返回 failed 并说明原因。""" # 数据:空输入。 request = InvokeRequest( run_id="r", node_instance_id="n", params={}, inputs={}, output_dir=str(tmp_path) ) # 测试过程 response = invoke(request) # 验证结果 assert response.status == "failed" assert "cn_srt_uri" in (response.error or "") def test_invoke_fails_when_input_file_missing(tmp_path: Path) -> None: """输入指向不存在的文件时返回 failed。""" # 数据:不存在的路径。 request = InvokeRequest( run_id="r", node_instance_id="n", params={}, inputs={"cn_srt_uri": str(tmp_path / "missing.srt")}, output_dir=str(tmp_path / "out"), ) # 测试过程 response = invoke(request) # 验证结果 assert response.status == "failed" assert "not found" in (response.error or "")