21d7a6f45a
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / Check if frontend-only change (pull_request) Successful in 1m31s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m49s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Failing after 3m15s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m12s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 2m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m32s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m57s
根因:批量生成的变体 1..N 走 clone_plan_for_variant 只重算起点,
素材/顺序/速度与源 plan 完全相同,导致 N 条成片片段同源(批次查重率 60.1%/33.3%)。
修复(多视频 = 单视频逻辑 × N):
- 新增 packages/domain/variant_plan_selector.py 纯核心:
素材池 shuffle + main 片段顺序洗牌 + _resolve_start_time 起点重选
(镜头洗牌/随机起点/历史区间避让,与单视频同一入口)+ 批次内同素材
区间重叠 >20% 自动重选起点(最多 6 次)
- EditPlanService.reselect_plan_for_variant:源 plan 骨架(clip_type/order/
duration/文案/转场/速度)保留,素材池=源素材∪批次素材,新片段区间
record_used_segments 写回(跨变体/跨任务避让),replace 事务落库
- 正式生成(generation_tasks):创建任务前预生成 N-1 个独立 plan,
变体0保留用户预览 plan;count>1 无源 plan 直接 400(严禁 N 任务兜底
共用同一 plan);任务循环兜底块加批量守卫(缺 plan 500 中断)
- 批量预览(generation_preview):count>1 变体0用源 plan、变体1..N-1
reselect 真实选片(所见即所得);count==1 保持 clone 零回归
- worker 渲染:上传 OSS 与落库查重拆分(_upload_rendered_video/
_record_video_and_dedup);批次任务 batch_similarity>20% 重选 plan
重渲一次(最多1次,重选失败保留首版,首版_v1后缀不覆盖);
封面按 task_id md5 稳定哈希分散选候选帧(非批次仍取首帧)
- dedup_helpers.create_video_record_and_dedup 返回 dict:
{video_count,is_duplicate,batch_similarity,duplicate_of}
测试:新增 24 个单测(纯核心选片差异化/重叠避让/骨架保留、服务层
reselect、重渲阈值判定、封面哈希、批次查重 dict 契约),适配 6 个旧测试,
全量 14469 passed;diff coverage 63%。
245 lines
9.6 KiB
Python
245 lines
9.6 KiB
Python
"""Tests for two-phase commit pattern in dedup_helpers (#1664 follow-up).
|
||
|
||
Verifies that the new dedup_helpers.py:
|
||
1. Creates video with all dedup fields in a single commit
|
||
2. Still creates video when fingerprint computation fails
|
||
3. Creates video with fingerprint but no rate when rate computation fails
|
||
4. Never does a partial commit (no create + separate update)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
# Mock cv2/numpy before imports
|
||
sys.modules.setdefault("cv2", MagicMock())
|
||
sys.modules.setdefault("numpy", MagicMock())
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT / "apps" / "api"))
|
||
sys.path.insert(0, str(ROOT / "packages"))
|
||
sys.path.insert(0, str(ROOT / "apps" / "worker"))
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||
|
||
import pytest
|
||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||
|
||
|
||
@pytest.fixture
|
||
def session():
|
||
s = MagicMock()
|
||
return s
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_fingerprint():
|
||
fp = MagicMock()
|
||
fp.duration = 15000 # 15 seconds in ms
|
||
fp.to_dict.return_value = {"md5": "abc123", "keyframe_phashes": ["aabb"], "color_histograms": []}
|
||
fp.chunks = []
|
||
fp.keyframe_phashes = ["aabb"]
|
||
fp.color_histograms = []
|
||
fp.md5 = "abc123"
|
||
return fp
|
||
|
||
|
||
class TestTwoPhaseCommit:
|
||
"""Verify that dedup data is computed before commit."""
|
||
|
||
def test_video_created_with_all_dedup_fields(self, session, mock_fingerprint):
|
||
"""When all computations succeed, video is created with all fields in one commit."""
|
||
mock_repo = MagicMock()
|
||
mock_deduplicator = MagicMock()
|
||
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
|
||
mock_deduplicator.check_duplicate.return_value = None
|
||
mock_deduplicator.compute_duplicate_rate.return_value = {
|
||
"duplicate_rate": 42.5,
|
||
"visual_similarity": 0.75,
|
||
"match_count": 2,
|
||
}
|
||
|
||
with (
|
||
patch(
|
||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||
return_value=mock_repo,
|
||
),
|
||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||
patch("video_processing.dedup._save_fingerprint_chunks"),
|
||
):
|
||
result = create_video_record_and_dedup(
|
||
generation_task_id="task-001",
|
||
project_id="proj-001",
|
||
user_id="user-001",
|
||
batch_id="",
|
||
file_url="https://example.com/v.mp4",
|
||
file_size=1024,
|
||
duration=15.0,
|
||
video_path="/tmp/fake.mp4",
|
||
mode="smart",
|
||
session=session,
|
||
)
|
||
|
||
# #1743:返回值由 int 改为 dict(含批次查重信息)
|
||
assert isinstance(result, dict)
|
||
assert result["video_count"] == 1
|
||
assert result["batch_similarity"] is None # 非批次任务无批次相似度
|
||
# create() should be called exactly once with the complete video object
|
||
mock_repo.create.assert_called_once()
|
||
created_video = mock_repo.create.call_args[0][0]
|
||
assert created_video.duplicate_rate == 42.5
|
||
assert created_video.visual_similarity == 0.75
|
||
assert created_video.match_count == 2
|
||
assert created_video.video_fingerprint is not None
|
||
# session.commit should be called exactly once (at the end)
|
||
session.commit.assert_called_once()
|
||
|
||
def test_video_created_even_when_fingerprint_fails(self, session):
|
||
"""When fingerprint computation fails, video is still created (without dedup data)."""
|
||
mock_repo = MagicMock()
|
||
mock_deduplicator = MagicMock()
|
||
mock_deduplicator.compute_fingerprint.side_effect = RuntimeError("cv2 not available")
|
||
|
||
with (
|
||
patch(
|
||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||
return_value=mock_repo,
|
||
),
|
||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||
):
|
||
result = create_video_record_and_dedup(
|
||
generation_task_id="task-002",
|
||
project_id="proj-001",
|
||
user_id="user-001",
|
||
batch_id="",
|
||
file_url="https://example.com/v.mp4",
|
||
file_size=1024,
|
||
duration=15.0,
|
||
video_path="/tmp/fake.mp4",
|
||
mode="smart",
|
||
session=session,
|
||
)
|
||
|
||
# #1743:返回值由 int 改为 dict(含批次查重信息)
|
||
assert isinstance(result, dict)
|
||
assert result["video_count"] == 1
|
||
assert result["batch_similarity"] is None # 非批次任务无批次相似度
|
||
mock_repo.create.assert_called_once()
|
||
created_video = mock_repo.create.call_args[0][0]
|
||
assert created_video.duplicate_rate is None
|
||
assert created_video.video_fingerprint is None
|
||
session.commit.assert_called_once()
|
||
# No dedup methods should have been called
|
||
mock_deduplicator.check_duplicate.assert_not_called()
|
||
mock_deduplicator.compute_duplicate_rate.assert_not_called()
|
||
|
||
def test_video_created_with_fingerprint_but_no_rate(self, session, mock_fingerprint):
|
||
"""When rate computation fails, video is created with fingerprint but no rate."""
|
||
mock_repo = MagicMock()
|
||
mock_deduplicator = MagicMock()
|
||
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
|
||
mock_deduplicator.check_duplicate.return_value = None
|
||
mock_deduplicator.compute_duplicate_rate.side_effect = RuntimeError("DB error")
|
||
|
||
with (
|
||
patch(
|
||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||
return_value=mock_repo,
|
||
),
|
||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||
patch("video_processing.dedup._save_fingerprint_chunks"),
|
||
):
|
||
result = create_video_record_and_dedup(
|
||
generation_task_id="task-003",
|
||
project_id="proj-001",
|
||
user_id="user-001",
|
||
batch_id="",
|
||
file_url="https://example.com/v.mp4",
|
||
file_size=1024,
|
||
duration=15.0,
|
||
video_path="/tmp/fake.mp4",
|
||
mode="smart",
|
||
session=session,
|
||
)
|
||
|
||
# #1743:返回值由 int 改为 dict(含批次查重信息)
|
||
assert isinstance(result, dict)
|
||
assert result["video_count"] == 1
|
||
assert result["batch_similarity"] is None # 非批次任务无批次相似度
|
||
mock_repo.create.assert_called_once()
|
||
created_video = mock_repo.create.call_args[0][0]
|
||
# Fingerprint should be set
|
||
assert created_video.video_fingerprint is not None
|
||
# But duplicate_rate should be None
|
||
assert created_video.duplicate_rate is None
|
||
session.commit.assert_called_once()
|
||
|
||
def test_no_separate_update_call(self, session, mock_fingerprint):
|
||
"""Verify the new pattern uses create() only, not create() + update()."""
|
||
mock_repo = MagicMock()
|
||
mock_deduplicator = MagicMock()
|
||
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
|
||
mock_deduplicator.check_duplicate.return_value = None
|
||
mock_deduplicator.compute_duplicate_rate.return_value = {
|
||
"duplicate_rate": 10.0,
|
||
"visual_similarity": 0.5,
|
||
"match_count": 1,
|
||
}
|
||
|
||
with (
|
||
patch(
|
||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||
return_value=mock_repo,
|
||
),
|
||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||
patch("video_processing.dedup._save_fingerprint_chunks"),
|
||
):
|
||
create_video_record_and_dedup(
|
||
generation_task_id="task-004",
|
||
project_id="proj-001",
|
||
user_id="user-001",
|
||
batch_id="",
|
||
file_url="https://example.com/v.mp4",
|
||
file_size=1024,
|
||
duration=15.0,
|
||
video_path="/tmp/fake.mp4",
|
||
mode="smart",
|
||
session=session,
|
||
)
|
||
|
||
# Only create() should be called, not update()
|
||
mock_repo.create.assert_called_once()
|
||
mock_repo.update.assert_not_called()
|
||
|
||
def test_commit_not_called_on_total_failure(self, session):
|
||
"""When the entire function fails, session.rollback is called instead of commit."""
|
||
mock_repo = MagicMock()
|
||
mock_repo.create.side_effect = RuntimeError("DB connection lost")
|
||
|
||
with patch(
|
||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||
return_value=mock_repo,
|
||
):
|
||
result = create_video_record_and_dedup(
|
||
generation_task_id="task-005",
|
||
project_id="proj-001",
|
||
user_id="user-001",
|
||
batch_id="",
|
||
file_url="https://example.com/v.mp4",
|
||
file_size=1024,
|
||
duration=15.0,
|
||
video_path="/tmp/fake.mp4",
|
||
mode="smart",
|
||
session=session,
|
||
)
|
||
|
||
# #1743:失败路径返回 dict(video_count=0)
|
||
assert result["video_count"] == 0
|
||
session.commit.assert_not_called()
|
||
session.rollback.assert_called_once()
|