From 4222df27e60f092e341e3c7ff3a2b1ff4e987a07 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 15:40:08 +0800 Subject: [PATCH] =?UTF-8?q?test(wave171):=20subtitle=5Fstyle=E5=AD=97?= =?UTF-8?q?=E5=B9=95=E6=A0=B7=E5=BC=8F=20+106=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/domain/test_subtitle_style.py | 514 +++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100755 tests/unit/domain/test_subtitle_style.py diff --git a/tests/unit/domain/test_subtitle_style.py b/tests/unit/domain/test_subtitle_style.py new file mode 100755 index 000000000..bd856851a --- /dev/null +++ b/tests/unit/domain/test_subtitle_style.py @@ -0,0 +1,514 @@ +"""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_ALIGNMENT, + POSITION_ALIASES, + 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