From 775f274d022453c8cfaca129fdf1800df5623991 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Wed, 18 Feb 2026 00:00:44 +0100 Subject: [PATCH] Fix and expand tests: 63 tests passing, covers all core modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 - tests/__init__.py | 0 tests/test_models.py | 42 +++++++ tests/test_organizer.py | 226 +++++++++++++++++++++++++++++++++++++ tests/test_playlist.py | 98 ++++++++++++++++ tests/test_ripper.py | 234 +++++++++++++++++++++++++++++++++++++++ tests/test_vision_llm.py | 37 +++++++ 7 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_models.py create mode 100644 tests/test_organizer.py create mode 100644 tests/test_playlist.py create mode 100644 tests/test_ripper.py create mode 100644 tests/test_vision_llm.py diff --git a/.gitignore b/.gitignore index 4616724..17df95b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ dist/ idea/ .idea/ -tests/ testdata/ CLAUDE.md diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..c3c2634 --- /dev/null +++ b/tests/test_models.py @@ -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" diff --git a/tests/test_organizer.py b/tests/test_organizer.py new file mode 100644 index 0000000..7446bde --- /dev/null +++ b/tests/test_organizer.py @@ -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) diff --git a/tests/test_playlist.py b/tests/test_playlist.py new file mode 100644 index 0000000..a55c51d --- /dev/null +++ b/tests/test_playlist.py @@ -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 diff --git a/tests/test_ripper.py b/tests/test_ripper.py new file mode 100644 index 0000000..592f931 --- /dev/null +++ b/tests/test_ripper.py @@ -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"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() diff --git a/tests/test_vision_llm.py b/tests/test_vision_llm.py new file mode 100644 index 0000000..b0dd881 --- /dev/null +++ b/tests/test_vision_llm.py @@ -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 = 'Ich denke nach...\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.")