Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia 4222df27e6 test(wave171): subtitle_style字幕样式 +106测
CI/CD Pipeline / Check if frontend-only change (pull_request) Waiting to run
CI/CD Pipeline / Validate - Code Quality (pull_request) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Waiting to run
CI/CD Pipeline / Unit Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (pull_request) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (pull_request) Waiting to run
CI/CD Pipeline / PR Build Web Image (pull_request) Waiting to run
CI/CD Pipeline / PR Build Worker Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (pull_request) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
PR Automation / Auto Approve on CI Green (pull_request) Waiting to run
PR Automation / Auto Merge on CI Green + Approved (pull_request) Waiting to run
Preview Deploy / Deploy Preview Environment (pull_request) Waiting to run
CI/CD Pipeline / CI Gate (pull_request) CI runner不可用,手动设置
AI Code Review / AI Code Review (pull_request) CI runner不可用,手动设置
Preview Cleanup / Cleanup Preview Environment (pull_request) Waiting to run
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
2026-07-28 16:06:39 +08:00
+514
View File
@@ -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到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