0fde7d687c
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
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 / Frontend Lint (push) 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
PR Automation / Auto Merge on CI Green + Approved (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 / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m51s
CI/CD Pipeline / Check push changed paths (push) Successful in 4m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m19s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m38s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 7m31s
AI Code Review / AI Code Review (pull_request) Successful in 7m55s
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 / 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 / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m35s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 16m43s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) 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 / 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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 1m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m23s
CI/CD Pipeline / Integration Tests (push) Successful in 7m32s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 13m40s
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 / Build Production Web 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 45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m21s
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>
630 lines
23 KiB
Python
630 lines
23 KiB
Python
"""查重引擎单元测试。
|
||
|
||
覆盖:
|
||
- hamming_distance() 汉明距离计算(XOR bit 计数)
|
||
- compute_phash() 感知哈希算法(需真实 cv2,无则跳过)
|
||
- compute_color_histogram() 颜色直方图(需真实 cv2,无则跳过)
|
||
- VideoDeduplicator.check_duplicate() 相似度判定逻辑
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from unittest.mock import MagicMock
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 保存 sys.modules 原始状态,测试结束后恢复,避免污染其他测试文件
|
||
# ---------------------------------------------------------------------------
|
||
_ORIGINAL_MODULES = dict(sys.modules)
|
||
_MOCKED_MODULE_NAMES: list[str] = []
|
||
|
||
|
||
def _mock_if_absent(name: str, mock_obj=None):
|
||
"""仅在模块不在 sys.modules 中时注入 mock,并记录以便清理。"""
|
||
if name not in sys.modules:
|
||
sys.modules[name] = mock_obj if mock_obj is not None else MagicMock()
|
||
_MOCKED_MODULE_NAMES.append(name)
|
||
|
||
|
||
# Mock heavy deps before importing dedup module
|
||
_mock_if_absent("ffmpeg")
|
||
|
||
# Mock worker_app (celery) and its submodules
|
||
for mod_name in ["worker_app", "worker_app.celery_app", "worker_app.db"]:
|
||
_mock_if_absent(mod_name)
|
||
if "worker_app.celery_app" in sys.modules and isinstance(sys.modules["worker_app.celery_app"], MagicMock):
|
||
sys.modules["worker_app.celery_app"].celery_app = MagicMock()
|
||
if "worker_app.db" in sys.modules and isinstance(sys.modules["worker_app.db"], MagicMock):
|
||
sys.modules["worker_app.db"].SessionLocal = MagicMock()
|
||
|
||
# Mock celery.Task base class
|
||
_mock_if_absent("celery", MagicMock())
|
||
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
|
||
sys.modules["celery"].Task = object
|
||
|
||
# Mock packages.shared.storage(只 mock 目标子模块,不要 mock 父包
|
||
# packages.shared——否则同进程后续从 packages.shared.* 导入任何子模块都会
|
||
# 拿到 MagicMock,污染其他测试文件,例如 thumbnail_generator 的纯逻辑测试)
|
||
_mock_if_absent("packages.shared.storage")
|
||
|
||
# Mock packages.adapters.sqlalchemy_impl.generated_video_repository
|
||
_mock_if_absent("packages.adapters.sqlalchemy_impl.generated_video_repository")
|
||
|
||
# Check if cv2 is available as a real module (not mocked)
|
||
_HAS_CV2 = False
|
||
try:
|
||
import cv2 as _cv2
|
||
|
||
if not isinstance(_cv2, MagicMock):
|
||
_HAS_CV2 = True
|
||
except (ImportError, ModuleNotFoundError) as e:
|
||
import logging
|
||
|
||
logging.warning("cv2 not available in test_dedup_engine: %s", e)
|
||
|
||
import numpy as np # noqa: E402
|
||
import pytest # noqa: E402
|
||
|
||
# Mock cv2 if not available (so dedup module can import)
|
||
if not _HAS_CV2:
|
||
_mock_if_absent("cv2")
|
||
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from apps.worker.video_processing.dedup import ( # noqa: E402
|
||
VideoDeduplicator,
|
||
VideoFingerprint,
|
||
compute_color_histogram,
|
||
compute_phash,
|
||
hamming_distance,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# dedup 模块已导入完成,立即恢复 worker_app 真实包,避免污染后续测试文件
|
||
# ---------------------------------------------------------------------------
|
||
for _name in ["worker_app", "worker_app.celery_app", "worker_app.db", "celery"]:
|
||
if _name in _MOCKED_MODULE_NAMES:
|
||
sys.modules.pop(_name, None)
|
||
_MOCKED_MODULE_NAMES.remove(_name)
|
||
|
||
|
||
@pytest.fixture(autouse=True, scope="session")
|
||
def _cleanup_mocks():
|
||
"""测试结束后恢复 sys.modules,防止 mock 污染其他测试文件。"""
|
||
yield
|
||
# 移除本次新增的 mock 模块
|
||
for name in _MOCKED_MODULE_NAMES:
|
||
sys.modules.pop(name, None)
|
||
# 恢复被覆盖的模块
|
||
for name, mod in _ORIGINAL_MODULES.items():
|
||
if sys.modules.get(name) is not mod:
|
||
sys.modules[name] = mod
|
||
|
||
|
||
class TestHammingDistance:
|
||
"""hamming_distance() 测试。
|
||
|
||
实现使用 XOR + bit 计数:bin(h1 ^ h2).count("1")。
|
||
空字符串会触发 ValueError(int("", 16) 失败),属于边界行为。
|
||
"""
|
||
|
||
def test_identical_hashes_zero_distance(self):
|
||
assert hamming_distance("abcdef01", "abcdef01") == 0
|
||
|
||
def test_completely_different_bytes(self):
|
||
# 0x00 XOR 0xFF = 0xFF → 8 bits
|
||
assert hamming_distance("00", "ff") == 8
|
||
|
||
def test_single_bit_difference(self):
|
||
# 0x0 XOR 0x1 = 0x1 → 1 bit
|
||
assert hamming_distance("0", "1") == 1
|
||
|
||
def test_unequal_length_leading_zeros(self):
|
||
# int("abc", 16) == int("0abc", 16) → XOR = 0 → 0 bits
|
||
dist = hamming_distance("abc", "0abc")
|
||
assert dist == 0
|
||
|
||
def test_unequal_length_with_leading_zeros_ff(self):
|
||
# int("ff", 16) == int("00ff", 16) → XOR = 0 → 0 bits
|
||
dist = hamming_distance("ff", "00ff")
|
||
assert dist == 0
|
||
|
||
def test_all_bits_different_64bit(self):
|
||
# 16 hex chars = 64 bits, all different → 64
|
||
dist = hamming_distance("0000000000000000", "ffffffffffffffff")
|
||
assert dist == 64
|
||
|
||
def test_partial_difference(self):
|
||
# 0x0F = 00001111, 0xF0 = 11110000 → XOR = 0xFF → 8 bits
|
||
assert hamming_distance("0f", "f0") == 8
|
||
|
||
def test_one_bit_in_second_byte(self):
|
||
# 0x0000 XOR 0x0001 = 0x0001 → 1 bit
|
||
assert hamming_distance("0000", "0001") == 1
|
||
|
||
|
||
@pytest.mark.skipif(not _HAS_CV2, reason="需要真实 cv2 模块")
|
||
class TestComputePhash:
|
||
"""compute_phash() 测试(需真实 cv2)。"""
|
||
|
||
def test_returns_hex_string(self):
|
||
image = np.random.randint(0, 256, (64, 64, 3), dtype=np.uint8)
|
||
result = compute_phash(image)
|
||
assert isinstance(result, str)
|
||
int(result, 16) # 不应抛出异常
|
||
|
||
def test_identical_images_same_hash(self):
|
||
image = np.full((64, 64, 3), 128, dtype=np.uint8)
|
||
hash1 = compute_phash(image)
|
||
hash2 = compute_phash(image)
|
||
assert hash1 == hash2
|
||
|
||
def test_different_images_different_hash(self):
|
||
# 用两张不同的随机噪声图测试(纯色图 pHash 会相同,因为排除了 DC 分量)
|
||
rng = np.random.RandomState(42)
|
||
img1 = rng.randint(0, 256, (64, 64, 3), dtype=np.uint8)
|
||
rng2 = np.random.RandomState(99)
|
||
img2 = rng2.randint(0, 256, (64, 64, 3), dtype=np.uint8)
|
||
hash1 = compute_phash(img1)
|
||
hash2 = compute_phash(img2)
|
||
assert hash1 != hash2
|
||
|
||
def test_custom_hash_size(self):
|
||
image = np.random.randint(0, 256, (64, 64, 3), dtype=np.uint8)
|
||
result = compute_phash(image, hash_size=16)
|
||
assert isinstance(result, str)
|
||
int(result, 16)
|
||
|
||
|
||
@pytest.mark.skipif(not _HAS_CV2, reason="需要真实 cv2 模块")
|
||
class TestComputeColorHistogram:
|
||
"""compute_color_histogram() 测试(需真实 cv2)。"""
|
||
|
||
def test_returns_correct_length(self):
|
||
image = np.random.randint(0, 256, (64, 64, 3), dtype=np.uint8)
|
||
hist = compute_color_histogram(image, bins=32)
|
||
assert len(hist) == 96 # 3 channels × 32 bins
|
||
|
||
def test_custom_bins(self):
|
||
image = np.random.randint(0, 256, (64, 64, 3), dtype=np.uint8)
|
||
hist = compute_color_histogram(image, bins=16)
|
||
assert len(hist) == 48 # 3 channels × 16 bins
|
||
|
||
def test_normalized_values(self):
|
||
image = np.random.randint(0, 256, (64, 64, 3), dtype=np.uint8)
|
||
hist = compute_color_histogram(image)
|
||
for v in hist:
|
||
assert 0.0 <= v <= 1.0 + 1e-6
|
||
|
||
def test_identical_images_same_histogram(self):
|
||
image = np.full((64, 64, 3), 100, dtype=np.uint8)
|
||
hist1 = compute_color_histogram(image)
|
||
hist2 = compute_color_histogram(image)
|
||
assert hist1 == hist2
|
||
|
||
|
||
class TestVideoDeduplicatorCheckDuplicate:
|
||
"""VideoDeduplicator.check_duplicate() 测试。
|
||
|
||
当前实现仅使用 MD5 精确匹配和 pHash 距离判定,
|
||
不包含颜色直方图相似度计算。
|
||
"""
|
||
|
||
@pytest.fixture
|
||
def deduplicator(self):
|
||
return VideoDeduplicator()
|
||
|
||
@pytest.fixture
|
||
def mock_session(self):
|
||
return MagicMock()
|
||
|
||
def _make_existing_video(self, video_id, md5, phashes=None):
|
||
"""创建模拟已有视频的 mock 对象。"""
|
||
video = MagicMock()
|
||
video.id = video_id
|
||
video.video_fingerprint = {
|
||
"md5": md5,
|
||
"keyframe_phashes": phashes or [],
|
||
"color_histograms": [],
|
||
}
|
||
return video
|
||
|
||
def _patch_repo(self, mock_repo):
|
||
"""Patch SQLAlchemyGeneratedVideoRepository。"""
|
||
import apps.worker.video_processing.dedup as dedup_module
|
||
|
||
original = dedup_module.SQLAlchemyGeneratedVideoRepository
|
||
dedup_module.SQLAlchemyGeneratedVideoRepository = MagicMock(return_value=mock_repo)
|
||
return original, dedup_module
|
||
|
||
def _restore_repo(self, dedup_module, original):
|
||
dedup_module.SQLAlchemyGeneratedVideoRepository = original
|
||
|
||
def test_exact_md5_match(self, deduplicator, mock_session):
|
||
"""MD5 完全匹配应返回 similarity=1.0。"""
|
||
existing = self._make_existing_video("vid-1", "abc123")
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [existing]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="abc123",
|
||
keyframe_phashes=["ff"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||
assert result is not None
|
||
assert result["duplicate"] is True
|
||
assert result["similarity"] == 1.0
|
||
assert result["reason"] == "exact_md5_match"
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_phash_similar_match(self, deduplicator, mock_session):
|
||
"""pHash 距离 < 阈值时应判定为重复。"""
|
||
existing = self._make_existing_video("vid-1", "different_md5", phashes=["abcdef01"])
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [existing]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="different_md5_new",
|
||
keyframe_phashes=["abcdef01"], # 完全相同,距离=0
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||
assert result is not None
|
||
assert result["duplicate"] is True
|
||
assert result["similarity"] == 1.0 # distance=0 → 1.0
|
||
assert result["reason"] == "phash_similar"
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_no_match_returns_none(self, deduplicator, mock_session):
|
||
"""pHash 平均距离 >= PHASH_THRESHOLD(10) 时应返回 None。"""
|
||
# 使用 16 字符 phash(64 bit),全部不同 → 距离=64 >= 10
|
||
existing = self._make_existing_video("vid-1", "md5_a", phashes=["0000000000000000"])
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [existing]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="md5_b",
|
||
keyframe_phashes=["ffffffffffffffff"], # 64 bits 全不同 → 距离=64
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||
assert result is None
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_empty_project_returns_none(self, deduplicator, mock_session):
|
||
"""项目中没有视频时应返回 None。"""
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = []
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="abc",
|
||
keyframe_phashes=["ff"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||
assert result is None
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_skip_videos_without_fingerprint(self, deduplicator, mock_session):
|
||
"""没有指纹的视频应被跳过。"""
|
||
existing = MagicMock()
|
||
existing.id = "vid-1"
|
||
existing.video_fingerprint = None
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [existing]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="abc",
|
||
keyframe_phashes=["ff"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||
assert result is None
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_first_match_returned(self, deduplicator, mock_session):
|
||
"""返回第一个通过阈值的匹配(非最优匹配)。"""
|
||
# vid-1: 距离=2 bits(0x03 XOR 0x01 = 0x02 → 1 bit),通过阈值
|
||
vid1 = self._make_existing_video("vid-1", "md5_1", phashes=["0000000000000003"])
|
||
# vid-2: 距离=0 bits(完全匹配)
|
||
vid2 = self._make_existing_video("vid-2", "md5_2", phashes=["0000000000000001"])
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [vid1, vid2]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="md5_new",
|
||
keyframe_phashes=["0000000000000001"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||
assert result is not None
|
||
# 返回第一个通过阈值的匹配(vid-1 距离=1 < 10)
|
||
assert result["duplicate_of"] == "vid-1"
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_no_phashes_skips_video(self, deduplicator, mock_session):
|
||
"""已有视频无 phashes 时应被跳过。"""
|
||
existing = self._make_existing_video("vid-1", "md5_a", phashes=[])
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [existing]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="md5_b",
|
||
keyframe_phashes=["abcdef01"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||
assert result is None
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_phash_similarity_formula(self, deduplicator, mock_session):
|
||
"""验证相似度公式:similarity = 1.0 - (avg_distance / 64)。"""
|
||
# 使用已知距离的 phash 对
|
||
# "0000000000000000" vs "0000000000000001" → XOR = 1 → 1 bit → distance = 1
|
||
existing = self._make_existing_video("vid-1", "md5_a", phashes=["0000000000000000"])
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [existing]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="md5_b",
|
||
keyframe_phashes=["0000000000000001"], # 1 bit different
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||
assert result is not None
|
||
assert result["duplicate"] is True
|
||
# similarity = 1.0 - (1 / 64) = 0.984375
|
||
assert abs(result["similarity"] - (1.0 - 1.0 / 64)) < 1e-6
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_multiple_phashes_avg_distance(self, deduplicator, mock_session):
|
||
"""多帧 phash 使用平均最小距离。"""
|
||
# 已有视频有 2 帧 phash
|
||
existing = self._make_existing_video(
|
||
"vid-1",
|
||
"md5_a",
|
||
phashes=["0000000000000000", "ffffffffffffffff"],
|
||
)
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [existing]
|
||
|
||
# 新视频有 1 帧 phash,与第一帧距离=0,与第二帧距离=64
|
||
# min_distance = 0, avg = 0 → 匹配
|
||
fingerprint = VideoFingerprint(
|
||
md5="md5_b",
|
||
keyframe_phashes=["0000000000000000"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||
assert result is not None
|
||
assert result["duplicate"] is True
|
||
assert result["similarity"] == 1.0 # avg_distance = 0
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
|
||
class TestVideoDeduplicatorCheckBatchDuplicate:
|
||
"""VideoDeduplicator.check_batch_duplicate() 测试。
|
||
|
||
批次内查重逻辑与历史查重一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。
|
||
"""
|
||
|
||
@pytest.fixture
|
||
def deduplicator(self):
|
||
return VideoDeduplicator()
|
||
|
||
@pytest.fixture
|
||
def mock_session(self):
|
||
return MagicMock()
|
||
|
||
def _make_batch_video(self, video_id, md5, phashes=None):
|
||
video = MagicMock()
|
||
video.id = video_id
|
||
video.video_fingerprint = {
|
||
"md5": md5,
|
||
"keyframe_phashes": phashes or [],
|
||
"color_histograms": [],
|
||
}
|
||
return video
|
||
|
||
def _patch_repo(self, mock_repo):
|
||
import apps.worker.video_processing.dedup as dedup_module
|
||
|
||
original = dedup_module.SQLAlchemyGeneratedVideoRepository
|
||
dedup_module.SQLAlchemyGeneratedVideoRepository = MagicMock(return_value=mock_repo)
|
||
return original, dedup_module
|
||
|
||
def _restore_repo(self, dedup_module, original):
|
||
dedup_module.SQLAlchemyGeneratedVideoRepository = original
|
||
|
||
def test_batch_exact_md5_match(self, deduplicator, mock_session):
|
||
"""批次内 MD5 完全匹配应返回 duplicate。"""
|
||
other = self._make_batch_video("vid-other", "abc123")
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_batch.return_value = [other]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="abc123",
|
||
keyframe_phashes=["ff"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||
assert result is not None
|
||
assert result["duplicate"] is True
|
||
assert result["reason"] == "batch_exact_md5_match"
|
||
assert result["similarity"] == 1.0
|
||
assert result["duplicate_of"] == "vid-other"
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_batch_phash_similar(self, deduplicator, mock_session):
|
||
"""批次内 pHash 距离 < 阈值应判定为重复。"""
|
||
other = self._make_batch_video("vid-other", "md5_diff", phashes=["abcdef01"])
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_batch.return_value = [other]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="md5_new",
|
||
keyframe_phashes=["abcdef01"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||
assert result is not None
|
||
assert result["duplicate"] is True
|
||
assert result["reason"] == "batch_phash_similar"
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_batch_excludes_self(self, deduplicator, mock_session):
|
||
"""批次查重应排除自身视频。"""
|
||
self_video = self._make_batch_video("vid-self", "abc123")
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_batch.return_value = [self_video]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="abc123",
|
||
keyframe_phashes=["ff"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||
assert result is None
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_batch_no_match(self, deduplicator, mock_session):
|
||
"""批次内无重复时应返回 None。"""
|
||
other = self._make_batch_video("vid-other", "md5_a", phashes=["0000000000000000"])
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_batch.return_value = [other]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="md5_b",
|
||
keyframe_phashes=["ffffffffffffffff"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||
assert result is None
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_batch_empty_returns_none(self, deduplicator, mock_session):
|
||
"""空批次应返回 None。"""
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_batch.return_value = []
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="abc",
|
||
keyframe_phashes=["ff"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||
assert result is None
|
||
finally:
|
||
self._restore_repo(mod, orig)
|
||
|
||
def test_batch_skips_no_fingerprint(self, deduplicator, mock_session):
|
||
"""批次内无指纹的视频应被跳过。"""
|
||
other = MagicMock()
|
||
other.id = "vid-other"
|
||
other.video_fingerprint = None
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_batch.return_value = [other]
|
||
|
||
fingerprint = VideoFingerprint(
|
||
md5="abc",
|
||
keyframe_phashes=["ff"],
|
||
color_histograms=[],
|
||
duration=10.0,
|
||
resolution=(1280, 720),
|
||
)
|
||
|
||
orig, mod = self._patch_repo(mock_repo)
|
||
try:
|
||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||
assert result is None
|
||
finally:
|
||
self._restore_repo(mod, orig)
|