Files
xiaoxia-saas/tests/unit/test_dedup_v2.py
T
CI Bot ad76eaa56f
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 7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
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 / 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 / PR Build API Image (pull_request) Successful in 28s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
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 / 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 / Validate - Python (mypy + alembic) (pull_request) Successful in 2m16s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m25s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 2m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m30s
AI Code Review / AI Code Review (pull_request) Successful in 4m20s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m42s
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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m25s
style: auto-format with black + isort + prettier [skip ci-format-check]
2026-09-03 16:39:08 +00:00

764 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Issue #1658: pHash 阈值校准 + 颜色直方图融合 — 完整单元测试。
测试覆盖:
1. PHASH_THRESHOLD 从 10 收紧到 8
2. 中位数替代均值(抵抗异常值)
3. 帧匹配比例条件 (≥70%)
4. Bhattacharyya 系数计算
5. 直方图融合逻辑
6. _check_fusion_duplicate 共用方法
7. check_duplicate / check_batch_duplicate 集成
8. compute_duplicate_rate 融合逻辑
9. 向后兼容(无直方图数据时不崩溃)
"""
from __future__ import annotations
import sys
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
def _mock_module(**attrs):
"""Create a mock module with __spec__ to avoid AttributeError."""
m = MagicMock()
m.__spec__ = None
if "__path__" not in attrs:
m.__path__ = []
for k, v in attrs.items():
setattr(m, k, v)
return m
# ── Mock setup ──
_SAVED_MODULES_KEYS = set(sys.modules.keys())
_SAVED_MODULES_VALUES = {
k: sys.modules.get(k)
for k in [
"cv2",
"celery",
"sqlalchemy",
"sqlalchemy.orm",
"sqlalchemy.engine",
"sqlalchemy.ext",
"sqlalchemy.ext.declarative",
"worker_app.db",
"worker_app.celery_app",
"worker_app.core.config",
"packages.adapters.sqlalchemy_impl.session",
"packages.adapters.sqlalchemy_impl.generated_video_repository",
"packages.adapters.sqlalchemy_impl.models",
"packages.shared.config",
"packages.shared.storage",
]
}
sys.modules["cv2"] = _mock_module()
_mock_celery = MagicMock()
_mock_celery.Task = MagicMock
_mock_celery.Celery = MagicMock
_mock_celery.__spec__ = None
sys.modules["celery"] = _mock_celery
_mock_sqla = MagicMock()
_mock_sqla.__path__ = []
_mock_sqla.__spec__ = None
sys.modules["sqlalchemy"] = _mock_sqla
_mock_sqla_orm = MagicMock()
_mock_sqla_orm.__path__ = []
_mock_sqla_orm.__spec__ = None
_mock_sqla_orm.Session = MagicMock
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
sys.modules["sqlalchemy.engine"] = _mock_module()
sys.modules["sqlalchemy.ext"] = _mock_module()
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()
sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock())
sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock())
sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock()))
sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module(
Base=MagicMock(),
build_engine=MagicMock(),
build_session_factory=MagicMock(),
ensure_database_exists=MagicMock(),
initialize_database=MagicMock(),
)
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _mock_module()
sys.modules["packages.adapters.sqlalchemy_impl.models"] = _mock_module(
VideoFingerprintChunkModel=MagicMock(),
GeneratedVideoModel=MagicMock(),
)
sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
sys.modules["packages.shared.storage"] = _mock_module()
import video_processing.dedup as _dedup_module # noqa: E402
# Import while mocks active
from video_processing.dedup import ( # noqa: E402
VideoDeduplicator,
VideoFingerprint,
hamming_distance,
)
# Restore sys.modules
for _key in list(sys.modules.keys()):
if _key not in _SAVED_MODULES_KEYS:
del sys.modules[_key]
for _key, _value in _SAVED_MODULES_VALUES.items():
if _value is not None:
sys.modules[_key] = _value
elif _key in sys.modules:
del sys.modules[_key]
del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value
# ── Helpers ──
def _make_fingerprint(phashes: list[str], histograms: list[list[float]] | None = None) -> VideoFingerprint:
"""构造测试用 VideoFingerprint."""
if histograms is None:
histograms = [[0.5] * 96 for _ in phashes]
return VideoFingerprint(
md5="test_md5",
keyframe_phashes=phashes,
color_histograms=histograms,
duration=30.0,
resolution=(1920, 1080),
)
# ══════════════════════════════════════════════
# 1. 常量验证
# ══════════════════════════════════════════════
class TestConstants:
"""Issue #1658 常量验证。"""
def test_phash_threshold_is_8(self):
"""PHASH_THRESHOLD 应为 8(从 10 收紧)。"""
assert VideoDeduplicator.PHASH_THRESHOLD == 8
def test_match_ratio_threshold(self):
assert VideoDeduplicator.MATCH_RATIO_THRESHOLD == 0.7
def test_duplicate_threshold(self):
assert VideoDeduplicator.DUPLICATE_THRESHOLD == 0.70
def test_phash_weight(self):
assert VideoDeduplicator.PHASH_WEIGHT == 0.7
def test_histogram_weight(self):
assert VideoDeduplicator.HISTOGRAM_WEIGHT == 0.3
# ══════════════════════════════════════════════
# 2. pHash 阈值变更测试
# ══════════════════════════════════════════════
class TestPHashThresholdChange:
"""pHash 阈值从 10 收紧到 8 的行为验证。"""
def test_distance_9_not_matching(self):
"""距离=9:旧阈值10会判匹配,新阈值8不匹配。"""
assert VideoDeduplicator.PHASH_THRESHOLD == 8
# 距离 9 > 8,不应匹配(strict <
assert 9 >= VideoDeduplicator.PHASH_THRESHOLD
def test_distance_8_is_boundary(self):
"""距离=8 等于阈值,不匹配(严格小于)。"""
assert 8 >= VideoDeduplicator.PHASH_THRESHOLD # 不满足 d < THRESHOLD
# ══════════════════════════════════════════════
# 3. 中位数 vs 均值
# ══════════════════════════════════════════════
class TestMedianVsMean:
"""中位数抵抗异常值。"""
def test_median_resists_outlier(self):
"""距离 [3, 3, 3, 3, 30]:均值=8.4,中位数=3。"""
import statistics
distances = [3, 3, 3, 3, 30]
mean_val = sum(distances) / len(distances)
median_val = statistics.median(distances)
assert mean_val == pytest.approx(8.4)
assert median_val == 3
def test_median_used_in_fusion(self):
"""验证 _check_fusion_duplicate 使用中位数。"""
d = VideoDeduplicator()
# 10帧: 9帧距离=2, 1帧距离=50
phashes_new = [f"{i:016x}" for i in range(10)]
# existing: 与 phashes_new 前9个完全相同,第10个完全不同
phashes_existing = phashes_new[:9] + ["ffffffffffffffff"]
fp = _make_fingerprint(
phashes_new,
[[1.0, 0.0, 0.5] * 32 for _ in range(10)],
)
existing_hist = [[1.0, 0.0, 0.5] * 32 for _ in range(10)]
result = d._check_fusion_duplicate(fp, {}, phashes_existing, existing_hist)
# 中位数距离应为 0(9帧距离0,1帧距离>0,中位数=0)
if result:
assert result["_debug"]["median_distance"] == 0
# ══════════════════════════════════════════════
# 4. 帧匹配比例测试
# ══════════════════════════════════════════════
class TestMatchRatio:
"""帧匹配比例 ≥ 70% 才通过。"""
def test_insufficient_match_ratio(self):
"""10帧中只有5帧距离<8 → match_ratio=0.5 < 0.7 → 不判重复。"""
d = VideoDeduplicator()
# 构造: 5帧完全匹配(距离0), 5帧完全不同(距离64)
new_phashes = ["0" * 16] * 5 + ["a" * 16] * 5
existing_phashes = ["0" * 16] # 只有一帧
fp = _make_fingerprint(new_phashes)
result = d._check_fusion_duplicate(fp, {}, existing_phashes, [[0.5] * 96])
# 5/10 = 0.5 < 0.7,应返回 None
assert result is None
def test_sufficient_match_ratio(self):
"""10帧中8帧距离<8 → match_ratio=0.8 ≥ 0.7 → 通过。"""
d = VideoDeduplicator()
new_phashes = ["0" * 16] * 8 + ["a" * 16] * 2
existing_phashes = ["0" * 16]
fp = _make_fingerprint(
new_phashes,
[[1.0, 0.0, 0.5] * 32 for _ in range(10)],
)
existing_hist = [[1.0, 0.0, 0.5] * 32]
result = d._check_fusion_duplicate(fp, {}, existing_phashes, existing_hist)
# 8/10 = 0.8 ≥ 0.7,应该通过(取决于combined_score
if result is not None:
assert result["_debug"]["match_ratio"] == 0.8
# ══════════════════════════════════════════════
# 5. Bhattacharyya 系数测试
# ══════════════════════════════════════════════
class TestBhattacharyyaCoefficient:
"""Bhattacharyya 系数计算验证。"""
def test_identical_histograms(self):
"""相同直方图 → 1.0。"""
h = [0.5, 0.3, 0.2]
bc = VideoDeduplicator._bhattacharyya_coefficient(h, h)
# Σ √(a[i]*a[i]) = Σ a[i] = 1.0 (如果已归一化)
assert bc == pytest.approx(sum(h))
def test_all_zeros(self):
"""全零直方图 → 0.0。"""
bc = VideoDeduplicator._bhattacharyya_coefficient([0.0, 0.0], [0.0, 0.0])
assert bc == 0.0
def test_known_value(self):
"""已知值验证: [1,0] vs [0,1] → 0。"""
bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 0.0], [0.0, 1.0])
assert bc == pytest.approx(0.0)
def test_known_value_partial(self):
"""已知值: [0.5, 0.5] vs [0.5, 0.5] → √0.25 + √0.25 = 1.0。"""
bc = VideoDeduplicator._bhattacharyya_coefficient([0.5, 0.5], [0.5, 0.5])
assert bc == pytest.approx(1.0)
def test_different_lengths(self):
"""不同长度取最小长度对齐。"""
bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 0.0, 0.0], [1.0, 0.0])
# 对齐到2个元素: √1 + √0 = 1.0
assert bc == pytest.approx(1.0)
# ══════════════════════════════════════════════
# 6. 直方图相似度(_compute_histogram_similarity
# ══════════════════════════════════════════════
class TestComputeHistogramSimilarity:
"""_compute_histogram_similarity 测试。"""
def test_empty_inputs(self):
"""空输入 → 0.0。"""
assert VideoDeduplicator._compute_histogram_similarity([], [[0.5]]) == 0.0
assert VideoDeduplicator._compute_histogram_similarity([[0.5]], []) == 0.0
def test_identical_histograms(self):
"""相同直方图组。"""
h = [[0.5, 0.3, 0.2]]
sim = VideoDeduplicator._compute_histogram_similarity(h, h)
assert sim == pytest.approx(sum(h[0]))
def test_best_match_selection(self):
"""多帧时取最佳匹配。"""
ha = [[1.0, 0.0], [0.0, 1.0]]
hb = [[0.0, 1.0]]
# 第一帧 [1,0] vs [0,1] → bc=0
# 第二帧 [0,1] vs [0,1] → bc=1.0
# 平均 = (0 + 1.0) / 2 = 0.5
sim = VideoDeduplicator._compute_histogram_similarity(ha, hb)
assert sim == pytest.approx(0.5)
# ══════════════════════════════════════════════
# 7. 融合逻辑测试
# ══════════════════════════════════════════════
class TestFusionLogic:
"""_check_fusion_duplicate 融合判定测试。"""
def test_high_phash_high_hist_is_duplicate(self):
"""pHash高相似 + 直方图高相似 → 判重复。"""
d = VideoDeduplicator()
phashes = ["0" * 16] * 10
hist = [[0.5] * 96] * 10
fp = _make_fingerprint(phashes, hist)
result = d._check_fusion_duplicate(fp, {}, phashes, hist)
# 完全相同 → combined_score = 0.7*1.0 + 0.3*~1.0 = ~1.0 > 0.70
assert result is not None
assert result["similarity"] > 0.70
def test_high_phash_low_hist_depends_on_score(self):
"""pHash高相似 + 直方图低相似 → 看 combined_score。"""
d = VideoDeduplicator()
phashes = ["0" * 16] * 10
fp_hist = [[1.0, 0.0] * 48] # 与 existing 完全不同
existing_hist = [[0.0, 1.0] * 48]
fp = _make_fingerprint(phashes, fp_hist)
result = d._check_fusion_duplicate(fp, {}, phashes, existing_hist)
# phash_similarity=1.0, hist_similarity≈0
# combined = 0.7*1.0 + 0.3*0 = 0.7 ≥ 0.70 → 刚好通过
if result:
assert result["_debug"]["phash_similarity"] == 1.0
assert result["_debug"]["hist_similarity"] == pytest.approx(0.0, abs=0.01)
def test_no_existing_phashes_returns_none(self):
"""无已有 pHash → 返回 None。"""
d = VideoDeduplicator()
fp = _make_fingerprint(["0" * 16])
result = d._check_fusion_duplicate(fp, {}, [], [])
assert result is None
# ══════════════════════════════════════════════
# 8. 向后兼容测试
# ══════════════════════════════════════════════
class TestBackwardCompatibility:
"""无直方图数据时不崩溃。"""
def test_no_histogram_fallback(self):
"""已有视频无分片直方图 → hist_similarity 回退到 0.0,不崩溃。"""
d = VideoDeduplicator()
phashes = ["0" * 16] * 10
fp = _make_fingerprint(phashes, [[0.5] * 96] * 10)
# existing_histograms 为空列表
result = d._check_fusion_duplicate(fp, {}, phashes, [])
# 应该不崩溃,hist_similarity=0.0(无数据时统一为0
if result:
assert result["_debug"]["hist_similarity"] == 0.0
def test_no_histogram_combined_score(self):
"""无直方图时的 combined_score = 0.7 * phash + 0.3 * 0.0。"""
d = VideoDeduplicator()
phashes = ["0" * 16] * 10 # 完全相同
fp = _make_fingerprint(phashes, [[0.5] * 96] * 10)
result = d._check_fusion_duplicate(fp, {}, phashes, [])
if result:
expected = 0.7 * 1.0 + 0.3 * 0.0 # = 0.7
assert result["similarity"] == pytest.approx(expected)
# ══════════════════════════════════════════════
# 8b. _compute_fusion_score 纯计算测试(无阈值过滤)
# ══════════════════════════════════════════════
class TestComputeFusionScore:
"""_compute_fusion_score 返回原始得分,不经过阈值过滤。"""
# 归一化直方图(sum=1.0),Bhattacharyya 系数范围 [0, 1]
_NORM_HIST = [1.0 / 96] * 96
def test_returns_score_below_threshold(self):
"""相似度低于 DUPLICATE_THRESHOLD 时仍返回得分(不被过滤)。"""
d = VideoDeduplicator()
# 10 帧新指纹,只有 3 帧能在已有指纹中找到匹配(距离<8)
# → match_ratio = 0.3 < MATCH_RATIO_THRESHOLD(0.7)
new_phashes = ["0" * 16] * 10
existing_phashes = ["0" * 16] * 3 + ["f" * 16] * 7 # 7 帧完全不同
hist = [self._NORM_HIST] * 10
fp = _make_fingerprint(new_phashes, hist)
# _compute_min_distances: 每帧找最近的已有帧
# "0"*16 到 "0"*16 → 0, 到 "f"*16 → 64, min=0 ✓ (< 8)
# 所以 10 帧新指纹的 min_distance 全是 0(因为已有里总有 "0"*16
# match_ratio = 10/10 = 1.0 —— 这不行
# 需要让新指纹中有些帧在已有里找不到近的
# 改为:新指纹 7 帧 "a"*16 + 3 帧 "0"*16,已有 10 帧 "0"*16
new_phashes = ["a" * 16] * 7 + ["0" * 16] * 3
existing_phashes = ["0" * 16] * 10
fp = _make_fingerprint(new_phashes, hist)
score = d._compute_fusion_score(fp, existing_phashes, hist)
assert score is not None
assert "similarity" in score
assert "match_ratio" in score
# "a"="1010..." vs "0"="0000..." → hamming_distance = 32 (>8, 不匹配)
# 7/10 帧不匹配 → match_ratio = 0.3 < 0.7
assert score["match_ratio"] < 0.7
# 但 similarity 仍然有值(不会被过滤为 None)
assert score["similarity"] > 0
def test_returns_score_for_completely_different(self):
"""完全不同也返回得分(phash=0 + hist 贡献)。"""
d = VideoDeduplicator()
fp = _make_fingerprint(["0" * 16], [self._NORM_HIST])
existing_phashes = ["f" * 16]
existing_histograms = [self._NORM_HIST]
score = d._compute_fusion_score(fp, existing_phashes, existing_histograms)
assert score is not None
# phash 完全不同 → phash_similarity=0, hist_similarity=1.0
assert score["phash_similarity"] == 0.0
assert score["hist_similarity"] == pytest.approx(1.0)
# combined = 0.7*0 + 0.3*1 = 0.3
assert score["similarity"] == pytest.approx(0.3)
def test_empty_phashes_returns_none(self):
"""空 pHash 列表 → None。"""
d = VideoDeduplicator()
fp = _make_fingerprint(["0" * 16])
assert d._compute_fusion_score(fp, [], []) is None
def test_no_fingerprint_phashes_returns_none(self):
"""新指纹无 pHash → None。"""
d = VideoDeduplicator()
fp = _make_fingerprint([])
assert d._compute_fusion_score(fp, ["0" * 16], [self._NORM_HIST]) is None
def test_check_fusion_still_filters(self):
"""_check_fusion_duplicate 仍然有阈值过滤(与 _compute_fusion_score 对比验证)。"""
d = VideoDeduplicator()
fp = _make_fingerprint(["0" * 16], [self._NORM_HIST])
existing_phashes = ["f" * 16]
existing_histograms = [self._NORM_HIST]
# _compute_fusion_score 返回原始得分 0.3
score = d._compute_fusion_score(fp, existing_phashes, existing_histograms)
assert score is not None
assert score["similarity"] == pytest.approx(0.3)
# _check_fusion_duplicate 过滤后返回 None0.3 < DUPLICATE_THRESHOLD 0.7
result = d._check_fusion_duplicate(fp, {}, existing_phashes, existing_histograms)
assert result is None
# ══════════════════════════════════════════════
# 9. check_duplicate 集成测试
# ══════════════════════════════════════════════
class TestCheckDuplicateIntegration:
"""check_duplicate 集成测试(mock DB)。"""
def test_md5_exact_match(self):
"""MD5 精确匹配。"""
d = VideoDeduplicator()
fp = _make_fingerprint(["0" * 16])
fp.md5 = "exact_md5"
mock_video = MagicMock()
mock_video.id = "vid_1"
mock_video.video_fingerprint = {"md5": "exact_md5", "keyframe_phashes": ["0" * 16]}
mock_repo = MagicMock()
mock_repo.list_by_project.return_value = [mock_video]
mock_session = MagicMock()
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
result = d.check_duplicate(fp, "proj_1", mock_session)
assert result is not None
assert result["reason"] == "exact_md5_match"
assert result["similarity"] == 1.0
def test_fusion_match_returns_correct_reason(self):
"""融合匹配返回 phash_histogram_fusion reason。"""
d = VideoDeduplicator()
phashes = ["0" * 16] * 10
hist = [[0.5] * 96] * 10
fp = _make_fingerprint(phashes, hist)
fp.md5 = "different_md5"
mock_video = MagicMock()
mock_video.id = "vid_1"
mock_video.video_fingerprint = {
"md5": "other_md5",
"keyframe_phashes": phashes,
"color_histograms": hist,
}
mock_repo = MagicMock()
mock_repo.list_by_project.return_value = [mock_video]
mock_session = MagicMock()
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
with patch.object(d, "_get_existing_chunks", return_value=[]):
result = d.check_duplicate(fp, "proj_1", mock_session)
assert result is not None
assert result["reason"] == "phash_histogram_fusion"
def test_no_duplicate_returns_none(self):
"""完全不相似 → None。"""
d = VideoDeduplicator()
# 全部帧距离都很大
new_phashes = ["0" * 16] * 10
existing_phashes = ["f" * 16] * 10
fp = _make_fingerprint(new_phashes)
mock_video = MagicMock()
mock_video.id = "vid_1"
mock_video.video_fingerprint = {
"md5": "other_md5",
"keyframe_phashes": existing_phashes,
"color_histograms": [[0.5] * 96] * 10,
}
mock_repo = MagicMock()
mock_repo.list_by_project.return_value = [mock_video]
mock_session = MagicMock()
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
with patch.object(d, "_get_existing_chunks", return_value=[]):
result = d.check_duplicate(fp, "proj_1", mock_session)
# 所有帧距离=64match_ratio=0 < 0.7 → None
assert result is None
# ══════════════════════════════════════════════
# 10. check_batch_duplicate 集成测试
# ══════════════════════════════════════════════
class TestCheckBatchDuplicateIntegration:
"""check_batch_duplicate 集成测试。"""
def test_batch_fusion_reason_has_prefix(self):
"""批次内融合匹配 reason 带 batch_ 前缀。"""
d = VideoDeduplicator()
phashes = ["0" * 16] * 10
hist = [[0.5] * 96] * 10
fp = _make_fingerprint(phashes, hist)
mock_video = MagicMock()
mock_video.id = "vid_other"
mock_video.video_fingerprint = {
"md5": "other_md5",
"keyframe_phashes": phashes,
"color_histograms": hist,
}
mock_repo = MagicMock()
mock_repo.list_by_batch.return_value = [mock_video]
mock_session = MagicMock()
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
with patch.object(d, "_get_existing_chunks", return_value=[]):
result = d.check_batch_duplicate(fp, "batch_1", "vid_self", mock_session)
assert result is not None
assert result["reason"] == "batch_phash_histogram_fusion"
def test_batch_skips_self(self):
"""批次内排除自身。"""
d = VideoDeduplicator()
fp = _make_fingerprint(["0" * 16])
mock_video = MagicMock()
mock_video.id = "vid_self"
mock_video.video_fingerprint = {"md5": "x", "keyframe_phashes": ["0" * 16]}
mock_repo = MagicMock()
mock_repo.list_by_batch.return_value = [mock_video]
mock_session = MagicMock()
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
result = d.check_batch_duplicate(fp, "batch_1", "vid_self", mock_session)
assert result is None
# ══════════════════════════════════════════════
# 11. compute_duplicate_rate 融合逻辑测试
# ══════════════════════════════════════════════
class TestComputeDuplicateRate:
"""compute_duplicate_rate 融合逻辑测试。"""
def test_md5_exact_returns_100(self):
"""MD5 精确匹配 → 100.0。"""
d = VideoDeduplicator()
fp = _make_fingerprint(["0" * 16])
fp.md5 = "exact_md5"
mock_model = MagicMock()
mock_domain = MagicMock()
mock_domain.id = "vid_1"
mock_domain.video_fingerprint = {"md5": "exact_md5", "keyframe_phashes": ["0" * 16]}
mock_repo = MagicMock()
mock_repo._to_domain.return_value = mock_domain
mock_session = MagicMock()
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = [mock_model]
mock_generated_model = MagicMock()
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
with patch.object(d, "_get_existing_chunks", return_value=[]):
# Patch the import inside the method
with patch.dict(
sys.modules,
{
"packages.adapters.sqlalchemy_impl.models": _mock_module(
GeneratedVideoModel=mock_generated_model
)
},
):
# Need to patch session.query to return mock_query
mock_session.query.return_value = mock_query
result = d.compute_duplicate_rate(fp, "proj_1", None, mock_session)
assert result == 100.0
def test_fusion_score_returns_percentage(self):
"""融合分数转为百分比。"""
d = VideoDeduplicator()
phashes = ["0" * 16] * 10
hist = [[0.5] * 96] * 10
fp = _make_fingerprint(phashes, hist)
fp.md5 = "different_md5"
mock_model = MagicMock()
mock_domain = MagicMock()
mock_domain.id = "vid_1"
mock_domain.video_fingerprint = {
"md5": "other_md5",
"keyframe_phashes": phashes,
"color_histograms": hist,
}
mock_repo = MagicMock()
mock_repo._to_domain.return_value = mock_domain
mock_session = MagicMock()
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = [mock_model]
mock_session.query.return_value = mock_query
mock_generated_model = MagicMock()
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
with patch.object(d, "_get_existing_chunks", return_value=[]):
with patch.dict(
sys.modules,
{
"packages.adapters.sqlalchemy_impl.models": _mock_module(
GeneratedVideoModel=mock_generated_model
)
},
):
result = d.compute_duplicate_rate(fp, "proj_1", None, mock_session)
# 完全相同 → similarity≈1.0 → rate≈100
assert result > 0
def test_completely_different_phash_returns_low_score(self):
"""pHash 完全不同但直方图相同 → 返回低分但不是 0(不经过阈值过滤)。
修复前:compute_duplicate_rate 复用 _check_fusion_duplicate,低于阈值返回 0.0。
修复后:使用 _compute_fusion_score 获取原始得分,如实返回相似度。
distance=64 → phash_sim=0.0, hist_sim=1.0 → combined=0.3 → rate=30.0
"""
d = VideoDeduplicator()
norm_hist = [1.0 / 96] * 96
fp = _make_fingerprint(["0" * 16] * 10, [norm_hist] * 10)
mock_model = MagicMock()
mock_domain = MagicMock()
mock_domain.id = "vid_1"
mock_domain.video_fingerprint = {
"md5": "other_md5",
"keyframe_phashes": ["f" * 16] * 10,
"color_histograms": [norm_hist] * 10,
}
mock_repo = MagicMock()
mock_repo._to_domain.return_value = mock_domain
mock_session = MagicMock()
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = [mock_model]
mock_session.query.return_value = mock_query
mock_generated_model = MagicMock()
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
with patch.object(d, "_get_existing_chunks", return_value=[]):
with patch.dict(
sys.modules,
{
"packages.adapters.sqlalchemy_impl.models": _mock_module(
GeneratedVideoModel=mock_generated_model
)
},
):
result = d.compute_duplicate_rate(fp, "proj_1", None, mock_session)
# phash 完全不同(0.0) + 直方图完全相同(1.0) → 0.7*0 + 0.3*1 = 0.3 → 30.0
assert result == 30.0
def test_no_other_videos_returns_zero(self):
"""无其他视频 → 0.0。"""
d = VideoDeduplicator()
fp = _make_fingerprint(["0" * 16])
mock_repo = MagicMock()
mock_session = MagicMock()
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = []
mock_session.query.return_value = mock_query
mock_generated_model = MagicMock()
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
with patch.dict(
sys.modules,
{"packages.adapters.sqlalchemy_impl.models": _mock_module(GeneratedVideoModel=mock_generated_model)},
):
result = d.compute_duplicate_rate(fp, "proj_1", None, mock_session)
assert result == 0.0
# ══════════════════════════════════════════════
# 12. _average_histogram_similarity 已删除验证
# ══════════════════════════════════════════════
class TestOldMethodDeleted:
"""验证旧方法已被删除。"""
def test_average_histogram_similarity_removed(self):
"""_average_histogram_similarity 不再存在。"""
assert not hasattr(VideoDeduplicator, "_average_histogram_similarity")