"""分片指纹存储单元测试 — 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