feat: implement episode range parsing and enhance search functionality with improved filtering options

This commit is contained in:
Benexl
2025-07-24 21:19:18 +03:00
parent ae3a59d116
commit 67a174158d
9 changed files with 585 additions and 114 deletions

View File

@@ -6,7 +6,7 @@ from . import examples
commands = {
# "trending": "trending.trending",
# "recent": "recent.recent",
# "search": "search.search",
"search": "search.search",
# "download": "download.download",
# "downloads": "downloads.downloads",
"auth": "auth.auth",

View File

@@ -2,25 +2,56 @@ from typing import TYPE_CHECKING
import click
from fastanime.cli.utils.completion import anime_titles_shell_complete
from .data import (
genres_available,
media_formats_available,
media_statuses_available,
seasons_available,
sorts_available,
tags_available_list,
years_available,
from .....core.config import AppConfig
from .....core.exceptions import FastAnimeError
from .....libs.media_api.types import (
MediaFormat,
MediaGenre,
MediaSeason,
MediaSort,
MediaStatus,
MediaTag,
MediaType,
MediaYear,
)
from ....utils.completion import anime_titles_shell_complete
from .. import examples
if TYPE_CHECKING:
from fastanime.core.config import AppConfig
from typing import TypedDict
from typing_extensions import Unpack
class SearchOptions(TypedDict, total=False):
title: str | None
dump_json: bool
page: int
per_page: int | None
season: str | None
status: tuple[str, ...]
status_not: tuple[str, ...]
sort: str | None
genres: tuple[str, ...]
genres_not: tuple[str, ...]
tags: tuple[str, ...]
tags_not: tuple[str, ...]
media_format: tuple[str, ...]
media_type: str | None
year: str | None
popularity_greater: int | None
popularity_lesser: int | None
score_greater: int | None
score_lesser: int | None
start_date_greater: int | None
start_date_lesser: int | None
end_date_greater: int | None
end_date_lesser: int | None
on_list: bool | None
@click.command(
help="Search for anime using anilists api and get top ~50 results",
short_help="Search for anime",
epilog=examples.search,
)
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
@click.option(
@@ -29,51 +60,126 @@ if TYPE_CHECKING:
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.option(
"--page",
"-p",
type=click.IntRange(min=1),
default=1,
help="Page number for pagination",
)
@click.option(
"--per-page",
type=click.IntRange(min=1, max=50),
help="Number of results per page (max 50)",
)
@click.option(
"--season",
help="The season the media was released",
type=click.Choice(seasons_available),
type=click.Choice([season.value for season in MediaSeason]),
)
@click.option(
"--status",
"-S",
help="The media status of the anime",
multiple=True,
type=click.Choice(media_statuses_available),
type=click.Choice([status.value for status in MediaStatus]),
)
@click.option(
"--status-not",
help="Exclude media with these statuses",
multiple=True,
type=click.Choice([status.value for status in MediaStatus]),
)
@click.option(
"--sort",
"-s",
help="What to sort the search results on",
type=click.Choice(sorts_available),
type=click.Choice([sort.value for sort in MediaSort]),
)
@click.option(
"--genres",
"-g",
multiple=True,
help="the genres to filter by",
type=click.Choice(genres_available),
type=click.Choice([genre.value for genre in MediaGenre]),
)
@click.option(
"--genres-not",
multiple=True,
help="Exclude these genres",
type=click.Choice([genre.value for genre in MediaGenre]),
)
@click.option(
"--tags",
"-T",
multiple=True,
help="the tags to filter by",
type=click.Choice(tags_available_list),
type=click.Choice([tag.value for tag in MediaTag]),
)
@click.option(
"--tags-not",
multiple=True,
help="Exclude these tags",
type=click.Choice([tag.value for tag in MediaTag]),
)
@click.option(
"--media-format",
"-f",
multiple=True,
help="Media format",
type=click.Choice(media_formats_available),
type=click.Choice([format.value for format in MediaFormat]),
)
@click.option(
"--media-type",
help="Media type (ANIME or MANGA)",
type=click.Choice([media_type.value for media_type in MediaType]),
)
@click.option(
"--year",
"-y",
type=click.Choice(years_available),
type=click.Choice([year.value for year in MediaYear]),
help="the year the media was released",
)
@click.option(
"--popularity-greater",
type=click.IntRange(min=0),
help="Minimum popularity score",
)
@click.option(
"--popularity-lesser",
type=click.IntRange(min=0),
help="Maximum popularity score",
)
@click.option(
"--score-greater",
type=click.IntRange(min=0, max=100),
help="Minimum average score (0-100)",
)
@click.option(
"--score-lesser",
type=click.IntRange(min=0, max=100),
help="Maximum average score (0-100)",
)
@click.option(
"--start-date-greater",
type=click.IntRange(min=10000101, max=99991231),
help="Minimum start date (YYYYMMDD format, e.g., 20240101)",
)
@click.option(
"--start-date-lesser",
type=click.IntRange(min=10000101, max=99991231),
help="Maximum start date (YYYYMMDD format, e.g., 20241231)",
)
@click.option(
"--end-date-greater",
type=click.IntRange(min=10000101, max=99991231),
help="Minimum end date (YYYYMMDD format, e.g., 20240101)",
)
@click.option(
"--end-date-lesser",
type=click.IntRange(min=10000101, max=99991231),
help="Maximum end date (YYYYMMDD format, e.g., 20241231)",
)
@click.option(
"--on-list/--not-on-list",
"-L/-no-L",
@@ -81,45 +187,84 @@ if TYPE_CHECKING:
type=bool,
)
@click.pass_obj
def search(
config: "AppConfig",
title: str,
dump_json: bool,
season: str,
status: tuple,
sort: str,
genres: tuple,
tags: tuple,
media_format: tuple,
year: str,
on_list: bool,
):
def search(config: AppConfig, **options: "Unpack[SearchOptions]"):
import json
from rich.progress import Progress
from fastanime.cli.utils.feedback import create_feedback_manager
from fastanime.core.exceptions import FastAnimeError
from fastanime.libs.media_api.api import create_api_client
from fastanime.libs.media_api.params import MediaSearchParams
from .....libs.media_api.api import create_api_client
from .....libs.media_api.params import MediaSearchParams
from ....service.feedback import FeedbackService
feedback = create_feedback_manager(config.general.icons)
feedback = FeedbackService(config.general.icons)
try:
# Create API client
api_client = create_api_client(config.general.media_api, config)
# Extract options
title = options.get("title")
dump_json = options.get("dump_json", False)
page = options.get("page", 1)
per_page = options.get("per_page") or config.anilist.per_page or 50
season = options.get("season")
status = options.get("status", ())
status_not = options.get("status_not", ())
sort = options.get("sort")
genres = options.get("genres", ())
genres_not = options.get("genres_not", ())
tags = options.get("tags", ())
tags_not = options.get("tags_not", ())
media_format = options.get("media_format", ())
media_type = options.get("media_type")
year = options.get("year")
popularity_greater = options.get("popularity_greater")
popularity_lesser = options.get("popularity_lesser")
score_greater = options.get("score_greater")
score_lesser = options.get("score_lesser")
start_date_greater = options.get("start_date_greater")
start_date_lesser = options.get("start_date_lesser")
end_date_greater = options.get("end_date_greater")
end_date_lesser = options.get("end_date_lesser")
on_list = options.get("on_list")
# Validate logical relationships
if score_greater is not None and score_lesser is not None and score_greater > score_lesser:
raise FastAnimeError("Minimum score cannot be higher than maximum score")
if popularity_greater is not None and popularity_lesser is not None and popularity_greater > popularity_lesser:
raise FastAnimeError("Minimum popularity cannot be higher than maximum popularity")
if start_date_greater is not None and start_date_lesser is not None and start_date_greater > start_date_lesser:
raise FastAnimeError("Start date greater cannot be later than start date lesser")
if end_date_greater is not None and end_date_lesser is not None and end_date_greater > end_date_lesser:
raise FastAnimeError("End date greater cannot be later than end date lesser")
# Build search parameters
search_params = MediaSearchParams(
query=title,
per_page=config.anilist.per_page or 50,
sort=[sort] if sort else None,
status_in=list(status) if status else None,
genre_in=list(genres) if genres else None,
tag_in=list(tags) if tags else None,
format_in=list(media_format) if media_format else None,
season=season,
page=page,
per_page=per_page,
sort=MediaSort(sort) if sort else None,
status_in=[MediaStatus(s) for s in status] if status else None,
status_not_in=[MediaStatus(s) for s in status_not] if status_not else None,
genre_in=[MediaGenre(g) for g in genres] if genres else None,
genre_not_in=[MediaGenre(g) for g in genres_not] if genres_not else None,
tag_in=[MediaTag(t) for t in tags] if tags else None,
tag_not_in=[MediaTag(t) for t in tags_not] if tags_not else None,
format_in=[MediaFormat(f) for f in media_format] if media_format else None,
type=MediaType(media_type) if media_type else None,
season=MediaSeason(season) if season else None,
seasonYear=int(year) if year else None,
popularity_greater=popularity_greater,
popularity_lesser=popularity_lesser,
averageScore_greater=score_greater,
averageScore_lesser=score_lesser,
startDate_greater=start_date_greater,
startDate_lesser=start_date_lesser,
endDate_greater=end_date_greater,
endDate_lesser=end_date_lesser,
on_list=on_list,
)
@@ -133,16 +278,30 @@ def search(
if dump_json:
# Use Pydantic's built-in serialization
print(json.dumps(search_result.model_dump(), indent=2))
print(json.dumps(search_result.model_dump(mode="json")))
else:
# Launch interactive session for browsing results
from fastanime.cli.interactive.session import session
from ....interactive.session import session
from ....interactive.state import MediaApiState, MenuName, State
feedback.info(
f"Found {len(search_result.media)} anime matching your search. Launching interactive mode..."
)
session.load_menus_from_folder()
session.run(config)
# Create initial state with search results
initial_state = State(
menu_name=MenuName.RESULTS,
media_api=MediaApiState(
search_result={
media_item.id: media_item for media_item in search_result.media
},
search_params=search_params,
page_info=search_result.page_info,
),
)
session.load_menus_from_folder("media")
session.run(config, history=[initial_state])
except FastAnimeError as e:
feedback.error("Search failed", str(e))

View File

@@ -1,27 +1,108 @@
search = """
\b
\b\bExamples:
# Basic search by title
fastanime anilist search -t "Attack on Titan"
\b
# Search with multiple filters
fastanime anilist search -g Action -T Isekai --score-greater 75 --status RELEASING
\b
# Get anime with the tag of isekai
fastanime anilist search -T isekai
\b
# Get anime of 2024 and sort by popularity, finished or releasing, not in your list
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
\b
# Get anime of 2024 season WINTER
fastanime anilist search -y 2024 --season WINTER
\b
# Get anime genre action and tag isekai,magic
fastanime anilist search -g Action -T Isekai -T Magic
\b
# Get anime of 2024 thats finished airing
fastanime anilist search -y 2024 -S FINISHED
\b
# Get the most favourite anime movies
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
\b
# Search with score and popularity filters
fastanime anilist search --score-greater 80 --popularity-greater 50000
\b
# Search excluding certain genres and tags
fastanime anilist search --genres-not Ecchi --tags-not "Hentai"
\b
# Search with date ranges (YYYYMMDD format)
fastanime anilist search --start-date-greater 20200101 --start-date-lesser 20241231
\b
# Get only TV series, exclude certain statuses
fastanime anilist search -f TV --status-not CANCELLED --status-not HIATUS
\b
# Paginated search with custom page size
fastanime anilist search -g Action --page 2 --per-page 25
\b
# Search for manga specifically
fastanime anilist search --media-type MANGA -g Fantasy
\b
# Complex search with multiple criteria
fastanime anilist search -t "demon" -g Action -g Supernatural --score-greater 70 --year 2020 -s SCORE_DESC
\b
# Dump search results as JSON instead of interactive mode
fastanime anilist search -g Action --dump-json
"""
main = """
\b
\b\bExamples:
# ---- search ----
\b
# get anime with the tag of isekai
# Basic search by title
fastanime anilist search -t "Attack on Titan"
\b
# Search with multiple filters
fastanime anilist search -g Action -T Isekai --score-greater 75 --status RELEASING
\b
# Get anime with the tag of isekai
fastanime anilist search -T isekai
\b
# get anime of 2024 and sort by popularity
# that has already finished airing or is releasing
# and is not in your anime lists
# Get anime of 2024 and sort by popularity, finished or releasing, not in your list
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
\b
# get anime of 2024 season WINTER
# Get anime of 2024 season WINTER
fastanime anilist search -y 2024 --season WINTER
\b
# get anime genre action and tag isekai,magic
# Get anime genre action and tag isekai,magic
fastanime anilist search -g Action -T Isekai -T Magic
\b
# get anime of 2024 thats finished airing
# Get anime of 2024 thats finished airing
fastanime anilist search -y 2024 -S FINISHED
\b
# get the most favourite anime movies
# Get the most favourite anime movies
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
\b
# Search with score and popularity filters
fastanime anilist search --score-greater 80 --popularity-greater 50000
\b
# Search excluding certain genres and tags
fastanime anilist search --genres-not Ecchi --tags-not "Hentai"
\b
# Search with date ranges (YYYYMMDD format)
fastanime anilist search --start-date-greater 20200101 --start-date-lesser 20241231
\b
# Get only TV series, exclude certain statuses
fastanime anilist search -f TV --status-not CANCELLED --status-not HIATUS
\b
# Paginated search with custom page size
fastanime anilist search -g Action --page 2 --per-page 25
\b
# Search for manga specifically
fastanime anilist search --media-type MANGA -g Fantasy
\b
# Complex search with multiple criteria
fastanime anilist search -t "demon" -g Action -g Supernatural --score-greater 70 --year 2020 -s SCORE_DESC
\b
# Dump search results as JSON instead of interactive mode
fastanime anilist search -g Action --dump-json
\b
# ---- login ----
\b

View File

@@ -150,39 +150,26 @@ def download(config: AppConfig, **options: "Unpack[Options]"):
if not anime:
raise FastAnimeError(f"Failed to fetch anime {anime_result.title}")
episodes_range = []
episodes: list[str] = sorted(
available_episodes: list[str] = sorted(
getattr(anime.episodes, config.stream.translation_type), key=float
)
if options["episode_range"]:
if ":" in options["episode_range"]:
ep_range_tuple = options["episode_range"].split(":")
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end) : int(step)
]
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(options["episode_range"]) :]
episodes_range = iter(episodes_range)
for episode in episodes_range:
download_anime(
config, options, provider, selector, anime, anime_title, episode
from ..utils.parser import parse_episode_range
try:
episodes_range = parse_episode_range(
options["episode_range"],
available_episodes
)
for episode in episodes_range:
download_anime(
config, options, provider, selector, anime, anime_title, episode
)
except (ValueError, IndexError) as e:
raise FastAnimeError(f"Invalid episode range: {e}") from e
else:
episode = selector.choose(
"Select Episode",

View File

@@ -89,37 +89,24 @@ def search(config: AppConfig, **options: "Unpack[Options]"):
if not anime:
raise FastAnimeError(f"Failed to fetch anime {anime_result.title}")
episodes_range = []
episodes: list[str] = sorted(
available_episodes: list[str] = sorted(
getattr(anime.episodes, config.stream.translation_type), key=float
)
if options["episode_range"]:
if ":" in options["episode_range"]:
ep_range_tuple = options["episode_range"].split(":")
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end) : int(step)
]
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(options["episode_range"]) :]
episodes_range = iter(episodes_range)
for episode in episodes_range:
stream_anime(config, provider, selector, anime, episode, anime_title)
from ..utils.parser import parse_episode_range
try:
episodes_range = parse_episode_range(
options["episode_range"],
available_episodes
)
for episode in episodes_range:
stream_anime(config, provider, selector, anime, episode, anime_title)
except (ValueError, IndexError) as e:
raise FastAnimeError(f"Invalid episode range: {e}") from e
else:
episode = selector.choose(
"Select Episode",

View File

@@ -124,7 +124,9 @@ class Session:
else:
logger.warning("Failed to continue from history. No sessions found")
if not self._history:
if history:
self._history = history
else:
self._history.append(State(menu_name=MenuName.MAIN))
try:

View File

@@ -0,0 +1,5 @@
"""CLI utilities for FastAnime."""
from .parser import parse_episode_range
__all__ = ["parse_episode_range"]

View File

@@ -0,0 +1,135 @@
"""Episode range parsing utilities for FastAnime CLI commands."""
from typing import Iterator
def parse_episode_range(
episode_range_str: str | None,
available_episodes: list[str]
) -> Iterator[str]:
"""
Parse an episode range string and return an iterator of episode numbers.
This function handles various episode range formats:
- Single episode: "5" -> episodes from index 5 onwards
- Range with start and end: "5:10" -> episodes from index 5 to 10 (exclusive)
- Range with step: "5:10:2" -> episodes from index 5 to 10 with step 2
- Start only: "5:" -> episodes from index 5 onwards
- End only: ":10" -> episodes from beginning to index 10
- All episodes: ":" -> all episodes
Args:
episode_range_str: The episode range string to parse (e.g., "5:10", "5:", ":10", "5")
available_episodes: List of available episode numbers/identifiers
Returns:
Iterator over the selected episode numbers
Raises:
ValueError: If the episode range format is invalid
IndexError: If the specified indices are out of range
Examples:
>>> episodes = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
>>> list(parse_episode_range("2:5", episodes))
['3', '4', '5']
>>> list(parse_episode_range("5:", episodes))
['6', '7', '8', '9', '10']
>>> list(parse_episode_range(":3", episodes))
['1', '2', '3']
>>> list(parse_episode_range("2:8:2", episodes))
['3', '5', '7']
"""
if not episode_range_str:
# No range specified, return all episodes
return iter(available_episodes)
# Sort episodes numerically for consistent ordering
episodes = sorted(available_episodes, key=float)
if ":" in episode_range_str:
# Handle colon-separated ranges
parts = episode_range_str.split(":")
if len(parts) == 3:
# Format: start:end:step
start_str, end_str, step_str = parts
if not all([start_str, end_str, step_str]):
raise ValueError(
f"Invalid episode range format: '{episode_range_str}'. "
"When using 3 parts (start:end:step), all parts must be non-empty."
)
try:
start_idx = int(start_str)
end_idx = int(end_str)
step = int(step_str)
if step <= 0:
raise ValueError("Step value must be positive")
return iter(episodes[start_idx:end_idx:step])
except ValueError as e:
if "invalid literal" in str(e):
raise ValueError(
f"Invalid episode range format: '{episode_range_str}'. "
"All parts must be valid integers."
) from e
raise
elif len(parts) == 2:
# Format: start:end or start: or :end
start_str, end_str = parts
if start_str and end_str:
# Both start and end specified: start:end
try:
start_idx = int(start_str)
end_idx = int(end_str)
return iter(episodes[start_idx:end_idx])
except ValueError as e:
raise ValueError(
f"Invalid episode range format: '{episode_range_str}'. "
"Start and end must be valid integers."
) from e
elif start_str and not end_str:
# Only start specified: start:
try:
start_idx = int(start_str)
return iter(episodes[start_idx:])
except ValueError as e:
raise ValueError(
f"Invalid episode range format: '{episode_range_str}'. "
"Start must be a valid integer."
) from e
elif not start_str and end_str:
# Only end specified: :end
try:
end_idx = int(end_str)
return iter(episodes[:end_idx])
except ValueError as e:
raise ValueError(
f"Invalid episode range format: '{episode_range_str}'. "
"End must be a valid integer."
) from e
else:
# Both empty: ":"
return iter(episodes)
else:
raise ValueError(
f"Invalid episode range format: '{episode_range_str}'. "
"Too many colon separators."
)
else:
# Single number: start from that index onwards
try:
start_idx = int(episode_range_str)
return iter(episodes[start_idx:])
except ValueError as e:
raise ValueError(
f"Invalid episode range format: '{episode_range_str}'. "
"Must be a valid integer."
) from e

115
tests/test_parser.py Normal file
View File

@@ -0,0 +1,115 @@
"""Tests for episode range parser."""
import pytest
from fastanime.cli.utils.parser import parse_episode_range
class TestParseEpisodeRange:
"""Test cases for the parse_episode_range function."""
@pytest.fixture
def episodes(self):
"""Sample episode list for testing."""
return ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
def test_no_range_returns_all_episodes(self, episodes):
"""Test that None or empty range returns all episodes."""
result = list(parse_episode_range(None, episodes))
assert result == episodes
def test_colon_only_returns_all_episodes(self, episodes):
"""Test that ':' returns all episodes."""
result = list(parse_episode_range(":", episodes))
assert result == episodes
def test_start_end_range(self, episodes):
"""Test start:end range format."""
result = list(parse_episode_range("2:5", episodes))
assert result == ["3", "4", "5"]
def test_start_only_range(self, episodes):
"""Test start: range format."""
result = list(parse_episode_range("5:", episodes))
assert result == ["6", "7", "8", "9", "10"]
def test_end_only_range(self, episodes):
"""Test :end range format."""
result = list(parse_episode_range(":3", episodes))
assert result == ["1", "2", "3"]
def test_start_end_step_range(self, episodes):
"""Test start:end:step range format."""
result = list(parse_episode_range("2:8:2", episodes))
assert result == ["3", "5", "7"]
def test_single_number_range(self, episodes):
"""Test single number format (start from index)."""
result = list(parse_episode_range("5", episodes))
assert result == ["6", "7", "8", "9", "10"]
def test_empty_start_end_in_three_part_range_raises_error(self, episodes):
"""Test that empty parts in start:end:step format raise error."""
with pytest.raises(ValueError, match="When using 3 parts"):
list(parse_episode_range(":5:2", episodes))
with pytest.raises(ValueError, match="When using 3 parts"):
list(parse_episode_range("2::2", episodes))
with pytest.raises(ValueError, match="When using 3 parts"):
list(parse_episode_range("2:5:", episodes))
def test_invalid_integer_raises_error(self, episodes):
"""Test that invalid integers raise ValueError."""
with pytest.raises(ValueError, match="Must be a valid integer"):
list(parse_episode_range("abc", episodes))
with pytest.raises(ValueError, match="Start and end must be valid integers"):
list(parse_episode_range("2:abc", episodes))
with pytest.raises(ValueError, match="All parts must be valid integers"):
list(parse_episode_range("2:5:abc", episodes))
def test_zero_step_raises_error(self, episodes):
"""Test that zero step raises ValueError."""
with pytest.raises(ValueError, match="Step value must be positive"):
list(parse_episode_range("2:5:0", episodes))
def test_negative_step_raises_error(self, episodes):
"""Test that negative step raises ValueError."""
with pytest.raises(ValueError, match="Step value must be positive"):
list(parse_episode_range("2:5:-1", episodes))
def test_too_many_colons_raises_error(self, episodes):
"""Test that too many colons raise ValueError."""
with pytest.raises(ValueError, match="Too many colon separators"):
list(parse_episode_range("2:5:7:9", episodes))
def test_edge_case_empty_list(self):
"""Test behavior with empty episode list."""
result = list(parse_episode_range(":", []))
assert result == []
def test_edge_case_single_episode(self):
"""Test behavior with single episode."""
episodes = ["1"]
result = list(parse_episode_range(":", episodes))
assert result == ["1"]
result = list(parse_episode_range("0:1", episodes))
assert result == ["1"]
def test_numerical_sorting(self):
"""Test that episodes are sorted numerically, not lexicographically."""
episodes = ["10", "2", "1", "11", "3"]
result = list(parse_episode_range(":", episodes))
assert result == ["1", "2", "3", "10", "11"]
def test_index_out_of_bounds_behavior(self, episodes):
"""Test behavior when indices exceed available episodes."""
# Python slicing handles out-of-bounds gracefully
result = list(parse_episode_range("15:", episodes))
assert result == [] # No episodes beyond index 15
result = list(parse_episode_range(":20", episodes))
assert result == episodes # All episodes (slice stops at end)