diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb610f..c756dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,64 @@ callers). ### Added +- **`TVShowBuilder` / `SeasonBuilder` — sole construction surface for the + TVShow aggregate** (`alfred/domain/tv_shows/builders.py`). The aggregate + is now fully frozen; building goes through a mutable scratchpad that + emits an immutable `TVShow` via `build()`. Both builders offer a + `from_existing()` classmethod to seed from a current frozen aggregate + and apply modifications. Episodes are emitted sorted by number within a + season, seasons sorted by number within the show. +- **`SeasonMode` enum** (`PACK` / `EPISODIC`) in + `alfred/domain/tv_shows/value_objects.py`. Computed at read time from + the season's structural shape (`Season.mode` property): a season with + no explicit episodes is `PACK` (a single release covering the whole + season), a season with episodes is `EPISODIC` (currently airing, one + release per episode). Never stored — the YAML sidecar encodes the + mode via the presence/absence of the `episodes:` block. + +### Changed + +- **TVShow aggregate is now frozen all the way down.** `TVShow`, + `Season` and `Episode` are all `@dataclass(frozen=True)`. Children + are stored as ordered tuples (`tuple[Season, ...]`, + `tuple[Episode, ...]`) sorted by their respective numbers, replacing + the previous mutable dicts. Lookup helpers `TVShow.get_season(n)` and + `Season.get_episode(n)` traverse the tuple lazily via `next()`. The + former `add_episode` / `add_season` mutation methods are gone — all + construction goes through `TVShowBuilder` / `SeasonBuilder`. + +### Removed + +- **ShowTracker-territory fields stripped from the TVShow aggregate.** + The aggregate now models only what the `.alfred` sidecar stores + (filesystem-observable facts + immutable identity). Dropped from the + domain: + - `TVShow.status` (`ShowStatus`) and the `ShowStatus` enum entirely, + along with its TMDB string mapping (`from_string`). + - `TVShow.expected_seasons`, `Season.expected_episodes`, + `Season.aired_episodes`, `Season.name`. + - `TVShow.collection_status()`, `is_complete_series()`, + `missing_episodes()`, `is_ongoing()`, `is_ended()` and the + `CollectionStatus` enum. + - `Season.is_complete()`, `is_fully_aired()`, `missing_episodes()` + and the `aired ≤ expected` validation. + - `TVShow.add_episode()` / `TVShow.add_season()` / + `Season.add_episode()` — replaced by the builder API. + These concerns will reappear in a dedicated `ShowTracker` layer (to + be designed) that combines the `.alfred` sidecar with live TMDB data + to answer questions like "is this show complete?" or "are new + episodes out?". Keeping volatile/derived state out of the aggregate + matches the factuel-only philosophy locked in `specs/dot_alfred.md`. + +### Internal + +- **Test suite rewritten for the new aggregate shape.** + `tests/domain/test_tv_shows.py` now covers frozen invariants, builder + ordering, last-write-wins on duplicates, `from_existing` round-trip, + and `SeasonMode` derivation. `tests/infrastructure/test_filesystem_extras.py` + helper simplified (no more `ShowStatus.ENDED` / `expected_seasons` on + test shows). 1078 tests still green. + - **Design doc for `.alfred/` sidecar persistence (`specs/dot_alfred.md`).** First entry in the new `specs/` directory. Specifies a per-show `.alfred/` directory holding a `show.yaml` and diff --git a/alfred/domain/tv_shows/__init__.py b/alfred/domain/tv_shows/__init__.py index bd6f144..504b72e 100644 --- a/alfred/domain/tv_shows/__init__.py +++ b/alfred/domain/tv_shows/__init__.py @@ -1,20 +1,21 @@ """TV Shows domain - Business logic for TV show management.""" +from .builders import SeasonBuilder, TVShowBuilder from .entities import Episode, Season, TVShow from .exceptions import InvalidEpisode, SeasonNotFound, TVShowNotFound from .value_objects import ( - CollectionStatus, EpisodeNumber, + SeasonMode, SeasonNumber, - ShowStatus, ) __all__ = [ "TVShow", "Season", "Episode", - "ShowStatus", - "CollectionStatus", + "TVShowBuilder", + "SeasonBuilder", + "SeasonMode", "SeasonNumber", "EpisodeNumber", "TVShowNotFound", diff --git a/alfred/domain/tv_shows/builders.py b/alfred/domain/tv_shows/builders.py new file mode 100644 index 0000000..c2f49c7 --- /dev/null +++ b/alfred/domain/tv_shows/builders.py @@ -0,0 +1,221 @@ +"""Builders for the TVShow aggregate. + +The aggregate is fully frozen — :class:`TVShow` and :class:`Season` are +``@dataclass(frozen=True)`` and offer no mutation methods. All construction +goes through these builders, which assemble the aggregate piece by piece +and emit a frozen instance via ``build()``. + +Typical usage during a filesystem walk:: + + builder = TVShowBuilder( + imdb_id=ImdbId("tt0903747"), + title="Breaking Bad", + tmdb_id=1396, + ) + builder.add_episode(Episode( + season_number=SeasonNumber(1), + episode_number=EpisodeNumber(1), + title="Pilot", + )) + builder.add_episode(Episode( + season_number=SeasonNumber(1), + episode_number=EpisodeNumber(2), + title="Cat's in the Bag...", + )) + show = builder.build() # frozen TVShow ready to circulate + +To modify an existing frozen aggregate, use :meth:`TVShowBuilder.from_existing` +to seed a builder from a current ``TVShow`` and apply changes:: + + builder = TVShowBuilder.from_existing(show) + builder.add_episode(new_ep) + updated = builder.build() + +Builders are **single-use scratchpads**: they hold mutable state during +construction, then produce an immutable aggregate. Reusing a builder after +``build()`` is allowed but its state continues to evolve independently of +the emitted TVShow. + +Invariants enforced at ``build()`` time: + +* Seasons are emitted sorted by ``season_number``. +* Episodes within each season are emitted sorted by ``episode_number``. +* No duplicate season numbers (last-write-wins on the season itself). +* No duplicate episode numbers within a season (last-write-wins on the + episode). +""" + +from __future__ import annotations + +from ..shared.value_objects import ImdbId +from .entities import Episode, Season, TVShow +from .value_objects import EpisodeNumber, SeasonNumber + + +# ════════════════════════════════════════════════════════════════════════════ +# SeasonBuilder +# ════════════════════════════════════════════════════════════════════════════ + + +class SeasonBuilder: + """ + Mutable scratchpad for a :class:`Season`. + + Episodes are stored in an internal dict keyed by :class:`EpisodeNumber`, + allowing last-write-wins replacement on a duplicate insert. ``build()`` + emits a frozen ``Season`` with episodes sorted by number. + """ + + def __init__(self, season_number: SeasonNumber | int) -> None: + if isinstance(season_number, int): + season_number = SeasonNumber(season_number) + self._season_number: SeasonNumber = season_number + self._episodes: dict[EpisodeNumber, Episode] = {} + + @classmethod + def from_existing(cls, season: Season) -> SeasonBuilder: + """Seed a builder from an existing frozen :class:`Season`.""" + builder = cls(season.season_number) + for ep in season.episodes: + builder._episodes[ep.episode_number] = ep + return builder + + @property + def season_number(self) -> SeasonNumber: + return self._season_number + + def add_episode(self, episode: Episode) -> SeasonBuilder: + """ + Add or replace an episode in this season. + + Raises ``ValueError`` if the episode's season number does not match + this builder's season number — episodes carry their season number + for invariant checks during aggregation. + """ + if episode.season_number != self._season_number: + raise ValueError( + f"Episode season ({episode.season_number}) does not match " + f"season ({self._season_number})" + ) + self._episodes[episode.episode_number] = episode + return self + + def build(self) -> Season: + """Emit a frozen :class:`Season` with episodes sorted by number.""" + ordered = tuple( + self._episodes[n] for n in sorted(self._episodes, key=lambda x: x.value) + ) + return Season(season_number=self._season_number, episodes=ordered) + + +# ════════════════════════════════════════════════════════════════════════════ +# TVShowBuilder +# ════════════════════════════════════════════════════════════════════════════ + + +class TVShowBuilder: + """ + Mutable scratchpad for the :class:`TVShow` aggregate root. + + Seasons are tracked via internal :class:`SeasonBuilder` instances keyed + by :class:`SeasonNumber`. Adding an episode auto-creates the season + builder if absent — matching the previous ``TVShow.add_episode`` + convenience without compromising immutability of the emitted aggregate. + """ + + def __init__( + self, + *, + imdb_id: ImdbId | str, + title: str, + tmdb_id: int | None = None, + ) -> None: + if isinstance(imdb_id, str): + imdb_id = ImdbId(imdb_id) + self._imdb_id: ImdbId = imdb_id + self._title: str = title + self._tmdb_id: int | None = tmdb_id + self._season_builders: dict[SeasonNumber, SeasonBuilder] = {} + + @classmethod + def from_existing(cls, show: TVShow) -> TVShowBuilder: + """Seed a builder from an existing frozen :class:`TVShow`.""" + builder = cls( + imdb_id=show.imdb_id, + title=show.title, + tmdb_id=show.tmdb_id, + ) + for season in show.seasons: + builder._season_builders[season.season_number] = SeasonBuilder.from_existing( + season + ) + return builder + + # ── Top-level mutators ───────────────────────────────────────────────── + + def set_title(self, title: str) -> TVShowBuilder: + self._title = title + return self + + def set_tmdb_id(self, tmdb_id: int | None) -> TVShowBuilder: + self._tmdb_id = tmdb_id + return self + + # ── Content ──────────────────────────────────────────────────────────── + + def add_episode(self, episode: Episode) -> TVShowBuilder: + """ + Add or replace an episode in the appropriate season. + + Auto-creates a :class:`SeasonBuilder` for the season if needed. + Last-write-wins on duplicate ``(season, episode)`` numbers. + """ + sb = self._season_builders.get(episode.season_number) + if sb is None: + sb = SeasonBuilder(episode.season_number) + self._season_builders[episode.season_number] = sb + sb.add_episode(episode) + return self + + def add_season(self, season: Season) -> TVShowBuilder: + """ + Attach (or replace) a fully-built :class:`Season`. + + The season is re-wrapped in a :class:`SeasonBuilder` internally so + subsequent ``add_episode`` calls remain coherent. Replaces any + existing season with the same number. + """ + self._season_builders[season.season_number] = SeasonBuilder.from_existing( + season + ) + return self + + def season_builder( + self, season_number: SeasonNumber | int + ) -> SeasonBuilder: + """ + Return (creating if needed) the :class:`SeasonBuilder` for a given + season number. Useful when assembling a season with many tweaks. + """ + if isinstance(season_number, int): + season_number = SeasonNumber(season_number) + sb = self._season_builders.get(season_number) + if sb is None: + sb = SeasonBuilder(season_number) + self._season_builders[season_number] = sb + return sb + + # ── Emit ─────────────────────────────────────────────────────────────── + + def build(self) -> TVShow: + """Emit a frozen :class:`TVShow` with seasons sorted by number.""" + ordered_seasons = tuple( + self._season_builders[n].build() + for n in sorted(self._season_builders, key=lambda x: x.value) + ) + return TVShow( + imdb_id=self._imdb_id, + title=self._title, + seasons=ordered_seasons, + tmdb_id=self._tmdb_id, + ) diff --git a/alfred/domain/tv_shows/entities.py b/alfred/domain/tv_shows/entities.py index d232947..f33099c 100644 --- a/alfred/domain/tv_shows/entities.py +++ b/alfred/domain/tv_shows/entities.py @@ -5,9 +5,9 @@ This module implements the TVShow aggregate following DDD principles. Aggregate ownership:: TVShow ← aggregate root (the repo returns this) - └── seasons: dict[SeasonNumber, Season] + └── seasons: tuple[Season, ...] └── Season - └── episodes: dict[EpisodeNumber, Episode] + └── episodes: tuple[Episode, ...] └── Episode ← file metadata + audio/subtitle tracks Rules: @@ -17,10 +17,18 @@ Rules: * ``Season`` is owned by TVShow. ``Episode`` is owned by Season. * Children do not back-reference the root (no ``show_imdb_id`` on Season/Episode): they are only ever reached *through* TVShow. -* Mutation invariants are enforced through aggregate-root methods such as - ``TVShow.add_episode()`` — never reach into ``show.seasons[...].episodes`` - to mutate without going through the root, otherwise invariants are not - guaranteed. +* The aggregate is **frozen all the way down**. Mutation happens exclusively + through :class:`TVShowBuilder` (see ``builders.py``), which produces a new + ``TVShow`` via ``build()``. There is no ``add_episode`` / ``add_season`` + on entities anymore. + +Scope (post-2026-05-22 refactor): + +* The entities model only what the ``.alfred`` sidecar stores — facts + observable on disk plus identity (``imdb_id`` / ``tmdb_id``). +* Volatile / TMDB-derived information (production status, expected vs aired + counts, collection completeness) lives in a separate ``ShowTracker`` + layer to be designed; the aggregate carries none of it. """ from __future__ import annotations @@ -36,10 +44,9 @@ from ..shared.value_objects import ( to_dot_folder_name, ) from .value_objects import ( - CollectionStatus, EpisodeNumber, + SeasonMode, SeasonNumber, - ShowStatus, ) # ════════════════════════════════════════════════════════════════════════════ @@ -58,7 +65,8 @@ class Episode(MediaWithTracks): scanned, or when no file is downloaded yet. Frozen: rebuild via ``dataclasses.replace`` to project enrichment results - onto a new instance. + onto a new instance, or use :class:`TVShowBuilder` to replace inside the + aggregate. Equality is identity-based within the aggregate: two ``Episode`` instances are equal iff they share the same ``(season_number, episode_number)``, @@ -125,51 +133,30 @@ class Episode(MediaWithTracks): # ════════════════════════════════════════════════════════════════════════════ -@dataclass +@dataclass(frozen=True) class Season: """ - A season of a TV show — owned by ``TVShow``. + A season of a TV show — owned by ``TVShow``, frozen value. - Owns its episodes via the ``episodes`` dict keyed by ``EpisodeNumber``. + Owns its episodes as an ordered tuple keyed by ``EpisodeNumber``. + The tuple is sorted by episode number at build time (guaranteed by + :class:`SeasonBuilder`). - Two TMDB-sourced counts shape the collection logic: - - * ``expected_episodes`` — total episodes planned for the season - (``None`` if unknown). - * ``aired_episodes`` — episodes **already aired** as of the latest TMDB - refresh. ``None`` falls back to ``expected_episodes`` (best-effort). - - The split matters: ``is_complete()`` checks owned against aired, so a season - in the middle of broadcasting can be "complete" today and become "partial" - later when new episodes air — that is correct behavior. + The presence or absence of episodes also encodes the + :class:`SeasonMode` (see ``mode`` property): a season with no episodes + is a PACK (single release covering the whole season), a season with + episodes is EPISODIC (currently airing, one release per episode). """ season_number: SeasonNumber - episodes: dict[EpisodeNumber, Episode] = field(default_factory=dict) - expected_episodes: int | None = None - aired_episodes: int | None = None - name: str | None = None + episodes: tuple[Episode, ...] = () def __post_init__(self) -> None: if not isinstance(self.season_number, SeasonNumber): if isinstance(self.season_number, int): - self.season_number = SeasonNumber(self.season_number) - - if self.expected_episodes is not None and self.expected_episodes < 0: - raise ValueError( - f"expected_episodes must be >= 0, got {self.expected_episodes}" - ) - if self.aired_episodes is not None and self.aired_episodes < 0: - raise ValueError(f"aired_episodes must be >= 0, got {self.aired_episodes}") - if ( - self.expected_episodes is not None - and self.aired_episodes is not None - and self.aired_episodes > self.expected_episodes - ): - raise ValueError( - f"aired_episodes ({self.aired_episodes}) cannot exceed " - f"expected_episodes ({self.expected_episodes})" - ) + object.__setattr__( + self, "season_number", SeasonNumber(self.season_number) + ) # ── Properties ───────────────────────────────────────────────────────── @@ -178,65 +165,32 @@ class Season: """Number of episodes currently owned in this season.""" return len(self.episodes) - # ── Collection state ─────────────────────────────────────────────────── + @property + def mode(self) -> SeasonMode: + """ + Storage mode of this season. - def _effective_aired(self) -> int | None: - """``aired_episodes`` if set, else fall back to ``expected_episodes``.""" - return ( - self.aired_episodes - if self.aired_episodes is not None - else self.expected_episodes + Derived from the structural shape: ``PACK`` when no episode is + explicitly modeled (the season is a single release), ``EPISODIC`` + when episodes are present (one release per episode). + """ + return SeasonMode.EPISODIC if self.episodes else SeasonMode.PACK + + # ── Episode access ───────────────────────────────────────────────────── + + def get_episode(self, number: EpisodeNumber) -> Episode | None: + """ + Return the episode with the given number, or ``None`` if absent. + + O(n) traversal — fine for season-sized collections (typically ≤ 25 + episodes). The aggregate guarantees uniqueness of episode numbers + within a season. + """ + return next( + (ep for ep in self.episodes if ep.episode_number == number), + None, ) - def is_complete(self) -> bool: - """ - True if every aired episode is owned. - - Returns False (conservative) when the aired count is unknown — without - knowing how many episodes have aired we cannot claim completeness. - """ - aired = self._effective_aired() - if aired is None: - return False - if aired == 0: - # No episode has aired yet → trivially "complete" - return True - return len(self.episodes) >= aired - - def is_fully_aired(self) -> bool: - """True if all planned episodes have already aired.""" - if self.expected_episodes is None or self.aired_episodes is None: - return False - return self.aired_episodes >= self.expected_episodes - - def missing_episodes(self) -> list[EpisodeNumber]: - """ - List of episode numbers that have aired but are not owned. - - Episodes beyond ``aired_episodes`` are **not** considered missing - (they have not aired yet). When the aired count is unknown, returns - an empty list — we cannot reason about gaps without a target. - """ - aired = self._effective_aired() - if aired is None or aired <= 0: - return [] - present = {ep.value for ep in self.episodes} - return [EpisodeNumber(n) for n in range(1, aired + 1) if n not in present] - - # ── Mutation (called through the aggregate root) ─────────────────────── - - def add_episode(self, episode: Episode) -> None: - """ - Insert an episode into this season. Replaces any episode with the same - number — callers wishing to detect conflicts should check beforehand. - """ - if episode.season_number != self.season_number: - raise ValueError( - f"Episode season ({episode.season_number}) does not match season " - f"({self.season_number})" - ) - self.episodes[episode.episode_number] = episode - # ── Naming ───────────────────────────────────────────────────────────── def is_special(self) -> bool: @@ -249,8 +203,6 @@ class Season: return f"Season {self.season_number.value:02d}" def __str__(self) -> str: - if self.name: - return f"Season {self.season_number.value}: {self.name}" return f"Season {self.season_number.value}" def __repr__(self) -> str: @@ -264,62 +216,35 @@ class Season: # ════════════════════════════════════════════════════════════════════════════ -@dataclass +@dataclass(frozen=True) class TVShow: """ - Aggregate root for the TV shows domain. + Aggregate root for the TV shows domain — frozen value. - Owns its seasons via the ``seasons`` dict keyed by ``SeasonNumber``. - All mutations (adding episodes, creating seasons) MUST go through the - methods on this class — that is how invariants are preserved. + Owns its seasons as an ordered tuple keyed by ``SeasonNumber``. The + tuple is sorted by season number at build time (guaranteed by + :class:`TVShowBuilder`). - Two axes describe the show, kept deliberately orthogonal: - - * ``status`` (``ShowStatus``) — production state (TMDB-sourced). - * ``collection_status()`` — what the user owns vs what has aired today. - - A third axis (upcoming/scheduled) will be added later as a separate flag - when scheduling support is introduced; for now we make no claim about - future episodes. + Identity is carried by ``imdb_id`` and ``tmdb_id``. The ``title`` here + is the human-readable label used when generating folder names; the + canonical title (and any other volatile metadata) lives on TMDB and is + re-fetched by the ``ShowTracker`` when needed. """ imdb_id: ImdbId title: str - status: ShowStatus - seasons: dict[SeasonNumber, Season] = field(default_factory=dict) - expected_seasons: int | None = None + seasons: tuple[Season, ...] = () tmdb_id: int | None = None def __post_init__(self) -> None: if not isinstance(self.imdb_id, ImdbId): if isinstance(self.imdb_id, str): - self.imdb_id = ImdbId(self.imdb_id) + object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id)) else: raise ValueError( f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}" ) - if not isinstance(self.status, ShowStatus): - if isinstance(self.status, str): - self.status = ShowStatus.from_string(self.status) - else: - raise ValueError( - f"status must be ShowStatus or str, got {type(self.status)}" - ) - - if self.expected_seasons is not None and self.expected_seasons < 0: - raise ValueError( - f"expected_seasons must be >= 0, got {self.expected_seasons}" - ) - - # ── Production-state queries ─────────────────────────────────────────── - - def is_ongoing(self) -> bool: - return self.status == ShowStatus.ONGOING - - def is_ended(self) -> bool: - return self.status == ShowStatus.ENDED - # ── Properties ───────────────────────────────────────────────────────── @property @@ -330,83 +255,22 @@ class TVShow: @property def episode_count(self) -> int: """Total episodes owned across all seasons.""" - return sum(s.episode_count for s in self.seasons.values()) + return sum(s.episode_count for s in self.seasons) - # ── Mutation — the sole entry point for adding content ───────────────── + # ── Season access ────────────────────────────────────────────────────── - def add_episode(self, episode: Episode) -> None: + def get_season(self, number: SeasonNumber) -> Season | None: """ - Add an episode to the appropriate season, creating the season if needed. + Return the season with the given number, or ``None`` if absent. - This is the **only** sanctioned way to add content to the aggregate — - it preserves the invariant that an episode is always reachable through - ``show.seasons[s].episodes[e]``. + O(n) traversal — fine for show-sized collections (typically ≤ 30 + seasons). The aggregate guarantees uniqueness of season numbers + within a show. """ - season = self.seasons.get(episode.season_number) - if season is None: - season = Season(season_number=episode.season_number) - self.seasons[episode.season_number] = season - season.add_episode(episode) - - def add_season(self, season: Season) -> None: - """ - Attach a (possibly already populated) Season to the show. - - Replaces any existing season with the same number. - """ - self.seasons[season.season_number] = season - - # ── Collection state ─────────────────────────────────────────────────── - - def collection_status(self) -> CollectionStatus: - """ - High-level state of the user's collection for this show. - - * ``EMPTY`` — no episode owned - * ``COMPLETE`` — every season is complete relative to its aired count - * ``PARTIAL`` — at least one aired episode is missing - - Seasons with an unknown aired count are treated conservatively: if no - season has any episode, the show is EMPTY; otherwise the unknown - seasons cannot prove completeness, so the show is PARTIAL. - """ - if self.episode_count == 0: - return CollectionStatus.EMPTY - - # Check completeness across all seasons we know about - for season in self.seasons.values(): - if not season.is_complete(): - return CollectionStatus.PARTIAL - - # We also need to consider whether seasons themselves are missing. - # If expected_seasons is known and we have fewer seasons than expected, - # the missing seasons may have aired episodes → cannot claim COMPLETE. - if ( - self.expected_seasons is not None - and len(self.seasons) < self.expected_seasons - ): - return CollectionStatus.PARTIAL - - return CollectionStatus.COMPLETE - - def is_complete_series(self) -> bool: - """ - True if the show is finished (ENDED) **and** the collection is complete. - - This is the strongest "I own the entire series, no more to come" claim - we can make today, before scheduling/upcoming-episode awareness lands. - """ - return self.is_ended() and self.collection_status() == CollectionStatus.COMPLETE - - def missing_episodes(self) -> list[tuple[SeasonNumber, EpisodeNumber]]: - """All aired-but-not-owned ``(season, episode)`` pairs across the show.""" - result: list[tuple[SeasonNumber, EpisodeNumber]] = [] - for season_number, season in sorted( - self.seasons.items(), key=lambda kv: kv[0].value - ): - for ep_number in season.missing_episodes(): - result.append((season_number, ep_number)) - return result + return next( + (s for s in self.seasons if s.season_number == number), + None, + ) # ── Naming ───────────────────────────────────────────────────────────── @@ -415,7 +279,7 @@ class TVShow: return to_dot_folder_name(self.title) def __str__(self) -> str: - return f"{self.title} ({self.status.value}, {self.seasons_count} seasons)" + return f"{self.title} ({self.seasons_count} seasons)" def __repr__(self) -> str: return f"TVShow(imdb_id={self.imdb_id}, title='{self.title}')" diff --git a/alfred/domain/tv_shows/value_objects.py b/alfred/domain/tv_shows/value_objects.py index 80ad481..c6371b7 100644 --- a/alfred/domain/tv_shows/value_objects.py +++ b/alfred/domain/tv_shows/value_objects.py @@ -8,49 +8,27 @@ from enum import Enum from ..shared.exceptions import ValidationError -class ShowStatus(Enum): +class SeasonMode(Enum): """ - Production status of a TV show (real-world, source of truth = TMDB). + Storage mode of a season on disk. - Describes the **production** state of the show, independently of what - the user owns. Orthogonal to ``CollectionStatus``. + Derived from the structural shape of the ``Season``: + + * ``PACK`` — the season was downloaded as a complete pack. Technical + fields (group, source, codec, quality, audio, subtitles) live on the + ``Season`` itself, episodes carry no technical metadata. + * ``EPISODIC`` — the season is being assembled episode by episode (show + currently airing). Technical fields live on each ``Episode``; the + season-level fields are unset (the release group of individual episodes + may vary). + + Computed at read time from ``len(season.episodes) > 0`` — never stored + explicitly. A season that becomes complete is rewritten by the ingestion + pipeline into PACK form (episodes flattened away). """ - ONGOING = "ongoing" - ENDED = "ended" - UNKNOWN = "unknown" - - @classmethod - def from_string(cls, status_str: str) -> ShowStatus: - """ - Parse a production status string into a ShowStatus. - - Accepts our internal vocabulary ("ongoing", "ended") as well as the - statuses returned by TMDB ("Returning Series", "In Production", - "Pilot", "Ended", "Canceled"). The mapping is intentionally binary: - - * ONGOING — any state where new episodes may still ship - * ENDED — production has stopped (naturally or cancelled) - * UNKNOWN — anything else / unrecognized - - Comparison is case-insensitive and whitespace-trimmed. - """ - if not status_str: - return cls.UNKNOWN - key = status_str.strip().lower() - status_map = { - # Internal - "ongoing": cls.ONGOING, - "ended": cls.ENDED, - # TMDB - "returning series": cls.ONGOING, - "in production": cls.ONGOING, - "pilot": cls.ONGOING, - "planned": cls.ONGOING, - "canceled": cls.ENDED, - "cancelled": cls.ENDED, - } - return status_map.get(key, cls.UNKNOWN) + PACK = "pack" + EPISODIC = "episodic" @dataclass(frozen=True) @@ -92,23 +70,6 @@ class SeasonNumber: return self.value -class CollectionStatus(Enum): - """ - State of the user's **collection** for a TV show (orthogonal to ShowStatus). - - Compares possessed episodes against episodes **already aired** — never - against announced/upcoming ones. A returning show with all aired episodes - owned is ``COMPLETE``, not ``PARTIAL``, even if more seasons are upcoming. - - Future scheduling info (upcoming seasons, next airing date) will live on - the TVShow aggregate as separate flags, not in this enum. - """ - - EMPTY = "empty" # 0 episode owned - PARTIAL = "partial" # some aired episodes are missing - COMPLETE = "complete" # all aired-to-date episodes are owned - - @dataclass(frozen=True) class EpisodeNumber: """ diff --git a/tests/domain/test_tv_shows.py b/tests/domain/test_tv_shows.py index 0625a73..b082388 100644 --- a/tests/domain/test_tv_shows.py +++ b/tests/domain/test_tv_shows.py @@ -1,76 +1,45 @@ -"""Tests for the TV Show domain — entities, value objects, aggregate behavior. +"""Tests for the TV Show domain — entities, value objects, builders. -Rewritten for the post-refactor aggregate: +Post-2026-05-22 refactor: -* ``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)``. +* 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: -* ``TestShowStatus`` — including the extended TMDB string mapping. * ``TestSeasonNumber`` / ``TestEpisodeNumber`` — value-object validation. +* ``TestSeasonMode`` — enum sanity (computed in ``TestSeason``). * ``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``. +* ``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 ( - CollectionStatus, EpisodeNumber, + SeasonMode, 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 # --------------------------------------------------------------------------- @@ -131,6 +100,17 @@ class TestEpisodeNumber: assert int(e) == 12 +# --------------------------------------------------------------------------- +# SeasonMode +# --------------------------------------------------------------------------- + + +class TestSeasonMode: + def test_values(self): + assert SeasonMode.PACK.value == "pack" + assert SeasonMode.EPISODIC.value == "episodic" + + # --------------------------------------------------------------------------- # Episode entity # --------------------------------------------------------------------------- @@ -151,6 +131,11 @@ class TestEpisode: 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() @@ -171,10 +156,10 @@ class TestEpisode: def test_has_audio_in_with_str(self): e = self._ep( - audio_tracks=[ + 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 @@ -187,49 +172,49 @@ class TestEpisode: native_name="Français", aliases=("fr", "fra", "french"), ) - e = self._ep(audio_tracks=[AudioTrack(0, "ac3", 6, "5.1", "fr")]) + 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=[ + 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")]) + 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=[ + 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")]) + 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=[ + subtitle_tracks=( SubtitleTrack(0, "subrip", "eng"), SubtitleTrack(1, "subrip", "fre"), SubtitleTrack(2, "subrip", "eng"), - ] + ) ) assert e.subtitle_languages() == ["eng", "fre"] @@ -247,7 +232,12 @@ class TestSeason: s = Season(season_number=1) assert isinstance(s.season_number, SeasonNumber) assert s.episode_count == 0 - assert s.episodes == {} + 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" @@ -257,82 +247,34 @@ class TestSeason: 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) + # ── Mode derivation ──────────────────────────────────────────────── - 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): + def test_mode_pack_when_no_episodes(self): s = Season(season_number=1) - ep = Episode(season_number=2, episode_number=1, title="x") - with pytest.raises(ValueError): - s.add_episode(ep) + assert s.mode == SeasonMode.PACK - 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_mode_episodic_when_episodes_present(self): + s = Season(season_number=1, episodes=(self._ep(1),)) + assert s.mode == SeasonMode.EPISODIC - def test_str_uses_name_when_present(self): - s = Season(season_number=1, name="Pilot Season") - assert "Pilot Season" in str(s) + # ── Episode access ───────────────────────────────────────────────── - # ── Completeness vs aired ────────────────────────────────────────── + 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_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_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_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() == [] + 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 # --------------------------------------------------------------------------- @@ -342,40 +284,30 @@ class TestSeason: class TestTVShow: def _show(self, **kwargs) -> TVShow: - defaults = dict( - imdb_id="tt0903747", - title="Breaking Bad", - status="ended", - ) + defaults = dict(imdb_id="tt0903747", title="Breaking Bad") defaults.update(kwargs) return TVShow(**defaults) # ── Construction & coercion ──────────────────────────────────────── def test_basic_creation(self): - show = self._show(expected_seasons=5) + show = self._show() assert show.title == "Breaking Bad" - assert show.expected_seasons == 5 - assert show.seasons == {} + 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_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 + 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" @@ -389,77 +321,224 @@ class TestTVShow: assert "Breaking Bad" in str(show) assert "tt0903747" in repr(show) - # ── add_episode — the only sanctioned mutation ───────────────────── + # ── 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 = self._show() - show.add_episode(Episode(season_number=1, episode_number=1, title="Pilot")) - assert SeasonNumber(1) in show.seasons + 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 = 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")) + 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): - 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 + 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] - # ── Collection status ────────────────────────────────────────────── + 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_collection_status_empty(self): - assert self._show().collection_status() == CollectionStatus.EMPTY + 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_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_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_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_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_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)] + 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 diff --git a/tests/infrastructure/test_filesystem_extras.py b/tests/infrastructure/test_filesystem_extras.py index ba867ae..d74212c 100644 --- a/tests/infrastructure/test_filesystem_extras.py +++ b/tests/infrastructure/test_filesystem_extras.py @@ -23,7 +23,6 @@ from alfred.domain.tv_shows.entities import Episode, TVShow from alfred.domain.tv_shows.value_objects import ( EpisodeNumber, SeasonNumber, - ShowStatus, ) from alfred.infrastructure.filesystem.filesystem_operations import ( create_folder, @@ -171,8 +170,6 @@ def _show() -> TVShow: return TVShow( imdb_id=ImdbId("tt0773262"), title="Dexter", - expected_seasons=8, - status=ShowStatus.ENDED, )