"""Test per core.catalog — sanitize + move_files + scan_folder. Focus principale sui casi in cui il piano ha promesso comportamento esplicito: sanitize dei chars vietati, struttura year/genre, gestione di anno/genere mancanti, conflitto di filename. """ from __future__ import annotations from pathlib import Path from unittest import mock import pytest from core import catalog # ------------------------------------------------------------------ # Sanitize # ------------------------------------------------------------------ class TestSanitizeFolder: def test_sanitize_folder_removes_forbidden_chars(self): # `/` (dai tag musicbrainz), `:` (Windows), `?`, `*`, `|`, ecc. s = catalog._sanitize_folder("electronic/house") assert "/" not in s assert "electronic" in s and "house" in s s2 = catalog._sanitize_folder("prog:rock?") for ch in '<>:"|?*\\/': assert ch not in s2 def test_sanitize_folder_collapses_spaces(self): assert catalog._sanitize_folder(" tech house ") == "tech house" def test_sanitize_folder_empty_returns_empty(self): assert catalog._sanitize_folder("") == "" assert catalog._sanitize_folder(None) == "" def test_sanitize_folder_truncates_long(self): s = catalog._sanitize_folder("a" * 300) assert len(s) <= 100 # ------------------------------------------------------------------ # move_files # ------------------------------------------------------------------ def _touch(path: Path, size: int = 8) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"\x00" * size) return path class TestMoveFiles: def test_move_files_creates_year_genre_structure(self, tmp_path): """Un file matched → finisce in ///.""" src = _touch(tmp_path / "source" / "song.mp3") target = tmp_path / "cat" entries = [{ "path": str(src), "size": src.stat().st_size, "matched": True, "year": 2005, "genre": "House", "artist": "X", "title": "T", "fingerprint": "FP", }] res = catalog.move_files(entries, str(target)) assert res["moved"] == 1 assert res["failed"] == [] dst = target / "2005" / "House" / "song.mp3" assert dst.exists() assert not src.exists() # Log operations popolato assert res["operations"] and res["operations"][0]["src"] == str(src) assert res["operations"][0]["dst"] == str(dst) def test_move_files_handles_missing_year_or_genre(self, tmp_path): """Year/genre mancanti → Unknown Year / Unknown Genre.""" no_year = _touch(tmp_path / "src" / "no_year.mp3") no_genre = _touch(tmp_path / "src" / "no_genre.mp3") no_both = _touch(tmp_path / "src" / "no_both.mp3") target = tmp_path / "cat" entries = [ {"path": str(no_year), "matched": True, "year": None, "genre": "House"}, {"path": str(no_genre), "matched": True, "year": 2010, "genre": ""}, {"path": str(no_both), "matched": False, "year": None, "genre": ""}, ] res = catalog.move_files(entries, str(target)) assert res["moved"] == 3 assert (target / "Unknown Year" / "House" / "no_year.mp3").exists() assert (target / "2010" / "Unknown Genre" / "no_genre.mp3").exists() assert (target / "Unknown Year" / "Unknown Genre" / "no_both.mp3").exists() def test_move_files_dedup_conflicting_filenames(self, tmp_path): """Se un file con lo stesso nome esiste gia' nel target, aggiungi suffisso _1, _2, ... (mai overwrite).""" src1 = _touch(tmp_path / "srcA" / "song.mp3", size=10) src2 = _touch(tmp_path / "srcB" / "song.mp3", size=20) target = tmp_path / "cat" entries = [ {"path": str(src1), "matched": True, "year": 2000, "genre": "Rock"}, {"path": str(src2), "matched": True, "year": 2000, "genre": "Rock"}, ] res = catalog.move_files(entries, str(target)) assert res["moved"] == 2 d1 = target / "2000" / "Rock" / "song.mp3" d2 = target / "2000" / "Rock" / "song_1.mp3" assert d1.exists() and d2.exists() # Contenuto preservato dal move (src1 = 10 byte, src2 = 20 byte). # L'ordine di iterazione garantisce che song.mp3 = src1. assert d1.stat().st_size == 10 assert d2.stat().st_size == 20 def test_move_files_sanitizes_forbidden_genre(self, tmp_path): """Genere con `/` (tipico di musicbrainz) va sanitizzato in `_`.""" src = _touch(tmp_path / "src" / "song.mp3") target = tmp_path / "cat" entries = [{ "path": str(src), "matched": True, "year": 2020, "genre": "electronic/house", }] res = catalog.move_files(entries, str(target)) assert res["moved"] == 1 # NON deve creare "electronic" e dentro "house": e' un solo nome. assert not (target / "2020" / "electronic").is_dir() # La cartella deve contenere entrambe le parti sanitizzate year_dir = target / "2020" subdirs = [p.name for p in year_dir.iterdir() if p.is_dir()] assert len(subdirs) == 1 assert "/" not in subdirs[0] assert "electronic" in subdirs[0] and "house" in subdirs[0] def test_move_files_missing_source_reports_failed(self, tmp_path): """File che non esiste piu' → finisce in failed, non alza.""" target = tmp_path / "cat" entries = [{ "path": str(tmp_path / "does_not_exist.mp3"), "matched": True, "year": 2000, "genre": "X", }] res = catalog.move_files(entries, str(target)) assert res["moved"] == 0 assert len(res["failed"]) == 1 assert "non esiste" in res["failed"][0]["error"].lower() def test_move_files_empty_list_returns_zero(self, tmp_path): res = catalog.move_files([], str(tmp_path / "cat")) assert res["moved"] == 0 assert res["failed"] == [] assert res["operations"] == [] # ------------------------------------------------------------------ # scan_folder — smoke integration test (mock fpcalc + lookup) # ------------------------------------------------------------------ class TestScanFolder: def test_scan_folder_empty_directory(self, tmp_path): # Cartella senza file audio → lista vuota, no crash with mock.patch.object(catalog, "find_fpcalc", return_value="/fake/fpcalc"): entries = catalog.scan_folder(str(tmp_path), recursive=False) assert entries == [] def test_scan_folder_processes_files(self, tmp_path): # Un file audio fake + mock di fpcalc + lookup (tmp_path / "a.mp3").write_bytes(b"\x00" * 100) (tmp_path / "readme.txt").write_text("hi") with mock.patch.object(catalog, "find_fpcalc", return_value="/fake/fpcalc"), \ mock.patch.object(catalog, "compute_fingerprint", return_value={"fingerprint": "FP", "duration": 100}), \ mock.patch.object(catalog, "lookup", return_value={ "matched": True, "year": 2018, "genre": "House", "artist": "A", "title": "T", }): entries = catalog.scan_folder(str(tmp_path), recursive=False) assert len(entries) == 1 e = entries[0] assert e["matched"] is True assert e["year"] == 2018 assert e["genre"] == "House" assert e["fingerprint"] == "FP" def test_scan_folder_records_fpcalc_error(self, tmp_path): """fpcalc fallito → entry con matched=False + error.""" (tmp_path / "broken.mp3").write_bytes(b"\x00" * 10) with mock.patch.object(catalog, "find_fpcalc", return_value="/fake/fpcalc"), \ mock.patch.object(catalog, "compute_fingerprint", return_value={"_error": "fingerprint vuoto"}): entries = catalog.scan_folder(str(tmp_path), recursive=False) assert len(entries) == 1 assert entries[0]["matched"] is False assert "vuoto" in entries[0]["error"]