Files
alfred/tests/test_prompts_edge_cases.py
T
francwa e45465d52d feat: split resolve_destination, persona-driven prompts, qBittorrent relocation
Destination resolution
- Replace the single ResolveDestinationUseCase with four dedicated
  functions, one per release type:
    resolve_season_destination    (pack season, folder move)
    resolve_episode_destination   (single episode, file move)
    resolve_movie_destination     (movie, file move)
    resolve_series_destination    (multi-season pack, folder move)
- Each returns a dedicated DTO carrying only the fields relevant to
  that release type — no more polymorphic ResolvedDestination with
  half the fields unused depending on the case.
- Looser series folder matching: exact computed-name match is reused
  silently; any deviation (different group, multiple candidates) now
  prompts the user with all options including the computed name.

Agent tools
- Four new tools wrapping the use cases above; old resolve_destination
  removed from the registry.
- New move_to_destination tool: create_folder + move, chained — used
  after a resolve_* call to perform the actual relocation.
- Low-level filesystem_operations module (create_folder, move via mv)
  for instant same-FS renames (ZFS).

Prompt & persona
- New PromptBuilder (alfred/agent/prompt.py) replacing prompts.py:
  identity + personality block, situational expressions, memory
  schema, episodic/STM/config context, tool catalogue.
- Per-user expression system: knowledge/users/common.yaml +
  {username}.yaml are merged at runtime; one phrase per situation
  (greeting/success/error/...) is sampled into the system prompt.

qBittorrent integration
- Credentials now come from settings (qbittorrent_url/username/password)
  instead of hardcoded defaults.
- New client methods: find_by_name, set_location, recheck — the trio
  needed to update a torrent's save path and re-verify after a move.
- Host→container path translation settings (qbittorrent_host_path /
  qbittorrent_container_path) for docker-mounted setups.

Subtitles
- Identifier: strip parenthesized qualifiers (simplified, brazil…) at
  tokenization; new _tokenize_suffix used for the episode_subfolder
  pattern so episode-stem tokens no longer pollute language detection.
- Placer: extract _build_dest_name so it can be reused by the new
  dry_run path in ManageSubtitlesUseCase.
- Knowledge: add yue, ell, ind, msa, rus, vie, heb, tam, tel, tha,
  hin, ukr; add 'fre' to fra; add 'simplified'/'traditional' to zho.

Misc
- LTM workspace: add 'trash' folder slot.
- Default LLM provider switched to deepseek.
- testing/debug_release.py: CLI to parse a release, hit TMDB, and
  dry-run the destination resolution end-to-end.
2026-05-14 05:01:59 +02:00

403 lines
12 KiB
Python

"""Edge case tests for PromptBuilder."""
from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import make_tools
from alfred.settings import settings
class TestPromptBuilderEdgeCases:
"""Edge case tests for PromptBuilder."""
def test_prompt_with_empty_memory(self, memory):
"""Should build prompt with completely empty memory."""
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
assert "AVAILABLE TOOLS" in prompt
assert "CURRENT CONFIGURATION" in prompt
def test_prompt_with_unicode_config(self, memory):
"""Should handle unicode in config."""
memory.ltm.download_folder = "/path/to/日本語"
memory.ltm.tvshow_folder = "/path/🎬"
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
assert "日本語" in prompt
assert "🎬" in prompt
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.download_folder = long_path
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
# Should include the path (possibly truncated)
assert "very/long/path" in prompt
def test_prompt_with_special_chars_in_config(self, memory):
"""Should escape special characters in config."""
memory.ltm.download_folder = '/path/with "quotes" and \\backslash'
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
# Should be valid (not crash)
assert "CURRENT CONFIGURATION" in prompt
def test_prompt_with_many_search_results(self, memory):
"""Should limit displayed search results."""
results = [{"name": f"Torrent {i}", "seeders": i} for i in range(50)]
memory.episodic.store_search_results("test query", results)
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
# Should show limited results
assert "LAST SEARCH" in prompt
# Should indicate there are more
assert "more" in prompt.lower() or "..." in prompt
def test_prompt_with_search_results_missing_fields(self, memory):
"""Should handle search results with missing fields."""
results = [
{"name": "Complete"},
{}, # Empty
{"seeders": 100}, # Missing name
]
memory.episodic.store_search_results("test", results)
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
# Should not crash
assert "LAST SEARCH" in prompt
def test_prompt_with_many_active_downloads(self, memory):
"""Should limit displayed active downloads."""
for i in range(20):
memory.episodic.add_active_download(
{
"task_id": str(i),
"name": f"Download {i}",
"progress": i * 5,
}
)
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
assert "ACTIVE DOWNLOADS" in prompt
# Should show limited number
assert "Download 0" in prompt
def test_prompt_with_many_errors(self, memory):
"""Should show recent errors."""
for i in range(10):
memory.episodic.add_error(f"action_{i}", f"Error {i}")
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
assert "RECENT ERRORS" in prompt
# Should show the most recent errors (up to 3)
def test_prompt_with_pending_question_many_options(self, memory):
"""Should handle pending question with many options."""
options = [{"index": i, "label": f"Option {i}"} for i in range(20)]
memory.episodic.set_pending_question("Choose one:", options, {})
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
assert "PENDING QUESTION" in prompt
assert "Choose one:" in prompt
def test_prompt_with_complex_workflow(self, memory):
"""Should handle complex workflow state."""
memory.stm.start_workflow(
"download",
{
"title": "Test Movie",
"year": 2024,
"quality": "1080p",
"nested": {"deep": {"value": "test"}},
},
)
memory.stm.update_workflow_stage("searching_torrents")
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
assert "CURRENT WORKFLOW" in prompt
assert "download" in prompt
assert "searching_torrents" in prompt
def test_prompt_with_many_entities(self, memory):
"""Should handle many extracted entities."""
for i in range(50):
memory.stm.set_entity(f"entity_{i}", f"value_{i}")
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
assert "EXTRACTED ENTITIES" in prompt
def test_prompt_with_null_values_in_entities(self, memory):
"""Should handle null values in entities."""
memory.stm.set_entity("null_value", None)
memory.stm.set_entity("empty_string", "")
memory.stm.set_entity("zero", 0)
memory.stm.set_entity("false", False)
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
# Should not crash
assert "EXTRACTED ENTITIES" in prompt
def test_prompt_with_unread_events(self, memory):
"""Should include unread events."""
memory.episodic.add_background_event("download_complete", {"name": "Movie.mkv"})
memory.episodic.add_background_event("new_files", {"count": 5})
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
assert "UNREAD EVENTS" in prompt
def test_prompt_with_all_sections(self, memory):
"""Should include all sections when all data present."""
# Config
memory.ltm.download_folder = "/downloads"
# Search results
memory.episodic.store_search_results("test", [{"name": "Result"}])
# Active downloads
memory.episodic.add_active_download({"task_id": "1", "name": "Download"})
# Errors
memory.episodic.add_error("action", "error")
# Pending question
memory.episodic.set_pending_question("Question?", [], {})
# Workflow
memory.stm.start_workflow("download", {"title": "Test"})
# Topic
memory.stm.set_topic("searching")
# Entities
memory.stm.set_entity("key", "value")
# Events
memory.episodic.add_background_event("event", {})
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
# All sections should be present
assert "CURRENT CONFIGURATION" in prompt
assert "LAST SEARCH" in prompt
assert "ACTIVE DOWNLOADS" in prompt
assert "RECENT ERRORS" in prompt
assert "PENDING QUESTION" in prompt
assert "CURRENT WORKFLOW" in prompt
assert "CURRENT TOPIC" in prompt
assert "EXTRACTED ENTITIES" in prompt
assert "UNREAD EVENTS" in prompt
def test_prompt_json_serializable(self, memory):
"""Should produce JSON-serializable content."""
memory.ltm.download_folder = "/some/path"
memory.stm.set_entity("complex", {"a": {"b": {"c": "d"}}})
tools = make_tools(settings)
builder = PromptBuilder(tools)
prompt = builder.build_system_prompt()
# The prompt itself is a string, but embedded JSON should be valid
assert isinstance(prompt, str)
class TestFormatToolsDescriptionEdgeCases:
"""Edge case tests for _format_tools_description."""
def test_format_with_no_tools(self, memory):
"""Should handle empty tools dict."""
builder = PromptBuilder({})
desc = builder._format_tools_description()
assert desc == ""
def test_format_with_complex_parameters(self, memory):
"""Should format complex parameter schemas."""
from alfred.agent.registry import Tool
tools = {
"complex_tool": Tool(
name="complex_tool",
description="A complex tool",
func=lambda: {},
parameters={
"type": "object",
"properties": {
"nested": {
"type": "object",
"properties": {
"deep": {"type": "string"},
},
},
"array": {
"type": "array",
"items": {"type": "integer"},
},
},
"required": ["nested"],
},
),
}
builder = PromptBuilder(tools)
desc = builder._format_tools_description()
assert "complex_tool" in desc
assert "nested" in desc
class TestFormatEpisodicContextEdgeCases:
"""Edge case tests for _format_episodic_context."""
def test_format_with_empty_search_query(self, memory):
"""Should handle empty search query."""
memory.episodic.store_search_results("", [{"name": "Result"}])
tools = make_tools(settings)
builder = PromptBuilder(tools)
context = builder._format_episodic_context(memory)
assert "LAST SEARCH" in context
def test_format_with_search_results_none_names(self, memory):
"""Should handle results with None names."""
memory.episodic.store_search_results(
"test",
[
{"name": None},
{"title": None},
{},
],
)
tools = make_tools(settings)
builder = PromptBuilder(tools)
context = builder._format_episodic_context(memory)
# Should not crash
assert "LAST SEARCH" in context
def test_format_with_download_missing_progress(self, memory):
"""Should handle download without progress."""
memory.episodic.add_active_download({"task_id": "1", "name": "Test"})
tools = make_tools(settings)
builder = PromptBuilder(tools)
context = builder._format_episodic_context(memory)
assert "ACTIVE DOWNLOADS" in context
assert "0%" in context # Default progress
class TestFormatStmContextEdgeCases:
"""Edge case tests for _format_stm_context."""
def test_format_with_workflow_missing_target(self, memory):
"""Should handle workflow with missing target."""
memory.stm.current_workflow = {
"type": "download",
"stage": "started",
}
tools = make_tools(settings)
builder = PromptBuilder(tools)
context = builder._format_stm_context(memory)
assert "CURRENT WORKFLOW" in context
def test_format_with_workflow_none_target(self, memory):
"""Should handle workflow with None target."""
memory.stm.start_workflow("download", None)
tools = make_tools(settings)
builder = PromptBuilder(tools)
try:
context = builder._format_stm_context(memory)
assert "CURRENT WORKFLOW" in context or True
except (AttributeError, TypeError):
# Expected if None target causes issues
pass
def test_format_with_empty_topic(self, memory):
"""Should handle empty topic."""
memory.stm.set_topic("")
tools = make_tools(settings)
builder = PromptBuilder(tools)
context = builder._format_stm_context(memory)
# Empty topic might not be shown
assert isinstance(context, str)
def test_format_with_entities_containing_json(self, memory):
"""Should handle entities containing JSON strings."""
memory.stm.set_entity("json_string", '{"key": "value"}')
tools = make_tools(settings)
builder = PromptBuilder(tools)
context = builder._format_stm_context(memory)
assert "EXTRACTED ENTITIES" in context