Files
alfred/tests/domain/test_tv_shows.py
T
2026-05-26 21:45:11 +02:00

495 lines
17 KiB
Python

"""Tests for the TV Show domain — entities, value objects, builders.
Post-2026-05-25 Phase 3 refactor:
* 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`).
Coverage:
* ``TestSeasonNumber`` / ``TestEpisodeNumber`` — value-object validation.
* ``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.
"""
from __future__ import annotations
import dataclasses
import pytest
from alfred.domain.shared_TO_CHECK.exceptions import ValidationError
from alfred.domain.shared_TO_CHECK.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 (
EpisodeNumber,
SeasonMode,
SeasonNumber,
)
# ---------------------------------------------------------------------------
# SeasonNumber
# ---------------------------------------------------------------------------
class TestSeasonNumber:
def test_valid_season(self):
assert SeasonNumber(1).value == 1
def test_season_zero_is_specials(self):
assert SeasonNumber(0).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):
assert EpisodeNumber(1).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
# ---------------------------------------------------------------------------
# SeasonMode
# ---------------------------------------------------------------------------
class TestSeasonMode:
def test_values(self):
assert SeasonMode.PACK.value == "pack"
assert SeasonMode.EPISODIC.value == "episodic"
# ---------------------------------------------------------------------------
# Episode entity
# ---------------------------------------------------------------------------
class TestEpisode:
def _ep(self, *, season=1, episode=1, title="Pilot") -> Episode:
return Episode(
season_number=season,
episode_number=episode,
title=title,
)
def test_basic_creation_coerces_numbers(self):
e = self._ep()
assert e.title == "Pilot"
assert isinstance(e.season_number, SeasonNumber)
assert isinstance(e.episode_number, EpisodeNumber)
def test_is_frozen(self):
e = self._ep()
with pytest.raises(dataclasses.FrozenInstanceError):
e.title = "Other" # type: ignore[misc]
def test_get_filename_format(self):
e = self._ep(season=1, episode=5, title="Gray Matter")
filename = e.get_filename()
assert filename.startswith("S01E05")
assert "Gray.Matter" in filename
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
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)
# ---------------------------------------------------------------------------
# Season entity
# ---------------------------------------------------------------------------
class TestSeason:
def _ep(self, episode: int) -> 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, episode_count=10)
assert isinstance(s.season_number, SeasonNumber)
assert s.episode_count == 10
def test_is_frozen(self):
s = Season(season_number=SeasonNumber(1))
with pytest.raises(dataclasses.FrozenInstanceError):
s.episode_count = 5 # type: ignore[misc]
def test_get_folder_name_normal(self):
s = Season(season_number=SeasonNumber(3))
assert s.get_folder_name() == "Season 03"
def test_get_folder_name_specials(self):
s = Season(season_number=SeasonNumber(0))
assert s.get_folder_name() == "Specials"
def test_get_episode_returns_match(self):
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=SeasonNumber(1), episodes=(self._ep(1),))
assert s.get_episode(EpisodeNumber(99)) is None
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=SeasonNumber(1),
episode_count=10,
episodes=(self._ep(1), self._ep(2)),
)
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)
# ---------------------------------------------------------------------------
# TVShow aggregate root
# ---------------------------------------------------------------------------
class TestTVShow:
def _show(self, **kwargs) -> TVShow:
defaults = dict(tmdb_id=TmdbId(1396), title="Breaking Bad")
defaults.update(kwargs)
return TVShow(**defaults) # type: ignore[arg-type]
def test_basic_creation(self):
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_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(tmdb_id=TmdbId(1), title="X", imdb_id="tt0903747") # type: ignore[arg-type]
def test_is_frozen(self):
s = self._show()
with pytest.raises(dataclasses.FrozenInstanceError):
s.title = "Other" # type: ignore[misc]
def test_get_folder_name_replaces_spaces(self):
assert self._show().get_folder_name() == "Breaking.Bad"
def test_get_folder_name_strips_special_chars(self):
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_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):
s = self._show(
seasons=(
Season(season_number=SeasonNumber(1)),
Season(season_number=SeasonNumber(2)),
)
)
season2 = s.get_season(SeasonNumber(2))
assert season2 is not None
assert season2.season_number == SeasonNumber(2)
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
# ---------------------------------------------------------------------------
# SeasonBuilder
# ---------------------------------------------------------------------------
class TestSeasonBuilder:
def _ep(self, episode: int) -> Episode:
return Episode(season_number=1, episode_number=episode, title=f"E{episode}")
def test_build_empty(self):
s = SeasonBuilder(1).build()
assert s.season_number == SeasonNumber(1)
assert s.episodes == ()
assert s.episode_count == 0
def test_build_emits_sorted_episodes(self):
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):
sb = SeasonBuilder(1)
sb.add_episode(self._ep(1))
sb.add_episode(
Episode(season_number=1, episode_number=1, title="Replacement")
)
s = sb.build()
assert len(s.episodes) == 1
assert s.episodes[0].title == "Replacement"
def test_add_episode_rejects_mismatched_season(self):
sb = SeasonBuilder(1)
with pytest.raises(ValueError):
sb.add_episode(
Episode(season_number=2, episode_number=1, title="X")
)
def test_int_season_number_coerced(self):
sb = SeasonBuilder(5)
assert sb.season_number == SeasonNumber(5)
def test_from_existing_round_trip(self):
original = Season(
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=SeasonNumber(1),
episode_count=2,
episodes=(self._ep(1),),
)
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
# ---------------------------------------------------------------------------
# TVShowBuilder
# ---------------------------------------------------------------------------
class TestTVShowBuilder:
def _ep(self, season: int, episode: int) -> Episode:
return Episode(
season_number=season, episode_number=episode, title=f"S{season}E{episode}"
)
def test_build_minimal(self):
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_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):
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):
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):
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):
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):
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):
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):
b = TVShowBuilder(tmdb_id=TmdbId(1396), title="X")
sb = b.season_builder(2)
assert sb.season_number == SeasonNumber(2)
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(
tmdb_id=TmdbId(1396),
title="Breaking Bad",
imdb_id=ImdbId("tt0903747"),
status="Ended",
)
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(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,
]