e07c9ec77b
Several weeks of work accumulated without being committed. Grouped here for clarity; see CHANGELOG.md [Unreleased] for the user-facing summary. Highlights ---------- P1 #2 — ISO 639-2/B canonical migration - New Language VO + LanguageRegistry (alfred/domain/shared/knowledge/). - iso_languages.yaml as single source of truth for language codes. - SubtitleKnowledgeBase now delegates lookup to LanguageRegistry; subtitles.yaml only declares subtitle-specific tokens (vostfr, vf, vff, …). - SubtitlePreferences default → ["fre", "eng"]; subtitle filenames written as {iso639_2b}.srt (legacy fr.srt still read via alias). - Scanner: dropped _LANG_KEYWORDS / _SDH_TOKENS / _FORCED_TOKENS / SUBTITLE_EXTENSIONS hardcoded dicts. - Fixed: 'hi' token no longer marks SDH (conflicted with Hindi alias). - Added settings.min_movie_size_bytes (was a module constant). P1 #3 — Release parser unification + data-driven tokenizer - parse_release() is now the single source of truth for release-name parsing. - alfred/knowledge/release/separators.yaml declares the token separators used by the tokenizer (., space, [, ], (, ), _). New conventions can be added without code changes. - Tokenizer now splits on any configured separator instead of name.split('.'). Releases like 'The Father (2020) [1080p] [WEBRip] [5.1] [YTS.MX]' parse via the direct path without sanitization fallback. - Site-tag extraction always runs first; well-formedness only rejects truly forbidden chars. - _parse_season_episode() extended with NxNN / NxNNxNN alt forms. - Removed dead helpers: _sanitize, _normalize. Domain cleanup - Deleted fossil services with zero production callers: alfred/domain/movies/services.py alfred/domain/tv_shows/services.py alfred/domain/subtitles/services.py (replaced by subtitles/services/ package) alfred/domain/subtitles/repositories.py - Split monolithic subtitle services into a package (identifier, matcher, placer, pattern_detector, utils) + dedicated knowledge/ package. - MediaInfo split into dedicated package (alfred/domain/shared/media/: audio, video, subtitle, info, matching). Persistence cleanup - Removed dead JSON repositories (movie/subtitle/tvshow_repository.py). Tests - Major expansion of the test suite organized to mirror the source tree. - Removed obsolete *_edge_cases test files superseded by structured tests. - Suite: 990 passed, 8 skipped. Misc - .gitignore: exclude env_backup/ and *.bak. - Adjustments across agent/llm, app.py, application/filesystem, and infrastructure/filesystem to align with the new domain layout.
333 lines
9.7 KiB
Python
333 lines
9.7 KiB
Python
"""Shared pytest fixtures for the Alfred test suite.
|
|
|
|
Provides three categories of fixtures used across all test packages:
|
|
|
|
1. **Isolation** — ``mock_memory_storage_dir`` (autouse) and ``temp_dir``
|
|
ensure no test ever touches the real ``data/`` directory.
|
|
2. **Memory builders** — ``memory``, ``memory_with_config``,
|
|
``memory_with_history``, ``memory_with_search_results``,
|
|
``memory_with_library`` produce ``Memory`` instances in known states for
|
|
tests that consume the global singleton.
|
|
3. **Test doubles** — ``mock_llm``, ``mock_llm_with_tool_call``,
|
|
``mock_tmdb_client``, ``mock_knaben_client``, ``mock_qbittorrent_client``,
|
|
``mock_deepseek``, and the filesystem fixture ``real_folder``.
|
|
|
|
All memory fixtures use the current component-based LTM API:
|
|
``ltm.library_paths.set(collection, path)`` and
|
|
``ltm.workspace.download``/``torrent``. Legacy flat attributes
|
|
(``movie_folder``, ``tvshow_folder``, ``download_folder``) no longer exist.
|
|
"""
|
|
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock
|
|
|
|
import pytest
|
|
|
|
from alfred.infrastructure.persistence import Memory, set_memory
|
|
from alfred.settings import settings
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_settings():
|
|
"""Create a mock Settings instance for testing."""
|
|
return settings
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_dir():
|
|
"""Create a temporary directory for tests."""
|
|
dirpath = tempfile.mkdtemp()
|
|
yield Path(dirpath)
|
|
shutil.rmtree(dirpath)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_memory_storage_dir(monkeypatch):
|
|
"""Override MEMORY_STORAGE_DIR for all tests to use a temp directory."""
|
|
test_dir = tempfile.mkdtemp()
|
|
monkeypatch.setenv("MEMORY_STORAGE_DIR", test_dir)
|
|
yield
|
|
# Cleanup
|
|
shutil.rmtree(test_dir, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def memory(temp_dir):
|
|
"""Create a fresh Memory instance for testing."""
|
|
mem = Memory(storage_dir=str(temp_dir))
|
|
set_memory(mem)
|
|
yield mem
|
|
|
|
|
|
@pytest.fixture
|
|
def memory_with_config(memory):
|
|
"""Memory with pre-configured workspace and library paths.
|
|
|
|
Uses the current component-based LTM API. The values are arbitrary
|
|
placeholders — tests that care about the actual paths should override.
|
|
"""
|
|
memory.ltm.workspace.download = "/tmp/downloads"
|
|
memory.ltm.workspace.torrent = "/tmp/torrents"
|
|
memory.ltm.library_paths.set("movies", "/tmp/movies")
|
|
memory.ltm.library_paths.set("tv_shows", "/tmp/tvshows")
|
|
return memory
|
|
|
|
|
|
@pytest.fixture
|
|
def memory_with_search_results(memory):
|
|
"""Memory with pre-populated search results."""
|
|
memory.episodic.store_search_results(
|
|
query="Inception 1080p",
|
|
results=[
|
|
{
|
|
"name": "Inception.2010.1080p.BluRay.x264",
|
|
"size": "2.5 GB",
|
|
"seeders": 150,
|
|
"leechers": 10,
|
|
"magnet": "magnet:?xt=urn:btih:abc123",
|
|
"tracker": "ThePirateBay",
|
|
},
|
|
{
|
|
"name": "Inception.2010.1080p.WEB-DL.x265",
|
|
"size": "1.8 GB",
|
|
"seeders": 80,
|
|
"leechers": 5,
|
|
"magnet": "magnet:?xt=urn:btih:def456",
|
|
"tracker": "1337x",
|
|
},
|
|
{
|
|
"name": "Inception.2010.720p.BluRay",
|
|
"size": "1.2 GB",
|
|
"seeders": 45,
|
|
"leechers": 2,
|
|
"magnet": "magnet:?xt=urn:btih:ghi789",
|
|
"tracker": "RARBG",
|
|
},
|
|
],
|
|
search_type="torrent",
|
|
)
|
|
return memory
|
|
|
|
|
|
@pytest.fixture
|
|
def memory_with_history(memory):
|
|
"""Memory with conversation history."""
|
|
memory.stm.add_message("user", "Hello")
|
|
memory.stm.add_message("assistant", "Hi! How can I help you?")
|
|
memory.stm.add_message("user", "Find me Inception")
|
|
memory.stm.add_message("assistant", "I found Inception (2010)...")
|
|
return memory
|
|
|
|
|
|
@pytest.fixture
|
|
def memory_with_library(memory):
|
|
"""Memory pre-populated with movies and TV shows.
|
|
|
|
Uses the current ``Library`` component (``library.movies`` and
|
|
``library.tv_shows`` lists of dicts).
|
|
"""
|
|
memory.ltm.library.movies = [
|
|
{
|
|
"imdb_id": "tt1375666",
|
|
"title": "Inception",
|
|
"release_year": 2010,
|
|
"quality": "1080p",
|
|
"file_path": "/movies/Inception.2010.1080p.mkv",
|
|
"added_at": "2024-01-15T10:30:00",
|
|
},
|
|
{
|
|
"imdb_id": "tt0816692",
|
|
"title": "Interstellar",
|
|
"release_year": 2014,
|
|
"quality": "4K",
|
|
"file_path": "/movies/Interstellar.2014.4K.mkv",
|
|
"added_at": "2024-01-16T14:20:00",
|
|
},
|
|
]
|
|
memory.ltm.library.tv_shows = [
|
|
{
|
|
"imdb_id": "tt0944947",
|
|
"title": "Game of Thrones",
|
|
"seasons_count": 8,
|
|
"status": "ended",
|
|
"added_at": "2024-01-10T09:00:00",
|
|
},
|
|
]
|
|
return memory
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm():
|
|
"""Create a mock LLM client that returns OpenAI-compatible format."""
|
|
llm = Mock()
|
|
|
|
# Return OpenAI-style message dict without tool calls
|
|
def complete_func(messages, tools=None):
|
|
return {"role": "assistant", "content": "I found what you're looking for!"}
|
|
|
|
llm.complete = Mock(side_effect=complete_func)
|
|
return llm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm_with_tool_call():
|
|
"""Create a mock LLM that returns a tool call then a response."""
|
|
llm = Mock()
|
|
|
|
# First call returns a tool call, second returns final response
|
|
def complete_side_effect(messages, tools=None):
|
|
if not hasattr(complete_side_effect, "call_count"):
|
|
complete_side_effect.call_count = 0
|
|
complete_side_effect.call_count += 1
|
|
|
|
if complete_side_effect.call_count == 1:
|
|
# First call: return tool call
|
|
return {
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_123",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "find_torrent",
|
|
"arguments": '{"media_title": "Inception"}',
|
|
},
|
|
}
|
|
],
|
|
}
|
|
else:
|
|
# Second call: return final response
|
|
return {"role": "assistant", "content": "I found 3 torrents for Inception!"}
|
|
|
|
llm.complete = Mock(side_effect=complete_side_effect)
|
|
return llm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_tmdb_client():
|
|
"""Create a mock TMDB client."""
|
|
client = Mock()
|
|
client.search_movie = Mock(
|
|
return_value=Mock(
|
|
results=[
|
|
Mock(
|
|
id=27205,
|
|
title="Inception",
|
|
release_date="2010-07-16",
|
|
overview="A thief who steals corporate secrets...",
|
|
)
|
|
]
|
|
)
|
|
)
|
|
client.get_external_ids = Mock(return_value={"imdb_id": "tt1375666"})
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_knaben_client():
|
|
"""Create a mock Knaben client."""
|
|
client = Mock()
|
|
client.search = Mock(
|
|
return_value=[
|
|
Mock(
|
|
title="Inception.2010.1080p.BluRay",
|
|
size="2.5 GB",
|
|
seeders=150,
|
|
leechers=10,
|
|
magnet="magnet:?xt=urn:btih:abc123",
|
|
info_hash="abc123",
|
|
tracker="TPB",
|
|
upload_date="2024-01-01",
|
|
category="Movies",
|
|
),
|
|
]
|
|
)
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_qbittorrent_client():
|
|
"""Create a mock qBittorrent client."""
|
|
client = Mock()
|
|
client.add_torrent = Mock(return_value=True)
|
|
client.get_torrents = Mock(return_value=[])
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def real_folder(temp_dir):
|
|
"""Create a real folder structure for filesystem tests."""
|
|
downloads = temp_dir / "downloads"
|
|
movies = temp_dir / "movies"
|
|
tvshows = temp_dir / "tvshows"
|
|
|
|
downloads.mkdir()
|
|
movies.mkdir()
|
|
tvshows.mkdir()
|
|
|
|
# Create some test files
|
|
(downloads / "test_movie.mkv").touch()
|
|
(downloads / "test_series").mkdir()
|
|
(downloads / "test_series" / "episode1.mkv").touch()
|
|
|
|
return {
|
|
"root": temp_dir,
|
|
"downloads": downloads,
|
|
"movies": movies,
|
|
"tvshows": tvshows,
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def mock_deepseek():
|
|
"""
|
|
Mock DeepSeekClient for individual tests that need it.
|
|
This prevents real API calls in tests that use this fixture.
|
|
|
|
Usage:
|
|
def test_something(mock_deepseek):
|
|
# Your test code here
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
# Save the original module if it exists
|
|
original_module = sys.modules.get("agent.llm.deepseek")
|
|
|
|
# Create a mock module for deepseek
|
|
mock_deepseek_module = MagicMock()
|
|
|
|
class MockDeepSeekClient:
|
|
def __init__(self, *args, **kwargs):
|
|
self.complete = Mock(return_value="Mocked LLM response")
|
|
|
|
mock_deepseek_module.DeepSeekClient = MockDeepSeekClient
|
|
|
|
# Inject the mock
|
|
sys.modules["agent.llm.deepseek"] = mock_deepseek_module
|
|
|
|
yield mock_deepseek_module
|
|
|
|
# Restore the original module
|
|
if original_module is not None:
|
|
sys.modules["agent.llm.deepseek"] = original_module
|
|
elif "agent.llm.deepseek" in sys.modules:
|
|
del sys.modules["agent.llm.deepseek"]
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_agent_step():
|
|
"""
|
|
Fixture to easily mock the agent's step method in API tests.
|
|
Returns a context manager that patches app.agent.step.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
def _mock_step(return_value="Mocked agent response"):
|
|
return patch("app.agent.step", return_value=return_value)
|
|
|
|
return _mock_step
|