"""Tests for the TV Show domain — entities, value objects, builders. Post-2026-05-22 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. * 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. 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. * ``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.media import AudioTrack, SubtitleTrack from alfred.domain.shared.value_objects import ImdbId, Language 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", **kwargs) -> Episode: return Episode( season_number=season, episode_number=episode, title=title, **kwargs, ) 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_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"] # --------------------------------------------------------------------------- # Season entity # --------------------------------------------------------------------------- class TestSeason: def _ep(self, episode: int) -> Episode: return Episode(season_number=1, episode_number=episode, title=f"Ep {episode}") def test_basic_creation_coerces_season_number(self): s = Season(season_number=1) assert isinstance(s.season_number, SeasonNumber) assert s.episode_count == 0 assert s.episodes == () def test_is_frozen(self): s = Season(season_number=1) with pytest.raises(dataclasses.FrozenInstanceError): s.episodes = (self._ep(1),) # type: ignore[misc] def test_get_folder_name_normal(self): assert Season(season_number=2).get_folder_name() == "Season 02" def test_get_folder_name_specials(self): s = Season(season_number=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 def test_get_episode_returns_none_when_absent(self): s = Season(season_number=1, episodes=(self._ep(1),)) assert s.get_episode(EpisodeNumber(99)) is None def test_episode_count_reflects_tuple_size(self): s = Season( season_number=1, episodes=(self._ep(1), self._ep(2), self._ep(3)), ) assert s.episode_count == 3 # --------------------------------------------------------------------------- # TVShow aggregate root # --------------------------------------------------------------------------- class TestTVShow: def _show(self, **kwargs) -> TVShow: defaults = dict(imdb_id="tt0903747", title="Breaking Bad") defaults.update(kwargs) return TVShow(**defaults) # ── Construction & coercion ──────────────────────────────────────── 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 def test_coerces_string_imdb_id(self): assert isinstance(self._show().imdb_id, ImdbId) def test_invalid_imdb_id_type_raises(self): with pytest.raises(ValueError): TVShow(imdb_id=12345, title="X") # type: ignore def test_is_frozen(self): show = self._show() with pytest.raises(dataclasses.FrozenInstanceError): show.title = "Other" # type: ignore[misc] def test_get_folder_name_replaces_spaces(self): assert self._show(title="Breaking Bad").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 def test_str_repr(self): show = self._show() assert "Breaking Bad" in str(show) assert "tt0903747" in repr(show) # ── Season access ────────────────────────────────────────────────── 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( seasons=( Season(season_number=1, episodes=(ep11, ep12)), Season(season_number=2, episodes=(ep21,)), ) ) assert show.episode_count == 3 assert show.seasons_count == 2 def test_tmdb_id_defaults_to_none(self): assert self._show().tmdb_id is None # --------------------------------------------------------------------------- # SeasonBuilder # --------------------------------------------------------------------------- class TestSeasonBuilder: def _ep(self, episode: int) -> Episode: return Episode(season_number=1, episode_number=episode, title=f"Ep {episode}") def test_build_empty(self): s = SeasonBuilder(SeasonNumber(1)).build() assert isinstance(s, Season) assert s.episodes == () assert s.mode == SeasonMode.PACK 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] 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() ) assert s.episodes == (second,) assert s.episodes[0].title == "Replacement" def test_add_episode_rejects_mismatched_season(self): builder = SeasonBuilder(SeasonNumber(1)) with pytest.raises(ValueError): builder.add_episode( Episode(season_number=2, episode_number=1, title="bad") ) def test_int_season_number_coerced(self): s = SeasonBuilder(1).build() assert s.season_number == SeasonNumber(1) def test_from_existing_round_trip(self): original = Season( season_number=1, 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() ) assert rebuilt.get_episode(EpisodeNumber(2)) is replacement # --------------------------------------------------------------------------- # TVShowBuilder # --------------------------------------------------------------------------- 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}", ) 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 def test_coerces_string_imdb_id(self): show = TVShowBuilder(imdb_id="tt0903747", title="x").build() assert isinstance(show.imdb_id, ImdbId) 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 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 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] 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] 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] 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) 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) 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_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() ) rebuilt = TVShowBuilder.from_existing(original).build() assert rebuilt == original 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