Files
xiaoxia-saas/tests/unit/test_fingerprint_chunks.py
T
xiaoxia 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
fix(dedup): 修复查重率恒为0%——指纹绕开降重裁剪+局部片段复用+阈值校准+3个单位bug (#1702) (#1703)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 07:57:23 +08:00

293 lines
9.8 KiB
Python

"""分片指纹存储单元测试 — Issue #1657.
覆盖:
- 分片策略:60秒视频 → 30片,120秒视频 → 24片
- VideoFingerprint.to_chunk_models() 输出正确
- _save_fingerprint_chunks 替换语义(Issue #1702:重算时先删旧分片再写入)
- to_dict() 向后兼容
"""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
def _mock_module(**attrs):
"""Create a mock module with __spec__ to avoid AttributeError."""
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 ──
_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",
]
}
# 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()
# Mock VideoFingerprintChunkModel with class-level column attributes
class _FakeChunkModel:
video_id = MagicMock()
project_id = MagicMock()
user_id = MagicMock()
start_time_ms = MagicMock()
end_time_ms = MagicMock()
phash_binary = MagicMock()
color_histogram = MagicMock()
frame_count = MagicMock()
created_at = MagicMock()
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
sys.modules["packages.adapters.sqlalchemy_impl.models"] = _mock_module(
VideoFingerprintChunkModel=_FakeChunkModel,
)
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
FingerprintChunk,
VideoFingerprint,
_save_fingerprint_chunks,
)
# ── Restore sys.modules immediately after import ──
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 TestVideoFingerprintToChunkModels:
"""测试 VideoFingerprint.to_chunk_models() 输出。"""
def test_to_chunk_models_output(self):
"""to_chunk_models 返回正确的 Model 列表。"""
fp = VideoFingerprint(
md5="abc123",
keyframe_phashes=["a1b2", "c3d4"],
color_histograms=[[0.1] * 96, [0.2] * 96],
duration=10.0,
resolution=(1920, 1080),
chunks=[
FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="a1b2", color_histogram=[0.1] * 96),
FingerprintChunk(start_time_ms=2000, end_time_ms=4000, phash_binary="c3d4", color_histogram=[0.2] * 96),
],
)
models = fp.to_chunk_models(video_id="v1", project_id="p1", user_id="u1")
assert len(models) == 2
assert models[0].video_id == "v1"
assert models[0].project_id == "p1"
assert models[0].user_id == "u1"
assert models[0].start_time_ms == 0
assert models[0].end_time_ms == 2000
assert models[0].phash_binary == "a1b2"
assert models[1].start_time_ms == 2000
assert models[1].end_time_ms == 4000
assert models[1].phash_binary == "c3d4"
def test_to_chunk_models_empty_chunks(self):
"""空 chunks 列表返回空 Model 列表。"""
fp = VideoFingerprint(
md5="abc",
keyframe_phashes=[],
color_histograms=[],
duration=0,
resolution=(0, 0),
chunks=[],
)
models = fp.to_chunk_models(video_id="v1", project_id="p1")
assert models == []
class TestSaveFingerprintChunksReplace:
"""测试 _save_fingerprint_chunks 替换语义(Issue #1702)。
重算查重时指纹算法已升级(中心裁剪 + 新采样/阈值),旧分片必须先删除
再写入新分片,否则 recompute-dedup 永远读到旧指纹、修复对存量视频不生效。
"""
def test_save_replaces_existing(self):
"""已有分片数据时:先删除旧分片,再写入新分片。"""
fp = VideoFingerprint(
md5="abc",
keyframe_phashes=["a1b2"],
color_histograms=[[0.1] * 96],
duration=5.0,
resolution=(1920, 1080),
chunks=[
FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="a1b2", color_histogram=[0.1] * 96),
],
)
session = MagicMock()
# Mock: 删除旧分片返回 3(旧算法留下的 3 条分片)
session.query.return_value.filter.return_value.delete.return_value = 3
_save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session)
# 必须先执行删除
session.query.return_value.filter.return_value.delete.assert_called_once()
# 新分片必须写入
session.bulk_save_objects.assert_called_once()
saved_models = session.bulk_save_objects.call_args[0][0]
assert len(saved_models) == 1
assert saved_models[0].video_id == "v1"
assert saved_models[0].phash_binary == "a1b2"
def test_save_writes_new(self):
"""无旧分片时直接写入。"""
fp = VideoFingerprint(
md5="abc",
keyframe_phashes=["a1b2"],
color_histograms=[[0.1] * 96],
duration=5.0,
resolution=(1920, 1080),
chunks=[
FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="a1b2", color_histogram=[0.1] * 96),
],
)
session = MagicMock()
# Mock: 无旧分片
session.query.return_value.filter.return_value.delete.return_value = 0
_save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session)
session.query.return_value.filter.return_value.delete.assert_called_once()
session.bulk_save_objects.assert_called_once()
saved_models = session.bulk_save_objects.call_args[0][0]
assert len(saved_models) == 1
assert saved_models[0].video_id == "v1"
assert saved_models[0].phash_binary == "a1b2"
def test_save_skips_no_chunks(self):
"""指纹无 chunks 时跳过(不删不写)。"""
fp = VideoFingerprint(
md5="abc",
keyframe_phashes=[],
color_histograms=[],
duration=0,
resolution=(0, 0),
chunks=[],
)
session = MagicMock()
_save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session)
# 无 chunks:不查询、不删除、不写入
session.query.assert_not_called()
session.bulk_save_objects.assert_not_called()
class TestFingerprintToDictBackwardCompat:
"""测试 to_dict() 向后兼容性。"""
def test_to_dict_includes_chunks(self):
"""to_dict() 包含 chunks 字段。"""
fp = VideoFingerprint(
md5="abc123",
keyframe_phashes=["a1b2"],
color_histograms=[[0.1] * 96],
duration=5.0,
resolution=(1920, 1080),
chunks=[
FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="a1b2", color_histogram=[0.1] * 96),
],
)
d = fp.to_dict()
assert "chunks" in d
assert len(d["chunks"]) == 1
assert d["chunks"][0]["start_time_ms"] == 0
assert d["chunks"][0]["end_time_ms"] == 2000
assert d["chunks"][0]["phash_binary"] == "a1b2"
def test_to_dict_preserves_legacy_fields(self):
"""to_dict() 保留 keyframe_phashes 和 color_histograms 字段。"""
fp = VideoFingerprint(
md5="abc",
keyframe_phashes=["a1b2", "c3d4"],
color_histograms=[[0.1] * 96, [0.2] * 96],
duration=10.0,
resolution=(1920, 1080),
)
d = fp.to_dict()
assert "keyframe_phashes" in d
assert "color_histograms" in d
assert len(d["keyframe_phashes"]) == 2
assert len(d["color_histograms"]) == 2