""" render_subtitles ASS 字幕纯函数测试. 覆盖 _hex_to_ass_color / _position_to_ass_alignment / _build_ass_style / _escape_ass_text / _format_ass_time 等纯逻辑. 文件生成与 FFmpeg 渲染由集成测试覆盖. """ from __future__ import annotations from pathlib import Path import pytest from video_processing.render_subtitles import ( _build_ass_style, _escape_ass_text, _format_ass_time, _hex_to_ass_color, _position_to_ass_alignment, ) class TestHexToAssColor: """HEX → ASS 颜色转换(不含 alpha 前缀版本).""" def test_white(self): assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF" def test_black(self): assert _hex_to_ass_color("#000000") == "&H000000" def test_red(self): # #FF0000 → R=FF, G=00, B=00 → BGR=0000FF assert _hex_to_ass_color("#FF0000") == "&H0000FF" def test_blue(self): # #0000FF → R=00, G=00, B=FF → BGR=FF0000 assert _hex_to_ass_color("#0000FF") == "&HFF0000" def test_green(self): # #00FF00 → R=00, G=FF, B=00 → BGR=00FF00 assert _hex_to_ass_color("#00FF00") == "&H00FF00" def test_without_hash(self): assert _hex_to_ass_color("FF0000") == "&H0000FF" def test_lowercase(self): assert _hex_to_ass_color("#ff0000") == "&H0000FF" def test_invalid_length_returns_default(self): assert _hex_to_ass_color("#FFF") == "&H000000" # 3位 assert _hex_to_ass_color("") == "&H000000" # 空 def test_mixed_case(self): result = _hex_to_ass_color("#aBcDeF") assert result == "&HEFCDAB" class TestPositionToAssAlignment: """位置 → ASS 对齐编号映射.""" def test_top(self): assert _position_to_ass_alignment("top") == 8 def test_center(self): assert _position_to_ass_alignment("center") == 5 def test_bottom(self): assert _position_to_ass_alignment("bottom") == 2 def test_unknown_returns_bottom_default(self): assert _position_to_ass_alignment("unknown") == 2 assert _position_to_ass_alignment("") == 2 assert _position_to_ass_alignment("left") == 2 assert _position_to_ass_alignment(None) == 2 class TestBuildAssStyle: """构建 ASS Style 行.""" def test_basic_style(self): style = _build_ass_style("Default") assert style.startswith("Style: Default,") assert "Noto Sans SC" in style assert "65" in style # font_size 48*1.35=65 def test_custom_font(self): style = _build_ass_style("Custom", font_name="Arial", font_size=32) assert "Arial" in style assert ",43," in style # font_size 32*1.35=43 def test_bold(self): style = _build_ass_style("Bold", bold=True) assert ",-1," in style # bold = -1 (true) def test_not_bold(self): style = _build_ass_style("Normal", bold=False) parts = style.split(",") # Bold 是第 8 个字段(index 7) assert parts[7] == "0" def test_italic(self): style = _build_ass_style("Italic", italic=True) parts = style.split(",") # Italic 是第 9 个字段(index 8) assert parts[8] == "-1" def test_alignment(self): style = _build_ass_style("Bottom", alignment=2) parts = style.split(",") # Alignment 是第 19 个字段(index 18) assert parts[18] == "2" def test_margins(self): style = _build_ass_style( "Margins", margin_v=80, margin_l=60, margin_r=60, ) parts = style.split(",") # MarginL = parts[19], MarginR = parts[20], MarginV = parts[21] assert parts[19] == "60" assert parts[20] == "60" assert parts[21] == "80" def test_outline_width(self): style = _build_ass_style("Outline", outline_width=3.0) parts = style.split(",") # Outline 是第 17 个字段(index 16) assert parts[16] == "3.0" def test_shadow_with_blur(self): style = _build_ass_style( "Shadow", shadow_blur=1.0, shadow_offset=(2, 3), ) parts = style.split(",") # Shadow 是第 18 个字段(index 17) assert parts[17] == "3" # shadow_offset[1] def test_shadow_without_blur(self): style = _build_ass_style( "NoShadow", shadow_blur=0.0, shadow_offset=(2, 3), ) parts = style.split(",") assert parts[17] == "0" # 无模糊时阴影深度为0 def test_style_format_has_correct_field_count(self): """ASS Style 行应该有 23 个字段.""" style = _build_ass_style("Test") parts = style.split(",") assert len(parts) >= 22 # 至少22个字段(Format定义的) class TestEscapeAssText: """ASS 文本转义.""" def test_plain_text(self): assert _escape_ass_text("hello") == "hello" def test_newline_unix(self): assert _escape_ass_text("a\nb") == "a\\Nb" def test_newline_windows(self): assert _escape_ass_text("a\r\nb") == "a\\Nb" def test_newline_mac(self): assert _escape_ass_text("a\rb") == "a\\Nb" def test_curly_braces(self): assert _escape_ass_text("{text}") == "(text)" def test_multiple_braces(self): assert _escape_ass_text("{a}b{c}") == "(a)b(c)" def test_mixed_special_chars(self): result = _escape_ass_text("line1\n{bold}\nline3") assert "\\N" in result assert "(bold)" in result assert "{" not in result def test_empty(self): assert _escape_ass_text("") == "" class TestFormatAssTime: """秒 → ASS 时间格式.""" def test_zero(self): assert _format_ass_time(0.0) == "0:00:00.00" def test_seconds(self): assert _format_ass_time(5.5) == "0:00:05.50" def test_minutes(self): assert _format_ass_time(65.25) == "0:01:05.25" def test_hours(self): assert _format_ass_time(3661.5) == "1:01:01.50" def test_exact_minute(self): assert _format_ass_time(60.0) == "0:01:00.00" def test_exact_hour(self): assert _format_ass_time(3600.0) == "1:00:00.00" def test_sub_second_precision(self): result = _format_ass_time(1.234) parts = result.split(":") sec_part = parts[2] decimals = sec_part.split(".")[1] assert len(decimals) == 2 # 两位小数(厘秒)