53fb25efcf
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m30s
CI/CD Pipeline / Validate - Style (push) Successful in 4m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m33s
CI/CD Pipeline / Validate - Security (push) Successful in 7m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (push) Successful in 10m11s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Failing after 26h14m3s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 26h24m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 26h19m47s
CI/CD Pipeline / PR Build Web Image (push) Failing after 26h23m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 26h23m44s
CI/CD Pipeline / Deploy Production (push) Failing after 26h13m23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 26h13m26s
CI/CD Pipeline / CI Gate (push) Failing after 26h13m25s
CI/CD Pipeline / Build Production API Image (push) Failing after 26h13m26s
CI/CD Pipeline / Canary Release to Production (push) Failing after 26h13m23s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 26h19m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 26h23m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 26h23m45s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 26h19m46s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
332 lines
11 KiB
Python
332 lines
11 KiB
Python
"""
|
||
#1286 can_generate 最后防线自动修复 — 单元测试
|
||
|
||
覆盖场景:
|
||
1. clips 无素材 + config.asset_ids 非空 → 自动分配成功 → can_generate 返回 True
|
||
2. clips 无素材 + config.asset_ids 为空 → 无法修复 → can_generate 返回 False
|
||
3. clips 已有素材 → 正常通过,不触发自动修复
|
||
4. 自动修复后素材数量与 clips 数量一致(循环分配验证)
|
||
5. 自动修复不影响预览生成流程(预览不依赖 EditPlan clips)
|
||
6. config 为 None 时安全降级
|
||
7. 无 clips 时仍返回 False
|
||
8. 部分 clips 有素材时正常通过(不触发自动修复)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
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"))
|
||
|
||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Stub Repositories
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class StubEditPlanRepository:
|
||
def __init__(self) -> None:
|
||
self._plans: dict[str, EditPlan] = {}
|
||
self._counter = 0
|
||
|
||
def _next_id(self) -> str:
|
||
self._counter += 1
|
||
return f"plan-auto-{self._counter:03d}"
|
||
|
||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||
return self._plans.get(plan_id)
|
||
|
||
def create(self, plan: EditPlan) -> EditPlan:
|
||
if not plan.id:
|
||
plan = EditPlan(
|
||
id=self._next_id(),
|
||
template_id=plan.template_id,
|
||
name=plan.name,
|
||
status=plan.status,
|
||
total_duration=plan.total_duration,
|
||
source_edit_plan_id=plan.source_edit_plan_id,
|
||
project_id=plan.project_id,
|
||
created_by_user_id=plan.created_by_user_id,
|
||
config=plan.config,
|
||
)
|
||
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:
|
||
return self._plans.pop(plan_id, None) is not None
|
||
|
||
def list_all(self, **kwargs):
|
||
return list(self._plans.values())
|
||
|
||
|
||
class StubEditPlanClipRepository:
|
||
def __init__(self) -> None:
|
||
self._clips: dict[str, EditPlanClip] = {}
|
||
self._counter = 0
|
||
|
||
def _next_id(self) -> str:
|
||
self._counter += 1
|
||
return f"clip-auto-{self._counter:03d}"
|
||
|
||
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 = EditPlanClip(
|
||
id=self._next_id(),
|
||
plan_id=clip.plan_id,
|
||
clip_type=clip.clip_type,
|
||
order=clip.order,
|
||
duration=clip.duration,
|
||
status=clip.status,
|
||
asset_id=clip.asset_id,
|
||
text_content=clip.text_content,
|
||
config=clip.config,
|
||
template_clip_config_id=clip.template_clip_config_id,
|
||
transition_effect=clip.transition_effect,
|
||
)
|
||
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:
|
||
return self._clips.pop(clip_id, None) is not None
|
||
|
||
def list_by_plan(
|
||
self,
|
||
plan_id: str,
|
||
*,
|
||
status: Optional[EditPlanClipStatus] = None,
|
||
skip: int = 0,
|
||
limit: int = 100,
|
||
) -> list[EditPlanClip]:
|
||
clips = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||
if status is not None:
|
||
clips = [c for c in clips if c.status == status]
|
||
return sorted(clips, key=lambda c: c.order)[skip : skip + limit]
|
||
|
||
def delete_by_plan(self, plan_id: str) -> int:
|
||
to_del = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
|
||
for cid in to_del:
|
||
del self._clips[cid]
|
||
return len(to_del)
|
||
|
||
def count_by_plan(self, plan_id: str) -> int:
|
||
return sum(1 for c in self._clips.values() if c.plan_id == plan_id)
|
||
|
||
|
||
class StubGenerationTaskRepository:
|
||
def __init__(self):
|
||
pass
|
||
|
||
|
||
def _make_service():
|
||
from app.services.edit_plan_service import EditPlanService
|
||
|
||
db = MagicMock()
|
||
svc = EditPlanService(db)
|
||
svc._plan_repo = StubEditPlanRepository()
|
||
svc._clip_repo = StubEditPlanClipRepository()
|
||
svc._generation_task_repo = StubGenerationTaskRepository()
|
||
return svc
|
||
|
||
|
||
# ===========================================================================
|
||
# 测试用例
|
||
# ===========================================================================
|
||
|
||
|
||
class TestCanGenerateAutoRepair:
|
||
"""#1286 can_generate 最后防线自动修复"""
|
||
|
||
def test_auto_repair_with_config_asset_ids(self):
|
||
"""clips 无素材 + config.asset_ids 非空 → 自动分配成功 → can_generate True"""
|
||
svc = _make_service()
|
||
p = svc.create_plan(
|
||
"tpl-001",
|
||
"测试",
|
||
config={"asset_ids": ["asset-001", "asset-002"]},
|
||
)
|
||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||
svc.create_clip(p.id, "intro", 0)
|
||
svc.create_clip(p.id, "main", 1)
|
||
|
||
# clips 无素材
|
||
clips = svc.list_clips(p.id)
|
||
assert all(not c.asset_id for c in clips)
|
||
|
||
# can_generate 应触发自动修复
|
||
can, reason = svc.can_generate(p.id)
|
||
assert can is True
|
||
assert reason == ""
|
||
|
||
# 验证 clips 已被分配素材
|
||
clips_after = svc.list_clips(p.id)
|
||
assert all(c.asset_id is not None for c in clips_after)
|
||
|
||
def test_auto_repair_fails_without_config_asset_ids(self):
|
||
"""clips 无素材 + config.asset_ids 为空 → 无法修复 → False"""
|
||
svc = _make_service()
|
||
p = svc.create_plan("tpl-001", "测试", config={})
|
||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||
svc.create_clip(p.id, "intro", 0)
|
||
|
||
can, reason = svc.can_generate(p.id)
|
||
assert can is False
|
||
assert "没有可渲染" in reason or "素材" in reason
|
||
|
||
def test_no_repair_when_clips_have_assets(self):
|
||
"""clips 已有素材 → 正常通过,不触发自动修复"""
|
||
svc = _make_service()
|
||
p = svc.create_plan(
|
||
"tpl-001",
|
||
"测试",
|
||
config={"asset_ids": ["asset-001", "asset-002"]},
|
||
)
|
||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||
clip = svc.create_clip(p.id, "intro", 0)
|
||
svc.assign_asset(clip.id, "asset-001")
|
||
|
||
can, reason = svc.can_generate(p.id)
|
||
assert can is True
|
||
# 确认 clip 的 asset_id 没有被改变
|
||
clips = svc.list_clips(p.id)
|
||
assert clips[0].asset_id == "asset-001"
|
||
|
||
def test_auto_repair_circular_assignment(self):
|
||
"""素材少于 clips 时循环分配(取模)"""
|
||
svc = _make_service()
|
||
p = svc.create_plan(
|
||
"tpl-001",
|
||
"测试",
|
||
config={"asset_ids": ["asset-A"]},
|
||
)
|
||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||
svc.create_clip(p.id, "intro", 0)
|
||
svc.create_clip(p.id, "main", 1)
|
||
svc.create_clip(p.id, "outro", 2)
|
||
|
||
can, reason = svc.can_generate(p.id)
|
||
assert can is True
|
||
|
||
clips = svc.list_clips(p.id)
|
||
# 所有 3 个 clips 都应被分配了同一个 asset-A
|
||
assert all(c.asset_id == "asset-A" for c in clips)
|
||
|
||
def test_auto_repair_does_not_affect_preview_flow(self):
|
||
"""预览生成走 generation_preview.py,不依赖 EditPlan clips 的 can_generate"""
|
||
svc = _make_service()
|
||
p = svc.create_plan(
|
||
"tpl-001",
|
||
"测试",
|
||
config={"asset_ids": ["asset-001"]},
|
||
)
|
||
# 不切到 editing 状态,模拟预览场景
|
||
svc.create_clip(p.id, "intro", 0)
|
||
|
||
can, reason = svc.can_generate(p.id)
|
||
assert can is False
|
||
assert "编辑" in reason or "模板" in reason
|
||
|
||
def test_auto_repair_empty_config(self):
|
||
"""config 为 None 时也不报错"""
|
||
svc = _make_service()
|
||
p = svc.create_plan("tpl-001", "测试")
|
||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||
svc.create_clip(p.id, "intro", 0)
|
||
|
||
can, reason = svc.can_generate(p.id)
|
||
assert can is False
|
||
assert "没有可渲染" in reason or "素材" in reason
|
||
|
||
def test_auto_repair_no_clips_still_fails(self):
|
||
"""没有 clips 时仍然返回 False(不进入自动修复分支)"""
|
||
svc = _make_service()
|
||
p = svc.create_plan(
|
||
"tpl-001",
|
||
"测试",
|
||
config={"asset_ids": ["asset-001"]},
|
||
)
|
||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||
|
||
can, reason = svc.can_generate(p.id)
|
||
assert can is False
|
||
assert "请先添加片段" in reason
|
||
|
||
def test_auto_repair_partial_assets_still_passes(self):
|
||
"""部分 clips 有素材、部分没有 → 至少有一个有素材 → 通过(原有逻辑)"""
|
||
svc = _make_service()
|
||
p = svc.create_plan(
|
||
"tpl-001",
|
||
"测试",
|
||
config={"asset_ids": ["asset-001"]},
|
||
)
|
||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||
clip1 = svc.create_clip(p.id, "intro", 0)
|
||
svc.create_clip(p.id, "main", 1)
|
||
svc.assign_asset(clip1.id, "asset-001")
|
||
|
||
# 至少一个 clip 有素材 → 通过(不触发自动修复)
|
||
can, reason = svc.can_generate(p.id)
|
||
assert can is True
|
||
|
||
def test_auto_repair_multiple_assets_distributed(self):
|
||
"""多个素材按顺序分配给多个 clips"""
|
||
svc = _make_service()
|
||
p = svc.create_plan(
|
||
"tpl-001",
|
||
"测试",
|
||
config={"asset_ids": ["asset-A", "asset-B", "asset-C"]},
|
||
)
|
||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||
svc.create_clip(p.id, "intro", 0)
|
||
svc.create_clip(p.id, "main", 1)
|
||
svc.create_clip(p.id, "outro", 2)
|
||
|
||
can, reason = svc.can_generate(p.id)
|
||
assert can is True
|
||
|
||
clips = svc.list_clips(p.id)
|
||
assert clips[0].asset_id == "asset-A"
|
||
assert clips[1].asset_id == "asset-B"
|
||
assert clips[2].asset_id == "asset-C"
|
||
|
||
def test_auto_repair_idempotent_on_second_call(self):
|
||
"""第二次调用 can_generate 不会重复分配(已有素材则跳过自动修复)"""
|
||
svc = _make_service()
|
||
p = svc.create_plan(
|
||
"tpl-001",
|
||
"测试",
|
||
config={"asset_ids": ["asset-001"]},
|
||
)
|
||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||
svc.create_clip(p.id, "intro", 0)
|
||
|
||
# 第一次调用触发自动修复
|
||
can1, _ = svc.can_generate(p.id)
|
||
assert can1 is True
|
||
|
||
# 第二次调用应该直接通过,不再触发修复
|
||
can2, reason2 = svc.can_generate(p.id)
|
||
assert can2 is True
|
||
assert reason2 == ""
|