0469272bd6
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
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 (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 9s
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 API Image (pull_request) Successful in 22s
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 / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m59s
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 Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m24s
CI/CD Pipeline / Integration Tests (push) Successful in 2m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (push) Successful in 3m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Validate - Security (push) Successful in 5m28s
AI Code Review / AI Code Review (pull_request) Failing after 5m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m5s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m54s
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 / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
245 lines
10 KiB
Python
245 lines
10 KiB
Python
"""#1743 dedup_helpers 批次查重 + worker 重选 plan 重试函数测试。
|
||
|
||
覆盖:
|
||
- 批次任务且无历史重复时走 check_batch_duplicate,batch_similarity 透传到返回 dict
|
||
- 批次任务历史已重复 → 不再做批次查重,is_duplicate=True / duplicate_of 透传
|
||
- 非批次任务 batch_similarity 恒为 None
|
||
- _reselect_plan_for_batch_retry:成功返回新 plan_id;异常返回 None(保留首版)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
for sub in ("apps/worker", "apps/api", "packages", ""):
|
||
p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
|
||
if p not in sys.path:
|
||
sys.path.insert(0, p)
|
||
|
||
from sqlalchemy import create_engine # noqa: E402
|
||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||
|
||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||
from packages.adapters.sqlalchemy_impl.models import Base # noqa: E402
|
||
|
||
|
||
@pytest.fixture
|
||
def session():
|
||
engine = create_engine("sqlite:///:memory:")
|
||
Base.metadata.create_all(engine)
|
||
s = sessionmaker(bind=engine)()
|
||
try:
|
||
yield s
|
||
finally:
|
||
s.close()
|
||
|
||
|
||
def _dedup_kwargs(batch_id):
|
||
return dict(
|
||
generation_task_id="task-batch-1",
|
||
project_id="proj-1",
|
||
user_id="user-1",
|
||
batch_id=batch_id,
|
||
file_url="https://oss.example.com/v.mp4",
|
||
file_size=1024,
|
||
duration=10.0,
|
||
video_path="/tmp/fake.mp4",
|
||
mode="edit_plan",
|
||
width=1280,
|
||
height=720,
|
||
fps=25.0,
|
||
)
|
||
|
||
|
||
class TestBatchDedupInHelpers:
|
||
"""dedup_helpers 内部 lazy import video_processing.dedup(依赖 cv2),
|
||
复用 test_generated_video_creation_logic 的 mock 模块注册模式。"""
|
||
|
||
@classmethod
|
||
def setup_class(cls):
|
||
import sys
|
||
|
||
if "cv2" not in sys.modules:
|
||
sys.modules["cv2"] = MagicMock()
|
||
|
||
# 保存 setup 前的真实模块引用,teardown 原样还原(沙箱无 cv2 时 reimport 会失败,
|
||
# 若直接 pop 掉 MagicMock 会让后续测试(如 test_dedup_1702)拿到残缺模块)
|
||
import video_processing
|
||
|
||
cls._orig_dedup = sys.modules.get("video_processing.dedup")
|
||
cls._orig_dedup_attr = getattr(video_processing, "dedup", None)
|
||
cls._orig_thumb = sys.modules.get("video_processing.thumbnail_generator")
|
||
cls._orig_thumb_attr = getattr(video_processing, "thumbnail_generator", None)
|
||
|
||
mock_dedup = MagicMock()
|
||
mock_dedup.VideoDeduplicator = MagicMock()
|
||
sys.modules["video_processing.dedup"] = mock_dedup
|
||
|
||
mock_thumb = MagicMock()
|
||
mock_thumb.extract_first_frame = MagicMock()
|
||
sys.modules["video_processing.thumbnail_generator"] = mock_thumb
|
||
|
||
video_processing.dedup = mock_dedup
|
||
video_processing.thumbnail_generator = mock_thumb
|
||
|
||
@classmethod
|
||
def teardown_class(cls):
|
||
import sys
|
||
|
||
import video_processing
|
||
|
||
# 还原 setup 前状态:原本有真模块→放回;原本没有→移除 mock
|
||
if cls._orig_dedup is not None:
|
||
sys.modules["video_processing.dedup"] = cls._orig_dedup
|
||
else:
|
||
sys.modules.pop("video_processing.dedup", None)
|
||
if cls._orig_dedup_attr is not None:
|
||
video_processing.dedup = cls._orig_dedup_attr
|
||
elif hasattr(video_processing, "dedup"):
|
||
delattr(video_processing, "dedup")
|
||
|
||
if cls._orig_thumb is not None:
|
||
sys.modules["video_processing.thumbnail_generator"] = cls._orig_thumb
|
||
else:
|
||
sys.modules.pop("video_processing.thumbnail_generator", None)
|
||
if cls._orig_thumb_attr is not None:
|
||
video_processing.thumbnail_generator = cls._orig_thumb_attr
|
||
elif hasattr(video_processing, "thumbnail_generator"):
|
||
delattr(video_processing, "thumbnail_generator")
|
||
|
||
def test_batch_similarity_returned_when_batch_duplicate_found(self, session):
|
||
"""批次任务 + 无历史重复 + 批次查重命中 → 返回 batch_similarity 与 is_duplicate。"""
|
||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||
|
||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||
dedup = mock_cls.return_value
|
||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||
dedup.check_duplicate.return_value = None # 历史无重复
|
||
dedup.check_batch_duplicate.return_value = {
|
||
"duplicate_of": "video-existing",
|
||
"reason": "batch_similar",
|
||
"similarity": 0.601,
|
||
}
|
||
dedup.compute_duplicate_rate.return_value = {
|
||
"duplicate_rate": 0.0,
|
||
"visual_similarity": 0.0,
|
||
"match_count": 0,
|
||
}
|
||
|
||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs("batch-abc"))
|
||
|
||
assert result["video_count"] == 1
|
||
assert result["batch_similarity"] == pytest.approx(0.601)
|
||
assert result["is_duplicate"] is True
|
||
assert result["duplicate_of"] == "video-existing"
|
||
# 批次查重确实被调用(历史查重为 None 才走批次)
|
||
dedup.check_batch_duplicate.assert_called_once()
|
||
|
||
def test_batch_check_skipped_when_historical_duplicate(self, session):
|
||
"""历史查重已命中 → 不再批次查重,batch_similarity 为 None。"""
|
||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||
|
||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||
dedup = mock_cls.return_value
|
||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||
dedup.check_duplicate.return_value = {
|
||
"duplicate_of": "video-old",
|
||
"reason": "global",
|
||
"similarity": 0.85,
|
||
}
|
||
dedup.compute_duplicate_rate.return_value = {
|
||
"duplicate_rate": 85.0,
|
||
"visual_similarity": 0.85,
|
||
"match_count": 3,
|
||
}
|
||
|
||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs("batch-abc"))
|
||
|
||
assert result["is_duplicate"] is True
|
||
assert result["duplicate_of"] == "video-old"
|
||
assert result["batch_similarity"] is None
|
||
dedup.check_batch_duplicate.assert_not_called()
|
||
|
||
def test_non_batch_never_runs_batch_check(self, session):
|
||
"""非批次任务(batch_id 为空)→ 不调用批次查重,batch_similarity None。"""
|
||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||
|
||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||
dedup = mock_cls.return_value
|
||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||
dedup.check_duplicate.return_value = None
|
||
dedup.check_batch_duplicate.return_value = {"similarity": 0.99}
|
||
dedup.compute_duplicate_rate.return_value = {
|
||
"duplicate_rate": 0.0,
|
||
"visual_similarity": 0.0,
|
||
"match_count": 0,
|
||
}
|
||
|
||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs(""))
|
||
|
||
assert result["video_count"] == 1
|
||
assert result["batch_similarity"] is None
|
||
assert result["is_duplicate"] is False
|
||
dedup.check_batch_duplicate.assert_not_called()
|
||
|
||
def test_batch_no_duplicate_returns_none_similarity(self, session):
|
||
"""批次任务但批次查重也未命中 → batch_similarity None(驱动不重渲)。"""
|
||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||
|
||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||
dedup = mock_cls.return_value
|
||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||
dedup.check_duplicate.return_value = None
|
||
dedup.check_batch_duplicate.return_value = None
|
||
dedup.compute_duplicate_rate.return_value = {
|
||
"duplicate_rate": 0.0,
|
||
"visual_similarity": 0.0,
|
||
"match_count": 0,
|
||
}
|
||
|
||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs("batch-xyz"))
|
||
|
||
assert result["batch_similarity"] is None
|
||
assert result["is_duplicate"] is False
|
||
|
||
|
||
class TestReselectPlanForBatchRetry:
|
||
def test_success_returns_new_plan_id(self):
|
||
"""重选成功 → 返回新 plan_id。"""
|
||
from worker_app.tasks.generation import _reselect_plan_for_batch_retry
|
||
|
||
fake_svc = MagicMock()
|
||
fake_svc.reselect_plan_for_variant.return_value = MagicMock(id="new-plan-999")
|
||
with patch("app.services.edit_plan_service.EditPlanService", return_value=fake_svc):
|
||
with patch("worker_app.tasks.generation.SessionLocal") as mock_session_local:
|
||
mock_session_local.return_value = MagicMock()
|
||
new_id = _reselect_plan_for_batch_retry(
|
||
"task-1",
|
||
"old-plan",
|
||
{"task_asset_ids": ["a1", "a2"], "user_id": "u-1"},
|
||
)
|
||
assert new_id == "new-plan-999"
|
||
fake_svc.reselect_plan_for_variant.assert_called_once()
|
||
args, kwargs = fake_svc.reselect_plan_for_variant.call_args
|
||
assert args[0] == "old-plan"
|
||
assert args[1] == ["a1", "a2"]
|
||
assert kwargs["created_by_user_id"] == "u-1"
|
||
|
||
def test_failure_returns_none_and_keeps_first_version(self):
|
||
"""重选抛异常 → 返回 None(调用方放弃重渲、保留首版),不抛出。"""
|
||
from worker_app.tasks.generation import _reselect_plan_for_batch_retry
|
||
|
||
fake_svc = MagicMock()
|
||
fake_svc.reselect_plan_for_variant.side_effect = RuntimeError("db down")
|
||
with patch("app.services.edit_plan_service.EditPlanService", return_value=fake_svc):
|
||
with patch("worker_app.tasks.generation.SessionLocal") as mock_session_local:
|
||
mock_session_local.return_value = MagicMock()
|
||
result = _reselect_plan_for_batch_retry("task-1", "old-plan", {"task_asset_ids": [], "user_id": "u-1"})
|
||
assert result is None
|