Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot b233f8e539 merge: 解决第56波与develop的add/add冲突(sticker+subtitle)
AI Code Review / AI Code Review (pull_request) Failing after 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 36s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 38s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m18s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m49s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 41s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m57s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m15s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m54s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m55s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 23s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 51m44s
2026-07-25 12:29:34 +08:00
CI Bot 78257fae96 test(P3-1): 第56波 worker层更多引擎纯逻辑单测(+108)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 47s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m13s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m41s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 15s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 3m0s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 49m36s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1h14m0s
- test_subtitle_render_engine.py: 53个(颜色转换/时间格式化/文字换行/ASS转义/样式)
- test_sticker_engine.py: 27个(配置类/位置解析/常量/便捷函数)
- test_tts_engine.py: 12个(数据类/入口判断/初始化)
- test_render_audio_utils.py: 16个(clip_effective_duration/RenderContext)

覆盖worker层4个模块的纯逻辑部分
2026-07-25 09:37:08 +08:00
4 changed files with 661 additions and 285 deletions
+107
View File
@@ -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)
+252 -90
View File
@@ -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)
+171 -195
View File
@@ -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) = 127255-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.23ASS 格式是两位小数/厘秒)
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
+131
View File
@@ -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()