Files
xiaoxia-saas/tests/unit/test_1286_can_generate_auto_repair.py
T
xiaoxia 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
fix(#1834): 批量修复 UP 系列静态分析警告(UP007/UP006/UP017/UP035) (#1928)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 12:59:17 +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, 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 == ""