diff --git a/tests/unit/test_multi_track_mixer_pure.py b/tests/unit/test_multi_track_mixer_pure.py index 9d6c2376b..ee63391ae 100755 --- a/tests/unit/test_multi_track_mixer_pure.py +++ b/tests/unit/test_multi_track_mixer_pure.py @@ -1,11 +1,8 @@ -"""多轨混音纯逻辑单元测试.""" - -from __future__ import annotations +"""multi_track_mixer_pure 单元测试.""" import math -import pytest -from video_processing.multi_track_mixer_pure import ( +from apps.worker.video_processing.multi_track_mixer_pure import ( build_amix_filter, build_mix_filter_complex, build_track_filter_chain, @@ -24,615 +21,747 @@ from video_processing.multi_track_mixer_pure import ( validate_mix_config, ) -# ───────────────────────────────────────────────────────────────────────────── -# 时间计算测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── calculate_effective_range ─────────────────────────────────────────────── class TestCalculateEffectiveRange: - """有效时间范围计算测试.""" - - def test_normal_track(self): - """正常轨道.""" - start, dur, trim = calculate_effective_range(5, 10, 30, 60) - assert start == 5.0 - assert dur == 10.0 + def test_simple_inside_target(self): + start, need, trim = calculate_effective_range( + track_start=2.0, + track_duration=5.0, + audio_duration=10.0, + target_duration=20.0, + ) + assert start == 2.0 + assert need == 5.0 assert trim == 0.0 - def test_track_longer_than_audio(self): - """轨道时长超过音频长度.""" - start, dur, trim = calculate_effective_range(0, 100, 30, 60) + def test_zero_duration_uses_full_audio(self): + start, need, trim = calculate_effective_range( + track_start=1.0, + track_duration=0, + audio_duration=8.0, + target_duration=20.0, + ) + assert start == 1.0 + assert need == 8.0 + assert trim == 0.0 + + def test_negative_start_trims_beginning(self): + start, need, trim = calculate_effective_range( + track_start=-2.0, + track_duration=0, + audio_duration=10.0, + target_duration=20.0, + ) assert start == 0.0 - assert dur == 30.0 # 用音频全长 + assert need == 8.0 # 10 - 2 + assert trim == 2.0 - def test_zero_track_duration(self): - """轨道时长为 0(用音频全长).""" - start, dur, trim = calculate_effective_range(0, 0, 30, 60) + def test_starts_after_target_duration(self): + start, need, trim = calculate_effective_range( + track_start=25.0, + track_duration=5.0, + audio_duration=10.0, + target_duration=20.0, + ) assert start == 0.0 - assert dur == 30.0 + assert need == 0.0 + assert trim == 0.0 - def test_negative_start_time(self): - """负开始时间(从音频中间取).""" - start, dur, trim = calculate_effective_range(-5, 20, 30, 60) - assert start == 0.0 - assert dur == 15.0 # 20 - 5 = 15 - assert trim == 5.0 + def test_ends_before_zero(self): + start, need, trim = calculate_effective_range( + track_start=-10.0, + track_duration=5.0, + audio_duration=10.0, + target_duration=20.0, + ) + assert need == 0.0 - def test_track_after_target(self): - """轨道完全在目标之后.""" - start, dur, trim = calculate_effective_range(100, 10, 30, 60) - assert dur == 0.0 - - def test_track_before_zero(self): - """轨道完全在 0 之前.""" - start, dur, trim = calculate_effective_range(-50, 10, 30, 60) - assert dur == 0.0 + def test_truncated_at_end(self): + start, need, trim = calculate_effective_range( + track_start=15.0, + track_duration=10.0, + audio_duration=10.0, + target_duration=20.0, + ) + assert start == 15.0 + assert need == 5.0 # 截断到目标时长 + assert trim == 0.0 def test_zero_audio_duration(self): - """音频时长为 0.""" - start, dur, trim = calculate_effective_range(0, 10, 0, 60) - assert dur == 0.0 + start, need, trim = calculate_effective_range( + track_start=0, + track_duration=10, + audio_duration=0, + target_duration=20.0, + ) + assert need == 0.0 - def test_track_extends_beyond_target(self): - """轨道超出目标时长.""" - start, dur, trim = calculate_effective_range(50, 20, 30, 60) - assert start == 50.0 - assert dur == 10.0 # 60 - 50 = 10 + def test_negative_audio_duration(self): + start, need, trim = calculate_effective_range( + track_start=0, + track_duration=10, + audio_duration=-1, + target_duration=20.0, + ) + assert need == 0.0 - def test_full_target_duration(self): - """轨道覆盖整个目标时长.""" - start, dur, trim = calculate_effective_range(0, 0, 100, 60) - assert start == 0.0 - assert dur == 60.0 + def test_track_longer_than_audio(self): + start, need, trim = calculate_effective_range( + track_start=0, + track_duration=20, + audio_duration=10, + target_duration=30, + ) + assert need == 10.0 # 受限于音频长度 + + def test_trim_start_exceeds_audio(self): + start, need, trim = calculate_effective_range( + track_start=-15.0, + track_duration=0, + audio_duration=10.0, + target_duration=20.0, + ) + # 被截掉15秒,但音频只有10秒 → 全没了 + assert need == 0.0 + + +# ── is_track_visible ──────────────────────────────────────────────────────── class TestIsTrackVisible: - """轨道可见性测试.""" - def test_visible_track(self): - """可见轨道.""" - assert is_track_visible(5, 10, 30, 60) is True + assert is_track_visible(2, 5, 10, 20) is True def test_invisible_after_target(self): - """目标之后不可见.""" - assert is_track_visible(100, 10, 30, 60) is False + assert is_track_visible(25, 5, 10, 20) is False - def test_invisible_zero_duration(self): - """零时长不可见.""" - assert is_track_visible(0, 0, 0, 60) is False + def test_invisible_zero_audio(self): + assert is_track_visible(0, 10, 0, 20) is False + + def test_invisible_all_trimmed(self): + assert is_track_visible(-20, 10, 10, 20) is False -# ───────────────────────────────────────────────────────────────────────────── -# 滤镜链构建测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── build_track_filter_chain ──────────────────────────────────────────────── class TestBuildTrackFilterChain: - """单轨滤镜链构建测试.""" - - def test_basic_structure(self): - """基本结构:截断+音量+淡入淡出+延迟+截断.""" - result = build_track_filter_chain( - volume=0.5, - fade_in=1.0, - fade_out=1.0, - effective_start=5.0, - need_duration=10.0, - trim_start=0.0, - target_duration=60.0, - ) - assert "atrim=0.000:10.000" in result - assert "volume=0.500" in result - assert "afade=t=in:st=0:d=1.000" in result - assert "afade=t=out" in result - assert "adelay=5000|5000" in result - assert "atrim=0:60.000" in result - - def test_volume_1_0_skipped(self): - """音量为 1.0 不添加 volume 滤镜.""" + def test_basic_chain_structure(self): result = build_track_filter_chain( volume=1.0, fade_in=0, fade_out=0, effective_start=0, - need_duration=10, + need_duration=5.0, trim_start=0, - target_duration=60, + target_duration=10.0, + ) + parts = result.split(",") + # 至少有: atrim, asetpts, atrim, asetpts + assert any("atrim=" in p for p in parts) + assert parts.count("asetpts=N/SR/TB") == 2 + + def test_volume_filter_applied(self): + result = build_track_filter_chain( + volume=0.5, + fade_in=0, + fade_out=0, + effective_start=0, + need_duration=5.0, + trim_start=0, + target_duration=10.0, + ) + assert "volume=0.500" in result + + def test_volume_one_omitted(self): + result = build_track_filter_chain( + volume=1.0, + fade_in=0, + fade_out=0, + effective_start=0, + need_duration=5.0, + trim_start=0, + target_duration=10.0, ) assert "volume=" not in result - def test_no_fade_in(self): - """无淡入.""" + def test_volume_clamped(self): result = build_track_filter_chain( - volume=1.0, - fade_in=0, - fade_out=2.0, - effective_start=0, - need_duration=10, - trim_start=0, - target_duration=60, - ) - assert "afade=t=in" not in result - assert "afade=t=out" in result - - def test_no_delay(self): - """无延迟(effective_start 很小).""" - result = build_track_filter_chain( - volume=1.0, + volume=3.0, fade_in=0, fade_out=0, - effective_start=0.001, - need_duration=10, + effective_start=0, + need_duration=5.0, trim_start=0, - target_duration=60, + target_duration=10.0, ) - assert "adelay" not in result + assert "volume=2.000" in result # 钳制到2.0 - def test_with_delay(self): - """有延迟.""" + def test_volume_negative_clamped(self): + result = build_track_filter_chain( + volume=-1.0, + fade_in=0, + fade_out=0, + effective_start=0, + need_duration=5.0, + trim_start=0, + target_duration=10.0, + ) + assert "volume=0.000" in result + + def test_fade_in_applied(self): + result = build_track_filter_chain( + volume=1.0, + fade_in=1.0, + fade_out=0, + effective_start=0, + need_duration=5.0, + trim_start=0, + target_duration=10.0, + ) + assert "afade=t=in:st=0:d=1.000" in result + + def test_fade_in_longer_than_duration_skipped(self): + result = build_track_filter_chain( + volume=1.0, + fade_in=10.0, + fade_out=0, + effective_start=0, + need_duration=5.0, + trim_start=0, + target_duration=10.0, + ) + assert "afade=t=in" not in result + + def test_fade_out_applied(self): + result = build_track_filter_chain( + volume=1.0, + fade_in=0, + fade_out=1.0, + effective_start=0, + need_duration=5.0, + trim_start=0, + target_duration=10.0, + ) + assert "afade=t=out:st=4.000:d=1.000" in result + + def test_fade_out_longer_than_duration_skipped(self): + result = build_track_filter_chain( + volume=1.0, + fade_in=0, + fade_out=10.0, + effective_start=0, + need_duration=5.0, + trim_start=0, + target_duration=10.0, + ) + assert "afade=t=out" not in result + + def test_delay_applied(self): result = build_track_filter_chain( volume=1.0, fade_in=0, fade_out=0, effective_start=2.5, - need_duration=10, + need_duration=5.0, trim_start=0, - target_duration=60, + target_duration=10.0, ) assert "adelay=2500|2500" in result - def test_fade_in_longer_than_duration(self): - """淡入超过总时长,不添加淡入.""" - result = build_track_filter_chain( - volume=1.0, - fade_in=20, - fade_out=0, - effective_start=0, - need_duration=10, - trim_start=0, - target_duration=60, - ) - assert "afade=t=in" not in result - - def test_fade_out_at_start(self): - """淡出从 0 开始(很短的音频).""" - result = build_track_filter_chain( - volume=1.0, - fade_in=0, - fade_out=15, - effective_start=0, - need_duration=10, - trim_start=0, - target_duration=60, - ) - # fade_out > need_duration,不添加 - assert "afade=t=out" not in result - - def test_trim_start_nonzero(self): - """从音频中间开始截取.""" + def test_zero_delay_skipped(self): result = build_track_filter_chain( volume=1.0, fade_in=0, fade_out=0, effective_start=0, - need_duration=5, - trim_start=3.0, - target_duration=60, + need_duration=5.0, + trim_start=0, + target_duration=10.0, ) - assert "atrim=3.000:8.000" in result # 3.0 to 3.0+5.0 + assert "adelay" not in result + + def test_final_truncation_exists(self): + result = build_track_filter_chain( + volume=1.0, + fade_in=0, + fade_out=0, + effective_start=0, + need_duration=5.0, + trim_start=0, + target_duration=10.0, + ) + assert "atrim=0:10.000" in result # 最终截断 + + def test_full_chain_with_all_features(self): + result = build_track_filter_chain( + volume=0.8, + fade_in=0.5, + fade_out=1.0, + effective_start=2.0, + need_duration=6.0, + trim_start=1.0, + target_duration=10.0, + ) + # 有atrim开头截断 + assert "atrim=1.000:7.000" in result + # 有音量 + assert "volume=0.800" in result + # 有淡入淡出 + assert "afade=t=in" in result + assert "afade=t=out" in result + # 有延迟 + assert "adelay=2000|2000" in result + # 有最终截断 + assert "atrim=0:10.000" in result -# ───────────────────────────────────────────────────────────────────────────── -# amix 滤镜测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── build_amix_filter ─────────────────────────────────────────────────────── class TestBuildAmixFilter: - """amix 滤镜构建测试.""" - - def test_two_inputs(self): - """两路输入.""" - result = build_amix_filter(2) - assert "amix=inputs=2" in result - assert "duration=longest" in result - - def test_five_inputs(self): - """五路输入.""" - result = build_amix_filter(5) - assert "amix=inputs=5" in result - def test_zero_inputs(self): - """零输入.""" assert build_amix_filter(0) == "" - def test_duration_shortest(self): - """shortest 模式.""" - result = build_amix_filter(3, "shortest") + def test_negative_inputs(self): + assert build_amix_filter(-1) == "" + + def test_single_input(self): + result = build_amix_filter(1) + assert "amix=inputs=1:" in result + assert "duration=longest" in result + assert "dropout_transition=0" in result + + def test_multiple_inputs(self): + result = build_amix_filter(5) + assert "amix=inputs=5:" in result + + def test_shortest_mode(self): + result = build_amix_filter(3, duration_mode="shortest") assert "duration=shortest" in result - def test_invalid_duration_mode(self): - """无效模式,默认 longest.""" - result = build_amix_filter(3, "invalid") + def test_first_mode(self): + result = build_amix_filter(3, duration_mode="first") + assert "duration=first" in result + + def test_invalid_mode_falls_back(self): + result = build_amix_filter(3, duration_mode="invalid") assert "duration=longest" in result +# ── calculate_amix_volume_compensation ────────────────────────────────────── + + class TestCalculateAmixVolumeCompensation: - """音量补偿计算测试.""" + def test_zero_inputs(self): + assert calculate_amix_volume_compensation(0) == 1.0 - def test_single_track(self): - """单轨,无需补偿.""" + def test_single_input(self): assert calculate_amix_volume_compensation(1) == 1.0 - def test_two_tracks(self): - """两轨,补偿 2x.""" + def test_two_inputs(self): assert calculate_amix_volume_compensation(2) == 2.0 - def test_five_tracks(self): - """五轨,补偿 5x.""" + def test_five_inputs(self): assert calculate_amix_volume_compensation(5) == 5.0 - def test_zero_tracks(self): - """零轨,返回 1.""" - assert calculate_amix_volume_compensation(0) == 1.0 + def test_negative_inputs(self): + assert calculate_amix_volume_compensation(-1) == 1.0 + + +# ── build_mix_filter_complex ──────────────────────────────────────────────── class TestBuildMixFilterComplex: - """完整混音滤镜测试.""" - - def test_with_main_and_two_tracks(self): - """主音频 + 2 条轨道.""" - result = build_mix_filter_complex(2, has_main=True) - assert "[0:a][1:a][2:a]" in result # 3 路输入 - assert "amix=inputs=3" in result - assert "volume=3" in result # 3x 补偿 - assert "[mixed]" in result - - def test_no_main_three_tracks(self): - """无主音频,3 条轨道.""" - result = build_mix_filter_complex(3, has_main=False) - assert "[0:a][1:a][2:a]" in result - assert "amix=inputs=3" in result - assert "[mixed]" in result - - def test_zero_tracks_no_main(self): - """无轨道无主音频.""" + def test_no_tracks_no_main_empty(self): assert build_mix_filter_complex(0, has_main=False) == "" + def test_main_only(self): + result = build_mix_filter_complex(0, has_main=True) + assert "[0:a]" in result + assert "amix=inputs=1:" in result + assert "[mixed]" in result + # 单路无音量补偿 + assert "volume=" not in result -# ───────────────────────────────────────────────────────────────────────────── -# 音量计算测试 -# ───────────────────────────────────────────────────────────────────────────── + def test_main_plus_tracks(self): + result = build_mix_filter_complex(2, has_main=True) + assert "[0:a][1:a][2:a]" in result + assert "amix=inputs=3:" in result + # 3路有音量补偿 + assert "volume=3.0" in result + + def test_tracks_only_no_main(self): + result = build_mix_filter_complex(3, has_main=False) + assert "[0:a][1:a][2:a]" in result + assert "amix=inputs=3:" in result + assert "volume=3.0" in result + + def test_duration_mode_passed(self): + result = build_mix_filter_complex(2, has_main=True, duration_mode="shortest") + assert "duration=shortest" in result + + def test_output_label(self): + result = build_mix_filter_complex(2, has_main=True) + assert result.endswith("[mixed]") + + +# ── normalize_volume ──────────────────────────────────────────────────────── class TestNormalizeVolume: - """音量规范化测试.""" - - def test_normal_volume(self): - """正常音量.""" - assert normalize_volume(0.5) == 0.5 - - def test_none_default(self): - """None 默认 1.0.""" + def test_none_returns_one(self): assert normalize_volume(None) == 1.0 - def test_below_zero_clamped(self): - """负值钳制到 0.""" - assert normalize_volume(-5) == 0.0 + def test_normal_value(self): + assert normalize_volume(0.5) == 0.5 + + def test_max_value(self): + assert normalize_volume(2.0) == 2.0 def test_above_max_clamped(self): - """超过上限钳制.""" assert normalize_volume(3.0) == 2.0 - def test_string_input(self): - """字符串输入.""" + def test_below_min_clamped(self): + assert normalize_volume(-1.0) == 0.0 + + def test_zero(self): + assert normalize_volume(0) == 0.0 + + def test_string_number(self): assert normalize_volume("0.5") == 0.5 def test_invalid_string(self): - """无效字符串默认 1.0.""" assert normalize_volume("abc") == 1.0 -class TestDbConversion: - """dB 转换测试.""" +# ── db_to_linear / linear_to_db ───────────────────────────────────────────── - def test_0_db_is_unity(self): - """0 dB = 1.0.""" - assert db_to_linear(0) == pytest.approx(1.0) - def test_negative_db(self): - """负 dB < 1.""" - assert db_to_linear(-6) == pytest.approx(0.5, rel=0.01) +class TestDbConversions: + def test_zero_db_is_one(self): + assert abs(db_to_linear(0) - 1.0) < 0.001 - def test_positive_db(self): - """正 dB > 1.""" - assert db_to_linear(6) == pytest.approx(2.0, rel=0.01) + def test_negative_db_less_than_one(self): + assert db_to_linear(-20) < 1.0 - def test_round_trip(self): - """往返转换.""" + def test_positive_db_greater_than_one(self): + assert db_to_linear(20) > 1.0 + + def test_roundtrip_conversion(self): original = 0.5 db = linear_to_db(original) - result = db_to_linear(db) - assert result == pytest.approx(original) + back = db_to_linear(db) + assert abs(back - original) < 0.001 - def test_zero_linear_is_negative_inf(self): - """零线性值 = -inf dB.""" + def test_20db_is_10x(self): + # 20dB = 10倍 + assert abs(db_to_linear(20) - 10.0) < 0.001 + + def test_linear_zero_is_neg_inf(self): assert math.isinf(linear_to_db(0)) assert linear_to_db(0) < 0 + def test_linear_negative_is_neg_inf(self): + assert math.isinf(linear_to_db(-1)) -# ───────────────────────────────────────────────────────────────────────────── -# 轨道排序与过滤测试 -# ───────────────────────────────────────────────────────────────────────────── + +# ── sort_tracks_by_priority ───────────────────────────────────────────────── class TestSortTracksByPriority: - """轨道优先级排序测试.""" - - def test_sorted_by_priority(self): - """按优先级排序.""" + def test_sorted_ascending(self): tracks = [ - {"priority": 10, "name": "high"}, - {"priority": 1, "name": "highest"}, - {"priority": 100, "name": "low"}, + {"name": "c", "priority": 3}, + {"name": "a", "priority": 1}, + {"name": "b", "priority": 2}, ] result = sort_tracks_by_priority(tracks) - assert result[0]["name"] == "highest" - assert result[1]["name"] == "high" - assert result[2]["name"] == "low" + assert [t["name"] for t in result] == ["a", "b", "c"] def test_default_priority_100(self): - """默认优先级 100.""" tracks = [ - {"priority": 50, "name": "mid"}, - {"name": "default"}, + {"name": "low", "priority": 50}, + {"name": "default"}, # 默认100 + {"name": "high", "priority": 150}, ] result = sort_tracks_by_priority(tracks) - assert result[0]["name"] == "mid" + assert result[0]["name"] == "low" assert result[1]["name"] == "default" + assert result[2]["name"] == "high" - def test_same_preserves_order(self): - """同优先级保持顺序.""" + def test_same_priority_stable(self): tracks = [ - {"priority": 10, "name": "first"}, - {"priority": 10, "name": "second"}, + {"name": "first", "priority": 5}, + {"name": "second", "priority": 5}, + {"name": "third", "priority": 5}, ] result = sort_tracks_by_priority(tracks) - assert result[0]["name"] == "first" - assert result[1]["name"] == "second" + assert [t["name"] for t in result] == ["first", "second", "third"] def test_empty_list(self): - """空列表.""" assert sort_tracks_by_priority([]) == [] + def test_original_not_modified(self): + tracks = [{"priority": 3}, {"priority": 1}] + original = list(tracks) + sort_tracks_by_priority(tracks) + assert tracks == original + + +# ── filter_enabled_tracks ─────────────────────────────────────────────────── + class TestFilterEnabledTracks: - """启用轨道过滤测试.""" - def test_all_enabled(self): - """全部启用.""" - tracks = [{"enabled": True}, {"enabled": True}] - assert len(filter_enabled_tracks(tracks)) == 2 + tracks = [{"name": "a", "enabled": True}, {"name": "b"}] + result = filter_enabled_tracks(tracks) + assert len(result) == 2 - def test_mixed(self): - """混合.""" + def test_some_disabled(self): tracks = [ - {"enabled": True, "name": "a"}, - {"enabled": False, "name": "b"}, + {"name": "a", "enabled": True}, + {"name": "b", "enabled": False}, + {"name": "c", "enabled": "false"}, + {"name": "d", "enabled": 0}, ] result = filter_enabled_tracks(tracks) assert len(result) == 1 assert result[0]["name"] == "a" - def test_default_enabled(self): - """默认启用.""" - tracks = [{"name": "a"}] - assert len(filter_enabled_tracks(tracks)) == 1 + def test_all_disabled(self): + tracks = [ + {"name": "a", "enabled": False}, + {"name": "b", "enabled": "false"}, + ] + assert filter_enabled_tracks(tracks) == [] def test_empty_list(self): - """空列表.""" assert filter_enabled_tracks([]) == [] + def test_string_true_enabled(self): + tracks = [{"name": "a", "enabled": "true"}] + result = filter_enabled_tracks(tracks) + assert len(result) == 1 + + +# ── count_track_types ─────────────────────────────────────────────────────── + class TestCountTrackTypes: - """轨道类型统计测试.""" - - def test_mixed_types(self): - """混合类型.""" + def test_multiple_types(self): tracks = [ {"track_type": "bgm"}, - {"track_type": "voiceover"}, + {"track_type": "voice"}, {"track_type": "bgm"}, {"track_type": "sfx"}, + {"track_type": "bgm"}, ] - counts = count_track_types(tracks) - assert counts["bgm"] == 2 - assert counts["voiceover"] == 1 - assert counts["sfx"] == 1 + result = count_track_types(tracks) + assert result == {"bgm": 3, "voice": 1, "sfx": 1} def test_default_type(self): - """默认类型.""" - tracks = [{}] - counts = count_track_types(tracks) - assert counts["unknown"] == 1 + tracks = [{"name": "a"}, {"track_type": "bgm"}] + result = count_track_types(tracks) + assert result["unknown"] == 1 + assert result["bgm"] == 1 def test_empty_list(self): - """空列表.""" assert count_track_types([]) == {} -# ───────────────────────────────────────────────────────────────────────────── -# 配置验证测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── validate_audio_track ──────────────────────────────────────────────────── class TestValidateAudioTrack: - """单轨验证测试.""" + def test_valid_with_asset_id(self): + valid, errors = validate_audio_track({"asset_id": "asset_123"}) + assert valid is True + assert errors == [] - def test_valid_track(self): - """合法轨道.""" - ok, errors = validate_audio_track( - { - "audio_path": "/audio.mp3", - "volume": 0.8, - "fade_in": 1.0, - "fade_out": 2.0, - } - ) - assert ok is True - assert len(errors) == 0 + def test_valid_with_audio_path(self): + valid, errors = validate_audio_track({"audio_path": "/tmp/a.mp3"}) + assert valid is True + assert errors == [] - def test_missing_path(self): - """缺路径.""" - ok, errors = validate_audio_track({}) - assert ok is False - assert any("audio_path" in e or "asset_id" in e for e in errors) + def test_missing_source(self): + valid, errors = validate_audio_track({}) + assert valid is False + assert any("audio_path 或 asset_id" in e for e in errors) def test_negative_volume(self): - """负音量.""" - ok, errors = validate_audio_track( - { - "audio_path": "/a.mp3", - "volume": -1, - } - ) - assert ok is False + valid, errors = validate_audio_track({"asset_id": "a", "volume": -1}) + assert valid is False + assert any("volume" in e for e in errors) + + def test_volume_too_high(self): + valid, errors = validate_audio_track({"asset_id": "a", "volume": 3.0}) + assert valid is False + assert any("volume" in e for e in errors) + + def test_invalid_volume_string(self): + valid, errors = validate_audio_track({"asset_id": "a", "volume": "abc"}) + assert valid is False assert any("volume" in e for e in errors) def test_negative_fade_in(self): - """负淡入.""" - ok, errors = validate_audio_track( - { - "audio_path": "/a.mp3", - "fade_in": -1, - } - ) - assert ok is False + valid, errors = validate_audio_track({"asset_id": "a", "fade_in": -1}) + assert valid is False assert any("fade_in" in e for e in errors) def test_negative_fade_out(self): - """负淡出.""" - ok, errors = validate_audio_track( - { - "audio_path": "/a.mp3", - "fade_out": -1, - } - ) - assert ok is False + valid, errors = validate_audio_track({"asset_id": "a", "fade_out": -1}) + assert valid is False assert any("fade_out" in e for e in errors) - def test_invalid_volume_type(self): - """无效音量类型.""" - ok, errors = validate_audio_track( + def test_invalid_fade_in_string(self): + valid, errors = validate_audio_track({"asset_id": "a", "fade_in": "abc"}) + assert valid is False + assert any("fade_in" in e for e in errors) + + def test_invalid_start_time(self): + valid, errors = validate_audio_track({"asset_id": "a", "start_time": "abc"}) + assert valid is False + assert any("start_time" in e for e in errors) + + def test_multiple_errors(self): + valid, errors = validate_audio_track( { - "audio_path": "/a.mp3", - "volume": "loud", + "volume": "abc", + "fade_in": "def", + "start_time": "ghi", } ) - assert ok is False - assert any("volume" in e for e in errors) + assert valid is False + assert len(errors) >= 4 # source + volume + fade_in + start_time - def test_with_asset_id(self): - """有 asset_id 无 audio_path 也合法.""" - ok, errors = validate_audio_track({"asset_id": "123"}) - assert ok is True + +# ── validate_mix_config ───────────────────────────────────────────────────── class TestValidateMixConfig: - """混音配置验证测试.""" - def test_valid_config(self): - """合法配置.""" - ok, errors = validate_mix_config( - { - "tracks": [ - {"audio_path": "/a.mp3", "volume": 0.5}, - {"audio_path": "/b.mp3", "volume": 0.8}, - ], - "target_duration": 60, - } - ) - assert ok is True + config = { + "tracks": [{"audio_path": "/a.mp3", "volume": 1.0}], + "target_duration": 10.0, + } + valid, errors = validate_mix_config(config) + assert valid is True + assert errors == [] + + def test_no_tracks(self): + valid, errors = validate_mix_config({}) + assert valid is False + assert any("至少需要一条轨道" in e for e in errors) def test_empty_tracks(self): - """空轨道列表.""" - ok, errors = validate_mix_config({"tracks": []}) - assert ok is False - assert any("至少需要" in e for e in errors) + valid, errors = validate_mix_config({"tracks": []}) + assert valid is False + assert any("至少需要一条轨道" in e for e in errors) - def test_invalid_track(self): - """无效轨道.""" - ok, errors = validate_mix_config( - { - "tracks": [ - {"audio_path": "/a.mp3"}, - {}, # 无效 - ], - } - ) - assert ok is False - assert len(errors) >= 1 + def test_invalid_track_errors_prefixed(self): + config = {"tracks": [{"volume": "abc"}]} + valid, errors = validate_mix_config(config) + assert valid is False + assert any(e.startswith("第1轨:") for e in errors) + + def test_multiple_invalid_tracks(self): + config = { + "tracks": [ + {"volume": "bad"}, + {"audio_path": "/a.mp3", "fade_in": "bad"}, + ] + } + valid, errors = validate_mix_config(config) + assert valid is False + track1_errors = [e for e in errors if e.startswith("第1轨:")] + track2_errors = [e for e in errors if e.startswith("第2轨:")] + assert len(track1_errors) >= 1 + assert len(track2_errors) >= 1 def test_negative_target_duration(self): - """负目标时长.""" - ok, errors = validate_mix_config( - { - "tracks": [{"audio_path": "/a.mp3"}], - "target_duration": -10, - } - ) - assert ok is False + config = { + "tracks": [{"audio_path": "/a.mp3"}], + "target_duration": -5, + } + valid, errors = validate_mix_config(config) + assert valid is False + assert any("target_duration" in e for e in errors) + + def test_invalid_target_duration(self): + config = { + "tracks": [{"audio_path": "/a.mp3"}], + "target_duration": "abc", + } + valid, errors = validate_mix_config(config) + assert valid is False assert any("target_duration" in e for e in errors) -# ───────────────────────────────────────────────────────────────────────────── -# 工具函数测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── calculate_total_tracks ────────────────────────────────────────────────── class TestCalculateTotalTracks: - """总轨道数计算测试.""" + def test_with_main_default(self): + config = {"tracks": [{}, {}, {}]} + assert calculate_total_tracks(config) == 4 # 3 + 1主 - def test_with_main(self): - """含主音频.""" - assert calculate_total_tracks({"tracks": [1, 2, 3]}) == 4 + def test_with_main_explicit(self): + config = {"tracks": [{}, {}], "has_main_audio": True} + assert calculate_total_tracks(config) == 3 def test_without_main(self): - """不含主音频.""" - assert ( - calculate_total_tracks( - { - "tracks": [1, 2], - "has_main_audio": False, - } - ) - == 2 - ) + config = {"tracks": [{}, {}], "has_main_audio": False} + assert calculate_total_tracks(config) == 2 - def test_empty_tracks_with_main(self): - """无轨道,只有主音频.""" - assert calculate_total_tracks({"tracks": []}) == 1 + def test_no_tracks_with_main(self): + config = {"tracks": [], "has_main_audio": True} + assert calculate_total_tracks(config) == 1 + + def test_empty_config(self): + assert calculate_total_tracks({}) == 1 + + +# ── estimate_mix_duration ─────────────────────────────────────────────────── class TestEstimateMixDuration: - """混音时长估算测试.""" + def test_single_track(self): + tracks = [{"start_time": 0, "duration": 10}] + assert estimate_mix_duration(tracks) == 10.0 - def test_multiple_tracks(self): - """多轨道取最长结束时间.""" + def test_multiple_tracks_takes_max(self): tracks = [ {"start_time": 0, "duration": 10}, - {"start_time": 5, "duration": 20}, # 结束 25 - {"start_time": 2, "duration": 5}, + {"start_time": 5, "duration": 20}, # end=25 + {"start_time": 2, "duration": 8}, # end=10 ] - assert estimate_mix_duration(tracks) == pytest.approx(25.0) + assert estimate_mix_duration(tracks) == 25.0 def test_empty_list(self): - """空列表.""" assert estimate_mix_duration([]) == 0.0 def test_zero_duration_tracks_ignored(self): - """零时长轨道忽略.""" tracks = [ {"start_time": 0, "duration": 0}, - {"start_time": 5, "duration": 10}, + {"start_time": 5, "duration": 0}, ] - assert estimate_mix_duration(tracks) == pytest.approx(15.0) + assert estimate_mix_duration(tracks) == 0.0 + + def test_invalid_values_skipped(self): + tracks = [ + {"start_time": "abc", "duration": 10}, + {"start_time": 0, "duration": "xyz"}, + {"start_time": 2, "duration": 5}, + ] + assert estimate_mix_duration(tracks) == 7.0 + + def test_negative_start_time(self): + tracks = [{"start_time": -5, "duration": 10}] # end=5 + assert estimate_mix_duration(tracks) == 5.0 + + def test_string_numbers(self): + tracks = [{"start_time": "2.5", "duration": "3.5"}] + assert estimate_mix_duration(tracks) == 6.0