"""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