2c5600cfc9
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API 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 6s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m58s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m49s
CI/CD Pipeline / Validate - Style (push) Successful in 6m35s
CI/CD Pipeline / Integration Tests (push) Successful in 6m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Security (push) Successful in 8m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 14m24s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
865 lines
36 KiB
Python
865 lines
36 KiB
Python
"""测试编辑器 from-assets 端点:按模板segment创建片段 + 事务性替换 + 随机起始.
|
||
|
||
覆盖:
|
||
- 片段数量 = segment 数量(required_clips_count 被忽略)
|
||
- 素材不足时同一素材轮询切多个片段
|
||
- 随机 start_time + used_segments 去重
|
||
- 素材时长不足时 clip duration 缩短
|
||
- 素材时长全部为 0/缺失时抛 400「素材可切区间不足」;混合池中零时长素材被跳过
|
||
- 使用 replace_all_clips_transactional 原子性替换
|
||
- order 从 0 开始
|
||
- start_time=None 时抛出 400
|
||
- mark_clips_ready 在事务方法内部完成
|
||
"""
|
||
|
||
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")
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||
|
||
import pytest
|
||
from fastapi import HTTPException
|
||
|
||
TEST_PLAN_ID = "plan-draft-001"
|
||
TEST_USER_ID = "user-001"
|
||
|
||
# 默认测试用 segments:4 个片段,每个 3~5 秒
|
||
DEFAULT_SEGMENTS = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0), (3, 3.0, 5.0)]
|
||
|
||
|
||
def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
|
||
return [(i, dur_min, dur_max) for i in range(count)]
|
||
|
||
|
||
def _patch_zero_noise():
|
||
"""消除 clips.py 排序随机噪声,用于确定性断言(如均衡分配)。
|
||
|
||
排序噪声(random.uniform(0, SCORE_RANDOM_NOISE_MAX))返回 0;
|
||
其他 uniform 调用(片段时长随机)委托给独立 Random 实例,行为不变。
|
||
"""
|
||
import random as _stdlib_random
|
||
|
||
from app.api.routes.templates_editor import clips as clips_module
|
||
|
||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||
|
||
_fallback = _stdlib_random.Random()
|
||
|
||
def _fake_uniform(a, b):
|
||
if b == SCORE_RANDOM_NOISE_MAX:
|
||
return 0.0
|
||
return _fallback.uniform(a, b)
|
||
|
||
return patch.object(clips_module.random, "uniform", _fake_uniform)
|
||
|
||
|
||
def _patch_segments(segments=None):
|
||
return patch(
|
||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||
return_value=segments if segments is not None else DEFAULT_SEGMENTS,
|
||
)
|
||
|
||
|
||
def _make_auth_user():
|
||
auth = MagicMock()
|
||
auth.user.id = TEST_USER_ID
|
||
auth.user.email = "test@example.com"
|
||
auth.user.display_name = "测试用户"
|
||
auth.user_id = TEST_USER_ID
|
||
return auth
|
||
|
||
|
||
def _make_mock_asset(asset_id, duration):
|
||
asset = MagicMock()
|
||
asset.id = asset_id
|
||
asset.duration = duration
|
||
# score_asset 所需的属性(避免 MagicMock 导致类型比较错误)
|
||
asset.quality_score = None
|
||
asset.created_at = None
|
||
asset.metadata = {}
|
||
return asset
|
||
|
||
|
||
def _make_plan_svc(replace_return_count=None):
|
||
svc = MagicMock()
|
||
# replace_all_clips_transactional 返回创建的片段数量
|
||
if replace_return_count is not None:
|
||
svc.replace_all_clips_transactional = MagicMock(return_value=replace_return_count)
|
||
else:
|
||
svc.replace_all_clips_transactional = MagicMock(return_value=0)
|
||
return svc
|
||
|
||
|
||
def _get_clips_data_from_call(mock_plan_svc):
|
||
"""从 replace_all_clips_transactional 的调用中获取 clips_data。"""
|
||
assert mock_plan_svc.replace_all_clips_transactional.called, "replace_all_clips_transactional 未被调用"
|
||
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
|
||
# call_args = ((plan_id, clips_data), kwargs)
|
||
if len(call_args.args) >= 2:
|
||
return call_args.args[1]
|
||
return call_args.kwargs.get("clips_data", [])
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _mock_segment_tracker():
|
||
"""from-assets 现在会读/写素材 metadata 的片段区间记录,测试中 mock 掉避免依赖真实 DB。
|
||
|
||
get_used_segments 返回空 dict(等价历史行为:无历史区间);
|
||
record/remove/reset 回调均无副作用。
|
||
"""
|
||
with (
|
||
patch(
|
||
"app.api.routes.templates_editor.clips.get_used_segments",
|
||
return_value={},
|
||
),
|
||
patch(
|
||
"app.api.routes.templates_editor.clips.record_used_segments",
|
||
return_value=None,
|
||
),
|
||
patch(
|
||
"app.api.routes.templates_editor.clips.make_reuse_callback",
|
||
return_value=lambda asset_id, clip_duration: None,
|
||
),
|
||
patch(
|
||
"app.api.routes.templates_editor.clips.remove_used_segment",
|
||
return_value=False,
|
||
),
|
||
):
|
||
yield
|
||
|
||
|
||
class TestEditorClipsBySegments:
|
||
"""测试按 segment 数量创建片段 + 素材轮询。"""
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_creates_clips_matching_segment_count(self, mock_storage):
|
||
"""4 个 segment 即使只有2个素材也创建4个片段,required_clips_count 被忽略。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=4)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
|
||
|
||
# 均衡分配由 use_count 贪心保证,消除排序噪声后确定性断言
|
||
with _patch_zero_noise(), _patch_segments(DEFAULT_SEGMENTS):
|
||
result = create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
assert result.created_count == 4
|
||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||
assert len(clips_data) == 4
|
||
|
||
# 验证均衡分配(贪心策略保证):2个素材分4个片段,每个素材恰好使用2次
|
||
from collections import Counter
|
||
|
||
asset_ids = [c["asset_id"] for c in clips_data]
|
||
counts = Counter(asset_ids)
|
||
assert counts["a1"] == 2 and counts["a2"] == 2
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_orders_start_at_zero(self, mock_storage):
|
||
"""片段 order 从 0 开始递增。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
|
||
|
||
with _patch_segments(_segments(3)):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||
assert clips_data[0]["order"] == 0
|
||
assert clips_data[1]["order"] == 1
|
||
assert clips_data[2]["order"] == 2
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_uses_transactional_replace(self, mock_storage):
|
||
"""使用 replace_all_clips_transactional 而不是分别 delete + create。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||
|
||
with _patch_segments(_segments(2)):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
# 必须调用事务方法
|
||
mock_plan_svc.replace_all_clips_transactional.assert_called_once()
|
||
# 不应调用单独的 delete 或 create
|
||
assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_no_segments_raises_400(self, mock_storage):
|
||
"""模板没有 segment 配置时返回 400。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc()
|
||
mock_asset_repo = MagicMock()
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||
|
||
with _patch_segments([]):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
assert exc_info.value.status_code == 400
|
||
assert "片段配置" in exc_info.value.detail
|
||
# 不应调用替换方法
|
||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||
|
||
|
||
class TestEditorClipsDurationAndStartTime:
|
||
"""测试素材时长获取、clip duration 缩短、start_time 传入。"""
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_clip_duration_shortened_for_short_assets(self, mock_storage):
|
||
"""素材只有 3s 时 clip duration 缩短到不超过 3.0。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=1)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["short"])
|
||
|
||
with _patch_segments(_segments(1, dur_min=5.0, dur_max=10.0)):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||
assert clips_data[0]["duration"] <= 3.0
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_zero_duration_asset_raises_400(self, mock_storage):
|
||
"""所有素材时长均为 0 时轮询无可用素材,抛出 400「素材可切区间不足」。
|
||
|
||
新轮询逻辑下零时长素材被跳过(而非立即报错);全部素材都被跳过时
|
||
返回 400,不创建无效片段。
|
||
"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc()
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("bad", 0.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["bad"])
|
||
|
||
with _patch_segments(_segments(1)):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
assert exc_info.value.status_code == 400
|
||
assert "素材可切区间不足" in exc_info.value.detail
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_zero_duration_asset_skipped_in_mixed_pool(self, mock_storage):
|
||
"""素材池混合零时长与正常素材时,零时长素材被跳过、正常素材承担片段。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(
|
||
side_effect=lambda aid: {
|
||
"zero": _make_mock_asset("zero", 0.0),
|
||
"good": _make_mock_asset("good", 30.0),
|
||
}[aid]
|
||
)
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["zero", "good"], required_clips_count=2)
|
||
|
||
with (
|
||
_patch_segments(_segments(2)),
|
||
patch(
|
||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||
side_effect=[5.0, 12.0],
|
||
),
|
||
):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||
assert len(clips_data) == 2
|
||
# 所有片段都分配给正常素材,零时长素材被跳过
|
||
assert all(c["asset_id"] == "good" for c in clips_data)
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_missing_duration_asset_raises_400(self, mock_storage):
|
||
"""素材时长缺失(asset_repo.get 返回 None)时抛出 400。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc()
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=None)
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["missing"])
|
||
|
||
with _patch_segments(_segments(1)):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
assert exc_info.value.status_code == 400
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_start_time_passed_to_create(self, mock_storage):
|
||
"""_calc_random_start_time 返回值被传入 clips_data。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
|
||
|
||
with (
|
||
_patch_segments(_segments(2)),
|
||
patch(
|
||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||
side_effect=[12.5, 18.0],
|
||
) as mock_calc,
|
||
):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
assert mock_calc.call_count == 2
|
||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||
# clips_data 按 order 排序,但分配顺序因 shuffle 而随机,
|
||
# 因此只验证两个 start_time 值都存在
|
||
start_times = {c["start_time"] for c in clips_data}
|
||
assert start_times == {12.5, 18.0}
|
||
# 验证 order 仍然有序
|
||
orders = [c["order"] for c in clips_data]
|
||
assert orders == sorted(orders)
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_asset_durations_deduped(self, mock_storage):
|
||
"""asset_ids 有重复时只查询一次素材时长。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a1", "a1"])
|
||
|
||
with _patch_segments(_segments(3)):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
# 去重后只调用 1 次获取素材时长
|
||
assert mock_asset_repo.get.call_count == 1
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_used_segments_maintained_across_clips(self, mock_storage):
|
||
"""同一素材切多个片段时,used_segments 应被维护。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
|
||
|
||
captured_used_segments = []
|
||
|
||
def fake_calc(asset_id, clip_duration, asset_durations, used_segments, on_exhausted=None):
|
||
captured_used_segments.append({aid: list(segs) for aid, segs in (used_segments or {}).items()})
|
||
return (len(captured_used_segments) - 1) * 5.0
|
||
|
||
with (
|
||
_patch_segments(_segments(3)),
|
||
patch(
|
||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||
side_effect=fake_calc,
|
||
),
|
||
):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
# 第一次没有已使用时间段
|
||
assert captured_used_segments[0] == {}
|
||
# 第二次有第一次的记录
|
||
assert len(captured_used_segments[1]["a1"]) == 1
|
||
# 第三次有前两次的记录
|
||
assert len(captured_used_segments[2]["a1"]) == 2
|
||
|
||
|
||
class TestEditorClipsErrorHandling:
|
||
"""测试异常处理。"""
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_none_start_time_raises_400(self, mock_storage):
|
||
"""所有素材 calc 均返回 None(区间耗尽且复用被拒)→ 轮询失败抛 400。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc()
|
||
mock_asset_repo = MagicMock()
|
||
# 素材有 duration 但 calc 返回 None(模拟可用区间耗尽、复用被闸门拒绝)
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||
|
||
with (
|
||
_patch_segments(_segments(1)),
|
||
patch(
|
||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||
return_value=None,
|
||
),
|
||
):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
assert exc_info.value.status_code == 400
|
||
assert "素材可切区间不足" in exc_info.value.detail
|
||
# 复用被拒导致无起点时,不应创建任何片段
|
||
assert not mock_plan_svc.replace_all_clips_transactional.called
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_transactional_replace_exception_propagates(self, mock_storage):
|
||
"""replace_all_clips_transactional 抛异常时应向上传播(事务已回滚)。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc()
|
||
mock_plan_svc.replace_all_clips_transactional = MagicMock(side_effect=RuntimeError("DB connection lost"))
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||
|
||
with _patch_segments(_segments(1)):
|
||
with pytest.raises(RuntimeError, match="DB connection lost"):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
|
||
class TestReuseRatioGate:
|
||
"""素材区间耗尽后的受控复用与 15% 占比闸门(路由级)。"""
|
||
|
||
@staticmethod
|
||
def _make_calc_with_reuse(normal_starts, reused_durations):
|
||
"""构造模拟「区间耗尽后受控复用」的 _calc_random_start_time。
|
||
|
||
normal_starts: list[float | None],前 N 次调用返回的空闲起点;
|
||
返回 None 表示随机找不到空闲 → 触发 on_exhausted 复用回调。
|
||
回调被调用时返回复用区间(固定 0.0 起点),复用片段时长由路由累加到
|
||
reused_durations;回调内部占比预判超 15% 时返回 None(calc 随之 None)。
|
||
"""
|
||
calls = {"i": 0}
|
||
|
||
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
|
||
i = calls["i"]
|
||
calls["i"] += 1
|
||
if i < len(normal_starts) and normal_starts[i] is not None:
|
||
return normal_starts[i]
|
||
# 空闲耗尽 → 走受控复用回调(回调返回 (start, end) 元组,calc 取起点)
|
||
if on_exhausted is not None:
|
||
result = on_exhausted(asset_id, clip_duration)
|
||
return result[0] if result else None
|
||
return None
|
||
|
||
return fake_calc, calls
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_reused_clip_ratio_within_threshold(self, mock_storage):
|
||
"""素材 60s、片段 5s:前 12 个用空闲区间,第 13 个复用,
|
||
复用占比 5/(12*5+5)=7.7% ≤ 15%,正常创建 13 个片段。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=13)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 60.0))
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=13)
|
||
|
||
reused: dict = {}
|
||
|
||
def reuse_cb(aid, dur):
|
||
# 模拟真实回调:返回复用区间前记录复用时长
|
||
reused[aid] = reused.get(aid, 0.0) + dur
|
||
return (0.0, dur)
|
||
|
||
# 前 12 次分配空闲起点;第 13 次 calc 直接走回调(normal_starts 越界 → None → 回调)
|
||
normal_starts = [float(i * 5) for i in range(12)]
|
||
fake_calc, _ = self._make_calc_with_reuse(normal_starts, reused)
|
||
with (
|
||
_patch_segments(_segments(13, dur_min=5.0, dur_max=5.0)),
|
||
patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
|
||
patch("app.api.routes.templates_editor.clips.make_reuse_callback", return_value=reuse_cb),
|
||
):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||
assert len(clips_data) == 13
|
||
# 1 个复用片段,占比 1/13 ≈ 7.7% ≤ 15%
|
||
# 转场补偿: raw_duration = 5.0 + (13-1)*0.5/13 ≈ 5.462 → round(5.462,1) = 5.5
|
||
assert abs(reused.get("a1", 0.0) - 5.5) < 0.1
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_reuse_ratio_exceeded_returns_400(self, mock_storage):
|
||
"""复用占比将超 15% 时回调拒绝复用 → 无可用素材 → 400「素材可切区间不足」。
|
||
|
||
60s 素材、5s 片段:前 12 个空闲、随后复用占比累计;当 (reused+d)/(assigned+d)
|
||
超过 15% 时回调返回 None,calc 返回 None,轮询无素材 → 400。
|
||
"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=0)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 60.0))
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=20)
|
||
|
||
# 模拟真实回调:累计复用时长,预判超 15% 拒绝
|
||
reused: dict = {}
|
||
assigned: dict = {}
|
||
|
||
def fake_reuse_cb(aid, clip_duration):
|
||
a = assigned.get(aid, 0.0)
|
||
r = reused.get(aid, 0.0)
|
||
if a > 0 and (r + clip_duration) / (a + clip_duration) > 0.15:
|
||
return None # 占比闸门拒绝
|
||
reused[aid] = r + clip_duration
|
||
return (0.0, clip_duration)
|
||
|
||
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
|
||
a = assigned.get(asset_id, 0.0)
|
||
# 前 12 个片段(60s/5s)有空闲区间
|
||
if a < 60.0:
|
||
start = a
|
||
assigned[asset_id] = a + clip_duration
|
||
return start
|
||
# 之后空闲耗尽 → 复用
|
||
if on_exhausted is not None:
|
||
result = on_exhausted(asset_id, clip_duration)
|
||
if result is not None:
|
||
assigned[asset_id] = assigned.get(asset_id, 0.0) + clip_duration
|
||
return result[0] if result else None
|
||
return None
|
||
|
||
with (
|
||
_patch_segments(_segments(20, dur_min=5.0, dur_max=5.0)),
|
||
patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
|
||
patch("app.api.routes.templates_editor.clips.make_reuse_callback", return_value=fake_reuse_cb),
|
||
):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
assert exc_info.value.status_code == 400
|
||
assert "素材可切区间不足" in exc_info.value.detail
|
||
# 闸门在复用占比达上限时拒绝:60s 空闲 + 至多 ~15% 复用
|
||
assert reused.get("a1", 0.0) <= 12.0 # 10.0 或 15.0 以内,不会无限复用
|
||
# 未创建任何片段(整批失败)
|
||
assert not mock_plan_svc.replace_all_clips_transactional.called
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_calc_none_falls_through_to_next_asset(self, mock_storage):
|
||
"""一个素材区间耗尽且复用被拒(calc 返回 None)时,轮询到下一个可用素材。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(
|
||
side_effect=lambda aid: {
|
||
"exhausted": _make_mock_asset("exhausted", 60.0),
|
||
"fresh": _make_mock_asset("fresh", 60.0),
|
||
}[aid]
|
||
)
|
||
body = ClipsFromAssetsRequest(asset_ids=["exhausted", "fresh"], required_clips_count=2)
|
||
|
||
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
|
||
if asset_id == "exhausted":
|
||
# 空闲耗尽 + 回调拒绝 → None
|
||
return on_exhausted(asset_id, clip_duration) if on_exhausted else None
|
||
return 8.0 # 新鲜素材正常返回
|
||
|
||
with (
|
||
_patch_segments(_segments(2, dur_min=5.0, dur_max=5.0)),
|
||
patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
|
||
patch(
|
||
"app.api.routes.templates_editor.clips.make_reuse_callback",
|
||
return_value=lambda aid, d: None, # 复用始终被拒
|
||
),
|
||
):
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||
assert len(clips_data) == 2
|
||
# 耗尽素材被跳过,两个片段都分配给新鲜素材
|
||
assert all(c["asset_id"] == "fresh" for c in clips_data)
|
||
|
||
|
||
# ── P0 回归:from-assets 对 undefined/null/空串 asset_ids 容错 ──────────────
|
||
# 线上事故:前端 smart-match 拿到 {asset: {...}} 包装层后 items.map(a=>a.id)
|
||
# 全为 undefined,POST /clips/from-assets 携带 null → 422,自动模式预览断裂。
|
||
|
||
|
||
class TestClipsFromAssetsInvalidIds:
|
||
def test_schema_filters_null_and_empty_ids(self):
|
||
"""请求 schema 在 pre 阶段剔除 null/空串/空白 id,不触发 422。"""
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", " ", "a2"]) # type: ignore[list-item]
|
||
assert body.asset_ids == ["a1", "a2"]
|
||
|
||
def test_schema_all_invalid_raises(self):
|
||
"""全部为非法 id 时 min_length=1 兜底报校验错误(前端得到 422 而非脏数据)。"""
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
from pydantic import ValidationError
|
||
|
||
with pytest.raises(ValidationError):
|
||
ClipsFromAssetsRequest(asset_ids=[None, "", " "]) # type: ignore[list-item]
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_route_filters_invalid_ids_and_uses_valid(self, mock_storage):
|
||
"""路由层二次兜底:混有 null/空串时只用合法 id 正常创建片段。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||
|
||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||
mock_asset_repo = MagicMock()
|
||
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0))
|
||
|
||
body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", "a2"]) # type: ignore[list-item]
|
||
|
||
# 消除排序噪声,确定性断言两条合法素材各被使用
|
||
with _patch_zero_noise(), _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)):
|
||
result = create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
|
||
assert result.created_count == 2
|
||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||
assert {c["asset_id"] for c in clips_data} == {"a1", "a2"}
|
||
|
||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||
def test_route_all_empty_ids_raises_400(self, mock_storage):
|
||
"""schema 被绕过直接调路由、且 id 全非法时,路由 400 而非 500/422。"""
|
||
from app.api.routes.templates_editor.clips import (
|
||
create_clips_from_assets_editor,
|
||
)
|
||
|
||
mock_plan_svc = _make_plan_svc()
|
||
mock_asset_repo = MagicMock()
|
||
|
||
body = MagicMock()
|
||
body.asset_ids = [None, "", " "]
|
||
|
||
with _patch_segments(DEFAULT_SEGMENTS):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
create_clips_from_assets_editor(
|
||
template_id="tpl-001",
|
||
body=body,
|
||
background_tasks=MagicMock(),
|
||
plan_id=TEST_PLAN_ID,
|
||
services=(MagicMock(), mock_plan_svc),
|
||
asset_repo=mock_asset_repo,
|
||
db=MagicMock(),
|
||
current_user=_make_auth_user(),
|
||
)
|
||
assert exc_info.value.status_code == 400
|
||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|