"""ASS 字幕构建领域模型单元测试 — 纯逻辑,无文件IO.""" from __future__ import annotations 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, ) # ── 颜色转换 ────────────────────────────────────────────────────────────────── 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_bottom(self): assert position_to_ass_alignment("unknown") == 2 def test_empty_default_bottom(self): assert position_to_ass_alignment("") == 2 # ── Style 行构建 ───────────────────────────────────────────────────────────── class TestBuildAssStyle: def test_minimal_style(self): result = build_ass_style("TestStyle") assert result.startswith("Style: TestStyle,") assert "Noto Sans SC" in result assert ",65," in result # 48*1.35=64.8→65 def test_custom_font_size(self): result = build_ass_style("Title", font_size=64) assert ",86," in result # 64*1.35=86.4→86 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 def test_font_name_mapping_siyuan(self): """思源黑体 → Noto Sans SC""" result = build_ass_style("Test", font_name="思源黑体") assert "Noto Sans SC" in result def test_font_name_mapping_apple(self): """苹方 → Noto Sans SC""" result = build_ass_style("Test", font_name="苹方") assert "Noto Sans SC" in result def test_font_name_mapping_msyh(self): """微软雅黑 → Noto Sans SC""" result = build_ass_style("Test", font_name="微软雅黑") assert "Noto Sans SC" in result def test_font_name_mapping_unknown_passthrough(self): """未映射字体原样使用""" result = build_ass_style("Test", font_name="CustomFont") assert "CustomFont" in result # ── 文本转义 ────────────────────────────────────────────────────────────────── 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("你好世界") == "你好世界" 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_mixed_slashes_and_newlines(self): """斜杠和换行符都应转为硬换行。""" result = escape_ass_text("A/B\nC/D") assert result == "A\\NB\\NC\\ND" # ── 时间格式化 ──────────────────────────────────────────────────────────────── 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 == 120 def test_title_margin_bottom(self): assert TITLE_MARGIN_BOTTOM == 60 def test_title_margin_side(self): assert TITLE_MARGIN_SIDE == 40 # ── 标题自动换行 ────────────────────────────────────────────────────────────── class TestWrapTitleText: """测试标题自动换行逻辑。""" def test_short_title_no_wrap(self): """短标题不需要换行。""" result = _wrap_title_text("你好世界", video_width=1080, font_size=48) assert "\\N" not in result assert result == "你好世界" def test_long_title_wraps(self): """长标题应该被换行。""" # 1080p竖屏 480px宽,字号48,边距40*2 # 可用宽度 = 480 - 40 - 40 = 400 # 每个中文字符 = 48px,最多约 8 个字符一行 long_title = "这是一个非常非常长的标题需要换行处理" result = _wrap_title_text(long_title, video_width=480, font_size=48) assert "\\N" in result lines = result.split("\\N") assert len(lines) >= 2 def test_empty_text(self): """空文本直接返回。""" assert _wrap_title_text("", 1080, 48) == "" def test_english_half_width(self): """英文字符按半角计算。""" # 英文宽度 = 48 * 0.55 = 26.4px # 可用宽度 = 480 - 80 = 400, 约15个字符 result = _wrap_title_text("a" * 20, video_width=480, font_size=48) # 20个英文字符 * 26.4 = 528 > 400,应该换行 assert "\\N" in result def test_zero_width(self): """视频宽度为0时直接返回原文。""" assert _wrap_title_text("测试", 0, 48) == "测试" def test_zero_font_size(self): """字号为0时直接返回原文。""" assert _wrap_title_text("测试", 1080, 0) == "测试" def test_preserves_explicit_newline(self): """已有的 \\N 换行标记应保留,不被当普通字符算宽度。""" text = "第一行\\N第二行" result = _wrap_title_text(text, video_width=1080, font_size=48) assert result == text def test_explicit_newline_each_segment_wraps_independently(self): """\\N 分段后,每段各自自动换行。""" # 480px 宽,48px 字号,可用 400px,每段约8个中文字 text = "这是第一段很长很长很长的内容\\N这是第二段也很长很长的内容" result = _wrap_title_text(text, video_width=480, font_size=48) # 应该有多个 \N:用户手动的 + 自动换行的 assert "\\N" in result segments = result.split("\\N") # 至少3行(两段都需要换行) assert len(segments) >= 3 # 验证包含两段的文字 joined = result.replace("\\N", "") assert "第一段" in joined assert "第二段" in joined def test_multiple_explicit_newlines(self): """多个 \\N 分段都应保留。""" text = "A\\NB\\NC" result = _wrap_title_text(text, video_width=1080, font_size=48) assert result == text assert result.count("\\N") == 2 def test_build_ass_content_integration(self): """集成测试:build_ass_content 中的标题应该自动换行。""" long_title = "这是一段非常长的标题文字用于测试自动换行功能是否正常工作" content = build_ass_content( video_width=480, video_height=854, video_duration=10.0, title_text=long_title, title_config={"size": 48, "position": "top"}, ) # 检查 Dialogue 行中包含 \N 换行 for line in content.split("\n"): if "Dialogue" in line and "TitleStyle" in line: assert "\\N" in line, f"标题应该包含换行符: {line}" break else: pytest.fail("未找到 TitleStyle Dialogue 行") class TestFontsizeCompensation: """ASS Fontsize 补偿系数(CSS 字号 → ASS em-square 字号)。""" def test_default_48_compensated_to_65(self): result = build_ass_style("S") parts = result.split(",") assert parts[2] == "65" # round(48*1.35)=65 def test_89_compensated_to_120(self): """实测对齐点:font_size=89 → ASS Fontsize=120。""" result = build_ass_style("S", font_size=89) parts = result.split(",") assert parts[2] == "120" def test_subtitle_also_compensated(self): content = build_ass_content( video_width=1080, video_height=1920, video_duration=5.0, subtitle_text="字幕", subtitle_config={"size": 24}, ) sub_line = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0] fields = [f.strip() for f in sub_line.split(",")] assert fields[2] == "32" # round(24*1.35)=32 def test_minimum_fontsize_at_least_one(self): result = build_ass_style("S", font_size=0) parts = result.split(",") assert int(parts[2]) >= 1 # ── 标题自由位置拖拽(工单 #1405 方案 B)────────────────────────────────────── def _title_style_line(content: str) -> str: return [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] def _title_dialogue_line(content: str) -> str: return [line for line in content.splitlines() if line.startswith("Dialogue:") and "TitleStyle" in line][0] class TestTitleFreePosition: """pos_x/pos_y 合法时注入 \\pos 且 Alignment=5;非法/缺失时回退原逻辑。""" def _base_kwargs(self): return dict( video_width=1080, video_height=1920, video_duration=8.0, title_text="测试标题", ) def test_valid_position_injects_pos_tag_and_alignment_5(self): content = build_ass_content( **self._base_kwargs(), title_config={"position": "top", "size": 36, "pos_x": 540, "pos_y": 300}, ) # Dialogue 文本前注入 {\pos(540,300)} dialogue = _title_dialogue_line(content) assert "{\\pos(540,300)}" in dialogue # TitleStyle Alignment 固定 5(\an5 中对齐,\pos 锚点为文本块中心) fields = [f.strip() for f in _title_style_line(content).split(",")] assert fields[18] == "5" def test_boundary_coordinates_zero_and_max_accepted(self): """边界值 0 和 video_width/video_height 合法(闭区间)。""" content = build_ass_content( **self._base_kwargs(), title_config={"pos_x": 0, "pos_y": 1920}, ) assert "{\\pos(0,1920)}" in _title_dialogue_line(content) content2 = build_ass_content( **self._base_kwargs(), title_config={"pos_x": 1080, "pos_y": 0}, ) assert "{\\pos(1080,0)}" in _title_dialogue_line(content2) def test_no_coords_output_identical_to_before(self): """不传坐标 → 输出与现有断言完全一致(回归保护)。""" content = build_ass_content( **self._base_kwargs(), title_config={"position": "top", "size": 36}, ) # 无 \pos 注入 assert "\\pos(" not in content # Alignment 走 position 映射(top → 8) fields = [f.strip() for f in _title_style_line(content).split(",")] assert fields[18] == "8" def test_out_of_bounds_falls_back(self): """越界坐标 → 回退 position 三档逻辑,输出与无坐标一致。""" base = build_ass_content( **self._base_kwargs(), title_config={"position": "top", "size": 36}, ) for pos_x, pos_y in [(-1, 300), (540, -1), (1081, 300), (540, 1921), (99999, 99999)]: content = build_ass_content( **self._base_kwargs(), title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y}, ) assert "\\pos(" not in content, f"({pos_x},{pos_y}) should be rejected" assert content == base, f"({pos_x},{pos_y}) output differs from fallback" def test_invalid_coords_falls_back(self): """非法类型坐标 → 回退原逻辑。""" base = build_ass_content( **self._base_kwargs(), title_config={"position": "top", "size": 36}, ) for pos_x, pos_y in [("abc", 300), (540, None), (None, None), (True, 300), (540, False), (540.5, 300.9)]: content = build_ass_content( **self._base_kwargs(), title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y}, ) assert "\\pos(" not in content, f"({pos_x!r},{pos_y!r}) should be rejected" assert content == base, f"({pos_x!r},{pos_y!r}) output differs from fallback" def test_only_one_coord_falls_back(self): """只传 pos_x 或 pos_y → 回退原逻辑。""" base = build_ass_content( **self._base_kwargs(), title_config={"position": "center", "size": 36}, ) content_x = build_ass_content( **self._base_kwargs(), title_config={"position": "center", "size": 36, "pos_x": 540}, ) content_y = build_ass_content( **self._base_kwargs(), title_config={"position": "center", "size": 36, "pos_y": 300}, ) assert content_x == base assert content_y == base assert "\\pos(" not in content_x assert "\\pos(" not in content_y def test_position_three_levels_unchanged_without_coords(self): """无坐标时 top/center/bottom 三档 Alignment 输出不变。""" for position, expected_align in [("top", "8"), ("center", "5"), ("bottom", "2")]: content = build_ass_content( **self._base_kwargs(), title_config={"position": position, "size": 36}, ) fields = [f.strip() for f in _title_style_line(content).split(",")] assert fields[18] == expected_align def test_pos_overrides_position_alignment(self): """有合法坐标时,无论 position 是什么,Alignment 都固定为 5。""" for position in ["top", "center", "bottom"]: content = build_ass_content( **self._base_kwargs(), title_config={"position": position, "size": 36, "pos_x": 100, "pos_y": 200}, ) fields = [f.strip() for f in _title_style_line(content).split(",")] assert fields[18] == "5" assert "{\\pos(100,200)}" in _title_dialogue_line(content) def test_subtitle_not_affected_by_pos(self): """pos_x/pos_y 只影响 Title,Subtitle 输出不变。""" content = build_ass_content( **self._base_kwargs(), title_config={"pos_x": 540, "pos_y": 300}, subtitle_text="配音字幕", subtitle_config={"position": "bottom", "size": 24}, ) sub_style = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0] sub_fields = [f.strip() for f in sub_style.split(",")] assert sub_fields[18] == "2" # bottom sub_dialogue = [ line for line in content.splitlines() if line.startswith("Dialogue:") and "SubtitleStyle" in line ][0] assert "\\pos(" not in sub_dialogue class TestDefaultPositionBottom: """默认 position 应为 bottom(alignment=2),与前端 DEFAULT_TITLE_SETTINGS 对齐。""" def _base_kwargs(self): return dict( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", ) def test_no_position_defaults_to_bottom_alignment(self): """不传 position 时,Alignment 应为 2(bottom)。""" content = build_ass_content( **self._base_kwargs(), title_config={"size": 36}, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] assert fields[18] == "2", f"Expected alignment 2 (bottom), got {fields[18]}" def test_no_position_no_coords_defaults_to_bottom(self): """不传 position 也不传坐标时,走 bottom 三档逻辑。""" content = build_ass_content( **self._base_kwargs(), title_config={}, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] assert fields[18] == "2" def test_explicit_top_still_works(self): """显式传 position='top' 仍然得到 alignment=8。""" content = build_ass_content( **self._base_kwargs(), title_config={"position": "top", "size": 36}, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] assert fields[18] == "8"