diff --git a/tests/unit/test_bgm_mixer.py b/tests/unit/test_bgm_mixer.py index 1891f9200..b9531c656 100755 --- a/tests/unit/test_bgm_mixer.py +++ b/tests/unit/test_bgm_mixer.py @@ -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 diff --git a/tests/unit/test_multi_track_mixer.py b/tests/unit/test_multi_track_mixer.py index 67c7b3a04..a6ac87e6c 100755 --- a/tests/unit/test_multi_track_mixer.py +++ b/tests/unit/test_multi_track_mixer.py @@ -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 diff --git a/tests/unit/test_sticker_engine.py b/tests/unit/test_sticker_engine.py new file mode 100755 index 000000000..a2023f33c --- /dev/null +++ b/tests/unit/test_sticker_engine.py @@ -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 # 引用相同,不做深拷贝