test(quality): guard reason content, force_import isolation, bitrate-as-threshold
Quality guard: rejects with a 'file is X, wanted Y' reason (the string the track-detail modal surfaces), accepts when a target is met or fallback is on, skips when unprobeable. force_import isolation: the 'quality' bypass must not skip the AcoustID check and vice-versa; a quality reject persists trigger='quality' (not 'acoustid') in the sidecar — so a quality mismatch never routes through the force_import path (reserved for AcoustID version-mismatch). Model: lossy matches a MINIMUM bitrate (>=, a range); lossless matches on bit depth + sample rate, never exact bitrate, so a FLAC's varying bitrate (mono / compression) can't falsely reject it. v2->v3 migration preserves order. 47 passing across the quality + guard suites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e0c55342bc
commit
95a7b51966
2 changed files with 182 additions and 0 deletions
109
tests/imports/test_quality_guard.py
Normal file
109
tests/imports/test_quality_guard.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Quality guard + quarantine isolation.
|
||||
|
||||
Locks two invariants the global-quality work depends on:
|
||||
|
||||
1. ``check_quality_target`` rejects with a 'what the file IS vs what was
|
||||
WANTED' reason (surfaced in the track-detail modal), and accepts when a
|
||||
target is met or fallback is on.
|
||||
2. A quality mismatch is isolated from the AcoustID/force_import path: it
|
||||
uses ``trigger='quality'`` and the ``'quality'`` bypass flag, which must
|
||||
NOT bypass the AcoustID check and vice-versa. force_imported stays
|
||||
reserved for AcoustID version-mismatch acceptance.
|
||||
"""
|
||||
|
||||
import json
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import core.imports.guards as guards
|
||||
import core.imports.file_ops as file_ops
|
||||
from core.imports.pipeline import _should_skip_quarantine_check
|
||||
from core.quality.model import AudioQuality
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, profile):
|
||||
self._p = profile
|
||||
|
||||
def get_quality_profile(self):
|
||||
return self._p
|
||||
|
||||
|
||||
def _patch_guard(monkeypatch, probe_aq, profile, downsample=False):
|
||||
monkeypatch.setattr(file_ops, 'probe_audio_quality', lambda fp: probe_aq)
|
||||
monkeypatch.setattr(guards, 'MusicDatabase', lambda: _FakeDB(profile))
|
||||
monkeypatch.setattr(
|
||||
guards, '_get_config_manager',
|
||||
lambda: types.SimpleNamespace(get=lambda k, d=None: downsample if 'downsample' in k else d),
|
||||
)
|
||||
|
||||
|
||||
_WANT_FLAC24 = {
|
||||
'fallback_enabled': False,
|
||||
'ranked_targets': [
|
||||
{'label': 'FLAC 24-bit/96kHz', 'format': 'flac', 'bit_depth': 24, 'min_sample_rate': 96000},
|
||||
],
|
||||
}
|
||||
_WANT_FLAC24_FALLBACK = {**_WANT_FLAC24, 'fallback_enabled': True}
|
||||
|
||||
|
||||
# ── check_quality_target ───────────────────────────────────────────────────
|
||||
|
||||
def test_rejects_with_wanted_vs_got_reason(monkeypatch):
|
||||
_patch_guard(monkeypatch, AudioQuality('flac', sample_rate=44100, bit_depth=16), _WANT_FLAC24)
|
||||
reason = guards.check_quality_target('/x/song.flac', {})
|
||||
assert reason is not None
|
||||
assert 'FLAC 16-bit' in reason # what the file IS
|
||||
assert 'FLAC 24-bit/96kHz' in reason # what was WANTED
|
||||
|
||||
|
||||
def test_accepts_when_target_met(monkeypatch):
|
||||
_patch_guard(monkeypatch, AudioQuality('flac', sample_rate=96000, bit_depth=24), _WANT_FLAC24)
|
||||
assert guards.check_quality_target('/x/song.flac', {}) is None
|
||||
|
||||
|
||||
def test_accepts_via_fallback(monkeypatch):
|
||||
_patch_guard(monkeypatch, AudioQuality('flac', sample_rate=44100, bit_depth=16), _WANT_FLAC24_FALLBACK)
|
||||
assert guards.check_quality_target('/x/song.flac', {}) is None
|
||||
|
||||
|
||||
def test_skips_when_unprobeable(monkeypatch):
|
||||
_patch_guard(monkeypatch, None, _WANT_FLAC24)
|
||||
assert guards.check_quality_target('/x/song.flac', {}) is None
|
||||
|
||||
|
||||
# ── force_import isolation ─────────────────────────────────────────────────
|
||||
|
||||
def test_quality_bypass_does_not_skip_acoustid():
|
||||
ctx = {'_skip_quarantine_check': 'quality'}
|
||||
assert _should_skip_quarantine_check(ctx, 'quality') is True
|
||||
assert _should_skip_quarantine_check(ctx, 'acoustid') is False
|
||||
|
||||
|
||||
def test_acoustid_bypass_does_not_skip_quality():
|
||||
ctx = {'_skip_quarantine_check': 'acoustid'}
|
||||
assert _should_skip_quarantine_check(ctx, 'acoustid') is True
|
||||
assert _should_skip_quarantine_check(ctx, 'quality') is False
|
||||
|
||||
|
||||
def test_quality_quarantine_persists_quality_trigger(monkeypatch, tmp_path):
|
||||
# A quality reject writes trigger='quality' (not 'acoustid') into the
|
||||
# sidecar, so Approve never routes it through the force_import path.
|
||||
monkeypatch.setattr(
|
||||
guards, '_get_config_manager',
|
||||
lambda: types.SimpleNamespace(get=lambda k, d=None: str(tmp_path) if 'download_path' in k else d),
|
||||
)
|
||||
src = tmp_path / 'song.flac'
|
||||
src.write_bytes(b'FLACfake')
|
||||
qpath = guards.move_to_quarantine(
|
||||
str(src), {}, 'Quality mismatch: file is FLAC 16-bit, wanted FLAC 24-bit/96kHz',
|
||||
automation_engine=None, trigger='quality',
|
||||
)
|
||||
sidecars = list((tmp_path / 'ss_quarantine').glob('*.json'))
|
||||
assert len(sidecars) == 1
|
||||
meta = json.loads(sidecars[0].read_text(encoding='utf-8'))
|
||||
assert meta['trigger'] == 'quality'
|
||||
assert meta['trigger'] != 'acoustid'
|
||||
assert 'wanted FLAC 24-bit/96kHz' in meta['quarantine_reason']
|
||||
assert qpath.endswith('.quarantined')
|
||||
73
tests/quality/test_model.py
Normal file
73
tests/quality/test_model.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""AudioQuality.matches_target + v2->v3 migration.
|
||||
|
||||
Locks the bitrate-as-threshold behaviour: lossy formats match on a MINIMUM
|
||||
bitrate (>=, a range), and lossless matches on bit depth + sample rate — NOT
|
||||
on exact bitrate, so a FLAC's wildly-varying bitrate (stereo vs mono, FLAC
|
||||
compression) never falsely rejects it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from core.quality.model import (
|
||||
AudioQuality,
|
||||
QualityTarget,
|
||||
v2_qualities_to_ranked_targets,
|
||||
)
|
||||
|
||||
|
||||
# ── lossy: bitrate is a minimum threshold (a range), never exact ───────────
|
||||
|
||||
def test_mp3_meets_minimum_bitrate():
|
||||
t = QualityTarget(format='mp3', min_bitrate=320)
|
||||
assert AudioQuality('mp3', bitrate=320).matches_target(t) is True
|
||||
assert AudioQuality('mp3', bitrate=400).matches_target(t) is True # above floor ok
|
||||
|
||||
|
||||
def test_mp3_below_minimum_bitrate_rejected():
|
||||
t = QualityTarget(format='mp3', min_bitrate=320)
|
||||
assert AudioQuality('mp3', bitrate=300).matches_target(t) is False
|
||||
|
||||
|
||||
def test_mp3_matches_lower_threshold():
|
||||
t = QualityTarget(format='mp3', min_bitrate=192)
|
||||
assert AudioQuality('mp3', bitrate=320).matches_target(t) is True
|
||||
|
||||
|
||||
# ── lossless: matched on bit depth + sample rate, NOT exact bitrate ────────
|
||||
|
||||
def test_flac_matches_on_depth_and_rate_regardless_of_bitrate():
|
||||
t = QualityTarget(format='flac', bit_depth=24, min_sample_rate=96000)
|
||||
# An unusual/low bitrate (e.g. a mono or highly-compressed FLAC) must
|
||||
# still match when bit depth + sample rate satisfy the target.
|
||||
weird = AudioQuality('flac', bitrate=300, sample_rate=96000, bit_depth=24)
|
||||
assert weird.matches_target(t) is True
|
||||
|
||||
|
||||
def test_flac_below_target_sample_rate_rejected():
|
||||
t = QualityTarget(format='flac', bit_depth=24, min_sample_rate=96000)
|
||||
assert AudioQuality('flac', sample_rate=44100, bit_depth=24).matches_target(t) is False
|
||||
|
||||
|
||||
def test_flac_below_target_bit_depth_rejected():
|
||||
t = QualityTarget(format='flac', bit_depth=24)
|
||||
assert AudioQuality('flac', sample_rate=96000, bit_depth=16).matches_target(t) is False
|
||||
|
||||
|
||||
def test_format_mismatch_rejected():
|
||||
t = QualityTarget(format='flac', bit_depth=16)
|
||||
assert AudioQuality('mp3', bitrate=320).matches_target(t) is False
|
||||
|
||||
|
||||
# ── v2 -> v3 migration preserves the user's priority order ─────────────────
|
||||
|
||||
def test_v2_to_v3_preserves_order_and_maps_fields():
|
||||
qualities = {
|
||||
'flac': {'enabled': True, 'priority': 1, 'bit_depth': '24'},
|
||||
'mp3_320': {'enabled': True, 'priority': 2},
|
||||
'mp3_192': {'enabled': False, 'priority': 3}, # disabled → dropped
|
||||
}
|
||||
targets = v2_qualities_to_ranked_targets(qualities)
|
||||
formats = [t['format'] for t in targets]
|
||||
assert formats == ['flac', 'mp3'] # disabled mp3_192 omitted
|
||||
assert targets[0]['bit_depth'] == 24
|
||||
assert targets[1]['min_bitrate'] == 320
|
||||
Loading…
Reference in a new issue