Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4222df27e6 |
Executable
+514
@@ -0,0 +1,514 @@
|
||||
"""subtitle_style 字幕样式单测.
|
||||
|
||||
纯逻辑模块,覆盖:颜色转换、透明度转换、文本转义、时间格式化、
|
||||
文本换行、SubtitleStyle数据类+from_dict、SubtitleSegment片段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.domain.subtitle_style import (
|
||||
ALLOWED_SUBTITLE_EXTENSIONS,
|
||||
DEFAULT_COLOR,
|
||||
DEFAULT_FONT,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_POSITION,
|
||||
DEFAULT_STROKE_COLOR,
|
||||
DEFAULT_STROKE_WIDTH,
|
||||
POSITION_ALIGNMENT,
|
||||
POSITION_ALIASES,
|
||||
SubtitleSegment,
|
||||
SubtitleStyle,
|
||||
escape_ass_text,
|
||||
format_ass_time,
|
||||
hex_to_ass_bgr,
|
||||
hex_to_ass_color,
|
||||
opacity_to_ass_alpha,
|
||||
wrap_text,
|
||||
)
|
||||
|
||||
|
||||
class TestHexToAssColor:
|
||||
def test_standard_red(self):
|
||||
assert hex_to_ass_color("#FF0000") == "&H000000FF"
|
||||
|
||||
def test_standard_green(self):
|
||||
assert hex_to_ass_color("#00FF00") == "&H0000FF00"
|
||||
|
||||
def test_standard_blue(self):
|
||||
assert hex_to_ass_color("#0000FF") == "&H00FF0000"
|
||||
|
||||
def test_white(self):
|
||||
assert hex_to_ass_color("#FFFFFF") == "&H00FFFFFF"
|
||||
|
||||
def test_black(self):
|
||||
assert hex_to_ass_color("#000000") == "&H00000000"
|
||||
|
||||
def test_no_hash_prefix(self):
|
||||
assert hex_to_ass_color("FF0000") == "&H000000FF"
|
||||
|
||||
def test_lowercase(self):
|
||||
assert hex_to_ass_color("#ff0000") == "&H000000FF"
|
||||
|
||||
def test_mixed_case(self):
|
||||
assert hex_to_ass_color("#aBcDeF") == "&H00EFCDAB"
|
||||
|
||||
def test_invalid_short_length(self):
|
||||
assert hex_to_ass_color("#FFF") == "&H00FFFFFF"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert hex_to_ass_color("") == "&H00FFFFFF"
|
||||
|
||||
def test_alpha_is_00(self):
|
||||
"""默认不透明(alpha=00)."""
|
||||
result = hex_to_ass_color("#123456")
|
||||
assert result.startswith("&H00")
|
||||
|
||||
|
||||
class TestHexToAssBgr:
|
||||
def test_red(self):
|
||||
assert hex_to_ass_bgr("#FF0000") == "0000FF"
|
||||
|
||||
def test_green(self):
|
||||
assert hex_to_ass_bgr("#00FF00") == "00FF00"
|
||||
|
||||
def test_blue(self):
|
||||
assert hex_to_ass_bgr("#0000FF") == "FF0000"
|
||||
|
||||
def test_white(self):
|
||||
assert hex_to_ass_bgr("#FFFFFF") == "FFFFFF"
|
||||
|
||||
def test_no_hash(self):
|
||||
assert hex_to_ass_bgr("FF0000") == "0000FF"
|
||||
|
||||
def test_non_hex_chars_still_processed(self):
|
||||
"""函数只校验长度,不校验字符有效性."""
|
||||
assert hex_to_ass_bgr("#GGGGGG") == "GGGGGG"
|
||||
|
||||
def test_short_returns_white(self):
|
||||
assert hex_to_ass_bgr("#FFF") == "FFFFFF"
|
||||
|
||||
|
||||
class TestOpacityToAssAlpha:
|
||||
def test_fully_opaque(self):
|
||||
assert opacity_to_ass_alpha(1.0) == "00"
|
||||
|
||||
def test_fully_transparent(self):
|
||||
assert opacity_to_ass_alpha(0.0) == "FF"
|
||||
|
||||
def test_half(self):
|
||||
assert opacity_to_ass_alpha(0.5) == "80"
|
||||
|
||||
def test_quarter(self):
|
||||
assert opacity_to_ass_alpha(0.25) == "C0"
|
||||
|
||||
def test_below_zero_clamped(self):
|
||||
assert opacity_to_ass_alpha(-0.5) == "FF"
|
||||
|
||||
def test_above_one_clamped(self):
|
||||
assert opacity_to_ass_alpha(1.5) == "00"
|
||||
|
||||
def test_zero_point_one(self):
|
||||
assert opacity_to_ass_alpha(0.1) == "E6"
|
||||
|
||||
|
||||
class TestEscapeAssText:
|
||||
def test_plain_text(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(self):
|
||||
assert escape_ass_text("{start") == "(start"
|
||||
|
||||
def test_closing_brace(self):
|
||||
assert escape_ass_text("end}") == "end)"
|
||||
|
||||
def test_multiple_braces(self):
|
||||
assert escape_ass_text("{a}{b}") == "(a)(b)"
|
||||
|
||||
def test_mixed_newlines_and_braces(self):
|
||||
assert escape_ass_text("{line1}\n{line2}") == "(line1)\\N(line2)"
|
||||
|
||||
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) == "0:00:00.00"
|
||||
|
||||
def test_seconds_only(self):
|
||||
assert format_ass_time(5.5) == "0:00:05.50"
|
||||
|
||||
def test_one_minute(self):
|
||||
assert format_ass_time(60.0) == "0:01:00.00"
|
||||
|
||||
def test_minutes_and_seconds(self):
|
||||
assert format_ass_time(125.5) == "0:02:05.50"
|
||||
|
||||
def test_one_hour(self):
|
||||
assert format_ass_time(3600.0) == "1:00:00.00"
|
||||
|
||||
def test_multi_hours(self):
|
||||
assert format_ass_time(7384.5) == "2:03:04.50"
|
||||
|
||||
def test_negative_becomes_zero(self):
|
||||
assert format_ass_time(-1.0) == "0:00:00.00"
|
||||
|
||||
def test_two_decimal_places(self):
|
||||
assert format_ass_time(1.234) == "0:00:01.23"
|
||||
|
||||
def test_minutes_two_digits(self):
|
||||
assert format_ass_time(599.0) == "0:09:59.00"
|
||||
|
||||
def test_float_input(self):
|
||||
assert format_ass_time(120.5) == "0:02:00.50"
|
||||
|
||||
|
||||
class TestWrapText:
|
||||
def test_short_text_no_wrap(self):
|
||||
result = wrap_text("你好世界", 20)
|
||||
assert result == ["你好世界"]
|
||||
|
||||
def test_empty_text(self):
|
||||
result = wrap_text("", 20)
|
||||
assert result == [""]
|
||||
|
||||
def test_zero_max_chars(self):
|
||||
result = wrap_text("你好世界", 0)
|
||||
assert result == ["你好世界"]
|
||||
|
||||
def test_negative_max_chars(self):
|
||||
result = wrap_text("你好世界", -5)
|
||||
assert result == ["你好世界"]
|
||||
|
||||
def test_exact_length(self):
|
||||
text = "一二三四五六七八九十"
|
||||
result = wrap_text(text, 10)
|
||||
assert result == [text]
|
||||
|
||||
def test_long_text_wrap_at_max(self):
|
||||
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
|
||||
result = wrap_text(text, 10)
|
||||
assert len(result) == 3
|
||||
assert len(result[0]) == 10
|
||||
|
||||
def test_wrap_at_punctuation(self):
|
||||
"""标点在搜索范围内时,优先在标点处断开."""
|
||||
text = "一二三四五六七八,九十一二三四五六"
|
||||
result = wrap_text(text, 15)
|
||||
# 搜索范围是15到7(15//2+1=8),","在位置8(0-indexed),在范围内
|
||||
assert result[0].endswith(",")
|
||||
assert len(result[0]) == 9 # 包括标点
|
||||
|
||||
def test_punctuation_out_of_range_breaks_at_max(self):
|
||||
"""标点在搜索范围外时,在max_chars处断开."""
|
||||
text = "你好,世界!这是一个测试。"
|
||||
result = wrap_text(text, 10)
|
||||
# "!"在位置5(< 10//2 = 5,不在搜索范围6-10内)
|
||||
assert len(result[0]) == 10
|
||||
|
||||
def test_multiple_lines(self):
|
||||
text = "一" * 50
|
||||
result = wrap_text(text, 10)
|
||||
assert len(result) == 5
|
||||
for line in result:
|
||||
assert len(line) <= 10
|
||||
|
||||
def test_punctuation_preferred_in_range(self):
|
||||
"""搜索范围内有句号时,在句号处断开而不是中间截断."""
|
||||
# 总长度12("一二三四五六七八九十。" + "二三四五六。")
|
||||
text = "一二三四五六七八九十。二三四五六。"
|
||||
result = wrap_text(text, 15)
|
||||
# "。"在位置10,在搜索范围15到7(8-15)内
|
||||
assert result[0] == "一二三四五六七八九十。"
|
||||
assert len(result[0]) == 11
|
||||
|
||||
|
||||
class TestSubtitleStyleDefaults:
|
||||
def test_default_font(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.font_name == DEFAULT_FONT
|
||||
|
||||
def test_default_size(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.font_size == DEFAULT_FONT_SIZE
|
||||
|
||||
def test_default_color(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.font_color == DEFAULT_COLOR
|
||||
|
||||
def test_default_bold_italic(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.bold is False
|
||||
assert style.italic is False
|
||||
|
||||
def test_default_stroke(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.stroke_enabled is True
|
||||
assert style.stroke_color == DEFAULT_STROKE_COLOR
|
||||
assert style.stroke_width == DEFAULT_STROKE_WIDTH
|
||||
|
||||
def test_default_shadow(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.shadow_enabled is False
|
||||
assert style.shadow_offset_x == 2
|
||||
assert style.shadow_offset_y == 2
|
||||
|
||||
def test_default_background(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.background_enabled is False
|
||||
assert style.background_opacity == 0.5
|
||||
|
||||
def test_default_position(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.position == DEFAULT_POSITION
|
||||
|
||||
def test_default_margins(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.margin_v == 60
|
||||
assert style.margin_l == 40
|
||||
assert style.margin_r == 40
|
||||
|
||||
def test_default_animation(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.fade_in == 0.0
|
||||
assert style.fade_out == 0.0
|
||||
assert style.animation_type == "none"
|
||||
|
||||
|
||||
class TestSubtitleStyleProperties:
|
||||
def test_alignment_bottom_center(self):
|
||||
style = SubtitleStyle(position="bottom_center")
|
||||
assert style.alignment == 2
|
||||
|
||||
def test_alignment_top_center(self):
|
||||
style = SubtitleStyle(position="top_center")
|
||||
assert style.alignment == 8
|
||||
|
||||
def test_alignment_center(self):
|
||||
style = SubtitleStyle(position="center")
|
||||
assert style.alignment == 5
|
||||
|
||||
def test_ass_font_color(self):
|
||||
style = SubtitleStyle(font_color="#FF0000")
|
||||
assert style.ass_font_color == "&H000000FF"
|
||||
|
||||
def test_ass_stroke_color(self):
|
||||
style = SubtitleStyle(stroke_color="#000000")
|
||||
assert style.ass_stroke_color == "&H00000000"
|
||||
|
||||
def test_ass_shadow_color(self):
|
||||
style = SubtitleStyle(shadow_color="#FFFFFF")
|
||||
assert style.ass_shadow_color == "&H00FFFFFF"
|
||||
|
||||
def test_ass_background_color(self):
|
||||
style = SubtitleStyle(
|
||||
background_enabled=True,
|
||||
background_color="#000000",
|
||||
background_opacity=0.5,
|
||||
)
|
||||
# alpha=80 (50%透明), bgr=000000
|
||||
assert style.ass_background_color == "&H80000000"
|
||||
|
||||
def test_ass_background_color_full_opaque(self):
|
||||
style = SubtitleStyle(background_color="#FF0000", background_opacity=1.0)
|
||||
assert style.ass_background_color == "&H000000FF"
|
||||
|
||||
|
||||
class TestSubtitleStyleFromDict:
|
||||
def test_none_returns_default(self):
|
||||
style = SubtitleStyle.from_dict(None)
|
||||
assert style == SubtitleStyle()
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
style = SubtitleStyle.from_dict({})
|
||||
assert style == SubtitleStyle()
|
||||
|
||||
def test_not_dict_returns_default(self):
|
||||
style = SubtitleStyle.from_dict("not a dict")
|
||||
assert style == SubtitleStyle()
|
||||
|
||||
def test_custom_font(self):
|
||||
style = SubtitleStyle.from_dict({"font": "微软雅黑", "size": 32})
|
||||
assert style.font_name == "微软雅黑"
|
||||
assert style.font_size == 32
|
||||
|
||||
def test_custom_color(self):
|
||||
style = SubtitleStyle.from_dict({"color": "#FF0000"})
|
||||
assert style.font_color == "#FF0000"
|
||||
|
||||
def test_bold_italic(self):
|
||||
style = SubtitleStyle.from_dict({"bold": True, "italic": True})
|
||||
assert style.bold is True
|
||||
assert style.italic is True
|
||||
|
||||
def test_stroke_custom(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"stroke_enabled": False,
|
||||
"stroke_color": "#FF0000",
|
||||
"stroke_width": 3.0,
|
||||
}
|
||||
)
|
||||
assert style.stroke_enabled is False
|
||||
assert style.stroke_color == "#FF0000"
|
||||
assert style.stroke_width == 3.0
|
||||
|
||||
def test_shadow_custom(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"shadow_enabled": True,
|
||||
"shadow_color": "#0000FF",
|
||||
"shadow_offset_x": 4,
|
||||
"shadow_offset_y": 4,
|
||||
"shadow_blur": 2.0,
|
||||
}
|
||||
)
|
||||
assert style.shadow_enabled is True
|
||||
assert style.shadow_color == "#0000FF"
|
||||
assert style.shadow_offset_x == 4
|
||||
assert style.shadow_offset_y == 4
|
||||
assert style.shadow_blur == 2.0
|
||||
|
||||
def test_background_custom(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"background_enabled": True,
|
||||
"background_color": "#00FF00",
|
||||
"background_opacity": 0.8,
|
||||
"background_padding": 12,
|
||||
"background_radius": 8,
|
||||
}
|
||||
)
|
||||
assert style.background_enabled is True
|
||||
assert style.background_color == "#00FF00"
|
||||
assert style.background_opacity == 0.8
|
||||
assert style.background_padding == 12
|
||||
assert style.background_radius == 8
|
||||
|
||||
def test_background_opacity_clamped(self):
|
||||
style = SubtitleStyle.from_dict({"background_opacity": -0.5})
|
||||
assert style.background_opacity == 0.0
|
||||
style2 = SubtitleStyle.from_dict({"background_opacity": 2.0})
|
||||
assert style2.background_opacity == 1.0
|
||||
|
||||
def test_position_alias_top(self):
|
||||
style = SubtitleStyle.from_dict({"position": "top"})
|
||||
assert style.position == "top_center"
|
||||
|
||||
def test_position_alias_bottom(self):
|
||||
style = SubtitleStyle.from_dict({"position": "bottom"})
|
||||
assert style.position == "bottom_center"
|
||||
|
||||
def test_position_alias_left(self):
|
||||
style = SubtitleStyle.from_dict({"position": "left"})
|
||||
assert style.position == "middle_left"
|
||||
|
||||
def test_invalid_position_falls_back(self):
|
||||
style = SubtitleStyle.from_dict({"position": "invalid_pos"})
|
||||
assert style.position == DEFAULT_POSITION
|
||||
|
||||
def test_margins(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"margin_v": 80,
|
||||
"margin_l": 60,
|
||||
"margin_r": 60,
|
||||
}
|
||||
)
|
||||
assert style.margin_v == 80
|
||||
assert style.margin_l == 60
|
||||
assert style.margin_r == 60
|
||||
|
||||
def test_line_spacing(self):
|
||||
style = SubtitleStyle.from_dict({"line_spacing": 4})
|
||||
assert style.line_spacing == 4
|
||||
|
||||
def test_fade_in_out_clamped(self):
|
||||
style = SubtitleStyle.from_dict({"fade_in": -1.0, "fade_out": -0.5})
|
||||
assert style.fade_in == 0.0
|
||||
assert style.fade_out == 0.0
|
||||
|
||||
def test_invalid_int_uses_default(self):
|
||||
style = SubtitleStyle.from_dict({"size": "not_a_number"})
|
||||
assert style.font_size == DEFAULT_FONT_SIZE
|
||||
|
||||
def test_none_str_uses_default(self):
|
||||
style = SubtitleStyle.from_dict({"color": None})
|
||||
assert style.font_color == DEFAULT_COLOR
|
||||
|
||||
def test_animation_type(self):
|
||||
style = SubtitleStyle.from_dict({"animation_type": "fade"})
|
||||
assert style.animation_type == "fade"
|
||||
|
||||
|
||||
class TestSubtitleSegment:
|
||||
def test_basic_segment(self):
|
||||
seg = SubtitleSegment(start=1.0, end=3.5, text="你好世界")
|
||||
assert seg.start == 1.0
|
||||
assert seg.end == 3.5
|
||||
assert seg.text == "你好世界"
|
||||
assert seg.style_name == "Default"
|
||||
|
||||
def test_custom_style(self):
|
||||
seg = SubtitleSegment(start=0.0, end=2.0, text="test", style_name="Title")
|
||||
assert seg.style_name == "Title"
|
||||
|
||||
def test_duration(self):
|
||||
seg = SubtitleSegment(start=1.0, end=3.5, text="test")
|
||||
assert seg.duration == 2.5
|
||||
|
||||
def test_duration_zero_when_end_before_start(self):
|
||||
seg = SubtitleSegment(start=5.0, end=3.0, text="test")
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_is_valid_true(self):
|
||||
seg = SubtitleSegment(start=1.0, end=3.0, text="hello")
|
||||
assert seg.is_valid is True
|
||||
|
||||
def test_is_valid_empty_text(self):
|
||||
seg = SubtitleSegment(start=1.0, end=3.0, text="")
|
||||
assert seg.is_valid is False
|
||||
|
||||
def test_is_valid_zero_duration(self):
|
||||
seg = SubtitleSegment(start=1.0, end=1.0, text="hello")
|
||||
assert seg.is_valid is False
|
||||
|
||||
def test_is_valid_negative_duration(self):
|
||||
seg = SubtitleSegment(start=5.0, end=3.0, text="hello")
|
||||
assert seg.is_valid is False
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_position_alignment_has_9_positions(self):
|
||||
assert len(POSITION_ALIGNMENT) == 9
|
||||
|
||||
def test_position_aliases_resolve_to_valid(self):
|
||||
for _alias, full in POSITION_ALIASES.items():
|
||||
assert full in POSITION_ALIGNMENT
|
||||
|
||||
def test_allowed_extensions_contains_common(self):
|
||||
assert ".srt" in ALLOWED_SUBTITLE_EXTENSIONS
|
||||
assert ".ass" in ALLOWED_SUBTITLE_EXTENSIONS
|
||||
assert ".vtt" in ALLOWED_SUBTITLE_EXTENSIONS
|
||||
|
||||
def test_defaults_are_valid(self):
|
||||
assert isinstance(DEFAULT_FONT, str)
|
||||
assert isinstance(DEFAULT_FONT_SIZE, int)
|
||||
assert DEFAULT_FONT_SIZE > 0
|
||||
assert DEFAULT_COLOR.startswith("#")
|
||||
assert DEFAULT_POSITION in POSITION_ALIGNMENT
|
||||
Reference in New Issue
Block a user