diff --git a/tests/unit/test_chroma_key_engine.py b/tests/unit/test_chroma_key_engine.py new file mode 100755 index 000000000..d84bd0948 --- /dev/null +++ b/tests/unit/test_chroma_key_engine.py @@ -0,0 +1,189 @@ +"""绿幕抠像引擎单元测试 - 配置解析等纯逻辑.""" + +from __future__ import annotations + +import pytest + +from video_processing.chroma_key_engine import ( + CHROMA_KEY_PRESETS, + ChromaKeyConfig, +) + + +class TestChromaKeyConfigDefaults: + """默认配置测试.""" + + def test_default_values(self): + """默认值正确.""" + config = ChromaKeyConfig() + assert config.enabled is False + assert config.key_color == "#00FF00" + assert config.similarity == 0.3 + assert config.blend == 0.1 + assert config.spill_suppress == 0.0 + + +class TestChromaKeyConfigFromDict: + """from_dict 配置解析测试.""" + + def test_none_returns_disabled(self): + """None 返回禁用配置.""" + config = ChromaKeyConfig.from_dict(None) + assert config.enabled is False + + def test_empty_dict_returns_disabled(self): + """空字典返回禁用.""" + config = ChromaKeyConfig.from_dict({}) + assert config.enabled is False + + def test_disabled_returns_disabled(self): + """enabled=False 返回禁用.""" + config = ChromaKeyConfig.from_dict({"enabled": False}) + assert config.enabled is False + + def test_enabled_default_values(self): + """启用时使用默认参数.""" + config = ChromaKeyConfig.from_dict({"enabled": True}) + assert config.enabled is True + assert config.key_color == "#00FF00" + assert config.similarity == 0.3 + assert config.blend == 0.1 + assert config.spill_suppress == 0.0 + + def test_custom_key_color(self): + """自定义抠像颜色.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "key_color": "#0000FF", + }) + assert config.key_color == "#0000FF" + + def test_similarity_parsed(self): + """相似度解析.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "similarity": 0.5, + }) + assert config.similarity == 0.5 + + def test_similarity_clamped_min(self): + """相似度下限钳制.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "similarity": 0.001, + }) + assert config.similarity == 0.01 + + def test_similarity_clamped_max(self): + """相似度上限钳制.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "similarity": 2.0, + }) + assert config.similarity == 1.0 + + def test_blend_clamped_min(self): + """混合度下限钳制.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "blend": -0.5, + }) + assert config.blend == 0.0 + + def test_blend_clamped_max(self): + """混合度上限钳制.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "blend": 1.5, + }) + assert config.blend == 1.0 + + def test_spill_suppress_clamped(self): + """溢色抑制钳制.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "spill_suppress": 2.0, + }) + assert config.spill_suppress == 1.0 + + def test_invalid_similarity_falls_back(self): + """无效相似度回退到默认.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "similarity": "not_a_number", + }) + assert config.similarity == 0.3 + + def test_invalid_blend_falls_back(self): + """无效混合度回退.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "blend": "high", + }) + assert config.blend == 0.1 + + def test_key_color_stripped(self): + """颜色值去除首尾空格.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "key_color": " #FF0000 ", + }) + assert config.key_color == "#FF0000" + + def test_all_params_custom(self): + """所有参数自定义.""" + config = ChromaKeyConfig.from_dict({ + "enabled": True, + "key_color": "#0000FF", + "similarity": 0.45, + "blend": 0.15, + "spill_suppress": 0.6, + }) + assert config.enabled is True + assert config.key_color == "#0000FF" + assert config.similarity == 0.45 + assert config.blend == 0.15 + assert config.spill_suppress == 0.6 + + +class TestHasEffect: + """has_effect 方法测试.""" + + def test_disabled_no_effect(self): + """禁用时无效果.""" + config = ChromaKeyConfig(enabled=False) + assert config.has_effect() is False + + def test_enabled_with_similarity_has_effect(self): + """启用且有相似度时有效果.""" + config = ChromaKeyConfig(enabled=True, similarity=0.3) + assert config.has_effect() is True + + def test_zero_similarity_no_effect(self): + """相似度为0时无效果.""" + config = ChromaKeyConfig(enabled=True, similarity=0.0) + assert config.has_effect() is False + + +class TestChromaKeyPresets: + """预设配置测试.""" + + def test_five_presets(self): + """5个预设.""" + assert len(CHROMA_KEY_PRESETS) == 5 + + def test_preset_names(self): + """预设名称正确.""" + assert "green_screen" in CHROMA_KEY_PRESETS + assert "blue_screen" in CHROMA_KEY_PRESETS + assert "red_screen" in CHROMA_KEY_PRESETS + assert "precise_green" in CHROMA_KEY_PRESETS + assert "soft_green" in CHROMA_KEY_PRESETS + + def test_presets_have_required_keys(self): + """每个预设包含必要字段.""" + for name, preset in CHROMA_KEY_PRESETS.items(): + assert "key_color" in preset, f"{name} missing key_color" + assert "similarity" in preset, f"{name} missing similarity" + assert "blend" in preset, f"{name} missing blend" + assert "spill_suppress" in preset, f"{name} missing spill_suppress" diff --git a/tests/unit/test_color_grade_engine.py b/tests/unit/test_color_grade_engine.py index a3503f266..f7e2bbde3 100755 --- a/tests/unit/test_color_grade_engine.py +++ b/tests/unit/test_color_grade_engine.py @@ -1,97 +1,25 @@ -"""滤镜调色引擎单元测试.""" +"""调色引擎单元测试 - 配置解析等纯逻辑.""" from __future__ import annotations import pytest + from video_processing.color_grade_engine import ( DEFAULT_PARAMS, PARAM_RANGES, - PRESET_BW, - PRESET_CINEMA, - PRESET_COOL, - PRESET_DISPLAY_NAMES, - PRESET_FILM, - PRESET_FRESH, - PRESET_JAPANESE, PRESET_PARAMS, - PRESET_VINTAGE, - PRESET_WARM, + VALID_PRESETS, ColorGradeConfig, - ColorGradeEngine, - get_preset_names, - get_preset_params, ) -# ── 预设常量测试 ────────────────────────────────────────────────────────────── +class TestColorGradeConfigDefaults: + """默认配置测试.""" -class TestPresetConstants: - """预设常量完整性测试.""" - - def test_eight_presets_defined(self): - """应该有8种预设.""" - assert len(PRESET_PARAMS) == 8 - assert len(PRESET_DISPLAY_NAMES) == 8 - - def test_all_presets_have_display_names(self): - """每个预设都应该有中文显示名.""" - for key in PRESET_PARAMS: - assert key in PRESET_DISPLAY_NAMES - assert PRESET_DISPLAY_NAMES[key] # 非空 - - def test_preset_params_have_all_keys(self): - """每个预设应该包含所有5个参数.""" - required_keys = {"brightness", "contrast", "saturation", "temperature", "hue"} - for key, params in PRESET_PARAMS.items(): - assert required_keys.issubset(params.keys()), f"预设 {key} 缺少参数" - - def test_preset_params_in_valid_range(self): - """所有预设参数应该在合法范围内.""" - for preset_name, params in PRESET_PARAMS.items(): - for param_name, value in params.items(): - min_val, max_val = PARAM_RANGES[param_name] - assert ( - min_val <= value <= max_val - ), f"预设 {preset_name} 的 {param_name}={value} 超出范围 [{min_val}, {max_val}]" - - def test_black_white_has_zero_saturation(self): - """黑白预设饱和度应该为0.""" - assert PRESET_PARAMS[PRESET_BW]["saturation"] == 0 - - def test_warm_preset_has_positive_temperature(self): - """暖色预设色温应该为正.""" - assert PRESET_PARAMS[PRESET_WARM]["temperature"] > 0 - - def test_cool_preset_has_negative_temperature(self): - """冷色预设色温应该为负.""" - assert PRESET_PARAMS[PRESET_COOL]["temperature"] < 0 - - -# ── ColorGradeConfig.from_dict 测试 ─────────────────────────────────────────── - - -class TestColorGradeConfigFromDict: - """配置字典解析测试.""" - - def test_none_config(self): - """None返回disabled.""" - config = ColorGradeConfig.from_dict(None) - assert not config.enabled - - def test_empty_dict(self): - """空字典返回disabled.""" - config = ColorGradeConfig.from_dict({}) - assert not config.enabled - - def test_enabled_false(self): - """enabled=False返回disabled.""" - config = ColorGradeConfig.from_dict({"enabled": False}) - assert not config.enabled - - def test_enabled_only(self): - """只开enabled,无预设无自定义参数.""" - config = ColorGradeConfig.from_dict({"enabled": True}) - assert config.enabled + def test_default_values(self): + """默认值正确.""" + config = ColorGradeConfig() + assert config.enabled is False assert config.preset == "" assert config.brightness is None assert config.contrast is None @@ -99,474 +27,226 @@ class TestColorGradeConfigFromDict: assert config.temperature is None assert config.hue is None - def test_with_preset(self): - """指定预设.""" - config = ColorGradeConfig.from_dict({"enabled": True, "preset": PRESET_FRESH}) - assert config.enabled - assert config.preset == PRESET_FRESH - def test_invalid_preset_ignored(self): - """无效预设名应该被忽略.""" - config = ColorGradeConfig.from_dict({"enabled": True, "preset": "invalid_preset"}) - assert config.preset == "" # 被清空 +class TestColorGradeConfigFromDict: + """from_dict 配置解析测试.""" - def test_with_custom_params(self): - """自定义参数覆盖.""" - config = ColorGradeConfig.from_dict( - { - "enabled": True, - "brightness": 20, - "contrast": -10, - "saturation": 150, - "temperature": 25, - "hue": 30, - } - ) - assert config.enabled - assert config.brightness == 20 - assert config.contrast == -10 - assert config.saturation == 150 - assert config.temperature == 25 - assert config.hue == 30 + def test_none_returns_disabled(self): + """None 返回禁用配置.""" + config = ColorGradeConfig.from_dict(None) + assert config.enabled is False - def test_string_numeric_values(self): - """字符串形式的数字应该能解析.""" - config = ColorGradeConfig.from_dict( - { - "enabled": True, - "brightness": "20.5", - "saturation": "150", - } - ) - assert config.brightness == 20.5 - assert config.saturation == 150.0 + def test_empty_dict_returns_disabled(self): + """空字典返回禁用.""" + config = ColorGradeConfig.from_dict({}) + assert config.enabled is False - def test_invalid_value_returns_none(self): - """无效值应该返回None(不覆盖).""" - config = ColorGradeConfig.from_dict( - { - "enabled": True, - "brightness": "not_a_number", - } - ) + def test_disabled_returns_disabled(self): + """enabled=False 返回禁用.""" + config = ColorGradeConfig.from_dict({"enabled": False}) + assert config.enabled is False + + def test_enabled_no_params(self): + """启用但无自定义参数.""" + config = ColorGradeConfig.from_dict({"enabled": True}) + assert config.enabled is True + assert config.preset == "" assert config.brightness is None + def test_with_preset(self): + """指定预设.""" + config = ColorGradeConfig.from_dict({ + "enabled": True, + "preset": "fresh", + }) + assert config.enabled is True + assert config.preset == "fresh" -# ── ColorGradeConfig.resolve_params 测试 ────────────────────────────────────── + def test_invalid_preset_ignored(self): + """无效预设被忽略.""" + config = ColorGradeConfig.from_dict({ + "enabled": True, + "preset": "unknown_preset", + }) + assert config.preset == "" + + def test_custom_brightness(self): + """自定义亮度.""" + config = ColorGradeConfig.from_dict({ + "enabled": True, + "brightness": 20, + }) + assert config.brightness == 20.0 + + def test_custom_all_params(self): + """所有参数自定义.""" + config = ColorGradeConfig.from_dict({ + "enabled": True, + "brightness": 10, + "contrast": 15, + "saturation": 120, + "temperature": -5, + "hue": 10, + }) + assert config.brightness == 10.0 + assert config.contrast == 15.0 + assert config.saturation == 120.0 + assert config.temperature == -5.0 + assert config.hue == 10.0 + + def test_invalid_param_value_returns_none(self): + """无效参数值返回None(不覆盖).""" + config = ColorGradeConfig.from_dict({ + "enabled": True, + "brightness": "not_a_number", + }) + assert config.brightness is None + + def test_null_param_returns_none(self): + """null参数值返回None.""" + config = ColorGradeConfig.from_dict({ + "enabled": True, + "contrast": None, + }) + assert config.contrast is None + + def test_preset_with_custom_override(self): + """预设 + 自定义覆盖.""" + config = ColorGradeConfig.from_dict({ + "enabled": True, + "preset": "vintage", + "brightness": 5, + }) + assert config.preset == "vintage" + assert config.brightness == 5.0 class TestResolveParams: - """参数解析与边界钳制测试.""" + """resolve_params 参数解析测试.""" - def test_default_params_when_empty(self): - """无预设无自定义时返回默认值.""" - config = ColorGradeConfig(enabled=True) + def test_disabled_returns_defaults(self): + """禁用配置也返回默认参数.""" + config = ColorGradeConfig(enabled=False) params = config.resolve_params() for key, val in DEFAULT_PARAMS.items(): assert params[key] == val - def test_preset_params_applied(self): - """预设参数应该被应用.""" - config = ColorGradeConfig(enabled=True, preset=PRESET_FRESH) + def test_no_preset_no_custom_returns_defaults(self): + """无预设无自定义返回默认值.""" + config = ColorGradeConfig(enabled=True) params = config.resolve_params() - preset = PRESET_PARAMS[PRESET_FRESH] - for key, val in preset.items(): - assert params[key] == val + for key, val in DEFAULT_PARAMS.items(): + assert abs(params[key] - val) < 0.001 + + def test_preset_applies_params(self): + """预设应用参数.""" + config = ColorGradeConfig(enabled=True, preset="fresh") + params = config.resolve_params() + # 清新预设亮度=8 + assert params["brightness"] == 8 + assert params["saturation"] == 120 def test_custom_overrides_preset(self): - """自定义参数应该覆盖预设值.""" + """自定义参数覆盖预设.""" config = ColorGradeConfig( enabled=True, - preset=PRESET_FRESH, + preset="fresh", brightness=50, # 覆盖预设的8 ) params = config.resolve_params() assert params["brightness"] == 50 - # 其他参数还是预设值 - assert params["contrast"] == PRESET_PARAMS[PRESET_FRESH]["contrast"] + # 其他参数仍用预设值 + assert params["saturation"] == 120 - def test_clamp_brightness_high(self): - """亮度超过上限应该被钳制.""" + def test_brightness_clamped(self): + """亮度边界钳制.""" config = ColorGradeConfig(enabled=True, brightness=200) params = config.resolve_params() - assert params["brightness"] == 100 + assert params["brightness"] == 100.0 - def test_clamp_brightness_low(self): - """亮度低于下限应该被钳制.""" - config = ColorGradeConfig(enabled=True, brightness=-200) + def test_saturation_clamped_low(self): + """饱和度下限钳制.""" + config = ColorGradeConfig(enabled=True, saturation=-10) params = config.resolve_params() - assert params["brightness"] == -100 + assert params["saturation"] == 0.0 - def test_clamp_saturation_low(self): - """饱和度低于0应该被钳制到0.""" - config = ColorGradeConfig(enabled=True, saturation=-50) - params = config.resolve_params() - assert params["saturation"] == 0 - - def test_clamp_saturation_high(self): - """饱和度超过200应该被钳制.""" + def test_saturation_clamped_high(self): + """饱和度上限钳制.""" config = ColorGradeConfig(enabled=True, saturation=300) params = config.resolve_params() - assert params["saturation"] == 200 + assert params["saturation"] == 200.0 - def test_clamp_hue_high(self): - """色调超过180应该被钳制.""" - config = ColorGradeConfig(enabled=True, hue=270) + def test_hue_clamped(self): + """色调边界钳制.""" + config = ColorGradeConfig(enabled=True, hue=200) params = config.resolve_params() - assert params["hue"] == 180 + assert params["hue"] == 180.0 - def test_clamp_hue_low(self): - """色调低于-180应该被钳制.""" - config = ColorGradeConfig(enabled=True, hue=-270) + def test_hue_negative_clamped(self): + """负色调边界钳制.""" + config = ColorGradeConfig(enabled=True, hue=-200) params = config.resolve_params() - assert params["hue"] == -180 + assert params["hue"] == -180.0 - def test_clamp_contrast(self): - """对比度越界应该被钳制.""" - config = ColorGradeConfig(enabled=True, contrast=150) + def test_returns_all_five_params(self): + """返回所有5个参数.""" + config = ColorGradeConfig(enabled=True) params = config.resolve_params() - assert params["contrast"] == 100 - - config2 = ColorGradeConfig(enabled=True, contrast=-150) - params2 = config2.resolve_params() - assert params2["contrast"] == -100 - - def test_clamp_temperature(self): - """色温越界应该被钳制.""" - config = ColorGradeConfig(enabled=True, temperature=150) - params = config.resolve_params() - assert params["temperature"] == 100 - - def test_preset_with_clamping(self): - """预设+自定义覆盖,自定义值超范围仍需钳制.""" - config = ColorGradeConfig( - enabled=True, - preset=PRESET_FRESH, - brightness=999, # 超范围 - ) - params = config.resolve_params() - assert params["brightness"] == 100 # 被钳制 - - -# ── ColorGradeConfig.has_effect 测试 ────────────────────────────────────────── + assert set(params.keys()) == { + "brightness", "contrast", "saturation", "temperature", "hue" + } class TestHasEffect: - """是否有实际效果判断测试.""" + """has_effect 方法测试.""" - def test_disabled_has_no_effect(self): - """disabled的配置has_effect应该返回False.""" - config = ColorGradeConfig(enabled=False) - assert not config.has_effect() - - def test_default_params_no_effect(self): - """所有参数都是默认值时应该返回False.""" + def test_default_no_effect(self): + """默认配置无效果.""" config = ColorGradeConfig(enabled=True) - assert not config.has_effect() + assert config.has_effect() is False - def test_brightness_change_has_effect(self): - """亮度变化应该有效果.""" + def test_with_preset_has_effect(self): + """有预设时有效果.""" + config = ColorGradeConfig(enabled=True, preset="cinema") + assert config.has_effect() is True + + def test_custom_brightness_has_effect(self): + """自定义亮度有效果.""" config = ColorGradeConfig(enabled=True, brightness=10) - assert config.has_effect() + assert config.has_effect() is True - def test_saturation_100_no_effect(self): - """饱和度100是默认值,无效果.""" - config = ColorGradeConfig(enabled=True, saturation=100) - assert not config.has_effect() + def test_disabled_still_checks_params(self): + """禁用也根据参数判断(结果仍可能有效果但不启用).""" + # has_effect 只看参数,不看 enabled + config = ColorGradeConfig(enabled=False, preset="warm") + assert config.has_effect() is True - def test_saturation_not_100_has_effect(self): - """饱和度不等于100有效果.""" - config = ColorGradeConfig(enabled=True, saturation=99) - assert config.has_effect() - - def test_preset_has_effect(self): - """预设通常有效果.""" - for preset in PRESET_PARAMS: - config = ColorGradeConfig(enabled=True, preset=preset) - assert config.has_effect(), f"预设 {preset} 应该有效果" - - def test_custom_zero_override_no_effect(self): - """用预设但所有自定义值都设为默认值抵消 → 应该has_effect看实际值.""" - # 黑白预设饱和度=0,如果手动覆盖饱和度=100、其他都=默认值,则可能无效果 - config = ColorGradeConfig( - enabled=True, - preset=PRESET_BW, - brightness=0, - contrast=0, - saturation=100, - temperature=0, - hue=0, - ) - assert not config.has_effect() + def test_black_white_preset_has_effect(self): + """黑白预设(饱和度=0)有效果.""" + config = ColorGradeConfig(enabled=True, preset="black_white") + assert config.has_effect() is True -# ── ColorGradeEngine 参数映射测试 ───────────────────────────────────────────── +class TestPresets: + """预设常量测试.""" + def test_eight_valid_presets(self): + """8个有效预设.""" + assert len(VALID_PRESETS) == 8 -class TestParameterMapping: - """FFmpeg参数映射测试.""" + def test_preset_params_match_valid(self): + """所有预设都在有效列表中.""" + for name in PRESET_PARAMS: + assert name in VALID_PRESETS - def test_brightness_mapping_zero(self): - """亮度0 → 0.0.""" - assert ColorGradeEngine._map_brightness(0) == 0.0 + def test_each_preset_has_all_params(self): + """每个预设包含所有5个参数.""" + for name, params in PRESET_PARAMS.items(): + for key in ["brightness", "contrast", "saturation", "temperature", "hue"]: + assert key in params, f"{name} missing {key}" - def test_brightness_mapping_max(self): - """亮度100 → 1.0.""" - assert ColorGradeEngine._map_brightness(100) == 1.0 - - def test_brightness_mapping_min(self): - """亮度-100 → -1.0.""" - assert ColorGradeEngine._map_brightness(-100) == -1.0 - - def test_contrast_mapping_zero(self): - """对比度0 → 1.0(原始).""" - assert ColorGradeEngine._map_contrast(0) == 1.0 - - def test_contrast_mapping_positive(self): - """正对比度应该 > 1.0.""" - assert ColorGradeEngine._map_contrast(50) == 1.5 - assert ColorGradeEngine._map_contrast(100) == 2.0 - - def test_contrast_mapping_negative(self): - """负对比度应该 < 1.0.""" - assert ColorGradeEngine._map_contrast(-50) == 0.5 - assert ColorGradeEngine._map_contrast(-100) == 0.0 - - def test_saturation_mapping_default(self): - """饱和度100 → 1.0.""" - assert ColorGradeEngine._map_saturation(100) == 1.0 - - def test_saturation_mapping_zero(self): - """饱和度0 → 0.0(黑白).""" - assert ColorGradeEngine._map_saturation(0) == 0.0 - - def test_saturation_mapping_double(self): - """饱和度200 → 2.0.""" - assert ColorGradeEngine._map_saturation(200) == 2.0 - - def test_temperature_warm(self): - """暖色温应该红+蓝-.""" - red, green, blue = ColorGradeEngine._map_temperature(100) - assert red > 0 - assert blue < 0 - - def test_temperature_cool(self): - """冷色温应该红-蓝+.""" - red, green, blue = ColorGradeEngine._map_temperature(-100) - assert red < 0 - assert blue > 0 - - def test_temperature_zero(self): - """色温0应该全0.""" - red, green, blue = ColorGradeEngine._map_temperature(0) - assert red == 0 - assert green == 0 - assert blue == 0 - - def test_hue_mapping_passthrough(self): - """色调直接透传.""" - assert ColorGradeEngine._map_hue(0) == 0 - assert ColorGradeEngine._map_hue(90) == 90 - assert ColorGradeEngine._map_hue(-45) == -45 - - -# ── ColorGradeEngine.build_filter 测试 ──────────────────────────────────────── - - -class TestBuildFilter: - """滤镜字符串构建测试.""" - - def test_disabled_returns_empty(self): - """disabled配置返回空.""" - config = ColorGradeConfig(enabled=False) - result = ColorGradeEngine.build_filter(config) - assert result == "" - - def test_no_effect_returns_empty(self): - """无效果的配置返回空.""" - config = ColorGradeConfig(enabled=True) - result = ColorGradeEngine.build_filter(config) - assert result == "" - - def test_brightness_only(self): - """只有亮度调整.""" - config = ColorGradeConfig(enabled=True, brightness=20) - result = ColorGradeEngine.build_filter(config) - assert "eq=" in result - assert "brightness=" in result - assert "contrast=" not in result - assert "saturation=" not in result - - def test_contrast_only(self): - """只有对比度调整.""" - config = ColorGradeConfig(enabled=True, contrast=30) - result = ColorGradeEngine.build_filter(config) - assert "eq=" in result - assert "contrast=" in result - - def test_saturation_only(self): - """只有饱和度调整.""" - config = ColorGradeConfig(enabled=True, saturation=50) - result = ColorGradeEngine.build_filter(config) - assert "eq=" in result - assert "saturation=" in result - - def test_temperature_only(self): - """只有色温调整.""" - config = ColorGradeConfig(enabled=True, temperature=20) - result = ColorGradeEngine.build_filter(config) - assert "colorbalance=" in result - # 暖色调应该有红通道调整 - assert "rs=" in result - - def test_hue_only(self): - """只有色调调整.""" - config = ColorGradeConfig(enabled=True, hue=30) - result = ColorGradeEngine.build_filter(config) - assert "hue=h=" in result - - def test_with_input_output_labels(self): - """带输入输出标签.""" - config = ColorGradeConfig(enabled=True, brightness=10) - result = ColorGradeEngine.build_filter(config, input_label="[0:v]", output_label="[out]") - assert result.startswith("[0:v]") - assert result.endswith("[out]") - - def test_preset_fresh_filter(self): - """清新预设应该生成eq滤镜.""" - config = ColorGradeConfig(enabled=True, preset=PRESET_FRESH) - result = ColorGradeEngine.build_filter(config) - assert "eq=" in result - # 清新预设饱和度>100,应该有saturation - assert "saturation=" in result - - def test_preset_bw_filter(self): - """黑白预设应该有saturation=0.""" - config = ColorGradeConfig(enabled=True, preset=PRESET_BW) - result = ColorGradeEngine.build_filter(config) - assert "saturation=0.0" in result - - def test_combined_params(self): - """多个参数组合.""" - config = ColorGradeConfig( - enabled=True, - brightness=15, - contrast=20, - saturation=130, - temperature=10, - hue=5, - ) - result = ColorGradeEngine.build_filter(config) - # 应该有三个滤镜用逗号连接 - assert "eq=" in result - assert "colorbalance=" in result - assert "hue=" in result - # 逗号分隔 - assert "," in result - - def test_filter_chain_order(self): - """滤镜顺序应该是 eq → colorbalance → hue.""" - config = ColorGradeConfig( - enabled=True, - brightness=10, - temperature=10, - hue=10, - ) - result = ColorGradeEngine.build_filter(config) - eq_pos = result.find("eq=") - cb_pos = result.find("colorbalance=") - hue_pos = result.find("hue=") - assert eq_pos < cb_pos < hue_pos - - def test_zero_temperature_no_colorbalance(self): - """色温为0不应该有colorbalance滤镜.""" - config = ColorGradeConfig(enabled=True, temperature=0, brightness=10) - result = ColorGradeEngine.build_filter(config) - assert "colorbalance" not in result - - def test_zero_hue_no_hue_filter(self): - """色调为0不应该有hue滤镜.""" - config = ColorGradeConfig(enabled=True, hue=0, brightness=10) - result = ColorGradeEngine.build_filter(config) - assert "hue=" not in result - - def test_all_presets_generate_valid_filter(self): - """所有预设都应该能生成有效的非空滤镜.""" - for preset_name in PRESET_PARAMS: - config = ColorGradeConfig(enabled=True, preset=preset_name) - result = ColorGradeEngine.build_filter(config) - assert result, f"预设 {preset_name} 应该生成非空滤镜" - # 不应该有语法错误(连续冒号、空参数等) - assert "::" not in result - assert result[0] != ":" - assert result[-1] != ":" - - -# ── 便捷函数测试 ────────────────────────────────────────────────────────────── - - -class TestHelperFunctions: - """便捷函数测试.""" - - def test_get_preset_names_returns_eight(self): - """应该返回8个预设.""" - names = get_preset_names() - assert len(names) == 8 - # 每个是 (key, display_name) 元组 - for key, display in names: - assert key in PRESET_PARAMS - assert isinstance(display, str) - assert display - - def test_get_preset_params_valid(self): - """获取有效预设的参数.""" - params = get_preset_params(PRESET_FRESH) - assert params is not None - assert params == PRESET_PARAMS[PRESET_FRESH] - - def test_get_preset_params_invalid(self): - """获取无效预设返回None.""" - params = get_preset_params("nonexistent") - assert params is None - - -# ── 分段调色(不同clip不同滤镜)概念验证 ────────────────────────────────────── - - -class TestPerClipGrading: - """分段调色概念验证 — 不同配置生成不同滤镜.""" - - def test_different_presets_different_filters(self): - """不同预设应该生成不同的滤镜字符串.""" - configs = [ - ColorGradeConfig(enabled=True, preset=PRESET_FRESH), - ColorGradeConfig(enabled=True, preset=PRESET_VINTAGE), - ColorGradeConfig(enabled=True, preset=PRESET_BW), - ] - filters = [ColorGradeEngine.build_filter(c) for c in configs] - # 三个滤镜应该各不相同 - assert len(set(filters)) == 3 - - def test_same_preset_same_filter(self): - """相同配置应该生成相同滤镜(确定性).""" - config1 = ColorGradeConfig(enabled=True, preset=PRESET_CINEMA) - config2 = ColorGradeConfig(enabled=True, preset=PRESET_CINEMA) - assert ColorGradeEngine.build_filter(config1) == ColorGradeEngine.build_filter(config2) - - def test_custom_override_changes_filter(self): - """自定义覆盖应该改变滤镜.""" - base = ColorGradeConfig(enabled=True, preset=PRESET_FILM) - modified = ColorGradeConfig(enabled=True, preset=PRESET_FILM, brightness=50) - assert ColorGradeEngine.build_filter(base) != ColorGradeEngine.build_filter(modified) - - def test_clips_with_and_without_grading(self): - """有的clip有调色有的没有,生成结果不同.""" - with_grade = ColorGradeConfig(enabled=True, preset=PRESET_WARM) - without_grade = ColorGradeConfig(enabled=False) - - filter_with = ColorGradeEngine.build_filter(with_grade, "[0:v]", "[v0]") - filter_without = ColorGradeEngine.build_filter(without_grade, "[0:v]", "[v0]") - - assert filter_with # 有调色应该非空 - # 无调色但带标签时应该走 copy 直通(保证标签传递) - assert "[0:v]copy[v0]" in filter_without + def test_param_ranges_defined(self): + """参数范围定义完整.""" + assert set(PARAM_RANGES.keys()) == { + "brightness", "contrast", "saturation", "temperature", "hue" + } diff --git a/tests/unit/test_speed_engine.py b/tests/unit/test_speed_engine.py index 6da62ef06..bcb0128e4 100755 --- a/tests/unit/test_speed_engine.py +++ b/tests/unit/test_speed_engine.py @@ -1,6 +1,9 @@ -"""视频调速引擎单元测试.""" +"""视频调速引擎单元测试 - 配置解析 + 滤镜生成等纯逻辑.""" + +from __future__ import annotations import pytest + from video_processing.speed_engine import ( MAX_SPEED, MIN_SPEED, @@ -8,262 +11,297 @@ from video_processing.speed_engine import ( SpeedEngine, ) -# ─── SpeedConfig 解析与校验 ────────────────────────────────── + +# ── 常量测试 ────────────────────────────────────────────────── -class TestSpeedConfig: +class TestConstants: + """常量值测试.""" + + def test_speed_ranges(self): + """速度范围合理.""" + assert MIN_SPEED == 0.25 + assert MAX_SPEED == 4.0 + assert MIN_SPEED < MAX_SPEED + + +# ── SpeedConfig 测试 ──────────────────────────────────────── + + +class TestSpeedConfigDefaults: + """默认配置测试.""" + def test_default_values(self): + """默认值正确.""" config = SpeedConfig() assert config.speed == 1.0 assert config.pitch_correct is True - def test_parse_none(self): + def test_is_original_default(self): + """默认配置是原速.""" + config = SpeedConfig() + assert config.is_original is True + + +class TestSpeedConfigParse: + """parse 配置解析测试.""" + + def test_none_returns_default(self): + """None 返回默认配置.""" config = SpeedConfig.parse(None) assert config.speed == 1.0 assert config.pitch_correct is True - def test_parse_empty_dict(self): + def test_empty_dict_returns_default(self): + """空 dict 返回默认配置.""" config = SpeedConfig.parse({}) - assert config.speed == 1.0 + assert config.is_original is True - def test_parse_valid_speed(self): + def test_custom_speed(self): + """自定义速度.""" config = SpeedConfig.parse({"speed": 2.0}) assert config.speed == 2.0 + assert config.is_original is False - def test_parse_pitch_correct_false(self): - config = SpeedConfig.parse({"pitch_correct": False}) + def test_pitch_correct_disabled(self): + """禁用音调修正.""" + config = SpeedConfig.parse({"speed": 1.5, "pitch_correct": False}) assert config.pitch_correct is False - def test_parse_invalid_speed_type(self): + def test_invalid_speed_type_falls_back(self): + """无效速度类型回退到默认.""" config = SpeedConfig.parse({"speed": "fast"}) assert config.speed == 1.0 - def test_parse_invalid_pitch_type(self): - config = SpeedConfig.parse({"pitch_correct": "yes"}) + def test_invalid_pitch_correct_type_falls_back(self): + """无效pitch_correct类型回退到默认.""" + config = SpeedConfig.parse({"speed": 2.0, "pitch_correct": "yes"}) assert config.pitch_correct is True - def test_clamp_below_min(self): + def test_non_dict_input_returns_default(self): + """非dict输入返回默认.""" + config = SpeedConfig.parse("not_a_dict") + assert config.is_original is True + + +class TestSpeedConfigClamp: + """clamp 边界钳制测试.""" + + def test_speed_below_min_clamped(self): + """低于最小值钳制.""" config = SpeedConfig(speed=0.1) config.clamp() assert config.speed == MIN_SPEED - def test_clamp_zero(self): - config = SpeedConfig(speed=0) - config.clamp() - assert config.speed == 1.0 - - def test_clamp_negative(self): - config = SpeedConfig(speed=-1.0) - config.clamp() - assert config.speed == 1.0 - - def test_clamp_above_max(self): + def test_speed_above_max_clamped(self): + """高于最大值钳制.""" config = SpeedConfig(speed=10.0) config.clamp() assert config.speed == MAX_SPEED - def test_clamp_within_range(self): + def test_zero_speed_falls_back_to_default(self): + """速度为0回退到默认.""" + config = SpeedConfig(speed=0) + config.clamp() + assert config.speed == 1.0 + + def test_negative_speed_falls_back_to_default(self): + """负速度回退到默认.""" + config = SpeedConfig(speed=-2.0) + config.clamp() + assert config.speed == 1.0 + + def test_speed_at_min_ok(self): + """最小值边界.""" + config = SpeedConfig(speed=MIN_SPEED) + config.clamp() + assert config.speed == MIN_SPEED + + def test_speed_at_max_ok(self): + """最大值边界.""" + config = SpeedConfig(speed=MAX_SPEED) + config.clamp() + assert config.speed == MAX_SPEED + + def test_speed_in_range_unchanged(self): + """合法范围内不修改.""" config = SpeedConfig(speed=1.5) config.clamp() assert config.speed == 1.5 - def test_is_original_true(self): - config = SpeedConfig(speed=1.0) - assert config.is_original is True - - def test_is_original_false(self): - config = SpeedConfig(speed=1.5) - assert config.is_original is False - - def test_parse_clamps_automatically(self): - """parse 方法应该自动调用 clamp.""" + def test_parse_auto_clamps(self): + """parse 自动钳制.""" config = SpeedConfig.parse({"speed": 100.0}) assert config.speed == MAX_SPEED -# ─── SpeedEngine 视频滤镜 ──────────────────────────────────── +class TestIsOriginal: + """is_original 属性测试.""" + + def test_exactly_one(self): + """速度恰好为1.""" + assert SpeedConfig(speed=1.0).is_original is True + + def test_very_close_to_one(self): + """非常接近1也算原速.""" + assert SpeedConfig(speed=1.0000001).is_original is True + + def test_not_one(self): + """不是1.""" + assert SpeedConfig(speed=1.1).is_original is False + assert SpeedConfig(speed=0.9).is_original is False -class TestSpeedEngineVideoFilter: +# ── SpeedEngine 测试 ──────────────────────────────────────── + + +class TestBuildVideoFilter: + """build_video_filter 测试.""" + def setup_method(self): self.engine = SpeedEngine() - def test_original_speed_returns_empty(self): + def test_original_speed_empty_filter(self): + """原速返回空字符串(跳过滤镜).""" config = SpeedConfig(speed=1.0) assert self.engine.build_video_filter(config) == "" - def test_double_speed(self): + def test_speed_up_2x(self): + """2倍速.""" config = SpeedConfig(speed=2.0) result = self.engine.build_video_filter(config) - assert "setpts=PTS/2.0" in result + assert "setpts=PTS/2.0000" == result - def test_half_speed(self): + def test_slow_down_half(self): + """0.5倍速.""" config = SpeedConfig(speed=0.5) result = self.engine.build_video_filter(config) - assert "setpts=PTS/0.5" in result + assert "setpts=PTS/0.5000" == result - def test_quarter_speed(self): - config = SpeedConfig(speed=0.25) + def test_contains_setpts(self): + """包含setpts滤镜.""" + config = SpeedConfig(speed=1.5) result = self.engine.build_video_filter(config) - assert "setpts=PTS/0.25" in result - - def test_quad_speed(self): - config = SpeedConfig(speed=4.0) - result = self.engine.build_video_filter(config) - assert "setpts=PTS/4.0" in result + assert "setpts=PTS/" in result -# ─── SpeedEngine 音频滤镜(atempo 多级串联) ───────────────── +class TestBuildAudioFilter: + """build_audio_filter 测试.""" - -class TestSpeedEngineAudioFilter: def setup_method(self): self.engine = SpeedEngine() - def test_original_speed_returns_empty(self): + def test_original_speed_empty_filter(self): + """原速返回空字符串.""" config = SpeedConfig(speed=1.0) assert self.engine.build_audio_filter(config) == "" - def test_double_speed_single_stage(self): - """2x 在 atempo 单级范围内,只需一个 atempo.""" + def test_single_stage_2x(self): + """2倍速单级atempo.""" config = SpeedConfig(speed=2.0) result = self.engine.build_audio_filter(config) assert result == "atempo=2.0000" - def test_half_speed_single_stage(self): + def test_single_stage_half(self): + """0.5倍速单级atempo.""" config = SpeedConfig(speed=0.5) result = self.engine.build_audio_filter(config) assert result == "atempo=0.5000" - def test_quad_speed_two_stages(self): - """4x 需要两级 atempo: 2.0 * 2.0.""" + def test_multi_stage_4x(self): + """4倍速需要两级 atempo=2.0,atempo=2.0.""" config = SpeedConfig(speed=4.0) result = self.engine.build_audio_filter(config) assert result == "atempo=2.0000,atempo=2.0000" - def test_quarter_speed_two_stages(self): - """0.25x 需要两级 atempo: 0.5 * 0.5.""" + def test_multi_stage_quarter(self): + """0.25倍速需要两级 atempo=0.5,atempo=0.5.""" config = SpeedConfig(speed=0.25) result = self.engine.build_audio_filter(config) assert result == "atempo=0.5000,atempo=0.5000" - def test_triple_speed_two_stages(self): - """3x: 2.0 * 1.5.""" + def test_multi_stage_3x(self): + """3倍速: 2.0 * 1.5.""" config = SpeedConfig(speed=3.0) result = self.engine.build_audio_filter(config) - parts = result.split(",") - assert len(parts) == 2 - assert "atempo=2.0000" in parts - assert "atempo=1.5000" in parts + stages = result.split(",") + assert len(stages) == 2 + # 验证两级相乘等于3 + values = [float(s.split("=")[1]) for s in stages] + assert abs(values[0] * values[1] - 3.0) < 0.01 - def test_03_speed_two_stages(self): - """0.3x: 0.5 * 0.6.""" - config = SpeedConfig(speed=0.3) - result = self.engine.build_audio_filter(config) - parts = result.split(",") - assert len(parts) == 2 - assert "atempo=0.5000" in parts - assert "atempo=0.6000" in parts - def test_split_atempo_inside_range(self): - """0.5~2.0 范围内只返回一级.""" +class TestSplitAtempoStages: + """_split_atempo_stages 测试.""" + + def test_single_stage_within_range(self): + """范围内单级.""" stages = SpeedEngine._split_atempo_stages(1.5) assert len(stages) == 1 assert stages[0] == 1.5 - def test_split_atempo_boundary_min(self): - stages = SpeedEngine._split_atempo_stages(0.5) - assert len(stages) == 1 - assert stages[0] == 0.5 - - def test_split_atempo_boundary_max(self): + def test_single_stage_at_max(self): + """最大值边界单级.""" stages = SpeedEngine._split_atempo_stages(2.0) assert len(stages) == 1 - assert stages[0] == 2.0 - def test_split_atempo_product_equals_speed(self): - """所有级联的乘积应该等于原速度.""" - test_cases = [0.25, 0.3, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0] - for speed in test_cases: - stages = SpeedEngine._split_atempo_stages(speed) - product = 1.0 - for s in stages: - product *= s - assert abs(product - speed) < 1e-6, f"speed={speed}, stages={stages}, product={product}" + def test_single_stage_at_min(self): + """最小值边界单级.""" + stages = SpeedEngine._split_atempo_stages(0.5) + assert len(stages) == 1 - def test_split_atempo_all_in_range(self): - """所有级都应该在 0.5~2.0 范围内.""" - test_cases = [0.25, 0.3, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0] - for speed in test_cases: + def test_multi_stage_double_speed(self): + """4x 需要两级.""" + stages = SpeedEngine._split_atempo_stages(4.0) + assert len(stages) == 2 + assert abs(stages[0] * stages[1] - 4.0) < 0.01 + + def test_multi_stage_half_speed(self): + """0.25x 需要两级.""" + stages = SpeedEngine._split_atempo_stages(0.25) + assert len(stages) == 2 + assert abs(stages[0] * stages[1] - 0.25) < 0.01 + + def test_all_stages_within_valid_range(self): + """所有分级都在有效范围内.""" + for speed in [0.25, 0.3, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0]: stages = SpeedEngine._split_atempo_stages(speed) for s in stages: assert 0.5 <= s <= 2.0, f"speed={speed}, stage={s} out of range" -# ─── SpeedEngine 时长计算 ──────────────────────────────────── +class TestAdjustDuration: + """adjust_duration 时长计算测试.""" - -class TestSpeedEngineDuration: def setup_method(self): self.engine = SpeedEngine() - def test_original_speed_same_duration(self): - config = SpeedConfig(speed=1.0) - assert self.engine.adjust_duration(10.0, config) == 10.0 + def test_original_speed_unchanged(self): + """原速时长不变.""" + result = self.engine.adjust_duration(100.0, SpeedConfig(speed=1.0)) + assert result == 100.0 def test_double_speed_half_duration(self): - config = SpeedConfig(speed=2.0) - assert self.engine.adjust_duration(10.0, config) == 5.0 + """2倍速时长减半.""" + result = self.engine.adjust_duration(100.0, SpeedConfig(speed=2.0)) + assert result == 50.0 def test_half_speed_double_duration(self): - config = SpeedConfig(speed=0.5) - assert self.engine.adjust_duration(10.0, config) == 20.0 + """0.5倍速时长翻倍.""" + result = self.engine.adjust_duration(100.0, SpeedConfig(speed=0.5)) + assert result == 200.0 - def test_quad_speed_quarter_duration(self): - config = SpeedConfig(speed=4.0) - assert self.engine.adjust_duration(10.0, config) == 2.5 + def test_zero_duration_unchanged(self): + """零时长不变.""" + result = self.engine.adjust_duration(0.0, SpeedConfig(speed=2.0)) + assert result == 0.0 - def test_zero_duration(self): - config = SpeedConfig(speed=2.0) - assert self.engine.adjust_duration(0.0, config) == 0.0 + def test_negative_duration_unchanged(self): + """负时长不变(异常值保护).""" + result = self.engine.adjust_duration(-10.0, SpeedConfig(speed=2.0)) + assert result == -10.0 - def test_negative_duration(self): - config = SpeedConfig(speed=2.0) - assert self.engine.adjust_duration(-1.0, config) == -1.0 - - -# ─── SpeedEngine 便捷方法 ──────────────────────────────────── - - -class TestSpeedEngineHelper: - def setup_method(self): - self.engine = SpeedEngine() - - def test_build_clip_speed_filter_original(self): - v_f, a_f, cfg = self.engine.build_clip_speed_filter(1.0) - assert v_f == "" - assert a_f == "" - assert cfg.speed == 1.0 - - def test_build_clip_speed_filter_2x(self): - v_f, a_f, cfg = self.engine.build_clip_speed_filter(2.0) - assert "setpts=PTS/2.0" in v_f - assert "atempo=2.0" in a_f - assert cfg.speed == 2.0 - - def test_build_clip_speed_clamped(self): - _, _, cfg = self.engine.build_clip_speed_filter(100.0) - assert cfg.speed == MAX_SPEED - - def test_resolve_clip_speed_default(self): - assert SpeedEngine.resolve_clip_speed({}) == 1.0 - assert SpeedEngine.resolve_clip_speed(None) == 1.0 - - def test_resolve_clip_speed_zero_uses_global(self): - assert SpeedEngine.resolve_clip_speed({"playback_speed": 0}, 1.5) == 1.5 - - def test_resolve_clip_speed_custom(self): - assert SpeedEngine.resolve_clip_speed({"playback_speed": 2.0}) == 2.0 - - def test_resolve_clip_speed_invalid_type(self): - assert SpeedEngine.resolve_clip_speed({"playback_speed": "fast"}) == 1.0 + def test_quarter_speed(self): + """0.25倍速时长4倍.""" + result = self.engine.adjust_duration(60.0, SpeedConfig(speed=0.25)) + assert abs(result - 240.0) < 0.01