Compare commits

...

1 Commits

Author SHA1 Message Date
CI Bot 601dd1dba9 refactor(wave112): 抽离intro_outro_config领域模型 + 52单测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m10s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m2s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m8s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 53s
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 / PR Build Web Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 56s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m18s
AI Code Review / AI Code Review (pull_request) Successful in 3m34s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m24s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m41s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
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 / Canary Release to Production (pull_request) Has been cancelled
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 26s
- 新建 packages/domain/intro_outro_config.py:IntroOutroConfig 数据类
  + from_dict / has_intro / has_outro / validate / total_extra_duration
  + 4种片头片尾类型常量 + 3种转场常量
- intro_outro_engine.py: 416→309行 (-107行, -26%)
  保留模块级导出,完全向后兼容
- 新增 52 个单测覆盖数据模型 + 纯逻辑函数
2026-07-27 00:59:14 +08:00
3 changed files with 765 additions and 116 deletions
@@ -11,129 +11,22 @@ from __future__ import annotations
import logging
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from packages.domain.intro_outro_config import ( # noqa: F401 — 向后兼容
INTRO_OUTRO_TYPE_FOLLOW,
INTRO_OUTRO_TYPE_NONE,
INTRO_OUTRO_TYPE_TEXT,
INTRO_OUTRO_TYPE_VIDEO,
TRANSITION_FADE,
IntroOutroConfig,
)
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
logger = logging.getLogger(__name__)
@dataclass
class IntroOutroConfig:
"""片头片尾配置.
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
"""
enabled: bool = False
# 片头
intro_type: str = "none" # none | video | text
intro_video_path: str = "" # 视频片段路径
intro_duration: float = 3.0 # 片头时长(秒)
# 文字片头配置
intro_background: str = "#000000" # 背景色
intro_title: str = ""
intro_subtitle: str = ""
intro_title_color: str = "white"
intro_title_size: int = 48
intro_subtitle_color: str = "gray"
intro_subtitle_size: int = 24
# 片尾
outro_type: str = "none" # none | video | text | follow
outro_video_path: str = "" # 视频片段路径
outro_duration: float = 3.0 # 片尾时长(秒)
# 文字片尾配置
outro_background: str = "#000000"
outro_title: str = "感谢观看"
outro_subtitle: str = "点赞关注不迷路"
outro_title_color: str = "white"
outro_title_size: int = 48
outro_subtitle_color: str = "gray"
outro_subtitle_size: int = 24
# 转场
transition_effect: str = "fade"
transition_duration: float = 0.5
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> IntroOutroConfig:
"""从字典构造."""
if not data:
return cls()
enabled = data.get("enabled", False)
if not enabled:
return cls()
intro = data.get("intro", {}) or {}
outro = data.get("outro", {}) or {}
return cls(
enabled=True,
# 片头
intro_type=str(intro.get("type", "none")),
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
intro_duration=float(intro.get("duration", 3.0)),
intro_background=str(intro.get("background", "#000000")),
intro_title=str(intro.get("title", "") or ""),
intro_subtitle=str(intro.get("subtitle", "") or ""),
intro_title_color=str(intro.get("title_color", "white")),
intro_title_size=int(intro.get("title_size", 48)),
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
intro_subtitle_size=int(intro.get("subtitle_size", 24)),
# 片尾
outro_type=str(outro.get("type", "none")),
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
outro_duration=float(outro.get("duration", 3.0)),
outro_background=str(outro.get("background", "#000000")),
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
outro_title_color=str(outro.get("title_color", "white")),
outro_title_size=int(outro.get("title_size", 48)),
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
outro_subtitle_size=int(outro.get("subtitle_size", 24)),
# 转场
transition_effect=str(data.get("transition", "fade")),
transition_duration=float(data.get("transition_duration", 0.5)),
)
@property
def has_intro(self) -> bool:
"""是否有片头."""
return self.enabled and self.intro_type in ("video", "text")
@property
def has_outro(self) -> bool:
"""是否有片尾."""
return self.enabled and self.outro_type in ("video", "text", "follow")
def validate(self) -> tuple[bool, str]:
"""校验配置."""
if not self.enabled:
return True, ""
if self.intro_type == "video" and not self.intro_video_path:
return False, "视频片头缺少 video_path"
if self.intro_type == "text" and not self.intro_title:
return False, "文字片头缺少 title"
if self.outro_type == "video" and not self.outro_video_path:
return False, "视频片尾缺少 video_path"
if self.outro_type in ("text", "follow") and not self.outro_title:
return False, "文字片尾缺少 title"
if self.intro_duration <= 0:
return False, "片头时长必须大于 0"
if self.outro_duration <= 0:
return False, "片尾时长必须大于 0"
return True, ""
# ── 片头片尾引擎 ──────────────────────────────────────────────────────────────
class IntroOutroEngine:
+219
View File
@@ -0,0 +1,219 @@
"""片头片尾配置领域模型 — 纯逻辑,无外部依赖.
抽离自 intro_outro_engine.py 的数据类和纯逻辑函数,
方便单测覆盖,同时保持向后兼容。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# ── 常量 ──────────────────────────────────────────────────────────────────────
INTRO_OUTRO_TYPE_NONE = "none"
INTRO_OUTRO_TYPE_VIDEO = "video"
INTRO_OUTRO_TYPE_TEXT = "text"
INTRO_OUTRO_TYPE_FOLLOW = "follow"
TRANSITION_FADE = "fade"
TRANSITION_SLIDE = "slide"
TRANSITION_WIPE = "wipe"
_VALID_INTRO_TYPES = {INTRO_OUTRO_TYPE_NONE, INTRO_OUTRO_TYPE_VIDEO, INTRO_OUTRO_TYPE_TEXT}
_VALID_OUTRO_TYPES = {
INTRO_OUTRO_TYPE_NONE,
INTRO_OUTRO_TYPE_VIDEO,
INTRO_OUTRO_TYPE_TEXT,
INTRO_OUTRO_TYPE_FOLLOW,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class IntroOutroConfig:
"""片头片尾配置.
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
"""
enabled: bool = False
# 片头
intro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text
intro_video_path: str = "" # 视频片段路径
intro_duration: float = 3.0 # 片头时长(秒)
# 文字片头配置
intro_background: str = "#000000" # 背景色
intro_title: str = ""
intro_subtitle: str = ""
intro_title_color: str = "white"
intro_title_size: int = 48
intro_subtitle_color: str = "gray"
intro_subtitle_size: int = 24
# 片尾
outro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text | follow
outro_video_path: str = "" # 视频片段路径
outro_duration: float = 3.0 # 片尾时长(秒)
# 文字片尾配置
outro_background: str = "#000000"
outro_title: str = "感谢观看"
outro_subtitle: str = "点赞关注不迷路"
outro_title_color: str = "white"
outro_title_size: int = 48
outro_subtitle_color: str = "gray"
outro_subtitle_size: int = 24
# 转场
transition_effect: str = TRANSITION_FADE
transition_duration: float = 0.5
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "IntroOutroConfig":
"""从字典构造."""
if not data:
return cls()
enabled = data.get("enabled", False)
if not enabled:
return cls()
intro = data.get("intro", {}) or {}
outro = data.get("outro", {}) or {}
# 安全解析数值,失败时回退到默认值
try:
intro_duration = float(intro.get("duration", 3.0))
except (TypeError, ValueError):
intro_duration = 3.0
try:
intro_title_size = int(intro.get("title_size", 48))
except (TypeError, ValueError):
intro_title_size = 48
try:
intro_subtitle_size = int(intro.get("subtitle_size", 24))
except (TypeError, ValueError):
intro_subtitle_size = 24
try:
outro_duration = float(outro.get("duration", 3.0))
except (TypeError, ValueError):
outro_duration = 3.0
try:
outro_title_size = int(outro.get("title_size", 48))
except (TypeError, ValueError):
outro_title_size = 48
try:
outro_subtitle_size = int(outro.get("subtitle_size", 24))
except (TypeError, ValueError):
outro_subtitle_size = 24
try:
transition_duration = float(data.get("transition_duration", 0.5))
except (TypeError, ValueError):
transition_duration = 0.5
return cls(
enabled=True,
# 片头
intro_type=str(intro.get("type", INTRO_OUTRO_TYPE_NONE)),
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
intro_duration=intro_duration,
intro_background=str(intro.get("background", "#000000")),
intro_title=str(intro.get("title", "") or ""),
intro_subtitle=str(intro.get("subtitle", "") or ""),
intro_title_color=str(intro.get("title_color", "white")),
intro_title_size=intro_title_size,
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
intro_subtitle_size=intro_subtitle_size,
# 片尾
outro_type=str(outro.get("type", INTRO_OUTRO_TYPE_NONE)),
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
outro_duration=outro_duration,
outro_background=str(outro.get("background", "#000000")),
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
outro_title_color=str(outro.get("title_color", "white")),
outro_title_size=outro_title_size,
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
outro_subtitle_size=outro_subtitle_size,
# 转场
transition_effect=str(data.get("transition", TRANSITION_FADE)),
transition_duration=transition_duration,
)
@property
def has_intro(self) -> bool:
"""是否有片头(视频或文字类型)."""
return self.enabled and self.intro_type in (
INTRO_OUTRO_TYPE_VIDEO,
INTRO_OUTRO_TYPE_TEXT,
)
@property
def has_outro(self) -> bool:
"""是否有片尾(视频/文字/follow类型)."""
return self.enabled and self.outro_type in (
INTRO_OUTRO_TYPE_VIDEO,
INTRO_OUTRO_TYPE_TEXT,
INTRO_OUTRO_TYPE_FOLLOW,
)
@property
def total_extra_duration(self) -> float:
"""片头片尾总共增加的时长(秒)."""
total = 0.0
if self.has_intro and self.intro_duration > 0:
total += self.intro_duration
if self.has_outro and self.outro_duration > 0:
total += self.outro_duration
return total
def validate(self) -> tuple[bool, str]:
"""校验配置合法性,返回 (是否合法, 错误信息)."""
if not self.enabled:
return True, ""
if self.intro_type not in _VALID_INTRO_TYPES:
return False, f"无效的片头类型: {self.intro_type}"
if self.outro_type not in _VALID_OUTRO_TYPES:
return False, f"无效的片尾类型: {self.outro_type}"
if self.intro_type == INTRO_OUTRO_TYPE_VIDEO and not self.intro_video_path:
return False, "视频片头缺少 video_path"
if self.intro_type == INTRO_OUTRO_TYPE_TEXT and not self.intro_title:
return False, "文字片头缺少 title"
if self.outro_type == INTRO_OUTRO_TYPE_VIDEO and not self.outro_video_path:
return False, "视频片尾缺少 video_path"
if self.outro_type in (INTRO_OUTRO_TYPE_TEXT, INTRO_OUTRO_TYPE_FOLLOW) and not self.outro_title:
return False, "文字片尾缺少 title"
if self.intro_duration <= 0:
return False, "片头时长必须大于 0"
if self.outro_duration <= 0:
return False, "片尾时长必须大于 0"
if self.transition_duration < 0:
return False, "转场时长不能为负数"
if self.intro_title_size <= 0:
return False, "片头标题字号必须大于 0"
if self.intro_subtitle_size <= 0:
return False, "片头副标题字号必须大于 0"
if self.outro_title_size <= 0:
return False, "片尾标题字号必须大于 0"
if self.outro_subtitle_size <= 0:
return False, "片尾副标题字号必须大于 0"
return True, ""
+537
View File
@@ -0,0 +1,537 @@
"""intro_outro_config 模块单测 — 纯逻辑,无外部依赖."""
from __future__ import annotations
import pytest
from packages.domain.intro_outro_config import (
INTRO_OUTRO_TYPE_FOLLOW,
INTRO_OUTRO_TYPE_NONE,
INTRO_OUTRO_TYPE_TEXT,
INTRO_OUTRO_TYPE_VIDEO,
TRANSITION_FADE,
IntroOutroConfig,
)
# ── 默认值 ────────────────────────────────────────────────────────────────────
class TestIntroOutroConfigDefaults:
def test_default_disabled(self):
cfg = IntroOutroConfig()
assert cfg.enabled is False
assert cfg.intro_type == INTRO_OUTRO_TYPE_NONE
assert cfg.outro_type == INTRO_OUTRO_TYPE_NONE
assert cfg.transition_effect == TRANSITION_FADE
assert cfg.transition_duration == 0.5
def test_default_intro_text(self):
cfg = IntroOutroConfig()
assert cfg.intro_background == "#000000"
assert cfg.intro_title == ""
assert cfg.intro_subtitle == ""
assert cfg.intro_title_color == "white"
assert cfg.intro_title_size == 48
assert cfg.intro_subtitle_color == "gray"
assert cfg.intro_subtitle_size == 24
assert cfg.intro_duration == 3.0
def test_default_outro_text(self):
cfg = IntroOutroConfig()
assert cfg.outro_background == "#000000"
assert cfg.outro_title == "感谢观看"
assert cfg.outro_subtitle == "点赞关注不迷路"
assert cfg.outro_title_color == "white"
assert cfg.outro_title_size == 48
assert cfg.outro_subtitle_color == "gray"
assert cfg.outro_subtitle_size == 24
assert cfg.outro_duration == 3.0
# ── from_dict ────────────────────────────────────────────────────────────────
class TestIntroOutroConfigFromDict:
def test_none_data_disabled(self):
cfg = IntroOutroConfig.from_dict(None)
assert cfg.enabled is False
def test_empty_dict_disabled(self):
cfg = IntroOutroConfig.from_dict({})
assert cfg.enabled is False
def test_enabled_false(self):
cfg = IntroOutroConfig.from_dict({"enabled": False})
assert cfg.enabled is False
def test_enabled_but_no_intro_outro(self):
cfg = IntroOutroConfig.from_dict({"enabled": True})
assert cfg.enabled is True
assert cfg.has_intro is False
assert cfg.has_outro is False
def test_intro_video(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "video",
"video_path": "/tmp/intro.mp4",
"duration": 2.5,
},
}
)
assert cfg.enabled is True
assert cfg.intro_type == "video"
assert cfg.intro_video_path == "/tmp/intro.mp4"
assert cfg.intro_duration == 2.5
assert cfg.has_intro is True
def test_intro_text(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "text",
"title": "欢迎来到",
"subtitle": "我的频道",
"background": "#FF0000",
"title_color": "yellow",
"title_size": 64,
"subtitle_color": "white",
"subtitle_size": 32,
},
}
)
assert cfg.intro_type == "text"
assert cfg.intro_title == "欢迎来到"
assert cfg.intro_subtitle == "我的频道"
assert cfg.intro_background == "#FF0000"
assert cfg.intro_title_size == 64
assert cfg.intro_subtitle_size == 32
assert cfg.has_intro is True
def test_intro_video_path_alias(self):
# video 和 video_path 都支持
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"type": "video", "video": "/tmp/a.mp4"},
}
)
assert cfg.intro_video_path == "/tmp/a.mp4"
def test_outro_video(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {
"type": "video",
"video_path": "/tmp/outro.mp4",
"duration": 4.0,
},
}
)
assert cfg.outro_type == "video"
assert cfg.outro_video_path == "/tmp/outro.mp4"
assert cfg.outro_duration == 4.0
assert cfg.has_outro is True
def test_outro_text(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {
"type": "text",
"title": "谢谢观看",
"subtitle": "下期再见",
},
}
)
assert cfg.outro_type == "text"
assert cfg.outro_title == "谢谢观看"
assert cfg.outro_subtitle == "下期再见"
assert cfg.has_outro is True
def test_outro_follow(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"type": "follow", "title": "关注我"},
}
)
assert cfg.outro_type == "follow"
assert cfg.has_outro is True
def test_outro_default_title_when_empty(self):
# 空字符串标题会回退到默认值
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"type": "text", "title": ""},
}
)
assert cfg.outro_title == "感谢观看"
def test_outro_default_subtitle_when_empty(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"type": "text", "subtitle": ""},
}
)
assert cfg.outro_subtitle == "点赞关注不迷路"
def test_transition_config(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"transition": "slide",
"transition_duration": 1.0,
}
)
assert cfg.transition_effect == "slide"
assert cfg.transition_duration == 1.0
def test_invalid_intro_duration_fallback(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"type": "text", "title": "hi", "duration": "bad"},
}
)
assert cfg.intro_duration == 3.0
def test_invalid_outro_duration_fallback(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"type": "text", "title": "hi", "duration": "bad"},
}
)
assert cfg.outro_duration == 3.0
def test_invalid_title_size_fallback(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"type": "text", "title": "hi", "title_size": "bad"},
}
)
assert cfg.intro_title_size == 48
def test_invalid_transition_duration_fallback(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"transition_duration": "bad",
}
)
assert cfg.transition_duration == 0.5
def test_intro_is_none_dict(self):
# intro 可能是 None
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": None,
"outro": None,
}
)
assert cfg.enabled is True
assert cfg.intro_type == "none"
def test_full_config(self):
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "text",
"title": "开场",
"subtitle": "精彩马上开始",
"background": "#123456",
"title_color": "white",
"title_size": 72,
"subtitle_color": "gray",
"subtitle_size": 28,
"duration": 2.0,
},
"outro": {
"type": "text",
"title": "结束",
"subtitle": "再见",
"background": "#654321",
"duration": 3.5,
},
"transition": "wipe",
"transition_duration": 0.8,
}
)
assert cfg.has_intro is True
assert cfg.has_outro is True
assert cfg.intro_title == "开场"
assert cfg.outro_title == "结束"
assert cfg.transition_effect == "wipe"
assert cfg.transition_duration == 0.8
# ── has_intro / has_outro ────────────────────────────────────────────────────
class TestHasIntroHasOutro:
def test_disabled_no_intro_outro(self):
cfg = IntroOutroConfig(enabled=False)
assert cfg.has_intro is False
assert cfg.has_outro is False
def test_enabled_none_type(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="none",
outro_type="none",
)
assert cfg.has_intro is False
assert cfg.has_outro is False
def test_intro_video_type(self):
cfg = IntroOutroConfig(enabled=True, intro_type="video", intro_video_path="a.mp4")
assert cfg.has_intro is True
def test_intro_text_type(self):
cfg = IntroOutroConfig(enabled=True, intro_type="text", intro_title="Hi")
assert cfg.has_intro is True
def test_outro_video(self):
cfg = IntroOutroConfig(enabled=True, outro_type="video", outro_video_path="a.mp4")
assert cfg.has_outro is True
def test_outro_text(self):
cfg = IntroOutroConfig(enabled=True, outro_type="text", outro_title="Bye")
assert cfg.has_outro is True
def test_outro_follow(self):
cfg = IntroOutroConfig(enabled=True, outro_type="follow", outro_title="Follow")
assert cfg.has_outro is True
def test_intro_follow_not_valid(self):
# intro 不支持 follow 类型
cfg = IntroOutroConfig(enabled=True, intro_type="follow")
assert cfg.has_intro is False
# ── total_extra_duration ─────────────────────────────────────────────────────
class TestTotalExtraDuration:
def test_disabled_zero(self):
cfg = IntroOutroConfig(enabled=False)
assert cfg.total_extra_duration == 0.0
def test_both_intro_outro(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="Hi",
intro_duration=2.0,
outro_type="text",
outro_title="Bye",
outro_duration=3.0,
)
assert cfg.total_extra_duration == 5.0
def test_only_intro(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="video",
intro_video_path="a.mp4",
intro_duration=2.5,
)
assert cfg.total_extra_duration == 2.5
def test_only_outro(self):
cfg = IntroOutroConfig(
enabled=True,
outro_type="video",
outro_video_path="a.mp4",
outro_duration=4.0,
)
assert cfg.total_extra_duration == 4.0
def test_zero_duration_ignored(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="Hi",
intro_duration=0.0,
outro_type="text",
outro_title="Bye",
outro_duration=0.0,
)
assert cfg.total_extra_duration == 0.0
# ── validate ─────────────────────────────────────────────────────────────────
class TestValidate:
def test_disabled_always_valid(self):
cfg = IntroOutroConfig(enabled=False)
ok, err = cfg.validate()
assert ok is True
assert err == ""
def test_none_type_valid(self):
cfg = IntroOutroConfig(enabled=True, intro_type="none", outro_type="none")
ok, err = cfg.validate()
assert ok is True
def test_video_intro_without_path_invalid(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="video",
intro_video_path="",
outro_type="none",
)
ok, err = cfg.validate()
assert ok is False
assert "片头" in err and "video_path" in err
def test_text_intro_without_title_invalid(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="",
outro_type="none",
)
ok, err = cfg.validate()
assert ok is False
assert "片头" in err and "title" in err
def test_video_outro_without_path_invalid(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="none",
outro_type="video",
outro_video_path="",
)
ok, err = cfg.validate()
assert ok is False
assert "片尾" in err and "video_path" in err
def test_text_outro_without_title_invalid(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="none",
outro_type="text",
outro_title="",
)
ok, err = cfg.validate()
assert ok is False
assert "片尾" in err and "title" in err
def test_follow_outro_without_title_invalid(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="none",
outro_type="follow",
outro_title="",
)
ok, err = cfg.validate()
assert ok is False
assert "片尾" in err and "title" in err
def test_zero_intro_duration_invalid(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="Hi",
intro_duration=0,
outro_type="none",
)
ok, err = cfg.validate()
assert ok is False
assert "片头时长" in err
def test_negative_intro_duration_invalid(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="Hi",
)
object.__setattr__(cfg, "intro_duration", -1.0)
ok, err = cfg.validate()
assert ok is False
assert "片头时长" in err
def test_zero_outro_duration_invalid(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="none",
outro_type="text",
outro_title="Bye",
outro_duration=0,
)
ok, err = cfg.validate()
assert ok is False
assert "片尾时长" in err
def test_negative_transition_duration_invalid(self):
cfg = IntroOutroConfig(
enabled=True,
transition_duration=-0.5,
)
ok, err = cfg.validate()
assert ok is False
assert "转场" in err
def test_zero_title_size_invalid(self):
cfg = IntroOutroConfig(enabled=True)
object.__setattr__(cfg, "intro_title_size", 0)
ok, err = cfg.validate()
assert ok is False
assert "片头" in err and "字号" in err
def test_zero_subtitle_size_invalid(self):
cfg = IntroOutroConfig(enabled=True)
object.__setattr__(cfg, "outro_subtitle_size", 0)
ok, err = cfg.validate()
assert ok is False
assert "片尾" in err and "副标题字号" in err
def test_valid_video_intro_outro(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="video",
intro_video_path="/tmp/i.mp4",
intro_duration=2.0,
outro_type="video",
outro_video_path="/tmp/o.mp4",
outro_duration=3.0,
)
ok, err = cfg.validate()
assert ok is True, f"expected valid but got: {err}"
def test_valid_text_intro_outro(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="Hi",
intro_duration=2.0,
outro_type="text",
outro_title="Bye",
outro_duration=3.0,
)
ok, err = cfg.validate()
assert ok is True, f"expected valid but got: {err}"
def test_invalid_intro_type(self):
cfg = IntroOutroConfig(enabled=True, intro_type="invalid")
ok, err = cfg.validate()
assert ok is False
assert "片头类型" in err
def test_invalid_outro_type(self):
cfg = IntroOutroConfig(enabled=True, outro_type="invalid")
ok, err = cfg.validate()
assert ok is False
assert "片尾类型" in err