Musiksammlung/tests/test_cddb.py
dschlueter 32c84b9edb Add phone-based EAN scanning, scanner server for cover upload, Vision-LLM integration
New features:
- EAN/Barcode can now be entered by typing or by photographing the CD sleeve;
  Vision-LLM (extract_barcode_from_image) reads the barcode from the photo
- Scanner server (port 8765) starts at the beginning of every album loop,
  serving both EAN barcode scanning and back cover upload via QR code
- Vision-LLM analyses back cover in background thread while ripping;
  priority: Vision-LLM > MusicBrainz > CDDB
- _find_abcde_mbid reads MBID from abcde temp dirs for CAA cover download
  even when the CD barcode is not linked in MusicBrainz
- Concrete copy-paste apply commands shown after each album in 'Next steps'
- _sanitize_name: whitelist approach (removes brackets and punctuation)
- qrcode added as dependency for terminal QR code display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:05:59 +01:00

168 lines
5.5 KiB
Python

"""Tests für cddb.py — CddbResult, DYEAR/DGENRE-Parsing."""
from unittest.mock import MagicMock, patch
from musiksammlung.cddb import CddbResult, _read_gnudb, lookup_by_discid
def _make_xmcd_response(
dtitle: str = "Artist / Album Title",
dyear: str = "",
dgenre: str = "",
ttitles: dict[int, str] | None = None,
) -> str:
"""Baut eine GnuDB-xmcd-Antwort zusammen."""
lines = ["210 OK"]
lines.append(f"DTITLE={dtitle}")
if dyear:
lines.append(f"DYEAR={dyear}")
if dgenre:
lines.append(f"DGENRE={dgenre}")
if ttitles is None:
ttitles = {0: "Track One", 1: "Track Two"}
for idx, title in sorted(ttitles.items()):
lines.append(f"TTITLE{idx}={title}")
lines.append(".")
return "\n".join(lines)
class TestReadGnudbCddbResult:
"""Tests für _read_gnudb mit CddbResult-Rückgabe."""
def _call(self, xmcd_text: str) -> CddbResult | None:
"""Ruft _read_gnudb mit gemockter HTTP-Antwort auf."""
mock_response = MagicMock()
mock_response.text = xmcd_text
mock_response.raise_for_status = MagicMock()
with patch("musiksammlung.cddb.httpx.get", return_value=mock_response):
with patch("musiksammlung.cddb.time.sleep"):
return _read_gnudb("rock", "ab0c1d0e")
def test_basic_result_fields(self) -> None:
"""CddbResult enthält Artist, Album, Tracks."""
text = _make_xmcd_response(dtitle="Beatles / Abbey Road")
result = self._call(text)
assert result is not None
assert isinstance(result, CddbResult)
assert result.artist == "Beatles"
assert result.album == "Abbey Road"
assert len(result.tracks) == 2
assert result.tracks[0].track_number == 1
assert result.tracks[0].title == "Track One"
def test_dyear_parsed(self) -> None:
"""DYEAR wird als int geparst."""
text = _make_xmcd_response(dyear="1969")
result = self._call(text)
assert result is not None
assert result.year == 1969
def test_dyear_empty(self) -> None:
"""Kein DYEAR → year=None."""
text = _make_xmcd_response(dyear="")
result = self._call(text)
assert result is not None
assert result.year is None
def test_dyear_invalid(self) -> None:
"""Ungültiges DYEAR → year=None."""
text = _make_xmcd_response(dyear="unknown")
result = self._call(text)
assert result is not None
assert result.year is None
def test_dgenre_parsed(self) -> None:
"""DGENRE wird übernommen."""
text = _make_xmcd_response(dgenre="Classical")
result = self._call(text)
assert result is not None
assert result.genre == "Classical"
def test_dgenre_empty(self) -> None:
"""Kein DGENRE → genre=''."""
text = _make_xmcd_response()
result = self._call(text)
assert result is not None
assert result.genre == ""
def test_dyear_and_dgenre_together(self) -> None:
"""Beide Felder gleichzeitig."""
text = _make_xmcd_response(
dtitle="Karajan / Beethoven Sinfonien",
dyear="1985",
dgenre="Classical",
)
result = self._call(text)
assert result is not None
assert result.artist == "Karajan"
assert result.album == "Beethoven Sinfonien"
assert result.year == 1985
assert result.genre == "Classical"
def test_no_ttitles_returns_none(self) -> None:
"""Keine TTITLEs → None."""
text = _make_xmcd_response(ttitles={})
result = self._call(text)
assert result is None
def test_dtitle_without_slash(self) -> None:
"""DTITLE ohne ' / ' → artist leer, album = gesamter DTITLE."""
text = _make_xmcd_response(dtitle="Just An Album Name")
result = self._call(text)
assert result is not None
assert result.artist == ""
assert result.album == "Just An Album Name"
def test_bad_status_code_returns_none(self) -> None:
"""Unerwarteter Statuscode → None."""
mock_response = MagicMock()
mock_response.text = "401 permission denied\n"
mock_response.raise_for_status = MagicMock()
with patch("musiksammlung.cddb.httpx.get", return_value=mock_response):
with patch("musiksammlung.cddb.time.sleep"):
result = _read_gnudb("rock", "ab0c1d0e")
assert result is None
class TestLookupByDiscidCddbResult:
"""Tests dass lookup_by_discid CddbResult zurückgibt."""
def test_returns_cddb_result(self) -> None:
"""Erfolgreicher Lookup liefert CddbResult."""
expected = CddbResult(
tracks=[],
artist="Test",
album="Album",
year=2000,
genre="Pop",
)
with (
patch("musiksammlung.cddb._query_gnudb", return_value=("rock", "ab0c1d0e")),
patch("musiksammlung.cddb._read_gnudb", return_value=expected),
):
result = lookup_by_discid("ab0c1d0e 2 150 1000 200")
assert result is expected
def test_returns_none_on_no_match(self) -> None:
"""Kein Treffer → None."""
with (
patch("musiksammlung.cddb._query_gnudb", return_value=None),
patch("musiksammlung.cddb.time.sleep"),
patch("musiksammlung.cddb.random.uniform", return_value=0.01),
):
result = lookup_by_discid("ab0c1d0e 2 150 1000 200", retries=0)
assert result is None