"""ass_subtitle_builder 单元测试 - wave169 覆盖: - hex_to_ass_color 颜色转换 - position_to_ass_alignment 位置对齐映射 - build_ass_style Style行构建 - escape_ass_text 文本转义 - format_ass_time 时间格式化 - build_ass_content 完整ASS内容生成 """ import pytest from packages.domain.ass_subtitle_builder import ( TITLE_MARGIN_BOTTOM, TITLE_MARGIN_SIDE, TITLE_MARGIN_TOP, _wrap_title_text, build_ass_content, build_ass_style, escape_ass_text, format_ass_time, hex_to_ass_color, position_to_ass_alignment, ) # ============================================================ # hex_to_ass_color # ============================================================ class TestHexToAssColor: def test_red(self): # #FF0000 → &H0000FF (BBGGRR) assert hex_to_ass_color("#FF0000") == "&H0000FF" def test_blue(self): # #0000FF → &HFF0000 assert hex_to_ass_color("#0000FF") == "&HFF0000" def test_green(self): # #00FF00 → &H00FF00 assert hex_to_ass_color("#00FF00") == "&H00FF00" def test_white(self): assert hex_to_ass_color("#FFFFFF") == "&HFFFFFF" def test_black(self): assert hex_to_ass_color("#000000") == "&H000000" def test_without_hash_prefix(self): assert hex_to_ass_color("FF0000") == "&H0000FF" def test_mixed_case(self): assert hex_to_ass_color("#aBcDeF") == "&HEFCDAB" def test_invalid_length_short(self): assert hex_to_ass_color("#FFF") == "&H000000" def test_invalid_length_long(self): assert hex_to_ass_color("#FF0000FF") == "&H000000" def test_empty_string(self): assert hex_to_ass_color("") == "&H000000" def test_uppercase_output(self): result = hex_to_ass_color("#abcdef") assert result == result.upper() # ============================================================ # position_to_ass_alignment # ============================================================ class TestPositionToAssAlignment: 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_defaults_bottom(self): assert position_to_ass_alignment("unknown") == 2 def test_empty_defaults_bottom(self): assert position_to_ass_alignment("") == 2 # ============================================================ # build_ass_style # ============================================================ class TestBuildAssStyle: def test_minimal_style(self): result = build_ass_style("Default") assert result.startswith("Style: Default,") def test_contains_font_name(self): result = build_ass_style("S1", font_name="Arial") assert "Arial" in result def test_contains_font_size(self): result = build_ass_style("S1", font_size=36) # Style行格式:Name, Fontname, Fontsize, ...(36*1.35=48.6→49) parts = result.split(",") assert parts[2] == "49" def test_bold_true(self): result = build_ass_style("S1", bold=True) parts = result.split(",") # Bold 是第7个字段(索引7) assert parts[7] == "-1" def test_bold_false(self): result = build_ass_style("S1", bold=False) parts = result.split(",") assert parts[7] == "0" def test_italic_true(self): result = build_ass_style("S1", italic=True) parts = result.split(",") # Italic 是第8个字段(索引8) assert parts[8] == "-1" def test_italic_false(self): result = build_ass_style("S1", italic=False) parts = result.split(",") assert parts[8] == "0" def test_alignment(self): result = build_ass_style("S1", alignment=5) parts = result.split(",") # Alignment 是第18个字段(索引18) assert parts[18] == "5" def test_outline_width(self): result = build_ass_style("S1", outline_width=3.0) parts = result.split(",") # Outline 是第16个字段(索引16) assert parts[16] == "3.0" def test_margins(self): result = build_ass_style("S1", margin_l=10, margin_r=20, margin_v=30) parts = result.split(",") assert parts[19] == "10" # MarginL assert parts[20] == "20" # MarginR assert parts[21] == "30" # MarginV def test_shadow_with_blur(self): result = build_ass_style("S1", shadow_blur=2.0, shadow_offset=(3, 5)) parts = result.split(",") # Shadow 深度 = shadow_offset[1] when blur > 0 assert parts[17] == "5" def test_shadow_without_blur(self): result = build_ass_style("S1", shadow_blur=0.0, shadow_offset=(3, 5)) parts = result.split(",") assert parts[17] == "0" def test_primary_color(self): result = build_ass_style("S1", primary_color="&H00FFFFFF") parts = result.split(",") assert parts[3] == "&H00FFFFFF" def test_outline_color(self): result = build_ass_style("S1", outline_color="&H000000FF") parts = result.split(",") assert parts[5] == "&H000000FF" def test_22_fields(self): # ASS Style 行应有23个字段(Style: 前缀 + 22个逗号分隔字段) result = build_ass_style("Default") parts = result.split(",") assert len(parts) >= 22 # 至少22个字段 # ============================================================ # escape_ass_text # ============================================================ class TestEscapeAssText: def test_plain_text_unchanged(self): assert escape_ass_text("Hello World") == "Hello World" def test_newline_converted(self): assert escape_ass_text("line1\nline2") == "line1\\Nline2" def test_crlf_converted(self): assert escape_ass_text("line1\r\nline2") == "line1\\Nline2" def test_carriage_return_converted(self): assert escape_ass_text("line1\rline2") == "line1\\Nline2" def test_curly_braces_escaped(self): assert escape_ass_text("{text}") == "(text)" def test_opening_brace_escaped(self): assert escape_ass_text("{hello") == "(hello" def test_closing_brace_escaped(self): assert escape_ass_text("hello}") == "hello)" def test_multiple_braces(self): assert escape_ass_text("{a}{b}") == "(a)(b)" def test_mixed_newlines_and_braces(self): result = escape_ass_text("line1\n{tag}line2") assert result == "line1\\N(tag)line2" def test_empty_string(self): assert escape_ass_text("") == "" def test_chinese_text(self): assert escape_ass_text("你好世界") == "你好世界" def test_slash_converted_to_newline(self): """半角斜杠 / 应转为 ASS 硬换行。""" assert escape_ass_text("标题一/标题二") == "标题一\\N标题二" def test_fullwidth_slash_converted_to_newline(self): """全角斜杠 / 应转为 ASS 硬换行。""" assert escape_ass_text("标题一/标题二") == "标题一\\N标题二" def test_backslash_n_in_input(self): # 文本里本身有 \n 字符串(不是换行符) result = escape_ass_text("\\n") assert result == "\\n" # 不变,因为不是实际换行符 # ============================================================ # format_ass_time # ============================================================ class TestFormatAssTime: def test_zero(self): assert format_ass_time(0) == "0:00:00.00" def test_seconds_only(self): assert format_ass_time(5.5) == "0:00:05.50" def test_minutes(self): assert format_ass_time(90.0) == "0:01:30.00" def test_hours(self): assert format_ass_time(3661.5) == "1:01:01.50" def test_multi_hours(self): assert format_ass_time(7384.25) == "2:03:04.25" def test_two_decimal_places(self): result = format_ass_time(1.234) # 两位小数 assert result.endswith(".23") or result.endswith(".24") def test_minutes_two_digits(self): result = format_ass_time(65.0) parts = result.split(":") assert len(parts[1]) == 2 assert parts[1] == "01" def test_seconds_two_digits_before_decimal(self): result = format_ass_time(5.0) parts = result.split(":") sec_part = parts[2] assert sec_part.startswith("05") def test_float_input(self): assert format_ass_time(123.45) == "0:02:03.45" def test_exactly_one_hour(self): assert format_ass_time(3600.0) == "1:00:00.00" # ============================================================ # build_ass_content # ============================================================ class TestBuildAssContent: def test_no_subtitles_returns_empty(self): result = build_ass_content(video_width=1920, video_height=1080, video_duration=10.0) assert result == "" def test_title_only(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="Test Title", ) assert result != "" assert "[Script Info]" in result assert "PlayResX: 1920" in result assert "PlayResY: 1080" in result assert "[V4+ Styles]" in result assert "[Events]" in result assert "TitleStyle" in result assert "Test Title" in result def test_subtitle_only(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, subtitle_text="Hello Subtitle", ) assert result != "" assert "SubtitleStyle" in result assert "Hello Subtitle" in result def test_both_title_and_subtitle(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="Title", subtitle_text="Subtitle", ) assert "TitleStyle" in result assert "SubtitleStyle" in result assert "Title" in result assert "Subtitle" in result def test_whitespace_title_returns_empty(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text=" ", ) assert result == "" def test_whitespace_subtitle_returns_empty(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, subtitle_text=" \n ", ) assert result == "" def test_title_disabled(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="Title", title_config={"enabled": False}, ) assert result == "" def test_subtitle_disabled(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, subtitle_text="Sub", subtitle_config={"enabled": False}, ) assert result == "" def test_title_color(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"color": "#FF0000"}, ) # 红色 → &H0000FF assert "&H0000FF" in result def test_title_position_top(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"position": "top"}, ) # top alignment = 8 assert "TitleStyle" in result def test_title_position_bottom(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"position": "bottom"}, ) # bottom=2, 检查Style行里有2 assert "TitleStyle" in result def test_subtitle_position_bottom(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, subtitle_text="S", subtitle_config={"position": "bottom"}, ) assert "SubtitleStyle" in result def test_title_font_size(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"size": 72}, ) # 在TitleStyle行里查找字体大小(字号上限已移除,72应原样保留) for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") assert parts[2] == "97" # 72*1.35=97.2→97 break def test_title_font_size_frontend_field_alias(self): """前端传 font_size 应归一化为内部 size 字段。""" result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"font_size": 48}, ) for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") assert parts[2] == "65" # 48*1.35=64.8→65 break def test_title_font_color_frontend_field_alias(self): """前端传 font_color 应归一化为内部 color 字段。""" result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"font_color": "#FF0000"}, ) # 红色 → &H0000FF assert "&H0000FF" in result def test_title_size_takes_precedence_over_font_size(self): """同时传 size 和 font_size 时,size 优先。""" result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"size": 56, "font_size": 28}, ) for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") assert parts[2] == "76" # 56*1.35=75.6→76 break def test_title_bold(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"bold": True}, ) for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") assert parts[7] == "-1" break def test_title_stroke_enabled(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"stroke": {"enabled": True, "width": 3, "color": "#000000"}}, ) for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") assert parts[16] == "3.0" break def test_title_stroke_disabled(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"stroke": {"enabled": False, "width": 3}}, ) for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") assert parts[16] == "0.0" break def test_title_shadow_enabled(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=5.0, title_text="T", title_config={"shadow": {"enabled": True, "blur": 2, "offset_x": 2, "offset_y": 4}}, ) for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") assert parts[17] == "4" # Shadow = offset_y break def test_dialogue_has_correct_timing(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=65.5, title_text="T", ) # 结束时间应该是 0:01:05.50 assert "0:01:05.50" in result def test_dialogue_starts_at_zero(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, subtitle_text="S", ) assert "0:00:00.00" in result def test_contains_script_info_header(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="T", ) assert "[Script Info]" in result assert "ScriptType: v4.00+" in result assert "ScaledBorderAndShadow: yes" in result def test_escaped_text_in_dialogue(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="line1\nline2", ) # 换行符应被转义为 \N assert "\\N" in result assert "line1" in result assert "line2" in result # ============================================================ # 常量验证 # ============================================================ class TestConstants: def test_margin_values(self): assert TITLE_MARGIN_TOP > 0 assert TITLE_MARGIN_BOTTOM > 0 assert TITLE_MARGIN_SIDE > 0 def test_margins_are_integers(self): assert isinstance(TITLE_MARGIN_TOP, int) assert isinstance(TITLE_MARGIN_BOTTOM, int) assert isinstance(TITLE_MARGIN_SIDE, int) # ============================================================ # _wrap_title_text 换行逻辑验证 # ============================================================ class TestWrapTitleTextFontSizeConsistency: """验证换行计算使用原始 font_size,与 CSS 预览一致。""" def test_font_size_113_uses_original_not_compensated(self): """font_size=113 时,每行应容纳8个字(113px字宽),而非6个字(153px字宽)。""" # 标题"永康拾掇脚阔头"共7个字 # 可用宽度 = 1080 - 60 - 60 = 960px # 用 font_size=113:char_width=113,960/113 ≈ 8.5,每行8个字 # 7个字 < 8个字,应该在一行内 title = "永康拾掇脚阔头" result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60) # 不应该有换行 assert "\\N" not in result assert result == title def test_long_title_wraps_correctly(self): """长标题应该按 font_size 字宽正确换行。""" # 16个中文字,每行8个字,应该换行为2行 title = "永康拾掇脚阔头来一个笑一个哈哈哈" result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60) # 应该有一个换行 assert result.count("\\N") == 1 # 每行8个字 lines = result.split("\\N") assert len(lines) == 2 assert len(lines[0]) == 8 assert len(lines[1]) == 8 def test_mixed_cjk_and_ascii(self): """混合中英文时,英文按半角宽度计算。""" # "测试test" = 2个中文(2*113=226) + 4个英文(4*113*0.55=248.6) = 474.6px title = "测试test" result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60) # 总宽度474.6px < 960px,应该在一行内 assert "\\N" not in result