Files
alfred/tests/infrastructure/persistence/dot_alfred/v2/conftest.py
T
francwa e65c1df229 feat(.alfred v2 — Phase 2): Pydantic sidecars, atomic repos, auto-heal index
Spec: specs/dot_alfred_v2.md (Phase 2).

New package alfred/infrastructure/persistence/dot_alfred/v2/:
  * sidecar_release.py / sidecar_root.py — Pydantic DTOs
    (extra="forbid", frozen=True) for per-item sidecars and the
    library-root index. schema_version enforced via model_validator.
  * serializer.py — read_yaml / atomic_write_yaml (.tmp + os.replace).
    SidecarSchemaError wraps YAML + Pydantic errors uniformly.
  * bridge.py — lossless domain <-> sidecar for SeriesRelease /
    MovieRelease; projection-only show_index_entry_from /
    movie_index_entry_from with multi-episode-file flattening.
  * repository.py — DotAlfredSeriesReleaseRepository /
    DotAlfredMovieReleaseRepository (log+skip on corruption),
    DotAlfredTVShowLibraryIndex / DotAlfredMovieLibraryIndex with
    silent auto-heal on missing/corrupt index reads. Writes never
    auto-heal (read paths handle that).

TMDB client extensions:
  * TmdbSeasonInfo / TmdbShowInfo DTOs + pure parse_tv_show_info.
  * TMDBClient.get_tv_show_info aggregates /tv/{id} +
    /tv/{id}/external_ids.

Domain change:
  * SubtitleTrack gains is_sdh: bool = False, populated from
    ffprobe's hearing_impaired disposition. Required for v2 sidecar
    parity (spec replaces v1's type: "sdh" with explicit flag).
    Default keeps every existing caller unchanged.

Tests: 37 new v2 integration tests on tmp_path (round-trips, atomic
writes, schema mismatch handling, anchor warnings, auto-heal paths)
plus 16 TMDB DTO tests. Full suite: 1240 -> 1277 passed.

Implementation notes filed in .claude/specs/dot_alfred_v2_notes.md
(strict=True trade-off, upsert signature deviation from spec, etc.).

Phases 3-5 (TVShow/Movie refactor to TMDB-only, rescan_show rewrite,
v1 deletion + wiring) are next.
2026-05-25 16:01:39 +02:00

201 lines
6.5 KiB
Python

"""Shared fixtures for v2 ``.alfred`` integration tests.
The fixtures here build realistic ``SeriesRelease`` / ``MovieRelease``
aggregates — populated tracks, multi-episode files, both PACK and
EPISODIC modes — so every test starts from a known-rich state.
The point is to make round-trip tests genuinely lossless-checking
(if a field is unused in the fixture, the round-trip can't prove
much about it).
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from alfred.domain.releases.entities import (
EpisodeRelease,
MovieRelease,
SeasonRelease,
SeriesRelease,
TrackProfile,
)
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber
from alfred.infrastructure.api.tmdb.dto import TmdbSeasonInfo, TmdbShowInfo
def _audio(lang: str = "eng", *, index: int = 0) -> AudioTrack:
# ``index`` defaults to 0 to match what the bridge reconstructs on
# read (sidecars don't persist ffprobe stream indices — see the
# bridge module's track-conversion notes). Pass explicit indices
# only when a fixture has multiple tracks of the same kind.
return AudioTrack(
index=index,
codec="eac3",
channels=6,
channel_layout="5.1",
language=lang,
)
def _sub(
lang: str = "eng",
*,
index: int = 0,
forced: bool = False,
sdh: bool = False,
) -> SubtitleTrack:
return SubtitleTrack(
index=index,
codec="subrip",
language=lang,
is_default=False,
is_forced=forced,
is_sdh=sdh,
)
@pytest.fixture
def foundation_release() -> SeriesRelease:
"""Foundation S01 (PACK, 3 files) + S02 (EPISODIC, one multi-episode file)."""
s01 = SeasonRelease(
season_number=SeasonNumber(1),
folder="Foundation.S01.1080p.WEBRip.x265-RARBG",
mode=ReleaseMode.PACK,
episodes=(
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
file_path=FilePath(
"Foundation.S01.1080p.WEBRip.x265-RARBG/"
"Foundation.S01E01.1080p.WEBRip.x265-RARBG.mkv"
),
tracks=TrackProfile(
audio_tracks=(_audio("eng"),),
subtitle_tracks=(
_sub("eng", index=0),
_sub("eng", index=1, sdh=True),
),
),
),
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(2), EpisodeNumber(2)),
file_path=FilePath(
"Foundation.S01.1080p.WEBRip.x265-RARBG/"
"Foundation.S01E02.1080p.WEBRip.x265-RARBG.mkv"
),
tracks=TrackProfile(audio_tracks=(_audio("eng"),)),
),
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(3), EpisodeNumber(3)),
file_path=FilePath(
"Foundation.S01.1080p.WEBRip.x265-RARBG/"
"Foundation.S01E03.1080p.WEBRip.x265-RARBG.mkv"
),
tracks=TrackProfile(audio_tracks=(_audio("eng"),)),
),
),
)
s02 = SeasonRelease(
season_number=SeasonNumber(2),
folder="Foundation.S02",
mode=ReleaseMode.EPISODIC,
episodes=(
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
file_path=FilePath(
"Foundation.S02/Foundation.S02E01.1080p.x265-ELiTE/"
"Foundation.S02E01.1080p.x265-ELiTE.mkv"
),
tracks=TrackProfile(audio_tracks=(_audio("eng"),)),
),
# Multi-episode file (E02 + E03 in one .mkv).
EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(2), EpisodeNumber(3)),
file_path=FilePath(
"Foundation.S02/Foundation.S02E02-E03.2160p.WEB.x265-CtrlHD/"
"Foundation.S02E02-E03.2160p.WEB.x265-CtrlHD.mkv"
),
tracks=TrackProfile(
audio_tracks=(_audio("eng"),),
subtitle_tracks=(_sub("eng", forced=True),),
),
),
),
)
return SeriesRelease(
tmdb_id=TmdbId(84958),
imdb_id=ImdbId("tt0804484"),
seasons=(s01, s02),
)
@pytest.fixture
def inception_release() -> MovieRelease:
"""Inception (2010) — single-file movie with rich tracks."""
return MovieRelease(
tmdb_id=TmdbId(27205),
imdb_id=ImdbId("tt1375666"),
folder="Inception.2010.1080p.BluRay.x264-GROUP",
file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"),
tracks=TrackProfile(
audio_tracks=(
AudioTrack(
index=0,
codec="dts",
channels=8,
channel_layout="7.1",
language="eng",
),
),
subtitle_tracks=(
_sub("eng", index=0),
_sub("fre", index=1, forced=True),
),
),
)
@pytest.fixture
def foundation_tmdb_info() -> TmdbShowInfo:
"""Foundation TMDB cache snapshot — 3 seasons, S03 not yet aired."""
return TmdbShowInfo(
tmdb_id=84958,
imdb_id="tt0804484",
name="Foundation",
status="Returning Series",
seasons=(
TmdbSeasonInfo(number=1, episode_count=10, aired=True),
TmdbSeasonInfo(number=2, episode_count=10, aired=True),
TmdbSeasonInfo(number=3, episode_count=10, aired=False),
),
)
@pytest.fixture
def now_utc() -> datetime:
"""Stable UTC reference for deterministic fetched_at fields."""
return datetime(2026, 5, 25, 8, 30, 0, tzinfo=UTC)
@pytest.fixture
def tv_library(tmp_path):
"""Empty ``tv_shows/`` directory pre-populated with show folders."""
root = tmp_path / "tv_shows"
root.mkdir()
(root / "Foundation").mkdir()
(root / "Fallout").mkdir()
return root
@pytest.fixture
def movie_library(tmp_path):
"""Empty ``movies/`` directory pre-populated with one movie folder."""
root = tmp_path / "movies"
root.mkdir()
(root / "Inception.2010.1080p.BluRay.x264-GROUP").mkdir()
return root