"""字幕生成器 + Mock ASR 单元测试。""" import tempfile from pathlib import Path import pytest from apps.worker.video_processing.subtitle_generator import ( _wrap_text, generate_ass_from_timeline, ) from packages.adapters.asr.mock_asr_service import MockASRService from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline from packages.ports.asr_service import ASRServiceError class TestMockASRService: def test_transcribe_with_mock_text(self): service = MockASRService(mock_text="你好世界!这是一段测试语音识别的文字。用来验证Mock ASR是否正常工作。") # 创建一个假的音频文件(mock不真的读内容) with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: f.write(b"fake audio data") audio_path = Path(f.name) try: timeline = service.transcribe(audio_path, language="zh") assert timeline is not None assert timeline.language == "zh" assert timeline.segment_count > 0 assert timeline.total_duration > 0 # 总字数应该对得上 assert timeline.total_chars == len("你好世界!这是一段测试语音识别的文字。用来验证Mock ASR是否正常工作。") finally: audio_path.unlink() def test_transcribe_file_not_found(self): service = MockASRService() with pytest.raises(ASRServiceError): service.transcribe(Path("/nonexistent/audio.wav")) def test_transcribe_with_word_timestamps(self): service = MockASRService(mock_text="你好世界!") with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: f.write(b"fake") audio_path = Path(f.name) try: timeline = service.transcribe(audio_path, with_word_timestamps=True) # 每段应该有词级时间戳 for seg in timeline.segments: if seg.words: assert len(seg.words) > 0 assert seg.words[0].start >= seg.start assert seg.words[-1].end <= seg.end finally: audio_path.unlink() def test_auto_detect_language(self): service = MockASRService(mock_text="Hello world. This is a test.") with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: f.write(b"fake") audio_path = Path(f.name) try: timeline = service.transcribe(audio_path, language=None) # None 时默认 zh assert timeline.language == "zh" finally: audio_path.unlink() class TestWrapText: def test_short_text_no_wrap(self): result = _wrap_text("你好世界", 20) assert result == ["你好世界"] def test_wrap_at_punctuation(self): result = _wrap_text("你好世界!这是一段很长的测试文字。", 10) assert len(result) == 2 assert "!" in result[0] def test_hard_wrap_no_punctuation(self): result = _wrap_text("一二三四五六七八九十一二三四五六七八九十", 10) assert len(result) == 2 assert len(result[0]) == 10 assert len(result[1]) == 10 def test_exact_length(self): result = _wrap_text("一二三四五六七八九十", 10) assert len(result) == 1 class TestGenerateAssFromTimeline: def test_generate_basic(self): timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="你好世界", start=0.0, end=2.0), SubtitleSegment(text="这是测试", start=2.0, end=4.0), ], language="zh", total_duration=4.0, ) with tempfile.TemporaryDirectory() as tmpdir: output_path = Path(tmpdir) / "test.ass" result = generate_ass_from_timeline( output_path, timeline, video_width=1920, video_height=1080, ) assert result.exists() content = result.read_text(encoding="utf-8") assert "[Script Info]" in content assert "[V4+ Styles]" in content assert "[Events]" in content assert "你好世界" in content assert "这是测试" in content assert "PlayResX: 1920" in content assert "PlayResY: 1080" in content def test_empty_timeline(self): timeline = SubtitleTimeline(segments=[]) with tempfile.TemporaryDirectory() as tmpdir: output_path = Path(tmpdir) / "empty.ass" result = generate_ass_from_timeline(output_path, timeline, video_width=1920, video_height=1080) assert result.exists() assert result.read_text(encoding="utf-8") == "" def test_with_custom_style(self): timeline = SubtitleTimeline( segments=[SubtitleSegment(text="测试", start=0, end=1)], total_duration=1.0, ) with tempfile.TemporaryDirectory() as tmpdir: output_path = Path(tmpdir) / "style.ass" generate_ass_from_timeline( output_path, timeline, video_width=1280, video_height=720, subtitle_config={ "font": "微软雅黑", "size": 32, "color": "#ff0000", "position": "bottom", }, ) content = output_path.read_text(encoding="utf-8") assert "微软雅黑" in content assert "32" in content def test_time_format(self): timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="测试", start=0.5, end=1.25), SubtitleSegment(text="长字幕", start=3661.0, end=3662.5), # 超过1小时 ], total_duration=3662.5, ) with tempfile.TemporaryDirectory() as tmpdir: output_path = Path(tmpdir) / "time.ass" generate_ass_from_timeline(output_path, timeline, video_width=1920, video_height=1080) content = output_path.read_text(encoding="utf-8") # 0:00:00.50 格式 assert "0:00:00.50" in content 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): """单段字幕""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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): """多段字幕""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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): """长字幕自动换行""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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): """自定义颜色正确转换""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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): """顶部位置""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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): """居中位置""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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): """自定义字体大小""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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分辨率""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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): """字幕文本含特殊字符(花括号、换行)""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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): """自动创建父目录""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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): """返回值等于输出路径""" import tempfile from pathlib import Path from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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