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
402 lines
13 KiB
Python
Executable File
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
|