Files
xiaoxia-saas/tests/unit/test_clone_plan_variant.py
CI Bot b2a334f99b
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m52s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m52s
AI Code Review / AI Code Review (pull_request) Failing after 4m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
fix(segments): AI Review 阻塞项修复 — 记录写入行级锁 + 克隆失败不降级共用
- record_used_segments 读取 AssetModel 加 with_for_update() 行级锁,
  序列化并发事务对 classification_result 的读-改-写,杜绝区间记录丢失更新
  (SQLite 下 with_for_update 为 no-op)
- 批量生成:变体 plan 克隆前置到任务创建之前,全部成功才建任务(无脏数据);
  克隆含 1 次重试抗 DB 抖动,仍失败则 500 中断,不再静默退回共用源 plan
- 预览生成:克隆失败标记任务 failed 并返回 500,不再退回原 plan
  (两条修复共同保证批量/多预览视频内容不重复的业务约束)
- 测试:FakeSession 支持 with_for_update;预览相关测试 mock EditPlanService
  或显式置空 source_edit_plan_id;zip 加 strict(B905)
2026-08-30 02:33:19 +08:00

202 lines
8.0 KiB
Python
Executable File
Raw Permalink 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.
"""clone_plan_for_variant 单元测试(Task G 验收项:批量 N 条视频片段独立)。
验证:
- 同一源 plan 克隆 3 次产出 3 个不同 plan_id,各自片段起点不同
- 源 plan 的片段不被修改
- 模板/config/时长结构被复制
- 复用占比闸门触发时保留原起点(不重复抽取)
- 源 plan 无片段时抛出 ValueError
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
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"))
sys.path.insert(0, str(Path(__file__).resolve().parent)) # tests/unit,便于复用同目录 stub
# 复用 test_edit_plan_service 里的内存 stub 仓储
from test_edit_plan_service import ( # noqa: E402
StubEditPlanClipRepository,
StubEditPlanRepository,
_make_service,
)
from packages.domain.edit_plan_clip import EditPlanClip
@pytest.fixture
def svc_with_source():
"""构造带源 plan + 3 个片段的 servicestub 仓储)。"""
svc = _make_service()
# clone 用 self._clip_repo.session 拿 dbstub 无 session,补一个 MagicMock
svc._clip_repo.session = MagicMock()
source = svc.create_plan(template_id="tpl-001", name="源计划", total_duration=15.0)
for i in range(3):
clip = EditPlanClip.create(
plan_id=source.id,
clip_type="main",
order=i,
asset_id=f"a{i % 2 + 1}", # a1, a2, a1
start_time=float(i * 5),
duration=5.0,
)
svc._clip_repo.create(clip)
return svc, source
def _clone_with_fake_calc(svc, source, starts, *, used=None):
"""用受控的 calc 起点列表执行一次克隆。
starts: 每次 _calc_random_start_time 返回的起点(按片段顺序)。
返回 (new_plan, replace_all 调用的 clips_data, calc 调用记录)。
"""
calc_calls: list[dict] = []
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
idx = len(calc_calls)
calc_calls.append({"asset_id": asset_id, "clip_duration": clip_duration, "on_exhausted": on_exhausted})
return starts[idx]
with (
patch(
"app.services.edit_plan_service.get_used_segments",
return_value=used or {},
),
patch(
"app.services.edit_plan_service.make_reuse_callback",
return_value=lambda aid, d: None,
),
patch(
"app.services.edit_plan_service.record_used_segments",
return_value=None,
),
patch(
"packages.domain.plan_generator_utils._calc_random_start_time",
side_effect=fake_calc,
),
patch.object(svc, "replace_all_clips_transactional", return_value=3) as mock_replace,
patch(
"packages.adapters.sqlalchemy_impl.models.AssetModel",
create=True,
) as mock_asset_model,
):
# db.query(AssetModel).filter(...).all() → 返回带 duration 的 mock 素材
m1 = MagicMock(id="a1")
m1.duration = 60.0
m2 = MagicMock(id="a2")
m2.duration = 60.0
svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = [m1, m2]
new_plan = svc.clone_plan_for_variant(source.id, created_by_user_id="u1", name_suffix="变体")
clips_data = mock_replace.call_args.args[1]
return new_plan, clips_data, calc_calls
class TestClonePlanForVariant:
def test_three_clones_produce_distinct_plans_and_starts(self, svc_with_source):
"""克隆 3 次:3 个不同 plan_id,片段起点互不相同(Task G 验收)。"""
svc, source = svc_with_source
start_sets = [
[10.0, 20.0, 30.0],
[11.0, 21.0, 31.0],
[12.0, 22.0, 32.0],
]
plans = []
all_clips = []
for starts in start_sets:
new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, starts)
plans.append(new_plan)
all_clips.append(clips_data)
# 3 个不同 plan_id,且都不等于源 plan
plan_ids = {p.id for p in plans}
assert len(plan_ids) == 3
assert source.id not in plan_ids
# 每次克隆的起点各自不同
for clips_data, starts in zip(all_clips, start_sets, strict=True):
assert [c["start_time"] for c in clips_data] == starts
# 三次克隆的起点集合互不相同
assert {tuple(c["start_time"] for c in clips) for clips in all_clips} == {
(10.0, 20.0, 30.0),
(11.0, 21.0, 31.0),
(12.0, 22.0, 32.0),
}
def test_source_plan_not_modified(self, svc_with_source):
"""克隆不修改源 plan 及其片段(保留用户手动编辑)。"""
svc, source = svc_with_source
source_clips_before = sorted(
[(c.order, c.asset_id, c.start_time, c.duration) for c in svc._clip_repo.list_by_plan(source.id)]
)
source_name_before = source.name
_clone_with_fake_calc(svc, source, [9.0, 19.0, 29.0])
_clone_with_fake_calc(svc, source, [8.0, 18.0, 28.0])
source_clips_after = sorted(
[(c.order, c.asset_id, c.start_time, c.duration) for c in svc._clip_repo.list_by_plan(source.id)]
)
assert source_clips_after == source_clips_before
assert svc._plan_repo.get(source.id).name == source_name_before
def test_clone_copies_structure(self, svc_with_source):
"""克隆复制 template_id / config / total_duration / 片段素材与时长。"""
svc, source = svc_with_source
source.config = {"mode": "ONE_TAKE"}
new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, [10.0, 20.0, 30.0])
assert new_plan.template_id == source.template_id
assert new_plan.total_duration == source.total_duration
assert new_plan.config == {"mode": "ONE_TAKE"}
assert "变体" in new_plan.name
# 片段素材与时长结构保持
assert [c["asset_id"] for c in clips_data] == ["a1", "a2", "a1"]
assert all(c["duration"] == 5.0 for c in clips_data)
assert [c["order"] for c in clips_data] == [0, 1, 2]
def test_clone_uses_reuse_callback(self, svc_with_source):
"""克隆时 calc 传入了 on_exhausted 受控复用回调(耗尽时复用而非清空历史)。"""
svc, source = svc_with_source
_, _, calc_calls = _clone_with_fake_calc(svc, source, [10.0, 20.0, 30.0])
assert len(calc_calls) == 3
for call in calc_calls:
assert call["on_exhausted"] is not None
def test_clone_ratio_blocked_keeps_original_start(self, svc_with_source):
"""复用占比闸门触发(calc 返回 None)时保留源片段原起点。"""
svc, source = svc_with_source
# 第 3 个片段 calc 返回 None(模拟复用占比超 15% 拒绝复用)
new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, [10.0, 20.0, None]) # type: ignore[list-item]
starts = [c["start_time"] for c in clips_data]
assert starts[0] == 10.0
assert starts[1] == 20.0
# 第 3 片段保留源起点(源 order=2 → start_time=10.0
assert starts[2] == 10.0
def test_clone_empty_source_raises(self):
"""源 plan 无片段时抛出 ValueError。"""
svc = _make_service()
svc._clip_repo.session = MagicMock()
empty = svc.create_plan(template_id="tpl-x", name="空计划")
with pytest.raises(ValueError, match="无片段"):
svc.clone_plan_for_variant(empty.id, name_suffix="变体")
def test_clone_nonexistent_source_raises(self):
"""源 plan 不存在时抛出 ValueError。"""
svc = _make_service()
svc._clip_repo.session = MagicMock()
with pytest.raises(ValueError, match="不存在"):
svc.clone_plan_for_variant("no-such-plan", name_suffix="变体")