449 lines
15 KiB
Python
Executable File
449 lines
15 KiB
Python
Executable File
"""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
|