refactor(persistence): Phase 4 Step 3 — delete v1 dot_alfred + ports
Now that rescan_show + rescan_movie run on the v2 release repositories (Phase 4 Steps 1-2), the v1 dot_alfred stack and its abstract domain ports have zero callers. Delete them and lift the Phase 3 quarantines. Deleted * alfred/infrastructure/persistence/dot_alfred/bridge.py * alfred/infrastructure/persistence/dot_alfred/repository.py (v1) * alfred/infrastructure/persistence/dot_alfred/serializer.py (v1) * alfred/infrastructure/persistence/dot_alfred/sidecar.py (v1) * alfred/domain/tv_shows/repositories.py (TVShowRepository ABC) * alfred/domain/movies/repositories.py (MovieRepository ABC) * tests/infrastructure/persistence/dot_alfred/test_repository.py * tests/infrastructure/persistence/dot_alfred/test_serializer.py Rewrite alfred/infrastructure/persistence/dot_alfred/__init__.py now re- exports only the v2 surface: the four concrete repositories (DotAlfredSeriesReleaseRepository, DotAlfredMovieReleaseRepository, DotAlfredTVShowLibraryIndex, DotAlfredMovieLibraryIndex) plus ShowFolderUnknown. DTO-level imports go through alfred.infrastructure.persistence.dot_alfred.v2 directly. No backwards-compat shims (per CLAUDE.md): the v1 names are gone, not aliased. Test suite drops from 10 → 8 skips (the two Phase 3 module-level skips disappear with the quarantined files). Full suite: 1233 passed / 8 skipped / 4 xfailed. The MediaWithTracks mixin in alfred.domain.shared.media is now orphaned (Episode lost its tracks in Phase 3, MovieRelease doesn't inherit it). Parked for Phase 5, which will either mount it on MovieRelease / SeasonRelease or delete it for good.
This commit is contained in:
@@ -1,316 +0,0 @@
|
||||
"""Tests for the filesystem-backed ``.alfred`` repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# Phase 3 (refactor/dot-alfred-v2): v1 repository 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 repository — replaced in Phase 4",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
|
||||
from alfred.domain.shared.value_objects import FilePath, ImdbId
|
||||
from alfred.domain.tv_shows.builders import TVShowBuilder
|
||||
from alfred.domain.tv_shows.entities import Episode
|
||||
from alfred.infrastructure.persistence.dot_alfred import (
|
||||
DotAlfredTVShowRepository,
|
||||
ShowFolderUnknown,
|
||||
)
|
||||
from alfred.infrastructure.persistence.dot_alfred.repository import (
|
||||
SIDECAR_FILENAME,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_breaking_bad_episodic():
|
||||
"""Breaking Bad with one EPISODIC season carrying two episodes."""
|
||||
return (
|
||||
TVShowBuilder(
|
||||
imdb_id="tt0903747",
|
||||
title="Breaking Bad",
|
||||
tmdb_id=1396,
|
||||
)
|
||||
.add_episode(
|
||||
Episode(
|
||||
season_number=5,
|
||||
episode_number=1,
|
||||
title="Live Free or Die",
|
||||
file_path=FilePath("Breaking.Bad.S05E01.mkv"),
|
||||
audio_tracks=(
|
||||
AudioTrack(
|
||||
index=0,
|
||||
codec=None,
|
||||
channels=None,
|
||||
channel_layout=None,
|
||||
language="eng",
|
||||
),
|
||||
),
|
||||
subtitle_tracks=(
|
||||
SubtitleTrack(
|
||||
index=0,
|
||||
codec=None,
|
||||
language="eng",
|
||||
is_default=False,
|
||||
is_forced=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
.add_episode(
|
||||
Episode(
|
||||
season_number=5,
|
||||
episode_number=2,
|
||||
title="Madrigal",
|
||||
file_path=FilePath("Breaking.Bad.S05E02.mkv"),
|
||||
audio_tracks=(
|
||||
AudioTrack(
|
||||
index=0,
|
||||
codec=None,
|
||||
channels=None,
|
||||
channel_layout=None,
|
||||
language="eng",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def _make_foundation_pack():
|
||||
"""Foundation as a PACK season (no episodes in the aggregate)."""
|
||||
return TVShowBuilder(
|
||||
imdb_id="tt0804484",
|
||||
title="Foundation",
|
||||
tmdb_id=84958,
|
||||
).build()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# save + find_by_imdb_id round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSaveAndRead:
|
||||
def test_save_then_find_by_imdb_id(self, tmp_path):
|
||||
# Folder must exist before save (the repo never invents one).
|
||||
(tmp_path / "Breaking.Bad").mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
show = _make_breaking_bad_episodic()
|
||||
repo.save(show)
|
||||
|
||||
recovered = repo.find_by_imdb_id(ImdbId("tt0903747"))
|
||||
assert recovered is not None
|
||||
assert recovered.imdb_id == show.imdb_id
|
||||
assert recovered.tmdb_id == show.tmdb_id
|
||||
assert recovered.seasons_count == 1
|
||||
assert recovered.episode_count == 2
|
||||
|
||||
def test_find_by_imdb_id_uses_folder_name_as_title(self, tmp_path):
|
||||
(tmp_path / "Breaking.Bad").mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
|
||||
recovered = repo.find_by_imdb_id(ImdbId("tt0903747"))
|
||||
assert recovered is not None
|
||||
assert recovered.title == "Breaking.Bad"
|
||||
|
||||
def test_find_returns_none_for_unknown(self, tmp_path):
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
assert repo.find_by_imdb_id(ImdbId("tt9999999")) is None
|
||||
|
||||
def test_find_returns_none_for_cold_folder(self, tmp_path):
|
||||
# Folder exists but has no .alfred — cold scan returns None.
|
||||
(tmp_path / "Breaking.Bad").mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
assert repo.find_by_imdb_id(ImdbId("tt0903747")) is None
|
||||
|
||||
def test_pack_season_round_trip(self, tmp_path):
|
||||
(tmp_path / "Foundation").mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
repo.save(_make_foundation_pack())
|
||||
recovered = repo.find_by_imdb_id(ImdbId("tt0804484"))
|
||||
assert recovered is not None
|
||||
assert recovered.seasons_count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindAll:
|
||||
def test_returns_every_sidecar(self, tmp_path):
|
||||
(tmp_path / "Breaking.Bad").mkdir()
|
||||
(tmp_path / "Foundation").mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
repo.save(_make_foundation_pack())
|
||||
|
||||
all_shows = repo.find_all()
|
||||
ids = {str(s.imdb_id) for s in all_shows}
|
||||
assert ids == {"tt0903747", "tt0804484"}
|
||||
|
||||
def test_skips_folders_without_sidecar(self, tmp_path):
|
||||
(tmp_path / "Breaking.Bad").mkdir()
|
||||
(tmp_path / "ColdFolder").mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
assert len(repo.find_all()) == 1
|
||||
|
||||
def test_skips_corrupted_sidecar(self, tmp_path, caplog):
|
||||
cold = tmp_path / "Garbage"
|
||||
cold.mkdir()
|
||||
(cold / SIDECAR_FILENAME).write_text("not: valid: yaml: :{[")
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
assert repo.find_all() == []
|
||||
|
||||
def test_skips_schema_violation(self, tmp_path):
|
||||
bad = tmp_path / "WrongSchema"
|
||||
bad.mkdir()
|
||||
(bad / SIDECAR_FILENAME).write_text(
|
||||
yaml.safe_dump({"schema_version": 99, "imdb_id": "tt0", "seasons": []})
|
||||
)
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
assert repo.find_all() == []
|
||||
|
||||
def test_empty_library(self, tmp_path):
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
assert repo.find_all() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# delete + exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteAndExists:
|
||||
def test_exists_after_save(self, tmp_path):
|
||||
(tmp_path / "Breaking.Bad").mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
assert repo.exists(ImdbId("tt0903747")) is True
|
||||
|
||||
def test_exists_false_before_save(self, tmp_path):
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
assert repo.exists(ImdbId("tt0903747")) is False
|
||||
|
||||
def test_delete_removes_sidecar(self, tmp_path):
|
||||
show_dir = tmp_path / "Breaking.Bad"
|
||||
show_dir.mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
assert (show_dir / SIDECAR_FILENAME).is_file()
|
||||
|
||||
assert repo.delete(ImdbId("tt0903747")) is True
|
||||
assert not (show_dir / SIDECAR_FILENAME).exists()
|
||||
# The show folder itself stays — the repo only owns the sidecar.
|
||||
assert show_dir.is_dir()
|
||||
|
||||
def test_delete_returns_false_when_unknown(self, tmp_path):
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
assert repo.delete(ImdbId("tt9999999")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# save edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSaveEdgeCases:
|
||||
def test_save_raises_when_folder_missing(self, tmp_path):
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
with pytest.raises(ShowFolderUnknown):
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
|
||||
def test_save_atomic_no_tmp_file_left_behind(self, tmp_path):
|
||||
show_dir = tmp_path / "Breaking.Bad"
|
||||
show_dir.mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
# Only the final .alfred should remain.
|
||||
leftovers = [p.name for p in show_dir.iterdir()]
|
||||
assert leftovers == [SIDECAR_FILENAME]
|
||||
|
||||
def test_save_overwrites_existing(self, tmp_path):
|
||||
show_dir = tmp_path / "Breaking.Bad"
|
||||
show_dir.mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
# Save a second time with a different aggregate — sidecar must
|
||||
# carry the new content.
|
||||
updated = TVShowBuilder(
|
||||
imdb_id="tt0903747",
|
||||
title="Breaking Bad",
|
||||
tmdb_id=1396,
|
||||
).build()
|
||||
repo.save(updated)
|
||||
|
||||
recovered = repo.find_by_imdb_id(ImdbId("tt0903747"))
|
||||
assert recovered is not None
|
||||
assert recovered.seasons_count == 0 # the second save dropped the season
|
||||
|
||||
def test_save_finds_folder_via_get_folder_name(self, tmp_path):
|
||||
# show.get_folder_name() returns "Breaking.Bad" for title "Breaking Bad"
|
||||
(tmp_path / "Breaking.Bad").mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
# No prior save → cache empty → falls back to get_folder_name().
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
assert (tmp_path / "Breaking.Bad" / SIDECAR_FILENAME).is_file()
|
||||
|
||||
def test_save_falls_back_to_scan_when_folder_renamed(self, tmp_path):
|
||||
# Create a custom folder name (not the show's default).
|
||||
custom = tmp_path / "Breaking.Bad.1080p"
|
||||
custom.mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
# Pre-populate the index by reading the sidecar — first we need
|
||||
# to put one there. Write a minimal one by hand.
|
||||
(custom / SIDECAR_FILENAME).write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"imdb_id": "tt0903747",
|
||||
"tmdb_id": 1396,
|
||||
"seasons": [],
|
||||
}
|
||||
)
|
||||
)
|
||||
# find_all primes the folder index.
|
||||
repo.find_all()
|
||||
# Now save through the repo — it must reuse the custom folder.
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
assert (custom / SIDECAR_FILENAME).is_file()
|
||||
# Default folder was never created.
|
||||
assert not (tmp_path / "Breaking.Bad").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sidecar content sanity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSidecarContent:
|
||||
def test_written_yaml_matches_schema(self, tmp_path):
|
||||
(tmp_path / "Breaking.Bad").mkdir()
|
||||
repo = DotAlfredTVShowRepository(tmp_path)
|
||||
repo.save(_make_breaking_bad_episodic())
|
||||
text = (tmp_path / "Breaking.Bad" / SIDECAR_FILENAME).read_text()
|
||||
data = yaml.safe_load(text)
|
||||
assert data["schema_version"] == 1
|
||||
assert data["imdb_id"] == "tt0903747"
|
||||
assert data["tmdb_id"] == 1396
|
||||
# One EPISODIC season with two episodes.
|
||||
assert len(data["seasons"]) == 1
|
||||
season = data["seasons"][0]
|
||||
assert season["number"] == 5
|
||||
assert len(season["episodes"]) == 2
|
||||
# Episode paths come from FilePath.
|
||||
assert season["episodes"][0]["path"] == "Breaking.Bad.S05E01.mkv"
|
||||
@@ -1,433 +0,0 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user