Musiksammlung/tests/test_cli.py
dschlueter 1ca88b0d6d Rename cover files: frontcover.jpg → front.jpg, backcover.jpg → back.jpg
Shorter, cleaner filenames consistent with Jellyfin conventions.
Updated all references in source, tests, and documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:56:12 +01:00

365 lines
13 KiB
Python

"""Tests für die CLI-Commands (cli.py) via typer.testing.CliRunner."""
from __future__ import annotations
import json
import struct
from pathlib import Path
from unittest.mock import patch
from typer.testing import CliRunner
from musiksammlung.cli import app
from musiksammlung.models import Album, Disc, Track
runner = CliRunner()
# ---------------------------------------------------------------------------
# Hilfsfunktionen (identisch zu test_tagger.py)
# ---------------------------------------------------------------------------
def _make_flac(path: Path) -> Path:
data = b"fLaC"
data += b"\x80\x00\x00\x22"
data += struct.pack(">HH", 4096, 4096)
data += b"\x00\x00\x00\x00\x00\x00"
val = (44100 << 44) | (0 << 41) | (15 << 36) | 0
data += val.to_bytes(8, "big")
data += b"\x00" * 16
path.write_bytes(data)
return path
def _make_album_json(path: Path, *, n_tracks: int = 2, n_discs: int = 1) -> Album:
discs = [
Disc(
disc_number=d,
tracks=[
Track(track_number=t, title=f"Disc{d} Track{t}") for t in range(1, n_tracks + 1)
],
)
for d in range(1, n_discs + 1)
]
album = Album(artist="TestArtist", album="TestAlbum", year=2024, discs=discs)
path.write_text(album.model_dump_json(indent=2), encoding="utf-8")
return album
def _make_disc_files(album_dir: Path, album: Album) -> None:
"""Erstellt FLAC-Dateien im korrekten Namensschema."""
multi = len(album.discs) > 1
for disc in album.discs:
disc_dir = album_dir / f"CD{disc.disc_number}" if multi else album_dir
disc_dir.mkdir(parents=True, exist_ok=True)
for track in disc.tracks:
fname = f"{track.track_number:02d}_-_{track.title.replace(' ', '_')}_-_TestArtist.flac"
_make_flac(disc_dir / fname)
# ---------------------------------------------------------------------------
# apply
# ---------------------------------------------------------------------------
class TestApplyCommand:
def test_apply_inplace_renames_and_tags(self, tmp_path: Path) -> None:
_make_album_json(tmp_path / "album.json")
# Audiodateien mit track*-Namen (wie nach CDDB-Fehler)
_make_flac(tmp_path / "track01.flac")
_make_flac(tmp_path / "track02.flac")
result = runner.invoke(app, [
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
])
assert result.exit_code == 0, result.output
def test_apply_fails_on_disc_mismatch(self, tmp_path: Path) -> None:
_make_album_json(tmp_path / "album.json", n_tracks=3)
# Nur 2 Dateien, JSON hat 3 Tracks
_make_flac(tmp_path / "track01.flac")
_make_flac(tmp_path / "track02.flac")
result = runner.invoke(app, [
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
])
assert result.exit_code == 1
assert "Diskrepanz" in result.output or "Diskrepanz" in (result.stderr or "")
def test_apply_dry_run_makes_no_changes(self, tmp_path: Path) -> None:
_make_album_json(tmp_path / "album.json")
f1 = _make_flac(tmp_path / "track01.flac")
f2 = _make_flac(tmp_path / "track02.flac")
result = runner.invoke(app, [
"apply", str(tmp_path), str(tmp_path / "album.json"),
"--in-place", "--dry-run",
])
assert result.exit_code == 0
assert "DRY-RUN" in result.output
# Originaldateien noch vorhanden
assert f1.exists()
assert f2.exists()
def test_apply_missing_json_raises(self, tmp_path: Path) -> None:
result = runner.invoke(app, [
"apply", str(tmp_path), str(tmp_path / "nope.json"), "--in-place",
])
assert result.exit_code != 0
def test_apply_creates_playlist(self, tmp_path: Path) -> None:
album = _make_album_json(tmp_path / "album.json")
_make_disc_files(tmp_path, album)
result = runner.invoke(app, [
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
])
assert result.exit_code == 0, result.output
playlists = list(tmp_path.glob("*.m3u"))
assert len(playlists) == 1
def test_apply_multi_disc(self, tmp_path: Path) -> None:
album = _make_album_json(tmp_path / "album.json", n_tracks=2, n_discs=2)
_make_disc_files(tmp_path, album)
result = runner.invoke(app, [
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
])
assert result.exit_code == 0, result.output
# ---------------------------------------------------------------------------
# check
# ---------------------------------------------------------------------------
class TestCheckCommand:
def test_check_shows_cover_status(self, tmp_path: Path) -> None:
(tmp_path / "front.jpg").write_bytes(b"\xff\xd8\xff\xe0") # minimal JPEG magic
result = runner.invoke(app, ["check", str(tmp_path)])
assert result.exit_code == 0
assert "front.jpg" in result.output
def test_check_shows_missing_cover(self, tmp_path: Path) -> None:
result = runner.invoke(app, ["check", str(tmp_path)])
assert result.exit_code == 0
assert "fehlt" in result.output
def test_check_lists_audio_files(self, tmp_path: Path) -> None:
_make_flac(tmp_path / "01_-_Track_-_Artist.flac")
result = runner.invoke(app, ["check", str(tmp_path)])
assert result.exit_code == 0
assert "01_-_Track_-_Artist.flac" in result.output
def test_check_nonexistent_directory(self, tmp_path: Path) -> None:
result = runner.invoke(app, ["check", str(tmp_path / "nope")])
assert result.exit_code == 1
def test_check_no_audio_files_message(self, tmp_path: Path) -> None:
result = runner.invoke(app, ["check", str(tmp_path)])
assert result.exit_code == 0
assert "Keine Audiodateien" in result.output
# ---------------------------------------------------------------------------
# scan (mockt LLM-Aufruf)
# ---------------------------------------------------------------------------
class TestScanCommand:
def test_scan_from_text_creates_json(self, tmp_path: Path) -> None:
text_file = tmp_path / "tracklist.txt"
text_file.write_text("1. Allegro\n2. Andante", encoding="utf-8")
output = tmp_path / "album.json"
fake_album = Album(
artist="Karajan",
album="Beethoven",
year=1963,
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="Allegro")])],
)
with patch("musiksammlung.cli.parse_tracklist", return_value=fake_album):
result = runner.invoke(app, [
"scan", "--from-text", str(text_file), "--output", str(output),
])
assert result.exit_code == 0, result.output
assert output.exists()
data = json.loads(output.read_text())
assert data["artist"] == "Karajan"
def test_scan_missing_text_file(self, tmp_path: Path) -> None:
result = runner.invoke(app, [
"scan", "--from-text", str(tmp_path / "nope.txt"),
])
assert result.exit_code == 1
def test_scan_no_args_fails(self) -> None:
result = runner.invoke(app, ["scan"])
assert result.exit_code == 1
# --- --from-photo ---
def test_scan_from_photo_creates_json(self, tmp_path: Path) -> None:
"""Foto → EAN extrahiert → MusicBrainz-Lookup → JSON."""
img = tmp_path / "cover.jpg"
img.write_bytes(b"fake")
output = tmp_path / "album.json"
fake_album = Album(
artist="Beatles",
album="Abbey Road",
year=1969,
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="Come Together")])],
)
mbid = "some-mbid"
with (
patch("musiksammlung.cli.extract_barcode_from_image", return_value="4006408262121"),
patch("musiksammlung.cli.lookup_by_barcode", return_value=(fake_album, mbid)),
):
result = runner.invoke(app, [
"scan", "--from-photo", str(img), "--output", str(output),
])
assert result.exit_code == 0, result.output
assert output.exists()
data = json.loads(output.read_text())
assert data["artist"] == "Beatles"
assert "4006408262121" in result.output
def test_scan_from_photo_file_not_found(self, tmp_path: Path) -> None:
"""Foto existiert nicht → Exit 1."""
result = runner.invoke(app, [
"scan", "--from-photo", str(tmp_path / "nope.jpg"),
])
assert result.exit_code == 1
def test_scan_from_photo_no_barcode_recognized(self, tmp_path: Path) -> None:
"""Vision-LLM erkennt keinen Barcode → Exit 1 mit Fehlermeldung."""
img = tmp_path / "cover.jpg"
img.write_bytes(b"fake")
with patch("musiksammlung.cli.extract_barcode_from_image", return_value=None):
result = runner.invoke(app, [
"scan", "--from-photo", str(img),
])
assert result.exit_code == 1
assert "Kein EAN" in result.output or "Kein EAN" in (result.stderr or "")
def test_scan_from_photo_passes_model_and_url(self, tmp_path: Path) -> None:
"""--vision-model und --url werden an extract_barcode_from_image weitergegeben."""
img = tmp_path / "cover.jpg"
img.write_bytes(b"fake")
fake_album = Album(
artist="A", album="B", year=2000,
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="T")])],
)
with (
patch("musiksammlung.cli.extract_barcode_from_image", return_value="1234567890123")
as mock_extract,
patch("musiksammlung.cli.lookup_by_barcode", return_value=(fake_album, "mbid")),
):
runner.invoke(app, [
"scan", "--from-photo", str(img),
"--vision-model", "my-vlm",
"--url", "http://myhost:11434",
"--output", str(tmp_path / "out.json"),
])
mock_extract.assert_called_once_with(
img, model="my-vlm", base_url="http://myhost:11434"
)
# ---------------------------------------------------------------------------
# _rename_album_dir_inplace (via apply --in-place)
# ---------------------------------------------------------------------------
class TestRenameAlbumDir:
"""Tests für automatisches Umbenennen des Album-Verzeichnisses."""
def test_cd_subdir_renames_parent(self, tmp_path: Path) -> None:
"""input_dir = CD1 → Elternverzeichnis wird umbenannt."""
album_root = tmp_path / "Falscher_Name"
cd1 = album_root / "CD1"
cd1.mkdir(parents=True)
_make_album_json(album_root / "album.json", n_tracks=1)
_make_flac(cd1 / "track01.flac")
result = runner.invoke(app, [
"apply", str(cd1), str(album_root / "album.json"), "--in-place",
])
assert result.exit_code == 0, result.output
expected = tmp_path / "TestAlbum_2024"
assert expected.exists(), f"Verzeichnis nicht umbenannt; output: {result.output}"
assert not album_root.exists()
def test_album_root_as_input_prints_hint(self, tmp_path: Path) -> None:
"""input_dir = AlbumRoot → kein auto-rename, stattdessen Hinweis."""
album = _make_album_json(tmp_path / "album.json", n_tracks=2)
_make_disc_files(tmp_path, album)
result = runner.invoke(app, [
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
])
assert result.exit_code == 0, result.output
# Verzeichnis nicht umbenannt
assert tmp_path.exists()
# Aber Hinweis ausgegeben
assert "Tipp" in result.output or "mv" in result.output
def test_already_correct_name_no_rename(self, tmp_path: Path) -> None:
"""CD1 als input_dir, Elternverzeichnis heißt bereits korrekt → keine Ausgabe."""
album_root = tmp_path / "TestAlbum_2024"
cd1 = album_root / "CD1"
cd1.mkdir(parents=True)
_make_album_json(album_root / "album.json", n_tracks=1)
_make_flac(cd1 / "track01.flac")
result = runner.invoke(app, [
"apply", str(cd1), str(album_root / "album.json"), "--in-place",
])
assert result.exit_code == 0, result.output
assert album_root.exists()
assert "umbenannt" not in result.output
def test_target_exists_prints_warning(self, tmp_path: Path) -> None:
"""Zielverzeichnis existiert bereits → Warnung, kein Umbenennen."""
album_root = tmp_path / "Falscher_Name"
cd1 = album_root / "CD1"
cd1.mkdir(parents=True)
# Ziel existiert bereits
(tmp_path / "TestAlbum_2024").mkdir()
_make_album_json(album_root / "album.json", n_tracks=1)
_make_flac(cd1 / "track01.flac")
result = runner.invoke(app, [
"apply", str(cd1), str(album_root / "album.json"), "--in-place",
])
assert result.exit_code == 0, result.output
assert album_root.exists() # nicht umbenannt