Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45e950a023 |
@@ -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)
|
||||
Reference in New Issue
Block a user