diff --git a/tests/unit/domain/test_ass_subtitle_builder.py b/tests/unit/domain/test_ass_subtitle_builder.py new file mode 100644 index 000000000..f4591e5c1 --- /dev/null +++ b/tests/unit/domain/test_ass_subtitle_builder.py @@ -0,0 +1,531 @@ +"""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, + 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_top(self): + assert position_to_ass_alignment("unknown") == 8 + + def test_empty_defaults_top(self): + assert position_to_ass_alignment("") == 8 + + +# ============================================================ +# 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, ... + parts = result.split(",") + assert parts[2] == "36" + + 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_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行里查找字体大小 + for line in result.split("\n"): + if line.startswith("Style: TitleStyle"): + parts = line.split(",") + assert parts[2] == "72" + 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)