"""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