diff --git a/tests/unit/test_bgm_mixer_pure.py b/tests/unit/test_bgm_mixer_pure.py index 1b4d43ed1..383bea515 100755 --- a/tests/unit/test_bgm_mixer_pure.py +++ b/tests/unit/test_bgm_mixer_pure.py @@ -1,9 +1,6 @@ -"""BGM 混音纯逻辑单元测试.""" +"""bgm_mixer_pure 单元测试.""" -from __future__ import annotations - -import pytest -from video_processing.bgm_mixer_pure import ( +from apps.worker.video_processing.bgm_mixer_pure import ( BGMPureConfig, build_bgm_filter_chain, build_sidechain_mix_filter, @@ -17,408 +14,286 @@ from video_processing.bgm_mixer_pure import ( validate_bgm_config, ) -# ───────────────────────────────────────────────────────────────────────────── -# should_loop_bgm 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── BGMPureConfig ──────────────────────────────────────────────────────────── -class TestShouldLoopBGM: - """BGM 循环判断测试.""" +class TestBGMPureConfig: + def test_default_values(self): + cfg = BGMPureConfig() + 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 + assert cfg.sidechain_attack == 0.02 + assert cfg.sidechain_release == 0.5 + assert cfg.sidechain_threshold == -25.0 - def test_need_loop_when_much_shorter(self): - """BGM 远短于目标时长,需要循环.""" - assert should_loop_bgm(10, 100, True) is True + def test_custom_values(self): + cfg = BGMPureConfig( + volume=0.5, + fade_in=1.0, + fade_out=2.0, + loop_enabled=False, + sidechain_enabled=True, + sidechain_ratio=0.5, + ) + assert cfg.volume == 0.5 + assert cfg.loop_enabled is False + assert cfg.sidechain_enabled is True + assert cfg.sidechain_ratio == 0.5 - 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 +# ── should_loop_bgm ───────────────────────────────────────────────────────── - 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 +class TestShouldLoopBgm: + def test_loop_enabled_much_shorter(self): + # BGM 10秒,目标60秒 → 需要循环 + assert should_loop_bgm(10, 60) is True def test_loop_disabled(self): - """禁用循环,即使 BGM 很短也不循环.""" - assert should_loop_bgm(10, 100, False) is False + assert should_loop_bgm(10, 60, loop_enabled=False) is False + + def test_bgm_longer_than_target(self): + # BGM 100秒,目标60秒 → 不需要循环 + assert should_loop_bgm(100, 60) is False + + def test_bgm_slightly_shorter_no_loop(self): + # BGM 58秒,目标60秒 → 58 > 60*0.9=54,不需要循环 + assert should_loop_bgm(58, 60) is False + + def test_bgm_significantly_shorter_loops(self): + # BGM 50秒,目标60秒 → 50 < 54,需要循环 + assert should_loop_bgm(50, 60) is True def test_zero_bgm_duration(self): - """BGM 时长为 0,不循环.""" - assert should_loop_bgm(0, 100, True) is False + assert should_loop_bgm(0, 60) is False def test_negative_bgm_duration(self): - """BGM 时长为负,不循环.""" - assert should_loop_bgm(-5, 100, True) is False + assert should_loop_bgm(-1, 60) is False def test_zero_target_duration(self): - """目标时长为 0,不循环.""" - assert should_loop_bgm(10, 0, True) is False + assert should_loop_bgm(10, 0) is False def test_negative_target_duration(self): - """目标时长为负,不循环.""" - assert should_loop_bgm(10, -10, True) is False + assert should_loop_bgm(10, -1) is False + + def test_exact_90_percent_no_loop(self): + # 边界:bgm == target * 0.9 → 不小于,不循环 + assert should_loop_bgm(54, 60) is False -# ───────────────────────────────────────────────────────────────────────────── -# calculate_loop_count 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── calculate_loop_count ──────────────────────────────────────────────────── class TestCalculateLoopCount: - """循环次数计算测试.""" + def test_exact_fit_returns_1(self): + assert calculate_loop_count(60, 60) == 1 - def test_exact_multiple(self): - """刚好整数倍.""" - # 100/10 = 10, +2 = 12 - assert calculate_loop_count(10, 100) == 12 + def test_bgm_longer_returns_1(self): + assert calculate_loop_count(100, 60) == 1 - def test_not_exact_multiple(self): - """不是整数倍.""" - # 100/30 = 3, +2 = 5 - assert calculate_loop_count(30, 100) == 5 + def test_needs_3_loops_plus_2_margin(self): + # 60/20 = 3 + 2 = 5 + assert calculate_loop_count(20, 60) == 5 - def test_bgm_longer_than_target(self): - """BGM 比目标长,至少 1 次.""" - assert calculate_loop_count(200, 100) == 1 + def test_needs_2_loops_plus_2_margin(self): + # 60/30 = 2 + 2 = 4 + assert calculate_loop_count(30, 60) == 4 def test_zero_bgm_duration(self): - """BGM 时长为 0,返回 1.""" - assert calculate_loop_count(0, 100) == 1 + assert calculate_loop_count(0, 60) == 1 def test_negative_bgm_duration(self): - """BGM 时长为负,返回 1.""" - assert calculate_loop_count(-5, 100) == 1 + assert calculate_loop_count(-1, 60) == 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 + assert calculate_loop_count(10, -1) == 1 - def test_very_short_bgm(self): - """非常短的 BGM,循环次数多.""" - # 100/1 = 100, +2 = 102 - assert calculate_loop_count(1, 100) == 102 + def test_minimum_is_1(self): + assert calculate_loop_count(10, 5) == 1 -# ───────────────────────────────────────────────────────────────────────────── -# build_bgm_filter_chain 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── build_bgm_filter_chain ────────────────────────────────────────────────── -class TestBuildBGMFilterChain: - """BGM 预处理滤镜链构建测试.""" +class TestBuildBgmFilterChain: + def test_basic_structure(self): + result = build_bgm_filter_chain(100, 60) + parts = result.split(",") + # 至少有 atrim + asetpts + assert any("atrim=" in p for p in parts) + assert "asetpts=N/SR/TB" in parts - def test_basic_volume_only(self): - """只有音量调节.""" - result = build_bgm_filter_chain( - bgm_duration=200, - target_duration=100, - volume=0.5, - ) + def test_volume_filter_applied(self): + result = build_bgm_filter_chain(100, 60, 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_volume_one_omitted(self): + result = build_bgm_filter_chain(100, 60, volume=1.0) + assert "volume=" not 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_volume_clamped(self): + # volume=2.0钳制到1.0,1.0等于默认值所以被跳过 + result = build_bgm_filter_chain(100, 60, volume=2.0) + assert "volume=" not in result # 钳制到1.0后与默认相同,跳过 + # 用0.5验证音量过滤器本身存在 + result2 = build_bgm_filter_chain(100, 60, volume=0.5) + assert "volume=0.500" in result2 - 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_volume_zero(self): + result = build_bgm_filter_chain(100, 60, volume=0.0) + assert "volume=0.000" in result - 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, - ) + def test_fade_in_applied(self): + result = build_bgm_filter_chain(100, 60, fade_in=1.5) 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_fade_in_zero_skipped(self): + result = build_bgm_filter_chain(100, 60, fade_in=0) + assert "afade=t=in" 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_fade_out_applied(self): + result = build_bgm_filter_chain(100, 60, fade_out=2.0) + assert "afade=t=out:st=58.000:d=2.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, - ) + def test_fade_out_longer_than_target_skipped(self): + result = build_bgm_filter_chain(100, 10, fade_out=20) + # fade_out >= safe_target,不做淡出 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_loop_applied_when_needed(self): + result = build_bgm_filter_chain(10, 60) + assert "aloop=loop=" 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, - ) + def test_no_loop_when_bgm_long(self): + result = build_bgm_filter_chain(100, 60) + assert "aloop=" not in result + + def test_loop_disabled(self): + result = build_bgm_filter_chain(10, 60, loop_enabled=False) + assert "aloop=" not in result + + def test_trim_to_target_duration(self): + result = build_bgm_filter_chain(100, 60) + assert "atrim=0:60.000" in result + + def test_zero_target_uses_fallback(self): + result = build_bgm_filter_chain(100, 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, - ) + def test_negative_target_uses_fallback(self): + result = build_bgm_filter_chain(100, -5) assert "atrim=0:5.000" in result - def test_full_chain_with_all_effects(self): - """完整滤镜链:循环+音量+淡入淡出+截断+重置.""" + def test_all_features_combined(self): result = build_bgm_filter_chain( - bgm_duration=10, - target_duration=100, - volume=0.4, + bgm_duration=15, + target_duration=60, + volume=0.3, 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] + assert "aloop=loop=" in result + assert "volume=0.300" in result + assert "afade=t=in" in result + assert "afade=t=out" in result + assert "atrim=0:60.000" in result + assert "asetpts=N/SR/TB" in result -# ───────────────────────────────────────────────────────────────────────────── -# calculate_sidechain_ratio 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── calculate_sidechain_ratio ─────────────────────────────────────────────── class TestCalculateSidechainRatio: - """Sidechain 压缩比计算测试.""" + def test_zero_ratio_minimum(self): + assert calculate_sidechain_ratio(0) == 2.0 - 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.""" + def test_negative_clamped(self): assert calculate_sidechain_ratio(-0.5) == 2.0 - def test_ratio_1_0(self): - """比例 1.0,返回上限 10.0.""" + def test_one_ratio_maximum(self): 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 + def test_above_one_clamped(self): + assert calculate_sidechain_ratio(1.5) == 10.0 + + def test_mid_value(self): + # ratio = 1/(1-0.5) = 2.0 + result = calculate_sidechain_ratio(0.5) + assert abs(result - 2.0) < 0.01 + + def test_high_value(self): + # 1/(1-0.9) = 10 → 钳制到10 + assert calculate_sidechain_ratio(0.9) == 10.0 + + def test_03_default(self): + # 1/(1-0.3) = 1.428... → 钳制到2.0 + result = calculate_sidechain_ratio(0.3) + assert result >= 2.0 -# ───────────────────────────────────────────────────────────────────────────── -# build_simple_mix_filter 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── build_simple_mix_filter ───────────────────────────────────────────────── class TestBuildSimpleMixFilter: - """普通混音滤镜构建测试.""" - - def test_contains_amix(self): - """包含 amix.""" + def test_contains_inputs_and_output(self): result = build_simple_mix_filter() + assert "[0:a][1:a]" in result assert "amix=inputs=2" in result - - def test_contains_volume_compensation(self): - """包含 volume=2 补偿.""" - result = build_simple_mix_filter() + assert "duration=first" in result + assert "[final]" in result 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 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── build_sidechain_mix_filter ────────────────────────────────────────────── class TestBuildSidechainMixFilter: - """Sidechain 混音滤镜构建测试.""" - def test_contains_sidechaincompress(self): - """包含 sidechaincompress.""" result = build_sidechain_mix_filter() assert "sidechaincompress=" in result + assert "[1:a][0:a]sidechaincompress" in result - def test_threshold_param(self): - """threshold 参数正确.""" + def test_threshold_in_db(self): 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_attack_and_release(self): + result = build_sidechain_mix_filter(attack=0.01, release=0.3) + assert "attack=0.010" in result + assert "release=0.300" in result def test_contains_amix(self): - """包含 amix 混音.""" result = build_sidechain_mix_filter() assert "amix=inputs=2" in result + assert "duration=first" in result - def test_volume_compensation(self): - """volume=1.5 轻微补偿.""" + def test_contains_volume_compensation(self): result = build_sidechain_mix_filter() assert "volume=1.5" in result - def test_bgmc_comp_label(self): - """包含 [bgm_comp] 中间标签.""" + def test_output_label(self): + result = build_sidechain_mix_filter() + assert "[final]" in result + + def test_bgm_comp_label(self): result = build_sidechain_mix_filter() assert "[bgm_comp]" in result -# ───────────────────────────────────────────────────────────────────────────── -# normalize_bgm_config 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── normalize_bgm_config ──────────────────────────────────────────────────── -class TestNormalizeBGMConfig: - """配置规范化测试.""" - - def test_empty_dict_defaults(self): - """空字典返回默认值.""" +class TestNormalizeBgmConfig: + def test_default_values(self): result = normalize_bgm_config({}) assert result["volume"] == 0.3 assert result["fade_in"] == 0.0 @@ -426,233 +301,184 @@ class TestNormalizeBGMConfig: assert result["loop_enabled"] is True assert result["sidechain_enabled"] is False assert result["sidechain_ratio"] == 0.3 + assert result["sidechain_attack"] == 0.02 + assert result["sidechain_release"] == 0.5 + assert result["sidechain_threshold"] == -25.0 def test_volume_clamped(self): - """音量钳制.""" - result = normalize_bgm_config({"volume": 1.5}) + result = normalize_bgm_config({"volume": 2.0}) assert result["volume"] == 1.0 - result2 = normalize_bgm_config({"volume": -0.5}) - assert result2["volume"] == 0.0 + result = normalize_bgm_config({"volume": -1.0}) + assert result["volume"] == 0.0 - def test_fade_in_negative(self): - """淡入为负钳制到 0.""" - result = normalize_bgm_config({"fade_in": -1}) + def test_fade_in_clamped_to_zero(self): + result = normalize_bgm_config({"fade_in": -5}) assert result["fade_in"] == 0.0 - def test_fade_out_negative(self): - """淡出为负钳制到 0.""" - result = normalize_bgm_config({"fade_out": -1}) + def test_fade_out_clamped_to_zero(self): + result = normalize_bgm_config({"fade_out": -5}) 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_loop_enabled_bool_conversion(self): + assert normalize_bgm_config({"loop_enabled": True})["loop_enabled"] is True + assert normalize_bgm_config({"loop_enabled": False})["loop_enabled"] is False + assert normalize_bgm_config({"loop_enabled": 1})["loop_enabled"] is True + assert normalize_bgm_config({"loop_enabled": 0})["loop_enabled"] is False - def test_sidechain_attack_min(self): - """attack 最小值 0.001.""" + def test_sidechain_ratio_clamped(self): + result = normalize_bgm_config({"sidechain_ratio": 2.0}) + assert result["sidechain_ratio"] == 1.0 + result = normalize_bgm_config({"sidechain_ratio": -1.0}) + assert result["sidechain_ratio"] == 0.0 + + def test_sidechain_attack_minimum(self): result = normalize_bgm_config({"sidechain_attack": 0}) assert result["sidechain_attack"] == 0.001 - def test_sidechain_release_min(self): - """release 最小值 0.01.""" + def test_sidechain_release_minimum(self): result = normalize_bgm_config({"sidechain_release": 0}) assert result["sidechain_release"] == 0.01 + def test_sidechain_threshold_pass_through(self): + result = normalize_bgm_config({"sidechain_threshold": -40.0}) + assert result["sidechain_threshold"] == -40.0 + def test_string_values_converted(self): - """字符串数值被转换.""" result = normalize_bgm_config( { "volume": "0.5", - "fade_in": "2.0", + "fade_in": "1.0", + "sidechain_ratio": "0.7", } ) 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 + assert result["fade_in"] == 1.0 + assert result["sidechain_ratio"] == 0.7 -# ───────────────────────────────────────────────────────────────────────────── -# validate_bgm_config 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── validate_bgm_config ───────────────────────────────────────────────────── -class TestValidateBGMConfig: - """配置验证测试.""" - +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 + valid, errors = validate_bgm_config({"volume": 0.3}) + assert valid is True + assert errors == [] - def test_volume_not_number(self): - """volume 不是数字.""" - ok, errors = validate_bgm_config({"volume": "high"}) - assert ok is False + def test_invalid_volume_type(self): + valid, errors = validate_bgm_config({"volume": "abc"}) + assert valid 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 + valid, errors = validate_bgm_config({"volume": -0.1}) + assert valid is False + assert any("volume" in e for e in errors) + valid, errors = validate_bgm_config({"volume": 1.1}) + assert valid 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 + def test_volume_at_boundaries(self): + assert validate_bgm_config({"volume": 0})[0] is True + assert validate_bgm_config({"volume": 1})[0] is True + + def test_invalid_fade_in_type(self): + valid, errors = validate_bgm_config({"fade_in": "abc"}) + assert valid 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 + def test_negative_fade_in(self): + valid, errors = validate_bgm_config({"fade_in": -1}) + assert valid is False + assert any("fade_in" in e for e in errors) + + def test_invalid_fade_out_type(self): + valid, errors = validate_bgm_config({"fade_out": "abc"}) + assert valid is False assert any("fade_out" in e for e in errors) + def test_negative_fade_out(self): + valid, errors = validate_bgm_config({"fade_out": -1}) + assert valid is False + assert any("fade_out" in e for e in errors) + + def test_invalid_sidechain_ratio_type(self): + valid, errors = validate_bgm_config({"sidechain_ratio": "abc"}) + assert valid is False + assert any("sidechain_ratio" 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 + valid, errors = validate_bgm_config({"sidechain_ratio": -0.1}) + assert valid is False + assert any("sidechain_ratio" in e for e in errors) + valid, errors = validate_bgm_config({"sidechain_ratio": 1.1}) + assert valid is False assert any("sidechain_ratio" in e for e in errors) def test_multiple_errors(self): - """多个错误同时报告.""" - ok, errors = validate_bgm_config( + valid, errors = validate_bgm_config( { - "volume": 2.0, + "volume": "bad", "fade_in": -1, - "sidechain_ratio": -0.5, + "sidechain_ratio": 2.0, } ) - assert ok is False + assert valid 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 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── calculate_fade_out_start ──────────────────────────────────────────────── class TestCalculateFadeOutStart: - """淡出开始时间计算测试.""" - def test_normal_case(self): - """正常情况.""" - assert calculate_fade_out_start(100, 3) == pytest.approx(97.0) + assert calculate_fade_out_start(60, 2) == 58.0 def test_zero_fade_out(self): - """淡出时长为 0,返回 None.""" - assert calculate_fade_out_start(100, 0) is None + assert calculate_fade_out_start(60, 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 + assert calculate_fade_out_start(60, -1) is None def test_zero_target(self): - """目标时长为 0,兜底 5 秒.""" - assert estimate_bgm_processing_duration(10, 0, True) == 5.0 + assert calculate_fade_out_start(0, 2) is None def test_negative_target(self): - """目标时长为负,兜底 5 秒.""" - assert estimate_bgm_processing_duration(10, -5, True) == 5.0 + assert calculate_fade_out_start(-5, 2) is None + + def test_fade_longer_than_target(self): + assert calculate_fade_out_start(10, 20) is None + + def test_fade_equal_to_target(self): + assert calculate_fade_out_start(10, 10) is None + + def test_float_values(self): + assert calculate_fade_out_start(60.5, 2.5) == 58.0 -# ───────────────────────────────────────────────────────────────────────────── -# BGMPureConfig 测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── estimate_bgm_processing_duration ──────────────────────────────────────── -class TestBGMPureConfig: - """BGMPureConfig 数据类测试.""" +class TestEstimateBgmProcessingDuration: + def test_bgm_longer_no_loop(self): + assert estimate_bgm_processing_duration(100, 60, loop_enabled=False) == 60.0 - 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_bgm_longer_with_loop(self): + # 够长但允许循环,仍然截断到target + assert estimate_bgm_processing_duration(100, 60, loop_enabled=True) == 60.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 + def test_bgm_shorter_with_loop(self): + assert estimate_bgm_processing_duration(10, 60, loop_enabled=True) == 60.0 + + def test_bgm_shorter_no_loop(self): + # 需要循环但不允许 → 截断到target + assert estimate_bgm_processing_duration(10, 60, loop_enabled=False) == 60.0 + + def test_zero_target_fallback(self): + assert estimate_bgm_processing_duration(100, 0) == 5.0 + + def test_negative_target_fallback(self): + assert estimate_bgm_processing_duration(100, -5) == 5.0 + + def test_equal_duration(self): + assert estimate_bgm_processing_duration(60, 60) == 60.0 diff --git a/tests/unit/test_concat_engine_pure.py b/tests/unit/test_concat_engine_pure.py index 49f21b7f7..2ffd34677 100755 --- a/tests/unit/test_concat_engine_pure.py +++ b/tests/unit/test_concat_engine_pure.py @@ -1,12 +1,12 @@ -"""视频拼接引擎纯逻辑单元测试.""" +"""concat_engine_pure 单元测试.""" -from __future__ import annotations +from pathlib import Path -import pytest -from video_processing.concat_engine_pure import ( +from apps.worker.video_processing.concat_engine_pure import ( build_concat_filter, build_fps_filter, build_scale_pad_filter, + build_setpts_filter, build_single_segment_filter_chain, calculate_scaled_size, can_use_stream_copy, @@ -20,200 +20,203 @@ from video_processing.concat_engine_pure import ( validate_video_path, ) -# ───────────────────────────────────────────────────────────────────────────── -# 帧率解析测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── parse_fps ──────────────────────────────────────────────────────────────── class TestParseFps: - """parse_fps 测试.""" - - def test_integer_fps(self): - """整数帧率.""" - assert parse_fps(30) == 30.0 - - def test_float_fps(self): - """浮点帧率.""" - assert parse_fps(29.97) == pytest.approx(29.97) - - def test_string_integer(self): - """字符串整数.""" - assert parse_fps("30") == 30.0 - - def test_string_fraction(self): - """分数字符串(30/1).""" - assert parse_fps("30/1") == 30.0 - - def test_fraction_24000_1001(self): - """23.976 帧率.""" - result = parse_fps("24000/1001") - assert result == pytest.approx(23.976, rel=0.01) - - def test_none_input(self): - """None 输入返回默认值.""" + def test_none_returns_default(self): assert parse_fps(None) == 30.0 - def test_empty_string(self): - """空字符串返回默认值.""" - assert parse_fps("") == 30.0 + def test_integer_value(self): + assert parse_fps(30) == 30.0 + assert parse_fps(24) == 24.0 - def test_invalid_string(self): - """无效字符串.""" - assert parse_fps("abc") == 30.0 + def test_float_value(self): + assert parse_fps(29.97) == 29.97 + + def test_string_integer(self): + assert parse_fps("30") == 30.0 + assert parse_fps(" 60 ") == 60.0 # 带空格 + + def test_string_fraction(self): + assert parse_fps("30/1") == 30.0 + assert abs(parse_fps("24000/1001") - 23.976) < 0.01 def test_zero_denominator(self): - """分母为 0.""" assert parse_fps("30/0") == 30.0 + def test_empty_string(self): + assert parse_fps("") == 30.0 + assert parse_fps(" ") == 30.0 + + def test_invalid_string(self): + assert parse_fps("abc") == 30.0 + assert parse_fps("30fps") == 30.0 + def test_negative_fps(self): - """负帧率.""" assert parse_fps(-30) == -30.0 + def test_zero_fps(self): + assert parse_fps(0) == 0.0 + + +# ── format_fps_filter ─────────────────────────────────────────────────────── + class TestFormatFpsFilter: - """format_fps_filter 测试.""" - def test_integer_fps(self): - """整数帧率.""" assert format_fps_filter(30.0) == "fps=30" - def test_float_fps(self): - """浮点帧率.""" + def test_near_integer_fps(self): + # 接近整数时用整数形式(注意:int(fps)是截断不是四舍五入) + assert format_fps_filter(30.0001) == "fps=30" + assert format_fps_filter(30.0005) == "fps=30" # int(30.0005)=30 + + def test_non_integer_fps(self): + result = format_fps_filter(23.976) + assert result.startswith("fps=") + assert "23.976" in result + + def test_float_precision(self): result = format_fps_filter(29.97) assert result.startswith("fps=") - assert "29.97" in result + # 三位小数 + parts = result.split("=")[1] + assert len(parts.split(".")[1]) == 3 - def test_near_integer(self): - """接近整数.""" - assert format_fps_filter(30.0001) == "fps=30" + def test_one_fps(self): + assert format_fps_filter(1.0) == "fps=1" -# ───────────────────────────────────────────────────────────────────────────── -# 输出参数计算测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── resolve_output_params ─────────────────────────────────────────────────── class TestResolveOutputParams: - """resolve_output_params 测试.""" - - def test_all_specified(self): - """全部显式指定.""" + def test_config_specified(self): w, h, fps = resolve_output_params(1920, 1080, 60.0) assert w == 1920 assert h == 1080 assert fps == 60.0 - def test_no_specified_use_defaults(self): - """全部未指定,用默认值.""" - w, h, fps = resolve_output_params(0, 0, 0) - assert w == 1080 - assert h == 1920 - assert fps == 30.0 - - def test_use_first_video_info(self): - """用第一段视频信息.""" + def test_fallback_to_first_video_info(self): info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"} w, h, fps = resolve_output_params(0, 0, 0, info) assert w == 1280 assert h == 720 assert fps == 24.0 - def test_partial_specified(self): - """部分指定,未指定的用探测值.""" + def test_fallback_to_defaults(self): + w, h, fps = resolve_output_params(0, 0, 0) + assert w == 1080 # default_width + assert h == 1920 # default_height + assert fps == 30.0 + + def test_partial_config(self): + # 宽度配置了,高度和帧率用探测的 info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"} w, h, fps = resolve_output_params(1920, 0, 0, info) - assert w == 1920 # 指定的 - assert h == 720 # 探测的 + assert w == 1920 + assert h == 720 assert fps == 24.0 - def test_zero_size_clamped(self): - """零尺寸被钳制.""" - w, h, fps = resolve_output_params(0, 0, 0, {}) - assert w >= 1 - assert h >= 1 - assert fps >= 1.0 - def test_custom_defaults(self): - """自定义默认值.""" - w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0) + w, h, fps = resolve_output_params( + 0, + 0, + 0, + default_width=640, + default_height=480, + default_fps=25.0, + ) assert w == 640 assert h == 480 assert fps == 25.0 + def test_minimum_size(self): + w, h, fps = resolve_output_params(0, 0, 0, {"width": 0, "height": 0, "r_frame_rate": "0/1"}) + assert w >= 1 + assert h >= 1 + assert fps >= 1.0 + + def test_fps_fraction_in_info(self): + info = {"width": 1920, "height": 1080, "r_frame_rate": "24000/1001"} + _, _, fps = resolve_output_params(0, 0, 0, info) + assert abs(fps - 23.976) < 0.01 + + +# ── calculate_scaled_size ─────────────────────────────────────────────────── + class TestCalculateScaledSize: - """calculate_scaled_size 测试.""" - def test_same_ratio(self): - """比例相同.""" sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080) assert sw == 1920 assert sh == 1080 assert ox == 0 assert oy == 0 - def test_wider_source(self): - """源更宽,上下填黑边.""" + def test_wider_source_pad_top_bottom(self): + # 源是16:9,目标是9:16竖屏 → 上下填黑边 sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920) assert sw == 1080 # 以宽度为准 - assert sh < 1920 # 高度按比例 + assert sh == 607 # 1080 * 1080 / 1920 = 607.5 → 607 assert ox == 0 assert oy > 0 # 垂直居中 - def test_taller_source(self): - """源更高,左右填黑边.""" + def test_taller_source_pad_left_right(self): + # 源是9:16竖屏,目标是16:9横屏 → 左右填黑边 sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080) assert sh == 1080 # 以高度为准 - assert sw < 1920 # 宽度按比例 + assert sw == 607 # 1080 * 1080 / 1920 = 607.5 → 607 assert ox > 0 # 水平居中 assert oy == 0 - def test_zero_source(self): - """零尺寸源.""" - sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100) - assert sw == 100 - assert sh == 100 - - def test_scale_down(self): - """缩小.""" - sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360) - assert sw == 640 - assert sh == 360 + def test_zero_source_size(self): + sw, sh, ox, oy = calculate_scaled_size(0, 0, 1920, 1080) + assert sw == 1920 + assert sh == 1080 assert ox == 0 assert oy == 0 - def test_scale_up(self): - """放大.""" + def test_negative_source_size(self): + sw, sh, ox, oy = calculate_scaled_size(-1, -1, 1920, 1080) + assert sw == 1920 + assert sh == 1080 + assert ox == 0 + assert oy == 0 + + def test_target_same_ratio_different_size(self): + # 比例相同,尺寸不同 → 直接缩放到目标大小 sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080) assert sw == 1920 assert sh == 1080 + assert ox == 0 + assert oy == 0 -# ───────────────────────────────────────────────────────────────────────────── -# stream copy 判断测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── can_use_stream_copy ───────────────────────────────────────────────────── class TestCanUseStreamCopy: - """can_use_stream_copy 测试.""" + def test_force_reencode_false(self): + assert can_use_stream_copy([], 1920, 1080, 30.0, force_reencode=True) is False - def test_identical_segments(self): - """所有段参数相同,可以 stream copy.""" + def test_empty_segments(self): + assert can_use_stream_copy([], 1920, 1080, 30.0) is False + + def test_single_segment_matching_params(self): + segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}] + assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True + + def test_multiple_segments_same_params(self): segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, + {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, ] assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True - def test_force_reencode(self): - """强制重编码.""" - segs = [ - {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, - ] - assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False - def test_different_codec(self): - """编码不同.""" segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, {"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, @@ -221,7 +224,6 @@ class TestCanUseStreamCopy: assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False def test_different_resolution(self): - """分辨率不同.""" segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, {"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"}, @@ -229,306 +231,349 @@ class TestCanUseStreamCopy: assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False def test_different_fps(self): - """帧率不同.""" segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"}, ] assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False - def test_target_differs(self): - """目标参数与源不同.""" - segs = [ - {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, - ] - assert can_use_stream_copy(segs, 1280, 720, 30.0) is False - - def test_empty_segments(self): - """空列表.""" - assert can_use_stream_copy([], 1920, 1080, 30.0) is False - - def test_single_segment(self): - """单段.""" + def test_target_differs_from_source(self): segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}] - assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True + # 目标分辨率不同 + assert can_use_stream_copy(segs, 1280, 720, 30.0) is False + # 目标帧率不同 + assert can_use_stream_copy(segs, 1920, 1080, 60.0) is False + + def test_fps_fraction_match(self): + segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}] + assert can_use_stream_copy(segs, 1920, 1080, 23.976) is True -# ───────────────────────────────────────────────────────────────────────────── -# 文件列表生成测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── generate_concat_file_list ─────────────────────────────────────────────── class TestGenerateConcatFileList: - """generate_concat_file_list 测试.""" - def test_single_file(self): - """单个文件.""" - result = generate_concat_file_list(["/a.mp4"]) - assert "file '/a.mp4'" in result - assert result.endswith("\n") + result = generate_concat_file_list(["/tmp/video.mp4"]) + assert result == "file '/tmp/video.mp4'\n" def test_multiple_files(self): - """多个文件.""" result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"]) lines = result.strip().split("\n") assert len(lines) == 3 assert lines[0] == "file '/a.mp4'" assert lines[1] == "file '/b.mp4'" assert lines[2] == "file '/c.mp4'" + assert result.endswith("\n") + + def test_escapes_single_quotes(self): + result = generate_concat_file_list(["/path/with'quote.mp4"]) + # 单引号转义: '\'' + assert "'\\''" in result def test_empty_list(self): - """空列表.""" result = generate_concat_file_list([]) assert result == "\n" - def test_path_with_single_quote(self): - """路径包含单引号(转义).""" - result = generate_concat_file_list(["/path/to/file's.mp4"]) - # 单引号应该被转义 - assert "'\\''" in result or file - assert "file '" in result - def test_path_with_spaces(self): - """路径包含空格.""" - result = generate_concat_file_list(["/path/to/my video.mp4"]) - assert "my video" in result + result = generate_concat_file_list(["/path/to/video file.mp4"]) + assert "file '/path/to/video file.mp4'" in result -# ───────────────────────────────────────────────────────────────────────────── -# 滤镜构建测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── build_scale_pad_filter ────────────────────────────────────────────────── class TestBuildScalePadFilter: - """scale+pad 滤镜测试.""" - - def test_contains_scale(self): - """包含 scale.""" - result = build_scale_pad_filter(1920, 1080) - assert "scale=" in result - - def test_contains_pad(self): - """包含 pad.""" - result = build_scale_pad_filter(1920, 1080) - assert "pad=" in result - assert "1920:1080" in result - - def test_force_original_aspect_ratio(self): - """保持宽高比.""" + def test_basic_filter(self): result = build_scale_pad_filter(1920, 1080) + assert "scale=1920:1080" in result assert "force_original_aspect_ratio=decrease" in result + assert "pad=1920:1080" in result + assert "black" in result + assert "(ow-iw)/2" in result + assert "(oh-ih)/2" in result - def test_black_padding(self): - """黑边填充.""" - result = build_scale_pad_filter(1920, 1080) - assert ":black" in result + def test_different_resolution(self): + result = build_scale_pad_filter(1080, 1920) + assert "scale=1080:1920" in result + assert "pad=1080:1920" in result + + def test_ignores_source_size(self): + # src_w/src_h 目前不影响输出,都是用表达式 + result1 = build_scale_pad_filter(1920, 1080) + result2 = build_scale_pad_filter(1920, 1080, src_w=1280, src_h=720) + assert result1 == result2 + + +# ── build_fps_filter ──────────────────────────────────────────────────────── class TestBuildFpsFilter: - """fps 滤镜测试.""" - def test_integer_fps(self): - """整数帧率.""" assert build_fps_filter(30.0) == "fps=30" def test_float_fps(self): - """浮点帧率.""" result = build_fps_filter(29.97) assert result.startswith("fps=") -class TestBuildConcatFilter: - """concat 滤镜测试.""" +# ── build_setpts_filter ───────────────────────────────────────────────────── - def test_two_inputs_with_audio(self): - """两路输入,有音频.""" - result = build_concat_filter(2, has_audio=True) - assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result + +class TestBuildSetptsFilter: + def test_returns_correct_string(self): + assert build_setpts_filter() == "setpts=PTS-STARTPTS" + + +# ── build_concat_filter ───────────────────────────────────────────────────── + + +class TestBuildConcatFilter: + def test_zero_inputs(self): + assert build_concat_filter(0) == "" + + def test_single_input_with_audio(self): + result = build_concat_filter(1) + assert "[0:v][0:a]" in result + assert "concat=n=1:v=1:a=1" in result assert "[concat_v][concat_a]" in result - def test_three_inputs_video_only(self): - """三路输入,无音频.""" - result = build_concat_filter(3, has_audio=False) - assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result + def test_single_input_no_audio(self): + result = build_concat_filter(1, has_audio=False) + assert "[0:v]" in result + assert "concat=n=1:v=1:a=0" in result assert "[concat_v]" in result + assert "[concat_a]" not in result - def test_single_input(self): - """单路输入.""" - result = build_concat_filter(1, has_audio=True) - assert "[0:v][0:a]concat=n=1:v=1:a=1" in result + def test_multiple_inputs_with_audio(self): + result = build_concat_filter(3) + assert "[0:v][0:a][1:v][1:a][2:v][2:a]" in result + assert "concat=n=3:v=1:a=1" in result - def test_zero_inputs(self): - """零输入.""" - assert build_concat_filter(0) == "" + def test_multiple_inputs_no_audio(self): + result = build_concat_filter(3, has_audio=False) + assert "[0:v][1:v][2:v]" in result + assert "concat=n=3:v=1:a=0" in result + + def test_negative_inputs(self): + assert build_concat_filter(-1) == "" + + +# ── build_single_segment_filter_chain ─────────────────────────────────────── class TestBuildSingleSegmentFilterChain: - """单段滤镜链测试.""" - def test_with_audio(self): - """有音频.""" result = build_single_segment_filter_chain(1920, 1080, 30.0, 0) - assert "scale=" in result - assert "fps=" in result - assert "setpts=PTS-STARTPTS" in result - assert "asetpts=PTS-STARTPTS" in result + # 视频链 + assert "[0:v]" in result assert "[v0]" in result + assert "scale=1920:1080" in result + assert "fps=30" in result + assert "setpts=PTS-STARTPTS" in result + # 音频链 + assert "[0:a]" in result assert "[a0]" in result + assert "asetpts=PTS-STARTPTS" in result + # 用分号分隔 + assert ";" in result - def test_video_only(self): - """无音频.""" - result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False) - assert "scale=" in result - assert "setpts=" in result - assert "asetpts" not in result - assert "[v1]" in result + def test_without_audio(self): + result = build_single_segment_filter_chain(1920, 1080, 30.0, 2, has_audio=False) + assert "[2:v]" in result + assert "[v2]" in result + assert "[2:a]" not in result + assert ";" not in result # 没有音频就没有分号 - def test_segment_index_in_labels(self): - """段索引在标签中.""" - result = build_single_segment_filter_chain(1920, 1080, 30.0, 5) - assert "[5:v]" in result - assert "[v5]" in result + def test_segment_index_propagated(self): + for idx in [0, 5, 10]: + result = build_single_segment_filter_chain(1920, 1080, 30.0, idx) + assert f"[{idx}:v]" in result + assert f"[v{idx}]" in result -# ───────────────────────────────────────────────────────────────────────────── -# 配置验证测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── validate_concat_config ────────────────────────────────────────────────── class TestValidateConcatConfig: - """配置验证测试.""" - def test_valid_config(self): - """合法配置.""" config = { - "segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}], + "segments": [ + {"video_path": "/a.mp4"}, + {"video_path": "/b.mp4"}, + ], "output_width": 1920, "output_height": 1080, "output_fps": 30, } - ok, errors = validate_concat_config(config) - assert ok is True - assert len(errors) == 0 + valid, errors = validate_concat_config(config) + assert valid is True + assert errors == [] + + def test_no_segments(self): + valid, errors = validate_concat_config({}) + assert valid is False + assert any("至少需要一个" in e for e in errors) def test_empty_segments(self): - """空段列表.""" - ok, errors = validate_concat_config({"segments": []}) - assert ok is False - assert any("至少需要" in e or "视频段" in e for e in errors) + valid, errors = validate_concat_config({"segments": []}) + assert valid is False + assert len(errors) >= 1 def test_missing_video_path(self): - """缺少 video_path.""" - config = {"segments": [{"video_path": "/a.mp4"}, {}]} - ok, errors = validate_concat_config(config) - assert ok is False + config = {"segments": [{"video_path": ""}]} + valid, errors = validate_concat_config(config) + assert valid is False assert any("video_path" in e for e in errors) - def test_negative_width(self): - """负宽度.""" - config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100} - ok, errors = validate_concat_config(config) - assert ok is False + def test_multiple_missing_paths(self): + config = { + "segments": [ + {"video_path": "/a.mp4"}, + {"video_path": ""}, + {"video_path": ""}, + ] + } + valid, errors = validate_concat_config(config) + assert valid is False + path_errors = [e for e in errors if "video_path" in e] + assert len(path_errors) == 2 + + def test_negative_output_width(self): + config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -1} + valid, errors = validate_concat_config(config) + assert valid is False assert any("output_width" in e for e in errors) - def test_negative_height(self): - """负高度.""" - config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100} - ok, errors = validate_concat_config(config) - assert ok is False + def test_negative_output_height(self): + config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -1} + valid, errors = validate_concat_config(config) + assert valid is False assert any("output_height" in e for e in errors) - def test_negative_fps(self): - """负帧率.""" - config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30} - ok, errors = validate_concat_config(config) - assert ok is False + def test_negative_output_fps(self): + config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -1} + valid, errors = validate_concat_config(config) + assert valid is False assert any("output_fps" in e for e in errors) - def test_zero_output_params_ok(self): - """零输出参数合法(表示自动探测).""" - config = {"segments": [{"video_path": "/a.mp4"}]} - ok, errors = validate_concat_config(config) - assert ok is True + def test_zero_output_params_valid(self): + # 0值表示未指定,是合法的 + config = { + "segments": [{"video_path": "/a.mp4"}], + "output_width": 0, + "output_height": 0, + "output_fps": 0, + } + valid, errors = validate_concat_config(config) + assert valid is True -# ───────────────────────────────────────────────────────────────────────────── -# 路径验证测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── validate_video_path ───────────────────────────────────────────────────── class TestValidateVideoPath: - """视频路径验证测试.""" - def test_empty_path(self): - """空路径.""" - ok, msg = validate_video_path("", "/work") - assert ok is False - assert "不能为空" in msg + valid, err = validate_video_path("", "/work") + assert valid is False + assert "不能为空" in err - def test_path_traversal(self): - """路径遍历.""" - ok, msg = validate_video_path("../etc/passwd", "/work") - assert ok is False - assert "回溯" in msg or ".." in msg + def test_relative_path_valid(self): + valid, err = validate_video_path("video.mp4", "/work") + assert valid is True + assert err == "" - def test_valid_relative_path(self): - """相对路径(不检查边界).""" - ok, msg = validate_video_path("video.mp4", "/work") - assert ok is True + def test_relative_path_with_subdir(self): + valid, err = validate_video_path("sub/video.mp4", "/work") + assert valid is True - def test_valid_absolute_path(self): - """绝对路径在工作目录内.""" - ok, msg = validate_video_path("/work/sub/video.mp4", "/work") - assert ok is True + def test_path_traversal_rejected(self): + valid, err = validate_video_path("../secret.mp4", "/work") + assert valid is False + assert ".." in err - def test_path_outside_work_dir(self): - """路径在工作目录外.""" - ok, msg = validate_video_path("/etc/passwd", "/work") - assert ok is False - assert "工作目录" in msg + def test_nested_path_traversal_rejected(self): + valid, err = validate_video_path("sub/../../secret.mp4", "/work") + assert valid is False + + def test_absolute_path_inside_workdir(self): + valid, err = validate_video_path("/work/sub/video.mp4", "/work") + assert valid is True + + def test_absolute_path_outside_workdir(self): + valid, err = validate_video_path("/etc/passwd", "/work") + assert valid is False + assert "工作目录内" in err + + def test_path_object_input(self): + valid, err = validate_video_path(Path("video.mp4"), Path("/work")) + assert valid is True -# ───────────────────────────────────────────────────────────────────────────── -# 工具函数测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── estimate_total_duration ───────────────────────────────────────────────── class TestEstimateTotalDuration: - """总时长估算测试.""" + def test_single_segment(self): + assert estimate_total_duration([{"duration": 10.5}]) == 10.5 def test_multiple_segments(self): - """多段视频.""" - segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}] - assert estimate_total_duration(segs) == pytest.approx(35.5) + segs = [ + {"duration": 10}, + {"duration": 20.5}, + {"duration": 5.5}, + ] + assert estimate_total_duration(segs) == 36.0 def test_empty_list(self): - """空列表.""" assert estimate_total_duration([]) == 0.0 - def test_invalid_duration_skipped(self): - """无效时长跳过.""" - segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}] - assert estimate_total_duration(segs) == pytest.approx(30.0) + def test_missing_duration_field(self): + segs = [{"path": "a.mp4"}, {"duration": 10}] + assert estimate_total_duration(segs) == 10.0 - def test_missing_duration(self): - """缺 duration 字段.""" - segs = [{}, {"duration": 10}] - assert estimate_total_duration(segs) == pytest.approx(10.0) + def test_invalid_duration_skipped(self): + segs = [ + {"duration": 10}, + {"duration": "abc"}, + {"duration": 20}, + ] + assert estimate_total_duration(segs) == 30.0 + + def test_string_duration(self): + segs = [{"duration": "15.5"}] + assert estimate_total_duration(segs) == 15.5 + + def test_negative_duration(self): + segs = [{"duration": -5}] + assert estimate_total_duration(segs) == -5.0 + + +# ── count_valid_segments ──────────────────────────────────────────────────── class TestCountValidSegments: - """有效段统计测试.""" - def test_all_valid(self): - """全部有效.""" - segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}] + segs = [ + {"video_path": "/a.mp4"}, + {"video_path": "/b.mp4"}, + ] assert count_valid_segments(segs) == 2 def test_some_invalid(self): - """部分无效.""" - segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}] - assert count_valid_segments(segs) == 1 + segs = [ + {"video_path": "/a.mp4"}, + {"video_path": ""}, + {"video_path": "/c.mp4"}, + ] + assert count_valid_segments(segs) == 2 + + def test_none_valid(self): + segs = [ + {"video_path": ""}, + {"other_field": "x"}, + ] + assert count_valid_segments(segs) == 0 def test_empty_list(self): - """空列表.""" assert count_valid_segments([]) == 0 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 diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 1d7c55e30..06adbe903 100755 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -1,9 +1,6 @@ -"""通用分页器单元测试.""" - -from __future__ import annotations +"""pagination 单元测试.""" import pytest -from pydantic import ValidationError from packages.application.common.pagination import ( PaginatedResponse, @@ -12,377 +9,171 @@ from packages.application.common.pagination import ( paginate, ) +# ── PaginationParams ──────────────────────────────────────────────────────── + class TestPaginationParams: - """PaginationParams 测试""" - def test_default_values(self): - """默认值正确""" params = PaginationParams() assert params.page == 1 assert params.page_size == 20 - def test_offset_first_page(self): - """第一页 offset 为 0""" + def test_custom_values(self): + params = PaginationParams(page=3, page_size=50) + assert params.page == 3 + assert params.page_size == 50 + + def test_offset_calculation(self): params = PaginationParams(page=1, page_size=20) assert params.offset == 0 - def test_offset_second_page(self): - """第二页 offset 计算正确""" - params = PaginationParams(page=2, page_size=20) - assert params.offset == 20 + params = PaginationParams(page=3, page_size=20) + assert params.offset == 40 - def test_offset_custom_page_size(self): - """自定义 page_size 的 offset""" - params = PaginationParams(page=3, page_size=10) - assert params.offset == 20 + params = PaginationParams(page=10, page_size=50) + assert params.offset == 450 def test_limit_equals_page_size(self): - """limit 等于 page_size""" - params = PaginationParams(page_size=50) - assert params.limit == 50 + params = PaginationParams(page_size=30) + assert params.limit == 30 def test_page_must_be_at_least_1(self): - """page 不能小于 1""" - with pytest.raises(ValidationError): + with pytest.raises(ValueError): PaginationParams(page=0) - def test_page_negative_raises(self): - """page 不能为负数""" - with pytest.raises(ValidationError): - PaginationParams(page=-1) - def test_page_size_must_be_at_least_1(self): - """page_size 不能小于 1""" - with pytest.raises(ValidationError): + with pytest.raises(ValueError): PaginationParams(page_size=0) def test_page_size_max_100(self): - """page_size 最大 100""" - with pytest.raises(ValidationError): + with pytest.raises(ValueError): PaginationParams(page_size=101) - def test_page_size_100_is_valid(self): - """page_size=100 是合法的""" - params = PaginationParams(page_size=100) - assert params.page_size == 100 + +# ── PaginationMeta ────────────────────────────────────────────────────────── class TestPaginationMeta: - """PaginationMeta 测试""" - def test_from_params_first_page(self): - """第一页元数据""" params = PaginationParams(page=1, page_size=10) meta = PaginationMeta.from_params(params, total=25) - assert meta.page == 1 assert meta.page_size == 10 assert meta.total == 25 - assert meta.total_pages == 3 + assert meta.total_pages == 3 # ceil(25/10) assert meta.has_next is True assert meta.has_prev is False def test_from_params_last_page(self): - """最后一页元数据""" params = PaginationParams(page=3, page_size=10) meta = PaginationMeta.from_params(params, total=25) - assert meta.page == 3 assert meta.total_pages == 3 assert meta.has_next is False assert meta.has_prev is True def test_from_params_middle_page(self): - """中间页元数据""" params = PaginationParams(page=2, page_size=10) - meta = PaginationMeta.from_params(params, total=50) - - assert meta.page == 2 - assert meta.total_pages == 5 + meta = PaginationMeta.from_params(params, total=25) assert meta.has_next is True assert meta.has_prev is True + def test_from_params_single_page(self): + params = PaginationParams(page=1, page_size=20) + meta = PaginationMeta.from_params(params, total=5) + assert meta.total_pages == 1 + assert meta.has_next is False + assert meta.has_prev is False + def test_from_params_zero_total(self): - """总数为 0 时""" params = PaginationParams(page=1, page_size=20) meta = PaginationMeta.from_params(params, total=0) - assert meta.total == 0 assert meta.total_pages == 0 assert meta.has_next is False assert meta.has_prev is False - def test_from_params_exact_multiple(self): - """总数刚好是 page_size 的整数倍""" + def test_from_params_exact_page_size(self): params = PaginationParams(page=1, page_size=10) - meta = PaginationMeta.from_params(params, total=30) - - assert meta.total_pages == 3 - - def test_from_params_single_page(self): - """单页即可放下所有数据""" - params = PaginationParams(page=1, page_size=100) - meta = PaginationMeta.from_params(params, total=50) - + meta = PaginationMeta.from_params(params, total=10) assert meta.total_pages == 1 - assert meta.has_next is False - assert meta.has_prev is False + + def test_from_params_one_extra(self): + params = PaginationParams(page=1, page_size=10) + meta = PaginationMeta.from_params(params, total=11) + assert meta.total_pages == 2 + + +# ── PaginatedResponse ─────────────────────────────────────────────────────── class TestPaginatedResponse: - """PaginatedResponse 测试""" - - def test_create_success(self): - """创建分页响应""" - params = PaginationParams(page=1, page_size=10) - data = [1, 2, 3] - - response = PaginatedResponse.create(data, params, total=25) - - assert response.data == [1, 2, 3] + def test_create_response(self): + params = PaginationParams(page=1, page_size=5) + data = [1, 2, 3, 4, 5] + response = PaginatedResponse.create(data, params, total=15) + assert response.data == data assert response.pagination.page == 1 - assert response.pagination.total == 25 + assert response.pagination.total == 15 assert response.pagination.total_pages == 3 - def test_create_empty_data(self): - """空数据分页响应""" - params = PaginationParams(page=1, page_size=20) - response = PaginatedResponse.create([], params, total=0) - assert response.data == [] - assert response.pagination.total == 0 - assert response.pagination.total_pages == 0 +# ── paginate function ─────────────────────────────────────────────────────── -class TestPaginateFunction: - """paginate 函数测试(内存分页)""" - +class TestPaginate: def test_first_page(self): - """第一页分页""" items = list(range(30)) params = PaginationParams(page=1, page_size=10) - result = paginate(items, params) - assert result.data == list(range(10)) assert result.pagination.total == 30 assert result.pagination.total_pages == 3 assert result.pagination.has_next is True assert result.pagination.has_prev is False - def test_second_page(self): - """第二页分页""" - items = list(range(30)) - params = PaginationParams(page=2, page_size=10) - - result = paginate(items, params) - - assert result.data == list(range(10, 20)) - assert result.pagination.page == 2 - def test_last_page(self): - """最后一页分页""" items = list(range(25)) params = PaginationParams(page=3, page_size=10) - result = paginate(items, params) - assert result.data == list(range(20, 25)) assert len(result.data) == 5 assert result.pagination.has_next is False + assert result.pagination.has_prev is True - def test_empty_list(self): - """空列表分页""" - params = PaginationParams(page=1, page_size=20) - result = paginate([], params) - - assert result.data == [] - assert result.pagination.total == 0 - assert result.pagination.total_pages == 0 - - def test_page_beyond_total(self): - """页码超出总数""" + def test_single_page(self): items = list(range(5)) - params = PaginationParams(page=10, page_size=10) - - result = paginate(items, params) - - assert result.data == [] - assert result.pagination.total == 5 - assert result.pagination.total_pages == 1 - - def test_custom_page_size(self): - """自定义每页数量""" - items = list(range(100)) - params = PaginationParams(page=1, page_size=50) - - result = paginate(items, params) - - assert len(result.data) == 50 - assert result.pagination.total_pages == 2 - - def test_single_item(self): - """单条数据""" - items = ["only_one"] params = PaginationParams(page=1, page_size=10) - result = paginate(items, params) - - assert result.data == ["only_one"] - assert result.pagination.total == 1 - assert result.pagination.total_pages == 1 - - def test_generic_type_preserved(self): - """泛型类型数据正确""" - items = [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}] - params = PaginationParams(page=1, page_size=10) - - result = paginate(items, params) - - assert len(result.data) == 2 - assert result.data[0]["id"] == 1 - - -# ── PaginationParams 补充边界 ─────────────────────────────────────────────── - - -class TestPaginationParamsEdgeCases: - """PaginationParams 补充边界场景.""" - - def test_page_size_1_minimum(self): - """page_size=1 是允许的最小值.""" - params = PaginationParams(page_size=1) - assert params.page_size == 1 - assert params.limit == 1 - - def test_page_size_100_maximum(self): - """page_size=100 是允许的最大值.""" - params = PaginationParams(page_size=100) - assert params.page_size == 100 - - def test_offset_page_1_size_100(self): - """第1页每页100条 offset=0.""" - params = PaginationParams(page=1, page_size=100) - assert params.offset == 0 - - def test_offset_page_100_size_100(self): - """第100页每页100条 offset=9900.""" - params = PaginationParams(page=100, page_size=100) - assert params.offset == 9900 - - def test_large_page_number_accepted(self): - """极大页码(超过实际页数)允许.""" - params = PaginationParams(page=999999, page_size=20) - assert params.page == 999999 - assert params.offset == (999999 - 1) * 20 - - -# ── PaginationMeta 补充边界 ───────────────────────────────────────────────── - - -class TestPaginationMetaEdgeCases: - """PaginationMeta 补充边界场景.""" - - def test_total_0_page_1(self): - """total=0, page=1 时 total_pages=0, 无上下页.""" - params = PaginationParams(page=1, page_size=20) - meta = PaginationMeta.from_params(params, total=0) - assert meta.total_pages == 0 - assert meta.has_next is False - assert meta.has_prev is False - - def test_total_0_page_beyond(self): - """total=0, page>1 时 has_prev=True(因为page>1).""" - params = PaginationParams(page=3, page_size=20) - meta = PaginationMeta.from_params(params, total=0) - assert meta.total_pages == 0 - assert meta.has_next is False - assert meta.has_prev is True - - def test_exact_last_page(self): - """刚好是最后一页时 has_next=False.""" - params = PaginationParams(page=5, page_size=10) - meta = PaginationMeta.from_params(params, total=50) - assert meta.total_pages == 5 - assert meta.has_next is False - assert meta.has_prev is True - - def test_one_more_than_exact(self): - """比整数页多1条时总页数+1.""" - params = PaginationParams(page=1, page_size=10) - meta = PaginationMeta.from_params(params, total=51) - assert meta.total_pages == 6 - - def test_page_exactly_total_pages(self): - """page == total_pages 时 has_next=False.""" - params = PaginationParams(page=3, page_size=10) - meta = PaginationMeta.from_params(params, total=30) - assert meta.has_next is False - - def test_total_1_page_1_size_1(self): - """1条数据1页.""" - params = PaginationParams(page=1, page_size=1) - meta = PaginationMeta.from_params(params, total=1) - assert meta.total_pages == 1 - assert meta.has_next is False - assert meta.has_prev is False - - -# ── paginate 补充边界 ────────────────────────────────────────────────────── - - -class TestPaginateEdgeCases: - """paginate 补充边界场景.""" - - def test_single_item_list(self): - """单元素列表.""" - result = paginate([42], PaginationParams(page=1, page_size=10)) - assert result.data == [42] - assert result.pagination.total == 1 - assert result.pagination.total_pages == 1 - - def test_page_exactly_last(self): - """刚好在最后一页.""" - items = list(range(25)) - result = paginate(items, PaginationParams(page=3, page_size=10)) - assert result.data == list(range(20, 25)) - assert result.pagination.has_next is False - - def test_page_past_end_returns_empty(self): - """页码超过总数返回空.""" - items = list(range(5)) - result = paginate(items, PaginationParams(page=10, page_size=10)) - assert result.data == [] - assert result.pagination.total == 5 - - def test_empty_list_page_1(self): - """空列表第1页.""" - result = paginate([], PaginationParams(page=1, page_size=10)) - assert result.data == [] - assert result.pagination.total == 0 - assert result.pagination.total_pages == 0 - - def test_page_size_1_iterates_all(self): - """page_size=1 时每页1条.""" - items = ["a", "b", "c"] - r1 = paginate(items, PaginationParams(page=1, page_size=1)) - r2 = paginate(items, PaginationParams(page=2, page_size=1)) - r3 = paginate(items, PaginationParams(page=3, page_size=1)) - assert r1.data == ["a"] - assert r2.data == ["b"] - assert r3.data == ["c"] - - def test_does_not_mutate_input(self): - """不修改输入列表.""" - items = [1, 2, 3, 4, 5] - original = items[:] - paginate(items, PaginationParams(page=1, page_size=2)) - assert items == original - - def test_page_size_greater_than_total(self): - """每页条数大于总数.""" - items = list(range(5)) - result = paginate(items, PaginationParams(page=1, page_size=100)) assert result.data == items assert result.pagination.total_pages == 1 + + def test_empty_list(self): + items = [] + params = PaginationParams(page=1, page_size=10) + result = paginate(items, params) + assert result.data == [] + assert result.pagination.total == 0 + assert result.pagination.total_pages == 0 + + def test_page_beyond_end(self): + items = list(range(5)) + params = PaginationParams(page=10, page_size=10) + result = paginate(items, params) + assert result.data == [] + assert result.pagination.total == 5 + + def test_page_size_larger_than_items(self): + items = list(range(5)) + params = PaginationParams(page=1, page_size=100) + result = paginate(items, params) + assert result.data == items + assert result.pagination.total_pages == 1 + + def test_middle_page(self): + items = list(range(100)) + params = PaginationParams(page=5, page_size=10) + result = paginate(items, params) + assert result.data == list(range(40, 50)) + assert result.pagination.has_next is True + assert result.pagination.has_prev is True diff --git a/tests/unit/test_pip_engine_pure.py b/tests/unit/test_pip_engine_pure.py index f04f61fb4..bac5d0542 100755 --- a/tests/unit/test_pip_engine_pure.py +++ b/tests/unit/test_pip_engine_pure.py @@ -1,15 +1,10 @@ -"""PiP Engine 纯逻辑单测. - -测试 pip_engine_pure.py 中的所有纯函数, -0 FFmpeg 依赖,快速轻量。 -""" - -from __future__ import annotations +"""pip_engine_pure 单元测试.""" from pathlib import Path import pytest -from video_processing.pip_engine_pure import ( + +from apps.worker.video_processing.pip_engine_pure import ( build_animation_filters, build_enable_expr, build_overlay_expr, @@ -21,7 +16,6 @@ from video_processing.pip_engine_pure import ( sort_layers_by_z_index, validate_pip_layer, ) - from packages.domain.pip_config import ( ANIMATION_FADE, ANIMATION_SLIDE_BOTTOM, @@ -31,936 +25,656 @@ from packages.domain.pip_config import ( PiPLayerConfig, ) -# ── 常量与工具 ──────────────────────────────────────────────────────────────── -OUTPUT_W = 1080 -OUTPUT_H = 1920 +def _make_layer(**kwargs): + """快速创建 PiPLayerConfig.""" + layer = PiPLayerConfig() + for k, v in kwargs.items(): + setattr(layer, k, v) + return layer -def _make_layer(**kwargs) -> PiPLayerConfig: - """快速创建图层配置.""" - defaults = dict( - source_type="local_path", - source="/tmp/test.mp4", - width="25%", - height=None, - position="bottom_right", - margin=20, - opacity=1.0, - corner_radius=0, - border_width=0, - border_color="black", - z_index=0, - start_time=0.0, - duration=None, - animation_in=None, - animation_out=None, - animation_duration=0.5, - ) - defaults.update(kwargs) - return PiPLayerConfig(**defaults) - - -# ═══════════════════════════════════════════════════════════════════════════════ -# compute_pip_size -# ═══════════════════════════════════════════════════════════════════════════════ +# ── compute_pip_size ──────────────────────────────────────────────────────── class TestComputePipSize: - """尺寸计算测试.""" - def test_percentage_width_auto_height(self): - """百分比宽度,自动高度(16:9).""" - layer = _make_layer(width="25%") - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == 270 # 1080 * 25% - assert h == 151 # 270 * 9 / 16 = 151.875 → 151 + layer = _make_layer(width="30%") + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 576 # 1920 * 0.3 + assert h == int(576 * 9 / 16) # 按16:9等比 def test_pixel_width_and_height(self): - """像素宽高.""" - layer = _make_layer(width=300, height=200) - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == 300 - assert h == 200 + layer = _make_layer(width="400", height="300") + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 400 + assert h == 300 - def test_pixel_width_percent_height(self): - """像素宽 + 百分比高.""" - layer = _make_layer(width=200, height="10%") - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == 200 - assert h == 192 # 1920 * 10% + def test_int_width_and_height(self): + layer = _make_layer(width=500, height=400) + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 500 + assert h == 400 - def test_full_width_clamped(self): - """超过输出尺寸时钳制到输出范围内.""" + def test_width_exceeds_output_clamped(self): layer = _make_layer(width="200%") - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == OUTPUT_W - assert h <= OUTPUT_H # 按比例后高度不超过输出 + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 1920 + assert h <= 1080 - def test_zero_width_minimum(self): - """极小尺寸钳制到至少 1 像素.""" - layer = _make_layer(width="0%") - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) + def test_height_exceeds_output_clamped(self): + layer = _make_layer(width="100", height="200%") + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 100 + assert h == 1080 + + def test_minimum_size(self): + layer = _make_layer(width="0", height="0") + w, h = compute_pip_size(layer, 1920, 1080) assert w >= 1 assert h >= 1 - def test_pixel_int_width(self): - """整数像素宽度.""" - layer = _make_layer(width=500, height=300) - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == 500 - assert h == 300 + def test_empty_height_auto_ratio(self): + layer = _make_layer(width="320", height="") + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 320 + assert h == int(320 * 9 / 16) -# ═══════════════════════════════════════════════════════════════════════════════ -# compute_pip_position -# ═══════════════════════════════════════════════════════════════════════════════ +# ── compute_pip_position ──────────────────────────────────────────────────── class TestComputePipPosition: - """位置计算测试.""" - - def test_bottom_right(self): - """右下角位置.""" + def test_bottom_right_position(self): layer = _make_layer(position="bottom_right", margin=20) - pip_w, pip_h = 200, 150 - x, y = compute_pip_position(layer, pip_w, pip_h, OUTPUT_W, OUTPUT_H) - assert x == OUTPUT_W - pip_w - 20 - assert y == OUTPUT_H - pip_h - 20 + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) + assert x == 1920 - 200 - 20 + assert y == 1080 - 150 - 20 - def test_top_left(self): - """左上角.""" + def test_top_left_position(self): layer = _make_layer(position="top_left", margin=10) - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) assert x == 10 assert y == 10 - def test_top_center(self): - """顶部居中.""" - layer = _make_layer(position="top_center", margin=20) - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) - assert x == (OUTPUT_W - 200) // 2 - assert y == 20 - - def test_center(self): - """正中心.""" + def test_center_position(self): layer = _make_layer(position="center") - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) - assert x == (OUTPUT_W - 200) // 2 - assert y == (OUTPUT_H - 150) // 2 + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) + assert x == (1920 - 200) // 2 + assert y == (1080 - 150) // 2 def test_custom_position(self): - """自定义坐标.""" layer = _make_layer(position="custom", x=100, y=200) - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) assert x == 100 assert y == 200 - def test_margin_effect(self): - """不同 margin 值影响位置.""" - layer1 = _make_layer(position="bottom_right", margin=0) - layer2 = _make_layer(position="bottom_right", margin=50) - x1, y1 = compute_pip_position(layer1, 200, 150, OUTPUT_W, OUTPUT_H) - x2, y2 = compute_pip_position(layer2, 200, 150, OUTPUT_W, OUTPUT_H) - assert x1 > x2 - assert y1 > y2 - - def test_clamped_when_outside(self): - """自定义坐标超出画面时钳制到边界内.""" - layer = _make_layer(position="custom", x=-50, y=99999) - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) + def test_clamped_to_left_edge(self): + # parse_size_value 有 max(1, value) 钳制,负数返回1 + layer = _make_layer(position="custom", x=-100, y=0) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) assert x >= 0 - assert x <= OUTPUT_W - 200 + assert x < 200 + + def test_clamped_to_right_edge(self): + layer = _make_layer(position="custom", x=9999, y=0) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) + assert x == 1920 - 200 + + def test_clamped_to_top_edge(self): + # parse_size_value 有 max(1, value) 钳制,负数返回1 + layer = _make_layer(position="custom", x=0, y=-50) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) assert y >= 0 - assert y == OUTPUT_H - 150 # y 超出底部,钳制到底部 + assert y < 150 - def test_bottom_center(self): - """底部居中.""" - layer = _make_layer(position="bottom_center", margin=30) - x, y = compute_pip_position(layer, 300, 200, OUTPUT_W, OUTPUT_H) - assert x == (OUTPUT_W - 300) // 2 - assert y == OUTPUT_H - 200 - 30 - - def test_center_left(self): - """左侧居中.""" - layer = _make_layer(position="center_left", margin=15) - x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == 15 - assert y == (OUTPUT_H - 100) // 2 - - def test_center_right(self): - """右侧居中.""" - layer = _make_layer(position="center_right", margin=15) - x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == OUTPUT_W - 150 - 15 - assert y == (OUTPUT_H - 100) // 2 + def test_clamped_to_bottom_edge(self): + layer = _make_layer(position="custom", x=0, y=9999) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) + assert y == 1080 - 150 -# ═══════════════════════════════════════════════════════════════════════════════ -# build_pip_pre_filter -# ═══════════════════════════════════════════════════════════════════════════════ +# ── build_pip_pre_filter ──────────────────────────────────────────────────── class TestBuildPipPreFilter: - """预处理滤镜构建测试.""" - - def test_basic_scale_setsar(self): - """基础:scale + setsar.""" - layer = _make_layer() + def test_basic_scale_and_sar(self): + layer = _make_layer(width="200", height="150") result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert result.startswith("[1:v]") assert "scale=200:150" in result assert "setsar=1" in result assert result.endswith("[pip_pre_0]") - def test_corner_radius_filter(self): - """圆角裁剪滤镜.""" + def test_with_corner_radius(self): layer = _make_layer(corner_radius=20) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "geq=" in result assert "format=yuva420p" in result - # 圆角半径应钳制到 min(r, w//2, h//2) - assert "hypot(" in result - def test_corner_radius_clamped(self): - """圆角半径超过尺寸一半时自动钳制.""" - layer = _make_layer(corner_radius=1000) # 超大 - result = build_pip_pre_filter("[0:v]", layer, 100, 80, "pre") - # 钳制后 r = min(1000, 50, 40) = 40 - # 检查 geq 表达式中的 r 值 - import re + def test_corner_radius_zero(self): + layer = _make_layer(corner_radius=0) + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") + assert "geq=" not in result - r_matches = re.findall(r"lt\(X,(\d+)\)\*lt\(Y,\1\)", result) - assert r_matches - assert int(r_matches[0]) <= 50 # 不超过宽的一半 - - def test_border_filter(self): - """边框滤镜.""" + def test_with_border(self): layer = _make_layer(border_width=5, border_color="red") - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "pad=210:160:5:5:red" in result - def test_zero_border_no_pad(self): - """border_width=0 时不加 pad.""" + def test_border_zero(self): layer = _make_layer(border_width=0) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "pad=" not in result - def test_opacity_filter(self): - """透明度滤镜.""" + def test_with_opacity(self): layer = _make_layer(opacity=0.5) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "colorchannelmixer=aa=0.5" in result - assert "format=yuva420p" in result - def test_full_opacity_no_alpha(self): - """opacity=1.0 时不加透明度滤镜.""" + def test_full_opacity_no_alpha_filter(self): layer = _make_layer(opacity=1.0) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "colorchannelmixer" not in result - def test_opacity_clamped_high(self): - """opacity > 1.0 时钳制.""" - layer = _make_layer(opacity=2.0) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") - # 钳制到 1.0,不加透明度滤镜 - assert "colorchannelmixer=aa=1" not in result - assert "colorchannelmixer" not in result - - def test_opacity_clamped_low(self): - """opacity < 0 时钳制到 0.""" + def test_opacity_clamped(self): layer = _make_layer(opacity=-0.5) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "colorchannelmixer=aa=0.0" in result - def test_fade_in_animation(self): - """淡入动画.""" - layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.3) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") - assert "fade=t=in:st=0:d=0.3:alpha=1" in result - - def test_fade_out_animation(self): - """淡出动画(需要 duration).""" - layer = _make_layer( - animation_out=ANIMATION_FADE, - animation_duration=0.5, - duration=5.0, - ) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") - assert "fade=t=out:st=4.5:d=0.5:alpha=1" in result - - def test_fade_out_no_duration(self): - """淡出无 duration 时不加.""" - layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5, duration=None) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") - assert "fade=t=out" not in result - - def test_combined_effects(self): - """多个效果组合:圆角 + 边框 + 透明度.""" - layer = _make_layer( - corner_radius=15, - border_width=3, - border_color="white", - opacity=0.8, - ) - result = build_pip_pre_filter("[0:v]", layer, 300, 200, "pre") - assert "geq=" in result # 圆角 - assert "pad=306:206:3:3:white" in result # 边框 - assert "colorchannelmixer=aa=0.8" in result # 透明度 - - def test_output_label(self): - """输出标签正确.""" - layer = _make_layer() - result = build_pip_pre_filter("[2:v]", layer, 100, 80, "my_label") - assert result.endswith("[my_label]") - - def test_input_label(self): - """输入标签正确.""" - layer = _make_layer() - result = build_pip_pre_filter("[5:v]", layer, 100, 80, "out") - assert result.startswith("[5:v]") - - -# ═══════════════════════════════════════════════════════════════════════════════ -# build_animation_filters -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestBuildAnimationFilters: - """动画滤镜构建测试.""" - - def test_no_animation(self): - """无动画返回空列表.""" - layer = _make_layer() - result = build_animation_filters(layer, 200, 150) - assert result == [] - - def test_fade_in_only(self): - """仅淡入.""" - layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.5) - result = build_animation_filters(layer, 200, 150) - assert len(result) == 1 - assert "fade=t=in" in result[0] - - def test_fade_out_with_duration(self): - """淡出(有 duration).""" - layer = _make_layer( - animation_out=ANIMATION_FADE, - animation_duration=0.3, - duration=10.0, - ) - result = build_animation_filters(layer, 200, 150) - assert len(result) == 1 - assert "fade=t=out:st=9.7:d=0.3" in result[0] - - def test_fade_out_no_duration_skipped(self): - """淡出无 duration 时跳过.""" - layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5) - result = build_animation_filters(layer, 200, 150) - assert result == [] - - def test_fade_in_and_out(self): - """淡入 + 淡出.""" + def test_with_fade_animation(self): layer = _make_layer( animation_in=ANIMATION_FADE, animation_out=ANIMATION_FADE, animation_duration=0.5, duration=3.0, ) + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") + assert "fade=t=in:st=0:d=0.5:alpha=1" in result + assert "fade=t=out" in result + + def test_all_features_combined(self): + layer = _make_layer( + corner_radius=15, + border_width=3, + border_color="blue", + opacity=0.8, + animation_in=ANIMATION_FADE, + animation_duration=0.3, + duration=5.0, + ) + result = build_pip_pre_filter("[1:v]", layer, 300, 200, "pip_out") + assert "scale=300:200" in result + assert "geq=" in result + assert "pad=" in result + assert "colorchannelmixer=aa=0.8" in result + assert "fade=t=in" in result + assert result.endswith("[pip_out]") + + +# ── build_animation_filters ───────────────────────────────────────────────── + + +class TestBuildAnimationFilters: + def test_no_animation(self): + layer = _make_layer() + assert build_animation_filters(layer, 200, 150) == [] + + def test_fade_in_only(self): + layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.5) + result = build_animation_filters(layer, 200, 150) + assert len(result) == 1 + assert "fade=t=in:st=0:d=0.5:alpha=1" in result[0] + + def test_fade_out_requires_duration(self): + layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5) + result = build_animation_filters(layer, 200, 150) + # 没有duration,出场动画不生效 + assert len(result) == 0 + + def test_fade_out_with_duration(self): + layer = _make_layer( + animation_out=ANIMATION_FADE, + animation_duration=0.5, + duration=5.0, + ) + result = build_animation_filters(layer, 200, 150) + assert len(result) == 1 + assert "fade=t=out" in result[0] + assert "st=4.5" in result[0] # 5.0 - 0.5 + + def test_both_fade_animations(self): + layer = _make_layer( + animation_in=ANIMATION_FADE, + animation_out=ANIMATION_FADE, + animation_duration=0.3, + duration=4.0, + ) result = build_animation_filters(layer, 200, 150) assert len(result) == 2 - assert any("fade=t=in" in f for f in result) - assert any("fade=t=out" in f for f in result) + assert "fade=t=in" in result[0] + assert "fade=t=out" in result[1] - def test_slide_in_not_here(self): - """slide 动画不在此函数处理.""" - layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5) - result = build_animation_filters(layer, 200, 150) - assert result == [] - - def test_zero_duration_no_animation(self): - """动画时长为 0 时不加.""" + def test_zero_duration_animation(self): layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0) result = build_animation_filters(layer, 200, 150) assert result == [] - def test_negative_duration_clamped(self): - """负动画时长钳制为 0.""" + def test_negative_animation_duration_clamped(self): layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=-1) result = build_animation_filters(layer, 200, 150) assert result == [] - def test_fade_out_start_clamped_to_zero(self): - """淡出开始时间不为负.""" - layer = _make_layer( - animation_out=ANIMATION_FADE, - animation_duration=2.0, - duration=1.0, # 比动画时长短 - ) + def test_slide_animation_not_in_this_function(self): + # slide类动画不在这个函数处理 + layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5) result = build_animation_filters(layer, 200, 150) - assert len(result) == 1 - # start = max(0, 1.0 - 2.0) = 0 - assert "st=0.0:d=2.0" in result[0] + assert result == [] -# ═══════════════════════════════════════════════════════════════════════════════ -# build_overlay_expr -# ═══════════════════════════════════════════════════════════════════════════════ +# ── build_overlay_expr ────────────────────────────────────────────────────── class TestBuildOverlayExpr: - """overlay 表达式构建测试.""" - def test_no_animation_static_position(self): - """无动画时返回静态坐标.""" layer = _make_layer() - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert y == "200" + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert x_expr == "100" + assert y_expr == "200" - def test_slide_in_from_left(self): - """从左侧滑入.""" + def test_slide_left_enter(self): layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert "if(lt(t,0.5)" in x - assert "-150" in x # 起始位置 = -pip_width - assert y == "200" # y 不变 + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(lt(t,0.5)" in x_expr + assert y_expr == "200" - def test_slide_in_from_right(self): - """从右侧滑入.""" + def test_slide_right_enter(self): layer = _make_layer(animation_in=ANIMATION_SLIDE_RIGHT, animation_duration=0.5) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert str(OUTPUT_W) in x - assert y == "200" + x_expr, _ = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "output_width" not in x_expr # 应该是具体数值 + assert "if(lt(t,0.5)" in x_expr + assert "1920" in x_expr - def test_slide_in_from_top(self): - """从顶部滑入.""" - layer = _make_layer(animation_in=ANIMATION_SLIDE_TOP, animation_duration=0.3) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert "if(lt(t,0.3)" in y - assert "-100" in y + def test_slide_top_enter(self): + layer = _make_layer(animation_in=ANIMATION_SLIDE_TOP, animation_duration=0.5) + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert x_expr == "100" + assert "if(lt(t,0.5)" in y_expr - def test_slide_in_from_bottom(self): - """从底部滑入.""" - layer = _make_layer(animation_in=ANIMATION_SLIDE_BOTTOM, animation_duration=0.3) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert str(OUTPUT_H) in y + def test_slide_bottom_enter(self): + layer = _make_layer(animation_in=ANIMATION_SLIDE_BOTTOM, animation_duration=0.5) + _, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(lt(t,0.5)" in y_expr + assert "1080" in y_expr - def test_slide_out_to_left(self): - """向左滑出.""" + def test_slide_left_exit(self): layer = _make_layer( animation_out=ANIMATION_SLIDE_LEFT, animation_duration=0.5, - duration=3.0, + duration=5.0, ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert "gt(t,2.5)" in x - assert y == "200" + x_expr, _ = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(gt(t,4.5)" in x_expr - def test_slide_out_to_right(self): - """向右滑出.""" + def test_slide_right_exit(self): layer = _make_layer( animation_out=ANIMATION_SLIDE_RIGHT, animation_duration=0.5, - duration=3.0, + duration=5.0, ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert "gt(t,2.5)" in x - assert y == "200" - # 向右滑出:结束时 x > base_x(值变大) - # 检查表达式中含增大方向的计算 - assert "+(t-2.5)/0.5*" in x + x_expr, _ = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(gt(t,4.5)" in x_expr - def test_slide_out_to_top(self): - """向上滑出.""" + def test_slide_top_exit(self): layer = _make_layer( animation_out=ANIMATION_SLIDE_TOP, animation_duration=0.5, duration=5.0, ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert "gt(t,4.5)" in y + _, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(gt(t,4.5)" in y_expr - def test_slide_out_to_bottom(self): - """向下滑出.""" + def test_slide_bottom_exit(self): layer = _make_layer( animation_out=ANIMATION_SLIDE_BOTTOM, animation_duration=0.5, duration=5.0, ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert "gt(t,4.5)" in y - # 向下滑出:y 值增大 - assert "+(t-4.5)/0.5*" in y + _, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(gt(t,4.5)" in y_expr - def test_slide_in_and_out_different_axes(self): - """滑入(x方向) + 滑出(y方向),两个轴都有动画.""" - layer = _make_layer( - animation_in=ANIMATION_SLIDE_LEFT, - animation_out=ANIMATION_SLIDE_BOTTOM, - animation_duration=0.5, - duration=4.0, - ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert "lt(t,0.5)" in x # x 方向入场 - assert "gt(t,3.5)" in y # y 方向出场 + def test_exit_animation_no_duration_ignored(self): + layer = _make_layer(animation_out=ANIMATION_SLIDE_LEFT, animation_duration=0.5) + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert x_expr == "100" + assert y_expr == "200" def test_zero_animation_duration_no_effect(self): - """动画时长为 0 时无效果.""" layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert y == "200" - - def test_no_duration_skip_outro(self): - """无 duration 时跳过滑出.""" - layer = _make_layer( - animation_out=ANIMATION_SLIDE_LEFT, - animation_duration=0.5, - duration=None, - ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert y == "200" - - def test_expression_format_quoted(self): - """有动画时表达式带单引号.""" - layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5) - x, _ = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x.startswith("'") - assert x.endswith("'") - - def test_static_position_unquoted(self): - """无动画时纯数字,不带引号.""" - layer = _make_layer() - x, y = build_overlay_expr(layer, 50, 60, 100, 80, OUTPUT_W, OUTPUT_H) - assert x == "50" - assert y == "60" - assert "'" not in x - assert "'" not in y + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert x_expr == "100" + assert y_expr == "200" -# ═══════════════════════════════════════════════════════════════════════════════ -# build_enable_expr -# ═══════════════════════════════════════════════════════════════════════════════ +# ── build_enable_expr ──────────────────────────────────────────────────────── class TestBuildEnableExpr: - """enable 表达式构建测试.""" - - def test_no_time_restriction(self): - """无时间限制返回空.""" - layer = _make_layer() + def test_no_start_no_duration_empty(self): + layer = _make_layer(start_time=0.0) + # duration默认是0.0 assert build_enable_expr(layer) == "" - def test_start_time_only(self): - """只有开始时间.""" + def test_with_start_and_duration(self): + layer = _make_layer(start_time=2.0, duration=3.0) + result = build_enable_expr(layer) + assert result == ":enable='between(t,2.0,5.0)'" + + def test_with_start_only_no_duration(self): layer = _make_layer(start_time=5.0) result = build_enable_expr(layer) assert result == ":enable='gte(t,5.0)'" - def test_duration_only(self): - """只有 duration(从 0 开始).""" - layer = _make_layer(duration=10.0) - result = build_enable_expr(layer) - assert result == ":enable='between(t,0.0,10.0)'" - - def test_start_and_duration(self): - """开始时间 + 时长.""" - layer = _make_layer(start_time=2.0, duration=5.0) - result = build_enable_expr(layer) - assert "between(t,2.0,7.0)" in result - - def test_zero_start_with_duration(self): - """0 开始 + 时长.""" - layer = _make_layer(start_time=0, duration=3.5) - result = build_enable_expr(layer) - assert "between(t,0.0,3.5)" in result - def test_negative_start_clamped(self): - """负开始时间钳制为 0.""" layer = _make_layer(start_time=-1.0, duration=5.0) result = build_enable_expr(layer) - assert "between(t,0.0,5.0)" in result + assert "between(t,0.0," in result - def test_none_duration(self): - """duration=None 视为无限.""" - layer = _make_layer(start_time=3.0, duration=None) + def test_zero_duration_with_start(self): + layer = _make_layer(start_time=3.0, duration=0.0) result = build_enable_expr(layer) assert "gte(t,3.0)" in result - assert "between" not in result + + def test_zero_start_and_duration(self): + layer = _make_layer(start_time=0, duration=0) + assert build_enable_expr(layer) == "" -# ═══════════════════════════════════════════════════════════════════════════════ -# build_pip_filters -# ═══════════════════════════════════════════════════════════════════════════════ +# ── build_pip_filters ─────────────────────────────────────────────────────── class TestBuildPipFilters: - """完整滤镜链构建测试.""" - def test_empty_layers(self): - """空图层列表返回空.""" - filters, inputs, label = build_pip_filters( - "base", - [], - [], - output_width=OUTPUT_W, - output_height=OUTPUT_H, - ) - assert filters == [] - assert inputs == [] - assert label == "base" + filter_parts, input_args, final = build_pip_filters("base", [], [], output_width=1920, output_height=1080) + assert filter_parts == [] + assert input_args == [] + assert final == "base" def test_single_layer(self): - """单个图层.""" - layer = _make_layer(width="20%", position="bottom_right") - path = Path("/tmp/clip1.mp4") - - filters, inputs, label = build_pip_filters( - "v0", - [layer], - [path], - output_width=OUTPUT_W, - output_height=OUTPUT_H, + layer = _make_layer(width="200", height="150", position="top_left", source="test.mp4") + filter_parts, input_args, final = build_pip_filters( + "base", [layer], ["/tmp/test.mp4"], output_width=1920, output_height=1080 ) - - # 2 个滤镜片段:预处理 + overlay - assert len(filters) == 2 - # 1 个输入 - assert inputs == ["-i", str(path)] - # 最终标签 - assert label == "pip_combined_0" + assert len(filter_parts) == 2 # pre_filter + overlay + assert input_args == ["-i", "/tmp/test.mp4"] + assert final == "pip_combined_0" def test_multiple_layers(self): - """多个图层.""" layers = [ - _make_layer(width="30%", position="bottom_left"), - _make_layer(width="25%", position="top_right"), - _make_layer(width="20%", position="top_left"), + _make_layer(width="100", height="100", position="top_left"), + _make_layer(width="100", height="100", position="top_right"), ] - paths = [Path("/tmp/a.mp4"), Path("/tmp/b.mp4"), Path("/tmp/c.mp4")] - - filters, inputs, label = build_pip_filters( - "base", - layers, - paths, - output_width=OUTPUT_W, - output_height=OUTPUT_H, + sources = ["/tmp/a.mp4", "/tmp/b.mp4"] + filter_parts, input_args, final = build_pip_filters( + "base", layers, sources, output_width=1920, output_height=1080 ) + assert len(filter_parts) == 4 # 2 pre + 2 overlay + assert len(input_args) == 4 # 2 * (-i, path) + assert final == "pip_combined_1" - # 每个图层 2 个滤镜(预处理 + overlay) - assert len(filters) == 6 - # 3 个输入 - assert len(inputs) == 6 # -i path × 3 - assert inputs[0::2] == ["-i", "-i", "-i"] - # 最终标签是最后一个 combined - assert label == "pip_combined_2" + def test_mismatched_layers_and_sources(self): + layer = _make_layer() + with pytest.raises(ValueError, match="长度不一致"): + build_pip_filters("base", [layer], [], output_width=1920, output_height=1080) - def test_base_input_idx_offset(self): - """base_input_idx 偏移.""" - layer = _make_layer(width="20%") - filters, inputs, label = build_pip_filters( + def test_custom_base_input_index(self): + layer = _make_layer(width="100", height="100", position="top_left") + filter_parts, input_args, _ = build_pip_filters( "base", [layer], - [Path("/tmp/x.mp4")], - output_width=OUTPUT_W, - output_height=OUTPUT_H, + ["/a.mp4"], + output_width=1920, + output_height=1080, base_input_idx=5, ) - # 预处理滤镜引用 [5:v] - assert "[5:v]" in filters[0] + assert "[5:v]" in filter_parts[0] + assert len(input_args) == 2 - def test_layer_count_mismatch_raises(self): - """图层和路径数量不一致时报错.""" - with pytest.raises(ValueError, match="长度不一致"): - build_pip_filters( - "base", - [_make_layer()], - [], - output_width=OUTPUT_W, - output_height=OUTPUT_H, - ) - - def test_filter_chaining(self): - """多图层时滤镜链正确串联.""" - layers = [_make_layer(width="10%"), _make_layer(width="10%")] - paths = [Path("/tmp/1.mp4"), Path("/tmp/2.mp4")] - - filters, _, _ = build_pip_filters( + def test_path_object_supported(self): + layer = _make_layer(width="100", height="100", position="top_left") + _, input_args, _ = build_pip_filters( "base", - layers, - paths, - output_width=OUTPUT_W, - output_height=OUTPUT_H, - ) - - # 第一个 overlay 的输入是 base + pip_pre_0 - # 输出是 pip_combined_0 - assert "[base]" in filters[1] - assert "[pip_combined_0]" in filters[1] - - # 第二个 overlay 的输入是 pip_combined_0 + pip_pre_1 - # 输出是 pip_combined_1 - assert "[pip_combined_0]" in filters[3] - assert "[pip_combined_1]" in filters[3] - - def test_with_animation_layer(self): - """带动画的图层生成正确表达式.""" - layer = _make_layer( - width="30%", - animation_in=ANIMATION_SLIDE_BOTTOM, - animation_duration=0.5, - ) - filters, inputs, _ = build_pip_filters( - "v0", [layer], - [Path("/tmp/a.mp4")], - output_width=OUTPUT_W, - output_height=OUTPUT_H, + [Path("/tmp/test.mp4")], + output_width=1920, + output_height=1080, ) - # overlay 滤镜中包含滑动表达式 - overlay_filter = filters[1] - assert "overlay=" in overlay_filter - assert str(OUTPUT_H) in overlay_filter # 从底部滑入 + assert input_args[1] == "/tmp/test.mp4" - def test_with_enable_time(self): - """带时间控制的图层.""" - layer = _make_layer(width="20%", start_time=2.0, duration=5.0) - filters, _, _ = build_pip_filters( - "v0", + def test_filter_parts_contain_correct_labels(self): + layer = _make_layer(width="100", height="100", position="top_left") + filter_parts, _, _ = build_pip_filters( + "base", [layer], - [Path("/tmp/a.mp4")], - output_width=OUTPUT_W, - output_height=OUTPUT_H, + ["/a.mp4"], + output_width=1920, + output_height=1080, ) - overlay_filter = filters[1] - assert "enable=" in overlay_filter - assert "between" in overlay_filter - - def test_string_paths(self): - """路径可以是字符串.""" - layer = _make_layer(width="10%") - filters, inputs, label = build_pip_filters( - "v0", - [layer], - ["/tmp/s.mp4"], - output_width=OUTPUT_W, - output_height=OUTPUT_H, - ) - assert inputs == ["-i", "/tmp/s.mp4"] - assert len(filters) == 2 + # pre filter输出 pip_pre_0 + assert "[pip_pre_0]" in filter_parts[0] + # overlay使用 pip_pre_0 作为输入 + assert "[pip_pre_0]" in filter_parts[1] + # overlay输出 pip_combined_0 + assert "[pip_combined_0]" in filter_parts[1] -# ═══════════════════════════════════════════════════════════════════════════════ -# validate_pip_layer -# ═══════════════════════════════════════════════════════════════════════════════ +# ── validate_pip_layer ────────────────────────────────────────────────────── class TestValidatePipLayer: - """配置验证测试.""" - def test_valid_layer(self): - """合法配置.""" - layer = _make_layer() - ok, err = validate_pip_layer(layer) - assert ok is True + layer = _make_layer( + source_type="asset_id", + source="asset_123", + width="25%", + position="bottom_right", + ) + valid, err = validate_pip_layer(layer) + assert valid is True assert err == "" - def test_empty_source_type(self): - """空 source_type.""" - layer = _make_layer(source_type="") - ok, err = validate_pip_layer(layer) - assert ok is False + def test_missing_source_type(self): + layer = _make_layer(source_type="", source="abc", width="25%", position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False assert "source_type" in err def test_invalid_source_type(self): - """不支持的 source_type.""" - layer = _make_layer(source_type="ftp") - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="invalid", source="abc", width="25%", position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False assert "source_type" in err - def test_empty_source(self): - """空 source.""" - layer = _make_layer(source="") - ok, err = validate_pip_layer(layer) - assert ok is False + def test_missing_source(self): + layer = _make_layer(source_type="asset_id", source="", width="25%", position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False assert "source" in err + def test_missing_width(self): + layer = _make_layer(source_type="asset_id", source="abc", width=None, position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False + assert "width" in err + + def test_empty_width(self): + layer = _make_layer(source_type="asset_id", source="abc", width="", position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False + assert "width" in err + def test_invalid_position(self): - """不支持的 position.""" - layer = _make_layer(position="middle") - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", position="invalid_pos") + valid, err = validate_pip_layer(layer) + assert valid is False assert "position" in err - def test_opacity_too_high(self): - """opacity > 1.""" - layer = _make_layer(opacity=1.5) - ok, err = validate_pip_layer(layer) - assert ok is False + def test_invalid_opacity_high(self): + layer = _make_layer(source_type="asset_id", source="abc", width="25%", opacity=2.0) + valid, err = validate_pip_layer(layer) + assert valid is False assert "opacity" in err - def test_opacity_negative(self): - """opacity < 0.""" - layer = _make_layer(opacity=-0.1) - ok, err = validate_pip_layer(layer) - assert ok is False + def test_invalid_opacity_low(self): + layer = _make_layer(source_type="asset_id", source="abc", width="25%", opacity=-0.5) + valid, err = validate_pip_layer(layer) + assert valid is False assert "opacity" in err def test_negative_corner_radius(self): - """负圆角.""" - layer = _make_layer(corner_radius=-5) - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", corner_radius=-1) + valid, err = validate_pip_layer(layer) + assert valid is False assert "corner_radius" in err def test_negative_border_width(self): - """负边框.""" - layer = _make_layer(border_width=-2) - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", border_width=-1) + valid, err = validate_pip_layer(layer) + assert valid is False assert "border_width" in err + def test_negative_animation_duration(self): + layer = _make_layer(source_type="asset_id", source="abc", width="25%", animation_duration=-1) + valid, err = validate_pip_layer(layer) + assert valid is False + assert "animation_duration" in err + def test_negative_start_time(self): - """负开始时间.""" - layer = _make_layer(start_time=-1.0) - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", start_time=-1) + valid, err = validate_pip_layer(layer) + assert valid is False assert "start_time" in err def test_negative_duration(self): - """负时长.""" - layer = _make_layer(duration=-5.0) - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", duration=-1) + valid, err = validate_pip_layer(layer) + assert valid is False assert "duration" in err def test_invalid_animation_in(self): - """不支持的入场动画.""" - layer = _make_layer(animation_in="zoom") - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", animation_in="invalid") + valid, err = validate_pip_layer(layer) + assert valid is False assert "animation_in" in err def test_invalid_animation_out(self): - """不支持的出场动画.""" - layer = _make_layer(animation_out="spin") - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", animation_out="invalid") + valid, err = validate_pip_layer(layer) + assert valid is False assert "animation_out" in err - def test_multiple_errors_combined(self): - """多个错误合并.""" - layer = _make_layer(source_type="", source="", opacity=2.0, position="xxx") - ok, err = validate_pip_layer(layer) - assert ok is False - assert err.count(";") >= 2 # 至少 2 个错误 + def test_multiple_errors_joined(self): + layer = _make_layer(source_type="", source="", width=None, position="bad") + valid, err = validate_pip_layer(layer) + assert valid is False + assert err.count(";") >= 2 # 至少3个错误,2个分号 - def test_valid_url_source(self): - """URL 类型 source 合法.""" - layer = _make_layer(source_type="url", source="https://example.com/v.mp4") - ok, err = validate_pip_layer(layer) - assert ok is True + def test_all_source_types_valid(self): + for st in ("local_path", "asset_id", "url"): + layer = _make_layer(source_type=st, source="abc", width="25%") + valid, _ = validate_pip_layer(layer) + assert valid is True - def test_valid_asset_id(self): - """asset_id 类型合法.""" - layer = _make_layer(source_type="asset_id", source="asset_123") - ok, err = validate_pip_layer(layer) - assert ok is True - - def test_zero_values_valid(self): - """0 值合法(不是负数).""" - layer = _make_layer( - corner_radius=0, - border_width=0, - start_time=0, - animation_duration=0, - ) - ok, err = validate_pip_layer(layer) - assert ok is True + def test_all_positions_valid(self): + for pos in ( + "top_left", + "top_center", + "top_right", + "center_left", + "center", + "center_right", + "bottom_left", + "bottom_center", + "bottom_right", + "custom", + ): + layer = _make_layer(source_type="asset_id", source="abc", width="25%", position=pos) + valid, err = validate_pip_layer(layer) + assert valid is True, f"position {pos} should be valid: {err}" -# ═══════════════════════════════════════════════════════════════════════════════ -# count_visible_layers -# ═══════════════════════════════════════════════════════════════════════════════ +# ── count_visible_layers ──────────────────────────────────────────────────── class TestCountVisibleLayers: - """可见图层统计测试.""" - def test_all_visible(self): - """全部可见.""" - layers = [_make_layer(opacity=1.0), _make_layer(opacity=0.5)] + layers = [ + _make_layer(opacity=1.0), + _make_layer(opacity=0.5), + ] assert count_visible_layers(layers) == 2 - def test_all_invisible(self): - """全部不可见.""" - layers = [_make_layer(opacity=0.0), _make_layer(opacity=0.0)] + def test_none_visible(self): + layers = [ + _make_layer(opacity=0.0), + _make_layer(opacity=0.0), + ] assert count_visible_layers(layers) == 0 def test_mixed(self): - """混合.""" layers = [ _make_layer(opacity=1.0), _make_layer(opacity=0.0), - _make_layer(opacity=0.001), + _make_layer(opacity=0.1), + _make_layer(opacity=0.0), ] assert count_visible_layers(layers) == 2 def test_empty_list(self): - """空列表.""" assert count_visible_layers([]) == 0 + def test_negative_opacity_not_counted(self): + # opacity < 0 也不算可见 + layer = _make_layer(opacity=-0.5) + assert count_visible_layers([layer]) == 0 -# ═══════════════════════════════════════════════════════════════════════════════ -# sort_layers_by_z_index -# ═══════════════════════════════════════════════════════════════════════════════ + +# ── sort_layers_by_z_index ────────────────────────────────────────────────── class TestSortLayersByZIndex: - """图层排序测试.""" - def test_sorted_by_z_index(self): - """按 z_index 从小到大排序.""" - layers = [ - _make_layer(z_index=5, source="/tmp/a.mp4"), - _make_layer(z_index=1, source="/tmp/b.mp4"), - _make_layer(z_index=3, source="/tmp/c.mp4"), - ] - sorted_layers = sort_layers_by_z_index(layers) - assert [layer.z_index for layer in sorted_layers] == [1, 3, 5] + l1 = _make_layer(z_index=3) + l2 = _make_layer(z_index=1) + l3 = _make_layer(z_index=2) + result = sort_layers_by_z_index([l1, l2, l3]) + assert result[0].z_index == 1 + assert result[1].z_index == 2 + assert result[2].z_index == 3 def test_same_z_index_stable(self): - """相同 z_index 保持相对顺序.""" - layers = [ - _make_layer(z_index=2, source="/tmp/1.mp4"), - _make_layer(z_index=2, source="/tmp/2.mp4"), - ] - sorted_layers = sort_layers_by_z_index(layers) - assert sorted_layers[0].source == "/tmp/1.mp4" - assert sorted_layers[1].source == "/tmp/2.mp4" + l1 = _make_layer(z_index=5) + l2 = _make_layer(z_index=5) + l3 = _make_layer(z_index=5) + result = sort_layers_by_z_index([l1, l2, l3]) + # 稳定排序,保持原顺序 + assert result[0] is l1 + assert result[1] is l2 + assert result[2] is l3 + + def test_negative_z_index(self): + l1 = _make_layer(z_index=-5) + l2 = _make_layer(z_index=0) + l3 = _make_layer(z_index=5) + result = sort_layers_by_z_index([l3, l1, l2]) + assert result[0].z_index == -5 + assert result[2].z_index == 5 def test_empty_list(self): - """空列表.""" assert sort_layers_by_z_index([]) == [] def test_single_layer(self): - """单个图层.""" - layers = [_make_layer(z_index=0)] - assert len(sort_layers_by_z_index(layers)) == 1 - - def test_negative_z_index(self): - """负 z_index.""" - layers = [ - _make_layer(z_index=0, source="/tmp/0.mp4"), - _make_layer(z_index=-5, source="/tmp/-5.mp4"), - _make_layer(z_index=3, source="/tmp/3.mp4"), - ] - sorted_layers = sort_layers_by_z_index(layers) - assert [layer.z_index for layer in sorted_layers] == [-5, 0, 3] + layer = _make_layer(z_index=10) + result = sort_layers_by_z_index([layer]) + assert len(result) == 1 + assert result[0] is layer diff --git a/tests/unit/test_shared_ai_service.py b/tests/unit/test_shared_ai_service.py new file mode 100755 index 000000000..f46002dbf --- /dev/null +++ b/tests/unit/test_shared_ai_service.py @@ -0,0 +1,453 @@ +"""shared.ai_service 单元测试. + +主要测试纯逻辑部分:_parse_recommend_response / _fallback_recommend_clips / _call_ai_cover_service. +""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest +from shared.ai_service import ( + _call_ai_cover_service, + _fallback_recommend_clips, + _parse_recommend_response, +) + +# ── _parse_recommend_response 测试 ──────────────────────────────────────── + + +class TestParseRecommendResponseBasic: + """基础解析测试.""" + + def test_parse_valid_json(self): + content = json.dumps( + { + "clips": [ + { + "clip_type": "intro", + "order": 0, + "text_content": "开场", + "duration": 3.0, + "transition_effect": "fade", + "asset_id": "asset1", + "start_time": 0.0, + "config": {}, + }, + { + "clip_type": "outro", + "order": 1, + "text_content": "结尾", + "duration": 2.0, + "transition_effect": "fade", + "asset_id": "", + "start_time": 0.0, + "config": {}, + }, + ], + "title": "测试视频", + "confidence": 0.85, + } + ) + result = _parse_recommend_response(content, ["asset1"], 30.0) + assert result is not None + assert len(result["clips"]) == 2 + assert result["confidence"] == 0.85 + assert result["total_duration"] == 5.0 + assert result["config"]["title"]["text"] == "测试视频" + assert result["config"]["title"]["ai_auto"] is True + + def test_parse_none_returns_none(self): + result = _parse_recommend_response(None, ["a1"], 30.0) # type: ignore[arg-type] + assert result is None + + def test_parse_empty_string_returns_none(self): + result = _parse_recommend_response("", ["a1"], 30.0) + assert result is None + + def test_parse_whitespace_only_returns_none(self): + result = _parse_recommend_response(" ", ["a1"], 30.0) + assert result is None + + def test_parse_invalid_json_returns_none(self): + result = _parse_recommend_response("not json", ["a1"], 30.0) + assert result is None + + def test_parse_non_dict_json_returns_none(self): + result = _parse_recommend_response("[1, 2, 3]", ["a1"], 30.0) + assert result is None + + +class TestParseRecommendResponseClips: + """clips 解析测试.""" + + def test_parse_no_clips_returns_none(self): + content = json.dumps({"title": "test", "clips": []}) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is None + + def test_parse_clips_not_list_returns_none(self): + content = json.dumps({"clips": "not a list"}) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is None + + def test_parse_clips_sorted_by_order(self): + content = json.dumps( + { + "clips": [ + {"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"}, + {"clip_type": "intro", "order": 0, "duration": 3, "asset_id": "a1"}, + {"clip_type": "showcase", "order": 1, "duration": 5, "asset_id": "a1"}, + ], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert len(result["clips"]) == 3 + assert result["clips"][0]["clip_type"] == "intro" + assert result["clips"][1]["clip_type"] == "showcase" + assert result["clips"][2]["clip_type"] == "outro" + + def test_parse_clips_renumbered_continuously(self): + content = json.dumps( + { + "clips": [ + {"clip_type": "intro", "order": 10, "duration": 2, "asset_id": "a1"}, + {"clip_type": "outro", "order": 20, "duration": 2, "asset_id": "a1"}, + ], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert result["clips"][0]["order"] == 0 + assert result["clips"][1]["order"] == 1 + + def test_parse_skips_invalid_clip_dicts(self): + content = json.dumps( + { + "clips": [ + {"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}, + "not a dict", + {"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"}, + ], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert len(result["clips"]) == 2 + + +class TestParseRecommendResponseFields: + """各字段解析与边界测试.""" + + def test_parse_duration_clamped_min(self): + content = json.dumps( + { + "clips": [ + {"clip_type": "intro", "order": 0, "duration": 0.5, "asset_id": "a1"}, + ], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert result["clips"][0]["duration"] == 1.0 + + def test_parse_duration_clamped_max(self): + content = json.dumps( + { + "clips": [ + {"clip_type": "intro", "order": 0, "duration": 100, "asset_id": "a1"}, + ], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert result["clips"][0]["duration"] == 30.0 + + def test_parse_start_time_clamped_min(self): + content = json.dumps( + { + "clips": [ + {"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1", "start_time": -5.0}, + ], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert result["clips"][0]["start_time"] == 0.0 + + def test_parse_asset_id_not_in_list_empty(self): + content = json.dumps( + { + "clips": [ + {"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "unknown_asset"}, + ], + } + ) + result = _parse_recommend_response(content, ["a1", "a2"], 30.0) + assert result is not None + assert result["clips"][0]["asset_id"] == "" + + def test_parse_asset_id_in_list_kept(self): + content = json.dumps( + { + "clips": [ + {"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a2"}, + ], + } + ) + result = _parse_recommend_response(content, ["a1", "a2"], 30.0) + assert result is not None + assert result["clips"][0]["asset_id"] == "a2" + + def test_parse_default_values(self): + content = json.dumps( + { + "clips": [ + {"order": 0}, + ], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + clip = result["clips"][0] + assert clip["clip_type"] == "showcase" + assert clip["text_content"] == "" + assert clip["duration"] == 3.0 + assert clip["transition_effect"] == "cut" + assert clip["asset_id"] == "" + assert clip["start_time"] == 0.0 + assert clip["config"] == {} + + +class TestParseRecommendResponseMarkdown: + """Markdown 代码块包裹的 JSON 测试.""" + + def test_parse_markdown_json(self): + content = ( + "```json\n" + + json.dumps( + { + "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}], + "title": "md test", + } + ) + + "\n```" + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert len(result["clips"]) == 1 + assert result["config"]["title"]["text"] == "md test" + + def test_parse_backticks_no_language(self): + content = ( + "```\n" + + json.dumps( + { + "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}], + } + ) + + "\n```" + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert len(result["clips"]) == 1 + + +class TestParseRecommendResponseConfidence: + """confidence 解析测试.""" + + def test_parse_confidence_normal(self): + content = json.dumps( + { + "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}], + "confidence": 0.85, + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert result["confidence"] == 0.85 + + def test_parse_confidence_clamped_min(self): + content = json.dumps( + { + "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}], + "confidence": -0.5, + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert result["confidence"] == 0.0 + + def test_parse_confidence_clamped_max(self): + content = json.dumps( + { + "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}], + "confidence": 1.5, + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert result["confidence"] == 1.0 + + def test_parse_confidence_default(self): + content = json.dumps( + { + "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert result["confidence"] == 0.7 + + +class TestParseRecommendResponseConfig: + """config 生成测试.""" + + def test_parse_no_title_no_ai_auto(self): + content = json.dumps( + { + "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + # 没有 title 时,config 的 title.text 保持默认(DEFAULT_EDIT_PLAN_CONFIG 中的值) + assert "title" in result["config"] + + def test_parse_config_is_deep_copy(self): + content = json.dumps( + { + "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}], + "title": "test", + } + ) + result1 = _parse_recommend_response(content, ["a1"], 30.0) + result2 = _parse_recommend_response(content, ["a1"], 30.0) + # 修改其中一个不影响另一个 + result1["config"]["title"]["text"] = "modified" + assert result2["config"]["title"]["text"] != "modified" + + +class TestParseRecommendResponseTotalDuration: + """total_duration 计算测试.""" + + def test_parse_total_duration_sum(self): + content = json.dumps( + { + "clips": [ + {"clip_type": "intro", "order": 0, "duration": 3.5, "asset_id": "a1"}, + {"clip_type": "showcase", "order": 1, "duration": 5.2, "asset_id": "a1"}, + {"clip_type": "outro", "order": 2, "duration": 2.0, "asset_id": "a1"}, + ], + } + ) + result = _parse_recommend_response(content, ["a1"], 30.0) + assert result is not None + assert result["total_duration"] == pytest.approx(10.7, abs=0.01) + + +# ── _fallback_recommend_clips 测试 ──────────────────────────────────────── + + +class TestFallbackRecommendClips: + """本地降级推荐方案测试.""" + + def test_fallback_returns_dict_with_clips(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0) + assert "clips" in result + assert "config" in result + assert "total_duration" in result + assert "confidence" in result + + def test_fallback_has_intro_and_outro(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0) + clips = result["clips"] + assert clips[0]["clip_type"] == "intro" + assert clips[-1]["clip_type"] == "outro" + + def test_fallback_showcase_count_matches_assets(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0) + showcase_clips = [c for c in result["clips"] if c["clip_type"] == "showcase"] + assert len(showcase_clips) == 3 + + def test_fallback_no_assets_still_works(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", [], "one_take", 30.0) + assert len(result["clips"]) >= 2 # 至少有intro和outro + + def test_fallback_intro_uses_first_asset(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0) + assert result["clips"][0]["asset_id"] == "a1" + + def test_fallback_outro_has_empty_asset(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0) + assert result["clips"][-1]["asset_id"] == "" + + def test_fallback_confidence_in_range(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0) + assert 0.75 <= result["confidence"] <= 0.95 + + def test_fallback_title_contains_asset_count(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0) + assert "3" in result["config"]["title"]["text"] + assert result["config"]["title"]["ai_auto"] is True + + def test_fallback_total_duration_matches(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0) + total = sum(c["duration"] for c in result["clips"]) + assert result["total_duration"] == round(total, 1) + + def test_fallback_orders_are_sequential(self): + with patch("shared.ai_service.time.sleep"): + result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0) + orders = [c["order"] for c in result["clips"]] + assert orders == list(range(len(result["clips"]))) + + +# ── _call_ai_cover_service 测试 ─────────────────────────────────────────── + + +class TestAiCoverService: + """AI封面生成服务测试.""" + + def test_cover_type_upload(self): + with patch("shared.ai_service.time.sleep"): + result = _call_ai_cover_service("plan1", ["a1"], "upload") + assert result["type"] == "upload" + assert result["image_url"] == "" + + def test_cover_type_manual_with_frame_time(self): + with patch("shared.ai_service.time.sleep"): + result = _call_ai_cover_service("plan1", ["a1"], "manual", frame_time=5.5) + assert result["type"] == "manual" + assert result["frame_time"] == 5.5 + assert "5.5" in result["image_url"] + + def test_cover_type_ai_frame(self): + with patch("shared.ai_service.time.sleep"): + with patch("shared.ai_service.random.uniform", side_effect=[5.0, 0.9]): + result = _call_ai_cover_service("plan1", ["a1"], "ai_frame") + assert result["type"] == "ai_frame" + assert result["frame_time"] == 5.0 + assert result["confidence"] == 0.9 + assert "plan1" in result["image_url"] + + def test_cover_type_ai_regenerate(self): + with patch("shared.ai_service.time.sleep"): + result = _call_ai_cover_service("plan1", ["a1"], "ai_regenerate") + assert result["type"] == "ai_frame" + + def test_cover_frame_time_in_range(self): + with patch("shared.ai_service.time.sleep"): + result = _call_ai_cover_service("plan1", ["a1"], "ai_frame") + assert 1.0 <= result["frame_time"] <= 10.0 diff --git a/tests/unit/test_sticker_engine_pure.py b/tests/unit/test_sticker_engine_pure.py index f945f5192..805d5ff58 100755 --- a/tests/unit/test_sticker_engine_pure.py +++ b/tests/unit/test_sticker_engine_pure.py @@ -1,9 +1,6 @@ -"""贴纸引擎纯逻辑单元测试.""" +"""sticker_engine_pure 单元测试.""" -from __future__ import annotations - -import pytest -from video_processing.sticker_engine_pure import ( +from apps.worker.video_processing.sticker_engine_pure import ( build_drawtext_alpha_expr, build_enable_expr, build_image_fade_filters, @@ -29,752 +26,806 @@ from video_processing.sticker_engine_pure import ( validate_text_sticker, ) -# ───────────────────────────────────────────────────────────────────────────── -# 安全类型转换测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── safe_float ────────────────────────────────────────────────────────────────── class TestSafeFloat: - """safe_float 测试.""" + def test_none_returns_none(self): + assert safe_float(None) is None def test_int_input(self): - """整数输入.""" assert safe_float(42) == 42.0 def test_float_input(self): - """浮点数输入.""" assert safe_float(3.14) == 3.14 - def test_string_number(self): - """字符串数字.""" + def test_string_int_string(self): assert safe_float("3.14") == 3.14 - def test_string_int(self): - """字符串整数.""" + def test_string_integer_string(self): assert safe_float("100") == 100.0 - def test_none_input(self): - """None 输入.""" - assert safe_float(None) is None - def test_invalid_string(self): - """无效字符串.""" assert safe_float("abc") is None def test_empty_string(self): - """空字符串.""" assert safe_float("") is None + def test_nan_returns_none(self): + import math + + assert safe_float(float("nan")) is None + assert math.isnan(float("nan")) # 确认NaN判断生效 + + def test_negative_number(self): + assert safe_float(-5.5) == -5.5 + def test_zero(self): - """零值.""" assert safe_float(0) == 0.0 - def test_negative(self): - """负值.""" - assert safe_float(-5.5) == -5.5 + def test_boolean(self): + assert safe_float(True) == 1.0 + assert safe_float(False) == 0.0 + + +# ── safe_int ────────────────────────────────────────────────────────────────── class TestSafeInt: - """safe_int 测试.""" + def test_none_returns_default(self): + assert safe_int(None) == 0 + assert safe_int(None, default=5) == 5 def test_int_input(self): - """整数输入.""" assert safe_int(42) == 42 - def test_float_input(self): - """浮点数输入(截断).""" + def test_float_input_truncates(self): assert safe_int(3.7) == 3 + assert safe_int(3.2) == 3 - def test_string_number(self): - """字符串数字.""" - assert safe_int("42") == 42 + def test_string_integer(self): + assert safe_int("100") == 100 - def test_none_input(self): - """None 输入用默认值.""" - assert safe_int(None) == 0 + def test_string_float(self): + assert safe_int("3.9") == 3 - def test_none_custom_default(self): - """None 输入自定义默认值.""" - assert safe_int(None, default=10) == 10 - - def test_invalid_string(self): - """无效字符串.""" + def test_invalid_string_returns_default(self): assert safe_int("abc") == 0 + assert safe_int("abc", default=-1) == -1 - def test_negative(self): - """负值.""" - assert safe_int(-5) == -5 + def test_empty_string(self): + assert safe_int("") == 0 + + def test_negative_number(self): + assert safe_int(-10) == -10 def test_zero(self): - """零值.""" assert safe_int(0) == 0 + def test_boolean(self): + assert safe_int(True) == 1 + assert safe_int(False) == 0 + + +# ── safe_bool ──────────────────────────────────────────────────────────────── + class TestSafeBool: - """safe_bool 测试.""" - - def test_true_bool(self): - """True.""" + def test_boolean_passthrough(self): assert safe_bool(True) is True - - def test_false_bool(self): - """False.""" assert safe_bool(False) is False - def test_none(self): - """None -> False.""" + def test_none_returns_false(self): assert safe_bool(None) is False - def test_string_true(self): - """字符串 true.""" + def test_string_true_variants(self): assert safe_bool("true") is True - - def test_string_yes(self): - """字符串 yes.""" - assert safe_bool("yes") is True - - def test_string_one(self): - """字符串 1.""" + assert safe_bool("True") is True + assert safe_bool("TRUE") is True assert safe_bool("1") is True + assert safe_bool("yes") is True + assert safe_bool("YES") is True + assert safe_bool("on") is True + assert safe_bool("On") is True - def test_string_false(self): - """字符串 false.""" + def test_string_false_variants(self): assert safe_bool("false") is False + assert safe_bool("0") is False + assert safe_bool("no") is False + assert safe_bool("off") is False - def test_int_one(self): - """整数 1 -> True.""" + def test_numeric_values(self): assert safe_bool(1) is True - - def test_int_zero(self): - """整数 0 -> False.""" assert safe_bool(0) is False + assert safe_bool(-1) is True - def test_empty_list(self): - """空列表 -> False.""" + def test_empty_string(self): + assert safe_bool("") is False + + def test_list_truthy_falsy(self): + assert safe_bool([1]) is True assert safe_bool([]) is False -# ───────────────────────────────────────────────────────────────────────────── -# 尺寸估算测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── estimate_sticker_size ──────────────────────────────────────────────── class TestEstimateStickerSize: - """贴纸尺寸估算测试.""" - def test_default_scale(self): - """默认 scale=1.0.""" - w, h = estimate_sticker_size(1000, 1000) - assert w == 300 # 1000 * 0.3 * 1.0 - assert h == 300 + w, h = estimate_sticker_size(1000, 800) + assert w == 300 # 1000 * 0.3 + assert h == 240 # 800 * 0.3 def test_custom_scale(self): - """自定义缩放.""" - w, h = estimate_sticker_size(1000, 1000, scale=0.5) - assert w == 150 + w, h = estimate_sticker_size(1000, 800, scale=2.0) + assert w == 600 + assert h == 480 + + def test_fixed_width_and_height(self): + w, h = estimate_sticker_size(1000, 800, fixed_width=200, fixed_height=150) + assert w == 200 assert h == 150 - def test_fixed_width_height(self): - """固定宽高.""" - w, h = estimate_sticker_size(1000, 1000, fixed_width=200, fixed_height=100) - assert w == 200 - assert h == 100 + def test_fixed_width_only(self): + w, h = estimate_sticker_size(1000, 800, fixed_width=500) + assert w == 500 + assert h == 240 # 仍然按比例算高 - def test_scale_2x(self): - """2倍缩放.""" - w, h = estimate_sticker_size(800, 600, scale=2.0) - assert w == 480 # 800 * 0.3 * 2 - assert h == 360 # 600 * 0.3 * 2 + def test_fixed_height_only(self): + w, h = estimate_sticker_size(1000, 800, fixed_height=400) + assert w == 300 + assert h == 400 + + def test_minimum_size(self): + w, h = estimate_sticker_size(1, 1, scale=0.01) + assert w >= 1 + assert h >= 1 def test_zero_canvas(self): - """零画布尺寸,返回最小 1.""" w, h = estimate_sticker_size(0, 0) assert w >= 1 assert h >= 1 + def test_scale_zero(self): + w, h = estimate_sticker_size(1000, 800, scale=0) + assert w >= 1 + assert h >= 1 + + +# ── estimate_text_size ────────────────────────────────────────────── + class TestEstimateTextSize: - """文字尺寸估算测试.""" - def test_normal_text(self): - """普通文字.""" - w, h = estimate_text_size("Hello", 36) - assert w == int(5 * 36 * 0.6) - assert h == int(36 * 1.4) + w, h = estimate_text_size("hello", 20) + assert w == int(5 * 20 * 0.6) + assert h == int(20 * 1.4) def test_empty_text(self): - """空文字.""" - w, h = estimate_text_size("", 36) + w, h = estimate_text_size("", 20) assert w == 0 assert h == 0 - def test_large_font(self): - """大字号.""" - w, h = estimate_text_size("A", 72) - assert w == int(1 * 72 * 0.6) - assert h == int(72 * 1.4) + def test_chinese_text(self): + w, h = estimate_text_size("你好世界", 30) + assert w == int(4 * 30 * 0.6) + assert h == int(30 * 1.4) - def test_chinese_chars(self): - """中文字符.""" - w, h = estimate_text_size("你好世界", 48) - assert w == int(4 * 48 * 0.6) - assert h == int(48 * 1.4) + def test_minimum_size(self): + w, h = estimate_text_size("a", 1) + assert w >= 1 + assert h >= 1 + + def test_single_char(self): + w, h = estimate_text_size("x", 100) + assert w == int(1 * 100 * 0.6) + assert h == int(100 * 1.4) -# ───────────────────────────────────────────────────────────────────────────── -# 时间计算测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── calculate_fade_out_start ──────────────────────────────────────── class TestCalculateFadeOutStart: - """淡出开始时间计算测试.""" - def test_normal_case(self): - """正常情况.""" - assert calculate_fade_out_start(10, 30, 2) == pytest.approx(38.0) + assert calculate_fade_out_start(10, 20, 3) == 27.0 # 10 + 20 - 3 - def test_no_fade_out(self): - """无淡出.""" - assert calculate_fade_out_start(10, 30, 0) == 0.0 - - def test_negative_fade_out(self): - """负淡出.""" - assert calculate_fade_out_start(10, 30, -1) == 0.0 + def test_zero_fade_out(self): + assert calculate_fade_out_start(10, 20, 0) == 0.0 def test_zero_duration(self): - """零时长.""" - assert calculate_fade_out_start(10, 0, 2) == 0.0 + assert calculate_fade_out_start(10, 0, 3) == 0.0 - def test_fade_out_longer_than_duration(self): - """淡出超过时长,返回 0.""" - # start=10, dur=5, fade=10 -> 10+5-10 = 5 > 0 - assert calculate_fade_out_start(10, 5, 10) == pytest.approx(5.0) + def test_negative_fade_out(self): + assert calculate_fade_out_start(10, 20, -1) == 0.0 - def test_fade_out_starts_before_zero(self): - """淡出开始时间在 0 之前,钳制到 0.""" - # start=0, dur=3, fade=5 -> 0+3-5 = -2 -> 0 - assert calculate_fade_out_start(0, 3, 5) == 0.0 + def test_fade_longer_than_duration(self): + result = calculate_fade_out_start(5, 3, 10) + assert result == 0.0 # max(0, 5+3-10) = max(0, -2) = 0 + + def test_start_at_zero(self): + assert calculate_fade_out_start(0, 10, 2) == 8.0 + + def test_float_values(self): + assert calculate_fade_out_start(1.5, 5.5, 2.0) == 5.0 + + +# ── calculate_end_time ──────────────────────────────────────────── class TestCalculateEndTime: - """结束时间计算测试.""" - def test_normal_case(self): - """正常情况.""" - assert calculate_end_time(10, 30) == 40.0 + assert calculate_end_time(10, 5) == 15.0 def test_zero_duration(self): - """零时长.""" assert calculate_end_time(10, 0) == 10.0 def test_negative_duration(self): - """负时长.""" assert calculate_end_time(10, -5) == 10.0 - def test_zero_start(self): - """零开始.""" - assert calculate_end_time(0, 100) == 100.0 + def test_start_at_zero(self): + assert calculate_end_time(0, 10) == 10.0 + + def test_float_values(self): + assert calculate_end_time(1.5, 2.5) == 4.0 + + +# ── has_time_range ────────────────────────────────────────────── class TestHasTimeRange: - """时间范围判断测试.""" - def test_positive_duration(self): - """正时长.""" - assert has_time_range(30) is True + assert has_time_range(10) is True + assert has_time_range(0.1) is True def test_zero_duration(self): - """零时长.""" assert has_time_range(0) is False def test_negative_duration(self): - """负时长.""" assert has_time_range(-5) is False -# ───────────────────────────────────────────────────────────────────────────── -# 滤镜构建测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── build_scale_filter ──────────────────────────────────────── class TestBuildScaleFilter: - """缩放滤镜构建测试.""" - def test_fixed_width_height(self): - """固定宽高.""" - result = build_scale_filter(width=200, height=100) - assert result == "scale=200:100" + assert build_scale_filter(width=100, height=200) == "scale=100:200" def test_scale_only(self): - """仅缩放.""" - result = build_scale_filter(scale=0.5) - assert result == "scale=iw*0.5:ih*0.5" + assert build_scale_filter(scale=0.5) == "scale=iw*0.5:ih*0.5" - def test_no_scaling_needed(self): - """无需缩放.""" - result = build_scale_filter(scale=1.0) - assert result is None + def test_default_no_scale(self): + assert build_scale_filter() is None + assert build_scale_filter(scale=1.0) is None - def test_scale_2x(self): - """2倍缩放.""" - result = build_scale_filter(scale=2.0) - assert result == "scale=iw*2.0:ih*2.0" + def test_width_only_returns_none(self): + # 只有width没有height,且scale=1.0,返回None + assert build_scale_filter(width=100) is None - def test_fixed_overrides_scale(self): - """固定宽高优先于 scale.""" - result = build_scale_filter(width=100, height=50, scale=0.5) - assert result == "scale=100:50" + def test_height_only_returns_none(self): + assert build_scale_filter(height=200) is None + + def test_scale_with_width_height_overrides_scale(self): + # width和height都有时优先 + assert build_scale_filter(width=100, height=200, scale=0.5) == "scale=100:200" + + +# ── build_opacity_filter ────────────────────────────────────── class TestBuildOpacityFilter: - """透明度滤镜构建测试.""" + def test_full_opacity(self): + assert build_opacity_filter(1.0) is None + assert build_opacity_filter(1.5) is None # 大于1也返回None def test_partial_opacity(self): - """部分透明.""" - result = build_opacity_filter(0.5) - assert result == "colorchannelmixer=aa=0.5" + assert build_opacity_filter(0.5) == "colorchannelmixer=aa=0.5" - def test_fully_opaque(self): - """完全不透明.""" - result = build_opacity_filter(1.0) - assert result is None + def test_zero_opacity(self): + assert build_opacity_filter(0.0) == "colorchannelmixer=aa=0.0" - def test_fully_transparent(self): - """完全透明.""" - result = build_opacity_filter(0.0) - assert result == "colorchannelmixer=aa=0.0" + def test_negative_clamped(self): + assert build_opacity_filter(-0.5) == "colorchannelmixer=aa=0.0" - def test_opacity_above_1_clamped(self): - """超过 1 被钳制.""" - result = build_opacity_filter(1.5) - assert result is None + def test_above_one_clamped(self): + # 大于1的情况:>=1.0返回None + assert build_opacity_filter(2.0) is None - def test_opacity_below_0_clamped(self): - """低于 0 被钳制.""" - result = build_opacity_filter(-0.5) - assert result == "colorchannelmixer=aa=0.0" + +# ── build_image_fade_filters ──────────────────────────────────── class TestBuildImageFadeFilters: - """图片淡入淡出滤镜测试.""" + def test_no_fade(self): + assert build_image_fade_filters(10, 20) == [] def test_fade_in_only(self): - """仅淡入.""" - result = build_image_fade_filters(10, 30, fade_in=1.0) + result = build_image_fade_filters(10, 20, fade_in=2) assert len(result) == 1 - assert "fade=in:st=10:d=1.0:alpha=1" in result[0] + assert "fade=in:st=10:d=2:alpha=1" in result[0] def test_fade_out_only(self): - """仅淡出.""" - result = build_image_fade_filters(10, 30, fade_out=2.0) + result = build_image_fade_filters(10, 20, fade_out=3) assert len(result) == 1 - assert "fade=out" in result[0] - assert "st=38.0" in result[0] # 10 + 30 - 2 = 38 + assert "fade=out:st=27:d=3:alpha=1" in result[0] - def test_fade_in_and_out(self): - """淡入+淡出.""" - result = build_image_fade_filters(0, 10, fade_in=1.0, fade_out=1.0) + def test_both_fades(self): + result = build_image_fade_filters(10, 20, fade_in=2, fade_out=3) assert len(result) == 2 assert "fade=in" in result[0] assert "fade=out" in result[1] - def test_no_fade(self): - """无淡入淡出.""" - result = build_image_fade_filters(10, 30) - assert len(result) == 0 + def test_fade_out_zero_duration_skipped(self): + result = build_image_fade_filters(10, 0, fade_out=3) + assert result == [] - def test_zero_duration_no_fade_out(self): - """零时长不生成淡出.""" - result = build_image_fade_filters(10, 0, fade_out=1.0) - assert len(result) == 0 + def test_fade_out_negative_duration(self): + result = build_image_fade_filters(10, -5, fade_out=3) + assert result == [] + + +# ── build_enable_expr ──────────────────────────────────────── class TestBuildEnableExpr: - """enable 表达式构建测试.""" - - def test_normal_duration(self): - """正常时长.""" - result = build_enable_expr(10, 30) - assert "between(t,10,40" in result - assert "enable" in result + def test_positive_duration(self): + result = build_enable_expr(10, 5) + assert result == ":enable='between(t,10,15)'" def test_zero_duration(self): - """零时长返回空.""" - result = build_enable_expr(10, 0) - assert result == "" + assert build_enable_expr(10, 0) == "" def test_negative_duration(self): - """负时长返回空.""" - result = build_enable_expr(10, -5) - assert result == "" + assert build_enable_expr(10, -1) == "" - def test_zero_start(self): - """从零开始.""" - result = build_enable_expr(0, 100) - assert "t,0,100" in result + def test_start_at_zero(self): + result = build_enable_expr(0, 10) + assert result == ":enable='between(t,0,10)'" + + def test_float_values(self): + result = build_enable_expr(1.5, 2.5) + assert "between(t,1.5,4.0)" in result -# ───────────────────────────────────────────────────────────────────────────── -# drawtext 相关测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── escape_drawtext_text ────────────────────────────────────── class TestEscapeDrawtextText: - """文字转义测试.""" - def test_no_special_chars(self): - """无特殊字符.""" - assert escape_drawtext_text("Hello") == "Hello" + assert escape_drawtext_text("hello") == "hello" def test_colon_escaped(self): - """冒号转义.""" - assert escape_drawtext_text("a:b") == "a\\:b" + assert escape_drawtext_text("a:b:c") == "a\\:b\\:c" - def test_quote_escaped(self): - """单引号转义.""" - assert escape_drawtext_text("it's") == "it\\'s" + def test_single_quote_escaped(self): + assert escape_drawtext_text("a'b'c") == "a\\'b\\'c" - def test_multiple_special_chars(self): - """多个特殊字符.""" - assert escape_drawtext_text("a:b:c'd") == "a\\:b\\:c\\'d" + def test_both_special_chars(self): + result = escape_drawtext_text("it's: test") + assert result == "it\\'s\\: test" def test_empty_string(self): - """空字符串.""" assert escape_drawtext_text("") == "" + def test_backslash_not_escaped(self): + # 只转义冒号和单引号 + assert escape_drawtext_text("a\\b") == "a\\b" + + +# ── build_drawtext_alpha_expr ────────────────────────────────── + class TestBuildDrawtextAlphaExpr: - """drawtext alpha 表达式测试.""" - def test_no_fade(self): - """无淡入淡出.""" - assert build_drawtext_alpha_expr(10, 30) == "1" + assert build_drawtext_alpha_expr(10, 20) == "1" def test_fade_in_only(self): - """仅淡入.""" - result = build_drawtext_alpha_expr(10, 30, fade_in=2.0) - assert "if(lt(t,12.0)" in result - assert "(t-10)/2.0" in result + result = build_drawtext_alpha_expr(10, 20, fade_in=2) + assert result == "if(lt(t,12),(t-10)/2,1)" def test_fade_out_only(self): - """仅淡出.""" - result = build_drawtext_alpha_expr(10, 30, fade_out=3.0) - assert "if(gt(t,37" in result - assert "-t)/3.0" in result + result = build_drawtext_alpha_expr(10, 20, fade_out=3) + assert result == "if(gt(t,27),(30-t)/3,1)" - def test_fade_in_and_out(self): - """淡入+淡出(相乘).""" - result = build_drawtext_alpha_expr(0, 10, fade_in=1.0, fade_out=1.0) - assert "*" in result - assert result.count("if(") == 2 + def test_both_fades(self): + result = build_drawtext_alpha_expr(10, 20, fade_in=2, fade_out=3) + assert "if(lt(t," in result + assert "if(gt(t," in result + assert result.count("*") == 1 # 两部分相乘 - def test_zero_duration_no_fade_out(self): - """零时长不生成淡出.""" - result = build_drawtext_alpha_expr(10, 0, fade_out=1.0) + def test_fade_out_zero_duration(self): + result = build_drawtext_alpha_expr(10, 0, fade_out=3) + assert result == "1" + + def test_zero_fade_in(self): + result = build_drawtext_alpha_expr(10, 20, fade_in=0) assert result == "1" +# ── build_stroke_params ────────────────────────────────────── + + class TestBuildStrokeParams: - """描边参数测试.""" - def test_no_stroke(self): - """无描边.""" - result = build_stroke_params(0) - assert len(result) == 0 + assert build_stroke_params() == [] + assert build_stroke_params(stroke_width=0) == [] + assert build_stroke_params(stroke_width=-1) == [] - def test_with_stroke(self): - """有描边.""" - result = build_stroke_params(2, "red") + def test_default_color(self): + result = build_stroke_params(stroke_width=2) assert len(result) == 2 assert "borderw=2" in result + assert "bordercolor=black" in result + + def test_custom_color(self): + result = build_stroke_params(stroke_width=3, stroke_color="red") + assert "borderw=3" in result assert "bordercolor=red" in result - def test_negative_width(self): - """负宽度.""" - result = build_stroke_params(-1) - assert len(result) == 0 + +# ── build_shadow_params ────────────────────────────────────── class TestBuildShadowParams: - """阴影参数测试.""" - def test_no_shadow(self): - """无阴影.""" - result = build_shadow_params(0) - assert len(result) == 0 + assert build_shadow_params() == [] + assert build_shadow_params(shadow_alpha=0) == [] + assert build_shadow_params(shadow_alpha=-1) == [] - def test_with_shadow(self): - """有阴影.""" - result = build_shadow_params(0.5, 3, 4, "black") + def test_default_values(self): + result = build_shadow_params(shadow_alpha=0.5) assert len(result) == 3 - assert "shadowx=3" in result - assert "shadowy=4" in result + assert "shadowx=2" in result + assert "shadowy=2" in result assert "shadowcolor=black@0.5" in result - def test_shadow_alpha_clamped(self): - """透明度钳制.""" - result = build_shadow_params(1.5) - assert "shadowcolor=black@1.0" in result[2] + def test_custom_offset(self): + result = build_shadow_params(shadow_alpha=0.3, shadow_x=5, shadow_y=7, shadow_color="red") + assert "shadowx=5" in result + assert "shadowy=7" in result + assert "shadowcolor=red@0.3" in result + + def test_alpha_clamped(self): + result = build_shadow_params(shadow_alpha=1.5) + assert "shadowcolor=black@1.0" in result + + def test_alpha_negative_clamped_to_zero(self): + # 负数会触发<=0分支,返回空列表 + assert build_shadow_params(shadow_alpha=-0.5) == [] -# ───────────────────────────────────────────────────────────────────────────── -# 贴纸排序与过滤测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── sort_stickers_by_z_index ──────────────────────────────────── class TestSortStickersByZIndex: - """贴纸排序测试.""" - def test_sorted_by_z_index(self): - """按 z_index 排序.""" stickers = [ - {"z_index": 20, "name": "top"}, - {"z_index": 5, "name": "bottom"}, - {"z_index": 10, "name": "middle"}, + {"name": "c", "z_index": 3}, + {"name": "a", "z_index": 1}, + {"name": "b", "z_index": 2}, ] result = sort_stickers_by_z_index(stickers) - assert result[0]["name"] == "bottom" - assert result[1]["name"] == "middle" - assert result[2]["name"] == "top" + assert [s["name"] for s in result] == ["a", "b", "c"] - def test_same_z_index_preserves_order(self): - """相同 z_index 保持原顺序.""" + def test_missing_z_index_defaults_to_10(self): stickers = [ - {"z_index": 10, "name": "first"}, - {"z_index": 10, "name": "second"}, + {"name": "low", "z_index": 5}, + {"name": "no_z"}, # 默认10 + {"name": "high", "z_index": 15}, ] result = sort_stickers_by_z_index(stickers) - assert result[0]["name"] == "first" - assert result[1]["name"] == "second" + assert [s["name"] for s in result] == ["low", "no_z", "high"] + + def test_same_z_index_stable(self): + stickers = [ + {"name": "first", "z_index": 5}, + {"name": "second", "z_index": 5}, + {"name": "third", "z_index": 5}, + ] + result = sort_stickers_by_z_index(stickers) + assert [s["name"] for s in result] == ["first", "second", "third"] def test_empty_list(self): - """空列表.""" assert sort_stickers_by_z_index([]) == [] - def test_default_z_index_10(self): - """无 z_index 默认 10.""" + def test_negative_z_index(self): stickers = [ - {"z_index": 5, "name": "low"}, - {"name": "default"}, + {"name": "neg", "z_index": -5}, + {"name": "zero", "z_index": 0}, + {"name": "pos", "z_index": 5}, ] result = sort_stickers_by_z_index(stickers) - assert result[0]["name"] == "low" - assert result[1]["name"] == "default" + assert [s["name"] for s in result] == ["neg", "zero", "pos"] + + def test_original_not_modified(self): + stickers = [{"z_index": 3}, {"z_index": 1}] + original = list(stickers) + sort_stickers_by_z_index(stickers) + assert stickers == original + + +# ── filter_enabled_stickers ──────────────────────────────────── class TestFilterEnabledStickers: - """启用贴纸过滤测试.""" - def test_all_enabled(self): - """全部启用.""" - stickers = [{"enabled": True}, {"enabled": True}] - assert len(filter_enabled_stickers(stickers)) == 2 - - def test_mixed(self): - """混合.""" stickers = [ - {"enabled": True, "name": "a"}, - {"enabled": False, "name": "b"}, - {"enabled": True, "name": "c"}, + {"name": "a", "enabled": True}, + {"name": "b"}, # 默认True ] result = filter_enabled_stickers(stickers) assert len(result) == 2 - assert result[0]["name"] == "a" - def test_default_enabled(self): - """默认启用.""" - stickers = [{"name": "a"}] + def test_mixed_enabled(self): + stickers = [ + {"name": "a", "enabled": True}, + {"name": "b", "enabled": False}, + {"name": "c"}, + ] result = filter_enabled_stickers(stickers) - assert len(result) == 1 + assert len(result) == 2 + assert [s["name"] for s in result] == ["a", "c"] + + def test_all_disabled(self): + stickers = [ + {"name": "a", "enabled": False}, + {"name": "b", "enabled": "false"}, + ] + result = filter_enabled_stickers(stickers) + assert len(result) == 0 def test_empty_list(self): - """空列表.""" assert filter_enabled_stickers([]) == [] + def test_string_enabled_values(self): + stickers = [ + {"name": "a", "enabled": "true"}, + {"name": "b", "enabled": "yes"}, + {"name": "c", "enabled": "0"}, + ] + result = filter_enabled_stickers(stickers) + assert [s["name"] for s in result] == ["a", "b"] + + +# ── count_sticker_types ──────────────────────────────────────── + class TestCountStickerTypes: - """贴纸类型统计测试.""" - - def test_mixed_types(self): - """混合类型.""" + def test_multiple_types(self): stickers = [ {"type": "image"}, {"type": "text"}, {"type": "image"}, + {"type": "image"}, + {"type": "emoji"}, ] - counts = count_sticker_types(stickers) - assert counts["image"] == 2 - assert counts["text"] == 1 + result = count_sticker_types(stickers) + assert result == {"image": 3, "text": 1, "emoji": 1} - def test_default_type(self): - """默认 image.""" - stickers = [{}] - counts = count_sticker_types(stickers) - assert counts["image"] == 1 + def test_default_type_image(self): + stickers = [ + {"name": "a"}, # 无type字段 + {"type": "text"}, + ] + result = count_sticker_types(stickers) + assert result == {"image": 1, "text": 1} def test_empty_list(self): - """空列表.""" assert count_sticker_types([]) == {} + def test_single_type(self): + stickers = [{"type": "text"} for _ in range(5)] + result = count_sticker_types(stickers) + assert result == {"text": 5} -# ───────────────────────────────────────────────────────────────────────────── -# overlay 相关测试 -# ───────────────────────────────────────────────────────────────────────────── + +# ── build_overlay_position ────────────────────────────────────── class TestBuildOverlayPosition: - """overlay 位置构建测试.""" - - def test_integer_position(self): - """整数位置.""" + def test_integer_values(self): assert build_overlay_position(100, 200) == "100:200" - def test_float_position_rounded(self): - """浮点取整.""" - assert build_overlay_position(100.6, 200.4) == "101:200" + def test_float_values_rounded(self): + assert build_overlay_position(100.6, 200.3) == "101:200" - def test_zero_position(self): - """零位置.""" + def test_negative_values(self): + assert build_overlay_position(-10, -20) == "-10:-20" + + def test_zero_values(self): assert build_overlay_position(0, 0) == "0:0" - def test_negative_position(self): - """负位置.""" - assert build_overlay_position(-10, -20) == "-10:-20" + +# ── build_pre_filter_label ───────────────────────────────────── class TestBuildPreFilterLabel: - """预处理标签构建测试.""" - - def test_normal_idx(self): - """正常索引.""" - assert build_pre_filter_label(3) == "sticker_3_scaled" - - def test_zero_idx(self): - """零索引.""" + def test_index_zero(self): assert build_pre_filter_label(0) == "sticker_0_scaled" + def test_positive_index(self): + assert build_pre_filter_label(5) == "sticker_5_scaled" -# ───────────────────────────────────────────────────────────────────────────── -# 验证函数测试 -# ───────────────────────────────────────────────────────────────────────────── + def test_large_index(self): + assert build_pre_filter_label(999) == "sticker_999_scaled" + + +# ── validate_image_sticker ────────────────────────────────── class TestValidateImageSticker: - """图片贴纸验证测试.""" - def test_valid_with_image_path(self): - """有 image_path,合法.""" - ok, errors = validate_image_sticker({"image_path": "/a.png"}) - assert ok is True - assert len(errors) == 0 + valid, errors = validate_image_sticker({"image_path": "/path/to/img.png"}) + assert valid is True + assert errors == [] def test_valid_with_asset_id(self): - """有 asset_id,合法.""" - ok, errors = validate_image_sticker({"asset_id": "123"}) - assert ok is True + valid, errors = validate_image_sticker({"asset_id": "asset_123"}) + assert valid is True + assert errors == [] - def test_missing_image_source(self): - """缺图片来源.""" - ok, errors = validate_image_sticker({}) - assert ok is False - assert any("image_path" in e or "asset_id" in e for e in errors) + def test_missing_image_and_asset(self): + valid, errors = validate_image_sticker({}) + assert valid is False + assert "image_path 或 asset_id" in errors[0] - def test_opacity_out_of_range(self): - """透明度超范围.""" - ok, errors = validate_image_sticker( + def test_invalid_opacity_high(self): + valid, errors = validate_image_sticker( { - "image_path": "/a.png", + "image_path": "a.png", "opacity": 1.5, } ) - assert ok is False + assert valid is False assert any("opacity" in e for e in errors) - def test_negative_scale(self): - """负缩放.""" - ok, errors = validate_image_sticker( + def test_invalid_opacity_low(self): + valid, errors = validate_image_sticker( { - "image_path": "/a.png", - "scale": -0.5, + "image_path": "a.png", + "opacity": -0.5, } ) - assert ok is False + assert valid is False + assert any("opacity" in e for e in errors) + + def test_valid_opacity_boundary(self): + valid, _ = validate_image_sticker({"image_path": "a.png", "opacity": 0}) + assert valid is True + valid, _ = validate_image_sticker({"image_path": "a.png", "opacity": 1}) + assert valid is True + + def test_invalid_scale_zero(self): + valid, errors = validate_image_sticker( + { + "image_path": "a.png", + "scale": 0, + } + ) + assert valid is False assert any("scale" in e for e in errors) - def test_negative_duration(self): - """负时长.""" - ok, errors = validate_image_sticker( + def test_invalid_scale_negative(self): + valid, errors = validate_image_sticker( { - "image_path": "/a.png", - "duration": -10, + "image_path": "a.png", + "scale": -1, } ) - assert ok is False - assert any("duration" in e for e in errors) + assert valid is False - def test_multiple_errors(self): - """多个错误.""" - ok, errors = validate_image_sticker( + def test_invalid_duration_negative(self): + valid, errors = validate_image_sticker( { - "opacity": 1.5, - "duration": -1, - "start_time": -5, - } - ) - assert ok is False - assert len(errors) >= 3 - - -class TestValidateTextSticker: - """文字贴纸验证测试.""" - - def test_valid(self): - """合法配置.""" - ok, errors = validate_text_sticker( - { - "text": "Hello", - "font_size": 36, - "font_color": "white", - } - ) - assert ok is True - assert len(errors) == 0 - - def test_empty_text(self): - """空文字.""" - ok, errors = validate_text_sticker({"text": ""}) - assert ok is False - assert any("text" in e for e in errors) - - def test_zero_font_size(self): - """零字号.""" - ok, errors = validate_text_sticker( - { - "text": "Hi", - "font_size": 0, - } - ) - assert ok is False - assert any("font_size" in e for e in errors) - - def test_empty_font_color(self): - """空颜色.""" - ok, errors = validate_text_sticker( - { - "text": "Hi", - "font_color": "", - } - ) - assert ok is False - assert any("font_color" in e for e in errors) - - def test_negative_duration(self): - """负时长.""" - ok, errors = validate_text_sticker( - { - "text": "Hi", + "image_path": "a.png", "duration": -5, } ) - assert ok is False + assert valid is False assert any("duration" in e for e in errors) + + def test_invalid_start_time_negative(self): + valid, errors = validate_image_sticker( + { + "image_path": "a.png", + "start_time": -1, + } + ) + assert valid is False + assert any("start_time" in e for e in errors) + + def test_multiple_errors(self): + valid, errors = validate_image_sticker( + { + "opacity": 2.0, + "scale": -1, + "duration": -5, + } + ) + assert valid is False + assert len(errors) >= 3 + + def test_valid_with_extra_fields(self): + valid, _ = validate_image_sticker( + { + "image_path": "a.png", + "extra_field": "ignored", + "z_index": 5, + } + ) + assert valid is True + + +# ── validate_text_sticker ──────────────────────────────────── + + +class TestValidateTextSticker: + def test_valid_text(self): + valid, errors = validate_text_sticker({"text": "hello"}) + assert valid is True + assert errors == [] + + def test_missing_text(self): + valid, errors = validate_text_sticker({}) + assert valid is False + assert any("text" in e for e in errors) + + def test_empty_text(self): + valid, errors = validate_text_sticker({"text": ""}) + assert valid is False + assert any("text" in e for e in errors) + + def test_invalid_font_size_zero(self): + valid, errors = validate_text_sticker( + { + "text": "hello", + "font_size": 0, + } + ) + assert valid is False + assert any("font_size" in e for e in errors) + + def test_invalid_font_size_negative(self): + valid, errors = validate_text_sticker( + { + "text": "hello", + "font_size": -5, + } + ) + assert valid is False + + def test_missing_font_color(self): + valid, errors = validate_text_sticker( + { + "text": "hello", + "font_color": "", + } + ) + assert valid is False + assert any("font_color" in e for e in errors) + + def test_invalid_duration_negative(self): + valid, errors = validate_text_sticker( + { + "text": "hello", + "duration": -3, + } + ) + assert valid is False + assert any("duration" in e for e in errors) + + def test_default_font_size_valid(self): + # 默认36,有效 + valid, _ = validate_text_sticker({"text": "hi"}) + assert valid is True + + def test_multiple_errors(self): + valid, errors = validate_text_sticker( + { + "text": "", + "font_size": -1, + "font_color": "", + } + ) + assert valid is False + assert len(errors) >= 3 diff --git a/tests/unit/test_text_splitter.py b/tests/unit/test_text_splitter.py index 3e221601e..831253df2 100755 --- a/tests/unit/test_text_splitter.py +++ b/tests/unit/test_text_splitter.py @@ -1,412 +1,100 @@ -"""文本分段工具单元测试.""" - -from __future__ import annotations - -import pytest +"""text_splitter 单元测试.""" from packages.application.tts_job.text_splitter import split_text class TestSplitText: - """split_text 函数测试""" - - def test_empty_string_returns_empty_list(self): - """空字符串返回空列表""" + def test_empty_text_returns_empty(self): assert split_text("") == [] - def test_whitespace_only_returns_empty_list(self): - """纯空白字符返回空列表""" - assert split_text(" \n \t ") == [] - - def test_short_text_returns_single_segment(self): - """短文本直接返回单段""" - text = "这是一段短文本。" - result = split_text(text, max_chars=500) - assert result == [text] - - def test_text_length_equals_max_chars(self): - """文本长度恰好等于 max_chars 时返回单段""" - text = "a" * 100 - result = split_text(text, max_chars=100) - assert len(result) == 1 - assert len(result[0]) == 100 - - def test_splits_on_sentence_boundary(self): - """在句子边界处分段""" - # 构造长文本,确保超过 max_chars - sentences = ["今天天气真好。我们一起去公园散步吧。", "公园里有很多花。还有很多小朋友在玩耍。"] * 10 - text = "".join(sentences) - - result = split_text(text, max_chars=200) - - assert len(result) >= 2 - # 每段都不超过 max_chars - for seg in result: - assert len(seg) <= 200 - - def test_all_segments_within_max_chars(self): - """所有分段都不超过 max_chars""" - text = "这是第一句话。这是第二句话。这是第三句话。这是第四句话。这是第五句话。" * 10 - - result = split_text(text, max_chars=100) - - for seg in result: - assert len(seg) <= 100 - - def test_long_single_sentence_hard_cut(self): - """超长单句会被硬切""" - text = "a" * 1000 # 没有标点 - - result = split_text(text, max_chars=200) - - assert len(result) > 1 - for seg in result: - assert len(seg) <= 200 - - def test_newline_is_sentence_end(self): - """换行符作为句子结束符""" - text = "第一行内容\n第二行内容\n第三行内容" * 10 - - result = split_text(text, max_chars=50) - - assert len(result) > 1 - for seg in result: - assert len(seg) <= 50 - - def test_chinese_punctuation(self): - """中文标点(。!?;)作为句子结束符""" - text = "你好!今天吃什么?我吃米饭;你呢?我也吃米饭。" * 10 - - result = split_text(text, max_chars=80) - - for seg in result: - assert len(seg) <= 80 - - def test_english_punctuation(self): - """英文标点(.!?;)作为句子结束符""" - text = "Hello! How are you? I'm fine; thank you. Good bye." * 10 - - result = split_text(text, max_chars=80) - - for seg in result: - assert len(seg) <= 80 - - def test_merged_short_segments(self): - """过短的段落会被合并""" - # 构造很多短句 - text = "你好。再见。谢谢。抱歉。好的。不行。可以。去吧。" * 5 # 每句3-4字 - - result = split_text(text, max_chars=100) - - # 合并后段数应该比单纯按句切的少 - assert len(result) < len(text) // 3 # 粗略估计 - for seg in result: - assert len(seg) <= 100 - - def test_preserves_content(self): - """分段后内容总和与原文基本一致(忽略strip的空白)""" - text = "这是测试文本。包含多个句子。用来验证分段正确性。" * 5 - - result = split_text(text, max_chars=50) - - # 合并所有分段,去掉空白后应该与原文去掉空白后基本一致 - combined = "".join(result).replace(" ", "") - original = text.strip().replace(" ", "") - assert combined == original - - def test_custom_max_chars(self): - """支持自定义 max_chars""" - text = "测试" * 100 # 200字 - - result_50 = split_text(text, max_chars=50) - result_100 = split_text(text, max_chars=100) - - # max_chars 越小,段数应该越多 - assert len(result_50) >= len(result_100) - - def test_single_char_text(self): - """单字符文本""" - assert split_text("好", max_chars=10) == ["好"] - - def test_text_with_only_punctuation(self): - """纯标点文本""" - text = "。。。。。。。。。。" # 10个句号 - result = split_text(text, max_chars=5) - - assert len(result) >= 1 - for seg in result: - assert len(seg) <= 5 - - def test_mixed_content(self): - """中英文混合内容""" - text = "今天的天气是 sunny and warm。我们去了 park 玩。真的很开心!" * 5 - - result = split_text(text, max_chars=80) - - for seg in result: - assert len(seg) <= 80 - - -# ── 短文本与空文本补充 ────────────────────────────────────────────────────── - - -class TestSplitTextEmptyAndShort: - """空文本与短文本补充场景.""" - - def test_whitespace_only_returns_empty(self): - """纯空白文本返回空列表.""" + def test_whitespace_only(self): assert split_text(" \n\t ") == [] - def test_single_char(self): - """单字符文本.""" - assert split_text("好", max_chars=10) == ["好"] - - def test_exactly_max_chars_no_split(self): - """刚好等于 max_chars 不分割.""" - text = "a" * 100 - result = split_text(text, max_chars=100) + def test_short_text_single_segment(self): + text = "你好世界。" + result = split_text(text, max_chars=500) assert len(result) == 1 assert result[0] == text - def test_one_over_max_chars_splits(self): - """超过 max_chars 1 个字符就会分割.""" - text = "a" * 101 + def test_exact_max_chars(self): + text = "a" * 500 + result = split_text(text, max_chars=500) + assert len(result) == 1 + assert len(result[0]) == 500 + + def test_splits_on_sentence_boundary(self): + # 两个长句子,各300字左右,超过50字阈值 + sent1 = "你" * 300 + "。" + sent2 = "我" * 300 + "。" + text = sent1 + sent2 + result = split_text(text, max_chars=500) + assert len(result) == 2 + assert result[0] == sent1 + assert result[1] == sent2 + + def test_long_sentence_hard_cut(self): + # 一个超长句子,没有句末标点,会被硬切 + text = "长" * 800 + result = split_text(text, max_chars=500) + assert len(result) >= 2 + assert all(len(seg) <= 500 for seg in result) + # 合起来应该等于原文本 + assert "".join(result) == text + + def test_short_segments_merged(self): + # 多个短句应该被合并 + sentences = [f"第{i}句。" for i in range(10)] + text = "".join(sentences) + result = split_text(text, max_chars=200) + # 每句5字左右,10句才50字,应该合并成1段 + assert len(result) < 10 + assert len(result[0]) <= 200 + + def test_preserves_content(self): + text = "今天天气真好。我们去公园玩吧!你觉得怎么样?好的,走吧。" + result = split_text(text, max_chars=20) + # 合并后内容应一致 + assert "".join(result) == text + + def test_multiple_punctuation_types(self): + # 构造足够长的文本触发分段 + text = "第一" * 30 + "。" + "第二" * 30 + "!" + "第三" * 30 + "?" + "第四" * 30 + ";" result = split_text(text, max_chars=100) assert len(result) >= 2 + assert "".join(result) == text - def test_none_raises(self): - """None 输入抛 AttributeError(strip 失败).""" - with pytest.raises(AttributeError): - split_text(None) + def test_custom_max_chars(self): + text = "a" * 100 + "。" + "b" * 100 + "。" + result = split_text(text, max_chars=150) + assert len(result) == 2 + assert "a" in result[0] + assert "b" in result[1] - -# ── 句子边界分段补充 ────────────────────────────────────────────────────── - - -class TestSplitTextSentenceBoundaries: - """句子边界分段补充场景.""" - - def test_split_on_fullwidth_period(self): - """全角句号分段.""" - text = "第一句很长的内容。" * 20 - result = split_text(text, max_chars=60) - assert len(result) > 1 - for seg in result: - assert len(seg) <= 60 - - def test_split_on_fullwidth_question(self): - """全角问号分段.""" - text = "你知道这是为什么吗?" + "是的。" * 20 - result = split_text(text, max_chars=60) - assert len(result) > 1 - - def test_split_on_fullwidth_exclamation(self): - """全角感叹号分段.""" - text = "真是太棒了!" + "内容。" * 20 - result = split_text(text, max_chars=60) - assert len(result) > 1 - - def test_split_on_newline(self): - """换行符分段.""" - lines = ["这是第一行很长的一段文字内容" * 3 for _ in range(5)] - text = "\n".join(lines) - result = split_text(text, max_chars=80) - assert len(result) > 1 - - def test_split_on_semicolon(self): - """全角分号分段.""" - text = "第一项内容;" + "其他内容。" * 20 - result = split_text(text, max_chars=60) - assert len(result) > 1 - - def test_english_period_splits(self): - """英文句号分段.""" - text = "Hello world. " * 30 - result = split_text(text, max_chars=80) - assert len(result) > 1 - - def test_short_sentences_stay_merged(self): - """短句(都 < 50字的句子不会单独成段,会累积到一起.""" - text = "你好。我好。大家好。" - result = split_text(text, max_chars=200) - assert len(result) == 1 - - -# ── 长句强制切段补充 ────────────────────────────────────────────────────── - - -class TestSplitTextLongSentenceForce: - """超长单句强制切段补充.""" - - def test_no_punctuation_forced_split(self): - """完全没有标点的超长文本硬切.""" - text = "字" * 300 - result = split_text(text, max_chars=100) - assert len(result) == 3 - for seg in result: - assert len(seg) == 100 - - def test_force_split_preserves_content(self): - """硬切不丢字符.""" - text = "a" * 250 - result = split_text(text, max_chars=100) - assert sum(len(s) for s in result) == 250 - - def test_mixed_long_and_short(self): - """长句短句混合.""" - long_part = "非常长的句子没有标点符号" * 15 - text = long_part + "。结尾。" - result = split_text(text, max_chars=100) - assert len(result) > 1 - for seg in result: - assert len(seg) <= 100 - - -# ── 短段合并补充 ───────────────────────────────────────────────────────── - - -class TestSplitTextShortSegmentMerge: - """短段合并补充场景.""" - - def test_multiple_short_sentences_merged(self): - """多个短句合并成一段.""" - sentences = ["你好。", "我好。", "大家好。", "天气好。", "心情好。"] - text = "".join(sentences) - result = split_text(text, max_chars=200) - assert len(result) == 1 - - def test_short_tail_merged(self): - """尾部短段被合并到前一段.""" - # 前面一段接近 max_chars,尾部很短 - long_part = "一二三四五六七八九十" * 9 + "。" # ~90字 - tail = "完。" # 2字 - text = long_part + tail - result = split_text(text, max_chars=100) - # 尾部短的应该被合并 - assert len(result) <= 2 - - -# ── 边界情况补充 ───────────────────────────────────────────────────────── - - -class TestSplitTextEdgeCases: - """边界情况补充.""" - - def test_only_punctuation(self): - """纯标点符号.""" - text = "。。。。。" - result = split_text(text, max_chars=10) - assert len(result) == 1 - - def test_mixed_chinese_english(self): - """中英文混合.""" - text = "你好Hello。World!" * 20 - result = split_text(text, max_chars=100) - assert len(result) > 1 - for seg in result: - assert len(seg) <= 100 - - def test_strip_whitespace(self): - """首尾空白被去除.""" - text = " 你好世界。 " - result = split_text(text, max_chars=100) - assert result == ["你好世界。"] - - def test_total_length_preserved(self): - """分段后总长度等于原文 strip 后长度.""" - text = "这是一段用于测试的文本内容。" * 20 - result = split_text(text, max_chars=100) + def test_newline_as_sentence_end(self): + text = "第一段\n第二段\n第三段" + result = split_text(text, max_chars=50) + assert len(result) >= 1 assert "".join(result) == text.strip() - def test_custom_small_max_chars(self): - """很小的 max_chars.""" - text = "一二三四五六七八九十。" * 5 + def test_minimum_segment_length(self): + # 句子太短(<50字)不会立即分段 + text = "短句一。短句二。短句三。" + result = split_text(text, max_chars=200) + assert len(result) == 1 + + def test_trailing_content_added(self): + # 最后一段不完整的句子也要加上 + text = "完整的句子。剩余内容" + result = split_text(text, max_chars=50) + assert "".join(result) == text + + def test_no_empty_segments(self): + text = "。。。。。" # 全是标点 + result = split_text(text, max_chars=2) + assert all(len(seg) > 0 for seg in result) + + def test_chinese_and_english_mixed(self): + text = "Hello世界。这是测试Test文本。Mixed混合。" result = split_text(text, max_chars=20) - assert len(result) > 1 - for seg in result: - assert len(seg) <= 20 - - -# ── 更多边界场景补充 ───────────────────────────────────────────────────────── - - -class TestSplitTextMoreEdgeCases: - """更多边界场景补充""" - - def test_max_chars_one(self): - """max_chars=1 每个字符一段""" - text = "一二三四五" - result = split_text(text, max_chars=1) - assert len(result) == 5 - for seg in result: - assert len(seg) == 1 - - def test_consecutive_newlines(self): - """连续多个换行符""" - text = "第一段\n\n\n第二段\n\n第三段" - result = split_text(text, max_chars=100) - # 合并后应该是一段(内容不长且合并逻辑会被合并) - assert len(result) >= 1 - assert "第一段" in result[0] - for seg in result: - assert len(seg) <= 100 - - def test_only_newlines_only(self): - """只有换行符(纯空白被strip掉返回空""" - assert split_text("\n\n\n\n") == [] - - def test_leading_trailing_whitespace(self): - """首尾空白被去除""" - text = " 你好世界。 " - result = split_text(text, max_chars=100) - assert result == ["你好世界。"] - - def test_very_long_single_sentence_many_segments(self): - """超长单句被切成很多段""" - text = "字" * 1000 - result = split_text(text, max_chars=100) - assert len(result) == 10 - for seg in result: - assert len(seg) == 100 - - def test_mixed_punctuation_types(self): - """全角半角标点混合""" - text = "你好!再见。谢谢?抱歉;好的" - result = split_text(text, max_chars=200) - assert len(result) == 1 - - def test_last_segment_short_merged_to_previous(self): - """尾部极短段被合并到前一段""" - # 构造第一段接近max_chars,结尾有个短句尾巴 - long_part = "一二三四五六七八九十" * 9 + "。" # ~90字 - tail = "完" # 1字 - text = long_part + tail - result = split_text(text, max_chars=100) - # 尾巴应该被合并 - combined = "".join(result) - assert combined == text.strip() - assert len(result) <= 2 - - def test_all_short_sentences_merged_into_one(self): - """大量短句全部合并成一段""" - sentences = ["你好。", "我好。", "他好。", "大家好。", "才是真的好。"] - text = "".join(sentences) - result = split_text(text, max_chars=200) - assert len(result) == 1 - - def test_punctuation_only_long(self): - """很长的纯标点文本""" - text = "。" * 200 - result = split_text(text, max_chars=50) - assert len(result) >= 4 - for seg in result: - assert len(seg) <= 50 - - def test_tab_not_sentence_end(self): - """制表符不是句子结束符""" - text = "这是一段\t包含制表符的文本内容" + "字" * 100 - result = split_text(text, max_chars=50) - # 制表符不在句子结束符集合中,不会触发分段 - # 制表符会保留在分段内容中 - has_tab = any("\t" in seg for seg in result) - assert has_tab + assert len(result) >= 2 + assert "".join(result) == text