30457629da
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m20s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 51s
CI/CD Pipeline / Unit Tests (push) Successful in 4m54s
CI/CD Pipeline / Integration Tests (push) Successful in 2m6s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 55s
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
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m6s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m34s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m29s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 5s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
251 lines
8.7 KiB
Python
Executable File
251 lines
8.7 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 TestAverageHistogramSimilarity:
|
||
"""_average_histogram_similarity 直方图相似度测试."""
|
||
|
||
def test_identical_histograms(self):
|
||
"""完全相同的直方图相似度为1.0."""
|
||
hist = [[0.5, 0.5, 0.0], [0.3, 0.4, 0.3]]
|
||
sim = VideoDeduplicator._average_histogram_similarity(hist, hist)
|
||
assert sim == pytest.approx(1.0)
|
||
|
||
def test_empty_first_list(self):
|
||
"""第一组为空返回0."""
|
||
sim = VideoDeduplicator._average_histogram_similarity([], [[0.5, 0.5]])
|
||
assert sim == 0.0
|
||
|
||
def test_empty_second_list(self):
|
||
"""第二组为空返回0."""
|
||
sim = VideoDeduplicator._average_histogram_similarity([[0.5, 0.5]], [])
|
||
assert sim == 0.0
|
||
|
||
def test_both_empty(self):
|
||
"""两组都为空返回0."""
|
||
sim = VideoDeduplicator._average_histogram_similarity([], [])
|
||
assert sim == 0.0
|
||
|
||
def test_orthogonal_histograms(self):
|
||
"""正交直方图相似度为0."""
|
||
# [1, 0] 和 [0, 1] 正交
|
||
sim = VideoDeduplicator._average_histogram_similarity([[1.0, 0.0]], [[0.0, 1.0]])
|
||
assert sim == pytest.approx(0.0)
|
||
|
||
def test_partial_similarity(self):
|
||
"""部分相似."""
|
||
# [1, 1] 和 [1, 0] 的余弦相似度 = 1/√2 ≈ 0.707
|
||
sim = VideoDeduplicator._average_histogram_similarity([[1.0, 1.0]], [[1.0, 0.0]])
|
||
assert sim == pytest.approx(1.0 / (2**0.5), rel=0.01)
|
||
|
||
def test_multiple_frames_best_match(self):
|
||
"""多帧时取最佳匹配."""
|
||
# 第一帧完全不同,第二帧完全相同 → 平均 best = (0 + 1) / 2 = 0.5
|
||
sim = VideoDeduplicator._average_histogram_similarity(
|
||
[[1.0, 0.0], [0.0, 1.0]],
|
||
[[0.0, 1.0]], # 只有一帧,和第一帧0相似,和第二帧1相似
|
||
)
|
||
# 第一帧最佳匹配=0,第二帧最佳匹配=1,平均=0.5
|
||
assert sim == pytest.approx(0.5)
|
||
|
||
def test_zero_norm_histogram_skipped(self):
|
||
"""零范数直方图被跳过."""
|
||
sim = VideoDeduplicator._average_histogram_similarity([[0.0, 0.0]], [[1.0, 1.0]])
|
||
# 第一组的零范数被跳过,similarities为空,返回0
|
||
assert sim == 0.0
|
||
|
||
def test_different_length_histograms(self):
|
||
"""不同长度的直方图取最小长度对齐."""
|
||
sim = VideoDeduplicator._average_histogram_similarity(
|
||
[[1.0, 1.0, 0.0, 0.0]], # 4维
|
||
[[1.0, 1.0]], # 2维
|
||
)
|
||
# 对齐到前2维,都是[1,1],相似度1.0
|
||
assert sim == pytest.approx(1.0)
|
||
|
||
def test_similarity_in_zero_one_range(self):
|
||
"""相似度在[0, 1]范围内."""
|
||
hist_a = [np.random.rand(96).tolist() for _ in range(5)]
|
||
hist_b = [np.random.rand(96).tolist() for _ in range(5)]
|
||
sim = VideoDeduplicator._average_histogram_similarity(hist_a, hist_b)
|
||
assert 0.0 <= sim <= 1.0
|