#!/usr/bin/env python3 """test_suite_enricher.py — Unit- und Integrationstests für music_enricher.""" from __future__ import annotations import sys import tempfile import traceback from pathlib import Path from typing import Callable sys.path.insert(0, str(Path(__file__).parent)) from models import AlbumScan, TrackHints, AlbumHints RESULTS: list[dict] = [] def record(test_id: str, passed: bool, detail: str = "") -> None: RESULTS.append({"id": test_id, "status": "PASS" if passed else "FAIL", "detail": detail}) def run_case(test_id: str, fn: Callable[[], str]) -> None: try: detail = fn() record(test_id, True, detail) except Exception: record(test_id, False, traceback.format_exc()[:300]) # --------------------------------------------------------------------------- # hint_extractor Tests # --------------------------------------------------------------------------- def test_parse_dirname_artist_album() -> str: from hint_extractor import _parse_dirname artist, album, year = _parse_dirname("Pink_Floyd_-_The_Wall") assert artist and "Pink" in artist, f"artist: {artist}" assert album and "Wall" in album, f"album: {album}" return f"artist={artist!r}, album={album!r}" def test_parse_dirname_with_year() -> str: from hint_extractor import _parse_dirname artist, album, year = _parse_dirname("Abba_-_Greatest_Hits_1992") assert year == "1992", f"year: {year}" return f"year={year}" def test_parse_dirname_album_only() -> str: from hint_extractor import _parse_dirname artist, album, year = _parse_dirname("Beethoven_Complete_Edition") assert album is not None, "album should not be None" return f"album={album!r}" def test_parse_filename_track_artist_title() -> str: from hint_extractor import _parse_filename r = _parse_filename("07 - ABBA - Dancing Queen") assert r.get("track") == "07", f"track: {r}" assert "ABBA" in r.get("artist", ""), f"artist: {r}" assert "Dancing" in r.get("title", ""), f"title: {r}" return str(r) def test_parse_filename_disc_track_title() -> str: from hint_extractor import _parse_filename r = _parse_filename("2-07 - Bach - Toccata") assert r.get("disc") == "2", f"disc: {r}" assert r.get("track") == "07", f"track: {r}" return str(r) def test_parse_filename_track_title() -> str: from hint_extractor import _parse_filename r = _parse_filename("01 - Dancing Queen") assert r.get("track") == "01", f"track: {r}" assert "Dancing" in r.get("title", ""), f"title: {r}" return str(r) def test_parse_filename_artist_title() -> str: from hint_extractor import _parse_filename r = _parse_filename("Miles Davis - So What") assert "Miles" in r.get("artist", ""), f"artist: {r}" assert "What" in r.get("title", ""), f"title: {r}" return str(r) def test_parse_tracklist_numbered() -> str: from hint_extractor import _parse_tracklist text = "1. Dancing Queen\n2. Waterloo\n3. Fernando" tracks = _parse_tracklist(text) assert len(tracks) == 3, f"count: {len(tracks)}" assert tracks[0]["title"] == "Dancing Queen", f"title: {tracks[0]}" return f"{len(tracks)} tracks parsed" def test_parse_tracklist_with_duration() -> str: from hint_extractor import _parse_tracklist text = "1-1 Toccata And Fugue 9:17\n1-2 Heartbeat 2:19\n2-1 Finale 5:00" tracks = _parse_tracklist(text) assert len(tracks) >= 2, f"count: {len(tracks)}" assert tracks[0]["disc"] == "1", f"disc: {tracks[0]}" return f"{len(tracks)} tracks parsed" def test_parse_tracklist_with_disc_sections() -> str: from hint_extractor import _parse_tracklist text = "CD 1\n1. Track A\n2. Track B\nCD 2\n1. Track C" tracks = _parse_tracklist(text) disc2 = [t for t in tracks if t.get("disc") == "2"] assert len(disc2) >= 1, f"disc2: {disc2}" return f"{len(tracks)} total, {len(disc2)} on disc 2" # --------------------------------------------------------------------------- # Scanner Tests # --------------------------------------------------------------------------- def test_scanner_classifies_files() -> str: from scanner import scan_album with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "TestAlbum" root.mkdir() (root / "01 - Song.mp3").write_bytes(b"\x00" * 100) (root / "02 - Song.flac").write_bytes(b"\x00" * 100) (root / "front.jpg").write_bytes(b"\xff\xd8" + b"\x00" * 100) (root / "tracklist.txt").write_text("1. Track One\n2. Track Two") (root / "notes.pdf").write_bytes(b"\x00" * 50) scan = scan_album(root) assert len(scan.audio_files) == 2, f"audio: {scan.audio_files}" assert len(scan.image_files) == 1, f"images: {scan.image_files}" assert len(scan.tracklist_files) == 1, f"tracklists: {scan.tracklist_files}" return "scan OK: 2 audio, 1 image, 1 tracklist" def test_scanner_ignores_hidden() -> str: from scanner import scan_album with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "Album" root.mkdir() (root / "song.mp3").write_bytes(b"\x00" * 100) (root / ".hidden.mp3").write_bytes(b"\x00" * 100) (root / "_trash.mp3").write_bytes(b"\x00" * 100) scan = scan_album(root) assert len(scan.audio_files) == 1, f"should ignore hidden: {scan.audio_files}" return "hidden files correctly ignored" # --------------------------------------------------------------------------- # extract_hints integration # --------------------------------------------------------------------------- def test_extract_hints_from_scan() -> str: from scanner import scan_album from hint_extractor import extract_hints with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "ABBA_-_Greatest_Hits" root.mkdir() (root / "01 - ABBA - Dancing Queen.mp3").write_bytes(b"\x00" * 1024) (root / "02 - ABBA - Waterloo.mp3").write_bytes(b"\x00" * 1024) (root / "tracklist.txt").write_text("1. Dancing Queen\n2. Waterloo\n") scan = scan_album(root) hints = extract_hints(scan) assert hints.dir_album is not None, "album hint missing" assert len(hints.tracks) == 2, f"tracks: {len(hints.tracks)}" assert hints.tracklist_text is not None, "tracklist not read" return f"hints OK: album={hints.dir_album!r}, {len(hints.tracks)} tracks" def test_extract_hints_multi_disc() -> str: from scanner import scan_album from hint_extractor import extract_hints with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "Bach_Complete" (root / "CD1").mkdir(parents=True) (root / "CD2").mkdir() (root / "CD1" / "01 - Toccata.mp3").write_bytes(b"\x00" * 1024) (root / "CD2" / "01 - Fugue.mp3").write_bytes(b"\x00" * 1024) scan = scan_album(root) hints = extract_hints(scan) disc_nums = {t.disc_number for t in hints.tracks if t.disc_number} assert 1 in disc_nums, f"disc 1 missing: {disc_nums}" assert 2 in disc_nums, f"disc 2 missing: {disc_nums}" return f"disc numbers detected: {disc_nums}" # --------------------------------------------------------------------------- # Vertical tracklist parser Tests # --------------------------------------------------------------------------- def test_vertical_tracklist_basic() -> str: from hint_extractor import _normalize_vertical_tracklist text = "1\nKatka dovádí\n3:22\n2\nZáludná\n2:15\n3\nPolka pro trubku\n4:01" result = _normalize_vertical_tracklist(text) assert result is not None, "should recognize vertical format" assert "1. Katka" in result, f"got: {result!r}" assert "2. Záludná" in result, f"got: {result!r}" return f"normalized: {result[:60]!r}" def test_vertical_tracklist_without_duration() -> str: from hint_extractor import _normalize_vertical_tracklist text = "1\nFirst Song\n2\nSecond Song\n3\nThird Song" result = _normalize_vertical_tracklist(text) assert result is not None, "should work without durations" assert "1. First Song" in result, f"got: {result!r}" return f"no-duration OK: {result[:60]!r}" def test_vertical_tracklist_not_triggered_for_normal() -> str: from hint_extractor import _normalize_vertical_tracklist text = "1. Dancing Queen\n2. Waterloo\n3. Fernando" result = _normalize_vertical_tracklist(text) assert result is None, f"should return None for normal format, got: {result!r}" return "correctly returns None for standard format" # --------------------------------------------------------------------------- # Single-CD disc handling Tests # --------------------------------------------------------------------------- def test_single_cd_tracklist_match() -> str: """Track-Nummer-Match darf nicht disc_num erfordern (Single-CD hat disc=None).""" from hint_extractor import _parse_tracklist from models import TrackHints, AlbumHints, AlbumScan from pathlib import Path import tempfile with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "Tufaranka_-_Katka_dovadi" root.mkdir() (root / "01_-_Tufaranka_-_AudioTrack_01.mp3").write_bytes(b"\x00" * 100) (root / "tracklist.txt").write_text("1\nKatka dovádí\n3:22\n2\nZáludná\n2:15\n3\nPolka\n4:01") from scanner import scan_album from hint_extractor import extract_hints scan = scan_album(root) hints = extract_hints(scan, use_ocr=False) track = hints.tracks[0] assert track.title == "Katka dovádí", f"expected tracklist title, got: {track.title!r}" return f"single-CD match OK: title={track.title!r}" # --------------------------------------------------------------------------- # Genre normalization Tests # --------------------------------------------------------------------------- def test_genre_normalize_german() -> str: from metadata_resolver import normalize_genre assert normalize_genre("volksmusik") == "Folk", "volksmusik → Folk" assert normalize_genre("klassik") == "Classical", "klassik → Classical" assert normalize_genre("marschmusik") == "March", "marschmusik → March" return "German genres normalized correctly" def test_genre_normalize_english_variants() -> str: from metadata_resolver import normalize_genre assert normalize_genre("rhythm and blues") == "R&B" assert normalize_genre("rock and roll") == "Rock 'n' Roll" return "English variants normalized correctly" def test_genre_normalize_titlecase() -> str: from metadata_resolver import normalize_genre assert normalize_genre("JAZZ") == "Jazz", f"got: {normalize_genre('JAZZ')!r}" assert normalize_genre("folk") == "Folk", f"got: {normalize_genre('folk')!r}" assert normalize_genre("Big Band") == "Big Band" # unchanged return "Titlecase normalization OK" # --------------------------------------------------------------------------- # _is_classical() Tests # --------------------------------------------------------------------------- def test_is_classical_by_genre() -> str: from executor import _is_classical assert _is_classical("Gardiner", "Bach", "Classical"), "Classical genre should trigger" assert _is_classical("Herreweghe", "Handel", "Baroque"), "Baroque should trigger" return "genre-based detection OK" def test_is_classical_by_composer() -> str: from executor import _is_classical assert _is_classical("Gardiner", "Bach", ""), "Bach as track_artist should trigger" assert _is_classical("Hurford", "beethoven", ""), "beethoven should trigger" return "composer-name detection OK" def test_is_classical_false_for_pop() -> str: from executor import _is_classical assert not _is_classical("Trini Lopez", "Trini Lopez", "Pop"), "same artist = not classical" assert not _is_classical("ABBA", "ABBA", "Pop"), "ABBA is not classical" assert not _is_classical("Trini Lopez", "", "R&B"), "empty track_artist = not classical" return "pop albums correctly not classical" def test_is_classical_false_for_folk() -> str: from executor import _is_classical assert not _is_classical("Tufaranka", "Tufaranka", "Folk"), "Folk is not classical" return "Folk correctly not classical" # --------------------------------------------------------------------------- # cover normalize Tests # --------------------------------------------------------------------------- def test_normalize_cover_renames_front_jpg() -> str: from cover_handler import normalize_cover_to_folder_jpg import tempfile, shutil with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) front = root / "Front.jpg" front.write_bytes(b"\xff\xd8" + b"\x00" * 200) result = normalize_cover_to_folder_jpg(front) assert result.name == "folder.jpg", f"expected folder.jpg, got {result.name!r}" assert (root / "folder.jpg").exists(), "folder.jpg should exist" assert not front.exists(), "Front.jpg should be gone" return "Front.jpg → folder.jpg rename OK" # --------------------------------------------------------------------------- # executor Tests # --------------------------------------------------------------------------- def test_proposed_filename_single_disc() -> str: from executor import _proposed_filename from models import TrackProposal from pathlib import Path # Pop schema: albumartist == track artist → TT_-_Artist_-_Title tp = TrackProposal(path=Path("dummy.mp3"), title="Dancing Queen", artist="ABBA", track_number=1, disc_number=None) name = _proposed_filename(tp, ".mp3", albumartist="ABBA") assert name == "01_-_ABBA_-_Dancing_Queen.mp3", f"got: {name!r}" return name def test_proposed_filename_multi_disc() -> str: from executor import _proposed_filename from models import TrackProposal from pathlib import Path # Classical schema: albumartist (performer) ≠ track artist (composer) tp = TrackProposal(path=Path("dummy.flac"), title="Toccata", artist="Bach", track_number=7, disc_number=2) name = _proposed_filename(tp, ".flac", albumartist="Gardiner") assert name == "2-07_-_Gardiner_-_Bach_-_Toccata.flac", f"got: {name!r}" return name def test_proposed_filename_sanitizes_chars() -> str: from executor import _proposed_filename from models import TrackProposal from pathlib import Path tp = TrackProposal(path=Path("x.mp3"), title='Track: "Live" / Today', artist="Artist?", track_number=3, disc_number=None) name = _proposed_filename(tp, ".mp3") assert "/" not in name and ":" not in name, f"unsafe chars in: {name!r}" return name # --------------------------------------------------------------------------- # Runner # --------------------------------------------------------------------------- def main() -> None: print("🧪 Starte Music Metadata Enricher Tests...") cases = [ ("UNIT_01_parse_dirname_artist_album", test_parse_dirname_artist_album), ("UNIT_02_parse_dirname_with_year", test_parse_dirname_with_year), ("UNIT_03_parse_dirname_album_only", test_parse_dirname_album_only), ("UNIT_04_parse_filename_track_artist_title", test_parse_filename_track_artist_title), ("UNIT_05_parse_filename_disc_track_title", test_parse_filename_disc_track_title), ("UNIT_06_parse_filename_track_title", test_parse_filename_track_title), ("UNIT_07_parse_filename_artist_title", test_parse_filename_artist_title), ("UNIT_08_parse_tracklist_numbered", test_parse_tracklist_numbered), ("UNIT_09_parse_tracklist_with_duration", test_parse_tracklist_with_duration), ("UNIT_10_parse_tracklist_disc_sections", test_parse_tracklist_with_disc_sections), ("UNIT_11_scanner_classifies_files", test_scanner_classifies_files), ("UNIT_12_scanner_ignores_hidden", test_scanner_ignores_hidden), ("UNIT_13_extract_hints_from_scan", test_extract_hints_from_scan), ("UNIT_14_extract_hints_multi_disc", test_extract_hints_multi_disc), ("UNIT_15_proposed_filename_single_disc", test_proposed_filename_single_disc), ("UNIT_16_proposed_filename_multi_disc", test_proposed_filename_multi_disc), ("UNIT_17_proposed_filename_sanitizes_chars", test_proposed_filename_sanitizes_chars), # Neue Tests ("UNIT_18_vertical_tracklist_basic", test_vertical_tracklist_basic), ("UNIT_19_vertical_tracklist_no_duration", test_vertical_tracklist_without_duration), ("UNIT_20_vertical_tracklist_no_false_pos", test_vertical_tracklist_not_triggered_for_normal), ("UNIT_21_single_cd_tracklist_match", test_single_cd_tracklist_match), ("UNIT_22_genre_normalize_german", test_genre_normalize_german), ("UNIT_23_genre_normalize_english", test_genre_normalize_english_variants), ("UNIT_24_genre_normalize_titlecase", test_genre_normalize_titlecase), ("UNIT_25_is_classical_by_genre", test_is_classical_by_genre), ("UNIT_26_is_classical_by_composer", test_is_classical_by_composer), ("UNIT_27_is_classical_false_pop", test_is_classical_false_for_pop), ("UNIT_28_is_classical_false_folk", test_is_classical_false_for_folk), ("UNIT_29_normalize_cover_renames", test_normalize_cover_renames_front_jpg), ] for test_id, fn in cases: run_case(test_id, fn) print("=" * 70) for r in RESULTS: icon = "✅" if r["status"] == "PASS" else "❌" detail = r["detail"][:100] + "..." if len(r["detail"]) > 100 else r["detail"] print(f"{icon} [{r['status']}] {r['id']} {detail}") print("=" * 70) passed = sum(1 for r in RESULTS if r["status"] == "PASS") total = len(RESULTS) print(f"📊 {passed}/{total} Tests erfolgreich") sys.exit(0 if passed == total else 1) if __name__ == "__main__": main()