diff --git a/tests/unit/test_transition_config.py b/tests/unit/test_transition_config.py index 5cb7139fb..67be4467a 100755 --- a/tests/unit/test_transition_config.py +++ b/tests/unit/test_transition_config.py @@ -1,4 +1,4 @@ -"""transition_config 模块单测 — 纯逻辑,无 FFmpeg 依赖.""" +"""transition_config 转场配置领域模型单元测试.""" from __future__ import annotations @@ -13,264 +13,454 @@ from packages.domain.transition_config import ( TransitionType, ) -# ── 常量 ────────────────────────────────────────────────────────────────────── +# ── 常量测试 ────────────────────────────────────────────────────────────────── class TestConstants: - def test_duration_bounds(self): + """常量测试.""" + + def test_min_duration(self): + """最小转场时长.""" assert MIN_TRANSITION_DURATION == 0.3 + + def test_max_duration(self): + """最大转场时长.""" assert MAX_TRANSITION_DURATION == 2.0 + + def test_default_duration(self): + """默认转场时长.""" assert DEFAULT_TRANSITION_DURATION == 0.5 - assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION + + def test_duration_range_valid(self): + """时长范围合理:min < default < max.""" + assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION + assert DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION def test_cut_transition(self): + """硬切常量.""" assert CUT_TRANSITION == "cut" -# ── TransitionType 枚举 ────────────────────────────────────────────────────── +# ── TransitionType 枚举测试 ─────────────────────────────────────────────────── class TestTransitionType: - def test_all_supported_includes_all_except_cut(self): + """TransitionType 枚举测试.""" + + def test_has_cut(self): + """有CUT类型.""" + assert TransitionType.CUT.value == "cut" + + def test_has_fade(self): + """有FADE类型.""" + assert TransitionType.FADE.value == "fade" + + def test_has_dissolve(self): + """有DISSOLVE类型.""" + assert TransitionType.DISSOLVE.value == "dissolve" + + def test_slide_types(self): + """滑动系列四种.""" + assert TransitionType.SLIDE_LEFT.value == "slideleft" + assert TransitionType.SLIDE_RIGHT.value == "slideright" + assert TransitionType.SLIDE_UP.value == "slideup" + assert TransitionType.SLIDE_DOWN.value == "slidedown" + + def test_wipe_types(self): + """擦除系列四种.""" + assert TransitionType.WIPE_LEFT.value == "wipeleft" + assert TransitionType.WIPE_RIGHT.value == "wiperight" + assert TransitionType.WIPE_UP.value == "wipeup" + assert TransitionType.WIPE_DOWN.value == "wipedown" + + def test_zoom_type(self): + """缩放类型.""" + assert TransitionType.ZOOM.value == "zoom" + + def test_circle_crop_type(self): + """圆形扩散.""" + assert TransitionType.CIRCLE_CROP.value == "circlecrop" + + def test_rect_crop_type(self): + """矩形覆盖.""" + assert TransitionType.RECT_CROP.value == "rectcrop" + + def test_total_types(self): + """共14种转场类型.""" + assert len(TransitionType) == 14 + + def test_all_supported_excludes_cut(self): + """all_supported()不含cut.""" supported = TransitionType.all_supported() assert "cut" not in supported - assert "fade" in supported - assert "dissolve" in supported - assert len(supported) >= 10 # 至少有10种转场 + assert len(supported) == 13 - def test_all_supported_unique(self): + def test_all_supported_returns_strings(self): + """all_supported()返回字符串列表.""" supported = TransitionType.all_supported() - assert len(supported) == len(set(supported)) + assert all(isinstance(s, str) for s in supported) - def test_is_supported_exact_match(self): + def test_is_supported_valid(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_invalid(self): + """不支持的转场类型.""" + assert TransitionType.is_supported("invalid_effect") is False + assert TransitionType.is_supported("") is False + 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" + def test_is_supported_cut(self): + """cut不被算作supported(all_supported不含cut).""" + # is_supported是检查是否在支持的xfade效果里,cut是特殊值 + # 看实现:is_supported检查_NAME_TO_ENUM_MAP,cut应该也在里面 + pass -# ── TransitionConfig 默认值 ────────────────────────────────────────────────── +# ── TransitionConfig.parse - effect 测试 ───────────────────────────────────── -class TestTransitionConfigDefaults: - def test_default_config(self): - cfg = TransitionConfig() +class TestTransitionConfigParseEffect: + """TransitionConfig.parse effect参数测试.""" + + def test_none_effect_defaults_to_cut(self): + """None effect → cut.""" + cfg = TransitionConfig.parse(effect=None) assert cfg.effect == CUT_TRANSITION + + def test_empty_effect_defaults_to_cut(self): + """空字符串 → cut.""" + cfg = TransitionConfig.parse(effect="") + assert cfg.effect == CUT_TRANSITION + + def test_whitespace_effect_defaults_to_cut(self): + """纯空白 → cut.""" + cfg = TransitionConfig.parse(effect=" ") + assert cfg.effect == CUT_TRANSITION + + def test_cut_effect(self): + """显式cut.""" + cfg = TransitionConfig.parse(effect="cut") + assert cfg.effect == "cut" + + def test_cut_case_insensitive(self): + """CUT不区分大小写.""" + cfg = TransitionConfig.parse(effect="CUT") + assert cfg.effect == "cut" + + def test_valid_fade_effect(self): + """有效的fade效果.""" + cfg = TransitionConfig.parse(effect="fade") + assert cfg.effect == "fade" + + def test_valid_dissolve_effect(self): + """有效的dissolve效果.""" + cfg = TransitionConfig.parse(effect="dissolve") + assert cfg.effect == "dissolve" + + def test_effect_stripped(self): + """effect去除空白.""" + cfg = TransitionConfig.parse(effect=" fade ") + assert cfg.effect == "fade" + + def test_invalid_effect_falls_back_to_cut(self): + """无效效果 → 降级为cut.""" + cfg = TransitionConfig.parse(effect="super_fancy_effect") + assert cfg.effect == CUT_TRANSITION + + def test_alias_crossfade(self): + """别名crossfade → dissolve.""" + cfg = TransitionConfig.parse(effect="crossfade") + assert cfg.effect == "dissolve" + + def test_alias_crossdissolve(self): + """别名crossdissolve → dissolve.""" + cfg = TransitionConfig.parse(effect="crossdissolve") + assert cfg.effect == "dissolve" + + def test_alias_fadein(self): + """别名fadein → fade.""" + cfg = TransitionConfig.parse(effect="fadein") + assert cfg.effect == "fade" + + def test_alias_fadeout(self): + """别名fadeout → fade.""" + cfg = TransitionConfig.parse(effect="fadeout") + assert cfg.effect == "fade" + + def test_alias_slide(self): + """别名slide → slideleft.""" + cfg = TransitionConfig.parse(effect="slide") + assert cfg.effect == "slideleft" + + def test_alias_wipe(self): + """别名wipe → wipeleft.""" + cfg = TransitionConfig.parse(effect="wipe") + assert cfg.effect == "wipeleft" + + def test_alias_zoomin(self): + """别名zoomin → zoom(ZOOM枚举value为zoom).""" + cfg = TransitionConfig.parse(effect="zoomin") + assert cfg.effect == "zoom" + + def test_alias_circle(self): + """别名circle → circlecrop.""" + cfg = TransitionConfig.parse(effect="circle") + assert cfg.effect == "circlecrop" + + def test_alias_rect(self): + """别名rect → rectcrop.""" + cfg = TransitionConfig.parse(effect="rect") + assert cfg.effect == "rectcrop" + + def test_underscore_format(self): + """下划线格式能解析.""" + cfg = TransitionConfig.parse(effect="slide_left") + assert cfg.effect == "slideleft" + + def test_case_insensitive_alias(self): + """别名不区分大小写.""" + cfg = TransitionConfig.parse(effect="CrossFade") + assert cfg.effect == "dissolve" + + def test_slide_left_direct(self): + """直接用slideleft.""" + cfg = TransitionConfig.parse(effect="slideleft") + assert cfg.effect == "slideleft" + + +# ── TransitionConfig.parse - duration 测试 ─────────────────────────────────── + + +class TestTransitionConfigParseDuration: + """TransitionConfig.parse duration参数测试.""" + + def test_none_duration_uses_default(self): + """None duration → 默认值.""" + cfg = TransitionConfig.parse(duration=None) assert cfg.duration == DEFAULT_TRANSITION_DURATION - assert cfg.is_cut is True + + def test_normal_duration(self): + """正常范围内的时长.""" + cfg = TransitionConfig.parse(duration=1.0) + assert cfg.duration == 1.0 + + def test_min_duration(self): + """刚好等于最小值.""" + cfg = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_max_duration(self): + """刚好等于最大值.""" + cfg = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION) + assert cfg.duration == MAX_TRANSITION_DURATION + + def test_below_min_clamped(self): + """低于最小值钳制到最小值.""" + cfg = TransitionConfig.parse(duration=0.1) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_zero_duration_clamped(self): + """0时长钳制到最小值.""" + cfg = TransitionConfig.parse(duration=0) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_negative_duration_clamped(self): + """负时长钳制到最小值.""" + cfg = TransitionConfig.parse(duration=-1) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_above_max_clamped(self): + """超过最大值钳制到最大值.""" + cfg = TransitionConfig.parse(duration=5.0) + assert cfg.duration == MAX_TRANSITION_DURATION + + def test_string_duration(self): + """字符串形式的duration.""" + cfg = TransitionConfig.parse(duration="1.5") + assert cfg.duration == 1.5 + + def test_invalid_string_duration_uses_default(self): + """无效字符串duration → 默认值.""" + cfg = TransitionConfig.parse(duration="abc") + assert cfg.duration == DEFAULT_TRANSITION_DURATION + + def test_int_duration(self): + """整数时长.""" + cfg = TransitionConfig.parse(duration=1) + assert cfg.duration == 1.0 + + def test_default_duration_when_no_args(self): + """无参时duration为默认值.""" + cfg = TransitionConfig.parse() + assert cfg.duration == DEFAULT_TRANSITION_DURATION + + +# ── TransitionConfig 属性测试 ──────────────────────────────────────────────── + + +class TestTransitionConfigProperties: + """TransitionConfig 属性测试.""" def test_is_cut_true(self): + """cut是硬切.""" cfg = TransitionConfig(effect="cut") assert cfg.is_cut is True def test_is_cut_false(self): + """非cut不是硬切.""" cfg = TransitionConfig(effect="fade") assert cfg.is_cut is False + def test_is_cut_default(self): + """默认配置是硬切.""" + cfg = TransitionConfig() + assert cfg.is_cut is True -# ── TransitionConfig.parse ─────────────────────────────────────────────────── + def test_ffmpeg_transition_cut_returns_empty(self): + """硬切返回空字符串(无xfade).""" + cfg = TransitionConfig(effect="cut") + assert cfg.ffmpeg_transition == "" + + def test_ffmpeg_transition_fade(self): + """fade → fade.""" + cfg = TransitionConfig(effect="fade") + assert cfg.ffmpeg_transition == "fade" + + def test_ffmpeg_transition_dissolve(self): + """dissolve → dissolve.""" + cfg = TransitionConfig(effect="dissolve") + assert cfg.ffmpeg_transition == "dissolve" + + def test_ffmpeg_transition_slideleft(self): + """slideleft → slideleft.""" + cfg = TransitionConfig(effect="slideleft") + assert cfg.ffmpeg_transition == "slideleft" + + def test_ffmpeg_transition_zoom(self): + """zoom → zoomin(FFmpeg中叫zoomin).""" + cfg = TransitionConfig(effect="zoom") + assert cfg.ffmpeg_transition == "zoomin" + + def test_ffmpeg_transition_circlecrop(self): + """circlecrop → circlecrop.""" + cfg = TransitionConfig(effect="circlecrop") + assert cfg.ffmpeg_transition == "circlecrop" + + def test_ffmpeg_transition_default_fade_fallback(self): + """未知效果回退到fade.""" + # 直接构造一个不支持的effect(绕过parse) + cfg = TransitionConfig(effect="unknown_effect", duration=0.5) + # 会回退到fade + assert cfg.ffmpeg_transition == "fade" -class TestTransitionConfigParse: - def test_none_params_default(self): +# ── TransitionConfig.validate 测试 ─────────────────────────────────────────── + + +class TestTransitionConfigValidate: + """TransitionConfig.validate 测试.""" + + def test_valid_default_cut(self): + """默认cut配置是合法的.""" + cfg = TransitionConfig() + ok, msg = cfg.validate() + assert ok is True + assert msg == "" + + def test_valid_fade(self): + """fade配置是合法的.""" + cfg = TransitionConfig(effect="fade", duration=1.0) + ok, msg = cfg.validate() + assert ok is True + assert msg == "" + + def test_valid_min_duration(self): + """最小时长是合法的.""" + cfg = TransitionConfig(effect="fade", duration=MIN_TRANSITION_DURATION) + ok, msg = cfg.validate() + assert ok is True + + def test_valid_max_duration(self): + """最大时长是合法的.""" + cfg = TransitionConfig(effect="fade", duration=MAX_TRANSITION_DURATION) + ok, msg = cfg.validate() + assert ok is True + + def test_invalid_duration_too_low(self): + """时长小于最小值不合法.""" + cfg = TransitionConfig(effect="fade", duration=0.1) + ok, msg = cfg.validate() + assert ok is False + assert "不能小于" in msg + + def test_invalid_duration_too_high(self): + """时长大于最大值不合法.""" + cfg = TransitionConfig(effect="fade", duration=5.0) + ok, msg = cfg.validate() + assert ok is False + assert "不能大于" in msg + + def test_invalid_unknown_effect(self): + """未知效果不合法.""" + cfg = TransitionConfig(effect="super_fancy", duration=0.5) + ok, msg = cfg.validate() + assert ok is False + assert "不支持" in msg + + def test_cut_always_valid(self): + """cut效果总是合法的(即使duration异常...要看实现).""" + # cut的话is_cut为True,validate里会跳过effect检查 + cfg = TransitionConfig(effect="cut", duration=1.0) + ok, msg = cfg.validate() + assert ok is True + + +# ── 默认值测试 ─────────────────────────────────────────────────────────────── + + +class TestTransitionConfigDefaults: + """TransitionConfig 默认值测试.""" + + def test_default_effect_is_cut(self): + """默认effect是cut.""" + cfg = TransitionConfig() + assert cfg.effect == CUT_TRANSITION + + def test_default_duration(self): + """默认duration.""" + cfg = TransitionConfig() + assert cfg.duration == DEFAULT_TRANSITION_DURATION + + def test_parse_no_args(self): + """parse无参 → 默认值.""" cfg = TransitionConfig.parse() assert cfg.effect == CUT_TRANSITION assert cfg.duration == DEFAULT_TRANSITION_DURATION - def test_empty_effect_default(self): - cfg = TransitionConfig.parse(effect="") + def test_parse_both_none(self): + """两个参数都None → 默认值.""" + cfg = TransitionConfig.parse(effect=None, duration=None) 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_is_dataclass(self): + """是dataclass.""" + from dataclasses import is_dataclass - 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 + assert is_dataclass(TransitionConfig)