"""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
