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
- 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)
202 lines
8.0 KiB
Python
Executable File
202 lines
8.0 KiB
Python
Executable File
"""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 个片段的 service(stub 仓储)。"""
|
||
svc = _make_service()
|
||
# clone 用 self._clip_repo.session 拿 db;stub 无 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="变体")
|