c67aadcb2b
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 75h39m10s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 75h39m22s
532 lines
18 KiB
Python
532 lines
18 KiB
Python
"""config_schemas + AI 推荐/封面生成 单元测试.
|
||
|
||
覆盖(20+ 测试用例):
|
||
- config_schemas: normalize_plan_config / normalize_template_config 默认值填充、部分覆盖、非标准字段保留
|
||
- config_schemas: Pydantic 枚举校验(CoverType / TextPosition / BGMSource)
|
||
- ai_tasks: run_ai_recommend / run_generate_cover stub 返回结构
|
||
- edit_plans API: POST /{plan_id}/ai-recommend 正常/404/400
|
||
- edit_plans API: POST /{plan_id}/generate-cover 正常/404
|
||
- edit_plans API: create_plan config 标准化
|
||
- edit_plans API: update_plan config 标准化
|
||
- edit_templates API: create_template / update_template config 标准化
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, 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"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# config_schemas 单元测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestNormalizePlanConfig:
|
||
def test_none_returns_full_defaults(self):
|
||
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG, normalize_plan_config
|
||
|
||
result = normalize_plan_config(None)
|
||
assert result == DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||
|
||
def test_empty_dict_returns_full_defaults(self):
|
||
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG, normalize_plan_config
|
||
|
||
result = normalize_plan_config({})
|
||
assert result["cover"]["type"] == "ai_frame"
|
||
assert result["title"]["enabled"] is True
|
||
assert result["subtitle"]["position"] == "bottom"
|
||
assert result["bgm"]["volume"] == 0.3
|
||
|
||
def test_partial_cover_override(self):
|
||
from packages.domain.config_schemas import normalize_plan_config
|
||
|
||
result = normalize_plan_config({"cover": {"type": "upload", "image_url": "https://example.com/cover.jpg"}})
|
||
assert result["cover"]["type"] == "upload"
|
||
assert result["cover"]["image_url"] == "https://example.com/cover.jpg"
|
||
# frame_time 保留默认值 None
|
||
assert result["cover"]["frame_time"] is None
|
||
|
||
def test_partial_title_override(self):
|
||
from packages.domain.config_schemas import normalize_plan_config
|
||
|
||
result = normalize_plan_config({"title": {"text": "我的标题", "ai_auto": False}})
|
||
assert result["title"]["text"] == "我的标题"
|
||
assert result["title"]["ai_auto"] is False
|
||
# 其他字段保留默认值
|
||
assert result["title"]["font"] == "思源黑体"
|
||
assert result["title"]["size"] == 48
|
||
|
||
def test_non_standard_fields_preserved(self):
|
||
from packages.domain.config_schemas import normalize_plan_config
|
||
|
||
result = normalize_plan_config({"generation_task_id": "task-123", "custom_key": "value"})
|
||
assert result["generation_task_id"] == "task-123"
|
||
assert result["custom_key"] == "value"
|
||
# 标准字段仍然完整
|
||
assert "cover" in result
|
||
assert "bgm" in result
|
||
|
||
def test_bgm_partial_override(self):
|
||
from packages.domain.config_schemas import normalize_plan_config
|
||
|
||
result = normalize_plan_config({"bgm": {"volume": 0.8}})
|
||
assert result["bgm"]["volume"] == 0.8
|
||
assert result["bgm"]["source"] == "library"
|
||
assert result["bgm"]["asset_id"] == ""
|
||
|
||
def test_multiple_sections_override(self):
|
||
from packages.domain.config_schemas import normalize_plan_config
|
||
|
||
result = normalize_plan_config(
|
||
{
|
||
"cover": {"type": "manual", "frame_time": 5.0},
|
||
"title": {"enabled": False},
|
||
"subtitle": {"size": 36},
|
||
"bgm": {"source": "ai_recommend"},
|
||
}
|
||
)
|
||
assert result["cover"]["type"] == "manual"
|
||
assert result["cover"]["frame_time"] == 5.0
|
||
assert result["title"]["enabled"] is False
|
||
assert result["subtitle"]["size"] == 36
|
||
assert result["bgm"]["source"] == "ai_recommend"
|
||
|
||
|
||
class TestNormalizeTemplateConfig:
|
||
def test_same_as_plan_config(self):
|
||
from packages.domain.config_schemas import normalize_plan_config, normalize_template_config
|
||
|
||
raw = {"title": {"text": "模板标题"}}
|
||
assert normalize_template_config(raw) == normalize_plan_config(raw)
|
||
|
||
def test_none_returns_defaults(self):
|
||
from packages.domain.config_schemas import DEFAULT_EDIT_TEMPLATE_CONFIG, normalize_template_config
|
||
|
||
result = normalize_template_config(None)
|
||
assert result == DEFAULT_EDIT_TEMPLATE_CONFIG.copy()
|
||
|
||
|
||
class TestConfigSchemas:
|
||
def test_cover_type_enum_values(self):
|
||
from packages.domain.config_schemas import CoverType
|
||
|
||
assert CoverType.AI_FRAME.value == "ai_frame"
|
||
assert CoverType.MANUAL.value == "manual"
|
||
assert CoverType.UPLOAD.value == "upload"
|
||
assert CoverType.AI_REGENERATE.value == "ai_regenerate"
|
||
|
||
def test_text_position_enum_values(self):
|
||
from packages.domain.config_schemas import TextPosition
|
||
|
||
assert TextPosition.TOP.value == "top"
|
||
assert TextPosition.CENTER.value == "center"
|
||
assert TextPosition.BOTTOM.value == "bottom"
|
||
|
||
def test_bgm_source_enum_values(self):
|
||
from packages.domain.config_schemas import BGMSource
|
||
|
||
assert BGMSource.LIBRARY.value == "library"
|
||
assert BGMSource.UPLOAD.value == "upload"
|
||
assert BGMSource.AI_RECOMMEND.value == "ai_recommend"
|
||
|
||
def test_cover_config_model(self):
|
||
from packages.domain.config_schemas import CoverConfig, CoverType
|
||
|
||
cfg = CoverConfig(type=CoverType.MANUAL, frame_time=3.5)
|
||
assert cfg.type == CoverType.MANUAL
|
||
assert cfg.frame_time == 3.5
|
||
assert cfg.image_url == ""
|
||
|
||
def test_title_config_defaults(self):
|
||
from packages.domain.config_schemas import TitleConfig
|
||
|
||
cfg = TitleConfig()
|
||
assert cfg.enabled is True
|
||
assert cfg.ai_auto is True
|
||
assert cfg.font == "思源黑体"
|
||
assert cfg.size == 48
|
||
assert cfg.bold is True
|
||
assert cfg.stroke.enabled is False
|
||
|
||
def test_bgm_config_validation(self):
|
||
from packages.domain.config_schemas import BGMConfig
|
||
|
||
cfg = BGMConfig(volume=0.5)
|
||
assert cfg.volume == 0.5
|
||
|
||
with pytest.raises(Exception):
|
||
BGMConfig(volume=1.5) # > 1.0 应该校验失败
|
||
|
||
def test_edit_plan_config_schema_full(self):
|
||
from packages.domain.config_schemas import EditPlanConfigSchema
|
||
|
||
schema = EditPlanConfigSchema()
|
||
assert schema.cover.type.value == "ai_frame"
|
||
assert schema.title.enabled is True
|
||
assert schema.subtitle.position.value == "bottom"
|
||
assert schema.bgm.source.value == "library"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ai_tasks 单元测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestAIRunTasks:
|
||
def test_run_ai_recommend_returns_expected_structure(self):
|
||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||
|
||
result = run_ai_recommend(
|
||
plan_id="plan-001",
|
||
template_id="tpl-001",
|
||
asset_ids=["asset-1", "asset-2"],
|
||
editing_mode="one_take",
|
||
target_duration=30.0,
|
||
)
|
||
assert "clips" in result
|
||
assert "config" in result
|
||
assert "total_duration" in result
|
||
assert "confidence" in result
|
||
assert len(result["clips"]) >= 2 # 至少 intro + outro
|
||
assert result["total_duration"] > 0
|
||
assert 0 <= result["confidence"] <= 1
|
||
|
||
def test_run_ai_recommend_clip_structure(self):
|
||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||
|
||
result = run_ai_recommend(
|
||
plan_id="plan-001",
|
||
template_id="tpl-001",
|
||
asset_ids=["asset-1"],
|
||
)
|
||
for clip in result["clips"]:
|
||
assert "clip_type" in clip
|
||
assert "order" in clip
|
||
assert "duration" in clip
|
||
assert clip["duration"] > 0
|
||
|
||
def test_run_ai_recommend_empty_assets(self):
|
||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||
|
||
result = run_ai_recommend(
|
||
plan_id="plan-001",
|
||
template_id="tpl-001",
|
||
asset_ids=[],
|
||
)
|
||
# 即使没有素材,也应该有 intro + outro
|
||
assert len(result["clips"]) >= 2
|
||
|
||
def test_run_generate_cover_ai_frame(self):
|
||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||
|
||
result = run_generate_cover(
|
||
plan_id="plan-001",
|
||
asset_ids=["asset-1"],
|
||
cover_type="ai_frame",
|
||
)
|
||
assert result["type"] == "ai_frame"
|
||
assert "image_url" in result
|
||
assert "frame_time" in result
|
||
assert "confidence" in result
|
||
|
||
def test_run_generate_cover_manual(self):
|
||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||
|
||
result = run_generate_cover(
|
||
plan_id="plan-001",
|
||
asset_ids=["asset-1"],
|
||
cover_type="manual",
|
||
frame_time=5.0,
|
||
)
|
||
assert result["type"] == "manual"
|
||
assert result["frame_time"] == 5.0
|
||
|
||
def test_run_generate_cover_upload(self):
|
||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||
|
||
result = run_generate_cover(
|
||
plan_id="plan-001",
|
||
asset_ids=[],
|
||
cover_type="upload",
|
||
)
|
||
assert result["type"] == "upload"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# API 端点测试 — AI 推荐 & 封面生成
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class StubEditPlanRepository:
|
||
"""内存中的 EditPlan 仓储 stub(支持 clips)"""
|
||
|
||
def __init__(self):
|
||
self._plans: dict[str, Any] = {}
|
||
self._clips: dict[str, list] = {} # plan_id → [clip]
|
||
self._counter = 0
|
||
|
||
def _next_id(self) -> str:
|
||
self._counter += 1
|
||
return f"plan-{self._counter:03d}"
|
||
|
||
def get(self, plan_id: str):
|
||
return self._plans.get(plan_id)
|
||
|
||
def create(self, plan):
|
||
self._plans[plan.id] = plan
|
||
return plan
|
||
|
||
def update(self, plan):
|
||
if plan.id not in self._plans:
|
||
raise ValueError(f"EditPlan {plan.id} not found")
|
||
self._plans[plan.id] = plan
|
||
return plan
|
||
|
||
def delete(self, plan_id: str):
|
||
if plan_id not in self._plans:
|
||
return False
|
||
del self._plans[plan_id]
|
||
return True
|
||
|
||
def list_all(self, *, status=None, skip=0, limit=50):
|
||
items = list(self._plans.values())
|
||
if status:
|
||
items = [p for p in items if p.status == status]
|
||
return items[skip : skip + limit]
|
||
|
||
def count(self, *, template_id=None, status=None):
|
||
return len(list(self._plans.values()))
|
||
|
||
def delete_by_plan(self, plan_id: str):
|
||
self._clips.pop(plan_id, None)
|
||
|
||
|
||
def _make_auth_user():
|
||
from app.auth import AuthenticatedUser
|
||
|
||
from packages.domain.entities import User
|
||
|
||
user = User(id="user-001", email="test@example.com", display_name="测试用户")
|
||
return AuthenticatedUser(user=user)
|
||
|
||
|
||
def _create_ai_test_app():
|
||
"""创建带 stub 注入的测试 FastAPI 应用(支持 AI 端点)"""
|
||
import app.services.edit_plan_service as service_module
|
||
from app.api.routes import edit_plans as edit_plans_module
|
||
from app.api.routes.edit_plans import router
|
||
|
||
stub_repo = StubEditPlanRepository()
|
||
|
||
# Mock service methods that interact with DB
|
||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||
|
||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_repo
|
||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_repo
|
||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: stub_repo
|
||
|
||
app = FastAPI()
|
||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||
app.dependency_overrides[edit_plans_module.get_current_user] = _make_auth_user
|
||
app.dependency_overrides[edit_plans_module.get_db_session] = lambda: MagicMock()
|
||
|
||
def cleanup():
|
||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||
|
||
return app, stub_repo, cleanup
|
||
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||
|
||
|
||
@pytest.fixture
|
||
def ai_client():
|
||
app, stub_repo, cleanup = _create_ai_test_app()
|
||
yield TestClient(app), stub_repo
|
||
cleanup()
|
||
|
||
|
||
class TestAIRecommendEndpoint:
|
||
def test_ai_recommend_success(self, ai_client):
|
||
c, repo = ai_client
|
||
plan = EditPlan.create("tpl-001", "测试计划")
|
||
repo.create(plan)
|
||
|
||
resp = c.post(
|
||
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
|
||
json={"asset_ids": ["asset-1", "asset-2"]},
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["plan_id"] == plan.id
|
||
assert "clips" in data
|
||
assert len(data["clips"]) >= 2
|
||
assert "config" in data
|
||
assert data["total_duration"] > 0
|
||
assert "confidence" in data
|
||
|
||
def test_ai_recommend_not_found(self, ai_client):
|
||
c, repo = ai_client
|
||
resp = c.post(
|
||
"/api/v1/edit-plans/nonexistent/ai-recommend",
|
||
json={"asset_ids": ["asset-1"]},
|
||
)
|
||
assert resp.status_code == 404
|
||
|
||
def test_ai_recommend_rejects_rendering_status(self, ai_client):
|
||
c, repo = ai_client
|
||
plan = EditPlan.create("tpl-001", "渲染中计划")
|
||
plan.start_editing()
|
||
plan.start_rendering()
|
||
repo.create(plan)
|
||
|
||
resp = c.post(
|
||
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
|
||
json={"asset_ids": ["asset-1"]},
|
||
)
|
||
assert resp.status_code == 400
|
||
assert "draft/editing" in resp.json()["detail"]
|
||
|
||
def test_ai_recommend_with_custom_params(self, ai_client):
|
||
c, repo = ai_client
|
||
plan = EditPlan.create("tpl-001", "自定义参数计划")
|
||
repo.create(plan)
|
||
|
||
resp = c.post(
|
||
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
|
||
json={
|
||
"asset_ids": ["asset-1"],
|
||
"editing_mode": "pip",
|
||
"target_duration": 15.0,
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
|
||
def test_ai_recommend_invalid_duration(self, ai_client):
|
||
c, repo = ai_client
|
||
plan = EditPlan.create("tpl-001", "测试计划")
|
||
repo.create(plan)
|
||
|
||
resp = c.post(
|
||
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
|
||
json={"asset_ids": ["asset-1"], "target_duration": -5.0},
|
||
)
|
||
assert resp.status_code == 422
|
||
|
||
|
||
class TestGenerateCoverEndpoint:
|
||
def test_generate_cover_ai_frame(self, ai_client):
|
||
c, repo = ai_client
|
||
plan = EditPlan.create("tpl-001", "封面测试计划")
|
||
repo.create(plan)
|
||
|
||
resp = c.post(
|
||
f"/api/v1/edit-plans/{plan.id}/generate-cover",
|
||
json={"asset_ids": ["asset-1"], "cover_type": "ai_frame"},
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["plan_id"] == plan.id
|
||
assert "cover" in data
|
||
assert data["cover"]["type"] == "ai_frame"
|
||
|
||
def test_generate_cover_manual(self, ai_client):
|
||
c, repo = ai_client
|
||
plan = EditPlan.create("tpl-001", "手动封面计划")
|
||
repo.create(plan)
|
||
|
||
resp = c.post(
|
||
f"/api/v1/edit-plans/{plan.id}/generate-cover",
|
||
json={"asset_ids": ["asset-1"], "cover_type": "manual", "frame_time": 3.5},
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["cover"]["type"] == "manual"
|
||
assert data["cover"]["frame_time"] == 3.5
|
||
|
||
def test_generate_cover_not_found(self, ai_client):
|
||
c, repo = ai_client
|
||
resp = c.post(
|
||
"/api/v1/edit-plans/nonexistent/generate-cover",
|
||
json={"asset_ids": []},
|
||
)
|
||
assert resp.status_code == 404
|
||
|
||
def test_generate_cover_default_type(self, ai_client):
|
||
c, repo = ai_client
|
||
plan = EditPlan.create("tpl-001", "默认封面计划")
|
||
repo.create(plan)
|
||
|
||
# 不传 cover_type,默认 ai_frame
|
||
resp = c.post(
|
||
f"/api/v1/edit-plans/{plan.id}/generate-cover",
|
||
json={"asset_ids": ["asset-1"]},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["cover"]["type"] == "ai_frame"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Config 标准化集成测试(create/update plan & template)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestConfigNormalizationInAPI:
|
||
"""验证 create/update 端点自动标准化 config"""
|
||
|
||
def test_create_plan_normalizes_config(self, ai_client):
|
||
c, repo = ai_client
|
||
resp = c.post(
|
||
"/api/v1/edit-plans",
|
||
json={
|
||
"template_id": "tpl-001",
|
||
"name": "标准化测试",
|
||
"config": {"title": {"text": "自定义标题"}},
|
||
},
|
||
)
|
||
assert resp.status_code == 201
|
||
config = resp.json()["config"]
|
||
# 传入的 title.text 被保留
|
||
assert config["title"]["text"] == "自定义标题"
|
||
# 未传入的 title 字段填充默认值
|
||
assert config["title"]["font"] == "思源黑体"
|
||
# cover/bgm/subtitle 全部填充默认值
|
||
assert config["cover"]["type"] == "ai_frame"
|
||
assert config["bgm"]["volume"] == 0.3
|
||
assert config["subtitle"]["position"] == "bottom"
|
||
|
||
def test_update_plan_normalizes_config(self, ai_client):
|
||
c, repo = ai_client
|
||
plan = EditPlan.create("tpl-001", "更新标准化测试")
|
||
repo.create(plan)
|
||
|
||
resp = c.put(
|
||
f"/api/v1/edit-plans/{plan.id}",
|
||
json={"config": {"bgm": {"volume": 0.9}}},
|
||
)
|
||
assert resp.status_code == 200
|
||
config = resp.json()["config"]
|
||
assert config["bgm"]["volume"] == 0.9
|
||
assert config["bgm"]["source"] == "library"
|
||
assert config["cover"]["type"] == "ai_frame"
|
||
assert config["title"]["enabled"] is True
|