28b3010668
CI/CD Pipeline / Check if frontend-only change (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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) 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 / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
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 / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 18s
CI/CD Pipeline / Validate - Style (pull_request) 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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 43s
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 / Deploy Production (pull_request) Has been skipped
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 8s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
AI Code Review / AI Code Review (pull_request) Failing after 2m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Style (push) Successful in 3m13s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Validate - Security (push) Successful in 5m58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m38s
CI/CD Pipeline / Unit Tests (push) Successful in 8m43s
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
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m35s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
252 lines
8.8 KiB
Python
Executable File
252 lines
8.8 KiB
Python
Executable File
"""去重纯算法测试 — hamming_distance + histogram_similarity + VideoFingerprint."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from unittest.mock import MagicMock
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
|
||
def _mock_module(**attrs):
|
||
"""Create a mock module with __spec__ to avoid AttributeError: __spec__."""
|
||
m = MagicMock()
|
||
m.__spec__ = None
|
||
for k, v in attrs.items():
|
||
setattr(m, k, v)
|
||
return m
|
||
|
||
|
||
# ── Module-level setup: mock deps, import dedup, then restore sys.modules ──
|
||
# This pattern ensures:
|
||
# 1. dedup is imported with mocks active (no db/celery/cv2 side effects)
|
||
# 2. sys.modules is restored immediately so other test files are not polluted
|
||
# 3. dedup objects are kept in module namespace for tests to use
|
||
|
||
_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.shared.config",
|
||
"packages.shared.storage",
|
||
]
|
||
}
|
||
|
||
# Set up mocks
|
||
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.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
|
||
sys.modules["packages.shared.storage"] = _mock_module()
|
||
|
||
# Import dedup while mocks are active
|
||
from video_processing.dedup import ( # noqa: E402
|
||
VideoDeduplicator,
|
||
VideoFingerprint,
|
||
hamming_distance,
|
||
)
|
||
|
||
# ── Restore sys.modules immediately after import ──
|
||
# dedup is now cached in this module's namespace; other test files will get
|
||
# their own fresh imports without our mock pollution
|
||
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
|
||
|
||
|
||
class TestHammingDistance:
|
||
"""hamming_distance 汉明距离计算测试."""
|
||
|
||
def test_identical_hashes_zero(self):
|
||
"""相同哈希距离为0."""
|
||
assert hamming_distance("ff", "ff") == 0
|
||
assert hamming_distance("00", "00") == 0
|
||
|
||
def test_all_different(self):
|
||
"""全不同的8bit哈希距离为8."""
|
||
assert hamming_distance("00", "ff") == 8
|
||
|
||
def test_single_bit_diff(self):
|
||
"""1个bit不同."""
|
||
# 0x01 = 00000001, 0x00 = 00000000 → 1 bit不同
|
||
assert hamming_distance("01", "00") == 1
|
||
|
||
def test_four_bits_diff(self):
|
||
"""4个bit不同."""
|
||
# 0x0F = 00001111, 0xF0 = 11110000 → 8 bits都不同
|
||
assert hamming_distance("0f", "f0") == 8
|
||
|
||
def test_longer_hashes(self):
|
||
"""更长的哈希(如64-bit pHash)."""
|
||
# 两个完全不同的64-bit哈希
|
||
assert hamming_distance("0000000000000000", "ffffffffffffffff") == 64
|
||
|
||
def test_partial_difference(self):
|
||
"""部分bit不同."""
|
||
# a = 1010, 5 = 0101 → 4 bits不同(每个hex digit)
|
||
assert hamming_distance("aa", "55") == 8
|
||
|
||
def test_case_insensitive(self):
|
||
"""十六进制不区分大小写."""
|
||
assert hamming_distance("FF", "ff") == 0
|
||
assert hamming_distance("AbC123", "aBc123") == 0
|
||
|
||
def test_different_length_hashes(self):
|
||
"""不同长度的哈希(短的前补零)."""
|
||
# "ff" = 0xff = 255, "0ff" = 0x0ff = 255
|
||
# int("ff", 16) = 255, int("0ff", 16) = 255
|
||
assert hamming_distance("ff", "0ff") == 0
|
||
|
||
|
||
class TestVideoFingerprint:
|
||
"""VideoFingerprint 数据结构测试."""
|
||
|
||
def test_to_dict_contains_all_fields(self):
|
||
"""to_dict返回完整字典."""
|
||
fp = VideoFingerprint(
|
||
md5="abc123",
|
||
keyframe_phashes=["hash1", "hash2"],
|
||
color_histograms=[[0.1, 0.2], [0.3, 0.4]],
|
||
duration=30.5,
|
||
resolution=(1920, 1080),
|
||
)
|
||
d = fp.to_dict()
|
||
assert d["md5"] == "abc123"
|
||
assert d["keyframe_phashes"] == ["hash1", "hash2"]
|
||
assert d["duration"] == 30.5
|
||
assert d["resolution"] == [1920, 1080]
|
||
assert "color_histograms" in d
|
||
|
||
def test_empty_phashes(self):
|
||
"""空关键帧列表."""
|
||
fp = VideoFingerprint(
|
||
md5="test",
|
||
keyframe_phashes=[],
|
||
color_histograms=[],
|
||
duration=0.0,
|
||
resolution=(0, 0),
|
||
)
|
||
d = fp.to_dict()
|
||
assert d["keyframe_phashes"] == []
|
||
assert d["color_histograms"] == []
|
||
|
||
|
||
class TestBhattacharyyaCoefficient:
|
||
"""_bhattacharyya_coefficient Bhattacharyya 系数测试."""
|
||
|
||
def test_identical_histograms(self):
|
||
"""完全相同的直方图系数为1.0(#1702:按 Σ 归一,概率分布语义)。"""
|
||
hist = [0.5, 0.5, 0.0, 0.0] # Σ=1 的概率分布
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient(hist, hist)
|
||
assert bc == pytest.approx(1.0)
|
||
# 非归一化输入也归一到 1.0(三通道拼接 Σ=3 的等价情形)
|
||
hist3 = [0.5, 0.5, 0.0, 0.3]
|
||
bc3 = VideoDeduplicator._bhattacharyya_coefficient(hist3, hist3)
|
||
assert bc3 == pytest.approx(1.0)
|
||
|
||
def test_zero_histograms(self):
|
||
"""全零直方图系数为0."""
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient([0.0, 0.0], [0.0, 0.0])
|
||
assert bc == 0.0
|
||
|
||
def test_orthogonal_histograms(self):
|
||
"""正交直方图(无重叠)系数为0."""
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 0.0], [0.0, 1.0])
|
||
assert bc == pytest.approx(0.0)
|
||
|
||
def test_different_lengths(self):
|
||
"""不同长度直方图取最小长度对齐,并按各自总量归一(#1702 概率分布语义)。"""
|
||
# 对齐到前 2 维:coeff = 2,norm = √(Σa·Σb) = √(2·2) = 2 → 1.0
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 1.0, 0.0, 0.0], [1.0, 1.0])
|
||
assert bc == pytest.approx(1.0)
|
||
|
||
def test_known_value(self):
|
||
"""已知值验证."""
|
||
# [0.25, 0.25, 0.25, 0.25] vs [0.25, 0.25, 0.25, 0.25]
|
||
# BC = 4 * √(0.25 * 0.25) = 4 * 0.25 = 1.0
|
||
hist = [0.25, 0.25, 0.25, 0.25]
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient(hist, hist)
|
||
assert bc == pytest.approx(1.0)
|
||
|
||
|
||
class TestComputeHistogramSimilarity:
|
||
"""_compute_histogram_similarity 多帧直方图相似度测试."""
|
||
|
||
def test_identical_histogram_groups(self):
|
||
"""完全相同的两组直方图."""
|
||
hist = [[0.5, 0.5], [0.3, 0.4]]
|
||
sim = VideoDeduplicator._compute_histogram_similarity(hist, hist)
|
||
# Each hist finds best match = itself
|
||
assert sim > 0.0
|
||
|
||
def test_empty_first(self):
|
||
"""第一组为空返回0."""
|
||
assert VideoDeduplicator._compute_histogram_similarity([], [[0.5]]) == 0.0
|
||
|
||
def test_empty_second(self):
|
||
"""第二组为空返回0."""
|
||
assert VideoDeduplicator._compute_histogram_similarity([[0.5]], []) == 0.0
|
||
|
||
def test_both_empty(self):
|
||
"""两组都为空返回0."""
|
||
assert VideoDeduplicator._compute_histogram_similarity([], []) == 0.0
|
||
|
||
def test_best_match_selection(self):
|
||
"""多帧时取最佳匹配."""
|
||
# ha[0] 与 hb[0] 正交,与 hb[1] 完全相同
|
||
a = [[1.0, 0.0]]
|
||
b = [[0.0, 1.0], [1.0, 0.0]]
|
||
sim = VideoDeduplicator._compute_histogram_similarity(a, b)
|
||
# Best match for [1,0]: max(BC([1,0],[0,1]), BC([1,0],[1,0])) = max(0, 1) = 1
|
||
assert sim == pytest.approx(1.0)
|