"""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()