0469272bd6
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
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 (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
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 / PR Build API Image (pull_request) Successful in 22s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m59s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m24s
CI/CD Pipeline / Integration Tests (push) Successful in 2m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (push) Successful in 3m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Validate - Security (push) Successful in 5m28s
AI Code Review / AI Code Review (pull_request) Failing after 5m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m5s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
207 lines
9.1 KiB
Python
207 lines
9.1 KiB
Python
"""#1743 EditPlanService.reselect_plan_for_variant 服务层测试。
|
||
|
||
与 clone_plan_for_variant(只重算起点、素材/顺序不变)不同,reselect 完整重跑
|
||
单视频选片:素材池 shuffle + main 片段顺序洗牌 + 起点重选 + 批次 20% 重叠避让。
|
||
|
||
覆盖:
|
||
- 独立 plan:新 plan_id 与源不同、命名带后缀、模板/config 复制
|
||
- 新片段经 replace_all_clips_transactional 落库,素材/起点与源 plan 存在差异
|
||
- 源 plan 区间作为批次避让初始对象;record_used_segments 随新片段写回
|
||
- 源 plan 无片段 → ValueError(不创建同源变体)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(REPO_ROOT / "apps" / "api"))
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # tests/unit
|
||
|
||
from test_edit_plan_service import StubEditPlanClipRepository, StubEditPlanRepository, _make_service # noqa: E402
|
||
|
||
from packages.domain.edit_plan_clip import EditPlanClip # noqa: E402
|
||
|
||
|
||
@pytest.fixture
|
||
def svc_with_source():
|
||
"""带源 plan(4 个 main 片段,素材 a1/a2/a3/a4)+ 2 个批次候选素材的 service。"""
|
||
svc = _make_service()
|
||
svc._clip_repo.session = MagicMock()
|
||
|
||
source = svc.create_plan(template_id="tpl-001", name="9/6-草稿", total_duration=20.0, config={"title": "源配置"})
|
||
for i, aid in enumerate(["a1", "a2", "a3", "a4"]):
|
||
clip = EditPlanClip.create(
|
||
plan_id=source.id,
|
||
clip_type="main",
|
||
order=i,
|
||
asset_id=aid,
|
||
start_time=float(i * 5),
|
||
duration=5.0,
|
||
text_content=f"文案{i}",
|
||
)
|
||
svc._clip_repo.create(clip)
|
||
return svc, source
|
||
|
||
|
||
def _patch_deps(svc, durations):
|
||
"""统一 patch reselect 的 DB/素材/历史区间依赖。返回 (patches, mock_replace)。"""
|
||
asset_models = []
|
||
for aid, dur in durations.items():
|
||
m = MagicMock(id=aid)
|
||
m.duration = dur
|
||
m.metadata = None # extract_scene_points_from_metadata(None) → 无场景点
|
||
asset_models.append(m)
|
||
|
||
mock_replace = patch.object(svc, "replace_all_clips_transactional", return_value=4)
|
||
patches = [
|
||
patch("app.services.edit_plan_service.get_used_segments", return_value={}),
|
||
patch("app.services.edit_plan_service.record_used_segments", return_value=None),
|
||
patch("packages.domain.plan_generator_utils.extract_scene_points_from_metadata", return_value=[]),
|
||
patch("packages.adapters.sqlalchemy_impl.models.AssetModel", create=True),
|
||
mock_replace,
|
||
]
|
||
started = []
|
||
for p in patches:
|
||
started.append(p.start())
|
||
# started[-1] 是 replace_all 的 MagicMock
|
||
mock_replace_obj = started[-1]
|
||
# db.query(AssetModel).filter(...).all() → 带 duration 的素材 mock
|
||
svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = asset_models
|
||
return patches, mock_replace_obj
|
||
|
||
|
||
class TestReselectPlanForVariant:
|
||
def test_creates_independent_plan_with_different_clips(self, svc_with_source):
|
||
"""reselect 产出新 plan(id/名称不同),片段素材或起点与源 plan 存在差异。"""
|
||
import random
|
||
|
||
svc, source = svc_with_source
|
||
durations = {"a1": 300.0, "a2": 300.0, "a3": 300.0, "a4": 300.0, "a5": 300.0, "a6": 300.0}
|
||
patches, mock_replace = _patch_deps(svc, durations)
|
||
try:
|
||
new_plan = svc.reselect_plan_for_variant(
|
||
source.id,
|
||
["a5", "a6"], # 批次素材并入素材池
|
||
created_by_user_id="u-1",
|
||
name_suffix="批量2",
|
||
rng=random.Random(42),
|
||
)
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.stop()
|
||
|
||
# 新 plan 独立、归属/模板/config 复制
|
||
assert new_plan.id != source.id
|
||
assert "批量2" in new_plan.name
|
||
assert new_plan.template_id == "tpl-001"
|
||
assert new_plan.config == {"title": "源配置"}
|
||
assert new_plan.created_by_user_id == "u-1"
|
||
|
||
# 落库片段数 == 源片段数,且 order 对齐
|
||
clips_data = mock_replace.call_args.args[1]
|
||
assert len(clips_data) == 4
|
||
assert [c["order"] for c in clips_data] == [0, 1, 2, 3]
|
||
|
||
# 与源 plan 对比:素材序列或起点必须存在差异(降重核心——不是克隆)
|
||
source_pairs = [(c.asset_id, round(float(c.start_time), 2)) for c in svc._clip_repo.list_by_plan(source.id)]
|
||
new_pairs = [(c["asset_id"], round(float(c["start_time"]), 2)) for c in clips_data]
|
||
assert new_pairs != source_pairs, f"reselect 片段应与源 plan 不同,实际相同: {new_pairs}"
|
||
# 素材全部来自素材池(源 a1-a4 ∪ 批次 a5-a6)
|
||
for c in clips_data:
|
||
assert c["asset_id"] in durations
|
||
|
||
def test_record_used_segments_called_per_clip(self, svc_with_source):
|
||
"""每个新片段区间调用 record_used_segments 写回(跨变体/跨任务避让链路)。"""
|
||
import random
|
||
|
||
svc, source = svc_with_source
|
||
durations = {"a1": 300.0, "a2": 300.0, "a3": 300.0, "a4": 300.0, "a5": 300.0}
|
||
|
||
asset_models = []
|
||
for aid in durations:
|
||
m = MagicMock(id=aid)
|
||
m.duration = durations[aid]
|
||
m.metadata = None
|
||
asset_models.append(m)
|
||
svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = asset_models
|
||
|
||
started = [
|
||
patch("app.services.edit_plan_service.get_used_segments", return_value={}).start(),
|
||
patch("packages.domain.plan_generator_utils.extract_scene_points_from_metadata", return_value=[]).start(),
|
||
patch("packages.adapters.sqlalchemy_impl.models.AssetModel", create=True).start(),
|
||
patch.object(svc, "replace_all_clips_transactional", return_value=4).start(),
|
||
]
|
||
mock_record = patch("app.services.edit_plan_service.record_used_segments", return_value=None).start()
|
||
try:
|
||
svc.reselect_plan_for_variant(
|
||
source.id, ["a5"], created_by_user_id="u-1", name_suffix="批量2", rng=random.Random(5)
|
||
)
|
||
finally:
|
||
patch.stopall()
|
||
|
||
assert mock_record.call_count == 4, "4 个片段应各写一次 used_segment"
|
||
for call in mock_record.call_args_list:
|
||
args = call.args
|
||
assert args[1] in durations, f"asset_id {args[1]} 不在素材池" # asset_id
|
||
assert args[3] > args[2], "区间 end 应大于 start" # end > start
|
||
assert args[4], "new plan_id 应非空"
|
||
|
||
def test_source_clips_seed_batch_avoidance(self, svc_with_source):
|
||
"""源 plan 片段区间进入批次避让集:与源完全同区间的起点重叠率应超限被避开。"""
|
||
import random
|
||
|
||
svc, source = svc_with_source
|
||
# 素材池只有源素材(极端小池),时长充足
|
||
durations = {"a1": 600.0, "a2": 600.0, "a3": 600.0, "a4": 600.0}
|
||
patches, mock_replace = _patch_deps(svc, durations)
|
||
try:
|
||
new_plan = svc.reselect_plan_for_variant(
|
||
source.id, [], created_by_user_id="u-1", name_suffix="批量2", rng=random.Random(99)
|
||
)
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.stop()
|
||
|
||
clips_data = mock_replace.call_args.args[1]
|
||
source_clips = svc._clip_repo.list_by_plan(source.id)
|
||
source_by_asset = {}
|
||
for c in source_clips:
|
||
source_by_asset.setdefault(c.asset_id, []).append(
|
||
(float(c.start_time), float(c.start_time) + float(c.duration))
|
||
)
|
||
|
||
# 同素材新片段与源区间的重叠占比均 ≤20%
|
||
from packages.domain.variant_plan_selector import _clip_overlap_ratio
|
||
|
||
for c in clips_data:
|
||
ratio = _clip_overlap_ratio(c["asset_id"], float(c["start_time"]), float(c["duration"]), source_by_asset)
|
||
assert (
|
||
ratio <= 0.20 + 1e-6
|
||
), f"变体片段与源 plan 同素材区间重叠超限: asset={c['asset_id']} ratio={ratio:.2%}"
|
||
assert new_plan.id != source.id
|
||
|
||
def test_source_plan_without_clips_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="源 plan 无片段"):
|
||
svc.reselect_plan_for_variant(empty.id, ["a1"], created_by_user_id="u-1", name_suffix="变体")
|
||
|
||
def test_missing_source_plan_raises(self):
|
||
"""源 plan 不存在 → get_plan_or_raise 抛错。"""
|
||
svc = _make_service()
|
||
svc._clip_repo.session = MagicMock()
|
||
with pytest.raises((ValueError, KeyError, LookupError)): # get_plan_or_raise 抛错
|
||
svc.reselect_plan_for_variant("not-exist-plan", ["a1"], created_by_user_id="u-1", name_suffix="变体")
|