diff --git a/apps/worker/video_processing/color_grade_engine.py b/apps/worker/video_processing/color_grade_engine.py index edf92b16f..da5b44d8d 100755 --- a/apps/worker/video_processing/color_grade_engine.py +++ b/apps/worker/video_processing/color_grade_engine.py @@ -10,233 +10,28 @@ from __future__ import annotations import logging -from dataclasses import dataclass -from typing import Any -logger = logging.getLogger(__name__) - - -# ── 预设滤镜包 ──────────────────────────────────────────────────────────────── - -# 预设名称常量 -PRESET_FRESH = "fresh" # 清新 -PRESET_JAPANESE = "japanese" # 日系 -PRESET_VINTAGE = "vintage" # 复古 -PRESET_CINEMA = "cinema" # 电影 -PRESET_FILM = "film" # 胶片 -PRESET_BW = "black_white" # 黑白 -PRESET_WARM = "warm" # 暖色 -PRESET_COOL = "cool" # 冷色 - -VALID_PRESETS = { +from packages.domain.color_grade_config import ( # noqa: F401 — 向后兼容 + DEFAULT_PARAMS, + PARAM_RANGES, + PRESET_BW, + PRESET_CINEMA, + PRESET_COOL, + PRESET_DISPLAY_NAMES, + PRESET_FILM, PRESET_FRESH, PRESET_JAPANESE, + PRESET_PARAMS, PRESET_VINTAGE, - PRESET_CINEMA, - PRESET_FILM, - PRESET_BW, PRESET_WARM, - PRESET_COOL, -} + VALID_PRESETS, + ColorGradeConfig, + clamp_param, + get_preset_names, + get_preset_params, +) -# 预设名称 → 中文显示名 -PRESET_DISPLAY_NAMES = { - PRESET_FRESH: "清新", - PRESET_JAPANESE: "日系", - PRESET_VINTAGE: "复古", - PRESET_CINEMA: "电影", - PRESET_FILM: "胶片", - PRESET_BW: "黑白", - PRESET_WARM: "暖色", - PRESET_COOL: "冷色", -} - -# 预设参数配置 -# 每个预设包含:brightness, contrast, saturation, temperature, hue -# 取值范围:brightness/contrast/temperature -100~100, saturation 0~200, hue -180~180 -PRESET_PARAMS: dict[str, dict[str, float]] = { - PRESET_FRESH: { - # 清新:提亮、高饱和、偏冷、微微调 - "brightness": 8, - "contrast": 10, - "saturation": 120, - "temperature": -8, - "hue": 5, - }, - PRESET_JAPANESE: { - # 日系:低对比、低饱和、偏暖、偏黄绿 - "brightness": 12, - "contrast": -15, - "saturation": 70, - "temperature": 10, - "hue": -5, - }, - PRESET_VINTAGE: { - # 复古:低饱和、偏黄、对比度适中、偏暖 - "brightness": -5, - "contrast": 5, - "saturation": 60, - "temperature": 25, - "hue": -8, - }, - PRESET_CINEMA: { - # 电影:高对比、低饱和、偏冷蓝、暗角感 - "brightness": -8, - "contrast": 20, - "saturation": 75, - "temperature": -15, - "hue": -3, - }, - PRESET_FILM: { - # 胶片:中对比、饱和适中、偏暖、颗粒感(这里只用调色模拟) - "brightness": -3, - "contrast": 12, - "saturation": 95, - "temperature": 15, - "hue": -2, - }, - PRESET_BW: { - # 黑白:饱和度为0,对比度略高 - "brightness": 0, - "contrast": 15, - "saturation": 0, - "temperature": 0, - "hue": 0, - }, - PRESET_WARM: { - # 暖色:高色温、偏红黄 - "brightness": 5, - "contrast": 8, - "saturation": 110, - "temperature": 30, - "hue": -5, - }, - PRESET_COOL: { - # 冷色:低色温、偏蓝青 - "brightness": 3, - "contrast": 8, - "saturation": 105, - "temperature": -25, - "hue": 8, - }, -} - - -# ── 参数范围 ────────────────────────────────────────────────────────────────── - -PARAM_RANGES = { - "brightness": (-100.0, 100.0), - "contrast": (-100.0, 100.0), - "saturation": (0.0, 200.0), - "temperature": (-100.0, 100.0), - "hue": (-180.0, 180.0), -} - -# 默认值(零调整) -DEFAULT_PARAMS = { - "brightness": 0.0, - "contrast": 0.0, - "saturation": 100.0, - "temperature": 0.0, - "hue": 0.0, -} - - -# ── 数据模型 ────────────────────────────────────────────────────────────────── - - -@dataclass -class ColorGradeConfig: - """色彩调色配置. - - 优先级:自定义参数 > 预设参数 - 即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖 - """ - - enabled: bool = False - preset: str = "" # 预设名称,空表示不使用预设 - # 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值) - brightness: float | None = None - contrast: float | None = None - saturation: float | None = None - temperature: float | None = None - hue: float | None = None - - def resolve_params(self) -> dict[str, float]: - """解析最终调色参数(预设 + 自定义覆盖 + 边界钳制). - - Returns: - 包含 brightness, contrast, saturation, temperature, hue 的参数字典 - """ - # 1. 从默认值开始 - params = dict(DEFAULT_PARAMS) - - # 2. 应用预设 - if self.preset and self.preset in PRESET_PARAMS: - params.update(PRESET_PARAMS[self.preset]) - - # 3. 应用自定义覆盖 - if self.brightness is not None: - params["brightness"] = self.brightness - if self.contrast is not None: - params["contrast"] = self.contrast - if self.saturation is not None: - params["saturation"] = self.saturation - if self.temperature is not None: - params["temperature"] = self.temperature - if self.hue is not None: - params["hue"] = self.hue - - # 4. 边界钳制 - for key, (min_val, max_val) in PARAM_RANGES.items(): - params[key] = max(min_val, min(max_val, params[key])) - - return params - - def has_effect(self) -> bool: - """判断是否有实际调色效果(所有参数都是默认值则无效果). - - 用于优化:无效果时跳过滤镜,不浪费性能。 - """ - params = self.resolve_params() - for key, default in DEFAULT_PARAMS.items(): - if abs(params[key] - default) > 0.001: - return True - return False - - @classmethod - def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig": - """从字典解析配置.""" - if not data or not data.get("enabled", False): - return cls(enabled=False) - - preset = data.get("preset", "") - if preset and preset not in VALID_PRESETS: - logger.warning("未知的调色预设: %s,忽略预设", preset) - preset = "" - - def _get_float(key: str) -> float | None: - val = data.get(key) - if val is None: - return None - try: - return float(val) - except (ValueError, TypeError): - return None - - try: - return cls( - enabled=True, - preset=preset, - brightness=_get_float("brightness"), - contrast=_get_float("contrast"), - saturation=_get_float("saturation"), - temperature=_get_float("temperature"), - hue=_get_float("hue"), - ) - except Exception as e: - logger.warning("调色配置解析失败: %s,使用默认配置", e) - return cls(enabled=False) +logger = logging.getLogger(__name__) # ── 调色引擎 ────────────────────────────────────────────────────────────────── diff --git a/packages/domain/color_grade_config.py b/packages/domain/color_grade_config.py new file mode 100755 index 000000000..7c0710824 --- /dev/null +++ b/packages/domain/color_grade_config.py @@ -0,0 +1,268 @@ +"""色彩调色配置领域模型 — 纯逻辑,无 FFmpeg 依赖. + +抽离自 color_grade_engine.py 的数据类、预设常量和纯逻辑函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + + +# ── 预设常量 ────────────────────────────────────────────────────────────────── + +PRESET_FRESH = "fresh" # 清新 +PRESET_JAPANESE = "japanese" # 日系 +PRESET_VINTAGE = "vintage" # 复古 +PRESET_CINEMA = "cinema" # 电影 +PRESET_FILM = "film" # 胶片 +PRESET_BW = "black_white" # 黑白 +PRESET_WARM = "warm" # 暖色 +PRESET_COOL = "cool" # 冷色 + +VALID_PRESETS = { + PRESET_FRESH, + PRESET_JAPANESE, + PRESET_VINTAGE, + PRESET_CINEMA, + PRESET_FILM, + PRESET_BW, + PRESET_WARM, + PRESET_COOL, +} + +# 预设名称 → 中文显示名 +PRESET_DISPLAY_NAMES = { + PRESET_FRESH: "清新", + PRESET_JAPANESE: "日系", + PRESET_VINTAGE: "复古", + PRESET_CINEMA: "电影", + PRESET_FILM: "胶片", + PRESET_BW: "黑白", + PRESET_WARM: "暖色", + PRESET_COOL: "冷色", +} + +# 预设参数配置 +# 每个预设包含:brightness, contrast, saturation, temperature, hue +PRESET_PARAMS: dict[str, dict[str, float]] = { + PRESET_FRESH: { + "brightness": 8, + "contrast": 10, + "saturation": 120, + "temperature": -8, + "hue": 5, + }, + PRESET_JAPANESE: { + "brightness": 12, + "contrast": -15, + "saturation": 70, + "temperature": 10, + "hue": -5, + }, + PRESET_VINTAGE: { + "brightness": -5, + "contrast": 5, + "saturation": 60, + "temperature": 25, + "hue": -8, + }, + PRESET_CINEMA: { + "brightness": -8, + "contrast": 20, + "saturation": 75, + "temperature": -15, + "hue": -3, + }, + PRESET_FILM: { + "brightness": -3, + "contrast": 12, + "saturation": 95, + "temperature": 15, + "hue": -2, + }, + PRESET_BW: { + "brightness": 0, + "contrast": 15, + "saturation": 0, + "temperature": 0, + "hue": 0, + }, + PRESET_WARM: { + "brightness": 5, + "contrast": 8, + "saturation": 110, + "temperature": 30, + "hue": -5, + }, + PRESET_COOL: { + "brightness": 3, + "contrast": 8, + "saturation": 105, + "temperature": -25, + "hue": 8, + }, +} + + +# ── 参数范围 ────────────────────────────────────────────────────────────────── + +PARAM_RANGES: dict[str, tuple[float, float]] = { + "brightness": (-100.0, 100.0), + "contrast": (-100.0, 100.0), + "saturation": (0.0, 200.0), + "temperature": (-100.0, 100.0), + "hue": (-180.0, 180.0), +} + +# 默认值(零调整) +DEFAULT_PARAMS: dict[str, float] = { + "brightness": 0.0, + "contrast": 0.0, + "saturation": 100.0, + "temperature": 0.0, + "hue": 0.0, +} + +ALL_PARAM_KEYS = ("brightness", "contrast", "saturation", "temperature", "hue") + + +# ── 数据模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class ColorGradeConfig: + """色彩调色配置. + + 优先级:自定义参数 > 预设参数 + 即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖 + """ + + enabled: bool = False + preset: str = "" # 预设名称,空表示不使用预设 + # 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值) + brightness: float | None = None + contrast: float | None = None + saturation: float | None = None + temperature: float | None = None + hue: float | None = None + + def resolve_params(self) -> dict[str, float]: + """解析最终调色参数(预设 + 自定义覆盖 + 边界钳制). + + Returns: + 包含 brightness, contrast, saturation, temperature, hue 的参数字典 + """ + # 1. 从默认值开始 + params = dict(DEFAULT_PARAMS) + + # 2. 应用预设 + if self.preset and self.preset in PRESET_PARAMS: + params.update(PRESET_PARAMS[self.preset]) + + # 3. 应用自定义覆盖 + if self.brightness is not None: + params["brightness"] = self.brightness + if self.contrast is not None: + params["contrast"] = self.contrast + if self.saturation is not None: + params["saturation"] = self.saturation + if self.temperature is not None: + params["temperature"] = self.temperature + if self.hue is not None: + params["hue"] = self.hue + + # 4. 边界钳制 + for key, (min_val, max_val) in PARAM_RANGES.items(): + params[key] = max(min_val, min(max_val, params[key])) + + return params + + def has_effect(self) -> bool: + """判断是否有实际调色效果(所有参数都是默认值则无效果). + + 用于优化:无效果时跳过滤镜,不浪费性能。 + """ + params = self.resolve_params() + for key, default in DEFAULT_PARAMS.items(): + if abs(params[key] - default) > 0.001: + return True + return False + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig": + """从字典解析配置.""" + if not data or not data.get("enabled", False): + return cls(enabled=False) + + preset = data.get("preset", "") + if preset and preset not in VALID_PRESETS: + logger.warning("未知的调色预设: %s,忽略预设", preset) + preset = "" + + def _get_float(key: str) -> float | None: + val = data.get(key) + if val is None: + return None + try: + return float(val) + except (ValueError, TypeError): + return None + + try: + return cls( + enabled=True, + preset=preset, + brightness=_get_float("brightness"), + contrast=_get_float("contrast"), + saturation=_get_float("saturation"), + temperature=_get_float("temperature"), + hue=_get_float("hue"), + ) + except Exception as e: + logger.warning("调色配置解析失败: %s,使用默认配置", e) + return cls(enabled=False) + + def validate(self) -> tuple[bool, str]: + """校验配置合法性,返回 (是否合法, 错误信息).""" + if not self.enabled: + return True, "" + + if self.preset and self.preset not in VALID_PRESETS: + return False, f"未知的预设: {self.preset}" + + # 解析后的参数自然在合法范围内(resolve_params 会钳制) + # 这里检查是否有明显无效的自定义值 + for key in ALL_PARAM_KEYS: + val = getattr(self, key) + if val is not None: + min_val, max_val = PARAM_RANGES[key] + if val < min_val or val > max_val: + return False, f"{key}超出范围[{min_val}, {max_val}]: {val}" + + return True, "" + + +# ── 工具函数 ────────────────────────────────────────────────────────────────── + + +def get_preset_names() -> list[tuple[str, str]]: + """获取所有预设的 (name, display_name) 列表.""" + return [(p, PRESET_DISPLAY_NAMES[p]) for p in sorted(VALID_PRESETS)] + + +def get_preset_params(preset: str) -> dict[str, float] | None: + """获取指定预设的参数,不存在返回 None.""" + return PRESET_PARAMS.get(preset) + + +def clamp_param(param_name: str, value: float) -> float: + """将参数钳制到合法范围内.""" + if param_name not in PARAM_RANGES: + return value + min_val, max_val = PARAM_RANGES[param_name] + return max(min_val, min(max_val, value)) diff --git a/tests/unit/test_color_grade_config.py b/tests/unit/test_color_grade_config.py new file mode 100755 index 000000000..0bc89a985 --- /dev/null +++ b/tests/unit/test_color_grade_config.py @@ -0,0 +1,356 @@ +"""color_grade_config 模块单测 — 纯逻辑,无 FFmpeg 依赖.""" + +from __future__ import annotations + +import pytest + +from packages.domain.color_grade_config import ( + ALL_PARAM_KEYS, + 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, + clamp_param, + get_preset_names, + get_preset_params, +) + +# ── 常量 ────────────────────────────────────────────────────────────────────── + + +class TestConstants: + def test_eight_presets(self): + assert len(VALID_PRESETS) == 8 + + def test_preset_display_names_match(self): + assert set(PRESET_DISPLAY_NAMES.keys()) == VALID_PRESETS + for name in VALID_PRESETS: + assert len(PRESET_DISPLAY_NAMES[name]) > 0 + + def test_preset_params_complete(self): + assert set(PRESET_PARAMS.keys()) == VALID_PRESETS + for preset, params in PRESET_PARAMS.items(): + assert set(params.keys()) == set(ALL_PARAM_KEYS) + + def test_default_params_keys(self): + assert set(DEFAULT_PARAMS.keys()) == set(ALL_PARAM_KEYS) + + def test_param_ranges_keys(self): + assert set(PARAM_RANGES.keys()) == set(ALL_PARAM_KEYS) + + def test_default_within_ranges(self): + for key in ALL_PARAM_KEYS: + min_val, max_val = PARAM_RANGES[key] + assert min_val <= DEFAULT_PARAMS[key] <= max_val + + def test_all_presets_within_ranges(self): + for preset, params in PRESET_PARAMS.items(): + for key in ALL_PARAM_KEYS: + min_val, max_val = PARAM_RANGES[key] + assert min_val <= params[key] <= max_val, f"{preset}.{key}={params[key]} out of range" + + +# ── 默认值 ──────────────────────────────────────────────────────────────────── + + +class TestColorGradeConfigDefaults: + def test_default_disabled(self): + cfg = ColorGradeConfig() + assert cfg.enabled is False + assert cfg.preset == "" + assert cfg.brightness is None + assert cfg.contrast is None + assert cfg.saturation is None + assert cfg.temperature is None + assert cfg.hue is None + + def test_default_has_no_effect(self): + cfg = ColorGradeConfig() + assert cfg.has_effect() is False + + def test_default_resolve_params_equals_defaults(self): + cfg = ColorGradeConfig() + params = cfg.resolve_params() + for key in ALL_PARAM_KEYS: + assert params[key] == DEFAULT_PARAMS[key] + + +# ── from_dict ──────────────────────────────────────────────────────────────── + + +class TestFromDict: + def test_none_data_disabled(self): + cfg = ColorGradeConfig.from_dict(None) + assert cfg.enabled is False + + def test_empty_dict_disabled(self): + cfg = ColorGradeConfig.from_dict({}) + assert cfg.enabled is False + + def test_enabled_false(self): + cfg = ColorGradeConfig.from_dict({"enabled": False}) + assert cfg.enabled is False + + def test_enabled_no_preset(self): + cfg = ColorGradeConfig.from_dict({"enabled": True}) + assert cfg.enabled is True + assert cfg.preset == "" + assert cfg.brightness is None + + def test_with_valid_preset(self): + cfg = ColorGradeConfig.from_dict({"enabled": True, "preset": "fresh"}) + assert cfg.enabled is True + assert cfg.preset == "fresh" + + def test_with_invalid_preset_ignored(self): + cfg = ColorGradeConfig.from_dict({"enabled": True, "preset": "unknown_preset"}) + assert cfg.enabled is True + assert cfg.preset == "" # 无效预设被忽略 + + def test_with_custom_params(self): + cfg = ColorGradeConfig.from_dict( + { + "enabled": True, + "brightness": 10, + "contrast": -5, + "saturation": 150, + "temperature": 20, + "hue": 30, + } + ) + assert cfg.enabled is True + assert cfg.brightness == 10.0 + assert cfg.contrast == -5.0 + assert cfg.saturation == 150.0 + assert cfg.temperature == 20.0 + assert cfg.hue == 30.0 + + def test_with_preset_and_custom_override(self): + cfg = ColorGradeConfig.from_dict( + { + "enabled": True, + "preset": "fresh", + "brightness": 50, # 覆盖预设的 8 + } + ) + assert cfg.preset == "fresh" + assert cfg.brightness == 50.0 + + def test_invalid_param_values_none(self): + cfg = ColorGradeConfig.from_dict( + { + "enabled": True, + "brightness": "not_a_number", + } + ) + assert cfg.enabled is True + assert cfg.brightness is None # 解析失败为 None + + def test_numeric_string_params(self): + cfg = ColorGradeConfig.from_dict( + { + "enabled": True, + "brightness": "15.5", + } + ) + assert cfg.brightness == 15.5 + + def test_preset_bw(self): + cfg = ColorGradeConfig.from_dict({"enabled": True, "preset": "black_white"}) + assert cfg.preset == "black_white" + params = cfg.resolve_params() + assert params["saturation"] == 0.0 + + +# ── resolve_params ─────────────────────────────────────────────────────────── + + +class TestResolveParams: + def test_default_returns_defaults(self): + cfg = ColorGradeConfig() + params = cfg.resolve_params() + assert params == DEFAULT_PARAMS + + def test_preset_fresh_params(self): + cfg = ColorGradeConfig(preset="fresh") + params = cfg.resolve_params() + assert params["brightness"] == 8 + assert params["contrast"] == 10 + assert params["saturation"] == 120 + + def test_preset_bw_saturation_zero(self): + cfg = ColorGradeConfig(preset="black_white") + params = cfg.resolve_params() + assert params["saturation"] == 0 + assert params["contrast"] == 15 + + def test_custom_override_preset(self): + cfg = ColorGradeConfig(preset="fresh", brightness=50) + params = cfg.resolve_params() + assert params["brightness"] == 50 # 覆盖了预设 + assert params["saturation"] == 120 # 预设值保留 + + def test_below_min_clamped(self): + cfg = ColorGradeConfig(brightness=-200, saturation=-10) + params = cfg.resolve_params() + assert params["brightness"] == PARAM_RANGES["brightness"][0] + assert params["saturation"] == PARAM_RANGES["saturation"][0] + + def test_above_max_clamped(self): + cfg = ColorGradeConfig(brightness=200, saturation=300, hue=360) + params = cfg.resolve_params() + assert params["brightness"] == PARAM_RANGES["brightness"][1] + assert params["saturation"] == PARAM_RANGES["saturation"][1] + assert params["hue"] == PARAM_RANGES["hue"][1] + + def test_all_presets_resolve_within_ranges(self): + for preset in VALID_PRESETS: + cfg = ColorGradeConfig(enabled=True, preset=preset) + params = cfg.resolve_params() + for key in ALL_PARAM_KEYS: + min_val, max_val = PARAM_RANGES[key] + assert min_val <= params[key] <= max_val, f"{preset}.{key}={params[key]}" + + def test_returns_new_dict_each_call(self): + cfg = ColorGradeConfig(preset="warm") + p1 = cfg.resolve_params() + p2 = cfg.resolve_params() + assert p1 is not p2 + p1["brightness"] = 999 + assert p2["brightness"] != 999 + + +# ── has_effect ─────────────────────────────────────────────────────────────── + + +class TestHasEffect: + def test_disabled_no_effect(self): + cfg = ColorGradeConfig(enabled=False) + assert cfg.has_effect() is False + + def test_default_no_effect(self): + cfg = ColorGradeConfig(enabled=True) + assert cfg.has_effect() is False + + def test_preset_has_effect(self): + for preset in VALID_PRESETS: + cfg = ColorGradeConfig(enabled=True, preset=preset) + # 大部分预设都有效果,除了默认值完全一致的(应该没有) + if preset in ("black_white",): + assert cfg.has_effect() is True # BW 有 contrast=15 + else: + assert cfg.has_effect() is True + + def test_custom_brightness_has_effect(self): + cfg = ColorGradeConfig(enabled=True, brightness=1.0) + assert cfg.has_effect() is True + + def test_tiny_change_no_effect(self): + # 小于 0.001 的差异视为无效果 + cfg = ColorGradeConfig(enabled=True) + # 直接通过默认值的话应该没有效果 + assert cfg.has_effect() is False + + def test_custom_saturation_changed_from_default(self): + # 默认 saturation=100,改成 101 就有效果 + cfg = ColorGradeConfig(enabled=True, saturation=101) + assert cfg.has_effect() is True + + +# ── validate ───────────────────────────────────────────────────────────────── + + +class TestValidate: + def test_disabled_always_valid(self): + cfg = ColorGradeConfig(enabled=False) + ok, err = cfg.validate() + assert ok is True + assert err == "" + + def test_enabled_no_preset_valid(self): + cfg = ColorGradeConfig(enabled=True) + ok, err = cfg.validate() + assert ok is True + + def test_valid_preset(self): + cfg = ColorGradeConfig(enabled=True, preset="cinema") + ok, _ = cfg.validate() + assert ok is True + + def test_invalid_preset(self): + cfg = ColorGradeConfig(enabled=True, preset="invalid") + ok, err = cfg.validate() + assert ok is False + assert "预设" in err + + def test_custom_param_out_of_range(self): + cfg = ColorGradeConfig(enabled=True, brightness=500) + ok, err = cfg.validate() + assert ok is False + assert "brightness" in err + + def test_custom_param_within_range(self): + cfg = ColorGradeConfig(enabled=True, brightness=50, contrast=-50) + ok, _ = cfg.validate() + assert ok is True + + def test_saturation_negative_invalid(self): + cfg = ColorGradeConfig(enabled=True, saturation=-1) + ok, err = cfg.validate() + assert ok is False + assert "saturation" in err + + +# ── 工具函数 ───────────────────────────────────────────────────────────────── + + +class TestUtils: + def test_get_preset_names_returns_all(self): + names = get_preset_names() + assert len(names) == len(VALID_PRESETS) + preset_keys = [n[0] for n in names] + assert set(preset_keys) == VALID_PRESETS + + def test_get_preset_names_sorted(self): + names = get_preset_names() + preset_keys = [n[0] for n in names] + assert preset_keys == sorted(preset_keys) + + def test_get_preset_params_valid(self): + params = get_preset_params("fresh") + assert params is not None + assert params["brightness"] == 8 + + def test_get_preset_params_invalid(self): + params = get_preset_params("unknown") + assert params is None + + @pytest.mark.parametrize( + "param,value,expected", + [ + ("brightness", 50, 50), + ("brightness", 200, 100), + ("brightness", -200, -100), + ("saturation", 50, 50), + ("saturation", -10, 0), + ("saturation", 300, 200), + ("hue", 0, 0), + ("hue", 200, 180), + ("hue", -200, -180), + ], + ) + def test_clamp_param(self, param, value, expected): + assert clamp_param(param, value) == expected + + def test_clamp_param_unknown_passthrough(self): + assert clamp_param("unknown_param", 999) == 999