Files
alfred/tests/domain/test_tv_shows.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

233 lines
7.0 KiB
Python

"""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