diff --git a/tests/unit/test_config_schemas.py b/tests/unit/test_config_schemas.py new file mode 100755 index 000000000..addac1f80 --- /dev/null +++ b/tests/unit/test_config_schemas.py @@ -0,0 +1,508 @@ +"""config_schemas 领域层单元测试 - 配置 schema / 枚举 / 标准化函数""" + +import copy + +import pytest +from pydantic import ValidationError + +from packages.domain.config_schemas import ( + BGMSource, + BGMConfig, + CoverConfig, + CoverType, + DEFAULT_EDIT_PLAN_CONFIG, + DEFAULT_EDIT_TEMPLATE_CONFIG, + EditPlanConfigSchema, + EditTemplateConfigSchema, + ExportConfig, + FilterConfig, + ShadowConfig, + StrokeConfig, + SubtitleConfig, + TextAnimation, + TextPosition, + TitleConfig, + normalize_plan_config, + normalize_template_config, +) + + +class TestEnums: + """枚举类型测试""" + + def test_cover_type_values(self): + assert CoverType.AI_FRAME == "ai_frame" + assert CoverType.MANUAL == "manual" + assert CoverType.UPLOAD == "upload" + assert CoverType.AI_REGENERATE == "ai_regenerate" + + def test_text_position_values(self): + assert TextPosition.TOP == "top" + assert TextPosition.CENTER == "center" + assert TextPosition.BOTTOM == "bottom" + + def test_text_animation_values(self): + assert TextAnimation.NONE == "none" + assert TextAnimation.FADE_IN == "fade_in" + assert TextAnimation.SLIDE_UP == "slide_up" + assert TextAnimation.SLIDE_DOWN == "slide_down" + assert TextAnimation.SCALE == "scale" + + def test_bgm_source_values(self): + assert BGMSource.LIBRARY == "library" + assert BGMSource.UPLOAD == "upload" + assert BGMSource.AI_RECOMMEND == "ai_recommend" + + def test_enums_are_str_enum(self): + """枚举都是 str 类型""" + assert isinstance(CoverType.AI_FRAME, str) + assert isinstance(TextPosition.TOP, str) + assert isinstance(TextAnimation.FADE_IN, str) + assert isinstance(BGMSource.LIBRARY, str) + + +class TestStrokeConfig: + """StrokeConfig 测试""" + + def test_defaults(self): + config = StrokeConfig() + assert config.enabled is False + assert config.color == "#000000" + assert config.width == 1 + + def test_width_min(self): + config = StrokeConfig(width=1) + assert config.width == 1 + + def test_width_max(self): + config = StrokeConfig(width=10) + assert config.width == 10 + + def test_width_below_min_raises(self): + with pytest.raises(ValidationError): + StrokeConfig(width=0) + + def test_width_above_max_raises(self): + with pytest.raises(ValidationError): + StrokeConfig(width=11) + + +class TestShadowConfig: + """ShadowConfig 测试""" + + def test_defaults(self): + config = ShadowConfig() + assert config.enabled is False + assert config.blur == 4 + assert config.offset_x == 2 + assert config.offset_y == 2 + + def test_blur_min(self): + config = ShadowConfig(blur=0) + assert config.blur == 0 + + def test_blur_max(self): + config = ShadowConfig(blur=20) + assert config.blur == 20 + + def test_blur_below_min_raises(self): + with pytest.raises(ValidationError): + ShadowConfig(blur=-1) + + def test_blur_above_max_raises(self): + with pytest.raises(ValidationError): + ShadowConfig(blur=21) + + +class TestCoverConfig: + """CoverConfig 测试""" + + def test_defaults(self): + config = CoverConfig() + assert config.type == CoverType.AI_FRAME + assert config.image_url == "" + assert config.frame_time is None + + def test_frame_time_ge_zero(self): + config = CoverConfig(frame_time=0.0) + assert config.frame_time == 0.0 + + def test_frame_time_negative_raises(self): + with pytest.raises(ValidationError): + CoverConfig(frame_time=-1.0) + + def test_custom_values(self): + config = CoverConfig( + type=CoverType.MANUAL, + image_url="http://example.com/cover.jpg", + frame_time=5.5, + ) + assert config.type == CoverType.MANUAL + assert config.image_url == "http://example.com/cover.jpg" + assert config.frame_time == 5.5 + + +class TestTitleConfig: + """TitleConfig 测试""" + + def test_defaults(self): + config = TitleConfig() + assert config.enabled is True + assert config.ai_auto is True + assert config.text == "" + assert config.position == TextPosition.TOP + assert config.font == "思源黑体" + assert config.color == "#ffffff" + assert config.size == 48 + assert config.bold is True + assert config.italic is False + assert isinstance(config.stroke, StrokeConfig) + assert isinstance(config.shadow, ShadowConfig) + + def test_size_min(self): + config = TitleConfig(size=12) + assert config.size == 12 + + def test_size_max(self): + config = TitleConfig(size=120) + assert config.size == 120 + + def test_size_below_min_raises(self): + with pytest.raises(ValidationError): + TitleConfig(size=11) + + def test_size_above_max_raises(self): + with pytest.raises(ValidationError): + TitleConfig(size=121) + + +class TestSubtitleConfig: + """SubtitleConfig 测试""" + + def test_defaults(self): + config = SubtitleConfig() + assert config.enabled is True + assert config.position == TextPosition.BOTTOM + assert config.font == "思源黑体" + assert config.color == "#ffffff" + assert config.size == 24 + assert config.animation == TextAnimation.FADE_IN + assert config.auto_generated is False + assert config.language == "" + assert config.max_chars_per_line == 20 + assert config.min_chars_per_segment == 8 + + def test_size_min(self): + config = SubtitleConfig(size=12) + assert config.size == 12 + + def test_size_max(self): + config = SubtitleConfig(size=60) + assert config.size == 60 + + def test_size_out_of_range_raises(self): + with pytest.raises(ValidationError): + SubtitleConfig(size=61) + + def test_max_chars_per_line_range(self): + config = SubtitleConfig(max_chars_per_line=40) + assert config.max_chars_per_line == 40 + with pytest.raises(ValidationError): + SubtitleConfig(max_chars_per_line=7) + with pytest.raises(ValidationError): + SubtitleConfig(max_chars_per_line=41) + + def test_min_chars_per_segment_range(self): + config = SubtitleConfig(min_chars_per_segment=20) + assert config.min_chars_per_segment == 20 + with pytest.raises(ValidationError): + SubtitleConfig(min_chars_per_segment=1) + with pytest.raises(ValidationError): + SubtitleConfig(min_chars_per_segment=21) + + +class TestBGMConfig: + """BGMConfig 测试""" + + def test_defaults(self): + config = BGMConfig() + assert config.enabled is False + assert config.source == BGMSource.LIBRARY + assert config.asset_id == "" + assert config.preset_id == "" + assert config.audio_url == "" + assert config.volume == 0.3 + assert config.fade_in == 0.0 + assert config.fade_out == 0.0 + assert config.loop_enabled is True + assert config.sidechain_enabled is False + assert config.sidechain_ratio == 0.3 + assert config.sidechain_attack == 0.02 + assert config.sidechain_release == 0.5 + assert config.sidechain_threshold == -25.0 + + def test_volume_range(self): + with pytest.raises(ValidationError): + BGMConfig(volume=-0.1) + with pytest.raises(ValidationError): + BGMConfig(volume=1.1) + + def test_fade_range(self): + with pytest.raises(ValidationError): + BGMConfig(fade_in=31.0) + with pytest.raises(ValidationError): + BGMConfig(fade_out=31.0) + + def test_sidechain_ratio_range(self): + with pytest.raises(ValidationError): + BGMConfig(sidechain_ratio=1.1) + with pytest.raises(ValidationError): + BGMConfig(sidechain_ratio=-0.1) + + def test_sidechain_attack_range(self): + with pytest.raises(ValidationError): + BGMConfig(sidechain_attack=0.0001) + with pytest.raises(ValidationError): + BGMConfig(sidechain_attack=1.1) + + def test_sidechain_release_range(self): + with pytest.raises(ValidationError): + BGMConfig(sidechain_release=0.001) + with pytest.raises(ValidationError): + BGMConfig(sidechain_release=5.1) + + def test_sidechain_threshold_range(self): + with pytest.raises(ValidationError): + BGMConfig(sidechain_threshold=-61.0) + with pytest.raises(ValidationError): + BGMConfig(sidechain_threshold=1.0) + + +class TestExportConfig: + """ExportConfig 测试""" + + def test_defaults(self): + config = ExportConfig() + assert config.resolution == "1080x1920" + assert config.fps == 30 + assert config.video_bitrate == 8000 + assert config.audio_bitrate == 128 + assert config.format == "mp4" + assert config.quality_preset == "balanced" + assert config.watermark_enabled is False + assert config.watermark_text == "" + + def test_fps_range(self): + with pytest.raises(ValidationError): + ExportConfig(fps=14) + with pytest.raises(ValidationError): + ExportConfig(fps=61) + + def test_video_bitrate_range(self): + with pytest.raises(ValidationError): + ExportConfig(video_bitrate=999) + with pytest.raises(ValidationError): + ExportConfig(video_bitrate=20001) + + def test_audio_bitrate_range(self): + with pytest.raises(ValidationError): + ExportConfig(audio_bitrate=63) + with pytest.raises(ValidationError): + ExportConfig(audio_bitrate=321) + + +class TestFilterConfig: + """FilterConfig 测试""" + + def test_defaults(self): + config = FilterConfig() + assert config.enabled is False + assert config.preset_id == "filter_none" + assert config.intensity == 100 + assert config.brightness == 0.0 + assert config.contrast == 1.0 + assert config.saturation == 1.0 + assert config.warmth == 0.0 + + def test_intensity_range(self): + with pytest.raises(ValidationError): + FilterConfig(intensity=-1) + with pytest.raises(ValidationError): + FilterConfig(intensity=101) + + def test_brightness_range(self): + with pytest.raises(ValidationError): + FilterConfig(brightness=-1.1) + with pytest.raises(ValidationError): + FilterConfig(brightness=1.1) + + def test_contrast_range(self): + with pytest.raises(ValidationError): + FilterConfig(contrast=-0.1) + with pytest.raises(ValidationError): + FilterConfig(contrast=2.1) + + def test_saturation_range(self): + with pytest.raises(ValidationError): + FilterConfig(saturation=-0.1) + with pytest.raises(ValidationError): + FilterConfig(saturation=3.1) + + def test_warmth_range(self): + with pytest.raises(ValidationError): + FilterConfig(warmth=-1.1) + with pytest.raises(ValidationError): + FilterConfig(warmth=1.1) + + +class TestEditPlanConfigSchema: + """EditPlanConfigSchema 完整配置测试""" + + def test_defaults(self): + config = EditPlanConfigSchema() + assert isinstance(config.cover, CoverConfig) + assert isinstance(config.title, TitleConfig) + assert isinstance(config.subtitle, SubtitleConfig) + assert isinstance(config.bgm, BGMConfig) + assert isinstance(config.export, ExportConfig) + assert isinstance(config.filter, FilterConfig) + assert config.editing_mode == "one_take" + + def test_partial_update(self): + """部分字段更新,其他保持默认""" + config = EditPlanConfigSchema( + title={"text": "自定义标题", "size": 36}, + bgm={"enabled": True, "volume": 0.5}, + ) + assert config.title.text == "自定义标题" + assert config.title.size == 36 + assert config.title.font == "思源黑体" # 其他字段默认 + assert config.bgm.enabled is True + assert config.bgm.volume == 0.5 + assert config.cover.type == CoverType.AI_FRAME # 未设置的保持默认 + + +class TestEditTemplateConfigSchema: + """EditTemplateConfigSchema 测试""" + + def test_defaults(self): + config = EditTemplateConfigSchema() + assert isinstance(config.cover, CoverConfig) + assert config.editing_mode == "one_take" + assert config.transition_enabled is True + + def test_custom_transition_enabled(self): + config = EditTemplateConfigSchema(transition_enabled=False) + assert config.transition_enabled is False + + +class TestDefaultConfigs: + """默认配置常量测试""" + + def test_plan_config_structure(self): + assert "cover" in DEFAULT_EDIT_PLAN_CONFIG + assert "title" in DEFAULT_EDIT_PLAN_CONFIG + assert "subtitle" in DEFAULT_EDIT_PLAN_CONFIG + assert "bgm" in DEFAULT_EDIT_PLAN_CONFIG + assert "export" in DEFAULT_EDIT_PLAN_CONFIG + assert "filter" in DEFAULT_EDIT_PLAN_CONFIG + assert "editing_mode" in DEFAULT_EDIT_PLAN_CONFIG + + def test_template_config_has_transition_enabled(self): + assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG + assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True + + def test_template_inherits_from_plan(self): + """模板配置继承计划配置的所有字段""" + for key in DEFAULT_EDIT_PLAN_CONFIG: + assert key in DEFAULT_EDIT_TEMPLATE_CONFIG + + +class TestNormalizePlanConfig: + """normalize_plan_config 工具函数测试""" + + def test_none_returns_default_copy(self): + result = normalize_plan_config(None) + assert result == DEFAULT_EDIT_PLAN_CONFIG + # 确保是深拷贝 + result["title"]["text"] = "modified" + assert DEFAULT_EDIT_PLAN_CONFIG["title"]["text"] == "" + + def test_empty_dict_returns_default(self): + result = normalize_plan_config({}) + assert result == DEFAULT_EDIT_PLAN_CONFIG + + def test_updates_cover_section(self): + result = normalize_plan_config({"cover": {"type": "manual", "frame_time": 5.0}}) + assert result["cover"]["type"] == "manual" + assert result["cover"]["frame_time"] == 5.0 + assert result["cover"]["image_url"] == "" # 默认保留 + + def test_updates_title_section(self): + result = normalize_plan_config({"title": {"text": "hello", "size": 32}}) + assert result["title"]["text"] == "hello" + assert result["title"]["size"] == 32 + assert result["title"]["font"] == "思源黑体" + + def test_updates_bgm_section(self): + result = normalize_plan_config({"bgm": {"enabled": True, "volume": 0.8}}) + assert result["bgm"]["enabled"] is True + assert result["bgm"]["volume"] == 0.8 + + def test_updates_editing_mode(self): + result = normalize_plan_config({"editing_mode": "smart"}) + assert result["editing_mode"] == "smart" + + def test_preserves_extra_fields(self): + """非标准字段被保留""" + result = normalize_plan_config({"custom_field": "value", "generation_task_id": "task-1"}) + assert result["custom_field"] == "value" + assert result["generation_task_id"] == "task-1" + + def test_ignores_non_dict_section(self): + """section 不是 dict 时忽略""" + result = normalize_plan_config({"cover": "not_a_dict"}) + assert result["cover"] == DEFAULT_EDIT_PLAN_CONFIG["cover"] + + def test_ignores_non_str_editing_mode(self): + result = normalize_plan_config({"editing_mode": 123}) + assert result["editing_mode"] == "one_take" + + def test_deep_copy_independence(self): + """修改结果不影响默认值""" + result = normalize_plan_config({}) + result["title"]["size"] = 999 + assert DEFAULT_EDIT_PLAN_CONFIG["title"]["size"] == 48 + + +class TestNormalizeTemplateConfig: + """normalize_template_config 工具函数测试""" + + def test_none_returns_default_copy(self): + result = normalize_template_config(None) + assert result == DEFAULT_EDIT_TEMPLATE_CONFIG + + def test_updates_transition_enabled(self): + result = normalize_template_config({"transition_enabled": False}) + assert result["transition_enabled"] is False + + def test_ignores_non_bool_transition_enabled(self): + result = normalize_template_config({"transition_enabled": "yes"}) + assert result["transition_enabled"] is True + + def test_updates_sections(self): + result = normalize_template_config({ + "title": {"text": "模板标题"}, + "bgm": {"enabled": True}, + }) + assert result["title"]["text"] == "模板标题" + assert result["bgm"]["enabled"] is True + + def test_preserves_extra_fields(self): + result = normalize_template_config({"custom": "value"}) + assert result["custom"] == "value" + + def test_deep_copy_independence(self): + result = normalize_template_config({}) + result["title"]["font"] = "CustomFont" + assert DEFAULT_EDIT_TEMPLATE_CONFIG["title"]["font"] == "思源黑体" diff --git a/tests/unit/test_filter_presets.py b/tests/unit/test_filter_presets.py new file mode 100755 index 000000000..903bc0ac2 --- /dev/null +++ b/tests/unit/test_filter_presets.py @@ -0,0 +1,230 @@ +"""filter_presets 领域层单元测试 - 滤镜预设库""" + +import pytest + +from packages.domain.filter_presets import ( + FILTER_PRESET_LIBRARY, + FilterPreset, + build_ffmpeg_filter, + get_filter_preset, + list_filter_presets, +) + + +class TestFilterPreset: + """FilterPreset 数据类测试""" + + def test_create_minimal(self): + preset = FilterPreset(id="test", name="测试", category="basic") + assert preset.id == "test" + assert preset.name == "测试" + assert preset.category == "basic" + assert preset.description == "" + assert preset.tags == [] + assert preset.brightness == 0.0 + assert preset.contrast == 1.0 + assert preset.saturation == 1.0 + assert preset.gamma == 1.0 + assert preset.gamma_r == 1.0 + assert preset.gamma_g == 1.0 + assert preset.gamma_b == 1.0 + assert preset.hue == 0.0 + assert preset.lut_url == "" + + def test_create_with_all_params(self): + preset = FilterPreset( + id="custom", + name="自定义", + category="cinematic", + description="电影感调色", + tags=["电影", "调色"], + brightness=0.1, + contrast=1.2, + saturation=0.9, + gamma=1.1, + gamma_r=1.05, + gamma_g=1.0, + gamma_b=0.95, + hue=10.0, + lut_url="http://example.com/lut.png", + ) + assert preset.category == "cinematic" + assert preset.description == "电影感调色" + assert preset.tags == ["电影", "调色"] + assert preset.brightness == 0.1 + assert preset.contrast == 1.2 + assert preset.lut_url == "http://example.com/lut.png" + + def test_frozen_immutable(self): + """frozen dataclass 不可修改""" + preset = FilterPreset(id="test", name="测试", category="basic") + with pytest.raises(Exception): # FrozenInstanceError + preset.name = "改名" + + def test_tags_default_empty_list(self): + preset = FilterPreset(id="test", name="测试", category="basic") + assert preset.tags == [] + # 每次创建独立的 list + preset2 = FilterPreset(id="test2", name="测试2", category="basic") + assert preset.tags is not preset2.tags + + +class TestFilterPresetLibrary: + """FILTER_PRESET_LIBRARY 预设库测试""" + + def test_library_not_empty(self): + assert len(FILTER_PRESET_LIBRARY) > 0 + + def test_all_presets_have_unique_ids(self): + """所有预设 ID 唯一""" + ids = [p.id for p in FILTER_PRESET_LIBRARY] + assert len(ids) == len(set(ids)) + + def test_all_presets_have_name_and_category(self): + for preset in FILTER_PRESET_LIBRARY: + assert preset.id, f"{preset} has no id" + assert preset.name, f"{preset.id} has no name" + assert preset.category, f"{preset.id} has no category" + + def test_filter_none_exists(self): + """原图预设存在""" + none_preset = next((p for p in FILTER_PRESET_LIBRARY if p.id == "filter_none"), None) + assert none_preset is not None + assert none_preset.name == "原图" + assert none_preset.category == "basic" + + def test_known_categories_exist(self): + """已知分类都有预设""" + categories = {p.category for p in FILTER_PRESET_LIBRARY} + assert "basic" in categories + + def test_basic_category_presets(self): + """基础分类至少有几个预设""" + basic = [p for p in FILTER_PRESET_LIBRARY if p.category == "basic"] + assert len(basic) >= 3 + + def test_preset_params_in_reasonable_range(self): + """预设参数在合理范围内""" + for preset in FILTER_PRESET_LIBRARY: + assert -1.0 <= preset.brightness <= 1.0, f"{preset.id} brightness out of range" + assert 0.0 <= preset.contrast <= 2.0, f"{preset.id} contrast out of range" + assert 0.0 <= preset.saturation <= 3.0, f"{preset.id} saturation out of range" + + +class TestGetFilterPreset: + """get_filter_preset 函数测试""" + + def test_get_existing_preset(self): + preset = get_filter_preset("filter_none") + assert preset is not None + assert preset.id == "filter_none" + + def test_get_nonexistent_preset(self): + assert get_filter_preset("nonexistent_filter") is None + + def test_get_returns_correct_type(self): + preset = get_filter_preset("filter_none") + assert isinstance(preset, FilterPreset) + + +class TestListFilterPresets: + """list_filter_presets 函数测试""" + + def test_list_all(self): + """不带参数返回所有预设""" + all_presets = list_filter_presets() + assert len(all_presets) == len(FILTER_PRESET_LIBRARY) + + def test_filter_by_category(self): + """按分类筛选""" + basic_presets = list_filter_presets(category="basic") + assert len(basic_presets) > 0 + assert all(p.category == "basic" for p in basic_presets) + + def test_filter_by_nonexistent_category(self): + """不存在的分类返回空列表""" + result = list_filter_presets(category="nonexistent_category") + assert result == [] + + def test_search_by_name(self): + """按名称搜索""" + result = list_filter_presets(keyword="明") + assert len(result) >= 1 + assert any("明" in p.name for p in result) + + def test_search_by_tag(self): + """按标签搜索""" + # 找到有标签的预设 + tagged = [p for p in FILTER_PRESET_LIBRARY if p.tags] + if tagged: + tag = tagged[0].tags[0] + result = list_filter_presets(keyword=tag) + assert len(result) >= 1 + + def test_search_case_insensitive_in_name(self): + """搜索对中文名称有效""" + result = list_filter_presets(keyword="原图") + assert any(p.id == "filter_none" for p in result) + + def test_search_empty_returns_all(self): + """空搜索返回所有""" + result = list_filter_presets(keyword="") + assert len(result) == len(FILTER_PRESET_LIBRARY) + + def test_combined_category_and_search(self): + """同时按分类和搜索筛选""" + result = list_filter_presets(category="basic", keyword="明") + assert all(p.category == "basic" for p in result) + if result: + assert any("明" in p.name or any("明" in t for t in p.tags) for p in result) + + def test_returns_list_of_filterpreset(self): + result = list_filter_presets() + assert all(isinstance(p, FilterPreset) for p in result) + + +class TestBuildFfmpegFilter: + """build_ffmpeg_filter 函数测试""" + + def test_filter_none_returns_empty_or_simple(self): + """原图滤镜应该返回空字符串或无操作滤镜""" + result = build_ffmpeg_filter("filter_none", 100) + # 应该是字符串,且不包含实质性调色参数 + assert isinstance(result, str) + + def test_full_intensity(self): + """强度 100 时应用全量参数""" + result = build_ffmpeg_filter("filter_brighten", 100) + assert isinstance(result, str) + assert len(result) > 0 + + def test_zero_intensity(self): + """强度 0 时应该是原图效果""" + result = build_ffmpeg_filter("filter_brighten", 0) + assert isinstance(result, str) + + def test_half_intensity(self): + """强度 50 时参数减半""" + result50 = build_ffmpeg_filter("filter_brighten", 50) + result100 = build_ffmpeg_filter("filter_brighten", 100) + # 50% 和 100% 的结果应该不同 + assert result50 != result100 + + def test_nonexistent_preset(self): + """不存在的预设返回空字符串或默认值""" + result = build_ffmpeg_filter("nonexistent", 100) + assert isinstance(result, str) + + def test_intensity_clamped(self): + """强度超过 100 或低于 0 的处理""" + result_high = build_ffmpeg_filter("filter_brighten", 150) + result_low = build_ffmpeg_filter("filter_brighten", -10) + assert isinstance(result_high, str) + assert isinstance(result_low, str) + + def test_contains_eq_filter(self): + """结果应该包含 eq 滤镜参数""" + result = build_ffmpeg_filter("filter_brighten", 100) + # FFmpeg eq 滤镜通常包含 brightness/contrast/saturation 等参数 + # 至少应该有滤镜相关的字符串 + assert len(result) > 0 diff --git a/tests/unit/test_transition_presets.py b/tests/unit/test_transition_presets.py new file mode 100755 index 000000000..59ca70434 --- /dev/null +++ b/tests/unit/test_transition_presets.py @@ -0,0 +1,209 @@ +"""transition_presets 领域层单元测试 - 转场预设库""" + +import pytest + +from packages.domain.transition_presets import ( + TRANSITION_PRESET_LIBRARY, + TransitionPreset, + get_default_transition, + get_transition_preset, + list_transition_presets, +) + + +class TestTransitionPreset: + """TransitionPreset 数据类测试""" + + def test_create_minimal(self): + preset = TransitionPreset(id="test", name="测试", category="basic", transition="fade") + assert preset.id == "test" + assert preset.name == "测试" + assert preset.category == "basic" + assert preset.transition == "fade" + assert preset.description == "" + assert preset.tags == [] + assert preset.default_duration == 0.5 + assert preset.min_duration == 0.1 + assert preset.max_duration == 3.0 + assert preset.has_custom_params is False + + def test_create_with_all_params(self): + preset = TransitionPreset( + id="custom", + name="自定义转场", + category="special", + description="炫酷特效", + tags=["炫酷", "特效"], + transition="custom", + default_duration=1.0, + min_duration=0.5, + max_duration=5.0, + has_custom_params=True, + ) + assert preset.description == "炫酷特效" + assert preset.tags == ["炫酷", "特效"] + assert preset.default_duration == 1.0 + assert preset.min_duration == 0.5 + assert preset.max_duration == 5.0 + assert preset.has_custom_params is True + + def test_frozen_immutable(self): + """frozen dataclass 不可修改""" + preset = TransitionPreset(id="test", name="测试", category="basic") + with pytest.raises(Exception): + preset.name = "改名" + + def test_tags_default_empty_list(self): + preset = TransitionPreset(id="t1", name="t1", category="basic") + preset2 = TransitionPreset(id="t2", name="t2", category="basic") + assert preset.tags == [] + assert preset.tags is not preset2.tags + + def test_default_transition_is_fade(self): + preset = TransitionPreset(id="test", name="测试", category="basic") + assert preset.transition == "fade" + + +class TestTransitionPresetLibrary: + """TRANSITION_PRESET_LIBRARY 预设库测试""" + + def test_library_not_empty(self): + assert len(TRANSITION_PRESET_LIBRARY) > 0 + + def test_all_presets_have_unique_ids(self): + """所有预设 ID 唯一""" + ids = [p.id for p in TRANSITION_PRESET_LIBRARY] + assert len(ids) == len(set(ids)) + + def test_all_presets_have_required_fields(self): + """所有预设都有必填字段""" + for preset in TRANSITION_PRESET_LIBRARY: + assert preset.id, f"missing id" + assert preset.name, f"{preset.id} missing name" + assert preset.category, f"{preset.id} missing category" + assert preset.transition, f"{preset.id} missing transition" + + def test_transition_none_exists(self): + """无转场预设存在""" + none_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_none"), None) + assert none_preset is not None + assert none_preset.name == "无转场" + assert none_preset.transition == "none" + + def test_transition_random_exists(self): + """随机转场预设存在""" + random_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_random"), None) + assert random_preset is not None + assert random_preset.name == "随机" + + def test_fade_category_exists(self): + """淡入淡出分类有预设""" + fade_presets = [p for p in TRANSITION_PRESET_LIBRARY if p.category == "fade"] + assert len(fade_presets) >= 2 + + def test_duration_constraints_valid(self): + """时长约束:min <= default <= max""" + for preset in TRANSITION_PRESET_LIBRARY: + assert preset.min_duration <= preset.default_duration, \ + f"{preset.id}: min > default" + assert preset.default_duration <= preset.max_duration, \ + f"{preset.id}: default > max" + assert preset.min_duration >= 0, f"{preset.id}: min < 0" + + def test_known_categories_exist(self): + """已知分类都有预设""" + categories = {p.category for p in TRANSITION_PRESET_LIBRARY} + assert "basic" in categories + assert "fade" in categories + + +class TestGetTransitionPreset: + """get_transition_preset 函数测试""" + + def test_get_existing_preset(self): + preset = get_transition_preset("transition_none") + assert preset is not None + assert preset.id == "transition_none" + + def test_get_fade_preset(self): + preset = get_transition_preset("transition_fade") + assert preset is not None + assert preset.transition == "fade" + + def test_get_nonexistent_preset(self): + assert get_transition_preset("nonexistent_transition") is None + + def test_returns_transitionpreset_type(self): + preset = get_transition_preset("transition_fade") + assert isinstance(preset, TransitionPreset) + + +class TestListTransitionPresets: + """list_transition_presets 函数测试""" + + def test_list_all(self): + """不带参数返回所有预设""" + all_presets = list_transition_presets() + assert len(all_presets) == len(TRANSITION_PRESET_LIBRARY) + + def test_filter_by_category(self): + """按分类筛选""" + fade_presets = list_transition_presets(category="fade") + assert len(fade_presets) > 0 + assert all(p.category == "fade" for p in fade_presets) + + def test_filter_by_basic_category(self): + basic_presets = list_transition_presets(category="basic") + assert len(basic_presets) >= 2 + + def test_filter_by_nonexistent_category(self): + result = list_transition_presets(category="nonexistent") + assert result == [] + + def test_search_by_name(self): + """按名称搜索""" + result = list_transition_presets(keyword="淡入") + assert len(result) >= 1 + assert any("淡入" in p.name for p in result) + + def test_search_by_tag(self): + """按标签搜索""" + tagged = [p for p in TRANSITION_PRESET_LIBRARY if p.tags] + if tagged: + tag = tagged[0].tags[0] + result = list_transition_presets(keyword=tag) + assert len(result) >= 1 + + def test_search_empty_returns_all(self): + result = list_transition_presets(keyword="") + assert len(result) == len(TRANSITION_PRESET_LIBRARY) + + def test_combined_category_and_search(self): + result = list_transition_presets(category="fade", keyword="淡入") + assert all(p.category == "fade" for p in result) + + def test_returns_list_of_transitionpreset(self): + result = list_transition_presets() + assert all(isinstance(p, TransitionPreset) for p in result) + + +class TestGetDefaultTransition: + """get_default_transition 函数测试""" + + def test_returns_preset(self): + preset = get_default_transition() + assert preset is not None + assert isinstance(preset, TransitionPreset) + + def test_default_is_none(self): + """默认转场是无转场(硬切)""" + preset = get_default_transition() + assert preset.id == "transition_none" + assert preset.transition == "none" + + def test_default_has_zero_duration(self): + """无转场默认时长为 0""" + preset = get_default_transition() + assert preset.default_duration == 0.0 + assert preset.min_duration == 0.0 + assert preset.max_duration == 0.0