diff --git a/tests/unit/domain/test_speed_config.py b/tests/unit/domain/test_speed_config.py new file mode 100755 index 000000000..795402d9e --- /dev/null +++ b/tests/unit/domain/test_speed_config.py @@ -0,0 +1,453 @@ +"""speed_config 调速配置领域模型单测.""" + +import pytest +from domain.speed_config import ( + DEFAULT_SPEED, + MAX_SPEED, + MIN_SPEED, + SpeedConfig, + adjust_duration, + build_audio_filter, + build_clip_speed_filter, + build_video_filter, + resolve_clip_speed, +) + +# ── 常量测试 ───────────────────────────────────────────────────────────────── + + +class TestConstants: + """模块常量""" + + def test_speed_limits(self): + assert MIN_SPEED == 0.25 + assert MAX_SPEED == 4.0 + assert DEFAULT_SPEED == 1.0 + + +# ── SpeedConfig 默认值与基础 ──────────────────────────────────────────────── + + +class TestSpeedConfigDefaults: + """SpeedConfig 默认值""" + + def test_default_values(self): + c = SpeedConfig() + assert c.speed == 1.0 + assert c.pitch_correct is True + + def test_custom_values(self): + c = SpeedConfig(speed=2.0, pitch_correct=False) + assert c.speed == 2.0 + assert c.pitch_correct is False + + +# ── SpeedConfig.parse ─────────────────────────────────────────────────────── + + +class TestSpeedConfigParse: + """SpeedConfig.parse 工厂方法""" + + def test_none_returns_default(self): + c = SpeedConfig.parse(None) + assert c.speed == 1.0 + assert c.pitch_correct is True + + def test_empty_dict_returns_default(self): + c = SpeedConfig.parse({}) + assert c.speed == 1.0 + + def test_not_dict_returns_default(self): + c = SpeedConfig.parse("not a dict") + assert c.speed == 1.0 + + def test_valid_speed(self): + c = SpeedConfig.parse({"speed": 2.0}) + assert c.speed == 2.0 + + def test_valid_speed_int(self): + c = SpeedConfig.parse({"speed": 2}) + assert c.speed == 2.0 + assert isinstance(c.speed, float) + + def test_pitch_correct_false(self): + c = SpeedConfig.parse({"pitch_correct": False}) + assert c.pitch_correct is False + + def test_pitch_correct_non_bool_falls_back(self): + c = SpeedConfig.parse({"pitch_correct": "true"}) + assert c.pitch_correct is True + + def test_invalid_speed_string_falls_back(self): + c = SpeedConfig.parse({"speed": "fast"}) + assert c.speed == 1.0 + + def test_speed_below_min_clamped(self): + c = SpeedConfig.parse({"speed": 0.1}) + assert c.speed == MIN_SPEED + + def test_speed_above_max_clamped(self): + c = SpeedConfig.parse({"speed": 10.0}) + assert c.speed == MAX_SPEED + + def test_zero_speed_falls_back_to_default(self): + c = SpeedConfig.parse({"speed": 0}) + assert c.speed == DEFAULT_SPEED + + def test_negative_speed_falls_back(self): + c = SpeedConfig.parse({"speed": -1.0}) + assert c.speed == DEFAULT_SPEED + + def test_min_speed_boundary(self): + c = SpeedConfig.parse({"speed": 0.25}) + assert c.speed == 0.25 + + def test_max_speed_boundary(self): + c = SpeedConfig.parse({"speed": 4.0}) + assert c.speed == 4.0 + + +# ── SpeedConfig.clamp ─────────────────────────────────────────────────────── + + +class TestSpeedConfigClamp: + """SpeedConfig.clamp 方法""" + + def test_normal_speed_no_change(self): + c = SpeedConfig(speed=1.5) + c.clamp() + assert c.speed == 1.5 + + def test_zero_speed_reset_default(self): + c = SpeedConfig(speed=0.0) + c.clamp() + assert c.speed == DEFAULT_SPEED + + def test_negative_speed_reset_default(self): + c = SpeedConfig(speed=-0.5) + c.clamp() + assert c.speed == DEFAULT_SPEED + + def test_below_min_clamped(self): + c = SpeedConfig(speed=0.1) + c.clamp() + assert c.speed == MIN_SPEED + + def test_above_max_clamped(self): + c = SpeedConfig(speed=5.0) + c.clamp() + assert c.speed == MAX_SPEED + + def test_exact_min_unchanged(self): + c = SpeedConfig(speed=MIN_SPEED) + c.clamp() + assert c.speed == MIN_SPEED + + def test_exact_max_unchanged(self): + c = SpeedConfig(speed=MAX_SPEED) + c.clamp() + assert c.speed == MAX_SPEED + + +# ── SpeedConfig 属性方法 ──────────────────────────────────────────────────── + + +class TestSpeedConfigProperties: + """SpeedConfig 属性方法""" + + def test_is_original_true(self): + c = SpeedConfig(speed=1.0) + assert c.is_original is True + + def test_is_original_very_close(self): + c = SpeedConfig(speed=1.0 + 1e-7) + assert c.is_original is True + + def test_is_original_false_fast(self): + c = SpeedConfig(speed=1.5) + assert c.is_original is False + + def test_is_original_false_slow(self): + c = SpeedConfig(speed=0.8) + assert c.is_original is False + + def test_is_fast_true(self): + c = SpeedConfig(speed=2.0) + assert c.is_fast is True + + def test_is_fast_false(self): + c = SpeedConfig(speed=0.5) + assert c.is_fast is False + + def test_is_fast_at_one(self): + c = SpeedConfig(speed=1.0) + assert c.is_fast is False + + def test_is_slow_true(self): + c = SpeedConfig(speed=0.5) + assert c.is_slow is True + + def test_is_slow_false(self): + c = SpeedConfig(speed=2.0) + assert c.is_slow is False + + def test_is_slow_at_one(self): + c = SpeedConfig(speed=1.0) + assert c.is_slow is False + + +# ── build_video_filter ─────────────────────────────────────────────────────── + + +class TestBuildVideoFilter: + """build_video_filter 视频滤镜构建""" + + def test_original_speed_empty(self): + c = SpeedConfig(speed=1.0) + assert build_video_filter(c) == "" + + def test_double_speed(self): + c = SpeedConfig(speed=2.0) + result = build_video_filter(c) + assert "setpts=PTS/2.0" in result + + def test_half_speed(self): + c = SpeedConfig(speed=0.5) + result = build_video_filter(c) + assert "setpts=PTS/0.5" in result + + def test_format_precision(self): + c = SpeedConfig(speed=1.5) + result = build_video_filter(c) + # 应该是 4 位小数 + assert "1.5000" in result + + def test_min_speed(self): + c = SpeedConfig(speed=0.25) + result = build_video_filter(c) + assert result.startswith("setpts=PTS/") + + def test_max_speed(self): + c = SpeedConfig(speed=4.0) + result = build_video_filter(c) + assert "4.0000" in result + + +# ── build_audio_filter / atempo 拆分 ──────────────────────────────────────── + + +class TestBuildAudioFilter: + """build_audio_filter 音频滤镜构建""" + + def test_original_speed_empty(self): + c = SpeedConfig(speed=1.0) + assert build_audio_filter(c) == "" + + def test_within_range_single_stage(self): + c = SpeedConfig(speed=1.5) + result = build_audio_filter(c) + assert result == "atempo=1.5000" + + def test_05_speed_single_stage(self): + c = SpeedConfig(speed=0.5) + result = build_audio_filter(c) + assert result == "atempo=0.5000" + + def test_20_speed_single_stage(self): + c = SpeedConfig(speed=2.0) + result = build_audio_filter(c) + assert result == "atempo=2.0000" + + def test_4x_speed_two_stages(self): + c = SpeedConfig(speed=4.0) + result = build_audio_filter(c) + # 2.0 * 2.0 = 4.0 + assert result == "atempo=2.0000,atempo=2.0000" + + def test_025_speed_two_stages(self): + c = SpeedConfig(speed=0.25) + result = build_audio_filter(c) + # 0.5 * 0.5 = 0.25 + assert result == "atempo=0.5000,atempo=0.5000" + + def test_3x_speed_two_stages(self): + c = SpeedConfig(speed=3.0) + result = build_audio_filter(c) + # 2.0 * 1.5 = 3.0 + stages = result.split(",") + assert len(stages) == 2 + assert "atempo=2.0000" in stages[0] + assert "atempo=1.5000" in stages[1] + + def test_03_speed_two_stages(self): + c = SpeedConfig(speed=0.3) + result = build_audio_filter(c) + stages = result.split(",") + assert len(stages) == 2 + # 0.5 * 0.6 = 0.3 + assert "atempo=0.5000" in stages[0] + + def test_format_each_stage(self): + c = SpeedConfig(speed=1.2345) + result = build_audio_filter(c) + assert "atempo=1.2345" in result + + +class TestAtempoStages: + """atempo 多级拆分逻辑验证""" + + def _extract_speeds(self, filter_str: str) -> list[float]: + """从 atempo 滤镜字符串中提取速度值.""" + import re + + return [float(m) for m in re.findall(r"atempo=([\d.]+)", filter_str)] + + def test_product_equals_speed_fast_3x(self): + c = SpeedConfig(speed=3.0) + speeds = self._extract_speeds(build_audio_filter(c)) + product = 1.0 + for s in speeds: + product *= s + assert abs(product - 3.0) < 1e-4 + + def test_product_equals_speed_4x(self): + c = SpeedConfig(speed=4.0) + speeds = self._extract_speeds(build_audio_filter(c)) + product = 1.0 + for s in speeds: + product *= s + assert abs(product - 4.0) < 1e-4 + + def test_product_equals_speed_slow_025(self): + c = SpeedConfig(speed=0.25) + speeds = self._extract_speeds(build_audio_filter(c)) + product = 1.0 + for s in speeds: + product *= s + assert abs(product - 0.25) < 1e-4 + + def test_product_equals_speed_slow_03(self): + c = SpeedConfig(speed=0.3) + speeds = self._extract_speeds(build_audio_filter(c)) + product = 1.0 + for s in speeds: + product *= s + assert abs(product - 0.3) < 1e-4 + + def test_each_stage_in_range_fast(self): + c = SpeedConfig(speed=3.5) + speeds = self._extract_speeds(build_audio_filter(c)) + for s in speeds: + assert 0.5 <= s <= 2.0 + + def test_each_stage_in_range_slow(self): + c = SpeedConfig(speed=0.35) + speeds = self._extract_speeds(build_audio_filter(c)) + for s in speeds: + assert 0.5 <= s <= 2.0 + + +# ── adjust_duration ────────────────────────────────────────────────────────── + + +class TestAdjustDuration: + """adjust_duration 时长计算""" + + def test_original_speed_no_change(self): + assert adjust_duration(10.0, SpeedConfig(speed=1.0)) == 10.0 + + def test_double_speed_half_duration(self): + assert adjust_duration(10.0, SpeedConfig(speed=2.0)) == 5.0 + + def test_half_speed_double_duration(self): + assert adjust_duration(10.0, SpeedConfig(speed=0.5)) == 20.0 + + def test_zero_duration_unchanged(self): + assert adjust_duration(0.0, SpeedConfig(speed=2.0)) == 0.0 + + def test_negative_duration_unchanged(self): + assert adjust_duration(-1.0, SpeedConfig(speed=2.0)) == -1.0 + + def test_original_with_zero_duration(self): + assert adjust_duration(0.0, SpeedConfig(speed=1.0)) == 0.0 + + def test_triple_speed(self): + assert adjust_duration(30.0, SpeedConfig(speed=3.0)) == 10.0 + + def test_quarter_speed(self): + assert adjust_duration(10.0, SpeedConfig(speed=0.25)) == 40.0 + + +# ── build_clip_speed_filter ────────────────────────────────────────────────── + + +class TestBuildClipSpeedFilter: + """build_clip_speed_filter 便捷方法""" + + def test_returns_tuple_of_three(self): + result = build_clip_speed_filter(1.5) + assert len(result) == 3 + video_filter, audio_filter, config = result + assert isinstance(video_filter, str) + assert isinstance(audio_filter, str) + assert isinstance(config, SpeedConfig) + + def test_normal_speed(self): + video_filter, audio_filter, config = build_clip_speed_filter(1.0) + assert video_filter == "" + assert audio_filter == "" + assert config.speed == 1.0 + + def test_double_speed(self): + video_filter, audio_filter, config = build_clip_speed_filter(2.0) + assert "setpts" in video_filter + assert "atempo" in audio_filter + assert config.speed == 2.0 + + def test_clamps_speed(self): + _, _, config = build_clip_speed_filter(10.0) + assert config.speed == MAX_SPEED + + def test_pitch_correct_false(self): + # pitch_correct=False 时仍然生成滤镜(实际使用中可能换其他算法,但接口返回不变) + video_filter, audio_filter, config = build_clip_speed_filter(2.0, pitch_correct=False) + assert config.pitch_correct is False + assert "setpts" in video_filter + + +# ── resolve_clip_speed ─────────────────────────────────────────────────────── + + +class TestResolveClipSpeed: + """resolve_clip_speed 片段速度解析""" + + def test_none_config_uses_global(self): + assert resolve_clip_speed(None, 1.5) == 1.5 + + def test_zero_speed_uses_global(self): + assert resolve_clip_speed({"playback_speed": 0}, 1.5) == 1.5 + + def test_missing_key_uses_global(self): + assert resolve_clip_speed({}, 2.0) == 2.0 + + def test_valid_speed(self): + assert resolve_clip_speed({"playback_speed": 1.5}, 1.0) == 1.5 + + def test_negative_speed_uses_global(self): + assert resolve_clip_speed({"playback_speed": -1.0}, 1.0) == 1.0 + + def test_invalid_type_uses_global(self): + assert resolve_clip_speed({"playback_speed": "fast"}, 1.0) == 1.0 + + def test_default_global_is_one(self): + assert resolve_clip_speed({"playback_speed": 0}) == 1.0 + + def test_int_speed(self): + result = resolve_clip_speed({"playback_speed": 2}) + assert result == 2.0 + assert isinstance(result, float) + + def test_very_small_positive_uses_it(self): + # 只要 > 0 就用 + result = resolve_clip_speed({"playback_speed": 0.1}) + assert result == 0.1