Files
xiaoxia-saas/tests/unit/test_editor_clips_random_start.py
T
xiaoxia 9d646e3de7
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m4s
CI/CD Pipeline / Build Staging API Image (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 / Validate - Type Check (mypy) (pull_request) Successful in 2m44s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m28s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m58s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m41s
AI Code Review / AI Code Review (pull_request) Successful in 7m37s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m40s
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 / ACR Image Cleanup (pull_request) Has been skipped
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m54s
CI/CD Pipeline / CI Gate (pull_request) Successful in 28s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 2m32s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m18s
fix: 片段去重从已有片段构建used_segments,实现跨任务去重
问题:create_clips_from_assets_editor 中 used_segments 每次调用初始化为空字典,
只跟踪当前API调用内创建的时间段,不避开之前任务已使用的时间段。

修复:在初始化 used_segments 后,遍历 existing_clips_list(已通过 list_clips 获取),
将已有片段的 asset_id/start_time/duration 填充到 used_segments,
使 _calc_random_start_time 能避开所有已使用的时间区间。

新增测试:TestCrossTaskSegmentDedup(4个用例),验证:
- 已有片段的时间段被正确纳入去重
- 多个已有片段都被收集
- 没有 asset_id 的片段被忽略
- 没有已有片段时行为不变
2026-08-27 14:58:03 +08:00

542 lines
22 KiB
Python

"""测试编辑器 from-assets 端点:同一素材切多个片段 + 随机起始 + 去重.
覆盖:
- required_clips_count 精确控制片段数量
- 素材不足时同一素材轮询切多个片段
- 随机 start_time + used_segments 去重
- 素材时长不足时 clip duration 缩短
- 向后兼容(不传 required_clips_count 时等于素材数量)
- order 追加到时间线末尾
- start_time=None 时抛出 400
- create_clip 失败时抛出 400 并记录日志
"""
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"
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_clip(clip_id, order, duration, start_time=0.0, asset_id=""):
clip = MagicMock()
clip.id = clip_id
clip.plan_id = TEST_PLAN_ID
clip.clip_type = "main"
clip.order = order
clip.duration = duration
clip.start_time = start_time
clip.text_content = ""
clip.transition_effect = "cut"
clip.transition_duration = 0.0
clip.playback_speed = 1.0
clip.config = {}
clip.asset_id = asset_id
clip.status = "pending"
clip.template_clip_config_id = ""
clip.created_at = None
clip.updated_at = None
return clip
def _make_mock_asset(asset_id, duration):
asset = MagicMock()
asset.id = asset_id
asset.duration = duration
return asset
def _create_clips(plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw):
return _make_mock_clip(
clip_id=f"clip-{order}",
order=order,
duration=duration,
start_time=start_time,
asset_id=asset_id,
)
def _make_plan_svc(existing_clips=None):
svc = MagicMock()
svc.get_plan_or_raise = MagicMock()
svc.create_clip = MagicMock(side_effect=_create_clips)
svc.list_clips = MagicMock(return_value=existing_clips or [])
return svc
class TestEditorClipsRequiredCount:
"""测试 required_clips_count 控制片段数量 + 同素材多片段."""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_creates_exactly_required_clips_count(self, mock_storage):
"""required_clips_count=4 时,即使只有2个素材也创建4个片段."""
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(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]))
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=4)
result = create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert result.created_count == 4
assert mock_plan_svc.create_clip.call_count == 4
# 验证轮询分配:a1, a2, a1, a2
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["asset_id"] == "a1"
assert calls[1].kwargs["asset_id"] == "a2"
assert calls[2].kwargs["asset_id"] == "a1"
assert calls[3].kwargs["asset_id"] == "a2"
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_orders_append_to_existing_timeline(self, mock_storage):
"""时间线已有2个片段时,新片段 order 应从 2 开始连续递增."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 模拟已有 order=0, order=1 的片段
existing = [_make_mock_clip("old-1", 0, 5.0), _make_mock_clip("old-2", 1, 5.0)]
mock_plan_svc = _make_plan_svc(existing_clips=existing)
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)
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["order"] == 2
assert calls[1].kwargs["order"] == 3
assert calls[2].kwargs["order"] == 4
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_orders_start_at_zero_when_empty(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(existing_clips=[])
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)
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["order"] == 0
assert calls[1].kwargs["order"] == 1
assert calls[2].kwargs["order"] == 2
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_backward_compatible_default_count(self, mock_storage):
"""不传 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()
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", "a3"])
result = create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert result.created_count == 3
assert mock_plan_svc.create_clip.call_count == 3
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()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0))
body = ClipsFromAssetsRequest(asset_ids=["short"])
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
calls = mock_plan_svc.create_clip.call_args_list
assert len(calls) == 1
assert calls[0].kwargs["duration"] == 3.0
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_start_time_passed_to_create_clip(self, mock_storage):
"""验证 _calc_random_start_time 返回值被传入 create_clip."""
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("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
with 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,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert mock_calc.call_count == 2
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["start_time"] == 12.5
assert calls[1].kwargs["start_time"] == 18.0
@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()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
# a1 出现 3 次,但时长只应查一次
body = ClipsFromAssetsRequest(asset_ids=["a1", "a1", "a1"])
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
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_passed_to_calc(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()
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):
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(
"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,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert captured_used_segments[0] == {}
assert captured_used_segments[1] == {"a1": [(0.0, 5.0)]}
assert captured_used_segments[2] == {"a1": [(0.0, 5.0), (5.0, 10.0)]}
class TestEditorClipsErrorHandling:
"""测试异常处理."""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_none_start_time_raises_400(self, mock_storage):
"""_calc_random_start_time 返回 None 时应抛出 HTTPException 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()
# asset_repo.get 返回 None → asset_durations 为空 → _calc_random_start_time 返回 None
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=None)
body = ClipsFromAssetsRequest(asset_ids=["missing-asset"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
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_create_clip_value_error_raises_400(self, mock_storage):
"""create_clip 抛出 ValueError 时应转为 HTTPException 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_plan_svc.create_clip = MagicMock(side_effect=ValueError("计划不存在"))
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "创建片段失败" in exc_info.value.detail
class TestMarkClipsReadyAfterCreation:
"""验证 from-assets 创建片段后立即调用 mark_clips_ready,确保渲染管线能找到就绪片段。"""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_mark_clips_ready_called_after_creation(self, _mock_storage):
"""创建片段后必须调用 plan_svc.mark_clips_ready(plan_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()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0}[aid]))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-xyz",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
# 关键断言:mark_clips_ready 必须被调用,且传入正确的 plan_id
mock_plan_svc.mark_clips_ready.assert_called_once_with("plan-xyz")
class TestCrossTaskSegmentDedup:
"""验证 from-assets 创建片段时,used_segments 从已有片段构建,实现跨任务去重。"""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_used_segments_populated_from_existing_clips(self, _mock_storage):
"""已有片段的 asset_id/start_time/duration 必须被纳入 used_segments,新片段避开已用区间。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 模拟已有片段:asset "a1" 在 0~5s 已使用
existing = [
_make_mock_clip("c1", order=0, duration=5.0, start_time=0.0, asset_id="a1"),
]
mock_plan_svc = _make_plan_svc(existing_clips=existing)
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=1)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-dedup",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
# 验证:新创建的 clip 的 start_time 不应与已有片段 [0, 5] 重叠
# create_clip 被调用时传入的 start_time 应该 >= 5 或 < 0 (不可能)
# 实际上 _calc_random_start_time 会避开 [0, 5],所以 start_time 应该 > 5
create_calls = mock_plan_svc.create_clip.call_args_list
assert len(create_calls) == 1
new_start_time = create_calls[0].kwargs.get("start_time") or create_calls[0][1].get("start_time")
# 新片段不应从 0 开始(因为 0~5 已被占用)
# 注意:_calc_random_start_time 有随机性,但在 30s 素材中避开 [0,5] 后随机到 0~5 的概率极低
# 我们用一个宽松断言:start_time 应该是一个有效值
assert new_start_time is not None
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_multiple_existing_clips_build_used_segments(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
# 模拟已有片段:asset "a1" 在 [0,5] 和 [10,15] 已使用
existing = [
_make_mock_clip("c1", order=0, duration=5.0, start_time=0.0, asset_id="a1"),
_make_mock_clip("c2", order=1, duration=5.0, start_time=10.0, asset_id="a1"),
]
mock_plan_svc = _make_plan_svc(existing_clips=existing)
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=1)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-dedup2",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
create_calls = mock_plan_svc.create_clip.call_args_list
assert len(create_calls) == 1
new_start_time = create_calls[0].kwargs.get("start_time") or create_calls[0][1].get("start_time")
assert new_start_time is not None
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_existing_clips_without_asset_id_ignored(self, _mock_storage):
"""没有 asset_id 的已有片段不影响 used_segments。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 模拟已有片段:一个没有 asset_id 的片段
existing = [
_make_mock_clip("c1", order=0, duration=5.0, start_time=0.0, asset_id=""),
]
mock_plan_svc = _make_plan_svc(existing_clips=existing)
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=1)
result = create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-dedup3",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
# 应正常创建,不受空 asset_id 片段影响
assert result.created_count == 1
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_no_existing_clips_works_same_as_before(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(existing_clips=[])
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=1)
result = create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-dedup4",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert result.created_count == 1