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>
293 lines
9.8 KiB
Python
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
|