Files
xiaoxia-saas/tests/unit/test_editor_clips_random_start.py
T
xiaoxia 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
feat: 素材选取评分注入随机噪声,降低成片查重率 (#1620)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-02 01:40:34 +08:00

865 lines
36 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.
"""测试编辑器 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% 时返回 Nonecalc 随之 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)
# 全为 undefinedPOST /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()