Files
alfred/tests/infrastructure/persistence/dot_alfred/test_serializer.py
T
francwa c22b2b78eb refactor(domain): Phase 3 — TVShow/Movie aggregates become TMDB-only
Filesystem-side concerns (file paths, tracks, quality, mode, added_at)
move to the releases/ domain added in Phase 1; the TMDB aggregates now
carry only identity + TMDB catalog facts.

Domain entities:
- TVShow: tmdb_id: TmdbId required (primary key), imdb_id: ImdbId | None
  optional, status: str = "unknown" added.
- Season: episode_count: int = 0 added (TMDB-cached); audio_tracks,
  subtitle_tracks, mode property removed.
- Episode: slimmed to identity + title. file_path/file_size/tracks
  removed. No longer inherits MediaWithTracks.
- Movie: tmdb_id required, imdb_id optional. file_path/file_size/quality/
  added_at/audio_tracks/subtitle_tracks removed. get_filename() now
  returns "Title.Year" — quality moves to MovieRelease.

Builders:
- TVShowBuilder requires tmdb_id: TmdbId; imdb_id/status optional.
- SeasonBuilder.set_episode_count(int) replaces set_audio_tracks /
  set_subtitle_tracks.

No-coercion contract: TVShow(tmdb_id=1396) raises — callers pass
TmdbId(1396). No ergonomic shim per the no-shims rule.

Cascade fixes:
- MediaOrganizer test fixtures updated to new Movie/TVShow shapes.
- Movie.get_filename() re-added (without Quality) so MediaOrganizer
  keeps working until Phase 4 rewires it through MovieRelease.

Quarantined (deleted in Phase 4 alongside v1 dot_alfred):
- tests/application/library/test_rescan.py — module-level skip.
- tests/infrastructure/persistence/dot_alfred/test_repository.py —
  module-level skip.
- tests/infrastructure/persistence/dot_alfred/test_serializer.py —
  module-level skip.

Suite: 1216 passed, 11 skipped (8 pre-existing + 3 Phase 3
quarantines), 4 xfailed. CHANGELOG updated under [Unreleased].
2026-05-25 19:54:35 +02:00

434 lines
16 KiB
Python

"""Tests for the ``.alfred`` sidecar serializer.
Covers:
* Round-trip equivalence (``serialize`` → ``deserialize`` → equal DTO).
* Field omission rules (``None`` / empty tuples never make it to dict).
* Strict schema (unknown keys rejected, missing keys raise clearly).
* The Foundation fixture (real-world PACK season with mixed subtitles)
to exercise the full surface on a realistic case.
The serializer is pure-dict in/out; YAML I/O lives in the repository
layer and is tested separately.
Note: release identifiers (group/source/quality/codec) live in folder
and file names — the parser derives them on demand. They are
deliberately absent from the sidecar schema.
"""
from __future__ import annotations
import pytest
import yaml
# Phase 3 (refactor/dot-alfred-v2): v1 serializer is intentionally
# left in tree as a frozen reference until Phase 4 deletes both v1
# and this test module in one swing.
pytest.skip(
"v1 dot_alfred serializer — replaced in Phase 4",
allow_module_level=True,
)
from alfred.domain.shared.value_objects import ImdbId # noqa: E402
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber
from alfred.infrastructure.persistence.dot_alfred import (
EpisodeSidecar,
SeasonSidecar,
ShowSidecar,
SubtitleEntry,
deserialize,
serialize,
)
from alfred.infrastructure.persistence.dot_alfred.serializer import (
SidecarSchemaError,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _foundation_sidecar() -> ShowSidecar:
"""The Foundation S01 PACK season — real-world fixture data.
Mirrors the layout seen in
``/mnt/testipool/tv_shows/Foundation.2021.1080p.WEBRip.x265-RARBG/`` —
superset audio/subs at season level (some episodes have a forced
English sub, captured at season scope).
"""
return ShowSidecar(
imdb_id=ImdbId("tt0804484"),
tmdb_id=84958,
seasons=(
SeasonSidecar(
number=SeasonNumber(1),
path="Foundation.2021.S01.1080p.WEBRip.x265-RARBG",
audio_languages=("eng",),
subtitles=(
SubtitleEntry(language="eng", source="adjacent", type="standard"),
SubtitleEntry(language="eng", source="adjacent", type="sdh"),
SubtitleEntry(language="eng", source="adjacent", type="forced"),
SubtitleEntry(language="fra", source="adjacent", type="standard"),
SubtitleEntry(language="fra", source="adjacent", type="sdh"),
),
),
),
)
def _minimal_sidecar() -> ShowSidecar:
"""Identity-only sidecar — no seasons, no track data."""
return ShowSidecar(imdb_id=ImdbId("tt0903747"))
def _episodic_sidecar() -> ShowSidecar:
"""A season in EPISODIC mode (per-episode track metadata)."""
return ShowSidecar(
imdb_id=ImdbId("tt0903747"),
tmdb_id=1396,
seasons=(
SeasonSidecar(
number=SeasonNumber(5),
path="Breaking.Bad.S05",
episodes=(
EpisodeSidecar(
number=EpisodeNumber(1),
path="Breaking.Bad.S05E01.Live.Free.or.Die-MeGusta/Breaking.Bad.S05E01.mkv",
audio_languages=("eng",),
subtitles=(
SubtitleEntry(
language="eng", source="embedded", type="standard"
),
),
),
EpisodeSidecar(
number=EpisodeNumber(2),
path="Breaking.Bad.S05E02.Madrigal-CtrlHD/Breaking.Bad.S05E02.mkv",
audio_languages=("eng",),
),
),
),
),
)
# ---------------------------------------------------------------------------
# Round-trip
# ---------------------------------------------------------------------------
class TestRoundTrip:
def test_minimal(self):
original = _minimal_sidecar()
assert deserialize(serialize(original)) == original
def test_foundation_pack_season(self):
original = _foundation_sidecar()
assert deserialize(serialize(original)) == original
def test_episodic_breaking_bad(self):
original = _episodic_sidecar()
assert deserialize(serialize(original)) == original
def test_round_trip_through_yaml(self):
"""Full pipeline: DTO → dict → YAML text → dict → DTO."""
original = _foundation_sidecar()
text = yaml.safe_dump(serialize(original), sort_keys=False)
recovered = deserialize(yaml.safe_load(text))
assert recovered == original
# ---------------------------------------------------------------------------
# Serialize — field omission
# ---------------------------------------------------------------------------
class TestSerializeOmission:
def test_tmdb_id_omitted_when_none(self):
out = serialize(_minimal_sidecar())
assert "tmdb_id" not in out
def test_empty_seasons_is_empty_list_not_omitted(self):
# We always emit `seasons:` even if empty — the key documents the
# show "has no season recorded yet" vs being entirely missing.
out = serialize(_minimal_sidecar())
assert out["seasons"] == []
def test_no_audio_when_empty(self):
sidecar = ShowSidecar(
imdb_id=ImdbId("tt0903747"),
seasons=(SeasonSidecar(number=SeasonNumber(1), path="X.S01"),),
)
out = serialize(sidecar)
assert "audio" not in out["seasons"][0]
def test_no_subtitles_when_empty(self):
sidecar = ShowSidecar(
imdb_id=ImdbId("tt0903747"),
seasons=(SeasonSidecar(number=SeasonNumber(1), path="X.S01"),),
)
out = serialize(sidecar)
assert "subtitles" not in out["seasons"][0]
def test_no_episodes_when_pack(self):
sidecar = ShowSidecar(
imdb_id=ImdbId("tt0903747"),
seasons=(SeasonSidecar(number=SeasonNumber(1), path="X.S01"),),
)
out = serialize(sidecar)
assert "episodes" not in out["seasons"][0]
def test_parser_derivable_fields_never_emitted(self):
"""group/source/quality/codec must never appear in the YAML."""
out = serialize(_foundation_sidecar())
season = out["seasons"][0]
for forbidden in ("group", "source", "quality", "codec"):
assert forbidden not in season
# ---------------------------------------------------------------------------
# Serialize — shape
# ---------------------------------------------------------------------------
class TestSerializeShape:
def test_root_keys(self):
out = serialize(_foundation_sidecar())
assert out["schema_version"] == 1
assert out["imdb_id"] == "tt0804484"
assert out["tmdb_id"] == 84958
assert isinstance(out["seasons"], list)
def test_season_number_is_int(self):
out = serialize(_foundation_sidecar())
assert out["seasons"][0]["number"] == 1
assert isinstance(out["seasons"][0]["number"], int)
def test_audio_as_list_of_dicts(self):
out = serialize(_foundation_sidecar())
assert out["seasons"][0]["audio"] == [{"language": "eng"}]
def test_subtitle_structure(self):
out = serialize(_foundation_sidecar())
subs = out["seasons"][0]["subtitles"]
assert subs[0] == {
"language": "eng",
"source": "adjacent",
"type": "standard",
}
# ---------------------------------------------------------------------------
# Deserialize — strict schema
# ---------------------------------------------------------------------------
class TestDeserializeStrict:
def _valid_minimal(self) -> dict:
return {
"schema_version": 1,
"imdb_id": "tt0903747",
"seasons": [],
}
def test_unknown_root_key_raises(self):
data = self._valid_minimal()
data["bogus"] = "x"
with pytest.raises(SidecarSchemaError, match="root has unknown keys"):
deserialize(data)
def test_unknown_season_key_raises(self):
data = self._valid_minimal()
data["seasons"] = [{"number": 1, "path": "X", "weird": True}]
with pytest.raises(SidecarSchemaError, match="season has unknown keys"):
deserialize(data)
def test_parser_derivable_season_key_raises(self):
"""A stray group/source/quality/codec key must be rejected."""
data = self._valid_minimal()
data["seasons"] = [{"number": 1, "path": "X", "group": "RARBG"}]
with pytest.raises(SidecarSchemaError, match="season has unknown keys"):
deserialize(data)
def test_unknown_episode_key_raises(self):
data = self._valid_minimal()
data["seasons"] = [
{
"number": 1,
"path": "X",
"episodes": [{"number": 1, "path": "p", "huh": 1}],
}
]
with pytest.raises(SidecarSchemaError, match="episode has unknown keys"):
deserialize(data)
def test_unknown_subtitle_key_raises(self):
data = self._valid_minimal()
data["seasons"] = [
{
"number": 1,
"path": "X",
"subtitles": [
{"language": "eng", "source": "adjacent", "type": "sdh", "x": 1}
],
}
]
with pytest.raises(SidecarSchemaError, match="subtitle has unknown keys"):
deserialize(data)
def test_unknown_audio_key_raises(self):
data = self._valid_minimal()
data["seasons"] = [
{
"number": 1,
"path": "X",
"audio": [{"language": "eng", "channels": 6}],
}
]
with pytest.raises(SidecarSchemaError, match=r"audio\[\] has unknown keys"):
deserialize(data)
def test_wrong_schema_version_raises(self):
data = self._valid_minimal()
data["schema_version"] = 2
with pytest.raises(SidecarSchemaError, match="schema_version"):
deserialize(data)
def test_missing_schema_version_raises(self):
data = self._valid_minimal()
del data["schema_version"]
with pytest.raises(SidecarSchemaError, match="schema_version"):
deserialize(data)
def test_imdb_id_must_be_string(self):
data = self._valid_minimal()
data["imdb_id"] = 12345
with pytest.raises(SidecarSchemaError, match="imdb_id must be a string"):
deserialize(data)
def test_tmdb_id_must_be_int_when_present(self):
data = self._valid_minimal()
data["tmdb_id"] = "1396"
with pytest.raises(SidecarSchemaError, match="tmdb_id"):
deserialize(data)
def test_seasons_must_be_list(self):
data = self._valid_minimal()
data["seasons"] = {"1": {}}
with pytest.raises(SidecarSchemaError, match="seasons must be a list"):
deserialize(data)
def test_season_number_must_be_int(self):
data = self._valid_minimal()
data["seasons"] = [{"number": "1", "path": "X"}]
with pytest.raises(SidecarSchemaError, match="season.number must be an int"):
deserialize(data)
def test_season_number_bool_rejected(self):
# bool is a subclass of int but should not pass — guards against
# YAML quirks where `True` could sneak in as a season number.
data = self._valid_minimal()
data["seasons"] = [{"number": True, "path": "X"}]
with pytest.raises(SidecarSchemaError, match="season.number must be an int"):
deserialize(data)
def test_season_path_must_be_string(self):
data = self._valid_minimal()
data["seasons"] = [{"number": 1, "path": 1}]
with pytest.raises(SidecarSchemaError, match="season.path"):
deserialize(data)
def test_subtitle_missing_field_raises(self):
data = self._valid_minimal()
data["seasons"] = [
{
"number": 1,
"path": "X",
"subtitles": [{"language": "eng", "source": "adjacent"}],
}
]
with pytest.raises(SidecarSchemaError, match="subtitle.type"):
deserialize(data)
# ---------------------------------------------------------------------------
# Foundation fixture — golden YAML
# ---------------------------------------------------------------------------
class TestFoundationGolden:
"""Use the Foundation case to validate the produced YAML reads well."""
def test_yaml_dump_shape(self):
text = yaml.safe_dump(serialize(_foundation_sidecar()), sort_keys=False)
# Sanity-check that the human-readable layout matches the spec.
assert "schema_version: 1" in text
assert "imdb_id: tt0804484" in text
assert "tmdb_id: 84958" in text
assert "- number: 1" in text
assert "path: Foundation.2021.S01.1080p.WEBRip.x265-RARBG" in text
# No episodes block (PACK mode).
assert "episodes:" not in text
# No release identifiers at season scope — those live in folder
# names. (We can't check ``source:`` here because the subtitle
# entries legitimately carry their own ``source`` key.)
for forbidden in ("group:", "quality:", "codec:"):
assert forbidden not in text
# ---------------------------------------------------------------------------
# Foundation on-disk fixture (real folder structure, no real .mkv)
# ---------------------------------------------------------------------------
@pytest.fixture
def foundation_tree(tmp_path):
"""Recreate the Foundation S01 layout in a tmp directory.
Mirrors the on-disk structure of
``/mnt/testipool/tv_shows/Foundation.2021.1080p.WEBRip.x265-RARBG/``
using empty placeholder files — sufficient for tests that need a
realistic show folder without dragging in real media.
"""
show = tmp_path / "Foundation.2021.1080p.WEBRip.x265-RARBG"
season = show / "Foundation.2021.S01.1080p.WEBRip.x265-RARBG"
season.mkdir(parents=True)
base = "Foundation.2021.S01E{n:02d}.1080p.WEBRip.x265-RARBG"
for ep in range(1, 11):
stem = base.format(n=ep)
(season / f"{stem}.mp4").touch()
(season / f"{stem}.eng.srt").touch()
(season / f"{stem}.eng.sdh.srt").touch()
(season / f"{stem}.fra.srt").touch()
(season / f"{stem}.fra.sdh.srt").touch()
if 4 <= ep <= 9:
(season / f"{stem}.eng.forced.srt").touch()
return show
class TestFoundationOnDisk:
"""The on-disk fixture is mostly for future tests (repository walk).
For now we exercise the basic shape — a placeholder for richer
walk-and-build tests landing in step 3 (repository).
"""
def test_fixture_has_expected_episode_count(self, foundation_tree):
season = foundation_tree / "Foundation.2021.S01.1080p.WEBRip.x265-RARBG"
mkvs = sorted(season.glob("*.mp4"))
assert len(mkvs) == 10
def test_fixture_has_forced_subs_only_on_some_episodes(self, foundation_tree):
season = foundation_tree / "Foundation.2021.S01.1080p.WEBRip.x265-RARBG"
forced = sorted(season.glob("*.eng.forced.srt"))
assert len(forced) == 6 # E04 through E09
def test_serialize_yaml_can_be_written_alongside(self, foundation_tree):
"""Write the sidecar next to the show folder and read it back."""
sidecar_path = foundation_tree / ".alfred"
sidecar_path.write_text(
yaml.safe_dump(serialize(_foundation_sidecar()), sort_keys=False)
)
recovered = deserialize(yaml.safe_load(sidecar_path.read_text()))
assert recovered == _foundation_sidecar()