"""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 # ── line_overrides 逐行样式覆盖测试 ───────────────────────────────────────── class TestBuildLineOverrideTag: def test_empty_overrides_returns_empty(self): style = SubtitleStyle() assert style.build_line_override_tag(0, 1) == "" def test_no_matching_line_returns_empty(self): style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 1, "color": "#FF0000"}]}) assert style.build_line_override_tag(0, 2) == "" def test_out_of_range_index_returns_empty(self): style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 5, "color": "#FF0000"}]}) assert style.build_line_override_tag(0, 2) == "" def test_negative_index_resolves_from_end(self): style = SubtitleStyle.from_dict( { "line_overrides": [{"line_index": -1, "color": "#FF0000", "font_size": 48}], } ) tag = style.build_line_override_tag(-1, 3) # last line, 3 lines total assert tag assert "\\c&H000000FF" in tag # red assert "\\fs48" in tag assert tag.startswith("{") and tag.endswith("}") def test_color_override(self): style = SubtitleStyle.from_dict( { "line_overrides": [{"line_index": 0, "color": "#00FF00"}], } ) tag = style.build_line_override_tag(0, 1) assert "\\c&H0000FF00" in tag # green = 00FF00 → bgr 00FF00 def test_font_size_override(self): style = SubtitleStyle.from_dict( { "line_overrides": [{"line_index": 0, "font_size": 60}], } ) tag = style.build_line_override_tag(0, 1) assert "\\fs60" in tag def test_font_override(self): style = SubtitleStyle.from_dict( { "line_overrides": [{"line_index": 0, "font": "优设标题黑"}], } ) tag = style.build_line_override_tag(0, 1) assert "\\fn优设标题黑" in tag def test_bold_on_off(self): style_bold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": True}]}) assert "\\b1" in style_bold.build_line_override_tag(0, 1) style_nobold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": False}]}) assert "\\b0" in style_nobold.build_line_override_tag(0, 1) def test_italic_on_off(self): style_italic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": True}]}) assert "\\i1" in style_italic.build_line_override_tag(0, 1) style_noitalic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": False}]}) assert "\\i0" in style_noitalic.build_line_override_tag(0, 1) def test_stroke_override(self): style = SubtitleStyle.from_dict( { "line_overrides": [{"line_index": 0, "stroke_color": "#0000FF", "stroke_width": 3.0}], } ) tag = style.build_line_override_tag(0, 1) assert "\\3c&H00FF0000" in tag # blue bgr assert "\\bord3" in tag def test_shadow_override(self): style = SubtitleStyle.from_dict( { "line_overrides": [ {"line_index": 0, "shadow_color": "#000000", "shadow_offset_x": 2, "shadow_offset_y": 3} ], } ) tag = style.build_line_override_tag(0, 1) assert "\\4c&H00000000" in tag # black assert "\\xshad2" in tag assert "\\yshad3" in tag def test_full_combo(self): style = SubtitleStyle.from_dict( { "line_overrides": [ { "line_index": 0, "color": "#FF0000", "font_size": 72, "font": "抖音美好体", "bold": True, "italic": False, "stroke_color": "#FFFFFF", "stroke_width": 2, "shadow_color": "#000000", "shadow_offset_x": 0, "shadow_offset_y": 4, } ], } ) tag = style.build_line_override_tag(0, 1) assert "\\c&H000000FF" in tag assert "\\fs72" in tag assert "\\fn抖音美好体" in tag assert "\\b1" in tag assert "\\i0" in tag assert "\\3c&H00FFFFFF" in tag assert "\\bord2" in tag assert "\\4c&H00000000" in tag assert "\\xshad0" in tag assert "\\yshad4" in tag def test_empty_override_dict_returns_empty(self): style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0}]}) assert style.build_line_override_tag(0, 1) == "" def test_non_dict_items_filtered(self): # 从 dict 解析时已过滤,这里手动构造测试 style = SubtitleStyle(line_overrides=["not a dict", {"line_index": 0, "color": "#FF0000"}]) tag = style.build_line_override_tag(0, 1) assert "\\c&H000000FF" in tag def test_total_lines_zero_returns_empty(self): style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]}) assert style.build_line_override_tag(0, 0) == "" def test_line_index_none_skipped(self): style = SubtitleStyle.from_dict({"line_overrides": [{"color": "#FF0000"}]}) # 无 line_index assert style.build_line_override_tag(0, 1) == "" class TestApplyLineOverrides: def test_empty_text_returns_empty(self): style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]}) assert style.apply_line_overrides("") == "" def test_no_overrides_returns_original(self): style = SubtitleStyle() assert style.apply_line_overrides("hello\\Nworld") == "hello\\Nworld" def test_single_line(self): style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 60}]}) result = style.apply_line_overrides("单行标题") assert result.startswith("{") assert "单行标题" in result assert "\\c&H000000FF" in result def test_multi_line_only_first_line_tagged(self): style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 72}]}) result = style.apply_line_overrides("第一行\\N第二行\\N第三行") lines = result.split("\\N") assert len(lines) == 3 assert lines[0].startswith("{\\c&H000000FF") assert "第一行" in lines[0] assert lines[1] == "第二行" # 无标签 assert lines[2] == "第三行" def test_multi_line_middle_and_last(self): style = SubtitleStyle.from_dict( { "line_overrides": [ {"line_index": 0, "font_size": 72, "bold": True}, {"line_index": -1, "color": "#00FF00", "font_size": 36}, ], } ) result = style.apply_line_overrides("主标题\\N副标题\\N脚注") lines = result.split("\\N") assert lines[0].startswith("{\\fs72\\b1}") assert lines[1] == "副标题" assert "\\c&H0000FF00" in lines[2] assert "\\fs36" in lines[2] def test_line_without_override_kept_verbatim(self): style = SubtitleStyle.from_dict( { "line_overrides": [{"line_index": 1, "bold": True}], } ) result = style.apply_line_overrides("第一行\\N第二行") lines = result.split("\\N") assert lines[0] == "第一行" assert lines[1].startswith("{\\b1}") assert "第二行" in lines[1] class TestFromDictLineOverrides: def test_from_dict_parses_line_overrides(self): cfg = { "line_overrides": [ {"line_index": 0, "color": "#FF0000", "bold": True}, {"line_index": 1, "font_size": 36}, ], } style = SubtitleStyle.from_dict(cfg) assert len(style.line_overrides) == 2 assert style.line_overrides[0]["line_index"] == 0 assert style.line_overrides[1]["font_size"] == 36 def test_from_dict_filters_non_dict_items(self): cfg = {"line_overrides": [{"line_index": 0, "color": "#FF0000"}, "bad", None, 123]} style = SubtitleStyle.from_dict(cfg) assert len(style.line_overrides) == 1 def test_from_dict_no_field_defaults_empty(self): style = SubtitleStyle.from_dict({"font": "微软雅黑"}) assert style.line_overrides == [] def test_default_has_empty_list(self): style = SubtitleStyle() assert style.line_overrides == []