"""历史 VR 双目字幕统一脚本(scripts/unify_ass_style.py)测试。 背景 ---- `scripts/unify_ass_style.py` 负责把媒体库中由旧版本生成、混有多种历史样式 的 *.CN_dual_eye.ass 原地改写为 nodes/ass.py 当前统一样式 (an8 顶部对齐 + 70% 透明 + DEFAULT_MARGIN_TOP=700)。 本测试验证三条关键路径: 1. 各历史样式(底部实心白 an2+MarginV1200 / 底部半透明 an2+MarginV1020 / 顶部 120 / 已是 700)都能正确重建为统一的 700 样式; 2. 重建结果与 nodes/ass.py 的 ass_header()/dialogue_line()(新生成字幕的 唯一出口)**逐字节一致**,防止"新字幕"与"历史改写"两套逻辑漂移; 3. 脚本文件能被直接加载执行(仓库根运行 uv run python scripts/...), 且 rewrite_content 对非 VR 字幕返回 None(不误伤普通 ASS 文件)。 测试使用真实 ASS 内容构造(Script Info + 样式 + 事件),不 mock 文件系统 之外的任何逻辑。 """ from __future__ import annotations import importlib.util import subprocess import sys from pathlib import Path import pytest # 仓库根(tests/ 的上一级),用于导入 scripts/unify_ass_style.py。 WORKSPACE = Path(__file__).resolve().parent.parent SCRIPT_PATH = WORKSPACE / "scripts" / "unify_ass_style.py" # 用真实可执行的统一脚本构造各历史样式样本:以下文本代表旧版实际输出格式。 HEADER_3840_OLD = """[Script Info] Title: VR Dual-Eye Subtitle ScriptType: v4.00+ Collisions: Normal PlayResX: 3840 PlayResY: 1920 WrapStyle: 1 ScaledBorderAndShadow: yes [V4+ Styles] Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding """ def _load_module(): """按文件路径加载 scripts/unify_ass_style.py(scripts 不是包,无法 import)。 与仓库运行方式一致:uv run python scripts/unify_ass_style.py 时 pythonpath 含仓库根,模块内 `from nodes.ass import ...` 可正常解析。""" spec = importlib.util.spec_from_file_location("unify_ass_style", SCRIPT_PATH) module = importlib.util.module_from_spec(spec) assert spec is not None and spec.loader is not None spec.loader.exec_module(module) return module U = _load_module() @pytest.fixture def sandbox(tmp_path): """构造含三种历史样式 + 一个非 VR 文件的临时媒体库。 每个测试独立临时目录(function scope),CLI 写盘测试不会污染其他测试。""" root = tmp_path # 1) 最早期:底部 an2 + 实心白 &H00FFFFFF + MarginV=1200。 (root / "old_solid.CN_dual_eye.ass").write_text( HEADER_3840_OLD + "Style: LeftEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,50,1970,1200,1\n" + "Style: RightEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,1970,50,1200,1\n" + "\n[Events]\n" + "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n" + 'Dialogue: 0,00:00:00.000,00:00:03.000,LeftEye,,0,0,0,,{\\an2}第一行\\N第二行\n' + 'Dialogue: 0,00:00:00.000,00:00:03.000,RightEye,,0,0,0,,{\\an2}第一行\\N第二行\n', encoding="utf-8", ) # 2) 过渡期:底部 an2 + 半透明 &H80FFFFFF + MarginV=1020。 (root / "mid_translucent.CN_dual_eye.ass").write_text( HEADER_3840_OLD + "Style: LeftEye,Arial,50,&H80FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,50,1970,1020,1\n" + "Style: RightEye,Arial,50,&H80FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,1970,50,1020,1\n" + "\n[Events]\n" + "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n" + 'Dialogue: 0,00:00:00.000,00:00:03.000,LeftEye,,0,0,0,,{\\an2}你好\n', encoding="utf-8", ) # 3) 已是顶部 120 样式(旧代码默认),仍应规整为 700。 (root / "top120.CN_dual_eye.ass").write_text( HEADER_3840_OLD + "Style: LeftEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,50,1920,120,1\n" + "Style: RightEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,1920,50,120,1\n" + "\n[Events]\n" + "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n" + 'Dialogue: 0,00:00:00.000,00:00:03.000,LeftEye,,0,0,0,,{\\an8}第三行\n', encoding="utf-8", ) # 4) 非 VR 字幕(无 LeftEye/RightEye 样式),脚本不应改动。 (root / "not_vr.CN_dual_eye.ass").write_text( "[Script Info]\nPlayResX: 1920\nPlayResY: 1080\n\n[V4+ Styles]\n" "Format: Name,Fontname,Fontsize,PrimaryColour,...\nStyle: Default,Arial,20,&H00FFFFFF,...\n", encoding="utf-8", ) return root def _read(path: Path) -> str: """以 UTF-8 读取测试文件内容。""" return path.read_text(encoding="utf-8") def test_rewrite_converges_all_generations(sandbox) -> None: """三种历史样式统一后都含 an8 顶部对齐 + 70% 透明 + MarginV=700。""" # (文件名, 该文件期望保留的事件文本) expected_text = { "old_solid": r"第一行\N第二行", # 最早期多行字幕(an2 底部) "mid_translucent": "你好", # 过渡期 "top120": "第三行", # 旧顶部 120 } for name, text in expected_text.items(): original = _read(sandbox / f"{name}.CN_dual_eye.ass") rewritten = U.rewrite_content(original) assert rewritten is not None, f"{name} 应被改写" # 左/右眼样式都落到统一值:70%透明 + an8 + MarginV=700。 assert "&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,50,1920,700,1" in rewritten assert "&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,1920,50,700,1" in rewritten # 对齐标签全部为 an8,不再残留 an2。 assert r"{\an8}" in rewritten assert r"{\an2}" not in rewritten # 事件文本原样保留。 assert text in rewritten def test_rewrite_byte_identical_to_write_ass(sandbox) -> None: """历史字幕统一后的输出与 write_ass() 生成的新字幕逐字节一致。 防止"统一脚本"与"节点生成"两套样式逻辑漂移:两边都经由 ass_header() /dialogue_line()(nodes/ass.py 单一出口)。""" from nodes.ass import DEFAULT_MARGIN_TOP, ass_header, dialogue_line # 取 old_solid 样本的事件,用 write_ass 语义重建期望文本。 original = _read(sandbox / "old_solid.CN_dual_eye.ass") rewritten = U.rewrite_content(original) assert rewritten is not None hdr = ass_header(3840, 1920, margin_top=DEFAULT_MARGIN_TOP).rstrip("\n") expected = "\n".join( [ hdr, dialogue_line("LeftEye", "00:00:00.000", "00:00:03.000", "第一行\\N第二行"), dialogue_line("RightEye", "00:00:00.000", "00:00:03.000", "第一行\\N第二行"), ] ) + "\n" assert rewritten == expected def test_rewrite_skips_non_vr_and_already_canonical(sandbox) -> None: """非 VR 字幕返回 None;已是目标样式(MarginV=700)也返回 None(幂等)。""" not_vr = _read(sandbox / "not_vr.CN_dual_eye.ass") assert U.rewrite_content(not_vr) is None # 构造一份已是 700 的新样式文本,改写应返回 None(内容不变)。 from nodes.ass import ass_header, dialogue_line canonical = ( ass_header(3840, 1920, margin_top=U.DEFAULT_MARGIN_TOP).rstrip("\n") + "\n" + dialogue_line("LeftEye", "00:00:00.000", "00:00:03.000", "嗨") + "\n" ) assert U.rewrite_content(canonical) is None def test_module_runs_as_cli_dry_run(sandbox) -> None: """脚本可作为 CLI 以 dry-run 方式运行(uv run python scripts/...),不写盘。 复现仓库根运行方式;断言 dry-run 输出含"旧样式分布"与"将改写", 且运行后沙盒内文件一个都没被改动(dry-run 语义)。""" before = {p.name: _read(p) for p in sandbox.glob("*.ass")} # 不带 --apply = dry-run:应只打印不写盘。 proc = subprocess.run( [sys.executable, str(SCRIPT_PATH), str(sandbox)], capture_output=True, text=True, cwd=WORKSPACE, # 与 uv run python 一致:仓库根在 sys.path ) assert proc.returncode == 0, proc.stderr out = proc.stdout assert "[将改写]" in out # 预览行标记 assert "[改写]" not in out # 未真正写盘 after = {p.name: _read(p) for p in sandbox.glob("*.ass")} assert before == after # dry-run 不改动任何文件 def test_module_cli_apply_writes_files(sandbox) -> None: """脚本加 --apply 后真正原地改写,写盘结果与 rewrite_content 一致。 覆盖 main() 的写盘分支:统计输出显示 [改写],且文件内容已变为 MarginV=700 的统一新样式(第二次运行幂等,不再改写)。""" # 备份一个将改写的样本,确保 CLI 走的是与单元层相同的重建逻辑。 old_solid = sandbox / "old_solid.CN_dual_eye.ass" expected = U.rewrite_content(old_solid.read_text(encoding="utf-8")) assert expected is not None proc = subprocess.run( [sys.executable, str(SCRIPT_PATH), str(sandbox), "--apply"], capture_output=True, text=True, cwd=WORKSPACE, ) assert proc.returncode == 0, proc.stderr assert "[改写]" in proc.stdout assert old_solid.read_text(encoding="utf-8") == expected # 幂等:第二次 --apply 不再有任何 [改写](文件已是目标样式)。 proc2 = subprocess.run( [sys.executable, str(SCRIPT_PATH), str(sandbox), "--apply"], capture_output=True, text=True, cwd=WORKSPACE, ) assert proc2.returncode == 0, proc2.stderr assert "[改写]" not in proc2.stdout def test_module_loads_and_exports() -> None: """模块可加载且暴露 rewrite_content / DEFAULT_MARGIN_TOP。""" assert U.DEFAULT_MARGIN_TOP == 700 assert callable(U.rewrite_content)