"""Tests for duplicate_rate computation and API response.""" import json import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest # Mock cv2 and numpy before any imports that need them sys.modules.setdefault("cv2", MagicMock()) ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "apps" / "api")) sys.path.insert(0, str(ROOT / "packages")) sys.path.insert(0, str(ROOT / "apps" / "worker")) # Issue #1658: 归一化颜色直方图(96 维 = 3 通道 × 32 bins,sum=1.0)。 # 相同归一化直方图的 Bhattacharyya 系数 ≈ 1.0(颜色完全一致), # 用于验证 pHash + 颜色直方图融合后的 duplicate_rate。 _NORM_HISTOGRAM = [1.0 / 96] * 96 class TestComputeDuplicateRate: """Test VideoDeduplicator.compute_duplicate_rate.""" def _make_fingerprint(self, md5="abc123", phashes=None, histograms=None): from video_processing.dedup import VideoFingerprint phashes = phashes or ["ff00ff00ff00ff00"] if histograms is None: histograms = [_NORM_HISTOGRAM] * len(phashes) return VideoFingerprint( md5=md5, keyframe_phashes=phashes, color_histograms=histograms, duration=10.0, resolution=(1920, 1080), ) def _make_existing_video(self, vid, fingerprint_dict): from packages.domain import GeneratedVideo return GeneratedVideo( id=vid, project_id="proj1", generation_task_id="task1", name=f"video-{vid}", file_url=f"https://example.com/{vid}.mp4", file_size=1000, duration=10.0, width=1920, height=1080, fps=25.0, video_fingerprint=fingerprint_dict, ) def test_no_existing_videos_returns_zero(self): from video_processing.dedup import VideoDeduplicator deduplicator = VideoDeduplicator() fingerprint = self._make_fingerprint() session = MagicMock() with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value query_mock = MagicMock() query_mock.filter.return_value = query_mock query_mock.order_by.return_value.limit.return_value.all.return_value = [] session.query.return_value = query_mock rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) assert rate == 0.0 def test_md5_match_returns_100(self): from video_processing.dedup import VideoDeduplicator from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel deduplicator = VideoDeduplicator() fingerprint = self._make_fingerprint(md5="exact_match_md5") session = MagicMock() existing = self._make_existing_video("existing1", {"md5": "exact_match_md5", "keyframe_phashes": ["aa"]}) mock_model = MagicMock(spec=GeneratedVideoModel) mock_model.id = existing.id mock_model.project_id = existing.project_id mock_model.video_fingerprint = existing.video_fingerprint mock_model.generated_at = "2026-01-01" with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value mock_repo._to_domain.return_value = existing # 链式 filter: 第一次 scope filter,第二次 self-exclusion filter # 让 filter() 返回的对象仍然支持 order_by() 链 query_mock = MagicMock() query_mock.filter.return_value = query_mock # filter → filter chainable query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model] session.query.return_value = query_mock rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) assert rate == 100.0 def test_phash_similarity_computed(self): from video_processing.dedup import VideoDeduplicator from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel deduplicator = VideoDeduplicator() fingerprint = self._make_fingerprint(md5="different_md5", phashes=["ff00ff00ff00ff00"]) session = MagicMock() existing = self._make_existing_video( "existing1", { "md5": "other_md5", "keyframe_phashes": ["ff00ff00ff00ff03"], # Issue #1658: 提供归一化直方图(颜色一致 → hist_sim≈1.0) "color_histograms": [_NORM_HISTOGRAM], }, ) mock_model = MagicMock(spec=GeneratedVideoModel) mock_model.id = existing.id mock_model.project_id = existing.project_id mock_model.video_fingerprint = existing.video_fingerprint mock_model.generated_at = "2026-01-01" with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value mock_repo._to_domain.return_value = existing query_mock = MagicMock() query_mock.filter.return_value = query_mock query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model] session.query.return_value = query_mock rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) # Issue #1658 融合公式:hamming distance = 2 → phash_sim = 1 - 2/64; # hist_sim ≈ 1.0 → fusion = 0.7*(1-2/64) + 0.3*1.0 = 0.978125 → 97.81 assert rate == pytest.approx(97.81, abs=0.1) def test_excludes_self_video(self): from video_processing.dedup import VideoDeduplicator from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel deduplicator = VideoDeduplicator() fingerprint = self._make_fingerprint(md5="same_md5") session = MagicMock() self_video = self._make_existing_video("vid1", {"md5": "same_md5", "keyframe_phashes": ["aa"]}) mock_model = MagicMock(spec=GeneratedVideoModel) mock_model.id = self_video.id mock_model.project_id = self_video.project_id mock_model.video_fingerprint = self_video.video_fingerprint mock_model.generated_at = "2026-01-01" with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value mock_repo._to_domain.return_value = self_video query_mock = MagicMock() query_mock.filter.return_value = query_mock query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model] session.query.return_value = query_mock rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) assert rate == 0.0 def test_takes_max_similarity(self): from video_processing.dedup import VideoDeduplicator from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel deduplicator = VideoDeduplicator() fingerprint = self._make_fingerprint(md5="new_md5", phashes=["ff00ff00ff00ff00"]) session = MagicMock() existing1 = self._make_existing_video( "e1", { "md5": "md5_1", "keyframe_phashes": ["ff00ff00ff00ff0f"], # Issue #1658: 颜色直方图一致 → hist_sim≈1.0 "color_histograms": [_NORM_HISTOGRAM], }, ) existing2 = self._make_existing_video( "e2", { "md5": "md5_2", "keyframe_phashes": ["ff00ff00ff00ff01"], "color_histograms": [_NORM_HISTOGRAM], }, ) mock_model1 = MagicMock(spec=GeneratedVideoModel) mock_model1.id = existing1.id mock_model1.project_id = existing1.project_id mock_model1.video_fingerprint = existing1.video_fingerprint mock_model1.generated_at = "2026-01-02" mock_model2 = MagicMock(spec=GeneratedVideoModel) mock_model2.id = existing2.id mock_model2.project_id = existing2.project_id mock_model2.video_fingerprint = existing2.video_fingerprint mock_model2.generated_at = "2026-01-01" with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value mock_repo._to_domain.side_effect = [existing1, existing2] query_mock = MagicMock() query_mock.filter.return_value = query_mock query_mock.order_by.return_value.limit.return_value.all.return_value = [ mock_model1, mock_model2, ] session.query.return_value = query_mock rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) # Issue #1658 融合公式:e1 distance=4 → 0.7*(1-4/64)+0.3 ≈ 0.95625 → 95.62; # e2 distance=1 → 0.7*(1-1/64)+0.3 = 0.9890625 → 98.91。取最大值 e2。 assert rate == pytest.approx(98.91, abs=0.1) def test_user_id_scope_cross_project(self): """传 user_id 时应跨项目查询,而非仅当前项目.""" from video_processing.dedup import VideoDeduplicator from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel deduplicator = VideoDeduplicator() fingerprint = self._make_fingerprint(md5="cross_proj_md5") session = MagicMock() # 模拟一个不同项目但同一用户的视频 existing = self._make_existing_video( "existing_other_proj", {"md5": "cross_proj_md5", "keyframe_phashes": ["aa"]} ) existing.project_id = "proj2" # 不同项目 existing.user_id = "user1" mock_model = MagicMock(spec=GeneratedVideoModel) mock_model.id = existing.id mock_model.project_id = existing.project_id mock_model.user_id = existing.user_id mock_model.video_fingerprint = existing.video_fingerprint mock_model.generated_at = "2026-01-01" with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value mock_repo._to_domain.return_value = existing query_mock = MagicMock() query_mock.filter.return_value = query_mock query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model] session.query.return_value = query_mock rate = deduplicator.compute_duplicate_rate( fingerprint, "proj1", "vid1", session, user_id="user1", ) # 应通过 user_id 过滤,且匹配到跨项目视频 assert rate == 100.0 def test_user_id_empty_falls_back_to_project(self): """user_id 为空时应回退到 project_id 过滤.""" from video_processing.dedup import VideoDeduplicator deduplicator = VideoDeduplicator() fingerprint = self._make_fingerprint() session = MagicMock() with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value query_mock = MagicMock() query_mock.filter.return_value = query_mock query_mock.order_by.return_value.limit.return_value.all.return_value = [] session.query.return_value = query_mock rate = deduplicator.compute_duplicate_rate( fingerprint, "proj1", "vid1", session, user_id="", ) assert rate == 0.0 # 验证使用的是 project_id 过滤(回退路径) # 通过检查 filter 被调用时的参数来间接验证 class TestDuplicateRateAPI: """Test that duplicate_rate is returned in API responses.""" def test_video_item_response_has_duplicate_rate(self): from app.schemas.video_center import VideoItemResponse resp = VideoItemResponse( id="v1", project_id="p1", generation_task_id="t1", name="test.mp4", file_url="https://example.com/test.mp4", file_size=1000, duration=10.0, width=1920, height=1080, fps=25.0, duplicate_rate=75.5, ) assert resp.duplicate_rate == 75.5 def test_video_item_response_duplicate_rate_default_none(self): from app.schemas.video_center import VideoItemResponse resp = VideoItemResponse( id="v1", project_id="p1", generation_task_id="t1", name="test.mp4", file_url="https://example.com/test.mp4", file_size=1000, duration=10.0, width=1920, height=1080, fps=25.0, ) assert resp.duplicate_rate is None