9bd3e7e30a
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m53s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m43s
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 / PR Build API Image (push) 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 / Validate - Migration (alembic) (push) Successful in 6m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m54s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 6m53s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m12s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 7m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m50s
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 / PR Build Worker Image (pull_request) Successful in 2m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m47s
AI Code Review / AI Code Review (pull_request) Failing after 6m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m35s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 12m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m9s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m51s
CI/CD Pipeline / Integration Tests (push) Successful in 6m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m0s
CI/CD Pipeline / Unit Tests (push) Successful in 16m23s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 14m27s
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 / CI Gate (pull_request) Successful in 48s
424 lines
16 KiB
Python
424 lines
16 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")
|