diff --git a/README.md b/README.md index bd24d41..5488999 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ Run without arguments for an interactive wizard (search → select series → pi |------|-------------| | `QUERY` | Title to search on shinden.pl | | `--url URL` | Skip search — use a direct series URL | -| `-c`, `--continue` | Resume from watch history | +| `-c`, `--continue` | Resume from watch history (with `-d`: resume from download history — first episode not yet downloaded) | | `-d`, `--download` | Download instead of playing | -| `-D`, `--delete-history` | Clear watch history and exit | +| `-D`, `--delete-history` | Clear watch and download history and exit | | `-e RANGE` | Episode number or range: `5`, `1-5`, `-1` (last), `1 5 7` | | `-q QUALITY` | Quality: `best`, `worst`, `1080p`, `720p` … (default: interactive menu) | | `-S N` | Auto-select N-th search result (1-based, skips menus) | @@ -112,6 +112,9 @@ alt-ani-cli -d -e 1-3 -q 720p --url https://shinden.pl/series/65137-fate-strange # Resume from history alt-ani-cli -c +# Download the next missing episode of the most recently downloaded series +alt-ani-cli -c -d + # Polish dub only alt-ani-cli --lang pl vinland saga diff --git a/src/alt_ani_cli/cli.py b/src/alt_ani_cli/cli.py index 358be7d..39f3a55 100644 --- a/src/alt_ani_cli/cli.py +++ b/src/alt_ani_cli/cli.py @@ -485,7 +485,15 @@ def _run_noninteractive(args, client) -> None: # noqa: C901 _ap.ArgumentParser(prog="alt-ani-cli").error(_CLI["errors"]["missing_input"]) - if args.resume: + downloaded_eps: frozenset[float] = frozenset() + if args.resume and args.download: + downloads = history.list_downloads() + if not downloads: + progress.error(_PROG["download_history_empty"]) + sys.exit(1) + ref, downloaded_eps = downloads[0] + last_ep = 0.0 + elif args.resume: all_entries = history.list_all() if not all_entries: progress.error(_PROG["history_empty"]) @@ -523,6 +531,12 @@ def _run_noninteractive(args, client) -> None: # noqa: C901 if not targets: progress.error(_PROG["range_not_found"].format(range=repr(args.episode))) sys.exit(1) + elif args.resume and args.download: + remaining = [ep for ep in episodes if ep.number not in downloaded_eps] + if not remaining: + progress.info(_PROG["downloaded_all"].format(title=ref.title)) + return + targets = [remaining[0]] elif args.resume and last_ep > 0: remaining = [ep for ep in episodes if ep.number > last_ep] if not remaining: @@ -605,6 +619,7 @@ def _run_noninteractive(args, client) -> None: # noqa: C901 completed = False if _action == "download": download.run(stream, ep, ref) + history.record_download(ref, ep.number) elif _action == "debug": _print_debug(stream, embed) else: diff --git a/src/alt_ani_cli/content/ui_content_pl-pl.yaml b/src/alt_ani_cli/content/ui_content_pl-pl.yaml index b6ac347..e02a35f 100644 --- a/src/alt_ani_cli/content/ui_content_pl-pl.yaml +++ b/src/alt_ani_cli/content/ui_content_pl-pl.yaml @@ -16,7 +16,7 @@ cli: help: query: "Tytuł do wyszukania na shinden.pl (pomijany gdy --url lub -c)." url: "Bezpośredni URL serii (https://shinden.pl/series/...)." - resume: "Kontynuuj oglądanie z historii." + resume: "Kontynuuj oglądanie z historii (z -d: kontynuuj pobieranie)." download: "Pobierz odcinek zamiast odtwarzać." delete_history: "Wyczyść historię i wyjdź." episode: 'Numer odcinka lub zakres: "5", "1-5", "-1" (ostatni).' @@ -58,6 +58,8 @@ progress: host_last_resort: "Pozostałe źródła zawiodły — próbuję odłożonego hosta {host}." host_dropped: "Host {host} nadal jest niedostępny po próbie last-resort — pomijam kolejnego playera na tym hoście." history_empty: "Historia jest pusta." + download_history_empty: "Historia pobierania jest pusta." + downloaded_all: "Wszystkie dostępne odcinki są już pobrane ({title})." history_cleared: "Historia wyczyszczona." interrupted: "Przerwano przez użytkownika." http_error: "Błąd HTTP {status} — {url}" @@ -83,8 +85,8 @@ menu: question: "Co chcesz zrobić?" options: search: "Szukaj nowego anime" - resume: "Kontynuuj z historii" - resume_with_count: "Kontynuuj z historii ({count} pozycji)" + resume_watch: "Kontynuuj oglądanie ({count} pozycji)" + resume_download: "Kontynuuj pobieranie ({count} pozycji)" url: "Wklej URL serii" quit: "Wyjdź" instruction: "↑↓ = nawigacja | Enter = wybierz | Ctrl+O = wersja | ESC = wyjście" @@ -124,10 +126,15 @@ menu: default_prompt: "Kontynuuj oglądanie" label: "{title} — ostatni ep {last_ep:g}" instruction: "Wpisz aby filtrować | Enter = wybierz | ESC = wstecz" + download_resume: + default_prompt: "Kontynuuj pobieranie" + label: "{title} — pobrano {count} odc." + instruction: "Wpisz aby filtrować | Enter = wybierz | ESC = wstecz" episodes: default_prompt: "Wybierz odcinek" label_unwatched: " {number:g}. {title}" label_watched: "✓ {number:g}. {title}" + label_downloaded: "↓ {number:g}. {title}" instruction_single: "Wpisz aby filtrować | Enter = wybierz | ESC = wstecz" instruction_multi: "Wpisz aby filtrować | Spacja = zaznacz/odznacz | Ctrl+R = wszystkie | Enter = potwierdź | ESC = wstecz" invalid_multi: "Zaznacz co najmniej jeden odcinek — użyj Spacji." diff --git a/src/alt_ani_cli/flow/handlers.py b/src/alt_ani_cli/flow/handlers.py index 0a57b29..2cb7367 100644 --- a/src/alt_ani_cli/flow/handlers.py +++ b/src/alt_ani_cli/flow/handlers.py @@ -101,11 +101,23 @@ def key(h: SeriesHit): return sorted(hits, key=key) +def _reset_download_resume(state: FlowState) -> None: + """Leave download-resume mode so a newly picked series is not auto-downloaded.""" + if state.resume_mode == "download" and state.episode_action == "download": + state.episode_action = None + state.resume_mode = None + state.downloaded_eps = set() + + +def _first_missing_index(episodes: list[EpisodeRow], downloaded: set[float]) -> int: + return next((i for i, ep in enumerate(episodes) if ep.number not in downloaded), len(episodes) - 1) + + def handle_start_mode(state: FlowState) -> ScreenResult: args = state.args # When launched with flags, skip the interactive menu if args.resume: - return Screen.RESUME_PICK + return Screen.DOWNLOAD_RESUME_PICK if args.download else Screen.RESUME_PICK if args.url: ref = shinden_series.parse_series_url(args.url) state.ref = ref @@ -115,12 +127,10 @@ def handle_start_mode(state: FlowState) -> ScreenResult: state.query = " ".join(args.query) return Screen.SERIES_PICK - all_entries = history.list_all() + watch_count = len(history.list_all()) + download_count = len(history.list_downloads()) while True: - choice = menus.select_start_mode( - has_history=bool(all_entries), - history_count=len(all_entries), - ) + choice = menus.select_start_mode(watch_count=watch_count, download_count=download_count) if choice != "version": break _sm = _M["start_mode"] @@ -129,7 +139,11 @@ def handle_start_mode(state: FlowState) -> ScreenResult: return BACK # ESC from first screen → exit via empty history_stack if choice == "quit": return None - if choice == "resume": + if choice == "resume_download": + state.hits = [] + return Screen.DOWNLOAD_RESUME_PICK + _reset_download_resume(state) + if choice == "resume_watch": state.hits = [] return Screen.RESUME_PICK if choice == "url": @@ -151,6 +165,7 @@ def handle_url_input(state: FlowState) -> ScreenResult: if url is None: return BACK ref = shinden_series.parse_series_url(url) + _reset_download_resume(state) state.ref = ref state.last_ep = 0.0 return Screen.FETCH_EPISODES @@ -164,7 +179,26 @@ def handle_resume_pick(state: FlowState) -> ScreenResult: result = menus.select_series_from_history(all_entries) if result is None: return BACK + _reset_download_resume(state) state.ref, state.last_ep = result + state.resume_mode = "watch" + return Screen.FETCH_EPISODES + + +def handle_download_resume_pick(state: FlowState) -> ScreenResult: + entries = history.list_downloads() + if not entries: + progress.error(_PROG["download_history_empty"]) + return BACK + result = menus.select_series_from_download_history(entries) + if result is None: + return BACK + ref, downloaded = result + state.ref = ref + state.last_ep = 0.0 + state.resume_mode = "download" + state.downloaded_eps = set(downloaded) + state.episode_action = "download" return Screen.FETCH_EPISODES @@ -196,6 +230,7 @@ def handle_series_pick(state: FlowState) -> ScreenResult: if action == "pick": hit = payload ref = shinden_series.parse_series_url(hit.url) + _reset_download_resume(state) state.ref = SeriesRef(id=ref.id, slug=ref.slug, title=hit.title, url=ref.url) state.last_ep = 0.0 return Screen.FETCH_EPISODES @@ -283,7 +318,10 @@ def handle_episodes_pick(state: FlowState) -> ScreenResult: default_index = None watched = set(state.completed_eps) - if state.last_ep > 0: + downloaded = set(state.downloaded_eps) + if state.resume_mode == "download": + default_index = _first_missing_index(state.episodes, downloaded) + elif state.last_ep > 0: watched |= {ep.number for ep in state.episodes if ep.number <= state.last_ep} default_index = next( (i for i, ep in enumerate(state.episodes) if ep.number > state.last_ep), @@ -295,6 +333,7 @@ def handle_episodes_pick(state: FlowState) -> ScreenResult: prompt=CONTENT["menu"]["episodes"]["prompt_with_title"].format(title=state.ref.title), multi=True, watched_numbers=watched, + downloaded_numbers=downloaded, default_index=default_index, ) if result is None: @@ -320,7 +359,8 @@ def handle_episode_dispatch(state: FlowState) -> ScreenResult: args = state.args raw_players = shinden_episode.parse_players(ep_resp.text) - players = shinden_episode.sort_players(raw_players, download=args.download) + download_mode = args.download or state.episode_action == "download" + players = shinden_episode.sort_players(raw_players, download=download_mode) if not players: progress.warn(_PROG["no_players"].format(number=ep.number)) @@ -501,6 +541,8 @@ def handle_run_action(state: FlowState) -> ScreenResult: state.embed = None return _fail_chosen_player(state, ep) diagnostics.download_result(host, ok=True, exc=None) + history.record_download(state.ref, ep.number) + state.downloaded_eps.add(ep.number) elif args.debug or state.episode_action == "debug": _print_debug(stream, state.embed) else: @@ -525,6 +567,7 @@ def handle_run_action(state: FlowState) -> ScreenResult: Screen.SEARCH_QUERY: handle_search_query, Screen.URL_INPUT: handle_url_input, Screen.RESUME_PICK: handle_resume_pick, + Screen.DOWNLOAD_RESUME_PICK: handle_download_resume_pick, Screen.SERIES_PICK: handle_series_pick, Screen.FETCH_EPISODES: handle_fetch_episodes, Screen.EPISODES_PICK: handle_episodes_pick, diff --git a/src/alt_ani_cli/flow/state.py b/src/alt_ani_cli/flow/state.py index ca4b268..7328d0c 100644 --- a/src/alt_ani_cli/flow/state.py +++ b/src/alt_ani_cli/flow/state.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from enum import Enum, auto -from typing import Any +from typing import Any, Literal from alt_ani_cli.health import ResolverHealth @@ -12,6 +12,7 @@ class Screen(Enum): SEARCH_QUERY = auto() URL_INPUT = auto() RESUME_PICK = auto() + DOWNLOAD_RESUME_PICK = auto() SERIES_PICK = auto() FETCH_EPISODES = auto() # wirtualny — I/O bez UI EPISODES_PICK = auto() @@ -59,6 +60,8 @@ class FlowState: hits: list = field(default_factory=list) ref: Any = None last_ep: float = 0.0 + resume_mode: Literal["watch", "download"] | None = None + downloaded_eps: set[float] = field(default_factory=set) # episodes episodes: list = field(default_factory=list) diff --git a/src/alt_ani_cli/history.py b/src/alt_ani_cli/history.py index 3aa7b3d..35ac688 100644 --- a/src/alt_ani_cli/history.py +++ b/src/alt_ani_cli/history.py @@ -22,31 +22,53 @@ def _save(data: dict) -> None: os.replace(tmp, HISTORY_FILE) +def _merge_entry(data: dict, series: SeriesRef) -> dict: + """Return the series entry with fresh metadata, keeping fields owned by the other history kind.""" + entry = data["series"].setdefault(series.id, {}) + entry.update(title=series.title, slug=series.slug, url=series.url) + return entry + + +def _ref(series_id: str, entry: dict) -> SeriesRef: + return SeriesRef( + id=series_id, + slug=entry.get("slug", ""), + title=entry.get("title", series_id), + url=entry.get("url", ""), + ) + + def upsert(series: SeriesRef, last_ep: float) -> None: data = _load() - data["series"][series.id] = { - "title": series.title, - "slug": series.slug, - "url": series.url, - "last_ep": last_ep, - "updated_at": datetime.now(UTC).isoformat(), - } + entry = _merge_entry(data, series) + entry["last_ep"] = last_ep + entry["updated_at"] = datetime.now(UTC).isoformat() + _save(data) + + +def record_download(series: SeriesRef, ep_number: float) -> None: + data = _load() + entry = _merge_entry(data, series) + downloaded = {float(n) for n in entry.get("downloaded_eps", [])} + downloaded.add(float(ep_number)) + entry["downloaded_eps"] = sorted(downloaded) + entry["download_updated_at"] = datetime.now(UTC).isoformat() _save(data) def list_all() -> list[tuple[SeriesRef, float]]: + """Watch history only — download-only entries have no last_ep and are skipped.""" + data = _load() + watched = [(sid, e) for sid, e in data["series"].items() if "last_ep" in e] + watched.sort(key=lambda item: item[1].get("updated_at", ""), reverse=True) + return [(_ref(sid, e), float(e["last_ep"])) for sid, e in watched] + + +def list_downloads() -> list[tuple[SeriesRef, frozenset[float]]]: data = _load() - result: list[tuple[SeriesRef, float]] = [] - for series_id, entry in data["series"].items(): - ref = SeriesRef( - id=series_id, - slug=entry.get("slug", ""), - title=entry.get("title", series_id), - url=entry.get("url", ""), - ) - result.append((ref, float(entry.get("last_ep", 0)))) - result.sort(key=lambda x: data["series"][x[0].id].get("updated_at", ""), reverse=True) - return result + downloads = [(sid, e) for sid, e in data["series"].items() if e.get("downloaded_eps")] + downloads.sort(key=lambda item: item[1].get("download_updated_at", ""), reverse=True) + return [(_ref(sid, e), frozenset(float(n) for n in e["downloaded_eps"])) for sid, e in downloads] def clear() -> None: diff --git a/src/alt_ani_cli/ui/menus.py b/src/alt_ani_cli/ui/menus.py index 9cd4776..78f5eb3 100644 --- a/src/alt_ani_cli/ui/menus.py +++ b/src/alt_ani_cli/ui/menus.py @@ -377,6 +377,19 @@ def select_series_from_history( ) +def select_series_from_download_history( + entries: list[tuple[SeriesRef, frozenset[float]]], + prompt: str = _M["download_resume"]["default_prompt"], +) -> tuple[SeriesRef, frozenset[float]] | None: + _dr = _M["download_resume"] + return _run_simple_picker( + entries, + lambda e: _dr["label"].format(title=e[0].title, count=len(e[1])), + prompt=prompt, + instruction=_dr["instruction"], + ) + + _EP_WINDOW_MAX_ROWS = 15 @@ -410,12 +423,19 @@ def select_episodes( multi: bool = False, watched_numbers: set[float] | None = None, default_index: int | None = None, + downloaded_numbers: set[float] | None = None, ) -> list[EpisodeRow] | None: _watched = watched_numbers or set() + _downloaded = downloaded_numbers or set() _ep = _M["episodes"] def _label(ep: EpisodeRow) -> str: - tmpl = _ep["label_watched"] if ep.number in _watched else _ep["label_unwatched"] + if ep.number in _watched: + tmpl = _ep["label_watched"] + elif ep.number in _downloaded: + tmpl = _ep["label_downloaded"] + else: + tmpl = _ep["label_unwatched"] return tmpl.format(number=ep.number, title=ep.title) if not _use_inquirer(): @@ -588,15 +608,16 @@ def _(event): def select_start_mode( - has_history: bool, history_count: int = 0 -) -> Literal["search", "resume", "url", "quit", "version"] | None: + watch_count: int = 0, download_count: int = 0 +) -> Literal["search", "resume_watch", "resume_download", "url", "quit", "version"] | None: _sm = _M["start_mode"] _opts = _sm["options"] options_plain: list[tuple[str, str]] = [("search", _opts["search"])] - if has_history: - resume_label = _opts["resume_with_count"].format(count=history_count) if history_count else _opts["resume"] - options_plain.append(("resume", resume_label)) + if watch_count: + options_plain.append(("resume_watch", _opts["resume_watch"].format(count=watch_count))) + if download_count: + options_plain.append(("resume_download", _opts["resume_download"].format(count=download_count))) options_plain.append(("url", _opts["url"])) options_plain.append(("quit", _opts["quit"])) diff --git a/tests/cli/test_noninteractive.py b/tests/cli/test_noninteractive.py index eb6f74c..33f6e0a 100644 --- a/tests/cli/test_noninteractive.py +++ b/tests/cli/test_noninteractive.py @@ -1,13 +1,15 @@ """Tests for noninteractive diagnostics wiring — session lifecycle, episode/player context.""" import argparse +from contextlib import contextmanager +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from alt_ani_cli import diagnostics from alt_ani_cli.cli import _run_noninteractive, main -from alt_ani_cli.errors import AntiBotError, NoStreamError +from alt_ani_cli.errors import AntiBotError, DownloadFailedError, NoStreamError from alt_ani_cli.models import EmbedURL, EpisodeRow, PlayerEntry, SeriesRef _REF = SeriesRef(id="1", slug="fate", title="Fate", url="http://shinden.pl/series/1-fate") @@ -296,3 +298,87 @@ def _resolve_embed_side_effect(_client, online_id): assert resolve_embed_ids[:2] == ["pa1", "pb1"] second_episode_resolve_ids = resolve_embed_ids[2:] assert second_episode_resolve_ids[0] == "qb2" + + +_EPISODES = [EpisodeRow(number=float(n), title=f"Ep {n}", url=f"http://shinden.pl/ep/{n}") for n in (1, 2, 3, 4, 5)] +_STREAM = MagicMock(qualities={}) + + +@contextmanager +def _resume_env(watch=(), downloads=(), download_side_effect=None): + """Patch everything after series resolution so only target selection and history writes are exercised.""" + client = MagicMock() + client.get.return_value = MagicMock(raise_for_status=MagicMock(), text="") + with ( + patch("alt_ani_cli.cli.history.list_all", return_value=list(watch)), + patch("alt_ani_cli.cli.history.list_downloads", return_value=list(downloads)), + patch("alt_ani_cli.cli.shinden_series.parse_series_url", return_value=_REF), + patch("alt_ani_cli.cli.shinden_series.list_episodes", return_value=(_REF, _EPISODES)), + patch("alt_ani_cli.cli.shinden_episode.parse_players", return_value=[_PLAYER_A]), + patch("alt_ani_cli.cli.shinden_episode.sort_players", return_value=[_PLAYER_A]), + patch("alt_ani_cli.cli._resolve_with_fallback", return_value=(_STREAM, None)), + patch("alt_ani_cli.cli._pick_quality", return_value=_STREAM), + patch("alt_ani_cli.cli.download.run", side_effect=download_side_effect) as mock_download, + patch("alt_ani_cli.cli.player_runner.play", return_value=MagicMock(rc=0, elapsed=5.0)) as mock_play, + patch("alt_ani_cli.cli.history.upsert") as mock_upsert, + patch("alt_ani_cli.cli.history.record_download") as mock_record, + ): + yield client, SimpleNamespace(download=mock_download, play=mock_play, upsert=mock_upsert, record=mock_record) + + +@pytest.mark.unit +class TestNoninteractiveDownloadHistory: + def test_continue_without_download_uses_watch_history(self): + with _resume_env(watch=[(_REF, 2.0)], downloads=[(_REF, frozenset({1.0}))]) as (client, m): + _run_noninteractive(_make_args(url=None, resume=True), client) + played_title = m.play.call_args.kwargs["title"] + assert "Odcinek 3" in played_title + m.download.assert_not_called() + m.upsert.assert_called_once_with(_REF, last_ep=3.0) + m.record.assert_not_called() + + def test_continue_with_download_uses_download_history(self): + with _resume_env(watch=[(_REF, 4.0)], downloads=[(_REF, frozenset({1.0, 2.0}))]) as (client, m): + _run_noninteractive(_make_args(url=None, resume=True, download=True), client) + (_, ep, _), _ = m.download.call_args + assert ep.number == 3.0 + m.record.assert_called_once_with(_REF, 3.0) + m.upsert.assert_not_called() + m.play.assert_not_called() + + def test_continue_with_download_picks_first_gap(self): + with _resume_env(downloads=[(_REF, frozenset({1.0, 2.0, 4.0}))]) as (client, m): + _run_noninteractive(_make_args(url=None, resume=True, download=True), client) + (_, ep, _), _ = m.download.call_args + assert ep.number == 3.0 + + def test_continue_with_download_all_downloaded_skips_download(self): + everything = frozenset(ep.number for ep in _EPISODES) + with ( + _resume_env(downloads=[(_REF, everything)]) as (client, m), + patch("alt_ani_cli.cli.progress.info") as mock_info, + ): + _run_noninteractive(_make_args(url=None, resume=True, download=True), client) + m.download.assert_not_called() + m.record.assert_not_called() + assert any(_REF.title in str(c.args[0]) for c in mock_info.call_args_list) + + def test_continue_with_download_empty_history_exits(self): + with _resume_env(watch=[(_REF, 2.0)]) as (client, m), pytest.raises(SystemExit): + _run_noninteractive(_make_args(url=None, resume=True, download=True), client) + m.download.assert_not_called() + + def test_plain_download_records_download_history(self): + with _resume_env() as (client, m): + _run_noninteractive(_make_args(download=True, episode="2"), client) + m.record.assert_called_once_with(_REF, 2.0) + m.upsert.assert_not_called() + + def test_failed_download_does_not_record_history(self): + with ( + _resume_env(download_side_effect=DownloadFailedError("x")) as (client, m), + pytest.raises(DownloadFailedError), + ): + _run_noninteractive(_make_args(download=True, episode="2"), client) + m.record.assert_not_called() + m.upsert.assert_not_called() diff --git a/tests/conftest.py b/tests/conftest.py index a13e8aa..d8a968b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,15 @@ def _isolate_diagnostics_dir(tmp_path, monkeypatch): handler.close() +@pytest.fixture(autouse=True) +def _isolate_history_file(tmp_path, monkeypatch): + """Successful downloads write history — never touch the real history.json.""" + from alt_ani_cli import history + + monkeypatch.setattr(history, "STATE_DIR", tmp_path / "state") + monkeypatch.setattr(history, "HISTORY_FILE", tmp_path / "state" / "history.json") + + @pytest.fixture def player_show_html(): return '
' diff --git a/tests/content/test_ui_labels.py b/tests/content/test_ui_labels.py index 66009b9..4706fe4 100644 --- a/tests/content/test_ui_labels.py +++ b/tests/content/test_ui_labels.py @@ -36,6 +36,12 @@ def test_watched_and_unwatched_templates_differ(self): unwatched = _ep["label_unwatched"].format(number=1.0, title="Ep A") assert watched != unwatched + def test_downloaded_template_differs_from_watched_and_unwatched(self): + _ep = CONTENT["menu"]["episodes"] + downloaded = _ep["label_downloaded"].format(number=1.0, title="Ep A") + assert downloaded != _ep["label_watched"].format(number=1.0, title="Ep A") + assert downloaded != _ep["label_unwatched"].format(number=1.0, title="Ep A") + def test_select_episodes_renders_watched_template(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) episodes = [ @@ -101,7 +107,7 @@ class TestStartModeLabels: def test_resume_count_in_label(self, monkeypatch, capsys): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value="2"): - select_start_mode(has_history=True, history_count=5) + select_start_mode(watch_count=5) assert "5" in capsys.readouterr().out @@ -117,7 +123,11 @@ class TestTemplatePlaceholders: (("menu", "series", "label_without_date"), ("{title}", "{id}")), (("menu", "episodes", "label_watched"), ("{number", "{title}")), (("menu", "episodes", "label_unwatched"), ("{number", "{title}")), - (("menu", "start_mode", "options", "resume_with_count"), ("{count}",)), + (("menu", "start_mode", "options", "resume_watch"), ("{count}",)), + (("menu", "start_mode", "options", "resume_download"), ("{count}",)), + (("menu", "download_resume", "label"), ("{title}", "{count}")), + (("menu", "episodes", "label_downloaded"), ("{number", "{title}")), + (("progress", "downloaded_all"), ("{title}",)), (("menu", "start_mode", "version_body"), ("{version}",)), (("menu", "player", "label_ok"), ("{player}", "{res}", "{audio}", "{subs}", "{origin}")), (("menu", "player", "label_failed"), ("{player}", "{res}", "{audio}", "{subs}", "{origin}")), diff --git a/tests/flow/test_handlers.py b/tests/flow/test_handlers.py index 7e40737..9c34880 100644 --- a/tests/flow/test_handlers.py +++ b/tests/flow/test_handlers.py @@ -446,7 +446,7 @@ def test_esc_from_search_query_returns_to_start(self): client = MagicMock() call_count = {"n": 0} - def _fake_start(has_history, history_count=0): + def _fake_start(**kw): call_count["n"] += 1 if call_count["n"] == 1: return "search" @@ -468,7 +468,7 @@ def test_esc_from_series_pick_returns_to_search_query(self): query_calls = {"n": 0} start_calls = {"n": 0} - def _fake_start(has_history, history_count=0): + def _fake_start(**kw): start_calls["n"] += 1 if start_calls["n"] == 1: return "search" @@ -1330,3 +1330,251 @@ def test_health_order_is_never_called(self): HANDLERS[Screen.RESOLVE_STREAM](state) mock_order.assert_not_called() + + +_EP4 = EpisodeRow(number=4.0, title="Ep 4", url="http://shinden.pl/ep/4") +_EP5 = EpisodeRow(number=5.0, title="Ep 5", url="http://shinden.pl/ep/5") +_EP6 = EpisodeRow(number=6.0, title="Ep 6", url="http://shinden.pl/ep/6") + + +@pytest.mark.unit +class TestStartModeHistoryOptions: + def _run(self, choice, watch=(), downloads=(), state=None): + state = state or _make_state() + with ( + patch("alt_ani_cli.history.list_all", return_value=list(watch)), + patch("alt_ani_cli.history.list_downloads", return_value=list(downloads)), + patch("alt_ani_cli.ui.menus.select_start_mode", return_value=choice) as mock_menu, + ): + result = HANDLERS[Screen.START_MODE](state) + return state, result, mock_menu.call_args.kwargs + + def test_menu_receives_both_counts(self): + _, _, kwargs = self._run( + None, + watch=[(_SERIES_REF, 1.0)] * 22, + downloads=[(_SERIES_REF, frozenset({1.0}))] * 3, + ) + assert kwargs == {"watch_count": 22, "download_count": 3} + + def test_resume_watch_goes_to_resume_pick(self): + _, result, _ = self._run("resume_watch", watch=[(_SERIES_REF, 1.0)]) + assert result is Screen.RESUME_PICK + + def test_resume_download_goes_to_download_resume_pick(self): + _, result, _ = self._run("resume_download", downloads=[(_SERIES_REF, frozenset({1.0}))]) + assert result is Screen.DOWNLOAD_RESUME_PICK + + @pytest.mark.parametrize( + "choice, expected", + [("search", Screen.SEARCH_QUERY), ("url", Screen.URL_INPUT), ("resume_watch", Screen.RESUME_PICK)], + ) + def test_leaving_download_resume_clears_its_state(self, choice, expected): + state = _make_state(resume_mode="download", downloaded_eps={1.0}, episode_action="download") + _, result, _ = self._run(choice, state=state) + assert result is expected + assert state.resume_mode is None + assert state.downloaded_eps == set() + assert state.episode_action is None + + def test_cached_download_action_outside_resume_mode_is_kept(self): + state = _make_state(episode_action="download") + self._run("search", state=state) + assert state.episode_action == "download" + + def test_args_resume_with_download_skips_menu_to_download_resume(self): + state = _make_state(args=_make_args(resume=True, download=True)) + assert HANDLERS[Screen.START_MODE](state) is Screen.DOWNLOAD_RESUME_PICK + + +@pytest.mark.unit +class TestHandleResumePickModes: + def test_resume_watch_keeps_last_ep(self): + state = _make_state(resume_mode="download", downloaded_eps={1.0}, episode_action="download") + with ( + patch("alt_ani_cli.history.list_all", return_value=[(_SERIES_REF, 5.0)]), + patch("alt_ani_cli.ui.menus.select_series_from_history", return_value=(_SERIES_REF, 5.0)), + ): + result = HANDLERS[Screen.RESUME_PICK](state) + assert result is Screen.FETCH_EPISODES + assert state.ref == _SERIES_REF + assert state.last_ep == 5.0 + assert state.resume_mode == "watch" + assert state.downloaded_eps == set() + assert state.episode_action is None + + def test_resume_download_sets_download_state(self): + state = _make_state(last_ep=3.0) + with ( + patch("alt_ani_cli.history.list_downloads", return_value=[(_SERIES_REF, frozenset({1.0, 2.0}))]), + patch( + "alt_ani_cli.ui.menus.select_series_from_download_history", + return_value=(_SERIES_REF, frozenset({1.0, 2.0})), + ), + ): + result = HANDLERS[Screen.DOWNLOAD_RESUME_PICK](state) + assert result is Screen.FETCH_EPISODES + assert state.ref == _SERIES_REF + assert state.resume_mode == "download" + assert state.downloaded_eps == {1.0, 2.0} + assert state.episode_action == "download" + assert state.last_ep == 0.0 + + def test_resume_download_esc_returns_back(self): + state = _make_state() + with ( + patch("alt_ani_cli.history.list_downloads", return_value=[(_SERIES_REF, frozenset({1.0}))]), + patch("alt_ani_cli.ui.menus.select_series_from_download_history", return_value=None), + ): + result = HANDLERS[Screen.DOWNLOAD_RESUME_PICK](state) + assert isinstance(result, _BackSentinel) + assert state.resume_mode is None + assert state.episode_action is None + + def test_resume_download_empty_history_returns_back(self): + state = _make_state() + with ( + patch("alt_ani_cli.history.list_downloads", return_value=[]), + patch("alt_ani_cli.ui.progress.error") as mock_err, + ): + result = HANDLERS[Screen.DOWNLOAD_RESUME_PICK](state) + assert isinstance(result, _BackSentinel) + mock_err.assert_called_once() + + +@pytest.mark.unit +class TestEpisodesPickDownloadResume: + def _pick(self, episodes, downloaded): + state = _make_state(ref=_SERIES_REF, episodes=episodes, resume_mode="download", downloaded_eps=set(downloaded)) + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=[episodes[-1]]) as mock_sel: + HANDLERS[Screen.EPISODES_PICK](state) + return mock_sel.call_args + + def test_passes_downloaded_numbers_and_cursor_on_first_missing(self): + args, kwargs = self._pick([_EP1, _EP2, _EP3], {1.0, 2.0}) + assert args[0] == [_EP1, _EP2, _EP3] + assert kwargs["downloaded_numbers"] == {1.0, 2.0} + assert kwargs["default_index"] == 2 + assert kwargs["watched_numbers"] == set() + + def test_gap_in_downloads_puts_cursor_on_gap_not_after_max(self): + _, kwargs = self._pick([_EP1, _EP2, _EP3, _EP4, _EP5, _EP6], {1.0, 2.0, 3.0, 5.0}) + assert kwargs["default_index"] == 3 + + def test_all_downloaded_shows_full_list_with_cursor_on_last(self): + args, kwargs = self._pick([_EP1, _EP2, _EP3], {1.0, 2.0, 3.0}) + assert args[0] == [_EP1, _EP2, _EP3] + assert kwargs["default_index"] == 2 + + def test_watch_resume_cursor_and_watched_ignore_downloads(self): + state = _make_state( + ref=_SERIES_REF, episodes=[_EP1, _EP2, _EP3], last_ep=1.0, resume_mode="watch", downloaded_eps={2.0} + ) + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=[_EP2]) as mock_sel: + HANDLERS[Screen.EPISODES_PICK](state) + kwargs = mock_sel.call_args.kwargs + assert kwargs["watched_numbers"] == {1.0} + assert kwargs["default_index"] == 1 + + def test_manual_download_outside_resume_marks_episode_without_moving_cursor(self): + state = _make_state(ref=_SERIES_REF, episodes=[_EP1, _EP2, _EP3], downloaded_eps={1.0}) + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=[_EP2]) as mock_sel: + HANDLERS[Screen.EPISODES_PICK](state) + kwargs = mock_sel.call_args.kwargs + assert kwargs["downloaded_numbers"] == {1.0} + assert kwargs["watched_numbers"] == set() + assert kwargs["default_index"] is None + + +@pytest.mark.unit +class TestRunActionDownloadHistory: + def test_success_records_download_and_updates_state(self): + state = _download_state(downloaded_eps={5.0}) + with ( + patch("alt_ani_cli.download.run"), + patch("alt_ani_cli.history.record_download") as mock_record, + patch("alt_ani_cli.history.upsert") as mock_upsert, + ): + handle_run_action(state) + mock_record.assert_called_once_with(_SERIES_REF, _EP1.number) + mock_upsert.assert_not_called() + assert state.downloaded_eps == {5.0, _EP1.number} + assert state.completed_eps == set() + + def test_failure_does_not_record_download(self): + state = _download_state() + with ( + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), + patch("alt_ani_cli.history.record_download") as mock_record, + patch("alt_ani_cli.ui.progress.error"), + ): + handle_run_action(state) + mock_record.assert_not_called() + assert state.downloaded_eps == set() + + def test_success_is_persisted_without_touching_watch_history(self): + from alt_ani_cli import history + + state = _download_state() + with patch("alt_ani_cli.download.run"): + handle_run_action(state) + assert history.list_downloads() == [(_SERIES_REF, frozenset({_EP1.number}))] + assert history.list_all() == [] + + +@pytest.mark.unit +class TestEpisodeDispatchDownloadSorting: + def test_cached_download_action_uses_download_ranking(self): + state = _make_ep_dispatch_state() + state.episode_action = "download" + with ( + patch("alt_ani_cli.shinden.episode.parse_players", return_value=[_PLAYER, _PLAYER2]), + patch("alt_ani_cli.shinden.episode.sort_players", return_value=[_PLAYER, _PLAYER2]) as mock_sort, + ): + HANDLERS[Screen.EPISODE_DISPATCH](state) + assert mock_sort.call_args.kwargs["download"] is True + + def test_play_action_keeps_playback_ranking(self): + state = _make_ep_dispatch_state() + state.episode_action = "play" + with ( + patch("alt_ani_cli.shinden.episode.parse_players", return_value=[_PLAYER, _PLAYER2]), + patch("alt_ani_cli.shinden.episode.sort_players", return_value=[_PLAYER, _PLAYER2]) as mock_sort, + ): + HANDLERS[Screen.EPISODE_DISPATCH](state) + assert mock_sort.call_args.kwargs["download"] is False + + +@pytest.mark.unit +class TestDownloadResumeBackToSearchRegression: + def test_back_from_download_resume_to_search_clears_download_mode(self): + """START→resume_download→pick→EPISODES_PICK(ESC)→DOWNLOAD_RESUME_PICK(ESC)→START→search.""" + captured: dict = {} + seen_at_search: dict = {} + starts = iter(["resume_download", "search", None]) + download_picks = iter([(_SERIES_REF, frozenset({1.0})), None]) + + def _factory(**kw): + captured["state"] = FlowState(**kw) + return captured["state"] + + def _fake_search_query(): + s = captured["state"] + seen_at_search.update( + resume_mode=s.resume_mode, episode_action=s.episode_action, downloaded_eps=set(s.downloaded_eps) + ) + return None + + with ( + patch("alt_ani_cli.flow.state.FlowState", side_effect=_factory), + patch("alt_ani_cli.history.list_all", return_value=[]), + patch("alt_ani_cli.history.list_downloads", return_value=[(_SERIES_REF, frozenset({1.0}))]), + patch("alt_ani_cli.ui.menus.select_start_mode", side_effect=lambda **kw: next(starts)), + patch("alt_ani_cli.ui.menus.select_series_from_download_history", side_effect=lambda e: next(download_picks)), + patch("alt_ani_cli.shinden.series.list_episodes", return_value=(_SERIES_REF, [_EP1, _EP2])), + patch("alt_ani_cli.ui.menus.select_episodes", return_value=None), + patch("alt_ani_cli.ui.menus.prompt_search_query", side_effect=_fake_search_query), + ): + _run_interactive_wrapped(_make_args(), MagicMock()) + + assert seen_at_search == {"resume_mode": None, "episode_action": None, "downloaded_eps": set()} diff --git a/tests/test_history.py b/tests/test_history.py index 2d3f046..5a31aa1 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -99,3 +99,134 @@ def test_list_missing_updated_at_sorts_last(self, tmp_path): entries = h.list_all() assert entries[0][0].id == "1" assert entries[1][0].id == "2" + + +def _history(tmp_path, data: dict | None = None): + hf = tmp_path / "h.json" + if data is not None: + hf.write_text(json.dumps(data), encoding="utf-8") + return hf, patch("alt_ani_cli.history.HISTORY_FILE", hf), patch("alt_ani_cli.history.STATE_DIR", tmp_path) + + +@pytest.mark.unit +class TestDownloadHistory: + def test_legacy_watch_only_entry_still_lists(self, tmp_path): + _, p_file, p_dir = _history(tmp_path, { + "version": 1, + "series": {"7": {"title": "Old", "slug": "old", "url": "http://x/7", "last_ep": 7, "updated_at": "2024-01-01T00:00:00+00:00"}}, + }) + with p_file, p_dir: + import alt_ani_cli.history as h + entries = h.list_all() + downloads = h.list_downloads() + assert [(e[0].id, e[1]) for e in entries] == [("7", 7.0)] + assert downloads == [] + + def test_record_download_creates_download_only_entry(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.record_download(_make_ref(), 1.0) + downloads = h.list_downloads() + assert downloads == [(_make_ref(), frozenset({1.0}))] + entry = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"] + assert entry["downloaded_eps"] == [1.0] + assert "download_updated_at" in entry + assert "last_ep" not in entry + assert "updated_at" not in entry + + def test_record_download_accumulates_without_duplicates(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + for n in (1.0, 2.0, 4.0, 2.0, 1.0): + h.record_download(_make_ref(), n) + downloads = h.list_downloads() + assert downloads[0][1] == frozenset({1.0, 2.0, 4.0}) + assert json.loads(hf.read_text(encoding="utf-8"))["series"]["123"]["downloaded_eps"] == [1.0, 2.0, 4.0] + + def test_list_downloads_sorted_by_download_updated_at_descending(self, tmp_path): + _, p_file, p_dir = _history(tmp_path, { + "version": 1, + "series": { + "1": {"title": "A", "slug": "a", "url": "http://x/1", "downloaded_eps": [1.0], "download_updated_at": "2024-01-01T00:00:00+00:00", + "last_ep": 1.0, "updated_at": "2025-01-01T00:00:00+00:00"}, + "2": {"title": "B", "slug": "b", "url": "http://x/2", "downloaded_eps": [1.0], "download_updated_at": "2024-06-15T00:00:00+00:00"}, + "3": {"title": "C", "slug": "c", "url": "http://x/3", "downloaded_eps": [1.0], "download_updated_at": "2023-12-31T00:00:00+00:00"}, + }, + }) + with p_file, p_dir: + import alt_ani_cli.history as h + downloads = h.list_downloads() + assert [d[0].id for d in downloads] == ["2", "1", "3"] + + def test_list_all_skips_download_only_entries(self, tmp_path): + _, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.record_download(_make_ref(id="1", title="Downloaded"), 1.0) + h.upsert(_make_ref(id="2", title="Watched"), last_ep=3.0) + entries = h.list_all() + assert [e[0].id for e in entries] == ["2"] + + def test_watch_upsert_preserves_download_fields(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + for n in (1.0, 2.0, 3.0): + h.record_download(_make_ref(), n) + before = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"]["download_updated_at"] + h.upsert(_make_ref(), last_ep=1.0) + entry = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"] + assert entry["downloaded_eps"] == [1.0, 2.0, 3.0] + assert entry["download_updated_at"] == before + assert entry["last_ep"] == 1.0 + + def test_record_download_preserves_watch_fields(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.upsert(_make_ref(), last_ep=5.0) + before = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"]["updated_at"] + h.record_download(_make_ref(), 6.0) + entry = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"] + entries = h.list_all() + assert entry["last_ep"] == 5.0 + assert entry["updated_at"] == before + assert entries[0][1] == 5.0 + + def test_record_download_refreshes_series_metadata(self, tmp_path): + _, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.upsert(_make_ref(title="Old Title"), last_ep=1.0) + h.record_download(_make_ref(title="New Title"), 1.0) + entries = h.list_all() + assert entries[0][0].title == "New Title" + + def test_clear_removes_both_history_kinds(self, tmp_path): + _, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.upsert(_make_ref(id="1"), last_ep=1.0) + h.record_download(_make_ref(id="2"), 1.0) + h.clear() + assert h.list_all() == [] + assert h.list_downloads() == [] + + def test_version_1_file_without_download_fields_accepts_new_writes(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path, { + "version": 1, + "series": {"123": {"title": "Test Anime", "slug": "test-anime", "url": "http://x", "last_ep": 4.0, + "updated_at": "2024-01-01T00:00:00+00:00"}}, + }) + with p_file, p_dir: + import alt_ani_cli.history as h + assert h.list_downloads() == [] + h.record_download(_make_ref(), 5.0) + entries = h.list_all() + downloads = h.list_downloads() + data = json.loads(hf.read_text(encoding="utf-8")) + assert data["version"] == 1 + assert entries[0][1] == 4.0 + assert downloads[0][1] == frozenset({5.0}) diff --git a/tests/ui/test_menus.py b/tests/ui/test_menus.py index 59aec7b..a1a28c1 100644 --- a/tests/ui/test_menus.py +++ b/tests/ui/test_menus.py @@ -4,6 +4,7 @@ import pytest +from alt_ani_cli.content import CONTENT from alt_ani_cli.models import PlayerSource from alt_ani_cli.shinden.models import EpisodeRow, PlayerEntry, RelatedSeries, SeriesHit, SeriesRef from alt_ani_cli.ui.menus import ( @@ -19,6 +20,7 @@ select_episodes, select_player_once, select_quality, + select_series_from_download_history, select_series_from_history, select_series_once, select_start_mode, @@ -64,27 +66,69 @@ def test_empty_enter_returns_none(self, monkeypatch): assert select_quality({"1080p": "u1"}) is None +def _start_mode_options(monkeypatch, **counts) -> tuple[list[str], str | None]: + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + printed: list[str] = [] + with ( + patch("builtins.print", side_effect=lambda *a, **k: printed.append(" ".join(str(x) for x in a))), + patch("builtins.input", return_value="1"), + ): + choice = select_start_mode(**counts) + return printed, choice + + +_SM_OPTS = CONTENT["menu"]["start_mode"]["options"] +_WATCH_PREFIX = _SM_OPTS["resume_watch"].split("(")[0].strip() +_DOWNLOAD_PREFIX = _SM_OPTS["resume_download"].split("(")[0].strip() + + @pytest.mark.unit class TestSelectStartMode: def test_search_is_first_option(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value="1"): - assert select_start_mode(has_history=False) == "search" + assert select_start_mode() == "search" - def test_resume_returns_resume(self, monkeypatch): + def test_resume_watch_returns_resume_watch(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value="2"): - assert select_start_mode(has_history=True, history_count=3) == "resume" + assert select_start_mode(watch_count=3) == "resume_watch" + + def test_resume_download_returns_resume_download(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + with patch("builtins.input", return_value="3"): + assert select_start_mode(watch_count=3, download_count=2) == "resume_download" def test_quit_without_history(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value="3"): - assert select_start_mode(has_history=False) == "quit" + assert select_start_mode() == "quit" def test_empty_enter_returns_none(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value=""): - assert select_start_mode(has_history=False) is None + assert select_start_mode() is None + + def test_no_history_shows_no_continue_options(self, monkeypatch): + printed, _ = _start_mode_options(monkeypatch) + assert len(printed) == 3 + assert not any(_WATCH_PREFIX in line or _DOWNLOAD_PREFIX in line for line in printed) + + def test_watch_only_shows_only_continue_watching(self, monkeypatch): + printed, _ = _start_mode_options(monkeypatch, watch_count=22) + assert any(_WATCH_PREFIX in line and "22" in line for line in printed) + assert not any(_DOWNLOAD_PREFIX in line for line in printed) + + def test_download_only_shows_only_continue_downloading(self, monkeypatch): + printed, _ = _start_mode_options(monkeypatch, download_count=3) + assert any(_DOWNLOAD_PREFIX in line and "3" in line for line in printed) + assert not any(_WATCH_PREFIX in line for line in printed) + + def test_both_histories_show_both_options(self, monkeypatch): + printed, _ = _start_mode_options(monkeypatch, watch_count=22, download_count=3) + assert any(_WATCH_PREFIX in line for line in printed) + assert any(_DOWNLOAD_PREFIX in line for line in printed) + assert len(printed) == 5 @pytest.mark.unit @@ -138,6 +182,20 @@ def test_default_index_passed_to_checkbox(self, monkeypatch): assert result == [episodes[2]] assert mock_cb.call_args.kwargs["default"] == 2 + def test_downloaded_marker_differs_from_watched(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + episodes = [EpisodeRow(number=n, title=f"Ep {n}", url=f"http://x/{n}") for n in (1, 2, 3)] + printed: list[str] = [] + with ( + patch("builtins.print", side_effect=lambda *a, **k: printed.append(" ".join(str(x) for x in a))), + patch("builtins.input", return_value="3"), + ): + select_episodes(episodes, watched_numbers={1.0}, downloaded_numbers={2.0}) + _ep = CONTENT["menu"]["episodes"] + assert any(_ep["label_watched"].format(number=1, title="Ep 1") in line for line in printed) + assert any(_ep["label_downloaded"].format(number=2, title="Ep 2") in line for line in printed) + assert any(_ep["label_unwatched"].format(number=3, title="Ep 3") in line for line in printed) + @pytest.fixture def pt_session(): @@ -256,6 +314,30 @@ def test_from_history_after_refactor(self, monkeypatch): assert select_series_from_history(entries) == entries[1] +@pytest.mark.unit +class TestSelectSeriesFromDownloadHistory: + _REF = SeriesRef(id="1", slug="tongari", title="Tongari Boushi no Atelier", url="https://shinden.pl/series/1-tongari") + + def test_label_shows_downloaded_count_not_last_ep(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + entries = [(self._REF, frozenset({1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0}))] + printed: list[str] = [] + with ( + patch("builtins.print", side_effect=lambda *a, **k: printed.append(" ".join(str(x) for x in a))), + patch("builtins.input", return_value="1"), + ): + result = select_series_from_download_history(entries) + assert result == entries[0] + expected = CONTENT["menu"]["download_resume"]["label"].format(title=self._REF.title, count=7) + assert any(expected in line for line in printed) + assert not any("ostatni ep" in line for line in printed) + + def test_empty_enter_returns_none(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + with patch("builtins.input", return_value=""): + assert select_series_from_download_history([(self._REF, frozenset({1.0}))]) is None + + @pytest.mark.unit class TestPickRelated: def test_empty_returns_none(self, monkeypatch):