soulsync/tests/downloads/test_downloads_validation.py
Broque Thomas 2fc08e199e Enforce duration tolerance for strict sources
Add duration tolerance logic and pre-download rejection for structured sources (tidal, qobuz, hifi, deezer_dl, amazon) when candidate duration deviates beyond allowed tolerance. Introduces helper functions _duration_tolerance_seconds and _duration_mismatch_exceeds_integrity_tolerance and uses resolve_duration_tolerance from core.imports.file_integrity. Log and skip candidates that would fail post-processing integrity checks to avoid wasted downloads. Update tests to include matching engine stub and new cases covering rejection and acceptance based on duration tolerance; also adjust imports and test fixtures.
2026-05-20 11:24:57 -07:00

125 lines
4.6 KiB
Python

"""Tests for core/downloads/validation.py — SoundCloud preview filter.
The SoundCloud anonymous tier serves a ~30s preview clip for tracks
gated behind Go+ / login. ``filter_soundcloud_previews`` drops these
candidates before they reach the matcher, the modal cache, or the
manual-pick download path.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from core.downloads import validation
from core.downloads.validation import filter_soundcloud_previews, get_valid_candidates
@dataclass
class _Track:
duration_ms: int
name: str = 'Song'
artists: tuple[str, ...] = ('Artist',)
@dataclass
class _Candidate:
username: str
duration: Optional[int] # milliseconds
title: str = 'Song'
artist: str = 'Artist'
filename: str = 'candidate'
class _MatchingEngine:
def score_track_match(self, **kwargs):
return 0.99, 'core_title_match'
def normalize_string(self, text):
return (text or '').lower()
def test_drops_soundcloud_30s_preview_when_expected_long():
"""A 30s SC candidate against a 5-minute expected track is the
canonical preview-snippet case — must be dropped."""
expected = _Track(duration_ms=338_000) # ~5:38
cands = [
_Candidate(username='soundcloud', duration=30_000, title='Preview'),
_Candidate(username='soundcloud', duration=338_000, title='Real'),
]
result = filter_soundcloud_previews(cands, expected)
assert len(result) == 1
assert result[0].title == 'Real'
def test_drops_under_half_expected_duration():
"""SC candidate at 100s against 300s expected = clearly truncated /
wrong content. Must be dropped even if not at the 30s boundary."""
expected = _Track(duration_ms=300_000)
cand = _Candidate(username='soundcloud', duration=100_000)
assert filter_soundcloud_previews([cand], expected) == []
def test_keeps_soundcloud_when_expected_track_is_short():
"""Genuinely short SC tracks (intros, sound effects, sub-minute
songs) must pass through when the expected track is also short.
Filter only kicks in when expected > 60s."""
expected = _Track(duration_ms=45_000) # 45s expected
cand = _Candidate(username='soundcloud', duration=30_000)
result = filter_soundcloud_previews([cand], expected)
assert result == [cand]
def test_does_not_filter_non_soundcloud_sources():
"""A 30s candidate from another streaming source isn't a SoundCloud
preview — leave it for the generic matching engine to score."""
expected = _Track(duration_ms=338_000)
yt = _Candidate(username='youtube', duration=30_000)
tidal = _Candidate(username='tidal', duration=30_000)
assert filter_soundcloud_previews([yt, tidal], expected) == [yt, tidal]
def test_returns_input_unchanged_without_expected_duration():
"""Without a Spotify-track / expected duration we can't reason
about previews — pass everything through."""
cands = [
_Candidate(username='soundcloud', duration=30_000),
_Candidate(username='soundcloud', duration=300_000),
]
assert filter_soundcloud_previews(cands, None) == cands
assert filter_soundcloud_previews(cands, _Track(duration_ms=0)) == cands
def test_empty_input_returns_empty_list():
assert filter_soundcloud_previews([], _Track(duration_ms=200_000)) == []
def test_keeps_soundcloud_candidate_at_threshold():
"""Boundary check: 35s candidate against 200s expected — exactly
at the 35s preview boundary, but 35s is also above
expected*0.5 (100s) check (35 < 100, so still drops). Use a
higher value to confirm the just-above threshold passes."""
expected = _Track(duration_ms=200_000) # 200s
# 110s passes both checks: > 35s AND > 100s (half of 200s)
cand = _Candidate(username='soundcloud', duration=110_000)
assert filter_soundcloud_previews([cand], expected) == [cand]
def test_rejects_tidal_candidate_that_would_fail_integrity_duration(monkeypatch):
"""Structured sources should not download candidates that post-processing
will immediately quarantine for the same duration mismatch."""
monkeypatch.setattr(validation, 'matching_engine', _MatchingEngine())
expected = _Track(duration_ms=338_000)
wrong_tidal = _Candidate(username='tidal', duration=30_000)
assert get_valid_candidates([wrong_tidal], expected, 'Artist Song') == []
def test_keeps_tidal_candidate_inside_integrity_duration_tolerance(monkeypatch):
monkeypatch.setattr(validation, 'matching_engine', _MatchingEngine())
expected = _Track(duration_ms=338_000)
tidal = _Candidate(username='tidal', duration=340_000)
result = get_valid_candidates([tidal], expected, 'Artist Song')
assert result == [tidal]