Musiksammlung/tests/test_organizer.py
dschlueter 70c096cde4 Lint-Fixes, process-Disc-Validierung und Forgejo-CI
- ruff: Import-Sortierung, unused imports, Zeilenlängen behoben
- cli.py: _check_disc_counts_or_exit() extrahiert; auch process-Befehl
  prüft jetzt Disc-Anzahlen vor dem Umbenennen
- .forgejo/workflows/ci.yml: ruff + pytest auf push/PR gegen main

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

248 lines
8.5 KiB
Python

"""Tests für den Organizer."""
from pathlib import Path
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_-_TestArtist.flac"
assert targets[1].name == "02_-_Zweiter_Song_-_TestArtist.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_-_Artist.flac"
def test_track_artist_overrides_album_artist(self, tmp_path: Path) -> None:
(tmp_path / "track01.flac").touch()
(tmp_path / "track02.flac").touch()
album = Album(
artist="Various Artists",
album="Sampler",
discs=[
Disc(
disc_number=1,
tracks=[
Track(track_number=1, title="Song A", artist="Artist X"),
Track(track_number=2, title="Song B"), # kein Track-Künstler → Fallback
],
)
],
)
mapping = build_mapping(album, tmp_path, in_place=True)
targets = list(mapping.values())
assert targets[0].name == "01_-_Song_A_-_Artist_X.flac"
assert targets[1].name == "02_-_Song_B_-_Various_Artists.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)