mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
Renames 314 defs and 41 module/class-level constants. Keeps 11 names underscored where the stripped form collides in scope, plus 5.5.0 shims for the three symbols external service repos call.
200 lines
6.8 KiB
Python
200 lines
6.8 KiB
Python
"""Tests for multi-part episodes: Episode.part, its naming, sorting and -w matching."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from unshackle.core.config import config
|
|
from unshackle.core.titles.episode import Episode, Series
|
|
from unshackle.core.utils.click_types import SeasonRange
|
|
|
|
|
|
class DummyService:
|
|
pass
|
|
|
|
|
|
class StubMediaInfo:
|
|
"""Minimal MediaInfo stand-in: the template context only reads track lists."""
|
|
|
|
video_tracks: list = []
|
|
audio_tracks: list = []
|
|
|
|
|
|
def make_episode(**overrides) -> Episode:
|
|
kwargs = dict(
|
|
id_="ep-0001",
|
|
service=DummyService,
|
|
title="The Show",
|
|
season=1,
|
|
number=1,
|
|
name="The Reckoning",
|
|
)
|
|
kwargs.update(overrides)
|
|
return Episode(**kwargs)
|
|
|
|
|
|
@pytest.fixture
|
|
def reset_template():
|
|
saved = (config.output_template, config.folder_templates, config.folder_template, config.tag)
|
|
config.output_template = {"series": "{title}.{season_episode}.{quality?}.{source}-{tag}"}
|
|
config.folder_templates = {}
|
|
config.folder_template = ""
|
|
config.tag = "GROUP"
|
|
yield config
|
|
config.output_template, config.folder_templates, config.folder_template, config.tag = saved
|
|
|
|
|
|
# --- validation -------------------------------------------------------------
|
|
|
|
|
|
def test_part_defaults_to_none():
|
|
assert make_episode().part is None
|
|
|
|
|
|
def test_digit_string_part_coerces():
|
|
assert make_episode(part="2").part == 2
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [0, -1])
|
|
def test_non_positive_part_rejected(bad):
|
|
with pytest.raises(ValueError):
|
|
make_episode(part=bad)
|
|
|
|
|
|
def test_non_int_part_rejected():
|
|
with pytest.raises(TypeError):
|
|
make_episode(part="x")
|
|
|
|
|
|
def test_bool_part_rejected():
|
|
"""bool is an int subclass; True would render as '.True' in keys and filenames."""
|
|
with pytest.raises(TypeError):
|
|
make_episode(part=True)
|
|
|
|
|
|
def test_old_cache_entry_reads_as_part_less():
|
|
"""jsonpickle restores by populating __dict__; an older entry has no 'part' key."""
|
|
restored = Episode.__new__(Episode)
|
|
restored.__dict__.update(title="The Show", season=1, number=1, name=None, year=None)
|
|
assert restored.part is None
|
|
|
|
|
|
# --- display ----------------------------------------------------------------
|
|
|
|
|
|
def test_str_is_unchanged_without_a_part():
|
|
without = make_episode()
|
|
assert str(without) == "The Show S01E01 The Reckoning"
|
|
|
|
|
|
def test_str_shows_the_selection_syntax():
|
|
assert str(make_episode(part=2)) == "The Show S01E01.2 The Reckoning"
|
|
|
|
|
|
def test_str_tells_dated_parts_apart():
|
|
# dated content renders the air date instead of SxxExx, so without the suffix both
|
|
# parts print the same string while their filenames differ
|
|
assert str(make_episode(part=2, air_date="2024-05-06")) == "The Show 2024.05.06.2 The Reckoning"
|
|
assert str(make_episode(air_date="2024-05-06")) == "The Show 2024.05.06 The Reckoning"
|
|
|
|
|
|
def test_tree_label_carries_the_part_on_dated_content():
|
|
series = Series([make_episode(part=1, air_date="2024-05-06"), make_episode(part=2, air_date="2024-05-06")])
|
|
labels = [node.label for node in series.tree(verbose=True).children[0].children]
|
|
assert labels == [
|
|
"[bold]2024.05.06.1.[/] [bright_black]The Reckoning",
|
|
"[bold]2024.05.06.2.[/] [bright_black]The Reckoning",
|
|
]
|
|
|
|
|
|
def test_tree_label_carries_the_part():
|
|
series = Series([make_episode(part=1), make_episode(part=2)])
|
|
labels = [node.label for node in series.tree(verbose=True).children[0].children]
|
|
assert labels == ["[bold]1.1.[/] [bright_black]The Reckoning", "[bold]1.2.[/] [bright_black]The Reckoning"]
|
|
|
|
|
|
def test_series_sorts_parts_within_their_episode():
|
|
series = Series(
|
|
[
|
|
make_episode(number=2),
|
|
make_episode(number=1, part=2),
|
|
make_episode(number=1),
|
|
make_episode(number=1, part=1),
|
|
]
|
|
)
|
|
assert [(e.number, e.part) for e in series] == [(1, None), (1, 1), (1, 2), (2, None)]
|
|
|
|
|
|
# --- naming -----------------------------------------------------------------
|
|
|
|
|
|
def test_filename_is_unchanged_without_a_part(reset_template):
|
|
assert make_episode().get_filename(StubMediaInfo()) == "The.Show.S01E01.DummyService-GROUP"
|
|
|
|
|
|
def test_dotted_template_folds_the_part_after_the_episode(reset_template):
|
|
name = make_episode(part=2).get_filename(StubMediaInfo())
|
|
assert name == "The.Show.S01E01.Part.2.DummyService-GROUP"
|
|
assert name.endswith("-GROUP") # group tag stays last
|
|
|
|
|
|
def test_spaced_template_uses_spaces(reset_template):
|
|
reset_template.output_template = {"series": "{title} {season_episode} {quality?} {source}-{tag}"}
|
|
assert make_episode(part=2).get_filename(StubMediaInfo()) == "The Show S01E01 Part 2 DummyService-GROUP"
|
|
|
|
|
|
def test_template_context_leaves_the_season_token_alone(reset_template):
|
|
ctx = make_episode(part=2).build_template_context(StubMediaInfo())
|
|
assert ctx["season"] == "S01"
|
|
assert ctx["episode"] == "E01.Part.2"
|
|
assert ctx["season_episode"] == "S01E01.Part.2"
|
|
assert ctx["part"] == 2
|
|
|
|
|
|
def test_part_token_is_empty_without_a_part(reset_template):
|
|
assert make_episode().build_template_context(StubMediaInfo())["part"] == ""
|
|
|
|
|
|
def test_parts_share_one_season_folder(reset_template):
|
|
# {season_episode} in a folder template would otherwise carry the part into the path
|
|
reset_template.folder_template = "{title}/{season_episode}"
|
|
folders = {make_episode(part=p).get_filename(StubMediaInfo(), folder=True) for p in (1, 2)}
|
|
assert folders == {"The.Show/S01E01"}
|
|
|
|
|
|
def test_parts_share_one_derived_season_folder(reset_template):
|
|
folders = {make_episode(part=p).get_filename(StubMediaInfo(), folder=True) for p in (1, 2)}
|
|
assert len(folders) == 1
|
|
assert folders == {make_episode().get_filename(StubMediaInfo(), folder=True)}
|
|
|
|
|
|
# --- matching ---------------------------------------------------------------
|
|
|
|
# the worked key scheme from the design: (token, part-less E1 selected, E1 part 2 selected)
|
|
MATCH_TABLE = [
|
|
(("S01",), True, True),
|
|
(("S01E01",), True, True),
|
|
(("S01E01.2",), False, True),
|
|
(("S01E01.1-S01E01.3",), False, True),
|
|
(("S01", "-S01E01.2"), True, False),
|
|
(("S01", "-S01E01"), False, False),
|
|
(("S01E02",), False, False),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("tokens,wants_plain,wants_part2", MATCH_TABLE)
|
|
def test_matches_wanted_table(tokens, wants_plain, wants_part2):
|
|
wanted = SeasonRange().parse_tokens(*tokens)
|
|
assert make_episode().matches_wanted(wanted) is wants_plain
|
|
assert make_episode(part=2).matches_wanted(wanted) is wants_part2
|
|
|
|
|
|
def test_a_part_exclusion_leaves_the_other_parts_alone():
|
|
wanted = SeasonRange().parse_tokens("S01", "-S01E01.2")
|
|
assert make_episode(part=1).matches_wanted(wanted) is True
|
|
assert make_episode(part=3).matches_wanted(wanted) is True
|
|
|
|
|
|
def test_asking_for_a_part_of_an_unsplit_episode_selects_nothing():
|
|
assert make_episode().matches_wanted(SeasonRange().parse_tokens("S01E01.2")) is False
|