Fix and expand tests: 63 tests passing, covers all core modules

Remove tests/ from .gitignore (was accidentally excluded).

- test_ripper.py: rewrite for current API (_parse_cddb_lines,
  _extract_tracks, _rename_files, _clean_input); fix default quality
- test_organizer.py: update filename assertions (spaces→underscores);
  add TestSanitizeFilename, TestCheckDiscCounts, in-place mode
- test_playlist.py: fix dummy filenames to underscore scheme;
  add multi-disc, filename sanitization, EXTINF and fallback tests

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-02-18 00:00:44 +01:00
commit 775f274d02
7 changed files with 637 additions and 1 deletions

1
.gitignore vendored
View file

@ -12,7 +12,6 @@ dist/
idea/
.idea/
tests/
testdata/
CLAUDE.md

0
tests/__init__.py Normal file
View file

42
tests/test_models.py Normal file
View file

@ -0,0 +1,42 @@
"""Tests für die Datenmodelle."""
from musiksammlung.models import Album
def test_album_folder_name_with_year():
album = Album(artist="Test", album="Mein Album", year=1987, discs=[])
assert album.folder_name == "Mein Album (1987)"
def test_album_folder_name_without_year():
album = Album(artist="Test", album="Mein Album", year=None, discs=[])
assert album.folder_name == "Mein Album"
def test_sanitize_name():
album = Album(artist='Art:ist', album='Al/bum?', year=None, discs=[])
assert ":" not in album.artist
assert "/" not in album.album
assert "?" not in album.album
def test_album_from_json():
data = {
"artist": "Die Toten Hosen",
"album": "Opium fürs Volk",
"year": 1996,
"discs": [
{
"disc_number": 1,
"tracks": [
{"track_number": 1, "title": "Bonnie & Clyde"},
{"track_number": 2, "title": "Zehn kleine Jägermeister"},
],
}
],
}
album = Album.model_validate(data)
assert album.artist == "Die Toten Hosen"
assert len(album.discs) == 1
assert len(album.discs[0].tracks) == 2
assert album.discs[0].tracks[1].title == "Zehn kleine Jägermeister"

226
tests/test_organizer.py Normal file
View file

@ -0,0 +1,226 @@
"""Tests für den Organizer."""
from pathlib import Path
import pytest
from musiksammlung.models import Album, Disc, Track
from musiksammlung.organizer import (
_sanitize_filename,
build_mapping,
check_disc_counts,
discover_audio_files,
)
class TestSanitizeFilename:
"""Tests für _sanitize_filename."""
def test_replaces_spaces(self) -> None:
assert _sanitize_filename("Hello World") == "Hello_World"
def test_replaces_punctuation(self) -> None:
assert _sanitize_filename("Song (Live)") == "Song_Live"
assert _sanitize_filename("Artist: Name") == "Artist_Name"
def test_collapses_multiple_underscores(self) -> None:
assert _sanitize_filename("A B") == "A_B"
assert _sanitize_filename("A--B") == "A_B"
def test_strips_leading_trailing_underscores(self) -> None:
assert _sanitize_filename("_Test_") == "Test"
def test_keeps_umlauts(self) -> None:
assert _sanitize_filename("Für Elise") == "Für_Elise"
assert _sanitize_filename("Über den Wolken") == "Über_den_Wolken"
def test_keeps_digits(self) -> None:
assert _sanitize_filename("Track 01") == "Track_01"
class TestDiscoverAudioFiles:
"""Tests für discover_audio_files."""
def test_finds_and_sorts_numerically(self, tmp_path: Path) -> None:
(tmp_path / "Track_03.flac").touch()
(tmp_path / "Track_01.flac").touch()
(tmp_path / "Track_02.flac").touch()
(tmp_path / "cover.jpg").touch()
files = discover_audio_files(tmp_path)
assert len(files) == 3
assert files[0].name == "Track_01.flac"
assert files[2].name == "Track_03.flac"
def test_ignores_non_audio_files(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
(tmp_path / "album.json").touch()
(tmp_path / "playlist.m3u").touch()
files = discover_audio_files(tmp_path)
assert len(files) == 1
def test_empty_directory(self, tmp_path: Path) -> None:
assert discover_audio_files(tmp_path) == []
class TestCheckDiscCounts:
"""Tests für check_disc_counts."""
def _album_with_tracks(self, n: int) -> Album:
return Album(
artist="Test",
album="Album",
discs=[
Disc(
disc_number=1,
tracks=[Track(track_number=i, title=f"Track {i}") for i in range(1, n + 1)],
)
],
)
def test_matching_counts_is_ok(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
(tmp_path / "track02.flac").touch()
album = self._album_with_tracks(2)
results = check_disc_counts(album, tmp_path)
assert len(results) == 1
assert results[0].ok is True
assert results[0].surplus_files == 0
assert results[0].surplus_json == 0
def test_surplus_files(self, tmp_path: Path) -> None:
for i in range(1, 4):
(tmp_path / f"track0{i}.flac").touch()
album = self._album_with_tracks(2) # JSON has 2, disk has 3
results = check_disc_counts(album, tmp_path)
assert results[0].ok is False
assert results[0].surplus_files == 1
assert results[0].surplus_json == 0
def test_surplus_json(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
album = self._album_with_tracks(3) # JSON has 3, disk has 1
results = check_disc_counts(album, tmp_path)
assert results[0].ok is False
assert results[0].surplus_json == 2
assert results[0].surplus_files == 0
def test_multi_disc(self, tmp_path: Path) -> None:
cd1 = tmp_path / "CD1"
cd2 = tmp_path / "CD2"
cd1.mkdir()
cd2.mkdir()
(cd1 / "track01.flac").touch()
(cd2 / "track01.flac").touch()
(cd2 / "track02.flac").touch()
album = Album(
artist="A",
album="B",
discs=[
Disc(disc_number=1, tracks=[Track(track_number=1, title="T1")]),
Disc(disc_number=2, tracks=[Track(track_number=1, title="T2"), Track(track_number=2, title="T3")]),
],
)
results = check_disc_counts(album, tmp_path)
assert all(r.ok for r in results)
def test_missing_disc_directory(self, tmp_path: Path) -> None:
album = Album(
artist="A",
album="B",
discs=[
Disc(disc_number=1, tracks=[Track(track_number=1, title="T")]),
Disc(disc_number=2, tracks=[Track(track_number=1, title="T")]),
],
)
# Neither CD1 nor CD2 exists
results = check_disc_counts(album, tmp_path)
assert all(r.audio_file_count == 0 for r in results)
assert all(not r.ok for r in results)
class TestBuildMapping:
"""Tests für build_mapping."""
def test_single_disc(self, tmp_path: Path) -> None:
(tmp_path / "Track_01.flac").touch()
(tmp_path / "Track_02.flac").touch()
album = Album(
artist="TestArtist",
album="TestAlbum",
year=2000,
discs=[
Disc(
disc_number=1,
tracks=[
Track(track_number=1, title="Erster Song"),
Track(track_number=2, title="Zweiter Song"),
],
)
],
)
mapping = build_mapping(album, tmp_path, tmp_path / "output")
targets = list(mapping.values())
assert targets[0].name == "01_Erster_Song.flac"
assert targets[1].name == "02_Zweiter_Song.flac"
# Single-Disc: kein CD1-Unterordner
assert "CD1" not in str(targets[0])
def test_multi_disc(self, tmp_path: Path) -> None:
cd1 = tmp_path / "CD1"
cd2 = tmp_path / "CD2"
cd1.mkdir()
cd2.mkdir()
(cd1 / "Track_01.flac").touch()
(cd2 / "Track_01.flac").touch()
album = Album(
artist="Artist",
album="Box Set",
year=1999,
discs=[
Disc(disc_number=1, tracks=[Track(track_number=1, title="Song A")]),
Disc(disc_number=2, tracks=[Track(track_number=1, title="Song B")]),
],
)
mapping = build_mapping(album, tmp_path, tmp_path / "output")
targets = list(mapping.values())
assert "CD1" in str(targets[0])
assert "CD2" in str(targets[1])
def test_in_place_mode(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
album = Album(
artist="Artist",
album="Album",
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="Song")])],
)
mapping = build_mapping(album, tmp_path, in_place=True)
src, dst = next(iter(mapping.items()))
# Source and target are in the same directory
assert src.parent == tmp_path
assert dst.parent == tmp_path
assert dst.name == "01_Song.flac"
def test_output_dir_structure(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
album = Album(
artist="Karl Böhm",
album="Beethoven Sinfonien",
year=1970,
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="Allegro")])],
)
output = tmp_path / "Musik"
mapping = build_mapping(album, tmp_path, output)
dst = next(iter(mapping.values()))
# Artist and album dirs are sanitized
assert "Karl_Böhm" in str(dst)
assert "Beethoven_Sinfonien" in str(dst)

98
tests/test_playlist.py Normal file
View file

@ -0,0 +1,98 @@
"""Tests für die Playlist-Generierung."""
from pathlib import Path
from musiksammlung.models import Album, Disc, Track
from musiksammlung.playlist import generate_playlist
def _make_album(n_tracks: int = 2, n_discs: int = 1) -> Album:
discs = [
Disc(
disc_number=d,
tracks=[Track(track_number=i, title=f"Disc{d} Song {i}") for i in range(1, n_tracks + 1)],
)
for d in range(1, n_discs + 1)
]
return Album(artist="Artist", album="TestAlbum", year=2000, discs=discs)
def test_generate_playlist_single_disc(tmp_path: Path):
"""M3U für Single-CD: keine CD-Prefix, korrekte Dateinamen."""
album = _make_album(n_tracks=2, n_discs=1)
# Dummy-Audiodateien im neuen Unterstrich-Schema
(tmp_path / "01_Disc1_Song_1.flac").touch()
(tmp_path / "02_Disc1_Song_2.flac").touch()
playlist_path = generate_playlist(album, tmp_path)
assert playlist_path.exists()
content = playlist_path.read_text()
assert "#EXTM3U" in content
assert "01_Disc1_Song_1.flac" in content
assert "02_Disc1_Song_2.flac" in content
# Kein CD-Prefix bei Single-Disc
assert "CD1/" not in content
def test_generate_playlist_multi_disc(tmp_path: Path):
"""M3U für Multi-CD: CD-Prefix in Pfaden."""
album = _make_album(n_tracks=2, n_discs=2)
cd1 = tmp_path / "CD1"
cd2 = tmp_path / "CD2"
cd1.mkdir()
cd2.mkdir()
(cd1 / "01_Disc1_Song_1.flac").touch()
(cd1 / "02_Disc1_Song_2.flac").touch()
(cd2 / "01_Disc2_Song_1.flac").touch()
(cd2 / "02_Disc2_Song_2.flac").touch()
playlist_path = generate_playlist(album, tmp_path)
content = playlist_path.read_text()
assert "CD1/01_Disc1_Song_1.flac" in content
assert "CD2/01_Disc2_Song_1.flac" in content
def test_generate_playlist_filename(tmp_path: Path):
"""Playlist-Dateiname ist sanitierter Albumname."""
album = Album(
artist="A",
album="Mein Album",
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="Song")])],
)
playlist_path = generate_playlist(album, tmp_path)
assert playlist_path.name == "Mein_Album.m3u"
def test_generate_playlist_extinf_contains_title(tmp_path: Path):
"""#EXTINF-Einträge enthalten den originalen Titel."""
album = Album(
artist="A",
album="X",
discs=[
Disc(
disc_number=1,
tracks=[Track(track_number=1, title="Für Elise")],
)
],
)
(tmp_path / "01_Für_Elise.flac").touch()
playlist_path = generate_playlist(album, tmp_path)
content = playlist_path.read_text()
assert "#EXTINF:0,Für Elise" in content
def test_generate_playlist_fallback_if_file_missing(tmp_path: Path):
"""Wenn Audiodatei fehlt, wird Fallback-Name erzeugt (kein Absturz)."""
album = Album(
artist="A",
album="X",
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="Ghost")])],
)
# Keine Audiodatei anlegen
playlist_path = generate_playlist(album, tmp_path)
content = playlist_path.read_text()
assert "01_Ghost.flac" in content

234
tests/test_ripper.py Normal file
View file

@ -0,0 +1,234 @@
"""Tests für den CD-Ripper."""
from pathlib import Path
import pytest
from musiksammlung.config import AudioFormat
from musiksammlung.ripper import (
RipperConfig,
TrackInfo,
_clean_input,
_extract_tracks,
_parse_cddb_lines,
_rename_files,
_sanitize_name,
)
class TestSanitizeName:
"""Tests für _sanitize_name."""
def test_replace_spaces(self) -> None:
assert _sanitize_name("Hello World") == "Hello_World"
assert _sanitize_name("Test Track Title") == "Test_Track_Title"
def test_remove_invalid_chars(self) -> None:
assert _sanitize_name("Test/Track:Name") == "TestTrackName"
assert _sanitize_name('Test<Track>"Name') == "TestTrackName"
assert _sanitize_name("Test|Track?Name*") == "TestTrackName"
def test_keep_umlauts(self) -> None:
assert _sanitize_name("Grüße aus Österreich") == "Grüße_aus_Österreich"
assert _sanitize_name("Schöne Sänger") == "Schöne_Sänger"
def test_strip_underscores(self) -> None:
assert _sanitize_name("_Test_") == "Test"
assert _sanitize_name(" _Test_ ") == "Test"
class TestCleanInput:
"""Tests für _clean_input."""
def test_strips_whitespace(self) -> None:
assert _clean_input(" hello ") == "hello"
def test_strips_quotes(self) -> None:
assert _clean_input('"Album Name"') == "Album Name"
assert _clean_input("'Album Name'") == "Album Name"
def test_removes_ansi_escape(self) -> None:
# ESC-based sequences (e.g. \x1b[A from arrow keys) are removed
assert _clean_input("\x1b[A word") == "word"
assert _clean_input("\x1b[1;5D text") == "text"
def test_empty_string(self) -> None:
assert _clean_input("") == ""
def test_plain_input_unchanged(self) -> None:
assert _clean_input("Beethoven Sinfonien") == "Beethoven Sinfonien"
class TestParseCddbLines:
"""Tests für _parse_cddb_lines."""
def test_parse_single_track(self) -> None:
lines = ["1: Artist - Title"]
tracks = _parse_cddb_lines(lines)
assert len(tracks) == 1
assert tracks[0].track_number == 1
assert tracks[0].artist == "Artist"
assert tracks[0].title == "Title"
def test_parse_multiple_tracks(self) -> None:
lines = [
"1: Artist One - Title One",
"2: Artist Two - Title Two",
"3: Artist Three - Title Three",
]
tracks = _parse_cddb_lines(lines)
assert len(tracks) == 3
assert tracks[2].track_number == 3
assert tracks[2].artist == "Artist Three"
assert tracks[2].title == "Title Three"
def test_parse_with_spaces_in_title(self) -> None:
lines = ["1: Wolfgang Anheisser - Wer recht in Freuden wandern will"]
tracks = _parse_cddb_lines(lines)
assert tracks[0].artist == "Wolfgang Anheisser"
assert tracks[0].title == "Wer recht in Freuden wandern will"
def test_ignores_non_matching_lines(self) -> None:
lines = [
"Retrieving CDDB info ...",
"1: Artist - Title",
"Tagging track 1 of 1",
]
tracks = _parse_cddb_lines(lines)
assert len(tracks) == 1
def test_empty_input(self) -> None:
assert _parse_cddb_lines([]) == []
class TestRipperConfig:
"""Tests für RipperConfig."""
def test_default_config(self) -> None:
config = RipperConfig()
assert config.device == "/dev/cdrom"
assert config.audio_format == AudioFormat.FLAC
assert config.quality == "high"
assert config.parallel_jobs == 1
assert config.use_pipes is False
assert config.use_cddb is True
def test_custom_config(self) -> None:
config = RipperConfig(
device="/dev/sr0",
audio_format=AudioFormat.MP3,
quality="low",
parallel_jobs=4,
use_pipes=True,
use_cddb=False,
)
assert config.device == "/dev/sr0"
assert config.audio_format == AudioFormat.MP3
assert config.quality == "low"
assert config.parallel_jobs == 4
assert config.use_pipes is True
assert config.use_cddb is False
class TestAudioFormat:
"""Tests für AudioFormat-Methoden."""
def test_abcde_format(self) -> None:
assert AudioFormat.FLAC.get_abcde_format() == "flac"
assert AudioFormat.MP3.get_abcde_format() == "mp3"
assert AudioFormat.OPUS.get_abcde_format() == "opus"
assert AudioFormat.AAC.get_abcde_format() == "m4a"
assert AudioFormat.WAV.get_abcde_format() == "wav"
def test_extension(self) -> None:
assert AudioFormat.FLAC.extension == ".flac"
assert AudioFormat.MP3.extension == ".mp3"
assert AudioFormat.OPUS.extension == ".opus"
assert AudioFormat.AAC.extension == ".aac"
assert AudioFormat.WAV.extension == ".wav"
def test_encoder_options_flac(self) -> None:
assert AudioFormat.FLAC.get_encoder_options("low") == ""
assert AudioFormat.FLAC.get_encoder_options("medium") == ""
assert AudioFormat.FLAC.get_encoder_options("high") == "-8"
def test_encoder_options_mp3(self) -> None:
assert AudioFormat.MP3.get_encoder_options("low") == "-V 7"
assert AudioFormat.MP3.get_encoder_options("medium") == "-V 5"
assert AudioFormat.MP3.get_encoder_options("high") == "-V 0"
def test_encoder_options_opus(self) -> None:
assert AudioFormat.OPUS.get_encoder_options("low") == "-b 96"
assert AudioFormat.OPUS.get_encoder_options("medium") == "-b 128"
assert AudioFormat.OPUS.get_encoder_options("high") == "-b 192"
def test_encoder_options_aac(self) -> None:
assert AudioFormat.AAC.get_encoder_options("low") == "-q:a 2"
assert AudioFormat.AAC.get_encoder_options("medium") == "-q:a 3"
assert AudioFormat.AAC.get_encoder_options("high") == "-q:a 4"
def test_encoder_options_wav(self) -> None:
assert AudioFormat.WAV.get_encoder_options("low") == ""
assert AudioFormat.WAV.get_encoder_options("high") == ""
class TestExtractTracks:
"""Tests für _extract_tracks."""
def test_moves_files_from_subdir(self, tmp_path: Path) -> None:
subdir = tmp_path / "abcde.XXXX"
subdir.mkdir()
(subdir / "track01.flac").touch()
(subdir / "track02.flac").touch()
result = _extract_tracks(tmp_path, AudioFormat.FLAC)
assert len(result) == 2
assert (tmp_path / "track01.flac").exists()
assert (tmp_path / "track02.flac").exists()
# Files no longer in subdir
assert not (subdir / "track01.flac").exists()
def test_leaves_already_flat_files(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
result = _extract_tracks(tmp_path, AudioFormat.FLAC)
assert len(result) == 1
def test_ignores_wrong_format(self, tmp_path: Path) -> None:
(tmp_path / "track01.mp3").touch()
result = _extract_tracks(tmp_path, AudioFormat.FLAC)
assert result == []
def test_empty_directory(self, tmp_path: Path) -> None:
result = _extract_tracks(tmp_path, AudioFormat.FLAC)
assert result == []
class TestRenameFiles:
"""Tests für _rename_files."""
def test_renames_with_cddb_data(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
(tmp_path / "track02.flac").touch()
tracks = [
TrackInfo(1, "Karajan", "Allegro con brio"),
TrackInfo(2, "Karajan", "Andante con moto"),
]
_rename_files(tmp_path, tracks, AudioFormat.FLAC)
assert (tmp_path / "01_-_Allegro_con_brio_-_Karajan.flac").exists()
assert (tmp_path / "02_-_Andante_con_moto_-_Karajan.flac").exists()
def test_fallback_without_cddb(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
_rename_files(tmp_path, [], AudioFormat.FLAC)
# No CDDB data → fallback to "01.flac"
assert (tmp_path / "01.flac").exists()
def test_sanitizes_title_in_filename(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
# _sanitize_name: spaces → _, then chars like :/\ are removed (not replaced)
tracks = [TrackInfo(1, "Art ist", "My Title")]
_rename_files(tmp_path, tracks, AudioFormat.FLAC)
assert (tmp_path / "01_-_My_Title_-_Art_ist.flac").exists()

37
tests/test_vision_llm.py Normal file
View file

@ -0,0 +1,37 @@
"""Tests für die Vision-LLM JSON-Extraktion."""
import pytest
from musiksammlung.vision_llm import _extract_json
def test_extract_pure_json():
text = '{"artist": "Test", "album": "Album"}'
assert '"Test"' in _extract_json(text)
def test_extract_json_from_markdown_block():
text = 'Hier ist das Ergebnis:\n```json\n{"artist": "Test"}\n```\nFertig.'
assert '"Test"' in _extract_json(text)
def test_extract_json_with_thinking_tags():
text = '<think>Ich denke nach...</think>\n{"artist": "Test", "album": "X"}'
result = _extract_json(text)
assert '"Test"' in result
def test_extract_json_with_surrounding_text():
text = 'Das JSON:\n{"artist": "A", "album": "B"}\nEnde.'
result = _extract_json(text)
assert '"A"' in result
def test_extract_json_empty_raises():
with pytest.raises(ValueError, match="Leere Antwort"):
_extract_json("")
def test_extract_json_no_json_raises():
with pytest.raises(ValueError, match="Kein JSON"):
_extract_json("Hier ist kein JSON, nur Text.")