Files
xiaoxia-saas/tests/unit/domain/test_subtitle_style.py
CI Bot 4c31026f81
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m29s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 4m7s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m34s
CI/CD Pipeline / Unit Tests (push) Failing after 8m43s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 17s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
style: auto-format with black + isort + prettier [skip ci-format-check]
2026-07-29 02:18:43 +00:00

515 lines
16 KiB
Python
Executable File
Raw Permalink 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.
"""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_ALIASES,
POSITION_ALIGNMENT,
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到715//2+1=8),""在位置80-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到78-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