206 lines
8.3 KiB
Python
206 lines
8.3 KiB
Python
"""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 <target>/<year>/<genre>/<filename>."""
|
|
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"]
|