diff --git a/apps/worker/video_processing/transition_engine.py b/apps/worker/video_processing/transition_engine.py index 03b8409ff..069b7915e 100755 --- a/apps/worker/video_processing/transition_engine.py +++ b/apps/worker/video_processing/transition_engine.py @@ -12,229 +12,20 @@ from __future__ import annotations import logging -import sys -from dataclasses import dataclass - -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from enum import Enum - - class StrEnum(str, Enum): - pass - +from packages.domain.transition_config import ( # noqa: F401 — 向后兼容 + CUT_TRANSITION, + DEFAULT_TRANSITION_DURATION, + MAX_TRANSITION_DURATION, + MIN_TRANSITION_DURATION, + TransitionConfig, + TransitionType, +) from video_processing.ffmpeg_utils import build_xfade_filter_chain logger = logging.getLogger(__name__) -# ── 常量 ────────────────────────────────────────────────────────────────────── - -# 转场时长范围(秒) -MIN_TRANSITION_DURATION = 0.3 -MAX_TRANSITION_DURATION = 2.0 -DEFAULT_TRANSITION_DURATION = 0.5 - -# 硬切(无转场) -CUT_TRANSITION = "cut" - - -# ── 转场类型枚举 ────────────────────────────────────────────────────────────── - - -class TransitionType(StrEnum): - """支持的转场效果类型. - - 每种类型对应 FFmpeg xfade filter 的一个 transition 值。 - 新增转场只需在此添加一项,并在 _FFMPEG_XFADE_MAP 中映射。 - """ - - # 硬切(无转场效果,直接拼接) - CUT = "cut" - - # 淡入淡出(最常用,默认 fallback) - FADE = "fade" - - # 溶解(交叉溶解) - DISSOLVE = "dissolve" - - # 滑入系列 - SLIDE_LEFT = "slideleft" - SLIDE_RIGHT = "slideright" - SLIDE_UP = "slideup" - SLIDE_DOWN = "slidedown" - - # 缩放 - ZOOM = "zoom" - - # 擦除系列 - WIPE_LEFT = "wipeleft" - WIPE_RIGHT = "wiperight" - WIPE_UP = "wipeup" - WIPE_DOWN = "wipedown" - - # 圆形扩散 - CIRCLE_CROP = "circlecrop" - - # 矩形覆盖 - RECT_CROP = "rectcrop" - - @classmethod - def all_supported(cls) -> list[str]: - """返回所有支持的转场类型名称列表.""" - return [t.value for t in cls if t != cls.CUT] - - @classmethod - def is_supported(cls, name: str) -> bool: - """检查转场类型是否支持(不区分大小写和下划线).""" - normalized = _normalize_transition_name(name) - return normalized in _NAME_TO_ENUM_MAP - - -# ── 名称 → 枚举 映射(支持多种别名)────────────────────────────────────────── - - -def _normalize_transition_name(name: str) -> str: - """标准化转场名称:小写 + 去下划线.""" - return name.lower().replace("_", "").replace("-", "") - - -# 构建别名映射 -_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {} -for _t in TransitionType: - _NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t - -# 额外的别名 -_ALIASES: dict[str, TransitionType] = { - "dissolve": TransitionType.DISSOLVE, - "crossfade": TransitionType.DISSOLVE, - "crossdissolve": TransitionType.DISSOLVE, - "fadein": TransitionType.FADE, - "fadeout": TransitionType.FADE, - "fadeblack": TransitionType.FADE, - "slide": TransitionType.SLIDE_LEFT, # 默认向左滑 - "wipe": TransitionType.WIPE_LEFT, # 默认向左擦 - "zoomin": TransitionType.ZOOM, - "zoomout": TransitionType.ZOOM, - "circle": TransitionType.CIRCLE_CROP, - "rect": TransitionType.RECT_CROP, -} -for _alias, _type in _ALIASES.items(): - _key = _normalize_transition_name(_alias) - if _key not in _NAME_TO_ENUM_MAP: - _NAME_TO_ENUM_MAP[_key] = _type - - -# ── TransitionType → FFmpeg xfade transition 名称映射 ───────────────────────── - - -_FFMPEG_XFADE_MAP: dict[TransitionType, str] = { - TransitionType.FADE: "fade", - TransitionType.DISSOLVE: "dissolve", - TransitionType.SLIDE_LEFT: "slideleft", - TransitionType.SLIDE_RIGHT: "slideright", - TransitionType.SLIDE_UP: "slideup", - TransitionType.SLIDE_DOWN: "slidedown", - TransitionType.ZOOM: "zoomin", - TransitionType.WIPE_LEFT: "wipeleft", - TransitionType.WIPE_RIGHT: "wiperight", - TransitionType.WIPE_UP: "wipeup", - TransitionType.WIPE_DOWN: "wipedown", - TransitionType.CIRCLE_CROP: "circlecrop", - TransitionType.RECT_CROP: "rectcrop", -} - - -# ── 转场配置 ────────────────────────────────────────────────────────────────── - - -@dataclass(slots=True) -class TransitionConfig: - """转场效果配置. - - Attributes: - effect: 转场效果名称(见 TransitionType) - duration: 转场时长(秒),范围 0.3~2.0,默认 0.5 - """ - - effect: str = CUT_TRANSITION - duration: float = DEFAULT_TRANSITION_DURATION - - @classmethod - def parse(cls, effect: str | None = None, duration: float | None = None) -> "TransitionConfig": - """解析并验证转场配置,自动处理边界和降级. - - Args: - effect: 转场效果名称(None 或空则使用默认 cut) - duration: 转场时长(None 则使用默认值) - - Returns: - 验证后的 TransitionConfig - """ - # 处理 effect - final_effect = CUT_TRANSITION - if effect and effect.strip(): - effect_clean = effect.strip() - if TransitionType.is_supported(effect_clean): - final_effect = _resolve_transition_enum(effect_clean).value - elif effect_clean.lower() == CUT_TRANSITION: - final_effect = CUT_TRANSITION - else: - # 降级:不支持的转场 → 硬切,不阻断渲染 - logger.warning( - "不支持的转场效果 '%s',已降级为硬切(cut)", - effect_clean, - ) - final_effect = CUT_TRANSITION - - # 处理 duration:边界钳制 - final_duration = DEFAULT_TRANSITION_DURATION - if duration is not None: - try: - d = float(duration) - if d < MIN_TRANSITION_DURATION: - logger.warning( - "转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值", - d, - MIN_TRANSITION_DURATION, - ) - final_duration = MIN_TRANSITION_DURATION - elif d > MAX_TRANSITION_DURATION: - logger.warning( - "转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值", - d, - MAX_TRANSITION_DURATION, - ) - final_duration = MAX_TRANSITION_DURATION - else: - final_duration = d - except (TypeError, ValueError): - logger.warning("无效的转场时长 '%s',使用默认值 %.1fs", duration, DEFAULT_TRANSITION_DURATION) - final_duration = DEFAULT_TRANSITION_DURATION - - return cls(effect=final_effect, duration=final_duration) - - @property - def is_cut(self) -> bool: - """是否为硬切(无转场效果).""" - return self.effect == CUT_TRANSITION - - @property - def ffmpeg_transition(self) -> str: - """获取对应的 FFmpeg xfade transition 名称.""" - if self.is_cut: - return "" - enum_type = _resolve_transition_enum(self.effect) - return _FFMPEG_XFADE_MAP.get(enum_type, "fade") - - -def _resolve_transition_enum(name: str) -> TransitionType: - """将名称解析为 TransitionType 枚举,必须先通过 is_supported 校验.""" - normalized = _normalize_transition_name(name) - return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE) - - # ── 转场引擎 ────────────────────────────────────────────────────────────────── diff --git a/packages/domain/transition_config.py b/packages/domain/transition_config.py new file mode 100755 index 000000000..3f7ee06dc --- /dev/null +++ b/packages/domain/transition_config.py @@ -0,0 +1,245 @@ +"""转场配置领域模型 — 纯逻辑,无 FFmpeg 依赖. + +抽离自 transition_engine.py 的枚举、数据类和纯逻辑函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +import logging +import sys +from dataclasses import dataclass + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + pass + + +logger = logging.getLogger(__name__) + + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +# 转场时长范围(秒) +MIN_TRANSITION_DURATION = 0.3 +MAX_TRANSITION_DURATION = 2.0 +DEFAULT_TRANSITION_DURATION = 0.5 + +# 硬切(无转场) +CUT_TRANSITION = "cut" + + +# ── 转场类型枚举 ────────────────────────────────────────────────────────────── + + +class TransitionType(StrEnum): + """支持的转场效果类型. + + 每种类型对应 FFmpeg xfade filter 的一个 transition 值。 + """ + + # 硬切(无转场效果,直接拼接) + CUT = "cut" + + # 淡入淡出(最常用,默认 fallback) + FADE = "fade" + + # 溶解(交叉溶解) + DISSOLVE = "dissolve" + + # 滑入系列 + SLIDE_LEFT = "slideleft" + SLIDE_RIGHT = "slideright" + SLIDE_UP = "slideup" + SLIDE_DOWN = "slidedown" + + # 缩放 + ZOOM = "zoom" + + # 擦除系列 + WIPE_LEFT = "wipeleft" + WIPE_RIGHT = "wiperight" + WIPE_UP = "wipeup" + WIPE_DOWN = "wipedown" + + # 圆形扩散 + CIRCLE_CROP = "circlecrop" + + # 矩形覆盖 + RECT_CROP = "rectcrop" + + @classmethod + def all_supported(cls) -> list[str]: + """返回所有支持的转场类型名称列表(不含 cut).""" + return [t.value for t in cls if t != cls.CUT] + + @classmethod + def is_supported(cls, name: str) -> bool: + """检查转场类型是否支持(不区分大小写和下划线).""" + normalized = _normalize_transition_name(name) + return normalized in _NAME_TO_ENUM_MAP + + +# ── 名称 → 枚举 映射(支持多种别名)────────────────────────────────────────── + + +def _normalize_transition_name(name: str) -> str: + """标准化转场名称:小写 + 去下划线 + 去中划线.""" + return name.lower().replace("_", "").replace("-", "") + + +# 构建别名映射 +_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {} +for _t in TransitionType: + _NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t + +# 额外的别名 +_ALIASES: dict[str, TransitionType] = { + "dissolve": TransitionType.DISSOLVE, + "crossfade": TransitionType.DISSOLVE, + "crossdissolve": TransitionType.DISSOLVE, + "fadein": TransitionType.FADE, + "fadeout": TransitionType.FADE, + "fadeblack": TransitionType.FADE, + "slide": TransitionType.SLIDE_LEFT, # 默认向左滑 + "wipe": TransitionType.WIPE_LEFT, # 默认向左擦 + "zoomin": TransitionType.ZOOM, + "zoomout": TransitionType.ZOOM, + "circle": TransitionType.CIRCLE_CROP, + "rect": TransitionType.RECT_CROP, +} +for _alias, _type in _ALIASES.items(): + _key = _normalize_transition_name(_alias) + if _key not in _NAME_TO_ENUM_MAP: + _NAME_TO_ENUM_MAP[_key] = _type + + +# ── TransitionType → FFmpeg xfade transition 名称映射 ───────────────────────── + + +_FFMPEG_XFADE_MAP: dict[TransitionType, str] = { + TransitionType.FADE: "fade", + TransitionType.DISSOLVE: "dissolve", + TransitionType.SLIDE_LEFT: "slideleft", + TransitionType.SLIDE_RIGHT: "slideright", + TransitionType.SLIDE_UP: "slideup", + TransitionType.SLIDE_DOWN: "slidedown", + TransitionType.ZOOM: "zoomin", + TransitionType.WIPE_LEFT: "wipeleft", + TransitionType.WIPE_RIGHT: "wiperight", + TransitionType.WIPE_UP: "wipeup", + TransitionType.WIPE_DOWN: "wipedown", + TransitionType.CIRCLE_CROP: "circlecrop", + TransitionType.RECT_CROP: "rectcrop", +} + + +def _resolve_transition_enum(name: str) -> TransitionType: + """将名称解析为 TransitionType 枚举,找不到则回退到 FADE.""" + normalized = _normalize_transition_name(name) + return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE) + + +# ── 转场配置 ────────────────────────────────────────────────────────────────── + + +@dataclass(slots=True) +class TransitionConfig: + """转场效果配置. + + Attributes: + effect: 转场效果名称(见 TransitionType) + duration: 转场时长(秒),范围 0.3~2.0,默认 0.5 + """ + + effect: str = CUT_TRANSITION + duration: float = DEFAULT_TRANSITION_DURATION + + @classmethod + def parse( + cls, + effect: str | None = None, + duration: float | None = None, + ) -> "TransitionConfig": + """解析并验证转场配置,自动处理边界和降级. + + Args: + effect: 转场效果名称(None 或空则使用默认 cut) + duration: 转场时长(None 则使用默认值) + + Returns: + 验证后的 TransitionConfig + """ + # 处理 effect + final_effect = CUT_TRANSITION + if effect and effect.strip(): + effect_clean = effect.strip() + if TransitionType.is_supported(effect_clean): + final_effect = _resolve_transition_enum(effect_clean).value + elif effect_clean.lower() == CUT_TRANSITION: + final_effect = CUT_TRANSITION + else: + # 降级:不支持的转场 → 硬切,不阻断渲染 + logger.warning( + "不支持的转场效果 '%s',已降级为硬切(cut)", + effect_clean, + ) + final_effect = CUT_TRANSITION + + # 处理 duration:边界钳制 + final_duration = DEFAULT_TRANSITION_DURATION + if duration is not None: + try: + d = float(duration) + if d < MIN_TRANSITION_DURATION: + logger.warning( + "转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值", + d, + MIN_TRANSITION_DURATION, + ) + final_duration = MIN_TRANSITION_DURATION + elif d > MAX_TRANSITION_DURATION: + logger.warning( + "转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值", + d, + MAX_TRANSITION_DURATION, + ) + final_duration = MAX_TRANSITION_DURATION + else: + final_duration = d + except (TypeError, ValueError): + logger.warning( + "无效的转场时长 '%s',使用默认值 %.1fs", + duration, + DEFAULT_TRANSITION_DURATION, + ) + final_duration = DEFAULT_TRANSITION_DURATION + + return cls(effect=final_effect, duration=final_duration) + + @property + def is_cut(self) -> bool: + """是否为硬切(无转场效果).""" + return self.effect == CUT_TRANSITION + + @property + def ffmpeg_transition(self) -> str: + """获取对应的 FFmpeg xfade transition 名称.""" + if self.is_cut: + return "" + enum_type = _resolve_transition_enum(self.effect) + return _FFMPEG_XFADE_MAP.get(enum_type, "fade") + + def validate(self) -> tuple[bool, str]: + """校验配置合法性,返回 (是否合法, 错误信息).""" + if self.duration < MIN_TRANSITION_DURATION: + return False, f"duration不能小于{MIN_TRANSITION_DURATION}s" + if self.duration > MAX_TRANSITION_DURATION: + return False, f"duration不能大于{MAX_TRANSITION_DURATION}s" + if not self.is_cut and not TransitionType.is_supported(self.effect): + return False, f"不支持的转场效果: {self.effect}" + return True, "" diff --git a/tests/unit/test_transition_config.py b/tests/unit/test_transition_config.py new file mode 100755 index 000000000..5cb7139fb --- /dev/null +++ b/tests/unit/test_transition_config.py @@ -0,0 +1,276 @@ +"""transition_config 模块单测 — 纯逻辑,无 FFmpeg 依赖.""" + +from __future__ import annotations + +import pytest + +from packages.domain.transition_config import ( + CUT_TRANSITION, + DEFAULT_TRANSITION_DURATION, + MAX_TRANSITION_DURATION, + MIN_TRANSITION_DURATION, + TransitionConfig, + TransitionType, +) + +# ── 常量 ────────────────────────────────────────────────────────────────────── + + +class TestConstants: + def test_duration_bounds(self): + assert MIN_TRANSITION_DURATION == 0.3 + assert MAX_TRANSITION_DURATION == 2.0 + assert DEFAULT_TRANSITION_DURATION == 0.5 + assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION + + def test_cut_transition(self): + assert CUT_TRANSITION == "cut" + + +# ── TransitionType 枚举 ────────────────────────────────────────────────────── + + +class TestTransitionType: + def test_all_supported_includes_all_except_cut(self): + supported = TransitionType.all_supported() + assert "cut" not in supported + assert "fade" in supported + assert "dissolve" in supported + assert len(supported) >= 10 # 至少有10种转场 + + def test_all_supported_unique(self): + supported = TransitionType.all_supported() + assert len(supported) == len(set(supported)) + + def test_is_supported_exact_match(self): + assert TransitionType.is_supported("fade") is True + assert TransitionType.is_supported("dissolve") is True + assert TransitionType.is_supported("slideleft") is True + + def test_is_supported_case_insensitive(self): + assert TransitionType.is_supported("FADE") is True + assert TransitionType.is_supported("Fade") is True + assert TransitionType.is_supported("SlideLeft") is True + + def test_is_supported_with_underscores(self): + assert TransitionType.is_supported("slide_left") is True + assert TransitionType.is_supported("wipe_right") is True + assert TransitionType.is_supported("circle_crop") is True + + def test_is_supported_with_hyphens(self): + assert TransitionType.is_supported("slide-left") is True + assert TransitionType.is_supported("wipe-down") is True + + def test_is_supported_aliases(self): + assert TransitionType.is_supported("crossfade") is True + assert TransitionType.is_supported("crossdissolve") is True + assert TransitionType.is_supported("fadein") is True + assert TransitionType.is_supported("fadeout") is True + assert TransitionType.is_supported("slide") is True + assert TransitionType.is_supported("wipe") is True + assert TransitionType.is_supported("zoomin") is True + assert TransitionType.is_supported("zoomout") is True + assert TransitionType.is_supported("circle") is True + assert TransitionType.is_supported("rect") is True + + def test_is_supported_unknown(self): + assert TransitionType.is_supported("unknown_effect") is False + assert TransitionType.is_supported("") is False + assert TransitionType.is_supported("12345") is False + + def test_enum_values_match_ffmpeg(self): + # 枚举值应该就是 ffmpeg xfade 的 transition 名 + assert TransitionType.FADE.value == "fade" + assert TransitionType.DISSOLVE.value == "dissolve" + assert TransitionType.SLIDE_LEFT.value == "slideleft" + assert TransitionType.CUT.value == "cut" + + +# ── TransitionConfig 默认值 ────────────────────────────────────────────────── + + +class TestTransitionConfigDefaults: + def test_default_config(self): + cfg = TransitionConfig() + assert cfg.effect == CUT_TRANSITION + assert cfg.duration == DEFAULT_TRANSITION_DURATION + assert cfg.is_cut is True + + def test_is_cut_true(self): + cfg = TransitionConfig(effect="cut") + assert cfg.is_cut is True + + def test_is_cut_false(self): + cfg = TransitionConfig(effect="fade") + assert cfg.is_cut is False + + +# ── TransitionConfig.parse ─────────────────────────────────────────────────── + + +class TestTransitionConfigParse: + def test_none_params_default(self): + cfg = TransitionConfig.parse() + assert cfg.effect == CUT_TRANSITION + assert cfg.duration == DEFAULT_TRANSITION_DURATION + + def test_empty_effect_default(self): + cfg = TransitionConfig.parse(effect="") + assert cfg.effect == CUT_TRANSITION + + def test_whitespace_effect_default(self): + cfg = TransitionConfig.parse(effect=" ") + assert cfg.effect == CUT_TRANSITION + + def test_valid_effect_fade(self): + cfg = TransitionConfig.parse(effect="fade") + assert cfg.effect == "fade" + assert cfg.is_cut is False + + def test_valid_effect_case_insensitive(self): + cfg = TransitionConfig.parse(effect="FADE") + assert cfg.effect == "fade" + + def test_valid_effect_with_underscores(self): + cfg = TransitionConfig.parse(effect="slide_left") + assert cfg.effect == "slideleft" + + def test_alias_effect(self): + cfg = TransitionConfig.parse(effect="crossfade") + assert cfg.effect == "dissolve" # 别名映射到 dissolve + + def test_unknown_effect_falls_back_to_cut(self): + cfg = TransitionConfig.parse(effect="magic_sparkles") + assert cfg.effect == CUT_TRANSITION + assert cfg.is_cut is True + + def test_cut_effect_stays_cut(self): + cfg = TransitionConfig.parse(effect="cut") + assert cfg.effect == CUT_TRANSITION + + def test_cut_effect_case_insensitive(self): + cfg = TransitionConfig.parse(effect="CUT") + assert cfg.effect == CUT_TRANSITION + + def test_duration_default(self): + cfg = TransitionConfig.parse(duration=None) + assert cfg.duration == DEFAULT_TRANSITION_DURATION + + def test_duration_within_range(self): + cfg = TransitionConfig.parse(duration=1.0) + assert cfg.duration == 1.0 + + def test_duration_at_min(self): + cfg = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_duration_at_max(self): + cfg = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION) + assert cfg.duration == MAX_TRANSITION_DURATION + + def test_duration_below_min_clamped(self): + cfg = TransitionConfig.parse(duration=0.1) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_duration_above_max_clamped(self): + cfg = TransitionConfig.parse(duration=3.0) + assert cfg.duration == MAX_TRANSITION_DURATION + + def test_duration_zero_clamped(self): + cfg = TransitionConfig.parse(duration=0) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_duration_negative_clamped(self): + cfg = TransitionConfig.parse(duration=-1.0) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_duration_invalid_string_fallback(self): + cfg = TransitionConfig.parse(duration="bad") # type: ignore[arg-type] + assert cfg.duration == DEFAULT_TRANSITION_DURATION + + def test_duration_numeric_string(self): + cfg = TransitionConfig.parse(duration="1.5") # type: ignore[arg-type] + assert cfg.duration == 1.5 + + def test_full_parse(self): + cfg = TransitionConfig.parse(effect="wipe_up", duration=1.2) + assert cfg.effect == "wipeup" + assert cfg.duration == 1.2 + assert cfg.is_cut is False + + +# ── TransitionConfig.ffmpeg_transition ─────────────────────────────────────── + + +class TestFfmpegTransition: + def test_cut_returns_empty(self): + cfg = TransitionConfig(effect="cut") + assert cfg.ffmpeg_transition == "" + + def test_fade_matches(self): + cfg = TransitionConfig(effect="fade") + assert cfg.ffmpeg_transition == "fade" + + def test_dissolve_matches(self): + cfg = TransitionConfig(effect="dissolve") + assert cfg.ffmpeg_transition == "dissolve" + + def test_slide_left_matches(self): + cfg = TransitionConfig(effect="slideleft") + assert cfg.ffmpeg_transition == "slideleft" + + def test_wipe_down_matches(self): + cfg = TransitionConfig(effect="wipedown") + assert cfg.ffmpeg_transition == "wipedown" + + def test_zoom_matches_zoomin(self): + cfg = TransitionConfig(effect="zoom") + assert cfg.ffmpeg_transition == "zoomin" + + def test_circle_crop_matches(self): + cfg = TransitionConfig(effect="circlecrop") + assert cfg.ffmpeg_transition == "circlecrop" + + +# ── TransitionConfig.validate ──────────────────────────────────────────────── + + +class TestTransitionConfigValidate: + def test_valid_cut(self): + cfg = TransitionConfig(effect="cut", duration=0.5) + ok, err = cfg.validate() + assert ok is True + assert err == "" + + def test_valid_fade(self): + cfg = TransitionConfig(effect="fade", duration=1.0) + ok, err = cfg.validate() + assert ok is True + + def test_duration_below_min_invalid(self): + cfg = TransitionConfig(effect="fade", duration=0.1) + ok, err = cfg.validate() + assert ok is False + assert "duration" in err + + def test_duration_above_max_invalid(self): + cfg = TransitionConfig(effect="fade", duration=3.0) + ok, err = cfg.validate() + assert ok is False + assert "duration" in err + + def test_unsupported_effect_invalid(self): + cfg = TransitionConfig(effect="unknown", duration=0.5) + ok, err = cfg.validate() + assert ok is False + assert "不支持的转场" in err + + def test_min_duration_boundary_valid(self): + cfg = TransitionConfig(effect="fade", duration=MIN_TRANSITION_DURATION) + ok, _ = cfg.validate() + assert ok is True + + def test_max_duration_boundary_valid(self): + cfg = TransitionConfig(effect="fade", duration=MAX_TRANSITION_DURATION) + ok, _ = cfg.validate() + assert ok is True