Files
alfred/tests/domain/test_shared_value_objects.py
T
francwa c0f6d01048 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.
2026-05-25 15:19:23 +02:00

167 lines
4.8 KiB
Python

"""Tests for shared domain value objects: ImdbId, TmdbId, FilePath, FileSize."""
from pathlib import Path
import pytest
from alfred.domain.shared.exceptions import ValidationError
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId, TmdbId
# ---------------------------------------------------------------------------
# ImdbId
# ---------------------------------------------------------------------------
class TestImdbId:
def test_valid_7_digits(self):
id_ = ImdbId("tt1375666")
assert str(id_) == "tt1375666"
def test_valid_8_digits(self):
id_ = ImdbId("tt12345678")
assert str(id_) == "tt12345678"
def test_empty_raises(self):
with pytest.raises(ValidationError):
ImdbId("")
def test_no_tt_prefix_raises(self):
with pytest.raises(ValidationError):
ImdbId("1375666")
def test_too_few_digits_raises(self):
with pytest.raises(ValidationError):
ImdbId("tt12345") # only 5 digits
def test_too_many_digits_raises(self):
with pytest.raises(ValidationError):
ImdbId("tt123456789") # 9 digits
def test_non_string_raises(self):
with pytest.raises(ValidationError):
ImdbId(1375666) # type: ignore
def test_repr(self):
assert "tt1375666" in repr(ImdbId("tt1375666"))
def test_equality(self):
assert ImdbId("tt1375666") == ImdbId("tt1375666")
assert ImdbId("tt1375666") != ImdbId("tt0903747")
def test_hashable(self):
# Frozen dataclass should be hashable
ids = {ImdbId("tt1375666"), ImdbId("tt0903747")}
assert len(ids) == 2
# ---------------------------------------------------------------------------
# TmdbId
# ---------------------------------------------------------------------------
class TestTmdbId:
def test_valid_int(self):
tid = TmdbId(84958)
assert tid.value == 84958
assert str(tid) == "84958"
assert int(tid) == 84958
def test_zero_raises(self):
with pytest.raises(ValidationError):
TmdbId(0)
def test_negative_raises(self):
with pytest.raises(ValidationError):
TmdbId(-1)
def test_string_raises(self):
with pytest.raises(ValidationError):
TmdbId("84958") # type: ignore[arg-type]
def test_float_raises(self):
with pytest.raises(ValidationError):
TmdbId(84958.0) # type: ignore[arg-type]
def test_bool_raises(self):
# bool is a subclass of int — make sure we reject it explicitly.
with pytest.raises(ValidationError):
TmdbId(True) # type: ignore[arg-type]
with pytest.raises(ValidationError):
TmdbId(False) # type: ignore[arg-type]
def test_repr(self):
assert repr(TmdbId(84958)) == "TmdbId(84958)"
def test_equality(self):
assert TmdbId(84958) == TmdbId(84958)
assert TmdbId(84958) != TmdbId(12345)
def test_hashable(self):
ids = {TmdbId(84958), TmdbId(12345), TmdbId(84958)}
assert len(ids) == 2
# ---------------------------------------------------------------------------
# FilePath
# ---------------------------------------------------------------------------
class TestFilePath:
def test_from_string(self, tmp_path):
p = FilePath(str(tmp_path))
assert isinstance(p.value, Path)
def test_from_path(self, tmp_path):
p = FilePath(tmp_path)
assert p.value == tmp_path
def test_invalid_type_raises(self):
with pytest.raises(ValidationError):
FilePath(123) # type: ignore
def test_str(self, tmp_path):
p = FilePath(tmp_path)
assert str(p) == str(tmp_path)
# ---------------------------------------------------------------------------
# FileSize
# ---------------------------------------------------------------------------
class TestFileSize:
def test_bytes(self):
s = FileSize(500)
assert s.bytes == 500
def test_negative_raises(self):
with pytest.raises(ValidationError):
FileSize(-1)
def test_non_integer_raises(self):
with pytest.raises(ValidationError):
FileSize(1.5) # type: ignore
def test_zero_is_valid(self):
s = FileSize(0)
assert s.bytes == 0
def test_human_readable_bytes(self):
assert FileSize(500).to_human_readable() == "500 B"
def test_human_readable_kb(self):
result = FileSize(2048).to_human_readable()
assert "KB" in result
def test_human_readable_mb(self):
result = FileSize(5 * 1024 * 1024).to_human_readable()
assert "MB" in result
def test_human_readable_gb(self):
result = FileSize(2 * 1024**3).to_human_readable()
assert "GB" in result
def test_str_is_human_readable(self):
s = FileSize(1024)
assert str(s) == s.to_human_readable()