"""Tests for the TV Show domain — entities, value objects, aggregate behavior. Rewritten for the post-refactor aggregate: * ``TVShow`` is the root, owning ``seasons: dict[SeasonNumber, Season]``. * ``Season`` owns ``episodes: dict[EpisodeNumber, Episode]`` and tracks ``expected_episodes`` + ``aired_episodes``. * ``Episode`` carries ``audio_tracks`` + ``subtitle_tracks`` and exposes language helpers following contract C+ (``str`` direct compare, ``Language`` cross-format). * No back-references on Season/Episode — they are reached through the root. * Sole sanctioned mutation entry point: ``TVShow.add_episode(ep)``. Coverage: * ``TestShowStatus`` — including the extended TMDB string mapping. * ``TestSeasonNumber`` / ``TestEpisodeNumber`` — value-object validation. * ``TestEpisode`` — basic shape, file presence, audio/subtitle helpers. * ``TestSeason`` — episode insertion, completeness vs aired, missing list. * ``TestTVShow`` — aggregate invariants, ``add_episode``, ``collection_status``, ``missing_episodes``, ``is_complete_series``. """ from __future__ import annotations 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.entities import Episode, Season, TVShow from alfred.domain.tv_shows.value_objects import ( CollectionStatus, 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 @pytest.mark.parametrize( "raw,expected", [ ("Returning Series", ShowStatus.ONGOING), ("In Production", ShowStatus.ONGOING), ("Pilot", ShowStatus.ONGOING), ("Planned", ShowStatus.ONGOING), ("Canceled", ShowStatus.ENDED), ("Cancelled", ShowStatus.ENDED), ], ) def test_from_string_tmdb_mappings(self, raw, expected): assert ShowStatus.from_string(raw) == expected def test_from_string_empty_or_unknown(self): assert ShowStatus.from_string("") == ShowStatus.UNKNOWN assert ShowStatus.from_string("borked") == ShowStatus.UNKNOWN # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # 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_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_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() def test_negative_aired_raises(self): with pytest.raises(ValueError): Season(season_number=1, aired_episodes=-1) def test_aired_cannot_exceed_expected(self): with pytest.raises(ValueError): Season(season_number=1, expected_episodes=5, aired_episodes=6) def test_add_episode_rejects_mismatched_season(self): s = Season(season_number=1) ep = Episode(season_number=2, episode_number=1, title="x") with pytest.raises(ValueError): s.add_episode(ep) def test_add_episode_replaces_same_number(self): s = Season(season_number=1) s.add_episode(self._ep(1)) s.add_episode(Episode(season_number=1, episode_number=1, title="Replaced")) assert s.episodes[EpisodeNumber(1)].title == "Replaced" def test_str_uses_name_when_present(self): s = Season(season_number=1, name="Pilot Season") assert "Pilot Season" in str(s) # ── Completeness vs aired ────────────────────────────────────────── def test_is_complete_unknown_aired_is_false(self): # Conservative: no aired count → cannot claim complete s = Season(season_number=1) s.add_episode(self._ep(1)) assert s.is_complete() is False def test_is_complete_when_owning_all_aired(self): s = Season(season_number=1, aired_episodes=3) for i in (1, 2, 3): s.add_episode(self._ep(i)) assert s.is_complete() is True def test_is_complete_zero_aired_is_trivially_true(self): s = Season(season_number=1, aired_episodes=0) assert s.is_complete() is True def test_partial_when_missing_aired_episodes(self): s = Season(season_number=1, aired_episodes=3) s.add_episode(self._ep(1)) assert s.is_complete() is False def test_is_fully_aired(self): s = Season(season_number=1, expected_episodes=10, aired_episodes=10) assert s.is_fully_aired() is True def test_is_fully_aired_false_when_in_flight(self): s = Season(season_number=1, expected_episodes=10, aired_episodes=4) assert s.is_fully_aired() is False def test_is_fully_aired_false_with_unknowns(self): assert Season(season_number=1).is_fully_aired() is False def test_missing_episodes_when_partial(self): s = Season(season_number=1, aired_episodes=5) s.add_episode(self._ep(1)) s.add_episode(self._ep(3)) missing = [n.value for n in s.missing_episodes()] assert missing == [2, 4, 5] def test_missing_episodes_empty_when_complete(self): s = Season(season_number=1, aired_episodes=2) s.add_episode(self._ep(1)) s.add_episode(self._ep(2)) assert s.missing_episodes() == [] def test_missing_episodes_empty_when_unknown_aired(self): # Without an aired count we cannot reason about gaps s = Season(season_number=1) s.add_episode(self._ep(2)) assert s.missing_episodes() == [] # --------------------------------------------------------------------------- # TVShow aggregate root # --------------------------------------------------------------------------- class TestTVShow: def _show(self, **kwargs) -> TVShow: defaults = dict( imdb_id="tt0903747", title="Breaking Bad", status="ended", ) defaults.update(kwargs) return TVShow(**defaults) # ── Construction & coercion ──────────────────────────────────────── def test_basic_creation(self): show = self._show(expected_seasons=5) assert show.title == "Breaking Bad" assert show.expected_seasons == 5 assert show.seasons == {} assert show.seasons_count == 0 def test_coerces_string_imdb_id(self): assert isinstance(self._show().imdb_id, ImdbId) def test_coerces_string_status(self): assert self._show(status="ongoing").status == ShowStatus.ONGOING def test_is_ongoing_and_is_ended(self): assert self._show(status="ongoing").is_ongoing() assert self._show(status="ended").is_ended() def test_negative_expected_seasons_raises(self): with pytest.raises(ValueError): self._show(expected_seasons=-1) def test_invalid_imdb_id_type_raises(self): with pytest.raises(ValueError): TVShow(imdb_id=12345, title="X", status="ended") # type: ignore 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) # ── add_episode — the only sanctioned mutation ───────────────────── def test_add_episode_creates_missing_season(self): show = self._show() show.add_episode(Episode(season_number=1, episode_number=1, title="Pilot")) assert SeasonNumber(1) in show.seasons assert show.seasons_count == 1 assert show.episode_count == 1 def test_add_episode_reuses_existing_season(self): show = self._show() show.add_episode(Episode(season_number=1, episode_number=1, title="A")) show.add_episode(Episode(season_number=1, episode_number=2, title="B")) assert show.seasons_count == 1 assert show.episode_count == 2 def test_add_season_replaces_existing(self): show = self._show() s1 = Season(season_number=1, aired_episodes=10) show.add_season(s1) s1bis = Season(season_number=1, aired_episodes=5) show.add_season(s1bis) assert show.seasons[SeasonNumber(1)] is s1bis # ── Collection status ────────────────────────────────────────────── def test_collection_status_empty(self): assert self._show().collection_status() == CollectionStatus.EMPTY def test_collection_status_partial_missing_episode(self): show = self._show() s = Season(season_number=1, aired_episodes=3) s.add_episode(Episode(season_number=1, episode_number=1, title="x")) show.add_season(s) assert show.collection_status() == CollectionStatus.PARTIAL def test_collection_status_complete(self): show = self._show(expected_seasons=1) s = Season(season_number=1, aired_episodes=2) for n in (1, 2): s.add_episode(Episode(season_number=1, episode_number=n, title=f"e{n}")) show.add_season(s) assert show.collection_status() == CollectionStatus.COMPLETE def test_collection_status_partial_when_seasons_missing(self): # Seasons we own are complete, but expected_seasons says more exist. show = self._show(expected_seasons=2) s = Season(season_number=1, aired_episodes=1) s.add_episode(Episode(season_number=1, episode_number=1, title="x")) show.add_season(s) assert show.collection_status() == CollectionStatus.PARTIAL def test_is_complete_series_requires_ended_and_complete(self): show = self._show(status="ongoing", expected_seasons=1) s = Season(season_number=1, aired_episodes=1) s.add_episode(Episode(season_number=1, episode_number=1, title="x")) show.add_season(s) # Ongoing → never "complete series" even if collection is COMPLETE assert show.is_complete_series() is False show.status = ShowStatus.ENDED assert show.is_complete_series() is True # ── missing_episodes traversal ───────────────────────────────────── def test_missing_episodes_walks_seasons_in_order(self): show = self._show() s2 = Season(season_number=2, aired_episodes=2) s1 = Season(season_number=1, aired_episodes=3) s1.add_episode(Episode(season_number=1, episode_number=2, title="x")) show.add_season(s2) show.add_season(s1) missing = [(s.value, e.value) for s, e in show.missing_episodes()] assert missing == [(1, 1), (1, 3), (2, 1), (2, 2)]