diff --git a/.gitignore b/.gitignore index b17dc23..ad5ad1f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.pyc .env .venv/ +*.egg-info/ reports/ *.sqlite *.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d25061..e15540a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,3 +11,9 @@ Never commit personal music library files, Serato databases, or crates. Never write repair code without dry-run mode, backup plan, and rollback log. + +## Testing + +Install development dependencies with `python3 -m pip install -e '.[dev]'`. + +Run `pytest` before committing. Tests and fixtures must use synthetic library data. diff --git a/ROADMAP.md b/ROADMAP.md index ef2fd8d..897e6c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ - [x] Missing reference CSV report - [x] Grouped missing reference report - [ ] HTML health dashboard -- [ ] Test suite +- [x] Test suite - [ ] Sample library fixtures - [ ] Database V2 read-only parser diff --git a/docs/design/test-foundation.md b/docs/design/test-foundation.md new file mode 100644 index 0000000..c6505ad --- /dev/null +++ b/docs/design/test-foundation.md @@ -0,0 +1,24 @@ +# Test Foundation + +## Problem + +The crate parser handles unusual binary text and known extension artifacts. Without +automated tests, a small refactor could silently change library counts or reports. + +## Architecture + +Tests cover the read-only pipeline from crate and filesystem inputs through the +core library model to CSV, text, and CLI output. All test data is synthetic and is +created in temporary directories. + +## Edge Cases + +- Known MP3, M4A, WAV, and AIF decode artifacts. +- Crate records without a recognized stop marker. +- Supported extensions with mixed case and unsupported files. +- References that exist by filename and references that remain missing. + +## Verification + +Run `pytest`. No test reads a real Serato library or writes outside pytest's +temporary directory. diff --git a/pyproject.toml b/pyproject.toml index dbd025f..5ec7c93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + [project] name = "serato-doctor" version = "0.1.0" @@ -5,5 +9,14 @@ description = "Inspect, diagnose, repair, and migrate DJ libraries." requires-python = ">=3.9" dependencies = [] +[project.optional-dependencies] +dev = ["pytest>=8,<9"] + [project.scripts] serato-doctor = "serato_doctor.cli:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.setuptools.packages.find] +include = ["serato_doctor*"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..1552733 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,49 @@ +import csv +import sys + +from serato_doctor.cli import main + + +def test_cli_writes_reports_and_prints_counts(tmp_path, monkeypatch, capsys): + serato = tmp_path / "serato" + subcrates = serato / "Subcrates" + music = tmp_path / "music" + subcrates.mkdir(parents=True) + music.mkdir() + + crate_text = ( + "Users/djsplice/OneDrive/Jukebox/Found.mp漳牴k" + "Users/djsplice/OneDrive/Jukebox/Missing.mp漳牴k" + ) + (subcrates / "Test.crate").write_bytes(crate_text.encode("utf-16-le")) + (music / "Found.mp3").write_bytes(b"synthetic audio") + csv_path = tmp_path / "scan.csv" + report_path = tmp_path / "report.txt" + monkeypatch.setattr( + sys, + "argv", + [ + "serato-doctor", + "--serato", + str(serato), + "--music", + str(music), + "--out", + str(csv_path), + "--report", + str(report_path), + ], + ) + + main() + + output = capsys.readouterr().out + assert "Crate references: 2" in output + assert "Disk tracks: 1" in output + assert "Missing by filename: 1" in output + assert f"CSV: {csv_path}" in output + assert f"Report: {report_path}" in output + with csv_path.open(newline="", encoding="utf-8") as csv_file: + rows = list(csv.DictReader(csv_file)) + assert [row["exists_by_filename"] for row in rows] == ["True", "False"] + assert "Total missing references: 1" in report_path.read_text(encoding="utf-8") diff --git a/tests/test_crates.py b/tests/test_crates.py new file mode 100644 index 0000000..dd058d4 --- /dev/null +++ b/tests/test_crates.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from serato_doctor.crate_parser import clean_path, load_crate, parse_crate + + +@pytest.mark.parametrize( + ("artifact", "expected"), + [ + ("song.mp漳", "song.mp3"), + ("song.m4愠", "song.m4a"), + ("song.wa瘠", "song.wav"), + ("song.ai映", "song.aif"), + ], +) +def test_clean_path_repairs_known_extension_artifacts(artifact, expected): + assert clean_path(artifact) == expected + + +def test_parse_crate_extracts_references(tmp_path): + crate_path = tmp_path / "House.crate" + crate_text = ( + "header" + "Users/djsplice/OneDrive/Jukebox/House/First.mp漳牴k" + "metadata" + "Users/djsplice/OneDrive/Jukebox/House/Second.m4愠otrk" + ) + crate_path.write_bytes(crate_text.encode("utf-16-le")) + + crate = load_crate(crate_path) + + assert crate.path == crate_path + assert [reference.filename for reference in crate.references] == [ + "First.mp3", + "Second.m4a", + ] + assert parse_crate(crate_path) == list(crate.references) + + +def test_parse_crate_ignores_record_without_stop_marker(tmp_path): + crate_path = Path(tmp_path) / "Incomplete.crate" + crate_path.write_bytes( + "Users/djsplice/OneDrive/Jukebox/House/Incomplete.mp3".encode("utf-16-le") + ) + + assert parse_crate(crate_path) == [] diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 0000000..3a046c1 --- /dev/null +++ b/tests/test_library.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from serato_doctor.models.library import Library +from serato_doctor.models.reference import TrackReference +from serato_doctor.models.track import DiskTrack + + +def test_library_reconciles_references_by_filename(): + crate = Path("House.crate") + references = [ + TrackReference(crate, Path("/old/Found.mp3"), "Found.mp3"), + TrackReference(crate, Path("/old/Missing.mp3"), "Missing.mp3"), + ] + tracks = [DiskTrack(Path("/new/Found.mp3"), "Found.mp3", 10, ".mp3")] + + results = Library.build(references, tracks).reconcile_by_filename() + + assert [result.exists_by_filename for result in results] == [True, False] + assert results[1].as_row() == { + "crate": "House.crate", + "serato_path": "/old/Missing.mp3", + "filename": "Missing.mp3", + "exists_by_filename": False, + } diff --git a/tests/test_scanner.py b/tests/test_scanner.py new file mode 100644 index 0000000..63484e1 --- /dev/null +++ b/tests/test_scanner.py @@ -0,0 +1,17 @@ +from serato_doctor.scanner import scan_audio + + +def test_scan_audio_finds_supported_files(tmp_path): + music = tmp_path / "music" + nested = music / "House" + nested.mkdir(parents=True) + (nested / "First.MP3").write_bytes(b"synthetic audio") + (nested / "Second.flac").write_bytes(b"fixture") + (nested / "notes.txt").write_text("not audio", encoding="utf-8") + + tracks = scan_audio(music) + + assert {track.filename for track in tracks} == {"First.MP3", "Second.flac"} + first = next(track for track in tracks if track.filename == "First.MP3") + assert first.suffix == ".mp3" + assert first.size == len(b"synthetic audio")