feat(releases): Phase 1 — new filesystem release domain + TmdbId VO
First step of specs/dot_alfred_v2.md. Introduces a separate bounded context (alfred/domain/releases/) for the filesystem-side aggregates, disjoint from TMDB identity which stays in tv_shows/ and movies/. The link between the two worlds is TmdbId, used as the natural key in the persistence layer (no domain-level reference). New package alfred/domain/releases/: - value_objects: EpisodeRange (covers SxxE01E02E03 multi-episode files via start/end inclusive range, with count/numbers/is_single helpers), ReleaseMode enum (PACK = N video files direct in the season folder, EPISODIC = N sub-folders). - entities: TrackProfile, EpisodeRelease, SeasonRelease (with episode_count() summing each EpisodeRange.count()), SeriesRelease (tmdb_id primary anchor, optional imdb_id secondary), MovieRelease. All frozen dataclasses. - builders: SeasonReleaseBuilder + SeriesReleaseBuilder mirroring the v1 TVShowBuilder pattern. Builders sort episodes by range start on emit and reject overlapping ranges (two files claiming the same TMDB slot). from_existing() seeds a builder from an existing frozen aggregate for round-trip edits. - repositories: abstract ports (SeriesReleaseRepository, MovieReleaseRepository); concrete .alfred sidecar impls arrive in Phase 2. New shared VO alfred/domain/shared/value_objects.py::TmdbId — positive int, rejects bool/str/float, symmetric with the existing ImdbId VO. 73 unit tests cover VO validation, entity invariants, builder sort + overlap detection, and from_existing() round-trips. v1 code paths are untouched at this stage; the new domain coexists with the old TVShow aggregate until Phase 3 refactors it.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""Tests for the releases domain value objects: EpisodeRange, ReleaseMode."""
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
|
||||
from alfred.domain.shared.exceptions import ValidationError
|
||||
from alfred.domain.tv_shows.value_objects import EpisodeNumber
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReleaseMode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReleaseMode:
|
||||
def test_values(self):
|
||||
assert ReleaseMode.PACK.value == "pack"
|
||||
assert ReleaseMode.EPISODIC.value == "episodic"
|
||||
|
||||
def test_str_subclass(self):
|
||||
# ReleaseMode is a (str, Enum) — string equality should work.
|
||||
assert ReleaseMode.PACK == "pack"
|
||||
assert ReleaseMode.EPISODIC == "episodic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EpisodeRange
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEpisodeRangeConstruction:
|
||||
def test_single_episode(self):
|
||||
r = EpisodeRange(EpisodeNumber(1), EpisodeNumber(1))
|
||||
assert r.start == EpisodeNumber(1)
|
||||
assert r.end == EpisodeNumber(1)
|
||||
|
||||
def test_multi_episode(self):
|
||||
r = EpisodeRange(EpisodeNumber(2), EpisodeNumber(4))
|
||||
assert r.start == EpisodeNumber(2)
|
||||
assert r.end == EpisodeNumber(4)
|
||||
|
||||
def test_end_before_start_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EpisodeRange(EpisodeNumber(5), EpisodeNumber(3))
|
||||
|
||||
def test_start_must_be_episode_number(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EpisodeRange(1, EpisodeNumber(2)) # type: ignore[arg-type]
|
||||
|
||||
def test_end_must_be_episode_number(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EpisodeRange(EpisodeNumber(1), 2) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestEpisodeRangeOperations:
|
||||
def test_count_single(self):
|
||||
assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)).count() == 1
|
||||
|
||||
def test_count_multi(self):
|
||||
assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(3)).count() == 3
|
||||
|
||||
def test_count_large(self):
|
||||
assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(10)).count() == 10
|
||||
|
||||
def test_numbers_single(self):
|
||||
r = EpisodeRange(EpisodeNumber(2), EpisodeNumber(2))
|
||||
assert r.numbers() == (EpisodeNumber(2),)
|
||||
|
||||
def test_numbers_multi(self):
|
||||
r = EpisodeRange(EpisodeNumber(2), EpisodeNumber(4))
|
||||
assert r.numbers() == (
|
||||
EpisodeNumber(2),
|
||||
EpisodeNumber(3),
|
||||
EpisodeNumber(4),
|
||||
)
|
||||
|
||||
def test_is_single_true(self):
|
||||
assert EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)).is_single()
|
||||
|
||||
def test_is_single_false(self):
|
||||
assert not EpisodeRange(EpisodeNumber(1), EpisodeNumber(2)).is_single()
|
||||
|
||||
def test_str_single(self):
|
||||
assert str(EpisodeRange(EpisodeNumber(2), EpisodeNumber(2))) == "E02"
|
||||
|
||||
def test_str_multi(self):
|
||||
assert str(EpisodeRange(EpisodeNumber(2), EpisodeNumber(4))) == "E02-E04"
|
||||
|
||||
def test_repr(self):
|
||||
r = EpisodeRange(EpisodeNumber(1), EpisodeNumber(3))
|
||||
assert repr(r) == "EpisodeRange(1, 3)"
|
||||
|
||||
def test_equality(self):
|
||||
a = EpisodeRange(EpisodeNumber(1), EpisodeNumber(3))
|
||||
b = EpisodeRange(EpisodeNumber(1), EpisodeNumber(3))
|
||||
c = EpisodeRange(EpisodeNumber(1), EpisodeNumber(2))
|
||||
assert a == b
|
||||
assert a != c
|
||||
|
||||
def test_hashable(self):
|
||||
ranges = {
|
||||
EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
|
||||
EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
|
||||
EpisodeRange(EpisodeNumber(2), EpisodeNumber(3)),
|
||||
}
|
||||
assert len(ranges) == 2
|
||||
Reference in New Issue
Block a user