"""Tests for the Music For Programming service module."""
import pytest
from io import BytesIO
from pathlib import Path
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError, URLError
from voice_mode.dj.mfp import (
HttpFetcher,
MfpEpisode,
MfpService,
RssFetcher,
)
# Sample RSS content for testing
SAMPLE_RSS_XML = '''<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Music For Programming</title>
<description>Datassette presents a series of mixes intended for listening while programming.</description>
<item>
<title>Episode 76: Material Object</title>
<enclosure url="https://datashat.net/music_for_programming_76-material_object.mp3" length="145678901" type="audio/mpeg"/>
</item>
<item>
<title>Episode 49: Julien Mier</title>
<enclosure url="https://datashat.net/music_for_programming_49-julien_mier.mp3" length="134567890" type="audio/mpeg"/>
</item>
<item>
<title>Episode 01: Datassette</title>
<enclosure url="https://datashat.net/music_for_programming_01-datassette.mp3" length="98765432" type="audio/mpeg"/>
</item>
</channel>
</rss>
'''
class MockFetcher:
"""Mock RSS fetcher for testing."""
def __init__(self, content: str = SAMPLE_RSS_XML, should_fail: bool = False):
"""Initialize mock fetcher.
Args:
content: RSS content to return.
should_fail: If True, raise RuntimeError on fetch.
"""
self.content = content
self.should_fail = should_fail
self.fetch_count = 0
def fetch(self, url: str) -> str:
"""Mock fetch implementation."""
self.fetch_count += 1
if self.should_fail:
raise RuntimeError("Network error")
return self.content
class TestMfpEpisode:
"""Tests for MfpEpisode dataclass."""
def test_basic_episode(self):
"""Test basic episode creation."""
episode = MfpEpisode(
number=49,
title="Episode 49: Julien Mier",
url="https://example.com/music_for_programming_49-julien_mier.mp3",
curator="julien mier",
)
assert episode.number == 49
assert episode.title == "Episode 49: Julien Mier"
assert episode.curator == "julien mier"
assert episode.has_chapters is False
assert episode.has_local_file is False
def test_episode_with_flags(self):
"""Test episode with chapter and local flags."""
episode = MfpEpisode(
number=49,
title="Episode 49",
url="https://example.com/ep49.mp3",
curator="artist",
has_chapters=True,
has_local_file=True,
length_bytes=123456789,
)
assert episode.has_chapters is True
assert episode.has_local_file is True
assert episode.length_bytes == 123456789
class TestMfpService:
"""Tests for MfpService class."""
@pytest.fixture
def mock_fetcher(self):
"""Create a mock fetcher."""
return MockFetcher()
@pytest.fixture
def service(self, tmp_path, mock_fetcher):
"""Create an MFP service with mock fetcher and temp cache."""
return MfpService(cache_dir=tmp_path, fetcher=mock_fetcher)
def test_list_episodes(self, service):
"""Test listing episodes from RSS."""
episodes = service.list_episodes(with_chapters_only=False)
assert len(episodes) == 3
# Should be sorted by number descending
assert episodes[0].number == 76
assert episodes[1].number == 49
assert episodes[2].number == 1
def test_list_episodes_with_chapters_only(self, service, tmp_path):
"""Test filtering to episodes with chapters."""
# Create a chapter file for episode 49
cue_file = tmp_path / "music_for_programming_49-julien_mier.cue"
cue_file.write_text("TRACK 01 AUDIO\n INDEX 01 00:00:00\n")
# Clear cache to reload with chapter detection
service._episodes_cache = None
episodes = service.list_episodes(with_chapters_only=True)
assert len(episodes) == 1
assert episodes[0].number == 49
def test_get_episode(self, service):
"""Test getting a single episode by number."""
episode = service.get_episode(49)
assert episode is not None
assert episode.number == 49
assert episode.curator == "julien mier"
def test_get_episode_not_found(self, service):
"""Test getting a non-existent episode."""
episode = service.get_episode(999)
assert episode is None
def test_get_stream_url(self, service):
"""Test getting streaming URL."""
url = service.get_stream_url(49)
assert url is not None
assert "music_for_programming_49" in url
assert url.endswith(".mp3")
def test_get_stream_url_not_found(self, service):
"""Test streaming URL for non-existent episode."""
url = service.get_stream_url(999)
assert url is None
def test_get_local_path_not_downloaded(self, service):
"""Test local path when file doesn't exist."""
path = service.get_local_path(49)
assert path is None
def test_get_local_path_exists(self, service, tmp_path):
"""Test local path when file exists."""
# Create a local MP3 file
mp3_file = tmp_path / "music_for_programming_49-julien_mier.mp3"
mp3_file.write_bytes(b"fake mp3 data")
# Clear cache to reload
service._episodes_cache = None
path = service.get_local_path(49)
assert path is not None
assert path.exists()
def test_get_chapters_file_none(self, service):
"""Test chapters file when none exists locally or in package."""
# Use episode 76 which doesn't have bundled chapters
path = service.get_chapters_file(76)
assert path is None
def test_get_chapters_file_copies_from_package(self, service, tmp_path):
"""Test that chapters are copied from package on-demand for bundled episodes."""
# Episode 49 has bundled chapters in the package
# Ensure no local files exist initially
local_ffmeta = tmp_path / "music_for_programming_49-julien_mier.ffmeta"
local_cue = tmp_path / "music_for_programming_49-julien_mier.cue"
assert not local_ffmeta.exists()
assert not local_cue.exists()
# Get chapters file - should copy from package
path = service.get_chapters_file(49)
# Should return a valid path
assert path is not None
assert path.suffix == ".ffmeta"
assert path.exists()
# Local files should now exist (copied from package)
assert local_ffmeta.exists() or local_cue.exists()
def test_get_chapters_file_ffmeta(self, service, tmp_path):
"""Test getting existing FFmeta chapters file."""
# Create an FFmeta file
ffmeta_file = tmp_path / "music_for_programming_49-julien_mier.ffmeta"
ffmeta_file.write_text(";FFMETADATA1\n")
path = service.get_chapters_file(49)
assert path is not None
assert path.suffix == ".ffmeta"
def test_get_chapters_file_converts_cue(self, service, tmp_path):
"""Test that CUE file is converted to FFmeta."""
# Create a CUE file
cue_file = tmp_path / "music_for_programming_49-julien_mier.cue"
cue_file.write_text('''TRACK 01 AUDIO
TITLE "Test Track"
INDEX 01 00:00:00
''')
path = service.get_chapters_file(49)
assert path is not None
assert path.suffix == ".ffmeta"
assert path.exists()
# Check content
content = path.read_text()
assert ";FFMETADATA1" in content
def test_sync_chapters(self, service, tmp_path):
"""Test syncing chapter files from package to local."""
# sync_chapters should copy files from package to local
results = service.sync_chapters()
# Should have added files from package (episode 49 CUE and FFmeta)
added_files = [f for f, status in results.items() if status == "Added"]
assert len(added_files) == 2
assert "music_for_programming_49-julien_mier.cue" in added_files
assert "music_for_programming_49-julien_mier.ffmeta" in added_files
# Files should exist in local cache
assert (tmp_path / "music_for_programming_49-julien_mier.cue").exists()
assert (tmp_path / "music_for_programming_49-julien_mier.ffmeta").exists()
# Local checksum file should be created
assert (tmp_path / ".chapters.sha256").exists()
def test_sync_chapters_skips_unchanged(self, service, tmp_path):
"""Test that sync detects unchanged files."""
# First sync - should add files
results1 = service.sync_chapters()
added_count = len([f for f, s in results1.items() if s == "Added"])
assert added_count == 2
# Second sync - should detect unchanged
results2 = service.sync_chapters()
unchanged_count = len([f for f, s in results2.items() if s == "Unchanged"])
assert unchanged_count == 2
def test_sync_chapters_force(self, service, tmp_path):
"""Test force sync backs up user modifications."""
# First sync to get files
service.sync_chapters()
# Modify a local file (simulate user edit)
cue_file = tmp_path / "music_for_programming_49-julien_mier.cue"
cue_file.write_text("USER MODIFIED CONTENT")
# Sync with force - should backup user file and restore package version
results = service.sync_chapters(force=True)
# Should show updated (because local was modified)
assert results.get("music_for_programming_49-julien_mier.cue") == "Updated"
# User backup should exist
backup_file = tmp_path / "music_for_programming_49-julien_mier.cue.user"
assert backup_file.exists()
assert backup_file.read_text() == "USER MODIFIED CONTENT"
# Original should be restored from package
assert cue_file.read_text() != "USER MODIFIED CONTENT"
def test_refresh(self, service, mock_fetcher):
"""Test refresh forces new RSS fetch."""
# First load
service.list_episodes(with_chapters_only=False)
initial_count = mock_fetcher.fetch_count
# Refresh
count = service.refresh()
assert mock_fetcher.fetch_count == initial_count + 1
assert count == 3
def test_rss_caching(self, tmp_path):
"""Test RSS caching on network failure."""
# First request with working fetcher
fetcher1 = MockFetcher()
service1 = MfpService(cache_dir=tmp_path, fetcher=fetcher1)
service1.list_episodes(with_chapters_only=False)
# Second request with failing fetcher - should use cache
fetcher2 = MockFetcher(should_fail=True)
service2 = MfpService(cache_dir=tmp_path, fetcher=fetcher2)
episodes = service2.list_episodes(with_chapters_only=False)
assert len(episodes) == 3
def test_no_cache_network_fail(self, tmp_path):
"""Test error when no cache and network fails."""
fetcher = MockFetcher(should_fail=True)
service = MfpService(cache_dir=tmp_path, fetcher=fetcher)
with pytest.raises(RuntimeError, match="Cannot fetch RSS"):
service.list_episodes(with_chapters_only=False)
class TestCuratorExtraction:
"""Tests for curator name extraction from URLs."""
@pytest.fixture
def service(self, tmp_path):
"""Create a service with various curator names in RSS."""
rss = '''<?xml version="1.0"?>
<rss version="2.0">
<channel>
<item>
<title>Episode 1</title>
<enclosure url="https://example.com/music_for_programming_01-simple_name.mp3" type="audio/mpeg"/>
</item>
<item>
<title>Episode 2</title>
<enclosure url="https://example.com/music_for_programming_02-name_with_multiple_parts.mp3" type="audio/mpeg"/>
</item>
</channel>
</rss>
'''
return MfpService(cache_dir=tmp_path, fetcher=MockFetcher(rss))
def test_simple_name(self, service):
"""Test simple curator name extraction."""
episode = service.get_episode(1)
assert episode.curator == "simple name"
def test_multi_part_name(self, service):
"""Test multi-part curator name."""
episode = service.get_episode(2)
assert episode.curator == "name with multiple parts"
class TestChapterDistribution:
"""Tests for chapter file distribution from package."""
@pytest.fixture
def mock_fetcher(self):
"""Create a mock fetcher."""
return MockFetcher()
@pytest.fixture
def service(self, tmp_path, mock_fetcher):
"""Create an MFP service with mock fetcher and temp cache."""
return MfpService(cache_dir=tmp_path, fetcher=mock_fetcher)
def test_get_package_mfp_dir_returns_valid_path(self, service):
"""Test get_package_mfp_dir() returns a valid path with expected content."""
package_dir = service.get_package_mfp_dir()
# Should return a valid Path
assert package_dir is not None
assert isinstance(package_dir, Path)
assert package_dir.is_dir()
# Should contain the bundled chapter files
expected_files = [
"music_for_programming_49-julien_mier.cue",
"music_for_programming_49-julien_mier.ffmeta",
"chapters.sha256",
]
for filename in expected_files:
file_path = package_dir / filename
assert file_path.exists(), f"Expected file {filename} not found in package dir"
def test_get_package_mfp_dir_checksum_file_valid(self, service):
"""Test that package checksum file has valid format."""
package_dir = service.get_package_mfp_dir()
assert package_dir is not None
checksum_file = package_dir / "chapters.sha256"
assert checksum_file.exists()
# Load and validate checksums
checksums = service._load_checksums(checksum_file)
assert len(checksums) >= 2 # At least CUE and FFmeta for episode 49
assert "music_for_programming_49-julien_mier.cue" in checksums
assert "music_for_programming_49-julien_mier.ffmeta" in checksums
# Each checksum should be a valid hex SHA256 (64 chars)
for filename, checksum in checksums.items():
assert len(checksum) == 64, f"Invalid checksum length for {filename}"
assert all(c in "0123456789abcdef" for c in checksum), f"Invalid hex for {filename}"
def test_sync_chapters_detects_package_update(self, service, tmp_path):
"""Test sync detects when package has updated files (different checksum)."""
# First sync - adds files
results1 = service.sync_chapters()
assert "music_for_programming_49-julien_mier.cue" in results1
assert results1["music_for_programming_49-julien_mier.cue"] == "Added"
# Verify local checksum file was created
local_checksum_file = tmp_path / ".chapters.sha256"
assert local_checksum_file.exists()
# Simulate scenario where package was updated with new content
# by modifying the local checksum file to have an old (different) checksum
# This simulates: local file matches OLD package version, package has NEW version
local_checksums = service._load_checksums(local_checksum_file)
# Change the stored checksum to simulate "old version"
local_checksums["music_for_programming_49-julien_mier.cue"] = "0" * 64
service._save_checksums(local_checksums, local_checksum_file)
# Also update local file to match the "old" checksum we just saved
# (The file content doesn't matter, what matters is that its hash matches
# the stored local checksum but differs from package checksum)
local_cue = tmp_path / "music_for_programming_49-julien_mier.cue"
# Write content that produces a different hash than the package file
old_content = "OLD VERSION - will be updated"
local_cue.write_text(old_content)
# Update the local checksum to match this file
actual_hash = service._compute_file_sha256(local_cue)
local_checksums["music_for_programming_49-julien_mier.cue"] = actual_hash
service._save_checksums(local_checksums, local_checksum_file)
# Now sync again - should detect the package has a newer version
results2 = service.sync_chapters()
# The CUE file should be marked as Updated (package version is newer)
assert results2.get("music_for_programming_49-julien_mier.cue") == "Updated"
# Local file should now have package content (not old content)
assert local_cue.read_text() != old_content
def test_on_demand_copy_creates_both_files(self, service, tmp_path):
"""Test that on-demand copy creates both CUE and FFmeta files when available."""
# Ensure no local files exist
local_cue = tmp_path / "music_for_programming_49-julien_mier.cue"
local_ffmeta = tmp_path / "music_for_programming_49-julien_mier.ffmeta"
assert not local_cue.exists()
assert not local_ffmeta.exists()
# Call get_chapters_file which triggers on-demand copy
result = service.get_chapters_file(49)
# Should return FFmeta file
assert result is not None
assert result.suffix == ".ffmeta"
# Both files should now exist locally
assert local_ffmeta.exists(), "FFmeta file should exist after on-demand copy"
assert local_cue.exists(), "CUE file should exist after on-demand copy"
class TestHttpFetcher:
"""Tests for HttpFetcher class."""
def test_timeout_setting(self):
"""Test timeout is configurable."""
fetcher = HttpFetcher(timeout=5)
assert fetcher.timeout == 5
def test_default_timeout(self):
"""Test default timeout."""
fetcher = HttpFetcher()
assert fetcher.timeout == 10
class TestGitHubChapterFetching:
"""Tests for GitHub fallback chapter fetching."""
@pytest.fixture
def mock_fetcher(self):
"""Create a mock RSS fetcher."""
return MockFetcher()
@pytest.fixture
def service(self, tmp_path, mock_fetcher):
"""Create an MFP service with mock fetcher and temp cache."""
return MfpService(cache_dir=tmp_path, fetcher=mock_fetcher)
def test_fetch_chapters_from_github_success_ffmeta(self, service, tmp_path):
"""Test successful FFmeta fetch from GitHub."""
filename_base = "music_for_programming_99-test_artist"
ffmeta_content = ";FFMETADATA1\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=60000\ntitle=Test Chapter\n"
def mock_urlopen(url, timeout=None):
if url.endswith(".ffmeta"):
response = MagicMock()
response.read.return_value = ffmeta_content.encode("utf-8")
response.__enter__ = MagicMock(return_value=response)
response.__exit__ = MagicMock(return_value=False)
return response
raise HTTPError(url, 404, "Not Found", {}, None)
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
result = service._fetch_chapters_from_github(filename_base)
assert result is not None
assert result.suffix == ".ffmeta"
assert result.exists()
assert result.read_text() == ffmeta_content
def test_fetch_chapters_from_github_success_cue(self, service, tmp_path):
"""Test successful CUE fetch from GitHub (when FFmeta not available)."""
filename_base = "music_for_programming_99-test_artist"
cue_content = 'TRACK 01 AUDIO\n TITLE "Test Track"\n INDEX 01 00:00:00\n'
def mock_urlopen(url, timeout=None):
if url.endswith(".ffmeta"):
raise HTTPError(url, 404, "Not Found", {}, None)
if url.endswith(".cue"):
response = MagicMock()
response.read.return_value = cue_content.encode("utf-8")
response.__enter__ = MagicMock(return_value=response)
response.__exit__ = MagicMock(return_value=False)
return response
raise HTTPError(url, 404, "Not Found", {}, None)
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
result = service._fetch_chapters_from_github(filename_base)
assert result is not None
assert result.suffix == ".cue"
assert result.exists()
assert result.read_text() == cue_content
def test_fetch_chapters_from_github_file_cached_locally(self, service, tmp_path):
"""Test that fetched file is cached in local cache directory."""
filename_base = "music_for_programming_99-test_artist"
ffmeta_content = ";FFMETADATA1\n[CHAPTER]\n"
def mock_urlopen(url, timeout=None):
response = MagicMock()
response.read.return_value = ffmeta_content.encode("utf-8")
response.__enter__ = MagicMock(return_value=response)
response.__exit__ = MagicMock(return_value=False)
return response
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
result = service._fetch_chapters_from_github(filename_base)
# Verify file is cached in the local cache directory
expected_path = tmp_path / f"{filename_base}.ffmeta"
assert result == expected_path
assert expected_path.exists()
assert expected_path.read_text() == ffmeta_content
def test_fetch_chapters_from_github_timeout_handling(self, service, tmp_path):
"""Test network timeout is handled gracefully."""
filename_base = "music_for_programming_99-test_artist"
def mock_urlopen(url, timeout=None):
raise TimeoutError("Connection timed out")
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
result = service._fetch_chapters_from_github(filename_base)
assert result is None
def test_fetch_chapters_from_github_404_handling(self, service, tmp_path):
"""Test 404 response (no chapters available) is handled gracefully."""
filename_base = "music_for_programming_99-test_artist"
def mock_urlopen(url, timeout=None):
raise HTTPError(url, 404, "Not Found", {}, None)
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
result = service._fetch_chapters_from_github(filename_base)
assert result is None
def test_fetch_chapters_from_github_network_error_handling(self, service, tmp_path):
"""Test network error is handled gracefully."""
filename_base = "music_for_programming_99-test_artist"
def mock_urlopen(url, timeout=None):
raise URLError("Network unreachable")
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
result = service._fetch_chapters_from_github(filename_base)
assert result is None
def test_fetch_chapters_from_github_uses_5_second_timeout(self, service, tmp_path):
"""Test that GitHub fetch uses 5-second timeout."""
filename_base = "music_for_programming_99-test_artist"
captured_timeout = None
def mock_urlopen(url, timeout=None):
nonlocal captured_timeout
captured_timeout = timeout
raise HTTPError(url, 404, "Not Found", {}, None)
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
service._fetch_chapters_from_github(filename_base)
assert captured_timeout == 5
def test_fetch_chapters_from_github_tries_ffmeta_first(self, service, tmp_path):
"""Test that FFmeta is tried before CUE."""
filename_base = "music_for_programming_99-test_artist"
urls_tried = []
def mock_urlopen(url, timeout=None):
urls_tried.append(url)
raise HTTPError(url, 404, "Not Found", {}, None)
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
service._fetch_chapters_from_github(filename_base)
assert len(urls_tried) == 2
assert urls_tried[0].endswith(".ffmeta")
assert urls_tried[1].endswith(".cue")
def test_existing_local_file_not_overwritten_by_github_fetch(self, service, tmp_path):
"""Test that get_chapters_file doesn't overwrite existing local file with GitHub fetch."""
filename_base = "music_for_programming_49-julien_mier"
original_content = ";FFMETADATA1\n;User customized content\n"
# Create existing local FFmeta file
local_ffmeta = tmp_path / f"{filename_base}.ffmeta"
local_ffmeta.write_text(original_content)
# Even if GitHub fetch would be called, it shouldn't be because local file exists
def mock_urlopen(url, timeout=None):
# This should never be reached for this filename_base
raise AssertionError(f"GitHub fetch should not be called when local file exists: {url}")
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
result = service.get_chapters_file(49)
assert result is not None
assert result.read_text() == original_content
def test_get_chapters_file_uses_github_as_fallback(self, service, tmp_path, mock_fetcher):
"""Test that get_chapters_file uses GitHub when local and package files don't exist."""
# Use a custom RSS with an episode that doesn't have bundled chapters
rss_with_ep99 = '''<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<title>Episode 99: Test Artist</title>
<enclosure url="https://datashat.net/music_for_programming_99-test_artist.mp3" length="145678901" type="audio/mpeg"/>
</item>
</channel>
</rss>
'''
mock_fetcher.content = rss_with_ep99
service._episodes_cache = None # Clear cache to pick up new RSS
ffmeta_content = ";FFMETADATA1\n[CHAPTER]\nSTART=0\ntitle=From GitHub\n"
github_was_called = False
def mock_urlopen(url, timeout=None):
nonlocal github_was_called
if "github" in url.lower() or "raw.githubusercontent.com" in url:
github_was_called = True
if url.endswith(".ffmeta"):
response = MagicMock()
response.read.return_value = ffmeta_content.encode("utf-8")
response.__enter__ = MagicMock(return_value=response)
response.__exit__ = MagicMock(return_value=False)
return response
raise HTTPError(url, 404, "Not Found", {}, None)
with patch("voice_mode.dj.mfp.urlopen", mock_urlopen):
result = service.get_chapters_file(99)
assert github_was_called, "GitHub should be called as fallback"
assert result is not None
assert result.exists()