Files
xiaoxia-saas/tests/unit/test_subtitle_style.py
T

625 lines
22 KiB
Python
Executable File

"""subtitle_style 领域模型单测 — 纯逻辑."""
from __future__ import annotations
import pytest
from packages.domain.subtitle_style import (
ALLOWED_SUBTITLE_EXTENSIONS,
DEFAULT_COLOR,
DEFAULT_FONT,
DEFAULT_FONT_SIZE,
DEFAULT_MAX_CHARS_PER_LINE,
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 TestConstants:
def test_position_alignment_has_9_positions(self):
assert len(POSITION_ALIGNMENT) == 9
assert POSITION_ALIGNMENT["bottom_center"] == 2
assert POSITION_ALIGNMENT["top_center"] == 8
assert POSITION_ALIGNMENT["center"] == 5
def test_position_aliases(self):
assert POSITION_ALIASES["top"] == "top_center"
assert POSITION_ALIASES["bottom"] == "bottom_center"
assert POSITION_ALIASES["middle"] == "center"
def test_default_values(self):
assert DEFAULT_FONT == "思源黑体"
assert DEFAULT_FONT_SIZE == 24
assert DEFAULT_COLOR == "#FFFFFF"
assert DEFAULT_POSITION == "bottom_center"
def test_allowed_extensions(self):
assert ".srt" in ALLOWED_SUBTITLE_EXTENSIONS
assert ".ass" in ALLOWED_SUBTITLE_EXTENSIONS
assert ".vtt" in ALLOWED_SUBTITLE_EXTENSIONS
# ── 工具函数测试 ──────────────────────────────────────────────────────────────
class TestHexToAssColor:
def test_white(self):
assert hex_to_ass_color("#FFFFFF") == "&H00FFFFFF"
def test_black(self):
assert hex_to_ass_color("#000000") == "&H00000000"
def test_red(self):
assert hex_to_ass_color("#FF0000") == "&H000000FF"
def test_blue(self):
assert hex_to_ass_color("#0000FF") == "&H00FF0000"
def test_green(self):
assert hex_to_ass_color("#00FF00") == "&H0000FF00"
def test_without_hash(self):
assert hex_to_ass_color("FF0000") == "&H000000FF"
def test_lowercase(self):
assert hex_to_ass_color("#ff0000") == "&H000000FF"
def test_invalid_length_returns_white(self):
assert hex_to_ass_color("#FFF") == "&H00FFFFFF"
assert hex_to_ass_color("#FF000000") == "&H00FFFFFF"
def test_empty_string(self):
assert hex_to_ass_color("") == "&H00FFFFFF"
class TestHexToAssBgr:
def test_white(self):
assert hex_to_ass_bgr("#FFFFFF") == "FFFFFF"
def test_red(self):
assert hex_to_ass_bgr("#FF0000") == "0000FF"
def test_blue(self):
assert hex_to_ass_bgr("#0000FF") == "FF0000"
def test_invalid_length(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_above_1_clamped(self):
assert opacity_to_ass_alpha(1.5) == "00"
def test_below_0_clamped(self):
assert opacity_to_ass_alpha(-0.5) == "FF"
class TestEscapeAssText:
def test_newline_unix(self):
assert escape_ass_text("hello\nworld") == "hello\\Nworld"
def test_newline_windows(self):
assert escape_ass_text("hello\r\nworld") == "hello\\Nworld"
def test_newline_mac(self):
assert escape_ass_text("hello\rworld") == "hello\\Nworld"
def test_curly_braces(self):
assert escape_ass_text("{text}") == "(text)"
def test_mixed(self):
assert escape_ass_text("hello\n{world}\r\nend") == "hello\\N(world)\\Nend"
def test_empty(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_minutes(self):
assert format_ass_time(65.25) == "0:01:05.25"
def test_hours(self):
assert format_ass_time(3661.5) == "1:01:01.50"
def test_negative_returns_zero(self):
assert format_ass_time(-1.0) == "0:00:00.00"
def test_centiseconds_precision(self):
assert format_ass_time(1.234) == "0:00:01.23"
class TestWrapText:
def test_short_text_no_wrap(self):
assert wrap_text("你好", 10) == ["你好"]
def test_exact_length_no_wrap(self):
text = "你" * 10
result = wrap_text(text, 10)
assert len(result) == 1
assert len(result[0]) == 10
def test_long_text_breaks_at_max(self):
text = "你" * 25
result = wrap_text(text, 10)
assert len(result) == 3
assert len(result[0]) == 10
assert len(result[1]) == 10
assert len(result[2]) == 5
def test_breaks_at_punctuation(self):
# "一二三四五六。七八九十"共10字,"。"在索引6
# max_chars=8 时,从 8 往回找到 4,会命中索引6的"。"
text = "一二三四五六。七八九十"
result = wrap_text(text, 8)
assert len(result) == 2
assert result[0] == "一二三四五六。"
assert result[1] == "七八九十"
def test_no_punctuation_breaks_at_max(self):
text = "一二三四五六七八九十一二三四五六七八九十"
result = wrap_text(text, 10)
assert len(result[0]) == 10
def test_empty_text(self):
assert wrap_text("", 10) == [""]
def test_zero_max_chars(self):
assert wrap_text("hello", 0) == ["hello"]
def test_negative_max_chars(self):
result = wrap_text("hello", -5)
assert isinstance(result, list)
assert len(result) == 1
# ── SubtitleStyle 测试 ───────────────────────────────────────────────────────
class TestSubtitleStyleDefaults:
def test_default_values(self):
style = SubtitleStyle()
assert style.font_name == DEFAULT_FONT
assert style.font_size == DEFAULT_FONT_SIZE
assert style.font_color == DEFAULT_COLOR
assert style.bold is False
assert style.italic is False
assert style.stroke_enabled is True
assert style.stroke_color == DEFAULT_STROKE_COLOR
assert style.stroke_width == DEFAULT_STROKE_WIDTH
assert style.position == DEFAULT_POSITION
assert style.max_chars_per_line == DEFAULT_MAX_CHARS_PER_LINE
class TestSubtitleStyleFromDict:
def test_none_returns_default(self):
style = SubtitleStyle.from_dict(None)
assert style.font_name == DEFAULT_FONT
def test_empty_dict_returns_default(self):
style = SubtitleStyle.from_dict({})
assert style.font_size == DEFAULT_FONT_SIZE
def test_custom_font(self):
style = SubtitleStyle.from_dict({"font": "微软雅黑", "size": 32})
assert style.font_name == "微软雅黑"
assert style.font_size == 32
def test_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_config(self):
style = SubtitleStyle.from_dict(
{
"stroke_enabled": False,
"stroke_color": "#00FF00",
"stroke_width": 2.0,
}
)
assert style.stroke_enabled is False
assert style.stroke_color == "#00FF00"
assert style.stroke_width == 2.0
def test_shadow_config(self):
style = SubtitleStyle.from_dict(
{
"shadow_enabled": True,
"shadow_color": "#111111",
"shadow_offset_x": 4,
"shadow_offset_y": 4,
"shadow_blur": 1.5,
}
)
assert style.shadow_enabled is True
assert style.shadow_color == "#111111"
assert style.shadow_offset_x == 4
assert style.shadow_offset_y == 4
assert style.shadow_blur == 1.5
def test_background_config(self):
style = SubtitleStyle.from_dict(
{
"background_enabled": True,
"background_color": "#000000",
"background_opacity": 0.7,
"background_padding": 10,
"background_radius": 6,
}
)
assert style.background_enabled is True
assert style.background_opacity == 0.7
assert style.background_padding == 10
def test_background_opacity_clamped_0_to_1(self):
style = SubtitleStyle.from_dict({"background_opacity": -0.5})
assert style.background_opacity == 0.0
style2 = SubtitleStyle.from_dict({"background_opacity": 1.5})
assert style2.background_opacity == 1.0
def test_position_valid(self):
style = SubtitleStyle.from_dict({"position": "top_center"})
assert style.position == "top_center"
def test_position_alias(self):
style = SubtitleStyle.from_dict({"position": "top"})
assert style.position == "top_center"
def test_position_invalid_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": 50, "margin_r": 50})
assert style.margin_v == 80
assert style.margin_l == 50
assert style.margin_r == 50
def test_max_chars_per_line(self):
style = SubtitleStyle.from_dict({"max_chars_per_line": 15})
assert style.max_chars_per_line == 15
def test_line_spacing(self):
style = SubtitleStyle.from_dict({"line_spacing": 4})
assert style.line_spacing == 4
def test_fade_in_out(self):
style = SubtitleStyle.from_dict({"fade_in": 0.5, "fade_out": 1.0})
assert style.fade_in == 0.5
assert style.fade_out == 1.0
def test_fade_negative_clamped(self):
style = SubtitleStyle.from_dict({"fade_in": -1, "fade_out": -2})
assert style.fade_in == 0.0
assert style.fade_out == 0.0
def test_animation_type(self):
style = SubtitleStyle.from_dict({"animation_type": "fade"})
assert style.animation_type == "fade"
def test_invalid_int_falls_back(self):
style = SubtitleStyle.from_dict({"size": "not_a_number"})
assert style.font_size == DEFAULT_FONT_SIZE
def test_invalid_float_falls_back(self):
style = SubtitleStyle.from_dict({"stroke_width": "abc"})
assert style.stroke_width == DEFAULT_STROKE_WIDTH
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_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="#00FF00")
assert style.ass_stroke_color == "&H0000FF00"
def test_ass_shadow_color(self):
style = SubtitleStyle(shadow_color="#0000FF")
assert style.ass_shadow_color == "&H00FF0000"
def test_ass_background_color(self):
style = SubtitleStyle(background_color="#FF0000", background_opacity=0.5)
# alpha = 255 - 127 = 128 = 0x80, bgr of red = 0000FF
assert style.ass_background_color == "&H800000FF"
# ── SubtitleSegment 测试 ─────────────────────────────────────────────────────
class TestSubtitleSegment:
def test_basic(self):
seg = SubtitleSegment(start=1.0, end=3.0, text="你好")
assert seg.start == 1.0
assert seg.end == 3.0
assert seg.text == "你好"
assert seg.style_name == "Default"
def test_custom_style(self):
seg = SubtitleSegment(start=0, end=2, text="hi", style_name="Title")
assert seg.style_name == "Title"
def test_duration(self):
seg = SubtitleSegment(start=1.5, end=4.0, 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=0, end=2, text="hello")
assert seg.is_valid is True
def test_is_valid_empty_text(self):
seg = SubtitleSegment(start=0, end=2, text="")
assert seg.is_valid is False
def test_is_valid_zero_duration(self):
seg = SubtitleSegment(start=1, end=1, text="hello")
assert seg.is_valid is False
# ── line_overrides 逐行样式覆盖测试 ─────────────────────────────────────────
class TestBuildLineOverrideTag:
def test_empty_overrides_returns_empty(self):
style = SubtitleStyle()
assert style.build_line_override_tag(0, 1) == ""
def test_no_matching_line_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 1, "color": "#FF0000"}]})
assert style.build_line_override_tag(0, 2) == ""
def test_out_of_range_index_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 5, "color": "#FF0000"}]})
assert style.build_line_override_tag(0, 2) == ""
def test_negative_index_resolves_from_end(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": -1, "color": "#FF0000", "font_size": 48}],
}
)
tag = style.build_line_override_tag(-1, 3) # last line, 3 lines total
assert tag
assert "\\c&H000000FF" in tag # red
assert "\\fs48" in tag
assert tag.startswith("{") and tag.endswith("}")
def test_color_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 0, "color": "#00FF00"}],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\c&H0000FF00" in tag # green = 00FF00 → bgr 00FF00
def test_font_size_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 0, "font_size": 60}],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\fs60" in tag
def test_font_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 0, "font": "优设标题黑"}],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\fn优设标题黑" in tag
def test_bold_on_off(self):
style_bold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": True}]})
assert "\\b1" in style_bold.build_line_override_tag(0, 1)
style_nobold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": False}]})
assert "\\b0" in style_nobold.build_line_override_tag(0, 1)
def test_italic_on_off(self):
style_italic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": True}]})
assert "\\i1" in style_italic.build_line_override_tag(0, 1)
style_noitalic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": False}]})
assert "\\i0" in style_noitalic.build_line_override_tag(0, 1)
def test_stroke_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 0, "stroke_color": "#0000FF", "stroke_width": 3.0}],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\3c&H00FF0000" in tag # blue bgr
assert "\\bord3" in tag
def test_shadow_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [
{"line_index": 0, "shadow_color": "#000000", "shadow_offset_x": 2, "shadow_offset_y": 3}
],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\4c&H00000000" in tag # black
assert "\\xshad2" in tag
assert "\\yshad3" in tag
def test_full_combo(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [
{
"line_index": 0,
"color": "#FF0000",
"font_size": 72,
"font": "抖音美好体",
"bold": True,
"italic": False,
"stroke_color": "#FFFFFF",
"stroke_width": 2,
"shadow_color": "#000000",
"shadow_offset_x": 0,
"shadow_offset_y": 4,
}
],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\c&H000000FF" in tag
assert "\\fs72" in tag
assert "\\fn抖音美好体" in tag
assert "\\b1" in tag
assert "\\i0" in tag
assert "\\3c&H00FFFFFF" in tag
assert "\\bord2" in tag
assert "\\4c&H00000000" in tag
assert "\\xshad0" in tag
assert "\\yshad4" in tag
def test_empty_override_dict_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0}]})
assert style.build_line_override_tag(0, 1) == ""
def test_non_dict_items_filtered(self):
# 从 dict 解析时已过滤,这里手动构造测试
style = SubtitleStyle(line_overrides=["not a dict", {"line_index": 0, "color": "#FF0000"}])
tag = style.build_line_override_tag(0, 1)
assert "\\c&H000000FF" in tag
def test_total_lines_zero_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]})
assert style.build_line_override_tag(0, 0) == ""
def test_line_index_none_skipped(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"color": "#FF0000"}]}) # 无 line_index
assert style.build_line_override_tag(0, 1) == ""
class TestApplyLineOverrides:
def test_empty_text_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]})
assert style.apply_line_overrides("") == ""
def test_no_overrides_returns_original(self):
style = SubtitleStyle()
assert style.apply_line_overrides("hello\\Nworld") == "hello\\Nworld"
def test_single_line(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 60}]})
result = style.apply_line_overrides("单行标题")
assert result.startswith("{")
assert "单行标题" in result
assert "\\c&H000000FF" in result
def test_multi_line_only_first_line_tagged(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 72}]})
result = style.apply_line_overrides("第一行\\N第二行\\N第三行")
lines = result.split("\\N")
assert len(lines) == 3
assert lines[0].startswith("{\\c&H000000FF")
assert "第一行" in lines[0]
assert lines[1] == "第二行" # 无标签
assert lines[2] == "第三行"
def test_multi_line_middle_and_last(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [
{"line_index": 0, "font_size": 72, "bold": True},
{"line_index": -1, "color": "#00FF00", "font_size": 36},
],
}
)
result = style.apply_line_overrides("主标题\\N副标题\\N脚注")
lines = result.split("\\N")
assert lines[0].startswith("{\\fs72\\b1}")
assert lines[1] == "副标题"
assert "\\c&H0000FF00" in lines[2]
assert "\\fs36" in lines[2]
def test_line_without_override_kept_verbatim(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 1, "bold": True}],
}
)
result = style.apply_line_overrides("第一行\\N第二行")
lines = result.split("\\N")
assert lines[0] == "第一行"
assert lines[1].startswith("{\\b1}")
assert "第二行" in lines[1]
class TestFromDictLineOverrides:
def test_from_dict_parses_line_overrides(self):
cfg = {
"line_overrides": [
{"line_index": 0, "color": "#FF0000", "bold": True},
{"line_index": 1, "font_size": 36},
],
}
style = SubtitleStyle.from_dict(cfg)
assert len(style.line_overrides) == 2
assert style.line_overrides[0]["line_index"] == 0
assert style.line_overrides[1]["font_size"] == 36
def test_from_dict_filters_non_dict_items(self):
cfg = {"line_overrides": [{"line_index": 0, "color": "#FF0000"}, "bad", None, 123]}
style = SubtitleStyle.from_dict(cfg)
assert len(style.line_overrides) == 1
def test_from_dict_no_field_defaults_empty(self):
style = SubtitleStyle.from_dict({"font": "微软雅黑"})
assert style.line_overrides == []
def test_default_has_empty_list(self):
style = SubtitleStyle()
assert style.line_overrides == []