the frontend keeps its own copy of the lossless set (settings.js RT_LOSSLESS_FORMATS + the index.html quality-profile dropdown) — runtime-fetching a yearly-changing list from the backend isn't worth the coupling. but that duplication IS the exact root cause of #941 (a format added to one place, not another). so instead of unifying, pin it: two tests parse the frontend lists and assert they match the backend LOSSLESS_FORMATS. adding a new lossless format now fails CI until it's added everywhere, instead of silently shipping a half-wired feature. verified the guard catches drift (not a tautology): a simulated backend-only 'ape' addition makes the equality fail. 18 lossless tests green, ruff clean.
163 lines
6.7 KiB
Python
163 lines
6.7 KiB
Python
"""The canonical "is this lossless?" seam + the lossy-copy overwrite invariant
|
|
(#941). All pure — no files, no ffmpeg — so the decision and the safety guard are
|
|
unit-testable in isolation. Both the import path (create_lossy_copy) and the Lossy
|
|
Converter repair job route through these, so the same rules drive both."""
|
|
|
|
from core.quality.lossless import (
|
|
LOSSLESS_FORMATS,
|
|
LOSSLESS_CANDIDATE_EXTENSIONS,
|
|
is_lossless_format,
|
|
is_lossless_audio_path,
|
|
lossy_output_would_overwrite_source,
|
|
)
|
|
from core.quality.model import AudioQuality
|
|
from core.repair_jobs.lossy_converter import _lossless_ext_where
|
|
|
|
|
|
# ── is_lossless_format ──
|
|
|
|
def test_lossless_formats_recognized():
|
|
for fmt in ('flac', 'alac', 'wav', 'dsf', 'FLAC', 'Dsf'):
|
|
assert is_lossless_format(fmt) is True
|
|
|
|
|
|
def test_lossy_formats_not_lossless():
|
|
for fmt in ('mp3', 'aac', 'ogg', 'opus', 'wma', 'unknown', '', None):
|
|
assert is_lossless_format(fmt) is False
|
|
|
|
|
|
# ── is_lossless_audio_path (the ambiguity is the whole point) ──
|
|
|
|
def test_unambiguous_extensions_decided_by_extension():
|
|
for path in ('/m/a.flac', '/m/a.wav', '/m/a.wave', '/m/a.aiff', '/m/a.aif',
|
|
'/m/a.dsf', '/m/a.dff', '/m/a.alac', '/m/A.FLAC'):
|
|
assert is_lossless_audio_path(path) is True
|
|
|
|
|
|
def test_lossy_extensions_are_not_lossless():
|
|
for path in ('/m/a.mp3', '/m/a.ogg', '/m/a.opus', '/m/a.wma', '/m/a.aac'):
|
|
assert is_lossless_audio_path(path) is False
|
|
|
|
|
|
def test_m4a_without_probe_is_not_lossless():
|
|
# The safe default: with no codec probe, an .m4a can't be proven lossless, so
|
|
# an AAC file is never misclassified as lossless and converted/deleted.
|
|
assert is_lossless_audio_path('/m/a.m4a') is False
|
|
assert is_lossless_audio_path('/m/a.mp4') is False
|
|
|
|
|
|
def test_m4a_alac_is_lossless_via_probe():
|
|
assert is_lossless_audio_path('/m/a.m4a', probe_codec=lambda _p: 'alac') is True
|
|
assert is_lossless_audio_path('/m/a.mp4', probe_codec=lambda _p: 'ALAC') is True
|
|
|
|
|
|
def test_m4a_aac_is_not_lossless_via_probe():
|
|
assert is_lossless_audio_path('/m/a.m4a', probe_codec=lambda _p: 'mp4a.40.2') is False
|
|
|
|
|
|
def test_probe_exception_is_not_lossless():
|
|
def _boom(_p):
|
|
raise RuntimeError("probe failed")
|
|
assert is_lossless_audio_path('/m/a.m4a', probe_codec=_boom) is False
|
|
|
|
|
|
# ── LOSSLESS_CANDIDATE_EXTENSIONS (the SQL pre-filter set) ──
|
|
|
|
def test_candidate_extensions_cover_lossless_plus_ambiguous():
|
|
for e in ('.flac', '.wav', '.aiff', '.dsf', '.dff', '.alac', '.m4a', '.mp4'):
|
|
assert e in LOSSLESS_CANDIDATE_EXTENSIONS
|
|
# raw lossy extensions must NOT be candidates
|
|
for e in ('.mp3', '.aac', '.ogg', '.opus', '.wma'):
|
|
assert e not in LOSSLESS_CANDIDATE_EXTENSIONS
|
|
|
|
|
|
def test_sql_where_clause_matches_candidates_only():
|
|
where = _lossless_ext_where('t.file_path')
|
|
assert "LIKE '%.flac'" in where and "LIKE '%.dsf'" in where and "LIKE '%.m4a'" in where
|
|
assert "LIKE '%.mp3'" not in where and "LIKE '%.aac'" not in where
|
|
|
|
|
|
# ── lossy_output_would_overwrite_source (the safety invariant) ──
|
|
|
|
def test_overwrite_detected_when_paths_equal():
|
|
assert lossy_output_would_overwrite_source('/m/Album/01.m4a', '/m/Album/01.m4a') is True
|
|
|
|
|
|
def test_overwrite_detected_after_normalization():
|
|
assert lossy_output_would_overwrite_source('/m/Album/../Album/01.m4a', '/m/Album/01.m4a') is True
|
|
|
|
|
|
def test_no_overwrite_for_different_extension():
|
|
assert lossy_output_would_overwrite_source('/m/Album/01.flac', '/m/Album/01.mp3') is False
|
|
assert lossy_output_would_overwrite_source('/m/Album/01.m4a', '/m/Album/01.mp3') is False
|
|
|
|
|
|
def test_overwrite_guard_handles_empty():
|
|
assert lossy_output_would_overwrite_source('', '/x.mp3') is False
|
|
assert lossy_output_would_overwrite_source('/x.flac', '') is False
|
|
|
|
|
|
# ── anti-drift: the seam must agree with the quality tier model ──
|
|
|
|
def test_lossless_set_consistent_with_tier_model():
|
|
"""If a format is in LOSSLESS_FORMATS it must out-rank every lossy format in
|
|
tier_score — guards against the two lists drifting apart (the whole reason
|
|
this seam exists)."""
|
|
lossy = ('mp3', 'aac', 'ogg', 'opus', 'wma')
|
|
worst_lossless = min(AudioQuality(f, bitrate=11290, sample_rate=44100, bit_depth=16).tier_score()
|
|
for f in LOSSLESS_FORMATS)
|
|
best_lossy = max(AudioQuality(f, bitrate=320).tier_score() for f in lossy)
|
|
assert worst_lossless > best_lossy
|
|
|
|
|
|
# ── regression: the exact bug class this guards (overwrite the original) ──
|
|
|
|
def test_regression_m4a_alac_to_aac_would_overwrite_and_is_blocked():
|
|
"""An .m4a ALAC source converted with the AAC codec lands on the SAME .m4a
|
|
path. The guard must catch it so ffmpeg -y never destroys the original."""
|
|
src = '/library/Sade/Diamond Life/01. Smooth Operator.m4a' # ALAC
|
|
out = src # AAC target → .m4a
|
|
assert is_lossless_audio_path(src, probe_codec=lambda _p: 'alac') is True
|
|
assert lossy_output_would_overwrite_source(src, out) is True # → callers skip
|
|
|
|
|
|
# ── cross-language drift guard: the frontend lossless lists must match the backend ──
|
|
# (#941's root cause: a format added to the quality profile but not the lossy-copy
|
|
# side. The lists are physically separate by choice — runtime-fetching a yearly-
|
|
# changing set isn't worth the coupling — so a test makes the drift impossible to
|
|
# ship silently instead.)
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
_SETTINGS_JS = _ROOT / "webui" / "static" / "settings.js"
|
|
_INDEX_HTML = _ROOT / "webui" / "index.html"
|
|
|
|
|
|
def _js_array(text, name):
|
|
m = re.search(rf"{name}\s*=\s*\[([^\]]*)\]", text)
|
|
assert m, f"{name} not found"
|
|
return set(re.findall(r"'([^']+)'", m.group(1)))
|
|
|
|
|
|
def test_frontend_rt_lossless_formats_matches_backend():
|
|
js = _SETTINGS_JS.read_text(encoding="utf-8")
|
|
frontend = _js_array(js, "RT_LOSSLESS_FORMATS")
|
|
assert frontend == set(LOSSLESS_FORMATS), (
|
|
f"settings.js RT_LOSSLESS_FORMATS {frontend} != backend LOSSLESS_FORMATS "
|
|
f"{set(LOSSLESS_FORMATS)} — add the new format to BOTH"
|
|
)
|
|
|
|
|
|
def test_quality_profile_dropdown_offers_every_lossless_format():
|
|
html = _INDEX_HTML.read_text(encoding="utf-8")
|
|
m = re.search(r'<optgroup label="Lossless">(.*?)</optgroup>', html, re.DOTALL)
|
|
assert m, "Lossless optgroup not found in index.html"
|
|
# concrete per-format option values (skip the 'group:lossless' convenience entry)
|
|
option_values = {v for v in re.findall(r'value="([^"]+)"', m.group(1))
|
|
if not v.startswith("group:")}
|
|
assert set(LOSSLESS_FORMATS) <= option_values, (
|
|
f"index.html lossless dropdown {option_values} is missing "
|
|
f"{set(LOSSLESS_FORMATS) - option_values}"
|
|
)
|