diff --git a/tests/unit/domain/test_config_schemas.py b/tests/unit/domain/test_config_schemas.py new file mode 100755 index 000000000..77bec38e1 --- /dev/null +++ b/tests/unit/domain/test_config_schemas.py @@ -0,0 +1,825 @@ +"""config_schemas 模块单测. + +覆盖:枚举类型、各子配置模型、完整Schema模型、normalize工具函数。 +""" + +import copy + +import pytest +from 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, +) +from pydantic import ValidationError + +# ── 枚举测试 ────────────────────────────────────────────────────────────────── + + +class TestCoverType: + """CoverType 枚举""" + + def test_enum_values(self): + assert CoverType.AI_FRAME.value == "ai_frame" + assert CoverType.MANUAL.value == "manual" + assert CoverType.UPLOAD.value == "upload" + assert CoverType.AI_REGENERATE.value == "ai_regenerate" + + def test_is_str_enum(self): + assert isinstance(CoverType.AI_FRAME, str) + assert CoverType.AI_FRAME == "ai_frame" + + def test_from_string(self): + assert CoverType("ai_frame") == CoverType.AI_FRAME + assert CoverType("manual") == CoverType.MANUAL + + def test_invalid_value_raises(self): + with pytest.raises(ValueError): + CoverType("invalid") + + +class TestTextPosition: + """TextPosition 枚举""" + + def test_enum_values(self): + assert TextPosition.TOP.value == "top" + assert TextPosition.CENTER.value == "center" + assert TextPosition.BOTTOM.value == "bottom" + + def test_from_string(self): + assert TextPosition("top") == TextPosition.TOP + assert TextPosition("bottom") == TextPosition.BOTTOM + + def test_invalid_value_raises(self): + with pytest.raises(ValueError): + TextPosition("left") + + +class TestTextAnimation: + """TextAnimation 枚举""" + + def test_enum_values(self): + assert TextAnimation.NONE.value == "none" + assert TextAnimation.FADE_IN.value == "fade_in" + assert TextAnimation.SLIDE_UP.value == "slide_up" + assert TextAnimation.SLIDE_DOWN.value == "slide_down" + assert TextAnimation.SCALE.value == "scale" + + def test_from_string(self): + assert TextAnimation("fade_in") == TextAnimation.FADE_IN + + def test_invalid_value_raises(self): + with pytest.raises(ValueError): + TextAnimation("bounce") + + +class TestBGMSource: + """BGMSource 枚举""" + + def test_enum_values(self): + assert BGMSource.LIBRARY.value == "library" + assert BGMSource.UPLOAD.value == "upload" + assert BGMSource.AI_RECOMMEND.value == "ai_recommend" + + def test_from_string(self): + assert BGMSource("library") == BGMSource.LIBRARY + + def test_invalid_value_raises(self): + with pytest.raises(ValueError): + BGMSource("spotify") + + +# ── StrokeConfig / ShadowConfig ────────────────────────────────────────────── + + +class TestStrokeConfig: + """StrokeConfig 描边配置""" + + def test_default_values(self): + s = StrokeConfig() + assert s.enabled is False + assert s.color == "#000000" + assert s.width == 1 + + def test_custom_values(self): + s = StrokeConfig(enabled=True, color="#ff0000", width=5) + assert s.enabled is True + assert s.color == "#ff0000" + assert s.width == 5 + + def test_width_min_boundary(self): + s = StrokeConfig(width=1) + assert s.width == 1 + + def test_width_max_boundary(self): + s = StrokeConfig(width=10) + assert s.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): + s = ShadowConfig() + assert s.enabled is False + assert s.blur == 4 + assert s.offset_x == 2 + assert s.offset_y == 2 + + def test_custom_values(self): + s = ShadowConfig(enabled=True, blur=10, offset_x=5, offset_y=5) + assert s.enabled is True + assert s.blur == 10 + assert s.offset_x == 5 + assert s.offset_y == 5 + + def test_blur_min_boundary(self): + s = ShadowConfig(blur=0) + assert s.blur == 0 + + def test_blur_max_boundary(self): + s = ShadowConfig(blur=20) + assert s.blur == 20 + + def test_blur_above_max_raises(self): + with pytest.raises(ValidationError): + ShadowConfig(blur=21) + + +# ── CoverConfig ────────────────────────────────────────────────────────────── + + +class TestCoverConfig: + """CoverConfig 封面配置""" + + def test_default_values(self): + c = CoverConfig() + assert c.type == CoverType.AI_FRAME + assert c.image_url == "" + assert c.frame_time is None + + def test_manual_type_with_frame_time(self): + c = CoverConfig(type=CoverType.MANUAL, frame_time=5.5) + assert c.type == CoverType.MANUAL + assert c.frame_time == 5.5 + + def test_upload_type_with_image_url(self): + c = CoverConfig(type=CoverType.UPLOAD, image_url="https://example.com/cover.jpg") + assert c.type == CoverType.UPLOAD + assert c.image_url == "https://example.com/cover.jpg" + + def test_frame_time_negative_raises(self): + with pytest.raises(ValidationError): + CoverConfig(frame_time=-1.0) + + def test_frame_time_zero_valid(self): + c = CoverConfig(frame_time=0.0) + assert c.frame_time == 0.0 + + def test_from_dict_with_string_enum(self): + c = CoverConfig(**{"type": "ai_regenerate", "image_url": ""}) + assert c.type == CoverType.AI_REGENERATE + + +# ── TitleConfig ─────────────────────────────────────────────────────────────── + + +class TestTitleConfig: + """TitleConfig 标题配置""" + + def test_default_values(self): + t = TitleConfig() + assert t.enabled is True + assert t.ai_auto is True + assert t.text == "" + assert t.position == TextPosition.TOP + assert t.font == "思源黑体" + assert t.color == "#ffffff" + assert t.size == 48 + assert t.bold is True + assert t.italic is False + assert isinstance(t.stroke, StrokeConfig) + assert isinstance(t.shadow, ShadowConfig) + + def test_custom_title(self): + t = TitleConfig( + enabled=True, + ai_auto=False, + text="我的视频标题", + position=TextPosition.CENTER, + font="微软雅黑", + color="#000000", + size=36, + bold=False, + italic=True, + ) + assert t.text == "我的视频标题" + assert t.position == TextPosition.CENTER + assert t.size == 36 + assert t.bold is False + assert t.italic is True + + def test_size_min_boundary(self): + t = TitleConfig(size=12) + assert t.size == 12 + + def test_size_max_boundary(self): + t = TitleConfig(size=120) + assert t.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_stroke_nested_config(self): + t = TitleConfig(stroke={"enabled": True, "color": "#ff0000", "width": 3}) + assert t.stroke.enabled is True + assert t.stroke.color == "#ff0000" + assert t.stroke.width == 3 + + def test_shadow_nested_config(self): + t = TitleConfig(shadow={"enabled": True, "blur": 8, "offset_x": 3, "offset_y": 3}) + assert t.shadow.enabled is True + assert t.shadow.blur == 8 + + +# ── SubtitleConfig ──────────────────────────────────────────────────────────── + + +class TestSubtitleConfig: + """SubtitleConfig 字幕配置""" + + def test_default_values(self): + s = SubtitleConfig() + assert s.enabled is True + assert s.position == TextPosition.BOTTOM + assert s.font == "思源黑体" + assert s.color == "#ffffff" + assert s.size == 24 + assert s.animation == TextAnimation.FADE_IN + assert s.auto_generated is False + assert s.language == "" + assert s.max_chars_per_line == 20 + assert s.min_chars_per_segment == 8 + + def test_custom_subtitle(self): + s = SubtitleConfig( + enabled=False, + position=TextPosition.TOP, + size=32, + animation=TextAnimation.SLIDE_UP, + auto_generated=True, + language="zh", + max_chars_per_line=30, + min_chars_per_segment=10, + ) + assert s.enabled is False + assert s.position == TextPosition.TOP + assert s.size == 32 + assert s.animation == TextAnimation.SLIDE_UP + assert s.auto_generated is True + assert s.language == "zh" + + def test_size_min_boundary(self): + s = SubtitleConfig(size=12) + assert s.size == 12 + + def test_size_max_boundary(self): + s = SubtitleConfig(size=60) + assert s.size == 60 + + def test_size_below_min_raises(self): + with pytest.raises(ValidationError): + SubtitleConfig(size=11) + + def test_max_chars_min_boundary(self): + s = SubtitleConfig(max_chars_per_line=8) + assert s.max_chars_per_line == 8 + + def test_max_chars_max_boundary(self): + s = SubtitleConfig(max_chars_per_line=40) + assert s.max_chars_per_line == 40 + + def test_max_chars_out_of_range_raises(self): + with pytest.raises(ValidationError): + SubtitleConfig(max_chars_per_line=41) + + def test_min_chars_min_boundary(self): + s = SubtitleConfig(min_chars_per_segment=2) + assert s.min_chars_per_segment == 2 + + def test_min_chars_max_boundary(self): + s = SubtitleConfig(min_chars_per_segment=20) + assert s.min_chars_per_segment == 20 + + def test_min_chars_out_of_range_raises(self): + with pytest.raises(ValidationError): + SubtitleConfig(min_chars_per_segment=1) + + +# ── BGMConfig ───────────────────────────────────────────────────────────────── + + +class TestBGMConfig: + """BGMConfig BGM配置""" + + def test_default_values(self): + b = BGMConfig() + assert b.enabled is False + assert b.source == BGMSource.LIBRARY + assert b.asset_id == "" + assert b.preset_id == "" + assert b.audio_url == "" + assert b.volume == 0.3 + assert b.fade_in == 0.0 + assert b.fade_out == 0.0 + assert b.loop_enabled is True + assert b.sidechain_enabled is False + assert b.sidechain_ratio == 0.3 + assert b.sidechain_attack == 0.02 + assert b.sidechain_release == 0.5 + assert b.sidechain_threshold == -25.0 + + def test_custom_bgm(self): + b = BGMConfig( + enabled=True, + source=BGMSource.UPLOAD, + asset_id="bgm_123", + volume=0.5, + fade_in=2.0, + fade_out=3.0, + sidechain_enabled=True, + sidechain_ratio=0.5, + ) + assert b.enabled is True + assert b.source == BGMSource.UPLOAD + assert b.volume == 0.5 + assert b.sidechain_enabled is True + assert b.sidechain_ratio == 0.5 + + def test_volume_range(self): + b = BGMConfig(volume=0.0) + assert b.volume == 0.0 + b = BGMConfig(volume=1.0) + assert b.volume == 1.0 + + def test_volume_out_of_range_raises(self): + with pytest.raises(ValidationError): + BGMConfig(volume=-0.1) + with pytest.raises(ValidationError): + BGMConfig(volume=1.1) + + def test_fade_in_range(self): + b = BGMConfig(fade_in=30.0) + assert b.fade_in == 30.0 + + def test_fade_in_out_of_range_raises(self): + with pytest.raises(ValidationError): + BGMConfig(fade_in=31.0) + + def test_sidechain_attack_min(self): + b = BGMConfig(sidechain_attack=0.001) + assert b.sidechain_attack == 0.001 + + def test_sidechain_attack_out_of_range_raises(self): + with pytest.raises(ValidationError): + BGMConfig(sidechain_attack=0.0001) + + def test_sidechain_threshold_range(self): + b = BGMConfig(sidechain_threshold=-60.0) + assert b.sidechain_threshold == -60.0 + b = BGMConfig(sidechain_threshold=0.0) + assert b.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) + + +# ── ExportConfig ────────────────────────────────────────────────────────────── + + +class TestExportConfig: + """ExportConfig 导出配置""" + + def test_default_values(self): + e = ExportConfig() + assert e.resolution == "1080x1920" + assert e.fps == 30 + assert e.video_bitrate == 8000 + assert e.audio_bitrate == 128 + assert e.format == "mp4" + assert e.quality_preset == "balanced" + assert e.watermark_enabled is False + assert e.watermark_text == "" + + def test_custom_export(self): + e = ExportConfig( + resolution="720x1280", + fps=60, + video_bitrate=5000, + audio_bitrate=192, + format="mov", + quality_preset="high", + watermark_enabled=True, + watermark_text="我的水印", + ) + assert e.resolution == "720x1280" + assert e.fps == 60 + assert e.format == "mov" + assert e.watermark_enabled is True + + def test_fps_min_boundary(self): + e = ExportConfig(fps=15) + assert e.fps == 15 + + def test_fps_max_boundary(self): + e = ExportConfig(fps=60) + assert e.fps == 60 + + def test_fps_out_of_range_raises(self): + with pytest.raises(ValidationError): + ExportConfig(fps=14) + with pytest.raises(ValidationError): + ExportConfig(fps=61) + + def test_video_bitrate_range(self): + e = ExportConfig(video_bitrate=1000) + assert e.video_bitrate == 1000 + e = ExportConfig(video_bitrate=20000) + assert e.video_bitrate == 20000 + + def test_video_bitrate_out_of_range_raises(self): + with pytest.raises(ValidationError): + ExportConfig(video_bitrate=999) + with pytest.raises(ValidationError): + ExportConfig(video_bitrate=20001) + + def test_audio_bitrate_range(self): + e = ExportConfig(audio_bitrate=64) + assert e.audio_bitrate == 64 + e = ExportConfig(audio_bitrate=320) + assert e.audio_bitrate == 320 + + +# ── FilterConfig ────────────────────────────────────────────────────────────── + + +class TestFilterConfig: + """FilterConfig 滤镜配置""" + + def test_default_values(self): + f = FilterConfig() + assert f.enabled is False + assert f.preset_id == "filter_none" + assert f.intensity == 100 + assert f.brightness == 0.0 + assert f.contrast == 1.0 + assert f.saturation == 1.0 + assert f.warmth == 0.0 + + def test_custom_filter(self): + f = FilterConfig( + enabled=True, + preset_id="vintage", + intensity=50, + brightness=0.3, + contrast=1.5, + saturation=2.0, + warmth=-0.5, + ) + assert f.enabled is True + assert f.preset_id == "vintage" + assert f.intensity == 50 + assert f.brightness == 0.3 + + def test_intensity_range(self): + f = FilterConfig(intensity=0) + assert f.intensity == 0 + f = FilterConfig(intensity=100) + assert f.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): + f = FilterConfig(brightness=-1.0) + assert f.brightness == -1.0 + f = FilterConfig(brightness=1.0) + assert f.brightness == 1.0 + + def test_contrast_range(self): + f = FilterConfig(contrast=0.0) + assert f.contrast == 0.0 + f = FilterConfig(contrast=2.0) + assert f.contrast == 2.0 + + def test_saturation_range(self): + f = FilterConfig(saturation=0.0) + assert f.saturation == 0.0 + f = FilterConfig(saturation=3.0) + assert f.saturation == 3.0 + + def test_warmth_range(self): + f = FilterConfig(warmth=-1.0) + assert f.warmth == -1.0 + f = FilterConfig(warmth=1.0) + assert f.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) + + +# ── 完整 Schema 模型 ────────────────────────────────────────────────────────── + + +class TestEditPlanConfigSchema: + """EditPlanConfigSchema 完整计划配置""" + + def test_default_values(self): + s = EditPlanConfigSchema() + assert isinstance(s.cover, CoverConfig) + assert isinstance(s.title, TitleConfig) + assert isinstance(s.subtitle, SubtitleConfig) + assert isinstance(s.bgm, BGMConfig) + assert isinstance(s.export, ExportConfig) + assert isinstance(s.filter, FilterConfig) + assert s.editing_mode == "one_take" + + def test_partial_update_via_dict(self): + s = EditPlanConfigSchema( + **{ + "cover": {"type": "manual", "frame_time": 10.0}, + "title": {"text": "自定义标题", "size": 60}, + "editing_mode": "template", + } + ) + assert s.cover.type == CoverType.MANUAL + assert s.cover.frame_time == 10.0 + assert s.title.text == "自定义标题" + assert s.title.size == 60 + assert s.editing_mode == "template" + + def test_full_config_dict_roundtrip(self): + data = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) + data["title"]["text"] = "测试标题" + data["bgm"]["enabled"] = True + s = EditPlanConfigSchema(**data) + assert s.title.text == "测试标题" + assert s.bgm.enabled is True + # 默认字段保留 + assert s.subtitle.size == 24 + assert s.export.fps == 30 + + def test_invalid_subfield_raises(self): + with pytest.raises(ValidationError): + EditPlanConfigSchema(**{"title": {"size": 999}}) + + +class TestEditTemplateConfigSchema: + """EditTemplateConfigSchema 模板配置""" + + def test_default_values(self): + s = EditTemplateConfigSchema() + assert isinstance(s.cover, CoverConfig) + assert s.editing_mode == "one_take" + assert s.transition_enabled is True + + def test_custom_transition_enabled(self): + s = EditTemplateConfigSchema(transition_enabled=False) + assert s.transition_enabled is False + + def test_has_all_plan_fields(self): + s = EditTemplateConfigSchema() + assert hasattr(s, "cover") + assert hasattr(s, "title") + assert hasattr(s, "subtitle") + assert hasattr(s, "bgm") + assert hasattr(s, "export") + assert hasattr(s, "filter") + assert hasattr(s, "editing_mode") + assert hasattr(s, "transition_enabled") + + +# ── 默认值常量 ──────────────────────────────────────────────────────────────── + + +class TestDefaultConfigs: + """默认配置常量""" + + def test_default_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_default_template_config_extra_field(self): + assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG + assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True + + def test_template_config_inherits_plan(self): + # 模板配置应该包含计划配置的所有字段 + for key in DEFAULT_EDIT_PLAN_CONFIG: + assert key in DEFAULT_EDIT_TEMPLATE_CONFIG + + def test_defaults_are_valid_for_schema(self): + # 默认值应该能通过 schema 校验 + plan = EditPlanConfigSchema(**DEFAULT_EDIT_PLAN_CONFIG) + assert plan.editing_mode == "one_take" + template = EditTemplateConfigSchema(**DEFAULT_EDIT_TEMPLATE_CONFIG) + assert template.transition_enabled is True + + def test_mutation_does_not_affect_original(self): + # 修改返回的 dict 不应该影响常量 + d = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) + d["cover"]["type"] = "upload" + assert DEFAULT_EDIT_PLAN_CONFIG["cover"]["type"] == "ai_frame" + + +# ── normalize_plan_config ──────────────────────────────────────────────────── + + +class TestNormalizePlanConfig: + """normalize_plan_config 工具函数""" + + def test_none_returns_full_defaults(self): + result = normalize_plan_config(None) + assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) + + def test_empty_dict_returns_defaults(self): + result = normalize_plan_config({}) + assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) + + def test_partial_cover_update(self): + result = normalize_plan_config({"cover": {"type": "manual"}}) + assert result["cover"]["type"] == "manual" + # 其他 cover 字段保留默认 + assert result["cover"]["image_url"] == "" + assert result["cover"]["frame_time"] is None + + def test_partial_title_update(self): + result = normalize_plan_config({"title": {"text": "我的标题", "size": 36}}) + assert result["title"]["text"] == "我的标题" + assert result["title"]["size"] == 36 + assert result["title"]["font"] == "思源黑体" + + def test_partial_subtitle_update(self): + result = normalize_plan_config({"subtitle": {"size": 28}}) + assert result["subtitle"]["size"] == 28 + assert result["subtitle"]["position"] == "bottom" + + def test_partial_bgm_update(self): + result = normalize_plan_config({"bgm": {"enabled": True, "volume": 0.5}}) + assert result["bgm"]["enabled"] is True + assert result["bgm"]["volume"] == 0.5 + assert result["bgm"]["source"] == "library" + + def test_editing_mode_update(self): + result = normalize_plan_config({"editing_mode": "template"}) + assert result["editing_mode"] == "template" + + def test_extra_fields_preserved(self): + result = normalize_plan_config({"generation_task_id": "task_123", "custom_field": "value"}) + assert result["generation_task_id"] == "task_123" + assert result["custom_field"] == "value" + # 标准字段也保留 + assert result["editing_mode"] == "one_take" + + def test_combined_update(self): + result = normalize_plan_config( + { + "cover": {"type": "upload", "image_url": "http://x.com/c.jpg"}, + "title": {"text": "标题", "size": 60}, + "bgm": {"enabled": True}, + "editing_mode": "smart", + "extra_key": "extra_value", + } + ) + assert result["cover"]["type"] == "upload" + assert result["title"]["text"] == "标题" + assert result["bgm"]["enabled"] is True + assert result["editing_mode"] == "smart" + assert result["extra_key"] == "extra_value" + + def test_non_dict_section_ignored(self): + result = normalize_plan_config({"cover": "not_a_dict"}) + # cover 应该还是默认值 + assert result["cover"]["type"] == "ai_frame" + + def test_editing_mode_non_string_ignored(self): + result = normalize_plan_config({"editing_mode": 123}) + assert result["editing_mode"] == "one_take" + + def test_does_not_mutate_input(self): + raw = {"cover": {"type": "manual"}, "extra": "value"} + raw_copy = copy.deepcopy(raw) + normalize_plan_config(raw) + assert raw == raw_copy + + def test_does_not_mutate_defaults(self): + original = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) + normalize_plan_config({"cover": {"type": "upload"}}) + assert DEFAULT_EDIT_PLAN_CONFIG == original + + +# ── normalize_template_config ───────────────────────────────────────────────── + + +class TestNormalizeTemplateConfig: + """normalize_template_config 工具函数""" + + def test_none_returns_full_defaults(self): + result = normalize_template_config(None) + assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG) + + def test_empty_dict_returns_defaults(self): + result = normalize_template_config({}) + assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG) + assert result["transition_enabled"] is True + + def test_partial_sections(self): + result = normalize_template_config( + { + "title": {"text": "模板标题"}, + "bgm": {"enabled": True}, + } + ) + assert result["title"]["text"] == "模板标题" + assert result["bgm"]["enabled"] is True + + def test_transition_enabled_update(self): + result = normalize_template_config({"transition_enabled": False}) + assert result["transition_enabled"] is False + + def test_transition_enabled_non_bool_ignored(self): + result = normalize_template_config({"transition_enabled": "yes"}) + assert result["transition_enabled"] is True + + def test_editing_mode_update(self): + result = normalize_template_config({"editing_mode": "story"}) + assert result["editing_mode"] == "story" + + def test_extra_fields_preserved(self): + result = normalize_template_config({"template_version": "v2", "author": "test"}) + assert result["template_version"] == "v2" + assert result["author"] == "test" + assert result["transition_enabled"] is True + + def test_combined_update(self): + result = normalize_template_config( + { + "cover": {"type": "ai_regenerate"}, + "subtitle": {"size": 20}, + "transition_enabled": False, + "editing_mode": "vlog", + "tags": ["travel", "food"], + } + ) + assert result["cover"]["type"] == "ai_regenerate" + assert result["subtitle"]["size"] == 20 + assert result["transition_enabled"] is False + assert result["editing_mode"] == "vlog" + assert result["tags"] == ["travel", "food"] + + def test_does_not_mutate_defaults(self): + original = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG) + normalize_template_config({"transition_enabled": False}) + assert DEFAULT_EDIT_TEMPLATE_CONFIG == original