Files
xiaoxia-saas/tests/unit/test_subtitle_style.py
CI Bot 9f153eec54
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m9s
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 / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m4s
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 / Validate - Code Quality (push) Failing after 2m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m21s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m22s
CI/CD Pipeline / Unit Tests (push) Failing after 6m18s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
style: auto-format with black + isort + prettier
2026-07-26 16:28:12 +00:00

402 lines
13 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