git commit -m "feat: major architectural refactor
- Refactor memory system (episodic/STM/LTM with components) - Implement complete subtitle domain (scanner, matcher, placer) - Add YAML workflow infrastructure - Externalize knowledge base (patterns, release groups) - Add comprehensive testing suite - Create manual testing CLIs"
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Tests for alfred.agent.registry — tool registration and JSON schema generation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.agent.registry import Tool, _create_tool_from_function, make_tools
|
||||
from alfred.settings import settings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _create_tool_from_function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateToolFromFunction:
|
||||
|
||||
def test_name_from_function(self):
|
||||
def my_tool(x: str) -> dict:
|
||||
"""Does something."""
|
||||
return {}
|
||||
tool = _create_tool_from_function(my_tool)
|
||||
assert tool.name == "my_tool"
|
||||
|
||||
def test_description_from_docstring_first_line(self):
|
||||
def my_tool(x: str) -> dict:
|
||||
"""First line description.
|
||||
|
||||
More details here.
|
||||
"""
|
||||
return {}
|
||||
tool = _create_tool_from_function(my_tool)
|
||||
assert tool.description == "First line description."
|
||||
|
||||
def test_description_fallback_to_name(self):
|
||||
def no_doc(x: str) -> dict:
|
||||
return {}
|
||||
tool = _create_tool_from_function(no_doc)
|
||||
assert tool.description == "no_doc"
|
||||
|
||||
def test_required_params_without_default(self):
|
||||
def tool(a: str, b: int) -> dict:
|
||||
"""Tool."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert "a" in t.parameters["required"]
|
||||
assert "b" in t.parameters["required"]
|
||||
|
||||
def test_optional_params_not_required(self):
|
||||
def tool(a: str, b: str = "default") -> dict:
|
||||
"""Tool."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert "a" in t.parameters["required"]
|
||||
assert "b" not in t.parameters["required"]
|
||||
|
||||
def test_none_default_not_required(self):
|
||||
def tool(a: str, b: str | None = None) -> dict:
|
||||
"""Tool."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert "b" not in t.parameters["required"]
|
||||
|
||||
def test_type_mapping_str(self):
|
||||
def tool(x: str) -> dict:
|
||||
"""T."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert t.parameters["properties"]["x"]["type"] == "string"
|
||||
|
||||
def test_type_mapping_int(self):
|
||||
def tool(x: int) -> dict:
|
||||
"""T."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert t.parameters["properties"]["x"]["type"] == "integer"
|
||||
|
||||
def test_type_mapping_float(self):
|
||||
def tool(x: float) -> dict:
|
||||
"""T."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert t.parameters["properties"]["x"]["type"] == "number"
|
||||
|
||||
def test_type_mapping_bool(self):
|
||||
def tool(x: bool) -> dict:
|
||||
"""T."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert t.parameters["properties"]["x"]["type"] == "boolean"
|
||||
|
||||
def test_unknown_type_defaults_to_string(self):
|
||||
def tool(x: list) -> dict:
|
||||
"""T."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert t.parameters["properties"]["x"]["type"] == "string"
|
||||
|
||||
def test_no_annotation_defaults_to_string(self):
|
||||
def tool(x) -> dict:
|
||||
"""T."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert t.parameters["properties"]["x"]["type"] == "string"
|
||||
|
||||
def test_self_param_excluded(self):
|
||||
class MyClass:
|
||||
def tool(self, x: str) -> dict:
|
||||
"""T."""
|
||||
return {}
|
||||
t = _create_tool_from_function(MyClass().tool)
|
||||
assert "self" not in t.parameters["properties"]
|
||||
|
||||
def test_parameters_schema_structure(self):
|
||||
def tool(a: str, b: int = 0) -> dict:
|
||||
"""T."""
|
||||
return {}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert t.parameters["type"] == "object"
|
||||
assert "properties" in t.parameters
|
||||
assert "required" in t.parameters
|
||||
|
||||
def test_func_stored_on_tool(self):
|
||||
def tool(x: str) -> dict:
|
||||
"""T."""
|
||||
return {"x": x}
|
||||
t = _create_tool_from_function(tool)
|
||||
assert t.func("hello") == {"x": "hello"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# make_tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMakeTools:
|
||||
|
||||
def test_returns_dict(self):
|
||||
tools = make_tools(settings)
|
||||
assert isinstance(tools, dict)
|
||||
|
||||
def test_all_expected_tools_present(self):
|
||||
tools = make_tools(settings)
|
||||
expected = {
|
||||
"set_path_for_folder",
|
||||
"list_folder",
|
||||
"resolve_destination",
|
||||
"move_media",
|
||||
"manage_subtitles",
|
||||
"create_seed_links",
|
||||
"learn",
|
||||
"find_media_imdb_id",
|
||||
"find_torrent",
|
||||
"add_torrent_by_index",
|
||||
"add_torrent_to_qbittorrent",
|
||||
"get_torrent_by_index",
|
||||
"set_language",
|
||||
}
|
||||
assert expected.issubset(tools.keys())
|
||||
|
||||
def test_each_tool_is_tool_instance(self):
|
||||
tools = make_tools(settings)
|
||||
for name, tool in tools.items():
|
||||
assert isinstance(tool, Tool), f"{name} is not a Tool instance"
|
||||
|
||||
def test_each_tool_has_callable_func(self):
|
||||
tools = make_tools(settings)
|
||||
for name, tool in tools.items():
|
||||
assert callable(tool.func), f"{name}.func is not callable"
|
||||
|
||||
def test_tool_name_matches_key(self):
|
||||
tools = make_tools(settings)
|
||||
for key, tool in tools.items():
|
||||
assert tool.name == key
|
||||
|
||||
def test_resolve_destination_schema(self):
|
||||
tools = make_tools(settings)
|
||||
t = tools["resolve_destination"]
|
||||
props = t.parameters["properties"]
|
||||
required = t.parameters["required"]
|
||||
# Required args
|
||||
assert "release_name" in required
|
||||
assert "source_file" in required
|
||||
assert "tmdb_title" in required
|
||||
assert "tmdb_year" in required
|
||||
# Optional args not required
|
||||
assert "tmdb_episode_title" not in required
|
||||
assert "confirmed_folder" not in required
|
||||
# tmdb_year is int
|
||||
assert props["tmdb_year"]["type"] == "integer"
|
||||
|
||||
def test_move_media_schema(self):
|
||||
tools = make_tools(settings)
|
||||
t = tools["move_media"]
|
||||
required = t.parameters["required"]
|
||||
assert "source" in required
|
||||
assert "destination" in required
|
||||
|
||||
def test_create_seed_links_schema(self):
|
||||
tools = make_tools(settings)
|
||||
t = tools["create_seed_links"]
|
||||
required = t.parameters["required"]
|
||||
assert "library_file" in required
|
||||
assert "original_download_folder" in required
|
||||
|
||||
def test_no_duplicate_tools(self):
|
||||
tools = make_tools(settings)
|
||||
# dict keys are unique by definition, but verify no name conflicts
|
||||
names = [t.name for t in tools.values()]
|
||||
assert len(names) == len(set(names))
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Fixtures for application-layer tests."""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.infrastructure.persistence import Memory, set_memory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_temp(tmp_path):
|
||||
"""Real folder structure: downloads, movies, tv_shows, torrents."""
|
||||
(tmp_path / "downloads").mkdir()
|
||||
(tmp_path / "movies").mkdir()
|
||||
(tmp_path / "tv_shows").mkdir()
|
||||
(tmp_path / "torrents").mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_configured(app_temp, tmp_path):
|
||||
"""
|
||||
Fresh Memory with library_paths and workspace configured using the real API.
|
||||
Replaces the broken memory_with_config from root conftest for these tests.
|
||||
"""
|
||||
import tempfile, os
|
||||
storage = tempfile.mkdtemp()
|
||||
mem = Memory(storage_dir=storage)
|
||||
set_memory(mem)
|
||||
|
||||
mem.ltm.workspace.download = str(app_temp / "downloads")
|
||||
mem.ltm.workspace.torrent = str(app_temp / "torrents")
|
||||
mem.ltm.library_paths.set("movie", str(app_temp / "movies"))
|
||||
mem.ltm.library_paths.set("tv_show", str(app_temp / "tv_shows"))
|
||||
mem.save()
|
||||
|
||||
yield mem
|
||||
|
||||
shutil.rmtree(storage, ignore_errors=True)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Tests for alfred.application.filesystem.create_seed_links.CreateSeedLinksUseCase
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.filesystem.create_seed_links import CreateSeedLinksUseCase
|
||||
from alfred.infrastructure.filesystem.file_manager import FileManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fm():
|
||||
return FileManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def use_case(fm):
|
||||
return CreateSeedLinksUseCase(fm)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_env(tmp_path_factory):
|
||||
"""
|
||||
Realistic post-move environment (uses its own tmp dir, independent of app_temp):
|
||||
- library video file (hard-linked from original)
|
||||
- original download folder with remaining files
|
||||
- torrents root folder
|
||||
"""
|
||||
d = tmp_path_factory.mktemp("seed_env")
|
||||
|
||||
lib_dir = d / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
lib_dir.mkdir(parents=True)
|
||||
lib_video = lib_dir / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
||||
lib_video.write_bytes(b"video")
|
||||
|
||||
dl = d / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
dl.mkdir(parents=True)
|
||||
(dl / "KONTRAST.txt").write_text("release notes")
|
||||
(dl / "[TGx]info.txt").write_text("tgx")
|
||||
subs = dl / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
|
||||
subs.mkdir(parents=True)
|
||||
(subs / "2_eng,English [CC][SDH].srt").write_text("1\n00:00:01 --> 00:00:02\nHello\n")
|
||||
|
||||
torrents = d / "torrents"
|
||||
torrents.mkdir()
|
||||
|
||||
return lib_video, dl, torrents
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateSeedLinksHappyPath:
|
||||
|
||||
def test_ok_when_torrent_folder_configured(self, use_case, seed_env, memory_configured):
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
mem = get_memory()
|
||||
lib_video, dl, torrents = seed_env
|
||||
mem.ltm.workspace.torrent = str(torrents)
|
||||
mem.save()
|
||||
|
||||
result = use_case.execute(str(lib_video), str(dl))
|
||||
|
||||
assert result.status == "ok"
|
||||
assert result.torrent_subfolder is not None
|
||||
assert result.linked_file is not None
|
||||
assert result.copied_count > 0
|
||||
|
||||
def test_to_dict_ok(self, use_case, seed_env, memory_configured):
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
mem = get_memory()
|
||||
lib_video, dl, torrents = seed_env
|
||||
mem.ltm.workspace.torrent = str(torrents)
|
||||
mem.save()
|
||||
|
||||
d = use_case.execute(str(lib_video), str(dl)).to_dict()
|
||||
assert d["status"] == "ok"
|
||||
assert "torrent_subfolder" in d
|
||||
assert "copied_files" in d
|
||||
assert isinstance(d["copied_files"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error: torrent folder not configured
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateSeedLinksErrors:
|
||||
|
||||
def test_error_when_torrent_not_configured(self, use_case, seed_env, memory):
|
||||
lib_video, dl, _ = seed_env
|
||||
result = use_case.execute(str(lib_video), str(dl))
|
||||
|
||||
assert result.status == "error"
|
||||
assert result.error == "torrent_folder_not_set"
|
||||
assert result.message is not None
|
||||
|
||||
def test_to_dict_error(self, use_case, seed_env, memory):
|
||||
lib_video, dl, _ = seed_env
|
||||
d = use_case.execute(str(lib_video), str(dl)).to_dict()
|
||||
assert d["status"] == "error"
|
||||
assert "error" in d
|
||||
assert "message" in d
|
||||
|
||||
def test_error_delegates_to_file_manager(self, memory_configured):
|
||||
"""FileManager errors are propagated correctly."""
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
mem = get_memory()
|
||||
# torrent already configured by memory_configured fixture
|
||||
# library_file does not exist → should propagate error from FileManager
|
||||
uc = CreateSeedLinksUseCase(FileManager())
|
||||
result = uc.execute("/nonexistent/lib.mkv", "/nonexistent/dl")
|
||||
assert result.status == "error"
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Tests for ListFolderUseCase and MoveMediaUseCase."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from alfred.application.filesystem.list_folder import ListFolderUseCase
|
||||
from alfred.application.filesystem.move_media import MoveMediaUseCase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ListFolderUseCase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestListFolderUseCase:
|
||||
|
||||
def _use_case(self, fm_result):
|
||||
fm = MagicMock()
|
||||
fm.list_folder.return_value = fm_result
|
||||
return ListFolderUseCase(fm)
|
||||
|
||||
def test_success_returns_response(self):
|
||||
uc = self._use_case({
|
||||
"status": "ok",
|
||||
"folder_type": "download",
|
||||
"path": ".",
|
||||
"entries": ["movie.mkv", "show/"],
|
||||
"count": 2,
|
||||
})
|
||||
resp = uc.execute("download")
|
||||
assert resp.status == "ok"
|
||||
assert resp.folder_type == "download"
|
||||
assert resp.path == "."
|
||||
assert resp.entries == ["movie.mkv", "show/"]
|
||||
assert resp.count == 2
|
||||
|
||||
def test_error_propagates(self):
|
||||
uc = self._use_case({
|
||||
"status": "error",
|
||||
"error": "folder_not_set",
|
||||
"message": "Download folder not configured.",
|
||||
})
|
||||
resp = uc.execute("download")
|
||||
assert resp.status == "error"
|
||||
assert resp.error == "folder_not_set"
|
||||
assert resp.message == "Download folder not configured."
|
||||
|
||||
def test_delegates_folder_type_and_path(self):
|
||||
fm = MagicMock()
|
||||
fm.list_folder.return_value = {
|
||||
"status": "ok",
|
||||
"folder_type": "tv_show",
|
||||
"path": "Breaking Bad",
|
||||
"entries": [],
|
||||
"count": 0,
|
||||
}
|
||||
uc = ListFolderUseCase(fm)
|
||||
uc.execute("tv_show", "Breaking Bad")
|
||||
fm.list_folder.assert_called_once_with("tv_show", "Breaking Bad")
|
||||
|
||||
def test_default_path_is_dot(self):
|
||||
fm = MagicMock()
|
||||
fm.list_folder.return_value = {
|
||||
"status": "ok", "folder_type": "download",
|
||||
"path": ".", "entries": [], "count": 0,
|
||||
}
|
||||
uc = ListFolderUseCase(fm)
|
||||
uc.execute("download")
|
||||
fm.list_folder.assert_called_once_with("download", ".")
|
||||
|
||||
def test_success_response_has_no_error(self):
|
||||
uc = self._use_case({
|
||||
"status": "ok",
|
||||
"folder_type": "movie",
|
||||
"path": ".",
|
||||
"entries": [],
|
||||
"count": 0,
|
||||
})
|
||||
resp = uc.execute("movie")
|
||||
assert resp.error is None
|
||||
|
||||
def test_error_response_has_no_entries(self):
|
||||
uc = self._use_case({
|
||||
"status": "error",
|
||||
"error": "not_found",
|
||||
"message": "Path does not exist",
|
||||
})
|
||||
resp = uc.execute("download", "some/path")
|
||||
assert resp.entries is None
|
||||
assert resp.count is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MoveMediaUseCase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMoveMediaUseCase:
|
||||
|
||||
def _use_case(self, fm_result):
|
||||
fm = MagicMock()
|
||||
fm.move_file.return_value = fm_result
|
||||
return MoveMediaUseCase(fm)
|
||||
|
||||
def test_success_returns_response(self, tmp_path):
|
||||
src = str(tmp_path / "src.mkv")
|
||||
dst = str(tmp_path / "dst.mkv")
|
||||
uc = self._use_case({
|
||||
"status": "ok",
|
||||
"source": src,
|
||||
"destination": dst,
|
||||
"filename": "dst.mkv",
|
||||
"size": 1024,
|
||||
})
|
||||
resp = uc.execute(src, dst)
|
||||
assert resp.status == "ok"
|
||||
assert resp.source == src
|
||||
assert resp.destination == dst
|
||||
assert resp.filename == "dst.mkv"
|
||||
assert resp.size == 1024
|
||||
|
||||
def test_error_propagates(self, tmp_path):
|
||||
uc = self._use_case({
|
||||
"status": "error",
|
||||
"error": "source_not_found",
|
||||
"message": "Source does not exist: /ghost.mkv",
|
||||
})
|
||||
resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv"))
|
||||
assert resp.status == "error"
|
||||
assert resp.error == "source_not_found"
|
||||
|
||||
def test_delegates_to_file_manager(self, tmp_path):
|
||||
src = "/downloads/movie.mkv"
|
||||
dst = "/movies/Movie.2024/movie.mkv"
|
||||
fm = MagicMock()
|
||||
fm.move_file.return_value = {
|
||||
"status": "ok", "source": src, "destination": dst,
|
||||
"filename": "movie.mkv", "size": 1,
|
||||
}
|
||||
uc = MoveMediaUseCase(fm)
|
||||
uc.execute(src, dst)
|
||||
fm.move_file.assert_called_once_with(src, dst)
|
||||
|
||||
def test_error_response_has_no_paths(self):
|
||||
uc = self._use_case({
|
||||
"status": "error",
|
||||
"error": "destination_exists",
|
||||
"message": "File already exists",
|
||||
})
|
||||
resp = uc.execute("/src.mkv", "/dst.mkv")
|
||||
assert resp.source is None
|
||||
assert resp.destination is None
|
||||
assert resp.filename is None
|
||||
|
||||
def test_to_dict_success(self, tmp_path):
|
||||
src = "/downloads/movie.mkv"
|
||||
dst = "/movies/movie.mkv"
|
||||
uc = self._use_case({
|
||||
"status": "ok",
|
||||
"source": src,
|
||||
"destination": dst,
|
||||
"filename": "movie.mkv",
|
||||
"size": 2048,
|
||||
})
|
||||
resp = uc.execute(src, dst)
|
||||
d = resp.to_dict()
|
||||
assert d["status"] == "ok"
|
||||
assert d["filename"] == "movie.mkv"
|
||||
assert d["size"] == 2048
|
||||
|
||||
def test_to_dict_error(self):
|
||||
uc = self._use_case({
|
||||
"status": "error",
|
||||
"error": "link_failed",
|
||||
"message": "Cross-device link not permitted",
|
||||
})
|
||||
resp = uc.execute("/src.mkv", "/dst.mkv")
|
||||
d = resp.to_dict()
|
||||
assert d["status"] == "error"
|
||||
assert "error" in d
|
||||
assert "message" in d
|
||||
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
Tests for alfred.application.filesystem.resolve_destination
|
||||
|
||||
Uses a real temp filesystem + a real Memory instance (via conftest fixtures).
|
||||
No network calls — TMDB data is passed in directly.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.filesystem.resolve_destination import (
|
||||
ResolveDestinationUseCase,
|
||||
_find_existing_series_folders,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _use_case():
|
||||
return ResolveDestinationUseCase()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Movies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveMovie:
|
||||
|
||||
def test_basic_movie(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Another.Round.2020.1080p.BluRay.x264-YTS",
|
||||
source_file="/downloads/Another.Round.2020.1080p.BluRay.x264-YTS/Another.Round.2020.1080p.BluRay.x264-YTS.mp4",
|
||||
tmdb_title="Another Round",
|
||||
tmdb_year=2020,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert "Another.Round.2020" in result.series_folder_name
|
||||
assert "1080p.BluRay.x264-YTS" in result.series_folder_name
|
||||
assert result.filename.endswith(".mp4")
|
||||
assert result.season_folder is None
|
||||
|
||||
def test_movie_library_file_path_is_inside_series_folder(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
|
||||
source_file="/downloads/Revolver.2005.1080p.BluRay.x265-RARBG.mkv",
|
||||
tmdb_title="Revolver",
|
||||
tmdb_year=2005,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.library_file.startswith(result.series_folder)
|
||||
|
||||
def test_movie_library_not_set(self, memory):
|
||||
# memory has no library paths configured
|
||||
result = _use_case().execute(
|
||||
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
|
||||
source_file="/downloads/Revolver.2005.1080p.BluRay.x265-RARBG.mkv",
|
||||
tmdb_title="Revolver",
|
||||
tmdb_year=2005,
|
||||
)
|
||||
assert result.status == "error"
|
||||
assert result.error == "library_not_set"
|
||||
|
||||
def test_movie_folder_marked_new(self, memory_configured):
|
||||
# No existing folder → is_new_series_folder = True
|
||||
result = _use_case().execute(
|
||||
release_name="Godzilla.Minus.One.2023.1080p.BluRay.x265-YTS",
|
||||
source_file="/downloads/Godzilla.Minus.One.2023.1080p.BluRay.x265-YTS.mp4",
|
||||
tmdb_title="Godzilla Minus One",
|
||||
tmdb_year=2023,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.is_new_series_folder is True
|
||||
|
||||
def test_movie_sanitises_forbidden_chars_in_title(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Alien.Earth.2024.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Alien.Earth.2024.1080p.WEBRip.x265-KONTRAST.mkv",
|
||||
tmdb_title="Alien: Earth",
|
||||
tmdb_year=2024,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert ":" not in result.series_folder_name
|
||||
|
||||
def test_to_dict_ok(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
|
||||
source_file="/downloads/Revolver.mkv",
|
||||
tmdb_title="Revolver",
|
||||
tmdb_year=2005,
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert d["status"] == "ok"
|
||||
assert "library_file" in d
|
||||
assert "series_folder_name" in d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TV shows — no existing folder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveTVShowNewFolder:
|
||||
|
||||
def test_oz_s01_creates_new_folder(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01.1080p.WEBRip.x265-KONTRAST/Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.is_new_series_folder is True
|
||||
assert result.series_folder_name == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||
assert result.season_folder_name == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
|
||||
def test_tv_library_not_set(self, memory):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.status == "error"
|
||||
assert result.error == "library_not_set"
|
||||
|
||||
def test_single_episode_filename(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Fallout.2024.S02E01.1080p.x265-ELiTE",
|
||||
source_file="/downloads/Fallout.2024.S02E01.1080p.x265-ELiTE.mkv",
|
||||
tmdb_title="Fallout",
|
||||
tmdb_year=2024,
|
||||
tmdb_episode_title="The Beginning",
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert "S02E01" in result.filename
|
||||
assert "The.Beginning" in result.filename
|
||||
assert result.filename.endswith(".mkv")
|
||||
|
||||
def test_season_pack_filename_is_folder_name_plus_ext(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01.1080p.WEBRip.x265-KONTRAST/Oz.S01E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
# Season pack: filename = season_folder_name + ext
|
||||
assert result.filename == result.season_folder_name + ".mp4"
|
||||
|
||||
def test_library_file_is_inside_season_folder(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.library_file.startswith(result.season_folder)
|
||||
assert result.season_folder.startswith(result.series_folder)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TV shows — existing folder matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveTVShowExistingFolder:
|
||||
|
||||
def _make_series_folder(self, tv_root, name):
|
||||
"""Create a series folder in the tv library."""
|
||||
import os
|
||||
path = tv_root / name
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def test_uses_existing_single_folder(self, memory_configured, app_temp):
|
||||
"""When exactly one folder matches title+year, use it regardless of group."""
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
mem = get_memory()
|
||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||
|
||||
existing = tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG"
|
||||
existing.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S02.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S02E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.series_folder_name == "Oz.1997.1080p.WEBRip.x265-RARBG"
|
||||
assert result.is_new_series_folder is False
|
||||
|
||||
def test_needs_clarification_on_multiple_folders(self, memory_configured, app_temp):
|
||||
"""When multiple folders match, return needs_clarification with options."""
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
mem = get_memory()
|
||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||
|
||||
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-RARBG").mkdir(parents=True, exist_ok=True)
|
||||
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _use_case().execute(
|
||||
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Slow.Horses.S05E01.mkv",
|
||||
tmdb_title="Slow Horses",
|
||||
tmdb_year=2022,
|
||||
)
|
||||
assert result.status == "needs_clarification"
|
||||
assert result.question is not None
|
||||
assert len(result.options) == 2
|
||||
assert "Slow.Horses.2022.1080p.WEBRip.x265-RARBG" in result.options
|
||||
assert "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST" in result.options
|
||||
|
||||
def test_confirmed_folder_bypasses_detection(self, memory_configured, app_temp):
|
||||
"""confirmed_folder skips the folder search."""
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
mem = get_memory()
|
||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||
chosen = "Slow.Horses.2022.1080p.WEBRip.x265-RARBG"
|
||||
(tv_root / chosen).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _use_case().execute(
|
||||
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Slow.Horses.S05E01.mkv",
|
||||
tmdb_title="Slow Horses",
|
||||
tmdb_year=2022,
|
||||
confirmed_folder=chosen,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.series_folder_name == chosen
|
||||
|
||||
def test_to_dict_needs_clarification(self, memory_configured, app_temp):
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
mem = get_memory()
|
||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||
(tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir(parents=True, exist_ok=True)
|
||||
(tv_root / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S03.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S03E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert d["status"] == "needs_clarification"
|
||||
assert "question" in d
|
||||
assert isinstance(d["options"], list)
|
||||
|
||||
def test_to_dict_error(self, memory):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert d["status"] == "error"
|
||||
assert "error" in d
|
||||
assert "message" in d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_existing_series_folders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindExistingSeriesFolders:
|
||||
|
||||
def test_empty_library(self, tmp_path):
|
||||
assert _find_existing_series_folders(tmp_path, "Oz", 1997) == []
|
||||
|
||||
def test_nonexistent_root(self, tmp_path):
|
||||
assert _find_existing_series_folders(tmp_path / "nope", "Oz", 1997) == []
|
||||
|
||||
def test_single_match(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
||||
assert result == ["Oz.1997.1080p.WEBRip.x265-KONTRAST"]
|
||||
|
||||
def test_multiple_matches(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
||||
assert len(result) == 2
|
||||
assert sorted(result) == result # sorted
|
||||
|
||||
def test_no_match_different_year(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 2000)
|
||||
assert result == []
|
||||
|
||||
def test_no_match_different_title(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Breaking Bad", 2008)
|
||||
assert result == []
|
||||
|
||||
def test_ignores_files_not_dirs(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
(tmp_path / "Oz.1997.some.file.txt").touch()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_case_insensitive_prefix(self, tmp_path):
|
||||
# Folder stored with mixed case
|
||||
(tmp_path / "OZ.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_title_with_special_chars_sanitised(self, tmp_path):
|
||||
# "Star Wars: Andor" → sanitised (colon removed) + spaces→dots → "Star.Wars.Andor.2022"
|
||||
(tmp_path / "Star.Wars.Andor.2022.1080p.WEBRip.x265-GROUP").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Star Wars: Andor", 2022)
|
||||
assert len(result) == 1
|
||||
+4
-4
@@ -50,10 +50,10 @@ def memory(temp_dir):
|
||||
@pytest.fixture
|
||||
def memory_with_config(memory):
|
||||
"""Memory with pre-configured folders."""
|
||||
memory.ltm.set_config("download_folder", "/tmp/downloads")
|
||||
memory.ltm.set_config("movie_folder", "/tmp/movies")
|
||||
memory.ltm.set_config("tvshow_folder", "/tmp/tvshows")
|
||||
memory.ltm.set_config("torrent_folder", "/tmp/torrents")
|
||||
memory.ltm.download_folder = "/tmp/downloads"
|
||||
memory.ltm.movie_folder = "/tmp/movies"
|
||||
memory.ltm.tvshow_folder = "/tmp/tvshows"
|
||||
memory.ltm.torrent_folder = "/tmp/torrents"
|
||||
return memory
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"""
|
||||
Tests for alfred.domain.media.release_parser
|
||||
|
||||
Real-data cases sourced from /mnt/testipool/downloads/.
|
||||
Covers: parsing, normalisation, naming methods, edge cases.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.media.release_parser import (
|
||||
ParsedRelease,
|
||||
_normalise,
|
||||
_sanitise_for_fs,
|
||||
_strip_episode_from_normalised,
|
||||
parse_release,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _normalise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalise:
|
||||
def test_dots_unchanged(self):
|
||||
assert _normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
|
||||
def test_spaces_become_dots(self):
|
||||
assert _normalise("Oz S01 1080p WEBRip x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
|
||||
def test_double_dots_collapsed(self):
|
||||
assert _normalise("Oz..S01..1080p") == "Oz.S01.1080p"
|
||||
|
||||
def test_leading_trailing_dots_stripped(self):
|
||||
assert _normalise(".Oz.S01.") == "Oz.S01"
|
||||
|
||||
def test_mixed_spaces_and_dots(self):
|
||||
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
result = _normalise("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
|
||||
assert " " not in result
|
||||
assert ".." not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitise_for_fs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSanitiseForFs:
|
||||
def test_clean_string_unchanged(self):
|
||||
assert _sanitise_for_fs("Oz.S01.1080p-KONTRAST") == "Oz.S01.1080p-KONTRAST"
|
||||
|
||||
def test_removes_question_mark(self):
|
||||
assert _sanitise_for_fs("What's Up?") == "What's Up"
|
||||
|
||||
def test_removes_colon(self):
|
||||
assert _sanitise_for_fs("He Said: She Said") == "He Said She Said"
|
||||
|
||||
def test_removes_all_forbidden(self):
|
||||
assert _sanitise_for_fs('a?b:c*d"e<f>g|h\\i') == "abcdefghi"
|
||||
|
||||
def test_apostrophe_kept(self):
|
||||
# apostrophe is not in the forbidden set
|
||||
assert _sanitise_for_fs("What's Up") == "What's Up"
|
||||
|
||||
def test_ellipsis_kept(self):
|
||||
assert _sanitise_for_fs("What If...") == "What If..."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_episode_from_normalised
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStripEpisode:
|
||||
def test_strips_single_episode(self):
|
||||
assert _strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST") \
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
|
||||
def test_strips_multi_episode(self):
|
||||
assert _strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb") \
|
||||
== "Archer.S14.1080p.HULU.WEB-DL-NTb"
|
||||
|
||||
def test_season_pack_unchanged(self):
|
||||
assert _strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST") \
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST") \
|
||||
== "oz.s01.1080p-KONTRAST"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_release — Season packs (dots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSeasonPackDots:
|
||||
"""Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc."""
|
||||
|
||||
def test_oz_s01_kontrast(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
assert p.title == "Oz"
|
||||
assert p.season == 1
|
||||
assert p.episode is None
|
||||
assert p.quality == "1080p"
|
||||
assert p.source == "WEBRip"
|
||||
assert p.codec == "x265"
|
||||
assert p.group == "KONTRAST"
|
||||
assert p.is_season_pack
|
||||
assert not p.is_movie
|
||||
|
||||
def test_fallout_s02_kontrast(self):
|
||||
p = parse_release("Fallout.2024.S02.1080p.WEBRip.x265-KONTRAST")
|
||||
assert p.title == "Fallout"
|
||||
assert p.year == 2024
|
||||
assert p.season == 2
|
||||
assert p.episode is None
|
||||
assert p.group == "KONTRAST"
|
||||
|
||||
def test_archer_s03_edge2020(self):
|
||||
p = parse_release("Archer.2009.S03.1080p.BluRay.DDP.5.1.x265-EDGE2020")
|
||||
assert p.title == "Archer"
|
||||
assert p.year == 2009
|
||||
assert p.season == 3
|
||||
assert p.quality == "1080p"
|
||||
assert p.source == "BluRay"
|
||||
assert p.codec == "x265"
|
||||
assert p.group == "EDGE2020"
|
||||
|
||||
def test_fargo_s05_hulu_webdl(self):
|
||||
p = parse_release("Fargo.S05.1080p.HULU.WEB-DL.x265.10bit-Protozoan")
|
||||
assert p.title == "Fargo"
|
||||
assert p.season == 5
|
||||
assert p.quality == "1080p"
|
||||
assert p.group == "Protozoan"
|
||||
|
||||
def test_xfiles_s01_bluray_rarbg(self):
|
||||
p = parse_release("The.X-Files.S01.1080p.BluRay.x265-RARBG")
|
||||
assert p.title == "The.X-Files"
|
||||
assert p.season == 1
|
||||
assert p.source == "BluRay"
|
||||
assert p.group == "RARBG"
|
||||
|
||||
def test_gilmore_girls_s01_s07_repack(self):
|
||||
p = parse_release("Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH")
|
||||
# Season range — we parse the first season number found
|
||||
assert p.season == 1
|
||||
assert p.group == "MONOLITH"
|
||||
|
||||
def test_plot_against_america_4k(self):
|
||||
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY")
|
||||
assert p.title == "The.Plot.Against.America"
|
||||
assert p.season == 1
|
||||
assert p.quality == "2160p"
|
||||
assert p.group == "SH3LBY"
|
||||
|
||||
def test_foundation_with_year_in_title(self):
|
||||
p = parse_release("Foundation.2021.S01.1080p.WEBRip.x265-RARBG")
|
||||
assert p.title == "Foundation"
|
||||
assert p.year == 2021
|
||||
assert p.season == 1
|
||||
assert p.group == "RARBG"
|
||||
|
||||
def test_gen_v_s02(self):
|
||||
p = parse_release("Gen.V.S02.1080p.WEBRip.x265-KONTRAST")
|
||||
assert p.title == "Gen.V"
|
||||
assert p.season == 2
|
||||
assert p.group == "KONTRAST"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_release — Single episodes (dots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSingleEpisodeDots:
|
||||
"""Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc."""
|
||||
|
||||
def test_fallout_s02e01_elite(self):
|
||||
p = parse_release("Fallout.2024.S02E01.1080p.x265-ELiTE")
|
||||
assert p.title == "Fallout"
|
||||
assert p.year == 2024
|
||||
assert p.season == 2
|
||||
assert p.episode == 1
|
||||
assert p.episode_end is None
|
||||
assert p.group == "ELiTE"
|
||||
assert not p.is_season_pack
|
||||
|
||||
def test_mare_of_easttown_with_episode_title_in_filename(self):
|
||||
# Episode filenames often embed the title — we parse the release folder name
|
||||
p = parse_release("Mare.of.Easttown.S01.1080p.10bit.WEBRip.6CH.x265.HEVC-PSA")
|
||||
assert p.title == "Mare.of.Easttown"
|
||||
assert p.season == 1
|
||||
assert p.group == "PSA"
|
||||
|
||||
def test_it_welcome_to_derry_s01e01(self):
|
||||
p = parse_release("IT.Welcome.to.Derry.S01E01.1080p.x265-ELiTE")
|
||||
assert p.title == "IT.Welcome.to.Derry"
|
||||
assert p.season == 1
|
||||
assert p.episode == 1
|
||||
assert p.group == "ELiTE"
|
||||
|
||||
def test_landman_s02e01(self):
|
||||
p = parse_release("Landman.S02E01.1080p.x265-ELiTE")
|
||||
assert p.title == "Landman"
|
||||
assert p.season == 2
|
||||
assert p.episode == 1
|
||||
|
||||
def test_prodiges_episode_with_number_in_title(self):
|
||||
# "Prodiges.S12E01.1ere.demi-finale..." — accented chars in episode title
|
||||
p = parse_release("Prodiges.S12E01.1080p.WEB.H264-THESYNDiCATE")
|
||||
assert p.title == "Prodiges"
|
||||
assert p.season == 12
|
||||
assert p.episode == 1
|
||||
assert p.group == "THESYNDiCATE"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_release — Multi-episode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiEpisode:
|
||||
def test_archer_triple_episode(self):
|
||||
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
p = parse_release("Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb")
|
||||
assert p.season == 14
|
||||
assert p.episode == 9
|
||||
assert p.episode_end == 10 # only first E-pair captured by regex group 2+3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_release — Movies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMovies:
|
||||
def test_another_round_yts(self):
|
||||
# "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised
|
||||
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
||||
assert p.is_movie
|
||||
assert p.title == "Another.Round"
|
||||
assert p.year == 2020
|
||||
assert p.quality == "1080p"
|
||||
assert p.source == "BluRay"
|
||||
assert p.group == "YTS"
|
||||
|
||||
def test_godzilla_minus_one(self):
|
||||
p = parse_release("Godzilla.Minus.One.2023.1080p.BluRay.x265.10bit.AAC5.1-YTS")
|
||||
assert p.title == "Godzilla.Minus.One"
|
||||
assert p.year == 2023
|
||||
assert p.is_movie
|
||||
assert p.group == "YTS"
|
||||
|
||||
def test_deadwood_movie_2019(self):
|
||||
p = parse_release("Deadwood.The.Movie.2019.1080p.BluRay.x265-RARBG")
|
||||
assert p.year == 2019
|
||||
assert p.is_movie
|
||||
assert p.group == "RARBG"
|
||||
|
||||
def test_revolver_2005_bluray(self):
|
||||
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
|
||||
assert p.title == "Revolver"
|
||||
assert p.year == 2005
|
||||
assert p.is_movie
|
||||
|
||||
def test_the_xfiles_movie_1998(self):
|
||||
p = parse_release("The.X.Files.1998.1080p.BluRay.x265-RARBG")
|
||||
assert p.year == 1998
|
||||
assert p.is_movie
|
||||
assert p.group == "RARBG"
|
||||
|
||||
def test_movie_no_group(self):
|
||||
p = parse_release("Jurassic.Park.1993.1080p.BluRay.x265")
|
||||
assert p.is_movie
|
||||
assert p.year == 1993
|
||||
assert p.group == "UNKNOWN"
|
||||
|
||||
def test_multi_language_movie(self):
|
||||
p = parse_release("Jumanji.1995.MULTi.1080p.DSNP.WEB.H265-THESYNDiCATE")
|
||||
assert p.year == 1995
|
||||
assert p.group == "THESYNDiCATE"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_release — Space-separated (no dots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpaceSeparated:
|
||||
def test_oz_spaces(self):
|
||||
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
|
||||
assert p.title == "Oz"
|
||||
assert p.season == 1
|
||||
assert p.quality == "1080p"
|
||||
assert p.group == "KONTRAST"
|
||||
|
||||
def test_archer_spaces(self):
|
||||
p = parse_release("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
|
||||
assert p.season == 14
|
||||
assert p.episode == 9
|
||||
assert p.group == "NTb"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_release — tech_string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTechString:
|
||||
def test_full_tech(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
assert p.tech_string == "1080p.WEBRip.x265"
|
||||
|
||||
def test_tech_string_used_in_folder_name(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
folder = p.show_folder_name("Oz", 1997)
|
||||
assert "1080p.WEBRip.x265" in folder
|
||||
|
||||
def test_no_tech_fallback(self):
|
||||
p = parse_release("SomeShow.S01")
|
||||
# tech_string is empty, show_folder_name uses "Unknown"
|
||||
folder = p.show_folder_name("SomeShow", 2020)
|
||||
assert "Unknown" in folder
|
||||
|
||||
def test_4k_hdr(self):
|
||||
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY")
|
||||
assert p.quality == "2160p"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ParsedRelease — naming methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNamingMethods:
|
||||
|
||||
def test_show_folder_name(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
assert p.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||
|
||||
def test_show_folder_name_sanitises_title(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
# Colon in TMDB title should be stripped, spaces become dots
|
||||
folder = p.show_folder_name("Star Wars: Andor", 2022)
|
||||
assert ":" not in folder
|
||||
assert "Star.Wars.Andor" in folder
|
||||
|
||||
def test_season_folder_name_from_season_pack(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
assert p.season_folder_name() == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
|
||||
def test_season_folder_name_strips_episode(self):
|
||||
p = parse_release("Fallout.2024.S02E01.1080p.x265-ELiTE")
|
||||
assert p.season_folder_name() == "Fallout.2024.S02.1080p.x265-ELiTE"
|
||||
|
||||
def test_episode_filename_with_title(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
fname = p.episode_filename("The Routine", ".mkv")
|
||||
assert fname == "Oz.S01.The.Routine.1080p.WEBRip.x265-KONTRAST.mkv"
|
||||
|
||||
def test_episode_filename_with_episode_number(self):
|
||||
p = parse_release("Fallout.2024.S02E01.1080p.x265-ELiTE")
|
||||
fname = p.episode_filename("The Beginning", ".mkv")
|
||||
assert fname == "Fallout.S02E01.The.Beginning.1080p.x265-ELiTE.mkv"
|
||||
|
||||
def test_episode_filename_without_episode_title(self):
|
||||
p = parse_release("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
|
||||
fname = p.episode_filename(None, ".mp4")
|
||||
assert fname == "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
||||
|
||||
def test_episode_filename_sanitises_episode_title(self):
|
||||
p = parse_release("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
|
||||
fname = p.episode_filename("What's Up?", ".mkv")
|
||||
assert "?" not in fname
|
||||
assert "What's.Up" in fname
|
||||
|
||||
def test_episode_filename_strips_leading_dot_from_ext(self):
|
||||
p = parse_release("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
|
||||
fname_with = p.episode_filename(None, ".mkv")
|
||||
fname_without = p.episode_filename(None, "mkv")
|
||||
assert fname_with == fname_without
|
||||
|
||||
def test_movie_folder_name(self):
|
||||
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
||||
assert p.movie_folder_name("Another Round", 2020) == "Another.Round.2020.1080p.BluRay.x264-YTS"
|
||||
|
||||
def test_movie_filename(self):
|
||||
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
||||
fname = p.movie_filename("Another Round", 2020, ".mp4")
|
||||
assert fname == "Another.Round.2020.1080p.BluRay.x264-YTS.mp4"
|
||||
|
||||
def test_movie_folder_same_as_show_folder(self):
|
||||
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
|
||||
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name("Revolver", 2005)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ParsedRelease — is_movie / is_season_pack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMediaTypeFlags:
|
||||
def test_season_pack_is_not_movie(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
assert not p.is_movie
|
||||
assert p.is_season_pack
|
||||
|
||||
def test_single_episode_is_not_season_pack(self):
|
||||
p = parse_release("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
|
||||
assert not p.is_movie
|
||||
assert not p.is_season_pack
|
||||
|
||||
def test_movie_is_not_season_pack(self):
|
||||
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
|
||||
assert p.is_movie
|
||||
assert not p.is_season_pack
|
||||
|
||||
def test_no_season_no_year_treated_as_movie(self):
|
||||
# No S/E marker → is_movie = True
|
||||
p = parse_release("SomeContent.1080p.WEBRip.x265-GROUP")
|
||||
assert p.is_movie
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tricky real-world releases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRealWorldEdgeCases:
|
||||
|
||||
def test_angel_integrale_multi(self):
|
||||
# "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
|
||||
p = parse_release("Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod")
|
||||
assert p.year == 1999
|
||||
assert p.quality == "1080p"
|
||||
assert p.source == "WEBRip"
|
||||
|
||||
def test_group_unknown_when_no_dash(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265")
|
||||
assert p.group == "UNKNOWN"
|
||||
|
||||
def test_normalised_stored_on_parsed(self):
|
||||
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
|
||||
assert p.normalised == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
|
||||
def test_raw_stored_as_is(self):
|
||||
raw = "Oz S01 1080p WEBRip x265-KONTRAST"
|
||||
p = parse_release(raw)
|
||||
assert p.raw == raw
|
||||
|
||||
def test_hevc_codec(self):
|
||||
# "Mare.of.Easttown.S01.1080p.10bit.WEBRip.6CH.x265.HEVC-PSA"
|
||||
p = parse_release("Mare.of.Easttown.S01.1080p.10bit.WEBRip.6CH.x265.HEVC-PSA")
|
||||
assert p.codec in ("x265", "HEVC")
|
||||
assert p.group == "PSA"
|
||||
|
||||
def test_xfiles_hyphen_in_title(self):
|
||||
p = parse_release("The.X-Files.S01.1080p.BluRay.x265-RARBG")
|
||||
# Title should preserve the hyphen
|
||||
assert "X-Files" in p.title
|
||||
|
||||
def test_foundation_s02_no_year(self):
|
||||
# Foundation.S02 has no year in release name — year is None
|
||||
p = parse_release("Foundation.S02.1080p.x265-ELiTE")
|
||||
assert p.year is None
|
||||
assert p.season == 2
|
||||
assert p.group == "ELiTE"
|
||||
|
||||
def test_slow_horses_two_groups_same_show(self):
|
||||
# Same show, different groups across seasons
|
||||
s01 = parse_release("Slow.Horses.S01.1080p.WEBRip.x265-RARBG")
|
||||
s04 = parse_release("Slow.Horses.S04.1080p.WEBRip.x265-KONTRAST")
|
||||
assert s01.title == s04.title == "Slow.Horses"
|
||||
assert s01.group == "RARBG"
|
||||
assert s04.group == "KONTRAST"
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for shared domain value objects: ImdbId, FilePath, FileSize."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from alfred.domain.shared.exceptions import ValidationError
|
||||
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ImdbId
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestImdbId:
|
||||
|
||||
def test_valid_7_digits(self):
|
||||
id_ = ImdbId("tt1375666")
|
||||
assert str(id_) == "tt1375666"
|
||||
|
||||
def test_valid_8_digits(self):
|
||||
id_ = ImdbId("tt12345678")
|
||||
assert str(id_) == "tt12345678"
|
||||
|
||||
def test_empty_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ImdbId("")
|
||||
|
||||
def test_no_tt_prefix_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ImdbId("1375666")
|
||||
|
||||
def test_too_few_digits_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ImdbId("tt12345") # only 5 digits
|
||||
|
||||
def test_too_many_digits_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ImdbId("tt123456789") # 9 digits
|
||||
|
||||
def test_non_string_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ImdbId(1375666) # type: ignore
|
||||
|
||||
def test_repr(self):
|
||||
assert "tt1375666" in repr(ImdbId("tt1375666"))
|
||||
|
||||
def test_equality(self):
|
||||
assert ImdbId("tt1375666") == ImdbId("tt1375666")
|
||||
assert ImdbId("tt1375666") != ImdbId("tt0903747")
|
||||
|
||||
def test_hashable(self):
|
||||
# Frozen dataclass should be hashable
|
||||
ids = {ImdbId("tt1375666"), ImdbId("tt0903747")}
|
||||
assert len(ids) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FilePath
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilePath:
|
||||
|
||||
def test_from_string(self, tmp_path):
|
||||
p = FilePath(str(tmp_path))
|
||||
assert isinstance(p.value, Path)
|
||||
|
||||
def test_from_path(self, tmp_path):
|
||||
p = FilePath(tmp_path)
|
||||
assert p.value == tmp_path
|
||||
|
||||
def test_invalid_type_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilePath(123) # type: ignore
|
||||
|
||||
def test_exists_true(self, tmp_path):
|
||||
p = FilePath(tmp_path)
|
||||
assert p.exists()
|
||||
|
||||
def test_exists_false(self, tmp_path):
|
||||
p = FilePath(tmp_path / "nonexistent")
|
||||
assert not p.exists()
|
||||
|
||||
def test_is_file(self, tmp_path):
|
||||
f = tmp_path / "file.txt"
|
||||
f.write_text("x")
|
||||
assert FilePath(f).is_file()
|
||||
assert not FilePath(tmp_path).is_file()
|
||||
|
||||
def test_is_dir(self, tmp_path):
|
||||
assert FilePath(tmp_path).is_dir()
|
||||
|
||||
def test_str(self, tmp_path):
|
||||
p = FilePath(tmp_path)
|
||||
assert str(p) == str(tmp_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FileSize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFileSize:
|
||||
|
||||
def test_bytes(self):
|
||||
s = FileSize(500)
|
||||
assert s.bytes == 500
|
||||
|
||||
def test_negative_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FileSize(-1)
|
||||
|
||||
def test_non_integer_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FileSize(1.5) # type: ignore
|
||||
|
||||
def test_zero_is_valid(self):
|
||||
s = FileSize(0)
|
||||
assert s.bytes == 0
|
||||
|
||||
def test_human_readable_bytes(self):
|
||||
assert FileSize(500).to_human_readable() == "500 B"
|
||||
|
||||
def test_human_readable_kb(self):
|
||||
result = FileSize(2048).to_human_readable()
|
||||
assert "KB" in result
|
||||
|
||||
def test_human_readable_mb(self):
|
||||
result = FileSize(5 * 1024 * 1024).to_human_readable()
|
||||
assert "MB" in result
|
||||
|
||||
def test_human_readable_gb(self):
|
||||
result = FileSize(2 * 1024 ** 3).to_human_readable()
|
||||
assert "GB" in result
|
||||
|
||||
def test_str_is_human_readable(self):
|
||||
s = FileSize(1024)
|
||||
assert str(s) == s.to_human_readable()
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for SubtitleScanner and _classify helper."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from alfred.domain.subtitles.scanner import (
|
||||
SubtitleCandidate,
|
||||
SubtitleScanner,
|
||||
_classify,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _classify — unit tests for the filename parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClassify:
|
||||
|
||||
def test_iso_lang_code(self, tmp_path):
|
||||
p = tmp_path / "fr.srt"
|
||||
p.write_text("")
|
||||
lang, is_sdh, is_forced = _classify(p)
|
||||
assert lang == "fr"
|
||||
assert not is_sdh
|
||||
assert not is_forced
|
||||
|
||||
def test_english_keyword(self, tmp_path):
|
||||
p = tmp_path / "english.srt"
|
||||
p.write_text("")
|
||||
lang, _, _ = _classify(p)
|
||||
assert lang == "en"
|
||||
|
||||
def test_french_keyword(self, tmp_path):
|
||||
p = tmp_path / "Show.S01E01.French.srt"
|
||||
p.write_text("")
|
||||
lang, _, _ = _classify(p)
|
||||
assert lang == "fr"
|
||||
|
||||
def test_vostfr_is_french(self, tmp_path):
|
||||
p = tmp_path / "Show.S01E01.VOSTFR.srt"
|
||||
p.write_text("")
|
||||
lang, _, _ = _classify(p)
|
||||
assert lang == "fr"
|
||||
|
||||
def test_sdh_token(self, tmp_path):
|
||||
p = tmp_path / "fr.sdh.srt"
|
||||
p.write_text("")
|
||||
lang, is_sdh, _ = _classify(p)
|
||||
assert lang == "fr"
|
||||
assert is_sdh
|
||||
|
||||
def test_hi_alias_for_sdh(self, tmp_path):
|
||||
p = tmp_path / "en.hi.srt"
|
||||
p.write_text("")
|
||||
_, is_sdh, _ = _classify(p)
|
||||
assert is_sdh
|
||||
|
||||
def test_forced_token(self, tmp_path):
|
||||
p = tmp_path / "fr.forced.srt"
|
||||
p.write_text("")
|
||||
_, _, is_forced = _classify(p)
|
||||
assert is_forced
|
||||
|
||||
def test_unknown_language_returns_none(self, tmp_path):
|
||||
p = tmp_path / "Show.S01E01.720p.srt"
|
||||
p.write_text("")
|
||||
lang, _, _ = _classify(p)
|
||||
assert lang is None
|
||||
|
||||
def test_dot_separator(self, tmp_path):
|
||||
p = tmp_path / "fr.sdh.srt"
|
||||
p.write_text("")
|
||||
lang, is_sdh, _ = _classify(p)
|
||||
assert lang == "fr"
|
||||
assert is_sdh
|
||||
|
||||
def test_hyphen_separator(self, tmp_path):
|
||||
p = tmp_path / "fr-forced.srt"
|
||||
p.write_text("")
|
||||
lang, _, is_forced = _classify(p)
|
||||
assert lang == "fr"
|
||||
assert is_forced
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubtitleCandidate.destination_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSubtitleCandidateDestinationName:
|
||||
|
||||
def _make(self, lang="fr", is_sdh=False, is_forced=False, ext=".srt", path=None):
|
||||
return SubtitleCandidate(
|
||||
source_path=path or Path("/fake/fr.srt"),
|
||||
language=lang,
|
||||
is_sdh=is_sdh,
|
||||
is_forced=is_forced,
|
||||
extension=ext,
|
||||
)
|
||||
|
||||
def test_standard(self):
|
||||
assert self._make().destination_name == "fr.srt"
|
||||
|
||||
def test_sdh(self):
|
||||
assert self._make(is_sdh=True).destination_name == "fr.sdh.srt"
|
||||
|
||||
def test_forced(self):
|
||||
assert self._make(is_forced=True).destination_name == "fr.forced.srt"
|
||||
|
||||
def test_ass_extension(self):
|
||||
assert self._make(ext=".ass").destination_name == "fr.ass"
|
||||
|
||||
def test_english_standard(self):
|
||||
assert self._make(lang="en").destination_name == "en.srt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubtitleScanner — integration with real filesystem
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSubtitleScanner:
|
||||
|
||||
def _scanner(self, languages=None, min_size_kb=0, keep_sdh=True, keep_forced=True):
|
||||
return SubtitleScanner(
|
||||
languages=languages or ["fr", "en"],
|
||||
min_size_kb=min_size_kb,
|
||||
keep_sdh=keep_sdh,
|
||||
keep_forced=keep_forced,
|
||||
)
|
||||
|
||||
def _video(self, tmp_path):
|
||||
video = tmp_path / "Movie.mkv"
|
||||
video.write_bytes(b"video")
|
||||
return video
|
||||
|
||||
def test_finds_adjacent_subtitle(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
(tmp_path / "fr.srt").write_text("subtitle content")
|
||||
|
||||
candidates = self._scanner().scan(video)
|
||||
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].language == "fr"
|
||||
|
||||
def test_finds_multiple_languages(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
(tmp_path / "fr.srt").write_text("fr subtitle")
|
||||
(tmp_path / "en.srt").write_text("en subtitle")
|
||||
|
||||
candidates = self._scanner().scan(video)
|
||||
langs = {c.language for c in candidates}
|
||||
assert langs == {"fr", "en"}
|
||||
|
||||
def test_scans_subs_subfolder(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
subs = tmp_path / "Subs"
|
||||
subs.mkdir()
|
||||
(subs / "fr.srt").write_text("subtitle")
|
||||
|
||||
candidates = self._scanner().scan(video)
|
||||
assert any(c.language == "fr" for c in candidates)
|
||||
|
||||
def test_filters_unknown_language(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
(tmp_path / "unknown.srt").write_text("subtitle")
|
||||
|
||||
candidates = self._scanner().scan(video)
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_filters_wrong_language(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
(tmp_path / "de.srt").write_text("german subtitle")
|
||||
|
||||
candidates = self._scanner(languages=["fr"]).scan(video)
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_filters_too_small_file(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
small = tmp_path / "fr.srt"
|
||||
small.write_bytes(b"x") # 1 byte, well below any min_size_kb
|
||||
|
||||
candidates = self._scanner(min_size_kb=10).scan(video)
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_filters_sdh_when_not_wanted(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
(tmp_path / "fr.sdh.srt").write_text("sdh subtitle")
|
||||
|
||||
candidates = self._scanner(keep_sdh=False).scan(video)
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_filters_forced_when_not_wanted(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
(tmp_path / "fr.forced.srt").write_text("forced subtitle")
|
||||
|
||||
candidates = self._scanner(keep_forced=False).scan(video)
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_keeps_sdh_when_wanted(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
(tmp_path / "fr.sdh.srt").write_text("sdh subtitle")
|
||||
|
||||
candidates = self._scanner(keep_sdh=True).scan(video)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].is_sdh
|
||||
|
||||
def test_ignores_non_subtitle_files(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
(tmp_path / "fr.nfo").write_text("nfo file")
|
||||
(tmp_path / "fr.jpg").write_bytes(b"image")
|
||||
|
||||
candidates = self._scanner().scan(video)
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_returns_empty_when_no_subtitles(self, tmp_path):
|
||||
video = self._video(tmp_path)
|
||||
candidates = self._scanner().scan(video)
|
||||
assert candidates == []
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Tests for TV Show domain — entities and value objects."""
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.shared.exceptions import ValidationError
|
||||
from alfred.domain.tv_shows.entities import Episode, Season, TVShow
|
||||
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ShowStatus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShowStatus:
|
||||
|
||||
def test_from_string_ongoing(self):
|
||||
assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING
|
||||
|
||||
def test_from_string_ended(self):
|
||||
assert ShowStatus.from_string("ended") == ShowStatus.ENDED
|
||||
|
||||
def test_from_string_case_insensitive(self):
|
||||
assert ShowStatus.from_string("ONGOING") == ShowStatus.ONGOING
|
||||
assert ShowStatus.from_string("Ended") == ShowStatus.ENDED
|
||||
|
||||
def test_from_string_unknown(self):
|
||||
assert ShowStatus.from_string("cancelled") == ShowStatus.UNKNOWN
|
||||
assert ShowStatus.from_string("") == ShowStatus.UNKNOWN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SeasonNumber
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSeasonNumber:
|
||||
|
||||
def test_valid_season(self):
|
||||
s = SeasonNumber(1)
|
||||
assert s.value == 1
|
||||
|
||||
def test_season_zero_is_specials(self):
|
||||
s = SeasonNumber(0)
|
||||
assert s.is_special()
|
||||
|
||||
def test_normal_season_not_special(self):
|
||||
assert not SeasonNumber(3).is_special()
|
||||
|
||||
def test_negative_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SeasonNumber(-1)
|
||||
|
||||
def test_too_high_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SeasonNumber(101)
|
||||
|
||||
def test_non_integer_raises(self):
|
||||
with pytest.raises((ValidationError, TypeError)):
|
||||
SeasonNumber("1") # type: ignore
|
||||
|
||||
def test_str_and_int(self):
|
||||
s = SeasonNumber(5)
|
||||
assert str(s) == "5"
|
||||
assert int(s) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EpisodeNumber
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEpisodeNumber:
|
||||
|
||||
def test_valid_episode(self):
|
||||
e = EpisodeNumber(1)
|
||||
assert e.value == 1
|
||||
|
||||
def test_zero_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EpisodeNumber(0)
|
||||
|
||||
def test_negative_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EpisodeNumber(-5)
|
||||
|
||||
def test_too_high_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EpisodeNumber(1001)
|
||||
|
||||
def test_str_and_int(self):
|
||||
e = EpisodeNumber(12)
|
||||
assert str(e) == "12"
|
||||
assert int(e) == 12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TVShow entity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTVShow:
|
||||
|
||||
def _make(self, imdb_id="tt0903747", title="Breaking Bad", seasons=5, status="ended"):
|
||||
return TVShow(imdb_id=imdb_id, title=title, seasons_count=seasons, status=status)
|
||||
|
||||
def test_basic_creation(self):
|
||||
show = self._make()
|
||||
assert show.title == "Breaking Bad"
|
||||
assert show.seasons_count == 5
|
||||
|
||||
def test_coerces_string_imdb_id(self):
|
||||
show = self._make()
|
||||
from alfred.domain.shared.value_objects import ImdbId
|
||||
assert isinstance(show.imdb_id, ImdbId)
|
||||
|
||||
def test_coerces_string_status(self):
|
||||
show = self._make(status="ongoing")
|
||||
assert show.status == ShowStatus.ONGOING
|
||||
|
||||
def test_is_ongoing(self):
|
||||
show = self._make(status="ongoing")
|
||||
assert show.is_ongoing()
|
||||
assert not show.is_ended()
|
||||
|
||||
def test_is_ended(self):
|
||||
show = self._make(status="ended")
|
||||
assert show.is_ended()
|
||||
assert not show.is_ongoing()
|
||||
|
||||
def test_negative_seasons_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TVShow(imdb_id="tt0903747", title="X", seasons_count=-1, status="ended")
|
||||
|
||||
def test_invalid_imdb_id_type_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TVShow(imdb_id=12345, title="X", seasons_count=1, status="ended") # type: ignore
|
||||
|
||||
def test_get_folder_name_replaces_spaces(self):
|
||||
show = self._make(title="Breaking Bad")
|
||||
assert show.get_folder_name() == "Breaking.Bad"
|
||||
|
||||
def test_get_folder_name_strips_special_chars(self):
|
||||
show = self._make(title="It's Always Sunny")
|
||||
name = show.get_folder_name()
|
||||
assert "'" not in name
|
||||
|
||||
def test_str_repr(self):
|
||||
show = self._make()
|
||||
assert "Breaking Bad" in str(show)
|
||||
assert "tt0903747" in repr(show)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Season entity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSeason:
|
||||
|
||||
def test_basic_creation(self):
|
||||
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7)
|
||||
assert s.episode_count == 7
|
||||
|
||||
def test_get_folder_name_normal(self):
|
||||
s = Season(show_imdb_id="tt0903747", season_number=2, episode_count=13)
|
||||
assert s.get_folder_name() == "Season 02"
|
||||
|
||||
def test_get_folder_name_specials(self):
|
||||
s = Season(show_imdb_id="tt0903747", season_number=0, episode_count=3)
|
||||
assert s.get_folder_name() == "Specials"
|
||||
assert s.is_special()
|
||||
|
||||
def test_negative_episode_count_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
Season(show_imdb_id="tt0903747", season_number=1, episode_count=-1)
|
||||
|
||||
def test_str(self):
|
||||
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7, name="Pilot Season")
|
||||
assert "Pilot Season" in str(s)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Episode entity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEpisode:
|
||||
|
||||
def test_basic_creation(self):
|
||||
e = Episode(
|
||||
show_imdb_id="tt0903747",
|
||||
season_number=1,
|
||||
episode_number=1,
|
||||
title="Pilot",
|
||||
)
|
||||
assert e.title == "Pilot"
|
||||
|
||||
def test_get_filename_format(self):
|
||||
e = Episode(
|
||||
show_imdb_id="tt0903747",
|
||||
season_number=1,
|
||||
episode_number=5,
|
||||
title="Gray Matter",
|
||||
)
|
||||
filename = e.get_filename()
|
||||
assert filename.startswith("S01E05")
|
||||
assert "Gray.Matter" in filename
|
||||
|
||||
def test_has_file_false_when_no_path(self):
|
||||
e = Episode(
|
||||
show_imdb_id="tt0903747",
|
||||
season_number=1,
|
||||
episode_number=1,
|
||||
title="Pilot",
|
||||
)
|
||||
assert not e.has_file()
|
||||
assert not e.is_downloaded()
|
||||
|
||||
def test_str_format(self):
|
||||
e = Episode(
|
||||
show_imdb_id="tt0903747",
|
||||
season_number=2,
|
||||
episode_number=3,
|
||||
title="Bit by a Dead Bee",
|
||||
)
|
||||
s = str(e)
|
||||
assert "S02E03" in s
|
||||
assert "Bit by a Dead Bee" in s
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Fixtures for infrastructure-layer tests."""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.infrastructure.persistence import Memory, set_memory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def infra_temp(tmp_path):
|
||||
"""Real folder layout: downloads (with files), movies, tv_shows, torrents."""
|
||||
dl = tmp_path / "downloads"
|
||||
dl.mkdir()
|
||||
(dl / "test_movie.mkv").write_bytes(b"fake video")
|
||||
series_dir = dl / "test_series"
|
||||
series_dir.mkdir()
|
||||
(series_dir / "episode1.mkv").write_bytes(b"fake episode")
|
||||
|
||||
(tmp_path / "movies").mkdir()
|
||||
(tmp_path / "tv_shows").mkdir()
|
||||
(tmp_path / "torrents").mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_configured(infra_temp):
|
||||
"""Fresh Memory configured with the real workspace/library_paths API."""
|
||||
storage = tempfile.mkdtemp()
|
||||
mem = Memory(storage_dir=storage)
|
||||
set_memory(mem)
|
||||
|
||||
mem.ltm.workspace.download = str(infra_temp / "downloads")
|
||||
mem.ltm.workspace.torrent = str(infra_temp / "torrents")
|
||||
mem.ltm.library_paths.set("movie", str(infra_temp / "movies"))
|
||||
mem.ltm.library_paths.set("tv_show", str(infra_temp / "tv_shows"))
|
||||
mem.save()
|
||||
|
||||
yield mem
|
||||
|
||||
shutil.rmtree(storage, ignore_errors=True)
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Tests for alfred.infrastructure.filesystem.file_manager.FileManager
|
||||
|
||||
Uses real temp filesystem. No mocks on os.link — we test the actual behavior.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.infrastructure.filesystem.file_manager import FileManager
|
||||
from alfred.infrastructure.filesystem.exceptions import PathTraversalError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fm():
|
||||
return FileManager()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# copy_file (hard-link)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCopyFile:
|
||||
|
||||
def test_creates_hard_link(self, fm, tmp_path):
|
||||
src = tmp_path / "source.mkv"
|
||||
src.write_bytes(b"video data")
|
||||
dst = tmp_path / "dest.mkv"
|
||||
|
||||
result = fm.copy_file(str(src), str(dst))
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert dst.exists()
|
||||
# Same inode = hard link
|
||||
assert src.stat().st_ino == dst.stat().st_ino
|
||||
|
||||
def test_returns_correct_metadata(self, fm, tmp_path):
|
||||
src = tmp_path / "movie.mkv"
|
||||
src.write_bytes(b"x" * 1024)
|
||||
dst = tmp_path / "movie_copy.mkv"
|
||||
|
||||
result = fm.copy_file(str(src), str(dst))
|
||||
|
||||
assert result["filename"] == "movie_copy.mkv"
|
||||
assert result["size"] == 1024
|
||||
assert result["source"] == str(src)
|
||||
assert result["destination"] == str(dst)
|
||||
|
||||
def test_source_not_found(self, fm, tmp_path):
|
||||
result = fm.copy_file(str(tmp_path / "nope.mkv"), str(tmp_path / "dst.mkv"))
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "source_not_found"
|
||||
|
||||
def test_source_is_directory(self, fm, tmp_path):
|
||||
src_dir = tmp_path / "a_dir"
|
||||
src_dir.mkdir()
|
||||
result = fm.copy_file(str(src_dir), str(tmp_path / "dst.mkv"))
|
||||
assert result["error"] == "source_not_file"
|
||||
|
||||
def test_destination_already_exists(self, fm, tmp_path):
|
||||
src = tmp_path / "src.mkv"
|
||||
src.write_bytes(b"data")
|
||||
dst = tmp_path / "dst.mkv"
|
||||
dst.write_bytes(b"other")
|
||||
|
||||
result = fm.copy_file(str(src), str(dst))
|
||||
assert result["error"] == "destination_exists"
|
||||
|
||||
def test_destination_dir_not_found(self, fm, tmp_path):
|
||||
src = tmp_path / "src.mkv"
|
||||
src.write_bytes(b"data")
|
||||
result = fm.copy_file(str(src), str(tmp_path / "nonexistent" / "dst.mkv"))
|
||||
assert result["error"] == "destination_dir_not_found"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# move_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMoveFile:
|
||||
|
||||
def test_moves_file(self, fm, tmp_path):
|
||||
src = tmp_path / "episode.mkv"
|
||||
src.write_bytes(b"video")
|
||||
dst_dir = tmp_path / "library"
|
||||
dst_dir.mkdir()
|
||||
dst = dst_dir / "episode.mkv"
|
||||
|
||||
result = fm.move_file(str(src), str(dst))
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert dst.exists()
|
||||
assert not src.exists()
|
||||
|
||||
def test_source_deleted_after_move(self, fm, tmp_path):
|
||||
src = tmp_path / "src.mkv"
|
||||
src.write_bytes(b"data")
|
||||
dst = tmp_path / "dst.mkv"
|
||||
|
||||
fm.move_file(str(src), str(dst))
|
||||
assert not src.exists()
|
||||
|
||||
def test_move_preserves_content(self, fm, tmp_path):
|
||||
content = b"important video content"
|
||||
src = tmp_path / "src.mkv"
|
||||
src.write_bytes(content)
|
||||
dst = tmp_path / "dst.mkv"
|
||||
|
||||
fm.move_file(str(src), str(dst))
|
||||
assert dst.read_bytes() == content
|
||||
|
||||
def test_move_fails_if_source_missing(self, fm, tmp_path):
|
||||
result = fm.move_file(str(tmp_path / "ghost.mkv"), str(tmp_path / "dst.mkv"))
|
||||
assert result["status"] == "error"
|
||||
|
||||
def test_move_fails_if_destination_exists(self, fm, tmp_path):
|
||||
src = tmp_path / "src.mkv"
|
||||
src.write_bytes(b"a")
|
||||
dst = tmp_path / "dst.mkv"
|
||||
dst.write_bytes(b"b")
|
||||
|
||||
result = fm.move_file(str(src), str(dst))
|
||||
assert result["status"] == "error"
|
||||
# Source should NOT be deleted since the link failed
|
||||
assert src.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_seed_links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateSeedLinks:
|
||||
|
||||
def _setup(self, tmp_path):
|
||||
"""Create realistic download + library + torrent structure."""
|
||||
download = tmp_path / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
download.mkdir(parents=True)
|
||||
video = download / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
||||
video.write_bytes(b"video content")
|
||||
(download / "KONTRAST.txt").write_text("release info")
|
||||
(download / "[TGx]info.txt").write_text("tgx info")
|
||||
subs = download / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
|
||||
subs.mkdir(parents=True)
|
||||
(subs / "2_eng.srt").write_text("subtitle content")
|
||||
|
||||
library = tmp_path / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
library.mkdir(parents=True)
|
||||
lib_video = library / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
|
||||
# Hard-link the video to simulate post-move state
|
||||
os.link(video, lib_video)
|
||||
video.unlink() # simulate the move
|
||||
|
||||
torrents = tmp_path / "torrents"
|
||||
torrents.mkdir()
|
||||
|
||||
return lib_video, download, torrents
|
||||
|
||||
def test_creates_torrent_subfolder(self, fm, tmp_path):
|
||||
lib_video, download, torrents = self._setup(tmp_path)
|
||||
result = fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||
|
||||
assert result["status"] == "ok"
|
||||
expected = torrents / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
assert expected.is_dir()
|
||||
|
||||
def test_hard_links_library_video(self, fm, tmp_path):
|
||||
lib_video, download, torrents = self._setup(tmp_path)
|
||||
fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||
|
||||
linked = torrents / "Oz.S01.1080p.WEBRip.x265-KONTRAST" / lib_video.name
|
||||
assert linked.exists()
|
||||
assert linked.stat().st_ino == lib_video.stat().st_ino
|
||||
|
||||
def test_copies_remaining_files(self, fm, tmp_path):
|
||||
lib_video, download, torrents = self._setup(tmp_path)
|
||||
result = fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||
|
||||
assert result["status"] == "ok"
|
||||
torrent_sub = torrents / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
# txt files should be copied
|
||||
assert (torrent_sub / "KONTRAST.txt").exists()
|
||||
assert (torrent_sub / "[TGx]info.txt").exists()
|
||||
|
||||
def test_copies_subs_subdirectory(self, fm, tmp_path):
|
||||
lib_video, download, torrents = self._setup(tmp_path)
|
||||
fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||
|
||||
srt = torrents / "Oz.S01.1080p.WEBRip.x265-KONTRAST" / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST" / "2_eng.srt"
|
||||
assert srt.exists()
|
||||
|
||||
def test_returns_copied_and_skipped(self, fm, tmp_path):
|
||||
lib_video, download, torrents = self._setup(tmp_path)
|
||||
result = fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||
|
||||
assert result["copied_count"] >= 3 # txt x2 + srt
|
||||
assert result["skipped"] == []
|
||||
|
||||
def test_skips_already_existing_files(self, fm, tmp_path):
|
||||
lib_video, download, torrents = self._setup(tmp_path)
|
||||
# First call
|
||||
fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||
|
||||
# Add a new txt file and call again with a fresh lib_video copy
|
||||
lib2 = lib_video.parent / "Oz.S01E02.1080p.WEBRip.x265-KONTRAST.mp4"
|
||||
lib2.write_bytes(b"ep2")
|
||||
(download / "extra.nfo").write_text("nfo")
|
||||
result2 = fm.create_seed_links(str(lib2), str(download), str(torrents))
|
||||
|
||||
assert result2["status"] == "ok"
|
||||
# The already-copied files should appear in skipped
|
||||
skipped_names = [Path(s).name for s in result2["skipped"]]
|
||||
assert "KONTRAST.txt" in skipped_names
|
||||
|
||||
def test_error_library_file_not_found(self, fm, tmp_path):
|
||||
download = tmp_path / "downloads" / "SomeShow.S01"
|
||||
download.mkdir(parents=True)
|
||||
torrents = tmp_path / "torrents"
|
||||
torrents.mkdir()
|
||||
|
||||
result = fm.create_seed_links(
|
||||
str(tmp_path / "ghost.mkv"),
|
||||
str(download),
|
||||
str(torrents),
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "library_file_not_found"
|
||||
|
||||
def test_error_source_folder_not_found(self, fm, tmp_path):
|
||||
lib = tmp_path / "lib.mkv"
|
||||
lib.write_bytes(b"v")
|
||||
torrents = tmp_path / "torrents"
|
||||
torrents.mkdir()
|
||||
|
||||
result = fm.create_seed_links(
|
||||
str(lib),
|
||||
str(tmp_path / "ghost_folder"),
|
||||
str(torrents),
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "source_folder_not_found"
|
||||
|
||||
def test_error_torrent_folder_not_found(self, fm, tmp_path):
|
||||
lib = tmp_path / "lib.mkv"
|
||||
lib.write_bytes(b"v")
|
||||
download = tmp_path / "dl"
|
||||
download.mkdir()
|
||||
|
||||
result = fm.create_seed_links(
|
||||
str(lib),
|
||||
str(download),
|
||||
str(tmp_path / "no_torrents"),
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "torrent_folder_not_found"
|
||||
|
||||
def test_error_link_already_exists(self, fm, tmp_path):
|
||||
lib_video, download, torrents = self._setup(tmp_path)
|
||||
fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||
|
||||
# Second call with same video → link_dest already exists
|
||||
result = fm.create_seed_links(str(lib_video), str(download), str(torrents))
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "destination_exists"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_folder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestListFolder:
|
||||
|
||||
def test_lists_entries(self, fm, memory_configured, infra_temp):
|
||||
result = fm.list_folder("download")
|
||||
assert result["status"] == "ok"
|
||||
assert result["count"] > 0
|
||||
assert isinstance(result["entries"], list)
|
||||
|
||||
def test_entries_are_sorted(self, fm, memory_configured, infra_temp):
|
||||
result = fm.list_folder("download")
|
||||
assert result["entries"] == sorted(result["entries"])
|
||||
|
||||
def test_folder_not_set(self, fm, memory):
|
||||
result = fm.list_folder("tv_show")
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "folder_not_set"
|
||||
|
||||
def test_invalid_folder_type(self, fm, memory):
|
||||
result = fm.list_folder("nonexistent_type")
|
||||
assert result["status"] == "error"
|
||||
|
||||
def test_relative_path_within_folder(self, fm, memory_configured, infra_temp):
|
||||
result = fm.list_folder("download", "test_series")
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitize_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSanitizePath:
|
||||
|
||||
def test_normal_path(self, fm):
|
||||
assert fm._sanitize_path("some/path") == "some/path"
|
||||
|
||||
def test_dot_path(self, fm):
|
||||
assert fm._sanitize_path(".") == "."
|
||||
|
||||
def test_absolute_path_rejected(self, fm):
|
||||
with pytest.raises(PathTraversalError):
|
||||
fm._sanitize_path("/etc/passwd")
|
||||
|
||||
def test_parent_traversal_rejected(self, fm):
|
||||
with pytest.raises(PathTraversalError):
|
||||
fm._sanitize_path("../../etc/passwd")
|
||||
|
||||
def test_null_byte_rejected(self, fm):
|
||||
with pytest.raises(PathTraversalError):
|
||||
fm._sanitize_path("some\x00path")
|
||||
|
||||
def test_normalises_redundant_dots(self, fm):
|
||||
result = fm._sanitize_path("some/./path")
|
||||
assert ".." not in result
|
||||
+4
-4
@@ -49,7 +49,7 @@ class TestExecuteToolCall:
|
||||
def test_execute_known_tool(self, memory, mock_settings, mock_llm, real_folder):
|
||||
"""Should execute known tool."""
|
||||
agent = Agent(settings=mock_settings, llm=mock_llm)
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
tool_call = {
|
||||
"id": "call_123",
|
||||
@@ -145,7 +145,7 @@ class TestStep:
|
||||
self, memory, mock_settings, mock_llm_with_tool_call, real_folder
|
||||
):
|
||||
"""Should execute tool and continue."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
agent = Agent(settings=mock_settings, llm=mock_llm_with_tool_call)
|
||||
|
||||
@@ -229,8 +229,8 @@ class TestAgentIntegration:
|
||||
|
||||
def test_multiple_tool_calls(self, memory, mock_settings, mock_llm, real_folder):
|
||||
"""Should handle multiple tool calls in sequence."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.set_config("movie_folder", str(real_folder["movies"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
memory.ltm.movie_folder = str(real_folder["movies"])
|
||||
|
||||
call_count = [0]
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestExecuteToolCallEdgeCases:
|
||||
def test_tool_with_extra_args(self, memory, mock_llm, real_folder):
|
||||
"""Should handle extra arguments gracefully."""
|
||||
agent = Agent(settings=settings, llm=mock_llm)
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
tool_call = {
|
||||
"id": "call_123",
|
||||
@@ -244,7 +244,7 @@ class TestAgentConcurrencyEdgeCases:
|
||||
|
||||
def test_tool_modifies_memory_during_step(self, memory, mock_llm, real_folder):
|
||||
"""Should handle memory modifications during step."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
call_count = [0]
|
||||
|
||||
@@ -272,7 +272,7 @@ class TestAgentConcurrencyEdgeCases:
|
||||
agent.step("Set movie folder")
|
||||
|
||||
mem = get_memory()
|
||||
assert mem.ltm.get_config("movie_folder") == str(real_folder["movies"])
|
||||
assert mem.ltm.movie_folder == str(real_folder["movies"])
|
||||
|
||||
|
||||
class TestAgentErrorRecovery:
|
||||
|
||||
@@ -337,7 +337,7 @@ class TestChatCompletionsEdgeCases:
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
mem = get_memory()
|
||||
mem.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
mem.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
call_count = [0]
|
||||
|
||||
@@ -453,7 +453,7 @@ class TestMemoryEndpointsEdgeCases:
|
||||
mock_llm.return_value = Mock()
|
||||
from alfred.app import app
|
||||
|
||||
memory.ltm.set_config("japanese", "日本語テスト")
|
||||
memory.ltm.download_folder = "/path/日本語テスト"
|
||||
memory.stm.add_message("user", "🎬 Movie request")
|
||||
|
||||
client = TestClient(app)
|
||||
@@ -501,7 +501,7 @@ class TestMemoryEndpointsEdgeCases:
|
||||
mock_llm.return_value = Mock()
|
||||
from alfred.app import app
|
||||
|
||||
memory.ltm.set_config("important", "data")
|
||||
memory.ltm.download_folder = "/important/data"
|
||||
memory.stm.add_message("user", "Hello")
|
||||
|
||||
client = TestClient(app)
|
||||
@@ -510,7 +510,7 @@ class TestMemoryEndpointsEdgeCases:
|
||||
response = client.get("/memory/state")
|
||||
data = response.json()
|
||||
|
||||
assert data["ltm"]["config"]["important"] == "data"
|
||||
assert data["ltm"]["download_folder"] == "/important/data"
|
||||
assert data["stm"]["conversation_history"] == []
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from alfred.infrastructure.persistence import (
|
||||
has_memory,
|
||||
init_memory,
|
||||
)
|
||||
from alfred.infrastructure.persistence.context import _memory_ctx
|
||||
from alfred.infrastructure.persistence.context import reset_memory
|
||||
|
||||
|
||||
def is_iso_format(s: str) -> bool:
|
||||
@@ -33,10 +33,8 @@ class TestLongTermMemory:
|
||||
|
||||
def test_default_values(self):
|
||||
ltm = LongTermMemory()
|
||||
assert ltm.config == {}
|
||||
assert ltm.preferences["preferred_quality"] == "1080p"
|
||||
assert "en" in ltm.preferences["preferred_languages"]
|
||||
assert ltm.library == {"movies": [], "tv_shows": []}
|
||||
assert ltm.media_preferences.quality == "1080p"
|
||||
assert "en" in ltm.media_preferences.audio_languages
|
||||
assert ltm.following == []
|
||||
|
||||
def test_set_and_get_config(self):
|
||||
@@ -124,7 +122,7 @@ class TestLongTermMemory:
|
||||
}
|
||||
ltm = LongTermMemory.from_dict(data)
|
||||
assert ltm.get_config("download_folder") == "/downloads"
|
||||
assert ltm.preferences["preferred_quality"] == "4K"
|
||||
assert ltm.media_preferences.quality == "4K"
|
||||
assert len(ltm.library["movies"]) == 1
|
||||
|
||||
|
||||
@@ -230,12 +228,12 @@ class TestMemoryContext:
|
||||
"""Tests for memory context functions."""
|
||||
|
||||
def test_get_memory_not_initialized(self):
|
||||
_memory_ctx.set(None)
|
||||
reset_memory()
|
||||
with pytest.raises(RuntimeError, match="Memory not initialized"):
|
||||
get_memory()
|
||||
|
||||
def test_init_memory(self, temp_dir):
|
||||
_memory_ctx.set(None)
|
||||
reset_memory()
|
||||
memory = init_memory(str(temp_dir))
|
||||
assert has_memory()
|
||||
assert get_memory() is memory
|
||||
|
||||
@@ -38,7 +38,7 @@ class TestPromptBuilder:
|
||||
|
||||
def test_includes_config(self, memory):
|
||||
"""Should include current configuration."""
|
||||
memory.ltm.set_config("download_folder", "/path/to/downloads")
|
||||
memory.ltm.download_folder = "/path/to/downloads"
|
||||
tools = make_tools(settings)
|
||||
builder = PromptBuilder(tools)
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ class TestPromptBuilderMemoryContext:
|
||||
tools = make_tools(settings)
|
||||
builder = PromptBuilder(tools)
|
||||
|
||||
memory.ltm.set_config("download_folder", "/test/downloads")
|
||||
memory.ltm.download_folder = "/test/downloads"
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
|
||||
@@ -212,12 +212,12 @@ class TestPromptBuilderStructure:
|
||||
tools = make_tools(settings)
|
||||
builder = PromptBuilder(tools)
|
||||
|
||||
memory.ltm.set_config("test_key", "test_value")
|
||||
memory.ltm.download_folder = "/test/downloads"
|
||||
|
||||
context = builder._format_config_context(memory)
|
||||
|
||||
assert "CONFIGURATION" in context
|
||||
assert "test_key" in context
|
||||
assert "download_folder" in context
|
||||
|
||||
|
||||
class TestPromptBuilderEdgeCases:
|
||||
|
||||
@@ -20,8 +20,8 @@ class TestPromptBuilderEdgeCases:
|
||||
|
||||
def test_prompt_with_unicode_config(self, memory):
|
||||
"""Should handle unicode in config."""
|
||||
memory.ltm.set_config("folder_日本語", "/path/to/日本語")
|
||||
memory.ltm.set_config("emoji_folder", "/path/🎬")
|
||||
memory.ltm.download_folder = "/path/to/日本語"
|
||||
memory.ltm.tvshow_folder = "/path/🎬"
|
||||
|
||||
tools = make_tools(settings)
|
||||
builder = PromptBuilder(tools)
|
||||
@@ -34,7 +34,7 @@ class TestPromptBuilderEdgeCases:
|
||||
def test_prompt_with_very_long_config_value(self, memory):
|
||||
"""Should handle very long config values."""
|
||||
long_path = "/very/long/path/" + "x" * 1000
|
||||
memory.ltm.set_config("download_folder", long_path)
|
||||
memory.ltm.download_folder = long_path
|
||||
|
||||
tools = make_tools(settings)
|
||||
builder = PromptBuilder(tools)
|
||||
@@ -46,7 +46,7 @@ class TestPromptBuilderEdgeCases:
|
||||
|
||||
def test_prompt_with_special_chars_in_config(self, memory):
|
||||
"""Should escape special characters in config."""
|
||||
memory.ltm.set_config("path", '/path/with "quotes" and \\backslash')
|
||||
memory.ltm.download_folder = '/path/with "quotes" and \\backslash'
|
||||
|
||||
tools = make_tools(settings)
|
||||
builder = PromptBuilder(tools)
|
||||
@@ -198,7 +198,7 @@ class TestPromptBuilderEdgeCases:
|
||||
def test_prompt_with_all_sections(self, memory):
|
||||
"""Should include all sections when all data present."""
|
||||
# Config
|
||||
memory.ltm.set_config("download_folder", "/downloads")
|
||||
memory.ltm.download_folder = "/downloads"
|
||||
|
||||
# Search results
|
||||
memory.episodic.store_search_results("test", [{"name": "Result"}])
|
||||
@@ -242,7 +242,7 @@ class TestPromptBuilderEdgeCases:
|
||||
|
||||
def test_prompt_json_serializable(self, memory):
|
||||
"""Should produce JSON-serializable content."""
|
||||
memory.ltm.set_config("key", {"nested": [1, 2, 3]})
|
||||
memory.ltm.download_folder = "/some/path"
|
||||
memory.stm.set_entity("complex", {"a": {"b": {"c": "d"}}})
|
||||
|
||||
tools = make_tools(settings)
|
||||
|
||||
@@ -258,7 +258,7 @@ class TestToolExecution:
|
||||
def test_tool_returns_dict(self, memory, real_folder):
|
||||
"""Should return dict from tool execution."""
|
||||
tools = make_tools(settings)
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = tools["list_folder"].func(folder_type="download")
|
||||
|
||||
@@ -267,7 +267,7 @@ class TestToolExecution:
|
||||
def test_tool_returns_status(self, memory, real_folder):
|
||||
"""Should return status in result."""
|
||||
tools = make_tools(settings)
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = tools["list_folder"].func(folder_type="download")
|
||||
|
||||
@@ -295,7 +295,7 @@ class TestToolExecution:
|
||||
def test_tool_handles_extra_args(self, memory, real_folder):
|
||||
"""Should handle extra arguments."""
|
||||
tools = make_tools(settings)
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
# Extra args should raise TypeError
|
||||
with pytest.raises(TypeError):
|
||||
|
||||
@@ -271,7 +271,7 @@ class TestFilesystemEdgeCases:
|
||||
"""Should list hidden files."""
|
||||
hidden_file = real_folder["downloads"] / ".hidden"
|
||||
hidden_file.touch()
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download")
|
||||
|
||||
@@ -285,7 +285,7 @@ class TestFilesystemEdgeCases:
|
||||
except OSError:
|
||||
pytest.skip("Cannot create symlinks")
|
||||
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download")
|
||||
|
||||
@@ -301,7 +301,7 @@ class TestFilesystemEdgeCases:
|
||||
|
||||
try:
|
||||
os.chmod(no_read, 0o000)
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download")
|
||||
|
||||
@@ -312,7 +312,7 @@ class TestFilesystemEdgeCases:
|
||||
|
||||
def test_list_folder_case_sensitivity(self, memory, real_folder):
|
||||
"""Should handle case sensitivity correctly."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
# Try with different cases
|
||||
result_lower = fs_tools.list_folder("download")
|
||||
@@ -324,7 +324,7 @@ class TestFilesystemEdgeCases:
|
||||
"""Should handle spaces in path."""
|
||||
space_dir = real_folder["downloads"] / "folder with spaces"
|
||||
space_dir.mkdir()
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "folder with spaces")
|
||||
|
||||
@@ -332,7 +332,7 @@ class TestFilesystemEdgeCases:
|
||||
|
||||
def test_path_traversal_with_encoded_chars(self, memory, real_folder):
|
||||
"""Should block URL-encoded traversal attempts."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
# Various encoding attempts
|
||||
attempts = [
|
||||
@@ -352,7 +352,7 @@ class TestFilesystemEdgeCases:
|
||||
|
||||
def test_path_with_null_byte(self, memory, real_folder):
|
||||
"""Should block null byte injection."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "file\x00.txt")
|
||||
|
||||
@@ -366,7 +366,7 @@ class TestFilesystemEdgeCases:
|
||||
deep_path = deep_path / f"level{i}"
|
||||
deep_path.mkdir(parents=True)
|
||||
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
# Navigate to deep path
|
||||
relative_path = "/".join([f"level{i}" for i in range(20)])
|
||||
@@ -380,7 +380,7 @@ class TestFilesystemEdgeCases:
|
||||
for i in range(1000):
|
||||
(real_folder["downloads"] / f"file_{i:04d}.txt").touch()
|
||||
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download")
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class TestSetPathForFolder:
|
||||
fs_tools.set_path_for_folder("download", str(real_folder["downloads"]))
|
||||
|
||||
mem = get_memory()
|
||||
assert mem.ltm.get_config("download_folder") == str(real_folder["downloads"])
|
||||
assert mem.ltm.download_folder == str(real_folder["downloads"])
|
||||
|
||||
def test_all_folder_types(self, memory, real_folder):
|
||||
"""Should accept all valid folder types."""
|
||||
@@ -73,7 +73,7 @@ class TestListFolder:
|
||||
|
||||
def test_success(self, memory, real_folder):
|
||||
"""Should list folder contents."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download")
|
||||
|
||||
@@ -84,7 +84,7 @@ class TestListFolder:
|
||||
|
||||
def test_subfolder(self, memory, real_folder):
|
||||
"""Should list subfolder contents."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "test_series")
|
||||
|
||||
@@ -105,7 +105,7 @@ class TestListFolder:
|
||||
|
||||
def test_path_traversal_dotdot(self, memory, real_folder):
|
||||
"""Should block path traversal with .."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "../")
|
||||
|
||||
@@ -113,7 +113,7 @@ class TestListFolder:
|
||||
|
||||
def test_path_traversal_absolute(self, memory, real_folder):
|
||||
"""Should block absolute paths."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "/etc/passwd")
|
||||
|
||||
@@ -121,7 +121,7 @@ class TestListFolder:
|
||||
|
||||
def test_path_traversal_encoded(self, memory, real_folder):
|
||||
"""Should block encoded traversal attempts."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "..%2F..%2Fetc")
|
||||
|
||||
@@ -130,7 +130,7 @@ class TestListFolder:
|
||||
|
||||
def test_path_not_exists(self, memory, real_folder):
|
||||
"""Should return error for non-existent path."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "nonexistent_folder")
|
||||
|
||||
@@ -138,7 +138,7 @@ class TestListFolder:
|
||||
|
||||
def test_path_is_file(self, memory, real_folder):
|
||||
"""Should return error if path is a file."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "test_movie.mkv")
|
||||
|
||||
@@ -148,7 +148,7 @@ class TestListFolder:
|
||||
"""Should handle empty folder."""
|
||||
empty_dir = real_folder["downloads"] / "empty"
|
||||
empty_dir.mkdir()
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "empty")
|
||||
|
||||
@@ -161,7 +161,7 @@ class TestListFolder:
|
||||
# Create files with different names
|
||||
(real_folder["downloads"] / "zebra.txt").touch()
|
||||
(real_folder["downloads"] / "alpha.txt").touch()
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download")
|
||||
|
||||
@@ -176,7 +176,7 @@ class TestFileManagerSecurity:
|
||||
|
||||
def test_null_byte_injection(self, memory, real_folder):
|
||||
"""Should block null byte injection."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "test\x00.txt")
|
||||
|
||||
@@ -184,7 +184,7 @@ class TestFileManagerSecurity:
|
||||
|
||||
def test_path_outside_root(self, memory, real_folder):
|
||||
"""Should block paths that escape root."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
# Try to access parent directory
|
||||
result = fs_tools.list_folder("download", "test_series/../../")
|
||||
@@ -200,7 +200,7 @@ class TestFileManagerSecurity:
|
||||
except OSError:
|
||||
pytest.skip("Cannot create symlinks")
|
||||
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "escape_link")
|
||||
|
||||
@@ -212,7 +212,7 @@ class TestFileManagerSecurity:
|
||||
"""Should handle special characters in path."""
|
||||
special_dir = real_folder["downloads"] / "special !@#$%"
|
||||
special_dir.mkdir()
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "special !@#$%")
|
||||
|
||||
@@ -222,7 +222,7 @@ class TestFileManagerSecurity:
|
||||
"""Should handle unicode in path."""
|
||||
unicode_dir = real_folder["downloads"] / "日本語フォルダ"
|
||||
unicode_dir.mkdir()
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
result = fs_tools.list_folder("download", "日本語フォルダ")
|
||||
|
||||
@@ -230,7 +230,7 @@ class TestFileManagerSecurity:
|
||||
|
||||
def test_very_long_path(self, memory, real_folder):
|
||||
"""Should handle very long paths gracefully."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.download_folder = str(real_folder["downloads"])
|
||||
|
||||
long_path = "a" * 1000
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Tests for language tools."""
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.agent.tools.language import set_language
|
||||
|
||||
|
||||
class TestSetLanguage:
|
||||
|
||||
def test_success_returns_ok(self, memory):
|
||||
result = set_language("fr")
|
||||
assert result["status"] == "ok"
|
||||
assert result["language"] == "fr"
|
||||
|
||||
def test_message_contains_language(self, memory):
|
||||
result = set_language("en")
|
||||
assert "en" in result["message"]
|
||||
|
||||
def test_persists_to_memory(self, memory):
|
||||
set_language("es")
|
||||
# Verify it's stored in STM
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
mem = get_memory()
|
||||
assert mem.stm.language == "es"
|
||||
|
||||
def test_various_language_codes(self, memory):
|
||||
for lang in ["en", "fr", "es", "de", "it", "pt"]:
|
||||
result = set_language(lang)
|
||||
assert result["status"] == "ok"
|
||||
assert result["language"] == lang
|
||||
|
||||
def test_error_on_memory_failure(self, monkeypatch):
|
||||
from alfred.agent.tools import language as lang_module
|
||||
|
||||
def broken_get_memory():
|
||||
raise RuntimeError("memory unavailable")
|
||||
|
||||
monkeypatch.setattr(lang_module, "get_memory", broken_get_memory)
|
||||
result = set_language("fr")
|
||||
assert result["status"] == "error"
|
||||
assert "error" in result
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Tests for alfred.agent.workflows.loader.WorkflowLoader
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from alfred.agent.workflows.loader import WorkflowLoader
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def workflows_dir(tmp_path):
|
||||
"""A temp directory pre-populated with one valid workflow YAML."""
|
||||
wf = {
|
||||
"name": "test_workflow",
|
||||
"description": "A test workflow",
|
||||
"tools": ["list_folder", "move_media"],
|
||||
"steps": [
|
||||
{"id": "step1", "tool": "list_folder"},
|
||||
],
|
||||
}
|
||||
(tmp_path / "test_workflow.yaml").write_text(yaml.dump(wf))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loader_from_dir(workflows_dir, monkeypatch):
|
||||
"""WorkflowLoader pointed at our temp dir."""
|
||||
import alfred.agent.workflows.loader as loader_module
|
||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", workflows_dir)
|
||||
return WorkflowLoader()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real loader (loads actual YAML files from the repo)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRealWorkflows:
|
||||
|
||||
def test_organize_media_loaded(self):
|
||||
loader = WorkflowLoader()
|
||||
assert "organize_media" in loader.names()
|
||||
|
||||
def test_organize_media_has_required_keys(self):
|
||||
loader = WorkflowLoader()
|
||||
wf = loader.get("organize_media")
|
||||
assert "name" in wf
|
||||
assert "steps" in wf
|
||||
assert "tools" in wf
|
||||
|
||||
def test_organize_media_tools_list(self):
|
||||
loader = WorkflowLoader()
|
||||
wf = loader.get("organize_media")
|
||||
tools = wf["tools"]
|
||||
assert "list_folder" in tools
|
||||
assert "move_media" in tools
|
||||
assert "manage_subtitles" in tools
|
||||
assert "create_seed_links" in tools
|
||||
assert "resolve_destination" in tools
|
||||
|
||||
def test_organize_media_steps_order(self):
|
||||
loader = WorkflowLoader()
|
||||
wf = loader.get("organize_media")
|
||||
step_ids = [s["id"] for s in wf["steps"]]
|
||||
# resolve_destination must come before move_file
|
||||
assert step_ids.index("resolve_destination") < step_ids.index("move_file")
|
||||
# move_file before handle_subtitles
|
||||
assert step_ids.index("move_file") < step_ids.index("handle_subtitles")
|
||||
# ask_seeding before create_seed_links
|
||||
assert step_ids.index("ask_seeding") < step_ids.index("create_seed_links")
|
||||
|
||||
def test_ask_seeding_has_yes_no_answers(self):
|
||||
loader = WorkflowLoader()
|
||||
wf = loader.get("organize_media")
|
||||
ask_step = next(s for s in wf["steps"] if s["id"] == "ask_seeding")
|
||||
answers = ask_step["ask_user"]["answers"]
|
||||
# PyYAML parses yes/no as booleans — we normalise to str in runtime
|
||||
answer_keys = {str(k) for k in answers.keys()}
|
||||
assert "yes" in answer_keys
|
||||
assert "no" in answer_keys
|
||||
|
||||
def test_naming_convention_present(self):
|
||||
loader = WorkflowLoader()
|
||||
wf = loader.get("organize_media")
|
||||
assert "naming_convention" in wf
|
||||
assert "tv_show" in wf["naming_convention"]
|
||||
assert "movie" in wf["naming_convention"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WorkflowLoader mechanics (via monkeypatched dir)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoaderMechanics:
|
||||
|
||||
def test_get_returns_workflow(self, loader_from_dir):
|
||||
wf = loader_from_dir.get("test_workflow")
|
||||
assert wf is not None
|
||||
assert wf["name"] == "test_workflow"
|
||||
|
||||
def test_get_returns_none_for_unknown(self, loader_from_dir):
|
||||
assert loader_from_dir.get("nonexistent") is None
|
||||
|
||||
def test_names_returns_list(self, loader_from_dir):
|
||||
names = loader_from_dir.names()
|
||||
assert isinstance(names, list)
|
||||
assert "test_workflow" in names
|
||||
|
||||
def test_all_returns_dict(self, loader_from_dir):
|
||||
all_wf = loader_from_dir.all()
|
||||
assert isinstance(all_wf, dict)
|
||||
assert "test_workflow" in all_wf
|
||||
|
||||
def test_uses_yaml_name_field(self, tmp_path, monkeypatch):
|
||||
"""name from YAML content takes priority over filename stem."""
|
||||
import alfred.agent.workflows.loader as loader_module
|
||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||
|
||||
wf = {"name": "my_custom_name", "steps": []}
|
||||
(tmp_path / "completely_different_filename.yaml").write_text(yaml.dump(wf))
|
||||
|
||||
loader = WorkflowLoader()
|
||||
assert "my_custom_name" in loader.names()
|
||||
assert "completely_different_filename" not in loader.names()
|
||||
|
||||
def test_falls_back_to_stem_when_no_name(self, tmp_path, monkeypatch):
|
||||
import alfred.agent.workflows.loader as loader_module
|
||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||
|
||||
(tmp_path / "my_workflow.yaml").write_text(yaml.dump({"steps": []}))
|
||||
loader = WorkflowLoader()
|
||||
assert "my_workflow" in loader.names()
|
||||
|
||||
def test_skips_malformed_yaml(self, tmp_path, monkeypatch):
|
||||
import alfred.agent.workflows.loader as loader_module
|
||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||
|
||||
(tmp_path / "valid.yaml").write_text(yaml.dump({"name": "valid", "steps": []}))
|
||||
(tmp_path / "broken.yaml").write_text("key: [unclosed bracket")
|
||||
|
||||
loader = WorkflowLoader()
|
||||
assert "valid" in loader.names()
|
||||
assert "broken" not in loader.names()
|
||||
|
||||
def test_deterministic_load_order(self, tmp_path, monkeypatch):
|
||||
"""Files loaded in sorted order — later file wins on name collision."""
|
||||
import alfred.agent.workflows.loader as loader_module
|
||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||
|
||||
(tmp_path / "a_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 1}))
|
||||
(tmp_path / "b_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 2}))
|
||||
|
||||
loader = WorkflowLoader()
|
||||
# b_workflow loaded last → version 2 wins
|
||||
assert loader.get("duplicate")["version"] == 2
|
||||
|
||||
def test_empty_directory(self, tmp_path, monkeypatch):
|
||||
import alfred.agent.workflows.loader as loader_module
|
||||
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
|
||||
|
||||
loader = WorkflowLoader()
|
||||
assert loader.names() == []
|
||||
assert loader.all() == {}
|
||||
Reference in New Issue
Block a user