Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b233f8e539 | |||
| 78257fae96 |
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
render_audio 纯工具函数测试.
|
||||
|
||||
覆盖 clip_effective_duration / RenderContext 等纯逻辑.
|
||||
核心混音逻辑依赖 FFmpeg,由集成测试覆盖.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from video_processing.render_audio import RenderContext, clip_effective_duration
|
||||
|
||||
|
||||
class TestClipEffectiveDuration:
|
||||
"""clip_effective_duration 有效时长计算."""
|
||||
|
||||
def test_duration_specified_and_actual_longer(self):
|
||||
"""指定了 duration,且实际时长更长 → 取 duration"""
|
||||
clip = SimpleNamespace(duration=5.0, actual_duration=10.0)
|
||||
assert clip_effective_duration(clip) == 5.0
|
||||
|
||||
def test_duration_specified_and_actual_shorter(self):
|
||||
"""指定了 duration,但实际时长更短 → 取实际时长"""
|
||||
clip = SimpleNamespace(duration=10.0, actual_duration=5.0)
|
||||
assert clip_effective_duration(clip) == 5.0
|
||||
|
||||
def test_duration_specified_actual_zero(self):
|
||||
"""指定了 duration,但实际时长为 0 → 取 duration"""
|
||||
clip = SimpleNamespace(duration=5.0, actual_duration=0.0)
|
||||
assert clip_effective_duration(clip) == 5.0
|
||||
|
||||
def test_duration_specified_actual_negative(self):
|
||||
"""指定了 duration,但实际时长为负 → 取 duration"""
|
||||
clip = SimpleNamespace(duration=5.0, actual_duration=-1.0)
|
||||
assert clip_effective_duration(clip) == 5.0
|
||||
|
||||
def test_no_duration_use_actual(self):
|
||||
"""未指定 duration(0),用实际时长"""
|
||||
clip = SimpleNamespace(duration=0.0, actual_duration=8.0)
|
||||
assert clip_effective_duration(clip) == 8.0
|
||||
|
||||
def test_no_duration_negative_actual(self):
|
||||
"""未指定 duration,实际时长也为负 → 返回 0"""
|
||||
clip = SimpleNamespace(duration=0.0, actual_duration=-5.0)
|
||||
assert clip_effective_duration(clip) == 0.0
|
||||
|
||||
def test_no_duration_zero_actual(self):
|
||||
"""都为 0 → 返回 0"""
|
||||
clip = SimpleNamespace(duration=0.0, actual_duration=0.0)
|
||||
assert clip_effective_duration(clip) == 0.0
|
||||
|
||||
def test_negative_duration_use_actual(self):
|
||||
"""duration 为负(视为未指定),用实际时长"""
|
||||
clip = SimpleNamespace(duration=-1.0, actual_duration=5.0)
|
||||
assert clip_effective_duration(clip) == 5.0
|
||||
|
||||
def test_both_negative_returns_zero(self):
|
||||
"""两者都为负 → 返回 0"""
|
||||
clip = SimpleNamespace(duration=-2.0, actual_duration=-3.0)
|
||||
assert clip_effective_duration(clip) == 0.0
|
||||
|
||||
def test_exact_match(self):
|
||||
"""duration 与实际时长相等"""
|
||||
clip = SimpleNamespace(duration=7.5, actual_duration=7.5)
|
||||
assert clip_effective_duration(clip) == 7.5
|
||||
|
||||
def test_very_short_duration(self):
|
||||
"""很短的时长"""
|
||||
clip = SimpleNamespace(duration=0.1, actual_duration=0.2)
|
||||
assert clip_effective_duration(clip) == 0.1
|
||||
|
||||
|
||||
class TestRenderContext:
|
||||
"""RenderContext 渲染上下文."""
|
||||
|
||||
def test_create_with_work_dir_and_plan_id(self, tmp_path):
|
||||
ctx = RenderContext(work_dir=tmp_path, plan_id="plan_123")
|
||||
assert ctx.work_dir == tmp_path
|
||||
assert ctx.plan_id == "plan_123"
|
||||
assert ctx.noise_reduction_config is None
|
||||
assert ctx._audio_cache == {}
|
||||
|
||||
def test_noise_reduction_config(self, tmp_path):
|
||||
config = {"enabled": True, "strength": 0.5}
|
||||
ctx = RenderContext(
|
||||
work_dir=tmp_path,
|
||||
plan_id="plan_123",
|
||||
noise_reduction_config=config,
|
||||
)
|
||||
assert ctx.noise_reduction_config == config
|
||||
assert ctx.noise_reduction_config["enabled"] is True
|
||||
|
||||
def test_audio_cache_isolation(self, tmp_path):
|
||||
"""每个实例有独立的缓存字典."""
|
||||
ctx1 = RenderContext(work_dir=tmp_path, plan_id="p1")
|
||||
ctx2 = RenderContext(work_dir=tmp_path, plan_id="p2")
|
||||
ctx1._audio_cache["key1"] = True
|
||||
assert "key1" not in ctx2._audio_cache
|
||||
assert len(ctx2._audio_cache) == 0
|
||||
|
||||
def test_work_dir_path_type(self, tmp_path):
|
||||
ctx = RenderContext(work_dir=tmp_path, plan_id="test")
|
||||
assert isinstance(ctx.work_dir, Path)
|
||||
Executable → Regular
+252
-90
@@ -1,122 +1,284 @@
|
||||
"""贴纸引擎单元测试 - 配置+解析等纯逻辑."""
|
||||
"""
|
||||
贴纸引擎配置与纯逻辑测试.
|
||||
|
||||
覆盖 ImageStickerConfig / TextStickerConfig / _resolve_position / 常量与便捷函数.
|
||||
引擎核心滤镜生成与渲染依赖 FFmpeg,由集成测试覆盖.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.sticker_engine import (
|
||||
POSITION_PRESETS,
|
||||
STICKER_CATEGORIES,
|
||||
ImageStickerConfig,
|
||||
StickerEngine,
|
||||
StickerOverlayResult,
|
||||
TextStickerConfig,
|
||||
get_sticker_categories,
|
||||
parse_stickers_from_config,
|
||||
)
|
||||
|
||||
|
||||
class TestImageStickerConfigDefaults:
|
||||
"""ImageStickerConfig 默认值测试."""
|
||||
class TestStickerConstants:
|
||||
"""常量测试."""
|
||||
|
||||
def test_nine_position_presets(self):
|
||||
assert len(POSITION_PRESETS) == 9
|
||||
assert "top_left" in POSITION_PRESETS
|
||||
assert "top_center" in POSITION_PRESETS
|
||||
assert "top_right" in POSITION_PRESETS
|
||||
assert "center_left" in POSITION_PRESETS
|
||||
assert "center" in POSITION_PRESETS
|
||||
assert "center_right" in POSITION_PRESETS
|
||||
assert "bottom_left" in POSITION_PRESETS
|
||||
assert "bottom_center" in POSITION_PRESETS
|
||||
assert "bottom_right" in POSITION_PRESETS
|
||||
|
||||
def test_position_values_are_fractions(self):
|
||||
for name, (x, y) in POSITION_PRESETS.items():
|
||||
assert 0.0 <= x <= 1.0, f"{name} x={x} out of range"
|
||||
assert 0.0 <= y <= 1.0, f"{name} y={y} out of range"
|
||||
|
||||
def test_sticker_categories(self):
|
||||
assert len(STICKER_CATEGORIES) >= 3
|
||||
for cat_id, cat_name in STICKER_CATEGORIES:
|
||||
assert isinstance(cat_id, str)
|
||||
assert isinstance(cat_name, str)
|
||||
assert len(cat_id) > 0
|
||||
assert len(cat_name) > 0
|
||||
|
||||
|
||||
class TestImageStickerConfig:
|
||||
"""图片贴纸配置."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
s = ImageStickerConfig()
|
||||
assert s.enabled is False
|
||||
assert s.type == "image"
|
||||
assert s.position == "top_right"
|
||||
assert s.x is None
|
||||
assert s.y is None
|
||||
assert s.x_unit == "percent"
|
||||
assert s.y_unit == "percent"
|
||||
assert s.scale == 1.0
|
||||
assert s.width is None
|
||||
assert s.height is None
|
||||
assert s.opacity == 1.0
|
||||
assert s.start_time == 0.0
|
||||
assert s.duration == 0.0
|
||||
assert s.fade_in == 0.0
|
||||
assert s.fade_out == 0.0
|
||||
assert s.z_index == 10
|
||||
assert s.image_url == ""
|
||||
assert s.preset_id == ""
|
||||
cfg = ImageStickerConfig()
|
||||
assert cfg.enabled is False
|
||||
assert cfg.type == "image"
|
||||
assert cfg.position == "top_right"
|
||||
assert cfg.x is None
|
||||
assert cfg.y is None
|
||||
assert cfg.x_unit == "percent"
|
||||
assert cfg.y_unit == "percent"
|
||||
assert cfg.scale == 1.0
|
||||
assert cfg.width is None
|
||||
assert cfg.height is None
|
||||
assert cfg.opacity == 1.0
|
||||
assert cfg.start_time == 0.0
|
||||
assert cfg.duration == 0.0
|
||||
assert cfg.fade_in == 0.0
|
||||
assert cfg.fade_out == 0.0
|
||||
assert cfg.z_index == 10
|
||||
assert cfg.image_url == ""
|
||||
assert cfg.preset_id == ""
|
||||
|
||||
def test_custom_values(self):
|
||||
cfg = ImageStickerConfig(
|
||||
enabled=True,
|
||||
position="center",
|
||||
x=50.0,
|
||||
y=30.0,
|
||||
scale=0.5,
|
||||
opacity=0.8,
|
||||
start_time=1.0,
|
||||
duration=5.0,
|
||||
fade_in=0.5,
|
||||
fade_out=0.5,
|
||||
z_index=5,
|
||||
image_url="/tmp/sticker.png",
|
||||
preset_id="sticker_001",
|
||||
)
|
||||
assert cfg.enabled is True
|
||||
assert cfg.position == "center"
|
||||
assert cfg.x == 50.0
|
||||
assert cfg.y == 30.0
|
||||
assert cfg.scale == 0.5
|
||||
assert cfg.opacity == 0.8
|
||||
assert cfg.start_time == 1.0
|
||||
assert cfg.duration == 5.0
|
||||
assert cfg.z_index == 5
|
||||
assert cfg.image_url == "/tmp/sticker.png"
|
||||
|
||||
|
||||
class TestTextStickerConfigDefaults:
|
||||
"""TextStickerConfig 默认值测试."""
|
||||
class TestTextStickerConfig:
|
||||
"""文字贴纸配置."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
s = TextStickerConfig()
|
||||
assert s.enabled is False
|
||||
assert s.type == "text"
|
||||
assert s.text == ""
|
||||
assert s.font_size == 36
|
||||
assert s.font_color == "#FFFFFF"
|
||||
assert s.font_family == "sans"
|
||||
assert s.stroke_color == "#000000"
|
||||
assert s.stroke_width == 2
|
||||
assert s.shadow_color == "#000000"
|
||||
assert s.shadow_x == 2
|
||||
assert s.shadow_y == 2
|
||||
assert s.shadow_alpha == 0.5
|
||||
assert s.position == "center"
|
||||
assert s.start_time == 0.0
|
||||
assert s.duration == 0.0
|
||||
assert s.z_index == 10
|
||||
assert s.bg_color == ""
|
||||
assert s.bg_padding == 8
|
||||
assert s.bg_alpha == 0.8
|
||||
assert s.bg_corner_radius == 8
|
||||
cfg = TextStickerConfig()
|
||||
assert cfg.enabled is False
|
||||
assert cfg.type == "text"
|
||||
assert cfg.text == ""
|
||||
assert cfg.font_size == 36
|
||||
assert cfg.font_color == "#FFFFFF"
|
||||
assert cfg.font_family == "sans"
|
||||
assert cfg.stroke_color == "#000000"
|
||||
assert cfg.stroke_width == 2
|
||||
assert cfg.shadow_color == "#000000"
|
||||
assert cfg.shadow_x == 2
|
||||
assert cfg.shadow_y == 2
|
||||
assert cfg.shadow_alpha == 0.5
|
||||
assert cfg.position == "center"
|
||||
assert cfg.z_index == 10
|
||||
assert cfg.bg_color == ""
|
||||
assert cfg.bg_padding == 8
|
||||
assert cfg.bg_alpha == 0.8
|
||||
assert cfg.bg_corner_radius == 8
|
||||
|
||||
def test_custom_text_sticker(self):
|
||||
cfg = TextStickerConfig(
|
||||
enabled=True,
|
||||
text="Hello",
|
||||
font_size=48,
|
||||
font_color="#FF0000",
|
||||
position="bottom_center",
|
||||
bg_color="#000000",
|
||||
bg_padding=16,
|
||||
)
|
||||
assert cfg.enabled is True
|
||||
assert cfg.text == "Hello"
|
||||
assert cfg.font_size == 48
|
||||
assert cfg.font_color == "#FF0000"
|
||||
assert cfg.position == "bottom_center"
|
||||
assert cfg.bg_color == "#000000"
|
||||
assert cfg.bg_padding == 16
|
||||
|
||||
|
||||
class TestStickerOverlayResult:
|
||||
"""贴纸叠加结果."""
|
||||
|
||||
def test_default_values(self):
|
||||
result = StickerOverlayResult(filter_str="overlay=10:20", output_label="[out]")
|
||||
assert result.filter_str == "overlay=10:20"
|
||||
assert result.output_label == "[out]"
|
||||
assert result.extra_inputs == []
|
||||
|
||||
def test_with_extra_inputs(self):
|
||||
result = StickerOverlayResult(
|
||||
filter_str="overlay=0:0",
|
||||
output_label="[out]",
|
||||
extra_inputs=["/tmp/sticker.png"],
|
||||
)
|
||||
assert len(result.extra_inputs) == 1
|
||||
assert result.extra_inputs[0] == "/tmp/sticker.png"
|
||||
|
||||
|
||||
class TestResolvePosition:
|
||||
"""_resolve_position 位置解析."""
|
||||
|
||||
def test_top_left_preset(self):
|
||||
cfg = ImageStickerConfig(position="top_left")
|
||||
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
|
||||
# top_left: (0.05, 0.05) → x = 0.05*1000 - 50 = 0, y = 0.05*500 - 25 = 0
|
||||
assert x >= 0
|
||||
assert y >= 0
|
||||
|
||||
def test_center_preset(self):
|
||||
cfg = ImageStickerConfig(position="center")
|
||||
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 200, 100)
|
||||
# center: (0.5, 0.5) → x = 500 - 100 = 400, y = 250 - 50 = 200
|
||||
assert x == 400.0
|
||||
assert y == 200.0
|
||||
|
||||
def test_bottom_right_preset(self):
|
||||
cfg = ImageStickerConfig(position="bottom_right")
|
||||
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
|
||||
# bottom_right: (0.95, 0.95) → x = 950 - 50 = 900, y = 475 - 25 = 450
|
||||
assert x == 900.0
|
||||
assert y == 450.0
|
||||
|
||||
def test_custom_percent_position(self):
|
||||
cfg = ImageStickerConfig(position="center", x=25.0, y=75.0, x_unit="percent", y_unit="percent")
|
||||
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
|
||||
# x = 0.25*1000 - 50 = 200, y = 0.75*500 - 25 = 350
|
||||
assert x == 200.0
|
||||
assert y == 350.0
|
||||
|
||||
def test_custom_pixel_position(self):
|
||||
cfg = ImageStickerConfig(position="center", x=300, y=200, x_unit="pixel", y_unit="pixel")
|
||||
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
|
||||
# x = 300/1000*1000 - 50 = 250, y = 200/500*500 - 25 = 175
|
||||
# 等等,让我重新算:px = config.x / canvas_w = 300/1000 = 0.3
|
||||
# x = px * canvas_w - sticker_w/2 = 0.3*1000 - 50 = 300 - 50 = 250
|
||||
assert x == 250.0
|
||||
assert y == 175.0
|
||||
|
||||
def test_zero_size_sticker(self):
|
||||
cfg = ImageStickerConfig(position="center")
|
||||
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 0, 0)
|
||||
# 贴纸尺寸为0时,位置就是中心点
|
||||
assert x == 500.0
|
||||
assert y == 250.0
|
||||
|
||||
def test_invalid_position_falls_back_to_center(self):
|
||||
cfg = ImageStickerConfig(position="invalid_position")
|
||||
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
|
||||
# 无效位置 → 默认居中 → x = 500 - 50 = 450, y = 250 - 25 = 225
|
||||
assert x == 450.0
|
||||
assert y == 225.0
|
||||
|
||||
def test_position_clamped_to_canvas(self):
|
||||
# 贴纸太大,位置被钳制
|
||||
cfg = ImageStickerConfig(position="top_left")
|
||||
x, y = StickerEngine._resolve_position(cfg, 100, 100, 200, 200)
|
||||
# 贴纸比画布还大,应该被钳制到 0
|
||||
assert x >= 0
|
||||
assert y >= 0
|
||||
assert x <= 100
|
||||
assert y <= 100
|
||||
|
||||
def test_zero_canvas_handling(self):
|
||||
cfg = ImageStickerConfig(position="center", x=50, y=50, x_unit="pixel", y_unit="pixel")
|
||||
x, y = StickerEngine._resolve_position(cfg, 0, 0, 10, 10)
|
||||
# 画布为0时不应崩溃,结果被钳制到0
|
||||
assert x == 0
|
||||
assert y == 0
|
||||
|
||||
def test_text_sticker_position(self):
|
||||
cfg = TextStickerConfig(position="top_right")
|
||||
x, y = StickerEngine._resolve_position(cfg, 800, 400, 100, 30)
|
||||
# top_right: (0.95, 0.05) → x = 760 - 50 = 710, 但被钳制到 canvas_w - sticker_w = 700
|
||||
# y = 20 - 15 = 5
|
||||
assert x == 700.0
|
||||
assert y == 5.0
|
||||
|
||||
|
||||
class TestParseStickersFromConfig:
|
||||
"""parse_stickers_from_config 测试."""
|
||||
"""parse_stickers_from_config 便捷函数."""
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
"""None返回空列表."""
|
||||
result = parse_stickers_from_config(None)
|
||||
assert result == []
|
||||
assert parse_stickers_from_config(None) == []
|
||||
|
||||
def test_empty_dict_returns_empty(self):
|
||||
"""空dict返回空."""
|
||||
result = parse_stickers_from_config({})
|
||||
assert result == []
|
||||
assert parse_stickers_from_config({}) == []
|
||||
|
||||
def test_no_stickers_key_returns_empty(self):
|
||||
"""无stickers键返回空."""
|
||||
result = parse_stickers_from_config({"other": "value"})
|
||||
assert result == []
|
||||
assert parse_stickers_from_config({"other": "data"}) == []
|
||||
|
||||
def test_stickers_not_list_returns_empty(self):
|
||||
"""stickers不是列表返回空."""
|
||||
result = parse_stickers_from_config({"stickers": "not_a_list"})
|
||||
assert result == []
|
||||
def test_stickers_list_returned(self):
|
||||
stickers = [{"type": "image", "url": "/a.png"}, {"type": "text", "text": "hi"}]
|
||||
result = parse_stickers_from_config({"stickers": stickers})
|
||||
assert result == stickers
|
||||
assert len(result) == 2
|
||||
|
||||
def test_stickers_not_a_list_returns_empty(self):
|
||||
assert parse_stickers_from_config({"stickers": "not_a_list"}) == []
|
||||
|
||||
def test_empty_stickers_list(self):
|
||||
"""空贴纸列表."""
|
||||
result = parse_stickers_from_config({"stickers": []})
|
||||
assert result == []
|
||||
assert parse_stickers_from_config({"stickers": []}) == []
|
||||
|
||||
def test_single_sticker(self):
|
||||
"""单个贴纸."""
|
||||
result = parse_stickers_from_config(
|
||||
{
|
||||
"stickers": [{"type": "text", "text": "hello"}],
|
||||
}
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["text"] == "hello"
|
||||
|
||||
def test_multiple_stickers(self):
|
||||
"""多个贴纸."""
|
||||
result = parse_stickers_from_config(
|
||||
{
|
||||
"stickers": [
|
||||
{"type": "text", "text": "a"},
|
||||
{"type": "image", "image_url": "/b.png"},
|
||||
{"type": "text", "text": "c"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(result) == 3
|
||||
class TestGetStickerCategories:
|
||||
"""get_sticker_categories 便捷函数."""
|
||||
|
||||
def test_returns_raw_dicts(self):
|
||||
"""返回原始dict,不做转换."""
|
||||
sticker = {"type": "text", "text": "test", "font_size": 48}
|
||||
result = parse_stickers_from_config({"stickers": [sticker]})
|
||||
assert result[0] is sticker # 引用相同,不做深拷贝
|
||||
def test_returns_list_of_tuples(self):
|
||||
result = get_sticker_categories()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
for item in result:
|
||||
assert isinstance(item, tuple)
|
||||
assert len(item) == 2
|
||||
|
||||
def test_matches_constant(self):
|
||||
result = get_sticker_categories()
|
||||
assert result == list(STICKER_CATEGORIES)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""字幕渲染引擎单元测试 - 工具函数+样式配置等纯逻辑."""
|
||||
"""
|
||||
字幕渲染引擎纯函数与配置测试.
|
||||
|
||||
覆盖 SubtitleStyle / SubtitleSegment / 颜色转换 / 时间格式化 / 文字换行 / ASS转义等纯逻辑.
|
||||
引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.subtitle_render_engine import (
|
||||
SubtitleSegment,
|
||||
SubtitleStyle,
|
||||
_escape_ass_text,
|
||||
_format_ass_time,
|
||||
@@ -13,306 +19,276 @@ from video_processing.subtitle_render_engine import (
|
||||
_wrap_text,
|
||||
)
|
||||
|
||||
# ── 颜色转换测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHexToAssColor:
|
||||
"""_hex_to_ass_color 测试."""
|
||||
"""HEX → ASS 颜色转换."""
|
||||
|
||||
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):
|
||||
"""红色 → BGR: 蓝绿红."""
|
||||
# #FF0000 → R=FF, G=00, B=00 → BGR=0000FF
|
||||
assert _hex_to_ass_color("#FF0000") == "&H000000FF"
|
||||
|
||||
def test_green(self):
|
||||
"""绿色."""
|
||||
assert _hex_to_ass_color("#00FF00") == "&H0000FF00"
|
||||
|
||||
def test_blue(self):
|
||||
"""蓝色."""
|
||||
# #0000FF → R=00, G=00, B=FF → BGR=FF0000
|
||||
assert _hex_to_ass_color("#0000FF") == "&H00FF0000"
|
||||
|
||||
def test_no_hash_prefix(self):
|
||||
"""不带#号."""
|
||||
def test_green(self):
|
||||
# #00FF00 → R=00, G=FF, B=00 → BGR=00FF00
|
||||
assert _hex_to_ass_color("#00FF00") == "&H0000FF00"
|
||||
|
||||
def test_without_hash_prefix(self):
|
||||
assert _hex_to_ass_color("FF0000") == "&H000000FF"
|
||||
|
||||
def test_invalid_length(self):
|
||||
"""长度不对返回默认白色."""
|
||||
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF"
|
||||
assert _hex_to_ass_color("#FF") == "&H00FFFFFF"
|
||||
assert _hex_to_ass_color("") == "&H00FFFFFF"
|
||||
def test_lowercase_hex(self):
|
||||
assert _hex_to_ass_color("#ff0000") == "&H000000FF"
|
||||
|
||||
def test_lowercase_input(self):
|
||||
"""小写输入转为大写输出."""
|
||||
assert _hex_to_ass_color("#aabbcc") == "&H00CCBBAA"
|
||||
def test_mixed_case(self):
|
||||
assert _hex_to_ass_color("#aBcDeF") == "&H00EFCDAB"
|
||||
|
||||
def test_invalid_length_returns_default(self):
|
||||
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF" # 3位
|
||||
assert _hex_to_ass_color("#FF") == "&H00FFFFFF" # 2位
|
||||
assert _hex_to_ass_color("#") == "&H00FFFFFF" # 空
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _hex_to_ass_color("") == "&H00FFFFFF"
|
||||
|
||||
|
||||
class TestHexToAssBgr:
|
||||
"""_hex_to_ass_bgr 测试."""
|
||||
"""HEX → ASS BGR 部分."""
|
||||
|
||||
def test_white(self):
|
||||
"""白色BGR."""
|
||||
assert _hex_to_ass_bgr("#FFFFFF") == "FFFFFF"
|
||||
|
||||
def test_red_bgr(self):
|
||||
"""红色 → BGR = 0000FF."""
|
||||
def test_black(self):
|
||||
assert _hex_to_ass_bgr("#000000") == "000000"
|
||||
|
||||
def test_red(self):
|
||||
# #FF0000 → BGR = 0000FF
|
||||
assert _hex_to_ass_bgr("#FF0000") == "0000FF"
|
||||
|
||||
def test_blue_bgr(self):
|
||||
"""蓝色 → BGR = FF0000."""
|
||||
def test_blue(self):
|
||||
# #0000FF → BGR = FF0000
|
||||
assert _hex_to_ass_bgr("#0000FF") == "FF0000"
|
||||
|
||||
def test_invalid_length(self):
|
||||
"""长度不对返回默认."""
|
||||
assert _hex_to_ass_bgr("#FF") == "FFFFFF"
|
||||
def test_without_hash(self):
|
||||
assert _hex_to_ass_bgr("FF0000") == "0000FF"
|
||||
|
||||
def test_invalid_length_returns_white(self):
|
||||
assert _hex_to_ass_bgr("#123") == "FFFFFF"
|
||||
|
||||
|
||||
class TestOpacityToAssAlpha:
|
||||
"""_opacity_to_ass_alpha 测试."""
|
||||
"""不透明度 → ASS alpha."""
|
||||
|
||||
def test_fully_opaque(self):
|
||||
"""完全不透明 → 00."""
|
||||
# 1.0 → alpha = 255 - 255 = 0 → "00"
|
||||
assert _opacity_to_ass_alpha(1.0) == "00"
|
||||
|
||||
def test_fully_transparent(self):
|
||||
"""完全透明 → FF."""
|
||||
# 0.0 → alpha = 255 - 0 = 255 → "FF"
|
||||
assert _opacity_to_ass_alpha(0.0) == "FF"
|
||||
|
||||
def test_half(self):
|
||||
"""50% → 128 → 80."""
|
||||
# 0.5 → alpha = 255 - 127 = 128 → "80" (因为 int(0.5*255)=127)
|
||||
# 注意:int(0.5 * 255) = 127,255-127=128 → "80"
|
||||
assert _opacity_to_ass_alpha(0.5) == "80"
|
||||
|
||||
def test_quarter(self):
|
||||
"""75%不透明 → 64 → 40."""
|
||||
# 0.25 → alpha = 255 - 63 = 192 → "C0"
|
||||
assert _opacity_to_ass_alpha(0.25) == "C0"
|
||||
|
||||
def test_three_quarters(self):
|
||||
# 0.75 → alpha = 255 - 191 = 64 → "40"
|
||||
assert _opacity_to_ass_alpha(0.75) == "40"
|
||||
|
||||
|
||||
# ── 文本处理测试 ──────────────────────────────────────────────
|
||||
def test_zero_padded(self):
|
||||
# 结果始终是2位十六进制
|
||||
result = _opacity_to_ass_alpha(1.0)
|
||||
assert len(result) == 2
|
||||
assert result == result.upper()
|
||||
|
||||
|
||||
class TestEscapeAssText:
|
||||
"""_escape_ass_text 转义测试."""
|
||||
"""ASS 文本转义."""
|
||||
|
||||
def test_normal_text_unchanged(self):
|
||||
"""普通文本不变."""
|
||||
def test_plain_text(self):
|
||||
assert _escape_ass_text("hello world") == "hello world"
|
||||
|
||||
def test_newline_converted(self):
|
||||
"""换行转成\\N."""
|
||||
def test_newline_unix(self):
|
||||
assert _escape_ass_text("line1\nline2") == "line1\\Nline2"
|
||||
|
||||
def test_crlf_converted(self):
|
||||
"""\\r\\n转成\\N."""
|
||||
def test_newline_windows(self):
|
||||
assert _escape_ass_text("line1\r\nline2") == "line1\\Nline2"
|
||||
|
||||
def test_carriage_return_converted(self):
|
||||
"""\\r转成\\N."""
|
||||
def test_newline_mac(self):
|
||||
assert _escape_ass_text("line1\rline2") == "line1\\Nline2"
|
||||
|
||||
def test_curly_braces_replaced(self):
|
||||
"""花括号替换成圆括号(ASS控制符)."""
|
||||
# ASS 中 {} 是样式标签,需要转义
|
||||
assert _escape_ass_text("{text}") == "(text)"
|
||||
|
||||
def test_multiple_braces(self):
|
||||
assert _escape_ass_text("{a}b{c}") == "(a)b(c)"
|
||||
|
||||
def test_mixed_special_chars(self):
|
||||
"""混合特殊字符."""
|
||||
text = "hello\n{world}\r\nend"
|
||||
text = "line1\n{bold}\nline3"
|
||||
result = _escape_ass_text(text)
|
||||
assert "\\N" in result
|
||||
assert "(bold)" in result
|
||||
assert "{" not in result
|
||||
assert "}" not in result
|
||||
assert "(world)" in result
|
||||
assert "\n" not in result
|
||||
assert "\r" not in result
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _escape_ass_text("") == ""
|
||||
|
||||
|
||||
class TestFormatAssTime:
|
||||
"""_format_ass_time 时间格式化测试."""
|
||||
"""秒 → ASS 时间格式."""
|
||||
|
||||
def test_zero(self):
|
||||
"""0秒."""
|
||||
assert _format_ass_time(0) == "0:00:00.00"
|
||||
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_and_seconds(self):
|
||||
"""分+秒."""
|
||||
assert _format_ass_time(125.5) == "0:02:05.50"
|
||||
def test_minutes(self):
|
||||
assert _format_ass_time(65.25) == "0:01:05.25"
|
||||
|
||||
def test_hours_minutes_seconds(self):
|
||||
"""时+分+秒."""
|
||||
assert _format_ass_time(3725.25) == "1:02:05.25"
|
||||
def test_hours(self):
|
||||
assert _format_ass_time(3661.5) == "1:01:01.50"
|
||||
|
||||
def test_exactly_one_hour(self):
|
||||
"""刚好1小时."""
|
||||
def test_exact_minute(self):
|
||||
assert _format_ass_time(60.0) == "0:01:00.00"
|
||||
|
||||
def test_exact_hour(self):
|
||||
assert _format_ass_time(3600.0) == "1:00:00.00"
|
||||
|
||||
def test_single_digit_minute(self):
|
||||
"""分钟补零."""
|
||||
result = _format_ass_time(65.0)
|
||||
def test_sub_second_precision(self):
|
||||
# 两位小数(厘秒精度)
|
||||
result = _format_ass_time(1.234)
|
||||
# 1.234 秒 = 0:00:01.23(ASS 格式是两位小数/厘秒)
|
||||
assert result.startswith("0:00:01.")
|
||||
# 检查秒部分是两位小数格式
|
||||
parts = result.split(":")
|
||||
assert parts[1] == "01"
|
||||
assert len(parts) == 3
|
||||
sec_part = parts[2]
|
||||
assert "." in sec_part
|
||||
decimals = sec_part.split(".")[1]
|
||||
assert len(decimals) == 2
|
||||
|
||||
def test_always_two_decimal_places(self):
|
||||
"""总是两位小数."""
|
||||
result = _format_ass_time(3.0)
|
||||
assert result.endswith(".00")
|
||||
def test_negative_returns_zero_hours(self):
|
||||
# 负数也应该能处理(虽然业务上不应该出现)
|
||||
result = _format_ass_time(-1.0)
|
||||
# 至少不崩溃
|
||||
assert isinstance(result, str)
|
||||
assert ":" in result
|
||||
|
||||
|
||||
class TestWrapText:
|
||||
"""_wrap_text 换行测试."""
|
||||
"""按字数换行."""
|
||||
|
||||
def test_short_text_no_wrap(self):
|
||||
"""短文本不换行."""
|
||||
result = _wrap_text("hello", 10)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "hello"
|
||||
result = _wrap_text("短文本", 10)
|
||||
assert result == ["短文本"]
|
||||
|
||||
def test_exact_length_no_wrap(self):
|
||||
"""刚好长度不换行."""
|
||||
text = "abcdefghij" # 10 chars
|
||||
text = "一二三四五六七八九十"
|
||||
result = _wrap_text(text, 10)
|
||||
assert len(result) == 1
|
||||
assert result[0] == text
|
||||
|
||||
def test_simple_wrap(self):
|
||||
"""简单换行."""
|
||||
text = "abcdefghijklmnopqrst" # 20 chars
|
||||
def test_long_text_wraps(self):
|
||||
text = "一二三四五六七八九十一二三四五六七八九十"
|
||||
result = _wrap_text(text, 10)
|
||||
assert len(result) == 2
|
||||
assert len(result[0]) == 10
|
||||
assert len(result[1]) == 10
|
||||
|
||||
def test_uneven_wrap(self):
|
||||
"""不均等换行."""
|
||||
text = "abcdefghijklm" # 13 chars
|
||||
result = _wrap_text(text, 5)
|
||||
def test_break_on_punctuation(self):
|
||||
# 优先在标点处断开(标点在max_chars范围内靠前位置)
|
||||
# 共12字,max=7,句号在第6位索引,range(7,3,-1)能扫到索引5的句号
|
||||
text = "一二三四五。六七八九十一"
|
||||
result = _wrap_text(text, 7)
|
||||
assert result[0] == "一二三四五。"
|
||||
assert result[1] == "六七八九十一"
|
||||
|
||||
def test_break_on_comma(self):
|
||||
text = "一二三四五,六七八九十一"
|
||||
result = _wrap_text(text, 7)
|
||||
assert result[0] == "一二三四五,"
|
||||
assert result[1] == "六七八九十一"
|
||||
|
||||
def test_multiple_lines(self):
|
||||
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
|
||||
result = _wrap_text(text, 10)
|
||||
assert len(result) == 3
|
||||
assert result[0] == "abcde"
|
||||
assert result[1] == "fghij"
|
||||
assert result[2] == "klm"
|
||||
assert len(result[0]) == 10
|
||||
assert len(result[1]) == 10
|
||||
assert len(result[2]) == 5
|
||||
|
||||
def test_chinese_text_wrap(self):
|
||||
"""中文文本换行(按字符数)."""
|
||||
text = "一二三四五六七八九十"
|
||||
result = _wrap_text(text, 5)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "一二三四五"
|
||||
assert result[1] == "六七八九十"
|
||||
def test_empty_string(self):
|
||||
result = _wrap_text("", 10)
|
||||
assert result == [""]
|
||||
|
||||
def test_max_chars_one(self):
|
||||
# max_chars=1 时每个字符一行
|
||||
text = "abc"
|
||||
result = _wrap_text(text, 1)
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_punctuation_at_boundary(self):
|
||||
# 标点刚好在 max_chars 位置
|
||||
text = "一二三四五六七八九。"
|
||||
result = _wrap_text(text, 10)
|
||||
assert len(result) == 1 # 刚好10个字符(含标点)
|
||||
|
||||
|
||||
# ── SubtitleStyle 测试 ────────────────────────────────────
|
||||
class TestSubtitleSegment:
|
||||
"""字幕片段数据类."""
|
||||
|
||||
def test_basic(self):
|
||||
seg = SubtitleSegment(start=0.0, end=5.0, text="hello")
|
||||
assert seg.start == 0.0
|
||||
assert seg.end == 5.0
|
||||
assert seg.text == "hello"
|
||||
|
||||
def test_duration(self):
|
||||
seg = SubtitleSegment(start=1.5, end=4.5, text="test")
|
||||
assert seg.end - seg.start == 3.0
|
||||
|
||||
|
||||
class TestSubtitleStyleDefaults:
|
||||
"""SubtitleStyle 默认值测试."""
|
||||
class TestSubtitleStyle:
|
||||
"""字幕样式配置."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
style = SubtitleStyle()
|
||||
assert style.font_size > 0
|
||||
assert style.bold is False
|
||||
assert style.italic is False
|
||||
assert style.stroke_enabled is True
|
||||
assert style.shadow_enabled is False
|
||||
assert style.background_enabled is False
|
||||
assert style.fade_in == 0.0
|
||||
assert style.fade_out == 0.0
|
||||
assert style.animation_type == "none"
|
||||
assert isinstance(style.font_color, str)
|
||||
assert isinstance(style.background_color, str)
|
||||
|
||||
def test_ass_color_generation(self):
|
||||
style = SubtitleStyle(font_color="#FFFFFF")
|
||||
# 应该能生成 ASS 颜色格式
|
||||
color = style.ass_font_color
|
||||
assert isinstance(color, str)
|
||||
assert color.startswith("&H")
|
||||
|
||||
class TestSubtitleStyleFromDict:
|
||||
"""SubtitleStyle.from_dict 测试."""
|
||||
def test_ass_background_color(self):
|
||||
style = SubtitleStyle(background_color="#000000", background_opacity=0.5)
|
||||
color = style.ass_background_color
|
||||
assert isinstance(color, str)
|
||||
assert color.startswith("&H")
|
||||
|
||||
def test_none_returns_default(self):
|
||||
"""None返回默认样式."""
|
||||
style = SubtitleStyle.from_dict(None)
|
||||
assert isinstance(style, SubtitleStyle)
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
"""空dict返回默认."""
|
||||
style = SubtitleStyle.from_dict({})
|
||||
assert isinstance(style, SubtitleStyle)
|
||||
|
||||
def test_custom_font_size(self):
|
||||
"""自定义字号."""
|
||||
style = SubtitleStyle.from_dict({"size": 48})
|
||||
assert style.font_size == 48
|
||||
|
||||
def test_custom_color(self):
|
||||
"""自定义颜色."""
|
||||
style = SubtitleStyle.from_dict({"color": "#FF0000"})
|
||||
assert style.font_color == "#FF0000"
|
||||
|
||||
def test_bold_enabled(self):
|
||||
"""启用粗体."""
|
||||
style = SubtitleStyle.from_dict({"bold": True})
|
||||
assert style.bold is True
|
||||
|
||||
def test_stroke_disabled(self):
|
||||
"""禁用描边."""
|
||||
style = SubtitleStyle.from_dict({"stroke_enabled": False})
|
||||
assert style.stroke_enabled is False
|
||||
|
||||
def test_background_enabled(self):
|
||||
"""启用背景框."""
|
||||
style = SubtitleStyle.from_dict({"background_enabled": True})
|
||||
assert style.background_enabled is True
|
||||
|
||||
def test_background_opacity_clamped(self):
|
||||
"""背景透明度钳制."""
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"background_enabled": True,
|
||||
"background_opacity": 2.0,
|
||||
}
|
||||
)
|
||||
assert style.background_opacity == 1.0
|
||||
|
||||
def test_invalid_position_falls_back(self):
|
||||
"""无效位置回退到默认."""
|
||||
style = SubtitleStyle.from_dict({"position": "invalid_pos"})
|
||||
# 回退到默认位置
|
||||
assert style.position is not None
|
||||
|
||||
def test_fade_in_non_negative(self):
|
||||
"""淡入时长不能为负."""
|
||||
style = SubtitleStyle.from_dict({"fade_in": -1.0})
|
||||
assert style.fade_in == 0.0
|
||||
|
||||
def test_custom_animation(self):
|
||||
"""自定义动画."""
|
||||
style = SubtitleStyle.from_dict({"animation_type": "fade"})
|
||||
assert style.animation_type == "fade"
|
||||
|
||||
|
||||
class TestSubtitleStyleProperties:
|
||||
"""SubtitleStyle 属性测试."""
|
||||
|
||||
def test_ass_font_color_format(self):
|
||||
"""ass_font_color格式正确."""
|
||||
style = SubtitleStyle(font_color="#FF0000")
|
||||
result = style.ass_font_color
|
||||
assert result.startswith("&H")
|
||||
assert len(result) == 10 # &H + AABBGGRR = 10 chars
|
||||
|
||||
def test_ass_background_color_format(self):
|
||||
"""背景颜色格式正确."""
|
||||
style = SubtitleStyle(
|
||||
background_enabled=True,
|
||||
background_color="#000000",
|
||||
background_opacity=0.5,
|
||||
)
|
||||
result = style.ass_background_color
|
||||
assert result.startswith("&H")
|
||||
|
||||
def test_alignment_is_int(self):
|
||||
"""alignment是整数."""
|
||||
style = SubtitleStyle()
|
||||
assert isinstance(style.alignment, int)
|
||||
def test_opacity_affects_alpha(self):
|
||||
style1 = SubtitleStyle(background_opacity=1.0)
|
||||
style2 = SubtitleStyle(background_opacity=0.0)
|
||||
# 不透明度不同,alpha 应该不同
|
||||
assert style1.ass_background_color != style2.ass_background_color
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
TTS 配音引擎数据类与纯逻辑测试.
|
||||
|
||||
覆盖 VoiceoverSegment / VoiceoverResult / TtsEngine 入口判断等纯逻辑.
|
||||
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
|
||||
|
||||
|
||||
class TestVoiceoverSegment:
|
||||
"""配音片段数据类."""
|
||||
|
||||
def test_default_values(self):
|
||||
seg = VoiceoverSegment(text="hello")
|
||||
assert seg.text == "hello"
|
||||
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="hello world",
|
||||
start_time=1.5,
|
||||
end_time=3.0,
|
||||
audio_path=Path("/tmp/test.wav"),
|
||||
duration=1.5,
|
||||
)
|
||||
assert seg.text == "hello world"
|
||||
assert seg.start_time == 1.5
|
||||
assert seg.end_time == 3.0
|
||||
assert seg.audio_path == Path("/tmp/test.wav")
|
||||
assert seg.duration == 1.5
|
||||
|
||||
def test_duration_calculation(self):
|
||||
seg = VoiceoverSegment(text="test", start_time=0.0, end_time=5.5)
|
||||
assert seg.end_time - seg.start_time == 5.5
|
||||
|
||||
|
||||
class TestVoiceoverResult:
|
||||
"""配音结果数据类."""
|
||||
|
||||
def test_default_failure(self):
|
||||
result = VoiceoverResult()
|
||||
assert result.success is False
|
||||
assert result.segments == []
|
||||
assert result.total_duration == 0.0
|
||||
assert result.error_message == ""
|
||||
|
||||
def test_success_result(self):
|
||||
segs = [
|
||||
VoiceoverSegment(text="hello", duration=1.0),
|
||||
VoiceoverSegment(text="world", duration=2.0),
|
||||
]
|
||||
result = VoiceoverResult(
|
||||
success=True,
|
||||
segments=segs,
|
||||
total_duration=3.0,
|
||||
)
|
||||
assert result.success is True
|
||||
assert len(result.segments) == 2
|
||||
assert result.total_duration == 3.0
|
||||
assert result.error_message == ""
|
||||
|
||||
def test_failure_with_message(self):
|
||||
result = VoiceoverResult(success=False, error_message="TTS服务不可用")
|
||||
assert result.success is False
|
||||
assert result.error_message == "TTS服务不可用"
|
||||
|
||||
def test_segments_isolated_list(self):
|
||||
"""确保每个实例有独立的segments列表."""
|
||||
r1 = VoiceoverResult()
|
||||
r2 = VoiceoverResult()
|
||||
r1.segments.append(VoiceoverSegment(text="test"))
|
||||
assert len(r2.segments) == 0
|
||||
|
||||
|
||||
class TestTtsEngineInit:
|
||||
"""TTS 引擎初始化."""
|
||||
|
||||
def test_init_creates_work_dir(self, tmp_path):
|
||||
mock_tts = MagicMock()
|
||||
work_dir = tmp_path / "tts_work"
|
||||
engine = TtsEngine(mock_tts, work_dir)
|
||||
assert work_dir.exists()
|
||||
assert work_dir.is_dir()
|
||||
|
||||
def test_init_with_existing_dir(self, tmp_path):
|
||||
mock_tts = MagicMock()
|
||||
work_dir = tmp_path / "existing"
|
||||
work_dir.mkdir()
|
||||
engine = TtsEngine(mock_tts, work_dir)
|
||||
assert work_dir.exists()
|
||||
|
||||
|
||||
class TestTtsEngineEntryConditions:
|
||||
"""TTS 引擎入口判断逻辑(不调用真实 TTS)."""
|
||||
|
||||
def test_disabled_returns_failure(self, tmp_path):
|
||||
mock_tts = MagicMock()
|
||||
engine = TtsEngine(mock_tts, tmp_path)
|
||||
config = TtsConfig(enabled=False, text="hello")
|
||||
result = engine.generate_full_voiceover(config)
|
||||
assert result.success is False
|
||||
assert "未启用" in result.error_message or "空" in result.error_message
|
||||
mock_tts.synthesize.assert_not_called()
|
||||
|
||||
def test_empty_text_returns_failure(self, tmp_path):
|
||||
mock_tts = MagicMock()
|
||||
engine = TtsEngine(mock_tts, tmp_path)
|
||||
config = TtsConfig(enabled=True, text="")
|
||||
result = engine.generate_full_voiceover(config)
|
||||
assert result.success is False
|
||||
mock_tts.synthesize.assert_not_called()
|
||||
|
||||
def test_whitespace_text_returns_failure(self, tmp_path):
|
||||
mock_tts = MagicMock()
|
||||
engine = TtsEngine(mock_tts, tmp_path)
|
||||
config = TtsConfig(enabled=True, text=" ")
|
||||
result = engine.generate_full_voiceover(config)
|
||||
assert result.success is False
|
||||
mock_tts.synthesize.assert_not_called()
|
||||
Reference in New Issue
Block a user