""" 转场特效 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