Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfdf7fcefa | |||
| 9dd2226eb6 | |||
| 548fc9beb5 |
Executable
+164
@@ -0,0 +1,164 @@
|
||||
"""去重纯算法测试 — hamming_distance + histogram_similarity + VideoFingerprint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# 模块级mock cv2(dedup模块import时需要)
|
||||
sys.modules["cv2"] = MagicMock()
|
||||
|
||||
from video_processing.dedup import ( # noqa: E402
|
||||
VideoDeduplicator,
|
||||
VideoFingerprint,
|
||||
hamming_distance,
|
||||
)
|
||||
|
||||
|
||||
class TestHammingDistance:
|
||||
"""hamming_distance 汉明距离计算测试."""
|
||||
|
||||
def test_identical_hashes_zero(self):
|
||||
"""相同哈希距离为0."""
|
||||
assert hamming_distance("ff", "ff") == 0
|
||||
assert hamming_distance("00", "00") == 0
|
||||
|
||||
def test_all_different(self):
|
||||
"""全不同的8bit哈希距离为8."""
|
||||
assert hamming_distance("00", "ff") == 8
|
||||
|
||||
def test_single_bit_diff(self):
|
||||
"""1个bit不同."""
|
||||
# 0x01 = 00000001, 0x00 = 00000000 → 1 bit不同
|
||||
assert hamming_distance("01", "00") == 1
|
||||
|
||||
def test_four_bits_diff(self):
|
||||
"""4个bit不同."""
|
||||
# 0x0F = 00001111, 0xF0 = 11110000 → 8 bits都不同
|
||||
assert hamming_distance("0f", "f0") == 8
|
||||
|
||||
def test_longer_hashes(self):
|
||||
"""更长的哈希(如64-bit pHash)."""
|
||||
# 两个完全不同的64-bit哈希
|
||||
assert hamming_distance("0000000000000000", "ffffffffffffffff") == 64
|
||||
|
||||
def test_partial_difference(self):
|
||||
"""部分bit不同."""
|
||||
# a = 1010, 5 = 0101 → 4 bits不同(每个hex digit)
|
||||
assert hamming_distance("aa", "55") == 8
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""十六进制不区分大小写."""
|
||||
assert hamming_distance("FF", "ff") == 0
|
||||
assert hamming_distance("AbC123", "aBc123") == 0
|
||||
|
||||
def test_different_length_hashes(self):
|
||||
"""不同长度的哈希(短的前补零)."""
|
||||
# "ff" = 0xff = 255, "0ff" = 0x0ff = 255
|
||||
# int("ff", 16) = 255, int("0ff", 16) = 255
|
||||
assert hamming_distance("ff", "0ff") == 0
|
||||
|
||||
|
||||
class TestVideoFingerprint:
|
||||
"""VideoFingerprint 数据结构测试."""
|
||||
|
||||
def test_to_dict_contains_all_fields(self):
|
||||
"""to_dict返回完整字典."""
|
||||
fp = VideoFingerprint(
|
||||
md5="abc123",
|
||||
keyframe_phashes=["hash1", "hash2"],
|
||||
color_histograms=[[0.1, 0.2], [0.3, 0.4]],
|
||||
duration=30.5,
|
||||
resolution=(1920, 1080),
|
||||
)
|
||||
d = fp.to_dict()
|
||||
assert d["md5"] == "abc123"
|
||||
assert d["keyframe_phashes"] == ["hash1", "hash2"]
|
||||
assert d["duration"] == 30.5
|
||||
assert d["resolution"] == [1920, 1080]
|
||||
assert "color_histograms" in d
|
||||
|
||||
def test_empty_phashes(self):
|
||||
"""空关键帧列表."""
|
||||
fp = VideoFingerprint(
|
||||
md5="test",
|
||||
keyframe_phashes=[],
|
||||
color_histograms=[],
|
||||
duration=0.0,
|
||||
resolution=(0, 0),
|
||||
)
|
||||
d = fp.to_dict()
|
||||
assert d["keyframe_phashes"] == []
|
||||
assert d["color_histograms"] == []
|
||||
|
||||
|
||||
class TestAverageHistogramSimilarity:
|
||||
"""_average_histogram_similarity 直方图相似度测试."""
|
||||
|
||||
def test_identical_histograms(self):
|
||||
"""完全相同的直方图相似度为1.0."""
|
||||
hist = [[0.5, 0.5, 0.0], [0.3, 0.4, 0.3]]
|
||||
sim = VideoDeduplicator._average_histogram_similarity(hist, hist)
|
||||
assert sim == pytest.approx(1.0)
|
||||
|
||||
def test_empty_first_list(self):
|
||||
"""第一组为空返回0."""
|
||||
sim = VideoDeduplicator._average_histogram_similarity([], [[0.5, 0.5]])
|
||||
assert sim == 0.0
|
||||
|
||||
def test_empty_second_list(self):
|
||||
"""第二组为空返回0."""
|
||||
sim = VideoDeduplicator._average_histogram_similarity([[0.5, 0.5]], [])
|
||||
assert sim == 0.0
|
||||
|
||||
def test_both_empty(self):
|
||||
"""两组都为空返回0."""
|
||||
sim = VideoDeduplicator._average_histogram_similarity([], [])
|
||||
assert sim == 0.0
|
||||
|
||||
def test_orthogonal_histograms(self):
|
||||
"""正交直方图相似度为0."""
|
||||
# [1, 0] 和 [0, 1] 正交
|
||||
sim = VideoDeduplicator._average_histogram_similarity([[1.0, 0.0]], [[0.0, 1.0]])
|
||||
assert sim == pytest.approx(0.0)
|
||||
|
||||
def test_partial_similarity(self):
|
||||
"""部分相似."""
|
||||
# [1, 1] 和 [1, 0] 的余弦相似度 = 1/√2 ≈ 0.707
|
||||
sim = VideoDeduplicator._average_histogram_similarity([[1.0, 1.0]], [[1.0, 0.0]])
|
||||
assert sim == pytest.approx(1.0 / (2**0.5), rel=0.01)
|
||||
|
||||
def test_multiple_frames_best_match(self):
|
||||
"""多帧时取最佳匹配."""
|
||||
# 第一帧完全不同,第二帧完全相同 → 平均 best = (0 + 1) / 2 = 0.5
|
||||
sim = VideoDeduplicator._average_histogram_similarity(
|
||||
[[1.0, 0.0], [0.0, 1.0]],
|
||||
[[0.0, 1.0]], # 只有一帧,和第一帧0相似,和第二帧1相似
|
||||
)
|
||||
# 第一帧最佳匹配=0,第二帧最佳匹配=1,平均=0.5
|
||||
assert sim == pytest.approx(0.5)
|
||||
|
||||
def test_zero_norm_histogram_skipped(self):
|
||||
"""零范数直方图被跳过."""
|
||||
sim = VideoDeduplicator._average_histogram_similarity([[0.0, 0.0]], [[1.0, 1.0]])
|
||||
# 第一组的零范数被跳过,similarities为空,返回0
|
||||
assert sim == 0.0
|
||||
|
||||
def test_different_length_histograms(self):
|
||||
"""不同长度的直方图取最小长度对齐."""
|
||||
sim = VideoDeduplicator._average_histogram_similarity(
|
||||
[[1.0, 1.0, 0.0, 0.0]], # 4维
|
||||
[[1.0, 1.0]], # 2维
|
||||
)
|
||||
# 对齐到前2维,都是[1,1],相似度1.0
|
||||
assert sim == pytest.approx(1.0)
|
||||
|
||||
def test_similarity_in_zero_one_range(self):
|
||||
"""相似度在[0, 1]范围内."""
|
||||
hist_a = [np.random.rand(96).tolist() for _ in range(5)]
|
||||
hist_b = [np.random.rand(96).tolist() for _ in range(5)]
|
||||
sim = VideoDeduplicator._average_histogram_similarity(hist_a, hist_b)
|
||||
assert 0.0 <= sim <= 1.0
|
||||
@@ -0,0 +1,310 @@
|
||||
"""字幕渲染纯函数测试 — _build_ass_style + generate_ass_subtitles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from video_processing.render_subtitles import (
|
||||
_build_ass_style,
|
||||
_escape_ass_text,
|
||||
_format_ass_time,
|
||||
_hex_to_ass_color,
|
||||
_position_to_ass_alignment,
|
||||
generate_ass_subtitles,
|
||||
)
|
||||
|
||||
|
||||
class TestHexToAssColor:
|
||||
"""_hex_to_ass_color 颜色转换测试."""
|
||||
|
||||
def test_white(self):
|
||||
"""白色 #FFFFFF → &HFFFFFF (ASS BGR格式)."""
|
||||
assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF"
|
||||
|
||||
def test_black(self):
|
||||
"""黑色 #000000 → &H000000."""
|
||||
assert _hex_to_ass_color("#000000") == "&H000000"
|
||||
|
||||
def test_red(self):
|
||||
"""红色 #FF0000 → ASS是BGR顺序 → &H0000FF."""
|
||||
assert _hex_to_ass_color("#FF0000") == "&H0000FF"
|
||||
|
||||
def test_blue(self):
|
||||
"""蓝色 #0000FF → BGR → &HFF0000."""
|
||||
assert _hex_to_ass_color("#0000FF") == "&HFF0000"
|
||||
|
||||
def test_green(self):
|
||||
"""绿色 #00FF00 → BGR → &H00FF00."""
|
||||
assert _hex_to_ass_color("#00FF00") == "&H00FF00"
|
||||
|
||||
def test_lowercase(self):
|
||||
"""小写hex也支持."""
|
||||
assert _hex_to_ass_color("#ff0000") == "&H0000FF"
|
||||
|
||||
def test_no_hash_prefix(self):
|
||||
"""不带#的颜色."""
|
||||
assert _hex_to_ass_color("FF0000") == "&H0000FF"
|
||||
|
||||
def test_invalid_length_fallback(self):
|
||||
"""长度不对返回默认黑色."""
|
||||
assert _hex_to_ass_color("#FFF") == "&H000000"
|
||||
|
||||
|
||||
class TestPositionToAssAlignment:
|
||||
"""_position_to_ass_alignment 位置映射测试."""
|
||||
|
||||
def test_top_center(self):
|
||||
"""top → 上中(8)."""
|
||||
assert _position_to_ass_alignment("top") == 8
|
||||
|
||||
def test_bottom_center(self):
|
||||
"""bottom → 下中(2)."""
|
||||
assert _position_to_ass_alignment("bottom") == 2
|
||||
|
||||
def test_center_middle(self):
|
||||
"""center → 居中(5)."""
|
||||
assert _position_to_ass_alignment("center") == 5
|
||||
|
||||
def test_unknown_defaults_to_top(self):
|
||||
"""未知位置默认顶部(8)."""
|
||||
assert _position_to_ass_alignment("unknown") == 8
|
||||
assert _position_to_ass_alignment("top_left") == 8
|
||||
assert _position_to_ass_alignment("bottom_right") == 8
|
||||
|
||||
def test_empty_string_defaults_to_top(self):
|
||||
"""空字符串默认顶部."""
|
||||
assert _position_to_ass_alignment("") == 8
|
||||
|
||||
|
||||
class TestBuildAssStyle:
|
||||
"""_build_ass_style ASS样式行构建测试."""
|
||||
|
||||
def test_basic_style_line(self):
|
||||
"""基本样式行包含关键字段."""
|
||||
line = _build_ass_style("Default")
|
||||
assert line.startswith("Style: Default,")
|
||||
assert "思源黑体" in line
|
||||
assert "48" in line # font_size
|
||||
|
||||
def test_bold_enabled(self):
|
||||
"""加粗时Bold=-1."""
|
||||
line = _build_ass_style("Bold", bold=True)
|
||||
assert "Style: Bold," in line
|
||||
# Bold字段位置:第7个逗号分隔字段=Bold=-1
|
||||
parts = line.split(",")
|
||||
# Name, Fontname, Fontsize, Primary, Secondary, Outline, Back, Bold, ...
|
||||
assert parts[7] == "-1" # Bold
|
||||
|
||||
def test_bold_disabled(self):
|
||||
"""不加粗时Bold=0."""
|
||||
line = _build_ass_style("Normal", bold=False)
|
||||
parts = line.split(",")
|
||||
assert parts[7] == "0"
|
||||
|
||||
def test_italic_enabled(self):
|
||||
"""斜体时Italic=-1."""
|
||||
line = _build_ass_style("Italic", italic=True)
|
||||
parts = line.split(",")
|
||||
assert parts[8] == "-1" # Italic
|
||||
|
||||
def test_custom_font_size(self):
|
||||
"""自定义字号."""
|
||||
line = _build_ass_style("Big", font_size=72)
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "72" # Fontsize
|
||||
|
||||
def test_custom_alignment(self):
|
||||
"""自定义对齐方式."""
|
||||
line = _build_ass_style("Bottom", alignment=2)
|
||||
# Alignment是第16个字段(数一下)
|
||||
# Name, Fontname, Fontsize, Primary, Secondary, Outline, Back, Bold, Italic, Underline, Strikeout, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, ...
|
||||
parts = line.split(",")
|
||||
assert parts[18] == "2" # Alignment (0-indexed: 18)
|
||||
|
||||
def test_outline_width(self):
|
||||
"""描边宽度."""
|
||||
line = _build_ass_style("Outline", outline_width=3.0)
|
||||
parts = line.split(",")
|
||||
assert parts[16] == "3.0" # Outline (BorderStyle后是Outline)
|
||||
|
||||
|
||||
class TestEscapeAssText:
|
||||
"""_escape_ass_text 文本转义测试."""
|
||||
|
||||
def test_plain_text_unchanged(self):
|
||||
"""普通文本不变."""
|
||||
assert _escape_ass_text("hello world") == "hello world"
|
||||
|
||||
def test_newline_converted(self):
|
||||
"""换行符转成\\N."""
|
||||
assert _escape_ass_text("line1\nline2") == "line1\\Nline2"
|
||||
|
||||
def test_crlf_converted(self):
|
||||
"""CRLF转成\\N."""
|
||||
assert _escape_ass_text("a\r\nb") == "a\\Nb"
|
||||
|
||||
def test_carriage_return_converted(self):
|
||||
"""纯\\r转成\\N."""
|
||||
assert _escape_ass_text("a\rb") == "a\\Nb"
|
||||
|
||||
def test_curly_braces_replaced(self):
|
||||
"""大括号转成圆括号(防止ASS样式注入)."""
|
||||
assert _escape_ass_text("{text}") == "(text)"
|
||||
|
||||
def test_mixed_special_chars(self):
|
||||
"""混合特殊字符."""
|
||||
result = _escape_ass_text("line1\nline2 {bold}\rlast")
|
||||
assert "line1\\Nline2 (bold)\\Nlast" == result
|
||||
|
||||
|
||||
class TestFormatAssTime:
|
||||
"""_format_ass_time 时间格式化测试."""
|
||||
|
||||
def test_zero_seconds(self):
|
||||
"""0秒."""
|
||||
assert _format_ass_time(0) == "0:00:00.00"
|
||||
|
||||
def test_seconds_only(self):
|
||||
"""只有秒."""
|
||||
assert _format_ass_time(5.5) == "0:00:05.50"
|
||||
|
||||
def test_minutes_and_seconds(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_always_two_decimal_places(self):
|
||||
"""总是两位小数."""
|
||||
assert _format_ass_time(1.0) == "0:00:01.00"
|
||||
assert _format_ass_time(1.1) == "0:00:01.10"
|
||||
|
||||
|
||||
class TestGenerateAssSubtitles:
|
||||
"""generate_ass_subtitles ASS字幕文件生成测试."""
|
||||
|
||||
def test_no_subtitles_empty_file(self, tmp_path):
|
||||
"""没有字幕生成空文件."""
|
||||
output = tmp_path / "empty.ass"
|
||||
result = generate_ass_subtitles(
|
||||
output,
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
)
|
||||
assert result == output
|
||||
assert output.exists()
|
||||
assert output.read_text(encoding="utf-8") == ""
|
||||
|
||||
def test_title_only(self, tmp_path):
|
||||
"""只有标题."""
|
||||
output = tmp_path / "title.ass"
|
||||
result = generate_ass_subtitles(
|
||||
output,
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=30.0,
|
||||
title_text="测试标题",
|
||||
)
|
||||
assert result == output
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "Script Info" in content
|
||||
assert "PlayResX: 1080" in content
|
||||
assert "PlayResY: 1920" in content
|
||||
assert "测试标题" in content
|
||||
assert "V4+ Styles" in content
|
||||
assert "Events" in content
|
||||
|
||||
def test_subtitle_only(self, tmp_path):
|
||||
"""只有底部字幕."""
|
||||
output = tmp_path / "sub.ass"
|
||||
result = generate_ass_subtitles(
|
||||
output,
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=15.0,
|
||||
subtitle_text="这是字幕",
|
||||
)
|
||||
assert result == output
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "这是字幕" in content
|
||||
assert "PlayResX: 1080" in content
|
||||
|
||||
def test_both_title_and_subtitle(self, tmp_path):
|
||||
"""标题+字幕都有."""
|
||||
output = tmp_path / "both.ass"
|
||||
result = generate_ass_subtitles(
|
||||
output,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=20.0,
|
||||
title_text="大标题",
|
||||
subtitle_text="底部字幕",
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "大标题" in content
|
||||
assert "底部字幕" in content
|
||||
# 应该有两种样式(title和subtitle)
|
||||
assert content.count("Style:") >= 2
|
||||
|
||||
def test_title_disabled_by_config(self, tmp_path):
|
||||
"""通过config禁用标题."""
|
||||
output = tmp_path / "disabled_title.ass"
|
||||
result = generate_ass_subtitles(
|
||||
output,
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="标题",
|
||||
title_config={"enabled": False},
|
||||
subtitle_text="字幕",
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "标题" not in content
|
||||
assert "字幕" in content
|
||||
|
||||
def test_empty_title_text_not_rendered(self, tmp_path):
|
||||
"""空标题文本不渲染."""
|
||||
output = tmp_path / "empty_title.ass"
|
||||
result = generate_ass_subtitles(
|
||||
output,
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text=" ",
|
||||
subtitle_text="有字幕",
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "有字幕" in content
|
||||
|
||||
def test_custom_title_color(self, tmp_path):
|
||||
"""自定义标题颜色."""
|
||||
output = tmp_path / "color.ass"
|
||||
result = generate_ass_subtitles(
|
||||
output,
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="红色标题",
|
||||
title_config={"color": "#FF0000"},
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# 红色 → ASS BGR格式 &H0000FF
|
||||
assert "&H0000FF" in content
|
||||
|
||||
def test_dialogue_line_format(self, tmp_path):
|
||||
"""Dialogue行格式正确."""
|
||||
output = tmp_path / "dialogue.ass"
|
||||
generate_ass_subtitles(
|
||||
output,
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=5.0,
|
||||
subtitle_text="测试字幕文本",
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "Dialogue:" in content
|
||||
assert "测试字幕文本" in content
|
||||
@@ -0,0 +1,184 @@
|
||||
"""TTS配音引擎纯逻辑测试 — 数据结构 + 边界情况(mock TTS服务)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from video_processing.tts_engine import (
|
||||
TtsEngine,
|
||||
VoiceoverResult,
|
||||
VoiceoverSegment,
|
||||
)
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
from packages.ports.tts_service import TtsError
|
||||
|
||||
|
||||
class TestVoiceoverSegment:
|
||||
"""VoiceoverSegment 数据结构测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
seg = VoiceoverSegment(text="你好")
|
||||
assert seg.text == "你好"
|
||||
assert seg.start_time == 0.0
|
||||
assert seg.end_time == 0.0
|
||||
assert seg.audio_path is None
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_full_values(self):
|
||||
"""完整字段."""
|
||||
seg = VoiceoverSegment(
|
||||
text="测试",
|
||||
start_time=1.5,
|
||||
end_time=3.5,
|
||||
audio_path=Path("/tmp/test.wav"),
|
||||
duration=2.0,
|
||||
)
|
||||
assert seg.text == "测试"
|
||||
assert seg.start_time == 1.5
|
||||
assert seg.end_time == 3.5
|
||||
assert seg.audio_path == Path("/tmp/test.wav")
|
||||
assert seg.duration == 2.0
|
||||
|
||||
|
||||
class TestVoiceoverResult:
|
||||
"""VoiceoverResult 数据结构测试."""
|
||||
|
||||
def test_failure_default(self):
|
||||
"""失败结果默认值."""
|
||||
result = VoiceoverResult(success=False)
|
||||
assert result.success is False
|
||||
assert result.segments == []
|
||||
assert result.total_duration == 0.0
|
||||
assert result.error_message == ""
|
||||
|
||||
def test_success_with_segments(self):
|
||||
"""成功结果带片段."""
|
||||
seg = VoiceoverSegment(text="hi", duration=5.0)
|
||||
result = VoiceoverResult(
|
||||
success=True,
|
||||
segments=[seg],
|
||||
total_duration=5.0,
|
||||
)
|
||||
assert result.success is True
|
||||
assert len(result.segments) == 1
|
||||
assert result.total_duration == 5.0
|
||||
|
||||
def test_failure_with_message(self):
|
||||
"""失败带错误信息."""
|
||||
result = VoiceoverResult(success=False, error_message="TTS出错")
|
||||
assert result.success is False
|
||||
assert result.error_message == "TTS出错"
|
||||
|
||||
|
||||
class TestTtsEngineFullVoiceover:
|
||||
"""TtsEngine.generate_full_voiceover 整段配音测试(mock TTS)."""
|
||||
|
||||
def _make_engine(self, tmp_path: Path, tts: MagicMock | None = None) -> TtsEngine:
|
||||
"""创建测试用TtsEngine."""
|
||||
if tts is None:
|
||||
tts = MagicMock()
|
||||
tts.synthesize.return_value = str(tmp_path / "out.wav")
|
||||
return TtsEngine(tts_service=tts, work_dir=tmp_path)
|
||||
|
||||
def test_disabled_returns_failure(self, tmp_path):
|
||||
"""配音未启用→失败."""
|
||||
engine = self._make_engine(tmp_path)
|
||||
config = TtsConfig(enabled=False, text="测试")
|
||||
result = engine.generate_full_voiceover(config)
|
||||
assert result.success is False
|
||||
assert "未启用" in result.error_message
|
||||
assert len(result.segments) == 0
|
||||
|
||||
def test_empty_text_returns_failure(self, tmp_path):
|
||||
"""文本为空→失败."""
|
||||
engine = self._make_engine(tmp_path)
|
||||
config = TtsConfig(enabled=True, text=" ")
|
||||
result = engine.generate_full_voiceover(config)
|
||||
assert result.success is False
|
||||
assert "文本为空" in result.error_message
|
||||
|
||||
def test_success_creates_segment(self, tmp_path):
|
||||
"""成功合成返回正确结构."""
|
||||
mock_tts = MagicMock()
|
||||
output_file = tmp_path / "voiceover_full.wav"
|
||||
output_file.write_bytes(b"fake audio")
|
||||
mock_tts.synthesize.return_value = str(output_file)
|
||||
|
||||
engine = self._make_engine(tmp_path, mock_tts)
|
||||
config = TtsConfig(enabled=True, text="测试文本", voice_id="female_warm", speed=1.0)
|
||||
|
||||
result = engine.generate_full_voiceover(config)
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.segments) == 1
|
||||
assert result.segments[0].text == "测试文本"
|
||||
assert result.segments[0].start_time == 0.0
|
||||
assert result.total_duration > 0
|
||||
mock_tts.synthesize.assert_called_once()
|
||||
|
||||
def test_tts_error_returns_failure_gracefully(self, tmp_path):
|
||||
"""TTS抛错→优雅降级返回失败."""
|
||||
mock_tts = MagicMock()
|
||||
mock_tts.synthesize.side_effect = TtsError("合成失败")
|
||||
|
||||
engine = self._make_engine(tmp_path, mock_tts)
|
||||
config = TtsConfig(enabled=True, text="测试")
|
||||
|
||||
result = engine.generate_full_voiceover(config)
|
||||
assert result.success is False
|
||||
assert "合成失败" in result.error_message
|
||||
|
||||
def test_generic_exception_returns_failure(self, tmp_path):
|
||||
"""其他异常也降级返回失败."""
|
||||
mock_tts = MagicMock()
|
||||
mock_tts.synthesize.side_effect = RuntimeError("未知错误")
|
||||
|
||||
engine = self._make_engine(tmp_path, mock_tts)
|
||||
config = TtsConfig(enabled=True, text="测试")
|
||||
|
||||
result = engine.generate_full_voiceover(config)
|
||||
assert result.success is False
|
||||
assert "未知错误" in result.error_message
|
||||
|
||||
def test_work_dir_created(self, tmp_path):
|
||||
"""工作目录自动创建."""
|
||||
new_dir = tmp_path / "nested" / "tts"
|
||||
mock_tts = MagicMock()
|
||||
TtsEngine(tts_service=mock_tts, work_dir=new_dir)
|
||||
assert new_dir.exists()
|
||||
|
||||
|
||||
class TestTtsEngineSubtitleVoiceover:
|
||||
"""TtsEngine.generate_subtitle_voiceover 字幕配音测试(mock TTS)."""
|
||||
|
||||
def _make_engine(self, tmp_path: Path, tts: MagicMock | None = None) -> TtsEngine:
|
||||
if tts is None:
|
||||
tts = MagicMock()
|
||||
return TtsEngine(tts_service=tts, work_dir=tmp_path)
|
||||
|
||||
def test_disabled_returns_failure(self, tmp_path):
|
||||
"""配音未启用→失败."""
|
||||
engine = self._make_engine(tmp_path)
|
||||
config = TtsConfig(enabled=False, text="")
|
||||
result = engine.generate_subtitle_voiceover(config, [{"text": "hi", "start_time": 0, "end_time": 1}])
|
||||
assert result.success is False
|
||||
assert "未启用" in result.error_message
|
||||
|
||||
def test_empty_subtitles_returns_failure(self, tmp_path):
|
||||
"""字幕列表为空→失败."""
|
||||
engine = self._make_engine(tmp_path)
|
||||
config = TtsConfig(enabled=True, text="")
|
||||
result = engine.generate_subtitle_voiceover(config, [])
|
||||
assert result.success is False
|
||||
assert "字幕为空" in result.error_message
|
||||
|
||||
def test_none_subtitles_returns_failure(self, tmp_path):
|
||||
"""None字幕也失败."""
|
||||
engine = self._make_engine(tmp_path)
|
||||
config = TtsConfig(enabled=True, text="")
|
||||
result = engine.generate_subtitle_voiceover(config, None) # type: ignore
|
||||
assert result.success is False
|
||||
Reference in New Issue
Block a user