git commit -m "feat: major architectural refactor

- Refactor memory system (episodic/STM/LTM with components)
- Implement complete subtitle domain (scanner, matcher, placer)
- Add YAML workflow infrastructure
- Externalize knowledge base (patterns, release groups)
- Add comprehensive testing suite
- Create manual testing CLIs"
This commit is contained in:
2026-05-11 21:33:37 +02:00
parent 62b5d0b998
commit de02bdea06
103 changed files with 8559 additions and 1346 deletions
View File
+465
View File
@@ -0,0 +1,465 @@
"""
Tests for alfred.domain.media.release_parser
Real-data cases sourced from /mnt/testipool/downloads/.
Covers: parsing, normalisation, naming methods, edge cases.
"""
import pytest
from alfred.domain.media.release_parser import (
ParsedRelease,
_normalise,
_sanitise_for_fs,
_strip_episode_from_normalised,
parse_release,
)
# ---------------------------------------------------------------------------
# _normalise
# ---------------------------------------------------------------------------
class TestNormalise:
def test_dots_unchanged(self):
assert _normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
def test_spaces_become_dots(self):
assert _normalise("Oz S01 1080p WEBRip x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
def test_double_dots_collapsed(self):
assert _normalise("Oz..S01..1080p") == "Oz.S01.1080p"
def test_leading_trailing_dots_stripped(self):
assert _normalise(".Oz.S01.") == "Oz.S01"
def test_mixed_spaces_and_dots(self):
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
result = _normalise("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
assert " " not in result
assert ".." not in result
# ---------------------------------------------------------------------------
# _sanitise_for_fs
# ---------------------------------------------------------------------------
class TestSanitiseForFs:
def test_clean_string_unchanged(self):
assert _sanitise_for_fs("Oz.S01.1080p-KONTRAST") == "Oz.S01.1080p-KONTRAST"
def test_removes_question_mark(self):
assert _sanitise_for_fs("What's Up?") == "What's Up"
def test_removes_colon(self):
assert _sanitise_for_fs("He Said: She Said") == "He Said She Said"
def test_removes_all_forbidden(self):
assert _sanitise_for_fs('a?b:c*d"e<f>g|h\\i') == "abcdefghi"
def test_apostrophe_kept(self):
# apostrophe is not in the forbidden set
assert _sanitise_for_fs("What's Up") == "What's Up"
def test_ellipsis_kept(self):
assert _sanitise_for_fs("What If...") == "What If..."
# ---------------------------------------------------------------------------
# _strip_episode_from_normalised
# ---------------------------------------------------------------------------
class TestStripEpisode:
def test_strips_single_episode(self):
assert _strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST") \
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
def test_strips_multi_episode(self):
assert _strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb") \
== "Archer.S14.1080p.HULU.WEB-DL-NTb"
def test_season_pack_unchanged(self):
assert _strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST") \
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
def test_case_insensitive(self):
assert _strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST") \
== "oz.s01.1080p-KONTRAST"
# ---------------------------------------------------------------------------
# parse_release — Season packs (dots)
# ---------------------------------------------------------------------------
class TestSeasonPackDots:
"""Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc."""
def test_oz_s01_kontrast(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
assert p.title == "Oz"
assert p.season == 1
assert p.episode is None
assert p.quality == "1080p"
assert p.source == "WEBRip"
assert p.codec == "x265"
assert p.group == "KONTRAST"
assert p.is_season_pack
assert not p.is_movie
def test_fallout_s02_kontrast(self):
p = parse_release("Fallout.2024.S02.1080p.WEBRip.x265-KONTRAST")
assert p.title == "Fallout"
assert p.year == 2024
assert p.season == 2
assert p.episode is None
assert p.group == "KONTRAST"
def test_archer_s03_edge2020(self):
p = parse_release("Archer.2009.S03.1080p.BluRay.DDP.5.1.x265-EDGE2020")
assert p.title == "Archer"
assert p.year == 2009
assert p.season == 3
assert p.quality == "1080p"
assert p.source == "BluRay"
assert p.codec == "x265"
assert p.group == "EDGE2020"
def test_fargo_s05_hulu_webdl(self):
p = parse_release("Fargo.S05.1080p.HULU.WEB-DL.x265.10bit-Protozoan")
assert p.title == "Fargo"
assert p.season == 5
assert p.quality == "1080p"
assert p.group == "Protozoan"
def test_xfiles_s01_bluray_rarbg(self):
p = parse_release("The.X-Files.S01.1080p.BluRay.x265-RARBG")
assert p.title == "The.X-Files"
assert p.season == 1
assert p.source == "BluRay"
assert p.group == "RARBG"
def test_gilmore_girls_s01_s07_repack(self):
p = parse_release("Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH")
# Season range — we parse the first season number found
assert p.season == 1
assert p.group == "MONOLITH"
def test_plot_against_america_4k(self):
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY")
assert p.title == "The.Plot.Against.America"
assert p.season == 1
assert p.quality == "2160p"
assert p.group == "SH3LBY"
def test_foundation_with_year_in_title(self):
p = parse_release("Foundation.2021.S01.1080p.WEBRip.x265-RARBG")
assert p.title == "Foundation"
assert p.year == 2021
assert p.season == 1
assert p.group == "RARBG"
def test_gen_v_s02(self):
p = parse_release("Gen.V.S02.1080p.WEBRip.x265-KONTRAST")
assert p.title == "Gen.V"
assert p.season == 2
assert p.group == "KONTRAST"
# ---------------------------------------------------------------------------
# parse_release — Single episodes (dots)
# ---------------------------------------------------------------------------
class TestSingleEpisodeDots:
"""Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc."""
def test_fallout_s02e01_elite(self):
p = parse_release("Fallout.2024.S02E01.1080p.x265-ELiTE")
assert p.title == "Fallout"
assert p.year == 2024
assert p.season == 2
assert p.episode == 1
assert p.episode_end is None
assert p.group == "ELiTE"
assert not p.is_season_pack
def test_mare_of_easttown_with_episode_title_in_filename(self):
# Episode filenames often embed the title — we parse the release folder name
p = parse_release("Mare.of.Easttown.S01.1080p.10bit.WEBRip.6CH.x265.HEVC-PSA")
assert p.title == "Mare.of.Easttown"
assert p.season == 1
assert p.group == "PSA"
def test_it_welcome_to_derry_s01e01(self):
p = parse_release("IT.Welcome.to.Derry.S01E01.1080p.x265-ELiTE")
assert p.title == "IT.Welcome.to.Derry"
assert p.season == 1
assert p.episode == 1
assert p.group == "ELiTE"
def test_landman_s02e01(self):
p = parse_release("Landman.S02E01.1080p.x265-ELiTE")
assert p.title == "Landman"
assert p.season == 2
assert p.episode == 1
def test_prodiges_episode_with_number_in_title(self):
# "Prodiges.S12E01.1ere.demi-finale..." — accented chars in episode title
p = parse_release("Prodiges.S12E01.1080p.WEB.H264-THESYNDiCATE")
assert p.title == "Prodiges"
assert p.season == 12
assert p.episode == 1
assert p.group == "THESYNDiCATE"
# ---------------------------------------------------------------------------
# parse_release — Multi-episode
# ---------------------------------------------------------------------------
class TestMultiEpisode:
def test_archer_triple_episode(self):
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
p = parse_release("Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb")
assert p.season == 14
assert p.episode == 9
assert p.episode_end == 10 # only first E-pair captured by regex group 2+3
# ---------------------------------------------------------------------------
# parse_release — Movies
# ---------------------------------------------------------------------------
class TestMovies:
def test_another_round_yts(self):
# "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
assert p.is_movie
assert p.title == "Another.Round"
assert p.year == 2020
assert p.quality == "1080p"
assert p.source == "BluRay"
assert p.group == "YTS"
def test_godzilla_minus_one(self):
p = parse_release("Godzilla.Minus.One.2023.1080p.BluRay.x265.10bit.AAC5.1-YTS")
assert p.title == "Godzilla.Minus.One"
assert p.year == 2023
assert p.is_movie
assert p.group == "YTS"
def test_deadwood_movie_2019(self):
p = parse_release("Deadwood.The.Movie.2019.1080p.BluRay.x265-RARBG")
assert p.year == 2019
assert p.is_movie
assert p.group == "RARBG"
def test_revolver_2005_bluray(self):
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
assert p.title == "Revolver"
assert p.year == 2005
assert p.is_movie
def test_the_xfiles_movie_1998(self):
p = parse_release("The.X.Files.1998.1080p.BluRay.x265-RARBG")
assert p.year == 1998
assert p.is_movie
assert p.group == "RARBG"
def test_movie_no_group(self):
p = parse_release("Jurassic.Park.1993.1080p.BluRay.x265")
assert p.is_movie
assert p.year == 1993
assert p.group == "UNKNOWN"
def test_multi_language_movie(self):
p = parse_release("Jumanji.1995.MULTi.1080p.DSNP.WEB.H265-THESYNDiCATE")
assert p.year == 1995
assert p.group == "THESYNDiCATE"
# ---------------------------------------------------------------------------
# parse_release — Space-separated (no dots)
# ---------------------------------------------------------------------------
class TestSpaceSeparated:
def test_oz_spaces(self):
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
assert p.title == "Oz"
assert p.season == 1
assert p.quality == "1080p"
assert p.group == "KONTRAST"
def test_archer_spaces(self):
p = parse_release("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
assert p.season == 14
assert p.episode == 9
assert p.group == "NTb"
# ---------------------------------------------------------------------------
# parse_release — tech_string
# ---------------------------------------------------------------------------
class TestTechString:
def test_full_tech(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
assert p.tech_string == "1080p.WEBRip.x265"
def test_tech_string_used_in_folder_name(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
folder = p.show_folder_name("Oz", 1997)
assert "1080p.WEBRip.x265" in folder
def test_no_tech_fallback(self):
p = parse_release("SomeShow.S01")
# tech_string is empty, show_folder_name uses "Unknown"
folder = p.show_folder_name("SomeShow", 2020)
assert "Unknown" in folder
def test_4k_hdr(self):
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY")
assert p.quality == "2160p"
# ---------------------------------------------------------------------------
# ParsedRelease — naming methods
# ---------------------------------------------------------------------------
class TestNamingMethods:
def test_show_folder_name(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
assert p.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
def test_show_folder_name_sanitises_title(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
# Colon in TMDB title should be stripped, spaces become dots
folder = p.show_folder_name("Star Wars: Andor", 2022)
assert ":" not in folder
assert "Star.Wars.Andor" in folder
def test_season_folder_name_from_season_pack(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
assert p.season_folder_name() == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
def test_season_folder_name_strips_episode(self):
p = parse_release("Fallout.2024.S02E01.1080p.x265-ELiTE")
assert p.season_folder_name() == "Fallout.2024.S02.1080p.x265-ELiTE"
def test_episode_filename_with_title(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
fname = p.episode_filename("The Routine", ".mkv")
assert fname == "Oz.S01.The.Routine.1080p.WEBRip.x265-KONTRAST.mkv"
def test_episode_filename_with_episode_number(self):
p = parse_release("Fallout.2024.S02E01.1080p.x265-ELiTE")
fname = p.episode_filename("The Beginning", ".mkv")
assert fname == "Fallout.S02E01.The.Beginning.1080p.x265-ELiTE.mkv"
def test_episode_filename_without_episode_title(self):
p = parse_release("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
fname = p.episode_filename(None, ".mp4")
assert fname == "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
def test_episode_filename_sanitises_episode_title(self):
p = parse_release("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
fname = p.episode_filename("What's Up?", ".mkv")
assert "?" not in fname
assert "What's.Up" in fname
def test_episode_filename_strips_leading_dot_from_ext(self):
p = parse_release("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
fname_with = p.episode_filename(None, ".mkv")
fname_without = p.episode_filename(None, "mkv")
assert fname_with == fname_without
def test_movie_folder_name(self):
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
assert p.movie_folder_name("Another Round", 2020) == "Another.Round.2020.1080p.BluRay.x264-YTS"
def test_movie_filename(self):
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
fname = p.movie_filename("Another Round", 2020, ".mp4")
assert fname == "Another.Round.2020.1080p.BluRay.x264-YTS.mp4"
def test_movie_folder_same_as_show_folder(self):
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name("Revolver", 2005)
# ---------------------------------------------------------------------------
# ParsedRelease — is_movie / is_season_pack
# ---------------------------------------------------------------------------
class TestMediaTypeFlags:
def test_season_pack_is_not_movie(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
assert not p.is_movie
assert p.is_season_pack
def test_single_episode_is_not_season_pack(self):
p = parse_release("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
assert not p.is_movie
assert not p.is_season_pack
def test_movie_is_not_season_pack(self):
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
assert p.is_movie
assert not p.is_season_pack
def test_no_season_no_year_treated_as_movie(self):
# No S/E marker → is_movie = True
p = parse_release("SomeContent.1080p.WEBRip.x265-GROUP")
assert p.is_movie
# ---------------------------------------------------------------------------
# Tricky real-world releases
# ---------------------------------------------------------------------------
class TestRealWorldEdgeCases:
def test_angel_integrale_multi(self):
# "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
p = parse_release("Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod")
assert p.year == 1999
assert p.quality == "1080p"
assert p.source == "WEBRip"
def test_group_unknown_when_no_dash(self):
p = parse_release("Oz.S01.1080p.WEBRip.x265")
assert p.group == "UNKNOWN"
def test_normalised_stored_on_parsed(self):
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
assert p.normalised == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
def test_raw_stored_as_is(self):
raw = "Oz S01 1080p WEBRip x265-KONTRAST"
p = parse_release(raw)
assert p.raw == raw
def test_hevc_codec(self):
# "Mare.of.Easttown.S01.1080p.10bit.WEBRip.6CH.x265.HEVC-PSA"
p = parse_release("Mare.of.Easttown.S01.1080p.10bit.WEBRip.6CH.x265.HEVC-PSA")
assert p.codec in ("x265", "HEVC")
assert p.group == "PSA"
def test_xfiles_hyphen_in_title(self):
p = parse_release("The.X-Files.S01.1080p.BluRay.x265-RARBG")
# Title should preserve the hyphen
assert "X-Files" in p.title
def test_foundation_s02_no_year(self):
# Foundation.S02 has no year in release name — year is None
p = parse_release("Foundation.S02.1080p.x265-ELiTE")
assert p.year is None
assert p.season == 2
assert p.group == "ELiTE"
def test_slow_horses_two_groups_same_show(self):
# Same show, different groups across seasons
s01 = parse_release("Slow.Horses.S01.1080p.WEBRip.x265-RARBG")
s04 = parse_release("Slow.Horses.S04.1080p.WEBRip.x265-KONTRAST")
assert s01.title == s04.title == "Slow.Horses"
assert s01.group == "RARBG"
assert s04.group == "KONTRAST"
+136
View File
@@ -0,0 +1,136 @@
"""Tests for shared domain value objects: ImdbId, FilePath, FileSize."""
import pytest
from pathlib import Path
from alfred.domain.shared.exceptions import ValidationError
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId
# ---------------------------------------------------------------------------
# ImdbId
# ---------------------------------------------------------------------------
class TestImdbId:
def test_valid_7_digits(self):
id_ = ImdbId("tt1375666")
assert str(id_) == "tt1375666"
def test_valid_8_digits(self):
id_ = ImdbId("tt12345678")
assert str(id_) == "tt12345678"
def test_empty_raises(self):
with pytest.raises(ValidationError):
ImdbId("")
def test_no_tt_prefix_raises(self):
with pytest.raises(ValidationError):
ImdbId("1375666")
def test_too_few_digits_raises(self):
with pytest.raises(ValidationError):
ImdbId("tt12345") # only 5 digits
def test_too_many_digits_raises(self):
with pytest.raises(ValidationError):
ImdbId("tt123456789") # 9 digits
def test_non_string_raises(self):
with pytest.raises(ValidationError):
ImdbId(1375666) # type: ignore
def test_repr(self):
assert "tt1375666" in repr(ImdbId("tt1375666"))
def test_equality(self):
assert ImdbId("tt1375666") == ImdbId("tt1375666")
assert ImdbId("tt1375666") != ImdbId("tt0903747")
def test_hashable(self):
# Frozen dataclass should be hashable
ids = {ImdbId("tt1375666"), ImdbId("tt0903747")}
assert len(ids) == 2
# ---------------------------------------------------------------------------
# FilePath
# ---------------------------------------------------------------------------
class TestFilePath:
def test_from_string(self, tmp_path):
p = FilePath(str(tmp_path))
assert isinstance(p.value, Path)
def test_from_path(self, tmp_path):
p = FilePath(tmp_path)
assert p.value == tmp_path
def test_invalid_type_raises(self):
with pytest.raises(ValidationError):
FilePath(123) # type: ignore
def test_exists_true(self, tmp_path):
p = FilePath(tmp_path)
assert p.exists()
def test_exists_false(self, tmp_path):
p = FilePath(tmp_path / "nonexistent")
assert not p.exists()
def test_is_file(self, tmp_path):
f = tmp_path / "file.txt"
f.write_text("x")
assert FilePath(f).is_file()
assert not FilePath(tmp_path).is_file()
def test_is_dir(self, tmp_path):
assert FilePath(tmp_path).is_dir()
def test_str(self, tmp_path):
p = FilePath(tmp_path)
assert str(p) == str(tmp_path)
# ---------------------------------------------------------------------------
# FileSize
# ---------------------------------------------------------------------------
class TestFileSize:
def test_bytes(self):
s = FileSize(500)
assert s.bytes == 500
def test_negative_raises(self):
with pytest.raises(ValidationError):
FileSize(-1)
def test_non_integer_raises(self):
with pytest.raises(ValidationError):
FileSize(1.5) # type: ignore
def test_zero_is_valid(self):
s = FileSize(0)
assert s.bytes == 0
def test_human_readable_bytes(self):
assert FileSize(500).to_human_readable() == "500 B"
def test_human_readable_kb(self):
result = FileSize(2048).to_human_readable()
assert "KB" in result
def test_human_readable_mb(self):
result = FileSize(5 * 1024 * 1024).to_human_readable()
assert "MB" in result
def test_human_readable_gb(self):
result = FileSize(2 * 1024 ** 3).to_human_readable()
assert "GB" in result
def test_str_is_human_readable(self):
s = FileSize(1024)
assert str(s) == s.to_human_readable()
+217
View File
@@ -0,0 +1,217 @@
"""Tests for SubtitleScanner and _classify helper."""
import pytest
from pathlib import Path
from alfred.domain.subtitles.scanner import (
SubtitleCandidate,
SubtitleScanner,
_classify,
)
# ---------------------------------------------------------------------------
# _classify — unit tests for the filename parser
# ---------------------------------------------------------------------------
class TestClassify:
def test_iso_lang_code(self, tmp_path):
p = tmp_path / "fr.srt"
p.write_text("")
lang, is_sdh, is_forced = _classify(p)
assert lang == "fr"
assert not is_sdh
assert not is_forced
def test_english_keyword(self, tmp_path):
p = tmp_path / "english.srt"
p.write_text("")
lang, _, _ = _classify(p)
assert lang == "en"
def test_french_keyword(self, tmp_path):
p = tmp_path / "Show.S01E01.French.srt"
p.write_text("")
lang, _, _ = _classify(p)
assert lang == "fr"
def test_vostfr_is_french(self, tmp_path):
p = tmp_path / "Show.S01E01.VOSTFR.srt"
p.write_text("")
lang, _, _ = _classify(p)
assert lang == "fr"
def test_sdh_token(self, tmp_path):
p = tmp_path / "fr.sdh.srt"
p.write_text("")
lang, is_sdh, _ = _classify(p)
assert lang == "fr"
assert is_sdh
def test_hi_alias_for_sdh(self, tmp_path):
p = tmp_path / "en.hi.srt"
p.write_text("")
_, is_sdh, _ = _classify(p)
assert is_sdh
def test_forced_token(self, tmp_path):
p = tmp_path / "fr.forced.srt"
p.write_text("")
_, _, is_forced = _classify(p)
assert is_forced
def test_unknown_language_returns_none(self, tmp_path):
p = tmp_path / "Show.S01E01.720p.srt"
p.write_text("")
lang, _, _ = _classify(p)
assert lang is None
def test_dot_separator(self, tmp_path):
p = tmp_path / "fr.sdh.srt"
p.write_text("")
lang, is_sdh, _ = _classify(p)
assert lang == "fr"
assert is_sdh
def test_hyphen_separator(self, tmp_path):
p = tmp_path / "fr-forced.srt"
p.write_text("")
lang, _, is_forced = _classify(p)
assert lang == "fr"
assert is_forced
# ---------------------------------------------------------------------------
# SubtitleCandidate.destination_name
# ---------------------------------------------------------------------------
class TestSubtitleCandidateDestinationName:
def _make(self, lang="fr", is_sdh=False, is_forced=False, ext=".srt", path=None):
return SubtitleCandidate(
source_path=path or Path("/fake/fr.srt"),
language=lang,
is_sdh=is_sdh,
is_forced=is_forced,
extension=ext,
)
def test_standard(self):
assert self._make().destination_name == "fr.srt"
def test_sdh(self):
assert self._make(is_sdh=True).destination_name == "fr.sdh.srt"
def test_forced(self):
assert self._make(is_forced=True).destination_name == "fr.forced.srt"
def test_ass_extension(self):
assert self._make(ext=".ass").destination_name == "fr.ass"
def test_english_standard(self):
assert self._make(lang="en").destination_name == "en.srt"
# ---------------------------------------------------------------------------
# SubtitleScanner — integration with real filesystem
# ---------------------------------------------------------------------------
class TestSubtitleScanner:
def _scanner(self, languages=None, min_size_kb=0, keep_sdh=True, keep_forced=True):
return SubtitleScanner(
languages=languages or ["fr", "en"],
min_size_kb=min_size_kb,
keep_sdh=keep_sdh,
keep_forced=keep_forced,
)
def _video(self, tmp_path):
video = tmp_path / "Movie.mkv"
video.write_bytes(b"video")
return video
def test_finds_adjacent_subtitle(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.srt").write_text("subtitle content")
candidates = self._scanner().scan(video)
assert len(candidates) == 1
assert candidates[0].language == "fr"
def test_finds_multiple_languages(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.srt").write_text("fr subtitle")
(tmp_path / "en.srt").write_text("en subtitle")
candidates = self._scanner().scan(video)
langs = {c.language for c in candidates}
assert langs == {"fr", "en"}
def test_scans_subs_subfolder(self, tmp_path):
video = self._video(tmp_path)
subs = tmp_path / "Subs"
subs.mkdir()
(subs / "fr.srt").write_text("subtitle")
candidates = self._scanner().scan(video)
assert any(c.language == "fr" for c in candidates)
def test_filters_unknown_language(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "unknown.srt").write_text("subtitle")
candidates = self._scanner().scan(video)
assert len(candidates) == 0
def test_filters_wrong_language(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "de.srt").write_text("german subtitle")
candidates = self._scanner(languages=["fr"]).scan(video)
assert len(candidates) == 0
def test_filters_too_small_file(self, tmp_path):
video = self._video(tmp_path)
small = tmp_path / "fr.srt"
small.write_bytes(b"x") # 1 byte, well below any min_size_kb
candidates = self._scanner(min_size_kb=10).scan(video)
assert len(candidates) == 0
def test_filters_sdh_when_not_wanted(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.sdh.srt").write_text("sdh subtitle")
candidates = self._scanner(keep_sdh=False).scan(video)
assert len(candidates) == 0
def test_filters_forced_when_not_wanted(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.forced.srt").write_text("forced subtitle")
candidates = self._scanner(keep_forced=False).scan(video)
assert len(candidates) == 0
def test_keeps_sdh_when_wanted(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.sdh.srt").write_text("sdh subtitle")
candidates = self._scanner(keep_sdh=True).scan(video)
assert len(candidates) == 1
assert candidates[0].is_sdh
def test_ignores_non_subtitle_files(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.nfo").write_text("nfo file")
(tmp_path / "fr.jpg").write_bytes(b"image")
candidates = self._scanner().scan(video)
assert len(candidates) == 0
def test_returns_empty_when_no_subtitles(self, tmp_path):
video = self._video(tmp_path)
candidates = self._scanner().scan(video)
assert candidates == []
+223
View File
@@ -0,0 +1,223 @@
"""Tests for TV Show domain — entities and value objects."""
import pytest
from alfred.domain.shared.exceptions import ValidationError
from alfred.domain.tv_shows.entities import Episode, Season, TVShow
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus
# ---------------------------------------------------------------------------
# ShowStatus
# ---------------------------------------------------------------------------
class TestShowStatus:
def test_from_string_ongoing(self):
assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING
def test_from_string_ended(self):
assert ShowStatus.from_string("ended") == ShowStatus.ENDED
def test_from_string_case_insensitive(self):
assert ShowStatus.from_string("ONGOING") == ShowStatus.ONGOING
assert ShowStatus.from_string("Ended") == ShowStatus.ENDED
def test_from_string_unknown(self):
assert ShowStatus.from_string("cancelled") == ShowStatus.UNKNOWN
assert ShowStatus.from_string("") == ShowStatus.UNKNOWN
# ---------------------------------------------------------------------------
# SeasonNumber
# ---------------------------------------------------------------------------
class TestSeasonNumber:
def test_valid_season(self):
s = SeasonNumber(1)
assert s.value == 1
def test_season_zero_is_specials(self):
s = SeasonNumber(0)
assert s.is_special()
def test_normal_season_not_special(self):
assert not SeasonNumber(3).is_special()
def test_negative_raises(self):
with pytest.raises(ValidationError):
SeasonNumber(-1)
def test_too_high_raises(self):
with pytest.raises(ValidationError):
SeasonNumber(101)
def test_non_integer_raises(self):
with pytest.raises((ValidationError, TypeError)):
SeasonNumber("1") # type: ignore
def test_str_and_int(self):
s = SeasonNumber(5)
assert str(s) == "5"
assert int(s) == 5
# ---------------------------------------------------------------------------
# EpisodeNumber
# ---------------------------------------------------------------------------
class TestEpisodeNumber:
def test_valid_episode(self):
e = EpisodeNumber(1)
assert e.value == 1
def test_zero_raises(self):
with pytest.raises(ValidationError):
EpisodeNumber(0)
def test_negative_raises(self):
with pytest.raises(ValidationError):
EpisodeNumber(-5)
def test_too_high_raises(self):
with pytest.raises(ValidationError):
EpisodeNumber(1001)
def test_str_and_int(self):
e = EpisodeNumber(12)
assert str(e) == "12"
assert int(e) == 12
# ---------------------------------------------------------------------------
# TVShow entity
# ---------------------------------------------------------------------------
class TestTVShow:
def _make(self, imdb_id="tt0903747", title="Breaking Bad", seasons=5, status="ended"):
return TVShow(imdb_id=imdb_id, title=title, seasons_count=seasons, status=status)
def test_basic_creation(self):
show = self._make()
assert show.title == "Breaking Bad"
assert show.seasons_count == 5
def test_coerces_string_imdb_id(self):
show = self._make()
from alfred.domain.shared.value_objects import ImdbId
assert isinstance(show.imdb_id, ImdbId)
def test_coerces_string_status(self):
show = self._make(status="ongoing")
assert show.status == ShowStatus.ONGOING
def test_is_ongoing(self):
show = self._make(status="ongoing")
assert show.is_ongoing()
assert not show.is_ended()
def test_is_ended(self):
show = self._make(status="ended")
assert show.is_ended()
assert not show.is_ongoing()
def test_negative_seasons_raises(self):
with pytest.raises(ValueError):
TVShow(imdb_id="tt0903747", title="X", seasons_count=-1, status="ended")
def test_invalid_imdb_id_type_raises(self):
with pytest.raises(ValueError):
TVShow(imdb_id=12345, title="X", seasons_count=1, status="ended") # type: ignore
def test_get_folder_name_replaces_spaces(self):
show = self._make(title="Breaking Bad")
assert show.get_folder_name() == "Breaking.Bad"
def test_get_folder_name_strips_special_chars(self):
show = self._make(title="It's Always Sunny")
name = show.get_folder_name()
assert "'" not in name
def test_str_repr(self):
show = self._make()
assert "Breaking Bad" in str(show)
assert "tt0903747" in repr(show)
# ---------------------------------------------------------------------------
# Season entity
# ---------------------------------------------------------------------------
class TestSeason:
def test_basic_creation(self):
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7)
assert s.episode_count == 7
def test_get_folder_name_normal(self):
s = Season(show_imdb_id="tt0903747", season_number=2, episode_count=13)
assert s.get_folder_name() == "Season 02"
def test_get_folder_name_specials(self):
s = Season(show_imdb_id="tt0903747", season_number=0, episode_count=3)
assert s.get_folder_name() == "Specials"
assert s.is_special()
def test_negative_episode_count_raises(self):
with pytest.raises(ValueError):
Season(show_imdb_id="tt0903747", season_number=1, episode_count=-1)
def test_str(self):
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7, name="Pilot Season")
assert "Pilot Season" in str(s)
# ---------------------------------------------------------------------------
# Episode entity
# ---------------------------------------------------------------------------
class TestEpisode:
def test_basic_creation(self):
e = Episode(
show_imdb_id="tt0903747",
season_number=1,
episode_number=1,
title="Pilot",
)
assert e.title == "Pilot"
def test_get_filename_format(self):
e = Episode(
show_imdb_id="tt0903747",
season_number=1,
episode_number=5,
title="Gray Matter",
)
filename = e.get_filename()
assert filename.startswith("S01E05")
assert "Gray.Matter" in filename
def test_has_file_false_when_no_path(self):
e = Episode(
show_imdb_id="tt0903747",
season_number=1,
episode_number=1,
title="Pilot",
)
assert not e.has_file()
assert not e.is_downloaded()
def test_str_format(self):
e = Episode(
show_imdb_id="tt0903747",
season_number=2,
episode_number=3,
title="Bit by a Dead Bee",
)
s = str(e)
assert "S02E03" in s
assert "Bit by a Dead Bee" in s