"""ASS 字幕构建领域模型单元测试 — 纯逻辑,无文件IO.""" from __future__ import annotations 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, ) # ── 颜色转换 ────────────────────────────────────────────────────────────────── class TestHexToAssColor: def test_red(self): assert hex_to_ass_color("#FF0000") == "&H0000FF" def test_green(self): assert hex_to_ass_color("#00FF00") == "&H00FF00" def test_blue(self): assert hex_to_ass_color("#0000FF") == "&HFF0000" 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(self): assert hex_to_ass_color("FF0000") == "&H0000FF" def test_lowercase(self): assert hex_to_ass_color("#ff0000") == "&H0000FF" def test_invalid_length_short(self): assert hex_to_ass_color("#FFF") == "&H000000" def test_invalid_length_long(self): assert hex_to_ass_color("#FFFFFFFF") == "&H000000" def test_empty(self): assert hex_to_ass_color("") == "&H000000" # ── 位置对齐 ────────────────────────────────────────────────────────────────── 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_default_top(self): assert position_to_ass_alignment("unknown") == 8 def test_empty_default_top(self): assert position_to_ass_alignment("") == 8 # ── Style 行构建 ───────────────────────────────────────────────────────────── class TestBuildAssStyle: def test_minimal_style(self): result = build_ass_style("TestStyle") assert result.startswith("Style: TestStyle,") assert "思源黑体" in result assert ",48," in result def test_custom_font_size(self): result = build_ass_style("Title", font_size=64) assert ",64," in result def test_bold_enabled(self): result = build_ass_style("BoldStyle", bold=True) parts = result.split(",") # Bold 是第 8 个字段(index 7) assert parts[7] == "-1" def test_bold_disabled(self): result = build_ass_style("NormalStyle", bold=False) parts = result.split(",") assert parts[7] == "0" def test_italic_enabled(self): result = build_ass_style("ItalicStyle", italic=True) parts = result.split(",") assert parts[8] == "-1" def test_alignment(self): result = build_ass_style("AlignBottom", alignment=2) parts = result.split(",") # Alignment 是第 19 个字段(index 18) assert parts[18] == "2" def test_margins(self): result = build_ass_style("MarginStyle", margin_v=100, margin_l=50, margin_r=50) parts = result.split(",") # MarginL, MarginR, MarginV 分别是 index 19, 20, 21 assert parts[19] == "50" assert parts[20] == "50" assert parts[21] == "100" def test_outline_width(self): result = build_ass_style("OutlineStyle", outline_width=3.5) # Outline 是 index 16 parts = result.split(",") assert parts[16] == "3.5" def test_shadow_with_blur(self): result = build_ass_style("ShadowStyle", shadow_blur=4.0, shadow_offset=(2, 3)) parts = result.split(",") # Shadow 深度(纵向偏移)是 index 17 assert parts[17] == "3" def test_shadow_without_blur(self): result = build_ass_style("NoShadowStyle", shadow_blur=0.0, shadow_offset=(2, 3)) parts = result.split(",") assert parts[17] == "0" def test_primary_color(self): result = build_ass_style("ColorStyle", primary_color="&H00FFFFFF") # PrimaryColour 是 index 3 parts = result.split(",") assert parts[3] == "&H00FFFFFF" def test_outline_color(self): result = build_ass_style("StrokeStyle", outline_color="&H00000000") # OutlineColour 是 index 5 parts = result.split(",") assert parts[5] == "&H00000000" def test_field_count(self): """验证 Style 行有正确的字段数(23 个字段).""" result = build_ass_style("FullStyle") parts = result.split(",") # Style: 行有 23 个字段(去掉 "Style: " 前缀后) assert len(parts) == 23 # ── 文本转义 ────────────────────────────────────────────────────────────────── class TestEscapeAssText: def test_plain_text(self): assert escape_ass_text("Hello World") == "Hello World" def test_newline_lf(self): assert escape_ass_text("line1\nline2") == "line1\\Nline2" def test_newline_crlf(self): assert escape_ass_text("line1\r\nline2") == "line1\\Nline2" def test_newline_cr(self): assert escape_ass_text("line1\rline2") == "line1\\Nline2" def test_curly_braces(self): assert escape_ass_text("text {tag} text") == "text (tag) text" def test_left_brace_only(self): assert escape_ass_text("{start") == "(start" def test_right_brace_only(self): assert escape_ass_text("end}") == "end)" def test_multiple_braces(self): assert escape_ass_text("{a}{b}{c}") == "(a)(b)(c)" def test_mixed_newline_and_braces(self): assert escape_ass_text("line1\n{tag}\nline2") == "line1\\N(tag)\\Nline2" def test_empty_string(self): assert escape_ass_text("") == "" def test_chinese_text(self): assert escape_ass_text("你好世界") == "你好世界" # ── 时间格式化 ──────────────────────────────────────────────────────────────── 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(125.0) == "0:02:05.00" def test_hours(self): assert format_ass_time(3661.5) == "1:01:01.50" def test_one_hour_exact(self): assert format_ass_time(3600) == "1:00:00.00" def test_sub_second_precision(self): result = format_ass_time(1.23) assert result == "0:00:01.23" def test_59_seconds(self): assert format_ass_time(59.99) == "0:00:59.99" def test_60_seconds(self): assert format_ass_time(60.0) == "0:01:00.00" def test_90_minutes(self): assert format_ass_time(5400.0) == "1:30:00.00" # ── 完整 ASS 内容生成 ───────────────────────────────────────────────────────── 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_disabled_returns_empty(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="Title", title_config={"enabled": False}, ) assert result == "" def test_empty_title_text_returns_empty(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text=" ", title_config={"enabled": True}, ) assert result == "" def test_with_title(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=30.0, title_text="My Title", title_config={"enabled": True, "color": "#FFFFFF"}, ) assert "[Script Info]" in result assert "PlayResX: 1920" in result assert "PlayResY: 1080" in result assert "[V4+ Styles]" in result assert "TitleStyle" in result assert "[Events]" in result assert "Dialogue:" in result assert "My Title" in result def test_with_subtitle(self): result = build_ass_content( video_width=1280, video_height=720, video_duration=15.0, subtitle_text="Subtitle Text", subtitle_config={"enabled": True}, ) assert "PlayResX: 1280" in result assert "PlayResY: 720" in result assert "SubtitleStyle" in result assert "Subtitle Text" in result def test_with_both_title_and_subtitle(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=60.0, title_text="Big Title", title_config={"enabled": True}, subtitle_text="Small subtitle", subtitle_config={"enabled": True}, ) assert "TitleStyle" in result assert "SubtitleStyle" in result assert "Big Title" in result assert "Small subtitle" in result # 两个 Dialogue 行 assert result.count("Dialogue:") == 2 def test_title_position_bottom(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="Bottom Title", title_config={"enabled": True, "position": "bottom"}, ) # 对齐方式为 2(底部居中) assert "TitleStyle" in result def test_title_with_stroke(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="Stroke Title", title_config={ "enabled": True, "stroke": {"enabled": True, "color": "#000000", "width": 3}, }, ) assert "Stroke Title" in result def test_title_with_shadow(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="Shadow Title", title_config={ "enabled": True, "shadow": {"enabled": True, "blur": 4, "offset_x": 2, "offset_y": 3}, }, ) assert "Shadow Title" in result def test_title_bold_default(self): """标题默认启用粗体.""" result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="Bold Title", title_config={"enabled": True}, ) # 在 TitleStyle 行中找 bold=-1 for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") assert parts[7] == "-1" break else: pytest.fail("TitleStyle not found") def test_subtitle_not_bold(self): """字幕默认不启用粗体.""" result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, subtitle_text="Normal Subtitle", subtitle_config={"enabled": True}, ) for line in result.split("\n"): if line.startswith("Style: SubtitleStyle"): parts = line.split(",") assert parts[7] == "0" break else: pytest.fail("SubtitleStyle not found") def test_duration_format_in_dialogue(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=125.5, title_text="Timed", title_config={"enabled": True}, ) # 结束时间应该是 0:02:05.50 assert "0:02:05.50" in result def test_title_text_escaped(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="Line1\n{tag}Line2", title_config={"enabled": True}, ) assert "Line1\\N(tag)Line2" in result def test_default_title_enabled(self): """不传 enabled 时默认为 True.""" result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="Default Enabled", title_config={}, ) assert result != "" assert "Default Enabled" in result def test_subtitle_position_top(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, subtitle_text="Top Subtitle", subtitle_config={"enabled": True, "position": "top"}, ) assert "Top Subtitle" in result def test_scaled_border_and_shadow(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="Test", title_config={"enabled": True}, ) assert "ScaledBorderAndShadow: yes" in result def test_wrap_style(self): result = build_ass_content( video_width=1920, video_height=1080, video_duration=10.0, title_text="Test", title_config={"enabled": True}, ) assert "WrapStyle: 2" in result # ── 常量 ────────────────────────────────────────────────────────────────────── class TestConstants: def test_title_margin_top(self): assert TITLE_MARGIN_TOP == 60 def test_title_margin_bottom(self): assert TITLE_MARGIN_BOTTOM == 60 def test_title_margin_side(self): assert TITLE_MARGIN_SIDE == 40