test(unit): 第66波 - multi_track + bgm_mixer + sticker 配置 (+52) #862

Merged
xiaoxia merged 1 commits from test/wave66-multi-track-sticker-bgm-config into develop 2026-07-25 08:13:08 +08:00
3 changed files with 499 additions and 706 deletions
+88 -344
View File
@@ -1,359 +1,103 @@
"""BGM 混音单元测试.
"""BGM混音单元测试 - 配置解析等纯逻辑."""
测试:
- BGMConfig 配置解析与边界值
- 预设 BGM 库查询
- 纯 BGM 音频生成(端到端 ffmpeg)
- BGM + 主音频混音(端到端 ffmpeg)
- 淡入淡出效果
- 音量边界(0 和 1
- sidechain 人声闪避
"""
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from __future__ import annotations
import pytest
from video_processing.bgm_mixer import BGMConfig, build_bgm_only, mix_bgm_with_main, prepare_bgm_track
from video_processing.render_audio import RenderContext
# ── Fixtures ──────────────────────────────────────────────────────────────────
from video_processing.bgm_mixer import BGMConfig
@pytest.fixture
def work_dir(tmp_path):
return tmp_path
@pytest.fixture
def ctx(work_dir):
return RenderContext(work_dir=work_dir, plan_id="test_plan")
@pytest.fixture
def main_audio_path(work_dir):
"""生成 10 秒测试主音频(正弦波模拟人声)。"""
import subprocess
path = work_dir / "main.aac"
# 生成 10 秒 440Hz 正弦波模拟主音频
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=10:sample_rate=44100",
"-c:a",
"aac",
"-b:a",
"128k",
str(path),
],
capture_output=True,
check=True,
timeout=30,
)
return str(path)
@pytest.fixture
def bgm_audio_path(work_dir):
"""生成 5 秒测试 BGM(更低频率模拟背景音乐)。"""
import subprocess
path = work_dir / "bgm.aac"
# 生成 5 秒 220Hz 正弦波模拟 BGM
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=220:duration=5:sample_rate=44100",
"-c:a",
"aac",
"-b:a",
"128k",
str(path),
],
capture_output=True,
check=True,
timeout=30,
)
return str(path)
# ── BGMConfig 测试 ───────────────────────────────────────────────────────────
class TestBGMConfig:
"""BGMConfig 配置解析测试。"""
class TestBGMConfigDefaults:
"""BGMConfig 默认值测试."""
def test_default_values(self):
cfg = BGMConfig(bgm_path="/tmp/bgm.mp3")
assert cfg.volume == 0.3
assert cfg.fade_in == 0.0
assert cfg.fade_out == 0.0
assert cfg.loop_enabled is True
assert cfg.sidechain_enabled is False
assert cfg.sidechain_ratio == 0.3
"""默认值正确."""
config = BGMConfig(bgm_path="/bgm.mp3")
assert config.bgm_path == "/bgm.mp3"
assert config.volume == 0.3
assert config.fade_in == 0.0
assert config.fade_out == 0.0
assert config.loop_enabled is True
assert config.sidechain_enabled is False
assert config.sidechain_ratio == 0.3
assert config.sidechain_attack == 0.02
assert config.sidechain_release == 0.5
assert config.sidechain_threshold == -25.0
def test_from_config_dict(self):
config_dict = {
"enabled": True,
"volume": 0.5,
class TestBGMConfigFromConfigDict:
"""BGMConfig.from_config_dict 解析测试."""
def test_empty_dict_defaults(self):
"""空字典用默认值."""
config = BGMConfig.from_config_dict("/bgm.mp3", {})
assert config.bgm_path == "/bgm.mp3"
assert config.volume == 0.3
assert config.loop_enabled is True
assert config.sidechain_enabled is False
def test_custom_volume(self):
"""自定义音量."""
config = BGMConfig.from_config_dict("/a.mp3", {"volume": 0.5})
assert config.volume == 0.5
def test_fade_in_out(self):
"""淡入淡出."""
config = BGMConfig.from_config_dict("/a.mp3", {
"fade_in": 2.0,
"fade_out": 3.0,
"loop_enabled": False,
})
assert config.fade_in == 2.0
assert config.fade_out == 3.0
def test_loop_disabled(self):
"""禁用循环."""
config = BGMConfig.from_config_dict("/a.mp3", {"loop_enabled": False})
assert config.loop_enabled is False
def test_sidechain_enabled(self):
"""启用人声闪避."""
config = BGMConfig.from_config_dict("/a.mp3", {"sidechain_enabled": True})
assert config.sidechain_enabled is True
def test_sidechain_custom_params(self):
"""闪避自定义参数."""
config = BGMConfig.from_config_dict("/a.mp3", {
"sidechain_enabled": True,
"sidechain_ratio": 0.5,
}
cfg = BGMConfig.from_config_dict("/bgm.mp3", config_dict)
assert cfg.bgm_path == "/bgm.mp3"
assert cfg.volume == 0.5
assert cfg.fade_in == 2.0
assert cfg.fade_out == 3.0
assert cfg.loop_enabled is False
assert cfg.sidechain_enabled is True
assert cfg.sidechain_ratio == 0.5
"sidechain_attack": 0.05,
"sidechain_release": 0.8,
"sidechain_threshold": -30.0,
})
assert config.sidechain_ratio == 0.5
assert config.sidechain_attack == 0.05
assert config.sidechain_release == 0.8
assert config.sidechain_threshold == -30.0
def test_volume_clamped_by_config_schema(self):
"""音量边界由 Pydantic Schema 在入口层保证,内部直接使用。"""
from packages.domain.config_schemas import BGMConfig as BGMConfigSchema
def test_bgm_path_preserved(self):
"""bgm_path保持不变."""
config = BGMConfig.from_config_dict("/custom/path.mp3", {"volume": 0.5})
assert config.bgm_path == "/custom/path.mp3"
# 边界值测试
cfg = BGMConfigSchema(enabled=True, volume=0.0)
assert cfg.volume == 0.0
cfg = BGMConfigSchema(enabled=True, volume=1.0)
assert cfg.volume == 1.0
def test_fade_boundaries(self):
from packages.domain.config_schemas import BGMConfig as BGMConfigSchema
# 0 是合法值
cfg = BGMConfigSchema(fade_in=0, fade_out=0)
assert cfg.fade_in == 0.0
assert cfg.fade_out == 0.0
# ── 预设 BGM 库测试 ─────────────────────────────────────────────────────────
class TestPresetBGM:
"""预设 BGM 库查询测试。"""
def test_total_count(self):
from packages.domain.preset_bgm import PRESET_BGM_LIBRARY
assert len(PRESET_BGM_LIBRARY) >= 10
def test_get_preset_by_id(self):
from packages.domain.preset_bgm import get_preset_bgm
bgm = get_preset_bgm("bgm_upbeat_001")
assert bgm is not None
assert bgm.name == "阳光清晨"
assert bgm.style == "upbeat"
def test_get_preset_not_found(self):
from packages.domain.preset_bgm import get_preset_bgm
assert get_preset_bgm("nonexistent") is None
def test_list_by_style(self):
from packages.domain.preset_bgm import list_preset_bgm_by_style
upbeat = list_preset_bgm_by_style("upbeat")
assert len(upbeat) >= 3
assert all(b.style == "upbeat" for b in upbeat)
def test_search_by_keyword(self):
from packages.domain.preset_bgm import search_preset_bgm
results = search_preset_bgm("钢琴")
assert len(results) >= 2
assert any("钢琴" in b.tags for b in results)
def test_all_presets_have_basic_fields(self):
from packages.domain.preset_bgm import PRESET_BGM_LIBRARY
for bgm in PRESET_BGM_LIBRARY:
assert bgm.id, f"{bgm.name} 缺少 id"
assert bgm.name, "缺少 name"
assert bgm.style, f"{bgm.name} 缺少 style"
assert bgm.duration > 0, f"{bgm.name} 时长无效"
# ── BGM 处理端到端测试 ──────────────────────────────────────────────────────
class TestPrepareBGMTrack:
"""prepare_bgm_track 端到端测试。"""
def test_bgm_without_loop_short_duration(self, ctx, bgm_audio_path):
"""BGM 比目标时长短且不循环 → 截断到目标时长(但前面没有足够内容)。"""
bgm = BGMConfig(bgm_path=bgm_audio_path, volume=0.5, loop_enabled=False)
result = prepare_bgm_track(ctx, bgm, target_duration=3.0)
assert result.exists()
assert result.stat().st_size > 0
def test_bgm_with_loop_longer_duration(self, ctx, bgm_audio_path):
"""BGM 比目标时长短,循环铺满。"""
bgm = BGMConfig(bgm_path=bgm_audio_path, volume=0.3, loop_enabled=True)
# BGM 5 秒,目标 12 秒,需要循环 3 次
result = prepare_bgm_track(ctx, bgm, target_duration=12.0)
assert result.exists()
assert result.stat().st_size > 0
def test_bgm_fade_in_and_fade_out(self, ctx, bgm_audio_path):
"""BGM 淡入淡出效果。"""
bgm = BGMConfig(
bgm_path=bgm_audio_path,
volume=0.5,
fade_in=1.0,
fade_out=1.0,
loop_enabled=False,
)
result = prepare_bgm_track(ctx, bgm, target_duration=4.0)
assert result.exists()
assert result.stat().st_size > 0
def test_volume_zero(self, ctx, bgm_audio_path):
"""音量为 0 时仍能正常处理。"""
bgm = BGMConfig(bgm_path=bgm_audio_path, volume=0.0, loop_enabled=False)
result = prepare_bgm_track(ctx, bgm, target_duration=3.0)
assert result.exists()
assert result.stat().st_size > 0
def test_volume_one(self, ctx, bgm_audio_path):
"""音量为 1(最大)时正常处理。"""
bgm = BGMConfig(bgm_path=bgm_audio_path, volume=1.0, loop_enabled=False)
result = prepare_bgm_track(ctx, bgm, target_duration=3.0)
assert result.exists()
assert result.stat().st_size > 0
class TestMixBGMMain:
"""BGM + 主音频混音端到端测试。"""
def test_simple_mix(self, ctx, main_audio_path, bgm_audio_path):
"""普通 amix 混音(无 sidechain)。"""
bgm = BGMConfig(
bgm_path=bgm_audio_path,
volume=0.3,
loop_enabled=True,
sidechain_enabled=False,
)
result = mix_bgm_with_main(ctx, Path(main_audio_path), bgm, target_duration=8.0)
assert result.exists()
assert result.stat().st_size > 0
def test_sidechain_mix(self, ctx, main_audio_path, bgm_audio_path):
"""sidechain 人声闪避混音。"""
bgm = BGMConfig(
bgm_path=bgm_audio_path,
volume=0.5,
loop_enabled=True,
sidechain_enabled=True,
sidechain_ratio=0.3,
sidechain_threshold=-25.0,
sidechain_attack=0.02,
sidechain_release=0.5,
)
result = mix_bgm_with_main(ctx, Path(main_audio_path), bgm, target_duration=8.0)
assert result.exists()
assert result.stat().st_size > 0
def test_sidechain_max_ratio(self, ctx, main_audio_path, bgm_audio_path):
"""sidechain 最大闪避比例。"""
bgm = BGMConfig(
bgm_path=bgm_audio_path,
volume=0.5,
loop_enabled=True,
sidechain_enabled=True,
sidechain_ratio=0.9, # 降低 90%
)
result = mix_bgm_with_main(ctx, Path(main_audio_path), bgm, target_duration=5.0)
assert result.exists()
assert result.stat().st_size > 0
class TestBuildBGMOnly:
"""纯 BGM 模式测试。"""
def test_build_bgm_only(self, ctx, bgm_audio_path):
"""只有 BGM、没有主音频时生成纯 BGM 音频。"""
bgm = BGMConfig(
bgm_path=bgm_audio_path,
volume=0.3,
fade_in=1.0,
fade_out=1.0,
loop_enabled=True,
)
result = build_bgm_only(ctx, bgm, target_duration=15.0)
assert result.exists()
assert result.stat().st_size > 0
# ── Config Schema 集成测试 ───────────────────────────────────────────────────
class TestConfigSchemaIntegration:
"""config schema 与渲染配置的集成测试。"""
def test_full_bgm_config(self):
"""完整 BGM 配置能正确解析。"""
from packages.domain.config_schemas import EditPlanConfigSchema, normalize_plan_config
config = normalize_plan_config(
{
"bgm": {
"enabled": True,
"source": "library",
"asset_id": "bgm-asset-001",
"volume": 0.4,
"fade_in": 2.5,
"fade_out": 3.0,
"loop_enabled": True,
"sidechain_enabled": True,
"sidechain_ratio": 0.4,
}
}
)
bgm = config["bgm"]
assert bgm["enabled"] is True
assert bgm["volume"] == 0.4
assert bgm["fade_in"] == 2.5
assert bgm["fade_out"] == 3.0
assert bgm["loop_enabled"] is True
assert bgm["sidechain_enabled"] is True
assert bgm["sidechain_ratio"] == 0.4
# 默认值保留
assert bgm["sidechain_attack"] == 0.02
assert bgm["sidechain_release"] == 0.5
assert bgm["sidechain_threshold"] == -25.0
def test_bgm_disabled_by_default(self):
"""默认 BGM 是关闭的。"""
from packages.domain.config_schemas import normalize_plan_config
config = normalize_plan_config({})
assert config["bgm"]["enabled"] is False
def test_all_params_custom(self):
"""所有参数自定义."""
config = BGMConfig.from_config_dict("/full.mp3", {
"volume": 0.7,
"fade_in": 1.5,
"fade_out": 2.0,
"loop_enabled": False,
"sidechain_enabled": True,
"sidechain_ratio": 0.4,
"sidechain_attack": 0.03,
"sidechain_release": 0.6,
"sidechain_threshold": -20.0,
})
assert config.volume == 0.7
assert config.fade_in == 1.5
assert config.fade_out == 2.0
assert config.loop_enabled is False
assert config.sidechain_enabled is True
assert config.sidechain_ratio == 0.4
assert config.sidechain_attack == 0.03
assert config.sidechain_release == 0.6
assert config.sidechain_threshold == -20.0
+292 -362
View File
@@ -1,13 +1,9 @@
"""
多轨道混音引擎配置与纯逻辑测试.
覆盖 AudioTrack.from_dict / MultiTrackMixConfig.from_config_dict / has_effect 等纯逻辑.
引擎核心混音方法依赖 FFmpeg,由集成测试覆盖.
"""
"""多轨道混音单元测试 - 配置解析等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.multi_track_mixer import (
DEFAULT_VOLUMES,
MAX_AUDIO_TRACKS,
@@ -21,383 +17,317 @@ from video_processing.multi_track_mixer import (
)
class TestTrackConstants:
"""轨道类型常量与默认值."""
class TestConstants:
"""常量测试."""
def test_track_types_exist(self):
def test_track_types(self):
"""5种轨道类型."""
assert TRACK_TYPE_MAIN == "main"
assert TRACK_TYPE_BGM == "bgm"
assert TRACK_TYPE_VOICEOVER == "voiceover"
assert TRACK_TYPE_SFX == "sfx"
assert TRACK_TYPE_AMBIENT == "ambient"
def test_max_tracks(self):
assert MAX_AUDIO_TRACKS == 8
def test_default_volumes(self):
"""5种默认音量."""
assert len(DEFAULT_VOLUMES) == 5
assert DEFAULT_VOLUMES[TRACK_TYPE_MAIN] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_BGM] == 0.3
assert DEFAULT_VOLUMES[TRACK_TYPE_VOICEOVER] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_SFX] == 0.7
assert DEFAULT_VOLUMES[TRACK_TYPE_AMBIENT] == 0.2
class TestAudioTrackFromDict:
"""AudioTrack.from_dict 构造逻辑."""
def test_basic(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
}
)
assert track.track_id == "t1"
assert track.track_type == "bgm"
assert track.audio_path == "/tmp/bgm.mp3"
assert track.volume == 0.3 # bgm 默认音量
def test_custom_volume(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "main",
"audio_path": "/tmp/main.wav",
"volume": 0.8,
}
)
assert track.volume == 0.8
def test_volume_clamped_to_zero(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "sfx",
"audio_path": "/tmp/sfx.wav",
"volume": -1.0,
}
)
assert track.volume == 0.0
def test_volume_clamped_to_max(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "sfx",
"audio_path": "/tmp/sfx.wav",
"volume": 3.0,
}
)
assert track.volume == 2.0
def test_invalid_volume_falls_back_to_default(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
"volume": "not_a_number",
}
)
assert track.volume == 0.3 # bgm 默认
def test_none_volume_falls_back(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "voiceover",
"audio_path": "/tmp/vo.wav",
"volume": None,
}
)
assert track.volume == 1.0 # voiceover 默认
def test_unknown_track_type_default_volume(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "unknown_type",
"audio_path": "/tmp/a.wav",
}
)
assert track.volume == 1.0 # 未知类型默认 1.0
def test_fade_in_fade_out(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
"fade_in": 1.5,
"fade_out": 2.0,
}
)
assert track.fade_in == 1.5
assert track.fade_out == 2.0
def test_negative_fade_clamped(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
"fade_in": -0.5,
"fade_out": -1.0,
}
)
assert track.fade_in == 0.0
assert track.fade_out == 0.0
def test_invalid_fade_falls_back(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
"fade_in": "abc",
"fade_out": None,
}
)
assert track.fade_in == 0.0
assert track.fade_out == 0.0
def test_start_time_and_duration(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "sfx",
"audio_path": "/tmp/sfx.wav",
"start_time": 5.0,
"duration": 3.0,
}
)
assert track.start_time == 5.0
assert track.duration == 3.0
def test_negative_start_time_clamped(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
"start_time": -10.0,
"duration": -2.0,
}
)
assert track.start_time == 0.0
assert track.duration == 0.0
def test_invalid_time_values_fall_back(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
"start_time": "invalid",
"duration": "bad",
}
)
assert track.start_time == 0.0
assert track.duration == 0.0
def test_enabled_default_true(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
}
)
assert track.enabled is True
def test_enabled_can_be_false(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
"enabled": False,
}
)
assert track.enabled is False
def test_max_tracks(self):
"""最大轨道数."""
assert MAX_AUDIO_TRACKS == 8
class TestMultiTrackMixConfigFromDict:
"""MultiTrackMixConfig.from_config_dict 构造逻辑."""
def test_none_returns_default(self):
cfg = MultiTrackMixConfig.from_config_dict(None)
assert cfg.tracks == []
assert cfg.master_volume == 1.0
assert cfg.normalize is True
def test_empty_dict_returns_default(self):
cfg = MultiTrackMixConfig.from_config_dict({})
assert cfg.tracks == []
def test_non_dict_returns_default(self):
cfg = MultiTrackMixConfig.from_config_dict([])
assert cfg.tracks == []
def test_single_track(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{
"track_id": "bgm1",
"track_type": "bgm",
"audio_path": "/tmp/bgm.mp3",
"volume": 0.5,
},
],
}
)
assert len(cfg.tracks) == 1
assert cfg.tracks[0].track_id == "bgm1"
assert cfg.tracks[0].volume == 0.5
def test_multiple_tracks(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "m", "track_type": "main", "audio_path": "/tmp/m.wav"},
{"track_id": "b", "track_type": "bgm", "audio_path": "/tmp/b.mp3"},
{"track_id": "v", "track_type": "voiceover", "audio_path": "/tmp/v.wav"},
],
}
)
assert len(cfg.tracks) == 3
assert cfg.tracks[0].track_type == "main"
assert cfg.tracks[1].track_type == "bgm"
assert cfg.tracks[2].track_type == "voiceover"
def test_disabled_tracks_filtered(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "a", "track_type": "sfx", "audio_path": "/tmp/a.wav"},
{"track_id": "b", "track_type": "sfx", "audio_path": "/tmp/b.wav", "enabled": False},
{"track_id": "c", "track_type": "sfx", "audio_path": "/tmp/c.wav"},
],
}
)
assert len(cfg.tracks) == 2
assert all(t.track_id != "b" for t in cfg.tracks)
def test_empty_audio_path_filtered(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "valid", "track_type": "sfx", "audio_path": "/tmp/a.wav"},
{"track_id": "empty", "track_type": "sfx", "audio_path": ""},
],
}
)
assert len(cfg.tracks) == 1
assert cfg.tracks[0].track_id == "valid"
def test_invalid_tracks_skipped(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "ok", "track_type": "sfx", "audio_path": "/tmp/a.wav"},
"not_a_dict",
None,
{"no_audio_path": "xxx"},
],
}
)
assert len(cfg.tracks) == 1
def test_tracks_not_a_list(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": "not_a_list",
}
)
assert cfg.tracks == []
def test_master_volume(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [],
"master_volume": 0.8,
}
)
assert cfg.master_volume == 0.8
def test_master_volume_clamped(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [],
"master_volume": 3.0,
}
)
assert cfg.master_volume == 2.0
cfg2 = MultiTrackMixConfig.from_config_dict(
{
"tracks": [],
"master_volume": -1.0,
}
)
assert cfg2.master_volume == 0.0
def test_invalid_master_volume_falls_back(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [],
"master_volume": "abc",
}
)
assert cfg.master_volume == 1.0
def test_normalize_and_max_output(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [],
"normalize": False,
"max_output_volume": 2.0,
}
)
assert cfg.normalize is False
assert cfg.max_output_volume == 2.0
class TestAudioTrackDefaults:
"""AudioTrack 默认值测试."""
def test_default_values(self):
cfg = MultiTrackMixConfig.from_config_dict({"tracks": []})
assert cfg.master_volume == 1.0
assert cfg.normalize is True
assert cfg.max_output_volume == 1.5
"""默认值正确."""
track = AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3")
assert track.track_id == "t1"
assert track.track_type == "bgm"
assert track.audio_path == "/a.mp3"
assert track.volume == 1.0
assert track.fade_in == 0.0
assert track.fade_out == 0.0
assert track.start_time == 0.0
assert track.duration == 0.0
assert track.enabled is True
class TestMultiTrackMixConfigProperties:
"""has_effect 属性."""
class TestAudioTrackFromDict:
"""AudioTrack.from_dict 解析测试."""
def test_has_effect_with_tracks(self):
cfg = MultiTrackMixConfig(
tracks=[
AudioTrack(track_id="t1", track_type="bgm", audio_path="/tmp/a.mp3"),
]
)
assert cfg.has_effect is True
def test_basic_parsing(self):
"""基本解析."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/bgm.mp3",
})
assert track.track_id == "t1"
assert track.track_type == "bgm"
assert track.audio_path == "/bgm.mp3"
def test_no_effect_empty(self):
cfg = MultiTrackMixConfig(tracks=[])
assert cfg.has_effect is False
def test_default_volume_by_type_bgm(self):
"""bgm默认音量0.3."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
})
assert track.volume == 0.3
def test_no_effect_all_disabled(self):
cfg = MultiTrackMixConfig(
tracks=[
AudioTrack(track_id="t1", track_type="bgm", audio_path="/tmp/a.mp3", enabled=False),
]
)
assert cfg.has_effect is False
def test_default_volume_by_type_sfx(self):
"""sfx默认音量0.7."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "sfx",
"audio_path": "/a.mp3",
})
assert track.volume == 0.7
def test_no_effect_empty_paths(self):
cfg = MultiTrackMixConfig(
tracks=[
AudioTrack(track_id="t1", track_type="bgm", audio_path=""),
]
)
assert cfg.has_effect is False
def test_default_volume_unknown_type(self):
"""未知类型默认音量1.0."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "unknown_type",
"audio_path": "/a.mp3",
})
assert track.volume == 1.0
def test_custom_volume(self):
"""自定义音量."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": 0.5,
})
assert track.volume == 0.5
def test_volume_clamped_high(self):
"""音量上限钳制."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": 3.0,
})
assert track.volume == 2.0
def test_volume_clamped_low(self):
"""音量下限钳制."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"volume": -1.0,
})
assert track.volume == 0.0
def test_volume_invalid_falls_back(self):
"""无效音量回退到类型默认值."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": "not_a_number",
})
assert track.volume == 0.3
def test_fade_in(self):
"""淡入时长."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"fade_in": 2.5,
})
assert track.fade_in == 2.5
def test_fade_negative_clamped(self):
"""负淡入钳制到0."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"fade_in": -1.0,
"fade_out": -2.0,
})
assert track.fade_in == 0.0
assert track.fade_out == 0.0
def test_start_time(self):
"""开始时间."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"start_time": 5.5,
})
assert track.start_time == 5.5
def test_start_time_negative_clamped(self):
"""负开始时间钳制到0."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"start_time": -3.0,
})
assert track.start_time == 0.0
def test_disabled_track(self):
"""禁用轨道."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"enabled": False,
})
assert track.enabled is False
def test_invalid_fade_in_falls_back(self):
"""无效淡入值回退到0."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"fade_in": "fast",
})
assert track.fade_in == 0.0
class TestMultiTrackMixConfigDefaults:
"""MultiTrackMixConfig 默认值测试."""
def test_default_values(self):
"""默认值正确."""
config = MultiTrackMixConfig()
assert config.tracks == []
assert config.master_volume == 1.0
assert config.normalize is True
assert config.max_output_volume == 1.5
class TestMultiTrackMixConfigFromConfigDict:
"""MultiTrackMixConfig.from_config_dict 测试."""
def test_none_returns_default(self):
"""None返回默认配置."""
config = MultiTrackMixConfig.from_config_dict(None)
assert config.tracks == []
assert config.master_volume == 1.0
def test_empty_dict_returns_default(self):
"""空dict返回默认."""
config = MultiTrackMixConfig.from_config_dict({})
assert config.tracks == []
def test_single_track(self):
"""单轨道."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{
"track_id": "bgm1",
"track_type": "bgm",
"audio_path": "/bgm.mp3",
},
],
})
assert len(config.tracks) == 1
assert config.tracks[0].track_id == "bgm1"
def test_multiple_tracks(self):
"""多轨道."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{"track_id": "t1", "track_type": "bgm", "audio_path": "/a.mp3"},
{"track_id": "t2", "track_type": "sfx", "audio_path": "/b.mp3"},
],
})
assert len(config.tracks) == 2
def test_skips_disabled_tracks(self):
"""跳过禁用轨道."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3", "enabled": True},
{"track_id": "t2", "audio_path": "/b.mp3", "enabled": False},
],
})
assert len(config.tracks) == 1
assert config.tracks[0].track_id == "t1"
def test_skips_no_audio_path(self):
"""跳过无audio_path的轨道."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
{"track_id": "t2", "audio_path": ""},
{"track_id": "t3"},
],
})
assert len(config.tracks) == 1
def test_master_volume(self):
"""主音量."""
config = MultiTrackMixConfig.from_config_dict({
"master_volume": 0.8,
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
})
assert config.master_volume == 0.8
def test_master_volume_clamped(self):
"""主音量边界钳制."""
config = MultiTrackMixConfig.from_config_dict({
"master_volume": 5.0,
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
})
assert config.master_volume == 2.0
def test_normalize_disabled(self):
"""禁用归一化."""
config = MultiTrackMixConfig.from_config_dict({
"normalize": False,
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
})
assert config.normalize is False
def test_tracks_not_list_ignored(self):
"""tracks不是列表时忽略."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": "not_a_list",
})
assert config.tracks == []
def test_non_dict_track_skipped(self):
"""非dict轨道跳过."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
"not_a_dict",
],
})
assert len(config.tracks) == 1
class TestHasEffect:
"""has_effect 属性测试."""
def test_no_tracks_no_effect(self):
"""无轨道无效果."""
config = MultiTrackMixConfig()
assert config.has_effect is False
def test_with_tracks_has_effect(self):
"""有轨道有效果."""
config = MultiTrackMixConfig(tracks=[
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3"),
])
assert config.has_effect is True
def test_disabled_tracks_no_effect(self):
"""所有轨道都禁用无效果."""
config = MultiTrackMixConfig(tracks=[
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3", enabled=False),
])
assert config.has_effect is False
+119
View File
@@ -0,0 +1,119 @@
"""贴纸引擎单元测试 - 配置+解析等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.sticker_engine import (
ImageStickerConfig,
TextStickerConfig,
parse_stickers_from_config,
)
class TestImageStickerConfigDefaults:
"""ImageStickerConfig 默认值测试."""
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 == ""
class TestTextStickerConfigDefaults:
"""TextStickerConfig 默认值测试."""
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
class TestParseStickersFromConfig:
"""parse_stickers_from_config 测试."""
def test_none_returns_empty(self):
"""None返回空列表."""
result = parse_stickers_from_config(None)
assert result == []
def test_empty_dict_returns_empty(self):
"""空dict返回空."""
result = parse_stickers_from_config({})
assert result == []
def test_no_stickers_key_returns_empty(self):
"""无stickers键返回空."""
result = parse_stickers_from_config({"other": "value"})
assert result == []
def test_stickers_not_list_returns_empty(self):
"""stickers不是列表返回空."""
result = parse_stickers_from_config({"stickers": "not_a_list"})
assert result == []
def test_empty_stickers_list(self):
"""空贴纸列表."""
result = parse_stickers_from_config({"stickers": []})
assert result == []
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
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 # 引用相同,不做深拷贝