diff --git a/tests/unit/test_subtitle_generator.py b/tests/unit/test_subtitle_generator.py index a25f3f322..291722431 100755 --- a/tests/unit/test_subtitle_generator.py +++ b/tests/unit/test_subtitle_generator.py @@ -176,3 +176,582 @@ class TestGenerateAssFromTimeline: assert "0:00:01.25" in content # 1:01:01.00 格式(3661秒 = 1小时1分1秒) assert "1:01:01.00" in content + + +# ── _hex_to_ass_color 深度测试 ────────────────────────────────────────────── + + +class TestHexToAssColor: + """_hex_to_ass_color 颜色转换测试""" + + def test_standard_hex_with_hash(self): + """带#号的标准6位HEX""" + from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color + + # #RRGGBB → &HBBGGRR + assert _hex_to_ass_color("#FF0000") == "&H0000FF" # 红 + assert _hex_to_ass_color("#00FF00") == "&H00FF00" # 绿 + assert _hex_to_ass_color("#0000FF") == "&HFF0000" # 蓝 + + def test_standard_hex_without_hash(self): + """不带#号的6位HEX""" + from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color + + assert _hex_to_ass_color("FF0000") == "&H0000FF" + assert _hex_to_ass_color("00FF00") == "&H00FF00" + assert _hex_to_ass_color("0000FF") == "&HFF0000" + + def test_white_and_black(self): + """白色和黑色""" + from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color + + assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF" # 白 + assert _hex_to_ass_color("#000000") == "&H000000" # 黑 + + def test_lowercase_hex(self): + """小写字母HEX""" + from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color + + result = _hex_to_ass_color("#aabbcc") + # 输出是大写的 + assert result == "&HCCBBAA" + + def test_mixed_case_hex(self): + """大小写混合HEX""" + from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color + + result = _hex_to_ass_color("#AaBbCc") + assert result == "&HCCBBAA" + + def test_short_hex_returns_default(self): + """长度不足6位返回默认白色""" + from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color + + assert _hex_to_ass_color("#FFF") == "&H00FFFFFF" # 3位 + assert _hex_to_ass_color("FF") == "&H00FFFFFF" # 2位 + + def test_long_hex_returns_default(self): + """长度超过6位返回默认白色""" + from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color + + assert _hex_to_ass_color("#AABBCCDD") == "&H00FFFFFF" + + def test_empty_string_returns_default(self): + """空字符串返回默认白色""" + from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color + + assert _hex_to_ass_color("") == "&H00FFFFFF" + + def test_gray_color(self): + """灰色调""" + from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color + + assert _hex_to_ass_color("#808080") == "&H808080" + + +# ── _position_to_ass_alignment 测试 ──────────────────────────────────────── + + +class TestPositionToAssAlignment: + """_position_to_ass_alignment 位置映射测试""" + + def test_top_maps_to_8(self): + """顶部 → 8""" + from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment + + assert _position_to_ass_alignment("top") == 8 + + def test_center_maps_to_5(self): + """居中 → 5""" + from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment + + assert _position_to_ass_alignment("center") == 5 + + def test_bottom_maps_to_2(self): + """底部 → 2""" + from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment + + assert _position_to_ass_alignment("bottom") == 2 + + def test_unknown_position_defaults_to_bottom(self): + """未知位置默认底部(2)""" + from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment + + assert _position_to_ass_alignment("left") == 2 + assert _position_to_ass_alignment("right") == 2 + assert _position_to_ass_alignment("middle") == 2 + + def test_empty_string_defaults_to_bottom(self): + """空字符串默认底部""" + from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment + + assert _position_to_ass_alignment("") == 2 + + def test_uppercase_not_matched(self): + """大写不匹配,走默认""" + from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment + + assert _position_to_ass_alignment("TOP") == 2 + assert _position_to_ass_alignment("CENTER") == 2 + + +# ── _format_ass_time 测试 ────────────────────────────────────────────────── + + +class TestFormatAssTime: + """_format_ass_time 时间格式化测试""" + + def test_zero_seconds(self): + """0秒""" + from apps.worker.video_processing.subtitle_generator import _format_ass_time + + assert _format_ass_time(0.0) == "0:00:00.00" + + def test_fractional_seconds(self): + """小数秒""" + from apps.worker.video_processing.subtitle_generator import _format_ass_time + + assert _format_ass_time(0.5) == "0:00:00.50" + assert _format_ass_time(1.25) == "0:00:01.25" + + def test_whole_seconds(self): + """整秒""" + from apps.worker.video_processing.subtitle_generator import _format_ass_time + + assert _format_ass_time(5.0) == "0:00:05.00" + assert _format_ass_time(30.0) == "0:00:30.00" + + def test_minutes_level(self): + """分钟级""" + from apps.worker.video_processing.subtitle_generator import _format_ass_time + + assert _format_ass_time(60.0) == "0:01:00.00" + assert _format_ass_time(90.5) == "0:01:30.50" + assert _format_ass_time(599.0) == "0:09:59.00" + + def test_hours_level(self): + """小时级""" + from apps.worker.video_processing.subtitle_generator import _format_ass_time + + assert _format_ass_time(3600.0) == "1:00:00.00" + assert _format_ass_time(3661.5) == "1:01:01.50" + assert _format_ass_time(7384.0) == "2:03:04.00" + + def test_centisecond_precision(self): + """百分秒精度""" + from apps.worker.video_processing.subtitle_generator import _format_ass_time + + assert _format_ass_time(1.01) == "0:00:01.01" + assert _format_ass_time(1.99) == "0:00:01.99" + + def test_sub_centisecond_truncated(self): + """毫秒级精度会被格式化截断到百分秒""" + from apps.worker.video_processing.subtitle_generator import _format_ass_time + + # Python 的 %05.2f 会四舍五入 + result = _format_ass_time(1.123) + assert result.startswith("0:00:01.") + assert len(result.split(".")[-1]) == 2 + + +# ── _escape_ass_text 测试 ────────────────────────────────────────────────── + + +class TestEscapeAssText: + """_escape_ass_text 转义测试""" + + def test_plain_text_unchanged(self): + """普通文本不改变""" + from apps.worker.video_processing.subtitle_generator import _escape_ass_text + + assert _escape_ass_text("你好世界") == "你好世界" + assert _escape_ass_text("Hello World") == "Hello World" + + def test_newline_to_ass_newline(self): + """\\n 转 \\N""" + from apps.worker.video_processing.subtitle_generator import _escape_ass_text + + assert _escape_ass_text("第一行\n第二行") == "第一行\\N第二行" + + def test_crlf_to_ass_newline(self): + """\\r\\n 转 \\N""" + from apps.worker.video_processing.subtitle_generator import _escape_ass_text + + assert _escape_ass_text("第一行\r\n第二行") == "第一行\\N第二行" + + def test_cr_to_ass_newline(self): + """\\r 转 \\N""" + from apps.worker.video_processing.subtitle_generator import _escape_ass_text + + assert _escape_ass_text("第一行\r第二行") == "第一行\\N第二行" + + def test_curly_braces_escaped(self): + """花括号转圆括号(防止ASS标签注入)""" + from apps.worker.video_processing.subtitle_generator import _escape_ass_text + + assert _escape_ass_text("{text}") == "(text)" + assert _escape_ass_text("{{double}}") == "((double))" + + def test_mixed_escapes(self): + """混合转义""" + from apps.worker.video_processing.subtitle_generator import _escape_ass_text + + text = "你好\n世界{测试}\r\n结束" + result = _escape_ass_text(text) + assert "\\N" in result + assert "(测试)" in result + assert "\n" not in result + assert "{" not in result + + def test_empty_string(self): + """空字符串""" + from apps.worker.video_processing.subtitle_generator import _escape_ass_text + + assert _escape_ass_text("") == "" + + def test_only_braces(self): + """只有花括号""" + from apps.worker.video_processing.subtitle_generator import _escape_ass_text + + assert _escape_ass_text("{}") == "()" + + +# ── _wrap_text 补充测试 ──────────────────────────────────────────────────── + + +class TestWrapTextMore: + """_wrap_text 补充边界测试""" + + def test_empty_string_returns_empty_list(self): + """空字符串返回空列表""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + assert _wrap_text("", 10) == [""] + + def test_single_character(self): + """单字符""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + assert _wrap_text("好", 10) == ["好"] + + def test_max_chars_equals_one(self): + """max_chars=1 每个字一行""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + result = _wrap_text("一二三四", 1) + assert len(result) == 4 + assert result[0] == "一" + assert result[1] == "二" + + def test_punctuation_in_middle(self): + """标点在正中间优先从标点断开""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + # 前10字内有句号,优先在句号后断开 + text = "一二三四五六七八九十。后面的内容继续写下去" + result = _wrap_text(text, 15) + # 第一行应该包含句号 + assert "。" in result[0] + + def test_punctuation_at_start_ignored(self): + """标点在开头位置(前半部分)不会触发断开""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + text = "。一二三四五六七八九十" + result = _wrap_text(text, 10) + # 标点在第0位,不会在max_chars//2到max_chars范围内 + assert len(result) == 2 + assert len(result[0]) == 10 + + def test_no_punctuation_long_text(self): + """完全没有标点的长文本硬切""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + text = "一二三四五六七八九十一二三四五六七八九十一二三四五" + 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_exactly_two_lines(self): + """恰好两行""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + text = "一" * 20 + result = _wrap_text(text, 10) + assert len(result) == 2 + assert len(result[0]) == 10 + assert len(result[1]) == 10 + + def test_three_lines(self): + """三行""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + 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_multiple_punctuation_points(self): + """多个标点,选择最后一个合适的""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + text = "你好。再见。谢谢。抱歉。好的不行了" + result = _wrap_text(text, 12) + # 应该在最靠后的(在范围内的)标点处断开 + assert "。" in result[0] + + def test_english_punctuation_wrap(self): + """英文标点也会触发换行""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + text = "Hello world. This is a test sentence." + result = _wrap_text(text, 20) + assert len(result) >= 2 + assert result[0].endswith(".") or "." in result[0] + + def test_total_length_preserved(self): + """换行后总字符数不变""" + from apps.worker.video_processing.subtitle_generator import _wrap_text + + text = "这是一段用于测试换行功能的中文文本,包含了各种标点符号。看看效果如何?" + result = _wrap_text(text, 10) + assert "".join(result) == text + + +# ── generate_ass_from_timeline 补充测试 ──────────────────────────────────── + + +class TestGenerateAssFromTimelineMore: + """generate_ass_from_timeline 补充测试""" + + def test_single_segment(self): + """单段字幕""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text="你好", start=0.0, end=1.0)], + total_duration=1.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "single.ass" + generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080) + content = output.read_text(encoding="utf-8") + assert "Dialogue:" in content + assert "你好" in content + assert content.count("Dialogue:") == 1 + + def test_multiple_segments(self): + """多段字幕""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text=f"第{i}段", start=float(i), end=float(i + 1)) for i in range(5)], + total_duration=5.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "multi.ass" + generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080) + content = output.read_text(encoding="utf-8") + assert content.count("Dialogue:") == 5 + + def test_long_text_auto_wrap(self): + """长字幕自动换行""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + long_text = "这是一段非常非常长的字幕文本,用来测试自动换行功能是否正常工作。" + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text=long_text, start=0, end=5)], + total_duration=5.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "wrap.ass" + generate_ass_from_timeline( + output, + timeline, + video_width=1920, + video_height=1080, + subtitle_config={"max_chars_per_line": 10}, + ) + content = output.read_text(encoding="utf-8") + # 应该包含 \N 换行符 + assert "\\N" in content + + def test_custom_color_hex(self): + """自定义颜色正确转换""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text="红", start=0, end=1)], + total_duration=1.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "red.ass" + generate_ass_from_timeline( + output, + timeline, + video_width=1920, + video_height=1080, + subtitle_config={"color": "#FF0000"}, + ) + content = output.read_text(encoding="utf-8") + # 红色 #FF0000 → &H0000FF (BBGGRR) + assert "&H0000FF" in content + + def test_position_top(self): + """顶部位置""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text="顶部", start=0, end=1)], + total_duration=1.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "top.ass" + generate_ass_from_timeline( + output, + timeline, + video_width=1920, + video_height=1080, + subtitle_config={"position": "top"}, + ) + content = output.read_text(encoding="utf-8") + # 顶部对齐是 \\an8 → Style 中 Alignment=8 + assert "1,1.5,0,8," in content or ",0,8," in content + + def test_position_center(self): + """居中位置""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text="居中", start=0, end=1)], + total_duration=1.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "center.ass" + generate_ass_from_timeline( + output, + timeline, + video_width=1920, + video_height=1080, + subtitle_config={"position": "center"}, + ) + content = output.read_text(encoding="utf-8") + # 居中对齐是 Alignment=5 + assert ",0,5," in content + + def test_custom_font_size(self): + """自定义字体大小""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text="大", start=0, end=1)], + total_duration=1.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "big.ass" + generate_ass_from_timeline( + output, + timeline, + video_width=1920, + video_height=1080, + subtitle_config={"size": 48}, + ) + content = output.read_text(encoding="utf-8") + # Style 行中字号应该是48 + assert "Default,思源黑体,48," in content + + def test_720p_resolution(self): + """720p分辨率""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text="720p", start=0, end=1)], + total_duration=1.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "720p.ass" + generate_ass_from_timeline(output, timeline, video_width=1280, video_height=720) + content = output.read_text(encoding="utf-8") + assert "PlayResX: 1280" in content + assert "PlayResY: 720" in content + + def test_special_characters_in_text(self): + """字幕文本含特殊字符(花括号、换行)""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text="你好{tag}\n世界", start=0, end=1)], + total_duration=1.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "special.ass" + generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080) + content = output.read_text(encoding="utf-8") + # 花括号被转义 + assert "(tag)" in content + # 换行被转义成 \N + assert "\\N" in content + + def test_output_path_creates_parent_dirs(self): + """自动创建父目录""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text="测试", start=0, end=1)], + total_duration=1.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "nested" / "deep" / "out.ass" + result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080) + assert result.exists() + assert result.parent.exists() + + def test_returns_output_path(self): + """返回值等于输出路径""" + from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + import tempfile + from pathlib import Path + + timeline = SubtitleTimeline( + segments=[SubtitleSegment(text="返回", start=0, end=1)], + total_duration=1.0, + ) + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "ret.ass" + result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080) + assert result == output diff --git a/tests/unit/test_text_splitter.py b/tests/unit/test_text_splitter.py index c87a3ad72..3e221601e 100755 --- a/tests/unit/test_text_splitter.py +++ b/tests/unit/test_text_splitter.py @@ -325,3 +325,88 @@ class TestSplitTextEdgeCases: assert len(result) > 1 for seg in result: assert len(seg) <= 20 + + +# ── 更多边界场景补充 ───────────────────────────────────────────────────────── + + +class TestSplitTextMoreEdgeCases: + """更多边界场景补充""" + + def test_max_chars_one(self): + """max_chars=1 每个字符一段""" + text = "一二三四五" + result = split_text(text, max_chars=1) + assert len(result) == 5 + for seg in result: + assert len(seg) == 1 + + def test_consecutive_newlines(self): + """连续多个换行符""" + text = "第一段\n\n\n第二段\n\n第三段" + result = split_text(text, max_chars=100) + # 合并后应该是一段(内容不长且合并逻辑会被合并) + assert len(result) >= 1 + assert "第一段" in result[0] + for seg in result: + assert len(seg) <= 100 + + def test_only_newlines_only(self): + """只有换行符(纯空白被strip掉返回空""" + assert split_text("\n\n\n\n") == [] + + def test_leading_trailing_whitespace(self): + """首尾空白被去除""" + text = " 你好世界。 " + result = split_text(text, max_chars=100) + assert result == ["你好世界。"] + + def test_very_long_single_sentence_many_segments(self): + """超长单句被切成很多段""" + text = "字" * 1000 + result = split_text(text, max_chars=100) + assert len(result) == 10 + for seg in result: + assert len(seg) == 100 + + def test_mixed_punctuation_types(self): + """全角半角标点混合""" + text = "你好!再见。谢谢?抱歉;好的" + result = split_text(text, max_chars=200) + assert len(result) == 1 + + def test_last_segment_short_merged_to_previous(self): + """尾部极短段被合并到前一段""" + # 构造第一段接近max_chars,结尾有个短句尾巴 + long_part = "一二三四五六七八九十" * 9 + "。" # ~90字 + tail = "完" # 1字 + text = long_part + tail + result = split_text(text, max_chars=100) + # 尾巴应该被合并 + combined = "".join(result) + assert combined == text.strip() + assert len(result) <= 2 + + def test_all_short_sentences_merged_into_one(self): + """大量短句全部合并成一段""" + sentences = ["你好。", "我好。", "他好。", "大家好。", "才是真的好。"] + text = "".join(sentences) + result = split_text(text, max_chars=200) + assert len(result) == 1 + + def test_punctuation_only_long(self): + """很长的纯标点文本""" + text = "。" * 200 + result = split_text(text, max_chars=50) + assert len(result) >= 4 + for seg in result: + assert len(seg) <= 50 + + def test_tab_not_sentence_end(self): + """制表符不是句子结束符""" + text = "这是一段\t包含制表符的文本内容" + "字" * 100 + result = split_text(text, max_chars=50) + # 制表符不在句子结束符集合中,不会触发分段 + # 制表符会保留在分段内容中 + has_tab = any("\t" in seg for seg in result) + assert has_tab