""" config_schemas 配置结构定义单元测试 覆盖: - 4个枚举类型 - 10个Pydantic模型 - 2个normalize工具函数 - 2个默认值常量 """ import copy import pytest from pydantic import ValidationError from packages.domain.config_schemas import ( DEFAULT_EDIT_PLAN_CONFIG, DEFAULT_EDIT_TEMPLATE_CONFIG, BGMConfig, BGMSource, CoverConfig, CoverType, EditPlanConfigSchema, EditTemplateConfigSchema, ExportConfig, FilterConfig, ShadowConfig, StrokeConfig, SubtitleConfig, TextAnimation, TextPosition, TitleConfig, normalize_plan_config, normalize_template_config, ) # ── 枚举测试 ────────────────────────────────────────────────────────────────── class TestCoverType: """CoverType 枚举测试""" def test_all_types_exist(self): assert CoverType.AI_FRAME == "ai_frame" assert CoverType.MANUAL == "manual" assert CoverType.UPLOAD == "upload" assert CoverType.AI_REGENERATE == "ai_regenerate" def test_total_count(self): assert len(CoverType) == 4 def test_is_string_enum(self): for t in CoverType: assert isinstance(t.value, str) assert isinstance(t, str) def test_string_comparison(self): assert CoverType.AI_FRAME == "ai_frame" assert CoverType.UPLOAD != "manual" class TestTextPosition: """TextPosition 枚举测试""" def test_all_positions_exist(self): assert TextPosition.TOP == "top" assert TextPosition.CENTER == "center" assert TextPosition.BOTTOM == "bottom" def test_total_count(self): assert len(TextPosition) == 3 def test_is_string_enum(self): for p in TextPosition: assert isinstance(p.value, str) assert isinstance(p, str) class TestTextAnimation: """TextAnimation 枚举测试""" def test_all_animations_exist(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_total_count(self): assert len(TextAnimation) == 5 def test_is_string_enum(self): for a in TextAnimation: assert isinstance(a.value, str) assert isinstance(a, str) class TestBGMSource: """BGMSource 枚举测试""" def test_all_sources_exist(self): assert BGMSource.LIBRARY == "library" assert BGMSource.UPLOAD == "upload" assert BGMSource.AI_RECOMMEND == "ai_recommend" def test_total_count(self): assert len(BGMSource) == 3 def test_is_string_enum(self): for s in BGMSource: assert isinstance(s.value, str) assert isinstance(s, str) # ── 子结构模型测试 ──────────────────────────────────────────────────────────── class TestStrokeConfig: """StrokeConfig 描边配置测试""" def test_default_values(self): config = StrokeConfig() assert config.enabled is False assert config.color == "#000000" assert config.width == 1 def test_custom_values(self): config = StrokeConfig(enabled=True, color="#ff0000", width=5) assert config.enabled is True assert config.color == "#ff0000" assert config.width == 5 def test_width_min_boundary(self): config = StrokeConfig(width=1) assert config.width == 1 def test_width_max_boundary(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_default_values(self): config = ShadowConfig() assert config.enabled is False assert config.blur == 4 assert config.offset_x == 2 assert config.offset_y == 2 def test_custom_values(self): config = ShadowConfig(enabled=True, blur=10, offset_x=5, offset_y=3) assert config.enabled is True assert config.blur == 10 assert config.offset_x == 5 assert config.offset_y == 3 def test_blur_min_boundary(self): config = ShadowConfig(blur=0) assert config.blur == 0 def test_blur_max_boundary(self): config = ShadowConfig(blur=20) assert config.blur == 20 def test_blur_above_max_raises(self): with pytest.raises(ValidationError): ShadowConfig(blur=21) def test_negative_offset(self): config = ShadowConfig(offset_x=-3, offset_y=-2) assert config.offset_x == -3 assert config.offset_y == -2 class TestCoverConfig: """CoverConfig 封面配置测试""" def test_default_values(self): config = CoverConfig() assert config.type == CoverType.AI_FRAME assert config.image_url == "" assert config.frame_time is None def test_custom_values(self): config = CoverConfig(type=CoverType.MANUAL, image_url="http://img/1.jpg", frame_time=5.5) assert config.type == CoverType.MANUAL assert config.image_url == "http://img/1.jpg" assert config.frame_time == 5.5 def test_frame_time_min_boundary(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_string_type_conversion(self): """字符串枚举值自动转换""" config = CoverConfig(type="upload") assert config.type == CoverType.UPLOAD class TestTitleConfig: """TitleConfig 标题配置测试""" def test_default_values(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_custom_values(self): config = TitleConfig( enabled=False, ai_auto=False, text="测试标题", position=TextPosition.BOTTOM, font="微软雅黑", color="#000000", size=72, bold=False, italic=True, ) assert config.enabled is False assert config.ai_auto is False assert config.text == "测试标题" assert config.position == TextPosition.BOTTOM assert config.size == 72 def test_size_min_boundary(self): config = TitleConfig(size=12) assert config.size == 12 def test_size_max_boundary(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) def test_nested_stroke_config(self): config = TitleConfig(stroke={"enabled": True, "width": 3}) assert config.stroke.enabled is True assert config.stroke.width == 3 def test_nested_shadow_config(self): config = TitleConfig(shadow={"enabled": True, "blur": 8}) assert config.shadow.enabled is True assert config.shadow.blur == 8 class TestSubtitleConfig: """SubtitleConfig 字幕配置测试""" def test_default_values(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_custom_values(self): config = SubtitleConfig( enabled=False, position=TextPosition.TOP, size=36, animation=TextAnimation.SLIDE_UP, auto_generated=True, language="zh", max_chars_per_line=30, min_chars_per_segment=10, ) assert config.enabled is False assert config.position == TextPosition.TOP assert config.animation == TextAnimation.SLIDE_UP assert config.auto_generated is True assert config.language == "zh" def test_size_min_boundary(self): config = SubtitleConfig(size=12) assert config.size == 12 def test_size_max_boundary(self): config = SubtitleConfig(size=60) assert config.size == 60 def test_size_below_min_raises(self): with pytest.raises(ValidationError): SubtitleConfig(size=11) def test_max_chars_min_boundary(self): config = SubtitleConfig(max_chars_per_line=8) assert config.max_chars_per_line == 8 def test_max_chars_max_boundary(self): config = SubtitleConfig(max_chars_per_line=40) assert config.max_chars_per_line == 40 def test_min_chars_min_boundary(self): config = SubtitleConfig(min_chars_per_segment=2) assert config.min_chars_per_segment == 2 def test_min_chars_max_boundary(self): config = SubtitleConfig(min_chars_per_segment=20) assert config.min_chars_per_segment == 20 class TestBGMConfig: """BGMConfig 背景音乐配置测试""" def test_default_values(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_custom_values(self): config = BGMConfig( enabled=True, source=BGMSource.UPLOAD, asset_id="bgm_001", volume=0.5, fade_in=2.0, fade_out=3.0, loop_enabled=False, sidechain_enabled=True, sidechain_ratio=0.5, sidechain_attack=0.05, sidechain_release=1.0, sidechain_threshold=-20.0, ) assert config.enabled is True assert config.source == BGMSource.UPLOAD assert config.asset_id == "bgm_001" assert config.volume == 0.5 assert config.loop_enabled is False assert config.sidechain_enabled is True assert config.sidechain_ratio == 0.5 def test_volume_min_boundary(self): config = BGMConfig(volume=0.0) assert config.volume == 0.0 def test_volume_max_boundary(self): config = BGMConfig(volume=1.0) assert config.volume == 1.0 def test_volume_above_max_raises(self): with pytest.raises(ValidationError): BGMConfig(volume=1.1) def test_fade_in_max_boundary(self): config = BGMConfig(fade_in=30.0) assert config.fade_in == 30.0 def test_fade_in_above_max_raises(self): with pytest.raises(ValidationError): BGMConfig(fade_in=31.0) def test_sidechain_ratio_range(self): config = BGMConfig(sidechain_ratio=0.0) assert config.sidechain_ratio == 0.0 config = BGMConfig(sidechain_ratio=1.0) assert config.sidechain_ratio == 1.0 def test_sidechain_attack_min(self): config = BGMConfig(sidechain_attack=0.001) assert config.sidechain_attack == 0.001 def test_sidechain_attack_below_min_raises(self): with pytest.raises(ValidationError): BGMConfig(sidechain_attack=0.0001) def test_sidechain_threshold_range(self): config = BGMConfig(sidechain_threshold=-60.0) assert config.sidechain_threshold == -60.0 config = BGMConfig(sidechain_threshold=0.0) assert config.sidechain_threshold == 0.0 def test_sidechain_threshold_out_of_range_raises(self): with pytest.raises(ValidationError): BGMConfig(sidechain_threshold=-61.0) with pytest.raises(ValidationError): BGMConfig(sidechain_threshold=1.0) class TestExportConfig: """ExportConfig 导出配置测试""" def test_default_values(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_custom_values(self): config = ExportConfig( resolution="2160x3840", fps=60, video_bitrate=20000, audio_bitrate=320, format="mov", quality_preset="best", watermark_enabled=True, watermark_text="测试水印", ) assert config.resolution == "2160x3840" assert config.fps == 60 assert config.video_bitrate == 20000 assert config.format == "mov" def test_fps_min_boundary(self): config = ExportConfig(fps=15) assert config.fps == 15 def test_fps_max_boundary(self): config = ExportConfig(fps=60) assert config.fps == 60 def test_fps_below_min_raises(self): with pytest.raises(ValidationError): ExportConfig(fps=14) def test_fps_above_max_raises(self): with pytest.raises(ValidationError): ExportConfig(fps=61) def test_video_bitrate_range(self): config = ExportConfig(video_bitrate=1000) assert config.video_bitrate == 1000 config = ExportConfig(video_bitrate=20000) assert config.video_bitrate == 20000 def test_audio_bitrate_range(self): config = ExportConfig(audio_bitrate=64) assert config.audio_bitrate == 64 config = ExportConfig(audio_bitrate=320) assert config.audio_bitrate == 320 class TestFilterConfig: """FilterConfig 滤镜配置测试""" def test_default_values(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_custom_values(self): config = FilterConfig( enabled=True, preset_id="filter_vintage", intensity=50, brightness=0.5, contrast=1.5, saturation=2.0, warmth=0.3, ) assert config.enabled is True assert config.preset_id == "filter_vintage" assert config.intensity == 50 assert config.brightness == 0.5 def test_intensity_min_boundary(self): config = FilterConfig(intensity=0) assert config.intensity == 0 def test_intensity_max_boundary(self): config = FilterConfig(intensity=100) assert config.intensity == 100 def test_intensity_out_of_range_raises(self): with pytest.raises(ValidationError): FilterConfig(intensity=-1) with pytest.raises(ValidationError): FilterConfig(intensity=101) def test_brightness_range(self): config = FilterConfig(brightness=-1.0) assert config.brightness == -1.0 config = FilterConfig(brightness=1.0) assert config.brightness == 1.0 def test_contrast_range(self): config = FilterConfig(contrast=0.0) assert config.contrast == 0.0 config = FilterConfig(contrast=2.0) assert config.contrast == 2.0 def test_saturation_range(self): config = FilterConfig(saturation=0.0) assert config.saturation == 0.0 config = FilterConfig(saturation=3.0) assert config.saturation == 3.0 def test_warmth_range(self): config = FilterConfig(warmth=-1.0) assert config.warmth == -1.0 config = FilterConfig(warmth=1.0) assert config.warmth == 1.0 def test_brightness_out_of_range_raises(self): with pytest.raises(ValidationError): FilterConfig(brightness=-1.1) with pytest.raises(ValidationError): FilterConfig(brightness=1.1) # ── 完整 config 模型测试 ───────────────────────────────────────────────────── class TestEditPlanConfigSchema: """EditPlanConfigSchema 完整计划配置测试""" def test_default_values(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={"enabled": False}, bgm={"enabled": True, "volume": 0.5}, editing_mode="pip", ) assert config.title.enabled is False assert config.bgm.enabled is True assert config.bgm.volume == 0.5 assert config.editing_mode == "pip" # 其他字段保持默认 assert config.cover.type == CoverType.AI_FRAME assert config.subtitle.enabled is True def test_nested_model_preservation(self): """嵌套模型完整可用""" config = EditPlanConfigSchema() assert config.title.stroke.enabled is False assert config.title.shadow.blur == 4 assert config.bgm.sidechain_ratio == 0.3 class TestEditTemplateConfigSchema: """EditTemplateConfigSchema 模板配置测试""" def test_default_values(self): config = EditTemplateConfigSchema() 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" assert config.transition_enabled is True def test_transition_enabled_false(self): config = EditTemplateConfigSchema(transition_enabled=False) assert config.transition_enabled is False def test_partial_update(self): config = EditTemplateConfigSchema( filter={"enabled": True, "preset_id": "filter_cinematic"}, transition_enabled=False, editing_mode="voice_over", ) assert config.filter.enabled is True assert config.filter.preset_id == "filter_cinematic" assert config.transition_enabled is False assert config.editing_mode == "voice_over" # ── 默认值常量测试 ──────────────────────────────────────────────────────────── class TestDefaultConfigs: """默认值常量测试""" def test_plan_config_structure(self): """DEFAULT_EDIT_PLAN_CONFIG 结构完整""" 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(self): """模板配置比计划配置多一个 transition_enabled""" assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True def test_template_extends_plan(self): """模板配置是计划配置的超集""" for key in DEFAULT_EDIT_PLAN_CONFIG: assert key in DEFAULT_EDIT_TEMPLATE_CONFIG def test_defaults_are_deep_copy_safe(self): """修改默认值不会影响常量本身""" original = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) config = EditPlanConfigSchema() config.title.text = "modified" assert DEFAULT_EDIT_PLAN_CONFIG == original # ── normalize 函数测试 ──────────────────────────────────────────────────────── class TestNormalizePlanConfig: """normalize_plan_config 函数测试""" def test_none_returns_default(self): result = normalize_plan_config(None) assert result == DEFAULT_EDIT_PLAN_CONFIG # 确保是深拷贝 result["cover"]["type"] = "manual" assert DEFAULT_EDIT_PLAN_CONFIG["cover"]["type"] == "ai_frame" def test_empty_dict_returns_default(self): result = normalize_plan_config({}) assert result == DEFAULT_EDIT_PLAN_CONFIG def test_partial_cover_update(self): raw = {"cover": {"type": "manual", "frame_time": 5.0}} result = normalize_plan_config(raw) assert result["cover"]["type"] == "manual" assert result["cover"]["frame_time"] == 5.0 # 其他字段保留默认 assert result["cover"]["image_url"] == "" def test_partial_title_update(self): raw = {"title": {"enabled": False, "text": "自定义标题"}} result = normalize_plan_config(raw) assert result["title"]["enabled"] is False assert result["title"]["text"] == "自定义标题" assert result["title"]["size"] == 48 # 默认值保留 def test_partial_subtitle_update(self): raw = {"subtitle": {"enabled": False, "size": 32}} result = normalize_plan_config(raw) assert result["subtitle"]["enabled"] is False assert result["subtitle"]["size"] == 32 def test_partial_bgm_update(self): raw = {"bgm": {"enabled": True, "volume": 0.8}} result = normalize_plan_config(raw) assert result["bgm"]["enabled"] is True assert result["bgm"]["volume"] == 0.8 def test_editing_mode_update(self): raw = {"editing_mode": "pip"} result = normalize_plan_config(raw) assert result["editing_mode"] == "pip" def test_non_standard_fields_preserved(self): """非标准字段会被保留(透传)""" raw = {"generation_task_id": "task_123", "custom_field": "value"} result = normalize_plan_config(raw) assert result["generation_task_id"] == "task_123" assert result["custom_field"] == "value" def test_full_update(self): """多个字段同时更新""" raw = { "cover": {"type": "upload", "image_url": "http://img.jpg"}, "title": {"enabled": False}, "bgm": {"enabled": True, "volume": 0.5}, "editing_mode": "voice_pip", "extra_key": "extra_val", } result = normalize_plan_config(raw) assert result["cover"]["type"] == "upload" assert result["title"]["enabled"] is False assert result["bgm"]["enabled"] is True assert result["bgm"]["volume"] == 0.5 assert result["editing_mode"] == "voice_pip" assert result["extra_key"] == "extra_val" def test_non_dict_section_ignored(self): """section不是dict时忽略""" raw = {"cover": "not_a_dict"} result = normalize_plan_config(raw) # cover 应保持默认值 assert result["cover"]["type"] == "ai_frame" # 但 cover 字段本身作为非标准字段保留 # 不对,看实现:只有当 isinstance(raw[section_key], dict) 时才 update # 非dict的section会作为非标准字段保留吗?看实现: # section_key in raw and isinstance -> update # 然后遍历所有key,不在标准列表中的才会保留 # cover 在标准列表中,所以不会被保留为非标准字段 # 所以结果应该是默认的cover配置 assert isinstance(result["cover"], dict) assert result["cover"]["type"] == "ai_frame" def test_editing_mode_non_string_ignored(self): """editing_mode不是字符串时忽略""" raw = {"editing_mode": 123} result = normalize_plan_config(raw) # 作为非标准字段保留?不,看实现: # 如果 "editing_mode" in raw and isinstance(str) 才更新 # 然后遍历所有key,不在标准列表中的保留 # editing_mode 在标准列表中,所以不会保留为非标准 # 所以 editing_mode 保持默认 assert result["editing_mode"] == "one_take" class TestNormalizeTemplateConfig: """normalize_template_config 函数测试""" def test_none_returns_default(self): result = normalize_template_config(None) assert result == DEFAULT_EDIT_TEMPLATE_CONFIG # 确保是深拷贝 result["transition_enabled"] = False assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True def test_empty_dict_returns_default(self): result = normalize_template_config({}) assert result == DEFAULT_EDIT_TEMPLATE_CONFIG def test_transition_enabled_update(self): raw = {"transition_enabled": False} result = normalize_template_config(raw) assert result["transition_enabled"] is False def test_transition_enabled_non_bool_ignored(self): raw = {"transition_enabled": "yes"} result = normalize_template_config(raw) # transition_enabled 在标准列表中,非bool则不更新 # 也不会作为非标准字段保留 assert result["transition_enabled"] is True def test_partial_sections_update(self): raw = { "cover": {"type": "ai_regenerate"}, "filter": {"enabled": True, "intensity": 75}, "transition_enabled": False, "editing_mode": "pip", } result = normalize_template_config(raw) assert result["cover"]["type"] == "ai_regenerate" assert result["filter"]["enabled"] is True assert result["filter"]["intensity"] == 75 assert result["transition_enabled"] is False assert result["editing_mode"] == "pip" def test_non_standard_fields_preserved(self): raw = {"template_version": 3, "author": "test"} result = normalize_template_config(raw) assert result["template_version"] == 3 assert result["author"] == "test" def test_template_has_extra_field_over_plan(self): """模板normalize结果比计划多transition_enabled""" plan_result = normalize_plan_config({"bgm": {"enabled": True}}) template_result = normalize_template_config({"bgm": {"enabled": True}}) assert "transition_enabled" in template_result assert "transition_enabled" not in plan_result