Musiksammlung/tests/test_cli.py
dschlueter 7554cade50 feat: auto-rename album directory after in-place apply
After all file operations (rename, tag, cover, playlist), apply now
renames the album root directory to match album.json metadata:

- input_dir = CD1/CD2 etc.: parent directory is renamed automatically
  e.g. Kärntner_Doppelsextett/ → Du_Berührst_Mi_20_Jahre_Kärntner_Doppelsextett/
- input_dir = album root: a hint with the mv command is printed instead
  (avoids renaming an actively used path)
- Existing directory with target name: warning, no rename

Also: _sanitize_filename() in organizer.py made public (sanitize_filename),
used consistently across organizer, playlist and cli modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:49:17 +01:00

289 lines
10 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 / "frontcover.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 "frontcover.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
# ---------------------------------------------------------------------------
# _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