Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 1abab50582 fix: auto-format runs on merge commit to match Frontend Lint scope
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m2s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 3m24s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 4m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m7s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 4m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m30s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m22s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 42s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 16s
- ensure_git_repo() no longer checks out PR source branch
- stays on merge commit so prettier sees all files including develop's
- after formatting, cherry-picks the fix commit to source branch before push
- ensures auto-fix and Frontend Lint check the same file set
2026-08-14 16:52:23 +08:00
44 changed files with 727 additions and 5031 deletions
+1 -2
View File
@@ -1,2 +1 @@
CI trigger file - safe to delete
updated!
trigger: 1784009947
+2 -376
View File
@@ -172,250 +172,6 @@ jobs:
[ -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
validate-code-quality:
name: Validate - Code Quality
runs-on: ci-l2
timeout-minutes: 8
permissions:
contents: write
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: Install dependencies
shell: sh
run: |
set -eu
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1 && break
echo "pip install black/isort 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run code quality and security checks
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash scripts/ci/validate_code_quality.sh
- name: Auto-fix formatting (black + isort)
if: failure()
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 scripts/ci/auto_fix_formatting.py
- name: CI failure notification
if: failure()
shell: sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: |
set +e
FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- 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="Validate - Code Quality" 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
validate-type-check:
name: Validate - Type Check (mypy)
runs-on: ci-l2
timeout-minutes: 8
permissions:
contents: read
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: Install dependencies
shell: sh
run: |
set -eu
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run mypy type check
shell: bash
run: bash scripts/ci/validate_mypy.sh
- name: CI failure notification
if: failure()
shell: sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: |
set +e
FAILED_JOB="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- 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="Validate - Type Check (mypy)" 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
validate-migration:
name: Validate - Migration (alembic)
runs-on: ci-l2
timeout-minutes: 8
permissions:
contents: read
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
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: Install dependencies
shell: sh
run: |
set -eu
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run alembic migration validation
shell: bash
run: bash scripts/ci/validate_migration.sh
- name: CI failure notification
if: failure()
shell: sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: |
set +e
FAILED_JOB="Validate - Migration (alembic)" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- 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="Validate - Migration (alembic)" 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
unit-tests:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
@@ -631,11 +387,9 @@ jobs:
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run Vitest (incremental for PRs, full for main branches)
- name: Run Vitest with coverage
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash scripts/ci/vitest_incremental.sh
run: bash scripts/ci/step_frontend_run.sh "npx --no-install vitest run --coverage"
- name: Job duration summary
if: always()
shell: sh
@@ -662,125 +416,6 @@ jobs:
[ -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
build-pr:
name: PR Build ${{ matrix.service_display }} Image
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
if: github.event_name == 'pull_request'
strategy:
fail-fast: false
matrix:
include:
- service: api
service_display: API
dockerfile: infra/docker/api.Dockerfile
image_name: xiaoxia-saas-api
cache_name: api-cache
timeout: 30
- service: worker
service_display: Worker
dockerfile: infra/docker/worker.Dockerfile
image_name: xiaoxia-saas-worker
cache_name: worker-cache
timeout: 40
- service: web
service_display: Web
dockerfile: infra/docker/web.Dockerfile
image_name: xiaoxia-saas-web
cache_name: web-cache
timeout: 30
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: Docker login to Registry (for cache read)
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "Docker login attempt $i/3"
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "Docker login successful"
break
fi
echo "Docker login failed ($i/3), retrying in 5s..."
sleep 5
done
- name: Build PR image (verify only, no push)
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "PR Build attempt $i/3"
if bash scripts/ci/docker_build_only.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
echo "PR Build successful"
break
fi
echo "PR Build failed (attempt $i/3)"
[ $i -eq 3 ] && exit 1
sleep 10
if [ $i -eq 2 ]; then
NO_CACHE_FLAG="--no-cache"
echo "Next retry with --no-cache"
fi
done
echo
echo "${{ matrix.service_display }} PR build verified: ${IMAGE_TAG}"
- name: Cleanup buildx builder
if: always()
shell: sh
run: |
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
docker buildx prune -f 2>/dev/null || true
echo "Builder cleanup done"
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- 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="PR Build ${{ matrix.service_display }} Image" 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
build-staging:
name: Build Staging ${{ matrix.service_display }} Image
runs-on: runtime-builder
@@ -895,15 +530,6 @@ jobs:
echo
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
- name: Cleanup buildx builder
if: always()
shell: sh
run: |
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
docker buildx rm ci-builder 2>/dev/null || true
docker buildx prune -f 2>/dev/null || true
echo "Builder cleanup done"
- name: Job duration summary
if: always()
shell: sh
-3
View File
@@ -235,9 +235,6 @@ jobs:
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"CI/CD Pipeline / PR Build API Image (pull_request)"
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
"CI/CD Pipeline / PR Build Web Image (pull_request)"
)
echo "检查required门禁(与分支保护一致)"
fi
Executable → Regular
+4 -4
View File
@@ -503,7 +503,7 @@ async def send_verification_code(
request: SendVerificationCodeRequest,
) -> SendVerificationCodeResponse:
"""发送验证码(手机或邮箱)"""
from app.dependencies import get_db_session
from app.dependencies import get_db
from packages.adapters.sms.sms_service import get_sms_service
from packages.adapters.smtp import get_email_service
@@ -516,7 +516,7 @@ async def send_verification_code(
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db_session())
db = next(get_db())
repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=repo)
sms_service = get_sms_service()
@@ -549,7 +549,7 @@ async def bind_contact(
user_repository: UserRepository = Depends(get_user_repository),
) -> BindContactResponse:
"""绑定手机号和/或邮箱(需登录态)"""
from app.dependencies import get_db_session
from app.dependencies import get_db
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
SQLAlchemyVerificationCodeRepository,
@@ -560,7 +560,7 @@ async def bind_contact(
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db_session())
db = next(get_db())
vc_repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=vc_repo)
+1 -1
View File
@@ -109,7 +109,7 @@ describe("useAuth hooks", () => {
expect(localStorage.getItem("access_token")).toBe("access-123")
expect(localStorage.getItem("refresh_token")).toBe("refresh-456")
expect(mockSetAuth).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
expect(mockNavigate).toHaveBeenCalledWith("/")
})
it("没有 refresh_token 时从 localStorage 移除", async () => {
+20 -2
View File
@@ -2,9 +2,10 @@
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身 + cache mount加速)
# 优化项:
# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时
# 2. ffmpeg 通过 apt 安装(阿里云镜像加速,几秒完成,稳定可靠)
# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB)
# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存
# 4. pip cache mount:加速依赖下载(跨构建共享pip wheel缓存)
# 5. ffmpeg cache mount:避免每次重新下载静态编译包
# ============================================================
# ==================== Builder 阶段 ====================
@@ -20,8 +21,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
python3-dev \
binutils \
wget \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# ---- 下载静态编译 ffmpeg(带缓存,避免每次重新下载)----
RUN --mount=type=cache,target=/tmp/ffmpeg-cache,sharing=locked \
cd /tmp \
&& if [ ! -f /tmp/ffmpeg-cache/ffmpeg-release-amd64-static.tar.xz ]; then \
wget -q -O /tmp/ffmpeg-cache/ffmpeg-release-amd64-static.tar.xz \
https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz; \
fi \
&& tar xf /tmp/ffmpeg-cache/ffmpeg-release-amd64-static.tar.xz \
&& cp ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ffmpeg \
&& cp ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ffprobe \
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
&& rm -rf ffmpeg-*
# ---- 安装 Python 依赖 ----
WORKDIR /tmp
@@ -79,10 +94,13 @@ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debia
# 安装最小运行时依赖(opencv-python-headless 需要 libglib2.0-0
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# 从 builder 复制 ffmpeg 静态二进制
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
# 从 builder 复制 Python 虚拟环境
COPY --from=builder /opt/venv /opt/venv
@@ -8,10 +8,8 @@ from __future__ import annotations
import logging
import os
import time
import urllib.parse
from dataclasses import dataclass
from threading import Lock
from typing import Optional
from uuid import uuid4
@@ -19,39 +17,6 @@ import requests
logger = logging.getLogger(__name__)
STATE_TTL_SECONDS = 600 # state 有效期 10 分钟
class MemoryStateStore:
"""内存 state 存储(简单实现,单节点可用)
多实例部署时建议替换为 Redis 实现。
"""
def __init__(self, ttl_seconds: int = STATE_TTL_SECONDS):
self._ttl = ttl_seconds
self._states: dict[str, float] = {} # state -> expire_at
self._lock = Lock()
def put(self, state: str) -> None:
with self._lock:
self._clean_expired()
self._states[state] = time.time() + self._ttl
def verify_and_consume(self, state: str) -> bool:
with self._lock:
self._clean_expired()
if state in self._states:
del self._states[state]
return True
return False
def _clean_expired(self) -> None:
now = time.time()
expired = [s for s, exp in self._states.items() if exp < now]
for s in expired:
del self._states[s]
@dataclass
class WechatUserInfo:
@@ -76,8 +41,7 @@ class WechatOAuthService:
self.app_id = app_id or os.environ.get("WECHAT_OPEN_APP_ID", "")
self.app_secret = app_secret or os.environ.get("WECHAT_OPEN_APP_SECRET", "")
self.redirect_uri = redirect_uri or os.environ.get("WECHAT_OPEN_REDIRECT_URI", "")
# state 存储(CSRF 防护),默认内存实现
self._state_store = state_store or MemoryStateStore()
self._state_store = state_store # 可选:state 存储(Redis/内存),用于 CSRF 防护
def is_configured(self) -> bool:
"""检查微信配置是否完整"""
@@ -91,8 +55,6 @@ class WechatOAuthService:
(授权URL, state)
"""
state = uuid4().hex
# 保存 state 用于回调校验(防 CSRF)
self._state_store.put(state)
if not self.is_configured():
# 未配置时返回 mock URL,方便前端联调
@@ -130,11 +92,6 @@ class WechatOAuthService:
if not code:
return None, "缺少授权码"
# 校验 state(防 CSRF)—— 一次性使用
if not state or not self._state_store.verify_and_consume(state):
logger.warning("微信回调 state 校验失败: state=%s", state)
return None, "无效的 state 参数,请求可能已过期或被篡改"
if not self.is_configured():
# 开发模式:返回 mock 用户信息
logger.info("微信未配置,使用 mock 用户信息")
+1 -1
View File
@@ -3,7 +3,7 @@
# 数据库(基础层)
psycopg2-binary==2.9.10
psycopg[binary]==3.2.2
psycopg[binary]>=3.2.2
sqlalchemy==2.0.35
alembic==1.13.3
+1 -1
View File
@@ -11,4 +11,4 @@ pytest==8.3.3
pytest-asyncio==0.24.0
pytest-cov==6.0.0
pytest-timeout==2.3.1
diff-cover==8.0.3
diff-cover>=8.0
+3 -3
View File
@@ -2,13 +2,13 @@
# 这些包体积大,API 服务不需要安装
# 数值计算
numpy==1.26.4
numpy>=1.24.0
# 科学计算
scipy==1.13.1
scipy>=1.10.0
# 计算机视觉(视频去重、帧处理)
opencv-python-headless==4.10.0.84
opencv-python-headless>=4.8.0
# 图像处理
Pillow==10.4.0
-99
View File
@@ -1,99 +0,0 @@
#!/bin/bash
# Agent代码提交前自动格式化+质量检查脚本
# 用法: scripts/agent-commit.sh <commit_message> [files...]
# 效果: 自动跑black+isort+ruff check,通过后才commit+push
set -e
if [ $# -lt 1 ]; then
echo "用法: $0 <commit_message> [file1 file2 ...]"
echo "示例: $0 \"feat: add new api\" apps/api/src/"
exit 1
fi
COMMIT_MSG="$1"
shift
TARGETS="${@:-.}"
cd "$(dirname "$0")/.."
REPO_ROOT=$(pwd)
echo "仓库根目录: $REPO_ROOT"
echo "提交信息: $COMMIT_MSG"
echo "目标路径: $TARGETS"
echo ""
# 后端代码格式化(Python文件)
PYTHON_FILES=$(find $TARGETS -name "*.py" -type f 2>/dev/null | head -100 || true)
if [ -n "$PYTHON_FILES" ]; then
echo "=== Step 1/4: 后端代码格式化 (black) ==="
if command -v black &> /dev/null; then
black $TARGETS 2>&1 | tail -3
echo "✅ black 完成"
else
echo "⚠️ 未安装black,跳过"
fi
echo ""
echo "=== Step 2/4: import排序 (isort) ==="
if command -v isort &> /dev/null; then
isort $TARGETS 2>&1 | tail -3
echo "✅ isort 完成"
else
echo "⚠️ 未安装isort,跳过"
fi
echo ""
echo "=== Step 3/4: 代码质量检查 (ruff check) ==="
if command -v ruff &> /dev/null; then
RUFF_OUTPUT=$(ruff check $TARGETS 2>&1) || true
RUFF_ERRORS=$(echo "$RUFF_OUTPUT" | grep -c "^" || echo 0)
if [ "$RUFF_ERRORS" -le 2 ] || echo "$RUFF_OUTPUT" | grep -q "All checks passed"; then
echo "✅ ruff 检查通过(错误数: $RUFF_ERRORS"
else
echo "❌ ruff 发现以下问题:"
echo "$RUFF_OUTPUT" | head -30
echo ""
echo "请修复后重新提交,或手动忽略特定问题"
exit 1
fi
else
echo "⚠️ 未安装ruff,跳过"
fi
echo ""
else
echo "ℹ️ 未检测到Python文件,跳过后端格式化"
echo ""
fi
# 前端代码格式化(TS/TSX文件)
TS_FILES=$(find $TARGETS -name "*.ts" -o -name "*.tsx" -type f 2>/dev/null | head -100 || true)
if [ -n "$TS_FILES" ] && [ -f "apps/web/package.json" ]; then
echo "=== Step 4/4: 前端代码格式化 (prettier) ==="
if command -v npx &> /dev/null; then
cd apps/web && npx prettier --write "src/**/*.{ts,tsx}" 2>&1 | tail -3 || true
cd "$REPO_ROOT"
echo "✅ prettier 完成"
else
echo "⚠️ 未安装npx,跳过前端格式化"
fi
echo ""
fi
# Git操作
echo "=== 提交代码 ==="
git add -A
git diff --cached --stat
echo ""
git commit -m "$COMMIT_MSG"
echo ""
echo "✅ 本地提交完成"
# 可选:自动推送
if [ "$AGENT_AUTO_PUSH" = "true" ]; then
echo "正在推送到远程..."
git push
echo "✅ 推送完成"
else
echo "ℹ️ 本地已提交,如需推送执行: git push"
echo " 设置 AGENT_AUTO_PUSH=true 可自动推送"
fi
+13 -15
View File
@@ -31,22 +31,20 @@ def main():
print("pending")
return
# 筛选目标context,按时间倒序取最新的
matching = [s for s in statuses if s.get("context") == target_context]
if not matching:
# 找不到说明CI还没开始写状态,返回pending继续等待
print("pending")
return
# API返回按时间倒序,第一个就是最新的
for s in statuses:
if s.get("context") == target_context:
status = s.get("status", "pending")
# skipped 视为通过(条件跳过的任务不需要等)
if status == "skipped":
print("success")
else:
print(status)
return
# Gitea statuses API按时间正序返回,必须取最新的一条
latest = max(matching, key=lambda s: s.get("created_at", ""))
status = latest.get("status", "pending")
# skipped 视为通过(条件跳过的任务不需要等)
if status == "skipped":
print("success")
else:
print(status)
# 找不到这个context说明CI还没开始写状态,返回pending继续等待
# (如果workflow真的被跳过,它会有一条status为skipped的记录)
print("pending")
if __name__ == "__main__":
+34 -67
View File
@@ -25,51 +25,47 @@ def run(cmd, check=True, capture=True, cwd=None):
def ensure_git_repo(api_url, repo, token, pr_number):
"""确保当前目录是git仓库,并切换到PR源分支
"""确保当前目录是git仓库,保持在 merge commit 状态
checkout脚本用tarball方式下载代码(PR merge后的commit),没有.git目录。
这里自动初始化git仓库,fetch PR源分支并强制checkout
使工作区变为PR源分支的代码,确保后续格式化修复基于源分支
这里自动初始化git仓库,但不切换分支,保持在merge commit状态
使格式化检查与 Frontend Lint 的范围完全一致
"""
if os.path.exists(".git"):
return
print("检测到tarball checkout(无.git目录),自动初始化git仓库...")
print("保持在 merge commit 状态(与 Frontend Lint 一致)")
# 构造带认证的远端URL
server_url = api_url.rsplit("/api/v1", 1)[0]
remote_url = f"{server_url.replace('https://', f'https://x-access-token:{token}@')}/{repo}.git"
# 获取PR的源分支
# 获取PR的源分支(仅用于后续推送,不checkout
pr_api_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
req_obj = urllib.request.Request(pr_api_url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req_obj) as resp:
pr = json.loads(resp.read())
head_branch = pr["head"]["ref"]
print(f"PR源分支: {head_branch}")
print(f"PR源分支: {head_branch}(仅用于推送,不切换)")
# 初始化git
# 初始化git(不fetch/checkout,保持tarball内容即merge commit状态)
run("git init -q")
run(f"git remote add origin {remote_url}")
run('git config user.name "CI Bot"')
run('git config user.email "ci-bot@xiaoxiajianji.com"')
# fetch源分支(浅克隆,只要最新commit
print("fetch源分支...")
run(f"git fetch --depth=1 origin {head_branch}")
# 强制checkout到源分支(覆盖tarball内容)
# tarball是merge后的commit,源分支才是我们要修改并推送的目标
print("切换到源分支...")
run(f"git checkout -f -B {head_branch} FETCH_HEAD")
# 将当前目录(tarball解压的merge commit)作为初始commit
run("git add -A")
run('git commit -q -m "CI merge commit snapshot"')
result = run("git status --porcelain")
if result.stdout.strip():
n = len(result.stdout.strip().splitlines())
print(f"⚠️ 工作区有 {n} 个未追踪文件")
print(f"⚠️ 工作区有 {n} 个未追踪文件")
else:
print("✅ git仓库就绪,工作区clean")
print("✅ git仓库就绪merge commit 状态)")
return head_branch
@@ -168,28 +164,6 @@ def main():
return
api_url = os.environ.get("GITHUB_API_URL", "")
# 获取PR作者信息,判断是人还是Agent提交的
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req_pr) as resp:
pr_info = json.loads(resp.read())
pr_author = pr_info.get("user", {}).get("login", "")
print(f"PR作者: {pr_author}")
# 判断是否为Agent提交的PR
# Agent账号:actions, auto-approve-bot 等bot用户
# 人提交的PR(如xiaoxia):只诊断不自动修
agent_authors = {"actions", "auto-approve-bot", "gitea-actions"}
is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower()
if is_agent_pr:
print(f"检测到Agent提交的PR(作者: {pr_author}),将自动修复并推送")
fix_mode = "auto_fix_and_push"
else:
print(f"检测到人提交的PR(作者: {pr_author}),仅诊断不自动修改")
print("(如需自动修复,请用Agent账号提交PR,或手动运行格式化脚本)")
fix_mode = "diagnose_only"
repo = os.environ.get("GITHUB_REPOSITORY", "")
token = os.environ.get("GITHUB_TOKEN", "")
scan_mode = os.environ.get("SCAN_MODE", "full")
@@ -253,49 +227,44 @@ def main():
print("没有需要提交的格式改动")
return
# 诊断模式:只报告问题,不修改不推送
if fix_mode == "diagnose_only":
print()
print("=" * 50)
print("📋 格式问题诊断报告(人提交的PR,仅诊断不自动修复)")
print("=" * 50)
print()
print("以下文件存在格式问题,建议手动修复:")
for line in result.stdout.strip().split("\n"):
print(f" {line}")
print()
print("修复方式:")
print(" 后端(Python): 运行 black + isort")
print(" 前端: 运行 prettier --write")
print(" 或使用 scripts/agent-commit.sh 提交(自动格式化)")
print()
print("=" * 50)
# 以非0状态码退出,让CI继续报失败(因为问题没修)
sys.exit(1)
print()
print("变更文件:")
for line in result.stdout.strip().split("\n"):
print(f" {line}")
# 提交修复
# 提交修复(在 merge commit 状态)
run("git add -A")
run('git commit -m "style: auto-format with black + isort + prettier [ci skip]"')
run('git commit -m "style: auto-format with black + isort + prettier"')
# 推送(head_branch已从ensure_git_repo获取)
# 记录格式修复的 commit hash
format_commit = run("git rev-parse HEAD").stdout.strip()
# 推送:将格式化变更推回 PR 源分支
print(f"\nPR来源分支: {head_branch}")
# 切换到源分支,cherry-pick 格式化修复
print(f"fetch 源分支 {head_branch}...")
run(f"git fetch --depth=1 origin {head_branch}")
run(f"git checkout -f -B {head_branch} FETCH_HEAD")
print(f"cherry-pick 格式化修复到源分支...")
cherry_result = run(f"git cherry-pick {format_commit}", check=False)
if cherry_result.returncode != 0:
print(f"cherry-pick 冲突(源分支可能已有不同格式),中止cherry-pick")
run("git cherry-pick --abort", check=False)
print("没有需要推送的格式化改动")
return
# 推送(保留原有的重试逻辑)
print("推送格式修复到远端...")
# 推送前先 rebase 拉取远端最新,避免快进冲突
# 最多重试 3 次:rebase → push,失败则重新拉取再试
max_retries = 3
push_success = False
last_error = ""
for attempt in range(1, max_retries + 1):
print(f" 尝试 {attempt}/{max_retries}: 拉取最新代码并推送...")
print(f" 尝试 {attempt}/{max_retries}: 推送...")
# 先拉取远端最新 commit 并 rebase
fetch_result = run(f"git fetch origin {head_branch}", check=False)
if fetch_result.returncode != 0:
last_error = f"git fetch 失败: {fetch_result.stderr.strip()}"
@@ -308,10 +277,8 @@ def main():
last_error = f"git rebase 失败,中止并重置: {rebase_result.stderr.strip()[:200]}"
print(f" {last_error}")
run("git rebase --abort", check=False)
# rebase 失败通常是冲突,重试没用,直接跳出
break
# 推送
push_result = run(f'git push origin "HEAD:{head_branch}"', check=False)
if push_result.returncode == 0:
push_success = True
-84
View File
@@ -1,84 +0,0 @@
#!/bin/bash
# PR构建专用:只构建不推送,只读缓存不写,用于PR阶段验证Dockerfile
set -eu
NO_CACHE_FLAG=""
if [ "$1" = "--no-cache" ]; then
NO_CACHE_FLAG="--no-cache"
shift
fi
DOCKERFILE="$1"
IMAGE_TAG="$2"
CACHE_REF="$3"
shift 3
BUILD_ARGS=""
for arg in "$@"; do
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
done
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
else
docker buildx use "$BUILDER_NAME"
fi
docker buildx inspect --bootstrap
CACHE_NAME=$(echo "$CACHE_REF" | tr "/" "_" | tr ":" "-")
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
mkdir -p "$LOCAL_CACHE_DIR"
echo "=== PR Build: build only, no push, read-only cache ==="
echo "Dockerfile: ${DOCKERFILE}"
echo "Image tag: ${IMAGE_TAG}"
echo ""
build_with_retry() {
local attempt=1
local max_attempts=2
while [ $attempt -le $max_attempts ]; do
local build_output
local exit_code
set +e
build_output=$(docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--load \
. 2>&1)
exit_code=$?
set -e
if [ $exit_code -eq 0 ]; then
echo "$build_output"
return 0
fi
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
echo "$build_output"
echo "Local cache corrupted, cleaning and retrying ($attempt/$max_attempts)..."
rm -rf "${LOCAL_CACHE_DIR}"
mkdir -p "${LOCAL_CACHE_DIR}"
docker buildx prune -f -a >/dev/null 2>&1 || true
attempt=$((attempt + 1))
else
echo "$build_output"
return $exit_code
fi
done
echo "Local cache failed, building with registry cache only..."
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--load \
.
}
build_with_retry
echo ""
echo "PR build OK (not pushed): ${IMAGE_TAG}"
+6 -8
View File
@@ -1,5 +1,6 @@
#!/bin/bash
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache共享
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache兜底
# M-2优化:解决registry缓存导入慢(247s)和推送不稳定问题
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
set -eu
@@ -34,7 +35,7 @@ LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
mkdir -p "$LOCAL_CACHE_DIR"
# 缓存源:local优先(带自动修复),registry兜底读写
# 缓存源:local优先(带自动修复),registry兜底
# 本地缓存损坏时自动清理后重试,避免snapshot not found导致构建全挂
build_with_cache_retry() {
local attempt=1
@@ -47,9 +48,8 @@ build_with_cache_retry() {
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
--cache-from "type=registry,ref=${CACHE_REF}" \
--cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
@@ -81,16 +81,15 @@ build_with_cache_retry() {
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
--cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
.
}
echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ==="
echo "=== Step 1: Build & push image (local cache + registry read, with auto-repair) ==="
echo "Local cache: ${LOCAL_CACHE_DIR}"
echo "Registry cache: ${CACHE_REF}"
echo ""
@@ -100,7 +99,6 @@ build_with_cache_retry
echo ""
echo "Image pushed: ${IMAGE_TAG}"
echo "Local cache updated"
echo "Registry cache updated (if supported)"
echo ""
echo "Build completed: ${IMAGE_TAG}"
+6 -7
View File
@@ -1,25 +1,24 @@
#!/bin/sh
# CI 公共步骤:前端依赖安装(在 docker node 容器中运行)
# 优化:增加国内npm镜像源,加重试间隔
# 用法:step_frontend_install.sh [模式]
# 模式: full (默认) - 完整安装所有依赖
# vitest - 同full(保持接口兼容)
set -eu
MODE="${1:-full}"
echo "=== 前端依赖安装开始 (模式: $MODE) ==="
# npm国内镜像源(加速下载,减少网络失败
NPM_REGISTRY="https://registry.npmmirror.com"
# npm ci 带重试(网络不稳定时自动重试
for i in 1 2 3; do
echo "npm ci 尝试 $i/3 (镜像: $NPM_REGISTRY)"
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc "npm config set registry $NPM_REGISTRY && npm ci --no-audit --no-fund" && break
sh -lc "npm ci --no-audit --no-fund" && break
echo "npm ci 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 10
sleep 5
done
echo "=== 前端依赖安装完成 ==="
-186
View File
@@ -1,186 +0,0 @@
#!/bin/bash
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
set -eu
echo "=== CI Validate: 代码质量与安全扫描 ==="
# --- 密钥检测 ---
echo ""
echo "=== [1/6] Secret detection (detect-secrets) ==="
python3 -m pip install -q detect-secrets
detect-secrets --version
detect-secrets scan \
--all-files \
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
--disable-plugin Base64HighEntropyString \
--disable-plugin HexHighEntropyString \
--disable-plugin BasicAuthDetector \
--disable-plugin KeywordDetector \
--disable-plugin IPPublicDetector \
> /tmp/secrets-scan.json 2>&1
FOUND=$(python3 -c "
import json
try:
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
results = data.get('results', {})
total = sum(len(v) for v in results.values())
print(total)
except Exception:
print('error')
")
echo "Secrets detected: $FOUND"
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
echo ""
echo "=== Secret details ==="
python3 -c "
import json
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
for fpath, items in data.get('results', {}).items():
for item in items:
line = item.get('line_number', '?')
stype = item.get('type', '?')
hashed = item.get('hashed_secret', '')[:16]
print(f' {fpath}:{line} [{stype}] {hashed}...')
"
echo ""
echo "ERROR: Potential secrets detected in code!"
exit 1
fi
echo "✅ Secret scan passed"
# --- 增量/全量模式判断 ---
echo ""
echo "=== [2/6] Code quality checks ==="
SCAN_MODE="full"
CHANGED_PY_FILES=""
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
set +e
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
set -e
if [ "$HTTP_CODE" = "200" ]; then
CHANGED_PY_FILES=$(echo "$BODY" | python3 -c "
import json, sys
try:
files = json.load(sys.stdin)
py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed']
print(' '.join(py_files))
except Exception:
print('')
")
if [ -n "$CHANGED_PY_FILES" ]; then
SCAN_MODE="incremental"
echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed"
else
SCAN_MODE="skip_py"
echo "No Python files changed in this PR"
fi
else
echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan"
fi
else
echo "Full scan mode (not a PR event)"
fi
if [ "$SCAN_MODE" = "incremental" ]; then
# 防御性过滤
EXISTING_PY_FILES=""
for f in $CHANGED_PY_FILES; do
if [ -f "$f" ]; then
if [ -z "$EXISTING_PY_FILES" ]; then
EXISTING_PY_FILES="$f"
else
EXISTING_PY_FILES="$EXISTING_PY_FILES $f"
fi
fi
done
CHANGED_PY_FILES="$EXISTING_PY_FILES"
python3 -m compileall -q $CHANGED_PY_FILES
python3 -m black --check --fast $CHANGED_PY_FILES
python3 -m isort --check-only $CHANGED_PY_FILES
RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs)
if [ -n "$RUFF_FILES" ]; then
python3 -m ruff check $RUFF_FILES --statistics
else
echo "No ruff-checkable files changed, skipping"
fi
elif [ "$SCAN_MODE" = "skip_py" ]; then
echo "No Python files changed - skipping Python lint checks"
else
echo "Full scan mode"
python3 -m compileall -q alembic apps packages tests scripts
python3 -m black --check --fast alembic apps packages tests scripts
python3 -m isort --check-only alembic apps packages tests scripts
python3 -m ruff check apps packages tests --statistics
fi
echo "✅ Code quality checks passed"
# --- Bandit 安全扫描(仅告警) ---
echo ""
echo "=== [3/6] Security scan (bandit, advisory only) ==="
set +e
bandit -r apps packages -q -ll
BANDIT_EXIT=$?
set -e
if [ "$BANDIT_EXIT" -ne 0 ]; then
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
else
echo "✅ Bandit security scan passed"
fi
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
echo ""
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
python3 -m pip install -q pip-audit
pip-audit --version
EXIT_CODE=0
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
if [ -f "$req_file" ]; then
echo "--- Scanning $req_file ---"
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
echo ""
fi
done
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
# --- Vulture 死代码检测(仅告警) ---
echo ""
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
set +e
python3 -m pip install -q vulture
vulture --version
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
echo ""
vulture apps packages scripts \
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
--min-confidence 70 \
2>&1 | sort -t'(' -k2 -rn | head -80
echo ""
echo "=== vulture scan summary ==="
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
echo "建议:定期人工审查高置信度(>=90%)条目"
set -e
# --- Release 脚本语法校验 ---
echo ""
echo "=== [6/6] Release scripts syntax validation ==="
bash -n scripts/backup_postgres.sh
bash -n scripts/restore_postgres_plan.sh
bash -n scripts/init_production_env.sh
echo "✅ Release scripts syntax OK"
echo ""
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
-182
View File
@@ -1,182 +0,0 @@
#!/bin/bash
# CI Validate: Alembic迁移验证(并行Job 3/3
# 需要PostgreSQL数据库
set -eu
echo "=== CI Validate: Alembic迁移验证 ==="
# --- DooD模式检测:确定宿主机访问地址 ---
detect_docker_host() {
local test_port="${1:-5432}"
local candidates=()
# 1. host.docker.internal
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
candidates+=("host.docker.internal")
fi
# 2. docker0 桥接网关
candidates+=("172.17.0.1")
# 3. 默认网关
local gw=""
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
candidates+=("$gw")
fi
# 4. 宿主机同网段的.1或.254
local my_ip=""
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$my_ip" ]; then
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
candidates+=("${subnet}.1")
candidates+=("${subnet}.254")
fi
# 5. 127.0.0.1 最后尝试
candidates+=("127.0.0.1")
for candidate in "${candidates[@]}"; do
if python3 -c "
import socket
s = socket.socket()
s.settimeout(2)
try:
s.connect(('$candidate', $test_port))
s.close()
print('ok')
except:
pass
" 2>/dev/null | grep -q ok; then
echo "$candidate"
return 0
fi
done
echo "127.0.0.1"
return 1
}
# 获取宿主机IP
if [ -S /var/run/docker.sock ]; then
DOCKER_HOST_IP=$(detect_docker_host 5433)
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
DOCKER_HOST_IP=$(detect_docker_host 22)
fi
echo "检测到DooD模式,宿主机地址: $DOCKER_HOST_IP"
else
DOCKER_HOST_IP="127.0.0.1"
echo "非DooD模式,使用 127.0.0.1"
fi
PG_HOST="$DOCKER_HOST_IP"
echo "PG host: $PG_HOST"
# 指数退避TCP连接检查
wait_tcp_ready() {
local host="$1"
local port="$2"
local max_attempts="${3:-5}"
local delay=1
local attempt=1
while [ "$attempt" -le "$max_attempts" ]; do
if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
return 0
fi
echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..."
sleep "$delay"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
return 1
}
USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
if [ "$USE_SHARED_PG" = "true" ]; then
# 使用常驻共享PG实例
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true"
SHARED_PG_HOST="$PG_HOST"
SHARED_PG_PORT="5433"
SHARED_PG_USER="postgres"
SHARED_PG_PASSWORD="ci_pg_2026!"
CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
echo "等待共享PG连接就绪..."
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
echo "创建测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
cur.close()
conn.close()
"
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
# 执行迁移
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
echo "✅ Alembic migrations applied successfully"
# 清理数据库
echo "清理测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
cur.close()
conn.close()
" 2>/dev/null || echo "WARN: 数据库清理失败"
echo "✅ 共享PG数据库已清理"
else
# 使用临时PG容器(默认模式)
echo "使用临时PG容器模式"
PG_CONTAINER=ci-pg-validate-migration-${GITHUB_RUN_ID:-$$}
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
--shm-size=256m \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=xiaoxia_saas \
-P \
--health-cmd "pg_isready -U postgres" \
--health-interval 3s \
--health-timeout 3s \
--health-retries 20 \
postgres:16-alpine
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
echo "PostgreSQL port: $PG_PORT"
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas"
# 等待容器健康
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
echo "PostgreSQL container is healthy on port $PG_PORT"
break
fi
echo "Waiting for PostgreSQL container health... ($i/30)"
sleep 2
done
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
# TCP连通性检查
echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..."
wait_tcp_ready "$PG_HOST" "$PG_PORT" 5
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
# 执行迁移
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
echo "✅ Alembic migrations applied successfully"
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
fi
echo ""
echo "=== CI Validate: Alembic迁移验证 通过 ✅ ==="
-10
View File
@@ -1,10 +0,0 @@
#!/bin/bash
# CI Validate: Mypy类型检查(并行Job 2/3
set -eu
echo "=== CI Validate: Mypy类型检查 ==="
bash scripts/ci/mypy_check.sh
echo ""
echo "=== CI Validate: Mypy类型检查 通过 ✅ ==="
-78
View File
@@ -1,78 +0,0 @@
#!/bin/bash
# Vitest 增量执行脚本
# PR模式下只跑与改动文件相关的测试,大幅节省时间
# 用法: bash scripts/ci/vitest_incremental.sh
set -eu
cd apps/web
# 如果不是PR事件,直接全量跑
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
echo "非PR模式,全量执行Vitest"
npx --no-install vitest run --coverage
exit $?
fi
# 获取PR改动的文件列表
PR_NUMBER=$(echo "${GITHUB_REF:-}" | sed 's|refs/pull/||; s|/.*||')
if [ -z "$PR_NUMBER" ]; then
echo "无法获取PR编号,全量执行Vitest"
npx --no-install vitest run --coverage
exit $?
fi
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
import json, sys
try:
files = json.load(sys.stdin)
web_files = []
for f in files:
fname = f['filename']
# 只关注前端源码文件
if fname.startswith('apps/web/src/') and fname.endswith(('.ts', '.tsx', '.js', '.jsx')) and f['status'] != 'removed':
# 去掉apps/web/前缀,变成相对路径
web_files.append(fname.replace('apps/web/', ''))
print(' '.join(web_files))
except Exception as e:
print('')
")
if [ -z "$CHANGED_FILES" ]; then
echo "PR未改动前端源码文件,跳过Vitest"
echo "(如果配置了前端单测门禁,请确保至少有一个相关测试)"
exit 0
fi
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -w)
echo "PR改动了 $FILE_COUNT 个前端文件"
echo "改动文件: $CHANGED_FILES"
# 如果改动文件太多(超过30个),全量跑更可靠
if [ "$FILE_COUNT" -gt 30 ]; then
echo "改动文件较多(>$FILE_COUNT),降级为全量执行以确保覆盖"
npx --no-install vitest run --coverage
exit $?
fi
# 使用vitest --related 跑增量测试
echo ""
echo "=== 增量执行 Vitest(只跑相关测试)==="
echo "相关源文件: $CHANGED_FILES"
echo ""
set +e
npx --no-install vitest run --related $CHANGED_FILES
VITEST_EXIT=$?
set -e
if [ "$VITEST_EXIT" -eq 0 ]; then
echo ""
echo "✅ 增量测试通过"
echo "(仅覆盖与改动相关的测试用例)"
exit 0
else
echo ""
echo "❌ 增量测试失败"
exit $VITEST_EXIT
fi
+96 -154
View File
@@ -1,9 +1,22 @@
#!/bin/sh
# ===========================================
# Staging 部署脚本(SSH 模式,并行优化版
# Staging 部署脚本(SSH 模式,支持自动回滚
# ===========================================
# 通过 SSH 在 staging 服务器上执行
#
# 环境变量:
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
# REGISTRY_TOKEN - Registry 访问令牌
# REGISTRY - Registry 地址(默认 xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji
# REGISTRY_USER - Registry 用户名(默认 xiaoxia
# ENV_FILE - 环境变量文件路径
# GENERATED_DIR - 生成文件目录
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false
# SKIP_ROLLBACK - 失败时跳过自动回滚(true/false,默认 false
set -eu
# ---- 重试工具函数 ----
retry_cmd() {
local max_attempts=$1
local backoff=$2
@@ -60,9 +73,10 @@ mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
echo "==========================================="
echo " Staging 部署 - $IMAGE_TAG (并行优化版)"
echo " Staging 部署 - $IMAGE_TAG"
echo "==========================================="
# ---- 记录当前运行的镜像版本(用于回滚) ----
echo "Recording current image versions for rollback..."
PREV_API_IMAGE=""
PREV_WORKER_IMAGE=""
@@ -81,6 +95,7 @@ for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
fi
done
# ---- 回滚函数 ----
rollback() {
echo ""
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
@@ -93,6 +108,7 @@ rollback() {
exit 1
fi
# 停止当前(失败的)新容器
echo "Stopping new containers..."
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
@@ -100,6 +116,7 @@ rollback() {
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# 恢复 API
if [ -n "$PREV_API_IMAGE" ]; then
echo "Rolling back API to: $PREV_API_IMAGE"
docker run -d \
@@ -120,9 +137,12 @@ rollback() {
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$PREV_API_IMAGE" &
"$PREV_API_IMAGE"
else
echo "No previous API image to roll back to"
fi
# 恢复 Worker
if [ -n "$PREV_WORKER_IMAGE" ]; then
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
docker run -d \
@@ -144,9 +164,12 @@ rollback() {
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$PREV_WORKER_IMAGE" &
"$PREV_WORKER_IMAGE"
else
echo "No previous Worker image to roll back to"
fi
# 恢复 Web
if [ -n "$PREV_WEB_IMAGE" ]; then
echo "Rolling back Web to: $PREV_WEB_IMAGE"
LEGACY_VOLUME=""
@@ -164,11 +187,12 @@ rollback() {
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$PREV_WEB_IMAGE" &
"$PREV_WEB_IMAGE"
else
echo "No previous Web image to roll back to"
fi
wait
# 等待 API 回滚后恢复健康
if [ -n "$PREV_API_IMAGE" ]; then
echo "Waiting for rolled-back API to become healthy..."
i=0
@@ -200,6 +224,7 @@ rollback() {
exit 1
}
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "=========================================="
echo " Login to Registry (with retries)"
@@ -208,64 +233,28 @@ if [ -n "$REGISTRY_TOKEN" ]; then
retry_docker_login
fi
# ---- 并行 Pull 三个镜像 ----
# ---- Pull 新版本镜像 ----
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
echo "=========================================="
echo " Pull images (parallel, up to 3 retries each)"
echo " Pull images (with retries)"
echo "=========================================="
PULL_LOG_DIR="/tmp/staging-pull-$$"
mkdir -p "$PULL_LOG_DIR"
retry_docker_pull "$REGISTRY_API" > "$PULL_LOG_DIR/api.log" 2>&1 &
PID_API=$!
retry_docker_pull "$REGISTRY_WORKER" > "$PULL_LOG_DIR/worker.log" 2>&1 &
PID_WORKER=$!
retry_docker_pull "$REGISTRY_WEB" > "$PULL_LOG_DIR/web.log" 2>&1 &
PID_WEB=$!
wait $PID_API $PID_WORKER $PID_WEB
echo ""
echo "Pull 结果:"
PULL_FAILED=0
for svc in api worker web; do
if tail -1 "$PULL_LOG_DIR/$svc.log" 2>/dev/null | grep -qE "Status:|Downloaded|already exists|is up to date"; then
echo " OK $svc"
elif grep -qE "Digest:|Status: Downloaded" "$PULL_LOG_DIR/$svc.log" 2>/dev/null; then
echo " OK $svc"
else
# 检查docker pull返回值不直接,用镜像是否存在来判断
img_var="REGISTRY_$(echo $svc | tr '[:lower:]' '[:upper:]')"
img_val=$(eval echo "\$$img_var")
if docker image inspect "$img_val" >/dev/null 2>&1; then
echo " OK $svc"
else
echo " FAIL $svc"
tail -5 "$PULL_LOG_DIR/$svc.log" 2>/dev/null || true
PULL_FAILED=$((PULL_FAILED + 1))
fi
fi
done
rm -rf "$PULL_LOG_DIR"
if [ "$PULL_FAILED" -gt 0 ]; then
echo ""
echo "ERROR: $PULL_FAILED 个镜像 pull 失败"
exit 1
fi
retry_docker_pull "$REGISTRY_API"
retry_docker_pull "$REGISTRY_WORKER"
retry_docker_pull "$REGISTRY_WEB"
echo "All images pulled."
# ---- 备份 legacy assets ----
echo "Backing up legacy assets from current web container..."
if docker inspect xiaoxia-web-staging >/dev/null 2>&1; then
_tmpdir="/tmp/legacy-assets-$$"
rm -rf "$_tmpdir"
mkdir -p "$_tmpdir"
docker cp xiaoxia-web-staging:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
# 只有目录非空才拷贝,避免覆盖有内容的 legacy assets
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
@@ -275,11 +264,13 @@ else
echo "No existing web container, skipping legacy assets backup"
fi
# 清理 7 天前的 legacy assets
if [ -d "$LEGACY_ASSETS_DIR" ]; then
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
echo "Legacy assets cleanup done (retain 7 days)"
fi
# ---- 检查基础设施容器 ----
echo "Checking infrastructure containers..."
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
if ! docker inspect "$c" >/dev/null 2>&1; then
@@ -293,8 +284,10 @@ for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
fi
done
# ---- 创建网络(不存在则创建) ----
docker network create xiaoxia-net-staging 2>/dev/null || true
# ---- 数据库迁移 ----
if [ "$SKIP_MIGRATION" != "true" ]; then
echo "Running database migrations..."
docker run --rm \
@@ -303,6 +296,8 @@ if [ "$SKIP_MIGRATION" != "true" ]; then
-e APP_ENV=staging \
"$REGISTRY_API" sh -c "cd /app && alembic upgrade head" || {
echo "ERROR: Database migration failed"
echo "Note: Migration failures are NOT automatically rolled back (data safety)"
echo "Please manually check and fix the migration, then redeploy"
exit 1
}
echo "Migrations completed."
@@ -310,6 +305,7 @@ else
echo "Skipping migrations (SKIP_MIGRATION=true)"
fi
# ---- 停止旧容器 ----
echo "Stopping old containers..."
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
@@ -317,14 +313,8 @@ docker rm -f xiaoxia-web-staging 2>/dev/null || true
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# ---- 并行启动三个容器 ----
echo "Starting all containers (parallel)..."
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
fi
# ---- 启动 API ----
echo "Starting API container..."
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
@@ -343,9 +333,10 @@ docker run -d \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$REGISTRY_API" &
PID_API_START=$!
"$REGISTRY_API" || rollback
# ---- 启动 Worker ----
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
@@ -365,9 +356,18 @@ docker run -d \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$REGISTRY_WORKER" &
PID_WORKER_START=$!
"$REGISTRY_WORKER" || rollback
# ---- 启动 Web ----
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
echo "Web container: legacy assets mounted (fallback)"
else
echo "Web container: no legacy assets to mount"
fi
echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-staging \
--network xiaoxia-net-staging \
@@ -379,111 +379,53 @@ docker run -d \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$REGISTRY_WEB" &
PID_WEB_START=$!
"$REGISTRY_WEB" || rollback
wait $PID_API_START $PID_WORKER_START $PID_WEB_START
START_FAILED=0
for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo " FAIL $c: not created"
START_FAILED=$((START_FAILED + 1))
else
state=$(docker inspect -f '{{.State.Status}}' "$c")
if [ "$state" = "running" ] || [ "$state" = "starting" ]; then
echo " OK $c: $state"
else
echo " FAIL $c: $state"
docker logs --tail 20 "$c" 2>/dev/null || true
START_FAILED=$((START_FAILED + 1))
fi
# ---- 等待 API 健康 ----
echo "Waiting for API to become healthy..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/40)"
sleep 3
done
if [ "$START_FAILED" -gt 0 ]; then
echo "ERROR: $START_FAILED 个容器启动失败"
rollback
fi
# ---- 并行等待 API 和 Web 健康 ----
echo ""
echo "Waiting for API + Web health (parallel)..."
HEALTH_LOG_DIR="/tmp/staging-health-$$"
mkdir -p "$HEALTH_LOG_DIR"
(
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "API healthy after $((i * 3))s"
exit 0
fi
i=$((i + 1))
sleep 3
done
echo "API FAILED after 120s"
exit 1
) > "$HEALTH_LOG_DIR/api.log" 2>&1 &
PID_API_HEALTH=$!
(
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
echo "Web healthy after $((i * 2))s"
exit 0
fi
i=$((i + 1))
sleep 2
done
echo "Web FAILED after 30s"
exit 1
) > "$HEALTH_LOG_DIR/web.log" 2>&1 &
PID_WEB_HEALTH=$!
set +e
wait $PID_API_HEALTH
API_EXIT=$?
wait $PID_WEB_HEALTH
WEB_EXIT=$?
set -e
echo ""
echo "健康检查结果:"
API_OK=0
WEB_OK=0
if [ "$API_EXIT" -eq 0 ]; then
echo " OK API: $(cat "$HEALTH_LOG_DIR/api.log")"
API_OK=1
else
echo " FAIL API: 120s未就绪"
if [ "$i" -ge 40 ]; then
echo "ERROR: API did not become healthy within 120s"
docker logs --tail 50 xiaoxia-api-staging
fi
if [ "$WEB_EXIT" -eq 0 ]; then
echo " OK Web: $(cat "$HEALTH_LOG_DIR/web.log")"
WEB_OK=1
else
echo " FAIL Web: 30s未就绪"
docker logs --tail 30 xiaoxia-web-staging
fi
rm -rf "$HEALTH_LOG_DIR"
if [ "$API_OK" -eq 0 ] || [ "$WEB_OK" -eq 0 ]; then
echo ""
echo "ERROR: 健康检查失败"
rollback
fi
# ---- 等待 Web 健康 ----
echo "Waiting for Web to become healthy..."
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
echo "Web is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/15)"
sleep 2
done
if [ "$i" -ge 15 ]; then
echo "ERROR: Web did not become healthy within 30s"
docker logs --tail 30 xiaoxia-web-staging
rollback
fi
# ---- 清理旧镜像 ----
echo "Cleaning up old images..."
docker image prune -af --filter "until=168h" 2>/dev/null || true
docker builder prune -af --filter "until=168h" 2>/dev/null || true
echo ""
echo "=== Staging deployment complete (并行优化版) ==="
echo "=== Staging deployment complete ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "Version: $IMAGE_TAG"
-139
View File
@@ -1,139 +0,0 @@
"""classification 模块单元测试."""
import pytest
from domain.classification import (
AssetClassification,
AssetLibraryKind,
ClassificationJob,
ClassificationJobStatus,
IngestJobStatus,
)
class TestAssetLibraryKind:
"""AssetLibraryKind 枚举测试."""
def test_values(self):
assert AssetLibraryKind.VIDEO == "video"
assert AssetLibraryKind.VOICE == "voice"
class TestIngestJobStatus:
"""IngestJobStatus 枚举测试."""
def test_values(self):
assert IngestJobStatus.PENDING == "pending"
assert IngestJobStatus.PROCESSING == "processing"
assert IngestJobStatus.COMPLETED == "completed"
assert IngestJobStatus.FAILED == "failed"
class TestClassificationJobStatus:
"""ClassificationJobStatus 枚举测试."""
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_values(self):
assert AssetClassification.SCENIC == "scenic"
assert AssetClassification.PRODUCT == "product"
assert AssetClassification.PERSON == "person"
assert AssetClassification.ANIMAL == "animal"
assert AssetClassification.FOOD == "food"
assert AssetClassification.TECH == "tech"
assert AssetClassification.SPORT == "sport"
assert AssetClassification.MUSIC == "music"
assert AssetClassification.OTHER == "other"
class TestClassificationJobCreate:
"""ClassificationJob.create 工厂方法测试."""
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.created_at is not None
assert job.updated_at is not None
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="a")
def test_create_whitespace_project_id_raises(self):
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="p", asset_id="")
def test_create_whitespace_asset_id_raises(self):
with pytest.raises(ValueError, match="asset_id"):
ClassificationJob.create(project_id="p", asset_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_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
class TestClassificationJobState:
"""ClassificationJob 状态操作测试"""
def test_set_processing(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.status = ClassificationJobStatus.PROCESSING
assert job.status == ClassificationJobStatus.PROCESSING
def test_set_completed_with_result(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.status = ClassificationJobStatus.COMPLETED
job.classification = AssetClassification.SCENIC
job.confidence = 0.95
assert job.status == ClassificationJobStatus.COMPLETED
assert job.classification == "scenic"
assert job.confidence == pytest.approx(0.95)
def test_set_failed_with_error(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.status = ClassificationJobStatus.FAILED
job.error_message = "model timeout"
assert job.status == ClassificationJobStatus.FAILED
assert job.error_message == "model timeout"
def test_confidence_range_zero(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.confidence = 0.0
assert job.confidence == 0.0
def test_confidence_range_one(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.confidence = 1.0
assert job.confidence == 1.0
-262
View File
@@ -1,262 +0,0 @@
"""edit_plan_clip 领域模型单元测试."""
from datetime import datetime, timezone
import pytest
from domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
class TestEditPlanClipStatus:
"""EditPlanClipStatus 枚举测试."""
def test_values(self):
assert EditPlanClipStatus.PENDING == "pending"
assert EditPlanClipStatus.READY == "ready"
assert EditPlanClipStatus.RENDERED == "rendered"
assert EditPlanClipStatus.FAILED == "failed"
class TestEditPlanClipCreate:
"""EditPlanClip.create 工厂方法测试."""
def test_create_with_required_fields(self):
clip = EditPlanClip.create(plan_id="plan_001", clip_type="video", order=1)
assert clip.id # 自动生成的 UUID
assert len(clip.id) == 32 # hex 格式
assert clip.plan_id == "plan_001"
assert clip.clip_type == "video"
assert clip.order == 1
assert clip.status == EditPlanClipStatus.PENDING
assert clip.start_time == 0.0
assert clip.duration == 0.0
assert clip.transition_effect == "cut"
assert clip.playback_speed == 1.0
assert clip.config == {}
def test_create_with_all_fields(self):
clip = EditPlanClip.create(
plan_id="plan_002",
clip_type="audio",
order=2,
template_clip_config_id="tpl_001",
asset_id="asset_001",
text_content="测试文案",
start_time=5.0,
duration=10.0,
transition_effect="fade",
transition_duration=0.5,
playback_speed=1.5,
config={"key": "value"},
)
assert clip.plan_id == "plan_002"
assert clip.clip_type == "audio"
assert clip.order == 2
assert clip.template_clip_config_id == "tpl_001"
assert clip.asset_id == "asset_001"
assert clip.text_content == "测试文案"
assert clip.start_time == 5.0
assert clip.duration == 10.0
assert clip.transition_effect == "fade"
assert clip.transition_duration == 0.5
assert clip.playback_speed == 1.5
assert clip.config == {"key": "value"}
def test_create_strips_strings(self):
clip = EditPlanClip.create(
plan_id=" plan_003 ",
clip_type=" video ",
order=1,
asset_id=" asset_001 ",
template_clip_config_id=" tpl_001 ",
text_content=" 测试 ",
transition_effect=" fade ",
)
assert clip.plan_id == "plan_003"
assert clip.clip_type == "video"
assert clip.asset_id == "asset_001"
assert clip.template_clip_config_id == "tpl_001"
assert clip.text_content == "测试"
assert clip.transition_effect == "fade"
def test_create_empty_plan_id_raises(self):
with pytest.raises(ValueError, match="plan_id"):
EditPlanClip.create(plan_id="", clip_type="video", order=1)
def test_create_whitespace_plan_id_raises(self):
with pytest.raises(ValueError, match="plan_id"):
EditPlanClip.create(plan_id=" ", clip_type="video", order=1)
def test_create_empty_clip_type_raises(self):
with pytest.raises(ValueError, match="clip_type"):
EditPlanClip.create(plan_id="plan_001", clip_type="", order=1)
def test_create_negative_start_time_raises(self):
with pytest.raises(ValueError, match="start_time"):
EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=-1.0)
def test_create_negative_duration_raises(self):
with pytest.raises(ValueError, match="duration"):
EditPlanClip.create(plan_id="p", clip_type="v", order=1, duration=-5.0)
def test_create_zero_speed_clamps_to_1(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.0)
assert clip.playback_speed == 1.0
def test_create_negative_speed_clamps_to_1(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=-1.0)
assert clip.playback_speed == 1.0
def test_create_low_speed_clamps_to_min(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.1)
assert clip.playback_speed == 0.25
def test_create_high_speed_clamps_to_max(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=5.0)
assert clip.playback_speed == 4.0
def test_create_speed_at_boundary_values(self):
# 边界值应该保持不变
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.25)
assert clip.playback_speed == 0.25
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=4.0)
assert clip.playback_speed == 4.0
def test_create_negative_transition_duration_clamps_to_0(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, transition_duration=-1.0)
assert clip.transition_duration == 0.0
def test_create_empty_transition_effect_defaults_to_cut(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, transition_effect="")
assert clip.transition_effect == "cut"
def test_create_empty_asset_id_stays_empty(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="")
assert clip.asset_id == ""
def test_create_none_config_defaults_to_empty_dict(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, config=None)
assert clip.config == {}
def test_create_ids_are_unique(self):
c1 = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
c2 = EditPlanClip.create(plan_id="p", clip_type="v", order=2)
assert c1.id != c2.id
def test_create_timestamps_are_utc(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
assert clip.created_at.tzinfo is not None
assert clip.updated_at.tzinfo is not None
class TestEditPlanClipStateMachine:
"""状态机流转测试."""
@pytest.fixture
def pending_clip(self):
return EditPlanClip.create(plan_id="plan_001", clip_type="video", order=1)
def test_initial_status_is_pending(self, pending_clip):
assert pending_clip.status == EditPlanClipStatus.PENDING
def test_pending_to_ready(self, pending_clip):
pending_clip.mark_ready()
assert pending_clip.status == EditPlanClipStatus.READY
def test_pending_cannot_mark_rendered(self, pending_clip):
with pytest.raises(ValueError, match="只有 ready"):
pending_clip.mark_rendered()
def test_pending_cannot_mark_failed(self, pending_clip):
with pytest.raises(ValueError, match="只有 ready"):
pending_clip.mark_failed()
def test_ready_to_rendered(self, pending_clip):
pending_clip.mark_ready()
pending_clip.mark_rendered()
assert pending_clip.status == EditPlanClipStatus.RENDERED
def test_ready_to_failed(self, pending_clip):
pending_clip.mark_ready()
pending_clip.mark_failed()
assert pending_clip.status == EditPlanClipStatus.FAILED
def test_rendered_cannot_mark_ready_again(self, pending_clip):
pending_clip.mark_ready()
pending_clip.mark_rendered()
with pytest.raises(ValueError):
pending_clip.mark_ready()
def test_failed_cannot_mark_ready_again(self, pending_clip):
pending_clip.mark_ready()
pending_clip.mark_failed()
with pytest.raises(ValueError):
pending_clip.mark_ready()
def test_state_transition_updates_updated_at(self, pending_clip):
old_updated = pending_clip.updated_at
# 确保时间不同
import time
time.sleep(0.001)
pending_clip.mark_ready()
assert pending_clip.updated_at > old_updated
class TestEditPlanClipAssignAsset:
"""assign_asset 方法测试."""
def test_assign_asset(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
assert not clip.has_asset
clip.assign_asset("asset_001")
assert clip.asset_id == "asset_001"
assert clip.has_asset
def test_assign_asset_strips_whitespace(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
clip.assign_asset(" asset_001 ")
assert clip.asset_id == "asset_001"
def test_assign_empty_asset_raises(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
with pytest.raises(ValueError, match="asset_id"):
clip.assign_asset("")
def test_assign_whitespace_asset_raises(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
with pytest.raises(ValueError, match="asset_id"):
clip.assign_asset(" ")
def test_assign_updates_updated_at(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
old_updated = clip.updated_at
import time
time.sleep(0.001)
clip.assign_asset("asset_001")
assert clip.updated_at > old_updated
class TestEditPlanClipProperties:
"""属性方法测试."""
def test_end_time(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=5.0, duration=10.0)
assert clip.end_time == 15.0
def test_end_time_zero_duration(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=3.0, duration=0.0)
assert clip.end_time == 3.0
def test_has_asset_true(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="a001")
assert clip.has_asset is True
def test_has_asset_false(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
assert clip.has_asset is False
def test_has_asset_empty_string(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="")
assert clip.has_asset is False
-237
View File
@@ -1,237 +0,0 @@
"""剪辑计划领域模型单元测试."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from packages.domain.edit_plan import EditPlan, EditPlanStatus
class TestEditPlanStatus:
"""EditPlanStatus 枚举测试."""
def test_status_values(self):
assert EditPlanStatus.DRAFT.value == "draft"
assert EditPlanStatus.EDITING.value == "editing"
assert EditPlanStatus.RENDERING.value == "rendering"
assert EditPlanStatus.COMPLETED.value == "completed"
assert EditPlanStatus.FAILED.value == "failed"
def test_status_is_str(self):
assert isinstance(EditPlanStatus.DRAFT, str)
assert EditPlanStatus.DRAFT == "draft"
class TestEditPlanCreate:
"""创建剪辑计划测试."""
def test_create_basic(self):
plan = EditPlan.create(template_id="tpl_001", name="测试计划")
assert plan.id
assert len(plan.id) == 32 # uuid4 hex
assert plan.template_id == "tpl_001"
assert plan.name == "测试计划"
assert plan.status == EditPlanStatus.DRAFT
assert plan.total_duration == 0.0
assert plan.config == {}
assert plan.source_edit_plan_id == ""
assert plan.project_id == ""
assert plan.created_by_user_id == ""
def test_create_with_all_fields(self):
plan = EditPlan.create(
template_id="tpl_001",
name="完整测试计划",
config={"key": "value"},
total_duration=60.5,
source_edit_plan_id="src_001",
project_id="proj_001",
created_by_user_id="user_001",
)
assert plan.name == "完整测试计划"
assert plan.total_duration == 60.5
assert plan.config == {"key": "value"}
assert plan.source_edit_plan_id == "src_001"
assert plan.project_id == "proj_001"
assert plan.created_by_user_id == "user_001"
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="名称不能为空"):
EditPlan.create(template_id="tpl_001", name="")
def test_create_whitespace_name_raises(self):
with pytest.raises(ValueError, match="名称不能为空"):
EditPlan.create(template_id="tpl_001", name=" ")
def test_create_empty_template_id_raises(self):
with pytest.raises(ValueError, match="template_id 不能为空"):
EditPlan.create(template_id="", name="测试")
def test_create_whitespace_template_id_raises(self):
with pytest.raises(ValueError, match="template_id 不能为空"):
EditPlan.create(template_id=" ", name="测试")
def test_create_name_stripped(self):
plan = EditPlan.create(template_id="tpl_001", name=" 我的计划 ")
assert plan.name == "我的计划"
def test_create_template_id_stripped(self):
plan = EditPlan.create(template_id=" tpl_001 ", name="测试")
assert plan.template_id == "tpl_001"
def test_create_timestamps_set(self):
before = datetime.now(timezone.utc)
plan = EditPlan.create(template_id="tpl_001", name="测试")
after = datetime.now(timezone.utc)
assert before <= plan.created_at <= after
assert before <= plan.updated_at <= after
def test_create_config_none_defaults_to_empty(self):
plan = EditPlan.create(template_id="tpl_001", name="测试", config=None)
assert plan.config == {}
class TestEditPlanStateMachine:
"""状态机流转测试."""
def _make_plan(self, status: EditPlanStatus) -> EditPlan:
return EditPlan(
id="test_id",
template_id="tpl_001",
name="测试计划",
status=status,
)
def test_draft_to_editing(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
plan.start_editing()
assert plan.status == EditPlanStatus.EDITING
assert plan.updated_at > plan.created_at
def test_editing_to_rendering(self):
plan = self._make_plan(EditPlanStatus.EDITING)
plan.start_rendering()
assert plan.status == EditPlanStatus.RENDERING
def test_rendering_to_completed(self):
plan = self._make_plan(EditPlanStatus.RENDERING)
plan.mark_completed()
assert plan.status == EditPlanStatus.COMPLETED
def test_rendering_to_failed(self):
plan = self._make_plan(EditPlanStatus.RENDERING)
plan.mark_failed()
assert plan.status == EditPlanStatus.FAILED
def test_completed_to_editing_resume(self):
plan = self._make_plan(EditPlanStatus.COMPLETED)
plan.resume_editing()
assert plan.status == EditPlanStatus.EDITING
def test_failed_to_editing_resume(self):
plan = self._make_plan(EditPlanStatus.FAILED)
plan.resume_editing()
assert plan.status == EditPlanStatus.EDITING
def test_failed_to_draft_reset(self):
plan = self._make_plan(EditPlanStatus.FAILED)
plan.reset_to_draft()
assert plan.status == EditPlanStatus.DRAFT
def test_invalid_start_editing_from_editing(self):
plan = self._make_plan(EditPlanStatus.EDITING)
with pytest.raises(ValueError, match="只有 draft 状态"):
plan.start_editing()
def test_invalid_start_editing_from_rendering(self):
plan = self._make_plan(EditPlanStatus.RENDERING)
with pytest.raises(ValueError):
plan.start_editing()
def test_invalid_start_rendering_from_draft(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
with pytest.raises(ValueError, match="只有 editing 状态"):
plan.start_rendering()
def test_invalid_start_rendering_from_completed(self):
plan = self._make_plan(EditPlanStatus.COMPLETED)
with pytest.raises(ValueError):
plan.start_rendering()
def test_invalid_mark_completed_from_draft(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
with pytest.raises(ValueError, match="只有 rendering 状态"):
plan.mark_completed()
def test_invalid_mark_failed_from_editing(self):
plan = self._make_plan(EditPlanStatus.EDITING)
with pytest.raises(ValueError):
plan.mark_failed()
def test_invalid_resume_editing_from_draft(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
with pytest.raises(ValueError, match="只有 completed/failed 状态"):
plan.resume_editing()
def test_invalid_resume_editing_from_rendering(self):
plan = self._make_plan(EditPlanStatus.RENDERING)
with pytest.raises(ValueError):
plan.resume_editing()
def test_invalid_reset_to_draft_from_draft(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
with pytest.raises(ValueError, match="只有 failed 状态"):
plan.reset_to_draft()
def test_invalid_reset_to_draft_from_completed(self):
plan = self._make_plan(EditPlanStatus.COMPLETED)
with pytest.raises(ValueError):
plan.reset_to_draft()
def test_state_transition_updates_updated_at(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
old_updated = plan.updated_at
plan.start_editing()
assert plan.updated_at >= old_updated
class TestEditPlanDataclass:
"""数据类属性测试."""
def test_slots_prevents_dynamic_attributes(self):
plan = EditPlan(id="1", template_id="t1", name="test")
with pytest.raises(AttributeError):
plan.new_field = "value"
def test_full_flow_draft_editing_rendering_completed(self):
"""完整流程:草稿 → 编辑 → 渲染 → 完成."""
plan = EditPlan.create(template_id="tpl_001", name="完整流程")
assert plan.status == EditPlanStatus.DRAFT
plan.start_editing()
assert plan.status == EditPlanStatus.EDITING
plan.start_rendering()
assert plan.status == EditPlanStatus.RENDERING
plan.mark_completed()
assert plan.status == EditPlanStatus.COMPLETED
def test_full_flow_draft_editing_rendering_failed_reset(self):
"""完整流程:草稿 → 编辑 → 渲染 → 失败 → 重置 → 编辑 → 渲染 → 完成."""
plan = EditPlan.create(template_id="tpl_001", name="失败重试流程")
plan.start_editing()
plan.start_rendering()
plan.mark_failed()
assert plan.status == EditPlanStatus.FAILED
plan.reset_to_draft()
assert plan.status == EditPlanStatus.DRAFT
plan.start_editing()
plan.start_rendering()
plan.mark_completed()
assert plan.status == EditPlanStatus.COMPLETED
-40
View File
@@ -1,40 +0,0 @@
"""
EditingMode 剪辑模式枚举单元测试
"""
from packages.domain.editing_mode import EditingMode
class TestEditingMode:
"""EditingMode 枚举测试"""
def test_all_modes_exist(self):
assert EditingMode.ONE_TAKE == "one_take"
assert EditingMode.PIP == "pip"
assert EditingMode.VOICE_OVER == "voice_over"
assert EditingMode.VOICE_PIP == "voice_pip"
def test_total_count(self):
assert len(EditingMode) == 4
def test_is_string_type(self):
for mode in EditingMode:
assert isinstance(mode.value, str)
assert isinstance(mode, str)
def test_mode_descriptions(self):
"""验证模式值有意义"""
assert "one" in EditingMode.ONE_TAKE
assert "pip" in EditingMode.PIP
assert "voice" in EditingMode.VOICE_OVER
assert "voice" in EditingMode.VOICE_PIP
def test_usage_in_comparison(self):
mode = EditingMode.ONE_TAKE
assert mode == "one_take"
assert mode != "pip"
def test_iterable(self):
modes = list(EditingMode)
assert len(modes) == 4
assert EditingMode.ONE_TAKE in modes
-230
View File
@@ -1,230 +0,0 @@
"""filter_presets 模块单元测试."""
from dataclasses import FrozenInstanceError
import pytest
from domain.filter_presets import (
FILTER_PRESET_LIBRARY,
FilterPreset,
build_ffmpeg_filter,
get_filter_preset,
list_filter_presets,
)
class TestFilterPreset:
"""FilterPreset 数据类测试."""
def test_create_required_fields(self):
f = FilterPreset(id="test_001", name="测试滤镜", category="basic")
assert f.id == "test_001"
assert f.name == "测试滤镜"
assert f.category == "basic"
# 默认值
assert f.description == ""
assert f.tags == []
assert f.brightness == 0.0
assert f.contrast == 1.0
assert f.saturation == 1.0
assert f.gamma == 1.0
assert f.gamma_r == 1.0
assert f.gamma_g == 1.0
assert f.gamma_b == 1.0
assert f.hue == 0.0
assert f.lut_url == ""
def test_create_all_fields(self):
f = FilterPreset(
id="test_002",
name="完整滤镜",
category="cinematic",
description="测试描述",
tags=["标签1", "标签2"],
brightness=0.1,
contrast=1.2,
saturation=0.8,
gamma=1.1,
gamma_r=1.05,
gamma_g=0.95,
gamma_b=1.15,
hue=10.0,
lut_url="https://example.com/lut.png",
)
assert f.category == "cinematic"
assert f.brightness == 0.1
assert f.contrast == 1.2
assert f.saturation == 0.8
assert f.gamma == 1.1
assert f.gamma_r == 1.05
assert f.gamma_g == 0.95
assert f.gamma_b == 1.15
assert f.hue == 10.0
assert f.lut_url == "https://example.com/lut.png"
def test_frozen_immutable(self):
f = FilterPreset(id="test", name="测试", category="basic")
with pytest.raises(FrozenInstanceError):
f.name = "修改" # type: ignore[misc]
def test_tags_default_new_list(self):
f1 = FilterPreset(id="1", name="a", category="basic")
f2 = FilterPreset(id="2", name="b", category="basic")
assert f1.tags is not f2.tags
assert f1.tags == []
class TestFilterPresetLibrary:
"""FILTER_PRESET_LIBRARY 预设库测试."""
def test_not_empty(self):
assert len(FILTER_PRESET_LIBRARY) > 0
def test_all_unique_ids(self):
ids = [f.id for f in FILTER_PRESET_LIBRARY]
assert len(ids) == len(set(ids))
def test_all_are_filter_preset_instances(self):
for f in FILTER_PRESET_LIBRARY:
assert isinstance(f, FilterPreset)
def test_contains_basic_category(self):
cats = {f.category for f in FILTER_PRESET_LIBRARY}
assert "basic" in cats
def test_none_filter_is_identity(self):
"""filter_none 应该所有参数都是默认值(不改变画面)"""
f = get_filter_preset("filter_none")
assert f is not None
assert f.brightness == 0.0
assert f.contrast == 1.0
assert f.saturation == 1.0
assert f.gamma == 1.0
class TestGetFilterPreset:
"""get_filter_preset 函数测试."""
def test_existing_id(self):
f = get_filter_preset("filter_brighten")
assert f is not None
assert f.id == "filter_brighten"
assert f.name == "明亮"
def test_nonexistent_id(self):
assert get_filter_preset("nonexistent") is None
def test_empty_string(self):
assert get_filter_preset("") is None
class TestListFilterPresets:
"""list_filter_presets 函数测试."""
def test_no_filters_returns_all(self):
result = list_filter_presets()
assert len(result) == len(FILTER_PRESET_LIBRARY)
def test_filter_by_category_basic(self):
result = list_filter_presets(category="basic")
assert len(result) >= 4
for f in result:
assert f.category == "basic"
def test_filter_by_unknown_category_returns_empty(self):
result = list_filter_presets(category="nonexistent")
assert result == []
def test_filter_by_keyword_name(self):
result = list_filter_presets(keyword="明亮")
assert len(result) >= 1
assert any(f.name == "明亮" for f in result)
def test_filter_by_keyword_tag(self):
result = list_filter_presets(keyword="提亮")
assert len(result) >= 1
def test_filter_by_keyword_description(self):
result = list_filter_presets(keyword="偏暗")
assert len(result) >= 1
def test_filter_keyword_case_insensitive(self):
r1 = list_filter_presets(keyword="FILTER")
r2 = list_filter_presets(keyword="filter")
assert len(r1) == len(r2)
def test_filter_keyword_no_match(self):
result = list_filter_presets(keyword="xyz_nonexistent_12345")
assert result == []
def test_combined_category_and_keyword(self):
result = list_filter_presets(category="basic", keyword="明亮")
assert len(result) >= 1
for f in result:
assert f.category == "basic"
def test_combined_no_match(self):
result = list_filter_presets(category="basic", keyword="电影感")
# 基础分类里没有电影感关键词
pass # 不做强断言,看实际数据
class TestBuildFFmpegFilter:
"""build_ffmpeg_filter 函数测试."""
def test_none_preset_returns_empty(self):
result = build_ffmpeg_filter("nonexistent")
assert result == ""
def test_zero_intensity_returns_empty(self):
result = build_ffmpeg_filter("filter_brighten", intensity=0)
assert result == ""
def test_negative_intensity_returns_empty(self):
result = build_ffmpeg_filter("filter_brighten", intensity=-10)
assert result == ""
def test_full_intensity_brighten(self):
result = build_ffmpeg_filter("filter_brighten", intensity=100)
assert result.startswith("eq=")
assert "brightness=0.120" in result
assert "contrast=1.050" in result
assert "saturation=1.050" in result
assert "gamma=1.100" in result
def test_half_intensity(self):
"""强度 50% 时参数应该是全量的一半(向原值插值)"""
full = build_ffmpeg_filter("filter_brighten", intensity=100)
half = build_ffmpeg_filter("filter_brighten", intensity=50)
# 50% 强度的 brightness 应该是 0.060 (0.120 * 0.5)
assert "brightness=0.060" in half
# full 和 half 都应该有 eq= 前缀
assert full.startswith("eq=")
assert half.startswith("eq=")
def test_intensity_over_100_clamps_to_100(self):
result1 = build_ffmpeg_filter("filter_brighten", intensity=100)
result2 = build_ffmpeg_filter("filter_brighten", intensity=150)
assert result1 == result2
def test_filter_none_returns_empty(self):
"""原图滤镜所有参数都是默认值,应该返回空字符串"""
result = build_ffmpeg_filter("filter_none")
assert result == ""
def test_warm_filter_has_gamma_channels(self):
"""暖色滤镜应该调整 RGB 通道伽马"""
result = build_ffmpeg_filter("filter_warm", intensity=100)
assert "gamma_r=" in result
# 暖色红通道伽马 > 1.0
assert "gamma_r=1.100" in result
def test_result_format_is_eq_params(self):
"""结果格式应该是 eq=param1=val:param2=val..."""
result = build_ffmpeg_filter("filter_brighten", intensity=100)
assert result.startswith("eq=")
# 参数之间用冒号分隔
parts = result[3:].split(":")
assert len(parts) >= 4 # 至少 brightness/contrast/saturation/gamma
for part in parts:
assert "=" in part # 每个部分都是 key=value 格式
-196
View File
@@ -1,196 +0,0 @@
"""generated_video 领域模型单元测试."""
import pytest
from domain.generated_video import GeneratedVideo
class TestGeneratedVideoCreate:
"""GeneratedVideo.create 工厂方法测试."""
def test_create_with_required_fields(self):
video = GeneratedVideo.create(
project_id="proj_001",
generation_task_id="task_001",
name="测试视频",
file_url="https://example.com/out.mp4",
)
assert video.id
assert len(video.id) == 32
assert video.project_id == "proj_001"
assert video.generation_task_id == "task_001"
assert video.name == "测试视频"
assert video.file_url == "https://example.com/out.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.status == "completed"
assert video.review_status == "pending_review"
assert video.generation_params == {}
assert video.video_fingerprint is None
assert video.is_duplicate is False
assert video.duplicate_of is None
assert video.generated_at is not None
assert video.created_at is not None
def test_create_with_all_fields(self):
video = GeneratedVideo.create(
project_id="proj_002",
generation_task_id="task_002",
name="完整视频",
file_url="https://example.com/full.mp4",
user_id="user_001",
file_size=1024000,
duration=30.5,
width=1920,
height=1080,
fps=30.0,
thumbnail_url="https://example.com/thumb.jpg",
generation_params={"quality": "high"},
)
assert video.user_id == "user_001"
assert video.file_size == 1024000
assert video.duration == 30.5
assert video.width == 1920
assert video.height == 1080
assert video.fps == 30.0
assert video.thumbnail_url == "https://example.com/thumb.jpg"
assert video.generation_params == {"quality": "high"}
def test_create_strips_strings(self):
video = GeneratedVideo.create(
project_id=" proj_003 ",
generation_task_id=" task_003 ",
name=" 测试视频 ",
file_url=" https://example.com/out.mp4 ",
user_id=" user_003 ",
)
assert video.project_id == "proj_003"
assert video.generation_task_id == "task_003"
assert video.name == "测试视频"
assert video.file_url == "https://example.com/out.mp4"
assert video.user_id == "user_003"
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
GeneratedVideo.create(
project_id="",
generation_task_id="t",
name="n",
file_url="u",
)
def test_create_whitespace_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
GeneratedVideo.create(
project_id=" ",
generation_task_id="t",
name="n",
file_url="u",
)
def test_create_empty_generation_task_id_raises(self):
with pytest.raises(ValueError, match="generation_task_id"):
GeneratedVideo.create(
project_id="p",
generation_task_id="",
name="n",
file_url="u",
)
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="name"):
GeneratedVideo.create(
project_id="p",
generation_task_id="t",
name="",
file_url="u",
)
def test_create_empty_file_url_raises(self):
with pytest.raises(ValueError, match="file_url"):
GeneratedVideo.create(
project_id="p",
generation_task_id="t",
name="n",
file_url="",
)
def test_create_none_generation_params_defaults_to_empty_dict(self):
video = GeneratedVideo.create(
project_id="p",
generation_task_id="t",
name="n",
file_url="u",
generation_params=None,
)
assert video.generation_params == {}
def test_create_ids_are_unique(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")
assert v1.id != v2.id
def test_create_timestamps_are_utc(self):
video = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
assert video.created_at.tzinfo is not None
assert video.generated_at.tzinfo is not None
class TestGeneratedVideoProperties:
"""GeneratedVideo 属性测试"""
def test_default_status_completed(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
assert gv.status == "completed"
def test_default_review_status(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
assert gv.review_status == "pending_review"
def test_set_status(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
gv.status = "failed"
assert gv.status == "failed"
def test_mark_as_duplicate(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
gv.is_duplicate = True
gv.duplicate_of = "video-original"
assert gv.is_duplicate is True
assert gv.duplicate_of == "video-original"
def test_set_fingerprint(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
fingerprint = {"phash": "abc123", "md5": "def456"}
gv.video_fingerprint = fingerprint
assert gv.video_fingerprint == fingerprint
-142
View File
@@ -1,142 +0,0 @@
"""MemoryStateStore 单元测试 - 微信 OAuth state 存储
覆盖:正常存取、一次性消费、过期清理、并发安全、空 state 处理。
"""
from __future__ import annotations
import time
from threading import Thread
import pytest
class TestMemoryStateStore:
def test_put_and_verify_success(self):
"""正常存入并校验成功"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("test_state_123")
assert store.verify_and_consume("test_state_123") is True
def test_verify_nonexistent_state_fails(self):
"""不存在的 state 校验失败"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
assert store.verify_and_consume("nonexistent") is False
def test_state_single_use(self):
"""state 只能消费一次(防重放)"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("single_use_state")
assert store.verify_and_consume("single_use_state") is True
assert store.verify_and_consume("single_use_state") is False
def test_empty_state_rejected(self):
"""空字符串 state 校验失败"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("")
# 空字符串作为 key 技术上可以存,但业务层应该拒绝
# 这里验证 store 本身行为一致性
assert store.verify_and_consume("") is True # 存入了就能通过一次
assert store.verify_and_consume("") is False # 消费后就没了
def test_expired_state_cleaned(self):
"""过期 state 会被清理,校验失败"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
# TTL 设为 0.01 秒,快速过期
store = MemoryStateStore(ttl_seconds=0.01)
store.put("expire_me")
time.sleep(0.02)
assert store.verify_and_consume("expire_me") is False
def test_multiple_states_independent(self):
"""多个 state 互不影响"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("state_a")
store.put("state_b")
store.put("state_c")
# 消费 b
assert store.verify_and_consume("state_b") is True
assert store.verify_and_consume("state_b") is False
# a 和 c 仍然有效
assert store.verify_and_consume("state_a") is True
assert store.verify_and_consume("state_c") is True
def test_clean_expired_doesnt_touch_valid(self):
"""过期清理不影响未过期的 state"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore(ttl_seconds=10)
store.put("valid_state")
# 手动触发清理(通过 verify 触发内部 clean_expired
# 由于所有 state 都没过期,清理不影响
assert store.verify_and_consume("valid_state") is True
def test_thread_safety_concurrent_put(self):
"""并发写入不丢数据"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore(ttl_seconds=60)
states = [f"state_{i}" for i in range(100)]
def put_states(states_list):
for s in states_list:
store.put(s)
threads = [Thread(target=put_states, args=(states[i * 20 : (i + 1) * 20],)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
# 每个 state 都能消费一次
for s in states:
assert store.verify_and_consume(s) is True
def test_thread_safety_concurrent_consume(self):
"""并发消费同一个 state 只有一个能成功"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("contested_state")
results = []
def try_consume():
results.append(store.verify_and_consume("contested_state"))
threads = [Thread(target=try_consume) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
# 只有一个成功,其余失败
assert sum(1 for r in results if r) == 1
assert sum(1 for r in results if not r) == 9
def test_default_ttl_is_10_minutes(self):
"""默认 TTL 是 600 秒(10分钟)"""
from packages.application.auth.wechat_oauth_service import (
STATE_TTL_SECONDS,
MemoryStateStore,
)
assert STATE_TTL_SECONDS == 600
store = MemoryStateStore()
# 验证默认值生效:存入后立即验证应该通过
store.put("default_ttl_test")
assert store.verify_and_consume("default_ttl_test") is True
-190
View File
@@ -1,190 +0,0 @@
"""preset_bgm 模块单元测试."""
from dataclasses import FrozenInstanceError
import pytest
from domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
PresetBGM,
get_preset_bgm,
list_preset_bgm_by_style,
search_preset_bgm,
)
class TestPresetBGM:
"""PresetBGM 数据类测试."""
def test_create_required_fields(self):
bgm = PresetBGM(id="test_001", name="测试音乐", style="upbeat", duration=120.0)
assert bgm.id == "test_001"
assert bgm.name == "测试音乐"
assert bgm.style == "upbeat"
assert bgm.duration == 120.0
# 默认值
assert bgm.artist == ""
assert bgm.description == ""
assert bgm.tags == []
assert bgm.audio_url == ""
def test_create_all_fields(self):
bgm = PresetBGM(
id="test_002",
name="完整版",
style="relax",
duration=180.5,
artist="测试艺术家",
description="测试描述",
tags=["标签1", "标签2"],
audio_url="https://example.com/test.mp3",
)
assert bgm.artist == "测试艺术家"
assert bgm.description == "测试描述"
assert bgm.tags == ["标签1", "标签2"]
assert bgm.audio_url == "https://example.com/test.mp3"
def test_frozen_immutable(self):
"""frozen=True,实例不可变."""
bgm = PresetBGM(id="test", name="测试", style="upbeat", duration=60.0)
with pytest.raises(FrozenInstanceError):
bgm.name = "修改" # type: ignore[misc]
def test_tags_default_new_list(self):
"""每次创建都有独立的 tags 列表."""
b1 = PresetBGM(id="1", name="a", style="upbeat", duration=60.0)
b2 = PresetBGM(id="2", name="b", style="upbeat", duration=60.0)
assert b1.tags is not b2.tags
assert b1.tags == []
assert b2.tags == []
class TestPresetBGMLibrary:
"""PRESET_BGM_LIBRARY 预设库测试."""
def test_not_empty(self):
assert len(PRESET_BGM_LIBRARY) > 0
def test_all_unique_ids(self):
ids = [b.id for b in PRESET_BGM_LIBRARY]
assert len(ids) == len(set(ids)), "BGM ID 不能重复"
def test_all_are_preset_bgm_instances(self):
for bgm in PRESET_BGM_LIBRARY:
assert isinstance(bgm, PresetBGM)
def test_all_have_positive_duration(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.duration > 0, f"{bgm.id} duration 必须为正"
def test_styles_are_known(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.style in BGM_STYLES, f"{bgm.id} style {bgm.style} 不在 BGM_STYLES 中"
def test_style_distribution(self):
"""每种风格至少有 1 个 BGM."""
styles_found = {b.style for b in PRESET_BGM_LIBRARY}
for style in ["upbeat", "relax", "tech", "commerce"]:
assert style in styles_found
class TestBGMStyles:
"""BGM_STYLES 风格字典测试."""
def test_has_expected_styles(self):
assert "upbeat" in BGM_STYLES
assert "relax" in BGM_STYLES
assert "tech" in BGM_STYLES
assert "commerce" in BGM_STYLES
assert "emotional" in BGM_STYLES
assert "cinematic" in BGM_STYLES
def test_values_are_chinese_labels(self):
assert BGM_STYLES["upbeat"] == "轻快"
assert BGM_STYLES["relax"] == "治愈"
class TestGetPresetBGM:
"""get_preset_bgm 函数测试."""
def test_existing_id(self):
bgm = get_preset_bgm("bgm_upbeat_001")
assert bgm is not None
assert bgm.id == "bgm_upbeat_001"
assert bgm.name == "阳光清晨"
assert bgm.style == "upbeat"
def test_nonexistent_id(self):
assert get_preset_bgm("nonexistent") is None
def test_empty_string(self):
assert get_preset_bgm("") is None
def test_returns_preset_bgm_instance(self):
bgm = get_preset_bgm("bgm_relax_001")
assert isinstance(bgm, PresetBGM)
class TestListPresetBGMByStyle:
"""list_preset_bgm_by_style 函数测试."""
def test_upbeat_style(self):
result = list_preset_bgm_by_style("upbeat")
assert len(result) >= 3
for bgm in result:
assert bgm.style == "upbeat"
def test_relax_style(self):
result = list_preset_bgm_by_style("relax")
assert len(result) >= 3
for bgm in result:
assert bgm.style == "relax"
def test_tech_style(self):
result = list_preset_bgm_by_style("tech")
assert len(result) >= 2
def test_unknown_style_returns_empty(self):
result = list_preset_bgm_by_style("nonexistent_style")
assert result == []
def test_empty_style_returns_empty(self):
result = list_preset_bgm_by_style("")
assert result == []
class TestSearchPresetBGM:
"""search_preset_bgm 函数测试."""
def test_search_by_name(self):
result = search_preset_bgm("阳光")
assert len(result) >= 1
assert any(b.name == "阳光清晨" for b in result)
def test_search_by_tag(self):
result = search_preset_bgm("钢琴")
assert len(result) >= 1
for bgm in result:
assert any("钢琴" in tag for tag in bgm.tags) or "钢琴" in bgm.name or "钢琴" in bgm.description
def test_search_by_description(self):
result = search_preset_bgm("vlog")
assert len(result) >= 1
def test_search_case_insensitive(self):
r1 = search_preset_bgm("BGM")
r2 = search_preset_bgm("bgm")
assert len(r1) == len(r2)
def test_search_no_match(self):
result = search_preset_bgm("xyz_nonexistent_keyword_12345")
assert result == []
def test_search_empty_keyword_returns_all(self):
"""空关键词应该匹配所有(keyword in string 恒成立)."""
result = search_preset_bgm("")
assert len(result) == len(PRESET_BGM_LIBRARY)
def test_search_partial_match(self):
result = search_preset_bgm("科技")
assert len(result) >= 1
-166
View File
@@ -1,166 +0,0 @@
"""
PresetVoice 预置音色领域模型单元测试
"""
import pytest
from domain.preset_voices import (
PRESET_VOICES,
PresetVoice,
get_preset_voice_by_id,
get_preset_voices,
is_preset_voice,
)
class TestPresetVoice:
"""PresetVoice 数据类测试"""
def test_create_required_fields(self):
v = PresetVoice(
voice_id="test_v1",
name="测试音色",
description="测试描述",
gender="female",
)
assert v.voice_id == "test_v1"
assert v.name == "测试音色"
assert v.description == "测试描述"
assert v.gender == "female"
def test_default_language(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
assert v.language == "zh-CN"
def test_default_preview_url(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
assert v.preview_url == ""
def test_default_tags_none(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
assert v.tags is None
def test_custom_tags(self):
v = PresetVoice(
voice_id="v1",
name="n",
description="d",
gender="female",
tags=["温柔", "女声"],
)
assert v.tags == ["温柔", "女声"]
def test_is_frozen(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
with pytest.raises(AttributeError):
v.name = "改了"
class TestPresetVoiceToDict:
"""to_dict 序列化测试"""
def test_to_dict_basic(self):
v = PresetVoice(
voice_id="longxiaochun_v3",
name="龙小淳",
description="温柔女声",
gender="female",
language="zh-CN",
preview_url="https://example.com/audio.mp3",
tags=["温柔", "女声"],
)
d = v.to_dict()
assert d["voice_id"] == "longxiaochun_v3"
assert d["name"] == "龙小淳"
assert d["description"] == "温柔女声"
assert d["gender"] == "female"
assert d["language"] == "zh-CN"
assert d["preview_url"] == "https://example.com/audio.mp3"
assert d["tags"] == ["温柔", "女声"]
def test_to_dict_tags_none_becomes_empty_list(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
d = v.to_dict()
assert d["tags"] == []
class TestPresetVoiceList:
"""预置音色列表测试"""
def test_list_not_empty(self):
voices = get_preset_voices()
assert len(voices) > 0
def test_all_are_preset_voice_instances(self):
for v in PRESET_VOICES:
assert isinstance(v, PresetVoice)
def test_voice_ids_unique(self):
ids = [v.voice_id for v in PRESET_VOICES]
assert len(ids) == len(set(ids))
def test_all_have_required_fields(self):
for v in PRESET_VOICES:
assert v.voice_id
assert v.name
assert v.description
assert v.gender in ("male", "female")
assert v.language
def test_total_count(self):
assert len(PRESET_VOICES) == 8
class TestGetPresetVoiceById:
"""按 ID 查询预置音色测试"""
def test_existing_voice(self):
v = get_preset_voice_by_id("longxiaochun_v3")
assert v is not None
assert v.name == "龙小淳"
assert v.gender == "female"
def test_nonexistent_voice(self):
v = get_preset_voice_by_id("nonexistent_voice")
assert v is None
def test_empty_string(self):
v = get_preset_voice_by_id("")
assert v is None
class TestIsPresetVoice:
"""判断是否预置音色测试"""
def test_existing_is_preset(self):
assert is_preset_voice("longxiaochen_v3") is True
def test_nonexistent_not_preset(self):
assert is_preset_voice("custom_voice_123") is False
def test_empty_not_preset(self):
assert is_preset_voice("") is False
class TestPresetVoiceSamples:
"""预置音色样本验证"""
@pytest.mark.parametrize(
"voice_id,expected_name,gender",
[
("longxiaochun_v3", "龙小淳", "female"),
("longxiaoxia_v3", "龙小夏", "female"),
("longxiaochen_v3", "龙小晨", "male"),
("longyue_v3", "龙悦", "female"),
("longshu_v3", "龙书", "male"),
("longjing_v3", "龙静", "female"),
("longbo_v3", "龙博", "male"),
("longtian_v3", "龙甜", "female"),
],
)
def test_all_preset_voices_sample(self, voice_id, expected_name, gender):
v = get_preset_voice_by_id(voice_id)
assert v is not None
assert v.name == expected_name
assert v.gender == gender
assert v.language == "zh-CN"
assert len(v.tags or []) >= 2
-84
View File
@@ -1,84 +0,0 @@
"""
Recipe 配方领域模型单元测试
"""
from packages.domain.recipe import Recipe, RecipeItem
class TestRecipeItem:
"""RecipeItem 测试"""
def test_create_item(self):
item = RecipeItem(
id="item-1",
recipe_id="recipe-1",
item_type="asset",
item_id="asset-123",
position=0,
)
assert item.id == "item-1"
assert item.recipe_id == "recipe-1"
assert item.item_type == "asset"
assert item.item_id == "asset-123"
assert item.position == 0
assert item.metadata_ == {}
def test_item_with_metadata(self):
item = RecipeItem(
id="item-1",
recipe_id="r1",
item_type="voice",
item_id="voice-1",
position=2,
metadata_={"speed": 1.0, "pitch": 0},
)
assert item.metadata_["speed"] == 1.0
assert item.metadata_["pitch"] == 0
class TestRecipe:
"""Recipe 测试"""
def test_create_minimal(self):
r = Recipe(id="r1", user_id="u1", name="我的配方")
assert r.id == "r1"
assert r.user_id == "u1"
assert r.name == "我的配方"
def test_default_values(self):
r = Recipe(id="r1", user_id="u1", name="n")
assert r.description == ""
assert r.template_id == ""
assert r.generation_params == {}
assert r.items == []
assert r.is_active is True
assert r.metadata_ == {}
def test_with_items(self):
items = [
RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=0),
RecipeItem(id="i2", recipe_id="r1", item_type="title", item_id="t1", position=1),
]
r = Recipe(id="r1", user_id="u1", name="n", items=items)
assert len(r.items) == 2
assert r.items[0].item_type == "asset"
assert r.items[1].item_type == "title"
def test_with_generation_params(self):
params = {"mode": "one_take", "duration": 30}
r = Recipe(id="r1", user_id="u1", name="n", generation_params=params)
assert r.generation_params["mode"] == "one_take"
def test_recipe_inactive(self):
r = Recipe(id="r1", user_id="u1", name="n", is_active=False)
assert r.is_active is False
def test_has_timestamps(self):
r = Recipe(id="r1", user_id="u1", name="n")
assert r.created_at is not None
assert r.updated_at is not None
def test_all_item_types(self):
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
+414 -183
View File
@@ -1,59 +1,73 @@
"""字幕领域模型单元测试."""
"""
Subtitle 字幕领域模型单元测试
"""
from __future__ import annotations
import pytest
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
from packages.domain.subtitle import (
SubtitleSegment,
SubtitleTimeline,
SubtitleWord,
)
class TestSubtitleWord:
"""SubtitleWord 测试."""
"""SubtitleWord 测试"""
def test_basic_properties(self):
word = SubtitleWord(text="你好", start=1.0, end=1.5)
assert word.text == "你好"
assert word.start == 1.0
assert word.end == 1.5
assert word.duration == 0.5
def test_duration_positive(self):
word = SubtitleWord(text="你好", start=1.0, end=2.5)
assert word.duration == pytest.approx(1.5)
def test_duration_zero_when_end_before_start(self):
word = SubtitleWord(text="test", start=2.0, end=1.0)
def test_duration_zero(self):
word = SubtitleWord(text="a", start=5.0, end=5.0)
assert word.duration == 0.0
def test_duration_zero_when_same_time(self):
word = SubtitleWord(text="test", start=1.0, end=1.0)
def test_duration_negative_returns_zero(self):
"""测试结束时间小于开始时间时返回 0"""
word = SubtitleWord(text="a", start=3.0, end=1.0)
assert word.duration == 0.0
class TestSubtitleSegment:
"""SubtitleSegment 测试."""
"""SubtitleSegment 测试"""
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
def test_duration(self):
seg = SubtitleSegment(text="你好世界", start=0.0, end=3.0)
assert seg.duration == pytest.approx(3.0)
def test_duration_zero(self):
seg = SubtitleSegment(text="test", start=5.0, end=5.0)
assert seg.duration == 0.0
def test_duration_negative_returns_zero(self):
seg = SubtitleSegment(text="test", start=5.0, end=2.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_default_words_empty(self):
seg = SubtitleSegment(text="test", start=0, end=1)
assert seg.words == []
def test_duration_with_words(self):
def test_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=0.0, end=1.0),
SubtitleWord(text="世界", start=1.0, end=2.0),
]
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
seg = SubtitleSegment(text="你好世界", start=0.0, end=2.0, words=words)
assert len(seg.words) == 2
assert seg.words[0].text == "你好"
assert seg.words[1].text == "世界"
class TestSubtitleTimelineBasics:
"""SubtitleTimeline 基础属性测试."""
"""SubtitleTimeline 基础属性测试"""
def test_empty_timeline(self):
tl = SubtitleTimeline()
@@ -62,211 +76,428 @@ class TestSubtitleTimelineBasics:
assert tl.language == "zh"
assert tl.total_duration == 0.0
def test_single_segment(self):
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):
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=segs, total_duration=3.0)
def test_segment_count(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="a", start=0, end=1),
SubtitleSegment(text="b", start=1, end=2),
SubtitleSegment(text="c", start=2, end=3),
]
)
assert tl.segment_count == 3
def test_total_chars(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=0, end=1),
SubtitleSegment(text="世界", start=1, end=2),
SubtitleSegment(text="abcde", start=2, end=3),
]
)
assert tl.total_chars == 9
assert tl.total_duration == 3.0
def test_custom_language(self):
tl = SubtitleTimeline(language="en")
assert tl.language == "en"
def test_custom_total_duration(self):
tl = SubtitleTimeline(total_duration=60.0)
assert tl.total_duration == 60.0
class TestSubtitleTimelineMergeShort:
"""合并短字幕片段测试."""
def test_empty_or_single_no_change(self):
class TestMergeShortSegments:
"""merge_short_segments 测试"""
def test_single_segment_no_merge(self):
"""单个片段不需要合并"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="a", start=0, end=1),
]
)
result = tl.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == "a"
def test_empty_timeline(self):
"""空时间轴"""
tl = SubtitleTimeline()
result = tl.merge_short_segments()
result = tl.merge_short_segments(min_chars=8)
assert result.segment_count == 0
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_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=segs)
result = tl.merge_short_segments(min_chars=4)
# "你好"+"世界"=4字,合并;"今天天气很好"=6字,保留
assert result.segment_count == 2
def test_all_short_segments_merge_into_one(self):
"""所有短片段合并成一个"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0, end=0.5),
SubtitleSegment(text="", start=0.5, end=1.0),
SubtitleSegment(text="", start=1.0, end=1.5),
SubtitleSegment(text="", start=1.5, end=2.0),
]
)
result = tl.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.0
assert result.segments[1].text == "今天天气很好"
assert result.segments[0].start == 0
assert result.segments[0].end == 2.0
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=segs)
result = tl.merge_short_segments(min_chars=4)
# "一二三四五六七八"=8字 → 保留
# "短"+"尾"=2字 < 4 → 合并到上一段
def test_merge_short_segments_preserves_timing(self):
"""合并后时间轴正确"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=1.0, end=2.0),
SubtitleSegment(text="世界", start=2.0, end=3.5),
]
)
result = tl.merge_short_segments(min_chars=10)
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六七八短尾"
assert result.segments[0].start == 1.0
assert result.segments[0].end == 3.5
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)
def test_merge_short_segments_with_words(self):
"""合并后词级信息保留"""
w1 = SubtitleWord(text="你好", start=0.0, end=1.0)
w2 = SubtitleWord(text="世界", start=1.0, end=2.0)
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]),
SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]),
]
)
result = tl.merge_short_segments(min_chars=10)
assert len(result.segments[0].words) == 2
assert result.segments[0].words[0].text == "你好"
assert result.segments[0].words[1].text == "世界"
def test_multiple_merged_groups(self):
"""多个合并组 — 短段会和后续段累积到够数才提交"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字,够数,提交
SubtitleSegment(text="", start=2, end=2.5), # 1字,入buffer
SubtitleSegment(text="", start=2.5, end=3), # 1字,入buffer(共2字)
SubtitleSegment(text="一二三四五六七八九十", start=3, end=5), # 10字,入buffer后共12字,够数提交
]
)
result = tl.merge_short_segments(min_chars=8)
# 第1段:"一二三四五六七八"(8字直接提交)
# 第2段:"九十" + "一二三四五六七八九十" 累积到12字一起提交
assert result.segment_count == 2
assert result.segments[0].text == "一二三四五六七八"
assert result.segments[1].text == "九十一二三四五六七八九十"
def test_remaining_short_merged_with_last(self):
"""剩余短片段合并到最后一段"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字
SubtitleSegment(text="一二三", start=2, end=3), # 3字,不够
]
)
result = tl.merge_short_segments(min_chars=8)
# 最后的3字会合并到上一段(因为 < min_chars
assert result.segment_count == 1
assert len(result.segments[0].words) == 4
assert result.segments[0].text == "一二三四五六七八一二三"
def test_custom_min_chars(self):
"""自定义最小字数 — 累积到够数就提交,剩余短的合并到最后"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二", start=0, end=1),
SubtitleSegment(text="三四", start=1, end=2),
SubtitleSegment(text="五六", start=2, end=3),
]
)
# min_chars=3
# "一二"(2字) → 不够
# +"三四"(共4字) → 够了,提交"一二三四"buffer清空
# "五六"(2字) → 循环结束,剩余<min_chars且merged非空 → 合并到最后一段
# 结果:1段 "一二三四五六"
result = tl.merge_short_segments(min_chars=3)
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六"
def test_preserves_language_and_duration(self):
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
"""合并后保留语言和总时长"""
tl = SubtitleTimeline(
segments=[SubtitleSegment(text="a", start=0, end=1)],
language="en",
total_duration=60.0,
)
result = tl.merge_short_segments(min_chars=8)
assert result.language == "en"
assert result.total_duration == 60.0
def test_does_not_modify_original(self):
"""不修改原时间轴"""
segments = [
SubtitleSegment(text="a", start=0, end=1),
SubtitleSegment(text="b", start=1, end=2),
]
tl = SubtitleTimeline(segments=segments)
result = tl.merge_short_segments(min_chars=5)
# 原时间轴不变
assert tl.segment_count == 2
assert result is not tl
class TestSubtitleTimelineSplitLong:
"""拆分长字幕片段测试."""
class TestSplitLongSegments:
"""split_long_segments 测试"""
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)
def test_short_segments_no_split(self):
"""片段不需要拆分"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=0, end=1),
]
)
result = tl.split_long_segments(max_chars=20)
assert result.segment_count == 1
assert result.segments[0].text == "你好"
def test_split_by_sentence_punctuation(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 result.segment_count >= 2
assert result.segments[0].text.endswith("")
assert result.total_chars == len(text)
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)
def test_single_long_segment_split_by_punctuation(self):
"""长片段按标点拆分"""
text = "你好世界。今天天气真好,我们出去玩吧!"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=10)
# 应该被拆成多段
assert result.segment_count > 1
# 所有片段都不超过 max_chars
for s in result.segments:
assert s.char_count <= 8
# 段都不超过 max_chars(除了硬切的情况)
for seg in result.segments:
assert seg.char_count <= len(text) # 至少比原文短
def test_split_preserves_total_text(self):
"""拆分后总文本不变"""
text = "你好世界。今天天气真好,我们出去玩吧!明天再见。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=8)
merged_text = "".join(s.text for s in result.segments)
assert merged_text == text
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=4)
assert result.segment_count >= 2
# 总时长保持一致
assert abs(result.segments[-1].end - 10.0) < 0.01
"""拆分后时间按字数比例分配"""
text = "一二三四五六七八九十。" # 11字
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=5)
# 总时长不变
assert result.segments[0].start == 0.0
assert result.segments[-1].end == pytest.approx(10.0)
# 各段首尾相接
for i in range(len(result.segments) - 1):
assert result.segments[i].end == pytest.approx(result.segments[i + 1].start)
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),
SubtitleWord(text="你好", start=0.0, end=1.0),
SubtitleWord(text="世界", start=1.0, end=2.0),
SubtitleWord(text="你好吗", start=2.0, end=3.5),
]
text = "一二三四五六七八"
seg = SubtitleSegment(text=text, start=0.0, end=4.0, words=words)
tl = SubtitleTimeline(segments=[seg])
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好世界。你好吗?", start=0.0, end=3.5, words=words),
]
)
result = tl.split_long_segments(max_chars=4)
assert result.segment_count >= 2
# 词的总数应该不变
# 第一段应该有前几个词
assert len(result.segments) >= 2
total_words = sum(len(s.words) for s in result.segments)
assert total_words == 4
assert total_words == 3 # 词的总数不变
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"
def test_multiple_mixed_segments(self):
"""混合长短片段"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0, end=1), # 短
SubtitleSegment(text="一二三四五六七八九十一二三四五六七八九十", start=1, end=5), # 长
SubtitleSegment(text="也短", start=5, end=6), # 短
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 3 # 至少3段(中间被拆成多段)
# 第一段还是原来的短的
assert result.segments[0].text == ""
# 最后一段还是原来的短的
assert result.segments[-1].text == "也短"
def test_no_punctuation_hard_split(self):
"""没有标点时硬切"""
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 3
for seg in result.segments:
# 硬切的每段应该 <= max_chars
assert seg.char_count <= 10
def test_preserves_language_and_duration(self):
"""拆分后保留语言和总时长"""
tl = SubtitleTimeline(
segments=[SubtitleSegment(text="a", start=0, end=1)],
language="ja",
total_duration=30.0,
)
result = tl.split_long_segments(max_chars=20)
assert result.language == "ja"
assert result.total_duration == 30.0
def test_does_not_modify_original(self):
"""不修改原时间轴"""
original_text = "一二三四五六七八九十一二三四五六七八九十"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=original_text, start=0, end=5),
]
)
result = tl.split_long_segments(max_chars=8)
assert tl.segment_count == 1
assert tl.segments[0].text == original_text
assert result is not tl
class TestSplitTextByPunctuation:
"""标点拆分静态方法测试."""
"""_split_text_by_punctuation 静态方法测试"""
def test_short_text_no_split(self):
result = SubtitleTimeline._split_text_by_punctuation("你好世界", 10)
assert result == ["你好世界"]
def test_split_at_sentence_end(self):
"""在句末标点处断开"""
result = SubtitleTimeline._split_text_by_punctuation("你好。世界。", 5)
assert len(result) == 2
assert result[0] == "你好。"
assert result[1] == "世界。"
def test_split_at_comma(self):
"""在逗号处断开(超过最大长度时)"""
text = "一二三四五六七八,二二三四五六七八。"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert len(result) >= 2
def test_no_punctuation_hard_split(self):
"""没有标点时硬切"""
result = SubtitleTimeline._split_text_by_punctuation("一二三四五六七八九十", 5)
assert len(result) == 2
assert result[0] == "一二三四五"
assert result[1] == "六七八九十"
def test_empty_text(self):
# 空字符串循环不执行,current为空不append,返回空列表
result = SubtitleTimeline._split_text_by_punctuation("", 10)
assert result == []
def test_short_text_no_split(self):
result = SubtitleTimeline._split_text_by_punctuation("短文本", 10)
assert len(result) == 1
def test_mixed_punctuation(self):
"""混合标点"""
text = "你好!吃饭了吗?是的,我吃过了。"
result = SubtitleTimeline._split_text_by_punctuation(text, 6)
# 验证所有段加起来等于原文
assert "".join(result) == text
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"
def test_sentence_end_with_min_length(self):
"""句末标点断句的「半长门槛」只在未超max_chars时生效;
超过max_chars回溯找标点时,即使首段很短也会断开。"""
# "你好。" 3字 < max_chars//2(5),未超max_chars时不会主动断开
# 但加上后面的"世界很大很美好"后超过10字,回溯找标点找到"。",强制断开
text = "你好。世界很大很美好。"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
# "你好世界。"=5字 < 10但>=5(half),应该拆分
assert len(result) >= 2
# 超过max_chars时回溯断开,首段可能很短
assert len(result) == 2
assert result[0] == "你好。"
assert result[1] == "世界很大很美好。"
# 总文本不变
assert "".join(result) == text
def test_exclamation_and_question_marks(self):
"""感叹号和问号也算句末标点"""
text = "你好吗!我很好!你呢?"
result = SubtitleTimeline._split_text_by_punctuation(text, 4)
assert len(result) >= 3
class TestMergeSegments:
"""_merge_segments 静态方法测试."""
"""_merge_segments 静态方法测试"""
def test_merge_empty(self):
def test_merge_two_segments(self):
result = SubtitleTimeline._merge_segments(
[
SubtitleSegment(text="你好", start=0.0, end=1.0),
SubtitleSegment(text="世界", start=1.0, end=2.0),
]
)
assert result.text == "你好世界"
assert result.start == 0.0
assert result.end == 2.0
def test_merge_empty_list(self):
result = SubtitleTimeline._merge_segments([])
assert result.text == ""
assert result.start == 0
assert result.end == 0
def test_merge_single(self):
def test_merge_single_segment(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_multiple(self):
segs = [
SubtitleSegment(text="第一", start=0.0, end=1.0),
SubtitleSegment(text="第二", start=1.0, end=2.0),
]
result = SubtitleTimeline._merge_segments(segs)
assert result.text == "第一第二"
def test_merge_preserves_words(self):
w1 = SubtitleWord(text="你好", start=0.0, end=1.0)
w2 = SubtitleWord(text="世界", start=1.0, end=2.0)
result = SubtitleTimeline._merge_segments(
[
SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]),
SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]),
]
)
assert len(result.words) == 2
assert result.words[0].text == "你好"
assert result.words[1].text == "世界"
def test_merge_non_contiguous_segments(self):
"""合并非连续片段(有间隙)"""
result = SubtitleTimeline._merge_segments(
[
SubtitleSegment(text="a", start=0.0, end=1.0),
SubtitleSegment(text="b", start=3.0, end=4.0),
]
)
assert result.start == 0.0
assert result.end == 2.0
assert result.end == 4.0
assert result.text == "ab"
class TestMergeAndSplitRoundtrip:
"""合并和拆分的组合测试"""
def test_split_then_merge_approximate(self):
"""拆分后再合并,总字数和总时长基本一致"""
original_text = "你好世界。今天天气真好,我们出去玩吧!明天见。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=original_text, start=0.0, end=10.0),
]
)
split = tl.split_long_segments(max_chars=5)
merged = split.merge_short_segments(min_chars=50) # 足够大的min_chars让它们都合并
assert merged.segment_count == 1
assert merged.segments[0].text == original_text
assert merged.segments[0].start == 0.0
assert merged.segments[0].end == pytest.approx(10.0)
-34
View File
@@ -1,34 +0,0 @@
"""
Tag 标签领域模型单元测试
"""
import pytest
from packages.domain.tag import Tag
class TestTagCreate:
"""创建标签测试"""
def test_create_basic(self):
tag = Tag.create(user_id="user-1", name="风景")
assert tag.id is not None
assert len(tag.id) == 32
assert tag.user_id == "user-1"
assert tag.name == "风景"
def test_create_strips_name(self):
tag = Tag.create(user_id="user-1", name=" 风景 ")
assert tag.name == "风景"
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="user-1", name="")
def test_create_whitespace_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="user-1", name=" ")
def test_create_has_created_at(self):
tag = Tag.create(user_id="user-1", name="美食")
assert tag.created_at is not None
@@ -1,208 +0,0 @@
"""template_clip_config 领域模型单元测试."""
import pytest
from domain.template_clip_config import (
ClipType,
TemplateClipConfig,
TransitionEffect,
)
class TestClipType:
"""ClipType 枚举测试."""
def test_values(self):
assert ClipType.INTRO == "intro"
assert ClipType.MAIN == "main"
assert ClipType.TRANSITION == "transition"
assert ClipType.OUTRO == "outro"
assert ClipType.TITLE == "title"
assert ClipType.SUBTITLE == "subtitle"
class TestTransitionEffect:
"""TransitionEffect 枚举测试."""
def test_values(self):
assert TransitionEffect.CUT == "cut"
assert TransitionEffect.FADE == "fade"
assert TransitionEffect.SLIDE_LEFT == "slide_left"
assert TransitionEffect.SLIDE_RIGHT == "slide_right"
assert TransitionEffect.DISSOLVE == "dissolve"
assert TransitionEffect.WIPE == "wipe"
class TestTemplateClipConfigCreate:
"""TemplateClipConfig.create 工厂方法测试."""
def test_create_with_required_fields(self):
clip = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=1)
assert clip.id
assert len(clip.id) == 32
assert clip.template_id == "tpl_001"
assert clip.clip_type == ClipType.MAIN
assert clip.order == 1
assert clip.min_duration == 0.0
assert clip.max_duration == 0.0
assert clip.text_template == ""
assert clip.material_requirements == {}
assert clip.transition_effect == TransitionEffect.CUT
assert clip.config == {}
def test_create_with_all_fields(self):
clip = TemplateClipConfig.create(
template_id="tpl_002",
clip_type=ClipType.INTRO,
order=2,
min_duration=3.0,
max_duration=10.0,
text_template="欢迎来到{channel}",
material_requirements={"type": "video", "min_count": 1},
transition_effect=TransitionEffect.FADE,
config={"key": "value"},
)
assert clip.clip_type == ClipType.INTRO
assert clip.min_duration == 3.0
assert clip.max_duration == 10.0
assert clip.text_template == "欢迎来到{channel}"
assert clip.material_requirements == {"type": "video", "min_count": 1}
assert clip.transition_effect == TransitionEffect.FADE
assert clip.config == {"key": "value"}
def test_create_with_string_clip_type(self):
clip = TemplateClipConfig.create(template_id="tpl_003", clip_type="title", order=1)
assert clip.clip_type == ClipType.TITLE
def test_create_with_string_transition_effect(self):
clip = TemplateClipConfig.create(
template_id="tpl_004",
clip_type=ClipType.MAIN,
order=1,
transition_effect="dissolve",
)
assert clip.transition_effect == TransitionEffect.DISSOLVE
def test_create_strips_strings(self):
clip = TemplateClipConfig.create(
template_id=" tpl_005 ",
clip_type=ClipType.MAIN,
order=1,
text_template=" 测试模板 ",
)
assert clip.template_id == "tpl_005"
assert clip.text_template == "测试模板"
def test_create_empty_template_id_raises(self):
with pytest.raises(ValueError, match="template_id"):
TemplateClipConfig.create(template_id="", clip_type=ClipType.MAIN, order=1)
def test_create_whitespace_template_id_raises(self):
with pytest.raises(ValueError, match="template_id"):
TemplateClipConfig.create(template_id=" ", clip_type=ClipType.MAIN, order=1)
def test_create_invalid_clip_type_raises(self):
with pytest.raises(ValueError):
TemplateClipConfig.create(template_id="tpl", clip_type="invalid_type", order=1)
def test_create_invalid_transition_effect_raises(self):
with pytest.raises(ValueError):
TemplateClipConfig.create(
template_id="tpl",
clip_type=ClipType.MAIN,
order=1,
transition_effect="invalid_effect",
)
def test_create_negative_min_duration_raises(self):
with pytest.raises(ValueError, match="min_duration"):
TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=-1.0)
def test_create_negative_max_duration_raises(self):
with pytest.raises(ValueError, match="max_duration"):
TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=-1.0)
def test_create_min_greater_than_max_raises(self):
with pytest.raises(ValueError, match="min_duration 不能大于 max_duration"):
TemplateClipConfig.create(
template_id="tpl",
clip_type=ClipType.MAIN,
order=1,
min_duration=10.0,
max_duration=5.0,
)
def test_create_min_equals_max_ok(self):
clip = TemplateClipConfig.create(
template_id="tpl",
clip_type=ClipType.MAIN,
order=1,
min_duration=5.0,
max_duration=5.0,
)
assert clip.min_duration == 5.0
assert clip.max_duration == 5.0
def test_create_zero_duration_range_ok(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
assert clip.min_duration == 0.0
assert clip.max_duration == 0.0
def test_create_none_material_requirements_defaults_to_empty_dict(self):
clip = TemplateClipConfig.create(
template_id="tpl", clip_type=ClipType.MAIN, order=1, material_requirements=None
)
assert clip.material_requirements == {}
def test_create_none_config_defaults_to_empty_dict(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, config=None)
assert clip.config == {}
def test_create_ids_are_unique(self):
c1 = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
c2 = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=2)
assert c1.id != c2.id
def test_create_timestamps_are_utc(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
assert clip.created_at.tzinfo is not None
assert clip.updated_at.tzinfo is not None
class TestTemplateClipConfigProperties:
"""属性方法测试."""
def test_has_duration_range_false_when_both_zero(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
assert clip.has_duration_range is False
def test_has_duration_range_true_when_min_set(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=2.0)
assert clip.has_duration_range is True
def test_has_duration_range_true_when_max_set(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=10.0)
assert clip.has_duration_range is True
def test_default_duration_both_zero(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
assert clip.default_duration == 0.0
def test_default_duration_only_min(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0)
assert clip.default_duration == 5.0
def test_default_duration_only_max(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=10.0)
assert clip.default_duration == 10.0
def test_default_duration_both_set_is_midpoint(self):
clip = TemplateClipConfig.create(
template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0, max_duration=15.0
)
assert clip.default_duration == 10.0
def test_default_duration_min_equals_max(self):
clip = TemplateClipConfig.create(
template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0, max_duration=5.0
)
assert clip.default_duration == 5.0
-153
View File
@@ -1,153 +0,0 @@
"""
Template 模板领域模型单元测试
"""
from domain.template import Template, TemplateCategory, TemplateSegment
class TestTemplateSegment:
"""TemplateSegment 测试"""
def test_create_segment(self):
seg = TemplateSegment(
id="seg-1",
template_id="tpl-1",
segment_order=1,
duration_min=3.0,
duration_max=5.0,
)
assert seg.id == "seg-1"
assert seg.template_id == "tpl-1"
assert seg.segment_order == 1
assert seg.duration_min == 3.0
assert seg.duration_max == 5.0
assert seg.material_type is None
def test_segment_with_material_type(self):
seg = TemplateSegment(
id="seg-1",
template_id="tpl-1",
segment_order=0,
duration_min=2.0,
duration_max=4.0,
material_type="人物",
)
assert seg.material_type == "人物"
def test_segment_has_timestamps(self):
seg = TemplateSegment(
id="seg-1",
template_id="tpl-1",
segment_order=1,
duration_min=1.0,
duration_max=2.0,
)
assert seg.created_at is not None
assert seg.updated_at is not None
class TestTemplate:
"""Template 测试"""
def test_create_template_minimal(self):
t = Template(
id="tpl-1",
user_id="user-1",
name="测试模板",
mode="one_take",
)
assert t.id == "tpl-1"
assert t.user_id == "user-1"
assert t.name == "测试模板"
assert t.mode == "one_take"
def test_default_values(self):
t = Template(id="tpl-1", user_id="u1", name="n", mode="one_take")
assert t.category == ""
assert t.tags == []
assert t.title_config == {}
assert t.subtitle_config == {}
assert t.bgm_config == {}
assert t.estimated_duration == 0.0
assert t.segments == []
assert t.is_active is True
def test_with_segments(self):
segs = [
TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=2, duration_max=4),
TemplateSegment(id="s2", template_id="t1", segment_order=1, duration_min=3, duration_max=5),
]
t = Template(
id="tpl-1",
user_id="u1",
name="n",
mode="voice_over",
segments=segs,
)
assert len(t.segments) == 2
assert t.segments[0].segment_order == 0
assert t.segments[1].segment_order == 1
def test_all_modes(self):
for mode in ["pip", "voice_pip", "one_take", "voice_over"]:
t = Template(id="t1", user_id="u1", name="n", mode=mode)
assert t.mode == mode
def test_with_configs(self):
t = Template(
id="t1",
user_id="u1",
name="n",
mode="one_take",
title_config={"font_size": 24, "color": "#ffffff"},
subtitle_config={"style": "bottom"},
bgm_config={"volume": 0.5},
)
assert t.title_config["font_size"] == 24
assert t.subtitle_config["style"] == "bottom"
assert t.bgm_config["volume"] == 0.5
def test_estimated_duration(self):
t = Template(
id="t1",
user_id="u1",
name="n",
mode="one_take",
estimated_duration=30.5,
)
assert t.estimated_duration == 30.5
def test_is_active_false(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", is_active=False)
assert t.is_active is False
def test_has_timestamps(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take")
assert t.created_at is not None
assert t.updated_at is not None
def test_tags_list(self):
t = Template(
id="t1",
user_id="u1",
name="n",
mode="one_take",
tags=["风景", "vlog"],
)
assert "风景" in t.tags
assert "vlog" in t.tags
assert len(t.tags) == 2
class TestTemplateCategory:
"""TemplateCategory 测试"""
def test_create_category(self):
cat = TemplateCategory(id="cat-1", user_id="u1", name="风景")
assert cat.id == "cat-1"
assert cat.user_id == "u1"
assert cat.name == "风景"
def test_category_has_timestamp(self):
cat = TemplateCategory(id="cat-1", user_id="u1", name="风景")
assert cat.created_at is not None
-127
View File
@@ -1,127 +0,0 @@
"""
EditTemplateVersion 模板版本领域模型单元测试
"""
import pytest
from domain.template_version import EditTemplateVersion
class TestEditTemplateVersionCreate:
"""创建模板版本测试"""
def test_create_required_fields(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1)
assert v.id is not None
assert len(v.id) == 32
assert v.template_id == "tpl-1"
assert v.version == 1
def test_default_values(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1)
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
def test_with_name_and_mode(self):
v = EditTemplateVersion.create(
template_id="tpl-1",
version=2,
name="风景Vlog模板",
editing_mode="voice_over",
)
assert v.name == "风景Vlog模板"
assert v.editing_mode == "voice_over"
def test_with_config(self):
config = {
"title": {"font_size": 24},
"subtitle": {"style": "bottom"},
"bgm": {"volume": 0.5},
}
v = EditTemplateVersion.create(
template_id="tpl-1",
version=1,
config=config,
)
assert v.config == config
assert v.config["title"]["font_size"] == 24
def test_with_clip_configs(self):
clips = [
{"clip_id": 1, "duration": 3.0, "transition": "fade"},
{"clip_id": 2, "duration": 5.0, "transition": "slide"},
]
v = EditTemplateVersion.create(
template_id="tpl-1",
version=1,
clip_configs=clips,
)
assert len(v.clip_configs) == 2
assert v.clip_configs[0]["clip_id"] == 1
def test_config_none_defaults_to_empty_dict(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1, config=None)
assert v.config == {}
def test_clip_configs_none_defaults_to_empty_list(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1, clip_configs=None)
assert v.clip_configs == []
def test_with_change_note(self):
v = EditTemplateVersion.create(
template_id="tpl-1",
version=3,
change_note="优化转场效果,新增滤镜",
)
assert v.change_note == "优化转场效果,新增滤镜"
def test_with_published_by(self):
v = EditTemplateVersion.create(
template_id="tpl-1",
version=1,
published_by="user-123",
)
assert v.published_by == "user-123"
def test_full_version(self):
config = {"bgm": {"volume": 0.3}}
clips = [{"clip_id": 1, "duration": 2.5}]
v = EditTemplateVersion.create(
template_id="tpl-abc",
version=5,
name="正式版v5",
editing_mode="one_take",
config=config,
clip_configs=clips,
change_note="第五次发布",
published_by="admin",
)
assert v.template_id == "tpl-abc"
assert v.version == 5
assert v.name == "正式版v5"
assert v.editing_mode == "one_take"
assert v.config == config
assert v.clip_configs == clips
assert v.change_note == "第五次发布"
assert v.published_by == "admin"
def test_version_number(self):
for ver in [1, 2, 5, 10, 99]:
v = EditTemplateVersion.create(template_id="t1", version=ver)
assert v.version == ver
def test_has_created_at(self):
v = EditTemplateVersion.create(template_id="t1", version=1)
assert v.created_at is not None
class TestEditTemplateVersionSlots:
"""slots 模式属性测试"""
def test_cannot_add_new_attribute(self):
v = EditTemplateVersion.create(template_id="t1", version=1)
with pytest.raises(AttributeError):
v.nonexistent_field = "value" # type: ignore[attr-defined]
-78
View File
@@ -1,78 +0,0 @@
"""
TitleLibraryItem 标题库领域模型单元测试
"""
from packages.domain.title_library import TitleLibraryItem
class TestTitleLibraryItem:
"""TitleLibraryItem 测试"""
def test_create_minimal(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="标题1", text="这是标题文本")
assert item.id == "t1"
assert item.user_id == "u1"
assert item.name == "标题1"
assert item.text == "这是标题文本"
def test_default_values(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
assert item.category == "default"
assert item.description == ""
assert item.tags == []
assert item.usage_count == 0
assert item.is_active is True
assert item.metadata_ == {}
def test_with_category(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="n",
text="t",
category="美食",
)
assert item.category == "美食"
def test_with_tags(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="n",
text="t",
tags=["爆款", "美食"],
)
assert len(item.tags) == 2
assert "爆款" in item.tags
def test_usage_count(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
assert item.usage_count == 0
item.usage_count = 10
assert item.usage_count == 10
def test_inactive(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="n",
text="t",
is_active=False,
)
assert item.is_active is False
def test_with_metadata(self):
meta = {"source": "import", "quality": "high"}
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="n",
text="t",
metadata_=meta,
)
assert item.metadata_["source"] == "import"
def test_has_timestamps(self):
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
@@ -1,181 +0,0 @@
"""transition_presets 模块单元测试."""
from dataclasses import FrozenInstanceError
import pytest
from domain.transition_presets import (
TRANSITION_PRESET_LIBRARY,
TransitionPreset,
get_default_transition,
get_transition_preset,
list_transition_presets,
)
class TestTransitionPreset:
"""TransitionPreset 数据类测试."""
def test_create_required_fields(self):
t = TransitionPreset(id="test_001", name="测试转场", category="basic")
assert t.id == "test_001"
assert t.name == "测试转场"
assert t.category == "basic"
# 默认值
assert t.description == ""
assert t.tags == []
assert t.transition == "fade"
assert t.default_duration == 0.5
assert t.min_duration == 0.1
assert t.max_duration == 3.0
assert t.has_custom_params is False
def test_create_all_fields(self):
t = TransitionPreset(
id="test_002",
name="完整转场",
category="slide",
description="测试描述",
tags=["标签1", "标签2"],
transition="slideleft",
default_duration=1.0,
min_duration=0.3,
max_duration=2.5,
has_custom_params=True,
)
assert t.category == "slide"
assert t.description == "测试描述"
assert t.tags == ["标签1", "标签2"]
assert t.transition == "slideleft"
assert t.default_duration == 1.0
assert t.min_duration == 0.3
assert t.max_duration == 2.5
assert t.has_custom_params is True
def test_frozen_immutable(self):
t = TransitionPreset(id="test", name="测试", category="basic")
with pytest.raises(FrozenInstanceError):
t.name = "修改" # type: ignore[misc]
def test_tags_default_new_list(self):
t1 = TransitionPreset(id="1", name="a", category="basic")
t2 = TransitionPreset(id="2", name="b", category="basic")
assert t1.tags is not t2.tags
assert t1.tags == []
class TestTransitionPresetLibrary:
"""TRANSITION_PRESET_LIBRARY 预设库测试."""
def test_not_empty(self):
assert len(TRANSITION_PRESET_LIBRARY) > 0
def test_all_unique_ids(self):
ids = [t.id for t in TRANSITION_PRESET_LIBRARY]
assert len(ids) == len(set(ids)), "转场 ID 不能重复"
def test_all_are_transition_preset_instances(self):
for t in TRANSITION_PRESET_LIBRARY:
assert isinstance(t, TransitionPreset)
def test_contains_basic_categories(self):
cats = {t.category for t in TRANSITION_PRESET_LIBRARY}
assert "basic" in cats
assert "fade" in cats
def test_duration_constraints_valid(self):
"""每个预设的 min <= default <= max."""
for t in TRANSITION_PRESET_LIBRARY:
assert t.min_duration <= t.default_duration, f"{t.id}: min > default"
assert t.default_duration <= t.max_duration, f"{t.id}: default > max"
def test_none_transition_zero_duration(self):
t = get_transition_preset("transition_none")
assert t is not None
assert t.default_duration == 0.0
assert t.min_duration == 0.0
assert t.max_duration == 0.0
class TestGetTransitionPreset:
"""get_transition_preset 函数测试."""
def test_existing_id(self):
t = get_transition_preset("transition_fade")
assert t is not None
assert t.id == "transition_fade"
assert t.name == "淡入淡出"
assert t.category == "fade"
def test_nonexistent_id(self):
assert get_transition_preset("nonexistent") is None
def test_empty_string(self):
assert get_transition_preset("") is None
class TestListTransitionPresets:
"""list_transition_presets 函数测试."""
def test_no_filters_returns_all(self):
result = list_transition_presets()
assert len(result) == len(TRANSITION_PRESET_LIBRARY)
def test_filter_by_category_basic(self):
result = list_transition_presets(category="basic")
assert len(result) >= 2
for t in result:
assert t.category == "basic"
def test_filter_by_category_fade(self):
result = list_transition_presets(category="fade")
assert len(result) >= 3
for t in result:
assert t.category == "fade"
def test_filter_by_unknown_category_returns_empty(self):
result = list_transition_presets(category="nonexistent")
assert result == []
def test_filter_by_keyword_name(self):
result = list_transition_presets(keyword="淡入")
assert len(result) >= 1
assert any(t.name == "淡入淡出" for t in result)
def test_filter_by_keyword_description(self):
result = list_transition_presets(keyword="经典")
assert len(result) >= 1
def test_filter_by_keyword_tag(self):
result = list_transition_presets(keyword="电影感")
assert len(result) >= 1
def test_filter_keyword_case_insensitive(self):
r1 = list_transition_presets(keyword="FADE")
r2 = list_transition_presets(keyword="fade")
assert len(r1) == len(r2)
def test_filter_keyword_no_match(self):
result = list_transition_presets(keyword="xyz_nonexistent_12345")
assert result == []
def test_combined_category_and_keyword(self):
result = list_transition_presets(category="fade", keyword="黑场")
assert len(result) >= 1
for t in result:
assert t.category == "fade"
def test_combined_no_match(self):
result = list_transition_presets(category="basic", keyword="黑场")
assert result == []
class TestGetDefaultTransition:
"""get_default_transition 函数测试."""
def test_returns_none_transition(self):
t = get_default_transition()
assert t.id == "transition_none"
assert t.name == "无转场"
def test_returns_transition_preset_instance(self):
assert isinstance(get_default_transition(), TransitionPreset)
+123 -125
View File
@@ -1,12 +1,14 @@
"""TTS 配音配置领域模型单元测试."""
"""
TTS 配音配置模型单元测试
"""
from __future__ import annotations
import pytest
from packages.domain.tts_config import TtsConfig
class TestTtsConfigDefaults:
"""默认值测试."""
"""默认值测试"""
def test_default_values(self):
config = TtsConfig()
@@ -21,184 +23,180 @@ class TestTtsConfigDefaults:
class TestTtsConfigParse:
"""parse 方法测试."""
"""parse 方法测试"""
def test_parse_none_returns_default(self):
def test_parse_none(self):
config = TtsConfig.parse(None)
assert config.enabled is False
assert config.speed == 1.0
def test_parse_empty_dict_returns_default(self):
def test_parse_empty_dict(self):
config = TtsConfig.parse({})
assert config.enabled is False
def test_parse_not_dict_returns_default(self):
config = TtsConfig.parse("invalid")
def test_parse_not_dict(self):
config = TtsConfig.parse("not a dict")
assert config.enabled is False
config2 = TtsConfig.parse(123)
assert config2.enabled is False
config3 = TtsConfig.parse([])
assert config3.enabled is False
def test_parse_enabled_false_ignores_other_fields(self):
data = {
"enabled": False,
"voice_id": "test_voice",
"speed": 2.0,
"text": "hello",
}
config = TtsConfig.parse(data)
def test_parse_disabled_returns_minimal(self):
"""disabled 时直接返回 enabled=False,忽略其他字段"""
config = TtsConfig.parse(
{
"enabled": False,
"voice_id": "v123",
"speed": 1.5,
}
)
assert config.enabled is False
assert config.voice_id == ""
assert config.speed == 1.0
assert config.voice_id == "" # 不保留
def test_parse_basic_enabled(self):
data = {"enabled": True, "voice_id": "voice_001"}
config = TtsConfig.parse(data)
def test_parse_enabled_true(self):
config = TtsConfig.parse(
{
"enabled": True,
"voice_id": "voice_001",
"speed": 1.2,
"pitch": 2.5,
"volume": 0.5,
"text": "你好世界",
"align_mode": "subtitle",
"overlap_mode": "mix",
}
)
assert config.enabled is True
assert config.voice_id == "voice_001"
assert config.speed == 1.0
assert config.pitch == 0.0
assert config.volume == 0.8
def test_parse_full_config(self):
data = {
"enabled": True,
"voice_id": "voice_001",
"speed": 1.5,
"pitch": 2.0,
"volume": 0.9,
"text": "测试配音文本",
"align_mode": "subtitle",
"overlap_mode": "mix",
}
config = TtsConfig.parse(data)
assert config.enabled is True
assert config.voice_id == "voice_001"
assert config.speed == 1.5
assert config.pitch == 2.0
assert config.volume == 0.9
assert config.text == "测试配音文本"
assert config.speed == 1.2
assert config.pitch == 2.5
assert config.volume == 0.5
assert config.text == "你好世界"
assert config.align_mode == "subtitle"
assert config.overlap_mode == "mix"
def test_parse_enabled_non_bool_fallback(self):
data = {"enabled": "true", "voice_id": "v1"}
config = TtsConfig.parse(data)
def test_parse_enabled_not_bool_false(self):
"""enabled 不是 bool 时视为 False"""
config = TtsConfig.parse({"enabled": "true"})
assert config.enabled is False
def test_parse_voice_id_non_string_fallback(self):
data = {"enabled": True, "voice_id": 123}
config = TtsConfig.parse(data)
def test_parse_enabled_not_bool_zero(self):
config = TtsConfig.parse({"enabled": 0})
assert config.enabled is False
def test_parse_voice_id_not_string(self):
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
assert config.voice_id == ""
def test_parse_speed_non_numeric_fallback(self):
data = {"enabled": True, "speed": "fast"}
config = TtsConfig.parse(data)
def test_parse_speed_not_number(self):
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
assert config.speed == 1.0
def test_parse_pitch_non_numeric_fallback(self):
data = {"enabled": True, "pitch": "high"}
config = TtsConfig.parse(data)
def test_parse_pitch_not_number(self):
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
assert config.pitch == 0.0
def test_parse_volume_non_numeric_fallback(self):
data = {"enabled": True, "volume": "loud"}
config = TtsConfig.parse(data)
def test_parse_volume_not_number(self):
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
assert config.volume == 0.8
def test_parse_text_non_string_fallback(self):
data = {"enabled": True, "text": 12345}
config = TtsConfig.parse(data)
def test_parse_text_not_string(self):
config = TtsConfig.parse({"enabled": True, "text": 12345})
assert config.text == ""
def test_parse_align_mode_invalid_fallback(self):
data = {"enabled": True, "align_mode": "invalid"}
config = TtsConfig.parse(data)
def test_parse_align_mode_invalid(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
assert config.align_mode == "full"
def test_parse_overlap_mode_invalid_fallback(self):
data = {"enabled": True, "overlap_mode": "invalid"}
config = TtsConfig.parse(data)
def test_parse_align_mode_subtitle(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
assert config.align_mode == "subtitle"
def test_parse_align_mode_full(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "full"})
assert config.align_mode == "full"
def test_parse_overlap_mode_invalid(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"})
assert config.overlap_mode == "replace"
def test_parse_overlap_mode_replace(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
assert config.overlap_mode == "replace"
def test_parse_overlap_mode_mix(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
assert config.overlap_mode == "mix"
def test_parse_integer_speed(self):
"""int 类型的 speed 应该被转成 float"""
config = TtsConfig.parse({"enabled": True, "speed": 2})
assert config.speed == 2.0
assert isinstance(config.speed, float)
def test_parse_integer_pitch(self):
config = TtsConfig.parse({"enabled": True, "pitch": -5})
assert config.pitch == -5.0
assert isinstance(config.pitch, float)
def test_parse_integer_volume(self):
config = TtsConfig.parse({"enabled": True, "volume": 1})
assert config.volume == 1.0
assert isinstance(config.volume, float)
class TestTtsConfigClamp:
"""边界钳制测试."""
"""边界钳制测试"""
def test_speed_below_min_clamped(self):
data = {"enabled": True, "speed": 0.1}
config = TtsConfig.parse(data)
def test_speed_too_low(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
assert config.speed == 0.5
def test_speed_above_max_clamped(self):
data = {"enabled": True, "speed": 3.0}
config = TtsConfig.parse(data)
def test_speed_too_high(self):
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
assert config.speed == 2.0
def test_speed_at_min_ok(self):
data = {"enabled": True, "speed": 0.5}
config = TtsConfig.parse(data)
def test_speed_lower_boundary(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.5})
assert config.speed == 0.5
def test_speed_at_max_ok(self):
data = {"enabled": True, "speed": 2.0}
config = TtsConfig.parse(data)
def test_speed_upper_boundary(self):
config = TtsConfig.parse({"enabled": True, "speed": 2.0})
assert config.speed == 2.0
def test_pitch_below_min_clamped(self):
data = {"enabled": True, "pitch": -20}
config = TtsConfig.parse(data)
def test_pitch_too_low(self):
config = TtsConfig.parse({"enabled": True, "pitch": -20})
assert config.pitch == -12
def test_pitch_above_max_clamped(self):
data = {"enabled": True, "pitch": 20}
config = TtsConfig.parse(data)
def test_pitch_too_high(self):
config = TtsConfig.parse({"enabled": True, "pitch": 20})
assert config.pitch == 12
def test_pitch_at_min_ok(self):
data = {"enabled": True, "pitch": -12}
config = TtsConfig.parse(data)
def test_pitch_lower_boundary(self):
config = TtsConfig.parse({"enabled": True, "pitch": -12})
assert config.pitch == -12
def test_pitch_at_max_ok(self):
data = {"enabled": True, "pitch": 12}
config = TtsConfig.parse(data)
def test_pitch_upper_boundary(self):
config = TtsConfig.parse({"enabled": True, "pitch": 12})
assert config.pitch == 12
def test_volume_below_min_clamped(self):
data = {"enabled": True, "volume": -0.5}
config = TtsConfig.parse(data)
def test_volume_negative(self):
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
assert config.volume == 0.0
def test_volume_above_max_clamped(self):
data = {"enabled": True, "volume": 2.0}
config = TtsConfig.parse(data)
def test_volume_over_one(self):
config = TtsConfig.parse({"enabled": True, "volume": 1.5})
assert config.volume == 1.0
def test_volume_at_min_ok(self):
data = {"enabled": True, "volume": 0.0}
config = TtsConfig.parse(data)
def test_volume_zero(self):
config = TtsConfig.parse({"enabled": True, "volume": 0.0})
assert config.volume == 0.0
def test_volume_at_max_ok(self):
data = {"enabled": True, "volume": 1.0}
config = TtsConfig.parse(data)
def test_volume_one(self):
config = TtsConfig.parse({"enabled": True, "volume": 1.0})
assert config.volume == 1.0
def test_int_speed_converted_to_float(self):
data = {"enabled": True, "speed": 1}
config = TtsConfig.parse(data)
assert isinstance(config.speed, float)
assert config.speed == 1.0
def test_int_pitch_converted_to_float(self):
data = {"enabled": True, "pitch": 5}
config = TtsConfig.parse(data)
assert isinstance(config.pitch, float)
assert config.pitch == 5.0
def test_int_volume_converted_to_float(self):
data = {"enabled": True, "volume": 1}
config = TtsConfig.parse(data)
assert isinstance(config.volume, float)
assert config.volume == 1.0
def test_clamp_via_direct_construction(self):
"""直接构造也应该钳制(通过 _clamp 方法)"""
config = TtsConfig(enabled=True, speed=5.0, pitch=100, volume=-1)
config._clamp()
assert config.speed == 2.0
assert config.pitch == 12
assert config.volume == 0.0
-393
View File
@@ -1,393 +0,0 @@
"""tts_job 领域模型单元测试."""
import pytest
from domain.tts_job import TERMINAL_STATUSES, TTSJob, TTSJobStatus
class TestTTSJobStatus:
"""TTSJobStatus 枚举测试."""
def test_values(self):
assert TTSJobStatus.PENDING == "pending"
assert TTSJobStatus.PROCESSING == "processing"
assert TTSJobStatus.COMPLETED == "completed"
assert TTSJobStatus.FAILED == "failed"
assert TTSJobStatus.CANCELLED == "cancelled"
def test_terminal_statuses(self):
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_with_required_fields(self):
job = TTSJob.create(user_id="user_001", input_text="你好世界")
assert job.id
assert len(job.id) == 32
assert job.user_id == "user_001"
assert job.input_text == "你好世界"
assert job.status == TTSJobStatus.PENDING
assert job.voice_id == ""
assert job.sample_rate == 22050
assert job.format == "mp3"
assert job.retry_count == 0
assert job.max_retries == 3
assert job.metadata == {}
assert job.created_at is not None
assert job.updated_at is not None
def test_create_with_all_fields(self):
job = TTSJob.create(
user_id="user_002",
input_text="测试文本",
voice_id="voice_001",
voice_model="cosyvoice",
project_id="proj_001",
voice_clone_profile_id="clone_001",
sample_rate=16000,
format="wav",
max_retries=5,
metadata={"key": "value"},
)
assert job.voice_id == "voice_001"
assert job.voice_model == "cosyvoice"
assert job.project_id == "proj_001"
assert job.voice_clone_profile_id == "clone_001"
assert job.sample_rate == 16000
assert job.format == "wav"
assert job.max_retries == 5
assert job.metadata == {"key": "value"}
def test_create_strips_strings(self):
job = TTSJob.create(
user_id=" user_003 ",
input_text=" 测试文本 ",
voice_id=" voice_001 ",
voice_model=" cosyvoice ",
project_id=" proj_001 ",
voice_clone_profile_id=" clone_001 ",
format="wav",
)
assert job.user_id == "user_003"
assert job.input_text == "测试文本"
assert job.voice_id == "voice_001"
assert job.voice_model == "cosyvoice"
assert job.project_id == "proj_001"
assert job.voice_clone_profile_id == "clone_001"
assert job.format == "wav"
def test_create_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id"):
TTSJob.create(user_id="", input_text="test")
def test_create_whitespace_user_id_raises(self):
with pytest.raises(ValueError, match="user_id"):
TTSJob.create(user_id=" ", input_text="test")
def test_create_empty_input_text_raises(self):
with pytest.raises(ValueError, match="input_text"):
TTSJob.create(user_id="u", input_text="")
def test_create_input_text_too_long_raises(self):
long_text = "a" * 10001
with pytest.raises(ValueError, match="10000"):
TTSJob.create(user_id="u", input_text=long_text)
def test_create_input_text_at_limit_ok(self):
text = "a" * 10000
job = TTSJob.create(user_id="u", input_text=text)
assert job.input_text == text
def test_create_invalid_format_raises(self):
with pytest.raises(ValueError, match="不支持的输出格式"):
TTSJob.create(user_id="u", input_text="t", format="flac")
def test_create_supported_formats(self):
for fmt in ["mp3", "wav", "pcm"]:
job = TTSJob.create(user_id="u", input_text="t", format=fmt)
assert job.format == fmt
def test_create_none_metadata_defaults_to_empty_dict(self):
job = TTSJob.create(user_id="u", input_text="t", metadata=None)
assert job.metadata == {}
def test_create_ids_are_unique(self):
j1 = TTSJob.create(user_id="u", input_text="t")
j2 = TTSJob.create(user_id="u", input_text="t")
assert j1.id != j2.id
class TestTTSJobStateMachine:
"""TTSJob 状态机测试."""
@pytest.fixture
def pending_job(self):
return TTSJob.create(user_id="user_001", input_text="测试")
def test_initial_status_is_pending(self, pending_job):
assert pending_job.status == TTSJobStatus.PENDING
assert not pending_job.is_terminal
def test_pending_to_processing(self, pending_job):
pending_job.mark_processing()
assert pending_job.status == TTSJobStatus.PROCESSING
assert pending_job.started_at is not None
assert pending_job.error_message == ""
def test_pending_can_fail_directly(self, pending_job):
"""pending 可以直接到 failed(比如入参校验失败)"""
pending_job.mark_failed("校验失败")
assert pending_job.status == TTSJobStatus.FAILED
assert pending_job.error_message == "校验失败"
def test_pending_can_be_cancelled(self, pending_job):
pending_job.mark_cancelled()
assert pending_job.status == TTSJobStatus.CANCELLED
def test_processing_to_completed(self, pending_job):
pending_job.mark_processing()
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
assert pending_job.status == TTSJobStatus.COMPLETED
assert pending_job.output_audio_url == "https://example.com/out.mp3"
assert pending_job.completed_at is not None
assert pending_job.error_message == ""
def test_processing_to_failed(self, pending_job):
pending_job.mark_processing()
pending_job.mark_failed("API 超时")
assert pending_job.status == TTSJobStatus.FAILED
assert pending_job.error_message == "API 超时"
def test_processing_can_be_cancelled(self, pending_job):
pending_job.mark_processing()
pending_job.mark_cancelled()
assert pending_job.status == TTSJobStatus.CANCELLED
def test_completed_is_terminal(self, pending_job):
pending_job.mark_processing()
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
assert pending_job.is_terminal
assert pending_job.is_completed
def test_failed_is_terminal_but_retryable(self, pending_job):
pending_job.mark_processing()
pending_job.mark_failed("error")
assert pending_job.is_terminal
assert pending_job.is_retryable
def test_cancelled_is_terminal_and_not_retryable(self, pending_job):
pending_job.mark_cancelled()
assert pending_job.is_terminal
assert not pending_job.is_retryable
def test_invalid_transition_completed_to_processing_raises(self, pending_job):
pending_job.mark_processing()
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
with pytest.raises(ValueError, match="非法状态转换"):
pending_job.mark_processing()
def test_invalid_transition_completed_to_failed_raises(self, pending_job):
pending_job.mark_processing()
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
with pytest.raises(ValueError, match="非法状态转换"):
pending_job.mark_failed("test")
def test_cancelled_cannot_transition(self, pending_job):
pending_job.mark_cancelled()
with pytest.raises(ValueError):
pending_job.mark_processing()
with pytest.raises(ValueError):
pending_job.mark_failed("test")
def test_transition_to_with_string(self, pending_job):
"""transition_to 支持字符串参数"""
pending_job.transition_to("processing")
assert pending_job.status == TTSJobStatus.PROCESSING
def test_transition_to_invalid_string_raises(self, pending_job):
with pytest.raises(ValueError, match="无效状态"):
pending_job.transition_to("invalid_status")
def test_state_transition_updates_updated_at(self, pending_job):
old_updated = pending_job.updated_at
import time
time.sleep(0.001)
pending_job.mark_processing()
assert pending_job.updated_at > old_updated
class TestTTSJobRetry:
"""TTSJob 重试逻辑测试."""
def test_failed_can_retry(self):
job = TTSJob.create(user_id="u", input_text="t", max_retries=3)
job.mark_processing()
job.mark_failed("error")
assert job.is_retryable
assert job.retry_count == 0
def test_prepare_retry_resets_to_pending(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_failed("error")
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_up_to_max_retries(self):
job = TTSJob.create(user_id="u", input_text="t", max_retries=2)
# 第 1 次失败 + 重试 → retry_count=1,还可以重试
job.mark_processing()
job.mark_failed("e1")
assert job.is_retryable
job.prepare_retry()
assert job.retry_count == 1
# 第 2 次失败 → retry_count=1,还是 failed 状态,还可以重试(max_retries=2
job.mark_processing()
job.mark_failed("e2")
assert job.is_retryable # retry_count=1 < max_retries=2
job.prepare_retry()
assert job.retry_count == 2
# 第 3 次失败 → retry_count=2,达到上限,不可重试
job.mark_processing()
job.mark_failed("e3")
assert not job.is_retryable # retry_count=2 == max_retries=2
def test_retry_exceed_max_raises(self):
job = TTSJob.create(user_id="u", input_text="t", max_retries=1)
job.mark_processing()
job.mark_failed("e")
job.prepare_retry() # 第 1 次重试,用完了
job.mark_processing()
job.mark_failed("e2")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_pending_not_retryable(self):
job = TTSJob.create(user_id="u", input_text="t")
assert not job.is_retryable
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_completed_not_retryable(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/out.mp3")
assert not job.is_retryable
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_cancelled_not_retryable(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_cancelled()
assert not job.is_retryable
class TestTTSJobMarkCompleted:
"""mark_completed 方法测试."""
def test_requires_output_url(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
with pytest.raises(ValueError, match="output_audio_url"):
job.mark_completed(output_audio_url="")
def test_sets_all_fields(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(
output_audio_url="https://example.com/out.mp3",
output_audio_key="audio/001.mp3",
duration=30.5,
file_size=102400,
)
assert job.output_audio_url == "https://example.com/out.mp3"
assert job.output_audio_key == "audio/001.mp3"
assert job.duration == 30.5
assert job.file_size == 102400
assert job.completed_at is not None
def test_strips_whitespace(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(
output_audio_url=" https://example.com/out.mp3 ",
output_audio_key=" audio/001.mp3 ",
)
assert job.output_audio_url == "https://example.com/out.mp3"
assert job.output_audio_key == "audio/001.mp3"
class TestTTSJobIsCompleted:
"""is_completed 属性测试."""
def test_completed_with_url_is_completed(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/out.mp3")
assert job.is_completed
def test_completed_without_url_not_completed(self):
"""极端情况:completed 状态但没有 URL(理论不会发生)"""
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.transition_to(TTSJobStatus.COMPLETED) # 直接转,不设 URL
assert not job.is_completed
def test_pending_not_completed(self):
job = TTSJob.create(user_id="u", input_text="t")
assert not job.is_completed
class TestTTSJobToDict:
"""to_dict 序列化测试."""
def test_pending_job_to_dict(self):
job = TTSJob.create(user_id="user_001", input_text="测试文本", voice_id="v001")
d = job.to_dict()
assert d["id"] == job.id
assert d["user_id"] == "user_001"
assert d["status"] == "pending"
assert d["input_text"] == "测试文本"
assert d["voice_id"] == "v001"
assert d["retry_count"] == 0
assert d["is_retryable"] is False
assert d["is_completed"] is False
assert d["metadata"] == {}
assert d["started_at"] is None
assert d["completed_at"] is None
assert d["created_at"] is not None
assert d["updated_at"] is not None
def test_completed_job_to_dict(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/out.mp3", duration=10.0)
d = job.to_dict()
assert d["status"] == "completed"
assert d["output_audio_url"] == "https://example.com/out.mp3"
assert d["duration"] == 10.0
assert d["is_completed"] is True
assert d["started_at"] is not None
assert d["completed_at"] is not None
def test_failed_job_to_dict(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_failed("出错了")
d = job.to_dict()
assert d["status"] == "failed"
assert d["error_message"] == "出错了"
assert d["is_retryable"] is True
-168
View File
@@ -1,168 +0,0 @@
"""
VerificationCode 验证码领域模型单元测试
"""
from datetime import datetime, timedelta, timezone
import pytest
from domain.verification_code import VerificationCode
class TestVerificationCodeCreate:
"""创建验证码测试"""
def test_create_default_6digit_code(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.id is not None
assert len(vc.id) == 32 # uuid4 hex
assert vc.recipient == "test@example.com"
assert vc.code_type == "email_bind"
assert len(vc.code) == 6
assert vc.code.isdigit()
assert vc.used_at is None
assert vc.attempts == 0
def test_create_custom_code(self):
vc = VerificationCode.create("13800138000", "phone_bind", custom_code="123456")
assert vc.code == "123456"
def test_create_default_ttl_300s(self):
before = datetime.now(timezone.utc)
vc = VerificationCode.create("test@example.com", "email_login")
after = datetime.now(timezone.utc)
expected_expiry_min = before + timedelta(seconds=300)
expected_expiry_max = after + timedelta(seconds=300)
assert expected_expiry_min <= vc.expires_at <= expected_expiry_max
def test_create_custom_ttl(self):
vc = VerificationCode.create("test@example.com", "reset_password", ttl_seconds=60)
expected = datetime.now(timezone.utc) + timedelta(seconds=60)
diff = abs((vc.expires_at - expected).total_seconds())
assert diff < 2
def test_create_recipient_stripped(self):
vc = VerificationCode.create(" test@example.com ", "email_bind")
assert vc.recipient == "test@example.com"
def test_create_phone_recipient(self):
vc = VerificationCode.create("13800138000", "phone_login")
assert vc.recipient == "13800138000"
assert vc.code_type == "phone_login"
def test_create_sets_created_at(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.created_at is not None
assert isinstance(vc.created_at, datetime)
class TestVerificationCodeExpiry:
"""过期状态测试"""
def test_fresh_code_not_expired(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.is_expired is False
def test_expired_code_is_expired(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-60)
assert vc.is_expired is True
def test_boundary_not_expired_at_expiry_time(self):
now = datetime.now(timezone.utc)
vc = VerificationCode.create("test@example.com", "email_bind")
vc.expires_at = now + timedelta(seconds=1)
assert vc.is_expired is False
def test_boundary_expired_right_after(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.expires_at = datetime.now(timezone.utc) - timedelta(microseconds=1)
assert vc.is_expired is True
class TestVerificationCodeUsed:
"""使用状态测试"""
def test_fresh_code_not_used(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.is_used is False
def test_mark_used(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.mark_used()
assert vc.is_used is True
assert vc.used_at is not None
assert isinstance(vc.used_at, datetime)
def test_mark_used_sets_recent_time(self):
vc = VerificationCode.create("test@example.com", "email_bind")
before = datetime.now(timezone.utc)
vc.mark_used()
after = datetime.now(timezone.utc)
assert before <= vc.used_at <= after
def test_mark_used_idempotent(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.mark_used()
first_used_at = vc.used_at
vc.mark_used()
# 第二次会更新时间
assert vc.used_at >= first_used_at
class TestVerificationCodeValidity:
"""有效性(未过期+未使用)测试"""
def test_fresh_code_is_valid(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.is_valid is True
def test_expired_code_not_valid(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-10)
assert vc.is_valid is False
def test_used_code_not_valid(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.mark_used()
assert vc.is_valid is False
def test_expired_and_used_not_valid(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-10)
vc.mark_used()
assert vc.is_valid is False
class TestVerificationCodeAttempts:
"""尝试次数测试"""
def test_initial_attempts_zero(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.attempts == 0
def test_increment_attempts(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.increment_attempts()
assert vc.attempts == 1
def test_increment_attempts_multiple(self):
vc = VerificationCode.create("test@example.com", "email_bind")
for _ in range(5):
vc.increment_attempts()
assert vc.attempts == 5
class TestVerificationCodeTypes:
"""不同验证码类型测试"""
@pytest.mark.parametrize(
"code_type",
[
"email_bind",
"phone_bind",
"email_login",
"phone_login",
"reset_password",
],
)
def test_all_supported_types(self, code_type):
vc = VerificationCode.create("test@example.com", code_type)
assert vc.code_type == code_type
assert vc.is_valid is True
-95
View File
@@ -1,95 +0,0 @@
"""
VoiceLibraryItem 配音库领域模型单元测试
"""
from packages.domain.voice_library import VoiceLibraryItem
class TestVoiceLibraryItem:
"""VoiceLibraryItem 测试"""
def test_create_minimal(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="我的配音")
assert item.id == "v1"
assert item.user_id == "u1"
assert item.name == "我的配音"
def test_default_values(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
assert item.text == ""
assert item.voice_provider == ""
assert item.voice_id == ""
assert item.voice_name == ""
assert item.audio_url == ""
assert item.duration == 0
assert item.file_size == 0
assert item.status == "completed"
assert item.project_id is None
assert item.tags == []
assert item.metadata_ == {}
def test_with_voice_info(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="温柔女声",
text="大家好",
voice_provider="cosyvoice",
voice_id="longxiaochun_v3",
voice_name="龙小淳",
)
assert item.voice_provider == "cosyvoice"
assert item.voice_id == "longxiaochun_v3"
assert item.voice_name == "龙小淳"
def test_with_audio_info(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="n",
audio_url="https://example.com/audio.wav",
duration=15.5,
file_size=102400,
)
assert item.audio_url == "https://example.com/audio.wav"
assert item.duration == 15.5
assert item.file_size == 102400
def test_with_project_id(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="n",
project_id="proj-123",
)
assert item.project_id == "proj-123"
def test_status_values(self):
for status in ["pending", "processing", "completed", "failed"]:
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status=status)
assert item.status == status
def test_with_tags(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="n",
tags=["温柔", "女声", "解说"],
)
assert len(item.tags) == 3
assert "温柔" in item.tags
def test_with_metadata(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="n",
metadata_={"speed": 1.0, "pitch": 0.5},
)
assert item.metadata_["speed"] == 1.0
assert item.metadata_["pitch"] == 0.5
def test_has_timestamps(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
assert item.created_at is not None
assert item.updated_at is not None
-231
View File
@@ -1,231 +0,0 @@
"""voice_presets 模块单元测试."""
import pytest
from domain.voice_presets import (
MOCK_VOICES,
VoiceGender,
VoicePreset,
VoiceStyle,
get_default_voice,
get_voice,
list_voices,
)
class TestVoiceGender:
"""VoiceGender 枚举测试."""
def test_values(self):
assert VoiceGender.MALE == "male"
assert VoiceGender.FEMALE == "female"
assert VoiceGender.CHILD == "child"
def test_is_str_enum(self):
# StrEnum 在不同 Python 版本 str() 行为可能不同(3.11+ 返回值,旧版自定义 StrEnum 可能返回类名)
# 用 value 比较更稳妥
assert isinstance(VoiceGender.FEMALE, str)
assert VoiceGender.MALE.value == "male"
assert VoiceGender.FEMALE.value == "female"
class TestVoiceStyle:
"""VoiceStyle 枚举测试."""
def test_values(self):
assert VoiceStyle.STABLE == "stable"
assert VoiceStyle.LIVELY == "lively"
assert VoiceStyle.CUSTOMER_SERVICE == "customer_service"
assert VoiceStyle.NARRATION == "narration"
assert VoiceStyle.NEWS == "news"
assert VoiceStyle.STORY == "story"
class TestVoicePreset:
"""VoicePreset 数据类测试."""
def test_create_with_required_fields(self):
v = VoicePreset(voice_id="test_001", name="测试音色")
assert v.voice_id == "test_001"
assert v.name == "测试音色"
# 默认值
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.NARRATION
assert v.provider == "mock"
assert v.default_speed == 1.0
assert v.default_pitch == 0.0
assert v.sample_rate == 22050
assert v.language == "zh-CN"
def test_create_with_all_fields(self):
v = VoicePreset(
voice_id="male_news",
name="新闻男声",
gender=VoiceGender.MALE,
style=VoiceStyle.NEWS,
description="字正腔圆",
provider="aliyun",
provider_voice_id="zhiqiang",
default_speed=0.9,
default_pitch=1.0,
sample_rate=16000,
language="zh-CN",
)
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.NEWS
assert v.provider == "aliyun"
assert v.default_speed == 0.9
assert v.sample_rate == 16000
def test_slots(self):
"""dataclass slots=True,不能添加新属性."""
v = VoicePreset(voice_id="test", name="测试")
with pytest.raises(AttributeError):
v.new_field = "value" # type: ignore[attr-defined]
class TestMockVoices:
"""MOCK_VOICES 预设列表测试."""
def test_not_empty(self):
assert len(MOCK_VOICES) > 0
def test_all_have_unique_voice_id(self):
ids = [v.voice_id for v in MOCK_VOICES]
assert len(ids) == len(set(ids)), "voice_id 不能重复"
def test_all_are_voice_preset_instances(self):
for v in MOCK_VOICES:
assert isinstance(v, VoicePreset)
assert v.provider == "mock"
def test_contains_expected_voices(self):
ids = {v.voice_id for v in MOCK_VOICES}
assert "female_warm" in ids
assert "male_stable" in ids
assert "female_lively" in ids
assert "child_cute" in ids
def test_voice_genders_coverage(self):
genders = {v.gender for v in MOCK_VOICES}
assert VoiceGender.FEMALE in genders
assert VoiceGender.MALE in genders
assert VoiceGender.CHILD in genders
class TestGetVoice:
"""get_voice 函数测试."""
def test_existing_mock_voice(self):
v = get_voice("female_warm")
assert v is not None
assert v.voice_id == "female_warm"
assert v.name == "温暖女声"
def test_nonexistent_voice(self):
assert get_voice("nonexistent") is None
def test_provider_mock(self):
v = get_voice("male_stable", provider="mock")
assert v is not None
assert v.voice_id == "male_stable"
def test_unknown_provider_returns_none(self):
assert get_voice("female_warm", provider="aliyun") is None
def test_empty_string_returns_none(self):
assert get_voice("") is None
class TestListVoices:
"""list_voices 函数测试."""
def test_no_filters_returns_all(self):
result = list_voices()
assert len(result) == len(MOCK_VOICES)
def test_filter_by_gender_female(self):
result = list_voices(gender="female")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.FEMALE
def test_filter_by_gender_male(self):
result = list_voices(gender="male")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.MALE
def test_filter_by_gender_child(self):
result = list_voices(gender="child")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.CHILD
def test_filter_by_style(self):
result = list_voices(style="story")
assert len(result) > 0
for v in result:
assert v.style == VoiceStyle.STORY
def test_filter_by_style_narration(self):
result = list_voices(style="narration")
assert len(result) >= 1
def test_filter_by_unknown_provider_returns_empty(self):
result = list_voices(provider="aliyun")
assert result == []
def test_filter_by_keyword_name(self):
result = list_voices(keyword="男声")
assert len(result) > 0
for v in result:
assert "男声" in v.name or "男声" in v.description
def test_filter_by_keyword_description(self):
result = list_voices(keyword="vlog")
assert len(result) > 0
def test_filter_by_keyword_voice_id(self):
result = list_voices(keyword="female")
assert len(result) > 0
for v in result:
assert "female" in v.voice_id.lower()
def test_filter_by_keyword_case_insensitive(self):
r1 = list_voices(keyword="FEMALE")
r2 = list_voices(keyword="female")
assert len(r1) == len(r2)
def test_filter_keyword_no_match(self):
result = list_voices(keyword="xyz_nonexistent_keyword")
assert result == []
def test_combined_filters_gender_and_style(self):
result = list_voices(gender="female", style="story")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.STORY
def test_combined_filters_gender_and_keyword(self):
result = list_voices(gender="male", keyword="新闻")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.MALE
def test_combined_no_match(self):
result = list_voices(gender="child", style="news")
# 童声没有新闻风格
assert result == []
class TestGetDefaultVoice:
"""get_default_voice 函数测试."""
def test_returns_first_mock_voice(self):
v = get_default_voice()
assert v == MOCK_VOICES[0]
assert v.voice_id == "female_warm"
def test_returns_voice_preset(self):
assert isinstance(get_default_voice(), VoicePreset)
@@ -271,49 +271,8 @@ class TestWechatOAuthService:
from packages.application.auth.wechat_oauth_service import WechatOAuthService
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
# 先生成授权 URL 获得有效 statestate 会被存入 store
_, valid_state = service.generate_auth_url()
user_info, err = service.handle_callback("test_code", valid_state)
user_info, err = service.handle_callback("test_code", "test_state")
assert err is None
assert user_info is not None
assert "mock" in user_info.openid
assert user_info.nickname == "微信测试用户"
def test_callback_invalid_state_rejected(self):
"""无效 state 应被拒绝(CSRF 防护)"""
from packages.application.auth.wechat_oauth_service import WechatOAuthService
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
# 直接用随机 state 调用,未经过 generate_auth_url
user_info, err = service.handle_callback("test_code", "random_fake_state")
assert err is not None
assert "state" in err
assert user_info is None
def test_callback_state_single_use(self):
"""state 只能使用一次(防重放)"""
from packages.application.auth.wechat_oauth_service import WechatOAuthService
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
_, valid_state = service.generate_auth_url()
# 第一次使用:成功
user_info, err = service.handle_callback("test_code", valid_state)
assert err is None
assert user_info is not None
# 第二次使用相同 state:失败(已被消费)
user_info2, err2 = service.handle_callback("test_code", valid_state)
assert err2 is not None
assert "state" in err2
assert user_info2 is None
def test_callback_empty_state_rejected(self):
"""空 state 应被拒绝"""
from packages.application.auth.wechat_oauth_service import WechatOAuthService
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
user_info, err = service.handle_callback("test_code", "")
assert err is not None
assert "state" in err
assert user_info is None