Compare commits

..

1 Commits

Author SHA1 Message Date
SaaS Frontend ae78918ae4 refactor(voices): Phase 1 - extract types, constants and utils
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m15s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m1s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 54s
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 Worker Image (pull_request) Successful in 1m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m23s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 43s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m42s
AI Code Review / AI Code Review (pull_request) Successful in 5m3s
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 / Unit Tests (pull_request) Failing after 4m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m58s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 16s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 16s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m46s
Extract types, constants, and utility functions from VoiceLibrary.tsx
into separate files following the same pattern as VoiceMaterialLibrary.

New files:
- types.ts: VoiceGender, VoiceLanguage, TabKey, display interfaces, mappers
- constants.ts: gender options, language options, clone status config
- utils/format.ts: genderLabel, languageLabel, genderClass, formatTime, formatFileSize
- utils/audio.ts: getAudioDuration
- test/pages/voices/smoke.test.tsx: smoke test for vitest related mode

Main file reduced from 1783 to 1647 lines.
Functionally equivalent, no behavior changes.

Signed-off-by: xiaoxia <xiaoxia@xiaoxiajianji.com>
2026-07-25 12:15:00 +08:00
90 changed files with 4765 additions and 17499 deletions
-162
View File
@@ -1,162 +0,0 @@
name: ACR Cleanup
on:
schedule:
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00
workflow_dispatch:
inputs:
pr_sha:
description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)"
required: false
default: ""
dry_run:
description: "预览模式(dry-run),不实际删除"
required: false
default: "true"
pull_request_target:
types: [closed]
branches: [develop, main]
concurrency:
group: acr-cleanup-${{ gitea.ref }}
cancel-in-progress: false
jobs:
cleanup:
name: ACR Image Cleanup
runs-on: ci-l2
timeout-minutes: 20
permissions:
contents: read
env:
ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com
ACR_NAMESPACE: xiaoxiakeji
ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa
GITEA_URL: https://git.xiaoxiajianji.com
GITEA_REPO: xiaoxia/xiaoxia-saas
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
- name: Get staging running images (whitelist)
id: protected_images
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set +e
echo "获取staging服务器运行中镜像作为白名单..."
mkdir -p ~/.ssh
echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_port="${STAGING_SSH_PORT:-22222}"
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
# 获取所有运行容器的镜像,提取tag部分
IMAGES=$(ssh -p "$staging_port" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no \
"root@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u)
PROTECTED_TAGS=""
if [ -n "$IMAGES" ]; then
while IFS= read -r img; do
# 从完整镜像名中提取tag(最后一个冒号后)
tag=$(echo "$img" | rev | cut -d: -f1 | rev)
if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then
if [ -z "$PROTECTED_TAGS" ]; then
PROTECTED_TAGS="$tag"
else
PROTECTED_TAGS="$PROTECTED_TAGS,$tag"
fi
fi
done <<< "$IMAGES"
fi
echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}"
echo "protected_tags=$PROTECTED_TAGS" >> $GITEA_OUTPUT
# ====== Docker登录 ======
- name: Docker login to ACR
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin
# ====== 模式1PR关闭时清理 ======
- name: Cleanup PR images (PR closed)
if: gitea.event_name == 'pull_request_target'
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PR_SHA: ${{ gitea.event.pull_request.head.sha }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
run: |
echo "============================================"
echo " PR #$PR_NUMBER 已关闭,清理对应镜像"
echo " Head SHA: ${PR_SHA::12}"
echo "============================================"
echo ""
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
--execute
# ====== 模式2Cron全量清理 ======
- name: Full cleanup (cron / manual)
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "============================================"
echo " ACR 全量清理(${{ gitea.event_name }}"
echo "============================================"
echo ""
# 决定是否dry-run
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
echo "模式: 预览模式 (dry-run)"
else
echo "模式: 执行模式"
fi
echo ""
python3 scripts/ci/acr_cleanup.py \
--keep 20 \
--protected-tags "$PROTECTED_TAGS" \
$DRY_RUN_FLAG
# ====== 模式3:手动指定PR SHA清理 ======
- name: Cleanup specific PR image (manual)
if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
PR_SHA: ${{ gitea.event.inputs.pr_sha }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "手动清理PR镜像: ${PR_SHA::12}"
echo ""
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
fi
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
$DRY_RUN_FLAG
+4 -95
View File
@@ -1230,7 +1230,7 @@ jobs:
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
needs:
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
if: startsWith(github.ref, 'refs/tags/v')
strategy:
fail-fast: false
matrix:
@@ -1307,16 +1307,10 @@ jobs:
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
# 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAG_NAME="${GITHUB_REF_NAME}"
else
TAG_NAME="${GITHUB_REF_NAME}-${GITHUB_SHA::8}"
fi
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_REF_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_REF_NAME}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
fi
@@ -1614,89 +1608,4 @@ jobs:
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
canary-release:
name: Canary Release to Production
runs-on: runtime-builder
timeout-minutes: 120
concurrency:
group: canary-release-production
cancel-in-progress: false
if: github.event_name == 'push' && github.ref_name == 'main'
needs:
- build-production
- staging-api-tests
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Notify canary release start
continue-on-error: true
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=start JOB_NAME="Canary Release" python3 scripts/ci_notify.py
- name: Install SSH client
shell: sh
run: |
set -eu
apt-get update -qq && apt-get install -y -qq openssh-client curl >/dev/null 2>&1
echo "openssh-client installed"
- name: Run canary release
shell: bash
env:
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set -eu
IMAGE_TAG="main-${GITHUB_SHA::8}"
export IMAGE_TAG
echo "Canary release version: $IMAGE_TAG"
bash scripts/ci/canary_release.sh
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- name: Notify on success
continue-on-error: true
if: success()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=success JOB_NAME="Canary Release" python3 scripts/ci_notify.py
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Canary Release" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
@@ -1,64 +0,0 @@
"""#642 - 生成任务新增 bgm_config 字段
Revision ID: 052_generation_task_bgm_config
Revises: 051_generation_task_resolution
Create Date: 2026-07-25
Changes:
1. generation_tasks 表新增 bgm_config 字段(JSON类型),存储用户自定义BGM配置
2. 为空时使用默认空字典
背景:
#642 一键生成支持自定义BGM 功能在 SQLAlchemy 模型中加了 bgm_config 字段,
但遗漏了 alembic migration,导致 staging 环境数据库没有该列,
创建生成任务时直接 500。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "052_generation_task_bgm_config"
down_revision = "051_generation_task_resolution"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否已存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column(
"bgm_config",
sa.JSON,
nullable=False,
server_default=sa.text("'{}'::json"),
),
)
def downgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is None:
return
op.drop_column("generation_tasks", "bgm_config")
+1550 -3014
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,128 +0,0 @@
import React from "react"
import {
PlayCircleOutlined,
CheckOutlined,
DeleteOutlined,
ExperimentOutlined,
LoadingOutlined,
CloseCircleOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { AssetItem } from "@/pages/assets/types"
import { thumbGradient } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
import { StatusPill } from "./AssetSkeleton"
/* ============================================================
* AssetCard — 素材卡片(网格视图)
* ============================================================ */
export interface AssetCardProps {
asset: AssetItem
selected: boolean
diagnosing?: boolean
onToggle: () => void
onDiagnose: () => void
onPlay: () => void
onDelete: () => void
}
const AssetCard: React.FC<AssetCardProps> = ({
asset,
selected,
diagnosing,
onToggle,
onDiagnose,
onPlay,
onDelete,
}) => (
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
{/* 缩略图区 */}
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
</span>
)}
{/* 处理中遮罩 */}
{asset.loading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
</div>
)}
{/* 失败状态标识 */}
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
<CloseCircleOutlined />
<span></span>
</div>
)}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
onClick={(e) => {
e.stopPropagation()
onPlay()
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<StatusPill status={asset.status} label={asset.statusLabel} />
{asset.duration && <span>{asset.duration}</span>}
</div>
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing || asset.loading || asset.status === "bad"}
onClick={(e) => {
e.stopPropagation()
onDiagnose()
}}
>
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
</div>
)
export default AssetCard
@@ -1,62 +0,0 @@
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import { TYPE_FILTER_OPTIONS, TIME_FILTER_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* AssetFilterBar — 筛选栏(搜索/类型/时间 + 全选/计数)
* ============================================================ */
export interface AssetFilterBarProps {
searchText: string
onSearchChange: (value: string) => void
filterType: string
onFilterTypeChange: (value: string) => void
filterTime: string
onFilterTimeChange: (value: string) => void
onSelectAll: () => void
totalCount: number
}
const AssetFilterBar: React.FC<AssetFilterBarProps> = ({
searchText,
onSearchChange,
filterType,
onFilterTypeChange,
filterTime,
onFilterTimeChange,
onSelectAll,
totalCount,
}) => (
<div className="xx-assets-filters">
<div className="xx-assets-filters-left">
<Input
placeholder="搜索素材名称..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterType}
onChange={onFilterTypeChange}
style={{ width: 120 }}
options={TYPE_FILTER_OPTIONS}
/>
<Select
value={filterTime}
onChange={onFilterTimeChange}
style={{ width: 120 }}
options={TIME_FILTER_OPTIONS}
/>
</div>
<div className="xx-assets-filters-right">
<Button buttonType="ghost" buttonSize="sm" onClick={onSelectAll}>
</Button>
<span className="xx-assets-filter-count"> {totalCount} </span>
</div>
</div>
)
export default AssetFilterBar
@@ -1,36 +0,0 @@
import React from "react"
import type { StatusType } from "@/pages/assets/types"
/* ============================================================
* StatusPill — 状态标签
* ============================================================ */
export interface StatusPillProps {
status: StatusType
label: string
}
export const StatusPill: React.FC<StatusPillProps> = ({ status, label }) => (
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
)
/* ============================================================
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
* ============================================================ */
export const SkeletonCard: React.FC = () => (
<div className="xx-asset-card xx-asset-skeleton">
<div className="xx-asset-thumb xx-skeleton-pulse" />
<div className="xx-asset-info">
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
<div
className="xx-skeleton-line xx-skeleton-pulse"
style={{
width: "100%",
height: 28,
marginTop: 8,
borderRadius: "var(--radius-xs)",
}}
/>
</div>
</div>
)
@@ -1,49 +0,0 @@
import React from "react"
import { Modal as AntModal, Select as AntSelect } from "antd"
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* BatchClassifyModal — 批量改分类弹窗
* ============================================================ */
export interface BatchClassifyModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
category: string
onCategoryChange: (value: string) => void
confirmLoading?: boolean
}
const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
category,
onCategoryChange,
confirmLoading,
}) => (
<AntModal
title={`批量改分类(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认修改"
cancelText="取消"
>
<div className="xx-batch-classify-modal">
<p className="xx-batch-classify-hint"> {selectedCount} </p>
<AntSelect
value={category || undefined}
onChange={(v) => onCategoryChange(v)}
placeholder="请选择分类"
style={{ width: "100%" }}
options={CATEGORY_OPTIONS}
/>
</div>
</AntModal>
)
export default BatchClassifyModal
@@ -1,67 +0,0 @@
import React from "react"
import { Modal as AntModal, Tag, Radio } from "antd"
/* ============================================================
* BatchMarkModal — 批量智能标记弹窗
* ============================================================ */
export type SmartViewType = "recommended" | "caution" | "high_risk"
export interface BatchMarkModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
smartView: SmartViewType
onSmartViewChange: (value: SmartViewType) => void
confirmLoading?: boolean
}
const BatchMarkModal: React.FC<BatchMarkModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
smartView,
onSmartViewChange,
confirmLoading,
}) => (
<AntModal
title={`批量智能标记(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认标记"
cancelText="取消"
>
<div className="xx-batch-mark-modal">
<p className="xx-batch-mark-hint"> {selectedCount} </p>
<Radio.Group
value={smartView}
onChange={(e) => onSmartViewChange(e.target.value)}
className="xx-batch-mark-options"
>
<div className="xx-batch-mark-option">
<Radio value="recommended">
<Tag color="success"></Tag>
<span className="xx-batch-mark-desc"></span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="caution">
<Tag color="warning"></Tag>
<span className="xx-batch-mark-desc">使</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="high_risk">
<Tag color="error"></Tag>
<span className="xx-batch-mark-desc">使</span>
</Radio>
</div>
</Radio.Group>
</div>
</AntModal>
)
export default BatchMarkModal
@@ -1,58 +0,0 @@
import React from "react"
import {
TagsOutlined,
FolderOutlined,
ThunderboltOutlined,
DeleteOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import { Button } from "@/components/ui"
/* ============================================================
* BatchOperationBar — 批量操作栏
* ============================================================ */
export interface BatchOperationBarProps {
selectedCount: number
onDeselectAll: () => void
onTagClick: () => void
onClassifyClick: () => void
onMarkClick: () => void
onBatchDelete: () => void
}
const BatchOperationBar: React.FC<BatchOperationBarProps> = ({
selectedCount,
onDeselectAll,
onTagClick,
onClassifyClick,
onMarkClick,
onBatchDelete,
}) => (
<div className="xx-assets-batch-bar">
<span className="xx-assets-batch-count"> {selectedCount} </span>
<Button buttonType="ghost" buttonSize="sm" onClick={onDeselectAll}>
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />} onClick={onTagClick}>
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<FolderOutlined />} onClick={onClassifyClick}>
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<ThunderboltOutlined />} onClick={onMarkClick}>
</Button>
<Popconfirm
title={`确定删除 ${selectedCount} 个素材?`}
onConfirm={onBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
)
export default BatchOperationBar
@@ -1,81 +0,0 @@
import React from "react"
import { Modal as AntModal, Tag, Radio, Input as AntInput } from "antd"
import { ExclamationCircleOutlined } from "@ant-design/icons"
/* ============================================================
* BatchTagModal — 批量打标签弹窗
* ============================================================ */
export interface BatchTagModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
tags: string[]
onTagInputChange: (value: string) => void
onTagInputKeyDown: (e: React.KeyboardEvent) => void
onRemoveTag: (tag: string) => void
tagInput: string
tagMode: "add" | "replace"
onTagModeChange: (mode: "add" | "replace") => void
confirmLoading?: boolean
}
const BatchTagModal: React.FC<BatchTagModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
tags,
onTagInputChange,
onTagInputKeyDown,
onRemoveTag,
tagInput,
tagMode,
onTagModeChange,
confirmLoading,
}) => (
<AntModal
title={`批量打标签(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认打标签"
cancelText="取消"
>
<div className="xx-batch-tag-modal">
<div className="xx-batch-tag-mode">
<span className="xx-batch-tag-mode-label"></span>
<Radio.Group value={tagMode} onChange={(e) => onTagModeChange(e.target.value)}>
<Radio value="add"></Radio>
<Radio value="replace"></Radio>
</Radio.Group>
</div>
<div className="xx-batch-tag-input-row">
<AntInput
placeholder="输入标签后按 Enter 添加"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={onTagInputKeyDown}
style={{ flex: 1 }}
/>
</div>
{tags.length > 0 && (
<div className="xx-batch-tag-list">
{tags.map((tag) => (
<Tag key={tag} closable onClose={() => onRemoveTag(tag)} color="blue">
{tag}
</Tag>
))}
</div>
)}
{tagMode === "replace" && tags.length > 0 && (
<div className="xx-batch-tag-warning">
<ExclamationCircleOutlined />
</div>
)}
</div>
</AntModal>
)
export default BatchTagModal
@@ -1,64 +0,0 @@
import React from "react"
import { Modal as AntModal } from "antd"
import { Input, Select } from "@/components/ui"
import type { AssetKind } from "@/pages/assets/types"
import { LIBRARY_KIND_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* CreateLibraryModal — 新建视频库弹窗
* ============================================================ */
export interface CreateLibraryModalProps {
open: boolean
onCancel: () => void
onOk: () => void
name: string
onNameChange: (value: string) => void
kind: AssetKind
onKindChange: (value: AssetKind) => void
confirmLoading?: boolean
}
const CreateLibraryModal: React.FC<CreateLibraryModalProps> = ({
open,
onCancel,
onOk,
name,
onNameChange,
kind,
onKindChange,
confirmLoading,
}) => (
<AntModal
title="新建视频库"
open={open}
onCancel={onCancel}
onOk={onOk}
okText="创建"
cancelText="取消"
destroyOnClose
confirmLoading={confirmLoading}
>
<div className="xx-asset-form-body">
<div>
<div className="xx-asset-form-label"></div>
<Input
placeholder="请输入视频库名称"
value={name}
onChange={(e) => onNameChange(e.target.value)}
maxLength={50}
/>
</div>
<div>
<div className="xx-asset-form-label"></div>
<Select
value={kind}
onChange={(v) => onKindChange(v)}
style={{ width: "100%" }}
options={LIBRARY_KIND_OPTIONS}
/>
</div>
</div>
</AntModal>
)
export default CreateLibraryModal
@@ -1,70 +0,0 @@
import React from "react"
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { LibraryItem } from "@/pages/assets/types"
import { kindLabel } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
/* ============================================================
* LibrarySidebar — 左侧素材库列表
* ============================================================ */
export interface LibrarySidebarProps {
libraries: LibraryItem[]
activeLibId: string
onSelect: (id: string) => void
onDelete: (id: string) => void
onCreateClick: () => void
}
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
libraries,
activeLibId,
onSelect,
onDelete,
onCreateClick,
}) => (
<div className="xx-asset-library-list">
{libraries.map((lib) => (
<div
key={lib.id}
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
onClick={() => onSelect(lib.id)}
>
<div className="xx-asset-library-header">
<h4>
{kindIcon(lib.kind)} {lib.name}
</h4>
<Popconfirm
title={`确定删除视频库 "${lib.name}"`}
onConfirm={(e) => {
e?.stopPropagation()
onDelete(lib.id)
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-asset-library-delete"
onClick={(e) => e.stopPropagation()}
title="删除视频库"
>
<DeleteOutlined />
</button>
</Popconfirm>
</div>
<span>
{kindLabel(lib.kind)} · {lib.count}
</span>
</div>
))}
{/* 新建视频库 */}
<div className="xx-asset-library-add" onClick={onCreateClick}>
<PlusOutlined />
</div>
</div>
)
export default LibrarySidebar
@@ -1,34 +0,0 @@
import React from "react"
import { Modal as AntModal } from "antd"
import type { AssetItem } from "@/pages/assets/types"
/* ============================================================
* PlayModal — 视频/音频播放弹窗
* ============================================================ */
export interface PlayModalProps {
open: boolean
asset: AssetItem | null
onClose: () => void
}
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
<AntModal
title={asset?.name ?? "播放"}
open={open}
onCancel={onClose}
footer={null}
width={640}
destroyOnClose
>
{asset?.fileUrl ? (
<video src={asset.fileUrl} controls autoPlay className="xx-asset-video-player" />
) : (
<div className="xx-asset-empty-fallback">
<p></p>
<p className="xx-asset-empty-fallback-id"> ID: {asset?.id}</p>
</div>
)}
</AntModal>
)
export default PlayModal
@@ -1,70 +0,0 @@
import React from "react"
import { Drawer } from "antd"
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
import type { BatchOperationResult } from "@/api/assets"
/* ============================================================
* ResultDrawer — 操作结果抽屉
* ============================================================ */
export interface ResultDrawerProps {
open: boolean
title: string
result: BatchOperationResult | null
onClose: () => void
}
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
{result && (
<div className="xx-batch-result">
<div className="xx-batch-result-summary">
<div className="xx-batch-result-stat">
<span className="xx-batch-result-total"> {result.total} </span>
</div>
<div className="xx-batch-result-stat success">
<CheckCircleOutlined />
<span> {result.success_count} </span>
</div>
{result.failure_count > 0 && (
<div className="xx-batch-result-stat fail">
<CloseCircleOutlined />
<span> {result.failure_count} </span>
</div>
)}
</div>
{(result?.succeeded?.length ?? 0) > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title success">
<CheckCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{result?.succeeded?.map((id) => (
<div key={id} className="xx-batch-result-id">
{id}
</div>
))}
</div>
</div>
)}
{(result?.failed?.length ?? 0) > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title fail">
<CloseCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{result?.failed?.map((id) => (
<div key={id} className="xx-batch-result-id fail">
{id}
</div>
))}
</div>
</div>
)}
</div>
)}
</Drawer>
)
export default ResultDrawer
@@ -1,56 +0,0 @@
import React from "react"
import { Modal as AntModal } from "antd"
/* ============================================================
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
* ============================================================ */
export interface UploadProgressModalProps {
open: boolean
progress: number
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
<AntModal
open={open}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{progress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
)
export default UploadProgressModal
-66
View File
@@ -1,66 +0,0 @@
/**
* 素材库常量
*/
import type { AssetKind } from "./types"
/** 文件大小限制 */
export const MAX_FILE_SIZE = 2048 * 1024 * 1024
export const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024
/** 素材类型标签 */
export const KIND_LABELS: Record<AssetKind, string> = {
video: "视频",
image: "图片",
voice: "配音",
}
/** 类型筛选选项 */
export const TYPE_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "image", label: "图片" },
]
/** 时间筛选选项 */
export const TIME_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "week", label: "近一周" },
{ value: "month", label: "近一月" },
]
/** 新建库类型选项(当前仅支持视频和图片) */
export const LIBRARY_KIND_OPTIONS: { value: AssetKind; label: string }[] = [
{ value: "video", label: "视频" },
{ value: "image", label: "图片" },
]
/** 分类选项 */
export const CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: "person", label: "人物" },
{ value: "scenic", label: "风景" },
{ value: "product", label: "产品" },
{ value: "food", label: "美食" },
{ value: "animal", label: "动物" },
{ value: "architecture", label: "建筑" },
{ value: "other", label: "其他" },
]
/** 智能标记视图 */
export const SMART_VIEW_OPTIONS: {
value: "recommended" | "caution" | "high_risk"
label: string
color: string
}[] = [
{ value: "recommended", label: "推荐", color: "success" },
{ value: "caution", label: "慎用", color: "warning" },
{ value: "high_risk", label: "高风险", color: "error" },
]
/** 状态配置 */
export const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
ok: { label: "合格", className: "xx-status-pill xx-status-pill-ok" },
warn: { label: "待优化", className: "xx-status-pill xx-status-pill-warn" },
bad: { label: "不合格", className: "xx-status-pill xx-status-pill-bad" },
info: { label: "待诊断", className: "xx-status-pill xx-status-pill-info" },
}
@@ -1,301 +0,0 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
deleteAsset,
getAssetDiagnosis,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { AssetItem } from "../types"
import type { SmartViewType } from "../components/BatchMarkModal"
/**
* 素材操作 Hook
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
* 以及相关弹窗和结果展示的状态管理
*/
interface UseAssetOperationsProps {
selectedIds: Set<string>
setSelectedIds: (ids: Set<string>) => void
}
export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOperationsProps) {
const queryClient = useQueryClient()
/* ── 诊断状态 ── */
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
/* ── 批量操作弹窗状态 ── */
const [tagModalOpen, setTagModalOpen] = useState(false)
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [markModalOpen, setMarkModalOpen] = useState(false)
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
/* ── 批量打标签表单 ── */
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
/* ── 批量改分类表单 ── */
const [batchCategory, setBatchCategory] = useState("")
/* ── 批量智能标记表单 ── */
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
/* ── 操作结果 ── */
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
const [operationTitle, setOperationTitle] = useState("")
/* ── 批量操作 loading ── */
const [batchLoading, setBatchLoading] = useState(false)
/* ── 刷新数据辅助函数 ── */
const invalidateAssets = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient])
/* ── 诊断 ── */
const handleDiagnose = useCallback(
async (asset: AssetItem) => {
setDiagnosingId(asset.id)
try {
const result = await getAssetDiagnosis(asset.id)
const score = result.readiness_score ?? "-"
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
} catch {
message.error(`"${asset.name}" 诊断失败`)
} finally {
setDiagnosingId(null)
}
},
[queryClient],
)
/* ── 单个素材删除 ── */
const handleSingleDelete = useCallback(
async (assetId: string) => {
try {
await deleteAsset(assetId)
invalidateAssets()
// 从选中集合中移除
setSelectedIds(
(() => {
const next = new Set(selectedIds)
next.delete(assetId)
return next
})(),
)
message.success("素材已删除")
} catch {
message.error("删除失败,请重试")
}
},
[invalidateAssets, selectedIds, setSelectedIds],
)
/* ── 显示操作结果 ── */
const showOperationResult = useCallback(
(result: BatchOperationResult, title: string, clearSelection = true) => {
setOperationResult(result)
setOperationTitle(title)
setResultDrawerOpen(true)
if (clearSelection) setSelectedIds(new Set())
},
[setSelectedIds],
)
/* ── 批量删除 ── */
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showOperationResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showOperationResult])
/* ── 批量打标签 ── */
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showOperationResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showOperationResult])
/* ── 标签输入处理 ── */
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
/* ── 批量改分类 ── */
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showOperationResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showOperationResult])
/* ── 批量智能标记 ── */
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showOperationResult(result, "批量智能标记")
setMarkModalOpen(false)
const labelMap: Record<SmartViewType, string> = {
recommended: "推荐",
caution: "慎用",
high_risk: "高风险",
}
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
/* ── 关闭结果 Drawer ── */
const handleResultDrawerClose = useCallback(() => {
setResultDrawerOpen(false)
setOperationResult(null)
}, [])
return {
// 诊断
diagnosingId,
handleDiagnose,
// 单个操作
handleSingleDelete,
// 批量操作 loading
batchLoading,
// 批量打标签
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
// 批量改分类
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
handleBatchClassify,
// 批量智能标记
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
handleBatchMark,
// 批量删除
handleBatchDelete,
// 操作结果
resultDrawerOpen,
operationResult,
operationTitle,
handleResultDrawerClose,
}
}
@@ -1,40 +0,0 @@
import { useState, useCallback } from "react"
import type { AssetItem } from "../types"
/**
* 素材选中态管理 Hook
* 封装单选、全选、取消全选等选中逻辑
*/
interface UseAssetSelectionProps {
filteredAssets: AssetItem[]
}
export function useAssetSelection({ filteredAssets }: UseAssetSelectionProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const selectAll = useCallback(() => {
setSelectedIds(new Set(filteredAssets.map((a) => a.id)))
}, [filteredAssets])
const deselectAll = useCallback(() => {
setSelectedIds(new Set())
}, [])
return {
selectedIds,
setSelectedIds,
toggleSelect,
selectAll,
deselectAll,
selectedCount: selectedIds.size,
}
}
@@ -1,65 +0,0 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { uploadAssetDirect } from "@/api/assets"
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
/**
* 素材上传 Hook
* 封装上传状态、进度管理和上传逻辑
*/
interface UseAssetUploadProps {
effectiveLibId: string
}
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
const queryClient = useQueryClient()
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const handleUpload = useCallback(
async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`)
return
}
if (!effectiveLibId) {
message.warning("请先选择或创建一个视频库")
return
}
setUploading(true)
setUploadProgress(0)
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`)
}
await uploadAssetDirect({
file,
library_id: effectiveLibId,
onProgress: (pct) => setUploadProgress(pct),
})
message.success(`"${file.name}" 上传成功`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : ""
console.error("[handleUpload] 上传失败:", err)
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`)
// 错误时延迟关闭弹窗,让用户能看到错误提示
await new Promise((r) => setTimeout(r, 1500))
} finally {
setUploading(false)
setUploadProgress(0)
}
},
[effectiveLibId, queryClient],
)
return {
uploading,
uploadProgress,
handleUpload,
}
}
@@ -1,118 +0,0 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import {
getAssetLibraries,
getAssets,
type AssetLibraryItem,
type AssetItem as ApiAssetItem,
} from "@/api/assets"
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
/**
* 素材库数据 Hook
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
*/
export function useAssetsData() {
/* ── 视频库列表查询 ── */
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
staleTime: 60_000,
})
const libraries: LibraryItem[] = useMemo(
() =>
(Array.isArray(apiLibraries) ? apiLibraries : [])
.map(mapLibrary)
.filter((lib) => lib.kind === "video"),
[apiLibraries],
)
/* ── 当前选中的视频库 ── */
const [activeLibId, setActiveLibId] = useState<string>("")
// 当库列表加载完成后,自动选中第一个
const effectiveLibId = activeLibId || libraries[0]?.id || ""
/* ── 当前库的素材列表查询 ── */
const {
data: apiAssets = { items: [], total: 0 },
isLoading: assetsLoading,
isError: assetsError,
error: assetsErrorObj,
refetch: refetchAssets,
} = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({
queryKey: ["assets", effectiveLibId],
queryFn: () =>
getAssets(effectiveLibId, {
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
status: "ready,uploading,ingesting,processing,pending,error,failed",
}),
enabled: !!effectiveLibId,
staleTime: 30_000,
})
const assets: AssetItem[] = useMemo(
() => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset),
[apiAssets],
)
/* ── 筛选状态 ── */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterTime, setFilterTime] = useState<string>("all")
/* ── 筛选后的素材列表 ── */
const filteredAssets = useMemo(() => {
let list = assets
/* 按素材类型过滤 */
if (filterType !== "all") {
list = list.filter((a) => a.kind === filterType)
}
/* 按时间筛选 */
if (filterTime !== "all") {
const now = new Date()
list = list.filter((a) => {
const d = new Date(a.createdAt)
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
if (filterTime === "today") return diffDays < 1
if (filterTime === "week") return diffDays < 7
if (filterTime === "month") return diffDays < 30
return true
})
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((a) => a.name.toLowerCase().includes(q))
}
return list
}, [assets, filterType, filterTime, searchText])
return {
// 视频库
libraries,
libLoading,
activeLibId,
setActiveLibId,
effectiveLibId,
// 素材列表
assets,
assetsLoading,
assetsError,
assetsErrorObj,
refetchAssets,
// 筛选
searchText,
setSearchText,
filterType,
setFilterType,
filterTime,
setFilterTime,
filteredAssets,
}
}
@@ -1,106 +0,0 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { createAssetLibrary, deleteAssetLibrary } from "@/api/assets"
import type { AssetKind, LibraryItem } from "../types"
/**
* 视频库管理 Hook
* 封装视频库的创建、删除操作,以及新建弹窗的表单状态
*/
interface UseLibraryManagementProps {
libraries: LibraryItem[]
activeLibId: string
setActiveLibId: (id: string) => void
effectiveLibId: string
}
export function useLibraryManagement({
libraries,
setActiveLibId,
effectiveLibId,
}: UseLibraryManagementProps) {
const queryClient = useQueryClient()
/* ── 状态 ── */
const [createModalOpen, setCreateModalOpen] = useState(false)
const [newLibName, setNewLibName] = useState("")
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
/* ── Mutations ── */
const createLibMutation = useMutation({
mutationFn: createAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库创建成功")
},
onError: () => {
message.error("创建视频库失败")
},
})
const deleteLibMutation = useMutation({
mutationFn: deleteAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库已删除")
},
onError: () => {
message.error("删除视频库失败")
},
})
/* ── 新建视频库 ── */
const handleCreateLibrary = useCallback(async () => {
if (!newLibName.trim()) {
message.warning("请输入视频库名称")
return
}
try {
const newLib = await createLibMutation.mutateAsync({
name: newLibName.trim(),
kind: newLibKind,
})
setActiveLibId(newLib.id)
setCreateModalOpen(false)
setNewLibName("")
setNewLibKind("video")
} catch {
// error handled in mutation
}
}, [newLibName, newLibKind, createLibMutation, setActiveLibId])
/* ── 删除视频库 ── */
const handleDeleteLibrary = useCallback(
async (id: string) => {
try {
await deleteLibMutation.mutateAsync(id)
if (effectiveLibId === id) {
const remaining = libraries.filter((l) => l.id !== id)
if (remaining.length > 0) setActiveLibId(remaining[0].id)
else setActiveLibId("")
}
} catch {
// error handled in mutation
}
},
[deleteLibMutation, effectiveLibId, libraries, setActiveLibId],
)
return {
// 弹窗状态
createModalOpen,
setCreateModalOpen,
// 表单状态
newLibName,
setNewLibName,
newLibKind,
setNewLibKind,
// Mutations
isCreating: createLibMutation.isPending,
isDeleting: deleteLibMutation.isPending,
// Handlers
handleCreateLibrary,
handleDeleteLibrary,
}
}
-115
View File
@@ -1,115 +0,0 @@
/**
* 素材库类型定义
*/
import type { AssetLibraryItem, AssetItem as ApiAssetItem } from "@/api/assets"
import { formatDuration } from "./utils/format"
export type AssetKind = "video" | "image" | "voice"
export type StatusType = "ok" | "warn" | "bad" | "info"
export interface LibraryItem {
id: string
name: string
kind: AssetKind
count: number
}
export interface AssetItem {
id: string
name: string
kind: AssetKind
thumbUrl?: string
fileUrl?: string
status: StatusType
statusLabel: string
/** 是否处于处理中状态(上传中/入库中/诊断中) */
loading?: boolean
duration?: string
size: number
createdAt: string
}
/** 根据 mime_type 推断前端 AssetKind */
export const inferKind = (mimeType: string): AssetKind => {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("audio/")) return "voice"
return "image"
}
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
export const inferStatus = (
score?: number,
classificationStatus?: string,
assetStatus?: string,
): { status: StatusType; label: string; loading?: boolean } => {
// 已删除素材(正常情况列表已过滤,这里是防御性处理)
if (assetStatus === "deleted") {
return { status: "bad", label: "已删除" }
}
// 处理中状态:上传中 / 入库中 / 处理中
if (
assetStatus === "uploading" ||
assetStatus === "ingesting" ||
assetStatus === "processing" ||
assetStatus === "pending"
) {
return { status: "info", label: "处理中", loading: true }
}
// 失败状态
if (assetStatus === "error" || assetStatus === "failed") {
return { status: "bad", label: "处理失败" }
}
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
if (assetStatus === "ready") {
if (score == null) return { status: "info", label: "待诊断" }
if (score >= 70) return { status: "ok", label: "合格" }
if (score >= 40) return { status: "warn", label: "待优化" }
return { status: "bad", label: "不合格" }
}
// 素材未就绪:classification 正在处理中
if (classificationStatus === "processing" || classificationStatus === "pending") {
return { status: "info", label: "处理中", loading: true }
}
if (score == null) return { status: "info", label: "待诊断" }
if (score >= 70) return { status: "ok", label: "合格" }
if (score >= 40) return { status: "warn", label: "待优化" }
return { status: "bad", label: "不合格" }
}
/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
export const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
id: item.id,
name: item.name,
kind: item.kind || inferKind("video"),
count: item.asset_count ?? 0,
})
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
export const mapAsset = (item: ApiAssetItem): AssetItem => {
const { status, label, loading } = inferStatus(
item.quality_score ?? undefined,
item.classification_status ?? undefined,
item.status ?? undefined,
)
const metadata = item.metadata || {}
const kind = inferKind(item.mime_type || "")
return {
id: item.id,
name: item.name,
kind,
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
// 处理中的素材没有缩略图,显示占位符
thumbUrl: loading
? undefined
: (item.thumbnail_url as string | undefined) ||
(metadata.thumbnail_url as string | undefined) ||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
fileUrl: (item.file_url as string | undefined) || (metadata.file_url as string | undefined),
status,
statusLabel: label,
loading,
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
}
}
-19
View File
@@ -1,19 +0,0 @@
/**
* 素材相关工具函数
*/
import type { AssetKind } from "../types"
import { KIND_LABELS } from "../constants"
export const kindLabel = (kind: AssetKind): string => KIND_LABELS[kind] ?? kind
/** 根据素材类型返回渐变背景 */
export const thumbGradient = (kind: AssetKind): string => {
switch (kind) {
case "video":
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
}
}
-22
View File
@@ -1,22 +0,0 @@
/**
* 格式化工具函数
*/
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
export const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export const formatDate = (iso: string): string =>
new Date(iso).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
@@ -1,15 +0,0 @@
import React from "react"
import { VideoCameraOutlined, PictureOutlined, AudioOutlined } from "@ant-design/icons"
import type { AssetKind } from "../types"
/** 根据素材类型返回对应图标 */
export const kindIcon = (kind: AssetKind): React.ReactNode => {
switch (kind) {
case "video":
return <VideoCameraOutlined />
case "image":
return <PictureOutlined />
case "voice":
return <AudioOutlined />
}
}
File diff suppressed because it is too large Load Diff
@@ -1,17 +0,0 @@
import React from "react"
/** 克隆音色卡片骨架屏 */
const CloneCardSkeleton: React.FC = () => (
<div className="xx-voice-card xx-skeleton-clone">
<div className="xx-skeleton-clone-avatar" />
<div className="xx-skeleton-clone-info">
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--name" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--status" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--desc" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--meta" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--footer" />
</div>
</div>
)
export default CloneCardSkeleton
@@ -1,100 +0,0 @@
import React from "react"
import {
SoundOutlined,
CloseCircleOutlined,
ReloadOutlined,
DeleteOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
export interface CloneDetailModalProps {
voice: ClonedVoiceDisplay
onClose: () => void
onUse: () => void
onDelete: () => void
onRetry: () => void
}
/** 克隆音色详情弹窗 */
const CloneDetailModal: React.FC<CloneDetailModalProps> = ({
voice,
onClose,
onUse,
onDelete,
onRetry,
}) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const genderText =
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender || "未知"
const langText = voice.language || "未知"
return (
<div className="xx-clone-detail-overlay" onClick={onClose}>
<div className="xx-clone-detail" onClick={(e) => e.stopPropagation()}>
<button type="button" className="xx-clone-detail-close" onClick={onClose}>
<CloseCircleOutlined />
</button>
<div className="xx-clone-detail-header">
<div className="xx-clone-avatar">
<SoundOutlined />
</div>
<div>
<h3 className="xx-clone-detail-name">{voice.name}</h3>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{voice.description && <p className="xx-clone-detail-desc">{voice.description}</p>}
<div className="xx-clone-detail-info">
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label"></span>
<span>{genderText}</span>
</div>
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label"></span>
<span>{langText}</span>
</div>
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label"></span>
<span>{voice.sourceName}</span>
</div>
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label"></span>
<span>{voice.createdAt}</span>
</div>
{voice.errorMessage && (
<div className="xx-clone-detail-row" style={{ color: "var(--error-color, #ef4444)" }}>
<span className="xx-clone-detail-label"></span>
<span>{voice.errorMessage}</span>
</div>
)}
</div>
<div className="xx-clone-detail-actions">
{voice.status === "failed" && (
<Button buttonType="ghost" buttonSize="sm" icon={<ReloadOutlined />} onClick={onRetry}>
</Button>
)}
<Button buttonType="ghost" buttonSize="sm" icon={<DeleteOutlined />} onClick={onDelete}>
</Button>
{voice.status === "ready" && (
<Button buttonType="primary" buttonSize="sm" onClick={onUse}>
使
</Button>
)}
</div>
</div>
</div>
)
}
export default CloneDetailModal
@@ -1,180 +0,0 @@
import React from "react"
import {
SoundOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
ReloadOutlined,
CloseCircleOutlined,
UserOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
export interface CloneVoiceCardProps {
voice: ClonedVoiceDisplay
isPlaying: boolean
currentTime: number
onPlay: () => void
onPause: () => void
onUse: () => void
onDelete: () => void
onRetry: () => void
onShowDetail: () => void
}
/** 克隆音色卡片 */
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
voice,
isPlaying,
currentTime,
onPlay,
onPause,
onUse,
onDelete,
onRetry,
onShowDetail,
}) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
const genderText =
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender
return (
<div
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
onClick={isFailed ? undefined : onShowDetail}
>
{/* 右上角操作按钮 */}
<div className="xx-clone-card-actions">
<Tooltip title="删除">
<button
type="button"
className="xx-clone-action-btn xx-clone-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<DeleteOutlined />
</button>
</Tooltip>
{isFailed && (
<Tooltip title="重试">
<button
type="button"
className="xx-clone-action-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
</Tooltip>
)}
</div>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-clone-card-header">
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
<SoundOutlined />
</div>
<div className="xx-clone-header-info">
<h4 className="xx-clone-name" title={voice.name}>
{voice.name}
</h4>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{/* 描述 */}
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
{/* 元信息 */}
<div className="xx-clone-meta">
{(voice.gender || voice.language) && (
<span className="xx-clone-meta-item">
<UserOutlined />
{genderText}
{voice.language ? ` · ${voice.language}` : ""}
</span>
)}
<span className="xx-clone-meta-item">{voice.createdAt}</span>
</div>
{/* 错误信息 */}
{isFailed && voice.errorMessage && (
<div className="xx-clone-error">
<CloseCircleOutlined />
<span>{voice.errorMessage}</span>
</div>
)}
{/* 底部操作区 */}
<div className="xx-clone-footer">
{voice.status === "ready" && (
<>
<button
type="button"
className="xx-clone-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="xx-clone-progress">
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
}}
/>
</div>
<button
type="button"
className="xx-clone-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse()
}}
>
使
</button>
</>
)}
{isProcessing && (
<div className="xx-clone-processing-hint">
<ReloadOutlined spin />
...
</div>
)}
{isFailed && (
<button
type="button"
className="xx-clone-retry-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
)}
</div>
</div>
)
}
export default CloneVoiceCard
@@ -1,37 +0,0 @@
import React from "react"
import { AudioOutlined } from "@ant-design/icons"
import { type AssetItem } from "@/api/assets"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface MaterialVoiceCardProps {
asset: AssetItem
onClick?: () => void
}
/** 配音素材卡片 */
const MaterialVoiceCard: React.FC<MaterialVoiceCardProps> = ({ asset, onClick }) => {
const duration = (asset.metadata?.duration as number) || 0
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return (
<div className="vmat-card" onClick={onClick}>
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>{asset.file_size ? formatFileSize(asset.file_size) : "--"}</span>
</div>
</div>
</div>
)
}
export default MaterialVoiceCard
@@ -1,258 +0,0 @@
import React from "react"
import { RobotOutlined } from "@ant-design/icons"
import { Modal, message } from "antd"
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import { genderLabel } from "@/pages/voices/utils/format"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export interface TtsModalProps {
open: boolean
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
onClose: () => void
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onSynthesize: () => void
onSave: () => void
}
/** AI 配音弹窗 */
const TtsModal: React.FC<TtsModalProps> = ({
open,
ttsText,
ttsVoiceId,
ttsSpeed,
ttsStatus,
ttsAudioUrl,
ttsError,
presetVoices,
onClose,
onTextChange,
onVoiceChange,
onSpeedChange,
onSynthesize,
onSave,
}) => {
return (
<Modal title="AI 配音" open={open} onCancel={onClose} footer={null} width={560}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 文本输入 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={ttsText}
onChange={(e) => onTextChange(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={2000}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={ttsVoiceId}
onChange={(e) => onVoiceChange(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{ttsSpeed.toFixed(1)}x
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 合成按钮 */}
<button
type="button"
onClick={() => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本")
return
}
onSynthesize()
}}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background:
ttsStatus === "synthesizing" || !ttsText.trim()
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: ttsStatus === "synthesizing" || !ttsText.trim() ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
</button>
{/* 错误提示 */}
{ttsError && (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={onSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
)}
</div>
</Modal>
)
}
export default TtsModal
@@ -1,329 +0,0 @@
import React from "react"
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
import { Modal, Upload, message } from "antd"
import { type VoiceGender } from "@/pages/voices/types"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface UploadVoiceModalProps {
open: boolean
uploadFile: File | null
uploadName: string
uploadGender: VoiceGender
uploadDesc: string
uploadProgress: number | null
onClose: () => void
onFileSelect: (file: File) => void
onFileRemove: () => void
onNameChange: (name: string) => void
onGenderChange: (gender: VoiceGender) => void
onDescChange: (desc: string) => void
onUpload: () => void
}
/** 上传音频弹窗 */
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
open,
uploadFile,
uploadName,
uploadGender,
uploadDesc,
uploadProgress,
onClose,
onFileSelect,
onFileRemove,
onNameChange,
onGenderChange,
onDescChange,
onUpload,
}) => {
return (
<Modal
title="上传音频"
open={open}
onCancel={() => {
if (uploadProgress !== null) return // 上传中不可关闭
onClose()
}}
footer={null}
width={520}
maskClosable={uploadProgress === null}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 拖拽上传区 */}
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
onFileSelect(file)
return false
}}
onRemove={() => {
onFileRemove()
}}
showUploadList={false}
disabled={uploadProgress !== null}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
{/* 已选文件信息 */}
{uploadFile && (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{uploadFile.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(uploadFile.size)}
</div>
</div>
</div>
)}
{/* 上传进度 */}
{uploadProgress !== null && (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{uploadProgress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{uploadProgress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${uploadProgress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{/* 名称 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<input
value={uploadName}
onChange={(e) => onNameChange(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
{/* 性别选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => onGenderChange(g)}
disabled={uploadProgress !== null}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: uploadGender === g ? 600 : 400,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={uploadDesc}
onChange={(e) => onDescChange(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
{/* 操作按钮 */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={onClose}
disabled={uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={() => {
if (!uploadFile) {
message.warning("请先选择音频文件")
return
}
if (!uploadName.trim()) {
message.warning("请输入素材名称")
return
}
onUpload()
}}
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "not-allowed"
: "pointer",
}}
>
{uploadProgress !== null ? "上传中..." : "开始上传"}
</button>
</div>
</div>
</Modal>
)
}
export default UploadVoiceModal
@@ -1,133 +0,0 @@
import React, { useRef } from "react"
import {
SoundOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
HeartOutlined,
} from "@ant-design/icons"
import { type VoiceGender } from "@/pages/voices/types"
import { genderClass, formatTime } from "@/pages/voices/utils/format"
export interface VoiceCardProps {
id: string
name: string
subtitle: string
tags: string[]
duration: number
gender: VoiceGender
isPlaying: boolean
isSelected: boolean
currentTime: number
starred?: boolean
status?: "ready" | "processing" | "failed"
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onSelect?: () => void
onToggleStar?: () => void
}
/** 音色卡片组件(预置音色 + 克隆音色统一) */
const VoiceCard: React.FC<VoiceCardProps> = ({
id: _id,
name,
subtitle,
tags,
duration,
gender,
isPlaying,
isSelected,
currentTime,
starred,
status = "ready",
onPlay,
onPause,
onSeek,
onSelect,
onToggleStar,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current || status !== "ready") return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
onSeek(percent * duration)
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div
className={`xx-voice-card ${genderClass(gender)}${isSelected ? " selected" : ""}${isPlaying ? " playing" : ""}`}
onClick={onSelect}
>
<div className="xx-voice-avatar">
<SoundOutlined />
</div>
<div className="xx-voice-info">
<div className="xx-voice-name-row">
<h4 className="xx-voice-name" title={name}>
{name}
</h4>
{starred !== undefined && (
<button
className={`xx-voice-star${starred ? " active" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleStar?.()
}}
title={starred ? "取消收藏" : "收藏"}
>
<HeartOutlined />
</button>
)}
</div>
<div className="xx-voice-subtitle">{subtitle}</div>
<div className="xx-voice-tags">
{tags.slice(0, 3).map((tag) => (
<span key={tag} className="xx-voice-tag">
{tag}
</span>
))}
</div>
</div>
{status === "processing" && (
<div className="xx-voice-status xx-voice-status--processing">
<span className="xx-voice-status-dot" />
...
</div>
)}
{status === "failed" && (
<div className="xx-voice-status xx-voice-status--failed"></div>
)}
{status === "ready" && <div className="xx-voice-wave" />}
{status === "ready" && (
<div className="xx-voice-controls">
<button
className="xx-voice-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="xx-voice-progress" onClick={handleProgressClick}>
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
</div>
<span className="xx-voice-time">
{isPlaying ? formatTime(currentTime) : formatTime(duration)}
</span>
</div>
)}
</div>
)
}
export default VoiceCard
@@ -1,61 +0,0 @@
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import { Input, Select } from "@/components/ui"
export interface VoiceFilterBarProps {
searchText: string
filterGender: string
filterLang: string
onSearchChange: (value: string) => void
onGenderChange: (value: string) => void
onLangChange: (value: string) => void
}
/** 音色筛选栏(搜索 + 性别 + 语言) */
const VoiceFilterBar: React.FC<VoiceFilterBarProps> = ({
searchText,
filterGender,
filterLang,
onSearchChange,
onGenderChange,
onLangChange,
}) => {
return (
<div className="xx-voices-filters">
<Input
placeholder="搜索音色名称、标签..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 260 }}
/>
<Select
value={filterGender}
onChange={onGenderChange}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部音色" },
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "elderly", label: "老年" },
]}
/>
<Select
value={filterLang}
onChange={onLangChange}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部语言" },
{ value: "zh", label: "中文" },
{ value: "en", label: "英文" },
{ value: "ja", label: "日文" },
{ value: "ko", label: "韩文" },
]}
/>
</div>
)
}
export default VoiceFilterBar
@@ -1,102 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react"
/**
* 音频播放控制 Hook
* 封装当前播放状态、播放/暂停/跳转控制,使用 setInterval 模拟进度更新
* (适用于预置音色/克隆音色卡片的播放按钮交互)
*/
export function useAudioPlayer() {
const [playingId, setPlayingId] = useState<string | null>(null)
const [currentTime, setCurrentTime] = useState(0)
const intervalRef = useRef<number | null>(null)
/** 开始播放指定音色(从 startTime 开始,默认从 0 开始) */
const handlePlay = useCallback(
(voiceId: string, duration: number, startTime: number = 0) => {
if (playingId === voiceId) return
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
setPlayingId(voiceId)
setCurrentTime(startTime)
intervalRef.current = window.setInterval(() => {
setCurrentTime((prev) => {
if (prev >= duration) {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setPlayingId(null)
return 0
}
return prev + 0.1
})
}, 100)
},
[playingId],
)
/** 暂停播放 */
const handlePause = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setPlayingId(null)
}, [])
/** 跳转到指定时间 */
const handleSeek = useCallback(
(voiceId: string, time: number, duration: number) => {
if (playingId !== voiceId) {
// 不同音色:从指定时间开始播放
handlePlay(voiceId, duration, time)
} else {
// 同一音色:直接跳转
setCurrentTime(time)
}
},
[playingId, handlePlay],
)
/** 切换播放/暂停 */
const handleTogglePlay = useCallback(
(voiceId: string, duration: number) => {
if (playingId === voiceId) {
handlePause()
} else {
handlePlay(voiceId, duration)
}
},
[playingId, handlePlay, handlePause],
)
/** 停止所有播放(切换 Tab 时调用) */
const stopPlayback = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setPlayingId(null)
setCurrentTime(0)
}, [])
// 组件卸载时清理
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [])
return {
playingId,
currentTime,
handlePlay,
handlePause,
handleSeek,
handleTogglePlay,
stopPlayback,
}
}
@@ -1,105 +0,0 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { deleteVoiceClone, retryVoiceClone, type VoiceClone } from "@/api/voice-clone"
import { type ClonedVoiceDisplay } from "../types"
/**
* 克隆音色操作 Hook
* 封装删除、重试、详情弹窗等克隆音色相关操作
*/
interface ToastShowFn {
(message: string, type: "success" | "error"): void
}
interface UseCloneOperationsProps {
showToast: ToastShowFn
}
export function useCloneOperations({ showToast }: UseCloneOperationsProps) {
const queryClient = useQueryClient()
const [detailVoice, setDetailVoice] = useState<ClonedVoiceDisplay | null>(null)
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/** 删除克隆音色 */
const deleteMutation = useMutation({
mutationFn: deleteVoiceClone,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
showToast("音色已删除", "success")
},
onError: () => {
showToast("删除失败", "error")
},
})
/** 重试克隆 */
const retryMutation = useMutation({
mutationFn: retryVoiceClone,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
showToast("已重新提交克隆", "success")
},
onError: () => {
showToast("重试失败", "error")
},
})
const handleCloneDelete = useCallback(
(voice: ClonedVoiceDisplay) => {
if (window.confirm(`确定删除音色「${voice.name}」吗?`)) {
deleteMutation.mutate(voice.id)
if (detailVoice?.id === voice.id) setDetailVoice(null)
}
},
[deleteMutation, detailVoice],
)
const handleCloneRetry = useCallback(
(voice: ClonedVoiceDisplay) => {
retryMutation.mutate(voice.id)
},
[retryMutation],
)
const handleCloneUse = useCallback(
(_voice: ClonedVoiceDisplay) => {
showToast("已选择音色", "success")
},
[showToast],
)
const handleShowDetail = useCallback((voice: ClonedVoiceDisplay) => {
setDetailVoice(voice)
}, [])
const handleCloseDetail = useCallback(() => {
setDetailVoice(null)
}, [])
/** 克隆成功回调 — 刷新列表 */
const handleCloneSuccess = useCallback(
(_voice: VoiceClone) => {
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
showToast("克隆已提交,正在生成中", "success")
},
[queryClient, showToast],
)
return {
// 状态
detailVoice,
cloneModalOpen,
setCloneModalOpen,
// Mutations
isDeleting: deleteMutation.isPending,
isRetrying: retryMutation.isPending,
// Handlers
handleCloneDelete,
handleCloneRetry,
handleCloneUse,
handleShowDetail,
handleCloseDetail,
handleCloneSuccess,
}
}
@@ -1,145 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { type PresetVoiceDisplay } from "../types"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
/**
* TTS 合成 Hook
* 封装 AI 配音弹窗状态、合成请求、轮询、保存到素材库等逻辑
*/
interface UseTtsSynthesizeProps {
presetVoices: PresetVoiceDisplay[]
showToast: (message: string, type: "success" | "error") => void
}
export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizeProps) {
const queryClient = useQueryClient()
const [ttsOpen, setTtsOpen] = useState(false)
const [ttsText, setTtsText] = useState("")
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsSpeed, setTtsSpeed] = useState(1.0)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
const [ttsError, setTtsError] = useState<string | null>(null)
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/** 开始 AI 配音合成 */
const handleTtsSynthesize = useCallback(async () => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本")
return
}
setTtsError(null)
setTtsStatus("synthesizing")
setTtsAudioUrl(null)
setTtsJobId(null)
try {
const resp = await synthesizeSpeech({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
})
setTtsJobId(resp.job_id)
// 轮询任务状态
ttsTimerRef.current = setInterval(async () => {
try {
const job = await getTTSJobStatus(resp.job_id)
if (job.status === "completed") {
clearInterval(ttsTimerRef.current!)
ttsTimerRef.current = null
setTtsStatus("done")
setTtsAudioUrl(job.output_audio_url)
} else if (job.status === "failed") {
clearInterval(ttsTimerRef.current!)
ttsTimerRef.current = null
setTtsStatus("error")
setTtsError(job.error_message || "合成失败")
}
} catch {
clearInterval(ttsTimerRef.current!)
ttsTimerRef.current = null
setTtsStatus("error")
setTtsError("查询合成状态失败")
}
}, 2000)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "合成请求失败"
setTtsStatus("error")
setTtsError(msg)
}
}, [ttsText, ttsVoiceId, ttsSpeed])
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
if (!ttsJobId) return
try {
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) })
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
showToast("已保存到配音库", "success")
setTtsOpen(false)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "保存失败"
showToast(msg, "error")
}
}, [ttsJobId, ttsText, queryClient, showToast])
/** 关闭 TTS 弹窗并清理状态 */
const handleTtsClose = useCallback(() => {
setTtsOpen(false)
setTtsText("")
setTtsVoiceId("")
setTtsSpeed(1.0)
setTtsStatus("idle")
setTtsAudioUrl(null)
setTtsError(null)
setTtsJobId(null)
if (ttsTimerRef.current) {
clearInterval(ttsTimerRef.current)
ttsTimerRef.current = null
}
}, [])
/** 打开 TTS 弹窗,可选指定音色 */
const openTtsWithVoice = useCallback((voiceId?: string) => {
setTtsOpen(true)
if (voiceId) setTtsVoiceId(voiceId)
}, [])
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current)
}
}, [])
return {
// 状态
ttsOpen,
ttsText,
ttsVoiceId,
ttsSpeed,
ttsJobId,
ttsStatus,
ttsAudioUrl,
ttsError,
// 可选音色列表
ttsPresetVoices: presetVoices,
// Setters
setTtsText,
setTtsVoiceId,
setTtsSpeed,
setTtsOpen,
// Actions
handleTtsSynthesize,
handleTtsSave,
handleTtsClose,
openTtsWithVoice,
}
}
@@ -1,131 +0,0 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { type VoiceGender } from "../types"
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
import { getAudioDuration } from "../utils/audio"
import { buildVoiceMetadata } from "../types"
/**
* 配音上传 Hook
* 封装上传音频弹窗状态、上传进度、上传 mutation 逻辑
*/
interface UseVoiceUploadProps {
showToast: (message: string, type: "success" | "error") => void
}
export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
const queryClient = useQueryClient()
const [uploadOpen, setUploadOpen] = useState(false)
const [uploadFile, setUploadFile] = useState<File | null>(null)
const [uploadName, setUploadName] = useState("")
const [uploadGender, setUploadGender] = useState<VoiceGender>("female")
const [uploadDesc, setUploadDesc] = useState("")
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File
name: string
gender: VoiceGender
description: string
}) => {
setUploadProgress(0)
try {
/* 获取或创建默认配音库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
const lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
/* 直传文件 */
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
/* 获取音频时长 */
const duration = await getAudioDuration(data.file)
/* 创建素材记录 */
await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildVoiceMetadata({
gender: data.gender,
description: data.description,
duration,
}),
})
} finally {
setUploadProgress(null)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
showToast("上传成功", "success")
handleUploadClose()
},
onError: (err: Error) => {
showToast(err.message || "上传失败,请重试", "error")
},
})
const handleUploadClose = useCallback(() => {
setUploadOpen(false)
setUploadFile(null)
setUploadName("")
setUploadDesc("")
setUploadProgress(null)
}, [])
const handleFileSelect = useCallback(
(file: File) => {
setUploadFile(file)
if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, ""))
},
[uploadName],
)
const handleFileRemove = useCallback(() => {
setUploadFile(null)
setUploadProgress(null)
}, [])
const handleUpload = useCallback(() => {
if (!uploadFile) return
uploadMutation.mutate({
file: uploadFile,
name: uploadName.trim(),
gender: uploadGender,
description: uploadDesc.trim(),
})
}, [uploadFile, uploadName, uploadGender, uploadDesc, uploadMutation])
return {
// 弹窗状态
uploadOpen,
setUploadOpen,
// 表单状态
uploadFile,
uploadName,
uploadGender,
uploadDesc,
uploadProgress,
isUploading: uploadMutation.isPending,
// Setters
setUploadName,
setUploadGender,
setUploadDesc,
// Handlers
handleFileSelect,
handleFileRemove,
handleUpload,
handleUploadClose,
}
}
@@ -1,119 +0,0 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { fetchPresetVoices, fetchVoices } from "@/api/voices"
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
import { getAssetsByKind, type AssetItem } from "@/api/assets"
import {
type TabKey,
type ClonedVoiceDisplay,
type PresetVoiceDisplay,
mapPresetToDisplay,
mapCloneToDisplay,
} from "../types"
/**
* 配音库数据 Hook
* 封装三个 Tab 的数据查询、筛选状态管理、数据映射逻辑
*/
export function useVoicesData() {
// ── Tab & 筛选状态 ─────────────────────────────────────
const [activeTab, setActiveTab] = useState<TabKey>("preset")
const [searchText, setSearchText] = useState("")
const [filterGender, setFilterGender] = useState<string>("all")
const [filterLang, setFilterLang] = useState<string>("all")
// ── 数据查询 ───────────────────────────────────────────
/** 预置音色列表 */
const { data: presetData, isLoading: presetLoading } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
/** 克隆音色列表 */
const { data: cloneData, isLoading: cloneLoading } = useQuery({
queryKey: ["voice-clones"],
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
})
/** 配音素材列表(用户上传音频) */
const { data: materialData, isLoading: materialLoading } = useQuery({
queryKey: ["voice-materials"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
/** 统一统计(preset_count / clone_count */
const { data: unifiedStats } = useQuery({
queryKey: ["voices-unified"],
queryFn: () => fetchVoices({ limit: 1 }),
})
// ── 数据映射 ─────────────────────────────────────────
const presetVoices: PresetVoiceDisplay[] = useMemo(
() => (presetData?.items ?? []).map(mapPresetToDisplay),
[presetData],
)
const clonedVoices: ClonedVoiceDisplay[] = useMemo(
() => (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))),
[cloneData],
)
const materials: AssetItem[] = useMemo(() => materialData ?? [], [materialData])
// ── 计数 ──────────────────────────────────────────────
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0
const materialCount = materialData?.length ?? 0
// ── 预置音色筛选 ─────────────────────────────────────
const filteredPreset = useMemo(() => {
let list = presetVoices
if (filterGender !== "all") {
list = list.filter((v) => v.gender === filterGender)
}
if (filterLang !== "all") {
list = list.filter((v) => v.language === filterLang)
}
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter(
(v) =>
v.name.toLowerCase().includes(q) ||
v.description.toLowerCase().includes(q) ||
v.tags.some((tag) => tag.toLowerCase().includes(q)),
)
}
return list
}, [presetVoices, filterGender, filterLang, searchText])
return {
// Tab 状态
activeTab,
setActiveTab,
// 筛选状态
searchText,
setSearchText,
filterGender,
setFilterGender,
filterLang,
setFilterLang,
// 加载状态
presetLoading,
cloneLoading,
materialLoading,
// 原始数据
presetVoices,
clonedVoices,
materials,
// 筛选后数据
filteredPreset,
// 计数
presetCount,
cloneCount,
materialCount,
}
}
@@ -1,45 +0,0 @@
/**
* AssetLibrary 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到
* assets 目录下所有文件的改动(包括子组件、Hook 和工具函数)
*/
import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/assets/AssetLibrary"
// 子组件
import "@/pages/assets/components/AssetCard"
import "@/pages/assets/components/AssetFilterBar"
import "@/pages/assets/components/AssetSkeleton"
import "@/pages/assets/components/BatchClassifyModal"
import "@/pages/assets/components/BatchMarkModal"
import "@/pages/assets/components/BatchOperationBar"
import "@/pages/assets/components/BatchTagModal"
import "@/pages/assets/components/CreateLibraryModal"
import "@/pages/assets/components/LibrarySidebar"
import "@/pages/assets/components/PlayModal"
import "@/pages/assets/components/ResultDrawer"
import "@/pages/assets/components/UploadProgressModal"
// 类型与常量
import "@/pages/assets/types"
import "@/pages/assets/constants"
// 工具函数
import "@/pages/assets/utils/format"
import "@/pages/assets/utils/asset"
// Hooks
import "@/pages/assets/hooks/useAssetsData"
import "@/pages/assets/hooks/useLibraryManagement"
import "@/pages/assets/hooks/useAssetUpload"
import "@/pages/assets/hooks/useAssetSelection"
import "@/pages/assets/hooks/useAssetOperations"
describe("AssetLibrary module smoke test", () => {
it("should load all asset modules", () => {
// 纯模块加载测试,确保所有组件/工具函数能正常 import
expect(true).toBe(true)
})
})
@@ -1,232 +0,0 @@
/**
* useAudioPlayer hook 测试 — VoiceLibrary 版本
*
* 该 Hook 使用 setInterval 模拟音频播放进度,纯逻辑可测。
* 参考 voice-materials/hooks/useAudioPlayer.test.ts 的测试结构。
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
import { renderHook, act } from "@testing-library/react"
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
describe("useAudioPlayer (voices)", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it("应该使用初始状态初始化", () => {
const { result } = renderHook(() => useAudioPlayer())
expect(result.current.playingId).toBeNull()
expect(result.current.currentTime).toBe(0)
})
it("handlePlay 应该开始播放指定音色", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBe(0)
})
it("handlePlay 对同一个音色不应重复启动播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
const initialTime = result.current.currentTime
// 推进一些时间让进度走动
act(() => {
vi.advanceTimersByTime(200)
})
const timeAfterAdvance = result.current.currentTime
expect(timeAfterAdvance).toBeGreaterThan(initialTime)
// 对同一个音色再次调用 handlePlay 不应重置
act(() => {
result.current.handlePlay("voice-1", 10)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBe(timeAfterAdvance)
})
it("handlePlay 切换音色时应停止上一个并从头开始", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBeGreaterThan(0)
act(() => {
result.current.handlePlay("voice-2", 15)
})
expect(result.current.playingId).toBe("voice-2")
expect(result.current.currentTime).toBe(0)
})
it("播放进度应该随时间递增", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
// 每 100ms 增加 0.1
act(() => {
vi.advanceTimersByTime(300)
})
expect(result.current.currentTime).toBeCloseTo(0.3, 1)
expect(result.current.playingId).toBe("voice-1")
})
it("播放到结尾应自动停止并重置", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 0.5) // 0.5 秒的短音频
})
act(() => {
vi.advanceTimersByTime(600) // 超过 0.5 秒
})
expect(result.current.playingId).toBeNull()
expect(result.current.currentTime).toBe(0)
})
it("handlePause 应该暂停播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
vi.advanceTimersByTime(200)
})
const timeBeforePause = result.current.currentTime
act(() => {
result.current.handlePause()
})
expect(result.current.playingId).toBeNull()
// 暂停后时间不应再变化
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.currentTime).toBe(timeBeforePause)
})
it("handleSeek 应该跳转到指定时间", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
result.current.handleSeek("voice-1", 5, 10)
})
expect(result.current.currentTime).toBe(5)
})
it("handleSeek 对不同音色应该开始播放该音色", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
result.current.handleSeek("voice-2", 3, 15)
})
expect(result.current.playingId).toBe("voice-2")
expect(result.current.currentTime).toBe(3)
})
it("handleTogglePlay 应该在播放和暂停之间切换", () => {
const { result } = renderHook(() => useAudioPlayer())
// 初始为暂停,调用应开始播放
act(() => {
result.current.handleTogglePlay("voice-1", 10)
})
expect(result.current.playingId).toBe("voice-1")
// 再次调用应暂停
act(() => {
result.current.handleTogglePlay("voice-1", 10)
})
expect(result.current.playingId).toBeNull()
})
it("stopPlayback 应该重置所有播放状态", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
vi.advanceTimersByTime(300)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBeGreaterThan(0)
act(() => {
result.current.stopPlayback()
})
expect(result.current.playingId).toBeNull()
expect(result.current.currentTime).toBe(0)
// 停止后定时器不应再触发
const timeAfterStop = result.current.currentTime
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.currentTime).toBe(timeAfterStop)
})
it("返回值应该包含所有必要的方法和状态", () => {
const { result } = renderHook(() => useAudioPlayer())
expect(typeof result.current.handlePlay).toBe("function")
expect(typeof result.current.handlePause).toBe("function")
expect(typeof result.current.handleSeek).toBe("function")
expect(typeof result.current.handleTogglePlay).toBe("function")
expect(typeof result.current.stopPlayback).toBe("function")
expect(typeof result.current.playingId).toBe("object") // string | null
expect(typeof result.current.currentTime).toBe("number")
})
})
-17
View File
@@ -8,16 +8,6 @@ import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/voices/VoiceLibrary"
// 子组件
import "@/pages/voices/components/VoiceCard"
import "@/pages/voices/components/CloneVoiceCard"
import "@/pages/voices/components/CloneDetailModal"
import "@/pages/voices/components/CloneCardSkeleton"
import "@/pages/voices/components/UploadVoiceModal"
import "@/pages/voices/components/TtsModal"
import "@/pages/voices/components/VoiceFilterBar"
import "@/pages/voices/components/MaterialVoiceCard"
// 类型与常量
import "@/pages/voices/types"
import "@/pages/voices/constants"
@@ -26,13 +16,6 @@ import "@/pages/voices/constants"
import "@/pages/voices/utils/format"
import "@/pages/voices/utils/audio"
// Hooks
import "@/pages/voices/hooks/useVoicesData"
import "@/pages/voices/hooks/useAudioPlayer"
import "@/pages/voices/hooks/useCloneOperations"
import "@/pages/voices/hooks/useTtsSynthesize"
import "@/pages/voices/hooks/useVoiceUpload"
describe("VoiceLibrary module smoke test", () => {
it("should load all voice-library modules", () => {
// 纯模块加载测试,确保所有组件/工具函数能正常 import
-58
View File
@@ -1,58 +0,0 @@
{
"": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"baseBranches": ["develop"],
"labels": ["dependencies"],
"assignees": ["xiaoxia"],
"prConcurrentLimit": 3,
"prHourlyLimit": 3,
"schedule": ["after 2am before 6am on monday"],
"timezone": "Asia/Shanghai",
"vulnerabilityAlerts": {
"enabled": true,
"labels": ["dependencies", "security"],
"schedule": ["at any time"]
},
"pip_requirements": {
"fileMatch": [
"(^|/)requirements\.txt$",
"(^|/)requirements-base\.txt$",
"(^|/)requirements-dev\.txt$",
"(^|/)requirements-worker\.txt$"
]
},
"npm": {
"fileMatch": [
"(^|/)apps/web/package\.json$"
]
},
"packageRules": [
{
"matchDepTypes": ["dependencies"],
"matchUpdateTypes": ["patch", "minor"],
"groupName": "production deps (minor & patch)",
"groupSlug": "prod-deps-minor-patch"
},
{
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["patch", "minor"],
"groupName": "dev deps (minor & patch)",
"groupSlug": "dev-deps-minor-patch"
},
{
"matchUpdateTypes": ["major"],
"labels": ["dependencies", "major-update"]
}
],
"rebaseWhen": "behind-base-branch",
"semanticCommits": "auto",
"semanticPrefix": "chore(deps): "
}
Executable → Regular
+92 -319
View File
@@ -1,32 +1,17 @@
#!/usr/bin/env python3
"""
ACR 镜像清理脚本(增强版)
清理策略:
ACR 镜像清理脚本
策略:
- 版本tag (v*): 永久保留
- 固定tag (latest, main, develop, master): 永久保留
- 缓存镜像 (*-cache): 永久保留
- 受保护tag (--protected-tags): 永久保留(如当前运行中镜像
- PR预览tag (pr-*):
- --pr-sha模式:删除指定PR commit的镜像(PR关闭时触发)
- cron模式:通过Gitea API检查PR状态,已关闭/合并的删除
- PR预览tag (pr-*): 保留 N 天(默认7天
- 普通commit hash tag: 保留最近 N 个(默认20),老的删除
使用方式:
# 预览不实际删除
python3 acr_cleanup.py --dry-run
# 实际执行(cron模式)
python3 acr_cleanup.py --execute
# 保留最近30个commit镜像
python3 acr_cleanup.py --keep 30 --execute
# PR关闭时清理指定commit的PR镜像
python3 acr_cleanup.py --pr-sha abc123def --execute
# 传入受保护tag列表(运行中镜像白名单)
python3 acr_cleanup.py --protected-tags "sha1,sha2" --execute
python3 acr_cleanup.py --dry-run # 预览不实际删除
python3 acr_cleanup.py --execute # 实际执行删除
python3 acr_cleanup.py --keep 20 --execute # 保留最近20个
"""
import argparse
@@ -38,8 +23,7 @@ import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
# ========== 配置 ==========
# 配置
REGISTRY = os.environ.get("ACR_REGISTRY", "xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com")
AUTH_URL = "https://dockerauth.cn-hangzhou.aliyuncs.com/auth"
SERVICE = os.environ.get("ACR_SERVICE", "registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa")
@@ -47,11 +31,6 @@ NAMESPACE = os.environ.get("ACR_NAMESPACE", "xiaoxiakeji")
USERNAME = os.environ.get("ACR_USERNAME", "")
PASSWORD = os.environ.get("ACR_PASSWORD", "")
# Gitea配置(用于PR状态检查)
GITEA_URL = os.environ.get("GITEA_URL", "https://git.xiaoxiajianji.com")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
GITEA_REPO = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
REPOS = [
"xiaoxia-saas-api",
"xiaoxia-saas-worker",
@@ -70,9 +49,6 @@ ACCEPT_MANIFEST_OCI = "application/vnd.oci.image.manifest.v1+json"
ACCEPT_MANIFEST_V2 = "application/vnd.docker.distribution.manifest.v2+json"
# ========== Registry API ==========
def get_token(repo, action="pull"):
"""获取仓库访问token"""
scope = "repository:" + NAMESPACE + "/" + repo + ":" + action
@@ -105,19 +81,22 @@ def http_get_json(url, token, accept_header):
def get_manifest_info(repo, tag, token):
"""
获取tag的manifest信息。
返回: {digest, created, media_type, error}
获取tag的manifest信息,支持OCI index和普通manifest两种格式
返回: {digest, created, media_type}
- digest: 顶层manifest的digest(用于删除)
- created: 镜像创建时间
"""
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag
result = {"digest": "", "created": "", "media_type": "", "error": ""}
# 先尝试 OCI index 格式
# 先尝试 OCI index 格式ACR多用这种)
try:
data, headers = http_get_json(url, token, ACCEPT_INDEX)
top_digest = headers.get("Docker-Content-Digest", "")
result["digest"] = top_digest
result["media_type"] = data.get("mediaType", ACCEPT_INDEX)
# OCI index:找amd64的manifest,再取config blob
manifests = data.get("manifests", [])
amd64_manifest = None
for m in manifests:
@@ -125,6 +104,7 @@ def get_manifest_info(repo, tag, token):
if arch == "amd64":
amd64_manifest = m
break
# 没有amd64就用第一个
if not amd64_manifest and manifests:
amd64_manifest = manifests[0]
@@ -134,6 +114,7 @@ def get_manifest_info(repo, tag, token):
try:
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_OCI)
except Exception:
# 退而求其次用v2格式
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_V2)
config_digest = inner_data.get("config", {}).get("digest", "")
@@ -200,58 +181,6 @@ def delete_manifest(repo, digest, token):
return False, str(e.code) + " " + e.read().decode()[:200]
# ========== Gitea API ==========
def gitea_get_open_prs():
"""获取所有打开的PR编号列表"""
if not GITEA_TOKEN:
print(" 警告: 无GITEA_TOKEN,跳过PR状态检查")
return None
open_prs = set()
page = 1
while True:
url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls?state=open&page=" + str(page) + "&limit=50"
req = urllib.request.Request(url)
req.add_header("Authorization", "token " + GITEA_TOKEN)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
if not data:
break
for pr in data:
open_prs.add(pr.get("number", 0))
if len(data) < 50:
break
page += 1
except Exception as e:
print(f" 警告: 获取Gitea PR列表失败: {e}")
return None
return open_prs
def gitea_get_pr_commits(pr_number):
"""获取指定PR的所有commit sha"""
if not GITEA_TOKEN:
return []
url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls/" + str(pr_number) + "/commits?limit=100"
req = urllib.request.Request(url)
req.add_header("Authorization", "token " + GITEA_TOKEN)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
return [c.get("sha", "") for c in data]
except Exception as e:
print(f" 警告: 获取PR #{pr_number} commits失败: {e}")
return []
# ========== 工具函数 ==========
def parse_time(created_str):
"""解析ISO时间字符串"""
if not created_str:
@@ -275,49 +204,12 @@ def is_fixed_tag(tag):
def is_pr_tag(tag):
"""判断是否是PR预览tag (pr-<sha>)"""
"""判断是否是PR预览tag"""
return tag.startswith("pr-")
def extract_sha_from_pr_tag(tag):
"""从pr-<sha> tag中提取sha"""
if tag.startswith("pr-"):
return tag[3:]
return tag
def is_in_protected_list(tag, protected_set):
"""检查tag是否在受保护列表中"""
if not protected_set:
return False
# 精确匹配
if tag in protected_set:
return True
# 前缀匹配(commit hash可能是完整或短的)
for p in protected_set:
if tag.startswith(p) or p.startswith(tag):
return True
return False
# ========== 核心清理逻辑 ==========
def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None):
"""
清理单个仓库
Args:
repo: 仓库名
keep_count: 保留最近N个commit tag
dry_run: 是否预览模式
protected_tags: 受保护tag集合(白名单)
pr_sha: 指定PR commit shaPR关闭模式),None表示cron模式
pr_open_set: 打开的PR编号集合(cron模式用)
Returns:
(总tag数, 删除数)
"""
def cleanup_repo(repo, keep_count, pr_days, dry_run):
"""清理单个仓库"""
print("=" * 60)
print("仓库:", repo)
print("=" * 60)
@@ -333,37 +225,6 @@ def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open
tags = get_tags(repo, token_pull)
print(" 总tag数:", len(tags))
if not tags:
print(" 无tag,跳过")
return 0, 0
# ========== PR-SHA模式:只删除指定commit的PR镜像 ==========
if pr_sha:
pr_tags_to_del = [
t
for t in tags
if t.startswith("pr-" + pr_sha) or t == "pr-" + pr_sha or pr_sha.startswith(extract_sha_from_pr_tag(t))
]
if not pr_tags_to_del:
print(f" 未找到PR镜像: pr-{pr_sha[:12]}")
return len(tags), 0
print(f" 找到 {len(pr_tags_to_del)} 个PR镜像待删除:")
for t in pr_tags_to_del:
print(f" - {t}")
to_delete = []
for tag in pr_tags_to_del:
info = get_manifest_info(repo, tag, token_pull)
if info["digest"]:
to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
else:
print(f" 警告: {tag} 无法获取digest,跳过")
return _execute_delete(repo, to_delete, dry_run, len(tags))
# ========== Cron模式:全量清理 ==========
# 分类
version_tags = []
fixed_tags = []
@@ -382,185 +243,122 @@ def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open
print(" 版本tag (v*):", len(version_tags), "-> 永久保留")
print(" 固定tag:", len(fixed_tags), "-> 永久保留")
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 已关闭PR的删除")
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 保留", pr_days, "")
print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "")
print(" 白名单tag:", len(protected_tags), "")
# --- PR tag清理:检查PR状态 ---
pr_to_delete = []
if pr_tags_list:
print()
print(" 检查PR镜像状态...")
# 策略:有Gitea token则检查PR状态,否则按时间保留7天
if pr_open_set is not None:
# 通过Gitea API检查每个PR镜像对应的PR是否还开着
# 注意:pr tag是pr-<sha>sha可能属于某个PR
# 简化策略:收集所有打开PR的commit sha,在白名单里的保留
print(" 模式: Gitea PR状态检查")
open_pr_shas = set()
# 这里做了简化:因为每个PR都查commits太慢,我们用另一种方式
# 对于PR tag,先尝试匹配PR编号(如果tag名里有编号),否则按时间
# 实际pr-<sha>没法直接知道PR编号,所以降级为按时间+打开PR的head sha白名单
open_head_shas = set()
page = 1
while True:
url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls?state=open&page=" + str(page) + "&limit=50"
req = urllib.request.Request(url)
req.add_header("Authorization", "token " + GITEA_TOKEN)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
if not data:
break
for pr in data:
head_sha = pr.get("head", {}).get("sha", "")
if head_sha:
open_head_shas.add(head_sha)
open_head_shas.add(head_sha[:7])
open_head_shas.add(head_sha[:12])
if len(data) < 50:
break
page += 1
except Exception:
break
deleted_count = 0
for tag in pr_tags_list:
sha = extract_sha_from_pr_tag(tag)
# 检查是否是打开PR的head sha
is_open_pr = False
for ohs in open_head_shas:
if sha.startswith(ohs) or ohs.startswith(sha):
is_open_pr = True
break
if not is_open_pr:
info = get_manifest_info(repo, tag, token_pull)
if info["digest"]:
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
deleted_count += 1
print(f" 打开PR数: {len(open_head_shas)}个head sha")
print(f" 将删除PR镜像: {deleted_count}")
else:
# 无Gitea token,降级为按7天保留
print(" 模式: 按时间保留7天(无Gitea token降级)")
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
for tag in pr_tags_list:
info = get_manifest_info(repo, tag, token_pull)
created = parse_time(info["created"])
if created < cutoff and info["digest"]:
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
print(f" 将删除PR镜像: {len(pr_to_delete)}")
# --- Commit tag清理:保留最近N个 ---
# 获取所有commit tag的创建时间
print()
print(" 获取commit tag创建时间...")
commit_tag_infos = []
tag_info_list = []
errors = 0
for i, tag in enumerate(commit_tags):
info = get_manifest_info(repo, tag, token_pull)
if info["error"] or not info["digest"]:
errors += 1
commit_tag_infos.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
# 取不到信息的tag,放到最后(最旧处理),但标记一下
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": "", "error": info.get("error", "")})
else:
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": info["created"], "error": ""})
if (i + 1) % 20 == 0:
print(" 已获取", i + 1, "/", len(commit_tags), "...")
if errors:
print(" 注意:", errors, "个tag获取manifest失败")
# 按时间倒序排序
commit_tag_infos.sort(key=lambda x: parse_time(x["created"]), reverse=True)
# 按时间倒序排序(空时间放最后)
tag_info_list.sort(key=lambda x: parse_time(x["created"]), reverse=True)
# 确定要删除的commit tag
commit_to_delete = []
if len(commit_tag_infos) > keep_count:
commit_to_delete = commit_tag_infos[keep_count:]
print(f" 保留前{keep_count}个commit tag,删除{len(commit_to_delete)}")
# 白名单过滤:受保护的tag不删除
if protected_tags:
before = len(commit_to_delete)
commit_to_delete = [t for t in commit_to_delete if not is_in_protected_list(t["tag"], protected_tags)]
removed = before - len(commit_to_delete)
to_delete = []
if len(tag_info_list) > keep_count:
to_delete = tag_info_list[keep_count:]
print(" 保留前", keep_count, "个commit tag,删除", len(to_delete), "")
# 打印保留范围
kept = tag_info_list[:keep_count]
valid_kept = [t for t in kept if t["created"]]
if valid_kept:
print(" 最早保留:", valid_kept[-1]["tag"][:12], "(" + valid_kept[-1]["created"][:10] + ")")
# 保护当前构建的tag(通过PROTECTED_TAG环境变量传入,如GITHUB_SHA
protected_tag = os.environ.get("PROTECTED_TAG", "").strip()
if protected_tag:
before = len(to_delete)
to_delete = [t for t in to_delete if not t["tag"].startswith(protected_tag)]
removed = before - len(to_delete)
if removed > 0:
print(f" 白名单保护: 跳过{removed}运行中镜像")
print(f" 保护当前构建tag: {protected_tag[:12]} (跳过{removed})")
# 过滤无digest的
commit_to_delete = [t for t in commit_to_delete if t["digest"]]
print(f" 可删除(有digest): {len(commit_to_delete)}")
to_del_valid = [t for t in to_delete if t["digest"]]
print(" 可删除(有digest):", len(to_del_valid), "")
else:
print(f" commit tag数量不足{keep_count},无需清理")
print(" commit tag数量不足", keep_count, ",无需清理")
# --- 合并所有待删除项 ---
all_to_delete = commit_to_delete + pr_to_delete
# PR tag按时间清理
pr_to_delete = []
if pr_tags_list:
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
print()
print(" 检查PR预览tag(超过", pr_days, "天删除)...")
for tag in pr_tags_list:
info = get_manifest_info(repo, tag, token_pull)
created = parse_time(info["created"])
if created < cutoff:
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
print(" PR tag将删除:", len(pr_to_delete), "")
# 再次过滤白名单(PR镜像也受白名单保护)
if protected_tags:
before = len(all_to_delete)
all_to_delete = [t for t in all_to_delete if not is_in_protected_list(t["tag"], protected_tags)]
removed = before - len(all_to_delete)
if removed > 0:
print(f" 白名单保护(PR镜像): 跳过{removed}")
all_to_delete = [t for t in to_delete if t["digest"]] + [t for t in pr_to_delete if t["digest"]]
return _execute_delete(repo, all_to_delete, dry_run, len(tags))
def _execute_delete(repo, to_delete, dry_run, total_tags):
"""执行删除操作"""
if not to_delete:
if not all_to_delete:
print()
print(" 无需删除任何tag")
return total_tags, 0
# 按digest去重
seen_digests = set()
unique_delete = []
for item in to_delete:
if item["digest"] and item["digest"] not in seen_digests:
seen_digests.add(item["digest"])
unique_delete.append(item)
return len(tags), 0
# 执行删除
print()
if dry_run:
print(f" [DRY RUN] 将删除{len(unique_delete)}个manifest(预览模式")
for item in unique_delete[:5]:
print(" [DRY RUN] 将删除", len(all_to_delete), "个tag(预览模式,不实际删除")
# 去重digest
unique_digests = set(t["digest"] for t in all_to_delete if t["digest"])
print(" 去重后唯一digest数:", len(unique_digests))
for item in all_to_delete[:5]:
created_str = item.get("created", "")[:10] or "未知"
print(f" - {item['tag'][:30]} ({created_str})")
if len(unique_delete) > 5:
print(f" ... 还有{len(unique_delete) - 5}")
return total_tags, len(unique_delete)
print(" -", item["tag"][:20], "(" + created_str + ")")
if len(all_to_delete) > 5:
print(" ... 还有", len(all_to_delete) - 5, "")
return len(tags), len(unique_digests)
token_delete = get_token(repo, "delete")
deleted = 0
failed = 0
# 按digest去重,避免重复删除同一镜像
seen_digests = set()
unique_delete = []
for item in all_to_delete:
if item["digest"] and item["digest"] not in seen_digests:
seen_digests.add(item["digest"])
unique_delete.append(item)
print(f" 开始删除{len(unique_delete)}个唯一manifest...")
print(" 开始删除", len(unique_delete), "个唯一manifest...")
for item in unique_delete:
success, result = delete_manifest(repo, item["digest"], token_delete)
if success:
deleted += 1
print(f" 已删除: {item['tag'][:30]}")
print(" 已删除:", item["tag"][:20])
else:
failed += 1
print(f" 删除失败: {item['tag'][:30]} - {result}")
print(" 删除失败:", item["tag"][:20], "-", result)
print()
print(f" 删除完成: 成功{deleted}个,失败{failed}")
return total_tags, deleted
# ========== 主函数 ==========
print(" 删除完成: 成功", deleted, "个,失败", failed, "")
return len(tags), deleted
def main():
parser = argparse.ArgumentParser(description="ACR镜像清理工具(增强版)")
parser = argparse.ArgumentParser(description="ACR镜像清理工具")
parser.add_argument("--keep", type=int, default=20, help="保留最近N个commit hash tag(默认20")
parser.add_argument("--pr-days", type=int, default=7, help="PR预览tag保留天数(默认7天)")
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际删除")
parser.add_argument("--execute", action="store_true", help="实际执行删除")
parser.add_argument("--repo", type=str, default="", help="只清理指定仓库")
parser.add_argument("--pr-sha", type=str, default="", help="PR关闭模式:删除指定commit sha的PR镜像")
parser.add_argument("--protected-tags", type=str, default="", help="受保护tag列表,逗号分隔(运行中镜像白名单)")
parser.add_argument("--skip-pr-check", action="store_true", help="跳过Gitea PR状态检查(纯按时间清理PR镜像)")
args = parser.parse_args()
# 必须指定 --dry-run 或 --execute
@@ -570,12 +368,13 @@ def main():
print("示例:")
print(" python3 acr_cleanup.py --dry-run # 预览清理效果")
print(" python3 acr_cleanup.py --execute # 实际执行清理")
print(" python3 acr_cleanup.py --pr-sha abc123 --execute # PR关闭时清理")
print(" python3 acr_cleanup.py --keep 20 --execute # 保留最近20个")
sys.exit(1)
# 凭证检查
global USERNAME, PASSWORD
if not USERNAME or not PASSWORD:
# 尝试从docker config读取
try:
docker_config_path = os.path.expanduser("~/.docker/config.json")
with open(docker_config_path) as f:
@@ -592,39 +391,15 @@ def main():
print("或确保已执行 docker login", REGISTRY)
sys.exit(1)
# 解析受保护tag
protected_tags = set()
if args.protected_tags:
protected_tags = set(t.strip() for t in args.protected_tags.split(",") if t.strip())
dry_run = args.dry_run or not args.execute
mode = "预览模式" if dry_run else "执行模式"
print("=" * 60)
print("ACR 镜像清理工具(增强版)-", mode)
print("=" * 60)
print("ACR 镜像清理工具 -", mode)
print("Registry:", REGISTRY)
print("Namespace:", NAMESPACE)
if args.pr_sha:
print("模式: PR关闭清理")
print("PR commit SHA:", args.pr_sha[:12])
else:
print("模式: Cron全量清理")
print("保留commit tag数:", args.keep)
print("PR状态检查:", "关闭" if args.skip_pr_check else "开启")
if protected_tags:
print("白名单tag数:", len(protected_tags))
print("保留commit tag数:", args.keep)
print("PR预览保留天数:", args.pr_days)
print()
# PR模式不需要查Gitea
pr_open_set = None
if not args.pr_sha and not args.skip_pr_check and GITEA_TOKEN:
print("获取打开的PR列表...")
pr_open_set = gitea_get_open_prs()
if pr_open_set is not None:
print(f" 打开的PR: {len(pr_open_set)}")
print()
repos_to_clean = REPOS
if args.repo:
repos_to_clean = [args.repo]
@@ -632,13 +407,11 @@ def main():
total_deleted = 0
total_tags = 0
for repo in repos_to_clean:
count, deleted = cleanup_repo(
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set
)
count, deleted = cleanup_repo(repo, args.keep, args.pr_days, dry_run)
total_tags += count
total_deleted += deleted
print()
print()
print("=" * 60)
print("清理完成")
print(" 总tag数:", total_tags)
-363
View File
@@ -1,363 +0,0 @@
#!/bin/bash
# ===========================================
# 金丝雀发布脚本 - 分阶段灰度到全量
# ===========================================
# 在 CI Runner 上执行,通过 SSH 控制生产服务器执行灰度发布。
# 流程:5%灰度 → 20%灰度 → 50%灰度 → 100%全量
# 每阶段自动健康检查,失败自动回滚。
#
# 用法:
# IMAGE_TAG=v0.1.130 ./scripts/ci/canary_release.sh
#
# 环境变量:
# IMAGE_TAG - 新版本镜像标签 (必填)
# CANARY_STAGES - 灰度阶段配置,格式: "百分比:等待秒数" 用逗号分隔
# 默认: "5:600,20:900,50:1200"
# PROD_API_URL - Production API 公网地址
# PROD_WEB_URL - Production Web 公网地址
# PRODUCTION_SSH_HOST - 生产服务器 SSH 地址
# PRODUCTION_SSH_USER - SSH 用户名
# PRODUCTION_SSH_PORT - SSH 端口
# PRODUCTION_SSH_KEY - SSH 私钥内容
# ACR_USERNAME - 容器镜像仓库用户名
# ACR_PASSWORD - 容器镜像仓库密码
# CI_NOTIFY_WEBHOOK - 通知 Webhook
# SKIP_ROLLBACK - 失败时不自动回滚 (调试用)
set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# 配置
IMAGE_TAG="${IMAGE_TAG:-}"
CANARY_STAGES="${CANARY_STAGES:-5:600,20:900,50:1200}"
PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}"
PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}"
PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}"
PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}"
PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}"
# gray_deploy.sh 的镜像命名格式是 ${REGISTRY}-component:tag
# 需要与 ACR 镜像名 xiaoxia-registry.../xiaoxiakeji/xiaoxia-saas-api:tag 匹配
GRAY_REGISTRY="${GRAY_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/xiaoxia-saas}"
ACR_REGISTRY_HOST="${ACR_REGISTRY_HOST:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com}"
ACR_USERNAME="${ACR_USERNAME:-}"
ACR_PASSWORD="${ACR_PASSWORD:-}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
if [[ -z "$IMAGE_TAG" ]]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
# ===========================================
# SSH 配置
# ===========================================
SSH_KEY_PATH=""
setup_ssh() {
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
SSH_KEY_PATH="$HOME/.ssh/canary_deploy_key"
mkdir -p "$HOME/.ssh"
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$SSH_KEY_PATH"
chmod 600 "$SSH_KEY_PATH"
else
log_error "没有可用的 SSH 密钥"
return 1
fi
ssh-keyscan -p "$PRODUCTION_SSH_PORT" -H "$PRODUCTION_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
log_info "SSH 已配置: ${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}:${PRODUCTION_SSH_PORT}"
}
run_ssh() {
local cmd="$1"
ssh -p "$PRODUCTION_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
"${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}" "$cmd"
}
# ===========================================
# 上传脚本 + Docker登录
# ===========================================
prepare_server() {
log_step "准备生产服务器环境"
# 创建临时目录
run_ssh "mkdir -p /tmp/canary-release"
# 上传 gray_deploy.sh
local gray_script="$REPO_ROOT/scripts/gray_deploy.sh"
if [[ -f "$gray_script" ]]; then
cat "$gray_script" | run_ssh "cat > /tmp/canary-release/gray_deploy.sh && chmod +x /tmp/canary-release/gray_deploy.sh"
log_info " gray_deploy.sh 已上传"
else
log_error "找不到 gray_deploy.sh: $gray_script"
return 1
fi
# 上传 rollback_gray.sh
local rollback_script="$REPO_ROOT/scripts/rollback_gray.sh"
if [[ -f "$rollback_script" ]]; then
cat "$rollback_script" | run_ssh "cat > /tmp/canary-release/rollback_gray.sh && chmod +x /tmp/canary-release/rollback_gray.sh"
log_info " rollback_gray.sh 已上传"
else
log_warn "找不到 rollback_gray.sh"
fi
# 上传 ci_production_deploy.sh
local prod_deploy="$REPO_ROOT/scripts/ci_production_deploy.sh"
if [[ -f "$prod_deploy" ]]; then
cat "$prod_deploy" | run_ssh "cat > /tmp/canary-release/ci_production_deploy.sh && chmod +x /tmp/canary-release/ci_production_deploy.sh"
log_info " ci_production_deploy.sh 已上传"
else
log_error "找不到 ci_production_deploy.sh: $prod_deploy"
return 1
fi
# Docker 登录到 ACR
if [[ -n "$ACR_USERNAME" && -n "$ACR_PASSWORD" ]]; then
log_info " Docker 登录到 ACR..."
run_ssh "docker login '$ACR_REGISTRY_HOST' -u '$ACR_USERNAME' -p '$ACR_PASSWORD' 2>/dev/null" || \
log_warn " Docker login 失败(可能已有凭证),将尝试直接 pull"
fi
log_info "✅ 服务器环境准备完成"
}
# ===========================================
# 健康检查(公网访问)
# ===========================================
health_check() {
local stage_name="$1"
local timeout="${2:-120}"
local interval=5
local elapsed=0
log_step "健康检查 - $stage_name (超时 ${timeout}s)"
while [ $elapsed -lt $timeout ]; do
local api_ok=false
local web_ok=false
# 检查 API
local api_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 --max-time 10 \
"${PROD_API_URL}/health" 2>/dev/null || echo "000")
if [[ "$api_code" == "200" ]]; then
api_ok=true
fi
# 检查 Web
local web_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 --max-time 10 \
"$PROD_WEB_URL" 2>/dev/null || echo "000")
if [[ "$web_code" == "200" || "$web_code" == "301" || "$web_code" == "302" ]]; then
web_ok=true
fi
if $api_ok && $web_ok; then
log_info "✅ 健康检查通过 (API=$api_code, Web=$web_code)"
return 0
fi
log_warn " 等待中... API=$api_code, Web=$web_code (${elapsed}s/${timeout}s)"
sleep $interval
elapsed=$((elapsed + interval))
done
log_error "❌ 健康检查超时"
return 1
}
# ===========================================
# 灰度发布
# ===========================================
gray_deploy() {
local pct="$1"
log_step "灰度发布 ${pct}% - $IMAGE_TAG"
run_ssh "cd /tmp/canary-release && \
REGISTRY='$GRAY_REGISTRY' \
./gray_deploy.sh '$IMAGE_TAG' '$pct'"
}
# ===========================================
# 全量部署
# ===========================================
full_deploy() {
log_step "全量部署 - $IMAGE_TAG"
run_ssh "cd /tmp/canary-release && \
IMAGE_TAG='$IMAGE_TAG' \
ACR_USERNAME='$ACR_USERNAME' \
ACR_PASSWORD='$ACR_PASSWORD' \
sh ./ci_production_deploy.sh"
}
# ===========================================
# 灰度回滚
# ===========================================
rollback_gray() {
log_error "执行灰度回滚..."
if [[ "$SKIP_ROLLBACK" == "true" ]]; then
log_warn "SKIP_ROLLBACK=true,跳过回滚"
return
fi
if run_ssh "test -f /tmp/canary-release/rollback_gray.sh"; then
run_ssh "cd /tmp/canary-release && ./rollback_gray.sh" || \
log_error "回滚脚本执行失败,请手动处理"
else
# 内联回滚逻辑
log_warn "使用内联回滚逻辑"
run_ssh '
NGINX_CONF="/etc/nginx/sites-enabled/00-xiaoxia-saas"
LATEST_BAK=$(ls -t "${NGINX_CONF}".bak.gray.* 2>/dev/null | head -1 || true)
if [[ -n "$LATEST_BAK" ]]; then
cp "$LATEST_BAK" "$NGINX_CONF"
else
sed -i "s|proxy_pass http://saas_api_backend|proxy_pass http://127.0.0.1:8001|g" "$NGINX_CONF"
sed -i "s|proxy_pass http://saas_web_backend/|proxy_pass http://127.0.0.1:3002/|g" "$NGINX_CONF"
fi
nginx -t && nginx -s reload
docker rm -f xiaoxia-api-canary xiaoxia-web-canary 2>/dev/null || true
' || log_error "回滚失败,请手动处理"
fi
}
# ===========================================
# 通知
# ===========================================
notify_status() {
local status="$1"
local message="$2"
if [ -n "${CI_NOTIFY_WEBHOOK:-}" ]; then
NOTIFY_MODE="$status" JOB_NAME="Canary Release - $message" \
python3 "$REPO_ROOT/scripts/ci_notify.py" 2>/dev/null || true
fi
}
# ===========================================
# 清理
# ===========================================
cleanup() {
log_step "清理生产服务器临时文件"
run_ssh "rm -rf /tmp/canary-release" 2>/dev/null || true
}
# ===========================================
# 主流程
# ===========================================
main() {
echo "==========================================="
echo " 🐦 金丝雀发布"
echo " 版本: $IMAGE_TAG"
echo " 阶段: $CANARY_STAGES"
echo "==========================================="
echo ""
setup_ssh
prepare_server
trap cleanup EXIT
# 解析灰度阶段
IFS=',' read -ra STAGES <<< "$CANARY_STAGES"
local total_stages=${#STAGES[@]}
local current_stage=0
# 逐阶段灰度
for stage in "${STAGES[@]}"; do
current_stage=$((current_stage + 1))
local pct=$(echo "$stage" | cut -d: -f1)
local wait_time=$(echo "$stage" | cut -d: -f2)
echo ""
echo "--- 阶段 $current_stage/$total_stages: ${pct}% 灰度 ---"
# 执行灰度发布
if ! gray_deploy "$pct"; then
log_error "灰度发布 ${pct}% 失败"
rollback_gray
notify_status "failure" "Stage ${pct}% Deploy Failed"
exit 1
fi
# 健康检查
if ! health_check "${pct}%灰度"; then
log_error "${pct}%灰度健康检查失败"
rollback_gray
notify_status "failure" "Stage ${pct}% Health Check Failed"
exit 1
fi
# 观察期
log_info "⏳ 观察期 ${wait_time}s,监控流量稳定性..."
local waited=0
local check_interval=60
while [ $waited -lt $wait_time ]; do
sleep $check_interval
waited=$((waited + check_interval))
# 每隔一段时间做一次快速健康检查
local api_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 --max-time 10 \
"${PROD_API_URL}/health" 2>/dev/null || echo "000")
if [[ "$api_code" != "200" ]]; then
log_error "❌ 观察期内 API 异常 (HTTP $api_code),触发回滚"
rollback_gray
notify_status "failure" "Stage ${pct}% Watch Period Failed"
exit 1
fi
log_info " 观察中... ${waited}s/${wait_time}s (API=$api_code)"
done
log_info "${pct}%灰度阶段完成,稳定运行 ${wait_time}s"
done
# 全量部署
echo ""
echo "--- 最终阶段: 100% 全量部署 ---"
if ! full_deploy; then
log_error "全量部署失败"
notify_status "failure" "Full Deploy Failed"
exit 1
fi
# 最终健康检查
if ! health_check "全量部署" "180"; then
log_error "全量部署后健康检查失败"
notify_status "failure" "Full Deploy Health Check Failed"
exit 1
fi
# 清理 canary 容器
log_step "清理 Canary 容器"
run_ssh "docker rm -f xiaoxia-api-canary xiaoxia-web-canary 2>/dev/null || true" || true
echo ""
echo "==========================================="
echo " ✅ 金丝雀发布完成"
echo " 版本: $IMAGE_TAG"
echo " 状态: 100%全量运行"
echo "==========================================="
notify_status "success" "$IMAGE_TAG Fully Deployed"
}
main "$@"
+68 -127
View File
@@ -1,164 +1,105 @@
"""BGM工具函数单元测试"""
"""BGM 配置工具函数单元测试."""
from __future__ import annotations
import pytest
from domain.bgm_utils import merge_bgm_config
from packages.domain.bgm_utils import merge_bgm_config
class TestMergeBgmConfigBothEmpty:
"""两边都为空的情况。"""
class TestMergeBgmConfig:
"""merge_bgm_config 测试"""
def test_both_empty(self):
result = merge_bgm_config({}, {})
assert result == {}
# 确保返回的是新字典,不是同一个引用
assert result is not {}
def test_user_none_returns_template_copy(self):
"""用户传 None 视为空配置,返回模板副本。"""
result = merge_bgm_config({}, None)
assert result == {}
class TestMergeBgmConfigOnlyTemplate:
"""只有模板配置。"""
def test_only_template_returns_copy(self):
template = {"enabled": True, "volume": 0.5, "track": "default.mp3"}
def test_user_bgm_empty_returns_template_copy(self):
"""用户配置为空时,返回模板配置的拷贝"""
template = {"enabled": True, "volume": 0.5, "asset_id": "tpl_123"}
result = merge_bgm_config(template, {})
assert result == template
# 确保是副本,不是同一引用
result["volume"] = 0.9
assert template["volume"] == 0.5
assert result is not template
def test_only_template_with_none_user(self):
def test_user_bgm_none_returns_template_copy(self):
"""用户配置为 None 时,返回模板配置的拷贝"""
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, None)
result = merge_bgm_config(template, None) # type: ignore
assert result == template
class TestMergeBgmConfigOnlyUser:
"""只有用户配置。"""
def test_only_user_returns_copy(self):
user = {"enabled": False, "volume": 0.8}
def test_template_bgm_empty_returns_user_copy(self):
"""模板配置为空时,返回用户配置的拷贝"""
user = {"enabled": False, "volume": 0.8, "asset_id": "user_456"}
result = merge_bgm_config({}, user)
assert result == user
# 确保是副本
result["volume"] = 0.1
assert user["volume"] == 0.8
assert result is not user
def test_only_user_with_none_template(self):
user = {"enabled": False}
result = merge_bgm_config(None, user)
def test_template_bgm_none_returns_user_copy(self):
"""模板配置为 None 时,返回用户配置的拷贝"""
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config(None, user) # type: ignore
assert result == user
class TestMergeBgmConfigBasicOverride:
"""用户配置覆盖模板配置。"""
def test_volume_override(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
def test_user_fields_override_template(self):
"""用户显式指定的字段覆盖模板对应字段"""
template = {
"enabled": True,
"volume": 0.5,
"asset_id": "tpl_123",
"fade_in": 1.0,
}
user = {
"volume": 0.8,
"asset_id": "user_456",
}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.8
assert result["enabled"] is True # 用户没传,保留模板
assert result["asset_id"] == "user_456"
assert result["fade_in"] == 1.0 # 模板值保留
def test_track_override(self):
template = {"track": "default.mp3", "volume": 0.5}
user = {"track": "custom.mp3"}
result = merge_bgm_config(template, user)
assert result["track"] == "custom.mp3"
assert result["volume"] == 0.5
def test_multiple_fields_override(self):
template = {"enabled": True, "volume": 0.5, "track": "a.mp3", "fade_in": 2}
user = {"volume": 0.9, "track": "b.mp3"}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.9
assert result["track"] == "b.mp3"
assert result["fade_in"] == 2
assert result["enabled"] is True
class TestMergeBgmConfigEnabledSpecialHandling:
"""enabled 字段的特殊处理:用户没传就保留模板的。"""
def test_user_does_not_pass_enabled_keeps_template_true(self):
def test_enabled_not_in_user_preserves_template_enabled(self):
"""enabled 特殊处理:用户没传 enabled 时保留模板的 enabled 值"""
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
user = {"volume": 0.8} # 没传 enabled
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["enabled"] is True # 保留模板的
assert result["volume"] == 0.8 # 用户指定的覆盖
def test_user_does_not_pass_enabled_keeps_template_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"volume": 0.8}
def test_enabled_in_user_overrides_template(self):
"""用户传了 enabled 时覆盖模板的 enabled"""
template = {"enabled": True, "volume": 0.5}
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
assert result["volume"] == 0.8
def test_user_explicitly_sets_enabled_true(self):
template = {"enabled": False, "volume": 0.5}
user = {"enabled": True}
def test_user_adds_new_fields(self):
"""用户配置中的新字段会被添加到结果中"""
template = {"enabled": True, "volume": 0.5}
user = {"sidechain_enabled": True, "sidechain_ratio": 0.6}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
def test_user_explicitly_sets_enabled_false(self):
template = {"enabled": True, "volume": 0.5}
user = {"enabled": False}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_template_no_enabled_user_no_enabled(self):
template = {"volume": 0.5}
user = {"track": "a.mp3"}
result = merge_bgm_config(template, user)
assert "enabled" not in result
def test_template_no_enabled_user_has_enabled(self):
template = {"volume": 0.5}
user = {"enabled": True}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
def test_user_sets_enabled_none_explicitly(self):
"""用户显式传 None 也视为传了,会覆盖模板。"""
template = {"enabled": True, "volume": 0.5}
user = {"enabled": None}
result = merge_bgm_config(template, user)
assert result["enabled"] is None
class TestMergeBgmConfigNewFields:
"""用户配置新增模板没有的字段。"""
def test_user_adds_new_field(self):
template = {"volume": 0.5}
user = {"fade_out": 3}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.5
assert result["fade_out"] == 3
assert result["sidechain_enabled"] is True
assert result["sidechain_ratio"] == 0.6
def test_user_adds_multiple_new_fields(self):
template = {"enabled": True}
user = {"volume": 0.7, "track": "x.mp3", "loop": True}
def test_both_empty_returns_empty_dict(self):
"""两者都为空时返回空字典"""
result = merge_bgm_config({}, {})
assert result == {}
def test_nested_dict_shallow_merge(self):
"""嵌套字典是浅合并(当前设计)"""
template = {"enabled": True, "config": {"eq": True, "compression": False}}
user = {"config": {"compression": True, "reverb": 0.5}}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.7
assert result["track"] == "x.mp3"
assert result["loop"] is True
# 浅合并:整个 config 被用户值覆盖
assert result["config"] == {"compression": True, "reverb": 0.5}
class TestMergeBgmConfigImmutableInput:
"""确保输入字典不被修改。"""
def test_template_not_modified(self):
def test_does_not_mutate_template(self):
"""不修改原始模板配置"""
template = {"enabled": True, "volume": 0.5}
original = dict(template)
merge_bgm_config(template, {"volume": 0.9})
merge_bgm_config(template, {"volume": 0.8})
assert template == original
def test_user_not_modified(self):
user = {"enabled": False, "track": "x.mp3"}
def test_does_not_mutate_user(self):
"""不修改原始用户配置"""
user = {"volume": 0.8}
original = dict(user)
merge_bgm_config({"volume": 0.5}, user)
merge_bgm_config({"enabled": True}, user)
assert user == original
-160
View File
@@ -1,160 +0,0 @@
"""BGM工具函数领域层单元测试."""
import pytest
from packages.domain.bgm_utils import merge_bgm_config
class TestMergeBgmConfig:
"""merge_bgm_config 函数测试."""
def test_both_empty(self):
"""两个都是空字典."""
result = merge_bgm_config({}, {})
assert result == {}
def test_user_empty_returns_template_copy(self):
"""用户配置为空,返回模板配置的拷贝."""
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, {})
assert result == template
# 确保是副本不是引用
result["volume"] = 0.9
assert template["volume"] == 0.5
def test_template_empty_returns_user_copy(self):
"""模板配置为空,返回用户配置的拷贝."""
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config({}, user)
assert result == user
# 确保是副本
result["volume"] = 0.1
assert user["volume"] == 0.8
def test_user_overrides_template(self):
"""用户配置覆盖模板配置."""
template = {"enabled": True, "volume": 0.5, "track": "default"}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.8
assert result["track"] == "default"
def test_enabled_special_handling_user_not_set(self):
"""enabled 特殊处理:用户没传就保留模板的."""
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8} # 没传 enabled
result = merge_bgm_config(template, user)
# 用户没传 enabled,保留模板的 True
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_enabled_user_explicit_false(self):
"""用户显式传 enabled=False,应该覆盖模板."""
template = {"enabled": True, "volume": 0.5}
user = {"enabled": False}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_enabled_user_explicit_true(self):
"""用户显式传 enabled=True,覆盖模板的 False."""
template = {"enabled": False, "volume": 0.5}
user = {"enabled": True}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
def test_full_override(self):
"""用户完全覆盖模板."""
template = {"enabled": True, "volume": 0.3, "track": "piano"}
user = {"enabled": False, "volume": 0.9, "track": "guitar"}
result = merge_bgm_config(template, user)
assert result == user
def test_partial_override_keep_rest(self):
"""部分覆盖,其余保留模板值."""
template = {
"enabled": True,
"volume": 0.5,
"fade_in": 1.0,
"fade_out": 1.0,
"track": "default",
}
user = {"volume": 0.7, "fade_in": 2.0}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.7
assert result["fade_in"] == 2.0
assert result["fade_out"] == 1.0
assert result["track"] == "default"
assert result["enabled"] is True # 用户没传,保留模板
def test_user_none(self):
"""user_bgm 为 None 的情况."""
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, None) # type: ignore
assert result == template
def test_template_none(self):
"""template_bgm 为 None 的情况."""
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config(None, user) # type: ignore
assert result == user
def test_preserves_extra_fields(self):
"""保留模板中的额外字段(用户没覆盖的)."""
template = {"enabled": True, "volume": 0.5, "custom_field": "value"}
user = {"volume": 0.6}
result = merge_bgm_config(template, user)
assert result["custom_field"] == "value"
def test_user_adds_new_fields(self):
"""用户可以添加模板中没有的新字段."""
template = {"enabled": True, "volume": 0.5}
user = {"loop": True, "start_time": 5.0}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.5
assert result["loop"] is True
assert result["start_time"] == 5.0
def test_nested_dict_behavior(self):
"""嵌套字典的合并行为(简单替换,不深度合并)."""
template = {"enabled": True, "effects": {"fade": True, "reverb": False}}
user = {"effects": {"reverb": True}}
result = merge_bgm_config(template, user)
# 简单合并,用户的 effects 整体覆盖模板的
assert result["effects"] == {"reverb": True}
def test_enabled_in_template_only(self):
"""只有模板有 enabled,用户没有."""
template = {"enabled": False, "volume": 0.5}
user = {"volume": 0.7}
result = merge_bgm_config(template, user)
# 用户没传 enabled,保留模板的 False
assert result["enabled"] is False
def test_both_have_enabled_false(self):
"""两边都有 enabled 且都是 False."""
template = {"enabled": False, "volume": 0.5}
user = {"enabled": False}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_return_type_is_dict(self):
"""返回类型是 dict."""
result = merge_bgm_config({"a": 1}, {"b": 2})
assert isinstance(result, dict)
def test_does_not_mutate_template(self):
"""不修改原始模板字典."""
template = {"enabled": True, "volume": 0.5}
template_copy = template.copy()
user = {"volume": 0.9}
merge_bgm_config(template, user)
assert template == template_copy
def test_does_not_mutate_user(self):
"""不修改原始用户字典."""
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.9}
user_copy = user.copy()
merge_bgm_config(template, user)
assert user == user_copy
+43 -276
View File
@@ -1,6 +1,4 @@
"""分类领域模型单元测试 - 纯逻辑部分。"""
from __future__ import annotations
"""classification 模块单元测试."""
import pytest
from domain.classification import (
@@ -8,203 +6,99 @@ from domain.classification import (
AssetLibraryKind,
ClassificationJob,
ClassificationJobStatus,
ClassificationStatus,
IngestJobStatus,
)
class TestAssetLibraryKind:
"""素材库类型枚举。"""
"""AssetLibraryKind 枚举测试."""
def test_video_value(self):
def test_values(self):
assert AssetLibraryKind.VIDEO == "video"
def test_voice_value(self):
assert AssetLibraryKind.VOICE == "voice"
def test_image_value(self):
assert AssetLibraryKind.IMAGE == "image"
def test_is_str_enum(self):
assert isinstance(AssetLibraryKind.VIDEO, str)
assert AssetLibraryKind.VIDEO + "_test" == "video_test"
def test_members_count(self):
assert len(AssetLibraryKind) == 3
class TestIngestJobStatus:
"""导入任务状态枚举。"""
"""IngestJobStatus 枚举测试."""
def test_pending_value(self):
def test_values(self):
assert IngestJobStatus.PENDING == "pending"
def test_processing_value(self):
assert IngestJobStatus.PROCESSING == "processing"
def test_completed_value(self):
assert IngestJobStatus.COMPLETED == "completed"
def test_failed_value(self):
assert IngestJobStatus.FAILED == "failed"
def test_members_count(self):
assert len(IngestJobStatus) == 4
class TestClassificationJobStatus:
"""ClassificationJobStatus 枚举测试."""
class TestClassificationJobStatusMissing:
"""ClassificationJobStatus._missing_ 兼容性测试。"""
def test_standard_values(self):
"""标准值正常解析。"""
assert ClassificationJobStatus("pending") == ClassificationJobStatus.PENDING
assert ClassificationJobStatus("processing") == ClassificationJobStatus.PROCESSING
assert ClassificationJobStatus("completed") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("failed") == ClassificationJobStatus.FAILED
def test_done_maps_to_completed(self):
"""历史值 done 映射到 COMPLETED。"""
assert ClassificationJobStatus("done") == ClassificationJobStatus.COMPLETED
def test_success_maps_to_completed(self):
assert ClassificationJobStatus("success") == ClassificationJobStatus.COMPLETED
def test_finished_maps_to_completed(self):
assert ClassificationJobStatus("finished") == ClassificationJobStatus.COMPLETED
def test_complete_maps_to_completed(self):
assert ClassificationJobStatus("complete") == ClassificationJobStatus.COMPLETED
def test_fail_maps_to_failed(self):
assert ClassificationJobStatus("fail") == ClassificationJobStatus.FAILED
def test_error_maps_to_failed(self):
assert ClassificationJobStatus("error") == ClassificationJobStatus.FAILED
def test_err_maps_to_failed(self):
assert ClassificationJobStatus("err") == ClassificationJobStatus.FAILED
def test_process_maps_to_processing(self):
assert ClassificationJobStatus("process") == ClassificationJobStatus.PROCESSING
def test_running_maps_to_processing(self):
assert ClassificationJobStatus("running") == ClassificationJobStatus.PROCESSING
def test_run_maps_to_processing(self):
assert ClassificationJobStatus("run") == ClassificationJobStatus.PROCESSING
def test_unknown_value_defaults_to_pending(self):
"""未知值兜底为 PENDING。"""
assert ClassificationJobStatus("unknown") == ClassificationJobStatus.PENDING
assert ClassificationJobStatus("whatever") == ClassificationJobStatus.PENDING
assert ClassificationJobStatus("") == ClassificationJobStatus.PENDING
def test_case_insensitive(self):
"""大小写不敏感。"""
assert ClassificationJobStatus("DONE") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("Done") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("FAIL") == ClassificationJobStatus.FAILED
assert ClassificationJobStatus("Error") == ClassificationJobStatus.FAILED
def test_stripped(self):
"""前后空白字符被忽略。"""
assert ClassificationJobStatus(" done ") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("\tfail\n") == ClassificationJobStatus.FAILED
def test_none_returns_pending(self):
"""None 值也返回 PENDING(不报错)。"""
assert ClassificationJobStatus(None) == ClassificationJobStatus.PENDING
def test_integer_returns_pending(self):
"""非字符串值返回 PENDING。"""
assert ClassificationJobStatus(123) == ClassificationJobStatus.PENDING
class TestClassificationStatusAlias:
"""向后兼容别名。"""
def test_alias_same_class(self):
assert ClassificationStatus is ClassificationJobStatus
def test_alias_values_same(self):
assert ClassificationStatus.PENDING == ClassificationJobStatus.PENDING
assert ClassificationStatus.COMPLETED == ClassificationJobStatus.COMPLETED
def test_values(self):
assert ClassificationJobStatus.PENDING == "pending"
assert ClassificationJobStatus.PROCESSING == "processing"
assert ClassificationJobStatus.COMPLETED == "completed"
assert ClassificationJobStatus.FAILED == "failed"
class TestAssetClassification:
"""素材分类枚举。"""
"""AssetClassification 枚举测试."""
def test_scenic(self):
def test_values(self):
assert AssetClassification.SCENIC == "scenic"
def test_product(self):
assert AssetClassification.PRODUCT == "product"
def test_person(self):
assert AssetClassification.PERSON == "person"
def test_animal(self):
assert AssetClassification.ANIMAL == "animal"
def test_food(self):
assert AssetClassification.FOOD == "food"
def test_tech(self):
assert AssetClassification.TECH == "tech"
def test_sport(self):
assert AssetClassification.SPORT == "sport"
def test_music(self):
assert AssetClassification.MUSIC == "music"
def test_other(self):
assert AssetClassification.OTHER == "other"
def test_members_count(self):
assert len(AssetClassification) == 9
class TestClassificationJobCreate:
"""ClassificationJob.create 工厂方法"""
"""ClassificationJob.create 工厂方法测试."""
def test_create_basic(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
assert job.project_id == "proj-1"
assert job.asset_id == "asset-1"
def test_create_with_valid_params(self):
job = ClassificationJob.create(project_id="proj_001", asset_id="asset_001")
assert job.id
assert len(job.id) == 32
assert job.project_id == "proj_001"
assert job.asset_id == "asset_001"
assert job.status == ClassificationJobStatus.PENDING
assert job.classification == ""
assert job.confidence == 0.0
assert job.error_message == ""
assert job.id # 自动生成的 ID 非空
assert job.created_at is not None
assert job.updated_at is not None
def test_create_strips_whitespace(self):
job = ClassificationJob.create(project_id=" proj-1 ", asset_id="\tasset-1\n")
assert job.project_id == "proj-1"
assert job.asset_id == "asset-1"
def test_create_strips_strings(self):
job = ClassificationJob.create(
project_id=" proj_002 ",
asset_id=" asset_002 ",
)
assert job.project_id == "proj_002"
assert job.asset_id == "asset_002"
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
ClassificationJob.create(project_id="", asset_id="asset-1")
with pytest.raises(ValueError, match="project_id"):
ClassificationJob.create(project_id="", asset_id="a")
def test_create_whitespace_project_id_raises(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
ClassificationJob.create(project_id=" ", asset_id="asset-1")
with pytest.raises(ValueError, match="project_id"):
ClassificationJob.create(project_id=" ", asset_id="a")
def test_create_empty_asset_id_raises(self):
with pytest.raises(ValueError, match="asset_id 不能为空"):
ClassificationJob.create(project_id="proj-1", asset_id="")
with pytest.raises(ValueError, match="asset_id"):
ClassificationJob.create(project_id="p", asset_id="")
def test_create_whitespace_asset_id_raises(self):
with pytest.raises(ValueError, match="asset_id 不能为空"):
ClassificationJob.create(project_id="proj-1", asset_id=" \t ")
with pytest.raises(ValueError, match="asset_id"):
ClassificationJob.create(project_id="p", asset_id=" ")
def test_create_generates_unique_ids(self):
job1 = ClassificationJob.create(project_id="p", asset_id="a")
job2 = ClassificationJob.create(project_id="p", asset_id="a")
assert job1.id != job2.id
def test_create_ids_are_unique(self):
j1 = ClassificationJob.create(project_id="p", asset_id="a")
j2 = ClassificationJob.create(project_id="p", asset_id="b")
assert j1.id != j2.id
def test_create_id_is_hex(self):
def test_create_timestamps_are_utc(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
assert job.created_at.tzinfo is not None
assert job.updated_at.tzinfo is not None
@@ -243,130 +137,3 @@ class TestClassificationJobState:
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.confidence = 1.0
assert job.confidence == 1.0
class TestClassificationJobStatusMissing:
"""ClassificationJobStatus._missing_ 兼容行为测试"""
def test_done_maps_to_completed(self):
assert ClassificationJobStatus("done") == ClassificationJobStatus.COMPLETED
def test_success_maps_to_completed(self):
assert ClassificationJobStatus("success") == ClassificationJobStatus.COMPLETED
def test_finished_maps_to_completed(self):
assert ClassificationJobStatus("finished") == ClassificationJobStatus.COMPLETED
def test_complete_maps_to_completed(self):
assert ClassificationJobStatus("complete") == ClassificationJobStatus.COMPLETED
def test_fail_maps_to_failed(self):
assert ClassificationJobStatus("fail") == ClassificationJobStatus.FAILED
def test_error_maps_to_failed(self):
assert ClassificationJobStatus("error") == ClassificationJobStatus.FAILED
def test_err_maps_to_failed(self):
assert ClassificationJobStatus("err") == ClassificationJobStatus.FAILED
def test_unknown_maps_to_pending(self):
assert ClassificationJobStatus("unknown_status") == ClassificationJobStatus.PENDING
def test_case_insensitive_mapping(self):
assert ClassificationJobStatus("DONE") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("Done") == ClassificationJobStatus.COMPLETED
def test_whitespace_stripped(self):
assert ClassificationJobStatus(" done ") == ClassificationJobStatus.COMPLETED
class TestClassificationJobExtended:
"""ClassificationJob 深度补充测试"""
def test_id_is_hex(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
int(job.id, 16)
def test_empty_classification(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
assert job.classification == ""
def test_zero_confidence(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
assert job.confidence == 0.0
def test_high_confidence(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
job.confidence = 0.99
assert job.confidence == pytest.approx(0.99)
def test_negative_confidence(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
job.confidence = -0.1
assert job.confidence == pytest.approx(-0.1)
def test_confidence_over_one(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
job.confidence = 1.5
assert job.confidence == pytest.approx(1.5)
def test_empty_error_message(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
assert job.error_message == ""
def test_long_error_message(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
long_msg = "error" * 100
job.error_message = long_msg
assert job.error_message == long_msg
assert len(job.error_message) == 500
def test_status_with_string_assignment(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
job.status = "processing"
assert job.status == ClassificationJobStatus.PROCESSING
class TestAssetLibraryKindExtended:
"""AssetLibraryKind 深度补充测试"""
def test_image_value(self):
assert AssetLibraryKind.IMAGE == "image"
def test_all_three_kinds(self):
assert len(AssetLibraryKind) == 3
def test_is_string_enum(self):
assert isinstance(AssetLibraryKind.VIDEO, str)
def test_from_string(self):
assert AssetLibraryKind("video") == AssetLibraryKind.VIDEO
class TestIngestJobStatusExtended:
"""IngestJobStatus 深度补充测试"""
def test_is_string_enum(self):
assert isinstance(IngestJobStatus.PENDING, str)
def test_total_count(self):
assert len(IngestJobStatus) == 4
def test_from_string(self):
assert IngestJobStatus("pending") == IngestJobStatus.PENDING
class TestAssetClassificationExtended:
"""AssetClassification 深度补充测试"""
def test_total_count(self):
assert len(AssetClassification) == 9
def test_is_string_enum(self):
assert isinstance(AssetClassification.SCENIC, str)
def test_from_string(self):
assert AssetClassification("scenic") == AssetClassification.SCENIC
def test_other_category(self):
assert AssetClassification.OTHER == "other"
-539
View File
@@ -1,539 +0,0 @@
"""CoverGenerator 纯逻辑单测 — 时间钳制 + 智能选帧算法.
通过 mock run_ffmpeg 和 probe_video_info 验证纯逻辑部分,
不实际执行 FFmpeg,确保测试轻量快速。
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from video_processing.cover_generator import (
CoverGenerator,
DEFAULT_COVER_HEIGHT,
DEFAULT_COVER_QUALITY,
DEFAULT_COVER_TIME,
DEFAULT_COVER_WIDTH,
SMART_COVER_FRAME_COUNT,
)
class TestCoverGeneratorConstants:
"""常量默认值测试."""
def test_default_cover_time(self):
"""默认抽帧时间为 1.0 秒."""
assert DEFAULT_COVER_TIME == 1.0
def test_default_dimensions(self):
"""默认封面尺寸 1080x1920 (竖屏)."""
assert DEFAULT_COVER_WIDTH == 1080
assert DEFAULT_COVER_HEIGHT == 1920
def test_default_quality(self):
"""默认质量为 5 (JPEG q:v, 越小越好)."""
assert DEFAULT_COVER_QUALITY == 5
def test_smart_cover_frame_count(self):
"""智能封面默认抽 3 帧."""
assert SMART_COVER_FRAME_COUNT == 3
class TestExtractFrameCommand:
"""extract_frame 命令构建测试."""
def _probe_video_info_mock(self, duration=10.0):
"""创建 probe_video_info 的 mock."""
return {"duration": duration, "width": 1920, "height": 1080, "fps": 25.0}
def test_default_params_command(self, tmp_path):
"""默认参数下 FFmpeg 命令正确."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(),
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
# 让 output_path 在 run_ffmpeg 后存在
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
result = CoverGenerator.extract_frame(str(video_file), str(output_file))
assert result == Path(output_file)
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
# 基本结构
assert cmd[0].endswith("ffmpeg") or "ffmpeg" in cmd[0]
assert "-y" in cmd
assert "-vframes" in cmd
assert cmd[cmd.index("-vframes") + 1] == "1"
assert "-f" in cmd
assert "mjpeg" in cmd[cmd.index("-f") + 1]
# 时间点
ss_idx = cmd.index("-ss")
assert float(cmd[ss_idx + 1]) == pytest.approx(DEFAULT_COVER_TIME, abs=0.001)
# 输入文件
i_idx = cmd.index("-i")
assert cmd[i_idx + 1] == str(video_file)
# 输出文件
assert cmd[-1] == str(output_file)
# scale + crop 滤镜
vf_idx = cmd.index("-vf")
vf_value = cmd[vf_idx + 1]
assert "scale=" in vf_value
assert "crop=" in vf_value
assert "force_original_aspect_ratio=increase" in vf_value
def test_custom_time(self, tmp_path):
"""自定义抽帧时间点."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(duration=30.0),
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=5.5)
cmd = mock_run.call_args[0][0]
ss_idx = cmd.index("-ss")
assert float(cmd[ss_idx + 1]) == pytest.approx(5.5, abs=0.001)
def test_custom_dimensions(self, tmp_path):
"""自定义输出尺寸."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(),
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.extract_frame(str(video_file), str(output_file), width=1920, height=1080)
cmd = mock_run.call_args[0][0]
vf_idx = cmd.index("-vf")
vf_value = cmd[vf_idx + 1]
assert "scale=1920:1080:" in vf_value
assert "crop=1920:1080" in vf_value
def test_custom_quality(self, tmp_path):
"""自定义 JPEG 质量."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(),
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.extract_frame(str(video_file), str(output_file), quality=2)
cmd = mock_run.call_args[0][0]
q_idx = cmd.index("-q:v")
assert cmd[q_idx + 1] == "2"
def test_time_exceeds_duration_clamps_to_midpoint(self, tmp_path):
"""抽帧时间超过视频时长时,钳制到中间帧."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(duration=5.0),
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
cmd = mock_run.call_args[0][0]
ss_idx = cmd.index("-ss")
# 钳制到 duration/2 = 2.5
assert float(cmd[ss_idx + 1]) == pytest.approx(2.5, abs=0.001)
def test_negative_time_clamps_to_zero(self, tmp_path):
"""负时间钳制到 0."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(duration=10.0),
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=-2.0)
cmd = mock_run.call_args[0][0]
ss_idx = cmd.index("-ss")
assert float(cmd[ss_idx + 1]) == pytest.approx(0.0, abs=0.001)
def test_time_equals_duration_clamps_to_midpoint(self, tmp_path):
"""时间点等于时长时钳制到中间帧."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(duration=10.0),
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
cmd = mock_run.call_args[0][0]
ss_idx = cmd.index("-ss")
assert float(cmd[ss_idx + 1]) == pytest.approx(5.0, abs=0.001)
def test_zero_duration_video(self, tmp_path):
"""视频时长为 0 时的行为(不钳制,用原始时间)."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(duration=0.0),
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=0.5)
cmd = mock_run.call_args[0][0]
ss_idx = cmd.index("-ss")
assert float(cmd[ss_idx + 1]) == pytest.approx(0.5, abs=0.001)
def test_video_not_found_raises(self, tmp_path):
"""视频文件不存在时抛出 FileNotFoundError."""
output_file = tmp_path / "cover.jpg"
with pytest.raises(FileNotFoundError):
CoverGenerator.extract_frame(str(tmp_path / "nonexistent.mp4"), str(output_file))
def test_output_creates_parent_dir(self, tmp_path):
"""输出目录不存在时自动创建."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
out_dir = tmp_path / "deep" / "nested"
output_file = out_dir / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(),
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.extract_frame(str(video_file), str(output_file))
assert out_dir.exists()
assert out_dir.is_dir()
def test_ffmpeg_failure_propagates(self, tmp_path):
"""FFmpeg 失败时异常向上传递."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value=self._probe_video_info_mock(),
),
patch(
"video_processing.cover_generator.run_ffmpeg",
side_effect=RuntimeError("FFmpeg error"),
),
):
with pytest.raises(RuntimeError, match="FFmpeg error"):
CoverGenerator.extract_frame(str(video_file), str(output_file))
class TestSmartCoverTimePoints:
"""智能封面时间点计算测试."""
def test_single_frame_falls_back_to_default(self, tmp_path):
"""只有 1 帧时退化为普通抽帧(取 DEFAULT_COVER_TIME 和 midpoint 中较小值)."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value={"duration": 20.0},
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
# frame_count=1 时退化为普通抽帧
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=1)
# 只调用一次(退化路径)
assert mock_run.call_count == 1
cmd = mock_run.call_args[0][0]
ss_idx = cmd.index("-ss")
# min(DEFAULT_COVER_TIME=1.0, duration/2=10.0) = 1.0
assert float(cmd[ss_idx + 1]) == pytest.approx(1.0, abs=0.001)
def test_zero_duration_falls_back(self, tmp_path):
"""视频时长为 0 时退化为普通抽帧."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value={"duration": 0.0},
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
# 只调用一次(退化路径)
assert mock_run.call_count == 1
def test_three_frames_uniform_distribution(self, tmp_path):
"""3 帧均匀分布在 5%~95% 区间."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
call_times = []
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value={"duration": 100.0},
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
# 记录抽帧时间
ss_idx = cmd.index("-ss")
call_times.append(float(cmd[ss_idx + 1]))
# 在输出路径写文件
output_arg = cmd[-1]
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
# 不同文件大小,让第三帧"最清晰"
idx = len(call_times) - 1
size = 1000 * (idx + 1) # 递增的文件大小
Path(output_arg).write_bytes(b"x" * size)
mock_run.side_effect = fake_run
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
# 3 帧:5%、50%、95%
assert len(call_times) == 3
assert call_times[0] == pytest.approx(5.0, abs=0.1) # 5%
assert call_times[1] == pytest.approx(50.0, abs=0.1) # 50%
assert call_times[2] == pytest.approx(95.0, abs=0.1) # 95%
def test_five_frames_distribution(self, tmp_path):
"""5 帧均匀分布."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
call_times = []
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value={"duration": 100.0},
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
def fake_run(cmd):
ss_idx = cmd.index("-ss")
call_times.append(float(cmd[ss_idx + 1]))
output_arg = cmd[-1]
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
idx = len(call_times) - 1
Path(output_arg).write_bytes(b"x" * (1000 * (idx + 1)))
mock_run.side_effect = fake_run
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=5)
assert len(call_times) == 5
# step = (95-5) / (5-1) = 22.5
# times: 5, 27.5, 50, 72.5, 95
assert call_times[0] == pytest.approx(5.0, abs=0.1)
assert call_times[1] == pytest.approx(27.5, abs=0.1)
assert call_times[2] == pytest.approx(50.0, abs=0.1)
assert call_times[3] == pytest.approx(72.5, abs=0.1)
assert call_times[4] == pytest.approx(95.0, abs=0.1)
def test_selects_largest_file_as_best(self, tmp_path):
"""选择文件最大的帧作为最佳封面(清晰度近似)."""
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
sizes = [5000, 15000, 8000] # 第二帧最大
with (
patch(
"video_processing.cover_generator.probe_video_info",
return_value={"duration": 100.0},
),
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
):
call_idx = [0]
def fake_run(cmd):
output_arg = cmd[-1]
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
idx = call_idx[0]
Path(output_arg).write_bytes(b"x" * sizes[idx])
call_idx[0] += 1
mock_run.side_effect = fake_run
result = CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
# 第二帧(索引1)应该是最佳
assert result == output_file
# 输出文件大小应等于第二帧大小
assert output_file.stat().st_size == 15000
class TestProcessCustomCover:
"""自定义封面处理测试."""
def test_custom_cover_resize_command(self, tmp_path):
"""自定义封面调整尺寸命令正确."""
input_file = tmp_path / "upload.jpg"
input_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.process_custom_cover(str(input_file), str(output_file))
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "-i" in cmd
assert cmd[cmd.index("-i") + 1] == str(input_file)
assert cmd[-1] == str(output_file)
# scale + crop
vf_idx = cmd.index("-vf")
vf_value = cmd[vf_idx + 1]
assert "scale=" in vf_value
assert "crop=" in vf_value
def test_custom_cover_not_found_raises(self, tmp_path):
"""自定义封面文件不存在时抛出 FileNotFoundError."""
output_file = tmp_path / "cover.jpg"
with pytest.raises(FileNotFoundError):
CoverGenerator.process_custom_cover(str(tmp_path / "nonexistent.jpg"), str(output_file))
def test_custom_cover_custom_dimensions(self, tmp_path):
"""自定义封面自定义输出尺寸."""
input_file = tmp_path / "upload.jpg"
input_file.write_bytes(b"fake")
output_file = tmp_path / "cover.jpg"
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
def fake_run(cmd):
output_file.write_bytes(b"fake jpg")
mock_run.side_effect = fake_run
CoverGenerator.process_custom_cover(str(input_file), str(output_file), width=800, height=600)
cmd = mock_run.call_args[0][0]
vf_idx = cmd.index("-vf")
vf_value = cmd[vf_idx + 1]
assert "scale=800:600:" in vf_value
assert "crop=800:600" in vf_value
-223
View File
@@ -1,223 +0,0 @@
"""去重纯算法测试 — hamming_distance + histogram_similarity + VideoFingerprint."""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
import numpy as np
import pytest
# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2
# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接
# cv2(视频处理依赖,纯算法测试不需要)
sys.modules["cv2"] = MagicMock()
# 模块级mock worker_app.dbdedup模块import时会触发数据库初始化,纯算法测试不需要)
sys.modules["worker_app.db"] = MagicMock()
sys.modules["worker_app.db"].SessionLocal = MagicMock()
# celery 及其子模块
_mock_celery = MagicMock()
_mock_celery.Task = MagicMock
_mock_celery.Celery = MagicMock
sys.modules["celery"] = _mock_celery
# sqlalchemy 作为包结构 mock
_mock_sqla = MagicMock()
_mock_sqla.__path__ = []
_mock_sqla.__package__ = "sqlalchemy"
_mock_sqla_orm = MagicMock()
_mock_sqla_orm.__path__ = []
_mock_sqla_orm.Session = MagicMock
_mock_sqla_engine = MagicMock()
sys.modules["sqlalchemy"] = _mock_sqla
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
sys.modules["sqlalchemy.engine"] = _mock_sqla_engine
sys.modules["sqlalchemy.ext"] = MagicMock()
sys.modules["sqlalchemy.ext.declarative"] = MagicMock()
# worker_app 及其子模块(避免导入时触发数据库连接)
_mock_worker_app = MagicMock()
_mock_worker_app.__path__ = []
_mock_worker_db = MagicMock()
_mock_worker_db.SessionLocal = MagicMock()
_mock_worker_celery = MagicMock()
_mock_worker_celery.celery_app = MagicMock()
_mock_worker_core = MagicMock()
_mock_worker_core.__path__ = []
_mock_worker_config = MagicMock()
_mock_worker_config.get_settings = MagicMock(return_value=MagicMock())
sys.modules["worker_app"] = _mock_worker_app
sys.modules["worker_app.db"] = _mock_worker_db
sys.modules["worker_app.celery_app"] = _mock_worker_celery
sys.modules["worker_app.core"] = _mock_worker_core
sys.modules["worker_app.core.config"] = _mock_worker_config
# packages.adapters.sqlalchemy_impl(整个包mock掉)
_mock_sqla_impl = MagicMock()
_mock_sqla_impl.__path__ = []
sys.modules["packages.adapters.sqlalchemy_impl"] = _mock_sqla_impl
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = MagicMock()
sys.modules["packages.adapters.sqlalchemy_impl.schema_guard"] = MagicMock()
# packages.shared
_mock_packages_shared = MagicMock()
_mock_packages_shared.__path__ = []
_mock_shared_config = MagicMock()
_mock_shared_config.get_shared_settings = MagicMock(return_value=MagicMock())
sys.modules["packages.shared"] = _mock_packages_shared
sys.modules["packages.shared.config"] = _mock_shared_config
sys.modules["packages.shared.storage"] = MagicMock()
from video_processing.dedup import ( # noqa: E402
VideoDeduplicator,
VideoFingerprint,
hamming_distance,
)
class TestHammingDistance:
"""hamming_distance 汉明距离计算测试."""
def test_identical_hashes_zero(self):
"""相同哈希距离为0."""
assert hamming_distance("ff", "ff") == 0
assert hamming_distance("00", "00") == 0
def test_all_different(self):
"""全不同的8bit哈希距离为8."""
assert hamming_distance("00", "ff") == 8
def test_single_bit_diff(self):
"""1个bit不同."""
# 0x01 = 00000001, 0x00 = 00000000 → 1 bit不同
assert hamming_distance("01", "00") == 1
def test_four_bits_diff(self):
"""4个bit不同."""
# 0x0F = 00001111, 0xF0 = 11110000 → 8 bits都不同
assert hamming_distance("0f", "f0") == 8
def test_longer_hashes(self):
"""更长的哈希(如64-bit pHash."""
# 两个完全不同的64-bit哈希
assert hamming_distance("0000000000000000", "ffffffffffffffff") == 64
def test_partial_difference(self):
"""部分bit不同."""
# a = 1010, 5 = 0101 → 4 bits不同(每个hex digit
assert hamming_distance("aa", "55") == 8
def test_case_insensitive(self):
"""十六进制不区分大小写."""
assert hamming_distance("FF", "ff") == 0
assert hamming_distance("AbC123", "aBc123") == 0
def test_different_length_hashes(self):
"""不同长度的哈希(短的前补零)."""
# "ff" = 0xff = 255, "0ff" = 0x0ff = 255
# int("ff", 16) = 255, int("0ff", 16) = 255
assert hamming_distance("ff", "0ff") == 0
class TestVideoFingerprint:
"""VideoFingerprint 数据结构测试."""
def test_to_dict_contains_all_fields(self):
"""to_dict返回完整字典."""
fp = VideoFingerprint(
md5="abc123",
keyframe_phashes=["hash1", "hash2"],
color_histograms=[[0.1, 0.2], [0.3, 0.4]],
duration=30.5,
resolution=(1920, 1080),
)
d = fp.to_dict()
assert d["md5"] == "abc123"
assert d["keyframe_phashes"] == ["hash1", "hash2"]
assert d["duration"] == 30.5
assert d["resolution"] == [1920, 1080]
assert "color_histograms" in d
def test_empty_phashes(self):
"""空关键帧列表."""
fp = VideoFingerprint(
md5="test",
keyframe_phashes=[],
color_histograms=[],
duration=0.0,
resolution=(0, 0),
)
d = fp.to_dict()
assert d["keyframe_phashes"] == []
assert d["color_histograms"] == []
class TestAverageHistogramSimilarity:
"""_average_histogram_similarity 直方图相似度测试."""
def test_identical_histograms(self):
"""完全相同的直方图相似度为1.0."""
hist = [[0.5, 0.5, 0.0], [0.3, 0.4, 0.3]]
sim = VideoDeduplicator._average_histogram_similarity(hist, hist)
assert sim == pytest.approx(1.0)
def test_empty_first_list(self):
"""第一组为空返回0."""
sim = VideoDeduplicator._average_histogram_similarity([], [[0.5, 0.5]])
assert sim == 0.0
def test_empty_second_list(self):
"""第二组为空返回0."""
sim = VideoDeduplicator._average_histogram_similarity([[0.5, 0.5]], [])
assert sim == 0.0
def test_both_empty(self):
"""两组都为空返回0."""
sim = VideoDeduplicator._average_histogram_similarity([], [])
assert sim == 0.0
def test_orthogonal_histograms(self):
"""正交直方图相似度为0."""
# [1, 0] 和 [0, 1] 正交
sim = VideoDeduplicator._average_histogram_similarity([[1.0, 0.0]], [[0.0, 1.0]])
assert sim == pytest.approx(0.0)
def test_partial_similarity(self):
"""部分相似."""
# [1, 1] 和 [1, 0] 的余弦相似度 = 1/√2 ≈ 0.707
sim = VideoDeduplicator._average_histogram_similarity([[1.0, 1.0]], [[1.0, 0.0]])
assert sim == pytest.approx(1.0 / (2**0.5), rel=0.01)
def test_multiple_frames_best_match(self):
"""多帧时取最佳匹配."""
# 第一帧完全不同,第二帧完全相同 → 平均 best = (0 + 1) / 2 = 0.5
sim = VideoDeduplicator._average_histogram_similarity(
[[1.0, 0.0], [0.0, 1.0]],
[[0.0, 1.0]], # 只有一帧,和第一帧0相似,和第二帧1相似
)
# 第一帧最佳匹配=0,第二帧最佳匹配=1,平均=0.5
assert sim == pytest.approx(0.5)
def test_zero_norm_histogram_skipped(self):
"""零范数直方图被跳过."""
sim = VideoDeduplicator._average_histogram_similarity([[0.0, 0.0]], [[1.0, 1.0]])
# 第一组的零范数被跳过,similarities为空,返回0
assert sim == 0.0
def test_different_length_histograms(self):
"""不同长度的直方图取最小长度对齐."""
sim = VideoDeduplicator._average_histogram_similarity(
[[1.0, 1.0, 0.0, 0.0]], # 4维
[[1.0, 1.0]], # 2维
)
# 对齐到前2维,都是[1,1],相似度1.0
assert sim == pytest.approx(1.0)
def test_similarity_in_zero_one_range(self):
"""相似度在[0, 1]范围内."""
hist_a = [np.random.rand(96).tolist() for _ in range(5)]
hist_b = [np.random.rand(96).tolist() for _ in range(5)]
sim = VideoDeduplicator._average_histogram_similarity(hist_a, hist_b)
assert 0.0 <= sim <= 1.0
-523
View File
@@ -1,523 +0,0 @@
"""领域实体测试 — Project + Asset + AssetStatus + IngestJob."""
from __future__ import annotations
import pytest
from packages.domain.entities import (
Asset,
AssetLibrary,
AssetStatus,
ClassificationStatus,
IngestJob,
Project,
User,
)
class TestUser:
"""User 实体测试."""
def test_create_user(self):
"""创建用户."""
user = User(
id="u1",
email="test@example.com",
display_name="测试用户",
username="testuser",
)
assert user.id == "u1"
assert user.email == "test@example.com"
assert user.display_name == "测试用户"
assert user.username == "testuser"
def test_default_subscription_free(self):
"""默认订阅免费版."""
user = User(id="u1", email="a@b.com", display_name="A")
assert user.subscription_plan == "free"
assert user.subscription_status == "active"
assert user.max_projects == 3
assert user.max_storage_gb == 10
def test_default_not_admin(self):
"""默认不是管理员."""
user = User(id="u1", email="a@b.com", display_name="A")
assert user.is_admin is False
class TestProject:
"""Project 实体测试."""
def test_create_project_success(self):
"""创建项目成功."""
project = Project.create(
owner_user_id="user_1",
name="我的项目",
description="测试项目描述",
)
assert project.id is not None
assert len(project.id) == 32 # uuid hex
assert project.owner_user_id == "user_1"
assert project.name == "我的项目"
assert project.description == "测试项目描述"
assert project.shared_users == []
def test_create_project_name_stripped(self):
"""项目名称首尾空白被去除."""
project = Project.create(owner_user_id="u1", name=" 测试项目 ")
assert project.name == "测试项目"
def test_create_project_empty_name_raises(self):
"""空项目名抛异常."""
with pytest.raises(ValueError, match="项目名称不能为空"):
Project.create(owner_user_id="u1", name="")
def test_create_project_whitespace_name_raises(self):
"""全空白项目名抛异常."""
with pytest.raises(ValueError, match="项目名称不能为空"):
Project.create(owner_user_id="u1", name=" ")
def test_is_owner_true(self):
"""是项目所有者."""
project = Project.create(owner_user_id="owner_1", name="项目")
assert project.is_owner("owner_1") is True
def test_is_owner_false(self):
"""不是项目所有者."""
project = Project.create(owner_user_id="owner_1", name="项目")
assert project.is_owner("other_user") is False
def test_is_shared_with_true(self):
"""项目已共享给用户."""
project = Project.create(owner_user_id="u1", name="项目")
project.shared_users.append("user_2")
assert project.is_shared_with("user_2") is True
def test_is_shared_with_false(self):
"""项目未共享给用户."""
project = Project.create(owner_user_id="u1", name="项目")
assert project.is_shared_with("nobody") is False
def test_can_access_owner(self):
"""所有者可以访问."""
project = Project.create(owner_user_id="u1", name="项目")
assert project.can_access("u1") is True
def test_can_access_shared_user(self):
"""共享用户可以访问."""
project = Project.create(owner_user_id="u1", name="项目")
project.shared_users.append("u2")
assert project.can_access("u2") is True
def test_can_access_outsider(self):
"""无关用户不能访问."""
project = Project.create(owner_user_id="u1", name="项目")
assert project.can_access("stranger") is False
class TestAssetLibrary:
"""AssetLibrary 实体测试."""
def test_create_library(self):
"""创建素材库."""
from packages.domain.classification import AssetLibraryKind
lib = AssetLibrary.create(
project_id="p1",
name="默认库",
kind=AssetLibraryKind.VIDEO,
)
assert lib.id is not None
assert lib.project_id == "p1"
assert lib.name == "默认库"
assert lib.kind == AssetLibraryKind.VIDEO
assert lib.asset_count == 0
def test_create_default_counts(self):
"""默认素材数量和大小为0."""
from packages.domain.classification import AssetLibraryKind
lib = AssetLibrary.create(
project_id="p1",
name="我的素材",
kind=AssetLibraryKind.VOICE,
)
assert lib.asset_count == 0
assert lib.total_size == 0
def test_empty_name_raises(self):
"""空名称抛异常."""
from packages.domain.classification import AssetLibraryKind
with pytest.raises(ValueError, match="素材库名称不能为空"):
AssetLibrary.create(project_id="p1", name="", kind=AssetLibraryKind.VIDEO)
class TestAssetStatus:
"""AssetStatus 枚举兼容测试."""
def test_direct_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):
"""历史值 uploaded → READY."""
assert AssetStatus("uploaded") == AssetStatus.READY
def test_missing_success_maps_to_ready(self):
"""success → READY."""
assert AssetStatus("success") == AssetStatus.READY
def test_missing_ok_maps_to_ready(self):
"""ok → READY."""
assert AssetStatus("ok") == AssetStatus.READY
def test_missing_done_maps_to_ready(self):
"""done → READY."""
assert AssetStatus("done") == AssetStatus.READY
def test_missing_upload_maps_to_uploading(self):
"""upload → UPLOADING."""
assert AssetStatus("upload") == AssetStatus.UPLOADING
def test_missing_uploading_start_maps_to_uploading(self):
"""uploading_start → UPLOADING."""
assert AssetStatus("uploading_start") == AssetStatus.UPLOADING
def test_missing_failed_maps_to_error(self):
"""failed → ERROR."""
assert AssetStatus("failed") == AssetStatus.ERROR
def test_missing_fail_maps_to_error(self):
"""fail → ERROR."""
assert AssetStatus("fail") == AssetStatus.ERROR
def test_missing_process_maps_to_processing(self):
"""process → PROCESSING."""
assert AssetStatus("process") == AssetStatus.PROCESSING
def test_missing_running_maps_to_processing(self):
"""running → PROCESSING."""
assert AssetStatus("running") == AssetStatus.PROCESSING
def test_unknown_value_fallback_to_ready(self):
"""完全未知值 → READY兜底."""
assert AssetStatus("completely_unknown") == AssetStatus.READY
def test_case_insensitive(self):
"""大小写不敏感."""
assert AssetStatus("UPLOADED") == AssetStatus.READY
assert AssetStatus("Failed") == AssetStatus.ERROR
def test_whitespace_stripped(self):
"""首尾空白被去除."""
assert AssetStatus(" ready ") == AssetStatus.READY
def test_none_value_fallback(self):
"""None值 → READY兜底(不抛异常)."""
assert AssetStatus(None) == AssetStatus.READY
class TestAsset:
"""Asset 实体测试."""
def test_create_asset_success(self):
"""创建素材成功."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="测试视频.mp4",
storage_key="projects/p1/assets/test.mp4",
mime_type="video/mp4",
file_size=1024000,
duration=30.5,
width=1920,
height=1080,
)
assert asset.id is not None
assert len(asset.id) == 32
assert asset.project_id == "p1"
assert asset.name == "测试视频.mp4"
assert asset.storage_key == "projects/p1/assets/test.mp4"
assert asset.mime_type == "video/mp4"
assert asset.file_size == 1024000
assert asset.duration == pytest.approx(30.5)
assert asset.width == 1920
assert asset.height == 1080
assert asset.status == AssetStatus.UPLOADING
assert asset.tag_ids == []
def test_file_type_video(self):
"""video类型从mime_type推导."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
)
assert asset.file_type == "video"
def test_file_type_image(self):
"""image类型."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="i.jpg",
storage_key="k",
mime_type="image/jpeg",
)
assert asset.file_type == "image"
def test_file_type_audio(self):
"""audio类型."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="a.mp3",
storage_key="k",
mime_type="audio/mpeg",
)
assert asset.file_type == "audio"
def test_file_type_no_slash(self):
"""mime_type没有斜杠时返回原值."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="f.txt",
storage_key="k",
mime_type="text",
)
assert asset.file_type == "text"
def test_empty_name_raises(self):
"""空名称抛异常."""
with pytest.raises(ValueError, match="素材名称不能为空"):
Asset.create(
project_id="p1",
library_id="lib1",
name="",
storage_key="k",
mime_type="video/mp4",
)
def test_empty_storage_key_raises(self):
"""空storage_key抛异常."""
with pytest.raises(ValueError, match="storage_key"):
Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key=" ",
mime_type="video/mp4",
)
def test_empty_mime_type_raises(self):
"""空mime_type抛异常."""
with pytest.raises(ValueError, match="mime_type"):
Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="",
)
def test_name_stripped(self):
"""名称首尾空白被去除."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name=" 视频.mp4 ",
storage_key="k",
mime_type="video/mp4",
)
assert asset.name == "视频.mp4"
def test_add_tag_success(self):
"""添加标签成功."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
)
asset.add_tag("tag_1")
assert "tag_1" in asset.tag_ids
assert len(asset.tag_ids) == 1
def test_add_duplicate_tag_deduped(self):
"""重复添加标签自动去重."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.add_tag("tag_1")
assert asset.tag_ids.count("tag_1") == 1
def test_add_empty_tag_raises(self):
"""空标签ID抛异常."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
)
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag(" ")
def test_remove_tag_success(self):
"""删除标签成功."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.remove_tag("tag_1")
assert "tag_1" not in asset.tag_ids
def test_remove_nonexistent_tag_no_error(self):
"""删除不存在的标签不报错(幂等)."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
)
# 不抛异常
asset.remove_tag("nonexistent_tag")
def test_default_status_uploading(self):
"""默认状态UPLOADING."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
)
assert asset.status == AssetStatus.UPLOADING
def test_custom_status(self):
"""自定义状态."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
status=AssetStatus.READY,
)
assert asset.status == AssetStatus.READY
def test_metadata_default_empty_dict(self):
"""metadata默认为空dict."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
)
assert asset.metadata == {}
def test_metadata_none_becomes_empty(self):
"""metadata=None → {}."""
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="v.mp4",
storage_key="k",
mime_type="video/mp4",
metadata=None,
)
assert asset.metadata == {}
class TestIngestJob:
"""IngestJob 实体测试."""
def test_create_ingest_job(self):
"""创建入库任务."""
job = IngestJob.create(
project_id="p1",
library_id="lib1",
storage_key="projects/p1/uploads/temp.mp4",
file_hash="abc123",
)
assert job.id is not None
assert job.project_id == "p1"
assert job.library_id == "lib1"
assert job.storage_key == "projects/p1/uploads/temp.mp4"
assert job.file_hash == "abc123"
def test_default_status_pending(self):
"""默认状态PENDING."""
from packages.domain.classification import IngestJobStatus
job = IngestJob.create(
project_id="p1",
library_id="lib1",
storage_key="projects/p1/v.mp4",
)
assert job.status == IngestJobStatus.PENDING
def test_empty_project_id_raises(self):
"""空project_id抛异常."""
with pytest.raises(ValueError, match="project_id"):
IngestJob.create(
project_id=" ",
library_id="lib1",
storage_key="k",
)
def test_empty_library_id_raises(self):
"""空library_id抛异常."""
with pytest.raises(ValueError, match="library_id"):
IngestJob.create(
project_id="p1",
library_id="",
storage_key="k",
)
def test_empty_storage_key_raises(self):
"""空storage_key抛异常."""
with pytest.raises(ValueError, match="storage_key"):
IngestJob.create(
project_id="p1",
library_id="lib1",
storage_key=" ",
)
def test_default_result_asset_id_empty(self):
"""默认result_asset_id为空."""
job = IngestJob.create(
project_id="p1",
library_id="lib1",
storage_key="k",
)
assert job.result_asset_id == ""
def test_error_message_empty_by_default(self):
"""默认error_message为空."""
job = IngestJob.create(
project_id="p1",
library_id="lib1",
storage_key="k",
)
assert job.error_message == ""
+69 -52
View File
@@ -1,6 +1,4 @@
"""
领域层异常类单元测试
"""
"""领域层通用异常单元测试."""
import pytest
@@ -16,99 +14,118 @@ class TestDomainError:
"""DomainError 基类测试"""
def test_is_exception(self):
"""DomainError 是 Exception 的子类"""
assert issubclass(DomainError, Exception)
def test_raise_and_catch(self):
def test_can_raise_and_catch(self):
"""可以抛出和捕获"""
with pytest.raises(DomainError):
raise DomainError("test error")
raise DomainError("something went wrong")
def test_error_message(self):
err = DomainError("something went wrong")
assert str(err) == "something went wrong"
def test_empty_message(self):
err = DomainError("")
assert str(err) == ""
def test_message(self):
"""异常消息正确"""
err = DomainError("test message")
assert str(err) == "test message"
class TestNotFoundError:
"""NotFoundError 测试"""
def test_is_domain_error(self):
"""NotFoundError 继承自 DomainError"""
assert issubclass(NotFoundError, DomainError)
def test_raise_and_catch_as_domain(self):
def test_can_raise_as_domain_error(self):
"""可以作为 DomainError 捕获"""
with pytest.raises(DomainError):
raise NotFoundError("resource not found")
def test_raise_and_catch_specific(self):
with pytest.raises(NotFoundError):
raise NotFoundError("not found")
def test_default_message(self):
"""无参构造"""
err = NotFoundError()
assert isinstance(err, NotFoundError)
def test_error_message(self):
def test_custom_message(self):
"""自定义消息"""
err = NotFoundError("user 123 not found")
assert "user 123 not found" in str(err)
assert str(err) == "user 123 not found"
class TestValidationError:
"""ValidationError 测试"""
def test_is_domain_error(self):
"""ValidationError 继承自 DomainError"""
assert issubclass(ValidationError, DomainError)
def test_raise_and_catch_as_domain(self):
def test_can_raise_as_domain_error(self):
"""可以作为 DomainError 捕获"""
with pytest.raises(DomainError):
raise ValidationError("invalid input")
def test_raise_and_catch_specific(self):
with pytest.raises(ValidationError):
raise ValidationError("validation failed")
def test_error_message(self):
err = ValidationError("name cannot be empty")
assert "name cannot be empty" in str(err)
def test_custom_message(self):
"""自定义消息"""
err = ValidationError("duration must be positive")
assert str(err) == "duration must be positive"
class TestQuotaExceededError:
"""QuotaExceededError 测试"""
def test_is_domain_error(self):
"""QuotaExceededError 继承自 DomainError"""
assert issubclass(QuotaExceededError, DomainError)
def test_constructor_sets_attributes(self):
err = QuotaExceededError(dimension="storage", limit=1024.0, used=2048.0)
assert err.dimension == "storage"
def test_can_raise_as_domain_error(self):
"""可以作为 DomainError 捕获"""
with pytest.raises(DomainError):
raise QuotaExceededError("storage", 1024.0, 2048.0)
def test_stores_dimension_limit_used(self):
"""保存 dimension、limit、used 属性"""
err = QuotaExceededError("storage_mb", 1024.0, 1500.0)
assert err.dimension == "storage_mb"
assert err.limit == 1024.0
assert err.used == 2048.0
assert err.used == 1500.0
def test_error_message_format(self):
err = QuotaExceededError(dimension="storage", limit=1024.0, used=2048.0)
"""异常消息格式正确"""
err = QuotaExceededError("storage_mb", 1024.0, 1500.0)
msg = str(err)
assert "storage" in msg
assert "2048.0" in msg
assert "storage_mb" in msg
assert "1500.0" in msg
assert "1024.0" in msg
assert "Quota exceeded" in msg
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise QuotaExceededError("projects", 10, 15)
def test_raise_and_catch_specific(self):
with pytest.raises(QuotaExceededError):
raise QuotaExceededError("render", 5, 10)
def test_integer_values(self):
"""整数值也能正常工作"""
err = QuotaExceededError("projects", 10, 15)
assert err.dimension == "projects"
assert err.limit == 10
assert err.used == 15
def test_zero_limit(self):
err = QuotaExceededError(dimension="test", limit=0.0, used=1.0)
assert err.limit == 0.0
assert err.used == 1.0
"""限制为 0 时也能正常工作"""
err = QuotaExceededError("custom_templates", 0, 1)
assert err.limit == 0
assert err.used == 1
def test_negative_values(self):
"""负数也能存(领域层不做额外校验)"""
err = QuotaExceededError(dimension="test", limit=-5.0, used=-3.0)
assert err.limit == -5.0
assert err.used == -3.0
def test_large_values(self):
err = QuotaExceededError(dimension="storage", limit=1e9, used=1.5e9)
assert err.limit == 1e9
assert err.used == 1.5e9
class TestExceptionHierarchy:
"""异常继承关系测试"""
def test_all_are_domain_errors(self):
"""所有异常都可以作为 DomainError 捕获"""
errors = [
NotFoundError(),
ValidationError("bad"),
QuotaExceededError("x", 10.0, 20.0),
]
for err in errors:
assert isinstance(err, DomainError)
def test_distinct_types(self):
"""不同异常类型可以区分"""
assert not issubclass(NotFoundError, ValidationError)
assert not issubclass(ValidationError, QuotaExceededError)
assert not issubclass(NotFoundError, QuotaExceededError)
@@ -1,5 +1,3 @@
from dataclasses import FrozenInstanceError
"""domain 层剩余小模块单测 - bgm_utils/exceptions/editing_mode/recipe/template/template_version/template_clip_config/preset_bgm/preset_voices/title_library/voice_library"""
import pytest
@@ -399,7 +397,7 @@ class TestPresetBGM:
def test_preset_bgm_frozen(self):
bgm = PresetBGM(id="t1", name="t", style="x", duration=10.0)
with pytest.raises(FrozenInstanceError):
with pytest.raises(Exception):
bgm.name = "改名"
def test_library_not_empty(self):
@@ -489,7 +487,7 @@ class TestPresetVoices:
def test_preset_voice_frozen(self):
v = PresetVoice(voice_id="v1", name="t", description="d", gender="female")
with pytest.raises(FrozenInstanceError):
with pytest.raises(Exception):
v.name = "改名"
def test_preset_voices_list_not_empty(self):
-100
View File
@@ -152,103 +152,3 @@ class TestEditTemplateBumpVersion:
old_updated = template.updated_at
template.bump_version()
assert template.updated_at > old_updated or template.updated_at == old_updated
class TestEditTemplateExtended:
"""EditTemplate 深度补充测试"""
def test_id_is_hex(self):
t = EditTemplate.create(name="test")
int(t.id, 16)
def test_ids_are_unique(self):
t1 = EditTemplate.create(name="test1")
t2 = EditTemplate.create(name="test2")
assert t1.id != t2.id
def test_empty_description(self):
t = EditTemplate.create(name="test", description="")
assert t.description == ""
def test_long_description(self):
desc = "描述" * 200
t = EditTemplate.create(name="test", description=desc)
assert t.description == desc
assert len(t.description) == 400
def test_unicode_name(self):
t = EditTemplate.create(name="🎬 口播 Vlog 模板")
assert "🎬" in t.name
assert "口播" in t.name
def test_special_characters_name(self):
special = "模!@#$%"
t = EditTemplate.create(name=special)
assert t.name == special
def test_long_name(self):
long_name = "模板名称" * 50
t = EditTemplate.create(name=long_name)
assert t.name == long_name
assert len(t.name) == 200
def test_config_independence(self):
t1 = EditTemplate.create(name="test1")
t2 = EditTemplate.create(name="test2")
t1.config["key"] = "val"
assert "key" not in t2.config
def test_sort_weight_negative(self):
t = EditTemplate.create(name="test", sort_weight=-100)
assert t.sort_weight == -100
def test_sort_weight_large(self):
t = EditTemplate.create(name="test", sort_weight=99999)
assert t.sort_weight == 99999
def test_sort_weight_zero(self):
t = EditTemplate.create(name="test", sort_weight=0)
assert t.sort_weight == 0
def test_preview_url_empty(self):
t = EditTemplate.create(name="test", preview_url="")
assert t.preview_url == ""
def test_version_zero(self):
t = EditTemplate.create(name="test", version=0)
assert t.version == 0
def test_version_large(self):
t = EditTemplate.create(name="test", version=999)
assert t.version == 999
def test_status_is_active_property(self):
t = EditTemplate.create(name="test", status=EditTemplateStatus.ACTIVE)
assert t.is_active is True
t.deactivate()
assert t.is_active is False
t.activate()
assert t.is_active is True
def test_bump_version_from_zero(self):
t = EditTemplate.create(name="test", version=0)
t.bump_version()
assert t.version == 1
def test_template_type_custom(self):
t = EditTemplate.create(name="test", template_type="custom_type")
assert t.template_type == "custom_type"
def test_template_type_strips_and_default(self):
"""空格的 template_type 回退到 default"""
t = EditTemplate.create(name="test", template_type=" ")
assert t.template_type == "default"
def test_create_with_empty_editing_mode_defaults(self):
"""空字符串 editing_mode 回退到 one_take"""
t = EditTemplate.create(name="test", editing_mode="")
assert t.editing_mode == "one_take"
def test_preview_url_strips_whitespace(self):
t = EditTemplate.create(name="test", preview_url=" https://example.com/v.mp4 ")
assert t.preview_url == "https://example.com/v.mp4"
-54
View File
@@ -38,57 +38,3 @@ class TestEditingMode:
modes = list(EditingMode)
assert len(modes) == 4
assert EditingMode.ONE_TAKE in modes
class TestEditingModeExtended:
"""EditingMode 深度补充测试"""
def test_from_string_value(self):
"""可以从字符串值构造枚举"""
assert EditingMode("one_take") == EditingMode.ONE_TAKE
assert EditingMode("pip") == EditingMode.PIP
assert EditingMode("voice_over") == EditingMode.VOICE_OVER
assert EditingMode("voice_pip") == EditingMode.VOICE_PIP
def test_invalid_string_raises(self):
import pytest
with pytest.raises(ValueError):
EditingMode("invalid_mode")
def test_string_concatenation(self):
"""StrEnum 支持字符串拼接"""
result = "mode_" + EditingMode.ONE_TAKE
assert result == "mode_one_take"
def test_dict_key_usage(self):
"""可以作为字典 key 使用"""
mapping = {
EditingMode.ONE_TAKE: "顺序拼接",
EditingMode.PIP: "画中画",
}
assert mapping[EditingMode.ONE_TAKE] == "顺序拼接"
assert mapping[EditingMode.PIP] == "画中画"
assert len(mapping) == 2
def test_value_lowercase(self):
"""所有枚举值都是小写字母+下划线"""
for mode in EditingMode:
assert mode.value == mode.value.lower()
assert " " not in mode.value
def test_unique_values(self):
"""所有枚举值唯一"""
values = [m.value for m in EditingMode]
assert len(values) == len(set(values))
def test_membership_test(self):
assert EditingMode.ONE_TAKE in EditingMode
assert "one_take" in [m.value for m in EditingMode]
def test_comparison_with_string(self):
"""和字符串直接比较"""
mode = EditingMode.VOICE_OVER
assert mode == "voice_over"
assert mode != "pip"
assert "voice_over" == mode
+1 -1
View File
@@ -267,4 +267,4 @@ class TestGetEmailService:
svc1 = get_email_service()
svc2 = get_email_service()
# 两个都可能是 Noop 或 EmailService,取决于环境
assert type(svc1) is type(svc2)
assert type(svc1) == type(svc2)
-166
View File
@@ -1,166 +0,0 @@
"""领域层异常类单元测试."""
import pytest
from packages.domain.exceptions import (
DomainError,
NotFoundError,
ValidationError,
QuotaExceededError,
)
class TestDomainError:
"""领域异常基类测试."""
def test_is_exception(self):
"""DomainError 继承自 Exception."""
err = DomainError("test")
assert isinstance(err, Exception)
def test_message(self):
"""可以设置错误消息."""
err = DomainError("something wrong")
assert str(err) == "something wrong"
def test_empty_message(self):
"""支持空消息."""
err = DomainError()
assert str(err) == ""
def test_can_be_raised(self):
"""可以被 raise 和 catch."""
with pytest.raises(DomainError) as exc_info:
raise DomainError("oops")
assert str(exc_info.value) == "oops"
class TestNotFoundError:
"""资源不存在异常测试."""
def test_inherits_domain_error(self):
"""NotFoundError 继承自 DomainError."""
err = NotFoundError("user not found")
assert isinstance(err, DomainError)
assert isinstance(err, Exception)
def test_message(self):
"""错误消息正确."""
err = NotFoundError("project 123 not found")
assert str(err) == "project 123 not found"
assert "123" in str(err)
def test_can_catch_as_domain_error(self):
"""可以用 DomainError 捕获."""
with pytest.raises(DomainError):
raise NotFoundError("not found")
class TestValidationError:
"""校验失败异常测试."""
def test_inherits_domain_error(self):
"""ValidationError 继承自 DomainError."""
err = ValidationError("invalid input")
assert isinstance(err, DomainError)
def test_message(self):
"""错误消息正确."""
msg = "name must not be empty"
err = ValidationError(msg)
assert str(err) == msg
def test_not_not_found(self):
"""ValidationError 不是 NotFoundError."""
err = ValidationError("bad")
assert not isinstance(err, NotFoundError)
class TestQuotaExceededError:
"""配额超限异常测试."""
def test_inherits_domain_error(self):
"""QuotaExceededError 继承自 DomainError."""
err = QuotaExceededError("storage", 100.0, 150.0)
assert isinstance(err, DomainError)
def test_dimension_attribute(self):
"""保存 dimension 属性."""
err = QuotaExceededError("storage", 100.0, 150.0)
assert err.dimension == "storage"
def test_limit_attribute(self):
"""保存 limit 属性."""
err = QuotaExceededError("storage", 100.0, 150.0)
assert err.limit == 100.0
def test_used_attribute(self):
"""保存 used 属性."""
err = QuotaExceededError("storage", 100.0, 150.0)
assert err.used == 150.0
def test_message_format(self):
"""错误消息格式正确."""
err = QuotaExceededError("credits", 50.0, 75.0)
msg = str(err)
assert "credits" in msg
assert "50" in msg
assert "75" in msg
assert "Quota exceeded" in msg
def test_zero_limit(self):
"""limit 为 0 的情况."""
err = QuotaExceededError("test", 0.0, 1.0)
assert err.limit == 0.0
assert err.used == 1.0
assert "0" in str(err)
def test_equal_limit_and_used(self):
"""used 刚好等于 limit(边界情况)."""
err = QuotaExceededError("test", 100.0, 100.0)
assert err.used == 100.0
assert err.limit == 100.0
def test_integer_values(self):
"""整数值也能正常工作."""
err = QuotaExceededError("count", 10, 20)
assert err.dimension == "count"
assert err.limit == 10
assert err.used == 20
def test_can_catch_as_domain_error(self):
"""可以用 DomainError 捕获."""
with pytest.raises(DomainError):
raise QuotaExceededError("x", 1.0, 2.0)
class TestExceptionHierarchy:
"""异常继承关系验证."""
def test_all_are_domain_errors(self):
"""所有领域异常都是 DomainError."""
errors = [
NotFoundError("test"),
ValidationError("test"),
QuotaExceededError("test", 1, 2),
]
for err in errors:
assert isinstance(err, DomainError)
def test_all_are_exceptions(self):
"""所有领域异常都是 Exception."""
errors = [
DomainError("test"),
NotFoundError("test"),
ValidationError("test"),
QuotaExceededError("test", 1, 2),
]
for err in errors:
assert isinstance(err, Exception)
def test_not_found_is_not_validation(self):
"""不同异常类型不能互相混淆."""
assert not isinstance(NotFoundError("x"), ValidationError)
assert not isinstance(ValidationError("x"), NotFoundError)
assert not isinstance(QuotaExceededError("x", 1, 2), NotFoundError)
assert not isinstance(QuotaExceededError("x", 1, 2), ValidationError)
+1 -3
View File
@@ -1,5 +1,3 @@
from dataclasses import FrozenInstanceError
"""filter_presets 领域层单元测试 - 滤镜预设库"""
import pytest
@@ -60,7 +58,7 @@ class TestFilterPreset:
def test_frozen_immutable(self):
"""frozen dataclass 不可修改"""
preset = FilterPreset(id="test", name="测试", category="basic")
with pytest.raises(FrozenInstanceError):
with pytest.raises(Exception): # FrozenInstanceError
preset.name = "改名"
def test_tags_default_empty_list(self):
-114
View File
@@ -194,117 +194,3 @@ class TestGeneratedVideoProperties:
fingerprint = {"phash": "abc123", "md5": "def456"}
gv.video_fingerprint = fingerprint
assert gv.video_fingerprint == fingerprint
class TestGeneratedVideoExtended:
"""GeneratedVideo 深度补充测试"""
def test_id_is_hex(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
int(v.id, 16)
def test_zero_file_size(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", file_size=0)
assert v.file_size == 0
def test_large_file_size(self):
large = 1024 * 1024 * 1024 # 1GB
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", file_size=large)
assert v.file_size == large
def test_zero_duration(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", duration=0.0)
assert v.duration == 0.0
def test_large_duration(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", duration=9999.99)
assert v.duration == 9999.99
def test_zero_dimensions(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", width=0, height=0)
assert v.width == 0
assert v.height == 0
def test_4k_dimensions(self):
v = GeneratedVideo.create(
project_id="p", generation_task_id="t", name="n", file_url="u", width=3840, height=2160
)
assert v.width == 3840
assert v.height == 2160
def test_zero_fps(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", fps=0.0)
assert v.fps == 0.0
def test_high_fps(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", fps=120.0)
assert v.fps == 120.0
def test_empty_user_id(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", user_id="")
assert v.user_id == ""
def test_long_name(self):
long_name = "视频" * 100
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name=long_name, file_url="u")
assert v.name == long_name
assert len(v.name) == 200
def test_unicode_name(self):
v = GeneratedVideo.create(
project_id="p", generation_task_id="t", name="🎬 我的精彩视频 · 旅行vlog", file_url="u"
)
assert "🎬" in v.name
assert "旅行vlog" in v.name
def test_special_characters_name(self):
special = "视!@#$%^&*()频"
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name=special, file_url="u")
assert v.name == special
def test_thumbnail_url_none(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", thumbnail_url=None)
assert v.thumbnail_url is None
def test_generation_params_complex(self):
params = {
"mode": "voice_over",
"quality": "high",
"resolution": {"width": 1920, "height": 1080},
"effects": ["filter", "transition"],
}
v = GeneratedVideo.create(
project_id="p", generation_task_id="t", name="n", file_url="u", generation_params=params
)
assert v.generation_params["mode"] == "voice_over"
assert v.generation_params["resolution"]["width"] == 1920
assert len(v.generation_params["effects"]) == 2
def test_generation_params_independence(self):
v1 = GeneratedVideo.create(project_id="p", generation_task_id="t1", name="n1", file_url="u1")
v2 = GeneratedVideo.create(project_id="p", generation_task_id="t2", name="n2", file_url="u2")
v1.generation_params["key"] = "val"
assert "key" not in v2.generation_params
def test_status_failed(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
v.status = "failed"
assert v.status == "failed"
def test_review_status_approved(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
v.review_status = "approved"
assert v.review_status == "approved"
def test_is_duplicate_default_false(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
assert v.is_duplicate is False
assert v.duplicate_of is None
def test_fingerprint_none_default(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
assert v.video_fingerprint is None
def test_empty_file_url_raises(self):
with pytest.raises(ValueError):
GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url=" ")
@@ -1,323 +0,0 @@
"""GeneratedVideo + VerificationCode 领域模型测试."""
from __future__ import annotations
from datetime import timedelta
import pytest
from packages.domain.generated_video import GeneratedVideo
from packages.domain.verification_code import VerificationCode
class TestGeneratedVideo:
"""GeneratedVideo 生成视频实体测试."""
def test_create_success(self):
"""创建成功."""
video = GeneratedVideo.create(
project_id="p1",
generation_task_id="task_1",
name="我的视频.mp4",
file_url="https://example.com/out.mp4",
user_id="u1",
file_size=1024000,
duration=30.5,
width=1080,
height=1920,
fps=25.0,
)
assert video.id is not None
assert len(video.id) == 32
assert video.project_id == "p1"
assert video.generation_task_id == "task_1"
assert video.name == "我的视频.mp4"
assert video.file_url == "https://example.com/out.mp4"
assert video.user_id == "u1"
assert video.file_size == 1024000
assert video.duration == pytest.approx(30.5)
assert video.width == 1080
assert video.height == 1920
assert video.fps == pytest.approx(25.0)
assert video.status == "completed"
assert video.review_status == "pending_review"
assert video.is_duplicate is False
assert video.duplicate_of is None
assert video.generation_params == {}
def test_create_empty_project_id_raises(self):
"""空project_id抛异常."""
with pytest.raises(ValueError, match="project_id"):
GeneratedVideo.create(
project_id=" ",
generation_task_id="t1",
name="v.mp4",
file_url="https://x.com/v.mp4",
)
def test_create_empty_task_id_raises(self):
"""空generation_task_id抛异常."""
with pytest.raises(ValueError, match="generation_task_id"):
GeneratedVideo.create(
project_id="p1",
generation_task_id="",
name="v.mp4",
file_url="https://x.com/v.mp4",
)
def test_create_empty_name_raises(self):
"""空name抛异常."""
with pytest.raises(ValueError, match="name"):
GeneratedVideo.create(
project_id="p1",
generation_task_id="t1",
name=" ",
file_url="https://x.com/v.mp4",
)
def test_create_empty_file_url_raises(self):
"""空file_url抛异常."""
with pytest.raises(ValueError, match="file_url"):
GeneratedVideo.create(
project_id="p1",
generation_task_id="t1",
name="v.mp4",
file_url="",
)
def test_create_strips_whitespace(self):
"""首尾空白被去除."""
video = GeneratedVideo.create(
project_id=" p1 ",
generation_task_id=" t1 ",
name=" 视频.mp4 ",
file_url=" https://x.com/v.mp4 ",
)
assert video.project_id == "p1"
assert video.generation_task_id == "t1"
assert video.name == "视频.mp4"
assert video.file_url == "https://x.com/v.mp4"
def test_default_values(self):
"""默认值正确."""
video = GeneratedVideo.create(
project_id="p1",
generation_task_id="t1",
name="v.mp4",
file_url="https://x.com/v.mp4",
)
assert video.user_id == ""
assert video.file_size == 0
assert video.duration == 0.0
assert video.width == 0
assert video.height == 0
assert video.fps == 0.0
assert video.thumbnail_url is None
assert video.generation_params == {}
def test_generation_params_none_becomes_empty(self):
"""generation_params=None → {}."""
video = GeneratedVideo.create(
project_id="p1",
generation_task_id="t1",
name="v.mp4",
file_url="https://x.com/v.mp4",
generation_params=None,
)
assert video.generation_params == {}
def test_custom_generation_params(self):
"""自定义生成参数."""
params = {"mode": "smart", "resolution": "1080x1920"}
video = GeneratedVideo.create(
project_id="p1",
generation_task_id="t1",
name="v.mp4",
file_url="https://x.com/v.mp4",
generation_params=params,
)
assert video.generation_params == params
def test_duplicate_flag(self):
"""重复标记可以设置."""
video = GeneratedVideo.create(
project_id="p1",
generation_task_id="t1",
name="v.mp4",
file_url="https://x.com/v.mp4",
)
video.is_duplicate = True
video.duplicate_of = "other_video_id"
assert video.is_duplicate is True
assert video.duplicate_of == "other_video_id"
def test_custom_status(self):
"""自定义状态."""
video = GeneratedVideo.create(
project_id="p1",
generation_task_id="t1",
name="v.mp4",
file_url="https://x.com/v.mp4",
)
video.status = "failed"
assert video.status == "failed"
def test_thumbnail_url(self):
"""缩略图URL."""
video = GeneratedVideo.create(
project_id="p1",
generation_task_id="t1",
name="v.mp4",
file_url="https://x.com/v.mp4",
thumbnail_url="https://x.com/thumb.jpg",
)
assert video.thumbnail_url == "https://x.com/thumb.jpg"
class TestVerificationCodeCreate:
"""VerificationCode 创建测试."""
def test_create_success(self):
"""创建验证码成功."""
vc = VerificationCode.create(
recipient="test@example.com",
code_type="email_bind",
ttl_seconds=300,
)
assert vc.id is not None
assert len(vc.id) == 32
assert vc.recipient == "test@example.com"
assert vc.code_type == "email_bind"
assert len(vc.code) == 6 # 默认6位数字
assert vc.code.isdigit() # 纯数字
assert vc.used_at is None
assert vc.attempts == 0
def test_create_with_custom_code(self):
"""自定义验证码."""
vc = VerificationCode.create(
recipient="u@test.com",
code_type="email_login",
custom_code="123456",
)
assert vc.code == "123456"
def test_create_recipient_stripped(self):
"""收件人空白被去除."""
vc = VerificationCode.create(
recipient=" test@example.com ",
code_type="email_bind",
)
assert vc.recipient == "test@example.com"
def test_expiry_time_correct(self):
"""过期时间正确(5分钟后)."""
from datetime import datetime, timezone
before = datetime.now(timezone.utc) + timedelta(seconds=299)
vc = VerificationCode.create(
recipient="u@test.com",
code_type="reset_password",
ttl_seconds=300,
)
after = datetime.now(timezone.utc) + timedelta(seconds=301)
assert before <= vc.expires_at <= after
def test_custom_ttl(self):
"""自定义过期时间."""
vc = VerificationCode.create(
recipient="u@test.com",
code_type="phone_login",
ttl_seconds=60,
)
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
# 应该在1分钟左右过期
diff = (vc.expires_at - now).total_seconds()
assert 0 < diff < 70
class TestVerificationCodeProperties:
"""VerificationCode 属性方法测试."""
def test_is_expired_false_for_new(self):
"""新创建的验证码未过期."""
vc = VerificationCode.create(
recipient="u@test.com",
code_type="email_bind",
ttl_seconds=300,
)
assert vc.is_expired is False
def test_is_expired_true_when_past(self):
"""已过期的验证码is_expired=True."""
vc = VerificationCode.create(
recipient="u@test.com",
code_type="email_bind",
ttl_seconds=1,
)
# 手动改过期时间到过去
from datetime import datetime, timedelta, timezone
vc.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
assert vc.is_expired is True
def test_is_used_false_by_default(self):
"""默认未使用."""
vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind")
assert vc.is_used is False
def test_is_valid_fresh_code(self):
"""新验证码有效."""
vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind")
assert vc.is_valid is True
def test_is_valid_expired(self):
"""过期的验证码无效."""
vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind", ttl_seconds=1)
from datetime import datetime, timedelta, timezone
vc.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
assert vc.is_valid is False
def test_is_valid_used(self):
"""已使用的验证码无效."""
vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind")
vc.mark_used()
assert vc.is_valid is False
class TestVerificationCodeActions:
"""VerificationCode 操作方法测试."""
def test_mark_used(self):
"""标记使用."""
vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind")
vc.mark_used()
assert vc.is_used is True
assert vc.used_at is not None
def test_mark_used_twice(self):
"""标记两次也没问题."""
vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind")
vc.mark_used()
first_time = vc.used_at
vc.mark_used()
# 第二次会覆盖时间
assert vc.used_at >= first_time
def test_increment_attempts(self):
"""增加尝试次数."""
vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind")
assert vc.attempts == 0
vc.increment_attempts()
assert vc.attempts == 1
vc.increment_attempts()
assert vc.attempts == 2
def test_all_code_types_supported(self):
"""支持所有code_type."""
for code_type in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]:
vc = VerificationCode.create(recipient="u@test.com", code_type=code_type)
assert vc.code_type == code_type
+4 -5
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import time
import pytest
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from packages.application.auth.jwt_handler import (
JWTHandler,
@@ -87,17 +86,17 @@ class TestJWTHandler:
# 等待一小段时间确保过期
time.sleep(0.1)
with pytest.raises(ExpiredSignatureError):
with pytest.raises(Exception):
handler.verify_access_token(token)
def test_invalid_token_raises_error(self, jwt_handler):
"""无效 token 验证失败"""
with pytest.raises(InvalidTokenError):
with pytest.raises(Exception):
jwt_handler.verify_access_token("invalid.token.here")
def test_empty_token_raises_error(self, jwt_handler):
"""空字符串 token 验证失败"""
with pytest.raises(InvalidTokenError):
with pytest.raises(Exception):
jwt_handler.verify_access_token("")
def test_different_secret_fails_verification(self):
@@ -107,7 +106,7 @@ class TestJWTHandler:
token = handler1.create_access_token(user_id="user_001")
with pytest.raises(InvalidTokenError):
with pytest.raises(Exception):
handler2.verify_access_token(token)
def test_custom_algorithm(self):
-144
View File
@@ -242,147 +242,3 @@ class TestPaginateFunction:
assert len(result.data) == 2
assert result.data[0]["id"] == 1
# ── PaginationParams 补充边界 ───────────────────────────────────────────────
class TestPaginationParamsEdgeCases:
"""PaginationParams 补充边界场景."""
def test_page_size_1_minimum(self):
"""page_size=1 是允许的最小值."""
params = PaginationParams(page_size=1)
assert params.page_size == 1
assert params.limit == 1
def test_page_size_100_maximum(self):
"""page_size=100 是允许的最大值."""
params = PaginationParams(page_size=100)
assert params.page_size == 100
def test_offset_page_1_size_100(self):
"""第1页每页100条 offset=0."""
params = PaginationParams(page=1, page_size=100)
assert params.offset == 0
def test_offset_page_100_size_100(self):
"""第100页每页100条 offset=9900."""
params = PaginationParams(page=100, page_size=100)
assert params.offset == 9900
def test_large_page_number_accepted(self):
"""极大页码(超过实际页数)允许."""
params = PaginationParams(page=999999, page_size=20)
assert params.page == 999999
assert params.offset == (999999 - 1) * 20
# ── PaginationMeta 补充边界 ─────────────────────────────────────────────────
class TestPaginationMetaEdgeCases:
"""PaginationMeta 补充边界场景."""
def test_total_0_page_1(self):
"""total=0, page=1 时 total_pages=0, 无上下页."""
params = PaginationParams(page=1, page_size=20)
meta = PaginationMeta.from_params(params, total=0)
assert meta.total_pages == 0
assert meta.has_next is False
assert meta.has_prev is False
def test_total_0_page_beyond(self):
"""total=0, page>1 时 has_prev=True(因为page>1."""
params = PaginationParams(page=3, page_size=20)
meta = PaginationMeta.from_params(params, total=0)
assert meta.total_pages == 0
assert meta.has_next is False
assert meta.has_prev is True
def test_exact_last_page(self):
"""刚好是最后一页时 has_next=False."""
params = PaginationParams(page=5, page_size=10)
meta = PaginationMeta.from_params(params, total=50)
assert meta.total_pages == 5
assert meta.has_next is False
assert meta.has_prev is True
def test_one_more_than_exact(self):
"""比整数页多1条时总页数+1."""
params = PaginationParams(page=1, page_size=10)
meta = PaginationMeta.from_params(params, total=51)
assert meta.total_pages == 6
def test_page_exactly_total_pages(self):
"""page == total_pages 时 has_next=False."""
params = PaginationParams(page=3, page_size=10)
meta = PaginationMeta.from_params(params, total=30)
assert meta.has_next is False
def test_total_1_page_1_size_1(self):
"""1条数据1页."""
params = PaginationParams(page=1, page_size=1)
meta = PaginationMeta.from_params(params, total=1)
assert meta.total_pages == 1
assert meta.has_next is False
assert meta.has_prev is False
# ── paginate 补充边界 ──────────────────────────────────────────────────────
class TestPaginateEdgeCases:
"""paginate 补充边界场景."""
def test_single_item_list(self):
"""单元素列表."""
result = paginate([42], PaginationParams(page=1, page_size=10))
assert result.data == [42]
assert result.pagination.total == 1
assert result.pagination.total_pages == 1
def test_page_exactly_last(self):
"""刚好在最后一页."""
items = list(range(25))
result = paginate(items, PaginationParams(page=3, page_size=10))
assert result.data == list(range(20, 25))
assert result.pagination.has_next is False
def test_page_past_end_returns_empty(self):
"""页码超过总数返回空."""
items = list(range(5))
result = paginate(items, PaginationParams(page=10, page_size=10))
assert result.data == []
assert result.pagination.total == 5
def test_empty_list_page_1(self):
"""空列表第1页."""
result = paginate([], PaginationParams(page=1, page_size=10))
assert result.data == []
assert result.pagination.total == 0
assert result.pagination.total_pages == 0
def test_page_size_1_iterates_all(self):
"""page_size=1 时每页1条."""
items = ["a", "b", "c"]
r1 = paginate(items, PaginationParams(page=1, page_size=1))
r2 = paginate(items, PaginationParams(page=2, page_size=1))
r3 = paginate(items, PaginationParams(page=3, page_size=1))
assert r1.data == ["a"]
assert r2.data == ["b"]
assert r3.data == ["c"]
def test_does_not_mutate_input(self):
"""不修改输入列表."""
items = [1, 2, 3, 4, 5]
original = items[:]
paginate(items, PaginationParams(page=1, page_size=2))
assert items == original
def test_page_size_greater_than_total(self):
"""每页条数大于总数."""
items = list(range(5))
result = paginate(items, PaginationParams(page=1, page_size=100))
assert result.data == items
assert result.pagination.total_pages == 1
-99
View File
@@ -82,102 +82,3 @@ class TestRecipe:
for itype in ["asset", "title", "voice"]:
item = RecipeItem(id="i1", recipe_id="r1", item_type=itype, item_id="x", position=0)
assert item.item_type == itype
def test_item_metadata_independence(self):
"""不同 RecipeItem 的 metadata_ 互不影响"""
item1 = RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1")
item2 = RecipeItem(id="i2", recipe_id="r1", item_type="asset", item_id="a2")
item1.metadata_["key"] = "val"
assert "key" not in item2.metadata_
def test_item_position_negative(self):
"""负数 position 也能存"""
item = RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=-5)
assert item.position == -5
def test_item_position_large(self):
item = RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=9999)
assert item.position == 9999
def test_item_empty_item_id(self):
item = RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="")
assert item.item_id == ""
def test_item_type_voice(self):
item = RecipeItem(id="i1", recipe_id="r1", item_type="voice", item_id="v1")
assert item.item_type == "voice"
def test_item_type_title(self):
item = RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="t1")
assert item.item_type == "title"
class TestRecipeExtended:
"""Recipe 深度补充测试"""
def test_items_order_preserved(self):
items = [
RecipeItem(id="i3", recipe_id="r1", item_type="asset", item_id="a1", position=2),
RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="t1", position=0),
RecipeItem(id="i2", recipe_id="r1", item_type="voice", item_id="v1", position=1),
]
r = Recipe(id="r1", user_id="u1", name="n", items=items)
assert len(r.items) == 3
assert r.items[0].position == 2
assert r.items[1].position == 0
assert r.items[2].position == 1
def test_empty_items_list(self):
r = Recipe(id="r1", user_id="u1", name="n", items=[])
assert r.items == []
def test_many_items(self):
items = [
RecipeItem(id=f"i{i}", recipe_id="r1", item_type="asset", item_id=f"a{i}", position=i) for i in range(50)
]
r = Recipe(id="r1", user_id="u1", name="n", items=items)
assert len(r.items) == 50
assert r.items[0].position == 0
assert r.items[49].position == 49
def test_generation_params_independence(self):
params = {"mode": "one_take", "duration": 30}
r1 = Recipe(id="r1", user_id="u1", name="n1", generation_params=params)
r2 = Recipe(id="r2", user_id="u1", name="n2")
r1.generation_params["new_key"] = "new_val"
# 传入同一个 dict 会共享,但默认生成的互不影响
assert r2.generation_params == {}
def test_metadata_independence_default(self):
r1 = Recipe(id="r1", user_id="u1", name="n1")
r2 = Recipe(id="r2", user_id="u1", name="n2")
r1.metadata_["key"] = "val"
assert "key" not in r2.metadata_
def test_with_description(self):
r = Recipe(id="r1", user_id="u1", name="n", description="这是一个测试配方")
assert r.description == "这是一个测试配方"
def test_with_template_id(self):
r = Recipe(id="r1", user_id="u1", name="n", template_id="tpl-123")
assert r.template_id == "tpl-123"
def test_empty_name(self):
r = Recipe(id="r1", user_id="u1", name="")
assert r.name == ""
def test_long_name(self):
long_name = "配方" * 200
r = Recipe(id="r1", user_id="u1", name=long_name)
assert r.name == long_name
assert len(r.name) == 400
def test_special_characters_in_name(self):
special = "配!@#$%"
r = Recipe(id="r1", user_id="u1", name=special)
assert r.name == special
def test_unicode_name(self):
r = Recipe(id="r1", user_id="u1", name="🎬 一键生成配方 · 美食探店")
assert "🎬" in r.name
assert "美食探店" in r.name
-107
View File
@@ -1,107 +0,0 @@
"""
render_audio 纯工具函数测试.
覆盖 clip_effective_duration / RenderContext 等纯逻辑.
核心混音逻辑依赖 FFmpeg,由集成测试覆盖.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from video_processing.render_audio import RenderContext, clip_effective_duration
class TestClipEffectiveDuration:
"""clip_effective_duration 有效时长计算."""
def test_duration_specified_and_actual_longer(self):
"""指定了 duration,且实际时长更长 → 取 duration"""
clip = SimpleNamespace(duration=5.0, actual_duration=10.0)
assert clip_effective_duration(clip) == 5.0
def test_duration_specified_and_actual_shorter(self):
"""指定了 duration,但实际时长更短 → 取实际时长"""
clip = SimpleNamespace(duration=10.0, actual_duration=5.0)
assert clip_effective_duration(clip) == 5.0
def test_duration_specified_actual_zero(self):
"""指定了 duration,但实际时长为 0 → 取 duration"""
clip = SimpleNamespace(duration=5.0, actual_duration=0.0)
assert clip_effective_duration(clip) == 5.0
def test_duration_specified_actual_negative(self):
"""指定了 duration,但实际时长为负 → 取 duration"""
clip = SimpleNamespace(duration=5.0, actual_duration=-1.0)
assert clip_effective_duration(clip) == 5.0
def test_no_duration_use_actual(self):
"""未指定 duration(0),用实际时长"""
clip = SimpleNamespace(duration=0.0, actual_duration=8.0)
assert clip_effective_duration(clip) == 8.0
def test_no_duration_negative_actual(self):
"""未指定 duration,实际时长也为负 → 返回 0"""
clip = SimpleNamespace(duration=0.0, actual_duration=-5.0)
assert clip_effective_duration(clip) == 0.0
def test_no_duration_zero_actual(self):
"""都为 0 → 返回 0"""
clip = SimpleNamespace(duration=0.0, actual_duration=0.0)
assert clip_effective_duration(clip) == 0.0
def test_negative_duration_use_actual(self):
"""duration 为负(视为未指定),用实际时长"""
clip = SimpleNamespace(duration=-1.0, actual_duration=5.0)
assert clip_effective_duration(clip) == 5.0
def test_both_negative_returns_zero(self):
"""两者都为负 → 返回 0"""
clip = SimpleNamespace(duration=-2.0, actual_duration=-3.0)
assert clip_effective_duration(clip) == 0.0
def test_exact_match(self):
"""duration 与实际时长相等"""
clip = SimpleNamespace(duration=7.5, actual_duration=7.5)
assert clip_effective_duration(clip) == 7.5
def test_very_short_duration(self):
"""很短的时长"""
clip = SimpleNamespace(duration=0.1, actual_duration=0.2)
assert clip_effective_duration(clip) == 0.1
class TestRenderContext:
"""RenderContext 渲染上下文."""
def test_create_with_work_dir_and_plan_id(self, tmp_path):
ctx = RenderContext(work_dir=tmp_path, plan_id="plan_123")
assert ctx.work_dir == tmp_path
assert ctx.plan_id == "plan_123"
assert ctx.noise_reduction_config is None
assert ctx._audio_cache == {}
def test_noise_reduction_config(self, tmp_path):
config = {"enabled": True, "strength": 0.5}
ctx = RenderContext(
work_dir=tmp_path,
plan_id="plan_123",
noise_reduction_config=config,
)
assert ctx.noise_reduction_config == config
assert ctx.noise_reduction_config["enabled"] is True
def test_audio_cache_isolation(self, tmp_path):
"""每个实例有独立的缓存字典."""
ctx1 = RenderContext(work_dir=tmp_path, plan_id="p1")
ctx2 = RenderContext(work_dir=tmp_path, plan_id="p2")
ctx1._audio_cache["key1"] = True
assert "key1" not in ctx2._audio_cache
assert len(ctx2._audio_cache) == 0
def test_work_dir_path_type(self, tmp_path):
ctx = RenderContext(work_dir=tmp_path, plan_id="test")
assert isinstance(ctx.work_dir, Path)
-310
View File
@@ -1,310 +0,0 @@
"""字幕渲染纯函数测试 — _build_ass_style + generate_ass_subtitles."""
from __future__ import annotations
from pathlib import Path
import pytest
from video_processing.render_subtitles import (
_build_ass_style,
_escape_ass_text,
_format_ass_time,
_hex_to_ass_color,
_position_to_ass_alignment,
generate_ass_subtitles,
)
class TestHexToAssColor:
"""_hex_to_ass_color 颜色转换测试."""
def test_white(self):
"""白色 #FFFFFF → &HFFFFFF (ASS BGR格式)."""
assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF"
def test_black(self):
"""黑色 #000000 → &H000000."""
assert _hex_to_ass_color("#000000") == "&H000000"
def test_red(self):
"""红色 #FF0000 → ASS是BGR顺序 → &H0000FF."""
assert _hex_to_ass_color("#FF0000") == "&H0000FF"
def test_blue(self):
"""蓝色 #0000FF → BGR → &HFF0000."""
assert _hex_to_ass_color("#0000FF") == "&HFF0000"
def test_green(self):
"""绿色 #00FF00 → BGR → &H00FF00."""
assert _hex_to_ass_color("#00FF00") == "&H00FF00"
def test_lowercase(self):
"""小写hex也支持."""
assert _hex_to_ass_color("#ff0000") == "&H0000FF"
def test_no_hash_prefix(self):
"""不带#的颜色."""
assert _hex_to_ass_color("FF0000") == "&H0000FF"
def test_invalid_length_fallback(self):
"""长度不对返回默认黑色."""
assert _hex_to_ass_color("#FFF") == "&H000000"
class TestPositionToAssAlignment:
"""_position_to_ass_alignment 位置映射测试."""
def test_top_center(self):
"""top → 上中(8)."""
assert _position_to_ass_alignment("top") == 8
def test_bottom_center(self):
"""bottom → 下中(2)."""
assert _position_to_ass_alignment("bottom") == 2
def test_center_middle(self):
"""center → 居中(5)."""
assert _position_to_ass_alignment("center") == 5
def test_unknown_defaults_to_top(self):
"""未知位置默认顶部(8)."""
assert _position_to_ass_alignment("unknown") == 8
assert _position_to_ass_alignment("top_left") == 8
assert _position_to_ass_alignment("bottom_right") == 8
def test_empty_string_defaults_to_top(self):
"""空字符串默认顶部."""
assert _position_to_ass_alignment("") == 8
class TestBuildAssStyle:
"""_build_ass_style ASS样式行构建测试."""
def test_basic_style_line(self):
"""基本样式行包含关键字段."""
line = _build_ass_style("Default")
assert line.startswith("Style: Default,")
assert "思源黑体" in line
assert "48" in line # font_size
def test_bold_enabled(self):
"""加粗时Bold=-1."""
line = _build_ass_style("Bold", bold=True)
assert "Style: Bold," in line
# Bold字段位置:第7个逗号分隔字段=Bold=-1
parts = line.split(",")
# Name, Fontname, Fontsize, Primary, Secondary, Outline, Back, Bold, ...
assert parts[7] == "-1" # Bold
def test_bold_disabled(self):
"""不加粗时Bold=0."""
line = _build_ass_style("Normal", bold=False)
parts = line.split(",")
assert parts[7] == "0"
def test_italic_enabled(self):
"""斜体时Italic=-1."""
line = _build_ass_style("Italic", italic=True)
parts = line.split(",")
assert parts[8] == "-1" # Italic
def test_custom_font_size(self):
"""自定义字号."""
line = _build_ass_style("Big", font_size=72)
parts = line.split(",")
assert parts[2] == "72" # Fontsize
def test_custom_alignment(self):
"""自定义对齐方式."""
line = _build_ass_style("Bottom", alignment=2)
# Alignment是第16个字段(数一下)
# Name, Fontname, Fontsize, Primary, Secondary, Outline, Back, Bold, Italic, Underline, Strikeout, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, ...
parts = line.split(",")
assert parts[18] == "2" # Alignment (0-indexed: 18)
def test_outline_width(self):
"""描边宽度."""
line = _build_ass_style("Outline", outline_width=3.0)
parts = line.split(",")
assert parts[16] == "3.0" # Outline (BorderStyle后是Outline)
class TestEscapeAssText:
"""_escape_ass_text 文本转义测试."""
def test_plain_text_unchanged(self):
"""普通文本不变."""
assert _escape_ass_text("hello world") == "hello world"
def test_newline_converted(self):
"""换行符转成\\N."""
assert _escape_ass_text("line1\nline2") == "line1\\Nline2"
def test_crlf_converted(self):
"""CRLF转成\\N."""
assert _escape_ass_text("a\r\nb") == "a\\Nb"
def test_carriage_return_converted(self):
"""\\r转成\\N."""
assert _escape_ass_text("a\rb") == "a\\Nb"
def test_curly_braces_replaced(self):
"""大括号转成圆括号(防止ASS样式注入)."""
assert _escape_ass_text("{text}") == "(text)"
def test_mixed_special_chars(self):
"""混合特殊字符."""
result = _escape_ass_text("line1\nline2 {bold}\rlast")
assert "line1\\Nline2 (bold)\\Nlast" == result
class TestFormatAssTime:
"""_format_ass_time 时间格式化测试."""
def test_zero_seconds(self):
"""0秒."""
assert _format_ass_time(0) == "0:00:00.00"
def test_seconds_only(self):
"""只有秒."""
assert _format_ass_time(5.5) == "0:00:05.50"
def test_minutes_and_seconds(self):
"""几分几秒."""
assert _format_ass_time(65.25) == "0:01:05.25"
def test_hours(self):
"""几小时."""
assert _format_ass_time(3661.5) == "1:01:01.50"
def test_always_two_decimal_places(self):
"""总是两位小数."""
assert _format_ass_time(1.0) == "0:00:01.00"
assert _format_ass_time(1.1) == "0:00:01.10"
class TestGenerateAssSubtitles:
"""generate_ass_subtitles ASS字幕文件生成测试."""
def test_no_subtitles_empty_file(self, tmp_path):
"""没有字幕生成空文件."""
output = tmp_path / "empty.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=10.0,
)
assert result == output
assert output.exists()
assert output.read_text(encoding="utf-8") == ""
def test_title_only(self, tmp_path):
"""只有标题."""
output = tmp_path / "title.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=30.0,
title_text="测试标题",
)
assert result == output
content = output.read_text(encoding="utf-8")
assert "Script Info" in content
assert "PlayResX: 1080" in content
assert "PlayResY: 1920" in content
assert "测试标题" in content
assert "V4+ Styles" in content
assert "Events" in content
def test_subtitle_only(self, tmp_path):
"""只有底部字幕."""
output = tmp_path / "sub.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=15.0,
subtitle_text="这是字幕",
)
assert result == output
content = output.read_text(encoding="utf-8")
assert "这是字幕" in content
assert "PlayResX: 1080" in content
def test_both_title_and_subtitle(self, tmp_path):
"""标题+字幕都有."""
output = tmp_path / "both.ass"
result = generate_ass_subtitles(
output,
video_width=720,
video_height=1280,
video_duration=20.0,
title_text="大标题",
subtitle_text="底部字幕",
)
content = output.read_text(encoding="utf-8")
assert "大标题" in content
assert "底部字幕" in content
# 应该有两种样式(title和subtitle
assert content.count("Style:") >= 2
def test_title_disabled_by_config(self, tmp_path):
"""通过config禁用标题."""
output = tmp_path / "disabled_title.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=10.0,
title_text="标题",
title_config={"enabled": False},
subtitle_text="字幕",
)
content = output.read_text(encoding="utf-8")
assert "标题" not in content
assert "字幕" in content
def test_empty_title_text_not_rendered(self, tmp_path):
"""空标题文本不渲染."""
output = tmp_path / "empty_title.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=10.0,
title_text=" ",
subtitle_text="有字幕",
)
content = output.read_text(encoding="utf-8")
assert "有字幕" in content
def test_custom_title_color(self, tmp_path):
"""自定义标题颜色."""
output = tmp_path / "color.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=10.0,
title_text="红色标题",
title_config={"color": "#FF0000"},
)
content = output.read_text(encoding="utf-8")
# 红色 → ASS BGR格式 &H0000FF
assert "&H0000FF" in content
def test_dialogue_line_format(self, tmp_path):
"""Dialogue行格式正确."""
output = tmp_path / "dialogue.ass"
generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=5.0,
subtitle_text="测试字幕文本",
)
content = output.read_text(encoding="utf-8")
assert "Dialogue:" in content
assert "测试字幕文本" in content
+90 -252
View File
@@ -1,284 +1,122 @@
"""
贴纸引擎配置与纯逻辑测试.
覆盖 ImageStickerConfig / TextStickerConfig / _resolve_position / 常量与便捷函数.
引擎核心滤镜生成与渲染依赖 FFmpeg,由集成测试覆盖.
"""
"""贴纸引擎单元测试 - 配置+解析等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.sticker_engine import (
POSITION_PRESETS,
STICKER_CATEGORIES,
ImageStickerConfig,
StickerEngine,
StickerOverlayResult,
TextStickerConfig,
get_sticker_categories,
parse_stickers_from_config,
)
class TestStickerConstants:
"""常量测试."""
def test_nine_position_presets(self):
assert len(POSITION_PRESETS) == 9
assert "top_left" in POSITION_PRESETS
assert "top_center" in POSITION_PRESETS
assert "top_right" in POSITION_PRESETS
assert "center_left" in POSITION_PRESETS
assert "center" in POSITION_PRESETS
assert "center_right" in POSITION_PRESETS
assert "bottom_left" in POSITION_PRESETS
assert "bottom_center" in POSITION_PRESETS
assert "bottom_right" in POSITION_PRESETS
def test_position_values_are_fractions(self):
for name, (x, y) in POSITION_PRESETS.items():
assert 0.0 <= x <= 1.0, f"{name} x={x} out of range"
assert 0.0 <= y <= 1.0, f"{name} y={y} out of range"
def test_sticker_categories(self):
assert len(STICKER_CATEGORIES) >= 3
for cat_id, cat_name in STICKER_CATEGORIES:
assert isinstance(cat_id, str)
assert isinstance(cat_name, str)
assert len(cat_id) > 0
assert len(cat_name) > 0
class TestImageStickerConfig:
"""图片贴纸配置."""
class TestImageStickerConfigDefaults:
"""ImageStickerConfig 默认值测试."""
def test_default_values(self):
cfg = ImageStickerConfig()
assert cfg.enabled is False
assert cfg.type == "image"
assert cfg.position == "top_right"
assert cfg.x is None
assert cfg.y is None
assert cfg.x_unit == "percent"
assert cfg.y_unit == "percent"
assert cfg.scale == 1.0
assert cfg.width is None
assert cfg.height is None
assert cfg.opacity == 1.0
assert cfg.start_time == 0.0
assert cfg.duration == 0.0
assert cfg.fade_in == 0.0
assert cfg.fade_out == 0.0
assert cfg.z_index == 10
assert cfg.image_url == ""
assert cfg.preset_id == ""
def test_custom_values(self):
cfg = ImageStickerConfig(
enabled=True,
position="center",
x=50.0,
y=30.0,
scale=0.5,
opacity=0.8,
start_time=1.0,
duration=5.0,
fade_in=0.5,
fade_out=0.5,
z_index=5,
image_url="/tmp/sticker.png",
preset_id="sticker_001",
)
assert cfg.enabled is True
assert cfg.position == "center"
assert cfg.x == 50.0
assert cfg.y == 30.0
assert cfg.scale == 0.5
assert cfg.opacity == 0.8
assert cfg.start_time == 1.0
assert cfg.duration == 5.0
assert cfg.z_index == 5
assert cfg.image_url == "/tmp/sticker.png"
"""默认值正确."""
s = ImageStickerConfig()
assert s.enabled is False
assert s.type == "image"
assert s.position == "top_right"
assert s.x is None
assert s.y is None
assert s.x_unit == "percent"
assert s.y_unit == "percent"
assert s.scale == 1.0
assert s.width is None
assert s.height is None
assert s.opacity == 1.0
assert s.start_time == 0.0
assert s.duration == 0.0
assert s.fade_in == 0.0
assert s.fade_out == 0.0
assert s.z_index == 10
assert s.image_url == ""
assert s.preset_id == ""
class TestTextStickerConfig:
"""文字贴纸配置."""
class TestTextStickerConfigDefaults:
"""TextStickerConfig 默认值测试."""
def test_default_values(self):
cfg = TextStickerConfig()
assert cfg.enabled is False
assert cfg.type == "text"
assert cfg.text == ""
assert cfg.font_size == 36
assert cfg.font_color == "#FFFFFF"
assert cfg.font_family == "sans"
assert cfg.stroke_color == "#000000"
assert cfg.stroke_width == 2
assert cfg.shadow_color == "#000000"
assert cfg.shadow_x == 2
assert cfg.shadow_y == 2
assert cfg.shadow_alpha == 0.5
assert cfg.position == "center"
assert cfg.z_index == 10
assert cfg.bg_color == ""
assert cfg.bg_padding == 8
assert cfg.bg_alpha == 0.8
assert cfg.bg_corner_radius == 8
def test_custom_text_sticker(self):
cfg = TextStickerConfig(
enabled=True,
text="Hello",
font_size=48,
font_color="#FF0000",
position="bottom_center",
bg_color="#000000",
bg_padding=16,
)
assert cfg.enabled is True
assert cfg.text == "Hello"
assert cfg.font_size == 48
assert cfg.font_color == "#FF0000"
assert cfg.position == "bottom_center"
assert cfg.bg_color == "#000000"
assert cfg.bg_padding == 16
class TestStickerOverlayResult:
"""贴纸叠加结果."""
def test_default_values(self):
result = StickerOverlayResult(filter_str="overlay=10:20", output_label="[out]")
assert result.filter_str == "overlay=10:20"
assert result.output_label == "[out]"
assert result.extra_inputs == []
def test_with_extra_inputs(self):
result = StickerOverlayResult(
filter_str="overlay=0:0",
output_label="[out]",
extra_inputs=["/tmp/sticker.png"],
)
assert len(result.extra_inputs) == 1
assert result.extra_inputs[0] == "/tmp/sticker.png"
class TestResolvePosition:
"""_resolve_position 位置解析."""
def test_top_left_preset(self):
cfg = ImageStickerConfig(position="top_left")
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
# top_left: (0.05, 0.05) → x = 0.05*1000 - 50 = 0, y = 0.05*500 - 25 = 0
assert x >= 0
assert y >= 0
def test_center_preset(self):
cfg = ImageStickerConfig(position="center")
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 200, 100)
# center: (0.5, 0.5) → x = 500 - 100 = 400, y = 250 - 50 = 200
assert x == 400.0
assert y == 200.0
def test_bottom_right_preset(self):
cfg = ImageStickerConfig(position="bottom_right")
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
# bottom_right: (0.95, 0.95) → x = 950 - 50 = 900, y = 475 - 25 = 450
assert x == 900.0
assert y == 450.0
def test_custom_percent_position(self):
cfg = ImageStickerConfig(position="center", x=25.0, y=75.0, x_unit="percent", y_unit="percent")
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
# x = 0.25*1000 - 50 = 200, y = 0.75*500 - 25 = 350
assert x == 200.0
assert y == 350.0
def test_custom_pixel_position(self):
cfg = ImageStickerConfig(position="center", x=300, y=200, x_unit="pixel", y_unit="pixel")
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
# x = 300/1000*1000 - 50 = 250, y = 200/500*500 - 25 = 175
# 等等,让我重新算:px = config.x / canvas_w = 300/1000 = 0.3
# x = px * canvas_w - sticker_w/2 = 0.3*1000 - 50 = 300 - 50 = 250
assert x == 250.0
assert y == 175.0
def test_zero_size_sticker(self):
cfg = ImageStickerConfig(position="center")
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 0, 0)
# 贴纸尺寸为0时,位置就是中心点
assert x == 500.0
assert y == 250.0
def test_invalid_position_falls_back_to_center(self):
cfg = ImageStickerConfig(position="invalid_position")
x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50)
# 无效位置 → 默认居中 → x = 500 - 50 = 450, y = 250 - 25 = 225
assert x == 450.0
assert y == 225.0
def test_position_clamped_to_canvas(self):
# 贴纸太大,位置被钳制
cfg = ImageStickerConfig(position="top_left")
x, y = StickerEngine._resolve_position(cfg, 100, 100, 200, 200)
# 贴纸比画布还大,应该被钳制到 0
assert x >= 0
assert y >= 0
assert x <= 100
assert y <= 100
def test_zero_canvas_handling(self):
cfg = ImageStickerConfig(position="center", x=50, y=50, x_unit="pixel", y_unit="pixel")
x, y = StickerEngine._resolve_position(cfg, 0, 0, 10, 10)
# 画布为0时不应崩溃,结果被钳制到0
assert x == 0
assert y == 0
def test_text_sticker_position(self):
cfg = TextStickerConfig(position="top_right")
x, y = StickerEngine._resolve_position(cfg, 800, 400, 100, 30)
# top_right: (0.95, 0.05) → x = 760 - 50 = 710, 但被钳制到 canvas_w - sticker_w = 700
# y = 20 - 15 = 5
assert x == 700.0
assert y == 5.0
"""默认值正确."""
s = TextStickerConfig()
assert s.enabled is False
assert s.type == "text"
assert s.text == ""
assert s.font_size == 36
assert s.font_color == "#FFFFFF"
assert s.font_family == "sans"
assert s.stroke_color == "#000000"
assert s.stroke_width == 2
assert s.shadow_color == "#000000"
assert s.shadow_x == 2
assert s.shadow_y == 2
assert s.shadow_alpha == 0.5
assert s.position == "center"
assert s.start_time == 0.0
assert s.duration == 0.0
assert s.z_index == 10
assert s.bg_color == ""
assert s.bg_padding == 8
assert s.bg_alpha == 0.8
assert s.bg_corner_radius == 8
class TestParseStickersFromConfig:
"""parse_stickers_from_config 便捷函数."""
"""parse_stickers_from_config 测试."""
def test_none_returns_empty(self):
assert parse_stickers_from_config(None) == []
"""None返回空列表."""
result = parse_stickers_from_config(None)
assert result == []
def test_empty_dict_returns_empty(self):
assert parse_stickers_from_config({}) == []
"""空dict返回空."""
result = parse_stickers_from_config({})
assert result == []
def test_no_stickers_key_returns_empty(self):
assert parse_stickers_from_config({"other": "data"}) == []
"""无stickers键返回空."""
result = parse_stickers_from_config({"other": "value"})
assert result == []
def test_stickers_list_returned(self):
stickers = [{"type": "image", "url": "/a.png"}, {"type": "text", "text": "hi"}]
result = parse_stickers_from_config({"stickers": stickers})
assert result == stickers
assert len(result) == 2
def test_stickers_not_a_list_returns_empty(self):
assert parse_stickers_from_config({"stickers": "not_a_list"}) == []
def test_stickers_not_list_returns_empty(self):
"""stickers不是列表返回空."""
result = parse_stickers_from_config({"stickers": "not_a_list"})
assert result == []
def test_empty_stickers_list(self):
assert parse_stickers_from_config({"stickers": []}) == []
"""空贴纸列表."""
result = parse_stickers_from_config({"stickers": []})
assert result == []
def test_single_sticker(self):
"""单个贴纸."""
result = parse_stickers_from_config(
{
"stickers": [{"type": "text", "text": "hello"}],
}
)
assert len(result) == 1
assert result[0]["text"] == "hello"
class TestGetStickerCategories:
"""get_sticker_categories 便捷函数."""
def test_multiple_stickers(self):
"""多个贴纸."""
result = parse_stickers_from_config(
{
"stickers": [
{"type": "text", "text": "a"},
{"type": "image", "image_url": "/b.png"},
{"type": "text", "text": "c"},
],
}
)
assert len(result) == 3
def test_returns_list_of_tuples(self):
result = get_sticker_categories()
assert isinstance(result, list)
assert len(result) > 0
for item in result:
assert isinstance(item, tuple)
assert len(item) == 2
def test_matches_constant(self):
result = get_sticker_categories()
assert result == list(STICKER_CATEGORIES)
def test_returns_raw_dicts(self):
"""返回原始dict,不做转换."""
sticker = {"type": "text", "text": "test", "font_size": 48}
result = parse_stickers_from_config({"stickers": [sticker]})
assert result[0] is sticker # 引用相同,不做深拷贝
+179 -458
View File
@@ -1,551 +1,272 @@
"""字幕领域模型单元测试 - 纯逻辑部分。"""
"""字幕领域模型单元测试."""
from __future__ import annotations
import pytest
from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
class TestSubtitleWord:
"""单个词级别字幕单元。"""
"""SubtitleWord 测试."""
def test_basic_creation(self):
word = SubtitleWord(text="你好", start=0.0, end=0.5)
def test_basic_properties(self):
word = SubtitleWord(text="你好", start=1.0, end=1.5)
assert word.text == "你好"
assert word.start == 0.0
assert word.end == 0.5
assert word.start == 1.0
assert word.end == 1.5
assert word.duration == 0.5
def test_duration_positive(self):
word = SubtitleWord(text="test", start=1.0, end=2.5)
assert word.duration == pytest.approx(1.5)
def test_duration_zero(self):
word = SubtitleWord(text="x", start=3.0, end=3.0)
def test_duration_zero_when_end_before_start(self):
word = SubtitleWord(text="test", start=2.0, end=1.0)
assert word.duration == 0.0
def test_duration_negative_returns_zero(self):
"""end < start 时 duration 返回 0,不抛异常。"""
word = SubtitleWord(text="x", start=5.0, end=3.0)
def test_duration_zero_when_same_time(self):
word = SubtitleWord(text="test", start=1.0, end=1.0)
assert word.duration == 0.0
class TestSubtitleSegment:
"""字幕段(一句话)。"""
"""SubtitleSegment 测试."""
def test_basic_creation(self):
seg = SubtitleSegment(text="你好世界", start=0.0, end=2.0)
assert seg.text == "你好世界"
def test_basic_properties(self):
seg = SubtitleSegment(text="大家好", start=0.0, end=2.0)
assert seg.text == "大家好"
assert seg.start == 0.0
assert seg.end == 2.0
assert seg.duration == 2.0
assert seg.char_count == 3
assert seg.words == []
def test_duration_positive(self):
seg = SubtitleSegment(text="test", start=1.0, end=3.5)
assert seg.duration == pytest.approx(2.5)
def test_duration_zero(self):
seg = SubtitleSegment(text="x", start=5.0, end=5.0)
assert seg.duration == 0.0
def test_duration_negative_returns_zero(self):
seg = SubtitleSegment(text="x", start=10.0, end=5.0)
assert seg.duration == 0.0
def test_char_count(self):
seg = SubtitleSegment(text="你好世界", start=0, end=1)
assert seg.char_count == 4
def test_char_count_empty(self):
seg = SubtitleSegment(text="", start=0, end=1)
assert seg.char_count == 0
def test_char_count_mixed_languages(self):
seg = SubtitleSegment(text="你好hello世界", start=0, end=1)
assert seg.char_count == 9 # 2中 + 5英 + 2中 = 9
def test_with_words(self):
def test_duration_with_words(self):
words = [
SubtitleWord(text="你好", start=0.0, end=0.5),
SubtitleWord(text="世界", start=0.5, end=1.0),
SubtitleWord(text="", start=0.0, end=0.5),
SubtitleWord(text="", start=0.5, end=1.0),
SubtitleWord(text="", start=1.0, end=1.5),
]
seg = SubtitleSegment(text="你好世界", start=0.0, end=1.0, words=words)
assert len(seg.words) == 2
assert seg.words[0].text == "你好"
seg = SubtitleSegment(text="大家好", start=0.0, end=1.5, words=words)
assert seg.duration == 1.5
assert seg.char_count == 3
assert len(seg.words) == 3
def test_duration_zero_when_end_before_start(self):
seg = SubtitleSegment(text="test", start=3.0, end=1.0)
assert seg.duration == 0.0
class TestSubtitleTimelineBasics:
"""字幕时间轴基础属性"""
"""SubtitleTimeline 基础属性测试."""
def test_empty_timeline(self):
tl = SubtitleTimeline()
assert tl.segments == []
assert tl.language == "zh"
assert tl.total_duration == 0.0
assert tl.segment_count == 0
assert tl.total_chars == 0
assert tl.language == "zh"
assert tl.total_duration == 0.0
def test_single_segment(self):
tl = SubtitleTimeline(
segments=[SubtitleSegment(text="你好", start=0, end=1)],
language="zh",
total_duration=1.0,
)
seg = SubtitleSegment(text="测试", start=0.0, end=1.0)
tl = SubtitleTimeline(segments=[seg])
assert tl.segment_count == 1
assert tl.total_chars == 2
def test_multiple_segments(self):
segments = [
SubtitleSegment(text="第一句", start=0, end=1),
SubtitleSegment(text="第二句更长一点", start=1, end=3),
SubtitleSegment(text="第三句", start=3, end=4),
segs = [
SubtitleSegment(text="第一句", start=0.0, end=1.0),
SubtitleSegment(text="第二句", start=1.0, end=2.0),
SubtitleSegment(text="第三句", start=2.0, end=3.0),
]
tl = SubtitleTimeline(segments=segments, total_duration=4.0)
tl = SubtitleTimeline(segments=segs, total_duration=3.0)
assert tl.segment_count == 3
assert tl.total_chars == 3 + 7 + 3 # 13
assert tl.total_chars == 9
assert tl.total_duration == 3.0
def test_custom_language(self):
tl = SubtitleTimeline(language="en")
assert tl.language == "en"
class TestMergeShortSegments:
"""合并短字幕片段"""
class TestSubtitleTimelineMergeShort:
"""合并短字幕片段测试."""
def test_empty_timeline_unchanged(self):
def test_empty_or_single_no_change(self):
tl = SubtitleTimeline()
result = tl.merge_short_segments()
assert result.segment_count == 0
def test_single_segment_unchanged(self):
tl = SubtitleTimeline(segments=[SubtitleSegment(text="", start=0, end=0.5)])
result = tl.merge_short_segments()
assert result.segment_count == 1
assert result.segments[0].text == ""
seg = SubtitleSegment(text="", start=0.0, end=0.5)
tl2 = SubtitleTimeline(segments=[seg])
result2 = tl2.merge_short_segments()
assert result2.segment_count == 1
def test_all_short_merged_into_one(self):
"""多个短片段合并成一个。"""
segments = [
SubtitleSegment(text="", start=0, end=0.2),
SubtitleSegment(text="", start=0.2, end=0.4),
SubtitleSegment(text="", start=0.4, end=0.6),
def test_merge_short_segments(self):
segs = [
SubtitleSegment(text="你好", start=0.0, end=0.5),
SubtitleSegment(text="世界", start=0.5, end=1.0),
SubtitleSegment(text="今天天气很好", start=1.0, end=2.0),
]
tl = SubtitleTimeline(segments=segments)
result = tl.merge_short_segments(min_chars=3)
assert result.segment_count == 1
assert result.segments[0].text == "一二三"
assert result.segments[0].start == 0
assert result.segments[0].end == 0.6
tl = SubtitleTimeline(segments=segs)
result = tl.merge_short_segments(min_chars=4)
# "你好"+"世界"=4字,合并;"今天天气很好"=6字,保留
assert result.segment_count == 2
assert result.segments[0].text == "你好世界"
assert result.segments[0].start == 0.0
assert result.segments[0].end == 1.0
assert result.segments[1].text == "今天天气很好"
def test_mixed_lengths(self):
"""长短混合,中间短的会合并成一段。"""
segments = [
SubtitleSegment(text="这是比较长的第一句", start=0, end=2), # 10字
SubtitleSegment(text="第一小段", start=2, end=2.4), # 4字
SubtitleSegment(text="第二小段", start=2.4, end=2.8), # 4字
SubtitleSegment(text="这是比较长的第四句", start=2.8, end=5), # 10字
def test_merge_trailing_short_to_last(self):
segs = [
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
SubtitleSegment(text="", start=1.0, end=1.2),
SubtitleSegment(text="", start=1.2, end=1.4),
]
tl = SubtitleTimeline(segments=segments)
result = tl.merge_short_segments(min_chars=8)
# 第一句10字够长单独保留;短1+短2=8字刚好够一段;第四句10字够长单独保留
assert result.segment_count == 3
assert result.segments[0].text == "这是比较长的第一句"
assert result.segments[1].text == "第一小段第二小段"
assert result.segments[2].text == "这是比较长的第四句"
tl = SubtitleTimeline(segments=segs)
result = tl.merge_short_segments(min_chars=4)
# "一二三四五六七八"=8字 → 保留
# "短"+"尾"=2字 < 4 → 合并到上一段
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六七八短尾"
def test_merge_with_words(self):
words1 = [SubtitleWord(text="", start=0.0, end=0.25), SubtitleWord(text="", start=0.25, end=0.5)]
words2 = [SubtitleWord(text="", start=0.5, end=0.75), SubtitleWord(text="", start=0.75, end=1.0)]
segs = [
SubtitleSegment(text="你好", start=0.0, end=0.5, words=words1),
SubtitleSegment(text="世界", start=0.5, end=1.0, words=words2),
]
tl = SubtitleTimeline(segments=segs)
result = tl.merge_short_segments(min_chars=4)
assert result.segment_count == 1
assert len(result.segments[0].words) == 4
def test_preserves_language_and_duration(self):
segments = [
SubtitleSegment(text="a", start=0, end=0.1),
SubtitleSegment(text="b", start=0.1, end=0.2),
]
tl = SubtitleTimeline(segments=segments, language="en", total_duration=10.0)
result = tl.merge_short_segments(min_chars=5)
assert result.language == "en"
assert result.total_duration == 10.0
segs = [SubtitleSegment(text="", start=0.0, end=0.5)]
tl = SubtitleTimeline(segments=segs, language="ja", total_duration=0.5)
result = tl.merge_short_segments(min_chars=4)
assert result.language == "ja"
assert result.total_duration == 0.5
def test_last_short_merged_to_previous(self):
"""最后剩余的短片段且不够min_chars,合并到上一段。"""
segments = [
SubtitleSegment(text="这是一句比较长的话", start=0, end=1.5), # 10字
SubtitleSegment(text="", start=1.5, end=1.6), # 1字
]
tl = SubtitleTimeline(segments=segments)
result = tl.merge_short_segments(min_chars=8)
# 第一句够长(10>=8),但尾只有1字不够,合并到上一句
class TestSubtitleTimelineSplitLong:
"""拆分长字幕片段测试."""
def test_short_segments_no_change(self):
segs = [SubtitleSegment(text="短句", start=0.0, end=1.0)]
tl = SubtitleTimeline(segments=segs)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count == 1
assert result.segments[0].text == "这是一句比较长的话尾"
def test_original_not_modified(self):
segments = [SubtitleSegment(text="a", start=0, end=0.1)]
tl = SubtitleTimeline(segments=segments)
tl.merge_short_segments(min_chars=5)
assert len(tl.segments) == 1 # 原对象不变
class TestSplitLongSegments:
"""拆分过长字幕片段。"""
def test_short_segments_unchanged(self):
segments = [
SubtitleSegment(text="短句", start=0, end=1),
SubtitleSegment(text="另一句", start=1, end=2),
]
tl = SubtitleTimeline(segments=segments)
result = tl.split_long_segments(max_chars=20)
assert result.segment_count == 2
assert result.segments[0].text == "短句"
def test_split_by_sentence_punctuation(self):
"""按句末标点拆分。"""
text = "这是第一句话。这是第二句话!这是第三句话?"
seg = SubtitleSegment(text=text, start=0, end=3.0)
text = "今天天气很好。我们出去散步吧!"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=8)
assert result.segment_count >= 2
# 合并起来应该等于原文
merged_text = "".join(s.text for s in result.segments)
assert merged_text == text
assert result.segments[0].text.endswith("")
assert result.total_chars == len(text)
def test_split_time_proportional(self):
"""拆分后的时间按字数比例分配。"""
text = "一二三四五六七八。二二三四五六七八。"
seg = SubtitleSegment(text=text, start=0, end=10.0)
def test_split_long_text_no_punctuation_hard_cut(self):
text = "一二三四五六七八九十十一十二十三十四十五十六十七十八"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=8)
assert len(result.segments) >= 2
# 总时长不超过原时长
assert result.segments[-1].end <= seg.end
# 第一个片段的开始时间正确
assert result.segments[0].start == 0.0
assert result.segment_count > 1
# 所有片段都不超过 max_chars
for s in result.segments:
assert s.char_count <= 8
def test_no_punctuation_hard_split(self):
"""没有标点时硬切"""
text = "一二三四五六七八九十十一十二十三十四十五十六十七十八十九二十"
seg = SubtitleSegment(text=text, start=0, end=5.0)
def test_split_time_proportional(self):
text = "一二三四。五六七八"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=10)
assert len(result.segments) >= 2
merged = "".join(s.text for s in result.segments)
assert merged == text
result = tl.split_long_segments(max_chars=4)
assert result.segment_count >= 2
# 总时长保持一致
assert abs(result.segments[-1].end - 10.0) < 0.01
def test_preserves_language_and_duration(self):
seg = SubtitleSegment(text="a" * 30, start=0, end=5)
tl = SubtitleTimeline(segments=[seg], language="en", total_duration=100.0)
result = tl.split_long_segments(max_chars=10)
def test_split_with_words(self):
words = [
SubtitleWord(text="", start=0.0, end=0.5),
SubtitleWord(text="", start=0.5, end=1.0),
SubtitleWord(text="", start=1.0, end=1.5),
SubtitleWord(text="", start=1.5, end=2.0),
]
text = "一二三四五六七八"
seg = SubtitleSegment(text=text, start=0.0, end=4.0, words=words)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=4)
assert result.segment_count >= 2
# 词的总数应该不变
total_words = sum(len(s.words) for s in result.segments)
assert total_words == 4
def test_split_preserves_language(self):
seg = SubtitleSegment(text="test", start=0.0, end=1.0)
tl = SubtitleTimeline(segments=[seg], language="en")
result = tl.split_long_segments(max_chars=2)
assert result.language == "en"
assert result.total_duration == 100.0
def test_empty_timeline_unchanged(self):
tl = SubtitleTimeline()
result = tl.split_long_segments()
assert result.segment_count == 0
class TestSplitTextByPunctuation:
"""_split_text_by_punctuation 静态方法"""
def test_short_text_unchanged(self):
result = SubtitleTimeline._split_text_by_punctuation("你好", 10)
assert result == ["你好"]
def test_split_by_period(self):
text = "这是第一句话。这是第二句话。"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) == 2
assert result[0] == "这是第一句话。"
assert result[1] == "这是第二句话。"
def test_split_by_exclamation(self):
text = "你好世界大家好!再见世界朋友们!"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) >= 2
def test_split_by_question(self):
text = "今天天气好不好呢?今天天气很好呀。"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) >= 2
def test_split_by_comma_when_long(self):
"""超过max_chars时,遇到逗号也会断开。"""
text = "这是很长的一句话,中间有个逗号,后面还有内容继续。"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert len(result) >= 2
"""标点拆分静态方法测试."""
def test_empty_text(self):
result = SubtitleTimeline._split_text_by_punctuation("", 10)
assert result == []
def test_no_punctuation_hard_cut(self):
text = "一二三四五六七八九十一二三四五六七八九十"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert len(result) == 2
assert len(result[0]) == 10
assert len(result[1]) == 10
def test_short_text_no_split(self):
result = SubtitleTimeline._split_text_by_punctuation("短文本", 10)
assert len(result) == 1
def test_english_punctuation(self):
result = SubtitleTimeline._split_text_by_punctuation("Hello, world! How are you?", 15)
def test_split_by_period(self):
result = SubtitleTimeline._split_text_by_punctuation("第一句。第二句。", 4)
assert len(result) >= 2
assert "" in result[0]
def test_split_by_exclamation(self):
result = SubtitleTimeline._split_text_by_punctuation("你好!世界!", 3)
assert len(result) >= 2
def test_split_by_comma_when_long(self):
text = "这是一个很长的句子,中间有逗号分隔,后面还有内容"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) >= 2
def test_no_punctuation_hard_cut(self):
text = "一二三四五六七八九十十一十二十三十四十五"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) > 1
for part in result:
assert len(part) <= 8
def test_sentence_end_triggers_split_when_half_max(self):
# 句末标点在 max_chars//2 以上就拆分
text = "你好世界。abcdefghij"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
# "你好世界。"=5字 < 10但>=5(half),应该拆分
assert len(result) >= 2
class TestMergeSegments:
"""_merge_segments 静态方法"""
"""_merge_segments 静态方法测试."""
def test_merge_two_segments(self):
segs = [
SubtitleSegment(text="你好", start=0.0, end=0.5),
SubtitleSegment(text="世界", start=0.5, end=1.0),
]
result = SubtitleTimeline._merge_segments(segs)
assert result.text == "你好世界"
assert result.start == 0.0
assert result.end == 1.0
def test_merge_empty_list(self):
def test_merge_empty(self):
result = SubtitleTimeline._merge_segments([])
assert result.text == ""
assert result.start == 0
assert result.end == 0
def test_merge_single_segment(self):
def test_merge_single(self):
seg = SubtitleSegment(text="test", start=1.0, end=2.0)
result = SubtitleTimeline._merge_segments([seg])
assert result.text == "test"
assert result.start == 1.0
assert result.end == 2.0
def test_merge_preserves_words(self):
def test_merge_multiple(self):
segs = [
SubtitleSegment(
text="你好",
start=0.0,
end=0.5,
words=[SubtitleWord(text="你好", start=0.0, end=0.5)],
),
SubtitleSegment(
text="世界",
start=0.5,
end=1.0,
words=[SubtitleWord(text="世界", start=0.5, end=1.0)],
),
SubtitleSegment(text="第一", start=0.0, end=1.0),
SubtitleSegment(text="第二", start=1.0, end=2.0),
]
result = SubtitleTimeline._merge_segments(segs)
assert len(result.words) == 2
assert result.words[0].text == "你好"
assert result.words[1].text == "世界"
class TestMergeShortSegmentsEdgeCases:
"""merge_short_segments 边界情况深度测试."""
def test_all_segments_too_short_merge_into_one(self):
"""所有片段都很短,全部合并成一段."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0.0, end=0.5),
SubtitleSegment(text="", start=0.5, end=1.0),
SubtitleSegment(text="", start=1.0, end=1.5),
],
total_duration=1.5,
)
result = timeline.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == "一二三"
assert result.segments[0].start == 0.0
assert result.segments[0].end == 1.5
def test_exactly_min_chars_no_merge(self):
"""刚好等于 min_chars,不合并."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
SubtitleSegment(text="一二三四五六七八", start=1.0, end=2.0),
],
total_duration=2.0,
)
result = timeline.merge_short_segments(min_chars=8)
assert result.segment_count == 2
def test_min_chars_one(self):
"""min_chars=1 时每个都够,不合并."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0.0, end=0.5),
SubtitleSegment(text="", start=0.5, end=1.0),
],
total_duration=1.0,
)
result = timeline.merge_short_segments(min_chars=1)
assert result.segment_count == 2
def test_merge_preserves_words_order(self):
"""合并后词的顺序保持正确."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(
text="你好",
start=0.0,
end=0.5,
words=[
SubtitleWord(text="", start=0.0, end=0.25),
SubtitleWord(text="", start=0.25, end=0.5),
],
),
SubtitleSegment(
text="世界",
start=0.5,
end=1.0,
words=[
SubtitleWord(text="", start=0.5, end=0.75),
SubtitleWord(text="", start=0.75, end=1.0),
],
),
],
total_duration=1.0,
)
result = timeline.merge_short_segments(min_chars=8)
assert result.segment_count == 1
words = result.segments[0].words
assert len(words) == 4
assert [w.text for w in words] == ["", "", "", ""]
def test_last_segment_short_merges_with_previous(self):
"""最后一段太短,合并到前一段."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
SubtitleSegment(text="", start=1.0, end=1.2),
],
total_duration=1.2,
)
result = timeline.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六七八九"
class TestSplitLongSegmentsEdgeCases:
"""split_long_segments 边界情况深度测试."""
def test_mixed_long_and_short(self):
"""长短片段混合,只拆分长的."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0.0, end=0.5),
SubtitleSegment(
text="这是一段非常长的字幕内容需要被拆分",
start=0.5,
end=3.0,
),
SubtitleSegment(text="", start=3.0, end=3.5),
],
total_duration=3.5,
)
result = timeline.split_long_segments(max_chars=10)
assert result.segment_count > 3 # 中间那段被拆分了
assert result.segments[0].text == ""
assert result.segments[-1].text == ""
def test_split_total_duration_preserved(self):
"""拆分后总时长不变."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(
text="一二三四五六七八九十一二三四五六七八九十",
start=0.0,
end=10.0,
),
],
total_duration=10.0,
)
result = timeline.split_long_segments(max_chars=10)
assert result.segment_count > 1
assert result.segments[0].start == 0.0
assert abs(result.segments[-1].end - 10.0) < 0.01
def test_max_chars_very_small(self):
"""max_chars 很小,每个字都要拆."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三", start=0.0, end=3.0),
],
total_duration=3.0,
)
result = timeline.split_long_segments(max_chars=1)
# 没有标点,硬切
assert result.segment_count >= 3
def test_empty_segments_list(self):
"""空片段列表不报错."""
timeline = SubtitleTimeline(segments=[], total_duration=0.0)
result = timeline.split_long_segments(max_chars=10)
assert result.segment_count == 0
class TestSplitTextByPunctuationDeep:
"""_split_text_by_punctuation 深度测试."""
def test_multiple_punctuation_types(self):
"""多种标点符号混合."""
text = "你好!世界?测试,哈哈。"
result = SubtitleTimeline._split_text_by_punctuation(text, 5)
assert len(result) >= 2
def test_consecutive_punctuation(self):
"""连续标点符号."""
text = "你好!!!测试。。。"
result = SubtitleTimeline._split_text_by_punctuation(text, 3)
assert len(result) >= 1
# 确保所有字符都保留
assert "".join(result) == text
def test_no_punctuation_long_text(self):
"""长文本没有标点,硬切."""
text = "一二三四五六七八九十一二三四五六七八九十"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert len(result) >= 2
assert "".join(result) == text
# 每段不超过 max_chars
for part in result[:-1]: # 最后一段可能短一些
assert len(part) <= 10
def test_punctuation_at_start(self):
"""标点在开头."""
text = ",你好世界"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert "".join(result) == text
def test_punctuation_at_end(self):
"""标点在结尾."""
text = "你好世界!"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert "".join(result) == text
assert result[-1].endswith("")
class TestSubtitleTimelineProperties:
"""SubtitleTimeline 属性计算深度测试."""
def test_total_chars_empty(self):
"""空时间轴 total_chars 为 0."""
timeline = SubtitleTimeline(segments=[])
assert timeline.total_chars == 0
def test_total_chars_sum(self):
"""total_chars 等于所有片段字数之和."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=0, end=1),
SubtitleSegment(text="世界", start=1, end=2),
SubtitleSegment(text="123", start=2, end=3),
],
)
assert timeline.total_chars == 2 + 2 + 3
def test_segment_count(self):
"""segment_count 正确."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="a", start=0, end=1),
SubtitleSegment(text="b", start=1, end=2),
],
)
assert timeline.segment_count == 2
def test_empty_segment_char_count(self):
"""空片段 char_count 为 0."""
seg = SubtitleSegment(text="", start=0.0, end=1.0)
assert seg.char_count == 0
assert result.text == "第一第二"
assert result.start == 0.0
assert result.end == 2.0
-590
View File
@@ -176,593 +176,3 @@ class TestGenerateAssFromTimeline:
assert "0:00:01.25" in content
# 1:01:01.00 格式(3661秒 = 1小时1分1秒)
assert "1:01:01.00" in content
# ── _hex_to_ass_color 深度测试 ──────────────────────────────────────────────
class TestHexToAssColor:
"""_hex_to_ass_color 颜色转换测试"""
def test_standard_hex_with_hash(self):
"""带#号的标准6位HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
# #RRGGBB → &HBBGGRR
assert _hex_to_ass_color("#FF0000") == "&H0000FF" # 红
assert _hex_to_ass_color("#00FF00") == "&H00FF00" # 绿
assert _hex_to_ass_color("#0000FF") == "&HFF0000" # 蓝
def test_standard_hex_without_hash(self):
"""不带#号的6位HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("FF0000") == "&H0000FF"
assert _hex_to_ass_color("00FF00") == "&H00FF00"
assert _hex_to_ass_color("0000FF") == "&HFF0000"
def test_white_and_black(self):
"""白色和黑色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF" # 白
assert _hex_to_ass_color("#000000") == "&H000000" # 黑
def test_lowercase_hex(self):
"""小写字母HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
result = _hex_to_ass_color("#aabbcc")
# 输出是大写的
assert result == "&HCCBBAA"
def test_mixed_case_hex(self):
"""大小写混合HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
result = _hex_to_ass_color("#AaBbCc")
assert result == "&HCCBBAA"
def test_short_hex_returns_default(self):
"""长度不足6位返回默认白色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF" # 3位
assert _hex_to_ass_color("FF") == "&H00FFFFFF" # 2位
def test_long_hex_returns_default(self):
"""长度超过6位返回默认白色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#AABBCCDD") == "&H00FFFFFF"
def test_empty_string_returns_default(self):
"""空字符串返回默认白色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("") == "&H00FFFFFF"
def test_gray_color(self):
"""灰色调"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#808080") == "&H808080"
# ── _position_to_ass_alignment 测试 ────────────────────────────────────────
class TestPositionToAssAlignment:
"""_position_to_ass_alignment 位置映射测试"""
def test_top_maps_to_8(self):
"""顶部 → 8"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("top") == 8
def test_center_maps_to_5(self):
"""居中 → 5"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("center") == 5
def test_bottom_maps_to_2(self):
"""底部 → 2"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("bottom") == 2
def test_unknown_position_defaults_to_bottom(self):
"""未知位置默认底部(2)"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("left") == 2
assert _position_to_ass_alignment("right") == 2
assert _position_to_ass_alignment("middle") == 2
def test_empty_string_defaults_to_bottom(self):
"""空字符串默认底部"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("") == 2
def test_uppercase_not_matched(self):
"""大写不匹配,走默认"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("TOP") == 2
assert _position_to_ass_alignment("CENTER") == 2
# ── _format_ass_time 测试 ──────────────────────────────────────────────────
class TestFormatAssTime:
"""_format_ass_time 时间格式化测试"""
def test_zero_seconds(self):
"""0秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(0.0) == "0:00:00.00"
def test_fractional_seconds(self):
"""小数秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(0.5) == "0:00:00.50"
assert _format_ass_time(1.25) == "0:00:01.25"
def test_whole_seconds(self):
"""整秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(5.0) == "0:00:05.00"
assert _format_ass_time(30.0) == "0:00:30.00"
def test_minutes_level(self):
"""分钟级"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(60.0) == "0:01:00.00"
assert _format_ass_time(90.5) == "0:01:30.50"
assert _format_ass_time(599.0) == "0:09:59.00"
def test_hours_level(self):
"""小时级"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(3600.0) == "1:00:00.00"
assert _format_ass_time(3661.5) == "1:01:01.50"
assert _format_ass_time(7384.0) == "2:03:04.00"
def test_centisecond_precision(self):
"""百分秒精度"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(1.01) == "0:00:01.01"
assert _format_ass_time(1.99) == "0:00:01.99"
def test_sub_centisecond_truncated(self):
"""毫秒级精度会被格式化截断到百分秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
# Python 的 %05.2f 会四舍五入
result = _format_ass_time(1.123)
assert result.startswith("0:00:01.")
assert len(result.split(".")[-1]) == 2
# ── _escape_ass_text 测试 ──────────────────────────────────────────────────
class TestEscapeAssText:
"""_escape_ass_text 转义测试"""
def test_plain_text_unchanged(self):
"""普通文本不改变"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("你好世界") == "你好世界"
assert _escape_ass_text("Hello World") == "Hello World"
def test_newline_to_ass_newline(self):
"""\\n 转 \\N"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("第一行\n第二行") == "第一行\\N第二行"
def test_crlf_to_ass_newline(self):
"""\\r\\n 转 \\N"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("第一行\r\n第二行") == "第一行\\N第二行"
def test_cr_to_ass_newline(self):
"""\\r 转 \\N"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("第一行\r第二行") == "第一行\\N第二行"
def test_curly_braces_escaped(self):
"""花括号转圆括号(防止ASS标签注入)"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("{text}") == "(text)"
assert _escape_ass_text("{{double}}") == "((double))"
def test_mixed_escapes(self):
"""混合转义"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
text = "你好\n世界{测试}\r\n结束"
result = _escape_ass_text(text)
assert "\\N" in result
assert "(测试)" in result
assert "\n" not in result
assert "{" not in result
def test_empty_string(self):
"""空字符串"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("") == ""
def test_only_braces(self):
"""只有花括号"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("{}") == "()"
# ── _wrap_text 补充测试 ────────────────────────────────────────────────────
class TestWrapTextMore:
"""_wrap_text 补充边界测试"""
def test_empty_string_returns_empty_list(self):
"""空字符串返回空列表"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
assert _wrap_text("", 10) == [""]
def test_single_character(self):
"""单字符"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
assert _wrap_text("", 10) == [""]
def test_max_chars_equals_one(self):
"""max_chars=1 每个字一行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
result = _wrap_text("一二三四", 1)
assert len(result) == 4
assert result[0] == ""
assert result[1] == ""
def test_punctuation_in_middle(self):
"""标点在正中间优先从标点断开"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
# 前10字内有句号,优先在句号后断开
text = "一二三四五六七八九十。后面的内容继续写下去"
result = _wrap_text(text, 15)
# 第一行应该包含句号
assert "" in result[0]
def test_punctuation_at_start_ignored(self):
"""标点在开头位置(前半部分)不会触发断开"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "。一二三四五六七八九十"
result = _wrap_text(text, 10)
# 标点在第0位,不会在max_chars//2到max_chars范围内
assert len(result) == 2
assert len(result[0]) == 10
def test_no_punctuation_long_text(self):
"""完全没有标点的长文本硬切"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
result = _wrap_text(text, 10)
assert len(result) == 3
assert len(result[0]) == 10
assert len(result[1]) == 10
assert len(result[2]) == 5
def test_exactly_two_lines(self):
"""恰好两行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "" * 20
result = _wrap_text(text, 10)
assert len(result) == 2
assert len(result[0]) == 10
assert len(result[1]) == 10
def test_three_lines(self):
"""三行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "" * 25
result = _wrap_text(text, 10)
assert len(result) == 3
assert len(result[0]) == 10
assert len(result[1]) == 10
assert len(result[2]) == 5
def test_multiple_punctuation_points(self):
"""多个标点,选择最后一个合适的"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "你好。再见。谢谢。抱歉。好的不行了"
result = _wrap_text(text, 12)
# 应该在最靠后的(在范围内的)标点处断开
assert "" in result[0]
def test_english_punctuation_wrap(self):
"""英文标点也会触发换行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "Hello world. This is a test sentence."
result = _wrap_text(text, 20)
assert len(result) >= 2
assert result[0].endswith(".") or "." in result[0]
def test_total_length_preserved(self):
"""换行后总字符数不变"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "这是一段用于测试换行功能的中文文本,包含了各种标点符号。看看效果如何?"
result = _wrap_text(text, 10)
assert "".join(result) == text
# ── generate_ass_from_timeline 补充测试 ────────────────────────────────────
class TestGenerateAssFromTimelineMore:
"""generate_ass_from_timeline 补充测试"""
def test_single_segment(self):
"""单段字幕"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="你好", start=0.0, end=1.0)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "single.ass"
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
content = output.read_text(encoding="utf-8")
assert "Dialogue:" in content
assert "你好" in content
assert content.count("Dialogue:") == 1
def test_multiple_segments(self):
"""多段字幕"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text=f"{i}", start=float(i), end=float(i + 1)) for i in range(5)],
total_duration=5.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "multi.ass"
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
content = output.read_text(encoding="utf-8")
assert content.count("Dialogue:") == 5
def test_long_text_auto_wrap(self):
"""长字幕自动换行"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
long_text = "这是一段非常非常长的字幕文本,用来测试自动换行功能是否正常工作。"
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text=long_text, start=0, end=5)],
total_duration=5.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "wrap.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"max_chars_per_line": 10},
)
content = output.read_text(encoding="utf-8")
# 应该包含 \N 换行符
assert "\\N" in content
def test_custom_color_hex(self):
"""自定义颜色正确转换"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "red.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"color": "#FF0000"},
)
content = output.read_text(encoding="utf-8")
# 红色 #FF0000 → &H0000FF (BBGGRR)
assert "&H0000FF" in content
def test_position_top(self):
"""顶部位置"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="顶部", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "top.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"position": "top"},
)
content = output.read_text(encoding="utf-8")
# 顶部对齐是 \\an8 → Style 中 Alignment=8
assert "1,1.5,0,8," in content or ",0,8," in content
def test_position_center(self):
"""居中位置"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="居中", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "center.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"position": "center"},
)
content = output.read_text(encoding="utf-8")
# 居中对齐是 Alignment=5
assert ",0,5," in content
def test_custom_font_size(self):
"""自定义字体大小"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "big.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"size": 48},
)
content = output.read_text(encoding="utf-8")
# Style 行中字号应该是48
assert "Default,思源黑体,48," in content
def test_720p_resolution(self):
"""720p分辨率"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="720p", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "720p.ass"
generate_ass_from_timeline(output, timeline, video_width=1280, video_height=720)
content = output.read_text(encoding="utf-8")
assert "PlayResX: 1280" in content
assert "PlayResY: 720" in content
def test_special_characters_in_text(self):
"""字幕文本含特殊字符(花括号、换行)"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="你好{tag}\n世界", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "special.ass"
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
content = output.read_text(encoding="utf-8")
# 花括号被转义
assert "(tag)" in content
# 换行被转义成 \N
assert "\\N" in content
def test_output_path_creates_parent_dirs(self):
"""自动创建父目录"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="测试", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "nested" / "deep" / "out.ass"
result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
assert result.exists()
assert result.parent.exists()
def test_returns_output_path(self):
"""返回值等于输出路径"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="返回", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "ret.ass"
result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
assert result == output
+195 -171
View File
@@ -1,15 +1,9 @@
"""
字幕渲染引擎纯函数与配置测试.
覆盖 SubtitleStyle / SubtitleSegment / 颜色转换 / 时间格式化 / 文字换行 / ASS转义等纯逻辑.
引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖.
"""
"""字幕渲染引擎单元测试 - 工具函数+样式配置等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.subtitle_render_engine import (
SubtitleSegment,
SubtitleStyle,
_escape_ass_text,
_format_ass_time,
@@ -19,276 +13,306 @@ from video_processing.subtitle_render_engine import (
_wrap_text,
)
# ── 颜色转换测试 ──────────────────────────────────────────────
class TestHexToAssColor:
"""HEX → ASS 颜色转换."""
"""_hex_to_ass_color 测试."""
def test_white(self):
"""白色."""
assert _hex_to_ass_color("#FFFFFF") == "&H00FFFFFF"
def test_black(self):
"""黑色."""
assert _hex_to_ass_color("#000000") == "&H00000000"
def test_red(self):
# #FF0000 → R=FF, G=00, B=00 → BGR=0000FF
"""红色 → BGR: 蓝绿红."""
assert _hex_to_ass_color("#FF0000") == "&H000000FF"
def test_blue(self):
# #0000FF → R=00, G=00, B=FF → BGR=FF0000
assert _hex_to_ass_color("#0000FF") == "&H00FF0000"
def test_green(self):
# #00FF00 → R=00, G=FF, B=00 → BGR=00FF00
"""绿色."""
assert _hex_to_ass_color("#00FF00") == "&H0000FF00"
def test_without_hash_prefix(self):
def test_blue(self):
"""蓝色."""
assert _hex_to_ass_color("#0000FF") == "&H00FF0000"
def test_no_hash_prefix(self):
"""不带#号."""
assert _hex_to_ass_color("FF0000") == "&H000000FF"
def test_lowercase_hex(self):
assert _hex_to_ass_color("#ff0000") == "&H000000FF"
def test_mixed_case(self):
assert _hex_to_ass_color("#aBcDeF") == "&H00EFCDAB"
def test_invalid_length_returns_default(self):
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF" # 3位
assert _hex_to_ass_color("#FF") == "&H00FFFFFF" # 2位
assert _hex_to_ass_color("#") == "&H00FFFFFF" # 空
def test_empty_string(self):
def test_invalid_length(self):
"""长度不对返回默认白色."""
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF"
assert _hex_to_ass_color("#FF") == "&H00FFFFFF"
assert _hex_to_ass_color("") == "&H00FFFFFF"
def test_lowercase_input(self):
"""小写输入转为大写输出."""
assert _hex_to_ass_color("#aabbcc") == "&H00CCBBAA"
class TestHexToAssBgr:
"""HEX → ASS BGR 部分."""
"""_hex_to_ass_bgr 测试."""
def test_white(self):
"""白色BGR."""
assert _hex_to_ass_bgr("#FFFFFF") == "FFFFFF"
def test_black(self):
assert _hex_to_ass_bgr("#000000") == "000000"
def test_red(self):
# #FF0000 → BGR = 0000FF
def test_red_bgr(self):
"""红色 → BGR = 0000FF."""
assert _hex_to_ass_bgr("#FF0000") == "0000FF"
def test_blue(self):
# #0000FF → BGR = FF0000
def test_blue_bgr(self):
"""蓝色 → BGR = FF0000."""
assert _hex_to_ass_bgr("#0000FF") == "FF0000"
def test_without_hash(self):
assert _hex_to_ass_bgr("FF0000") == "0000FF"
def test_invalid_length_returns_white(self):
assert _hex_to_ass_bgr("#123") == "FFFFFF"
def test_invalid_length(self):
"""长度不对返回默认."""
assert _hex_to_ass_bgr("#FF") == "FFFFFF"
class TestOpacityToAssAlpha:
"""不透明度 → ASS alpha."""
"""_opacity_to_ass_alpha 测试."""
def test_fully_opaque(self):
# 1.0 → alpha = 255 - 255 = 0"00"
"""完全不透明 → 00."""
assert _opacity_to_ass_alpha(1.0) == "00"
def test_fully_transparent(self):
# 0.0 → alpha = 255 - 0 = 255 → "FF"
"""完全透明 → FF."""
assert _opacity_to_ass_alpha(0.0) == "FF"
def test_half(self):
# 0.5 → alpha = 255 - 127 = 128 → "80" (因为 int(0.5*255)=127)
# 注意:int(0.5 * 255) = 127255-127=128 → "80"
"""50% 128 → 80."""
assert _opacity_to_ass_alpha(0.5) == "80"
def test_quarter(self):
# 0.25 → alpha = 255 - 63 = 192 → "C0"
assert _opacity_to_ass_alpha(0.25) == "C0"
def test_three_quarters(self):
# 0.75 → alpha = 255 - 191 = 64 → "40"
"""75%不透明 → 64 → 40."""
assert _opacity_to_ass_alpha(0.75) == "40"
def test_zero_padded(self):
# 结果始终是2位十六进制
result = _opacity_to_ass_alpha(1.0)
assert len(result) == 2
assert result == result.upper()
# ── 文本处理测试 ──────────────────────────────────────────────
class TestEscapeAssText:
"""ASS 文本转义."""
"""_escape_ass_text 转义测试."""
def test_plain_text(self):
def test_normal_text_unchanged(self):
"""普通文本不变."""
assert _escape_ass_text("hello world") == "hello world"
def test_newline_unix(self):
def test_newline_converted(self):
"""换行转成\\N."""
assert _escape_ass_text("line1\nline2") == "line1\\Nline2"
def test_newline_windows(self):
def test_crlf_converted(self):
"""\\r\\n转成\\N."""
assert _escape_ass_text("line1\r\nline2") == "line1\\Nline2"
def test_newline_mac(self):
def test_carriage_return_converted(self):
"""\\r转成\\N."""
assert _escape_ass_text("line1\rline2") == "line1\\Nline2"
def test_curly_braces_replaced(self):
# ASS 中 {} 是样式标签,需要转义
"""花括号替换成圆括号(ASS控制符)."""
assert _escape_ass_text("{text}") == "(text)"
def test_multiple_braces(self):
assert _escape_ass_text("{a}b{c}") == "(a)b(c)"
def test_mixed_special_chars(self):
text = "line1\n{bold}\nline3"
"""混合特殊字符."""
text = "hello\n{world}\r\nend"
result = _escape_ass_text(text)
assert "\\N" in result
assert "(bold)" in result
assert "{" not in result
assert "}" not in result
assert "\n" not in result
assert "\r" not in result
def test_empty_string(self):
assert _escape_ass_text("") == ""
assert "(world)" in result
class TestFormatAssTime:
"""秒 → ASS 时间格式."""
"""_format_ass_time 时间格式化测试."""
def test_zero(self):
assert _format_ass_time(0.0) == "0:00:00.00"
"""0秒."""
assert _format_ass_time(0) == "0:00:00.00"
def test_seconds_only(self):
"""只有秒."""
assert _format_ass_time(5.5) == "0:00:05.50"
def test_minutes(self):
assert _format_ass_time(65.25) == "0:01:05.25"
def test_minutes_and_seconds(self):
"""分+秒."""
assert _format_ass_time(125.5) == "0:02:05.50"
def test_hours(self):
assert _format_ass_time(3661.5) == "1:01:01.50"
def test_hours_minutes_seconds(self):
"""时+分+秒."""
assert _format_ass_time(3725.25) == "1:02:05.25"
def test_exact_minute(self):
assert _format_ass_time(60.0) == "0:01:00.00"
def test_exact_hour(self):
def test_exactly_one_hour(self):
"""刚好1小时."""
assert _format_ass_time(3600.0) == "1:00:00.00"
def test_sub_second_precision(self):
# 两位小数(厘秒精度)
result = _format_ass_time(1.234)
# 1.234 秒 = 0:00:01.23ASS 格式是两位小数/厘秒)
assert result.startswith("0:00:01.")
# 检查秒部分是两位小数格式
def test_single_digit_minute(self):
"""分钟补零."""
result = _format_ass_time(65.0)
parts = result.split(":")
assert len(parts) == 3
sec_part = parts[2]
assert "." in sec_part
decimals = sec_part.split(".")[1]
assert len(decimals) == 2
assert parts[1] == "01"
def test_negative_returns_zero_hours(self):
# 负数也应该能处理(虽然业务上不应该出现)
result = _format_ass_time(-1.0)
# 至少不崩溃
assert isinstance(result, str)
assert ":" in result
def test_always_two_decimal_places(self):
"""总是两位小数."""
result = _format_ass_time(3.0)
assert result.endswith(".00")
class TestWrapText:
"""按字数换行."""
"""_wrap_text 换行测试."""
def test_short_text_no_wrap(self):
result = _wrap_text("短文本", 10)
assert result == ["短文本"]
"""短文本不换行."""
result = _wrap_text("hello", 10)
assert len(result) == 1
assert result[0] == "hello"
def test_exact_length_no_wrap(self):
text = "一二三四五六七八九十"
"""刚好长度不换行."""
text = "abcdefghij" # 10 chars
result = _wrap_text(text, 10)
assert len(result) == 1
assert result[0] == text
def test_long_text_wraps(self):
text = "一二三四五六七八九十一二三四五六七八九十"
def test_simple_wrap(self):
"""简单换行."""
text = "abcdefghijklmnopqrst" # 20 chars
result = _wrap_text(text, 10)
assert len(result) == 2
assert len(result[0]) == 10
assert len(result[1]) == 10
def test_break_on_punctuation(self):
# 优先在标点处断开(标点在max_chars范围内靠前位置)
# 共12字,max=7,句号在第6位索引,range(7,3,-1)能扫到索引5的句号
text = "一二三四五。六七八九十一"
result = _wrap_text(text, 7)
assert result[0] == "一二三四五。"
assert result[1] == "六七八九十一"
def test_break_on_comma(self):
text = "一二三四五,六七八九十一"
result = _wrap_text(text, 7)
assert result[0] == "一二三四五,"
assert result[1] == "六七八九十一"
def test_multiple_lines(self):
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
result = _wrap_text(text, 10)
def test_uneven_wrap(self):
"""不均等换行."""
text = "abcdefghijklm" # 13 chars
result = _wrap_text(text, 5)
assert len(result) == 3
assert len(result[0]) == 10
assert len(result[1]) == 10
assert len(result[2]) == 5
assert result[0] == "abcde"
assert result[1] == "fghij"
assert result[2] == "klm"
def test_empty_string(self):
result = _wrap_text("", 10)
assert result == [""]
def test_max_chars_one(self):
# max_chars=1 时每个字符一行
text = "abc"
result = _wrap_text(text, 1)
assert result == ["a", "b", "c"]
def test_punctuation_at_boundary(self):
# 标点刚好在 max_chars 位置
text = "一二三四五六七八九。"
result = _wrap_text(text, 10)
assert len(result) == 1 # 刚好10个字符(含标点)
def test_chinese_text_wrap(self):
"""中文文本换行(按字符数)."""
text = "一二三四五六七八九十"
result = _wrap_text(text, 5)
assert len(result) == 2
assert result[0] == "一二三四五"
assert result[1] == "六七八九十"
class TestSubtitleSegment:
"""字幕片段数据类."""
def test_basic(self):
seg = SubtitleSegment(start=0.0, end=5.0, text="hello")
assert seg.start == 0.0
assert seg.end == 5.0
assert seg.text == "hello"
def test_duration(self):
seg = SubtitleSegment(start=1.5, end=4.5, text="test")
assert seg.end - seg.start == 3.0
# ── SubtitleStyle 测试 ────────────────────────────────────
class TestSubtitleStyle:
"""字幕样式配置."""
class TestSubtitleStyleDefaults:
"""SubtitleStyle 默认值测试."""
def test_default_values(self):
"""默认值正确."""
style = SubtitleStyle()
assert style.font_size > 0
assert isinstance(style.font_color, str)
assert isinstance(style.background_color, str)
assert style.bold is False
assert style.italic is False
assert style.stroke_enabled is True
assert style.shadow_enabled is False
assert style.background_enabled is False
assert style.fade_in == 0.0
assert style.fade_out == 0.0
assert style.animation_type == "none"
def test_ass_color_generation(self):
style = SubtitleStyle(font_color="#FFFFFF")
# 应该能生成 ASS 颜色格式
color = style.ass_font_color
assert isinstance(color, str)
assert color.startswith("&H")
def test_ass_background_color(self):
style = SubtitleStyle(background_color="#000000", background_opacity=0.5)
color = style.ass_background_color
assert isinstance(color, str)
assert color.startswith("&H")
class TestSubtitleStyleFromDict:
"""SubtitleStyle.from_dict 测试."""
def test_opacity_affects_alpha(self):
style1 = SubtitleStyle(background_opacity=1.0)
style2 = SubtitleStyle(background_opacity=0.0)
# 不透明度不同,alpha 应该不同
assert style1.ass_background_color != style2.ass_background_color
def test_none_returns_default(self):
"""None返回默认样式."""
style = SubtitleStyle.from_dict(None)
assert isinstance(style, SubtitleStyle)
def test_empty_dict_returns_default(self):
"""空dict返回默认."""
style = SubtitleStyle.from_dict({})
assert isinstance(style, SubtitleStyle)
def test_custom_font_size(self):
"""自定义字号."""
style = SubtitleStyle.from_dict({"size": 48})
assert style.font_size == 48
def test_custom_color(self):
"""自定义颜色."""
style = SubtitleStyle.from_dict({"color": "#FF0000"})
assert style.font_color == "#FF0000"
def test_bold_enabled(self):
"""启用粗体."""
style = SubtitleStyle.from_dict({"bold": True})
assert style.bold is True
def test_stroke_disabled(self):
"""禁用描边."""
style = SubtitleStyle.from_dict({"stroke_enabled": False})
assert style.stroke_enabled is False
def test_background_enabled(self):
"""启用背景框."""
style = SubtitleStyle.from_dict({"background_enabled": True})
assert style.background_enabled is True
def test_background_opacity_clamped(self):
"""背景透明度钳制."""
style = SubtitleStyle.from_dict(
{
"background_enabled": True,
"background_opacity": 2.0,
}
)
assert style.background_opacity == 1.0
def test_invalid_position_falls_back(self):
"""无效位置回退到默认."""
style = SubtitleStyle.from_dict({"position": "invalid_pos"})
# 回退到默认位置
assert style.position is not None
def test_fade_in_non_negative(self):
"""淡入时长不能为负."""
style = SubtitleStyle.from_dict({"fade_in": -1.0})
assert style.fade_in == 0.0
def test_custom_animation(self):
"""自定义动画."""
style = SubtitleStyle.from_dict({"animation_type": "fade"})
assert style.animation_type == "fade"
class TestSubtitleStyleProperties:
"""SubtitleStyle 属性测试."""
def test_ass_font_color_format(self):
"""ass_font_color格式正确."""
style = SubtitleStyle(font_color="#FF0000")
result = style.ass_font_color
assert result.startswith("&H")
assert len(result) == 10 # &H + AABBGGRR = 10 chars
def test_ass_background_color_format(self):
"""背景颜色格式正确."""
style = SubtitleStyle(
background_enabled=True,
background_color="#000000",
background_opacity=0.5,
)
result = style.ass_background_color
assert result.startswith("&H")
def test_alignment_is_int(self):
"""alignment是整数."""
style = SubtitleStyle()
assert isinstance(style.alignment, int)
-58
View File
@@ -32,61 +32,3 @@ class TestTagCreate:
def test_create_has_created_at(self):
tag = Tag.create(user_id="user-1", name="美食")
assert tag.created_at is not None
class TestTagExtended:
"""Tag 深度补充测试"""
def test_create_id_is_unique(self):
"""多次创建生成不同的 id"""
tag1 = Tag.create(user_id="u1", name="标签1")
tag2 = Tag.create(user_id="u1", name="标签2")
assert tag1.id != tag2.id
assert len(tag1.id) == 32
assert len(tag2.id) == 32
def test_create_id_is_hex(self):
"""id 是十六进制字符串"""
tag = Tag.create(user_id="u1", name="测试")
int(tag.id, 16) # 不抛错就是合法 hex
def test_slots_no_extra_attributes(self):
"""slots=True 不能添加新属性"""
import pytest
tag = Tag.create(user_id="u1", name="测试")
with pytest.raises(AttributeError):
tag.new_attr = "value" # type: ignore[attr-defined]
def test_create_very_long_name(self):
long_name = "标签" * 50
tag = Tag.create(user_id="u1", name=long_name)
assert tag.name == long_name
assert len(tag.name) == 100
def test_create_unicode_name(self):
tag = Tag.create(user_id="u1", name="🔥热门标签✨")
assert tag.name == "🔥热门标签✨"
def test_create_name_only_spaces_between_text(self):
"""中间有空格的标签名正常保留"""
tag = Tag.create(user_id="u1", name=" 美食 探店 ")
assert tag.name == "美食 探店"
def test_create_different_user_ids(self):
tag1 = Tag.create(user_id="user-001", name="标签")
tag2 = Tag.create(user_id="user-999", name="标签")
assert tag1.user_id == "user-001"
assert tag2.user_id == "user-999"
assert tag1.id != tag2.id
def test_name_is_string(self):
tag = Tag.create(user_id="u1", name="12345")
assert isinstance(tag.name, str)
assert tag.name == "12345"
def test_equality(self):
"""两个相同属性的 tag 不相等(id 不同)"""
tag1 = Tag.create(user_id="u1", name="同名")
tag2 = Tag.create(user_id="u1", name="同名")
assert tag1 != tag2
-139
View File
@@ -151,142 +151,3 @@ class TestTemplateCategory:
def test_category_has_timestamp(self):
cat = TemplateCategory(id="cat-1", user_id="u1", name="风景")
assert cat.created_at is not None
class TestTemplateExtended:
"""Template 深度补充测试"""
def test_tags_independence(self):
"""不同模板的 tags 列表互不影响"""
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
t1.tags.append("新标签")
assert "新标签" not in t2.tags
assert len(t2.tags) == 0
def test_title_config_independence(self):
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
t1.title_config["key"] = "val"
assert "key" not in t2.title_config
def test_subtitle_config_independence(self):
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
t1.subtitle_config["key"] = "val"
assert "key" not in t2.subtitle_config
def test_bgm_config_independence(self):
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
t1.bgm_config["key"] = "val"
assert "key" not in t2.bgm_config
def test_segments_independence(self):
segs = [
TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=2),
]
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take", segments=segs)
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
assert len(t2.segments) == 0
def test_empty_segments(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", segments=[])
assert t.segments == []
def test_many_segments(self):
segs = [
TemplateSegment(id=f"s{i}", template_id="t1", segment_order=i, duration_min=1, duration_max=3)
for i in range(30)
]
t = Template(id="t1", user_id="u1", name="n", mode="one_take", segments=segs)
assert len(t.segments) == 30
assert t.segments[0].segment_order == 0
assert t.segments[29].segment_order == 29
def test_zero_estimated_duration(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", estimated_duration=0.0)
assert t.estimated_duration == 0.0
def test_negative_estimated_duration(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", estimated_duration=-1.0)
assert t.estimated_duration == -1.0
def test_large_estimated_duration(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", estimated_duration=9999.9)
assert t.estimated_duration == 9999.9
def test_empty_category(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", category="")
assert t.category == ""
def test_custom_category(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", category="美食探店")
assert t.category == "美食探店"
def test_empty_name(self):
t = Template(id="t1", user_id="u1", name="", mode="one_take")
assert t.name == ""
def test_unicode_name(self):
t = Template(id="t1", user_id="u1", name="🎬 美食探店 · Vlog模板", mode="one_take")
assert "🎬" in t.name
assert "美食探店" in t.name
def test_special_characters_in_name(self):
special = "模!@#$%"
t = Template(id="t1", user_id="u1", name=special, mode="one_take")
assert t.name == special
def test_long_name(self):
long_name = "模板" * 100
t = Template(id="t1", user_id="u1", name=long_name, mode="one_take")
assert t.name == long_name
assert len(t.name) == 200
class TestTemplateSegmentExtended:
"""TemplateSegment 深度补充测试"""
def test_zero_duration(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=0, duration_max=0)
assert seg.duration_min == 0
assert seg.duration_max == 0
def test_negative_duration_min(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=-1, duration_max=5)
assert seg.duration_min == -1
def test_negative_duration_max(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=-5)
assert seg.duration_max == -5
def test_large_duration(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=0, duration_max=9999.9)
assert seg.duration_max == 9999.9
def test_negative_order(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=-5, duration_min=1, duration_max=3)
assert seg.segment_order == -5
def test_large_order(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=999, duration_min=1, duration_max=3)
assert seg.segment_order == 999
def test_material_type_none(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=3, material_type=None
)
assert seg.material_type is None
def test_material_type_empty_string(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=3, material_type=""
)
assert seg.material_type == ""
def test_material_type_unicode(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=3, material_type="风景"
)
assert seg.material_type == "风景"
-78
View File
@@ -125,81 +125,3 @@ class TestEditTemplateVersionSlots:
v = EditTemplateVersion.create(template_id="t1", version=1)
with pytest.raises(AttributeError):
v.nonexistent_field = "value" # type: ignore[attr-defined]
class TestEditTemplateVersionExtended:
"""EditTemplateVersion 深度补充测试"""
def test_id_is_hex(self):
"""id 是十六进制字符串"""
v = EditTemplateVersion.create(template_id="t1", version=1)
int(v.id, 16) # 不抛错就是合法 hex
def test_ids_are_unique(self):
"""不同版本的 id 不同"""
v1 = EditTemplateVersion.create(template_id="t1", version=1)
v2 = EditTemplateVersion.create(template_id="t1", version=2)
assert v1.id != v2.id
def test_zero_version(self):
"""version=0 也能创建"""
v = EditTemplateVersion.create(template_id="t1", version=0)
assert v.version == 0
def test_negative_version(self):
"""负数 version 也能创建(领域层不做业务校验)"""
v = EditTemplateVersion.create(template_id="t1", version=-1)
assert v.version == -1
def test_large_version(self):
v = EditTemplateVersion.create(template_id="t1", version=9999)
assert v.version == 9999
def test_empty_template_id(self):
v = EditTemplateVersion.create(template_id="", version=1)
assert v.template_id == ""
def test_empty_name(self):
v = EditTemplateVersion.create(template_id="t1", version=1, name="")
assert v.name == ""
def test_empty_change_note(self):
v = EditTemplateVersion.create(template_id="t1", version=1, change_note="")
assert v.change_note == ""
def test_empty_published_by(self):
v = EditTemplateVersion.create(template_id="t1", version=1, published_by="")
assert v.published_by == ""
def test_unicode_name(self):
v = EditTemplateVersion.create(template_id="t1", version=1, name="🎨 V5 优化版")
assert "🎨" in v.name
assert "V5" in v.name
def test_long_name(self):
long_name = "版本" * 50
v = EditTemplateVersion.create(template_id="t1", version=1, name=long_name)
assert v.name == long_name
assert len(v.name) == 100
def test_config_complex_nested(self):
config = {
"layer1": {
"layer2": {
"layer3": [1, 2, 3],
}
}
}
v = EditTemplateVersion.create(template_id="t1", version=1, config=config)
assert v.config["layer1"]["layer2"]["layer3"] == [1, 2, 3]
def test_clip_configs_empty_list(self):
v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=[])
assert v.clip_configs == []
def test_clip_configs_many(self):
clips = [{"clip_id": i, "duration": float(i)} for i in range(50)]
v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=clips)
assert len(v.clip_configs) == 50
assert v.clip_configs[0]["clip_id"] == 0
assert v.clip_configs[49]["clip_id"] == 49
-265
View File
@@ -145,268 +145,3 @@ class TestSplitText:
for seg in result:
assert len(seg) <= 80
# ── 短文本与空文本补充 ──────────────────────────────────────────────────────
class TestSplitTextEmptyAndShort:
"""空文本与短文本补充场景."""
def test_whitespace_only_returns_empty(self):
"""纯空白文本返回空列表."""
assert split_text(" \n\t ") == []
def test_single_char(self):
"""单字符文本."""
assert split_text("", max_chars=10) == [""]
def test_exactly_max_chars_no_split(self):
"""刚好等于 max_chars 不分割."""
text = "a" * 100
result = split_text(text, max_chars=100)
assert len(result) == 1
assert result[0] == text
def test_one_over_max_chars_splits(self):
"""超过 max_chars 1 个字符就会分割."""
text = "a" * 101
result = split_text(text, max_chars=100)
assert len(result) >= 2
def test_none_raises(self):
"""None 输入抛 AttributeErrorstrip 失败)."""
with pytest.raises(AttributeError):
split_text(None)
# ── 句子边界分段补充 ──────────────────────────────────────────────────────
class TestSplitTextSentenceBoundaries:
"""句子边界分段补充场景."""
def test_split_on_fullwidth_period(self):
"""全角句号分段."""
text = "第一句很长的内容。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
for seg in result:
assert len(seg) <= 60
def test_split_on_fullwidth_question(self):
"""全角问号分段."""
text = "你知道这是为什么吗?" + "是的。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
def test_split_on_fullwidth_exclamation(self):
"""全角感叹号分段."""
text = "真是太棒了!" + "内容。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
def test_split_on_newline(self):
"""换行符分段."""
lines = ["这是第一行很长的一段文字内容" * 3 for _ in range(5)]
text = "\n".join(lines)
result = split_text(text, max_chars=80)
assert len(result) > 1
def test_split_on_semicolon(self):
"""全角分号分段."""
text = "第一项内容;" + "其他内容。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
def test_english_period_splits(self):
"""英文句号分段."""
text = "Hello world. " * 30
result = split_text(text, max_chars=80)
assert len(result) > 1
def test_short_sentences_stay_merged(self):
"""短句(都 < 50字的句子不会单独成段,会累积到一起."""
text = "你好。我好。大家好。"
result = split_text(text, max_chars=200)
assert len(result) == 1
# ── 长句强制切段补充 ──────────────────────────────────────────────────────
class TestSplitTextLongSentenceForce:
"""超长单句强制切段补充."""
def test_no_punctuation_forced_split(self):
"""完全没有标点的超长文本硬切."""
text = "" * 300
result = split_text(text, max_chars=100)
assert len(result) == 3
for seg in result:
assert len(seg) == 100
def test_force_split_preserves_content(self):
"""硬切不丢字符."""
text = "a" * 250
result = split_text(text, max_chars=100)
assert sum(len(s) for s in result) == 250
def test_mixed_long_and_short(self):
"""长句短句混合."""
long_part = "非常长的句子没有标点符号" * 15
text = long_part + "。结尾。"
result = split_text(text, max_chars=100)
assert len(result) > 1
for seg in result:
assert len(seg) <= 100
# ── 短段合并补充 ─────────────────────────────────────────────────────────
class TestSplitTextShortSegmentMerge:
"""短段合并补充场景."""
def test_multiple_short_sentences_merged(self):
"""多个短句合并成一段."""
sentences = ["你好。", "我好。", "大家好。", "天气好。", "心情好。"]
text = "".join(sentences)
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_short_tail_merged(self):
"""尾部短段被合并到前一段."""
# 前面一段接近 max_chars,尾部很短
long_part = "一二三四五六七八九十" * 9 + "" # ~90字
tail = "完。" # 2字
text = long_part + tail
result = split_text(text, max_chars=100)
# 尾部短的应该被合并
assert len(result) <= 2
# ── 边界情况补充 ─────────────────────────────────────────────────────────
class TestSplitTextEdgeCases:
"""边界情况补充."""
def test_only_punctuation(self):
"""纯标点符号."""
text = "。。。。。"
result = split_text(text, max_chars=10)
assert len(result) == 1
def test_mixed_chinese_english(self):
"""中英文混合."""
text = "你好Hello。World!" * 20
result = split_text(text, max_chars=100)
assert len(result) > 1
for seg in result:
assert len(seg) <= 100
def test_strip_whitespace(self):
"""首尾空白被去除."""
text = " 你好世界。 "
result = split_text(text, max_chars=100)
assert result == ["你好世界。"]
def test_total_length_preserved(self):
"""分段后总长度等于原文 strip 后长度."""
text = "这是一段用于测试的文本内容。" * 20
result = split_text(text, max_chars=100)
assert "".join(result) == text.strip()
def test_custom_small_max_chars(self):
"""很小的 max_chars."""
text = "一二三四五六七八九十。" * 5
result = split_text(text, max_chars=20)
assert len(result) > 1
for seg in result:
assert len(seg) <= 20
# ── 更多边界场景补充 ─────────────────────────────────────────────────────────
class TestSplitTextMoreEdgeCases:
"""更多边界场景补充"""
def test_max_chars_one(self):
"""max_chars=1 每个字符一段"""
text = "一二三四五"
result = split_text(text, max_chars=1)
assert len(result) == 5
for seg in result:
assert len(seg) == 1
def test_consecutive_newlines(self):
"""连续多个换行符"""
text = "第一段\n\n\n第二段\n\n第三段"
result = split_text(text, max_chars=100)
# 合并后应该是一段(内容不长且合并逻辑会被合并)
assert len(result) >= 1
assert "第一段" in result[0]
for seg in result:
assert len(seg) <= 100
def test_only_newlines_only(self):
"""只有换行符(纯空白被strip掉返回空"""
assert split_text("\n\n\n\n") == []
def test_leading_trailing_whitespace(self):
"""首尾空白被去除"""
text = " 你好世界。 "
result = split_text(text, max_chars=100)
assert result == ["你好世界。"]
def test_very_long_single_sentence_many_segments(self):
"""超长单句被切成很多段"""
text = "" * 1000
result = split_text(text, max_chars=100)
assert len(result) == 10
for seg in result:
assert len(seg) == 100
def test_mixed_punctuation_types(self):
"""全角半角标点混合"""
text = "你好!再见。谢谢?抱歉;好的"
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_last_segment_short_merged_to_previous(self):
"""尾部极短段被合并到前一段"""
# 构造第一段接近max_chars,结尾有个短句尾巴
long_part = "一二三四五六七八九十" * 9 + "" # ~90字
tail = "" # 1字
text = long_part + tail
result = split_text(text, max_chars=100)
# 尾巴应该被合并
combined = "".join(result)
assert combined == text.strip()
assert len(result) <= 2
def test_all_short_sentences_merged_into_one(self):
"""大量短句全部合并成一段"""
sentences = ["你好。", "我好。", "他好。", "大家好。", "才是真的好。"]
text = "".join(sentences)
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_punctuation_only_long(self):
"""很长的纯标点文本"""
text = "" * 200
result = split_text(text, max_chars=50)
assert len(result) >= 4
for seg in result:
assert len(seg) <= 50
def test_tab_not_sentence_end(self):
"""制表符不是句子结束符"""
text = "这是一段\t包含制表符的文本内容" + "" * 100
result = split_text(text, max_chars=50)
# 制表符不在句子结束符集合中,不会触发分段
# 制表符会保留在分段内容中
has_tab = any("\t" in seg for seg in result)
assert has_tab
-81
View File
@@ -76,84 +76,3 @@ class TestTitleLibraryItem:
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
assert item.created_at is not None
assert item.updated_at is not None
def test_tags_independence_between_instances(self):
"""不同实例的 tags 列表互不影响"""
item1 = TitleLibraryItem(id="t1", user_id="u1", name="n1", text="t")
item2 = TitleLibraryItem(id="t2", user_id="u1", name="n2", text="t")
item1.tags.append("新标签")
assert "新标签" not in item2.tags
assert len(item2.tags) == 0
def test_metadata_independence_between_instances(self):
"""不同实例的 metadata_ 字典互不影响"""
item1 = TitleLibraryItem(id="t1", user_id="u1", name="n1", text="t")
item2 = TitleLibraryItem(id="t2", user_id="u1", name="n2", text="t")
item1.metadata_["key"] = "value"
assert "key" not in item2.metadata_
def test_zero_usage_count(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", usage_count=0)
assert item.usage_count == 0
def test_large_usage_count(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", usage_count=999999)
assert item.usage_count == 999999
def test_negative_usage_count(self):
"""负数使用次数也能存(领域层不做业务校验)"""
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", usage_count=-1)
assert item.usage_count == -1
def test_empty_text(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="")
assert item.text == ""
def test_long_text(self):
long_text = "标题" * 1000
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text=long_text)
assert item.text == long_text
assert len(item.text) == 2000
def test_empty_name(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="", text="t")
assert item.name == ""
def test_special_characters_in_name_and_text(self):
special = "!@#$%^&*()_+-=[]{}|;':\",./<>?\n\t"
item = TitleLibraryItem(id="t1", user_id="u1", name=special, text=special)
assert item.name == special
assert item.text == special
def test_unicode_in_name_and_text(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="中文标题🔥emoji",
text="支持各种文字:中文、English、日本語、한국어",
)
assert "中文" in item.name
assert "🔥" in item.name
assert "日本語" in item.text
def test_many_tags(self):
tags = [f"tag_{i}" for i in range(100)]
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", tags=tags)
assert len(item.tags) == 100
assert item.tags[0] == "tag_0"
assert item.tags[99] == "tag_99"
def test_is_active_toggle(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", is_active=True)
item.is_active = False
assert item.is_active is False
item.is_active = True
assert item.is_active is True
def test_empty_description(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", description="")
assert item.description == ""
def test_custom_category(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", category="自定义分类")
assert item.category == "自定义分类"
+2 -4
View File
@@ -1,5 +1,3 @@
from dataclasses import FrozenInstanceError
"""transition_presets 领域层单元测试 - 转场预设库"""
import pytest
@@ -52,7 +50,7 @@ class TestTransitionPreset:
def test_frozen_immutable(self):
"""frozen dataclass 不可修改"""
preset = TransitionPreset(id="test", name="测试", category="basic")
with pytest.raises(FrozenInstanceError):
with pytest.raises(Exception):
preset.name = "改名"
def test_tags_default_empty_list(self):
@@ -80,7 +78,7 @@ class TestTransitionPresetLibrary:
def test_all_presets_have_required_fields(self):
"""所有预设都有必填字段"""
for preset in TRANSITION_PRESET_LIBRARY:
assert preset.id, "missing id"
assert preset.id, f"missing id"
assert preset.name, f"{preset.id} missing name"
assert preset.category, f"{preset.id} missing category"
assert preset.transition, f"{preset.id} missing transition"
-136
View File
@@ -202,139 +202,3 @@ class TestTtsConfigClamp:
config = TtsConfig.parse(data)
assert isinstance(config.volume, float)
assert config.volume == 1.0
class TestTtsConfigTextEdge:
"""文本字段边界测试."""
def test_long_text_preserved(self):
long_text = "配音文本" * 500
data = {"enabled": True, "voice_id": "v1", "text": long_text}
config = TtsConfig.parse(data)
assert config.text == long_text
assert len(config.text) == 2000
def test_unicode_text_preserved(self):
data = {"enabled": True, "voice_id": "v1", "text": "こんにちは世界🎵"}
config = TtsConfig.parse(data)
assert config.text == "こんにちは世界🎵"
def test_special_chars_text_preserved(self):
data = {"enabled": True, "voice_id": "v1", "text": "line1\nline2\t tab <>&\"'"}
config = TtsConfig.parse(data)
assert config.text == "line1\nline2\t tab <>&\"'"
def test_empty_text_ok(self):
data = {"enabled": True, "voice_id": "v1", "text": ""}
config = TtsConfig.parse(data)
assert config.text == ""
def test_text_none_fallback(self):
data = {"enabled": True, "voice_id": "v1", "text": None}
config = TtsConfig.parse(data)
assert config.text == ""
class TestTtsConfigVoiceIdEdge:
"""voice_id 边界测试."""
def test_very_long_voice_id_preserved(self):
long_id = "voice_" + "x" * 200
data = {"enabled": True, "voice_id": long_id}
config = TtsConfig.parse(data)
assert config.voice_id == long_id
def test_voice_id_empty_string_ok(self):
data = {"enabled": True, "voice_id": ""}
config = TtsConfig.parse(data)
assert config.voice_id == ""
def test_voice_id_unicode_ok(self):
data = {"enabled": True, "voice_id": "音色_测试_001"}
config = TtsConfig.parse(data)
assert config.voice_id == "音色_测试_001"
class TestTtsConfigClampEdge:
"""钳制边界附近值测试."""
def test_speed_just_below_min_clamped(self):
data = {"enabled": True, "speed": 0.499}
config = TtsConfig.parse(data)
assert config.speed == 0.5
def test_speed_just_above_max_clamped(self):
data = {"enabled": True, "speed": 2.001}
config = TtsConfig.parse(data)
assert config.speed == 2.0
def test_pitch_just_below_min_clamped(self):
data = {"enabled": True, "pitch": -12.1}
config = TtsConfig.parse(data)
assert config.pitch == -12
def test_pitch_just_above_max_clamped(self):
data = {"enabled": True, "pitch": 12.1}
config = TtsConfig.parse(data)
assert config.pitch == 12
def test_volume_just_below_min_clamped(self):
data = {"enabled": True, "volume": -0.001}
config = TtsConfig.parse(data)
assert config.volume == 0.0
def test_volume_just_above_max_clamped(self):
data = {"enabled": True, "volume": 1.001}
config = TtsConfig.parse(data)
assert config.volume == 1.0
def test_direct_construct_clamp_speed(self):
config = TtsConfig(enabled=True, speed=0.1)
config._clamp()
assert config.speed == 0.5
def test_direct_construct_clamp_pitch_volume(self):
config = TtsConfig(enabled=True, pitch=-20, volume=2.0)
config._clamp()
assert config.pitch == -12
assert config.volume == 1.0
class TestTtsConfigAlignOverlapEdge:
"""对齐与叠加模式边界."""
def test_align_mode_empty_string_fallback(self):
data = {"enabled": True, "align_mode": ""}
config = TtsConfig.parse(data)
assert config.align_mode == "full"
def test_overlap_mode_empty_string_fallback(self):
data = {"enabled": True, "overlap_mode": ""}
config = TtsConfig.parse(data)
assert config.overlap_mode == "replace"
def test_align_mode_case_sensitive(self):
data = {"enabled": True, "align_mode": "SUBTITLE"}
config = TtsConfig.parse(data)
assert config.align_mode == "full"
class TestTtsConfigEquality:
"""相等性与独立性测试."""
def test_same_config_equal(self):
c1 = TtsConfig(enabled=True, voice_id="v1", speed=1.5)
c2 = TtsConfig(enabled=True, voice_id="v1", speed=1.5)
assert c1 == c2
def test_different_config_not_equal(self):
c1 = TtsConfig(enabled=True, voice_id="v1")
c2 = TtsConfig(enabled=True, voice_id="v2")
assert c1 != c2
def test_modify_one_does_not_affect_other(self):
c1 = TtsConfig(enabled=True, voice_id="v1")
c2 = TtsConfig(enabled=True, voice_id="v1")
c2.speed = 2.0
assert c1.speed == 1.0
assert c1 != c2
-131
View File
@@ -1,131 +0,0 @@
"""
TTS 配音引擎数据类与纯逻辑测试.
覆盖 VoiceoverSegment / VoiceoverResult / TtsEngine 入口判断等纯逻辑.
TTS 合成调用依赖外部服务由集成测试覆盖.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from video_processing.tts_engine import TtsEngine, VoiceoverResult, VoiceoverSegment
from packages.domain.tts_config import TtsConfig
class TestVoiceoverSegment:
"""配音片段数据类."""
def test_default_values(self):
seg = VoiceoverSegment(text="hello")
assert seg.text == "hello"
assert seg.start_time == 0.0
assert seg.end_time == 0.0
assert seg.audio_path is None
assert seg.duration == 0.0
def test_full_values(self):
seg = VoiceoverSegment(
text="hello world",
start_time=1.5,
end_time=3.0,
audio_path=Path("/tmp/test.wav"),
duration=1.5,
)
assert seg.text == "hello world"
assert seg.start_time == 1.5
assert seg.end_time == 3.0
assert seg.audio_path == Path("/tmp/test.wav")
assert seg.duration == 1.5
def test_duration_calculation(self):
seg = VoiceoverSegment(text="test", start_time=0.0, end_time=5.5)
assert seg.end_time - seg.start_time == 5.5
class TestVoiceoverResult:
"""配音结果数据类."""
def test_default_failure(self):
result = VoiceoverResult()
assert result.success is False
assert result.segments == []
assert result.total_duration == 0.0
assert result.error_message == ""
def test_success_result(self):
segs = [
VoiceoverSegment(text="hello", duration=1.0),
VoiceoverSegment(text="world", duration=2.0),
]
result = VoiceoverResult(
success=True,
segments=segs,
total_duration=3.0,
)
assert result.success is True
assert len(result.segments) == 2
assert result.total_duration == 3.0
assert result.error_message == ""
def test_failure_with_message(self):
result = VoiceoverResult(success=False, error_message="TTS服务不可用")
assert result.success is False
assert result.error_message == "TTS服务不可用"
def test_segments_isolated_list(self):
"""确保每个实例有独立的segments列表."""
r1 = VoiceoverResult()
r2 = VoiceoverResult()
r1.segments.append(VoiceoverSegment(text="test"))
assert len(r2.segments) == 0
class TestTtsEngineInit:
"""TTS 引擎初始化."""
def test_init_creates_work_dir(self, tmp_path):
mock_tts = MagicMock()
work_dir = tmp_path / "tts_work"
engine = TtsEngine(mock_tts, work_dir)
assert work_dir.exists()
assert work_dir.is_dir()
def test_init_with_existing_dir(self, tmp_path):
mock_tts = MagicMock()
work_dir = tmp_path / "existing"
work_dir.mkdir()
engine = TtsEngine(mock_tts, work_dir)
assert work_dir.exists()
class TestTtsEngineEntryConditions:
"""TTS 引擎入口判断逻辑(不调用真实 TTS)."""
def test_disabled_returns_failure(self, tmp_path):
mock_tts = MagicMock()
engine = TtsEngine(mock_tts, tmp_path)
config = TtsConfig(enabled=False, text="hello")
result = engine.generate_full_voiceover(config)
assert result.success is False
assert "未启用" in result.error_message or "" in result.error_message
mock_tts.synthesize.assert_not_called()
def test_empty_text_returns_failure(self, tmp_path):
mock_tts = MagicMock()
engine = TtsEngine(mock_tts, tmp_path)
config = TtsConfig(enabled=True, text="")
result = engine.generate_full_voiceover(config)
assert result.success is False
mock_tts.synthesize.assert_not_called()
def test_whitespace_text_returns_failure(self, tmp_path):
mock_tts = MagicMock()
engine = TtsEngine(mock_tts, tmp_path)
config = TtsConfig(enabled=True, text=" ")
result = engine.generate_full_voiceover(config)
assert result.success is False
mock_tts.synthesize.assert_not_called()
-184
View File
@@ -1,184 +0,0 @@
"""TTS配音引擎纯逻辑测试 — 数据结构 + 边界情况(mock TTS服务)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from video_processing.tts_engine import (
TtsEngine,
VoiceoverResult,
VoiceoverSegment,
)
from packages.domain.tts_config import TtsConfig
from packages.ports.tts_service import TtsError
class TestVoiceoverSegment:
"""VoiceoverSegment 数据结构测试."""
def test_default_values(self):
"""默认值正确."""
seg = VoiceoverSegment(text="你好")
assert seg.text == "你好"
assert seg.start_time == 0.0
assert seg.end_time == 0.0
assert seg.audio_path is None
assert seg.duration == 0.0
def test_full_values(self):
"""完整字段."""
seg = VoiceoverSegment(
text="测试",
start_time=1.5,
end_time=3.5,
audio_path=Path("/tmp/test.wav"),
duration=2.0,
)
assert seg.text == "测试"
assert seg.start_time == 1.5
assert seg.end_time == 3.5
assert seg.audio_path == Path("/tmp/test.wav")
assert seg.duration == 2.0
class TestVoiceoverResult:
"""VoiceoverResult 数据结构测试."""
def test_failure_default(self):
"""失败结果默认值."""
result = VoiceoverResult(success=False)
assert result.success is False
assert result.segments == []
assert result.total_duration == 0.0
assert result.error_message == ""
def test_success_with_segments(self):
"""成功结果带片段."""
seg = VoiceoverSegment(text="hi", duration=5.0)
result = VoiceoverResult(
success=True,
segments=[seg],
total_duration=5.0,
)
assert result.success is True
assert len(result.segments) == 1
assert result.total_duration == 5.0
def test_failure_with_message(self):
"""失败带错误信息."""
result = VoiceoverResult(success=False, error_message="TTS出错")
assert result.success is False
assert result.error_message == "TTS出错"
class TestTtsEngineFullVoiceover:
"""TtsEngine.generate_full_voiceover 整段配音测试(mock TTS."""
def _make_engine(self, tmp_path: Path, tts: MagicMock | None = None) -> TtsEngine:
"""创建测试用TtsEngine."""
if tts is None:
tts = MagicMock()
tts.synthesize.return_value = str(tmp_path / "out.wav")
return TtsEngine(tts_service=tts, work_dir=tmp_path)
def test_disabled_returns_failure(self, tmp_path):
"""配音未启用→失败."""
engine = self._make_engine(tmp_path)
config = TtsConfig(enabled=False, text="测试")
result = engine.generate_full_voiceover(config)
assert result.success is False
assert "未启用" in result.error_message
assert len(result.segments) == 0
def test_empty_text_returns_failure(self, tmp_path):
"""文本为空→失败."""
engine = self._make_engine(tmp_path)
config = TtsConfig(enabled=True, text=" ")
result = engine.generate_full_voiceover(config)
assert result.success is False
assert "文本为空" in result.error_message
def test_success_creates_segment(self, tmp_path):
"""成功合成返回正确结构."""
mock_tts = MagicMock()
output_file = tmp_path / "voiceover_full.wav"
output_file.write_bytes(b"fake audio")
mock_tts.synthesize.return_value = str(output_file)
engine = self._make_engine(tmp_path, mock_tts)
config = TtsConfig(enabled=True, text="测试文本", voice_id="female_warm", speed=1.0)
result = engine.generate_full_voiceover(config)
assert result.success is True
assert len(result.segments) == 1
assert result.segments[0].text == "测试文本"
assert result.segments[0].start_time == 0.0
assert result.total_duration > 0
mock_tts.synthesize.assert_called_once()
def test_tts_error_returns_failure_gracefully(self, tmp_path):
"""TTS抛错→优雅降级返回失败."""
mock_tts = MagicMock()
mock_tts.synthesize.side_effect = TtsError("合成失败")
engine = self._make_engine(tmp_path, mock_tts)
config = TtsConfig(enabled=True, text="测试")
result = engine.generate_full_voiceover(config)
assert result.success is False
assert "合成失败" in result.error_message
def test_generic_exception_returns_failure(self, tmp_path):
"""其他异常也降级返回失败."""
mock_tts = MagicMock()
mock_tts.synthesize.side_effect = RuntimeError("未知错误")
engine = self._make_engine(tmp_path, mock_tts)
config = TtsConfig(enabled=True, text="测试")
result = engine.generate_full_voiceover(config)
assert result.success is False
assert "未知错误" in result.error_message
def test_work_dir_created(self, tmp_path):
"""工作目录自动创建."""
new_dir = tmp_path / "nested" / "tts"
mock_tts = MagicMock()
TtsEngine(tts_service=mock_tts, work_dir=new_dir)
assert new_dir.exists()
class TestTtsEngineSubtitleVoiceover:
"""TtsEngine.generate_subtitle_voiceover 字幕配音测试(mock TTS."""
def _make_engine(self, tmp_path: Path, tts: MagicMock | None = None) -> TtsEngine:
if tts is None:
tts = MagicMock()
return TtsEngine(tts_service=tts, work_dir=tmp_path)
def test_disabled_returns_failure(self, tmp_path):
"""配音未启用→失败."""
engine = self._make_engine(tmp_path)
config = TtsConfig(enabled=False, text="")
result = engine.generate_subtitle_voiceover(config, [{"text": "hi", "start_time": 0, "end_time": 1}])
assert result.success is False
assert "未启用" in result.error_message
def test_empty_subtitles_returns_failure(self, tmp_path):
"""字幕列表为空→失败."""
engine = self._make_engine(tmp_path)
config = TtsConfig(enabled=True, text="")
result = engine.generate_subtitle_voiceover(config, [])
assert result.success is False
assert "字幕为空" in result.error_message
def test_none_subtitles_returns_failure(self, tmp_path):
"""None字幕也失败."""
engine = self._make_engine(tmp_path)
config = TtsConfig(enabled=True, text="")
result = engine.generate_subtitle_voiceover(config, None) # type: ignore
assert result.success is False
-403
View File
@@ -1,403 +0,0 @@
"""TTSJob领域模型测试 — 状态机 + 状态转换 + 属性方法."""
from __future__ import annotations
import pytest
from packages.domain.tts_job import (
TERMINAL_STATUSES,
TTSJob,
TTSJobStatus,
)
def _make_job(
*,
status: TTSJobStatus = TTSJobStatus.PENDING,
retry_count: int = 0,
max_retries: int = 3,
output_audio_url: str = "",
) -> TTSJob:
"""快速创建测试用TTSJob."""
return TTSJob(
id="job_1",
user_id="user_1",
input_text="测试文本",
voice_id="female_warm",
status=status,
output_audio_url=output_audio_url,
retry_count=retry_count,
max_retries=max_retries,
)
class TestTTSJobStatus:
"""TTSJobStatus 枚举测试."""
def test_all_statuses_exist(self):
"""所有5种状态都存在."""
assert TTSJobStatus.PENDING.value == "pending"
assert TTSJobStatus.PROCESSING.value == "processing"
assert TTSJobStatus.COMPLETED.value == "completed"
assert TTSJobStatus.FAILED.value == "failed"
assert TTSJobStatus.CANCELLED.value == "cancelled"
def test_terminal_statuses(self):
"""终态集合包含COMPLETED/FAILED/CANCELLED."""
assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES
assert TTSJobStatus.FAILED in TERMINAL_STATUSES
assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES
assert TTSJobStatus.PENDING not in TERMINAL_STATUSES
assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES
class TestTTSJobCreate:
"""TTSJob.create 创建测试."""
def test_create_pending_job(self):
"""创建成功,默认PENDING状态."""
job = TTSJob.create(
user_id="u1",
input_text="你好世界",
voice_id="v1",
)
assert job.id is not None
assert len(job.id) == 32
assert job.user_id == "u1"
assert job.input_text == "你好世界"
assert job.voice_id == "v1"
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 0
assert job.max_retries == 3
assert job.sample_rate == 22050
def test_create_empty_user_id_raises(self):
"""空user_id抛异常."""
with pytest.raises(ValueError, match="user_id"):
TTSJob.create(user_id=" ", input_text="hi", voice_id="v")
def test_create_empty_text_raises(self):
"""空input_text抛异常."""
with pytest.raises(ValueError, match="input_text"):
TTSJob.create(user_id="u1", input_text="", voice_id="v")
def test_create_strips_whitespace(self):
"""首尾空白被去除."""
job = TTSJob.create(
user_id=" u1 ",
input_text=" 你好 ",
voice_id=" v1 ",
)
assert job.user_id == "u1"
assert job.input_text == "你好"
assert job.voice_id == "v1"
class TestTerminalStatus:
"""is_terminal 终态判定测试."""
def test_pending_not_terminal(self):
"""PENDING不是终态."""
job = _make_job(status=TTSJobStatus.PENDING)
assert job.is_terminal is False
def test_processing_not_terminal(self):
"""PROCESSING不是终态."""
job = _make_job(status=TTSJobStatus.PROCESSING)
assert job.is_terminal is False
def test_completed_is_terminal(self):
"""COMPLETED是终态."""
job = _make_job(status=TTSJobStatus.COMPLETED)
assert job.is_terminal is True
def test_failed_is_terminal(self):
"""FAILED是终态."""
job = _make_job(status=TTSJobStatus.FAILED)
assert job.is_terminal is True
def test_cancelled_is_terminal(self):
"""CANCELLED是终态."""
job = _make_job(status=TTSJobStatus.CANCELLED)
assert job.is_terminal is True
class TestIsRetryable:
"""is_retryable 可重试判定测试."""
def test_failed_within_limit_is_retryable(self):
"""失败且未超上限→可重试."""
job = _make_job(status=TTSJobStatus.FAILED, retry_count=1, max_retries=3)
assert job.is_retryable is True
def test_failed_at_limit_not_retryable(self):
"""失败且已达上限→不可重试."""
job = _make_job(status=TTSJobStatus.FAILED, retry_count=3, max_retries=3)
assert job.is_retryable is False
def test_failed_over_limit_not_retryable(self):
"""失败且超上限→不可重试."""
job = _make_job(status=TTSJobStatus.FAILED, retry_count=5, max_retries=3)
assert job.is_retryable is False
def test_pending_not_retryable(self):
"""PENDING不可重试."""
job = _make_job(status=TTSJobStatus.PENDING)
assert job.is_retryable is False
def test_completed_not_retryable(self):
"""COMPLETED不可重试."""
job = _make_job(status=TTSJobStatus.COMPLETED)
assert job.is_retryable is False
def test_cancelled_not_retryable(self):
"""CANCELLED不可重试."""
job = _make_job(status=TTSJobStatus.CANCELLED)
assert job.is_retryable is False
class TestIsCompleted:
"""is_completed 完成判定测试."""
def test_completed_with_output_is_completed(self):
"""COMPLETED + 有输出URL→已完成."""
job = _make_job(
status=TTSJobStatus.COMPLETED,
output_audio_url="https://example.com/out.mp3",
)
assert job.is_completed is True
def test_completed_without_output_not_completed(self):
"""COMPLETED但无输出URL→不算完成."""
job = _make_job(status=TTSJobStatus.COMPLETED, output_audio_url="")
assert job.is_completed is False
def test_pending_not_completed(self):
"""PENDING不是完成."""
job = _make_job(status=TTSJobStatus.PENDING)
assert job.is_completed is False
def test_failed_not_completed(self):
"""FAILED不是完成."""
job = _make_job(status=TTSJobStatus.FAILED)
assert job.is_completed is False
class TestStateTransitions:
"""状态机转换测试."""
def test_pending_to_processing(self):
"""PENDING → PROCESSING 合法."""
job = _make_job(status=TTSJobStatus.PENDING)
job.transition_to(TTSJobStatus.PROCESSING)
assert job.status == TTSJobStatus.PROCESSING
def test_pending_to_failed(self):
"""PENDING → FAILED 合法."""
job = _make_job(status=TTSJobStatus.PENDING)
job.transition_to(TTSJobStatus.FAILED)
assert job.status == TTSJobStatus.FAILED
def test_pending_to_cancelled(self):
"""PENDING → CANCELLED 合法."""
job = _make_job(status=TTSJobStatus.PENDING)
job.transition_to(TTSJobStatus.CANCELLED)
assert job.status == TTSJobStatus.CANCELLED
def test_pending_to_completed_illegal(self):
"""PENDING → COMPLETED 非法."""
job = _make_job(status=TTSJobStatus.PENDING)
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(TTSJobStatus.COMPLETED)
def test_processing_to_completed(self):
"""PROCESSING → COMPLETED 合法."""
job = _make_job(status=TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.COMPLETED)
assert job.status == TTSJobStatus.COMPLETED
def test_processing_to_failed(self):
"""PROCESSING → FAILED 合法."""
job = _make_job(status=TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.FAILED)
assert job.status == TTSJobStatus.FAILED
def test_failed_to_pending_retry(self):
"""FAILED → PENDING 合法(重试)."""
job = _make_job(status=TTSJobStatus.FAILED)
job.transition_to(TTSJobStatus.PENDING)
assert job.status == TTSJobStatus.PENDING
def test_failed_to_completed_illegal(self):
"""FAILED → COMPLETED 非法."""
job = _make_job(status=TTSJobStatus.FAILED)
with pytest.raises(ValueError):
job.transition_to(TTSJobStatus.COMPLETED)
def test_completed_to_anything_illegal(self):
"""COMPLETED → 任何状态都非法(终态不可转换)."""
job = _make_job(status=TTSJobStatus.COMPLETED)
with pytest.raises(ValueError):
job.transition_to(TTSJobStatus.FAILED)
with pytest.raises(ValueError):
job.transition_to(TTSJobStatus.PENDING)
def test_cancelled_to_anything_illegal(self):
"""CANCELLED → 任何状态都非法."""
job = _make_job(status=TTSJobStatus.CANCELLED)
with pytest.raises(ValueError):
job.transition_to(TTSJobStatus.PENDING)
def test_transition_updates_updated_at(self):
"""状态转换更新updated_at."""
job = _make_job(status=TTSJobStatus.PENDING)
old_updated = job.updated_at
job.transition_to(TTSJobStatus.PROCESSING)
assert job.updated_at >= old_updated
def test_transition_with_string_status(self):
"""字符串状态也能转换."""
job = _make_job(status=TTSJobStatus.PENDING)
job.transition_to("processing")
assert job.status == TTSJobStatus.PROCESSING
def test_transition_with_invalid_string_raises(self):
"""无效字符串状态抛异常."""
job = _make_job(status=TTSJobStatus.PENDING)
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("invalid_status")
class TestMarkProcessing:
"""mark_processing 标记处理中测试."""
def test_mark_processing_sets_status_and_time(self):
"""标记处理中更新状态+开始时间+清错误."""
job = _make_job(status=TTSJobStatus.PENDING)
job.error_message = "旧错误"
job.mark_processing()
assert job.status == TTSJobStatus.PROCESSING
assert job.started_at is not None
assert job.error_message == ""
class TestMarkCompleted:
"""mark_completed 标记完成测试."""
def test_mark_completed_success(self):
"""成功标记完成."""
job = _make_job(status=TTSJobStatus.PROCESSING)
job.mark_completed(
output_audio_url="https://example.com/out.mp3",
output_audio_key="tts/jobs/job_1/out.mp3",
duration=10.5,
file_size=204800,
)
assert job.status == TTSJobStatus.COMPLETED
assert job.output_audio_url == "https://example.com/out.mp3"
assert job.output_audio_key == "tts/jobs/job_1/out.mp3"
assert job.duration == pytest.approx(10.5)
assert job.file_size == 204800
assert job.completed_at is not None
assert job.error_message == ""
def test_mark_completed_empty_url_raises(self):
"""空URL抛异常."""
job = _make_job(status=TTSJobStatus.PROCESSING)
with pytest.raises(ValueError, match="output_audio_url"):
job.mark_completed(output_audio_url=" ")
def test_mark_completed_strips_url(self):
"""URL首尾空白被去除."""
job = _make_job(status=TTSJobStatus.PROCESSING)
job.mark_completed(output_audio_url=" https://example.com/out.mp3 ")
assert job.output_audio_url == "https://example.com/out.mp3"
def test_mark_completed_from_pending_illegal(self):
"""从PENDING直接标记完成非法(先processing)."""
job = _make_job(status=TTSJobStatus.PENDING)
with pytest.raises(ValueError, match="非法状态转换"):
job.mark_completed(output_audio_url="https://x.com/out.mp3")
class TestMarkFailed:
"""mark_failed 标记失败测试."""
def test_mark_failed_from_pending(self):
"""从PENDING标记失败."""
job = _make_job(status=TTSJobStatus.PENDING)
job.mark_failed("网络超时")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "网络超时"
def test_mark_failed_from_processing(self):
"""从PROCESSING标记失败."""
job = _make_job(status=TTSJobStatus.PROCESSING)
job.mark_failed("合成失败")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "合成失败"
def test_mark_failed_from_completed_illegal(self):
"""从COMPLETED标记失败非法."""
job = _make_job(status=TTSJobStatus.COMPLETED, output_audio_url="https://x.com/out.mp3")
with pytest.raises(ValueError):
job.mark_failed("错误")
class TestMarkCancelled:
"""mark_cancelled 标记取消测试."""
def test_cancel_from_pending(self):
"""从PENDING取消."""
job = _make_job(status=TTSJobStatus.PENDING)
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_cancel_from_processing(self):
"""从PROCESSING取消."""
job = _make_job(status=TTSJobStatus.PROCESSING)
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_cancel_from_completed_illegal(self):
"""从COMPLETED取消非法."""
job = _make_job(status=TTSJobStatus.COMPLETED, output_audio_url="https://x.com/out.mp3")
with pytest.raises(ValueError):
job.mark_cancelled()
class TestPrepareRetry:
"""prepare_retry 重试准备测试."""
def test_retry_resets_to_pending(self):
"""重试重置为PENDINGretry_count+1."""
job = _make_job(status=TTSJobStatus.FAILED, retry_count=0, max_retries=3)
job.error_message = "失败了"
job.prepare_retry()
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 1
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
def test_retry_at_max_raises(self):
"""已达重试上限时不能再重试."""
job = _make_job(status=TTSJobStatus.FAILED, retry_count=3, max_retries=3)
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_retry_from_pending_raises(self):
"""PENDING状态不能重试."""
job = _make_job(status=TTSJobStatus.PENDING, retry_count=0, max_retries=3)
with pytest.raises(ValueError):
job.prepare_retry()
def test_multiple_retries_increment(self):
"""多次重试计数递增."""
job = _make_job(status=TTSJobStatus.FAILED, retry_count=0, max_retries=5)
job.prepare_retry()
assert job.retry_count == 1
# 模拟再次失败
job.mark_failed("又失败了")
job.prepare_retry()
assert job.retry_count == 2
-219
View File
@@ -9,7 +9,6 @@ url_security URL安全校验单元测试
- safe_download_file / safe_download_bytes: mock 网络测试
"""
import ipaddress
import os
import tempfile
from unittest.mock import MagicMock, patch
@@ -25,7 +24,6 @@ from packages.shared.url_security import (
NoRedirectHandler,
UrlSecurityError,
_check_internal_hostnames,
_check_ssrf_ip,
_is_trusted_domain,
_validate_magic_number,
is_url_safe,
@@ -597,220 +595,3 @@ class TestConstants:
def test_max_url_length(self):
assert MAX_URL_LENGTH == 2048
# ── SSRF IP 检查详细覆盖 ──────────────────────────────────────────────────────
class TestSSRFIPCheck:
"""_check_ssrf_ip 各类型 IP 拦截覆盖."""
def test_loopback_ipv4_blocked(self):
"""IPv4 回环 127.0.0.1 被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("127.0.0.1"))
def test_loopback_ipv4_another_blocked(self):
"""127.x 其他段也被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("127.255.255.1"))
def test_loopback_ipv6_blocked(self):
"""IPv6 回环 ::1 被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("::1"))
def test_private_10_range_blocked(self):
"""10.0.0.0/8 私有段被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("10.255.255.255"))
def test_private_172_range_blocked(self):
"""172.16.0.0/12 私有段被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("172.31.255.255"))
def test_private_192_range_blocked(self):
"""192.168.0.0/16 私有段被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("192.168.255.255"))
def test_link_local_ipv4_blocked(self):
"""169.254.x.x 链路本地被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("169.254.1.1"))
def test_multicast_ipv4_blocked(self):
"""224.x 组播被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("224.0.0.251"))
def test_unspecified_ipv4_blocked(self):
"""0.0.0.0 未指定地址被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("0.0.0.0"))
def test_unspecified_ipv6_blocked(self):
""":: 未指定地址被拦."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("::"))
def test_reserved_ipv4_blocked(self):
"""240.0.0.0/4 保留段被拦(含在 is_private 或 is_reserved 中)."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("240.0.0.1"))
def test_public_ipv4_passes(self):
"""公网 IPv4 通过 _check_ssrf_ip."""
_check_ssrf_ip(ipaddress.ip_address("8.8.8.8"))
def test_public_ipv4_another_passes(self):
"""另一个公网 IPv4 通过."""
_check_ssrf_ip(ipaddress.ip_address("1.1.1.1"))
def test_public_ipv6_passes(self):
"""公网 IPv6 通过."""
_check_ssrf_ip(ipaddress.ip_address("2001:4860:4860::8888"))
# ── 直接 IP 访问拦截 ────────────────────────────────────────────────────────
class TestDirectIPAccess:
"""直接 IP 访问控制(ALLOW_DIRECT_IP 开关)."""
def test_direct_ipv4_blocked_by_default(self):
"""默认禁止直接 IP 访问."""
with pytest.raises(UrlSecurityError, match="禁止直接 IP 访问"):
validate_url_safety("https://8.8.8.8/audio.mp3")
def test_direct_private_ip_blocked_even_with_flag(self):
"""ALLOW_DIRECT_IP=true 时私有 IP 仍被 SSRF 拦."""
with patch("packages.shared.url_security.ALLOW_DIRECT_IP", True):
with pytest.raises(UrlSecurityError):
validate_url_safety("https://192.168.1.1/a.mp3")
def test_direct_ip_allowed_when_flag_on(self):
"""ALLOW_DIRECT_IP=true 时公网 IP 通过."""
with patch("packages.shared.url_security.ALLOW_DIRECT_IP", True):
# 用 mock 绕过 DNS 解析路径,走 IP 分支
with patch("packages.shared.url_security._check_ssrf_ip") as mock_check:
result = validate_url_safety("https://8.8.8.8/a.mp3")
assert "8.8.8.8" in result
mock_check.assert_called_once()
# ── 魔数校验补充覆盖 ────────────────────────────────────────────────────────
class TestMagicNumberExtended:
"""魔数校验补充:更多格式 + 边界场景."""
def test_aac_adts_mpeg4(self):
"""AAC ADTS MPEG-4 魔数通过."""
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"\xff\xf1" + b"\x00" * 50)
tmp = f.name
try:
_validate_magic_number(tmp, {"audio/aac"})
finally:
os.unlink(tmp)
def test_m4a_ftyp_magic(self):
"""M4A ftyp 魔数通过."""
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"\x00\x00\x00\x20ftypM4A " + b"\x00" * 50)
tmp = f.name
try:
_validate_magic_number(tmp, {"audio/x-m4a"})
finally:
os.unlink(tmp)
def test_webp_riff_webp(self):
"""WebP RIFF+WEBP 魔数通过."""
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 50)
tmp = f.name
try:
_validate_magic_number(tmp, {"image/webp"})
finally:
os.unlink(tmp)
def test_video_mp4_magic(self):
"""video/mp4 ftyp 魔数通过."""
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"\x00\x00\x00\x20ftypmp42" + b"\x00" * 50)
tmp = f.name
try:
_validate_magic_number(tmp, {"video/mp4"})
finally:
os.unlink(tmp)
def test_matroska_webm_magic(self):
"""WebM EBML 魔数通过."""
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"\x1a\x45\xdf\xa3" + b"\x00" * 50)
tmp = f.name
try:
_validate_magic_number(tmp, {"video/webm"})
finally:
os.unlink(tmp)
def test_bmp_magic(self):
"""BMP BM 魔数通过."""
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"BM" + b"\x00" * 50)
tmp = f.name
try:
_validate_magic_number(tmp, {"image/bmp"})
finally:
os.unlink(tmp)
def test_flac_magic(self):
"""FLAC fLaC 魔数通过."""
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"fLaC" + b"\x00" * 50)
tmp = f.name
try:
_validate_magic_number(tmp, {"audio/flac"})
finally:
os.unlink(tmp)
def test_ogg_magic(self):
"""OGG OggS 魔数通过."""
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"OggS" + b"\x00" * 50)
tmp = f.name
try:
_validate_magic_number(tmp, {"audio/ogg"})
finally:
os.unlink(tmp)
def test_read_error_raises_security_error(self):
"""文件读取失败包装为 UrlSecurityError."""
with pytest.raises(UrlSecurityError, match="读取文件头失败"):
_validate_magic_number("/nonexistent/path/file.mp3", {"audio/mpeg"})
def test_multi_type_one_match(self):
"""多类型白名单,只要一个匹配就通过."""
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"\xff\xd8\xff" + b"\x00" * 50)
tmp = f.name
try:
_validate_magic_number(tmp, {"image/png", "image/jpeg", "image/gif"})
finally:
os.unlink(tmp)
def test_application_octet_stream_skipped(self):
"""application/octet-stream 没有专属魔数,跳过校验."""
# 注意:octet-stream 在 _MAGIC_NUMBERS 中没有条目,所以跳过
# 但实际白名单中常包含它,所以它的存在不应阻断
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"random stuff")
tmp = f.name
try:
# octet-stream + pngpng有魔数,png不匹配就会失败
# 只有 octet-stream 时应该跳过
_validate_magic_number(tmp, {"application/octet-stream"})
finally:
os.unlink(tmp)
-85
View File
@@ -166,88 +166,3 @@ class TestVerificationCodeTypes:
vc = VerificationCode.create("test@example.com", code_type)
assert vc.code_type == code_type
assert vc.is_valid is True
class TestVerificationCodeExtended:
"""VerificationCode 深度补充测试"""
def test_id_is_hex(self):
vc = VerificationCode.create("test@example.com", "email_bind")
int(vc.id, 16)
def test_ids_are_unique(self):
vc1 = VerificationCode.create("test@example.com", "email_bind")
vc2 = VerificationCode.create("test@example.com", "email_bind")
assert vc1.id != vc2.id
def test_slots_no_extra_attrs(self):
import pytest
vc = VerificationCode.create("test@example.com", "email_bind")
with pytest.raises(AttributeError):
vc.new_field = "value" # type: ignore[attr-defined]
def test_custom_code_non_numeric(self):
"""自定义 code 可以是非数字"""
vc = VerificationCode.create("test@example.com", "email_bind", custom_code="abcdef")
assert vc.code == "abcdef"
def test_custom_code_short(self):
vc = VerificationCode.create("test@example.com", "email_bind", custom_code="12")
assert vc.code == "12"
def test_custom_code_long(self):
long_code = "1" * 20
vc = VerificationCode.create("test@example.com", "email_bind", custom_code=long_code)
assert vc.code == long_code
assert len(vc.code) == 20
def test_ttl_zero_expires_immediately(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=0)
# ttl=0 时 expires_at = now,可能已过期或刚好
assert vc.expires_at is not None
def test_very_long_ttl(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=86400)
assert vc.is_expired is False
def test_negative_ttl_expired(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-100)
assert vc.is_expired is True
def test_attempts_starts_at_zero(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.attempts == 0
def test_increment_attempts_returns_none(self):
vc = VerificationCode.create("test@example.com", "email_bind")
result = vc.increment_attempts()
assert result is None
assert vc.attempts == 1
def test_mark_used_returns_none(self):
vc = VerificationCode.create("test@example.com", "email_bind")
result = vc.mark_used()
assert result is None
assert vc.is_used is True
def test_code_type_empty_string(self):
vc = VerificationCode.create("test@example.com", "")
assert vc.code_type == ""
def test_recipient_empty_string(self):
vc = VerificationCode.create("", "email_bind")
assert vc.recipient == ""
def test_created_at_equals_expires_minus_ttl(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=60)
diff = (vc.expires_at - vc.created_at).total_seconds()
assert abs(diff - 60) < 2
def test_multiple_mark_used_updates_time(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.mark_used()
first = vc.used_at
vc.mark_used()
second = vc.used_at
assert second >= first
-365
View File
@@ -1,365 +0,0 @@
"""VideoProcessor 纯逻辑单测 — 数据类 + 输入校验 + 解析逻辑.
通过 mock ffmpeg-python 库验证纯逻辑部分
不实际执行 FFmpeg确保测试轻量快速
"""
from __future__ import annotations
import os
from dataclasses import fields
from unittest.mock import MagicMock, patch
import pytest
from video_processing.processor import VideoProcessor, VideoResult
class TestVideoResultDataclass:
"""VideoResult 数据类测试."""
def test_all_fields_exist(self):
"""所有字段都存在."""
field_names = {f.name for f in fields(VideoResult)}
expected = {
"output_path",
"thumbnail_path",
"duration",
"width",
"height",
"fps",
"file_size",
}
assert expected.issubset(field_names)
def test_default_construction(self):
"""正常构造 VideoResult."""
result = VideoResult(
output_path="/tmp/out.mp4",
thumbnail_path="/tmp/out.jpg",
duration=10.5,
width=1920,
height=1080,
fps=25.0,
file_size=1024000,
)
assert result.output_path == "/tmp/out.mp4"
assert result.thumbnail_path == "/tmp/out.jpg"
assert result.duration == 10.5
assert result.width == 1920
assert result.height == 1080
assert result.fps == 25.0
assert result.file_size == 1024000
def test_zero_values(self):
"""零值/边界值构造."""
result = VideoResult(
output_path="",
thumbnail_path="",
duration=0.0,
width=0,
height=0,
fps=0.0,
file_size=0,
)
assert result.duration == 0.0
assert result.file_size == 0
class TestVideoProcessorInit:
"""VideoProcessor 初始化测试."""
def test_default_temp_dir(self):
"""默认使用系统临时目录."""
import tempfile
vp = VideoProcessor()
assert vp.temp_dir == tempfile.gettempdir()
def test_custom_temp_dir(self):
"""自定义临时目录."""
vp = VideoProcessor(temp_dir="/my/temp")
assert vp.temp_dir == "/my/temp"
class TestVideoProcessorConcatenateValidation:
"""concatenate_videos 输入校验测试."""
def test_empty_input_raises(self):
"""空输入列表抛出 ValueError."""
vp = VideoProcessor()
with pytest.raises(ValueError, match="cannot be empty"):
vp.concatenate_videos([], "/tmp/output.mp4")
def test_none_input_raises(self):
"""None 输入抛出异常."""
vp = VideoProcessor()
with pytest.raises((ValueError, TypeError)):
vp.concatenate_videos(None, "/tmp/output.mp4") # type: ignore[arg-type]
class TestVideoProcessorGetVideoInfoParsing:
"""get_video_info 解析逻辑测试(mock ffmpeg.probe."""
def _mock_probe(self, streams=None, fmt=None):
"""创建 ffmpeg.probe 的 mock 返回值."""
return {
"streams": streams
or [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1", "codec_name": "h264"}],
"format": fmt or {"duration": "10.5", "bit_rate": "5000000"},
}
def test_basic_info_parsing(self):
"""基本视频信息解析正确."""
vp = VideoProcessor()
probe_data = self._mock_probe()
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["duration"] == 10.5
assert info["width"] == 1920
assert info["height"] == 1080
assert info["fps"] == 25.0
assert info["codec"] == "h264"
assert info["bitrate"] == 5000000
def test_fps_fraction_parsing(self):
"""分数帧率解析(如 30000/1001 = 29.97."""
vp = VideoProcessor()
probe_data = self._mock_probe(
streams=[
{
"codec_type": "video",
"width": 1920,
"height": 1080,
"r_frame_rate": "30000/1001",
"codec_name": "h264",
}
]
)
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["fps"] == pytest.approx(29.97, abs=0.01)
def test_fps_integer_string(self):
"""整数字符串帧率(如 "60"."""
vp = VideoProcessor()
probe_data = self._mock_probe(
streams=[{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "60", "codec_name": "h264"}]
)
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["fps"] == 60.0
def test_missing_r_frame_rate(self):
"""缺少 r_frame_rate 时使用默认值."""
vp = VideoProcessor()
probe_data = self._mock_probe(
streams=[{"codec_type": "video", "width": 1920, "height": 1080, "codec_name": "h264"}]
)
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["fps"] == 25.0
def test_no_video_stream(self):
"""没有视频流时的行为."""
vp = VideoProcessor()
probe_data = {
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
"format": {"duration": "10.0", "bit_rate": "128000"},
}
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
with pytest.raises(StopIteration):
vp.get_video_info("/tmp/test.mp4")
def test_float_duration(self):
"""浮点时长解析."""
vp = VideoProcessor()
probe_data = self._mock_probe(fmt={"duration": "123.456", "bit_rate": "0"})
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["duration"] == pytest.approx(123.456, abs=0.001)
def test_bitrate_zero(self):
"""码率为 0 时."""
vp = VideoProcessor()
probe_data = self._mock_probe(fmt={"duration": "10.0", "bit_rate": "0"})
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["bitrate"] == 0
def test_ffmpeg_probe_error_raises(self):
"""ffmpeg.probe 失败时抛出 RuntimeError."""
vp = VideoProcessor()
import ffmpeg
with patch(
"video_processing.processor.ffmpeg.probe",
side_effect=ffmpeg.Error([], b"", b"No such file"),
):
with pytest.raises(RuntimeError, match="probe error"):
vp.get_video_info("/tmp/nonexistent.mp4")
class TestVideoProcessorGenerateThumbnail:
"""generate_thumbnail 测试."""
def _build_mock_chain(self):
"""构建 ffmpeg.input → .output → .overwrite_output → .run 调用链."""
mock_input_node = MagicMock()
mock_output_node = MagicMock()
mock_overwrite_node = MagicMock()
mock_input_node.output.return_value = mock_output_node
mock_output_node.overwrite_output.return_value = mock_overwrite_node
return mock_input_node, mock_output_node, mock_overwrite_node
def test_default_output_path(self):
"""默认输出路径为视频路径 + _thumb.jpg."""
vp = VideoProcessor()
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
result = vp.generate_thumbnail("/tmp/video.mp4")
assert result == "/tmp/video_thumb.jpg"
mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=1.0)
mock_input_node.output.assert_called_once()
# 验证输出路径和参数
output_args = mock_input_node.output.call_args
assert output_args[0][0] == "/tmp/video_thumb.jpg"
assert output_args[1].get("vframes") == 1
assert output_args[1].get("format") == "image2"
assert output_args[1].get("vcodec") == "mjpeg"
def test_custom_output_path(self):
"""自定义输出路径."""
vp = VideoProcessor()
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
result = vp.generate_thumbnail("/tmp/video.mp4", output_path="/custom/thumb.jpg")
assert result == "/custom/thumb.jpg"
def test_custom_timestamp(self):
"""自定义截图时间点."""
vp = VideoProcessor()
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
vp.generate_thumbnail("/tmp/video.mp4", timestamp=3.5)
# 验证 ss 参数
mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=3.5)
def test_ffmpeg_error_raises_runtime(self):
"""FFmpeg 失败时抛出 RuntimeError."""
vp = VideoProcessor()
import ffmpeg
mock_input_node, mock_output, mock_overwrite = self._build_mock_chain()
mock_overwrite.run.side_effect = ffmpeg.Error([], b"", b"Output file #0 does not contain any stream")
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node):
with pytest.raises(RuntimeError, match="thumbnail error"):
vp.generate_thumbnail("/tmp/video.mp4")
class TestVideoProcessorConcatFileFormat:
"""concat 临时文件格式验证."""
def test_concat_file_format(self, tmp_path):
"""concat 临时文件格式符合 FFmpeg concat demuxer 规范."""
import os
vp = VideoProcessor(temp_dir=str(tmp_path))
written_content = {}
def fake_input(path, *args, **kwargs):
mock_node = MagicMock()
mock_output = MagicMock()
mock_overwrite = MagicMock()
mock_node.output.return_value = mock_output
mock_output.overwrite_output.return_value = mock_overwrite
if kwargs.get("format") == "concat":
# 读取 concat 文件内容
with open(path) as f:
written_content["concat"] = f.read()
return mock_node
mock_probe = MagicMock(
return_value={
"streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}],
"format": {"duration": "5.0", "bit_rate": "1000000"},
}
)
with (
patch("video_processing.processor.ffmpeg.input", side_effect=fake_input),
patch("video_processing.processor.ffmpeg.probe", mock_probe),
patch("video_processing.processor.os.path.getsize", return_value=1024),
):
with patch.object(VideoProcessor, "generate_thumbnail", return_value="/tmp/thumb.jpg"):
vp.concatenate_videos(
["/tmp/a.mp4", "/tmp/b.mp4", "/tmp/c.mp4"],
str(tmp_path / "output.mp4"),
)
# 验证 concat 文件格式
assert "concat" in written_content
lines = written_content["concat"].strip().split("\n")
assert len(lines) == 3
assert lines[0].startswith("file '")
assert "a.mp4'" in lines[0]
assert "b.mp4'" in lines[1]
assert "c.mp4'" in lines[2]
# 使用绝对路径
first_path = lines[0].replace("file '", "").rstrip("'")
assert os.path.isabs(first_path)
def test_concat_creates_output_directory(self, tmp_path):
"""输出目录不存在时自动创建."""
vp = VideoProcessor(temp_dir=str(tmp_path))
out_dir = tmp_path / "deep" / "output"
out_file = out_dir / "result.mp4"
mock_node = MagicMock()
mock_output = MagicMock()
mock_overwrite = MagicMock()
mock_node.output.return_value = mock_output
mock_output.overwrite_output.return_value = mock_overwrite
mock_probe = MagicMock(
return_value={
"streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}],
"format": {"duration": "5.0", "bit_rate": "1000000"},
}
)
with (
patch("video_processing.processor.ffmpeg.input", return_value=mock_node),
patch("video_processing.processor.ffmpeg.probe", mock_probe),
patch("video_processing.processor.os.path.getsize", return_value=1024),
):
with patch.object(VideoProcessor, "generate_thumbnail", return_value=str(out_dir / "thumb.jpg")):
vp.concatenate_videos(["/tmp/a.mp4"], str(out_file))
assert out_dir.exists()
assert out_dir.is_dir()
-97
View File
@@ -223,100 +223,3 @@ class TestVideoShareCounters:
share.revoke()
assert share.is_active is False
assert share.is_accessible is False
class TestGenerateShareTokenExtended:
"""generate_share_token 深度补充测试"""
def test_zero_length(self):
token = generate_share_token(0)
assert token == ""
def test_length_one(self):
token = generate_share_token(1)
assert len(token) == 1
def test_very_long_token(self):
token = generate_share_token(100)
assert len(token) == 100
def test_no_special_characters(self):
token = generate_share_token(50)
assert token.isalnum()
def test_all_characters_from_alphabet(self):
alphabet = set("abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789")
token = generate_share_token(200)
for c in token:
assert c in alphabet
class TestVideoShareExtended:
"""VideoShare 深度补充测试"""
def test_zero_view_count(self):
share = VideoShare.create(video_id="v1", user_id="u1")
assert share.view_count == 0
def test_zero_download_count(self):
share = VideoShare.create(video_id="v1", user_id="u1")
assert share.download_count == 0
def test_large_view_count(self):
share = VideoShare.create(video_id="v1", user_id="u1")
for _ in range(1000):
share.increment_view_count()
assert share.view_count == 1000
def test_revoke_idempotent(self):
share = VideoShare.create(video_id="v1", user_id="u1")
share.revoke()
assert share.is_active is False
share.revoke()
assert share.is_active is False
def test_revoke_returns_none(self):
share = VideoShare.create(video_id="v1", user_id="u1")
result = share.revoke()
assert result is None
def test_increment_view_returns_none(self):
share = VideoShare.create(video_id="v1", user_id="u1")
result = share.increment_view_count()
assert result is None
def test_increment_download_returns_none(self):
share = VideoShare.create(video_id="v1", user_id="u1")
result = share.increment_download_count()
assert result is None
def test_id_is_hex(self):
share = VideoShare.create(video_id="v1", user_id="u1")
int(share.id, 16)
def test_ids_are_unique(self):
s1 = VideoShare.create(video_id="v1", user_id="u1")
s2 = VideoShare.create(video_id="v1", user_id="u1")
assert s1.id != s2.id
def test_expires_at_boundary_exact_now(self):
"""expires_at 恰好是现在,应该被认为过期"""
share = VideoShare.create(video_id="v1", user_id="u1")
share.expires_at = datetime.now(timezone.utc) - timedelta(microseconds=1)
assert share.is_expired is True
def test_expires_at_boundary_one_second_future(self):
share = VideoShare.create(video_id="v1", user_id="u1")
share.expires_at = datetime.now(timezone.utc) + timedelta(seconds=1)
assert share.is_expired is False
def test_password_with_special_characters(self):
special_pass = "pass!@#$%^&*()"
share = VideoShare.create(video_id="v1", user_id="u1", password=special_pass)
assert share.verify_password(special_pass) is True
assert share.verify_password("wrong") is False
def test_password_unicode(self):
unicode_pass = "密码🔐测试"
share = VideoShare.create(video_id="v1", user_id="u1", password=unicode_pass)
assert share.verify_password(unicode_pass) is True
-260
View File
@@ -1,260 +0,0 @@
"""VoiceExtractor 纯逻辑单测 — 命令构建 + 边界用例.
通过 mock run_ffmpeg 验证 FFmpeg 命令参数是否正确
不实际执行 FFmpeg确保测试轻量快速
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from worker_app.tasks.voice_extraction import VoiceExtractor
class TestVoiceExtractorExtractVoiceCommand:
"""extract_voice 命令构建测试."""
def test_default_params_correct_command(self):
"""默认参数下 FFmpeg 命令正确."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
result = extractor.extract_voice("/tmp/input.mp4", "/tmp/output.mp3")
assert result == "/tmp/output.mp3"
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
# 基本结构验证
assert cmd[0] == "ffmpeg"
assert "-y" in cmd
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
assert "-vn" in cmd # 无视频流
assert cmd[-1] == "/tmp/output.mp3"
# 音频滤镜验证
af_idx = cmd.index("-af")
af_value = cmd[af_idx + 1]
assert "highpass=f=200" in af_value
assert "afftdn=bn=20" in af_value
assert "bandpass=f=300:width_type=h:width=3000" in af_value
assert "loudnorm" in af_value
# 编码验证
assert "libmp3lame" in cmd
assert "-q:a" in cmd
assert cmd[cmd.index("-q:a") + 1] == "2"
def test_custom_highpass(self):
"""自定义 highpass 频率."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=500)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "highpass=f=500" in af_value
def test_custom_bandpass_freq(self):
"""自定义 bandpass 中心频率."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=500)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "bandpass=f=500:" in af_value
def test_custom_bandpass_width(self):
"""自定义 bandpass 宽度."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_width=5000)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "width=5000" in af_value
def test_custom_noise_reduction(self):
"""自定义降噪强度."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=30)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "afftdn=bn=30" in af_value
def test_filter_order_is_correct(self):
"""滤镜顺序:highpass → 降噪 → bandpass → loudnorm."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
hp_pos = af_value.index("highpass")
dn_pos = af_value.index("afftdn")
bp_pos = af_value.index("bandpass")
ln_pos = af_value.index("loudnorm")
assert hp_pos < dn_pos < bp_pos < ln_pos
def test_creates_output_directory(self, tmp_path):
"""输出目录不存在时自动创建."""
out_dir = tmp_path / "nested" / "deep"
out_file = out_dir / "voice.mp3"
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg"):
extractor.extract_voice("/tmp/in.mp4", str(out_file))
assert out_dir.exists()
assert out_dir.is_dir()
def test_returns_output_path(self):
"""返回值为输出路径."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg"):
result = extractor.extract_voice("/tmp/in.mp4", "/tmp/voice.mp3")
assert result == "/tmp/voice.mp3"
class TestVoiceExtractorExtractBackgroundCommand:
"""extract_background 命令构建测试."""
def test_default_params_correct_command(self):
"""默认参数下 FFmpeg 命令正确."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
result = extractor.extract_background("/tmp/input.mp4", "/tmp/output.mp3")
assert result == "/tmp/output.mp3"
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
# 基本结构
assert cmd[0] == "ffmpeg"
assert "-y" in cmd
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
assert "-vn" in cmd
assert cmd[-1] == "/tmp/output.mp3"
# 音频滤镜
af_idx = cmd.index("-af")
af_value = cmd[af_idx + 1]
assert "lowpass=f=200" in af_value
assert "loudnorm" in af_value
# 编码
assert "libmp3lame" in cmd
def test_custom_lowpass_freq(self):
"""自定义 lowpass 频率."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=500)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "lowpass=f=500" in af_value
def test_filter_order_background(self):
"""背景音滤镜顺序:lowpass → loudnorm."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3")
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
lp_pos = af_value.index("lowpass")
ln_pos = af_value.index("loudnorm")
assert lp_pos < ln_pos
def test_background_creates_output_directory(self, tmp_path):
"""背景音输出目录不存在时自动创建."""
out_dir = tmp_path / "bgm" / "tracks"
out_file = out_dir / "bg.mp3"
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg"):
extractor.extract_background("/tmp/in.mp4", str(out_file))
assert out_dir.exists()
class TestVoiceExtractorEdgeCases:
"""边界情况测试."""
def test_zero_highpass(self):
"""highpass=0 时的行为(极端低值)."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=0)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "highpass=f=0" in af_value
def test_zero_bandpass_freq(self):
"""bandpass_freq=0 时的极端情况."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=0)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "bandpass=f=0:" in af_value
def test_very_high_noise_reduction(self):
"""极高降噪强度."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=100)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "afftdn=bn=100" in af_value
def test_negative_lowpass_allowed(self):
"""lowpass 负值(由调用方保证合法性,函数不做校验)."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=-10)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "lowpass=f=-10" in af_value
def test_run_ffmpeg_propagates_error(self):
"""_run_ffmpeg 抛出异常时向上传递."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=RuntimeError("FFmpeg failed")):
with pytest.raises(RuntimeError, match="FFmpeg failed"):
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
def test_voice_extractor_is_static_method(self):
"""_run_ffmpeg 是静态方法,可在类上直接调用."""
# 验证 VoiceExtractor 可以直接实例化(无需参数)
extractor = VoiceExtractor()
assert extractor is not None
def test_multiple_extractions_same_instance(self):
"""同一个实例可多次执行提取."""
extractor = VoiceExtractor()
call_count = 0
def fake_run(cmd):
nonlocal call_count
call_count += 1
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=fake_run):
extractor.extract_voice("/tmp/a.mp4", "/tmp/a_voice.mp3")
extractor.extract_background("/tmp/a.mp4", "/tmp/a_bg.mp3")
assert call_count == 2
-90
View File
@@ -93,93 +93,3 @@ class TestVoiceLibraryItem:
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
assert item.created_at is not None
assert item.updated_at is not None
class TestVoiceLibraryItemExtended:
"""VoiceLibraryItem 深度补充测试"""
def test_zero_duration(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=0)
assert item.duration == 0
def test_negative_duration(self):
"""负数 duration 领域层不校验"""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=-1.5)
assert item.duration == -1.5
def test_large_duration(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=9999.99)
assert item.duration == 9999.99
def test_zero_file_size(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", file_size=0)
assert item.file_size == 0
def test_large_file_size(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", file_size=10**9)
assert item.file_size == 10**9
def test_empty_audio_url(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", audio_url="")
assert item.audio_url == ""
def test_tags_independence(self):
item1 = VoiceLibraryItem(id="v1", user_id="u1", name="n1")
item2 = VoiceLibraryItem(id="v2", user_id="u1", name="n2")
item1.tags.append("新标签")
assert "新标签" not in item2.tags
assert len(item2.tags) == 0
def test_metadata_independence(self):
item1 = VoiceLibraryItem(id="v1", user_id="u1", name="n1")
item2 = VoiceLibraryItem(id="v2", user_id="u1", name="n2")
item1.metadata_["key"] = "val"
assert "key" not in item2.metadata_
def test_many_tags(self):
tags = [f"tag_{i}" for i in range(30)]
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", tags=tags)
assert len(item.tags) == 30
assert item.tags[0] == "tag_0"
def test_empty_text(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", text="")
assert item.text == ""
def test_long_text(self):
long_text = "这是一段很长的配音文本" * 100
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", text=long_text)
assert item.text == long_text
assert len(item.text) == 1100
def test_empty_voice_id(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", voice_id="")
assert item.voice_id == ""
def test_empty_project_id(self):
"""project_id 默认是 None 不是空字符串"""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
assert item.project_id is None
def test_project_id_with_string(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", project_id="")
# 传空字符串的话就是空字符串
assert item.project_id == ""
def test_status_empty_string(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status="")
assert item.status == ""
def test_unicode_name(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="🎙️ 专业配音 · 龙小淳")
assert "🎙️" in item.name
assert "龙小淳" in item.name
def test_special_characters_in_name(self):
special = "配!@#$%^&*()音"
item = VoiceLibraryItem(id="v1", user_id="u1", name=special)
assert item.name == special
def test_empty_user_id(self):
item = VoiceLibraryItem(id="v1", user_id="", name="n")
assert item.user_id == ""
-839
View File
@@ -1,839 +0,0 @@
"""第76波:TTSJob + Job + Tag 领域纯逻辑单测。"""
from datetime import datetime, timezone
import pytest
from packages.domain.job import Job, JobStatus, JobType
from packages.domain.tag import Tag
from packages.domain.tts_job import TTSJob, TTSJobStatus
# ============================================================
# Tag.create 测试
# ============================================================
class TestTagCreate:
def test_create_basic(self):
tag = Tag.create(user_id="u1", name="风景")
assert tag.id
assert tag.user_id == "u1"
assert tag.name == "风景"
assert isinstance(tag.created_at, datetime)
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="u1", name="")
def test_create_whitespace_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="u1", name=" ")
def test_create_name_stripped(self):
tag = Tag.create(user_id="u1", name=" 美食 ")
assert tag.name == "美食"
def test_create_id_is_unique(self):
tag1 = Tag.create(user_id="u1", name="a")
tag2 = Tag.create(user_id="u1", name="b")
assert tag1.id != tag2.id
def test_create_uses_utc_timezone(self):
before = datetime.now(timezone.utc)
tag = Tag.create(user_id="u1", name="t")
after = datetime.now(timezone.utc)
assert before <= tag.created_at <= after
# ============================================================
# TTSJob.create 测试
# ============================================================
class TestTTSJobCreate:
def test_create_minimal(self):
job = TTSJob.create(user_id="u1", input_text="你好世界")
assert job.id
assert job.user_id == "u1"
assert job.input_text == "你好世界"
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 0
assert job.max_retries == 3
assert job.format == "mp3"
assert job.sample_rate == 22050
assert job.error_message == ""
assert isinstance(job.created_at, datetime)
assert isinstance(job.updated_at, datetime)
def test_create_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id 不能为空"):
TTSJob.create(user_id=" ", input_text="hi")
def test_create_empty_input_text_raises(self):
with pytest.raises(ValueError, match="input_text 不能为空"):
TTSJob.create(user_id="u1", input_text="")
def test_create_whitespace_input_raises(self):
with pytest.raises(ValueError, match="input_text 不能为空"):
TTSJob.create(user_id="u1", input_text=" \n\t ")
def test_create_too_long_input_raises(self):
long_text = "a" * 10001
with pytest.raises(ValueError, match="长度不能超过"):
TTSJob.create(user_id="u1", input_text=long_text)
def test_create_boundary_length_ok(self):
text = "a" * 10000
job = TTSJob.create(user_id="u1", input_text=text)
assert len(job.input_text) == 10000
def test_create_invalid_format_raises(self):
with pytest.raises(ValueError, match="不支持的输出格式"):
TTSJob.create(user_id="u1", input_text="hi", format="flac")
def test_create_valid_formats(self):
for fmt in ("mp3", "wav", "pcm"):
job = TTSJob.create(user_id="u1", input_text="hi", format=fmt)
assert job.format == fmt
def test_create_strips_whitespace(self):
job = TTSJob.create(
user_id=" u1 ",
input_text=" hello ",
voice_id=" v1 ",
voice_model=" cosyvoice ",
project_id=" p1 ",
voice_clone_profile_id=" vp1 ",
)
assert job.user_id == "u1"
assert job.input_text == "hello"
assert job.voice_id == "v1"
assert job.voice_model == "cosyvoice"
assert job.project_id == "p1"
assert job.voice_clone_profile_id == "vp1"
def test_create_custom_params(self):
job = TTSJob.create(
user_id="u1",
input_text="hi",
voice_id="voice_001",
voice_model="cosyvoice",
project_id="proj_001",
voice_clone_profile_id="vcp_001",
sample_rate=16000,
format="wav",
max_retries=5,
metadata={"key": "val"},
)
assert job.voice_id == "voice_001"
assert job.voice_model == "cosyvoice"
assert job.project_id == "proj_001"
assert job.voice_clone_profile_id == "vcp_001"
assert job.sample_rate == 16000
assert job.format == "wav"
assert job.max_retries == 5
assert job.metadata == {"key": "val"}
def test_create_metadata_default_empty_dict(self):
job = TTSJob.create(user_id="u1", input_text="hi")
assert job.metadata == {}
def test_create_id_unique(self):
j1 = TTSJob.create(user_id="u1", input_text="a")
j2 = TTSJob.create(user_id="u1", input_text="b")
assert j1.id != j2.id
# ============================================================
# TTSJob 状态属性测试
# ============================================================
class TestTTSJobStatusProperties:
def test_is_terminal_pending_false(self):
job = TTSJob.create(user_id="u1", input_text="hi")
assert not job.is_terminal
def test_is_terminal_processing_false(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
assert not job.is_terminal
def test_is_terminal_completed_true(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed("http://out.mp3")
assert job.is_terminal
def test_is_terminal_failed_true(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_failed("error")
assert job.is_terminal
def test_is_terminal_cancelled_true(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_cancelled()
assert job.is_terminal
def test_is_retryable_failed_within_limit(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
job.mark_processing()
job.mark_failed("err")
assert job.is_retryable
def test_is_retryable_failed_at_limit(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=2)
job.mark_processing()
job.mark_failed("err")
job.retry_count = 2
assert not job.is_retryable
def test_is_retryable_pending_false(self):
job = TTSJob.create(user_id="u1", input_text="hi")
assert not job.is_retryable
def test_is_completed_needs_url(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.status = TTSJobStatus.COMPLETED
job.output_audio_url = ""
assert not job.is_completed
job.output_audio_url = "http://x.mp3"
assert job.is_completed
# ============================================================
# TTSJob 状态转换测试
# ============================================================
class TestTTSJobTransitions:
def test_pending_to_processing(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
assert job.status == TTSJobStatus.PROCESSING
def test_pending_to_failed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.FAILED)
assert job.status == TTSJobStatus.FAILED
def test_pending_to_cancelled(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.CANCELLED)
assert job.status == TTSJobStatus.CANCELLED
def test_pending_to_completed_invalid(self):
job = TTSJob.create(user_id="u1", input_text="hi")
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(TTSJobStatus.COMPLETED)
def test_processing_to_completed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.COMPLETED)
assert job.status == TTSJobStatus.COMPLETED
def test_processing_to_failed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.FAILED)
assert job.status == TTSJobStatus.FAILED
def test_failed_to_pending_retry(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.FAILED)
job.retry_count = 0
job.transition_to(TTSJobStatus.PENDING)
assert job.status == TTSJobStatus.PENDING
def test_completed_to_anything_invalid(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.COMPLETED)
with pytest.raises(ValueError):
job.transition_to(TTSJobStatus.PENDING)
with pytest.raises(ValueError):
job.transition_to(TTSJobStatus.FAILED)
def test_transition_to_accepts_string(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to("processing")
assert job.status == TTSJobStatus.PROCESSING
def test_transition_to_invalid_string_raises(self):
job = TTSJob.create(user_id="u1", input_text="hi")
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("invalid_state")
def test_transition_updates_updated_at(self):
job = TTSJob.create(user_id="u1", input_text="hi")
before = job.updated_at
import time
time.sleep(0.001)
job.transition_to(TTSJobStatus.PROCESSING)
assert job.updated_at >= before
# ============================================================
# TTSJob 业务方法测试
# ============================================================
class TestTTSJobBusinessMethods:
def test_mark_processing_sets_started_at(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
assert job.started_at is not None
assert job.status == TTSJobStatus.PROCESSING
assert job.error_message == ""
def test_mark_processing_clears_error(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.error_message = "old error"
job.mark_processing()
assert job.error_message == ""
def test_mark_completed_sets_fields(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed(
"http://out.mp3",
output_audio_key="oss://key",
duration=10.5,
file_size=102400,
)
assert job.status == TTSJobStatus.COMPLETED
assert job.output_audio_url == "http://out.mp3"
assert job.output_audio_key == "oss://key"
assert job.duration == 10.5
assert job.file_size == 102400
assert job.completed_at is not None
assert job.is_completed
def test_mark_completed_empty_url_raises(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
with pytest.raises(ValueError, match="output_audio_url 不能为空"):
job.mark_completed(" ")
def test_mark_failed_sets_error(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_failed("网络超时")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "网络超时"
def test_mark_cancelled_from_pending(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_mark_cancelled_from_processing(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_prepare_retry_success(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
job.mark_processing()
job.mark_failed("err")
assert job.retry_count == 0
job.prepare_retry()
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 1
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
def test_prepare_retry_multiple_times(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
for i in range(3):
job.mark_processing()
job.mark_failed(f"err{i}")
job.prepare_retry()
assert job.retry_count == i + 1
assert job.status == TTSJobStatus.PENDING
def test_prepare_retry_exceed_max_raises(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=2)
job.mark_processing()
job.mark_failed("err1")
job.prepare_retry()
job.mark_processing()
job.mark_failed("err2")
job.prepare_retry()
# 第3次失败后 retry_count=2 == max_retries=2,不可重试
job.mark_processing()
job.mark_failed("err3")
assert not job.is_retryable
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_prepare_retry_pending_raises(self):
job = TTSJob.create(user_id="u1", input_text="hi")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_to_dict_contains_all_fields(self):
job = TTSJob.create(
user_id="u1",
input_text="hi",
voice_id="v1",
project_id="p1",
metadata={"k": "v"},
)
d = job.to_dict()
expected_keys = {
"id",
"user_id",
"project_id",
"voice_clone_profile_id",
"status",
"input_text",
"voice_id",
"voice_model",
"output_audio_url",
"output_audio_key",
"duration",
"file_size",
"sample_rate",
"format",
"error_message",
"retry_count",
"max_retries",
"is_retryable",
"is_completed",
"metadata",
"started_at",
"completed_at",
"created_at",
"updated_at",
}
assert set(d.keys()) == expected_keys
assert d["status"] == "pending"
assert d["is_retryable"] is False
assert d["is_completed"] is False
assert d["metadata"] == {"k": "v"}
# 时间字段应为 ISO 字符串或 None
assert isinstance(d["created_at"], str)
assert d["started_at"] is None
def test_to_dict_completed_state(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed("http://out.mp3")
d = job.to_dict()
assert d["status"] == "completed"
assert d["is_completed"] is True
assert d["started_at"] is not None
assert d["completed_at"] is not None
# ============================================================
# Job.create 测试
# ============================================================
class TestJobCreate:
def test_create_minimal(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert job.id
assert job.project_id == "p1"
assert job.job_type == JobType.VIDEO_COMPOSE
assert job.status == JobStatus.PENDING
assert job.progress == 0.0
assert job.retry_count == 0
assert job.max_retries == 3
assert job.payload == {}
assert job.result == {}
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
def test_create_string_job_type(self):
job = Job.create(project_id="p1", job_type="video_compose")
assert job.job_type == JobType.VIDEO_COMPOSE
def test_create_invalid_job_type_string_raises(self):
with pytest.raises(ValueError, match="不支持的任务类型"):
Job.create(project_id="p1", job_type="unknown_type")
def test_create_strips_project_id(self):
job = Job.create(project_id=" p1 ", job_type=JobType.VIDEO_COMPOSE)
assert job.project_id == "p1"
def test_create_full_params(self):
job = Job.create(
project_id="p1",
job_type=JobType.RENDER_EDIT_PLAN,
payload={"edit_plan_id": "ep1"},
source_id="src_001",
created_by_user_id="u1",
max_retries=5,
)
assert job.job_type == JobType.RENDER_EDIT_PLAN
assert job.payload == {"edit_plan_id": "ep1"}
assert job.source_id == "src_001"
assert job.created_by_user_id == "u1"
assert job.max_retries == 5
def test_create_all_job_types(self):
for jt in JobType:
job = Job.create(project_id="p1", job_type=jt)
assert job.job_type == jt
def test_create_id_unique(self):
j1 = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
j2 = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert j1.id != j2.id
def test_create_strips_source_and_user(self):
job = Job.create(
project_id="p1",
job_type=JobType.VIDEO_COMPOSE,
source_id=" s1 ",
created_by_user_id=" u1 ",
)
assert job.source_id == "s1"
assert job.created_by_user_id == "u1"
# ============================================================
# Job 状态属性测试
# ============================================================
class TestJobStatusProperties:
def test_is_terminal_pending_false(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert not job.is_terminal
def test_is_terminal_running_false(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
assert not job.is_terminal
def test_is_terminal_success_true(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert job.is_terminal
def test_is_terminal_failed_true(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_failed("err")
assert job.is_terminal
def test_is_terminal_cancelled_true(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_cancelled()
assert job.is_terminal
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("err")
assert job.is_retryable
def test_is_retryable_failed_at_limit(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=2)
job.mark_running()
job.mark_failed("err")
job.retry_count = 2
assert not job.is_retryable
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 not job.is_retryable
# ============================================================
# Job 状态转换测试
# ============================================================
class TestJobTransitions:
def test_pending_to_running(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.status == JobStatus.RUNNING
def test_pending_to_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
def test_pending_to_cancelled(self):
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):
"""pending 不能直接到 failed,必须经过 running 或直接 success/cancelled。"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(JobStatus.FAILED)
def test_running_to_success(self):
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
def test_running_to_failed(self):
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
def test_running_to_cancelled(self):
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_failed_to_pending_retry(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
job.transition_to(JobStatus.PENDING)
assert job.status == JobStatus.PENDING
def test_success_to_pending_invalid(self):
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.PENDING)
def test_transition_to_accepts_string(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to("running")
assert job.status == JobStatus.RUNNING
def test_transition_to_invalid_string_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("bogus")
def test_transition_running_sets_started_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert job.started_at is None
job.transition_to(JobStatus.RUNNING)
assert job.started_at is not None
def test_transition_success_sets_completed_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.completed_at is None
job.transition_to(JobStatus.SUCCESS)
assert job.completed_at is not None
def test_transition_failed_sets_completed_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.completed_at is not None
def test_started_at_not_overwritten_on_second_running_transition(self):
"""通过 transition_to 再次到 RUNNING 时,如果 started_at 已有值不覆盖。"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
first = job.started_at
# 走个 retry 流程再回来
job.transition_to(JobStatus.FAILED)
job.transition_to(JobStatus.PENDING)
job.started_at = None # prepare_retry 会清掉,这里模拟
job.transition_to(JobStatus.RUNNING)
second = job.started_at
# started_at 被重置后应该重新设置
assert second is not None
# 时间可能相同(精度问题),但逻辑上第二次 running 会重新设置
assert isinstance(second, datetime)
# ============================================================
# Job 业务方法测试
# ============================================================
class TestJobBusinessMethods:
def test_mark_running_with_stage(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_without_stage(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.current_stage = "之前"
job.mark_running()
assert job.status == JobStatus.RUNNING
assert job.current_stage == "之前" # 不传 stage 不修改
def test_mark_success_with_result(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success({"output_url": "http://x.mp4"})
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
assert job.current_stage == "完成"
assert job.result == {"output_url": "http://x.mp4"}
def test_mark_success_without_result(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
assert job.result == {}
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 == "失败"
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_update_progress_normal(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_boundary_zero(self):
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_boundary_hundred(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_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_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(100.1)
def test_update_progress_updates_updated_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
before = job.updated_at
import time
time.sleep(0.001)
job.update_progress(30.0)
assert job.updated_at >= before
def test_prepare_retry_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running("阶段1")
job.mark_failed("错误1")
job.celery_task_id = "celery-123"
job.prepare_retry()
assert job.status == JobStatus.PENDING
assert job.retry_count == 1
assert job.progress == 0.0
assert "第 1 次重试" in job.current_stage
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
assert job.celery_task_id == ""
def test_prepare_retry_multiple_times(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
for i in range(3):
job.mark_running()
job.mark_failed(f"err{i}")
job.prepare_retry()
assert job.retry_count == i + 1
assert job.status == JobStatus.PENDING
def test_prepare_retry_exceed_max_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=2)
job.mark_running()
job.mark_failed("err1")
job.prepare_retry()
job.mark_running()
job.mark_failed("err2")
job.prepare_retry()
job.mark_running()
job.mark_failed("err3")
assert not job.is_retryable
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_prepare_retry_pending_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_to_dict_contains_all_fields(self):
job = Job.create(
project_id="p1",
job_type=JobType.VIDEO_COMPOSE,
payload={"k": "v"},
source_id="s1",
created_by_user_id="u1",
)
d = job.to_dict()
expected_keys = {
"id",
"project_id",
"job_type",
"status",
"progress",
"current_stage",
"payload",
"result",
"error_message",
"retry_count",
"max_retries",
"celery_task_id",
"source_id",
"created_by_user_id",
"is_retryable",
"started_at",
"completed_at",
"created_at",
"updated_at",
}
assert set(d.keys()) == expected_keys
assert d["status"] == "pending"
assert d["job_type"] == "video_compose"
assert d["progress"] == 0.0
assert d["payload"] == {"k": "v"}
assert d["is_retryable"] is False
assert d["started_at"] is None
assert isinstance(d["created_at"], str)
def test_to_dict_success_state(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success({"output": "ok"})
d = job.to_dict()
assert d["status"] == "success"
assert d["progress"] == 100.0
assert d["result"] == {"output": "ok"}
assert d["started_at"] is not None
assert d["completed_at"] is not None
-466
View File
@@ -1,466 +0,0 @@
"""第77波:GenerationTask 领域模型纯逻辑单测。"""
import json
import pytest
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ============================================================
# GenerationTaskStatus._missing_ 兼容枚举测试
# ============================================================
class TestGenerationTaskStatusMissing:
def test_completed_aliases(self):
for val in ("done", "success", "finished", "complete", "completed"):
assert GenerationTaskStatus(val) == GenerationTaskStatus.COMPLETED
assert GenerationTaskStatus(val.upper()) == GenerationTaskStatus.COMPLETED
def test_failed_aliases(self):
for val in ("fail", "failed", "error", "err"):
assert GenerationTaskStatus(val) == GenerationTaskStatus.FAILED
def test_running_aliases(self):
for val in ("process", "processing", "run", "running", "in_progress"):
assert GenerationTaskStatus(val) == GenerationTaskStatus.RUNNING
def test_cancelled_aliases(self):
for val in ("cancel", "cancelled", "canceled"):
assert GenerationTaskStatus(val) == GenerationTaskStatus.CANCELLED
def test_unknown_defaults_to_pending(self):
assert GenerationTaskStatus("unknown_state") == GenerationTaskStatus.PENDING
assert GenerationTaskStatus("") == GenerationTaskStatus.PENDING
def test_whitespace_and_case_insensitive(self):
assert GenerationTaskStatus(" DONE ") == GenerationTaskStatus.COMPLETED
assert GenerationTaskStatus("Failed") == GenerationTaskStatus.FAILED
def test_non_string_falls_back_to_pending(self):
assert GenerationTaskStatus(None) == GenerationTaskStatus.PENDING
assert GenerationTaskStatus(123) == GenerationTaskStatus.PENDING
def test_normal_values_still_work(self):
assert GenerationTaskStatus("pending") == GenerationTaskStatus.PENDING
assert GenerationTaskStatus("running") == GenerationTaskStatus.RUNNING
assert GenerationTaskStatus("completed") == GenerationTaskStatus.COMPLETED
assert GenerationTaskStatus("failed") == GenerationTaskStatus.FAILED
assert GenerationTaskStatus("cancelled") == GenerationTaskStatus.CANCELLED
# ============================================================
# GenerationTask.create 测试
# ============================================================
class TestGenerationTaskCreate:
def test_create_minimal(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert task.id
assert task.project_id == "p1"
assert task.asset_library_id == "lib1"
assert task.status == GenerationTaskStatus.PENDING
assert task.progress == 0.0
assert task.result_count == 0
assert task.retry_count == 0
assert task.asset_ids == []
assert task.title_ids == []
assert task.voice_ids == []
assert task.bgm_config == {}
assert task.logs == "[]"
def test_create_requires_project_or_template(self):
"""project_id 和 template_id 至少需要一个。"""
with pytest.raises(ValueError, match="至少需要提供一个"):
GenerationTask.create(project_id="", asset_library_id="lib1", template_id="")
def test_create_template_id_only(self):
task = GenerationTask.create(project_id="", template_id="tpl1", asset_library_id="lib1")
assert task.template_id == "tpl1"
assert task.project_id == ""
def test_create_requires_library_or_assets(self):
"""asset_library_id 和 asset_ids/title_ids/voice_ids 至少需要一个。"""
with pytest.raises(ValueError, match="至少需要提供一个"):
GenerationTask.create(
project_id="p1",
asset_library_id="",
asset_ids=None,
title_ids=None,
voice_ids=None,
)
def test_create_asset_ids_only(self):
task = GenerationTask.create(project_id="p1", asset_library_id="", asset_ids=["a1", "a2"])
assert task.asset_ids == ["a1", "a2"]
assert task.asset_library_id == ""
def test_create_title_ids_only(self):
task = GenerationTask.create(project_id="p1", asset_library_id="", title_ids=["t1"])
assert task.title_ids == ["t1"]
def test_create_voice_ids_only(self):
task = GenerationTask.create(project_id="p1", asset_library_id="", voice_ids=["v1"])
assert task.voice_ids == ["v1"]
def test_create_full_params(self):
task = GenerationTask.create(
project_id="p1",
asset_library_id="lib1",
strategy_id="s1",
voice_library_id="vlib1",
template_id="tpl1",
asset_ids=["a1", "a2"],
title_ids=["t1"],
voice_ids=["v1"],
created_by_user_id="u1",
source_edit_plan_id="ep1",
asset_select_mode="random",
batch_id="batch_001",
video_title="测试视频",
resolution="1080p",
bgm_config={"volume": 0.5},
auto_retry_enabled=True,
auto_retry_max=3,
)
assert task.strategy_id == "s1"
assert task.voice_library_id == "vlib1"
assert task.template_id == "tpl1"
assert task.asset_ids == ["a1", "a2"]
assert task.title_ids == ["t1"]
assert task.voice_ids == ["v1"]
assert task.created_by_user_id == "u1"
assert task.source_edit_plan_id == "ep1"
assert task.asset_select_mode == "random"
assert task.batch_id == "batch_001"
assert task.video_title == "测试视频"
assert task.resolution == "1080p"
assert task.bgm_config == {"volume": 0.5}
assert task.auto_retry_enabled is True
assert task.auto_retry_max == 3
def test_create_strips_string_fields(self):
task = GenerationTask.create(
project_id=" p1 ",
asset_library_id=" lib1 ",
strategy_id=" s1 ",
template_id=" tpl1 ",
created_by_user_id=" u1 ",
video_title=" 测试 ",
resolution=" 1080p ",
)
assert task.project_id == "p1"
assert task.asset_library_id == "lib1"
assert task.strategy_id == "s1"
assert task.template_id == "tpl1"
assert task.created_by_user_id == "u1"
assert task.video_title == "测试"
assert task.resolution == "1080p"
def test_create_asset_ids_is_copy(self):
ids = ["a1", "a2"]
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=ids)
ids.append("a3")
assert task.asset_ids == ["a1", "a2"]
def test_create_bgm_config_is_copy(self):
cfg = {"vol": 0.5}
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", bgm_config=cfg)
cfg["vol"] = 0.8
assert task.bgm_config == {"vol": 0.5}
def test_create_id_unique(self):
t1 = GenerationTask.create(project_id="p1", asset_library_id="lib1")
t2 = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert t1.id != t2.id
# ============================================================
# GenerationTask 状态查询测试
# ============================================================
class TestGenerationTaskStatusQuery:
def test_is_terminal_pending_false(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert not task.is_terminal
def test_is_terminal_running_false(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
assert not task.is_terminal
def test_is_terminal_completed_true(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_completed()
assert task.is_terminal
def test_is_terminal_failed_true(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_failed("err")
assert task.is_terminal
def test_is_terminal_cancelled_true(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_cancelled()
assert task.is_terminal
def test_is_completed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert not task.is_completed
task.mark_processing()
task.mark_completed()
assert task.is_completed
def test_is_failed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert not task.is_failed
task.mark_processing()
task.mark_failed("err")
assert task.is_failed
def test_is_running(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert not task.is_running
task.mark_processing()
assert task.is_running
# ============================================================
# GenerationTask 状态转换测试
# ============================================================
class TestGenerationTaskTransitions:
def test_pending_to_running(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
assert task.status == GenerationTaskStatus.RUNNING
def test_pending_to_failed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.FAILED)
assert task.status == GenerationTaskStatus.FAILED
def test_pending_to_cancelled(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.CANCELLED)
assert task.status == GenerationTaskStatus.CANCELLED
def test_pending_to_completed_invalid(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
with pytest.raises(ValueError, match="非法状态转换"):
task.transition_to(GenerationTaskStatus.COMPLETED)
def test_running_to_completed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.COMPLETED)
assert task.status == GenerationTaskStatus.COMPLETED
def test_running_to_failed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.FAILED)
assert task.status == GenerationTaskStatus.FAILED
def test_running_to_cancelled(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.CANCELLED)
assert task.status == GenerationTaskStatus.CANCELLED
def test_failed_to_pending(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.FAILED)
task.transition_to(GenerationTaskStatus.PENDING)
assert task.status == GenerationTaskStatus.PENDING
def test_completed_to_pending_invalid(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.COMPLETED)
with pytest.raises(ValueError):
task.transition_to(GenerationTaskStatus.PENDING)
def test_transition_to_accepts_string(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to("running")
assert task.status == GenerationTaskStatus.RUNNING
def test_transition_to_unknown_string_defaults_pending(self):
"""_missing_ 兜底:未知字符串映射为 PENDING,再走状态机校验。"""
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
# 当前已经是 pendingpending→pending 不合法
with pytest.raises(ValueError, match="非法状态转换"):
task.transition_to("bogus_status")
# ============================================================
# GenerationTask 业务方法测试
# ============================================================
class TestGenerationTaskBusinessMethods:
def test_mark_processing_sets_started_at(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert task.started_at is None
task.mark_processing()
assert task.started_at is not None
assert task.status == GenerationTaskStatus.RUNNING
def test_mark_processing_clears_error(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.error_message = "old error"
task.mark_processing()
assert task.error_message == ""
def test_mark_completed_default_count(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_completed()
assert task.status == GenerationTaskStatus.COMPLETED
assert task.progress == 100.0
assert task.result_count == 1
assert task.completed_at is not None
assert task.error_message == ""
def test_mark_completed_custom_count(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_completed(result_count=5)
assert task.result_count == 5
def test_mark_failed_with_message(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_failed("网络超时")
assert task.status == GenerationTaskStatus.FAILED
assert task.error_message == "网络超时"
assert task.completed_at is not None
def test_mark_failed_with_error_info(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
info = {"error_type": "NetworkError", "stage": "download"}
task.mark_failed("超时", error_info=info)
assert task.error_info == info
def test_mark_failed_default_error_info(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_failed("未知错误")
assert "error_type" in task.error_info
assert task.error_info["message"] == "未知错误"
assert "failed_at" in task.error_info
def test_mark_cancelled_from_pending(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
assert task.completed_at is not None
def test_mark_cancelled_from_running(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
def test_mark_pending_from_failed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_failed("err")
task.mark_pending_from_failed()
assert task.status == GenerationTaskStatus.PENDING
assert task.error_message == ""
assert task.error_info == {}
assert task.started_at is None
assert task.completed_at is None
assert task.progress == 0.0
assert task.result_count == 0
assert task.retry_count == 1
def test_mark_pending_from_failed_multiple_retries(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
for i in range(3):
task.mark_processing()
task.mark_failed(f"err{i}")
task.mark_pending_from_failed()
assert task.retry_count == i + 1
assert task.status == GenerationTaskStatus.PENDING
def test_mark_pending_from_failed_wrong_status_raises(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
with pytest.raises(ValueError, match="只有 failed 状态"):
task.mark_pending_from_failed()
# ============================================================
# GenerationTask 日志系统测试
# ============================================================
class TestGenerationTaskLogs:
def test_append_log_basic(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.append_log("初始化", "任务创建成功")
logs = task.get_logs()
assert len(logs) == 1
assert logs[0]["stage"] == "初始化"
assert logs[0]["message"] == "任务创建成功"
assert logs[0]["level"] == "INFO"
assert "ts" in logs[0]
def test_append_log_with_level(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.append_log("渲染", "渲染失败", level="ERROR")
logs = task.get_logs()
assert logs[0]["level"] == "ERROR"
def test_append_log_with_extra_fields(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.append_log("下载", "下载完成", asset_id="a1", duration=10.5)
logs = task.get_logs()
assert logs[0]["asset_id"] == "a1"
assert logs[0]["duration"] == 10.5
def test_append_multiple_logs(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
for i in range(5):
task.append_log(f"阶段{i}", f"消息{i}")
logs = task.get_logs()
assert len(logs) == 5
assert logs[0]["stage"] == "阶段0"
assert logs[4]["stage"] == "阶段4"
def test_get_logs_empty(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert task.get_logs() == []
def test_get_logs_corrupted_json(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.logs = "not valid json"
assert task.get_logs() == []
def test_append_log_respects_max_limit(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
# _MAX_LOGS = 200,超过后保留最新的 200 条
for i in range(250):
task.append_log("阶段", f"消息{i}")
logs = task.get_logs()
assert len(logs) == 200
assert logs[0]["message"] == "消息50" # 前50条被截掉
assert logs[-1]["message"] == "消息249"
def test_logs_persisted_as_json_string(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.append_log("阶段", "消息")
assert isinstance(task.logs, str)
# 可以被 json.loads 解析
parsed = json.loads(task.logs)
assert isinstance(parsed, list)
assert len(parsed) == 1
@@ -1,742 +0,0 @@
"""第78波:Duplication 查重领域模型纯逻辑单测。"""
import pytest
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
# ============================================================
# DuplicateSegment.create 测试
# ============================================================
class TestDuplicateSegmentCreate:
def test_create_basic(self):
seg = DuplicateSegment.create(
source_start=10.0,
source_end=20.0,
matched_video_id="mv1",
matched_video_name="匹配视频",
matched_start=5.0,
matched_end=15.0,
similarity=85.5,
)
assert seg.id
assert seg.source_start == 10.0
assert seg.source_end == 20.0
assert seg.matched_video_id == "mv1"
assert seg.matched_video_name == "匹配视频"
assert seg.matched_start == 5.0
assert seg.matched_end == 15.0
assert seg.similarity == 85.5
def test_create_invalid_source_start_negative(self):
with pytest.raises(ValueError, match="invalid source segment range"):
DuplicateSegment.create(
source_start=-1.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=10.0,
similarity=50.0,
)
def test_create_invalid_source_end_lte_start(self):
with pytest.raises(ValueError, match="invalid source segment range"):
DuplicateSegment.create(
source_start=20.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=10.0,
similarity=50.0,
)
def test_create_source_end_equal_start_invalid(self):
with pytest.raises(ValueError, match="invalid source segment range"):
DuplicateSegment.create(
source_start=10.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=10.0,
similarity=50.0,
)
def test_create_invalid_matched_start_negative(self):
with pytest.raises(ValueError, match="invalid matched segment range"):
DuplicateSegment.create(
source_start=0.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=-1.0,
matched_end=10.0,
similarity=50.0,
)
def test_create_invalid_matched_end_lte_start(self):
with pytest.raises(ValueError, match="invalid matched segment range"):
DuplicateSegment.create(
source_start=0.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=15.0,
matched_end=5.0,
similarity=50.0,
)
def test_create_similarity_zero(self):
seg = DuplicateSegment.create(
source_start=0.0,
source_end=5.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=0.0,
)
assert seg.similarity == 0.0
def test_create_similarity_hundred(self):
seg = DuplicateSegment.create(
source_start=0.0,
source_end=5.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=100.0,
)
assert seg.similarity == 100.0
def test_create_similarity_negative_raises(self):
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
DuplicateSegment.create(
source_start=0.0,
source_end=5.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=-1.0,
)
def test_create_similarity_over_100_raises(self):
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
DuplicateSegment.create(
source_start=0.0,
source_end=5.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=101.0,
)
def test_create_id_unique(self):
s1 = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 50.0)
s2 = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 50.0)
assert s1.id != s2.id
# ============================================================
# DuplicationRecord.create 测试
# ============================================================
class TestDuplicationRecordCreate:
def test_create_minimal(self):
rec = DuplicationRecord.create(
user_id="u1",
filename="test.mp4",
file_size=102400,
storage_key="oss://key",
)
assert rec.id
assert rec.user_id == "u1"
assert rec.filename == "test.mp4"
assert rec.file_size == 102400
assert rec.storage_key == "oss://key"
assert rec.duration_seconds == 0.0
assert rec.status == "pending"
assert rec.duplicate_rate is None
assert rec.duplicate_count == 0
assert rec.segments == []
assert rec.error_message == ""
def test_create_with_duration(self):
rec = DuplicationRecord.create(
user_id="u1",
filename="test.mp4",
file_size=1024,
storage_key="oss://k",
duration_seconds=120.5,
)
assert rec.duration_seconds == 120.5
def test_create_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id cannot be empty"):
DuplicationRecord.create(
user_id=" ",
filename="t.mp4",
file_size=100,
storage_key="k",
)
def test_create_empty_filename_raises(self):
with pytest.raises(ValueError, match="filename cannot be empty"):
DuplicationRecord.create(
user_id="u1",
filename="",
file_size=100,
storage_key="k",
)
def test_create_zero_file_size_raises(self):
with pytest.raises(ValueError, match="file_size must be positive"):
DuplicationRecord.create(
user_id="u1",
filename="t.mp4",
file_size=0,
storage_key="k",
)
def test_create_negative_file_size_raises(self):
with pytest.raises(ValueError, match="file_size must be positive"):
DuplicationRecord.create(
user_id="u1",
filename="t.mp4",
file_size=-100,
storage_key="k",
)
def test_create_strips_whitespace(self):
rec = DuplicationRecord.create(
user_id=" u1 ",
filename=" test.mp4 ",
file_size=100,
storage_key=" oss://k ",
)
assert rec.user_id == "u1"
assert rec.filename == "test.mp4"
# storage_key 不确定有没有 strip,看源码是直接赋值
assert rec.storage_key == " oss://k "
def test_create_id_unique(self):
r1 = DuplicationRecord.create("u1", "a.mp4", 100, "k1")
r2 = DuplicationRecord.create("u1", "b.mp4", 200, "k2")
assert r1.id != r2.id
def test_create_default_segments_empty_list(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
assert rec.segments == []
assert isinstance(rec.segments, list)
# ============================================================
# DuplicationRecord 状态流转测试
# ============================================================
class TestDuplicationRecordStatusFlow:
def test_mark_processing(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
before = rec.updated_at
rec.mark_processing()
assert rec.status == "processing"
assert rec.updated_at >= before
def test_mark_completed(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
seg = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 90.0)
rec.mark_completed(
duplicate_rate=35.5,
duplicate_count=1,
segments=[seg],
)
assert rec.status == "completed"
assert rec.duplicate_rate == 35.5
assert rec.duplicate_count == 1
assert len(rec.segments) == 1
assert rec.segments[0].id == seg.id
def test_mark_completed_zero_rate(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_completed(0.0, 0, [])
assert rec.status == "completed"
assert rec.duplicate_rate == 0.0
assert rec.duplicate_count == 0
assert rec.segments == []
def test_mark_completed_full_rate(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
seg = DuplicateSegment.create(0, 10, "m", "n", 0, 10, 100.0)
rec.mark_completed(100.0, 1, [seg])
assert rec.duplicate_rate == 100.0
def test_mark_completed_invalid_rate_negative(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
rec.mark_completed(-1.0, 0, [])
def test_mark_completed_invalid_rate_over_100(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
rec.mark_completed(100.1, 0, [])
def test_mark_failed(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_failed("网络超时")
assert rec.status == "failed"
assert rec.error_message == "网络超时"
def test_mark_failed_from_processing(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_processing()
rec.mark_failed("解析失败")
assert rec.status == "failed"
assert rec.error_message == "解析失败"
# ============================================================
# DuplicationRecord 重试机制测试
# ============================================================
class TestDuplicationRecordRetry:
def test_can_retry_failed_true(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_failed("err")
assert rec.can_retry() is True
def test_can_retry_pending_false(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
assert rec.can_retry() is False
def test_can_retry_processing_false(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_processing()
assert rec.can_retry() is False
def test_can_retry_completed_false(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_completed(10.0, 0, [])
assert rec.can_retry() is False
def test_reset_for_retry_clears_all(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
seg = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 80.0)
rec.mark_completed(30.0, 1, [seg])
# 先设为 failed 再 reset
rec.status = "failed"
rec.reset_for_retry()
assert rec.status == "pending"
assert rec.duplicate_rate is None
assert rec.duplicate_count == 0
assert rec.error_message == ""
assert rec.segments == []
assert rec.video_fingerprint is None
def test_reset_for_retry_updates_timestamp(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.status = "failed"
before = rec.updated_at
rec.reset_for_retry()
assert rec.updated_at >= before
def test_full_retry_flow(self):
"""完整的 创建→失败→重置→再处理→完成 流程。"""
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
assert rec.status == "pending"
rec.mark_processing()
assert rec.status == "processing"
rec.mark_failed("超时")
assert rec.can_retry()
rec.reset_for_retry()
assert rec.status == "pending"
assert rec.error_message == ""
rec.mark_processing()
rec.mark_completed(0.0, 0, [])
assert rec.status == "completed"
assert not rec.can_retry()
# ============================================================
# QuotaTier 测试
# ============================================================
class TestQuotaTier:
def test_get_limit_defined(self):
from packages.domain.quota import QUOTA_TIERS, QuotaTier
free = QUOTA_TIERS["free"]
assert free.get_limit("storage_gb") == 2
assert free.get_limit("videos_per_month") == 5
def test_get_limit_undefined_returns_zero(self):
from packages.domain.quota import QuotaTier
tier = QuotaTier(name="test", limits={"a": 10})
assert tier.get_limit("nonexistent") == 0
def test_is_unlimited_inf(self):
from packages.domain.quota import QUOTA_TIERS
premium = QUOTA_TIERS["premium"]
assert premium.is_unlimited("max_templates") is True
def test_is_unlimited_finite(self):
from packages.domain.quota import QUOTA_TIERS
free = QUOTA_TIERS["free"]
assert free.is_unlimited("storage_gb") is False
def test_is_unlimited_undefined_returns_true(self):
"""未定义的维度 limits.get 默认 infis_unlimited 返回 True。"""
from packages.domain.quota import QuotaTier
tier = QuotaTier(name="test", limits={})
assert tier.is_unlimited("unknown") is True
# ============================================================
# QUOTA_TIERS 三档套餐验证
# ============================================================
class TestQuotaTiers:
def test_free_tier_storage(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["free"].get_limit("storage_gb") == 2
def test_free_tier_no_ai_voice(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["free"].get_limit("ai_voice_enabled") == 0
def test_basic_tier_storage(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["basic"].get_limit("storage_gb") == 20
def test_basic_tier_has_ai_voice(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["basic"].get_limit("ai_voice_enabled") == 1
def test_basic_tier_ai_voice_credits(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["basic"].get_limit("ai_voice_credits") == 100
def test_premium_tier_storage(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["premium"].get_limit("storage_gb") == 100
def test_premium_tier_unlimited_templates(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["premium"].is_unlimited("max_templates")
def test_premium_tier_multi_platform(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["premium"].get_limit("multi_platform_enabled") == 1
def test_basic_no_multi_platform(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["basic"].get_limit("multi_platform_enabled") == 0
def test_all_tiers_exist(self):
from packages.domain.quota import QUOTA_TIERS
assert set(QUOTA_TIERS.keys()) == {"free", "basic", "premium"}
# ============================================================
# QuotaCheckResult 测试
# ============================================================
class TestQuotaCheckResult:
def test_usage_percent_normal(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=True,
dimension="storage_gb",
limit=100,
used=50,
remaining=50,
warning_level=QuotaWarningLevel.NORMAL,
)
assert r.usage_percent == 50.0
def test_usage_percent_capped_at_100(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=False,
dimension="x",
limit=10,
used=15,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert r.usage_percent == 100.0
def test_usage_percent_unlimited(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=True,
dimension="x",
limit=float("inf"),
used=999,
remaining=float("inf"),
warning_level=QuotaWarningLevel.NORMAL,
)
assert r.usage_percent == 0.0
def test_usage_percent_zero_limit_with_usage(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=False,
dimension="x",
limit=0,
used=5,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert r.usage_percent == 100.0
def test_usage_percent_zero_limit_no_usage(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=True,
dimension="x",
limit=0,
used=0,
remaining=0,
warning_level=QuotaWarningLevel.NORMAL,
)
assert r.usage_percent == 0.0
# ============================================================
# QuotaWarningLevel 计算测试
# ============================================================
class TestWarningLevel:
def test_normal_below_80(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(50, 100) == QuotaWarningLevel.NORMAL
def test_warning_at_80(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(80, 100) == QuotaWarningLevel.WARNING
def test_warning_below_95(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(90, 100) == QuotaWarningLevel.WARNING
def test_critical_at_95(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(95, 100) == QuotaWarningLevel.CRITICAL
def test_critical_below_100(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(99, 100) == QuotaWarningLevel.CRITICAL
def test_exceeded_at_100(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(100, 100) == QuotaWarningLevel.EXCEEDED
def test_exceeded_over_100(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(150, 100) == QuotaWarningLevel.EXCEEDED
def test_unlimited_always_normal(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(99999, float("inf")) == QuotaWarningLevel.NORMAL
def test_zero_limit_with_usage_is_exceeded(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(1, 0) == QuotaWarningLevel.EXCEEDED
def test_zero_limit_no_usage_is_normal(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(0, 0) == QuotaWarningLevel.NORMAL
# ============================================================
# QuotaChecker 测试
# ============================================================
class TestQuotaChecker:
def test_check_allowed(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("free", "storage_gb", 1)
assert r.allowed is True
assert r.limit == 2
assert r.used == 1
assert r.remaining == 1
assert r.warning_level == QuotaWarningLevel.NORMAL
def test_check_exceeded(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("free", "storage_gb", 5)
assert r.allowed is False
assert r.remaining == 0
assert r.warning_level == QuotaWarningLevel.EXCEEDED
def test_check_at_limit_not_allowed(self):
"""used == limit 时 allowed 为 False(严格小于才算允许)。"""
from packages.domain.quota import QuotaChecker
checker = QuotaChecker()
r = checker.check("free", "storage_gb", 2)
assert r.allowed is False
def test_check_unlimited_always_allowed(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("premium", "max_templates", 9999)
assert r.allowed is True
assert r.remaining == float("inf")
assert r.warning_level == QuotaWarningLevel.NORMAL
def test_check_unknown_plan_returns_zero(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("unknown_plan", "storage_gb", 1)
assert r.allowed is False
assert r.limit == 0
assert r.warning_level == QuotaWarningLevel.EXCEEDED
def test_check_warning_level_80_percent(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("basic", "storage_gb", 16) # 20 * 0.8 = 16
assert r.warning_level == QuotaWarningLevel.WARNING
def test_check_multiple(self):
from packages.domain.quota import QuotaChecker
checker = QuotaChecker()
results = checker.check_multiple(
"free",
{"storage_gb": 1, "videos_per_month": 10, "max_templates": 2},
)
assert len(results) == 3
dims = {r.dimension: r for r in results}
assert dims["storage_gb"].allowed is True
assert dims["videos_per_month"].allowed is False
assert dims["max_templates"].allowed is True
# ============================================================
# QuotaRegistry 测试
# ============================================================
class TestQuotaRegistry:
def test_list_dimensions_includes_builtin(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
dims = reg.list_dimensions()
assert "storage_gb" in dims
assert "videos_per_month" in dims
assert "ai_voice_enabled" in dims
def test_list_tiers(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert set(tiers) == {"free", "basic", "premium"}
def test_register_new_dimension(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
reg.register_dimension("custom_dim", "自定义维度", {"free": 5, "basic": 20})
dims = reg.list_dimensions()
assert "custom_dim" in dims
assert dims["custom_dim"] == "自定义维度"
assert reg.get_limit("free", "custom_dim") == 5
assert reg.get_limit("basic", "custom_dim") == 20
def test_register_dimension_idempotent(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
reg.register_dimension("custom_dim", "v1", {"free": 5})
reg.register_dimension("custom_dim", "v2", {"free": 10})
# 第二次应该被忽略(幂等),描述和限制都保持第一次
assert reg.list_dimensions()["custom_dim"] == "v1"
assert reg.get_limit("free", "custom_dim") == 5
def test_register_without_defaults_defaults_to_zero(self):
from packages.domain.quota import QuotaRegistry
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_get_tier_exists(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
tier = reg.get_tier("free")
assert tier is not None
assert tier.name == "free"
def test_get_tier_not_exists(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
assert reg.get_tier("nonexistent") is None
def test_global_registry_instance(self):
from packages.domain.quota import quota_checker, quota_registry
assert quota_registry is not None
assert quota_checker is not None
assert quota_registry.get_limit("free", "storage_gb") == 2