Compare commits

..

1 Commits

Author SHA1 Message Date
CI Bot 8f99774620 test(wave105): extract render_layer_utils domain module + 65 unit tests
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 22s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 37s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m10s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
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 30s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m13s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 3m36s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m56s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m20s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 24s
从 unified_render_service.py (1985→1947行) 抽出渲染图层纯逻辑工具:
- resolve_layer_role / get_layer_z_index: 图层角色解析
- clip_effective_duration / clip_playback_speed / clip_adjusted_duration: 时长计算
- estimate_total_duration: 总时长估算
- can_pass_through: 直通路径判断
- LAYER_Z_INDEX / MAIN_LAYER_ROLES 等常量

保留向后兼容: 模块级函数 + 类静态方法均委托到新模块
验证: 88 passed (23原有 + 65新增),全套render 373 passed
2026-07-26 22:19:12 +08:00
20 changed files with 1363 additions and 2636 deletions
@@ -206,3 +206,4 @@ class PlanGeneratorService:
委托给 plan_generator_utils.distribute_assets 纯函数。
"""
distribute_assets(clips, asset_ids, editing_mode)
@@ -21,14 +21,14 @@ from __future__ import annotations
import logging
from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX
from packages.domain.asset_scoring import MIN_QUALITY_SCORE as _MIN_QUALITY_SCORE
from packages.domain.asset_scoring import OPTIMAL_DURATION_MAX as _OPTIMAL_DURATION_MAX
from packages.domain.asset_scoring import OPTIMAL_DURATION_MIN as _OPTIMAL_DURATION_MIN
from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX
from packages.domain.asset_scoring import TARGET_HEIGHT as _TARGET_HEIGHT
from packages.domain.asset_scoring import TARGET_WIDTH as _TARGET_WIDTH
from packages.domain.asset_scoring import (
MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX,
MIN_QUALITY_SCORE as _MIN_QUALITY_SCORE,
OPTIMAL_DURATION_MAX as _OPTIMAL_DURATION_MAX,
OPTIMAL_DURATION_MIN as _OPTIMAL_DURATION_MIN,
SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX,
TARGET_HEIGHT as _TARGET_HEIGHT,
TARGET_WIDTH as _TARGET_WIDTH,
AssetScoreDetail,
SmartSelectResult,
diverse_selection,
@@ -2,12 +2,7 @@
* 混剪单图层配置区
*/
import React from "react"
import type {
PipLayer,
PipAnimType,
PipSlideDirection,
PipGridPosition,
} from "@/pages/editing-planner/types"
import type { PipLayer, PipAnimType, PipSlideDirection, PipGridPosition } from "@/pages/editing-planner/types"
import {
GRID_POSITIONS,
ANIM_OPTIONS,
@@ -52,6 +52,15 @@ from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_fr
from video_processing.tts_engine import TtsEngine
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
from packages.domain.render_layer_utils import (
LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX,
can_pass_through as _can_pass_through_pure,
clip_adjusted_duration as _clip_adjusted_duration_pure,
clip_effective_duration as _clip_effective_duration_pure,
clip_playback_speed as _clip_playback_speed_pure,
estimate_total_duration as _estimate_total_duration_pure,
resolve_layer_role as _resolve_layer_role_pure,
)
from packages.domain.tts_config import TtsConfig
logger = logging.getLogger(__name__)
@@ -107,47 +116,16 @@ class RenderResult:
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
"""根据 clip_type 和 config.role 确定图层角色。
"""根据 clip_type 和 config.role 确定图层角色(向后兼容别名)
映射规则:
intro / outro → "main"(按 order 排在首/尾)
overlay → "overlay"(画中画叠加,z=1
corner_voice → "corner_voice"(右上角小窗,z=1
background → "background"(全屏底图,z=0
b_roll → "broll"z=0
main + config.role=b_roll → "broll"
main (default) → "main"
实际实现移至 packages.domain.render_layer_utils.resolve_layer_role。
"""
role = config.get("role", "")
if clip_type in ("intro", "outro"):
return "main"
if clip_type == "overlay":
return "overlay"
if clip_type == "corner_voice":
return "corner_voice"
if clip_type == "background":
return "background"
if clip_type == "b_roll":
return "broll"
# main type
if role == "b_roll":
return "broll"
if role == "audio":
return "audio"
return "main"
return _resolve_layer_role_pure(clip_type, config)
# ── 图层默认 z_index ─────────────────────────────────────────────────────────
_LAYER_Z_INDEX: dict[str, int] = {
"background": -1,
"broll": 0,
"main": 0,
"overlay": 1,
"corner_voice": 1,
"audio": 2,
}
_LAYER_Z_INDEX: dict[str, int] = _IMPORTED_LAYER_Z_INDEX
# 图层默认 PiP 位置(相对输出画布的偏移)
_PIP_SCALE = 0.25 # PiP 占主画面的比例
@@ -489,29 +467,9 @@ class UnifiedRenderService:
def _estimate_total_duration(self, layers: list[RenderLayer]) -> float:
"""估算视频总时长(用于字幕等需要)。
取主图层(main/broll/background)的总时长,转场重叠按 transition_duration 估算
实际实现移至 packages.domain.render_layer_utils.estimate_total_duration。
"""
# 找主图层(第一个有视频内容的图层)
main_layer = None
for role in ("main", "broll", "background"):
for layer in layers:
if layer.role == role:
main_layer = layer
break
if main_layer:
break
if not main_layer or not main_layer.clips:
return 0.0
total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in main_layer.clips)
# 减去转场重叠时间(粗略估算)
n_clips = len(main_layer.clips)
if n_clips > 1:
total -= (n_clips - 1) * self.transition_duration
return max(0.1, total)
return _estimate_total_duration_pure(layers, self.transition_duration)
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
"""根据 plan.config 生成 ASS 字幕文件。
@@ -1869,10 +1827,11 @@ class UnifiedRenderService:
@staticmethod
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长(原速 trim 后时长)."""
if clip.duration > 0:
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
return clip.actual_duration if clip.actual_duration > 0 else 0.0
"""计算 clip 的有效时长(原速 trim 后时长)
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
"""
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
@@ -1969,17 +1928,20 @@ class UnifiedRenderService:
@staticmethod
def _clip_speed(clip: ResolvedClip) -> float:
"""获取 clip 的播放速度,无效值回退到 1.0."""
speed = getattr(clip, "playback_speed", 1.0)
if not isinstance(speed, (int, float)) or speed <= 0:
return 1.0
return float(speed)
"""获取 clip 的播放速度,无效值回退到 1.0
实际实现移至 packages.domain.render_layer_utils.clip_playback_speed。
"""
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
@staticmethod
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
"""计算调速后的 clip 实际时长(用于拼接计算)."""
base = UnifiedRenderService._clip_effective_duration(clip)
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) < 1e-6:
return base
return base / speed
"""计算调速后的 clip 实际时长(用于拼接计算)
实际实现移至 packages.domain.render_layer_utils.clip_adjusted_duration。
"""
return _clip_adjusted_duration_pure(
clip.duration,
clip.actual_duration,
getattr(clip, "playback_speed", 1.0),
)
+9 -11
View File
@@ -20,21 +20,19 @@ import time
from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import probe_duration
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from worker_app.tasks.generation_plan_builder import VirtualClip as _VirtualClip
from worker_app.tasks.generation_plan_builder import VirtualPlan as _VirtualPlan
from worker_app.tasks.generation_plan_builder import apply_template_clip_effects as _apply_template_clip_effects
from worker_app.tasks.generation_plan_builder import (
build_clips_by_mode,
)
from worker_app.tasks.generation_plan_builder import build_error_info as _build_error_info
from worker_app.tasks.generation_plan_builder import (
extract_intro_outro_from_clip_configs as _extract_intro_outro_from_clip_configs,
)
from packages.domain.bgm_utils import merge_bgm_config
from video_processing.ffmpeg_utils import probe_duration
from worker_app.tasks.generation_plan_builder import (
VirtualPlan as _VirtualPlan,
VirtualClip as _VirtualClip,
build_error_info as _build_error_info,
extract_intro_outro_from_clip_configs as _extract_intro_outro_from_clip_configs,
apply_template_clip_effects as _apply_template_clip_effects,
build_clips_by_mode,
)
OUTPUT_WIDTH = 1280
OUTPUT_HEIGHT = 720
+92 -8
View File
@@ -14,18 +14,70 @@ from packages.adapters.sqlalchemy_impl import (
SQLAlchemyIngestJobRepository,
)
from packages.domain import Asset, AssetStatus, IngestJobStatus
from packages.domain.media_validation import (
MIN_AUDIO_FILE_SIZE,
MIN_IMAGE_FILE_SIZE,
MIN_VIDEO_FILE_SIZE,
SUPPORTED_VIDEO_CODECS,
is_valid_media as _is_valid_media,
safe_parse_fps as _safe_parse_fps,
)
logger = get_task_logger(__name__)
# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体
MIN_VIDEO_FILE_SIZE = 1024 # 1KB
MIN_AUDIO_FILE_SIZE = 100 # 100B
MIN_IMAGE_FILE_SIZE = 100 # 100B
# 支持的视频编码格式(白名单,尽可能放宽)
# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested
SUPPORTED_VIDEO_CODECS = {
"h264",
"avc1",
"avc", # H.264 / AVC
"hevc",
"h265",
"hev1",
"hvc1", # H.265 / HEVC
"vp9",
"vp09", # VP9
"av1",
"av01", # AV1
"vp8",
"vp08", # VP8
"mpeg4",
"mp4v", # MPEG-4
"mpeg2video",
"mpg2", # MPEG-2
"wmv2",
"wmv1",
"vc1", # WMV / VC-1
"flv1",
"flv",
"vp6f", # Flash / FLV
"theora",
"ogg", # Theora
"prores",
"prores_ks",
"apcn",
"apch",
"apco",
"apcs",
"ap4h",
"ap4x", # Apple ProRes
"dnxhd",
"dnxhr", # DNxHD / DNxHR
}
def _safe_parse_fps(fps_str: str) -> float:
"""Safely parse fps from a fraction string like \"30/1\" or \"30000/1001\"."""
try:
if "/" in fps_str:
num, den = fps_str.split("/", 1)
den_val = float(den)
if den_val == 0:
return 0.0
return float(num) / den_val
return float(fps_str)
except (ValueError, ZeroDivisionError):
return 0.0
def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
"""
提取媒体文件的元数据。
@@ -155,6 +207,38 @@ def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
return metadata, success
def _is_valid_media(metadata: dict, media_type: str) -> bool:
"""根据元数据判断文件是否为有效媒体文件。
Args:
metadata: extract_media_metadata 返回的元数据
media_type: 媒体类型
Returns:
True 表示文件有效
"""
size = int(metadata.get("size_bytes", 0))
if media_type == "video":
duration = float(metadata.get("duration", 0))
if size < MIN_VIDEO_FILE_SIZE or duration <= 0:
return False
# 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许
# 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截
codec = str(metadata.get("codec", "")).lower()
if codec and codec not in SUPPORTED_VIDEO_CODECS:
logger.info("检测到非白名单视频编码 %s,仍允许 ingested,渲染层会统一转码", codec)
return True
if media_type == "audio":
duration = float(metadata.get("duration", 0))
return size >= MIN_AUDIO_FILE_SIZE and duration > 0
if media_type == "image":
width = int(metadata.get("width", 0))
height = int(metadata.get("height", 0))
return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0
return False
@celery_app.task(name="worker.ingest_asset")
def ingest_asset(job_id: str) -> dict:
"""
-110
View File
@@ -1,110 +0,0 @@
"""媒体文件有效性校验与元数据解析工具。
从 worker ingest 任务中抽取的纯逻辑模块,包含:
- FPS 解析:从分数格式字符串(如 30000/1001)安全解析帧率
- 媒体有效性校验:根据元数据判断视频/音频/图片文件是否有效
- 常量定义:最小文件大小、支持的视频编码白名单
"""
from __future__ import annotations
# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体
MIN_VIDEO_FILE_SIZE = 1024 # 1KB
MIN_AUDIO_FILE_SIZE = 100 # 100B
MIN_IMAGE_FILE_SIZE = 100 # 100B
# 支持的视频编码格式(白名单,尽可能放宽)
# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested
SUPPORTED_VIDEO_CODECS: frozenset[str] = frozenset(
{
"h264",
"avc1",
"avc", # H.264 / AVC
"hevc",
"h265",
"hev1",
"hvc1", # H.265 / HEVC
"vp9",
"vp09", # VP9
"av1",
"av01", # AV1
"vp8",
"vp08", # VP8
"mpeg4",
"mp4v", # MPEG-4
"mpeg2video",
"mpg2", # MPEG-2
"wmv2",
"wmv1",
"vc1", # WMV / VC-1
"flv1",
"flv",
"vp6f", # Flash / FLV
"theora",
"ogg", # Theora
"prores",
"prores_ks",
"apcn",
"apch",
"apco",
"apcs",
"ap4h",
"ap4x", # Apple ProRes
"dnxhd",
"dnxhr", # DNxHD / DNxHR
}
)
def safe_parse_fps(fps_str: str) -> float:
"""Safely parse fps from a fraction string like "30/1" or "30000/1001".
Args:
fps_str: FPS 字符串,支持小数格式("30.0")或分数格式("30000/1001"
Returns:
解析得到的帧率浮点数;解析失败或分母为0时返回 0.0
"""
try:
if "/" in fps_str:
num, den = fps_str.split("/", 1)
den_val = float(den)
if den_val == 0:
return 0.0
return float(num) / den_val
return float(fps_str)
except (ValueError, ZeroDivisionError):
return 0.0
def is_valid_media(metadata: dict, media_type: str) -> bool:
"""根据元数据判断文件是否为有效媒体文件。
Args:
metadata: 媒体元数据字典,可能包含 size_bytes / duration / codec / width / height 等
media_type: 媒体类型(video / audio / image
Returns:
True 表示文件有效
"""
size = int(metadata.get("size_bytes", 0))
if media_type == "video":
duration = float(metadata.get("duration", 0))
if size < MIN_VIDEO_FILE_SIZE or duration <= 0:
return False
# 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许
# 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截
codec = str(metadata.get("codec", "")).lower()
if codec and codec not in SUPPORTED_VIDEO_CODECS:
# 非白名单编码仍允许通过,仅记录日志(调用方负责日志)
pass
return True
if media_type == "audio":
duration = float(metadata.get("duration", 0))
return size >= MIN_AUDIO_FILE_SIZE and duration > 0
if media_type == "image":
width = int(metadata.get("width", 0))
height = int(metadata.get("height", 0))
return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0
return False
+5 -1
View File
@@ -321,7 +321,11 @@ def create_clips_from_configs(
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
# transition_effect 可能是枚举或字符串
transition = cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
transition = (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
)
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
clip_cfg = cfg.config or {}
+241
View File
@@ -0,0 +1,241 @@
"""渲染图层工具函数 — 纯函数集合.
从 unified_render_service.py 抽离的纯逻辑,负责:
- clip 时长计算(有效时长、调速后时长)
- clip_type → layer_role 映射
- 总时长估算
- 图层默认属性(z_index 等)
所有函数均为纯函数,不依赖 FFmpeg、数据库或外部 IO。
"""
from __future__ import annotations
from typing import Any
# ── 图层角色定义 ─────────────────────────────────────────────────────────────
# 图层默认 z_index 映射
LAYER_Z_INDEX: dict[str, int] = {
"background": -1,
"broll": 0,
"main": 0,
"overlay": 1,
"corner_voice": 1,
"audio": 2,
}
# 图层默认 PiP 缩放比例(相对于主画面)
PIP_DEFAULT_SCALE = 0.25
# 主视频图层角色(用于总时长计算、直通判断等)
MAIN_LAYER_ROLES = frozenset({"main", "broll", "background"})
# ── clip_type → layer_role 映射 ──────────────────────────────────────────────
def resolve_layer_role(clip_type: str, config: dict[str, Any] | None = None) -> str:
"""根据 clip_type 和 config.role 确定图层角色。
映射规则:
intro / outro → "main"(按 order 排在首/尾)
overlay → "overlay"(画中画叠加,z=1
corner_voice → "corner_voice"(右上角小窗,z=1
background → "background"(全屏底图,z=0
b_roll → "broll"z=0
main + config.role=b_roll → "broll"
main + config.role=audio → "audio"
main (default) → "main"
Args:
clip_type: 片段类型字符串
config: 片段配置字典(可选)
Returns:
图层角色字符串
"""
role = (config or {}).get("role", "") if config else ""
if clip_type in ("intro", "outro"):
return "main"
if clip_type == "overlay":
return "overlay"
if clip_type == "corner_voice":
return "corner_voice"
if clip_type == "background":
return "background"
if clip_type == "b_roll":
return "broll"
# main type
if role == "b_roll":
return "broll"
if role == "audio":
return "audio"
return "main"
def get_layer_z_index(role: str) -> int:
"""获取图层角色的默认 z_index。
Args:
role: 图层角色
Returns:
z_index 值,未知角色返回 0
"""
return LAYER_Z_INDEX.get(role, 0)
# ── clip 时长计算 ──────────────────────────────────────────────────────────
def clip_effective_duration(
duration: float,
actual_duration: float = 0.0,
) -> float:
"""计算 clip 的有效时长(原速 trim 后时长)。
规则:
- duration > 0: min(duration, actual_duration)actual=0 时用 duration
- duration <= 0: actual_durationactual=0 时返回 0
Args:
duration: 配置的时长(0 表示使用完整素材)
actual_duration: 素材实际时长(probe 后的结果)
Returns:
有效时长(秒)
"""
if duration > 0:
return min(duration, actual_duration) if actual_duration > 0 else duration
return actual_duration if actual_duration > 0 else 0.0
def clip_playback_speed(playback_speed: Any) -> float:
"""获取 clip 的播放速度,无效值回退到 1.0。
Args:
playback_speed: 播放速度(可为任意类型
Returns:
有效的播放速度(正数)
"""
if not isinstance(playback_speed, (int, float)):
return 1.0
if playback_speed <= 0:
return 1.0
return float(playback_speed)
def clip_adjusted_duration(
duration: float,
actual_duration: float = 0.0,
playback_speed: Any = 1.0,
) -> float:
"""计算调速后的 clip 实际时长(用于拼接计算)。
Args:
duration: 配置的时长
actual_duration: 素材实际时长
playback_speed: 播放速度
Returns:
调速后的时长
"""
base = clip_effective_duration(duration, actual_duration)
speed = clip_playback_speed(playback_speed)
if abs(speed - 1.0) < 1e-6:
return base
return base / speed
# ── 总时长估算 ────────────────────────────────────────────────────────────
def estimate_total_duration(
layers: list[Any],
transition_duration: float = 0.0,
) -> float:
"""估算视频总时长。
取主图层(main/broll/background)的总调整后时长,减去转场重叠时间。
Args:
layers: 图层列表(每个元素需有 role 和 clips 属性,
clips 中元素需有 duration/actual_duration/playback_speed 属性)
transition_duration: 转场时长(秒),用于估算重叠时间
Returns:
估算的总时长(秒),最小 0.1
"""
# 找主图层(第一个有视频内容的图层)
main_layer = None
for role in ("main", "broll", "background"):
for layer in layers:
if getattr(layer, "role", None) == role and getattr(layer, "clips", None):
main_layer = layer
break
if main_layer:
break
if not main_layer or not getattr(main_layer, "clips", None):
return 0.0
clips = getattr(main_layer, "clips", [])
total = sum(
clip_adjusted_duration(
duration=getattr(c, "duration", 0),
actual_duration=getattr(c, "actual_duration", 0.0),
playback_speed=getattr(c, "playback_speed", 1.0),
)
for c in clips
)
# 减去转场重叠时间(粗略估算)
n_clips = len(clips)
if n_clips > 1 and transition_duration > 0:
total -= (n_clips - 1) * transition_duration
return max(0.1, total)
# ── 直通 / Stream Copy 判断辅助 ──────────────────────────────────────────────
def can_pass_through(
layers: list[Any],
has_stickers: bool = False,
has_watermark: bool = False,
) -> bool:
"""判断是否可以走直通优化路径(单 clip 简单场景)。
条件:
1. 只有 1 个图层
2. 该图层是视频图层(main/broll/background
3. 该图层只有 1 个 clip(无转场需求)
4. 没有贴纸
5. 没有水印
Args:
layers: 图层列表
has_stickers: 是否有贴纸
has_watermark: 是否有水印
Returns:
是否可以走直通
"""
if len(layers) != 1:
return False
layer = layers[0]
role = getattr(layer, "role", "")
if role not in MAIN_LAYER_ROLES:
return False
clips = getattr(layer, "clips", [])
if len(clips) != 1:
return False
if has_stickers:
return False
if has_watermark:
return False
return True
-499
View File
@@ -1,499 +0,0 @@
"""Application 层零测试模块合集 — 第100波里程碑。
覆盖:
- packages/application/generated_videos.py (8个UseCase)
- packages/application/assets.py (ListAssets + CreateAsset)
- packages/application/asset_libraries.py (ListLibraries + CreateLibrary)
策略: Mock repository,测参数校验 + 委托行为
"""
from unittest.mock import MagicMock
import pytest
from packages.application.asset_libraries import (
CreateAssetLibraryCommand,
CreateAssetLibraryUseCase,
ListAssetLibrariesUseCase,
)
from packages.application.assets import (
CreateAssetCommand,
CreateAssetUseCase,
ListAssetsUseCase,
)
from packages.application.generated_videos import (
GetGeneratedVideoDownloadUrlUseCase,
GetGeneratedVideoUseCase,
GetVideosByIdsUseCase,
ListGeneratedVideosByTaskUseCase,
ListGeneratedVideosPaginatedUseCase,
ListGeneratedVideosUseCase,
UpdateVideoReviewStatusUseCase,
)
from packages.domain import AssetLibraryKind, AssetStatus, ClassificationStatus, GeneratedVideo
# ── generated_videos.py ────────────────────────────────────────────────────────
class TestListGeneratedVideosUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.list_by_project.return_value = [MagicMock(spec=GeneratedVideo)]
use_case = ListGeneratedVideosUseCase(mock_repo)
result = use_case.execute("proj1")
assert len(result) == 1
mock_repo.list_by_project.assert_called_once_with("proj1")
def test_strips_project_id(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosUseCase(mock_repo)
use_case.execute(" proj1 ")
mock_repo.list_by_project.assert_called_once_with("proj1")
def test_empty_project_id_raises(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosUseCase(mock_repo)
with pytest.raises(ValueError, match="project_id 不能为空"):
use_case.execute("")
def test_whitespace_project_id_raises(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosUseCase(mock_repo)
with pytest.raises(ValueError, match="project_id 不能为空"):
use_case.execute(" \t ")
class TestListGeneratedVideosPaginatedUseCase:
def test_default_params(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
result, total = use_case.execute()
assert total == 0
assert result == []
mock_repo.list_paginated.assert_called_once_with(
user_id=None,
project_id=None,
status=None,
review_status=None,
page=1,
page_size=20,
)
def test_page_below_1_clamps_to_1(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page=0)
mock_repo.list_paginated.assert_called_once()
call_kwargs = mock_repo.list_paginated.call_args.kwargs
assert call_kwargs["page"] == 1
def test_negative_page_clamps(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page=-5)
assert mock_repo.list_paginated.call_args.kwargs["page"] == 1
def test_page_size_zero_clamps(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page_size=0)
assert mock_repo.list_paginated.call_args.kwargs["page_size"] == 20
def test_page_size_over_100_clamps(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page_size=200)
assert mock_repo.list_paginated.call_args.kwargs["page_size"] == 20
def test_page_size_50_ok(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page_size=50)
assert mock_repo.list_paginated.call_args.kwargs["page_size"] == 50
def test_with_all_filters(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(
user_id="u1",
project_id="p1",
status="completed",
review_status="approved",
page=2,
page_size=10,
)
mock_repo.list_paginated.assert_called_once_with(
user_id="u1",
project_id="p1",
status="completed",
review_status="approved",
page=2,
page_size=10,
)
class TestGetGeneratedVideoUseCase:
def test_found(self):
mock_repo = MagicMock()
expected = MagicMock(spec=GeneratedVideo)
mock_repo.get.return_value = expected
use_case = GetGeneratedVideoUseCase(mock_repo)
result = use_case.execute("vid1")
assert result == expected
mock_repo.get.assert_called_once_with("vid1")
def test_not_found(self):
mock_repo = MagicMock()
mock_repo.get.return_value = None
use_case = GetGeneratedVideoUseCase(mock_repo)
result = use_case.execute("vid1")
assert result is None
class TestListGeneratedVideosByTaskUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.list_by_generation_task.return_value = [MagicMock()]
use_case = ListGeneratedVideosByTaskUseCase(mock_repo)
result = use_case.execute("task1")
assert len(result) == 1
mock_repo.list_by_generation_task.assert_called_once_with("task1")
def test_strips_task_id(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosByTaskUseCase(mock_repo)
use_case.execute(" task1 ")
mock_repo.list_by_generation_task.assert_called_once_with("task1")
def test_empty_task_id_raises(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosByTaskUseCase(mock_repo)
with pytest.raises(ValueError, match="generation_task_id 不能为空"):
use_case.execute("")
class TestGetGeneratedVideoDownloadUrlUseCase:
def test_found(self):
mock_repo = MagicMock()
mock_item = MagicMock()
mock_item.file_url = "https://cdn/v.mp4"
mock_repo.get.return_value = mock_item
use_case = GetGeneratedVideoDownloadUrlUseCase(mock_repo)
result = use_case.execute("vid1")
assert result == "https://cdn/v.mp4"
def test_not_found_returns_none(self):
mock_repo = MagicMock()
mock_repo.get.return_value = None
use_case = GetGeneratedVideoDownloadUrlUseCase(mock_repo)
result = use_case.execute("vid1")
assert result is None
class TestUpdateVideoReviewStatusUseCase:
def test_pending_review(self):
mock_repo = MagicMock()
mock_repo.update_review_status.return_value = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
use_case.execute("vid1", "pending_review")
mock_repo.update_review_status.assert_called_once_with("vid1", "pending_review")
def test_approved(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
use_case.execute("vid1", "approved")
mock_repo.update_review_status.assert_called_once_with("vid1", "approved")
def test_rejected(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
use_case.execute("vid1", "rejected")
mock_repo.update_review_status.assert_called_once_with("vid1", "rejected")
def test_strips_video_id(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
use_case.execute(" vid1 ", "approved")
mock_repo.update_review_status.assert_called_once_with("vid1", "approved")
def test_empty_video_id_raises(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
with pytest.raises(ValueError, match="video_id 不能为空"):
use_case.execute("", "approved")
def test_invalid_status_raises(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
with pytest.raises(ValueError, match="无效的 review_status"):
use_case.execute("vid1", "invalid_status")
def test_not_found_returns_none(self):
mock_repo = MagicMock()
mock_repo.update_review_status.return_value = None
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
result = use_case.execute("vid1", "approved")
assert result is None
class TestGetVideosByIdsUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.get_by_ids.return_value = [MagicMock(), MagicMock()]
use_case = GetVideosByIdsUseCase(mock_repo)
result = use_case.execute(["id1", "id2", "id3"])
assert len(result) == 2
mock_repo.get_by_ids.assert_called_once_with(["id1", "id2", "id3"])
def test_empty_list(self):
mock_repo = MagicMock()
mock_repo.get_by_ids.return_value = []
use_case = GetVideosByIdsUseCase(mock_repo)
result = use_case.execute([])
assert result == []
mock_repo.get_by_ids.assert_called_once_with([])
# ── assets.py ──────────────────────────────────────────────────────────────────
class TestCreateAssetCommand:
def test_minimal(self):
cmd = CreateAssetCommand(
project_id="p1",
library_id="l1",
name="test.mp4",
storage_key="k",
mime_type="video/mp4",
)
assert cmd.project_id == "p1"
assert cmd.library_id == "l1"
assert cmd.name == "test.mp4"
assert cmd.storage_key == "k"
assert cmd.mime_type == "video/mp4"
assert cmd.file_size == 0
assert cmd.status == AssetStatus.UPLOADING
assert cmd.classification_status == ClassificationStatus.PENDING
def test_full(self):
cmd = CreateAssetCommand(
project_id="p1",
library_id="l1",
name="test.mp4",
storage_key="k",
mime_type="video/mp4",
metadata={"k": "v"},
file_size=1024,
duration=10.0,
width=1920,
height=1080,
fps=30.0,
codec="h264",
status=AssetStatus.READY,
quality_score=0.9,
uploaded_by_user_id="u1",
)
assert cmd.file_size == 1024
assert cmd.duration == 10.0
assert cmd.status == AssetStatus.READY
assert cmd.quality_score == 0.9
class TestListAssetsUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.find_by_library.return_value = []
use_case = ListAssetsUseCase(mock_repo)
result = use_case.execute("lib1")
assert result == []
mock_repo.find_by_library.assert_called_once_with("lib1")
def test_strips_library_id(self):
mock_repo = MagicMock()
use_case = ListAssetsUseCase(mock_repo)
use_case.execute(" lib1 ")
mock_repo.find_by_library.assert_called_once_with("lib1")
def test_empty_library_id_raises(self):
mock_repo = MagicMock()
use_case = ListAssetsUseCase(mock_repo)
with pytest.raises(ValueError, match="library_id 不能为空"):
use_case.execute("")
class TestCreateAssetUseCase:
def test_creates_asset_via_repo(self):
mock_repo = MagicMock()
mock_repo.create.return_value = MagicMock()
use_case = CreateAssetUseCase(mock_repo)
cmd = CreateAssetCommand(
project_id="p1",
library_id="l1",
name="test.mp4",
storage_key="videos/t.mp4",
mime_type="video/mp4",
file_size=1024,
)
result = use_case.execute(cmd)
assert result is not None
mock_repo.create.assert_called_once()
created_asset = mock_repo.create.call_args[0][0]
assert created_asset.project_id == "p1"
assert created_asset.name == "test.mp4"
assert created_asset.file_size == 1024
assert created_asset.status == AssetStatus.UPLOADING
def test_asset_create_validation_propagates(self):
mock_repo = MagicMock()
use_case = CreateAssetUseCase(mock_repo)
cmd = CreateAssetCommand(
project_id="p1",
library_id="l1",
name="",
storage_key="k",
mime_type="video/mp4",
)
with pytest.raises(ValueError, match="素材名称不能为空"):
use_case.execute(cmd)
# ── asset_libraries.py ────────────────────────────────────────────────────────
class TestCreateAssetLibraryCommand:
def test_creation(self):
cmd = CreateAssetLibraryCommand(
project_id="p1",
name="我的库",
kind=AssetLibraryKind.VIDEO,
)
assert cmd.project_id == "p1"
assert cmd.name == "我的库"
assert cmd.kind == AssetLibraryKind.VIDEO
class TestListAssetLibrariesUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.find_by_project.return_value = []
use_case = ListAssetLibrariesUseCase(mock_repo)
result = use_case.execute("p1")
assert result == []
mock_repo.find_by_project.assert_called_once_with("p1")
def test_strips_project_id(self):
mock_repo = MagicMock()
use_case = ListAssetLibrariesUseCase(mock_repo)
use_case.execute(" p1 ")
mock_repo.find_by_project.assert_called_once_with("p1")
def test_empty_project_id_raises(self):
mock_repo = MagicMock()
use_case = ListAssetLibrariesUseCase(mock_repo)
with pytest.raises(ValueError, match="project_id 不能为空"):
use_case.execute("")
class TestCreateAssetLibraryUseCase:
def test_creates_library_via_repo(self):
mock_repo = MagicMock()
mock_repo.create.return_value = MagicMock()
use_case = CreateAssetLibraryUseCase(mock_repo)
cmd = CreateAssetLibraryCommand(
project_id="p1",
name="视频库",
kind=AssetLibraryKind.VIDEO,
)
result = use_case.execute(cmd)
assert result is not None
mock_repo.create.assert_called_once()
created = mock_repo.create.call_args[0][0]
assert created.project_id == "p1"
assert created.name == "视频库"
assert created.kind == AssetLibraryKind.VIDEO
def test_validation_propagates(self):
mock_repo = MagicMock()
use_case = CreateAssetLibraryUseCase(mock_repo)
cmd = CreateAssetLibraryCommand(
project_id="p1",
name="",
kind=AssetLibraryKind.VIDEO,
)
with pytest.raises(ValueError, match="素材库名称不能为空"):
use_case.execute(cmd)
+2 -2
View File
@@ -19,19 +19,19 @@ from typing import Optional
import pytest
from packages.domain.asset_scoring import (
AssetScoreDetail,
MEDIUM_BUCKET_MAX,
MIN_QUALITY_SCORE,
OPTIMAL_DURATION_MAX,
OPTIMAL_DURATION_MIN,
SHORT_BUCKET_MAX,
SmartSelectResult,
TARGET_HEIGHT,
TARGET_WIDTH,
WEIGHT_BITRATE,
WEIGHT_DURATION,
WEIGHT_QUALITY,
WEIGHT_RESOLUTION,
AssetScoreDetail,
SmartSelectResult,
_bucket_by_duration,
calculate_total_score,
diverse_selection,
-488
View File
@@ -1,488 +0,0 @@
"""Domain entities 单元测试。"""
from datetime import datetime, timezone
import pytest
from packages.domain.classification import (
AssetLibraryKind,
ClassificationStatus,
IngestJobStatus,
)
from packages.domain.entities import (
Asset,
AssetLibrary,
AssetStatus,
IngestJob,
Project,
User,
)
class TestProjectCreate:
def test_create_success(self):
project = Project.create(owner_user_id="user1", name="我的项目")
assert project.id is not None
assert len(project.id) == 32
assert project.owner_user_id == "user1"
assert project.name == "我的项目"
assert project.description == ""
assert project.shared_users == []
assert isinstance(project.created_at, datetime)
def test_create_with_description(self):
project = Project.create("u1", "Test Project", "A test description")
assert project.description == "A test description"
def test_create_strips_name(self):
project = Project.create("u1", " 带空格的项目 ")
assert project.name == "带空格的项目"
def test_create_strips_description(self):
project = Project.create("u1", "P1", " desc ")
assert project.description == "desc"
def test_create_empty_name(self):
with pytest.raises(ValueError, match="项目名称不能为空"):
Project.create("u1", "")
def test_create_whitespace_name(self):
with pytest.raises(ValueError, match="项目名称不能为空"):
Project.create("u1", " \t ")
def test_create_unique_ids(self):
p1 = Project.create("u1", "P1")
p2 = Project.create("u1", "P2")
assert p1.id != p2.id
class TestProjectAccess:
def test_is_owner_true(self):
project = Project.create("owner1", "P1")
assert project.is_owner("owner1") is True
def test_is_owner_false(self):
project = Project.create("owner1", "P1")
assert project.is_owner("other") is False
def test_is_shared_with_true(self):
project = Project.create("owner1", "P1")
project.shared_users = ["user_a", "user_b"]
assert project.is_shared_with("user_a") is True
assert project.is_shared_with("user_b") is True
def test_is_shared_with_false(self):
project = Project.create("owner1", "P1")
project.shared_users = ["user_a"]
assert project.is_shared_with("user_c") is False
def test_can_access_owner(self):
project = Project.create("owner1", "P1")
assert project.can_access("owner1") is True
def test_can_access_shared_user(self):
project = Project.create("owner1", "P1")
project.shared_users = ["shared_user"]
assert project.can_access("shared_user") is True
def test_cannot_access_other(self):
project = Project.create("owner1", "P1")
assert project.can_access("stranger") is False
def test_empty_shared_users(self):
project = Project.create("owner1", "P1")
assert project.shared_users == []
assert project.is_shared_with("anyone") is False
class TestAssetLibraryCreate:
def test_create_video_library(self):
lib = AssetLibrary.create("proj1", "视频素材库", AssetLibraryKind.VIDEO)
assert lib.id is not None
assert len(lib.id) == 32
assert lib.project_id == "proj1"
assert lib.name == "视频素材库"
assert lib.kind == AssetLibraryKind.VIDEO
assert lib.asset_count == 0
assert lib.total_size == 0
def test_create_voice_library(self):
lib = AssetLibrary.create("proj1", "音乐库", AssetLibraryKind.VOICE)
assert lib.kind == AssetLibraryKind.VOICE
def test_create_image_library(self):
lib = AssetLibrary.create("proj1", "图片库", AssetLibraryKind.IMAGE)
assert lib.kind == AssetLibraryKind.IMAGE
def test_create_strips_name(self):
lib = AssetLibrary.create("p1", " 我的库 ", AssetLibraryKind.VIDEO)
assert lib.name == "我的库"
def test_create_empty_name(self):
with pytest.raises(ValueError, match="素材库名称不能为空"):
AssetLibrary.create("p1", "", AssetLibraryKind.VIDEO)
def test_create_whitespace_name(self):
with pytest.raises(ValueError, match="素材库名称不能为空"):
AssetLibrary.create("p1", " \t ", AssetLibraryKind.VIDEO)
class TestAssetStatusEnum:
def test_basic_values(self):
assert AssetStatus.UPLOADING.value == "uploading"
assert AssetStatus.READY.value == "ready"
assert AssetStatus.PROCESSING.value == "processing"
assert AssetStatus.ERROR.value == "error"
assert AssetStatus.DELETED.value == "deleted"
def test_missing_uploaded_maps_to_ready(self):
assert AssetStatus("uploaded") == AssetStatus.READY
def test_missing_success_maps_to_ready(self):
assert AssetStatus("success") == AssetStatus.READY
def test_missing_ok_maps_to_ready(self):
assert AssetStatus("ok") == AssetStatus.READY
def test_missing_done_maps_to_ready(self):
assert AssetStatus("done") == AssetStatus.READY
def test_missing_complete_maps_to_ready(self):
assert AssetStatus("complete") == AssetStatus.READY
def test_missing_upload_maps_to_uploading(self):
assert AssetStatus("upload") == AssetStatus.UPLOADING
def test_missing_uploading_start_maps_to_uploading(self):
assert AssetStatus("uploading_start") == AssetStatus.UPLOADING
def test_missing_upload_start_maps_to_uploading(self):
assert AssetStatus("upload_start") == AssetStatus.UPLOADING
def test_missing_failed_maps_to_error(self):
assert AssetStatus("failed") == AssetStatus.ERROR
def test_missing_fail_maps_to_error(self):
assert AssetStatus("fail") == AssetStatus.ERROR
def test_missing_err_maps_to_error(self):
assert AssetStatus("err") == AssetStatus.ERROR
def test_missing_process_maps_to_processing(self):
assert AssetStatus("process") == AssetStatus.PROCESSING
def test_missing_running_maps_to_processing(self):
assert AssetStatus("running") == AssetStatus.PROCESSING
def test_missing_run_maps_to_processing(self):
assert AssetStatus("run") == AssetStatus.PROCESSING
def test_missing_unknown_value_falls_back_to_ready(self):
assert AssetStatus("completely_unknown_status") == AssetStatus.READY
def test_missing_empty_string_falls_back_to_ready(self):
assert AssetStatus("") == AssetStatus.READY
def test_missing_case_insensitive(self):
assert AssetStatus("UPLOADED") == AssetStatus.READY
assert AssetStatus("Success") == AssetStatus.READY
assert AssetStatus("FAILED") == AssetStatus.ERROR
def test_missing_with_whitespace(self):
assert AssetStatus(" uploaded ") == AssetStatus.READY
assert AssetStatus("\tfailed\n") == AssetStatus.ERROR
def test_missing_non_string_value(self):
assert AssetStatus(None) == AssetStatus.READY
assert AssetStatus(123) == AssetStatus.READY
def test_known_values_still_work(self):
assert AssetStatus("uploading") == AssetStatus.UPLOADING
assert AssetStatus("ready") == AssetStatus.READY
assert AssetStatus("processing") == AssetStatus.PROCESSING
assert AssetStatus("error") == AssetStatus.ERROR
assert AssetStatus("deleted") == AssetStatus.DELETED
class TestAssetCreate:
def test_create_minimal(self):
asset = Asset.create(
project_id="proj1",
library_id="lib1",
name="test.mp4",
storage_key="videos/test.mp4",
mime_type="video/mp4",
)
assert asset.id is not None
assert len(asset.id) == 32
assert asset.project_id == "proj1"
assert asset.library_id == "lib1"
assert asset.name == "test.mp4"
assert asset.storage_key == "videos/test.mp4"
assert asset.mime_type == "video/mp4"
assert asset.file_size == 0
assert asset.thumbnail_url is None
assert asset.duration is None
assert asset.width is None
assert asset.height is None
assert asset.status == AssetStatus.UPLOADING
assert asset.classification_status == ClassificationStatus.PENDING
assert asset.quality_score is None
assert asset.tag_ids == []
assert isinstance(asset.created_at, datetime)
assert isinstance(asset.updated_at, datetime)
def test_create_with_all_fields(self):
asset = Asset.create(
project_id="proj1",
library_id="lib1",
name="movie.mp4",
storage_key="v/m.mp4",
mime_type="video/mp4",
metadata={"key": "val"},
file_size=1024000,
thumbnail_url="http://cdn/thumb.jpg",
duration=120.5,
width=1920,
height=1080,
fps=30.0,
codec="h264",
status=AssetStatus.READY,
classification_status=ClassificationStatus.COMPLETED,
quality_score=0.85,
uploaded_by_user_id="user1",
file_hash="abc123",
)
assert asset.file_size == 1024000
assert asset.thumbnail_url == "http://cdn/thumb.jpg"
assert asset.duration == 120.5
assert asset.width == 1920
assert asset.height == 1080
assert asset.fps == 30.0
assert asset.codec == "h264"
assert asset.status == AssetStatus.READY
assert asset.classification_status == ClassificationStatus.COMPLETED
assert asset.quality_score == 0.85
assert asset.uploaded_by_user_id == "user1"
assert asset.file_hash == "abc123"
assert asset.metadata == {"key": "val"}
def test_create_strips_name(self):
asset = Asset.create("p1", "l1", " test.mp4 ", "k", "video/mp4")
assert asset.name == "test.mp4"
def test_create_strips_storage_key(self):
asset = Asset.create("p1", "l1", "n", " key.mp4 ", "video/mp4")
assert asset.storage_key == "key.mp4"
def test_create_strips_mime_type(self):
asset = Asset.create("p1", "l1", "n", "k", " video/mp4 ")
assert asset.mime_type == "video/mp4"
def test_create_empty_name(self):
with pytest.raises(ValueError, match="素材名称不能为空"):
Asset.create("p1", "l1", "", "k", "video/mp4")
def test_create_empty_storage_key(self):
with pytest.raises(ValueError, match="storage_key 不能为空"):
Asset.create("p1", "l1", "n", "", "video/mp4")
def test_create_empty_mime_type(self):
with pytest.raises(ValueError, match="mime_type 不能为空"):
Asset.create("p1", "l1", "n", "k", "")
def test_create_whitespace_storage_key(self):
with pytest.raises(ValueError, match="storage_key 不能为空"):
Asset.create("p1", "l1", "n", " \t ", "video/mp4")
def test_create_none_metadata_defaults_to_empty_dict(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4", metadata=None)
assert asset.metadata == {}
def test_create_unique_ids(self):
a1 = Asset.create("p1", "l1", "n1", "k1", "video/mp4")
a2 = Asset.create("p1", "l1", "n2", "k2", "video/mp4")
assert a1.id != a2.id
class TestAssetFileType:
def test_video_mime(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
assert asset.file_type == "video"
def test_audio_mime(self):
asset = Asset.create("p1", "l1", "n", "k", "audio/mpeg")
assert asset.file_type == "audio"
def test_image_mime(self):
asset = Asset.create("p1", "l1", "n", "k", "image/jpeg")
assert asset.file_type == "image"
def test_simple_mime_no_slash(self):
asset = Asset.create("p1", "l1", "n", "k", "application")
assert asset.file_type == "application"
class TestAssetTags:
def test_add_tag(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("tag1")
assert "tag1" in asset.tag_ids
assert len(asset.tag_ids) == 1
def test_add_tag_strips(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag(" tag_trim ")
assert "tag_trim" in asset.tag_ids
def test_add_tag_duplicate_prevented(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("tag1")
asset.add_tag("tag1")
assert asset.tag_ids.count("tag1") == 1
assert len(asset.tag_ids) == 1
def test_add_tag_empty(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag("")
def test_add_tag_whitespace(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag(" \t ")
def test_add_multiple_tags(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
asset.add_tag("t2")
asset.add_tag("t3")
assert asset.tag_ids == ["t1", "t2", "t3"]
def test_remove_tag(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
asset.add_tag("t2")
asset.remove_tag("t1")
assert asset.tag_ids == ["t2"]
def test_remove_nonexistent_tag_idempotent(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
# 删除不存在的标签不报错
asset.remove_tag("nonexistent")
assert asset.tag_ids == ["t1"]
def test_remove_tag_strips(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
asset.remove_tag(" t1 ")
assert asset.tag_ids == []
def test_add_tag_updates_updated_at(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
old_time = asset.updated_at
asset.add_tag("t1")
assert asset.updated_at >= old_time
def test_remove_tag_updates_updated_at(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
old_time = asset.updated_at
asset.remove_tag("t1")
assert asset.updated_at >= old_time
class TestIngestJobCreate:
def test_create_success(self):
job = IngestJob.create(
project_id="proj1",
library_id="lib1",
storage_key="videos/test.mp4",
)
assert job.id is not None
assert len(job.id) == 32
assert job.project_id == "proj1"
assert job.library_id == "lib1"
assert job.storage_key == "videos/test.mp4"
assert job.status == IngestJobStatus.PENDING
assert job.error_message == ""
assert job.result_asset_id == ""
assert job.file_hash == ""
def test_create_with_hash(self):
job = IngestJob.create("p1", "l1", "k", file_hash="abcdef123456")
assert job.file_hash == "abcdef123456"
def test_create_strips_project_id(self):
job = IngestJob.create(" p1 ", "l1", "k")
assert job.project_id == "p1"
def test_create_strips_library_id(self):
job = IngestJob.create("p1", " l1 ", "k")
assert job.library_id == "l1"
def test_create_strips_storage_key(self):
job = IngestJob.create("p1", "l1", " k ")
assert job.storage_key == "k"
def test_create_strips_file_hash(self):
job = IngestJob.create("p1", "l1", "k", file_hash=" hash ")
assert job.file_hash == "hash"
def test_create_empty_project_id(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
IngestJob.create("", "l1", "k")
def test_create_empty_library_id(self):
with pytest.raises(ValueError, match="library_id 不能为空"):
IngestJob.create("p1", "", "k")
def test_create_empty_storage_key(self):
with pytest.raises(ValueError, match="storage_key 不能为空"):
IngestJob.create("p1", "l1", "")
def test_create_whitespace_project_id(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
IngestJob.create(" \t ", "l1", "k")
def test_create_unique_ids(self):
j1 = IngestJob.create("p1", "l1", "k1")
j2 = IngestJob.create("p1", "l1", "k2")
assert j1.id != j2.id
class TestUserDataclass:
def test_default_values(self):
user = User(id="u1", email="test@example.com", display_name="Test User")
assert user.id == "u1"
assert user.email == "test@example.com"
assert user.display_name == "Test User"
assert user.username == ""
assert user.password_hash == ""
assert user.email_verified is False
assert user.subscription_plan == "free"
assert user.subscription_status == "active"
assert user.max_projects == 3
assert user.max_storage_gb == 10
assert user.used_storage_gb == 0.0
assert user.is_admin is False
assert user.wechat_openid is None
assert user.phone is None
assert user.phone_verified is False
assert isinstance(user.created_at, datetime)
def test_admin_user(self):
user = User(id="admin", email="admin@test.com", display_name="Admin", is_admin=True)
assert user.is_admin is True
def test_pro_subscription(self):
user = User(
id="u1",
email="u@t.com",
display_name="U",
subscription_plan="pro",
max_storage_gb=100,
)
assert user.subscription_plan == "pro"
assert user.max_storage_gb == 100
-391
View File
@@ -1,391 +0,0 @@
"""Domain 小模块合集单元测试。
覆盖零测试的小 domain 模块:
- EditingMode 枚举
- Template / TemplateSegment
- TemplateClipConfig + ClipType + TransitionEffect
- EditTemplateVersion
- VoiceLibraryItem
- TitleLibraryItem
- Recipe / RecipeItem
"""
from datetime import datetime, timezone
import pytest
from packages.domain.editing_mode import EditingMode
from packages.domain.recipe import RecipeItem
from packages.domain.template import TemplateSegment
from packages.domain.template_clip_config import (
ClipType,
TemplateClipConfig,
TransitionEffect,
)
from packages.domain.template_version import EditTemplateVersion
from packages.domain.title_library import TitleLibraryItem
from packages.domain.voice_library import VoiceLibraryItem
class TestEditingMode:
def test_all_modes_exist(self):
assert EditingMode.ONE_TAKE.value == "one_take"
assert EditingMode.PIP.value == "pip"
assert EditingMode.VOICE_OVER.value == "voice_over"
assert EditingMode.VOICE_PIP.value == "voice_pip"
def test_from_string(self):
assert EditingMode("one_take") == EditingMode.ONE_TAKE
assert EditingMode("voice_over") == EditingMode.VOICE_OVER
def test_invalid_mode_raises(self):
with pytest.raises(ValueError):
EditingMode("invalid_mode")
def test_is_str_enum(self):
# StrEnum 的值是字符串,可以直接比较
assert EditingMode.ONE_TAKE == "one_take"
class TestTemplateSegment:
def test_create_minimal(self):
seg = TemplateSegment(
id="seg1",
template_id="tpl1",
segment_order=1,
duration_min=5.0,
duration_max=10.0,
)
assert seg.id == "seg1"
assert seg.template_id == "tpl1"
assert seg.segment_order == 1
assert seg.duration_min == 5.0
assert seg.duration_max == 10.0
assert seg.material_type is None
assert isinstance(seg.created_at, datetime)
def test_create_with_material_type(self):
seg = TemplateSegment(
id="seg2",
template_id="tpl1",
segment_order=2,
duration_min=3.0,
duration_max=8.0,
material_type="人物",
)
assert seg.material_type == "人物"
class TestClipType:
def test_basic_types_exist(self):
assert hasattr(ClipType, "MAIN")
assert hasattr(ClipType, "INTRO")
assert hasattr(ClipType, "OUTRO")
assert hasattr(ClipType, "TRANSITION")
def test_values_are_strings(self):
for ct in ClipType:
assert isinstance(ct.value, str)
class TestTransitionEffect:
def test_effects_exist(self):
assert TransitionEffect.CUT.value == "cut"
assert TransitionEffect.FADE.value == "fade"
assert TransitionEffect.DISSOLVE.value == "dissolve"
# 至少有 5 种以上转场效果
assert len(list(TransitionEffect)) >= 5
class TestTemplateClipConfig:
def test_create_minimal(self):
config = TemplateClipConfig.create(
template_id="tpl1",
clip_type=ClipType.MAIN,
order=1,
min_duration=3.0,
max_duration=8.0,
)
assert config.id is not None
assert config.template_id == "tpl1"
assert config.clip_type == ClipType.MAIN
assert config.order == 1
assert config.min_duration == 3.0
assert config.max_duration == 8.0
def test_create_with_string_type(self):
config = TemplateClipConfig.create(
template_id="tpl1",
clip_type="intro",
order=0,
min_duration=2.0,
max_duration=5.0,
)
assert config.clip_type == ClipType.INTRO
def test_has_duration_range_true(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=3.0,
max_duration=8.0,
)
assert config.has_duration_range is True
def test_has_duration_range_false_when_both_zero(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
)
assert config.has_duration_range is False
def test_default_duration_midpoint(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=4.0,
max_duration=6.0,
)
assert config.default_duration == pytest.approx(5.0)
def test_default_duration_when_only_max(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
max_duration=5.0,
)
assert config.default_duration == 5.0
def test_create_negative_min_duration_raises(self):
with pytest.raises(ValueError, match="min_duration"):
TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=-1.0,
)
def test_create_min_greater_than_max_raises(self):
with pytest.raises(ValueError, match="min_duration.*max_duration"):
TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=10.0,
max_duration=5.0,
)
def test_create_empty_template_id_raises(self):
with pytest.raises(ValueError, match="template_id"):
TemplateClipConfig.create(
template_id="",
clip_type=ClipType.MAIN,
order=1,
)
def test_default_transition_is_cut(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
)
assert config.transition_effect == TransitionEffect.CUT
def test_custom_transition_effect(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
transition_effect="fade",
)
assert config.transition_effect == TransitionEffect.FADE
class TestEditTemplateVersion:
def test_create_minimal(self):
version = EditTemplateVersion.create(
template_id="tpl1",
version=1,
)
assert version.id is not None
assert len(version.id) == 32
assert version.template_id == "tpl1"
assert version.version == 1
assert version.config == {}
assert version.clip_configs == []
assert version.published_by == ""
assert version.change_note == ""
assert version.name == ""
assert version.editing_mode == "one_take"
assert isinstance(version.created_at, datetime)
def test_create_with_config_and_clip_configs(self):
version = EditTemplateVersion.create(
template_id="tpl1",
version=2,
config={"layout": "one_take"},
clip_configs=[{"clip_id": "c1", "type": "main"}],
published_by="user1",
change_note="添加了片头效果",
)
assert version.config == {"layout": "one_take"}
assert len(version.clip_configs) == 1
assert version.published_by == "user1"
assert version.change_note == "添加了片头效果"
def test_create_with_name_and_mode(self):
version = EditTemplateVersion.create(
template_id="t1",
version=1,
name="v1.0 正式版",
editing_mode="voice_over",
)
assert version.name == "v1.0 正式版"
assert version.editing_mode == "voice_over"
def test_create_unique_ids(self):
v1 = EditTemplateVersion.create("t1", 1)
v2 = EditTemplateVersion.create("t1", 2)
assert v1.id != v2.id
def test_none_config_defaults_to_empty_dict(self):
version = EditTemplateVersion.create("t1", 1, config=None)
assert version.config == {}
def test_none_clip_configs_defaults_to_empty_list(self):
version = EditTemplateVersion.create("t1", 1, clip_configs=None)
assert version.clip_configs == []
class TestVoiceLibraryItem:
def test_create_minimal(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="我的配音",
)
assert item.id == "v1"
assert item.user_id == "u1"
assert item.name == "我的配音"
assert item.text == ""
assert item.voice_provider == ""
assert item.duration == 0
assert item.status == "completed"
assert item.tags == []
assert item.project_id is None
assert isinstance(item.created_at, datetime)
def test_create_with_all_fields(self):
item = VoiceLibraryItem(
id="v2",
user_id="u1",
name="产品介绍",
text="欢迎来到我们的产品",
voice_provider="cosyvoice",
voice_id="voice_001",
voice_name="温柔女声",
audio_url="https://cdn/v2.mp3",
duration=30.5,
file_size=102400,
status="processing",
project_id="proj1",
tags=["产品", "介绍"],
)
assert item.text == "欢迎来到我们的产品"
assert item.voice_provider == "cosyvoice"
assert item.voice_id == "voice_001"
assert item.audio_url == "https://cdn/v2.mp3"
assert item.duration == 30.5
assert item.file_size == 102400
assert item.status == "processing"
assert item.project_id == "proj1"
assert item.tags == ["产品", "介绍"]
class TestTitleLibraryItem:
def test_create_minimal(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="爆款标题1",
text="这是一个爆款标题",
)
assert item.id == "t1"
assert item.user_id == "u1"
assert item.name == "爆款标题1"
assert item.text == "这是一个爆款标题"
assert item.category == "default"
assert item.description == ""
assert item.tags == []
assert item.usage_count == 0
assert item.is_active is True
def test_create_with_category(self):
item = TitleLibraryItem(
id="t2",
user_id="u1",
name="美食标题",
text="太好吃了!",
category="美食",
)
assert item.category == "美食"
def test_inactive_item(self):
item = TitleLibraryItem(
id="t3",
user_id="u1",
name="旧标题",
text="旧文案",
is_active=False,
)
assert item.is_active is False
def test_usage_count_increment(self):
item = TitleLibraryItem(
id="t4",
user_id="u1",
name="T",
text="T",
)
item.usage_count += 1
assert item.usage_count == 1
class TestRecipeItem:
def test_create_minimal(self):
item = RecipeItem(
id="ri1",
recipe_id="r1",
item_type="asset",
item_id="asset_001",
)
assert item.id == "ri1"
assert item.recipe_id == "r1"
assert item.item_type == "asset"
assert item.item_id == "asset_001"
assert item.position == 0
assert item.metadata_ == {}
def test_create_with_position_and_metadata(self):
item = RecipeItem(
id="ri2",
recipe_id="r1",
item_type="title",
item_id="title_001",
position=2,
metadata_={"style": "bold"},
)
assert item.position == 2
assert item.metadata_ == {"style": "bold"}
def test_item_types_variety(self):
asset_item = RecipeItem(id="a", recipe_id="r", item_type="asset", item_id="i1")
title_item = RecipeItem(id="t", recipe_id="r", item_type="title", item_id="i2")
voice_item = RecipeItem(id="v", recipe_id="r", item_type="voice", item_id="i3")
assert asset_item.item_type == "asset"
assert title_item.item_type == "title"
assert voice_item.item_type == "voice"
@@ -15,6 +15,7 @@ from typing import Any
from unittest.mock import patch
import pytest
from worker_app.tasks.generation_plan_builder import (
VirtualClip,
VirtualPlan,
+293 -308
View File
@@ -1,6 +1,4 @@
"""Job 领域模型单元测试"""
from datetime import datetime, timezone
"""Job 领域单元测试 - job.py"""
import pytest
@@ -12,45 +10,45 @@ from packages.domain.job import (
)
class TestJobTypeEnum:
def test_all_types_exist(self):
assert JobType.VIDEO_COMPOSE.value == "video_compose"
assert JobType.RENDER_EDIT_PLAN.value == "render_edit_plan"
assert JobType.ASSET_INGEST.value == "asset_ingest"
assert JobType.CLASSIFICATION.value == "classification"
assert JobType.VOICE_EXTRACTION.value == "voice_extraction"
assert JobType.GENERATION.value == "generation"
class TestJobType:
"""JobType 枚举测试"""
def test_from_string(self):
assert JobType("video_compose") == JobType.VIDEO_COMPOSE
assert JobType("generation") == JobType.GENERATION
def test_all_types_have_values(self):
"""所有枚举成员都有字符串值"""
for jt in JobType:
assert isinstance(jt.value, str)
assert jt.value
def test_invalid_type_raises(self):
with pytest.raises(ValueError):
JobType("invalid_type")
def test_str_enum_behavior(self):
"""是 str 枚举"""
assert JobType.VIDEO_COMPOSE == "video_compose"
assert isinstance(JobType.VIDEO_COMPOSE, str)
def test_known_types_exist(self):
"""核心任务类型都存在"""
assert JobType.VIDEO_COMPOSE
assert JobType.RENDER_EDIT_PLAN
assert JobType.ASSET_INGEST
assert JobType.CLASSIFICATION
assert JobType.GENERATION
class TestJobStatusEnum:
def test_all_statuses_exist(self):
assert JobStatus.PENDING.value == "pending"
assert JobStatus.RUNNING.value == "running"
assert JobStatus.SUCCESS.value == "success"
assert JobStatus.FAILED.value == "failed"
assert JobStatus.CANCELLED.value == "cancelled"
class TestJobStatus:
"""JobStatus 枚举测试"""
def test_from_string(self):
assert JobStatus("pending") == JobStatus.PENDING
assert JobStatus("success") == JobStatus.SUCCESS
def test_all_statuses_have_values(self):
for js in JobStatus:
assert isinstance(js.value, str)
assert js.value
def test_str_enum_behavior(self):
assert JobStatus.PENDING == "pending"
assert isinstance(JobStatus.PENDING, str)
class TestTerminalStatuses:
def test_success_is_terminal(self):
def test_terminal_statuses(self):
"""终态集合包含成功/失败/取消"""
assert JobStatus.SUCCESS in TERMINAL_STATUSES
def test_failed_is_terminal(self):
assert JobStatus.FAILED in TERMINAL_STATUSES
def test_cancelled_is_terminal(self):
assert JobStatus.CANCELLED in TERMINAL_STATUSES
def test_pending_not_terminal(self):
@@ -61,376 +59,372 @@ class TestTerminalStatuses:
class TestJobCreate:
def test_create_minimal(self):
job = Job.create(project_id="proj1", job_type=JobType.VIDEO_COMPOSE)
assert job.id is not None
assert len(job.id) == 32
assert job.project_id == "proj1"
"""Job.create 工厂方法测试"""
def test_create_basic(self):
"""基本创建"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
)
assert job.id
assert len(job.id) == 32 # uuid4 hex
assert job.project_id == "proj-1"
assert job.job_type == JobType.VIDEO_COMPOSE
assert job.status == JobStatus.PENDING
assert job.progress == 0.0
assert job.current_stage == ""
assert job.payload == {}
assert job.result == {}
assert job.error_message == ""
assert job.retry_count == 0
assert job.max_retries == 3
assert job.celery_task_id == ""
assert job.source_id == ""
assert job.created_by_user_id == ""
assert job.started_at is None
assert job.completed_at is None
assert isinstance(job.created_at, datetime)
assert isinstance(job.updated_at, datetime)
assert job.created_at
assert job.updated_at
def test_create_with_enum_type(self):
job = Job.create("p1", JobType.GENERATION)
assert job.job_type == JobType.GENERATION
def test_create_with_string_type(self):
job = Job.create("p1", "video_compose")
def test_create_with_string_job_type(self):
"""用字符串创建任务类型"""
job = Job.create(
project_id="proj-1",
job_type="video_compose",
)
assert job.job_type == JobType.VIDEO_COMPOSE
def test_create_invalid_string_job_type_raises(self):
"""无效的任务类型字符串抛 ValueError"""
with pytest.raises(ValueError, match="不支持的任务类型"):
Job.create(project_id="proj-1", job_type="invalid_type")
def test_create_empty_project_id_raises(self):
"""空 project_id 抛 ValueError"""
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
def test_create_with_payload(self):
payload = {"edit_plan_id": "plan123", "resolution": "1080p"}
job = Job.create("p1", JobType.VIDEO_COMPOSE, payload=payload)
"""带 payload 创建"""
payload = {"video_id": "v1", "quality": "1080p"}
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
payload=payload,
)
assert job.payload == payload
def test_create_with_none_payload(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, payload=None)
assert job.payload == {}
def test_create_with_source_id(self):
job = Job.create("p1", JobType.GENERATION, source_id="gen123")
assert job.source_id == "gen123"
"""带 source_id 创建"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
source_id="plan-123",
)
assert job.source_id == "plan-123"
def test_create_with_user_id(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, created_by_user_id="user1")
assert job.created_by_user_id == "user1"
def test_create_with_created_by(self):
"""带创建人"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
created_by_user_id="user-1",
)
assert job.created_by_user_id == "user-1"
def test_create_with_custom_max_retries(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=5)
"""自定义最大重试次数"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
max_retries=5,
)
assert job.max_retries == 5
def test_create_strips_project_id(self):
job = Job.create(" proj1 ", JobType.VIDEO_COMPOSE)
assert job.project_id == "proj1"
def test_create_project_id_stripped(self):
"""project_id 会被 strip"""
job = Job.create(
project_id=" proj-1 ",
job_type=JobType.VIDEO_COMPOSE,
)
assert job.project_id == "proj-1"
def test_create_strips_source_id(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, source_id=" src1 ")
assert job.source_id == "src1"
def test_create_source_id_stripped(self):
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
source_id=" src-1 ",
)
assert job.source_id == "src-1"
def test_create_strips_user_id(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, created_by_user_id=" u1 ")
assert job.created_by_user_id == "u1"
def test_create_created_by_stripped(self):
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
created_by_user_id=" user-1 ",
)
assert job.created_by_user_id == "user-1"
def test_create_empty_project_id(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create("", JobType.VIDEO_COMPOSE)
def test_create_whitespace_project_id(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create(" \t ", JobType.VIDEO_COMPOSE)
def test_create_invalid_job_type_string(self):
with pytest.raises(ValueError, match="不支持的任务类型"):
Job.create("p1", "invalid_type")
def test_create_unique_ids(self):
j1 = Job.create("p1", JobType.VIDEO_COMPOSE)
j2 = Job.create("p1", JobType.VIDEO_COMPOSE)
assert j1.id != j2.id
def test_create_none_payload_defaults_to_empty_dict(self):
"""payload=None 时默认为空 dict"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
payload=None,
)
assert job.payload == {}
class TestIsTerminal:
class TestJobIsTerminal:
"""is_terminal 属性测试"""
def test_pending_not_terminal(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert job.is_terminal is False
def test_running_not_terminal(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.is_terminal is False
def test_success_is_terminal(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert job.is_terminal is True
def test_failed_is_terminal(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.is_terminal is True
def test_cancelled_is_terminal(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.CANCELLED)
assert job.is_terminal is True
class TestIsRetryable:
def test_pending_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
assert job.is_retryable is False
class TestJobTransitions:
"""状态转换测试"""
def test_running_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.is_retryable is False
def test_success_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert job.is_retryable is False
def test_failed_within_limit_is_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("error")
assert job.is_retryable is True
def test_failed_at_limit_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("error")
job.retry_count = 3 # 已达到上限
assert job.is_retryable is False
def test_failed_over_limit_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
job.retry_count = 5
job.status = JobStatus.FAILED
assert job.is_retryable is False
def test_zero_max_retries_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=0)
job.status = JobStatus.FAILED
assert job.is_retryable is False
class TestTransitionTo:
def test_pending_to_running(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.status == JobStatus.RUNNING
assert job.started_at is not None
def test_pending_to_success(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
"""pending 可以直接到 success(快速成功)"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
assert job.completed_at is not None
def test_pending_to_cancelled(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_pending_to_failed_invalid(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(JobStatus.FAILED)
def test_running_to_success(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
assert job.completed_at is not None
def test_running_to_failed(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.status == JobStatus.FAILED
assert job.completed_at is not None
def test_running_to_cancelled(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_running_to_pending_invalid(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(JobStatus.PENDING)
def test_failed_to_pending(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_failed_to_pending_retry(self):
"""失败后可以回到 pending(重试)"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
# 注意:_VALID_TRANSITIONS 中 FAILED → PENDING 是允许的
job.transition_to(JobStatus.PENDING)
assert job.status == JobStatus.PENDING
def test_success_to_anything_invalid(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_invalid_transition_raises(self):
"""非法状态转换抛 ValueError"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
# pending 不能直接到 failed
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(JobStatus.FAILED)
def test_success_to_pending_raises(self):
"""成功后不能回到 pending"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
with pytest.raises(ValueError):
job.transition_to(JobStatus.FAILED)
with pytest.raises(ValueError):
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.PENDING)
def test_transition_with_string_status(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
"""用字符串做状态转换"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to("running")
assert job.status == JobStatus.RUNNING
def test_transition_with_invalid_string(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_transition_invalid_string_raises(self):
"""无效状态字符串抛 ValueError"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("invalid_status")
def test_transition_updates_updated_at(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
old_time = job.updated_at
"""状态转换更新 updated_at"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
old_updated = job.updated_at
import time
time.sleep(0.001)
job.transition_to(JobStatus.RUNNING)
assert job.updated_at >= old_time
assert job.updated_at >= old_updated
def test_started_at_only_set_once(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
"""started_at 只在第一次 RUNNING 时设置"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
first_start = job.started_at
# 再次 RUNNING 不合法,但我们测试 started_at 在多次 running→success→retry→running 时的行为
# 先失败重试
job.transition_to(JobStatus.FAILED)
job.transition_to(JobStatus.PENDING)
job.started_at = None # 模拟 prepare_retry 的重置
job.transition_to(JobStatus.RUNNING)
assert job.started_at is not None
assert job.started_at != first_start
first_started = job.started_at
job.transition_to(JobStatus.SUCCESS)
# 回到 pending 再 running(模拟重试场景,但started_at是None时才设置)
# 注意:正常重试是通过 prepare_retry 重置的
assert first_started is not None
class TestMarkRunning:
def test_mark_running_basic(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
class TestJobMarkMethods:
"""便捷标记方法测试"""
def test_mark_running(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running("合成中")
assert job.status == JobStatus.RUNNING
assert job.current_stage == "合成中"
def test_mark_running_no_stage(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
assert job.status == JobStatus.RUNNING
assert job.started_at is not None
assert job.current_stage == ""
def test_mark_running_with_stage(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running(stage="下载素材")
assert job.status == JobStatus.RUNNING
assert job.current_stage == "下载素材"
def test_mark_running_empty_stage_unchanged(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.current_stage = "已有阶段"
job.mark_running() # 不传 stage
assert job.current_stage == "已有阶段"
class TestMarkSuccess:
def test_mark_success_basic(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_mark_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
job.mark_success({"output_url": "http://..."})
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
assert job.current_stage == "完成"
assert job.completed_at is not None
assert job.result == {"output_url": "http://..."}
def test_mark_success_with_result(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_mark_success_no_result(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
result = {"video_url": "https://...", "duration": 30}
job.mark_success(result=result)
assert job.result == result
def test_mark_success_without_result(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
original_result = job.result.copy()
job.mark_success()
assert job.result == original_result # 不变
assert job.status == JobStatus.SUCCESS
assert job.result == {}
class TestMarkFailed:
def test_mark_failed_basic(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_mark_failed(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_failed("网络超时")
assert job.status == JobStatus.FAILED
assert job.error_message == "网络超时"
assert job.current_stage == "失败"
assert job.completed_at is not None
def test_mark_failed_empty_message(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_failed("")
assert job.error_message == ""
class TestMarkCancelled:
def test_mark_cancelled_from_pending(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_mark_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_cancelled()
assert job.status == JobStatus.CANCELLED
assert job.current_stage == "已取消"
def test_mark_cancelled_from_running(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_cancelled()
assert job.status == JobStatus.CANCELLED
class TestJobProgress:
"""进度更新测试"""
class TestUpdateProgress:
def test_update_progress_valid(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.update_progress(50.0)
def test_update_progress(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(50.0, "渲染中")
assert job.progress == 50.0
assert job.current_stage == "渲染中"
def test_update_progress_zero(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(0.0)
assert job.progress == 0.0
def test_update_progress_hundred(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_update_progress_100(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(100.0)
assert job.progress == 100.0
def test_update_progress_negative(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_update_progress_negative_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(-1.0)
def test_update_progress_over_100(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
def test_update_progress_over_100_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(101.0)
def test_update_progress_with_stage(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.update_progress(30.0, stage="渲染中")
def test_update_progress_without_stage(self):
"""不传 stage 时不修改 current_stage"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.current_stage = "初始阶段"
job.update_progress(30.0)
assert job.progress == 30.0
assert job.current_stage == "渲染中"
assert job.current_stage == "初始阶段"
def test_update_progress_without_stage_unchanged(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.current_stage = "原阶段"
def test_update_progress_updates_updated_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
old_updated = job.updated_at
import time
time.sleep(0.001)
job.update_progress(50.0)
assert job.current_stage == "原阶段"
def test_update_progress_updates_timestamp(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
old_time = job.updated_at
job.update_progress(25.0)
assert job.updated_at >= old_time
assert job.updated_at >= old_updated
class TestPrepareRetry:
def test_prepare_retry_success(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
class TestJobRetry:
"""重试逻辑测试"""
def test_is_retryable_failed_within_limit(self):
"""失败且未超过重试上限时可重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("错误")
assert job.is_retryable is True
def test_is_retryable_failed_at_limit(self):
"""达到重试上限时不可重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=1)
job.mark_running()
job.mark_failed("错误")
job.retry_count = 1
assert job.is_retryable is False
def test_is_retryable_pending_false(self):
"""pending 状态不可重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert job.is_retryable is False
def test_is_retryable_success_false(self):
"""成功状态不可重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert job.is_retryable is False
def test_prepare_retry(self):
"""准备重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("网络错误")
job.celery_task_id = "task-123"
job.prepare_retry()
@@ -443,41 +437,38 @@ class TestPrepareRetry:
assert job.completed_at is None
assert job.celery_task_id == ""
def test_prepare_retry_increments_count(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=5)
def test_prepare_retry_not_retryable_raises(self):
"""不可重试时抛 ValueError"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=0)
job.mark_running()
job.mark_failed("err")
job.mark_failed("错误")
with pytest.raises(ValueError, match="任务不可重试"):
job.prepare_retry()
def test_prepare_retry_increments_correctly(self):
"""多次重试计数正确"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("错误1")
job.prepare_retry()
assert job.retry_count == 1
# 再次失败重试
job.mark_running()
job.mark_failed("err2")
job.mark_failed("错误2")
job.prepare_retry()
assert job.retry_count == 2
def test_prepare_retry_not_retryable_raises(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=0)
job.mark_running()
job.mark_failed("err")
with pytest.raises(ValueError, match="任务不可重试"):
job.prepare_retry()
def test_prepare_retry_wrong_status_raises(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="任务不可重试"):
job.prepare_retry()
class TestJobToDict:
"""to_dict 序列化测试"""
class TestToDict:
def test_to_dict_structure(self):
def test_to_dict_contains_all_fields(self):
job = Job.create(
"p1",
JobType.VIDEO_COMPOSE,
payload={"key": "val"},
source_id="src1",
created_by_user_id="u1",
project_id="p1",
job_type=JobType.VIDEO_COMPOSE,
payload={"key": "value"},
source_id="src-1",
created_by_user_id="user-1",
)
d = job.to_dict()
assert d["id"] == job.id
@@ -485,39 +476,33 @@ class TestToDict:
assert d["job_type"] == "video_compose"
assert d["status"] == "pending"
assert d["progress"] == 0.0
assert d["current_stage"] == ""
assert d["payload"] == {"key": "val"}
assert d["result"] == {}
assert d["error_message"] == ""
assert d["retry_count"] == 0
assert d["max_retries"] == 3
assert d["celery_task_id"] == ""
assert d["source_id"] == "src1"
assert d["created_by_user_id"] == "u1"
assert d["payload"] == {"key": "value"}
assert d["source_id"] == "src-1"
assert d["created_by_user_id"] == "user-1"
assert d["is_retryable"] is False
def test_to_dict_datetime_fields_are_strings(self):
"""时间字段序列化为 ISO 字符串"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
d = job.to_dict()
assert isinstance(d["created_at"], str)
assert isinstance(d["updated_at"], str)
def test_to_dict_none_datetime_fields(self):
"""未设置的时间字段为 None"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
d = job.to_dict()
assert d["started_at"] is None
assert d["completed_at"] is None
assert d["created_at"] is not None
assert d["updated_at"] is not None
def test_to_dict_after_success(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running("渲染")
job.mark_success({"url": "https://..."})
"""成功后 to_dict 状态正确"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success({"url": "http://..."})
d = job.to_dict()
assert d["status"] == "success"
assert d["progress"] == 100.0
assert d["is_retryable"] is False
assert d["result"] == {"url": "http://..."}
assert d["started_at"] is not None
assert d["completed_at"] is not None
assert isinstance(d["started_at"], str)
assert isinstance(d["completed_at"], str)
def test_to_dict_after_failed(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("timeout")
d = job.to_dict()
assert d["status"] == "failed"
assert d["error_message"] == "timeout"
assert d["is_retryable"] is True
-299
View File
@@ -1,299 +0,0 @@
"""media_validation 领域模块单元测试。"""
import pytest
from packages.domain.media_validation import (
MIN_AUDIO_FILE_SIZE,
MIN_IMAGE_FILE_SIZE,
MIN_VIDEO_FILE_SIZE,
SUPPORTED_VIDEO_CODECS,
is_valid_media,
safe_parse_fps,
)
class TestSafeParseFpsBasic:
def test_integer_fps(self):
assert safe_parse_fps("30") == 30.0
def test_decimal_fps(self):
assert safe_parse_fps("29.97") == pytest.approx(29.97)
def test_fraction_simple(self):
assert safe_parse_fps("30/1") == 30.0
def test_fraction_ntsc(self):
assert safe_parse_fps("30000/1001") == pytest.approx(29.97002997)
def test_fraction_pal(self):
assert safe_parse_fps("25/1") == 25.0
def test_fraction_24fps_cine(self):
assert safe_parse_fps("24000/1001") == pytest.approx(23.976023976)
def test_zero_fps(self):
assert safe_parse_fps("0") == 0.0
def test_zero_fraction(self):
assert safe_parse_fps("0/1") == 0.0
class TestSafeParseFpsEdgeCases:
def test_zero_denominator(self):
assert safe_parse_fps("30/0") == 0.0
def test_empty_string(self):
assert safe_parse_fps("") == 0.0
def test_garbage_string(self):
assert safe_parse_fps("not_a_number") == 0.0
def test_multiple_slashes(self):
# split("/", 1) 只切第一个,后面的作为 den 的一部分会解析失败
assert safe_parse_fps("30/1/2") == 0.0
def test_negative_fps(self):
assert safe_parse_fps("-30") == -30.0
def test_negative_fraction(self):
assert safe_parse_fps("-30/1") == -30.0
def test_very_high_fps(self):
assert safe_parse_fps("240/1") == 240.0
def test_fraction_float_num(self):
assert safe_parse_fps("29.97/1") == pytest.approx(29.97)
def test_fraction_float_den(self):
assert safe_parse_fps("30/1.001") == pytest.approx(29.97002997)
def test_whitespace_in_string(self):
# float(" 30 ") 能解析,所以应该返回 30.0
assert safe_parse_fps(" 30 ") == 30.0
class TestMinFileSizeConstants:
def test_min_video_size_is_1kb(self):
assert MIN_VIDEO_FILE_SIZE == 1024
def test_min_audio_size(self):
assert MIN_AUDIO_FILE_SIZE == 100
def test_min_image_size(self):
assert MIN_IMAGE_FILE_SIZE == 100
class TestSupportedVideoCodecs:
def test_h264_family_present(self):
assert "h264" in SUPPORTED_VIDEO_CODECS
assert "avc1" in SUPPORTED_VIDEO_CODECS
assert "avc" in SUPPORTED_VIDEO_CODECS
def test_h265_family_present(self):
assert "hevc" in SUPPORTED_VIDEO_CODECS
assert "h265" in SUPPORTED_VIDEO_CODECS
assert "hev1" in SUPPORTED_VIDEO_CODECS
assert "hvc1" in SUPPORTED_VIDEO_CODECS
def test_vp9_av1_present(self):
assert "vp9" in SUPPORTED_VIDEO_CODECS
assert "vp09" in SUPPORTED_VIDEO_CODECS
assert "av1" in SUPPORTED_VIDEO_CODECS
assert "av01" in SUPPORTED_VIDEO_CODECS
def test_vp8_present(self):
assert "vp8" in SUPPORTED_VIDEO_CODECS
assert "vp08" in SUPPORTED_VIDEO_CODECS
def test_mpeg_family_present(self):
assert "mpeg4" in SUPPORTED_VIDEO_CODECS
assert "mp4v" in SUPPORTED_VIDEO_CODECS
assert "mpeg2video" in SUPPORTED_VIDEO_CODECS
def test_prores_family_present(self):
assert "prores" in SUPPORTED_VIDEO_CODECS
assert "apcn" in SUPPORTED_VIDEO_CODECS
assert "apch" in SUPPORTED_VIDEO_CODECS
def test_unknown_codec_not_present(self):
assert "unknown_codec_xyz" not in SUPPORTED_VIDEO_CODECS
def test_codecs_count_reasonable(self):
# 白名单应该有足够多的编码格式
assert len(SUPPORTED_VIDEO_CODECS) >= 30
class TestIsValidMediaVideo:
def test_valid_video(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is True
def test_video_too_small(self):
metadata = {"size_bytes": 500, "duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_exact_min_size(self):
metadata = {"size_bytes": 1024, "duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is True
def test_video_zero_duration(self):
metadata = {"size_bytes": 5000, "duration": 0, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_negative_duration(self):
metadata = {"size_bytes": 5000, "duration": -1.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_missing_size_default_zero(self):
metadata = {"duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_missing_duration_default_zero(self):
metadata = {"size_bytes": 5000, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_unknown_codec_still_valid(self):
# 非白名单编码仍允许通过(渲染层统一转码)
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "some_unknown_codec"}
assert is_valid_media(metadata, "video") is True
def test_video_missing_codec_still_valid(self):
metadata = {"size_bytes": 5000, "duration": 10.0}
assert is_valid_media(metadata, "video") is True
def test_video_codec_case_insensitive(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "H264"}
assert is_valid_media(metadata, "video") is True
def test_video_empty_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": ""}
assert is_valid_media(metadata, "video") is True
def test_video_hevc_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "hevc"}
assert is_valid_media(metadata, "video") is True
def test_video_vp9_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "vp9"}
assert is_valid_media(metadata, "video") is True
def test_video_av1_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "av1"}
assert is_valid_media(metadata, "video") is True
def test_video_prores_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "prores"}
assert is_valid_media(metadata, "video") is True
def test_video_empty_metadata(self):
assert is_valid_media({}, "video") is False
class TestIsValidMediaAudio:
def test_valid_audio(self):
metadata = {"size_bytes": 5000, "duration": 30.0}
assert is_valid_media(metadata, "audio") is True
def test_audio_too_small(self):
metadata = {"size_bytes": 50, "duration": 30.0}
assert is_valid_media(metadata, "audio") is False
def test_audio_exact_min_size(self):
metadata = {"size_bytes": 100, "duration": 10.0}
assert is_valid_media(metadata, "audio") is True
def test_audio_zero_duration(self):
metadata = {"size_bytes": 5000, "duration": 0}
assert is_valid_media(metadata, "audio") is False
def test_audio_negative_duration(self):
metadata = {"size_bytes": 5000, "duration": -1.0}
assert is_valid_media(metadata, "audio") is False
def test_audio_missing_size(self):
metadata = {"duration": 10.0}
assert is_valid_media(metadata, "audio") is False
def test_audio_missing_duration(self):
metadata = {"size_bytes": 5000}
assert is_valid_media(metadata, "audio") is False
def test_audio_with_codec_info(self):
metadata = {"size_bytes": 5000, "duration": 30.0, "codec": "aac"}
assert is_valid_media(metadata, "audio") is True
def test_audio_empty_metadata(self):
assert is_valid_media({}, "audio") is False
class TestIsValidMediaImage:
def test_valid_image(self):
metadata = {"size_bytes": 5000, "width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is True
def test_image_too_small(self):
metadata = {"size_bytes": 50, "width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_exact_min_size(self):
metadata = {"size_bytes": 100, "width": 100, "height": 100}
assert is_valid_media(metadata, "image") is True
def test_image_zero_width(self):
metadata = {"size_bytes": 5000, "width": 0, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_zero_height(self):
metadata = {"size_bytes": 5000, "width": 1920, "height": 0}
assert is_valid_media(metadata, "image") is False
def test_image_negative_dimensions(self):
metadata = {"size_bytes": 5000, "width": -1, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_missing_width(self):
metadata = {"size_bytes": 5000, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_missing_height(self):
metadata = {"size_bytes": 5000, "width": 1920}
assert is_valid_media(metadata, "image") is False
def test_image_missing_size(self):
metadata = {"width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_small_but_valid(self):
metadata = {"size_bytes": 100, "width": 1, "height": 1}
assert is_valid_media(metadata, "image") is True
def test_image_empty_metadata(self):
assert is_valid_media({}, "image") is False
class TestIsValidMediaUnknownType:
def test_unknown_type_returns_false(self):
metadata = {"size_bytes": 5000, "duration": 10.0}
assert is_valid_media(metadata, "unknown") is False
def test_empty_type_returns_false(self):
metadata = {"size_bytes": 5000, "duration": 10.0}
assert is_valid_media(metadata, "") is False
def test_text_type_returns_false(self):
metadata = {"size_bytes": 5000}
assert is_valid_media(metadata, "text") is False
class TestIsValidMediaSizeTypes:
def test_size_as_string(self):
# int("5000") 能解析
metadata = {"size_bytes": "5000", "duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is True
def test_size_as_none(self):
# int(None) 会 TypeError,但 metadata.get 返回 0 默认值
metadata = {"size_bytes": None, "duration": 10.0, "codec": "h264"}
# int(None) 会抛 TypeError
with pytest.raises(TypeError):
is_valid_media(metadata, "video")
+26 -5
View File
@@ -17,6 +17,7 @@ from packages.domain.plan_generator_utils import (
)
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
# ── 辅助函数 ──────────────────────────────────────────────────────────
@@ -183,7 +184,11 @@ class TestDistributeVoicePip:
def test_three_assets_full_distribution(self):
"""3个素材:background + corner_voice + b_roll 各一个."""
clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(1, "b_roll")
clips = (
_make_clips(1, "background")
+ _make_clips(1, "corner_voice")
+ _make_clips(1, "b_roll")
)
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value)
bgs = [c for c in clips if c.clip_type == "background"]
voices = [c for c in clips if c.clip_type == "corner_voice"]
@@ -194,7 +199,11 @@ class TestDistributeVoicePip:
def test_single_asset_only_background(self):
"""1个素材:只分配给 background."""
clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(2, "b_roll")
clips = (
_make_clips(1, "background")
+ _make_clips(1, "corner_voice")
+ _make_clips(2, "b_roll")
)
distribute_assets(clips, ["a1"], EditingMode.VOICE_PIP.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == ""
@@ -203,7 +212,11 @@ class TestDistributeVoicePip:
def test_two_assets_bg_and_voice(self):
"""2个素材:background + corner_voice."""
clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(2, "b_roll")
clips = (
_make_clips(1, "background")
+ _make_clips(1, "corner_voice")
+ _make_clips(2, "b_roll")
)
distribute_assets(clips, ["a1", "a2"], EditingMode.VOICE_PIP.value)
bgs = [c for c in clips if c.clip_type == "background"]
voices = [c for c in clips if c.clip_type == "corner_voice"]
@@ -214,7 +227,11 @@ class TestDistributeVoicePip:
def test_many_broll_clips(self):
"""多个 b_roll clip:按顺序分配剩余素材."""
clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(5, "b_roll")
clips = (
_make_clips(1, "background")
+ _make_clips(1, "corner_voice")
+ _make_clips(5, "b_roll")
)
distribute_assets(
clips,
["a1", "a2", "a3", "a4", "a5"],
@@ -320,7 +337,11 @@ class TestMapClipTypesForMode:
def test_non_main_clips_unchanged(self):
"""非 MAIN 类型 clip 不受影响."""
clips = _make_clips(1, "intro") + _make_clips(3) + _make_clips(1, "outro") # main
clips = (
_make_clips(1, "intro")
+ _make_clips(3) # main
+ _make_clips(1, "outro")
)
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assert clips[0].clip_type == "intro"
assert clips[1].clip_type == "main" # 第1个 main
-201
View File
@@ -1,201 +0,0 @@
"""Preset BGM 预设背景音乐单元测试。"""
import pytest
from packages.domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
PresetBGM,
get_preset_bgm,
list_preset_bgm_by_style,
search_preset_bgm,
)
class TestPresetBGMDataclass:
def test_creation_required_fields(self):
bgm = PresetBGM(id="test_001", name="Test BGM", style="upbeat", duration=120.0)
assert bgm.id == "test_001"
assert bgm.name == "Test BGM"
assert bgm.style == "upbeat"
assert bgm.duration == 120.0
assert bgm.artist == ""
assert bgm.description == ""
assert bgm.tags == []
assert bgm.audio_url == ""
def test_creation_all_fields(self):
bgm = PresetBGM(
id="test_002",
name="Full BGM",
style="relax",
duration=180.5,
artist="Artist Name",
description="A test description",
tags=["tag1", "tag2"],
audio_url="https://cdn/test.mp3",
)
assert bgm.artist == "Artist Name"
assert bgm.description == "A test description"
assert bgm.tags == ["tag1", "tag2"]
assert bgm.audio_url == "https://cdn/test.mp3"
def test_frozen_immutable(self):
bgm = PresetBGM(id="t1", name="T", style="upbeat", duration=60.0)
with pytest.raises(Exception): # FrozenInstanceError
bgm.name = "new name"
def test_equality(self):
bgm1 = PresetBGM(id="same", name="N", style="upbeat", duration=60.0)
bgm2 = PresetBGM(id="same", name="N", style="upbeat", duration=60.0)
assert bgm1 == bgm2
def test_inequality(self):
bgm1 = PresetBGM(id="a", name="A", style="upbeat", duration=60.0)
bgm2 = PresetBGM(id="b", name="B", style="upbeat", duration=60.0)
assert bgm1 != bgm2
def test_frozen_with_list_field_not_hashable(self):
# 包含 list 字段的 frozen dataclass 仍然不可哈希(list 不可哈希)
bgm = PresetBGM(id="h1", name="H", style="upbeat", duration=60.0, tags=["a"])
with pytest.raises(TypeError, match="unhashable"):
hash(bgm)
class TestPresetBGMLibrary:
def test_library_not_empty(self):
assert len(PRESET_BGM_LIBRARY) > 0
def test_library_has_entries(self):
assert len(PRESET_BGM_LIBRARY) >= 10
def test_all_have_unique_ids(self):
ids = [bgm.id for bgm in PRESET_BGM_LIBRARY]
assert len(ids) == len(set(ids))
def test_all_have_valid_styles(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.style in BGM_STYLES
def test_all_have_positive_duration(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.duration > 0
def test_all_have_non_empty_name(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.name.strip() != ""
class TestBGMStyles:
def test_styles_dict_keys(self):
assert "upbeat" in BGM_STYLES
assert "relax" in BGM_STYLES
assert "tech" in BGM_STYLES
assert "commerce" in BGM_STYLES
assert "emotional" in BGM_STYLES
assert "cinematic" in BGM_STYLES
def test_styles_have_chinese_names(self):
for key, value in BGM_STYLES.items():
assert isinstance(value, str)
assert len(value) > 0
class TestGetPresetBGM:
def test_get_existing(self):
bgm = get_preset_bgm("bgm_upbeat_001")
assert bgm is not None
assert bgm.id == "bgm_upbeat_001"
assert bgm.name == "阳光清晨"
assert bgm.style == "upbeat"
def test_get_nonexistent(self):
assert get_preset_bgm("nonexistent_id") is None
def test_get_empty_string(self):
assert get_preset_bgm("") is None
def test_get_returns_same_object(self):
bgm1 = get_preset_bgm("bgm_relax_001")
bgm2 = get_preset_bgm("bgm_relax_001")
assert bgm1 is bgm2 # 同一实例(引用同一列表中的对象)
class TestListPresetBGMByStyle:
def test_list_upbeat(self):
results = list_preset_bgm_by_style("upbeat")
assert len(results) >= 3
for bgm in results:
assert bgm.style == "upbeat"
def test_list_relax(self):
results = list_preset_bgm_by_style("relax")
assert len(results) >= 3
for bgm in results:
assert bgm.style == "relax"
def test_list_tech(self):
results = list_preset_bgm_by_style("tech")
assert len(results) >= 2
for bgm in results:
assert bgm.style == "tech"
def test_list_commerce(self):
results = list_preset_bgm_by_style("commerce")
assert len(results) >= 2
for bgm in results:
assert bgm.style == "commerce"
def test_list_empty_style(self):
results = list_preset_bgm_by_style("nonexistent_style")
assert results == []
def test_list_preserves_order(self):
results = list_preset_bgm_by_style("upbeat")
ids = [b.id for b in results]
# 应该按照在列表中的出现顺序排列
assert ids == sorted(ids, key=lambda x: PRESET_BGM_LIBRARY.index(get_preset_bgm(x)))
class TestSearchPresetBGM:
def test_search_by_name(self):
results = search_preset_bgm("阳光")
assert len(results) >= 1
assert any("阳光" in b.name for b in results)
def test_search_by_description(self):
results = search_preset_bgm("钢琴")
assert len(results) >= 1
# 钢琴出现在名称或描述或标签中
found = False
for b in results:
if "钢琴" in b.description or "钢琴" in b.name or "钢琴" in b.tags:
found = True
break
assert found
def test_search_by_tag(self):
results = search_preset_bgm("科技")
assert len(results) >= 1
found_tech = any(b.style == "tech" for b in results)
assert found_tech
def test_search_case_insensitive(self):
results1 = search_preset_bgm("Tech")
results2 = search_preset_bgm("tech")
assert len(results1) == len(results2)
def test_search_no_match(self):
results = search_preset_bgm("zzzzzzzzzzz_nonexistent_keyword")
assert results == []
def test_search_empty_keyword(self):
# 空字符串应该匹配所有(因为空字符串 in 任何字符串都是 True)
results = search_preset_bgm("")
assert len(results) == len(PRESET_BGM_LIBRARY)
def test_search_no_duplicates(self):
# 确保同一个 BGM 不会出现多次
results = search_preset_bgm("电子")
ids = [b.id for b in results]
assert len(ids) == len(set(ids))
+290 -228
View File
@@ -1,11 +1,13 @@
"""Quota 配额系统单元测试。"""
"""Quota 领域层单元测试 - quota.py"""
import math
import pytest
from packages.domain.quota import (
QUOTA_TIERS,
QuotaCheckResult,
QuotaChecker,
QuotaCheckResult,
QuotaDimension,
QuotaRegistry,
QuotaTier,
@@ -17,114 +19,103 @@ from packages.domain.quota import (
class TestQuotaDimension:
def test_core_dimensions_exist(self):
assert QuotaDimension.STORAGE_GB.value == "storage_gb"
assert QuotaDimension.VIDEOS_PER_MONTH.value == "videos_per_month"
assert QuotaDimension.MAX_CONCURRENT.value == "max_concurrent"
assert QuotaDimension.MAX_TEMPLATES.value == "max_templates"
assert QuotaDimension.MAX_TITLES.value == "max_titles"
assert QuotaDimension.MAX_VOICEOVERS.value == "max_voiceovers"
assert QuotaDimension.AI_VOICE_ENABLED.value == "ai_voice_enabled"
"""QuotaDimension 枚举测试"""
def test_extended_dimensions_exist(self):
assert QuotaDimension.AI_VOICE_CREDITS.value == "ai_voice_credits"
assert QuotaDimension.BATCH_EXPORT_ENABLED.value == "batch_export_enabled"
assert QuotaDimension.MULTI_PLATFORM_ENABLED.value == "multi_platform_enabled"
assert QuotaDimension.DEDUP_REPORT_ENABLED.value == "dedup_report_enabled"
def test_all_dimensions_are_strings(self):
def test_all_dimensions_have_values(self):
"""所有枚举成员都有字符串值"""
for dim in QuotaDimension:
assert isinstance(dim.value, str)
assert dim.value
def test_dimension_count(self):
"""配额维度数量 >= 内置维度"""
# 至少有 storage_gb, videos_per_month, max_concurrent, max_templates 等
assert len(QuotaDimension) >= 7
def test_str_enum_behavior(self):
"""是 str 枚举,可直接当字符串用"""
assert QuotaDimension.STORAGE_GB == "storage_gb"
assert isinstance(QuotaDimension.STORAGE_GB, str)
class TestQuotaTier:
"""QuotaTier 测试"""
def test_get_limit_defined(self):
tier = QuotaTier(name="test", limits={"storage_gb": 10, "videos": 5})
assert tier.get_limit("storage_gb") == 10
"""已定义的维度返回正确值"""
tier = QuotaTier(name="test", limits={"storage": 10, "videos": 5})
assert tier.get_limit("storage") == 10
assert tier.get_limit("videos") == 5
def test_get_limit_undefined_returns_zero(self):
tier = QuotaTier(name="test", limits={"storage_gb": 10})
assert tier.get_limit("unknown_dim") == 0
"""未定义的维度返回 0"""
tier = QuotaTier(name="test", limits={"storage": 10})
assert tier.get_limit("unknown") == 0
def test_is_unlimited_false_for_finite(self):
tier = QuotaTier(name="test", limits={"storage_gb": 10})
assert tier.is_unlimited("storage_gb") is False
def test_is_unlimited_true_for_inf(self):
def test_is_unlimited_true(self):
"""不限量判断 - inf"""
tier = QuotaTier(name="test", limits={"templates": float("inf")})
assert tier.is_unlimited("templates") is True
def test_is_unlimited_undefined(self):
tier = QuotaTier(name="test", limits={})
# 未定义的维度,limits.get 返回默认 inf,所以 is_unlimited 返回 True
assert tier.is_unlimited("unknown") is True
def test_is_unlimited_false(self):
"""限量判断"""
tier = QuotaTier(name="test", limits={"storage": 10})
assert tier.is_unlimited("storage") is False
def test_empty_limits(self):
tier = QuotaTier(name="empty")
assert tier.limits == {}
assert tier.name == "empty"
def test_is_unlimited_undefined_returns_true(self):
"""未定义的维度默认 infis_unlimited 返回 True"""
tier = QuotaTier(name="test", limits={})
# get_limit 用 dict.get 默认 0,但 is_unlimited 用 dict.get 默认 inf
assert tier.is_unlimited("unknown") is True
class TestQuotaTiers:
"""内置套餐配额测试"""
def test_three_tiers_exist(self):
"""三个套餐等级都存在"""
assert "free" in QUOTA_TIERS
assert "basic" in QUOTA_TIERS
assert "premium" in QUOTA_TIERS
def test_free_tier_limits(self):
free = QUOTA_TIERS["free"]
assert free.get_limit("storage_gb") == 2
assert free.get_limit("videos_per_month") == 5
assert free.get_limit("max_concurrent") == 3
assert free.get_limit("max_templates") == 3
assert free.get_limit("max_titles") == 50
assert free.get_limit("max_voiceovers") == 10
assert free.get_limit("ai_voice_enabled") == 0
def test_free_tier_storage(self):
"""free 套餐 2GB 存储"""
assert QUOTA_TIERS["free"].get_limit(QuotaDimension.STORAGE_GB) == 2
def test_basic_tier_limits(self):
basic = QUOTA_TIERS["basic"]
assert basic.get_limit("storage_gb") == 20
assert basic.get_limit("videos_per_month") == 30
assert basic.get_limit("max_concurrent") == 10
assert basic.get_limit("max_templates") == 15
assert basic.get_limit("max_titles") == 500
assert basic.get_limit("max_voiceovers") == 100
assert basic.get_limit("ai_voice_enabled") == 1
assert basic.get_limit("ai_voice_credits") == 100
assert basic.get_limit("batch_export_enabled") == 1
def test_basic_tier_storage(self):
"""basic 套餐 20GB 存储"""
assert QUOTA_TIERS["basic"].get_limit(QuotaDimension.STORAGE_GB) == 20
def test_premium_tier_limits(self):
premium = QUOTA_TIERS["premium"]
assert premium.get_limit("storage_gb") == 100
assert premium.get_limit("videos_per_month") == 100
assert premium.get_limit("max_concurrent") == 20
assert premium.is_unlimited("max_templates") is True
assert premium.get_limit("ai_voice_enabled") == 1
assert premium.get_limit("ai_voice_credits") == 500
assert premium.get_limit("batch_export_enabled") == 1
assert premium.get_limit("multi_platform_enabled") == 1
assert premium.get_limit("dedup_report_enabled") == 1
def test_premium_tier_storage(self):
"""premium 套餐 100GB 存储"""
assert QUOTA_TIERS["premium"].get_limit(QuotaDimension.STORAGE_GB) == 100
def test_tier_increase_monotonic(self):
free = QUOTA_TIERS["free"]
basic = QUOTA_TIERS["basic"]
premium = QUOTA_TIERS["premium"]
# 高级套餐应该 >= 低级套餐的所有限制
for dim in [
"storage_gb",
"videos_per_month",
"max_concurrent",
"max_titles",
"max_voiceovers",
"ai_voice_credits",
]:
assert basic.get_limit(dim) >= free.get_limit(dim)
assert premium.get_limit(dim) >= basic.get_limit(dim)
def test_free_no_ai_voice(self):
"""free 套餐没有 AI 配音"""
assert QUOTA_TIERS["free"].get_limit(QuotaDimension.AI_VOICE_ENABLED) == 0
def test_basic_has_ai_voice(self):
"""basic 套餐有 AI 配音"""
assert QUOTA_TIERS["basic"].get_limit(QuotaDimension.AI_VOICE_ENABLED) == 1
def test_premium_templates_unlimited(self):
"""premium 套餐模板不限量"""
assert QUOTA_TIERS["premium"].is_unlimited(QuotaDimension.MAX_TEMPLATES) is True
def test_free_videos_per_month(self):
"""free 每月 5 个视频"""
assert QUOTA_TIERS["free"].get_limit(QuotaDimension.VIDEOS_PER_MONTH) == 5
def test_premium_multi_platform_enabled(self):
"""premium 支持多平台发布"""
assert QUOTA_TIERS["premium"].get_limit(QuotaDimension.MULTI_PLATFORM_ENABLED) == 1
class TestQuotaWarningLevel:
def test_levels_exist(self):
"""告警级别常量测试"""
def test_level_values(self):
"""四个告警级别都有定义"""
assert QuotaWarningLevel.NORMAL == "normal"
assert QuotaWarningLevel.WARNING == "warning"
assert QuotaWarningLevel.CRITICAL == "critical"
@@ -132,231 +123,302 @@ class TestQuotaWarningLevel:
class TestQuotaCheckResult:
"""QuotaCheckResult 测试"""
def test_usage_percent_normal(self):
"""正常使用百分比计算"""
result = QuotaCheckResult(
allowed=True,
dimension="storage_gb",
dimension="storage",
limit=100,
used=50,
remaining=50,
warning_level="normal",
used=30,
remaining=70,
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 50.0
assert result.usage_percent == 30.0
def test_usage_percent_exceeded(self):
def test_usage_percent_capped_at_100(self):
"""超过 100% 时截断为 100%"""
result = QuotaCheckResult(
allowed=False, dimension="d", limit=100, used=150, remaining=0, warning_level="exceeded"
allowed=False,
dimension="storage",
limit=100,
used=150,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert result.usage_percent == 100.0 # min(100, 150%)
def test_usage_percent_zero_used(self):
result = QuotaCheckResult(allowed=True, dimension="d", limit=100, used=0, remaining=100, warning_level="normal")
assert result.usage_percent == 0.0
assert result.usage_percent == 100.0
def test_usage_percent_zero_limit_with_usage(self):
result = QuotaCheckResult(allowed=False, dimension="d", limit=0, used=10, remaining=0, warning_level="exceeded")
"""limit=0 但有使用量,返回 100%"""
result = QuotaCheckResult(
allowed=False,
dimension="storage",
limit=0,
used=5,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert result.usage_percent == 100.0
def test_usage_percent_zero_limit_no_usage(self):
result = QuotaCheckResult(allowed=True, dimension="d", limit=0, used=0, remaining=0, warning_level="normal")
"""limit=0 且无使用量,返回 0%"""
result = QuotaCheckResult(
allowed=True,
dimension="storage",
limit=0,
used=0,
remaining=0,
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 0.0
def test_usage_percent_unlimited(self):
"""不限量时使用百分比为 0"""
result = QuotaCheckResult(
allowed=True,
dimension="d",
dimension="templates",
limit=float("inf"),
used=1000,
used=50,
remaining=float("inf"),
warning_level="normal",
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 0.0
class TestQuotaRegistry:
def test_initial_dimensions(self):
reg = QuotaRegistry()
dims = reg.list_dimensions()
assert "storage_gb" in dims
assert "videos_per_month" in dims
assert len(dims) == len(QuotaDimension)
"""QuotaRegistry 测试"""
def test_list_tiers(self):
reg = QuotaRegistry()
tiers = reg.list_tiers()
def test_initial_dimensions(self):
"""初始化时内置维度已注册"""
registry = QuotaRegistry()
dims = registry.list_dimensions()
assert QuotaDimension.STORAGE_GB in dims
assert QuotaDimension.VIDEOS_PER_MONTH in dims
def test_initial_tiers(self):
"""初始化时三个套餐已注册"""
registry = QuotaRegistry()
tiers = registry.list_tiers()
assert "free" in tiers
assert "basic" in tiers
assert "premium" in tiers
assert len(tiers) == 3
def test_register_new_dimension(self):
"""注册新的配额维度"""
registry = QuotaRegistry()
registry.register_dimension("custom_dim", "自定义维度")
dims = registry.list_dimensions()
assert "custom_dim" in dims
assert dims["custom_dim"] == "自定义维度"
def test_register_dimension_idempotent(self):
"""重复注册是幂等的"""
registry = QuotaRegistry()
registry.register_dimension("custom", "描述1")
registry.register_dimension("custom", "描述2")
# 保留第一次注册的描述
assert registry.list_dimensions()["custom"] == "描述1"
def test_register_with_default_limits(self):
"""注册时指定各套餐的默认限制"""
registry = QuotaRegistry()
registry.register_dimension(
"custom",
"自定义",
default_limits={"free": 1, "basic": 10, "premium": 100},
)
assert registry.get_limit("free", "custom") == 1
assert registry.get_limit("basic", "custom") == 10
assert registry.get_limit("premium", "custom") == 100
def test_register_without_default_limits_defaults_to_zero(self):
"""不指定默认限制时各套餐该维度为 0"""
registry = QuotaRegistry()
registry.register_dimension("custom_no_limit", "自定义")
assert registry.get_limit("free", "custom_no_limit") == 0
assert registry.get_limit("basic", "custom_no_limit") == 0
def test_register_default_limits_ignores_unknown_plan(self):
"""默认限制中未知的套餐名被忽略"""
registry = QuotaRegistry()
registry.register_dimension(
"custom",
"自定义",
default_limits={"nonexistent": 999},
)
# 不报错,但也不会创建新套餐
assert registry.get_tier("nonexistent") is None
def test_get_tier_existing(self):
reg = QuotaRegistry()
tier = reg.get_tier("free")
"""获取存在的套餐"""
registry = QuotaRegistry()
tier = registry.get_tier("free")
assert tier is not None
assert tier.name == "free"
def test_get_tier_unknown(self):
reg = QuotaRegistry()
assert reg.get_tier("unknown_plan") is None
def test_get_tier_nonexistent(self):
"""获取不存在的套餐返回 None"""
registry = QuotaRegistry()
assert registry.get_tier("enterprise") is None
def test_get_limit_known(self):
reg = QuotaRegistry()
assert reg.get_limit("free", "storage_gb") == 2
assert reg.get_limit("premium", "storage_gb") == 100
def test_get_limit_existing(self):
"""获取存在的套餐和维度的限制"""
registry = QuotaRegistry()
assert registry.get_limit("free", QuotaDimension.STORAGE_GB) == 2
def test_get_limit_unknown_plan(self):
reg = QuotaRegistry()
assert reg.get_limit("unknown", "storage_gb") == 0
def test_get_limit_nonexistent_plan(self):
"""不存在的套餐返回 0"""
registry = QuotaRegistry()
assert registry.get_limit("unknown", QuotaDimension.STORAGE_GB) == 0
def test_register_new_dimension(self):
reg = QuotaRegistry()
reg.register_dimension("new_feature", "新功能", default_limits={"free": 0, "basic": 1, "premium": 5})
dims = reg.list_dimensions()
assert "new_feature" in dims
assert dims["new_feature"] == "新功能"
assert reg.get_limit("free", "new_feature") == 0
assert reg.get_limit("basic", "new_feature") == 1
assert reg.get_limit("premium", "new_feature") == 5
def test_list_dimensions_returns_copy(self):
"""list_dimensions 返回副本,修改不影响内部"""
registry = QuotaRegistry()
dims = registry.list_dimensions()
dims["fake"] = "fake"
assert "fake" not in registry.list_dimensions()
def test_register_dimension_idempotent(self):
reg = QuotaRegistry()
reg.register_dimension("storage_gb", "should not change", default_limits={"free": 999})
# 已经存在的不覆盖
assert reg.get_limit("free", "storage_gb") == 2
def test_register_without_defaults(self):
reg = QuotaRegistry()
reg.register_dimension("new_dim", "描述")
assert reg.get_limit("free", "new_dim") == 0
assert reg.get_limit("basic", "new_dim") == 0
assert reg.get_limit("premium", "new_dim") == 0
def test_register_partial_limits(self):
reg = QuotaRegistry()
reg.register_dimension("partial", "partial", default_limits={"premium": 42})
assert reg.get_limit("free", "partial") == 0 # 未设置的保持 0
assert reg.get_limit("premium", "partial") == 42
def test_list_tiers_returns_all_three(self):
"""列出所有套餐"""
registry = QuotaRegistry()
tiers = registry.list_tiers()
assert len(tiers) == 3
assert set(tiers) == {"free", "basic", "premium"}
class TestQuotaChecker:
def test_check_within_limit(self):
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 1)
assert result.allowed is True
assert result.limit == 2
assert result.used == 1
assert result.remaining == 1
assert result.dimension == "storage_gb"
"""QuotaChecker 测试"""
def test_check_exceeded(self):
def test_check_under_limit_allowed(self):
"""使用量低于限制,允许"""
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 3)
result = checker.check("free", QuotaDimension.STORAGE_GB, 1.0)
assert result.allowed is True
assert result.remaining == 1.0
assert result.warning_level == QuotaWarningLevel.NORMAL
def test_check_at_limit_not_allowed(self):
"""使用量等于限制,不允许(used < limit 判定)"""
checker = QuotaChecker()
result = checker.check("free", QuotaDimension.STORAGE_GB, 2.0)
assert result.allowed is False
assert result.remaining == 0
assert result.warning_level == "exceeded"
assert result.warning_level == QuotaWarningLevel.EXCEEDED
def test_check_exact_limit_not_allowed(self):
# used < limit 才 allowed,等于不算
def test_check_over_limit(self):
"""使用量超过限制"""
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 2)
result = checker.check("free", QuotaDimension.STORAGE_GB, 3.0)
assert result.allowed is False
assert result.remaining == 0
assert result.warning_level == QuotaWarningLevel.EXCEEDED
def test_check_unlimited(self):
def test_check_warning_level_80_percent(self):
"""80% 触发 WARNING"""
checker = QuotaChecker()
result = checker.check("premium", "max_templates", 999999)
assert result.allowed is True
assert result.remaining == float("inf")
assert result.warning_level == "normal"
# 100GB 的 80% = 80GB
result = checker.check("premium", QuotaDimension.STORAGE_GB, 80.0)
assert result.warning_level == QuotaWarningLevel.WARNING
def test_check_warning_level_normal(self):
def test_check_warning_level_95_percent(self):
"""95% 触发 CRITICAL"""
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 1) # 50%
assert result.warning_level == "normal"
def test_check_warning_level_warning(self):
checker = QuotaChecker()
# 80% <= used < 95%
result = checker.check("free", "max_templates", 2.5) # 2.5/3 = 83%
assert result.warning_level == "warning"
def test_check_warning_level_critical(self):
checker = QuotaChecker()
# 95% <= used < 100%
result = checker.check("free", "max_templates", 2.9) # 2.9/3 = 97%
assert result.warning_level == "critical"
result = checker.check("premium", QuotaDimension.STORAGE_GB, 95.0)
assert result.warning_level == QuotaWarningLevel.CRITICAL
def test_check_warning_level_exceeded(self):
"""100% 及以上触发 EXCEEDED"""
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 5) # 250%
assert result.warning_level == "exceeded"
result = checker.check("premium", QuotaDimension.STORAGE_GB, 100.0)
assert result.warning_level == QuotaWarningLevel.EXCEEDED
def test_check_unlimited_always_allowed(self):
"""不限量的维度始终允许"""
checker = QuotaChecker()
result = checker.check("premium", QuotaDimension.MAX_TEMPLATES, 9999)
assert result.allowed is True
assert math.isinf(result.remaining)
assert result.warning_level == QuotaWarningLevel.NORMAL
def test_check_unknown_plan_zero_limit(self):
"""未知套餐限制为 0,used=0 时不允许(0 < 0 为 False"""
checker = QuotaChecker()
result = checker.check("unknown", QuotaDimension.STORAGE_GB, 0)
assert result.limit == 0
assert result.allowed is False
def test_check_multiple(self):
"""批量检查多个维度"""
checker = QuotaChecker()
results = checker.check_multiple(
"free",
{"storage_gb": 1, "max_templates": 2, "max_titles": 10},
{
QuotaDimension.STORAGE_GB: 1.0,
QuotaDimension.VIDEOS_PER_MONTH: 3,
},
)
assert len(results) == 3
assert results[0].dimension == "storage_gb"
assert results[1].dimension == "max_templates"
assert results[2].dimension == "max_titles"
assert len(results) == 2
assert all(r.allowed for r in results)
dims = {r.dimension for r in results}
assert QuotaDimension.STORAGE_GB in dims
assert QuotaDimension.VIDEOS_PER_MONTH in dims
def test_check_zero_limit(self):
checker = QuotaChecker()
result = checker.check("free", "ai_voice_enabled", 0)
# limit=0, used=0: used < limit 为 False → allowed=False
assert result.allowed is False
assert result.remaining == 0
assert result.warning_level == "normal"
def test_compute_warning_level_normal(self):
assert QuotaChecker._compute_warning_level(50, 100) == "normal"
assert QuotaChecker._compute_warning_level(79, 100) == "normal"
def test_compute_warning_level_warning_boundary(self):
assert QuotaChecker._compute_warning_level(80, 100) == "warning"
assert QuotaChecker._compute_warning_level(94, 100) == "warning"
def test_compute_warning_level_critical_boundary(self):
assert QuotaChecker._compute_warning_level(95, 100) == "critical"
assert QuotaChecker._compute_warning_level(99, 100) == "critical"
def test_compute_warning_level_exceeded(self):
assert QuotaChecker._compute_warning_level(100, 100) == "exceeded"
assert QuotaChecker._compute_warning_level(150, 100) == "exceeded"
def test_compute_warning_level_unlimited(self):
assert QuotaChecker._compute_warning_level(9999, float("inf")) == "normal"
def test_compute_warning_level_zero_limit_with_usage(self):
assert QuotaChecker._compute_warning_level(1, 0) == "exceeded"
def test_check_with_custom_registry(self):
"""使用自定义注册表"""
registry = QuotaRegistry()
registry.register_dimension("custom", "自定义", default_limits={"free": 5})
checker = QuotaChecker(registry)
result = checker.check("free", "custom", 3)
assert result.allowed is True
assert result.limit == 5
def test_compute_warning_level_zero_limit_no_usage(self):
assert QuotaChecker._compute_warning_level(0, 0) == "normal"
"""limit=0, used=0 → NORMAL"""
level = QuotaChecker._compute_warning_level(0, 0)
assert level == QuotaWarningLevel.NORMAL
def test_compute_warning_level_zero_limit_with_usage(self):
"""limit=0, used>0 → EXCEEDED"""
level = QuotaChecker._compute_warning_level(1, 0)
assert level == QuotaWarningLevel.EXCEEDED
def test_compute_warning_level_negative_limit(self):
# limit <= 0 且 used=0 → NORMAL
assert QuotaChecker._compute_warning_level(0, -1) == "normal"
"""limit<0 视同 0 处理"""
level = QuotaChecker._compute_warning_level(1, -1)
assert level == QuotaWarningLevel.EXCEEDED
class TestGetWarningLevel:
def test_convenience_function(self):
assert get_warning_level(50, 100) == "normal"
assert get_warning_level(99, 100) == "critical"
assert get_warning_level(100, 100) == "exceeded"
assert get_warning_level(0, 0) == "normal"
assert get_warning_level(1, 0) == "exceeded"
"""get_warning_level 便捷函数测试"""
def test_normal(self):
assert get_warning_level(50, 100) == QuotaWarningLevel.NORMAL
def test_warning(self):
assert get_warning_level(85, 100) == QuotaWarningLevel.WARNING
def test_critical(self):
assert get_warning_level(97, 100) == QuotaWarningLevel.CRITICAL
def test_exceeded(self):
assert get_warning_level(100, 100) == QuotaWarningLevel.EXCEEDED
def test_unlimited(self):
assert get_warning_level(9999, float("inf")) == QuotaWarningLevel.NORMAL
class TestGlobalSingletons:
"""全局单例测试"""
def test_quota_registry_is_instance(self):
assert isinstance(quota_registry, QuotaRegistry)
def test_quota_checker_is_instance(self):
assert isinstance(quota_checker, QuotaChecker)
def test_global_checker_works(self):
result = quota_checker.check("free", "storage_gb", 1)
def test_global_checker_uses_global_registry(self):
"""全局 checker 使用全局 registry"""
# 验证能正常工作
result = quota_checker.check("free", QuotaDimension.STORAGE_GB, 1.0)
assert result.allowed is True
+361
View File
@@ -0,0 +1,361 @@
"""render_layer_utils 模块单元测试."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from packages.domain.render_layer_utils import (
LAYER_Z_INDEX,
MAIN_LAYER_ROLES,
PIP_DEFAULT_SCALE,
can_pass_through,
clip_adjusted_duration,
clip_effective_duration,
clip_playback_speed,
estimate_total_duration,
get_layer_z_index,
resolve_layer_role,
)
# ── 辅助数据类 ──────────────────────────────────────────────────────────────
@dataclass
class FakeClip:
duration: float = 0.0
actual_duration: float = 0.0
playback_speed: Any = 1.0
@dataclass
class FakeLayer:
role: str = "main"
clips: list[FakeClip] = field(default_factory=list)
# ── 常量验证 ────────────────────────────────────────────────────────────────
class TestConstants:
def test_layer_z_index_has_expected_keys(self):
assert set(LAYER_Z_INDEX.keys()) == {
"background",
"broll",
"main",
"overlay",
"corner_voice",
"audio",
}
def test_layer_z_index_ordering(self):
assert LAYER_Z_INDEX["background"] < LAYER_Z_INDEX["main"]
assert LAYER_Z_INDEX["main"] == LAYER_Z_INDEX["broll"]
assert LAYER_Z_INDEX["overlay"] > LAYER_Z_INDEX["main"]
assert LAYER_Z_INDEX["corner_voice"] > LAYER_Z_INDEX["main"]
assert LAYER_Z_INDEX["audio"] > LAYER_Z_INDEX["overlay"]
def test_pip_default_scale_positive(self):
assert 0 < PIP_DEFAULT_SCALE < 1
def test_main_layer_roles(self):
assert "main" in MAIN_LAYER_ROLES
assert "broll" in MAIN_LAYER_ROLES
assert "background" in MAIN_LAYER_ROLES
assert "overlay" not in MAIN_LAYER_ROLES
# ── resolve_layer_role ──────────────────────────────────────────────────────
class TestResolveLayerRole:
def test_intro_maps_to_main(self):
assert resolve_layer_role("intro") == "main"
def test_outro_maps_to_main(self):
assert resolve_layer_role("outro") == "main"
def test_overlay_maps_to_overlay(self):
assert resolve_layer_role("overlay") == "overlay"
def test_corner_voice_maps_to_corner_voice(self):
assert resolve_layer_role("corner_voice") == "corner_voice"
def test_background_maps_to_background(self):
assert resolve_layer_role("background") == "background"
def test_b_roll_maps_to_broll(self):
assert resolve_layer_role("b_roll") == "broll"
def test_main_defaults_to_main(self):
assert resolve_layer_role("main") == "main"
def test_main_with_b_roll_role(self):
assert resolve_layer_role("main", {"role": "b_roll"}) == "broll"
def test_main_with_audio_role(self):
assert resolve_layer_role("main", {"role": "audio"}) == "audio"
def test_main_with_other_role_stays_main(self):
assert resolve_layer_role("main", {"role": "overlay"}) == "main"
def test_none_config(self):
assert resolve_layer_role("main", None) == "main"
def test_empty_config(self):
assert resolve_layer_role("main", {}) == "main"
def test_unknown_type_defaults_to_main(self):
assert resolve_layer_role("unknown_type") == "main"
# ── get_layer_z_index ──────────────────────────────────────────────────────
class TestGetLayerZIndex:
def test_known_roles(self):
for role, expected in LAYER_Z_INDEX.items():
assert get_layer_z_index(role) == expected
def test_unknown_role_returns_zero(self):
assert get_layer_z_index("nonexistent") == 0
def test_empty_string_returns_zero(self):
assert get_layer_z_index("") == 0
# ── clip_effective_duration ────────────────────────────────────────────────
class TestClipEffectiveDuration:
def test_explicit_duration_no_actual(self):
assert clip_effective_duration(5.0) == 5.0
def test_explicit_duration_with_shorter_actual(self):
assert clip_effective_duration(5.0, 3.0) == 3.0
def test_explicit_duration_with_longer_actual(self):
assert clip_effective_duration(5.0, 10.0) == 5.0
def test_zero_duration_uses_actual(self):
assert clip_effective_duration(0, 8.0) == 8.0
def test_negative_duration_uses_actual(self):
assert clip_effective_duration(-1.0, 8.0) == 8.0
def test_zero_duration_zero_actual(self):
assert clip_effective_duration(0, 0) == 0.0
def test_no_args_returns_zero(self):
assert clip_effective_duration(0) == 0.0
def test_equal_duration_and_actual(self):
assert clip_effective_duration(5.0, 5.0) == 5.0
# ── clip_playback_speed ────────────────────────────────────────────────────
class TestClipPlaybackSpeed:
def test_normal_speed(self):
assert clip_playback_speed(1.0) == 1.0
def test_fast_speed(self):
assert clip_playback_speed(2.0) == 2.0
def test_slow_speed(self):
assert clip_playback_speed(0.5) == 0.5
def test_zero_speed_defaults_to_one(self):
assert clip_playback_speed(0) == 1.0
def test_negative_speed_defaults_to_one(self):
assert clip_playback_speed(-1.0) == 1.0
def test_none_defaults_to_one(self):
assert clip_playback_speed(None) == 1.0
def test_string_defaults_to_one(self):
assert clip_playback_speed("fast") == 1.0
def test_int_speed(self):
assert clip_playback_speed(2) == 2.0
# ── clip_adjusted_duration ─────────────────────────────────────────────────
class TestClipAdjustedDuration:
def test_normal_speed_same_as_effective(self):
assert clip_adjusted_duration(5.0, 10.0, 1.0) == 5.0
def test_double_speed_half_duration(self):
assert clip_adjusted_duration(10.0, 10.0, 2.0) == pytest.approx(5.0)
def test_half_speed_double_duration(self):
assert clip_adjusted_duration(5.0, 10.0, 0.5) == pytest.approx(10.0)
def test_invalid_speed_uses_default(self):
assert clip_adjusted_duration(5.0, 10.0, 0) == 5.0
def test_zero_duration(self):
assert clip_adjusted_duration(0, 0, 1.0) == 0.0
def test_actual_duration_only(self):
assert clip_adjusted_duration(0, 8.0, 1.0) == 8.0
def test_actual_duration_only_with_speed(self):
assert clip_adjusted_duration(0, 8.0, 2.0) == pytest.approx(4.0)
def test_very_close_to_normal_speed(self):
# 1.0000001 应该被认为接近 1.0,不做除法
result = clip_adjusted_duration(5.0, 10.0, 1.0 + 1e-10)
assert result == 5.0
# ── estimate_total_duration ────────────────────────────────────────────────
class TestEstimateTotalDuration:
def test_empty_layers(self):
assert estimate_total_duration([]) == 0.0
def test_no_main_layer(self):
layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])]
assert estimate_total_duration(layers) == 0.0
def test_single_clip_main_layer(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert estimate_total_duration(layers) == pytest.approx(5.0)
def test_multiple_clips_no_transition(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=3.0),
FakeClip(duration=2.0),
FakeClip(duration=5.0),
],
)
]
assert estimate_total_duration(layers) == pytest.approx(10.0)
def test_multiple_clips_with_transition(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=3.0),
FakeClip(duration=2.0),
FakeClip(duration=5.0),
],
)
]
# 3 + 2 + 5 - 2 * 0.5 = 9.0
assert estimate_total_duration(layers, transition_duration=0.5) == pytest.approx(9.0)
def test_prefers_main_over_broll(self):
layers = [
FakeLayer(role="broll", clips=[FakeClip(duration=10.0)]),
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
]
assert estimate_total_duration(layers) == pytest.approx(5.0)
def test_prefers_broll_over_background(self):
layers = [
FakeLayer(role="background", clips=[FakeClip(duration=10.0)]),
FakeLayer(role="broll", clips=[FakeClip(duration=5.0)]),
]
assert estimate_total_duration(layers) == pytest.approx(5.0)
def test_main_layer_empty_clips(self):
layers = [FakeLayer(role="main", clips=[])]
assert estimate_total_duration(layers) == 0.0
def test_minimum_duration(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=0.01),
FakeClip(duration=0.01),
],
)
]
result = estimate_total_duration(layers, transition_duration=0.5)
assert result >= 0.1
def test_with_playback_speed(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=10.0, playback_speed=2.0),
FakeClip(duration=10.0, playback_speed=0.5),
],
)
]
# 5 + 20 = 25
assert estimate_total_duration(layers) == pytest.approx(25.0)
# ── can_pass_through ──────────────────────────────────────────────────────
class TestCanPassThrough:
def test_single_main_clip_no_effects(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is True
def test_single_broll_clip(self):
layers = [FakeLayer(role="broll", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is True
def test_single_background_clip(self):
layers = [FakeLayer(role="background", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is True
def test_multiple_layers(self):
layers = [
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
FakeLayer(role="overlay", clips=[FakeClip(duration=3.0)]),
]
assert can_pass_through(layers) is False
def test_overlay_layer(self):
layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is False
def test_multiple_clips_in_layer(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=3.0),
FakeClip(duration=2.0),
],
)
]
assert can_pass_through(layers) is False
def test_with_stickers(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers, has_stickers=True) is False
def test_with_watermark(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers, has_watermark=True) is False
def test_with_stickers_and_watermark(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers, has_stickers=True, has_watermark=True) is False
def test_empty_layer_list(self):
assert can_pass_through([]) is False
def test_audio_layer_only(self):
layers = [FakeLayer(role="audio", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is False