6a6ad8b00f
CI/CD Pipeline / Check push changed paths (pull_request) 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 / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
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 / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m8s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m30s
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
AI Code Review / AI Code Review (pull_request) Failing after 3m58s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- test_dedup_engine.py: 5 个失败测试修复 - 补充归一化颜色直方图数据使融合相似度能通过阈值 - 更新 reason 字符串为 phash_histogram_fusion / batch_phash_histogram_fusion - 更新相似度期望值(融合公式 0.7*phash + 0.3*hist) - 更新 _make_existing_video 辅助函数支持 histograms 参数 - test_duplicate_rate.py: 2 个失败测试修复 - 补充归一化直方图数据并更新期望值(97.81, 98.91) - 更新 _make_fingerprint 辅助函数支持 histograms 参数 全量 81 passed, 8 skipped(cv2 依赖测试跳过)
330 lines
13 KiB
Python
330 lines
13 KiB
Python
"""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
|