"""Tests for the show-tree walker.""" from __future__ import annotations from alfred.application.tv_shows.walker import walk_show from alfred.domain.releases.value_objects import ReleaseMode from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge _KB = YamlReleaseKnowledge() _SCANNER = PathlibFilesystemScanner() def _file(dir_path, name: str) -> None: (dir_path / name).write_bytes(b"") # ════════════════════════════════════════════════════════════════════════════ # Season-folder detection (by Sxx token in folder name) # ════════════════════════════════════════════════════════════════════════════ 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" ] # ════════════════════════════════════════════════════════════════════════════ # PACK classification (flat numbered videos inside season folder) # ════════════════════════════════════════════════════════════════════════════ class TestPackClassification: def test_flat_videos_yield_pack(self, tmp_path): show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S01.1080p.WEB-DL.x265-GROUP" season.mkdir() _file(season, "Foundation.S01E01.1080p.WEB-DL.x265-GROUP.mkv") _file(season, "Foundation.S01E02.1080p.WEB-DL.x265-GROUP.mkv") tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders assert folder.mode is ReleaseMode.PACK assert [p.name for p in folder.video_files] == [ "Foundation.S01E01.1080p.WEB-DL.x265-GROUP.mkv", "Foundation.S01E02.1080p.WEB-DL.x265-GROUP.mkv", ] def test_pack_keeps_only_video_extensions(self, tmp_path): show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S01.WEB" season.mkdir() _file(season, "Foundation.S01E01.mkv") _file(season, "Foundation.S01E02.mp4") _file(season, "Foundation.S01E01.eng.srt") # not a video _file(season, "info.nfo") # not a video tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders assert folder.mode is ReleaseMode.PACK suffixes = sorted(p.suffix for p in folder.video_files) assert suffixes == [".mkv", ".mp4"] # ════════════════════════════════════════════════════════════════════════════ # EPISODIC classification (subfolders, one video each) # ════════════════════════════════════════════════════════════════════════════ class TestEpisodicClassification: def test_subfolders_yield_episodic(self, tmp_path): show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S02" season.mkdir() for n in (1, 2): sub = season / f"Foundation.S02E{n:02d}.1080p.x265-GROUP" sub.mkdir() _file(sub, f"Foundation.S02E{n:02d}.1080p.x265-GROUP.mkv") tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders assert folder.mode is ReleaseMode.EPISODIC assert [p.name for p in folder.video_files] == [ "Foundation.S02E01.1080p.x265-GROUP.mkv", "Foundation.S02E02.1080p.x265-GROUP.mkv", ] def test_episodic_paths_descend_into_subfolders(self, tmp_path): show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S02" season.mkdir() sub = season / "Foundation.S02E01-RG" sub.mkdir() _file(sub, "Foundation.S02E01-RG.mkv") tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders assert folder.video_files == (sub / "Foundation.S02E01-RG.mkv",) def test_episodic_skips_empty_subfolder(self, tmp_path, caplog): show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S02" season.mkdir() (season / "Foundation.S02E01-RG").mkdir() # empty subfolder good_sub = season / "Foundation.S02E02-RG" good_sub.mkdir() _file(good_sub, "Foundation.S02E02-RG.mkv") with caplog.at_level("WARNING"): tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders assert folder.mode is ReleaseMode.EPISODIC assert len(folder.video_files) == 1 assert any("no video" in r.message for r in caplog.records) def test_episodic_with_two_videos_in_subfolder_skips_season( self, tmp_path, caplog ): show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S02" season.mkdir() sub = season / "Foundation.S02E01-RG" sub.mkdir() _file(sub, "Foundation.S02E01-RG.mkv") _file(sub, "Foundation.S02E01-RG-extras.mkv") # second video! with caplog.at_level("WARNING"): tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders assert folder.mode is None assert folder.video_files == () assert any("malformed" in r.message for r in caplog.records) # ════════════════════════════════════════════════════════════════════════════ # Malformed and empty seasons # ════════════════════════════════════════════════════════════════════════════ class TestMalformedAndEmpty: def test_mixed_flat_and_subfolders_is_malformed(self, tmp_path, caplog): show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S01.Mixed" season.mkdir() _file(season, "Foundation.S01E01.mkv") # flat video sub = season / "Foundation.S01E02-RG" # AND a subfolder sub.mkdir() _file(sub, "Foundation.S01E02-RG.mkv") with caplog.at_level("WARNING"): tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders assert folder.mode is None assert folder.video_files == () assert any("mixes flat videos and subfolders" in r.message for r in caplog.records) def test_empty_season_folder_yields_unknown_mode(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.mode is None assert folder.video_files == () def test_season_with_only_non_video_files_is_unknown(self, tmp_path): # No videos, no subfolders → unknown. The .nfo doesn't count. show = tmp_path / "Foundation" show.mkdir() season = show / "Foundation.S01.WEB" season.mkdir() _file(season, "info.nfo") tree = walk_show(show, scanner=_SCANNER, kb=_KB) (folder,) = tree.season_folders assert folder.mode is None assert folder.video_files == () # ════════════════════════════════════════════════════════════════════════════ # Edge cases on the show root itself # ════════════════════════════════════════════════════════════════════════════ 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 == ()