diff --git a/tests/unit/domain/test_filter_presets.py b/tests/unit/domain/test_filter_presets.py new file mode 100755 index 000000000..a768bf09d --- /dev/null +++ b/tests/unit/domain/test_filter_presets.py @@ -0,0 +1,350 @@ +"""filter_presets 单元测试 - wave164 + +覆盖: +- FilterPreset 数据类(frozen/默认值/字段) +- FILTER_PRESET_LIBRARY 预设库(数量/分类/ID唯一性) +- get_filter_preset 按ID获取 +- list_filter_presets 筛选列表(分类/关键词) +- build_ffmpeg_filter 滤镜生成(强度/参数插值/边界/空值) +""" + +import dataclasses + +import pytest + +from packages.domain.filter_presets import ( + FILTER_PRESET_LIBRARY, + FilterPreset, + build_ffmpeg_filter, + get_filter_preset, + list_filter_presets, +) + +# ============================================================ +# FilterPreset 数据类 +# ============================================================ + + +class TestFilterPresetDataclass: + def test_minimal_creation(self): + p = FilterPreset(id="test", name="Test", category="basic") + assert p.id == "test" + assert p.name == "Test" + assert p.category == "basic" + + def test_default_values(self): + p = FilterPreset(id="t", name="T", category="basic") + assert p.description == "" + assert p.tags == [] + assert p.brightness == 0.0 + assert p.contrast == 1.0 + assert p.saturation == 1.0 + assert p.gamma == 1.0 + assert p.gamma_r == 1.0 + assert p.gamma_g == 1.0 + assert p.gamma_b == 1.0 + assert p.hue == 0.0 + assert p.lut_url == "" + + def test_full_creation(self): + p = FilterPreset( + id="full", + name="Full", + category="cinematic", + description="test desc", + tags=["tag1", "tag2"], + brightness=0.5, + contrast=1.5, + saturation=2.0, + gamma=1.2, + gamma_r=1.3, + gamma_g=0.9, + gamma_b=0.8, + hue=30.0, + lut_url="http://lut.png", + ) + assert p.description == "test desc" + assert p.tags == ["tag1", "tag2"] + assert p.brightness == 0.5 + assert p.contrast == 1.5 + assert p.gamma == 1.2 + assert p.hue == 30.0 + assert p.lut_url == "http://lut.png" + + def test_frozen_immutable(self): + p = FilterPreset(id="t", name="T", category="basic") + with pytest.raises((AttributeError, dataclasses.FrozenInstanceError)): + p.brightness = 0.5 # frozen=True,不能修改 + + def test_tags_default_new_list(self): + # 每个实例有独立的列表 + p1 = FilterPreset(id="t1", name="T1", category="basic") + p2 = FilterPreset(id="t2", name="T2", category="basic") + assert p1.tags is not p2.tags + + +# ============================================================ +# FILTER_PRESET_LIBRARY 预设库 +# ============================================================ + + +class TestFilterPresetLibrary: + def test_library_not_empty(self): + assert len(FILTER_PRESET_LIBRARY) > 0 + + def test_all_ids_unique(self): + ids = [p.id for p in FILTER_PRESET_LIBRARY] + assert len(ids) == len(set(ids)) + + def test_all_have_required_fields(self): + for p in FILTER_PRESET_LIBRARY: + assert p.id, f"预设缺少id: {p}" + assert p.name, f"预设缺少name: {p.id}" + assert p.category, f"预设缺少category: {p.id}" + + def test_categories_are_valid(self): + valid_categories = {"basic", "cinematic", "vintage", "bw", "style"} + for p in FILTER_PRESET_LIBRARY: + assert p.category in valid_categories, f"无效分类: {p.id} -> {p.category}" + + def test_basic_category_exists(self): + basics = [p for p in FILTER_PRESET_LIBRARY if p.category == "basic"] + assert len(basics) >= 3 + + def test_cinematic_category_exists(self): + cinematic = [p for p in FILTER_PRESET_LIBRARY if p.category == "cinematic"] + assert len(cinematic) >= 1 + + def test_vintage_category_exists(self): + vintage = [p for p in FILTER_PRESET_LIBRARY if p.category == "vintage"] + assert len(vintage) >= 1 + + def test_bw_category_exists(self): + bw = [p for p in FILTER_PRESET_LIBRARY if p.category == "bw"] + assert len(bw) >= 1 + # 所有黑白预设饱和度为0 + for p in bw: + assert p.saturation == 0.0 + + def test_style_category_exists(self): + style = [p for p in FILTER_PRESET_LIBRARY if p.category == "style"] + assert len(style) >= 1 + + def test_none_filter_has_no_effect(self): + none_preset = get_filter_preset("filter_none") + assert none_preset is not None + assert none_preset.brightness == 0.0 + assert none_preset.contrast == 1.0 + assert none_preset.saturation == 1.0 + assert none_preset.gamma == 1.0 + + def test_total_count(self): + # 至少有15个预设 + assert len(FILTER_PRESET_LIBRARY) >= 15 + + +# ============================================================ +# get_filter_preset +# ============================================================ + + +class TestGetFilterPreset: + def test_existing_id(self): + p = get_filter_preset("filter_brighten") + assert p is not None + assert p.id == "filter_brighten" + assert p.name == "明亮" + + def test_nonexistent_id(self): + assert get_filter_preset("nonexistent") is None + + def test_empty_id(self): + assert get_filter_preset("") is None + + def test_case_sensitive(self): + # 大小写敏感 + assert get_filter_preset("Filter_Brighten") is None + + def test_returns_preset_object(self): + p = get_filter_preset("filter_cinematic") + assert isinstance(p, FilterPreset) + + +# ============================================================ +# list_filter_presets +# ============================================================ + + +class TestListFilterPresets: + def test_no_filters_returns_all(self): + result = list_filter_presets() + assert len(result) == len(FILTER_PRESET_LIBRARY) + + def test_filter_by_category_basic(self): + result = list_filter_presets(category="basic") + assert len(result) > 0 + for p in result: + assert p.category == "basic" + + def test_filter_by_category_cinematic(self): + result = list_filter_presets(category="cinematic") + for p in result: + assert p.category == "cinematic" + + def test_filter_by_category_bw(self): + result = list_filter_presets(category="bw") + for p in result: + assert p.category == "bw" + + def test_filter_by_category_vintage(self): + result = list_filter_presets(category="vintage") + for p in result: + assert p.category == "vintage" + + def test_filter_by_category_style(self): + result = list_filter_presets(category="style") + for p in result: + assert p.category == "style" + + def test_invalid_category_returns_empty(self): + result = list_filter_presets(category="nonexistent") + assert result == [] + + def test_keyword_search_name(self): + result = list_filter_presets(keyword="电影") + assert len(result) >= 1 + names = [p.name for p in result] + assert any("电影" in n for n in names) + + def test_keyword_search_tags(self): + result = list_filter_presets(keyword="复古") + assert len(result) >= 1 + # 至少有一个的标签或名称包含复古 + found = any(any("复古" in t for t in p.tags) or "复古" in p.name for p in result) + assert found + + def test_keyword_search_description(self): + result = list_filter_presets(keyword="青橙") + assert len(result) >= 1 + + def test_keyword_case_insensitive(self): + # 中文不区分大小写,用英文标签/名称试 + result = list_filter_presets(keyword="FILTER") + # 搜索 filter 应该能匹配到很多(id里有但搜索name/desc/tags) + # 用确定的中文关键词更可靠 + assert isinstance(result, list) + + def test_keyword_nonexistent_returns_empty(self): + result = list_filter_presets(keyword="zzz不存在的关键词zzz") + assert result == [] + + def test_category_and_keyword_combined(self): + result = list_filter_presets(category="basic", keyword="亮") + for p in result: + assert p.category == "basic" + assert "亮" in p.name or "亮" in p.description or any("亮" in t for t in p.tags) + + def test_keyword_empty_returns_all(self): + result = list_filter_presets(keyword="") + assert len(result) == len(FILTER_PRESET_LIBRARY) + + def test_returns_list_of_presets(self): + result = list_filter_presets() + for p in result: + assert isinstance(p, FilterPreset) + + +# ============================================================ +# build_ffmpeg_filter +# ============================================================ + + +class TestBuildFfmpegFilter: + def test_valid_preset_full_intensity(self): + result = build_ffmpeg_filter("filter_brighten", 100) + assert result.startswith("eq=") + assert "brightness=" in result + assert "contrast=" in result + + def test_invalid_preset_returns_empty(self): + assert build_ffmpeg_filter("nonexistent", 100) == "" + + def test_zero_intensity_returns_empty(self): + assert build_ffmpeg_filter("filter_brighten", 0) == "" + + def test_negative_intensity_returns_empty(self): + assert build_ffmpeg_filter("filter_brighten", -10) == "" + + def test_intensity_over_100_clamped(self): + # >100 按100算 + r100 = build_ffmpeg_filter("filter_saturate", 100) + r200 = build_ffmpeg_filter("filter_saturate", 200) + assert r100 == r200 + + def test_half_intensity_half_effect(self): + full = build_ffmpeg_filter("filter_brighten", 100) + half = build_ffmpeg_filter("filter_brighten", 50) + # 半强度的brightness应该小一些 + assert full != half + # 半强度仍然有效果 + assert half.startswith("eq=") + + def test_none_filter_returns_empty(self): + # filter_none所有参数都是默认值,应该返回空 + result = build_ffmpeg_filter("filter_none", 100) + assert result == "" + + def test_format_has_eq_prefix(self): + result = build_ffmpeg_filter("filter_saturate", 100) + assert result.startswith("eq=") + + def test_parameters_separated_by_colon(self): + result = build_ffmpeg_filter("filter_cinematic", 100) + parts = result[len("eq=") :].split(":") + assert len(parts) >= 3 # 至少有3个参数 + + def test_brightness_value_format(self): + result = build_ffmpeg_filter("filter_brighten", 100) + # brightness参数应该存在且是数值 + assert "brightness=0." in result or "brightness=-0." in result + + def test_saturation_value_format(self): + result = build_ffmpeg_filter("filter_saturate", 100) + assert "saturation=" in result + + def test_contrast_value_format(self): + result = build_ffmpeg_filter("filter_contrast", 100) + assert "contrast=" in result + + def test_gamma_parameters_present(self): + result = build_ffmpeg_filter("filter_warm", 100) + # 暖色预设应该有gamma_r/gamma_b变化 + assert "gamma_r=" in result or "gamma_b=" in result + + def test_very_low_intensity_near_zero(self): + # 强度1%,效果接近0,但因为有brightness等可能仍然在阈值以上 + result = build_ffmpeg_filter("filter_brighten", 1) + # 至少应该有一个参数(brightness * 0.01 = 0.0012 > 0.001) + assert isinstance(result, str) + + def test_all_presets_generate_valid_output(self): + # 每个预设在100强度下都能生成合法结果 + for preset in FILTER_PRESET_LIBRARY: + result = build_ffmpeg_filter(preset.id, 100) + assert isinstance(result, str) + if result: # filter_none 可能为空 + assert result.startswith("eq=") + + def test_intensity_50_between_0_and_100(self): + # 验证50%强度确实在0和100之间插值 + r0 = build_ffmpeg_filter("filter_saturate", 0) + r50 = build_ffmpeg_filter("filter_saturate", 50) + r100 = build_ffmpeg_filter("filter_saturate", 100) + assert r0 == "" + assert r50 != r100 + assert r50 != "" + + def test_hue_not_included_in_eq(self): + # hue 不在 eq 滤镜参数中(当前实现没有hue) + result = build_ffmpeg_filter("filter_cinematic", 100) + assert "hue=" not in result