6aec0ec6f4
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m44s
CI/CD Pipeline / Unit Tests (push) Successful in 2m48s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 5m57s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 29s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m41s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 2m3s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m13s
feat: 转场特效 - 预设库 + 单片段设置 + 批量应用 (#398)
495 lines
18 KiB
Python
495 lines
18 KiB
Python
"""
|
|
转场特效 API 单元测试
|
|
|
|
覆盖:
|
|
- GET /transition-presets - 转场预设列表
|
|
- PUT /clips/{clip_id}/transition - 设置单个片段转场
|
|
- POST /{plan_id}/transitions/batch - 批量设置转场
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from unittest.mock import MagicMock
|
|
|
|
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from packages.domain.config_schemas import normalize_plan_config
|
|
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
|
from packages.domain.transition_presets import TRANSITION_PRESET_LIBRARY
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stub Repository
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class StubEditPlanRepository:
|
|
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
|
self._plans = plans or {}
|
|
|
|
def list_all(self, *, status=None, skip=0, limit=50):
|
|
items = list(self._plans.values())
|
|
if status is not None:
|
|
items = [p for p in items if p.status == status]
|
|
items.sort(key=lambda p: p.created_at, reverse=True)
|
|
return items[skip : skip + limit]
|
|
|
|
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
|
items = [p for p in self._plans.values() if p.template_id == template_id]
|
|
if status is not None:
|
|
items = [p for p in items if p.status == status]
|
|
items.sort(key=lambda p: p.created_at, reverse=True)
|
|
return items[skip : skip + limit]
|
|
|
|
def get(self, plan_id: str) -> Optional[EditPlan]:
|
|
return self._plans.get(plan_id)
|
|
|
|
def create(self, plan: EditPlan) -> EditPlan:
|
|
self._plans[plan.id] = plan
|
|
return plan
|
|
|
|
def update(self, plan: EditPlan) -> EditPlan:
|
|
self._plans[plan.id] = plan
|
|
return plan
|
|
|
|
def delete(self, plan_id: str) -> bool:
|
|
if plan_id in self._plans:
|
|
del self._plans[plan_id]
|
|
return True
|
|
return False
|
|
|
|
def count(self, *, status=None, template_id=None):
|
|
items = list(self._plans.values())
|
|
if status is not None:
|
|
items = [p for p in items if p.status == status]
|
|
if template_id is not None:
|
|
items = [p for p in items if p.template_id == template_id]
|
|
return len(items)
|
|
|
|
|
|
class StubEditPlanClipRepository:
|
|
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
|
|
self._clips = clips or {}
|
|
self._counter = 0
|
|
|
|
def _next_id(self) -> str:
|
|
self._counter += 1
|
|
return f"clip-new{self._counter}"
|
|
|
|
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
|
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
|
if status is not None:
|
|
items = [c for c in items if c.status == status]
|
|
items.sort(key=lambda c: c.order)
|
|
return items[skip : skip + limit]
|
|
|
|
def count(self, plan_id, *, status=None):
|
|
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
|
if status is not None:
|
|
items = [c for c in items if c.status == status]
|
|
return len(items)
|
|
|
|
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
|
return self._clips.get(clip_id)
|
|
|
|
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
|
if not clip.id:
|
|
clip.id = self._next_id()
|
|
self._clips[clip.id] = clip
|
|
return clip
|
|
|
|
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
|
self._clips[clip.id] = clip
|
|
return clip
|
|
|
|
def delete(self, clip_id: str) -> bool:
|
|
if clip_id in self._clips:
|
|
del self._clips[clip_id]
|
|
return True
|
|
return False
|
|
|
|
def delete_by_plan(self, plan_id: str) -> int:
|
|
to_delete = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
|
|
for cid in to_delete:
|
|
del self._clips[cid]
|
|
return len(to_delete)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_sample_plan(plan_id="plan-001"):
|
|
return EditPlan(
|
|
id=plan_id,
|
|
template_id="tpl-001",
|
|
name="测试计划",
|
|
status=EditPlanStatus.EDITING,
|
|
total_duration=30.0,
|
|
config=normalize_plan_config({}),
|
|
project_id="",
|
|
created_by_user_id="user-001",
|
|
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
|
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
|
)
|
|
|
|
|
|
def _make_clip(clip_id, plan_id="plan-001", order=0, transition_effect="cut", transition_duration=0.0):
|
|
return EditPlanClip(
|
|
id=clip_id,
|
|
plan_id=plan_id,
|
|
clip_type="video",
|
|
order=order,
|
|
asset_id="asset-001",
|
|
text_content="",
|
|
start_time=0.0,
|
|
duration=10.0,
|
|
transition_effect=transition_effect,
|
|
transition_duration=transition_duration,
|
|
playback_speed=1.0,
|
|
status=EditPlanClipStatus.READY,
|
|
config={},
|
|
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
|
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
|
)
|
|
|
|
|
|
def _create_test_app():
|
|
import app.api.routes.edit_plans_transitions as transitions_module
|
|
import app.services.edit_plan_service as service_module
|
|
from app.api.routes.edit_plans import router
|
|
|
|
plan = _make_sample_plan()
|
|
clips = {
|
|
"clip-001": _make_clip("clip-001", order=0),
|
|
"clip-002": _make_clip("clip-002", order=1),
|
|
"clip-003": _make_clip("clip-003", order=2),
|
|
"clip-004": _make_clip("clip-004", order=3),
|
|
}
|
|
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
|
stub_clip_repo = StubEditPlanClipRepository(clips)
|
|
|
|
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
|
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
|
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
|
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
|
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
|
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
|
|
|
app = FastAPI()
|
|
app.include_router(router, prefix="/api/v1/edit-plans")
|
|
|
|
# Mock 认证
|
|
def _mock_auth():
|
|
mock = MagicMock()
|
|
mock.user.id = "user-001"
|
|
return mock
|
|
|
|
# Mock 项目访问检查
|
|
import app.api.routes._helpers as helpers_module
|
|
|
|
original_check = helpers_module.check_project_access
|
|
helpers_module.check_project_access = lambda *a, **kw: None
|
|
|
|
# 覆盖依赖
|
|
from app.api.routes import edit_plans as main_module
|
|
|
|
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
|
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
|
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
|
|
|
app.dependency_overrides[transitions_module.get_current_user] = _mock_auth
|
|
app.dependency_overrides[transitions_module.get_db_session] = lambda: MagicMock()
|
|
app.dependency_overrides[transitions_module.get_project_repository] = lambda: MagicMock()
|
|
|
|
def cleanup():
|
|
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
|
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
|
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
|
helpers_module.check_project_access = original_check
|
|
|
|
return app, stub_plan_repo, stub_clip_repo, cleanup
|
|
|
|
|
|
@pytest.fixture
|
|
def transition_client():
|
|
app, plan_repo, clip_repo, cleanup = _create_test_app()
|
|
yield TestClient(app), plan_repo, clip_repo
|
|
cleanup()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transition Presets 测试
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTransitionPresets:
|
|
def test_list_all_presets(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.get("/api/v1/edit-plans/transition-presets")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == len(TRANSITION_PRESET_LIBRARY)
|
|
assert data["total"] > 10
|
|
first = data["items"][0]
|
|
assert "id" in first
|
|
assert "name" in first
|
|
assert "category" in first
|
|
assert "default_duration" in first
|
|
assert "min_duration" in first
|
|
assert "max_duration" in first
|
|
|
|
def test_filter_by_category_fade(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.get("/api/v1/edit-plans/transition-presets?category=fade")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] >= 3
|
|
for item in data["items"]:
|
|
assert item["category"] == "fade"
|
|
|
|
def test_filter_by_category_slide(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.get("/api/v1/edit-plans/transition-presets?category=slide")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] >= 4
|
|
for item in data["items"]:
|
|
assert item["category"] == "slide"
|
|
|
|
def test_filter_by_keyword(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.get("/api/v1/edit-plans/transition-presets?keyword=模糊")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] > 0
|
|
names = [item["name"] for item in data["items"]]
|
|
assert any("模糊" in n for n in names)
|
|
|
|
def test_filter_empty_result(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.get("/api/v1/edit-plans/transition-presets?keyword=不存在的转场")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 0
|
|
|
|
def test_contains_none_transition(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.get("/api/v1/edit-plans/transition-presets?category=basic")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
ids = [item["id"] for item in data["items"]]
|
|
assert "transition_none" in ids
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PUT /clips/{clip_id}/transition 测试
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateClipTransition:
|
|
def test_set_fade_transition(self, transition_client):
|
|
c, _, clip_repo = transition_client
|
|
resp = c.put(
|
|
"/api/v1/edit-plans/clips/clip-001/transition",
|
|
json={"effect": "transition_fade", "duration": 0.8},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["clip_id"] == "clip-001"
|
|
assert data["effect"] == "fade"
|
|
assert data["duration"] == 0.8
|
|
|
|
clip = clip_repo.get("clip-001")
|
|
assert clip.transition_effect == "fade"
|
|
assert clip.transition_duration == 0.8
|
|
|
|
def test_set_none_transition(self, transition_client):
|
|
c, _, clip_repo = transition_client
|
|
resp = c.put(
|
|
"/api/v1/edit-plans/clips/clip-001/transition",
|
|
json={"effect": "transition_none"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["effect"] == "cut"
|
|
assert data["duration"] == 0.0
|
|
|
|
def test_use_default_duration(self, transition_client):
|
|
c, _, _ = transition_client
|
|
# 不传 duration,使用预设默认值
|
|
resp = c.put(
|
|
"/api/v1/edit-plans/clips/clip-001/transition",
|
|
json={"effect": "transition_fade"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["effect"] == "fade"
|
|
assert data["duration"] > 0 # 使用默认值
|
|
|
|
def test_invalid_effect(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.put(
|
|
"/api/v1/edit-plans/clips/clip-001/transition",
|
|
json={"effect": "invalid_effect"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "无效的转场效果" in resp.json()["detail"]
|
|
|
|
def test_clip_not_found(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.put(
|
|
"/api/v1/edit-plans/clips/clip-nonexist/transition",
|
|
json={"effect": "transition_fade"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_negative_duration_422(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.put(
|
|
"/api/v1/edit-plans/clips/clip-001/transition",
|
|
json={"effect": "transition_fade", "duration": -0.5},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_duration_clamped_to_max(self, transition_client):
|
|
c, _, clip_repo = transition_client
|
|
# 传一个超过最大值的时长,应该被钳制
|
|
resp = c.put(
|
|
"/api/v1/edit-plans/clips/clip-001/transition",
|
|
json={"effect": "transition_fade", "duration": 10.0},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# fade 最大 2.0s
|
|
assert data["duration"] <= 2.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /{plan_id}/transitions/batch 测试
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBatchUpdateTransitions:
|
|
def test_batch_all(self, transition_client):
|
|
c, _, clip_repo = transition_client
|
|
resp = c.post(
|
|
"/api/v1/edit-plans/plan-001/transitions/batch",
|
|
json={"effect": "transition_fade", "duration": 0.5, "apply_to": "all"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["updated_count"] == 4 # 4个片段
|
|
|
|
for cid in ["clip-001", "clip-002", "clip-003", "clip-004"]:
|
|
clip = clip_repo.get(cid)
|
|
assert clip.transition_effect == "fade"
|
|
assert clip.transition_duration == 0.5
|
|
|
|
def test_batch_except_first(self, transition_client):
|
|
c, _, clip_repo = transition_client
|
|
resp = c.post(
|
|
"/api/v1/edit-plans/plan-001/transitions/batch",
|
|
json={"effect": "transition_fade", "apply_to": "except_first"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["updated_count"] == 3
|
|
|
|
# 第一个不变
|
|
assert clip_repo.get("clip-001").transition_effect == "cut"
|
|
# 其余三个被更新
|
|
for cid in ["clip-002", "clip-003", "clip-004"]:
|
|
assert clip_repo.get(cid).transition_effect == "fade"
|
|
|
|
def test_batch_except_last(self, transition_client):
|
|
c, _, clip_repo = transition_client
|
|
resp = c.post(
|
|
"/api/v1/edit-plans/plan-001/transitions/batch",
|
|
json={"effect": "transition_slideleft", "apply_to": "except_last"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["updated_count"] == 3
|
|
|
|
# 最后一个不变
|
|
assert clip_repo.get("clip-004").transition_effect == "cut"
|
|
# 前三个被更新
|
|
for cid in ["clip-001", "clip-002", "clip-003"]:
|
|
assert clip_repo.get(cid).transition_effect == "slideleft"
|
|
|
|
def test_batch_middle(self, transition_client):
|
|
c, _, clip_repo = transition_client
|
|
resp = c.post(
|
|
"/api/v1/edit-plans/plan-001/transitions/batch",
|
|
json={"effect": "transition_dissolve", "apply_to": "middle"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["updated_count"] == 2 # 4个片段,中间2个
|
|
|
|
# 首尾不变
|
|
assert clip_repo.get("clip-001").transition_effect == "cut"
|
|
assert clip_repo.get("clip-004").transition_effect == "cut"
|
|
# 中间被更新
|
|
assert clip_repo.get("clip-002").transition_effect == "dissolve"
|
|
assert clip_repo.get("clip-003").transition_effect == "dissolve"
|
|
|
|
def test_batch_invalid_effect(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.post(
|
|
"/api/v1/edit-plans/plan-001/transitions/batch",
|
|
json={"effect": "invalid"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_batch_plan_not_found(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.post(
|
|
"/api/v1/edit-plans/plan-nonexist/transitions/batch",
|
|
json={"effect": "transition_fade"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_batch_invalid_apply_to(self, transition_client):
|
|
c, _, _ = transition_client
|
|
resp = c.post(
|
|
"/api/v1/edit-plans/plan-001/transitions/batch",
|
|
json={"effect": "transition_fade", "apply_to": "invalid"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_batch_none_transition(self, transition_client):
|
|
c, _, clip_repo = transition_client
|
|
# 先设一个转场
|
|
c.post(
|
|
"/api/v1/edit-plans/plan-001/transitions/batch",
|
|
json={"effect": "transition_fade", "apply_to": "all"},
|
|
)
|
|
# 再全部设为无
|
|
resp = c.post(
|
|
"/api/v1/edit-plans/plan-001/transitions/batch",
|
|
json={"effect": "transition_none", "apply_to": "all"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["updated_count"] == 4
|
|
|
|
for cid in ["clip-001", "clip-002", "clip-003", "clip-004"]:
|
|
clip = clip_repo.get(cid)
|
|
assert clip.transition_effect == "cut"
|
|
assert clip.transition_duration == 0.0
|