Files
xiaoxia-saas/tests/unit/test_ass_subtitle_builder.py
T

449 lines
15 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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