Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 919f69f072 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m11s
AI Code Review / AI Code Review (pull_request) Successful in 1m15s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m6s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m55s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m3s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m2s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m15s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 24s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m38s
2026-07-29 10:31:47 +00:00
xiaoxia c60f4f60c5 test(wave182): config_schemas 配置Schema +110测
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 42s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m50s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
2026-07-29 18:28:20 +08:00
+825
View File
@@ -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