"""Tests for the music library module."""
from pathlib import Path
import pytest
from voice_mode.dj.library import (
MusicLibrary,
Track,
LibraryStats,
FileScanner,
SUPPORTED_EXTENSIONS,
)
class MockFileScanner:
"""Mock file scanner for testing."""
def __init__(self, files: list[Path] | None = None):
"""Initialize with a list of file paths to return."""
self._files = files or []
def scan(self, root: Path) -> list[Path]:
"""Return the mock file list."""
return self._files
@pytest.fixture
def temp_db(tmp_path):
"""Create a temporary database path."""
return tmp_path / "test-music.db"
@pytest.fixture
def temp_music_dir(tmp_path):
"""Create a temporary music directory with test files."""
music_dir = tmp_path / "music"
music_dir.mkdir()
# Create a sample directory structure
# Artist/Year-Album/Track.ext
artist_dir = music_dir / "Daft_Punk" / "2001-Discovery"
artist_dir.mkdir(parents=True)
# Create test files
(artist_dir / "01-One_More_Time.mp3").touch()
(artist_dir / "02-Aerodynamic.mp3").touch()
(artist_dir / "03-Digital_Love.mp3").touch()
# Create another artist
artist2_dir = music_dir / "Boards_Of_Canada" / "2002-Geogaddi"
artist2_dir.mkdir(parents=True)
(artist2_dir / "01-Music_Is_Math.flac").touch()
(artist2_dir / "02-Beware_The_Friendly_Stranger.flac").touch()
# Create a sidecar directory (stems)
sidecar_dir = artist_dir / "01-One_More_Time.mp3.d" / "stems"
sidecar_dir.mkdir(parents=True)
(sidecar_dir / "vocals.mp3").touch()
(sidecar_dir / "drums.mp3").touch()
return music_dir
@pytest.fixture
def library(temp_db, temp_music_dir):
"""Create a library with test database and music directory."""
return MusicLibrary(db_path=temp_db, music_root=temp_music_dir)
class TestTrackDataclass:
"""Tests for the Track dataclass."""
def test_track_creation(self):
"""Test creating a Track with all fields."""
track = Track(
id=1,
path="Artist/2001-Album/01-Track.mp3",
filename="01-Track.mp3",
artist="Artist",
album="Album",
title="Track",
year=2001,
track_number=1,
format="mp3",
is_favorite=False,
play_count=0,
)
assert track.id == 1
assert track.artist == "Artist"
assert track.title == "Track"
assert track.year == 2001
assert track.is_favorite is False
def test_track_with_sidecar(self):
"""Test creating a sidecar track."""
track = Track(
id=2,
path="Artist/Album/track.mp3.d/stems/vocals.mp3",
filename="vocals.mp3",
artist="Artist",
album="Album",
title="vocals",
year=None,
track_number=None,
format="mp3",
is_favorite=False,
play_count=0,
is_sidecar=True,
sidecar_type="stem",
parent_track_id=1,
)
assert track.is_sidecar is True
assert track.sidecar_type == "stem"
assert track.parent_track_id == 1
class TestLibraryStats:
"""Tests for LibraryStats dataclass."""
def test_stats_creation(self):
"""Test creating LibraryStats."""
stats = LibraryStats(
total_tracks=100,
total_sidecars=20,
total_favorites=5,
total_artists=10,
total_albums=15,
)
assert stats.total_tracks == 100
assert stats.total_sidecars == 20
assert stats.total_favorites == 5
assert stats.total_artists == 10
assert stats.total_albums == 15
class TestMusicLibraryInit:
"""Tests for MusicLibrary initialization."""
def test_creates_db_directory(self, tmp_path):
"""Test that database directory is created."""
db_path = tmp_path / "subdir" / "music.db"
MusicLibrary(db_path=db_path)
assert db_path.parent.exists()
def test_creates_schema(self, temp_db):
"""Test that database schema is created."""
import sqlite3
library = MusicLibrary(db_path=temp_db)
with sqlite3.connect(temp_db) as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
tables = {row[0] for row in cursor.fetchall()}
assert "tracks" in tables
assert "play_history" in tables
assert "music_roots" in tables
def test_accepts_custom_scanner(self, temp_db, tmp_path):
"""Test that custom scanner can be injected."""
scanner = MockFileScanner([])
library = MusicLibrary(
db_path=temp_db,
music_root=tmp_path,
scanner=scanner,
)
count = library.scan()
assert count == 0
class TestPathMetadataParsing:
"""Tests for metadata parsing from path structure."""
def test_parses_artist_year_album_track(self, temp_db):
"""Test parsing Artist/Year-Album/Track.ext format."""
library = MusicLibrary(db_path=temp_db)
metadata = library._parse_path_metadata(
"Daft_Punk/2001-Discovery/01-One_More_Time.mp3"
)
assert metadata["artist"] == "Daft Punk"
assert metadata["album"] == "Discovery"
assert metadata["year"] == 2001
assert metadata["track_number"] == 1
assert metadata["title"] == "One More Time"
assert metadata["format"] == "mp3"
assert metadata["is_sidecar"] is False
def test_parses_album_without_year(self, temp_db):
"""Test parsing when album has no year prefix."""
library = MusicLibrary(db_path=temp_db)
metadata = library._parse_path_metadata(
"Artist/Some_Album/02-Track_Name.flac"
)
assert metadata["artist"] == "Artist"
assert metadata["album"] == "Some Album"
assert metadata["year"] is None
assert metadata["track_number"] == 2
assert metadata["title"] == "Track Name"
def test_parses_track_without_number(self, temp_db):
"""Test parsing when track has no number prefix."""
library = MusicLibrary(db_path=temp_db)
metadata = library._parse_path_metadata(
"Artist/2020-Album/Untitled.wav"
)
assert metadata["track_number"] is None
assert metadata["title"] == "Untitled"
def test_identifies_sidecar_stem(self, temp_db):
"""Test identifying stem sidecar files."""
library = MusicLibrary(db_path=temp_db)
metadata = library._parse_path_metadata(
"Artist/Album/track.mp3.d/stems/vocals.mp3"
)
assert metadata["is_sidecar"] is True
assert metadata["sidecar_type"] == "stem"
def test_identifies_sidecar_loop(self, temp_db):
"""Test identifying loop sidecar files."""
library = MusicLibrary(db_path=temp_db)
metadata = library._parse_path_metadata(
"Artist/Album/track.mp3.d/loops/beat.mp3"
)
assert metadata["is_sidecar"] is True
assert metadata["sidecar_type"] == "loop"
def test_identifies_sidecar_sample(self, temp_db):
"""Test identifying sample sidecar files."""
library = MusicLibrary(db_path=temp_db)
metadata = library._parse_path_metadata(
"Artist/Album/track.mp3.d/samples/sample1.wav"
)
assert metadata["is_sidecar"] is True
assert metadata["sidecar_type"] == "sample"
def test_cleans_underscores_to_spaces(self, temp_db):
"""Test that underscores are converted to spaces."""
library = MusicLibrary(db_path=temp_db)
metadata = library._parse_path_metadata(
"The_Chemical_Brothers/2015-Born_In_The_Echoes/01-Sometimes_I_Feel_So_Deserted.mp3"
)
assert metadata["artist"] == "The Chemical Brothers"
assert metadata["album"] == "Born In The Echoes"
assert metadata["title"] == "Sometimes I Feel So Deserted"
class TestMusicLibraryScan:
"""Tests for MusicLibrary.scan()."""
def test_scans_music_directory(self, library, temp_music_dir):
"""Test scanning indexes all tracks."""
count = library.scan()
# 5 main tracks + 2 sidecars = 7 total
assert count == 7
def test_scan_nonexistent_directory(self, temp_db, tmp_path):
"""Test scanning nonexistent directory returns 0."""
library = MusicLibrary(
db_path=temp_db,
music_root=tmp_path / "nonexistent",
)
count = library.scan()
assert count == 0
def test_scan_with_mock_scanner(self, temp_db, tmp_path):
"""Test scanning with mock scanner."""
music_dir = tmp_path / "music"
music_dir.mkdir()
# Create test files
test_files = [
music_dir / "Artist" / "Album" / "track1.mp3",
music_dir / "Artist" / "Album" / "track2.mp3",
]
for f in test_files:
f.parent.mkdir(parents=True, exist_ok=True)
f.touch()
scanner = MockFileScanner(test_files)
library = MusicLibrary(
db_path=temp_db,
music_root=music_dir,
scanner=scanner,
)
count = library.scan()
assert count == 2
class TestMusicLibrarySearch:
"""Tests for MusicLibrary.search()."""
def test_search_by_artist(self, library):
"""Test searching by artist name."""
library.scan()
results = library.search("Daft Punk")
assert len(results) == 3
assert all(t.artist == "Daft Punk" for t in results)
def test_search_by_title(self, library):
"""Test searching by track title."""
library.scan()
results = library.search("Digital Love")
assert len(results) == 1
assert results[0].title == "Digital Love"
def test_search_by_album(self, library):
"""Test searching by album name."""
library.scan()
results = library.search("Geogaddi")
assert len(results) == 2
assert all(t.album == "Geogaddi" for t in results)
def test_search_case_insensitive(self, library):
"""Test that search is case-insensitive."""
library.scan()
results = library.search("daft punk")
assert len(results) == 3
def test_search_excludes_sidecars_by_default(self, library):
"""Test that sidecars are excluded by default."""
library.scan()
results = library.search("vocals")
assert len(results) == 0
def test_search_includes_sidecars_when_requested(self, library):
"""Test that sidecars can be included."""
library.scan()
results = library.search("vocals", include_sidecars=True)
assert len(results) == 1
assert results[0].is_sidecar is True
def test_search_respects_limit(self, library):
"""Test that search respects limit parameter."""
library.scan()
results = library.search("", limit=2) # Match all
assert len(results) <= 2
def test_search_no_results(self, library):
"""Test search with no matches."""
library.scan()
results = library.search("nonexistent artist xyz")
assert len(results) == 0
class TestMusicLibraryGetTrack:
"""Tests for MusicLibrary.get_track()."""
def test_get_track_by_id(self, library):
"""Test getting track by ID."""
library.scan()
results = library.search("One More Time")
track_id = results[0].id
track = library.get_track(track_id)
assert track is not None
assert track.title == "One More Time"
assert track.artist == "Daft Punk"
def test_get_track_not_found(self, library):
"""Test getting nonexistent track returns None."""
library.scan()
track = library.get_track(99999)
assert track is None
class TestMusicLibraryGetTrackByPath:
"""Tests for MusicLibrary.get_track_by_path()."""
def test_get_track_by_path(self, library):
"""Test getting track by relative path."""
library.scan()
path = "Daft_Punk/2001-Discovery/01-One_More_Time.mp3"
track = library.get_track_by_path(path)
assert track is not None
assert track.title == "One More Time"
def test_get_track_by_path_not_found(self, library):
"""Test getting nonexistent path returns None."""
library.scan()
track = library.get_track_by_path("nonexistent/path.mp3")
assert track is None
class TestMusicLibraryFavorites:
"""Tests for favorite functionality."""
def test_toggle_favorite_on(self, library):
"""Test toggling favorite to true."""
library.scan()
results = library.search("One More Time")
track_id = results[0].id
is_favorite = library.toggle_favorite(track_id)
assert is_favorite is True
track = library.get_track(track_id)
assert track.is_favorite is True
def test_toggle_favorite_off(self, library):
"""Test toggling favorite to false."""
library.scan()
results = library.search("One More Time")
track_id = results[0].id
# Toggle on then off
library.toggle_favorite(track_id)
is_favorite = library.toggle_favorite(track_id)
assert is_favorite is False
track = library.get_track(track_id)
assert track.is_favorite is False
def test_get_favorites(self, library):
"""Test getting favorite tracks."""
library.scan()
results = library.search("Daft Punk")
# Mark first two as favorites
library.toggle_favorite(results[0].id)
library.toggle_favorite(results[1].id)
favorites = library.get_favorites()
assert len(favorites) == 2
assert all(t.is_favorite for t in favorites)
class TestMusicLibraryPlayHistory:
"""Tests for play history functionality."""
def test_record_play(self, library):
"""Test recording a play."""
library.scan()
results = library.search("One More Time")
track_id = results[0].id
# Record multiple plays
library.record_play(track_id)
library.record_play(track_id)
track = library.get_track(track_id)
assert track.play_count == 2
def test_get_history_empty(self, library):
"""Test get_history returns empty list when no plays."""
library.scan()
history = library.get_history()
assert history == []
def test_get_history_returns_plays(self, library):
"""Test get_history returns recorded plays."""
library.scan()
results = library.search("One More Time")
track_id = results[0].id
library.record_play(track_id)
history = library.get_history()
assert len(history) == 1
track, played_at = history[0]
assert track.id == track_id
assert track.title == "One More Time"
assert played_at is not None
def test_get_history_most_recent_first(self, library):
"""Test get_history returns most recent plays first."""
library.scan()
track1 = library.search("One More Time")[0]
track2 = library.search("Digital Love")[0]
# Play track1 first, then track2
library.record_play(track1.id)
library.record_play(track2.id)
history = library.get_history()
assert len(history) == 2
# Most recent (track2) should be first (ordered by id DESC as tiebreaker)
assert history[0][0].title == "Digital Love"
assert history[1][0].title == "One More Time"
def test_get_history_respects_limit(self, library):
"""Test get_history respects limit parameter."""
library.scan()
results = library.search("Daft Punk")
# Record plays for all 3 Daft Punk tracks
for track in results:
library.record_play(track.id)
history = library.get_history(limit=2)
assert len(history) == 2
def test_get_history_includes_favorites_status(self, library):
"""Test get_history includes whether track is favorite."""
library.scan()
track = library.search("One More Time")[0]
library.toggle_favorite(track.id)
library.record_play(track.id)
history = library.get_history()
assert len(history) == 1
returned_track, _ = history[0]
assert returned_track.is_favorite is True
class TestMusicLibraryStats:
"""Tests for MusicLibrary.stats()."""
def test_stats_counts(self, library):
"""Test stats returns correct counts."""
library.scan()
stats = library.stats()
assert stats.total_tracks == 5 # 3 Daft Punk + 2 Boards of Canada
assert stats.total_sidecars == 2 # 2 stem files
assert stats.total_favorites == 0
assert stats.total_artists == 2
assert stats.total_albums == 2
def test_stats_with_favorites(self, library):
"""Test stats includes favorites count."""
library.scan()
results = library.search("Daft Punk")
library.toggle_favorite(results[0].id)
stats = library.stats()
assert stats.total_favorites == 1
class TestMusicLibraryGetFullPath:
"""Tests for MusicLibrary.get_full_path()."""
def test_get_full_path(self, library, temp_music_dir):
"""Test getting full filesystem path."""
library.scan()
results = library.search("One More Time")
track = results[0]
full_path = library.get_full_path(track)
assert full_path.exists()
assert full_path.name == "01-One_More_Time.mp3"
assert str(full_path).startswith(str(temp_music_dir))
class TestSupportedExtensions:
"""Tests for supported extensions constant."""
def test_supported_extensions(self):
"""Test that common audio formats are supported."""
assert ".mp3" in SUPPORTED_EXTENSIONS
assert ".flac" in SUPPORTED_EXTENSIONS
assert ".m4a" in SUPPORTED_EXTENSIONS
assert ".wav" in SUPPORTED_EXTENSIONS
assert ".ogg" in SUPPORTED_EXTENSIONS
assert ".opus" in SUPPORTED_EXTENSIONS
def test_unsupported_extensions_excluded(self):
"""Test that non-audio formats are not included."""
assert ".txt" not in SUPPORTED_EXTENSIONS
assert ".jpg" not in SUPPORTED_EXTENSIONS
assert ".mp4" not in SUPPORTED_EXTENSIONS