db9ee89ffa
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
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 - Style (pull_request) Successful in 1m20s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 10s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m0s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 2m34s
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 / Unit Tests (pull_request) Successful in 1m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m26s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m17s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 22m37s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API 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 / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""Tests for Issue #1670 — 跨视频片段避让(生成前注入已用区间)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
|
|
SQLAlchemyEditPlanClipRepository,
|
|
)
|
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
|
from packages.domain.plan_generator_utils import (
|
|
_distribute_one_take,
|
|
distribute_assets,
|
|
)
|
|
|
|
# ── Repository 层测试 ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestListUsedSegmentsByUser:
|
|
"""测试 list_used_segments_by_user 方法."""
|
|
|
|
def _make_repo(self, session_mock):
|
|
return SQLAlchemyEditPlanClipRepository(session_mock)
|
|
|
|
def test_empty_user_id_returns_empty_dict(self):
|
|
"""空 user_id 直接返回空 dict,不查 DB."""
|
|
session = MagicMock()
|
|
repo = self._make_repo(session)
|
|
result = repo.list_used_segments_by_user("")
|
|
assert result == {}
|
|
session.query.assert_not_called()
|
|
|
|
def test_no_completed_plans_returns_empty_dict(self):
|
|
"""用户没有已完成的 plan 时返回空 dict."""
|
|
session = MagicMock()
|
|
# Mock plan query returns empty
|
|
plan_query = MagicMock()
|
|
plan_query.filter.return_value = plan_query
|
|
plan_query.order_by.return_value = plan_query
|
|
plan_query.limit.return_value = plan_query
|
|
plan_query.all.return_value = []
|
|
session.query.return_value = plan_query
|
|
|
|
repo = self._make_repo(session)
|
|
result = repo.list_used_segments_by_user("user_123")
|
|
assert result == {}
|
|
|
|
def test_aggregates_clips_from_multiple_plans(self):
|
|
"""从多个已完成 plan 的 clips 聚合已用区间."""
|
|
session = MagicMock()
|
|
|
|
# Mock plan query: 2 completed plans
|
|
plan_query = MagicMock()
|
|
plan_query.filter.return_value = plan_query
|
|
plan_query.order_by.return_value = plan_query
|
|
plan_query.limit.return_value = plan_query
|
|
plan_query.all.return_value = [("plan_1",), ("plan_2",)]
|
|
session.query.return_value = plan_query
|
|
|
|
# Mock clip query: clips from both plans
|
|
clip_query = MagicMock()
|
|
clip_query.filter.return_value = clip_query
|
|
clip_query.all.return_value = [
|
|
("asset_A", 0.0, 5.0), # plan_1, asset A: 0~5s
|
|
("asset_A", 10.0, 3.0), # plan_1, asset A: 10~13s
|
|
("asset_B", 2.0, 4.0), # plan_2, asset B: 2~6s
|
|
]
|
|
# Second session.query call is for clips
|
|
session.query.side_effect = [plan_query, clip_query]
|
|
|
|
repo = self._make_repo(session)
|
|
result = repo.list_used_segments_by_user("user_123")
|
|
|
|
assert "asset_A" in result
|
|
assert len(result["asset_A"]) == 2
|
|
assert result["asset_A"][0] == (0.0, 5.0)
|
|
assert result["asset_A"][1] == (10.0, 13.0)
|
|
assert "asset_B" in result
|
|
assert result["asset_B"][0] == (2.0, 6.0)
|
|
|
|
def test_respects_limit_recent_parameter(self):
|
|
"""limit_recent 参数限制查询的 plan 数量."""
|
|
session = MagicMock()
|
|
|
|
plan_query = MagicMock()
|
|
plan_query.filter.return_value = plan_query
|
|
plan_query.order_by.return_value = plan_query
|
|
plan_query.limit.return_value = plan_query
|
|
plan_query.all.return_value = [("plan_1",)]
|
|
session.query.return_value = plan_query
|
|
|
|
clip_query = MagicMock()
|
|
clip_query.filter.return_value = clip_query
|
|
clip_query.all.return_value = [("asset_X", 1.0, 2.0)]
|
|
session.query.side_effect = [plan_query, clip_query]
|
|
|
|
repo = self._make_repo(session)
|
|
result = repo.list_used_segments_by_user("user_123", limit_recent=10)
|
|
|
|
# Verify limit was called with the parameter
|
|
plan_query.limit.assert_called_once_with(10)
|
|
assert "asset_X" in result
|
|
|
|
|
|
# ── Domain 层测试 ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestDistributeAssetsWithExternalSegments:
|
|
"""测试 distribute_assets 传入 external_used_segments 的行为."""
|
|
|
|
def _make_clips(self, count: int, duration: float = 3.0) -> list[EditPlanClip]:
|
|
"""创建指定数量的 MAIN 类型 clips."""
|
|
return [
|
|
EditPlanClip(
|
|
id=f"clip_{i}",
|
|
plan_id="plan_1",
|
|
clip_type="main",
|
|
order=i,
|
|
template_clip_config_id="",
|
|
asset_id="",
|
|
text_content="",
|
|
start_time=0.0,
|
|
duration=duration,
|
|
status=EditPlanClipStatus.PENDING,
|
|
)
|
|
for i in range(count)
|
|
]
|
|
|
|
def test_external_used_segments_none_backward_compatible(self):
|
|
"""external_used_segments=None 时行为不变(向后兼容)."""
|
|
clips = self._make_clips(3)
|
|
asset_ids = ["asset_1", "asset_2", "asset_3"]
|
|
asset_durations = {aid: 30.0 for aid in asset_ids}
|
|
|
|
# Should not raise
|
|
distribute_assets(
|
|
clips,
|
|
asset_ids,
|
|
"one_take",
|
|
asset_durations=asset_durations,
|
|
external_used_segments=None,
|
|
)
|
|
|
|
# All clips should have assets assigned
|
|
for clip in clips:
|
|
assert clip.asset_id != ""
|
|
|
|
def test_external_used_segments_avoids_existing_ranges(self):
|
|
"""传入 external_used_segments 后,新分配的 start_time 避开已有区间."""
|
|
clips = self._make_clips(2, duration=3.0)
|
|
asset_ids = ["asset_1"]
|
|
asset_durations = {"asset_1": 30.0}
|
|
|
|
# Pretend asset_1 0~10s is already used by another video
|
|
external = {"asset_1": [(0.0, 10.0)]}
|
|
|
|
# Run multiple times to check that start_time always avoids 0~10s
|
|
# (with some randomness, but the avoidance should be consistent)
|
|
for _ in range(10):
|
|
test_clips = self._make_clips(1, duration=3.0)
|
|
distribute_assets(
|
|
test_clips,
|
|
asset_ids,
|
|
"one_take",
|
|
asset_durations=asset_durations,
|
|
external_used_segments=external,
|
|
)
|
|
start = test_clips[0].start_time
|
|
# Start time + duration (3s) should not overlap with 0~10
|
|
# i.e., start >= 10.0 or start + 3 <= 0.0 (impossible since start >= 0)
|
|
assert (
|
|
start >= 10.0 or start + 3.0 <= 0.0 or start >= 10.0
|
|
), f"start_time {start} overlaps with existing segment 0~10"
|
|
|
|
def test_external_used_segments_deep_copy(self):
|
|
"""external_used_segments 会被深拷贝,不会修改外部数据."""
|
|
external = {"asset_1": [(0.0, 5.0)]}
|
|
original = {"asset_1": [(0.0, 5.0)]}
|
|
|
|
clips = self._make_clips(1, duration=2.0)
|
|
asset_ids = ["asset_1"]
|
|
asset_durations = {"asset_1": 20.0}
|
|
|
|
distribute_assets(
|
|
clips,
|
|
asset_ids,
|
|
"one_take",
|
|
asset_durations=asset_durations,
|
|
external_used_segments=external,
|
|
)
|
|
|
|
# External dict should be unchanged
|
|
assert external == original
|
|
|
|
def test_empty_external_used_segments_same_as_none(self):
|
|
"""空 dict 的 external_used_segments 行为与 None 相同."""
|
|
clips = self._make_clips(2, duration=3.0)
|
|
asset_ids = ["asset_1", "asset_2"]
|
|
asset_durations = {aid: 30.0 for aid in asset_ids}
|
|
|
|
# Should not raise and should assign assets normally
|
|
distribute_assets(
|
|
clips,
|
|
asset_ids,
|
|
"one_take",
|
|
asset_durations=asset_durations,
|
|
external_used_segments={},
|
|
)
|
|
for clip in clips:
|
|
assert clip.asset_id != ""
|
|
|
|
|
|
# ── Service 层测试 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestServiceLayerIntegration:
|
|
"""测试 _distribute_assets 在 service 层的查询逻辑."""
|
|
|
|
def _make_service(self, clip_repo_mock, asset_repo_mock=None):
|
|
"""创建 PlanGeneratorService 并注入 mock repos."""
|
|
|
|
from apps.api.app.services.plan_generator_service import PlanGeneratorService
|
|
|
|
with (
|
|
patch("apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository"),
|
|
patch(
|
|
"apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository",
|
|
return_value=clip_repo_mock,
|
|
),
|
|
):
|
|
db = MagicMock()
|
|
svc = PlanGeneratorService(db, asset_repo=asset_repo_mock)
|
|
svc._clip_repo = clip_repo_mock
|
|
return svc
|
|
|
|
def _make_clip(self):
|
|
return EditPlanClip(
|
|
id="clip_1",
|
|
plan_id="plan_1",
|
|
clip_type="main",
|
|
order=0,
|
|
template_clip_config_id="",
|
|
asset_id="",
|
|
text_content="",
|
|
start_time=0.0,
|
|
duration=3.0,
|
|
status=EditPlanClipStatus.PENDING,
|
|
)
|
|
|
|
def test_query_called_with_user_id(self):
|
|
"""有 user_id 时调用 list_used_segments_by_user."""
|
|
clip_repo = MagicMock()
|
|
clip_repo.list_used_segments_by_user.return_value = {"asset_A": [(0.0, 5.0)]}
|
|
asset_repo = MagicMock()
|
|
asset_repo.get.return_value = None # smart_match fallback
|
|
|
|
svc = self._make_service(clip_repo, asset_repo)
|
|
clips = [self._make_clip()]
|
|
|
|
svc._distribute_assets(
|
|
clips,
|
|
["asset_A"],
|
|
"one_take",
|
|
asset_durations={"asset_A": 30.0},
|
|
user_id="user_123",
|
|
)
|
|
|
|
clip_repo.list_used_segments_by_user.assert_called_once_with("user_123", limit_recent=50)
|
|
|
|
def test_query_not_called_without_user_id(self):
|
|
"""无 user_id 时不调用查询."""
|
|
clip_repo = MagicMock()
|
|
asset_repo = MagicMock()
|
|
asset_repo.get.return_value = None
|
|
|
|
svc = self._make_service(clip_repo, asset_repo)
|
|
clips = [self._make_clip()]
|
|
|
|
svc._distribute_assets(
|
|
clips,
|
|
["asset_A"],
|
|
"one_take",
|
|
asset_durations={"asset_A": 30.0},
|
|
user_id="",
|
|
)
|
|
|
|
clip_repo.list_used_segments_by_user.assert_not_called()
|
|
|
|
def test_query_failure_does_not_block_generation(self):
|
|
"""查询失败时不阻塞生成,回退到纯随机."""
|
|
clip_repo = MagicMock()
|
|
clip_repo.list_used_segments_by_user.side_effect = Exception("DB error")
|
|
asset_repo = MagicMock()
|
|
asset_repo.get.return_value = None
|
|
|
|
svc = self._make_service(clip_repo, asset_repo)
|
|
clips = [self._make_clip()]
|
|
|
|
# Should not raise
|
|
svc._distribute_assets(
|
|
clips,
|
|
["asset_A"],
|
|
"one_take",
|
|
asset_durations={"asset_A": 30.0},
|
|
user_id="user_123",
|
|
)
|
|
|
|
# Clip should still get an asset assigned (fallback to random)
|
|
assert clips[0].asset_id == "asset_A"
|
|
|
|
def test_preview_and_final_both_query(self):
|
|
"""预览和正式生成都触发查询."""
|
|
for random_selection in [True, False]:
|
|
clip_repo = MagicMock()
|
|
clip_repo.list_used_segments_by_user.return_value = {}
|
|
asset_repo = MagicMock()
|
|
asset_repo.get.return_value = None
|
|
|
|
svc = self._make_service(clip_repo, asset_repo)
|
|
clips = [self._make_clip()]
|
|
|
|
svc._distribute_assets(
|
|
clips,
|
|
["asset_A"],
|
|
"one_take",
|
|
random_selection=random_selection,
|
|
asset_durations={"asset_A": 30.0},
|
|
user_id="user_123",
|
|
)
|
|
|
|
clip_repo.list_used_segments_by_user.assert_called_once()
|