"""BGM 混音纯逻辑单元测试.""" from __future__ import annotations import pytest from video_processing.bgm_mixer_pure import ( BGMPureConfig, build_bgm_filter_chain, build_sidechain_mix_filter, build_simple_mix_filter, calculate_fade_out_start, calculate_loop_count, calculate_sidechain_ratio, estimate_bgm_processing_duration, normalize_bgm_config, should_loop_bgm, validate_bgm_config, ) # ───────────────────────────────────────────────────────────────────────────── # should_loop_bgm 测试 # ───────────────────────────────────────────────────────────────────────────── class TestShouldLoopBGM: """BGM 循环判断测试.""" def test_need_loop_when_much_shorter(self): """BGM 远短于目标时长,需要循环.""" assert should_loop_bgm(10, 100, True) is True def test_no_loop_when_long_enough(self): """BGM 够长,不需要循环.""" assert should_loop_bgm(100, 100, True) is False def test_no_loop_when_just_slightly_shorter(self): """BGM 只差一点点(>90%),不循环.""" assert should_loop_bgm(95, 100, True) is False def test_threshold_90_percent(self): """刚好 90% 阈值,不循环(<90% 才循环).""" assert should_loop_bgm(90, 100, True) is False def test_just_below_threshold(self): """略低于 90%,需要循环.""" assert should_loop_bgm(89, 100, True) is True def test_loop_disabled(self): """禁用循环,即使 BGM 很短也不循环.""" assert should_loop_bgm(10, 100, False) is False def test_zero_bgm_duration(self): """BGM 时长为 0,不循环.""" assert should_loop_bgm(0, 100, True) is False def test_negative_bgm_duration(self): """BGM 时长为负,不循环.""" assert should_loop_bgm(-5, 100, True) is False def test_zero_target_duration(self): """目标时长为 0,不循环.""" assert should_loop_bgm(10, 0, True) is False def test_negative_target_duration(self): """目标时长为负,不循环.""" assert should_loop_bgm(10, -10, True) is False # ───────────────────────────────────────────────────────────────────────────── # calculate_loop_count 测试 # ───────────────────────────────────────────────────────────────────────────── class TestCalculateLoopCount: """循环次数计算测试.""" def test_exact_multiple(self): """刚好整数倍.""" # 100/10 = 10, +2 = 12 assert calculate_loop_count(10, 100) == 12 def test_not_exact_multiple(self): """不是整数倍.""" # 100/30 = 3, +2 = 5 assert calculate_loop_count(30, 100) == 5 def test_bgm_longer_than_target(self): """BGM 比目标长,至少 1 次.""" assert calculate_loop_count(200, 100) == 1 def test_zero_bgm_duration(self): """BGM 时长为 0,返回 1.""" assert calculate_loop_count(0, 100) == 1 def test_negative_bgm_duration(self): """BGM 时长为负,返回 1.""" assert calculate_loop_count(-5, 100) == 1 def test_zero_target_duration(self): """目标时长为 0,返回 1.""" assert calculate_loop_count(10, 0) == 1 def test_negative_target_duration(self): """目标时长为负,返回 1.""" assert calculate_loop_count(10, -10) == 1 def test_very_short_bgm(self): """非常短的 BGM,循环次数多.""" # 100/1 = 100, +2 = 102 assert calculate_loop_count(1, 100) == 102 # ───────────────────────────────────────────────────────────────────────────── # build_bgm_filter_chain 测试 # ───────────────────────────────────────────────────────────────────────────── class TestBuildBGMFilterChain: """BGM 预处理滤镜链构建测试.""" def test_basic_volume_only(self): """只有音量调节.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=100, volume=0.5, ) assert "volume=0.500" in result assert "aloop" not in result assert "afade=t=in" not in result assert "afade=t=out" not in result assert "atrim=0:100.000" in result assert "asetpts=N/SR/TB" in result def test_with_loop(self): """需要循环的情况.""" result = build_bgm_filter_chain( bgm_duration=10, target_duration=100, volume=0.3, loop_enabled=True, ) assert "aloop=loop=" in result assert "volume=0.300" in result def test_no_loop_when_disabled(self): """禁用循环,即使 BGM 短也不循环.""" result = build_bgm_filter_chain( bgm_duration=10, target_duration=100, volume=0.3, loop_enabled=False, ) assert "aloop" not in result def test_fade_in_only(self): """只有淡入.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=100, volume=1.0, fade_in=2.5, ) assert "afade=t=in:st=0:d=2.500" in result assert "afade=t=out" not in result assert "volume=" not in result # volume=1.0 不加 def test_fade_out_only(self): """只有淡出.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=100, volume=1.0, fade_out=3.0, ) assert "afade=t=out:st=97.000:d=3.000" in result assert "afade=t=in" not in result def test_fade_in_and_out(self): """淡入+淡出.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=100, volume=1.0, fade_in=1.5, fade_out=2.0, ) assert "afade=t=in:st=0:d=1.500" in result assert "afade=t=out:st=98.000:d=2.000" in result def test_volume_1_0_skipped(self): """音量为 1.0 时不添加 volume 滤镜.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=100, volume=1.0, ) assert "volume=" not in result def test_volume_0(self): """音量为 0.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=100, volume=0.0, ) assert "volume=0.000" in result def test_volume_clamped_high(self): """音量超过 1.0 被钳制.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=100, volume=1.5, ) assert "volume=1.000" not in result # 1.0不加 # 钳制到1.0后和1.0一样,不加volume滤镜 # 但因为abs(1.0 - 1.0) < 0.001,所以不添加 assert "volume=" not in result def test_volume_clamped_low(self): """音量为负被钳制到 0.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=100, volume=-0.5, ) assert "volume=0.000" in result def test_fade_out_longer_than_duration(self): """淡出时长超过总时长,不加淡出.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=10, volume=1.0, fade_out=20.0, ) assert "afade=t=out" not in result def test_fade_out_equal_to_duration(self): """淡出时长等于总时长,不加淡出.""" result = build_bgm_filter_chain( bgm_duration=200, target_duration=10, volume=1.0, fade_out=10.0, ) assert "afade=t=out" not in result def test_zero_target_duration_fallback(self): """目标时长为 0,兜底 5 秒.""" result = build_bgm_filter_chain( bgm_duration=3, target_duration=0, volume=0.5, ) assert "atrim=0:5.000" in result def test_negative_target_duration_fallback(self): """目标时长为负,兜底 5 秒.""" result = build_bgm_filter_chain( bgm_duration=3, target_duration=-5, volume=0.5, ) assert "atrim=0:5.000" in result def test_full_chain_with_all_effects(self): """完整滤镜链:循环+音量+淡入淡出+截断+重置.""" result = build_bgm_filter_chain( bgm_duration=10, target_duration=100, volume=0.4, fade_in=1.0, fade_out=2.0, loop_enabled=True, ) parts = result.split(",") # 顺序:aloop -> volume -> afade in -> afade out -> atrim -> asetpts assert len(parts) >= 6 assert "aloop" in parts[0] assert "volume" in parts[1] assert "afade=t=in" in parts[2] assert "afade=t=out" in parts[3] assert "atrim" in parts[4] assert "asetpts" in parts[5] # ───────────────────────────────────────────────────────────────────────────── # calculate_sidechain_ratio 测试 # ───────────────────────────────────────────────────────────────────────────── class TestCalculateSidechainRatio: """Sidechain 压缩比计算测试.""" def test_default_ratio_0_3(self): """默认 0.3.""" # 1 / (1 - 0.3) = 1.428... 但下限是 2.0 assert calculate_sidechain_ratio(0.3) == pytest.approx(2.0, rel=0.01) def test_ratio_0_5(self): """比例 0.5.""" # 1 / (1 - 0.5) = 2.0 assert calculate_sidechain_ratio(0.5) == pytest.approx(2.0, rel=0.01) def test_ratio_0_8(self): """比例 0.8.""" # 1 / (1 - 0.8) = 5.0 assert calculate_sidechain_ratio(0.8) == pytest.approx(5.0, rel=0.01) def test_ratio_0_9(self): """比例 0.9.""" # 1 / (1 - 0.9) = 10.0 assert calculate_sidechain_ratio(0.9) == pytest.approx(10.0, rel=0.01) def test_ratio_0(self): """比例 0,返回下限 2.0.""" assert calculate_sidechain_ratio(0.0) == 2.0 def test_ratio_negative(self): """比例为负,返回下限 2.0.""" assert calculate_sidechain_ratio(-0.5) == 2.0 def test_ratio_1_0(self): """比例 1.0,返回上限 10.0.""" assert calculate_sidechain_ratio(1.0) == 10.0 def test_ratio_greater_than_1(self): """比例超过 1.0,返回上限 10.0.""" assert calculate_sidechain_ratio(2.0) == 10.0 # ───────────────────────────────────────────────────────────────────────────── # build_simple_mix_filter 测试 # ───────────────────────────────────────────────────────────────────────────── class TestBuildSimpleMixFilter: """普通混音滤镜构建测试.""" def test_contains_amix(self): """包含 amix.""" result = build_simple_mix_filter() assert "amix=inputs=2" in result def test_contains_volume_compensation(self): """包含 volume=2 补偿.""" result = build_simple_mix_filter() assert "volume=2" in result def test_output_label(self): """输出标签为 [final].""" result = build_simple_mix_filter() assert "[final]" in result def test_duration_first(self): """duration=first,以主音频时长为准.""" result = build_simple_mix_filter() assert "duration=first" in result # ───────────────────────────────────────────────────────────────────────────── # build_sidechain_mix_filter 测试 # ───────────────────────────────────────────────────────────────────────────── class TestBuildSidechainMixFilter: """Sidechain 混音滤镜构建测试.""" def test_contains_sidechaincompress(self): """包含 sidechaincompress.""" result = build_sidechain_mix_filter() assert "sidechaincompress=" in result def test_threshold_param(self): """threshold 参数正确.""" result = build_sidechain_mix_filter(threshold=-30.0) assert "threshold=-30.0dB" in result def test_attack_param(self): """attack 参数正确.""" result = build_sidechain_mix_filter(attack=0.05) assert "attack=0.050" in result def test_release_param(self): """release 参数正确.""" result = build_sidechain_mix_filter(release=0.8) assert "release=0.800" in result def test_knee_param(self): """knee=6 参数.""" result = build_sidechain_mix_filter() assert "knee=6" in result def test_contains_amix(self): """包含 amix 混音.""" result = build_sidechain_mix_filter() assert "amix=inputs=2" in result def test_volume_compensation(self): """volume=1.5 轻微补偿.""" result = build_sidechain_mix_filter() assert "volume=1.5" in result def test_bgmc_comp_label(self): """包含 [bgm_comp] 中间标签.""" result = build_sidechain_mix_filter() assert "[bgm_comp]" in result # ───────────────────────────────────────────────────────────────────────────── # normalize_bgm_config 测试 # ───────────────────────────────────────────────────────────────────────────── class TestNormalizeBGMConfig: """配置规范化测试.""" def test_empty_dict_defaults(self): """空字典返回默认值.""" result = normalize_bgm_config({}) assert result["volume"] == 0.3 assert result["fade_in"] == 0.0 assert result["fade_out"] == 0.0 assert result["loop_enabled"] is True assert result["sidechain_enabled"] is False assert result["sidechain_ratio"] == 0.3 def test_volume_clamped(self): """音量钳制.""" result = normalize_bgm_config({"volume": 1.5}) assert result["volume"] == 1.0 result2 = normalize_bgm_config({"volume": -0.5}) assert result2["volume"] == 0.0 def test_fade_in_negative(self): """淡入为负钳制到 0.""" result = normalize_bgm_config({"fade_in": -1}) assert result["fade_in"] == 0.0 def test_fade_out_negative(self): """淡出为负钳制到 0.""" result = normalize_bgm_config({"fade_out": -1}) assert result["fade_out"] == 0.0 def test_sidechain_ratio_clamped(self): """sidechain_ratio 钳制.""" result = normalize_bgm_config({"sidechain_ratio": 1.5}) assert result["sidechain_ratio"] == 1.0 result2 = normalize_bgm_config({"sidechain_ratio": -0.1}) assert result2["sidechain_ratio"] == 0.0 def test_sidechain_attack_min(self): """attack 最小值 0.001.""" result = normalize_bgm_config({"sidechain_attack": 0}) assert result["sidechain_attack"] == 0.001 def test_sidechain_release_min(self): """release 最小值 0.01.""" result = normalize_bgm_config({"sidechain_release": 0}) assert result["sidechain_release"] == 0.01 def test_string_values_converted(self): """字符串数值被转换.""" result = normalize_bgm_config( { "volume": "0.5", "fade_in": "2.0", } ) assert result["volume"] == 0.5 assert result["fade_in"] == 2.0 def test_loop_enabled_truthy(self): """loop_enabled 真值转换.""" result = normalize_bgm_config({"loop_enabled": 1}) assert result["loop_enabled"] is True result2 = normalize_bgm_config({"loop_enabled": 0}) assert result2["loop_enabled"] is False def test_preserves_unknown_keys(self): """未知 key 不保留.""" result = normalize_bgm_config({"unknown_key": "value", "volume": 0.5}) assert "unknown_key" not in result assert result["volume"] == 0.5 # ───────────────────────────────────────────────────────────────────────────── # validate_bgm_config 测试 # ───────────────────────────────────────────────────────────────────────────── class TestValidateBGMConfig: """配置验证测试.""" def test_valid_config(self): """合法配置.""" ok, errors = validate_bgm_config( { "volume": 0.5, "fade_in": 1.0, "fade_out": 2.0, "sidechain_ratio": 0.3, } ) assert ok is True assert len(errors) == 0 def test_volume_not_number(self): """volume 不是数字.""" ok, errors = validate_bgm_config({"volume": "high"}) assert ok is False assert any("volume" in e for e in errors) def test_volume_out_of_range(self): """volume 超出范围.""" ok, errors = validate_bgm_config({"volume": 1.5}) assert ok is False assert any("volume" in e for e in errors) def test_fade_in_negative(self): """fade_in 为负.""" ok, errors = validate_bgm_config({"fade_in": -1}) assert ok is False assert any("fade_in" in e for e in errors) def test_fade_out_negative(self): """fade_out 为负.""" ok, errors = validate_bgm_config({"fade_out": -1}) assert ok is False assert any("fade_out" in e for e in errors) def test_sidechain_ratio_out_of_range(self): """sidechain_ratio 超出范围.""" ok, errors = validate_bgm_config({"sidechain_ratio": 2.0}) assert ok is False assert any("sidechain_ratio" in e for e in errors) def test_multiple_errors(self): """多个错误同时报告.""" ok, errors = validate_bgm_config( { "volume": 2.0, "fade_in": -1, "sidechain_ratio": -0.5, } ) assert ok is False assert len(errors) >= 3 def test_empty_config_valid(self): """空配置(全用默认值)视为合法.""" ok, errors = validate_bgm_config({}) assert ok is True assert len(errors) == 0 # ───────────────────────────────────────────────────────────────────────────── # calculate_fade_out_start 测试 # ───────────────────────────────────────────────────────────────────────────── class TestCalculateFadeOutStart: """淡出开始时间计算测试.""" def test_normal_case(self): """正常情况.""" assert calculate_fade_out_start(100, 3) == pytest.approx(97.0) def test_zero_fade_out(self): """淡出时长为 0,返回 None.""" assert calculate_fade_out_start(100, 0) is None def test_negative_fade_out(self): """淡出时长为负,返回 None.""" assert calculate_fade_out_start(100, -1) is None def test_zero_duration(self): """总时长为 0,返回 None.""" assert calculate_fade_out_start(0, 3) is None def test_fade_out_longer_than_duration(self): """淡出超过总时长,返回 None.""" assert calculate_fade_out_start(10, 20) is None def test_fade_out_equal_to_duration(self): """淡出等于总时长,返回 None.""" assert calculate_fade_out_start(10, 10) is None # ───────────────────────────────────────────────────────────────────────────── # estimate_bgm_processing_duration 测试 # ───────────────────────────────────────────────────────────────────────────── class TestEstimateBGMProcessingDuration: """BGM 处理时长估算测试.""" def test_normal_case_with_loop(self): """正常循环情况,输出目标时长.""" assert estimate_bgm_processing_duration(10, 100, True) == 100 def test_bgm_longer_no_loop(self): """BGM 够长,不循环,截断到目标时长.""" assert estimate_bgm_processing_duration(200, 100, False) == 100 def test_bgm_shorter_no_loop(self): """BGM 短但不循环,仍然截断到目标时长(实际会更短,但 atrim 会截断).""" assert estimate_bgm_processing_duration(10, 100, False) == 100 def test_zero_target(self): """目标时长为 0,兜底 5 秒.""" assert estimate_bgm_processing_duration(10, 0, True) == 5.0 def test_negative_target(self): """目标时长为负,兜底 5 秒.""" assert estimate_bgm_processing_duration(10, -5, True) == 5.0 # ───────────────────────────────────────────────────────────────────────────── # BGMPureConfig 测试 # ───────────────────────────────────────────────────────────────────────────── class TestBGMPureConfig: """BGMPureConfig 数据类测试.""" def test_default_values(self): """默认值正确.""" config = BGMPureConfig() 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_custom_values(self): """自定义值.""" config = BGMPureConfig( volume=0.7, fade_in=1.0, fade_out=2.0, loop_enabled=False, sidechain_enabled=True, sidechain_ratio=0.5, sidechain_attack=0.05, sidechain_release=0.8, sidechain_threshold=-30.0, ) assert config.volume == 0.7 assert config.loop_enabled is False assert config.sidechain_enabled is True assert config.sidechain_threshold == -30.0