"""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.exceptions import ValidationError 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 ( 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, ]