|
|
|
@@ -1,24 +1,27 @@
|
|
|
|
|
"""Tests for the TV Show domain — entities, value objects, builders.
|
|
|
|
|
|
|
|
|
|
Post-2026-05-22 refactor:
|
|
|
|
|
Post-2026-05-25 Phase 3 refactor:
|
|
|
|
|
|
|
|
|
|
* The aggregate is **frozen all the way** — ``TVShow``, ``Season`` and
|
|
|
|
|
``Episode`` are all ``@dataclass(frozen=True)``. Children are stored as
|
|
|
|
|
ordered tuples sorted by number.
|
|
|
|
|
* TMDB-only aggregate. ``TVShow`` requires ``TmdbId`` (primary key);
|
|
|
|
|
``ImdbId`` is optional. Status is a raw TMDB string (``"unknown"``
|
|
|
|
|
default).
|
|
|
|
|
* ``Season`` carries TMDB ``episode_count`` (cached count, independent
|
|
|
|
|
of how many ``Episode`` objects are materialized). No ``mode``
|
|
|
|
|
property — release mode lives on ``SeasonRelease``.
|
|
|
|
|
* ``Episode`` carries identity + title only. No tracks, no file path —
|
|
|
|
|
those live on ``EpisodeRelease`` keyed by the same
|
|
|
|
|
``(season_number, episode_number)`` slot.
|
|
|
|
|
* Construction goes exclusively through :class:`TVShowBuilder` (and its
|
|
|
|
|
helper :class:`SeasonBuilder`). No more ``add_episode`` / ``add_season``
|
|
|
|
|
on entities.
|
|
|
|
|
* ShowTracker-territory fields (production status, expected vs aired
|
|
|
|
|
counts, collection completeness) are removed from the domain. The
|
|
|
|
|
aggregate carries only what the ``.alfred`` sidecar stores.
|
|
|
|
|
helper :class:`SeasonBuilder`).
|
|
|
|
|
|
|
|
|
|
Coverage:
|
|
|
|
|
|
|
|
|
|
* ``TestSeasonNumber`` / ``TestEpisodeNumber`` — value-object validation.
|
|
|
|
|
* ``TestSeasonMode`` — enum sanity (computed in ``TestSeason``).
|
|
|
|
|
* ``TestEpisode`` — basic shape, file presence, audio/subtitle helpers.
|
|
|
|
|
* ``TestSeason`` — frozen shape, episode lookup, mode derivation.
|
|
|
|
|
* ``TestTVShow`` — frozen aggregate root, season lookup, counts.
|
|
|
|
|
* ``TestSeasonMode`` — enum sanity (the legacy SeasonMode VO is still
|
|
|
|
|
used by parser/release code; this test guards its values).
|
|
|
|
|
* ``TestEpisode`` — frozen identity-only shape.
|
|
|
|
|
* ``TestSeason`` — frozen shape, episode lookup, episode_count.
|
|
|
|
|
* ``TestTVShow`` — TmdbId-keyed aggregate root, season lookup, counts.
|
|
|
|
|
* ``TestSeasonBuilder`` / ``TestTVShowBuilder`` — sole sanctioned mutation
|
|
|
|
|
surface; ordering, last-write-wins, ``from_existing`` round-trip.
|
|
|
|
|
"""
|
|
|
|
@@ -30,8 +33,7 @@ import dataclasses
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from alfred.domain.shared.exceptions import ValidationError
|
|
|
|
|
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
|
|
|
|
|
from alfred.domain.shared.value_objects import ImdbId, Language
|
|
|
|
|
from alfred.domain.shared.value_objects import ImdbId, TmdbId
|
|
|
|
|
from alfred.domain.tv_shows.builders import SeasonBuilder, TVShowBuilder
|
|
|
|
|
from alfred.domain.tv_shows.entities import Episode, Season, TVShow
|
|
|
|
|
from alfred.domain.tv_shows.value_objects import (
|
|
|
|
@@ -117,12 +119,11 @@ class TestSeasonMode:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEpisode:
|
|
|
|
|
def _ep(self, *, season=1, episode=1, title="Pilot", **kwargs) -> Episode:
|
|
|
|
|
def _ep(self, *, season=1, episode=1, title="Pilot") -> Episode:
|
|
|
|
|
return Episode(
|
|
|
|
|
season_number=season,
|
|
|
|
|
episode_number=episode,
|
|
|
|
|
title=title,
|
|
|
|
|
**kwargs,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_basic_creation_coerces_numbers(self):
|
|
|
|
@@ -142,81 +143,19 @@ class TestEpisode:
|
|
|
|
|
assert filename.startswith("S01E05")
|
|
|
|
|
assert "Gray.Matter" in filename
|
|
|
|
|
|
|
|
|
|
def test_file_path_unset_by_default(self):
|
|
|
|
|
e = self._ep()
|
|
|
|
|
assert e.file_path is None
|
|
|
|
|
|
|
|
|
|
def test_str_format(self):
|
|
|
|
|
e = self._ep(season=2, episode=3, title="Bit by a Dead Bee")
|
|
|
|
|
s = str(e)
|
|
|
|
|
assert "S02E03" in s
|
|
|
|
|
assert "Bit by a Dead Bee" in s
|
|
|
|
|
|
|
|
|
|
# ── Audio helpers ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_has_audio_in_with_str(self):
|
|
|
|
|
e = self._ep(
|
|
|
|
|
audio_tracks=(
|
|
|
|
|
AudioTrack(0, "eac3", 6, "5.1", "eng"),
|
|
|
|
|
AudioTrack(1, "ac3", 6, "5.1", "fre"),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert e.has_audio_in("eng") is True
|
|
|
|
|
assert e.has_audio_in("ENG") is True # case-insensitive
|
|
|
|
|
assert e.has_audio_in("ger") is False
|
|
|
|
|
|
|
|
|
|
def test_has_audio_in_with_language(self):
|
|
|
|
|
lang = Language(
|
|
|
|
|
iso="fre",
|
|
|
|
|
english_name="French",
|
|
|
|
|
native_name="Français",
|
|
|
|
|
aliases=("fr", "fra", "french"),
|
|
|
|
|
)
|
|
|
|
|
e = self._ep(audio_tracks=(AudioTrack(0, "ac3", 6, "5.1", "fr"),))
|
|
|
|
|
# str query "fre" wouldn't match "fr" directly — but Language does cross-format
|
|
|
|
|
assert e.has_audio_in(lang) is True
|
|
|
|
|
assert e.has_audio_in("fre") is False # direct compare misses
|
|
|
|
|
|
|
|
|
|
def test_audio_languages_dedup_in_order(self):
|
|
|
|
|
e = self._ep(
|
|
|
|
|
audio_tracks=(
|
|
|
|
|
AudioTrack(0, "ac3", 6, "5.1", "eng"),
|
|
|
|
|
AudioTrack(1, "ac3", 6, "5.1", "fre"),
|
|
|
|
|
AudioTrack(2, "aac", 2, "stereo", "eng"), # dupe
|
|
|
|
|
AudioTrack(3, "aac", 2, "stereo", None), # skipped
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert e.audio_languages() == ["eng", "fre"]
|
|
|
|
|
|
|
|
|
|
# ── Subtitle helpers ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_has_subtitles_in(self):
|
|
|
|
|
e = self._ep(subtitle_tracks=(SubtitleTrack(0, "subrip", "fre"),))
|
|
|
|
|
assert e.has_subtitles_in("fre") is True
|
|
|
|
|
assert e.has_subtitles_in("eng") is False
|
|
|
|
|
|
|
|
|
|
def test_has_forced_subs(self):
|
|
|
|
|
e = self._ep(
|
|
|
|
|
subtitle_tracks=(
|
|
|
|
|
SubtitleTrack(0, "subrip", "eng", is_forced=False),
|
|
|
|
|
SubtitleTrack(1, "subrip", "eng", is_forced=True),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert e.has_forced_subs() is True
|
|
|
|
|
|
|
|
|
|
def test_has_forced_subs_false_when_none(self):
|
|
|
|
|
e = self._ep(subtitle_tracks=(SubtitleTrack(0, "subrip", "eng"),))
|
|
|
|
|
assert e.has_forced_subs() is False
|
|
|
|
|
|
|
|
|
|
def test_subtitle_languages_dedup_in_order(self):
|
|
|
|
|
e = self._ep(
|
|
|
|
|
subtitle_tracks=(
|
|
|
|
|
SubtitleTrack(0, "subrip", "eng"),
|
|
|
|
|
SubtitleTrack(1, "subrip", "fre"),
|
|
|
|
|
SubtitleTrack(2, "subrip", "eng"),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert e.subtitle_languages() == ["eng", "fre"]
|
|
|
|
|
def test_equality_is_identity_within_aggregate(self):
|
|
|
|
|
a = self._ep(season=1, episode=1, title="Pilot")
|
|
|
|
|
b = self._ep(season=1, episode=1, title="DIFFERENT TITLE")
|
|
|
|
|
c = self._ep(season=1, episode=2, title="Pilot")
|
|
|
|
|
assert a == b # same slot, different title
|
|
|
|
|
assert a != c # different slot
|
|
|
|
|
assert hash(a) == hash(b)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@@ -226,55 +165,53 @@ class TestEpisode:
|
|
|
|
|
|
|
|
|
|
class TestSeason:
|
|
|
|
|
def _ep(self, episode: int) -> Episode:
|
|
|
|
|
return Episode(season_number=1, episode_number=episode, title=f"Ep {episode}")
|
|
|
|
|
return Episode(season_number=1, episode_number=episode, title=f"E{episode}")
|
|
|
|
|
|
|
|
|
|
def test_basic_creation_coerces_season_number(self):
|
|
|
|
|
s = Season(season_number=1)
|
|
|
|
|
s = Season(season_number=1, episode_count=10)
|
|
|
|
|
assert isinstance(s.season_number, SeasonNumber)
|
|
|
|
|
assert s.episode_count == 0
|
|
|
|
|
assert s.episodes == ()
|
|
|
|
|
assert s.episode_count == 10
|
|
|
|
|
|
|
|
|
|
def test_is_frozen(self):
|
|
|
|
|
s = Season(season_number=1)
|
|
|
|
|
s = Season(season_number=SeasonNumber(1))
|
|
|
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
|
|
|
s.episodes = (self._ep(1),) # type: ignore[misc]
|
|
|
|
|
s.episode_count = 5 # type: ignore[misc]
|
|
|
|
|
|
|
|
|
|
def test_get_folder_name_normal(self):
|
|
|
|
|
assert Season(season_number=2).get_folder_name() == "Season 02"
|
|
|
|
|
s = Season(season_number=SeasonNumber(3))
|
|
|
|
|
assert s.get_folder_name() == "Season 03"
|
|
|
|
|
|
|
|
|
|
def test_get_folder_name_specials(self):
|
|
|
|
|
s = Season(season_number=0)
|
|
|
|
|
s = Season(season_number=SeasonNumber(0))
|
|
|
|
|
assert s.get_folder_name() == "Specials"
|
|
|
|
|
assert s.is_special()
|
|
|
|
|
|
|
|
|
|
# ── Mode derivation ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_mode_pack_when_no_episodes(self):
|
|
|
|
|
s = Season(season_number=1)
|
|
|
|
|
assert s.mode == SeasonMode.PACK
|
|
|
|
|
|
|
|
|
|
def test_mode_episodic_when_episodes_present(self):
|
|
|
|
|
s = Season(season_number=1, episodes=(self._ep(1),))
|
|
|
|
|
assert s.mode == SeasonMode.EPISODIC
|
|
|
|
|
|
|
|
|
|
# ── Episode access ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_get_episode_returns_match(self):
|
|
|
|
|
ep1 = self._ep(1)
|
|
|
|
|
ep2 = self._ep(2)
|
|
|
|
|
s = Season(season_number=1, episodes=(ep1, ep2))
|
|
|
|
|
assert s.get_episode(EpisodeNumber(2)) is ep2
|
|
|
|
|
e1 = self._ep(1)
|
|
|
|
|
e2 = self._ep(2)
|
|
|
|
|
s = Season(season_number=SeasonNumber(1), episodes=(e1, e2))
|
|
|
|
|
assert s.get_episode(EpisodeNumber(2)) is e2
|
|
|
|
|
|
|
|
|
|
def test_get_episode_returns_none_when_absent(self):
|
|
|
|
|
s = Season(season_number=1, episodes=(self._ep(1),))
|
|
|
|
|
s = Season(season_number=SeasonNumber(1), episodes=(self._ep(1),))
|
|
|
|
|
assert s.get_episode(EpisodeNumber(99)) is None
|
|
|
|
|
|
|
|
|
|
def test_episode_count_reflects_tuple_size(self):
|
|
|
|
|
def test_episode_count_defaults_to_zero(self):
|
|
|
|
|
s = Season(season_number=SeasonNumber(1))
|
|
|
|
|
assert s.episode_count == 0
|
|
|
|
|
|
|
|
|
|
def test_episode_count_independent_of_materialized_episodes(self):
|
|
|
|
|
# TMDB says 10 episodes exist; only 2 have been hydrated so far.
|
|
|
|
|
s = Season(
|
|
|
|
|
season_number=1,
|
|
|
|
|
episodes=(self._ep(1), self._ep(2), self._ep(3)),
|
|
|
|
|
season_number=SeasonNumber(1),
|
|
|
|
|
episode_count=10,
|
|
|
|
|
episodes=(self._ep(1), self._ep(2)),
|
|
|
|
|
)
|
|
|
|
|
assert s.episode_count == 3
|
|
|
|
|
assert s.episode_count == 10
|
|
|
|
|
assert len(s.episodes) == 2
|
|
|
|
|
|
|
|
|
|
def test_negative_episode_count_raises(self):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
Season(season_number=SeasonNumber(1), episode_count=-1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@@ -284,70 +221,75 @@ class TestSeason:
|
|
|
|
|
|
|
|
|
|
class TestTVShow:
|
|
|
|
|
def _show(self, **kwargs) -> TVShow:
|
|
|
|
|
defaults = dict(imdb_id="tt0903747", title="Breaking Bad")
|
|
|
|
|
defaults = dict(tmdb_id=TmdbId(1396), title="Breaking Bad")
|
|
|
|
|
defaults.update(kwargs)
|
|
|
|
|
return TVShow(**defaults)
|
|
|
|
|
|
|
|
|
|
# ── Construction & coercion ────────────────────────────────────────
|
|
|
|
|
return TVShow(**defaults) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
def test_basic_creation(self):
|
|
|
|
|
show = self._show()
|
|
|
|
|
assert show.title == "Breaking Bad"
|
|
|
|
|
assert show.seasons == ()
|
|
|
|
|
assert show.seasons_count == 0
|
|
|
|
|
assert show.episode_count == 0
|
|
|
|
|
s = self._show()
|
|
|
|
|
assert s.tmdb_id == TmdbId(1396)
|
|
|
|
|
assert s.title == "Breaking Bad"
|
|
|
|
|
assert s.imdb_id is None
|
|
|
|
|
assert s.status == "unknown"
|
|
|
|
|
assert s.seasons == ()
|
|
|
|
|
|
|
|
|
|
def test_coerces_string_imdb_id(self):
|
|
|
|
|
assert isinstance(self._show().imdb_id, ImdbId)
|
|
|
|
|
def test_imdb_id_optional(self):
|
|
|
|
|
s = self._show(imdb_id=ImdbId("tt0903747"))
|
|
|
|
|
assert s.imdb_id == ImdbId("tt0903747")
|
|
|
|
|
|
|
|
|
|
def test_invalid_tmdb_id_type_raises(self):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
TVShow(tmdb_id=1396, title="X") # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
def test_invalid_imdb_id_type_raises(self):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
TVShow(imdb_id=12345, title="X") # type: ignore
|
|
|
|
|
TVShow(tmdb_id=TmdbId(1), title="X", imdb_id="tt0903747") # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
def test_is_frozen(self):
|
|
|
|
|
show = self._show()
|
|
|
|
|
s = self._show()
|
|
|
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
|
|
|
show.title = "Other" # type: ignore[misc]
|
|
|
|
|
s.title = "Other" # type: ignore[misc]
|
|
|
|
|
|
|
|
|
|
def test_get_folder_name_replaces_spaces(self):
|
|
|
|
|
assert self._show(title="Breaking Bad").get_folder_name() == "Breaking.Bad"
|
|
|
|
|
assert self._show().get_folder_name() == "Breaking.Bad"
|
|
|
|
|
|
|
|
|
|
def test_get_folder_name_strips_special_chars(self):
|
|
|
|
|
name = self._show(title="It's Always Sunny").get_folder_name()
|
|
|
|
|
assert "'" not in name
|
|
|
|
|
s = self._show(title="Marvel's Agents of S.H.I.E.L.D.")
|
|
|
|
|
# Special chars stripped, spaces become dots
|
|
|
|
|
folder = s.get_folder_name()
|
|
|
|
|
assert " " not in folder
|
|
|
|
|
# Apostrophe gone; dot-segments preserved
|
|
|
|
|
assert "Marvels" in folder
|
|
|
|
|
|
|
|
|
|
def test_str_repr(self):
|
|
|
|
|
show = self._show()
|
|
|
|
|
assert "Breaking Bad" in str(show)
|
|
|
|
|
assert "tt0903747" in repr(show)
|
|
|
|
|
|
|
|
|
|
# ── Season access ──────────────────────────────────────────────────
|
|
|
|
|
def test_str_and_repr(self):
|
|
|
|
|
s = self._show(seasons=(Season(season_number=SeasonNumber(1)),))
|
|
|
|
|
assert "Breaking Bad" in str(s)
|
|
|
|
|
assert "TVShow(tmdb_id=" in repr(s)
|
|
|
|
|
|
|
|
|
|
def test_get_season_returns_match(self):
|
|
|
|
|
s1 = Season(season_number=1)
|
|
|
|
|
s2 = Season(season_number=2)
|
|
|
|
|
show = self._show(seasons=(s1, s2))
|
|
|
|
|
assert show.get_season(SeasonNumber(2)) is s2
|
|
|
|
|
|
|
|
|
|
def test_get_season_returns_none_when_absent(self):
|
|
|
|
|
show = self._show(seasons=(Season(season_number=1),))
|
|
|
|
|
assert show.get_season(SeasonNumber(99)) is None
|
|
|
|
|
|
|
|
|
|
def test_episode_count_aggregates_across_seasons(self):
|
|
|
|
|
ep11 = Episode(season_number=1, episode_number=1, title="x")
|
|
|
|
|
ep12 = Episode(season_number=1, episode_number=2, title="y")
|
|
|
|
|
ep21 = Episode(season_number=2, episode_number=1, title="z")
|
|
|
|
|
show = self._show(
|
|
|
|
|
s = self._show(
|
|
|
|
|
seasons=(
|
|
|
|
|
Season(season_number=1, episodes=(ep11, ep12)),
|
|
|
|
|
Season(season_number=2, episodes=(ep21,)),
|
|
|
|
|
Season(season_number=SeasonNumber(1)),
|
|
|
|
|
Season(season_number=SeasonNumber(2)),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert show.episode_count == 3
|
|
|
|
|
assert show.seasons_count == 2
|
|
|
|
|
season2 = s.get_season(SeasonNumber(2))
|
|
|
|
|
assert season2 is not None
|
|
|
|
|
assert season2.season_number == SeasonNumber(2)
|
|
|
|
|
|
|
|
|
|
def test_tmdb_id_defaults_to_none(self):
|
|
|
|
|
assert self._show().tmdb_id is None
|
|
|
|
|
def test_get_season_returns_none_when_absent(self):
|
|
|
|
|
s = self._show()
|
|
|
|
|
assert s.get_season(SeasonNumber(99)) is None
|
|
|
|
|
|
|
|
|
|
def test_episode_count_aggregates_across_seasons(self):
|
|
|
|
|
s = self._show(
|
|
|
|
|
seasons=(
|
|
|
|
|
Season(season_number=SeasonNumber(1), episode_count=7),
|
|
|
|
|
Season(season_number=SeasonNumber(2), episode_count=13),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert s.episode_count == 20
|
|
|
|
|
assert s.seasons_count == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@@ -357,62 +299,72 @@ class TestTVShow:
|
|
|
|
|
|
|
|
|
|
class TestSeasonBuilder:
|
|
|
|
|
def _ep(self, episode: int) -> Episode:
|
|
|
|
|
return Episode(season_number=1, episode_number=episode, title=f"Ep {episode}")
|
|
|
|
|
return Episode(season_number=1, episode_number=episode, title=f"E{episode}")
|
|
|
|
|
|
|
|
|
|
def test_build_empty(self):
|
|
|
|
|
s = SeasonBuilder(SeasonNumber(1)).build()
|
|
|
|
|
assert isinstance(s, Season)
|
|
|
|
|
s = SeasonBuilder(1).build()
|
|
|
|
|
assert s.season_number == SeasonNumber(1)
|
|
|
|
|
assert s.episodes == ()
|
|
|
|
|
assert s.mode == SeasonMode.PACK
|
|
|
|
|
assert s.episode_count == 0
|
|
|
|
|
|
|
|
|
|
def test_build_emits_sorted_episodes(self):
|
|
|
|
|
s = (
|
|
|
|
|
SeasonBuilder(SeasonNumber(1))
|
|
|
|
|
.add_episode(self._ep(3))
|
|
|
|
|
.add_episode(self._ep(1))
|
|
|
|
|
.add_episode(self._ep(2))
|
|
|
|
|
.build()
|
|
|
|
|
)
|
|
|
|
|
assert [ep.episode_number.value for ep in s.episodes] == [1, 2, 3]
|
|
|
|
|
sb = SeasonBuilder(1)
|
|
|
|
|
sb.add_episode(self._ep(3))
|
|
|
|
|
sb.add_episode(self._ep(1))
|
|
|
|
|
sb.add_episode(self._ep(2))
|
|
|
|
|
s = sb.build()
|
|
|
|
|
assert [e.episode_number.value for e in s.episodes] == [1, 2, 3]
|
|
|
|
|
|
|
|
|
|
def test_set_episode_count_propagates(self):
|
|
|
|
|
s = SeasonBuilder(1).set_episode_count(10).build()
|
|
|
|
|
assert s.episode_count == 10
|
|
|
|
|
|
|
|
|
|
def test_set_episode_count_rejects_negative(self):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
SeasonBuilder(1).set_episode_count(-1)
|
|
|
|
|
|
|
|
|
|
def test_add_episode_last_write_wins(self):
|
|
|
|
|
first = Episode(season_number=1, episode_number=1, title="First")
|
|
|
|
|
second = Episode(season_number=1, episode_number=1, title="Replacement")
|
|
|
|
|
s = (
|
|
|
|
|
SeasonBuilder(SeasonNumber(1))
|
|
|
|
|
.add_episode(first)
|
|
|
|
|
.add_episode(second)
|
|
|
|
|
.build()
|
|
|
|
|
sb = SeasonBuilder(1)
|
|
|
|
|
sb.add_episode(self._ep(1))
|
|
|
|
|
sb.add_episode(
|
|
|
|
|
Episode(season_number=1, episode_number=1, title="Replacement")
|
|
|
|
|
)
|
|
|
|
|
assert s.episodes == (second,)
|
|
|
|
|
s = sb.build()
|
|
|
|
|
assert len(s.episodes) == 1
|
|
|
|
|
assert s.episodes[0].title == "Replacement"
|
|
|
|
|
|
|
|
|
|
def test_add_episode_rejects_mismatched_season(self):
|
|
|
|
|
builder = SeasonBuilder(SeasonNumber(1))
|
|
|
|
|
sb = SeasonBuilder(1)
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
builder.add_episode(
|
|
|
|
|
Episode(season_number=2, episode_number=1, title="bad")
|
|
|
|
|
sb.add_episode(
|
|
|
|
|
Episode(season_number=2, episode_number=1, title="X")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_int_season_number_coerced(self):
|
|
|
|
|
s = SeasonBuilder(1).build()
|
|
|
|
|
assert s.season_number == SeasonNumber(1)
|
|
|
|
|
sb = SeasonBuilder(5)
|
|
|
|
|
assert sb.season_number == SeasonNumber(5)
|
|
|
|
|
|
|
|
|
|
def test_from_existing_round_trip(self):
|
|
|
|
|
original = Season(
|
|
|
|
|
season_number=1,
|
|
|
|
|
season_number=SeasonNumber(1),
|
|
|
|
|
episode_count=3,
|
|
|
|
|
episodes=(self._ep(1), self._ep(2)),
|
|
|
|
|
)
|
|
|
|
|
rebuilt = SeasonBuilder.from_existing(original).build()
|
|
|
|
|
assert rebuilt == original
|
|
|
|
|
|
|
|
|
|
def test_from_existing_then_add_replaces(self):
|
|
|
|
|
original = Season(season_number=1, episodes=(self._ep(1), self._ep(2)))
|
|
|
|
|
replacement = Episode(season_number=1, episode_number=2, title="New")
|
|
|
|
|
rebuilt = (
|
|
|
|
|
SeasonBuilder.from_existing(original).add_episode(replacement).build()
|
|
|
|
|
original = Season(
|
|
|
|
|
season_number=SeasonNumber(1),
|
|
|
|
|
episode_count=2,
|
|
|
|
|
episodes=(self._ep(1),),
|
|
|
|
|
)
|
|
|
|
|
assert rebuilt.get_episode(EpisodeNumber(2)) is replacement
|
|
|
|
|
sb = SeasonBuilder.from_existing(original)
|
|
|
|
|
sb.add_episode(self._ep(2))
|
|
|
|
|
s = sb.build()
|
|
|
|
|
assert [e.episode_number.value for e in s.episodes] == [1, 2]
|
|
|
|
|
# episode_count preserved across from_existing
|
|
|
|
|
assert s.episode_count == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@@ -423,122 +375,120 @@ class TestSeasonBuilder:
|
|
|
|
|
class TestTVShowBuilder:
|
|
|
|
|
def _ep(self, season: int, episode: int) -> Episode:
|
|
|
|
|
return Episode(
|
|
|
|
|
season_number=season,
|
|
|
|
|
episode_number=episode,
|
|
|
|
|
title=f"S{season:02d}E{episode:02d}",
|
|
|
|
|
season_number=season, episode_number=episode, title=f"S{season}E{episode}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_build_minimal(self):
|
|
|
|
|
show = TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad").build()
|
|
|
|
|
assert isinstance(show, TVShow)
|
|
|
|
|
assert show.title == "Breaking Bad"
|
|
|
|
|
assert show.seasons == ()
|
|
|
|
|
assert show.tmdb_id is None
|
|
|
|
|
s = TVShowBuilder(tmdb_id=TmdbId(1396), title="Breaking Bad").build()
|
|
|
|
|
assert s.tmdb_id == TmdbId(1396)
|
|
|
|
|
assert s.title == "Breaking Bad"
|
|
|
|
|
assert s.imdb_id is None
|
|
|
|
|
assert s.status == "unknown"
|
|
|
|
|
assert s.seasons == ()
|
|
|
|
|
|
|
|
|
|
def test_coerces_string_imdb_id(self):
|
|
|
|
|
show = TVShowBuilder(imdb_id="tt0903747", title="x").build()
|
|
|
|
|
assert isinstance(show.imdb_id, ImdbId)
|
|
|
|
|
def test_builder_with_imdb_and_status(self):
|
|
|
|
|
s = TVShowBuilder(
|
|
|
|
|
tmdb_id=TmdbId(1396),
|
|
|
|
|
title="Breaking Bad",
|
|
|
|
|
imdb_id=ImdbId("tt0903747"),
|
|
|
|
|
status="Ended",
|
|
|
|
|
).build()
|
|
|
|
|
assert s.imdb_id == ImdbId("tt0903747")
|
|
|
|
|
assert s.status == "Ended"
|
|
|
|
|
|
|
|
|
|
def test_builder_rejects_bare_int_tmdb_id(self):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
TVShowBuilder(tmdb_id=1396, title="X") # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
def test_builder_rejects_bare_str_imdb_id(self):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
TVShowBuilder(
|
|
|
|
|
tmdb_id=TmdbId(1), title="X", imdb_id="tt0903747" # type: ignore[arg-type]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_add_episode_creates_missing_season(self):
|
|
|
|
|
show = (
|
|
|
|
|
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
|
|
|
|
.add_episode(self._ep(1, 1))
|
|
|
|
|
.build()
|
|
|
|
|
)
|
|
|
|
|
assert show.seasons_count == 1
|
|
|
|
|
assert show.get_season(SeasonNumber(1)) is not None
|
|
|
|
|
assert show.episode_count == 1
|
|
|
|
|
b = TVShowBuilder(tmdb_id=TmdbId(1396), title="Breaking Bad")
|
|
|
|
|
b.add_episode(self._ep(1, 1))
|
|
|
|
|
s = b.build()
|
|
|
|
|
assert len(s.seasons) == 1
|
|
|
|
|
assert s.seasons[0].season_number == SeasonNumber(1)
|
|
|
|
|
assert s.seasons[0].episodes[0].title == "S1E1"
|
|
|
|
|
|
|
|
|
|
def test_add_episode_reuses_existing_season(self):
|
|
|
|
|
show = (
|
|
|
|
|
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
|
|
|
|
.add_episode(self._ep(1, 1))
|
|
|
|
|
.add_episode(self._ep(1, 2))
|
|
|
|
|
.build()
|
|
|
|
|
)
|
|
|
|
|
assert show.seasons_count == 1
|
|
|
|
|
assert show.episode_count == 2
|
|
|
|
|
b = TVShowBuilder(tmdb_id=TmdbId(1396), title="Breaking Bad")
|
|
|
|
|
b.add_episode(self._ep(1, 1))
|
|
|
|
|
b.add_episode(self._ep(1, 2))
|
|
|
|
|
s = b.build()
|
|
|
|
|
assert len(s.seasons) == 1
|
|
|
|
|
assert [e.episode_number.value for e in s.seasons[0].episodes] == [1, 2]
|
|
|
|
|
|
|
|
|
|
def test_seasons_emitted_sorted(self):
|
|
|
|
|
show = (
|
|
|
|
|
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
|
|
|
|
.add_episode(self._ep(3, 1))
|
|
|
|
|
.add_episode(self._ep(1, 1))
|
|
|
|
|
.add_episode(self._ep(2, 1))
|
|
|
|
|
.build()
|
|
|
|
|
)
|
|
|
|
|
assert [s.season_number.value for s in show.seasons] == [1, 2, 3]
|
|
|
|
|
b = TVShowBuilder(tmdb_id=TmdbId(1396), title="Breaking Bad")
|
|
|
|
|
b.add_episode(self._ep(3, 1))
|
|
|
|
|
b.add_episode(self._ep(1, 1))
|
|
|
|
|
b.add_episode(self._ep(2, 1))
|
|
|
|
|
s = b.build()
|
|
|
|
|
assert [se.season_number.value for se in s.seasons] == [1, 2, 3]
|
|
|
|
|
|
|
|
|
|
def test_episodes_within_season_emitted_sorted(self):
|
|
|
|
|
show = (
|
|
|
|
|
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
|
|
|
|
.add_episode(self._ep(1, 3))
|
|
|
|
|
.add_episode(self._ep(1, 1))
|
|
|
|
|
.add_episode(self._ep(1, 2))
|
|
|
|
|
.build()
|
|
|
|
|
)
|
|
|
|
|
season = show.get_season(SeasonNumber(1))
|
|
|
|
|
assert season is not None
|
|
|
|
|
assert [ep.episode_number.value for ep in season.episodes] == [1, 2, 3]
|
|
|
|
|
b = TVShowBuilder(tmdb_id=TmdbId(1396), title="Breaking Bad")
|
|
|
|
|
b.add_episode(self._ep(1, 3))
|
|
|
|
|
b.add_episode(self._ep(1, 1))
|
|
|
|
|
b.add_episode(self._ep(1, 2))
|
|
|
|
|
s = b.build()
|
|
|
|
|
assert [e.episode_number.value for e in s.seasons[0].episodes] == [1, 2, 3]
|
|
|
|
|
|
|
|
|
|
def test_add_season_replaces_existing(self):
|
|
|
|
|
first = Season(season_number=1, episodes=(self._ep(1, 1),))
|
|
|
|
|
second = Season(
|
|
|
|
|
season_number=1, episodes=(self._ep(1, 5), self._ep(1, 6))
|
|
|
|
|
)
|
|
|
|
|
show = (
|
|
|
|
|
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
|
|
|
|
.add_season(first)
|
|
|
|
|
.add_season(second)
|
|
|
|
|
.build()
|
|
|
|
|
)
|
|
|
|
|
season = show.get_season(SeasonNumber(1))
|
|
|
|
|
assert season is not None
|
|
|
|
|
assert [ep.episode_number.value for ep in season.episodes] == [5, 6]
|
|
|
|
|
b = TVShowBuilder(tmdb_id=TmdbId(1396), title="Breaking Bad")
|
|
|
|
|
b.add_episode(self._ep(1, 1))
|
|
|
|
|
# Wholesale replace S1 with a 5-episode-count season carrying no episodes
|
|
|
|
|
b.add_season(Season(season_number=SeasonNumber(1), episode_count=5))
|
|
|
|
|
s = b.build()
|
|
|
|
|
assert s.seasons[0].episode_count == 5
|
|
|
|
|
assert s.seasons[0].episodes == ()
|
|
|
|
|
|
|
|
|
|
def test_season_builder_returns_same_instance(self):
|
|
|
|
|
builder = TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
|
|
|
|
sb1 = builder.season_builder(1)
|
|
|
|
|
sb2 = builder.season_builder(1)
|
|
|
|
|
b = TVShowBuilder(tmdb_id=TmdbId(1396), title="X")
|
|
|
|
|
sb1 = b.season_builder(1)
|
|
|
|
|
sb2 = b.season_builder(SeasonNumber(1))
|
|
|
|
|
assert sb1 is sb2
|
|
|
|
|
|
|
|
|
|
def test_season_builder_via_int(self):
|
|
|
|
|
builder = TVShowBuilder(imdb_id="tt0903747", title="x")
|
|
|
|
|
sb = builder.season_builder(5)
|
|
|
|
|
assert sb.season_number == SeasonNumber(5)
|
|
|
|
|
b = TVShowBuilder(tmdb_id=TmdbId(1396), title="X")
|
|
|
|
|
sb = b.season_builder(2)
|
|
|
|
|
assert sb.season_number == SeasonNumber(2)
|
|
|
|
|
|
|
|
|
|
def test_set_title_and_tmdb_id(self):
|
|
|
|
|
show = (
|
|
|
|
|
TVShowBuilder(imdb_id="tt0903747", title="Initial")
|
|
|
|
|
.set_title("Updated")
|
|
|
|
|
.set_tmdb_id(1396)
|
|
|
|
|
.build()
|
|
|
|
|
)
|
|
|
|
|
assert show.title == "Updated"
|
|
|
|
|
assert show.tmdb_id == 1396
|
|
|
|
|
def test_set_title_imdb_and_status(self):
|
|
|
|
|
b = TVShowBuilder(tmdb_id=TmdbId(1396), title="A")
|
|
|
|
|
b.set_title("B").set_imdb_id(ImdbId("tt0903747")).set_status("Ended")
|
|
|
|
|
s = b.build()
|
|
|
|
|
assert s.title == "B"
|
|
|
|
|
assert s.imdb_id == ImdbId("tt0903747")
|
|
|
|
|
assert s.status == "Ended"
|
|
|
|
|
|
|
|
|
|
def test_from_existing_round_trip(self):
|
|
|
|
|
original = (
|
|
|
|
|
TVShowBuilder(
|
|
|
|
|
imdb_id="tt0903747",
|
|
|
|
|
title="Breaking Bad",
|
|
|
|
|
tmdb_id=1396,
|
|
|
|
|
)
|
|
|
|
|
.add_episode(self._ep(1, 1))
|
|
|
|
|
.add_episode(self._ep(2, 1))
|
|
|
|
|
.build()
|
|
|
|
|
original = TVShowBuilder(
|
|
|
|
|
tmdb_id=TmdbId(1396),
|
|
|
|
|
title="Breaking Bad",
|
|
|
|
|
imdb_id=ImdbId("tt0903747"),
|
|
|
|
|
status="Ended",
|
|
|
|
|
)
|
|
|
|
|
rebuilt = TVShowBuilder.from_existing(original).build()
|
|
|
|
|
assert rebuilt == original
|
|
|
|
|
original.add_episode(self._ep(1, 1))
|
|
|
|
|
original.add_episode(self._ep(2, 1))
|
|
|
|
|
show = original.build()
|
|
|
|
|
rebuilt = TVShowBuilder.from_existing(show).build()
|
|
|
|
|
assert rebuilt == show
|
|
|
|
|
|
|
|
|
|
def test_from_existing_then_add_extends(self):
|
|
|
|
|
original = (
|
|
|
|
|
TVShowBuilder(imdb_id="tt0903747", title="Breaking Bad")
|
|
|
|
|
.add_episode(self._ep(1, 1))
|
|
|
|
|
.build()
|
|
|
|
|
)
|
|
|
|
|
extended = (
|
|
|
|
|
TVShowBuilder.from_existing(original).add_episode(self._ep(1, 2)).build()
|
|
|
|
|
)
|
|
|
|
|
assert extended.episode_count == 2
|
|
|
|
|
assert original.episode_count == 1 # original untouched
|
|
|
|
|
original = TVShowBuilder(tmdb_id=TmdbId(1396), title="X")
|
|
|
|
|
original.add_episode(self._ep(1, 1))
|
|
|
|
|
show = original.build()
|
|
|
|
|
b = TVShowBuilder.from_existing(show)
|
|
|
|
|
b.add_episode(self._ep(1, 2))
|
|
|
|
|
b.add_episode(self._ep(2, 1))
|
|
|
|
|
new_show = b.build()
|
|
|
|
|
assert [s.season_number.value for s in new_show.seasons] == [1, 2]
|
|
|
|
|
assert [e.episode_number.value for e in new_show.seasons[0].episodes] == [
|
|
|
|
|
1,
|
|
|
|
|
2,
|
|
|
|
|
]
|
|
|
|
|