Files
alfred/tests/application/tv_shows/test_walker.py
T
francwa 7da0f887e7 refactor(rescan): Phase 4 Step 1 — rescan_show on v2 release repo
Rewrite rescan_show to build a SeriesRelease (Phase 1 v2 aggregate)
and persist it via DotAlfredSeriesReleaseRepository. The orchestrator
keeps reusing inspect_release as the single source of parse/probe
truth — only the assembly target changes (SeriesRelease/SeasonRelease/
EpisodeRelease instead of TVShow/Season/Episode).

New signature

    rescan_show(
        show_root,
        *,
        tmdb_id: TmdbId,
        imdb_id: ImdbId | None = None,
        series_repo: DotAlfredSeriesReleaseRepository,
        scanner,
        prober,
        kb,
    ) -> SeriesRelease

Identity is TMDB-anchored (tmdb_id required, no coercion); imdb_id is
optional. No TMDB call from rescan — the library index auto-heals
from the new sidecar on its next read.

PACK vs EPISODIC

* Single-video + season-parsed + no-episode → SeasonRelease(
  mode=PACK, folder=<season folder>, episodes=()). The slot map stays
  empty until the Phase 5 TMDB sync supplies episode_count. We do
  not fabricate an EpisodeRange we cannot prove on disk.
* Otherwise → EPISODIC: every file with (season, episode) becomes an
  EpisodeRelease with EpisodeRange(start, end) = (E, E). Multi-episode
  files (S01E01E02) still record only the first slot — Parser does
  not yet expose episode_end (existing tech debt, unchanged).

Package move

The orchestrator moves from alfred/application/library/ to
alfred/application/tv_shows/ for symmetry with alfred/application/
movies/ (Step 2). walker.py + its tests move with it. The empty
library/ package is deleted.

Tests

tests/application/tv_shows/test_rescan.py rewritten end-to-end on
the real v2 repository, real KB, real scanner, stubbed prober.
9 happy-path + edge-case scenarios cover EPISODIC track flattening,
PACK empty-episodes semantics, sidecar round-trip, imdb_id optional,
empty show root, season folder with no videos, prober returning None.
test_walker.py moved verbatim (import path updated).

Full suite: 1214 passed / 10 skipped / 4 xfailed. The three v1
dot_alfred quarantines from Phase 3 stay in place until Step 3.
2026-05-25 21:07:25 +02:00

129 lines
4.8 KiB
Python

"""Tests for the show-tree walker."""
from __future__ import annotations
from alfred.application.tv_shows.walker import walk_show
from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
_KB = YamlReleaseKnowledge()
_SCANNER = PathlibFilesystemScanner()
def _episode(dir_path, name: str) -> None:
(dir_path / name).write_bytes(b"")
class TestSeasonFolderDetection:
def test_keeps_folders_with_season_token(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
(show / "Foundation.S01.1080p.WEB-DL.x265-GROUP").mkdir()
(show / "Foundation.S02.Complete.1080p.WEB-DL-GROUP").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
names = sorted(s.season_dir.name for s in tree.season_folders)
assert names == [
"Foundation.S01.1080p.WEB-DL.x265-GROUP",
"Foundation.S02.Complete.1080p.WEB-DL-GROUP",
]
def test_skips_folders_without_season_token(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
(show / "Sample").mkdir()
(show / "Soundtrack").mkdir()
(show / "Foundation.S01.WEB").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert [s.season_dir.name for s in tree.season_folders] == [
"Foundation.S01.WEB"
]
def test_season_token_is_case_insensitive(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
(show / "Foundation.s01.WEB").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert len(tree.season_folders) == 1
def test_season_token_rejects_in_word(self, tmp_path):
# "Season01" without separator must not match (we want a real
# Sxx token, not any letter S followed by digits).
show = tmp_path / "WeirdShow"
show.mkdir()
(show / "Pass100").mkdir()
(show / "ABS01XYZ").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert tree.season_folders == ()
def test_ignores_files_at_show_root(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
(show / "Foundation.S01.mkv").write_bytes(b"") # file, not a folder
(show / "Foundation.S01.WEB").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert [s.season_dir.name for s in tree.season_folders] == [
"Foundation.S01.WEB"
]
class TestVideoFileCollection:
def test_keeps_only_video_extensions(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
season = show / "Foundation.S01.WEB"
season.mkdir()
_episode(season, "Foundation.S01E01.mkv")
_episode(season, "Foundation.S01E02.mp4")
_episode(season, "Foundation.S01E01.eng.srt") # not a video
_episode(season, "info.nfo") # not a video
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
(folder,) = tree.season_folders
suffixes = sorted(p.suffix for p in folder.video_files)
assert suffixes == [".mkv", ".mp4"]
def test_empty_season_folder_surfaces(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
(show / "Foundation.S01.WEB").mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
(folder,) = tree.season_folders
assert folder.video_files == ()
def test_single_pack_video_is_kept(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
season = show / "Foundation.S01.Complete.WEB"
season.mkdir()
_episode(season, "Foundation.S01.Complete.WEB.mkv")
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
(folder,) = tree.season_folders
assert [p.name for p in folder.video_files] == [
"Foundation.S01.Complete.WEB.mkv"
]
def test_does_not_recurse_into_sub_subfolders(self, tmp_path):
show = tmp_path / "Foundation"
show.mkdir()
season = show / "Foundation.S01.WEB"
season.mkdir()
nested = season / "Extras"
nested.mkdir()
_episode(nested, "Foundation.S01E01.mkv")
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
(folder,) = tree.season_folders
assert folder.video_files == ()
class TestEdgeCases:
def test_empty_show_root(self, tmp_path):
show = tmp_path / "Empty"
show.mkdir()
tree = walk_show(show, scanner=_SCANNER, kb=_KB)
assert tree.show_root == show
assert tree.season_folders == ()
def test_missing_show_root_returns_empty_tree(self, tmp_path):
# Scanner returns [] for non-existent paths; walker must not raise.
tree = walk_show(tmp_path / "Nope", scanner=_SCANNER, kb=_KB)
assert tree.season_folders == ()