"""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_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 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到7(15//2+1=8),","在位置8(0-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到7(8-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