Files
xiaoxia-saas/tests/unit/test_1286_can_generate_auto_repair.py
T
xiaoxia 76f41529fe
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m17s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m18s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m34s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Unit Tests (push) Successful in 9m36s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 17s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 33s
CI/CD Pipeline / Canary Release to Production (push) Failing after 952h28m49s
CI/CD Pipeline / Production Browser E2E (push) Failing after 952h34m16s
CI/CD Pipeline / Deploy Production (push) Failing after 952h34m17s
CI/CD Pipeline / CI Gate (push) Failing after 952h34m21s
CI/CD Pipeline / Build Production Web Image (push) Failing after 952h34m23s
CI/CD Pipeline / Build Production API Image (push) Failing after 952h34m24s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 952h43m50s
CI/CD Pipeline / PR Build API Image (push) Failing after 952h43m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 952h43m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 952h44m49s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 953h7m44s
CI/CD Pipeline / PR Build Web Image (push) Failing after 953h17m14s
fix: #1286 can_generate 增加最后防线自动修复,解决「没有可渲染的就绪片段」报错 (#1288)
2026-08-07 22:42:20 +08:00

332 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
#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, List, 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 == ""