From 0e6aeeea18a5f876d8bd6bbc0473f28847b5cd8c Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 21 Jul 2025 22:28:09 +0300 Subject: [PATCH] feat: update interactive session logic --- .../cli/commands/anilist/commands/random.py | 29 +- .../cli/commands/anilist/commands/search.py | 28 +- .../cli/commands/anilist/commands/stats.py | 24 +- fastanime/cli/commands/anilist/helpers.py | 90 ++-- fastanime/cli/commands/helpers.py | 2 +- fastanime/cli/interactive/menus/auth.py | 90 ++-- fastanime/cli/interactive/menus/main.py | 65 ++- fastanime/cli/interactive/session.py | 326 +++---------- fastanime/cli/interactive/state.py | 6 +- fastanime/cli/services/auth/__init__.py | 2 + fastanime/cli/services/session/__init__.py | 4 +- fastanime/cli/services/session/service.py | 2 +- fastanime/core/config/model.py | 2 +- tests/cli/interactive/menus/base_test.py | 144 +++--- .../menus/test_additional_menus.py | 198 ++++---- tests/cli/interactive/test_session.py | 433 ++++++++++++------ 16 files changed, 739 insertions(+), 706 deletions(-) diff --git a/fastanime/cli/commands/anilist/commands/random.py b/fastanime/cli/commands/anilist/commands/random.py index 87c8254..56a4aa9 100644 --- a/fastanime/cli/commands/anilist/commands/random.py +++ b/fastanime/cli/commands/anilist/commands/random.py @@ -20,45 +20,44 @@ if TYPE_CHECKING: def random_anime(config: "AppConfig", dump_json: bool): import json import random - from rich.progress import Progress + from fastanime.cli.utils.feedback import create_feedback_manager from fastanime.core.exceptions import FastAnimeError from fastanime.libs.api.factory import create_api_client from fastanime.libs.api.params import ApiSearchParams - from fastanime.cli.utils.feedback import create_feedback_manager + from rich.progress import Progress feedback = create_feedback_manager(config.general.icons) - + try: # Create API client - api_client = create_api_client(config.general.api_client, config) - + api_client = create_api_client(config.general.media_api, config) + # Generate random IDs random_ids = random.sample(range(1, 100000), k=50) - + # Search for random anime with Progress() as progress: progress.add_task("Fetching random anime...", total=None) - search_params = ApiSearchParams( - id_in=random_ids, - per_page=50 - ) + search_params = ApiSearchParams(id_in=random_ids, per_page=50) search_result = api_client.search_media(search_params) - + if not search_result or not search_result.media: raise FastAnimeError("No random anime found") - + if dump_json: # Use Pydantic's built-in serialization print(json.dumps(search_result.model_dump(), indent=2)) else: # Launch interactive session for browsing results from fastanime.cli.interactive.session import session - - feedback.info(f"Found {len(search_result.media)} random anime. Launching interactive mode...") + + feedback.info( + f"Found {len(search_result.media)} random anime. Launching interactive mode..." + ) session.load_menus_from_folder() session.run(config) - + except FastAnimeError as e: feedback.error("Failed to fetch random anime", str(e)) raise click.Abort() diff --git a/fastanime/cli/commands/anilist/commands/search.py b/fastanime/cli/commands/anilist/commands/search.py index d8646e9..6e62fef 100644 --- a/fastanime/cli/commands/anilist/commands/search.py +++ b/fastanime/cli/commands/anilist/commands/search.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING import click - from fastanime.cli.utils.completions import anime_titles_shell_complete + from .data import ( genres_available, media_formats_available, @@ -94,19 +94,19 @@ def search( on_list: bool, ): 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.api.factory import create_api_client from fastanime.libs.api.params import ApiSearchParams - from fastanime.cli.utils.feedback import create_feedback_manager + from rich.progress import Progress feedback = create_feedback_manager(config.general.icons) - + try: # Create API client - api_client = create_api_client(config.general.api_client, config) - + api_client = create_api_client(config.general.media_api, config) + # Build search parameters search_params = ApiSearchParams( query=title, @@ -118,28 +118,30 @@ def search( format_in=list(media_format) if media_format else None, season=season, seasonYear=int(year) if year else None, - on_list=on_list + on_list=on_list, ) - + # Search for anime with Progress() as progress: progress.add_task("Searching anime...", total=None) search_result = api_client.search_media(search_params) - + if not search_result or not search_result.media: raise FastAnimeError("No anime found matching your search criteria") - + if dump_json: # Use Pydantic's built-in serialization print(json.dumps(search_result.model_dump(), indent=2)) else: # Launch interactive session for browsing results from fastanime.cli.interactive.session import session - - feedback.info(f"Found {len(search_result.media)} anime matching your search. Launching interactive mode...") + + feedback.info( + f"Found {len(search_result.media)} anime matching your search. Launching interactive mode..." + ) session.load_menus_from_folder() session.run(config) - + except FastAnimeError as e: feedback.error("Search failed", str(e)) raise click.Abort() diff --git a/fastanime/cli/commands/anilist/commands/stats.py b/fastanime/cli/commands/anilist/commands/stats.py index 02e25c6..0261d9c 100644 --- a/fastanime/cli/commands/anilist/commands/stats.py +++ b/fastanime/cli/commands/anilist/commands/stats.py @@ -12,26 +12,22 @@ def stats(config: "AppConfig"): import shutil import subprocess + from fastanime.cli.utils.feedback import create_feedback_manager + from fastanime.core.exceptions import FastAnimeError + from fastanime.libs.api.factory import create_api_client from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel - from fastanime.core.exceptions import FastAnimeError - from fastanime.libs.api.factory import create_api_client - from fastanime.cli.utils.feedback import create_feedback_manager - feedback = create_feedback_manager(config.general.icons) console = Console() try: # Create API client and ensure authentication - api_client = create_api_client(config.general.api_client, config) - + api_client = create_api_client(config.general.media_api, config) + if not api_client.user_profile: - feedback.error( - "Not authenticated", - "Please run: fastanime anilist login" - ) + feedback.error("Not authenticated", "Please run: fastanime anilist login") raise click.Abort() user_profile = api_client.user_profile @@ -48,7 +44,7 @@ def stats(config: "AppConfig"): image_y = int(console.size.height * 0.1) img_w = console.size.width // 3 img_h = console.size.height // 3 - + image_process = subprocess.run( [ KITTEN_EXECUTABLE, @@ -60,13 +56,13 @@ def stats(config: "AppConfig"): ], check=False, ) - + if image_process.returncode != 0: feedback.warning("Failed to display profile image") # Display user information - about_text = getattr(user_profile, 'about', '') or "No description available" - + about_text = getattr(user_profile, "about", "") or "No description available" + console.print( Panel( Markdown(about_text), diff --git a/fastanime/cli/commands/anilist/helpers.py b/fastanime/cli/commands/anilist/helpers.py index 123f78f..1e333eb 100644 --- a/fastanime/cli/commands/anilist/helpers.py +++ b/fastanime/cli/commands/anilist/helpers.py @@ -17,38 +17,34 @@ if TYPE_CHECKING: def get_authenticated_api_client(config: "AppConfig") -> "BaseApiClient": """ Get an authenticated API client or raise an error if not authenticated. - + Args: config: Application configuration - + Returns: Authenticated API client - + Raises: click.Abort: If user is not authenticated """ - from fastanime.libs.api.factory import create_api_client from fastanime.cli.utils.feedback import create_feedback_manager - + from fastanime.libs.api.factory import create_api_client + feedback = create_feedback_manager(config.general.icons) - api_client = create_api_client(config.general.api_client, config) - + api_client = create_api_client(config.general.media_api, config) + # Check if user is authenticated by trying to get viewer profile try: user_profile = api_client.get_viewer_profile() if not user_profile: - feedback.error( - "Not authenticated", - "Please run: fastanime anilist login" - ) + feedback.error("Not authenticated", "Please run: fastanime anilist login") raise click.Abort() except Exception: feedback.error( - "Authentication check failed", - "Please run: fastanime anilist login" + "Authentication check failed", "Please run: fastanime anilist login" ) raise click.Abort() - + return api_client @@ -57,11 +53,11 @@ def handle_media_search_command( dump_json: bool, task_name: str, search_params_factory, - empty_message: str + empty_message: str, ): """ Generic handler for media search commands (trending, popular, recent, etc). - + Args: config: Application configuration dump_json: Whether to output JSON instead of launching interactive mode @@ -69,36 +65,38 @@ def handle_media_search_command( search_params_factory: Function that returns ApiSearchParams empty_message: Message to show when no results found """ + from fastanime.cli.utils.feedback import create_feedback_manager from fastanime.core.exceptions import FastAnimeError from fastanime.libs.api.factory import create_api_client - from fastanime.cli.utils.feedback import create_feedback_manager feedback = create_feedback_manager(config.general.icons) - + try: # Create API client - api_client = create_api_client(config.general.api_client, config) - + api_client = create_api_client(config.general.media_api, config) + # Fetch media with Progress() as progress: progress.add_task(task_name, total=None) search_params = search_params_factory(config) search_result = api_client.search_media(search_params) - + if not search_result or not search_result.media: raise FastAnimeError(empty_message) - + if dump_json: # Use Pydantic's built-in serialization print(json.dumps(search_result.model_dump(), indent=2)) else: # Launch interactive session for browsing results from fastanime.cli.interactive.session import session - - feedback.info(f"Found {len(search_result.media)} anime. Launching interactive mode...") + + feedback.info( + f"Found {len(search_result.media)} anime. Launching interactive mode..." + ) session.load_menus_from_folder() session.run(config) - + except FastAnimeError as e: feedback.error(f"Failed to fetch {task_name.lower()}", str(e)) raise click.Abort() @@ -108,61 +106,69 @@ def handle_media_search_command( def handle_user_list_command( - config: "AppConfig", - dump_json: bool, - status: str, - list_name: str + config: "AppConfig", dump_json: bool, status: str, list_name: str ): """ Generic handler for user list commands (watching, completed, planning, etc). - + Args: config: Application configuration dump_json: Whether to output JSON instead of launching interactive mode status: The list status to fetch (CURRENT, COMPLETED, PLANNING, etc) list_name: Human-readable name for the list (e.g., "watching", "completed") """ + from fastanime.cli.utils.feedback import create_feedback_manager from fastanime.core.exceptions import FastAnimeError from fastanime.libs.api.params import UserListParams - from fastanime.cli.utils.feedback import create_feedback_manager feedback = create_feedback_manager(config.general.icons) - + # Validate status parameter - valid_statuses = ["CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"] + valid_statuses = [ + "CURRENT", + "PLANNING", + "COMPLETED", + "DROPPED", + "PAUSED", + "REPEATING", + ] if status not in valid_statuses: - feedback.error(f"Invalid status: {status}", f"Valid statuses are: {valid_statuses}") + feedback.error( + f"Invalid status: {status}", f"Valid statuses are: {valid_statuses}" + ) raise click.Abort() - + try: # Get authenticated API client api_client = get_authenticated_api_client(config) - + # Fetch user's anime list with Progress() as progress: progress.add_task(f"Fetching your {list_name} list...", total=None) list_params = UserListParams( status=status, # type: ignore # We validated it above page=1, - per_page=config.anilist.per_page or 50 + per_page=config.anilist.per_page or 50, ) user_list = api_client.fetch_user_list(list_params) - + if not user_list or not user_list.media: feedback.info(f"You have no anime in your {list_name} list") return - + if dump_json: # Use Pydantic's built-in serialization print(json.dumps(user_list.model_dump(), indent=2)) else: # Launch interactive session for browsing results from fastanime.cli.interactive.session import session - - feedback.info(f"Found {len(user_list.media)} anime in your {list_name} list. Launching interactive mode...") + + feedback.info( + f"Found {len(user_list.media)} anime in your {list_name} list. Launching interactive mode..." + ) session.load_menus_from_folder() session.run(config) - + except FastAnimeError as e: feedback.error(f"Failed to fetch {list_name} list", str(e)) raise click.Abort() diff --git a/fastanime/cli/commands/helpers.py b/fastanime/cli/commands/helpers.py index 15ba7d7..5424f1d 100644 --- a/fastanime/cli/commands/helpers.py +++ b/fastanime/cli/commands/helpers.py @@ -23,7 +23,7 @@ def search_as_you_type(config: AppConfig, query: str): # Don't search for very short queries to avoid spamming the API return - api_client = create_api_client(config.general.api_client, config) + api_client = create_api_client(config.general.media_api, config) search_params = ApiSearchParams(query=query, per_page=25) results = api_client.search_media(search_params) diff --git a/fastanime/cli/interactive/menus/auth.py b/fastanime/cli/interactive/menus/auth.py index 40be125..9a253a0 100644 --- a/fastanime/cli/interactive/menus/auth.py +++ b/fastanime/cli/interactive/menus/auth.py @@ -75,7 +75,9 @@ def auth(ctx: Context, state: State) -> State | ControlFlow: return ControlFlow.BACK -def _display_auth_status(console: Console, user_profile: Optional[UserProfile], icons: bool): +def _display_auth_status( + console: Console, user_profile: Optional[UserProfile], icons: bool +): """Display current authentication status in a nice panel.""" if user_profile: status_icon = "🟒" if icons else "[green]●[/green]" @@ -95,37 +97,49 @@ def _display_auth_status(console: Console, user_profile: Optional[UserProfile], console.print() -def _handle_login(ctx: Context, auth_manager: AuthManager, feedback, icons: bool) -> State | ControlFlow: +def _handle_login( + ctx: Context, auth_manager: AuthManager, feedback, icons: bool +) -> State | ControlFlow: """Handle the interactive login process.""" - + def perform_login(): # Open browser to AniList OAuth page oauth_url = "https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token" - - if feedback.confirm("Open AniList authorization page in browser?", default=True): + + if feedback.confirm( + "Open AniList authorization page in browser?", default=True + ): try: webbrowser.open(oauth_url) - feedback.info("Browser opened", "Complete the authorization process in your browser") + feedback.info( + "Browser opened", + "Complete the authorization process in your browser", + ) except Exception as e: - feedback.warning("Could not open browser automatically", f"Please manually visit: {oauth_url}") + feedback.warning( + "Could not open browser automatically", + f"Please manually visit: {oauth_url}", + ) else: feedback.info("Manual authorization", f"Please visit: {oauth_url}") # Get token from user - feedback.info("Token Input", "Paste the token from the browser URL after '#access_token='") - token = ctx.selector.ask( - "Enter your AniList Access Token" + feedback.info( + "Token Input", "Paste the token from the browser URL after '#access_token='" ) - + token = ctx.selector.ask("Enter your AniList Access Token") + if not token or not token.strip(): feedback.error("Login cancelled", "No token provided") return None # Authenticate with the API profile = ctx.media_api.authenticate(token.strip()) - + if not profile: - feedback.error("Authentication failed", "The token may be invalid or expired") + feedback.error( + "Authentication failed", "The token may be invalid or expired" + ) return None # Save credentials using the auth manager @@ -137,40 +151,46 @@ def _handle_login(ctx: Context, auth_manager: AuthManager, feedback, icons: bool feedback, "authenticate", loading_msg="Validating token with AniList", - success_msg=f"Successfully logged in! πŸŽ‰" if icons else f"Successfully logged in!", + success_msg=f"Successfully logged in! πŸŽ‰" + if icons + else f"Successfully logged in!", error_msg="Login failed", - show_loading=True + show_loading=True, ) if success and profile: - feedback.success(f"Logged in as {profile.name}" if profile else "Successfully logged in") + feedback.success( + f"Logged in as {profile.name}" if profile else "Successfully logged in" + ) feedback.pause_for_user("Press Enter to continue") - + return ControlFlow.CONTINUE -def _handle_logout(ctx: Context, auth_manager: AuthManager, feedback, icons: bool) -> State | ControlFlow: +def _handle_logout( + ctx: Context, auth_manager: AuthManager, feedback, icons: bool +) -> State | ControlFlow: """Handle the logout process with confirmation.""" if not feedback.confirm( - "Are you sure you want to logout?", + "Are you sure you want to logout?", "This will remove your saved AniList token and log you out", - default=False + default=False, ): return ControlFlow.CONTINUE def perform_logout(): # Clear from auth manager - if hasattr(auth_manager, 'logout'): + if hasattr(auth_manager, "logout"): auth_manager.logout() else: auth_manager.clear_user_profile() - + # Clear from API client ctx.media_api.token = None ctx.media_api.user_profile = None - if hasattr(ctx.media_api, 'http_client'): + if hasattr(ctx.media_api, "http_client"): ctx.media_api.http_client.headers.pop("Authorization", None) - + return True success, _ = execute_with_feedback( @@ -178,18 +198,22 @@ def _handle_logout(ctx: Context, auth_manager: AuthManager, feedback, icons: boo feedback, "logout", loading_msg="Logging out", - success_msg="Successfully logged out πŸ‘‹" if icons else "Successfully logged out", + success_msg="Successfully logged out πŸ‘‹" + if icons + else "Successfully logged out", error_msg="Logout failed", - show_loading=False + show_loading=False, ) if success: feedback.pause_for_user("Press Enter to continue") - - return ControlFlow.RELOAD_CONFIG + + return ControlFlow.CONFIG_EDIT -def _display_user_profile_details(console: Console, user_profile: UserProfile, icons: bool): +def _display_user_profile_details( + console: Console, user_profile: UserProfile, icons: bool +): """Display detailed user profile information.""" if not user_profile: console.print("[red]No user profile available[/red]") @@ -202,10 +226,10 @@ def _display_user_profile_details(console: Console, user_profile: UserProfile, i table.add_row("Name", user_profile.name) table.add_row("User ID", str(user_profile.id)) - + if user_profile.avatar_url: table.add_row("Avatar URL", user_profile.avatar_url) - + if user_profile.banner_url: table.add_row("Banner URL", user_profile.banner_url) @@ -222,7 +246,7 @@ def _display_user_profile_details(console: Console, user_profile: UserProfile, i f"{'πŸ”„ ' if icons else 'β€’ '}Sync progress with AniList\n" f"{'πŸ”” ' if icons else 'β€’ '}Access AniList notifications", title="Available with Authentication", - border_style="green" + border_style="green", ) console.print(features_panel) @@ -254,7 +278,7 @@ list management and does not access sensitive account information. panel = Panel( help_text, title=f"{'❓ ' if icons else ''}AniList Token Help", - border_style="blue" + border_style="blue", ) console.print() console.print(panel) diff --git a/fastanime/cli/interactive/menus/main.py b/fastanime/cli/interactive/menus/main.py index b356d62..03ae8f2 100644 --- a/fastanime/cli/interactive/menus/main.py +++ b/fastanime/cli/interactive/menus/main.py @@ -5,12 +5,15 @@ from rich.console import Console from ....libs.api.params import ApiSearchParams, UserListParams from ....libs.api.types import MediaSearchResult, MediaStatus, UserListStatusType +from ...utils.auth.utils import check_authentication_required, format_auth_menu_header from ...utils.feedback import create_feedback_manager, execute_with_feedback -from ...utils.auth.utils import format_auth_menu_header, check_authentication_required from ..session import Context, session from ..state import ControlFlow, MediaApiState, State -MenuAction = Callable[[], Tuple[str, MediaSearchResult | None, ApiSearchParams | None, UserListParams | None]] +MenuAction = Callable[ + [], + Tuple[str, MediaSearchResult | None, ApiSearchParams | None, UserListParams | None], +] @session.menu @@ -59,13 +62,33 @@ def main(ctx: Context, state: State) -> State | ControlFlow: ctx, "REPEATING" ), # --- List Management --- - f"{'πŸ“š ' if icons else ''}AniList Lists Manager": lambda: ("ANILIST_LISTS", None, None, None), - f"{'πŸ“– ' if icons else ''}Local Watch History": lambda: ("WATCH_HISTORY", None, None, None), + f"{'πŸ“š ' if icons else ''}AniList Lists Manager": lambda: ( + "ANILIST_LISTS", + None, + None, + None, + ), + f"{'πŸ“– ' if icons else ''}Local Watch History": lambda: ( + "WATCH_HISTORY", + None, + None, + None, + ), # --- Authentication and Account Management --- f"{'πŸ” ' if icons else ''}Authentication": lambda: ("AUTH", None, None, None), # --- Control Flow and Utility Options --- - f"{'πŸ”§ ' if icons else ''}Session Management": lambda: ("SESSION_MANAGEMENT", None, None, None), - f"{'πŸ“ ' if icons else ''}Edit Config": lambda: ("RELOAD_CONFIG", None, None, None), + f"{'πŸ”§ ' if icons else ''}Session Management": lambda: ( + "SESSION_MANAGEMENT", + None, + None, + None, + ), + f"{'πŸ“ ' if icons else ''}Edit Config": lambda: ( + "RELOAD_CONFIG", + None, + None, + None, + ), f"{'❌ ' if icons else ''}Exit": lambda: ("EXIT", None, None, None), } @@ -86,7 +109,7 @@ def main(ctx: Context, state: State) -> State | ControlFlow: if next_menu_name == "EXIT": return ControlFlow.EXIT if next_menu_name == "RELOAD_CONFIG": - return ControlFlow.RELOAD_CONFIG + return ControlFlow.CONFIG_EDIT if next_menu_name == "SESSION_MANAGEMENT": return State(menu_name="SESSION_MANAGEMENT") if next_menu_name == "AUTH": @@ -141,7 +164,11 @@ def _create_media_list_action( ) # Return the search parameters along with the result for pagination - return ("RESULTS", result, search_params, None) if success else ("CONTINUE", None, None, None) + return ( + ("RESULTS", result, search_params, None) + if success + else ("CONTINUE", None, None, None) + ) return action @@ -168,7 +195,11 @@ def _create_random_media_list(ctx: Context) -> MenuAction: ) # Return the search parameters along with the result for pagination - return ("RESULTS", result, search_params, None) if success else ("CONTINUE", None, None, None) + return ( + ("RESULTS", result, search_params, None) + if success + else ("CONTINUE", None, None, None) + ) return action @@ -196,7 +227,11 @@ def _create_search_media_list(ctx: Context) -> MenuAction: ) # Return the search parameters along with the result for pagination - return ("RESULTS", result, search_params, None) if success else ("CONTINUE", None, None, None) + return ( + ("RESULTS", result, search_params, None) + if success + else ("CONTINUE", None, None, None) + ) return action @@ -214,7 +249,9 @@ def _create_user_list_action(ctx: Context, status: UserListStatusType) -> MenuAc return "CONTINUE", None, None, None # Create the user list parameters - user_list_params = UserListParams(status=status, per_page=ctx.config.anilist.per_page) + user_list_params = UserListParams( + status=status, per_page=ctx.config.anilist.per_page + ) def fetch_data(): return ctx.media_api.fetch_user_list(user_list_params) @@ -228,6 +265,10 @@ def _create_user_list_action(ctx: Context, status: UserListStatusType) -> MenuAc ) # Return the user list parameters along with the result for pagination - return ("RESULTS", result, None, user_list_params) if success else ("CONTINUE", None, None, None) + return ( + ("RESULTS", result, None, user_list_params) + if success + else ("CONTINUE", None, None, None) + ) return action diff --git a/fastanime/cli/interactive/session.py b/fastanime/cli/interactive/session.py index c28887b..4cf0a6e 100644 --- a/fastanime/cli/interactive/session.py +++ b/fastanime/cli/interactive/session.py @@ -2,20 +2,27 @@ import importlib.util import logging import os from dataclasses import dataclass -from datetime import datetime from pathlib import Path -from typing import Callable, List +from typing import Callable, List, Optional import click from ...core.config import AppConfig from ...core.constants import APP_DIR, USER_CONFIG_PATH from ...libs.api.base import BaseApiClient +from ...libs.api.factory import create_api_client +from ...libs.players import create_player from ...libs.players.base import BasePlayer from ...libs.providers.anime.base import BaseAnimeProvider +from ...libs.providers.anime.provider import create_provider +from ...libs.selectors import create_selector from ...libs.selectors.base import BaseSelector from ..config import ConfigLoader -from ..utils.session.manager import SessionManager +from ..services.auth import AuthService +from ..services.feedback import FeedbackService +from ..services.registry import MediaRegistryService +from ..services.session import SessionsService +from ..services.watch_history import WatchHistoryService from .state import ControlFlow, State logger = logging.getLogger(__name__) @@ -27,55 +34,53 @@ MENUS_DIR = APP_DIR / "cli" / "interactive" / "menus" @dataclass(frozen=True) -class Context: - """ - A mutable container for long-lived, shared services and configurations. - This object is passed to every menu state, providing access to essential - application components like API clients and UI selectors. - """ +class Services: + feedback: FeedbackService + media_registry: MediaRegistryService + watch_history: WatchHistoryService + session: SessionsService + auth: AuthService + +@dataclass(frozen=True) +class Context: config: AppConfig provider: BaseAnimeProvider selector: BaseSelector player: BasePlayer media_api: BaseApiClient + services: Services @dataclass(frozen=True) class Menu: - """Represents a registered menu, linking a name to an executable function.""" - name: str execute: MenuFunction class Session: - """ - The orchestrator for the interactive UI state machine. - - This class manages the state history, holds the application context, - runs the main event loop, and provides the decorator for registering menus. - """ - - def __init__(self): - self._context: Context | None = None - self._history: List[State] = [] - self._menus: dict[str, Menu] = {} - self._session_manager = SessionManager() - self._auto_save_enabled = True + _context: Context + _history: List[State] = [] + _menus: dict[str, Menu] = {} def _load_context(self, config: AppConfig): """Initializes all shared services based on the provided configuration.""" - from ...libs.api.factory import create_api_client - from ...libs.players import create_player - from ...libs.providers.anime.provider import create_provider - from ...libs.selectors import create_selector + media_registry = MediaRegistryService( + media_api=config.general.media_api, config=config.media_registry + ) + auth = AuthService(config.general.media_api) + services = Services( + feedback=FeedbackService(config.general.icons), + media_registry=media_registry, + watch_history=WatchHistoryService(config, media_registry), + session=SessionsService(config.sessions), + auth=auth, + ) - # Create API client - media_api = create_api_client(config.general.api_client, config) + media_api = create_api_client(config.general.media_api, config) - # Attempt to load saved user authentication - self._load_saved_authentication(media_api) + if auth_profile := auth.get_auth(): + media_api.authenticate(auth_profile.token) self._context = Context( config=config, @@ -83,267 +88,66 @@ class Session: selector=create_selector(config), player=create_player(config), media_api=media_api, + services=services, ) logger.info("Application context reloaded.") - def _load_saved_authentication(self, media_api): - """Attempt to load saved user authentication.""" - try: - from ..auth.manager import AuthManager - - auth_manager = AuthManager() - user_data = auth_manager.load_user_profile() - - if user_data and user_data.get("token"): - # Try to authenticate with the saved token - profile = media_api.authenticate(user_data["token"]) - if profile: - logger.info(f"Successfully authenticated as {profile.name}") - else: - logger.warning("Saved authentication token is invalid or expired") - else: - logger.debug("No saved authentication found") - - except Exception as e: - logger.error(f"Failed to load saved authentication: {e}") - # Continue without authentication rather than failing completely - def _edit_config(self): - """Handles the logic for editing the config file and reloading the context.""" - from ..utils.feedback import create_feedback_manager - - feedback = create_feedback_manager( - True - ) # Always use icons for session feedback - - # Confirm before opening editor - if not feedback.confirm("Open configuration file in editor?", default=True): - return - - try: - click.edit(filename=str(USER_CONFIG_PATH)) - - def reload_config(): - loader = ConfigLoader() - new_config = loader.load() - self._load_context(new_config) - return new_config - - from ..utils.feedback import execute_with_feedback - - success, _ = execute_with_feedback( - reload_config, - feedback, - "reload configuration", - loading_msg="Reloading configuration", - success_msg="Configuration reloaded successfully", - error_msg="Failed to reload configuration", - show_loading=False, - ) - - if success: - feedback.pause_for_user("Press Enter to continue") - - except Exception as e: - feedback.error("Failed to edit configuration", str(e)) - feedback.pause_for_user("Press Enter to continue") - - def run(self, config: AppConfig, resume_path: Path | None = None): - """ - Starts and manages the main interactive session loop. - - Args: - config: The initial application configuration. - resume_path: Optional path to a saved session file to resume from. - """ - from ..utils.feedback import create_feedback_manager - - feedback = create_feedback_manager(True) # Always use icons for session messages - + click.edit(filename=str(USER_CONFIG_PATH)) + logger.debug(f"Config changed; Reloading context") + loader = ConfigLoader() + config = loader.load() self._load_context(config) - # Handle session recovery - if resume_path: - self.resume(resume_path, feedback) - elif self._session_manager.has_crash_backup(): - # Offer to resume from crash backup - if feedback.confirm( - "Found a crash backup from a previous session. Would you like to resume?", - default=True + def run( + self, + config: AppConfig, + resume: bool = False, + history: Optional[List[State]] = None, + ): + self._load_context(config) + if resume: + if ( + history + := self._context.services.session.get_most_recent_session_history() ): - crash_history = self._session_manager.load_crash_backup(feedback) - if crash_history: - self._history = crash_history - feedback.info("Session restored from crash backup") - # Clear the crash backup after successful recovery - self._session_manager.clear_crash_backup() - elif self._session_manager.has_auto_save(): - # Offer to resume from auto-save - if feedback.confirm( - "Found an auto-saved session. Would you like to resume?", - default=False - ): - auto_history = self._session_manager.load_auto_save(feedback) - if auto_history: - self._history = auto_history - feedback.info("Session restored from auto-save") + self._history = history + else: + logger.warning("Failed to continue from history. No sessions found") - # Start with main menu if no history if not self._history: self._history.append(State(menu_name="MAIN")) - # Create crash backup before starting - if self._auto_save_enabled: - self._session_manager.create_crash_backup(self._history) - try: self._run_main_loop() - except KeyboardInterrupt: - feedback.warning("Session interrupted by user") - self._handle_session_exit(feedback, interrupted=True) except Exception as e: - feedback.error("Session crashed unexpectedly", str(e)) - self._handle_session_exit(feedback, crashed=True) + self._context.services.session.save_session(self._history) raise - else: - self._handle_session_exit(feedback, normal_exit=True) + self._context.services.session.save_session(self._history) def _run_main_loop(self): """Run the main session loop.""" while self._history: current_state = self._history[-1] - menu_to_run = self._menus.get(current_state.menu_name) - if not menu_to_run or not self._context: - logger.error( - f"Menu '{current_state.menu_name}' not found or context not loaded." - ) - break - - # Auto-save periodically (every 5 state changes) - if self._auto_save_enabled and len(self._history) % 5 == 0: - self._session_manager.auto_save_session(self._history) - - # Execute the menu function, which returns the next step. - next_step = menu_to_run.execute(self._context, current_state) + next_step = self._menus[current_state.menu_name].execute( + self._context, current_state + ) if isinstance(next_step, ControlFlow): - # A control command was issued. if next_step == ControlFlow.EXIT: - break # Exit the loop + break elif next_step == ControlFlow.BACK: if len(self._history) > 1: - self._history.pop() # Go back one state - elif next_step == ControlFlow.RELOAD_CONFIG: + self._history.pop() + elif next_step == ControlFlow.CONFIG_EDIT: self._edit_config() - # For CONTINUE, we do nothing, allowing the loop to re-run the current state. - elif isinstance(next_step, State): + else: # if the state is main menu we should reset the history if next_step.menu_name == "MAIN": self._history = [next_step] else: - # A new state was returned, push it to history for the next loop. self._history.append(next_step) - else: - logger.error( - f"Menu '{current_state.menu_name}' returned invalid type: {type(next_step)}" - ) - break - - def _handle_session_exit(self, feedback, normal_exit=False, interrupted=False, crashed=False): - """Handle session cleanup on exit.""" - if self._auto_save_enabled and self._history: - if normal_exit: - # Clear auto-save on normal exit - self._session_manager.clear_auto_save() - self._session_manager.clear_crash_backup() - feedback.info("Session completed normally") - elif interrupted: - # Save session on interruption - self._session_manager.auto_save_session(self._history) - feedback.info("Session auto-saved due to interruption") - elif crashed: - # Keep crash backup on crash - feedback.error("Session backup maintained for recovery") - - click.echo("Exiting interactive session.") - - def save(self, file_path: Path, session_name: str = None, description: str = None): - """ - Save session history to a file with comprehensive metadata and error handling. - - Args: - file_path: Path to save the session - session_name: Optional name for the session - description: Optional description for the session - """ - from ..utils.feedback import create_feedback_manager - - feedback = create_feedback_manager(True) - return self._session_manager.save_session( - self._history, - file_path, - session_name=session_name, - description=description, - feedback=feedback - ) - - def resume(self, file_path: Path, feedback=None): - """ - Load session history from a file with comprehensive error handling. - - Args: - file_path: Path to the session file - feedback: Optional feedback manager for user notifications - """ - if not feedback: - from ..utils.feedback import create_feedback_manager - feedback = create_feedback_manager(True) - - history = self._session_manager.load_session(file_path, feedback) - if history: - self._history = history - return True - return False - - def list_saved_sessions(self): - """List all saved sessions with their metadata.""" - return self._session_manager.list_saved_sessions() - - def cleanup_old_sessions(self, max_sessions: int = 10): - """Clean up old session files, keeping only the most recent ones.""" - return self._session_manager.cleanup_old_sessions(max_sessions) - - def enable_auto_save(self, enabled: bool = True): - """Enable or disable auto-save functionality.""" - self._auto_save_enabled = enabled - - def get_session_stats(self) -> dict: - """Get statistics about the current session.""" - return { - "current_states": len(self._history), - "current_menu": self._history[-1].menu_name if self._history else None, - "auto_save_enabled": self._auto_save_enabled, - "has_auto_save": self._session_manager.has_auto_save(), - "has_crash_backup": self._session_manager.has_crash_backup() - } - - def create_manual_backup(self, backup_name: str = None): - """Create a manual backup of the current session.""" - from ..utils.feedback import create_feedback_manager - from ...core.constants import APP_DIR - - feedback = create_feedback_manager(True) - backup_name = backup_name or f"manual_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - backup_path = APP_DIR / "sessions" / f"{backup_name}.json" - - return self._session_manager.save_session( - self._history, - backup_path, - session_name=backup_name, - description="Manual backup created by user", - feedback=feedback - ) @property def menu(self) -> Callable[[MenuFunction], MenuFunction]: diff --git a/fastanime/cli/interactive/state.py b/fastanime/cli/interactive/state.py index ba0de62..6b7b165 100644 --- a/fastanime/cli/interactive/state.py +++ b/fastanime/cli/interactive/state.py @@ -3,13 +3,13 @@ from typing import Iterator, List, Literal, Optional from pydantic import BaseModel, ConfigDict +from ...libs.api.params import ApiSearchParams, UserListParams # Add this import from ...libs.api.types import ( MediaItem, MediaSearchResult, MediaStatus, UserListStatusType, ) -from ...libs.api.params import ApiSearchParams, UserListParams # Add this import from ...libs.players.types import PlayerResult from ...libs.providers.anime.types import Anime, SearchResults, Server @@ -27,7 +27,7 @@ class ControlFlow(Enum): EXIT = auto() """Terminate the interactive session gracefully.""" - RELOAD_CONFIG = auto() + CONFIG_EDIT = auto() """Reload the application configuration and re-initialize the context.""" CONTINUE = auto() @@ -77,7 +77,7 @@ class MediaApiState(BaseModel): user_media_status: Optional[UserListStatusType] = None media_status: Optional[MediaStatus] = None anime: Optional[MediaItem] = None - + # Add pagination support: store original search parameters to enable page navigation original_api_params: Optional[ApiSearchParams] = None original_user_list_params: Optional[UserListParams] = None diff --git a/fastanime/cli/services/auth/__init__.py b/fastanime/cli/services/auth/__init__.py index 8b13789..d4ab1af 100644 --- a/fastanime/cli/services/auth/__init__.py +++ b/fastanime/cli/services/auth/__init__.py @@ -1 +1,3 @@ +from .service import AuthService +__all__ = ["AuthService"] diff --git a/fastanime/cli/services/session/__init__.py b/fastanime/cli/services/session/__init__.py index 8c5b0ea..63f5852 100644 --- a/fastanime/cli/services/session/__init__.py +++ b/fastanime/cli/services/session/__init__.py @@ -1,3 +1,3 @@ -from .service import SessionService +from .service import SessionsService -__all__ = ["SessionService"] +__all__ = ["SessionsService"] diff --git a/fastanime/cli/services/session/service.py b/fastanime/cli/services/session/service.py index 0350868..ca7b397 100644 --- a/fastanime/cli/services/session/service.py +++ b/fastanime/cli/services/session/service.py @@ -11,7 +11,7 @@ from .model import Session logger = logging.getLogger(__name__) -class SessionService: +class SessionsService: def __init__(self, config: SessionsConfig): self.dir = config.dir self._ensure_sessions_directory() diff --git a/fastanime/core/config/model.py b/fastanime/core/config/model.py index 29e78ec..54607e3 100644 --- a/fastanime/core/config/model.py +++ b/fastanime/core/config/model.py @@ -24,7 +24,7 @@ class GeneralConfig(BaseModel): pygment_style: str = Field( default=defaults.GENERAL_PYGMENT_STYLE, description=desc.GENERAL_PYGMENT_STYLE ) - api_client: Literal["anilist", "jikan"] = Field( + media_api: Literal["anilist", "jikan"] = Field( default=defaults.GENERAL_API_CLIENT, description=desc.GENERAL_API_CLIENT, ) diff --git a/tests/cli/interactive/menus/base_test.py b/tests/cli/interactive/menus/base_test.py index 579103e..d881424 100644 --- a/tests/cli/interactive/menus/base_test.py +++ b/tests/cli/interactive/menus/base_test.py @@ -3,12 +3,12 @@ Base test utilities for interactive menu testing. Provides common patterns and utilities following DRY principles. """ -import pytest +from typing import Any, Dict, List, Optional from unittest.mock import Mock, patch -from typing import Any, Optional, Dict, List -from fastanime.cli.interactive.state import State, ControlFlow +import pytest from fastanime.cli.interactive.session import Context +from fastanime.cli.interactive.state import ControlFlow, State class BaseMenuTest: @@ -16,102 +16,108 @@ class BaseMenuTest: Base class for menu tests providing common testing patterns and utilities. Follows DRY principles by centralizing common test logic. """ - + @pytest.fixture(autouse=True) def setup_base_mocks(self, mock_create_feedback_manager, mock_rich_console): """Automatically set up common mocks for all menu tests.""" self.mock_feedback = mock_create_feedback_manager self.mock_console = mock_rich_console - + def assert_exit_behavior(self, result: Any): """Assert that the menu returned EXIT control flow.""" assert isinstance(result, ControlFlow) assert result == ControlFlow.EXIT - + def assert_back_behavior(self, result: Any): """Assert that the menu returned BACK control flow.""" assert isinstance(result, ControlFlow) assert result == ControlFlow.BACK - + def assert_continue_behavior(self, result: Any): """Assert that the menu returned CONTINUE control flow.""" assert isinstance(result, ControlFlow) assert result == ControlFlow.CONTINUE - + def assert_reload_config_behavior(self, result: Any): """Assert that the menu returned RELOAD_CONFIG control flow.""" assert isinstance(result, ControlFlow) - assert result == ControlFlow.RELOAD_CONFIG - + assert result == ControlFlow.CONFIG_EDIT + def assert_menu_transition(self, result: Any, expected_menu: str): """Assert that the menu transitioned to the expected menu state.""" assert isinstance(result, State) assert result.menu_name == expected_menu - + def setup_selector_choice(self, context: Context, choice: Optional[str]): """Helper to configure selector choice return value.""" context.selector.choose.return_value = choice - + def setup_selector_input(self, context: Context, input_value: str): """Helper to configure selector input return value.""" context.selector.input.return_value = input_value - + def setup_selector_confirm(self, context: Context, confirm: bool): """Helper to configure selector confirm return value.""" context.selector.confirm.return_value = confirm - + def setup_feedback_confirm(self, confirm: bool): """Helper to configure feedback confirm return value.""" self.mock_feedback.confirm.return_value = confirm - + def assert_console_cleared(self): """Assert that the console was cleared.""" self.mock_console.clear.assert_called_once() - + def assert_feedback_error_called(self, message_contains: str = None): """Assert that feedback.error was called, optionally with specific message.""" self.mock_feedback.error.assert_called() if message_contains: call_args = self.mock_feedback.error.call_args assert message_contains in str(call_args) - + def assert_feedback_info_called(self, message_contains: str = None): """Assert that feedback.info was called, optionally with specific message.""" self.mock_feedback.info.assert_called() if message_contains: call_args = self.mock_feedback.info.call_args assert message_contains in str(call_args) - + def assert_feedback_warning_called(self, message_contains: str = None): """Assert that feedback.warning was called, optionally with specific message.""" self.mock_feedback.warning.assert_called() if message_contains: call_args = self.mock_feedback.warning.call_args assert message_contains in str(call_args) - + def assert_feedback_success_called(self, message_contains: str = None): """Assert that feedback.success was called, optionally with specific message.""" self.mock_feedback.success.assert_called() if message_contains: call_args = self.mock_feedback.success.call_args assert message_contains in str(call_args) - - def create_test_options_dict(self, base_options: Dict[str, str], icons: bool = True) -> Dict[str, str]: + + def create_test_options_dict( + self, base_options: Dict[str, str], icons: bool = True + ) -> Dict[str, str]: """ Helper to create options dictionary with or without icons. Useful for testing both icon and non-icon configurations. """ if not icons: # Remove emoji icons from options - return {key: value.split(' ', 1)[-1] if ' ' in value else value - for key, value in base_options.items()} + return { + key: value.split(" ", 1)[-1] if " " in value else value + for key, value in base_options.items() + } return base_options - + def get_menu_choices(self, options_dict: Dict[str, str]) -> List[str]: """Extract the choice strings from an options dictionary.""" return list(options_dict.values()) - - def simulate_user_choice(self, context: Context, choice_key: str, options_dict: Dict[str, str]): + + def simulate_user_choice( + self, context: Context, choice_key: str, options_dict: Dict[str, str] + ): """Simulate a user making a specific choice from the menu options.""" choice_value = options_dict.get(choice_key) if choice_value: @@ -124,67 +130,69 @@ class MenuTestMixin: Mixin providing additional test utilities that can be combined with BaseMenuTest. Useful for specialized menu testing scenarios. """ - + def setup_api_search_result(self, context: Context, search_result: Any): """Configure the API client to return a specific search result.""" context.media_api.search_media.return_value = search_result - + def setup_api_search_failure(self, context: Context): """Configure the API client to fail search requests.""" context.media_api.search_media.return_value = None - + def setup_provider_search_result(self, context: Context, search_result: Any): """Configure the provider to return a specific search result.""" context.provider.search.return_value = search_result - + def setup_provider_search_failure(self, context: Context): """Configure the provider to fail search requests.""" context.provider.search.return_value = None - + def setup_authenticated_user(self, context: Context, user_profile: Any): """Configure the context for an authenticated user.""" context.media_api.user_profile = user_profile - + def setup_unauthenticated_user(self, context: Context): """Configure the context for an unauthenticated user.""" context.media_api.user_profile = None - - def verify_selector_called_with_choices(self, context: Context, expected_choices: List[str]): + + def verify_selector_called_with_choices( + self, context: Context, expected_choices: List[str] + ): """Verify that the selector was called with the expected choices.""" context.selector.choose.assert_called_once() call_args = context.selector.choose.call_args - actual_choices = call_args[1]['choices'] # Get choices from kwargs + actual_choices = call_args[1]["choices"] # Get choices from kwargs assert actual_choices == expected_choices - + def verify_selector_prompt(self, context: Context, expected_prompt: str): """Verify that the selector was called with the expected prompt.""" context.selector.choose.assert_called_once() call_args = context.selector.choose.call_args - actual_prompt = call_args[1]['prompt'] # Get prompt from kwargs + actual_prompt = call_args[1]["prompt"] # Get prompt from kwargs assert actual_prompt == expected_prompt class AuthMenuTestMixin(MenuTestMixin): """Specialized mixin for authentication menu tests.""" - + def setup_auth_manager_mock(self): """Set up AuthManager mock for authentication tests.""" - with patch('fastanime.cli.auth.manager.AuthManager') as mock_auth: + with patch("fastanime.cli.auth.manager.AuthManager") as mock_auth: auth_instance = Mock() auth_instance.load_user_profile.return_value = None auth_instance.save_user_profile.return_value = True auth_instance.clear_user_profile.return_value = True mock_auth.return_value = auth_instance return auth_instance - + def setup_webbrowser_mock(self): """Set up webbrowser.open mock for authentication tests.""" - return patch('webbrowser.open') + return patch("webbrowser.open") class SessionMenuTestMixin(MenuTestMixin): """Specialized mixin for session management menu tests.""" - + def setup_session_manager_mock(self): """Set up session manager mock for session tests.""" session_manager = Mock() @@ -193,45 +201,47 @@ class SessionMenuTestMixin(MenuTestMixin): session_manager.load_session.return_value = [] session_manager.cleanup_old_sessions.return_value = 0 return session_manager - + def setup_path_exists_mock(self, exists: bool = True): """Set up Path.exists mock for file system tests.""" - return patch('pathlib.Path.exists', return_value=exists) + return patch("pathlib.Path.exists", return_value=exists) class MediaMenuTestMixin(MenuTestMixin): """Specialized mixin for media-related menu tests.""" - + def setup_media_list_success(self, context: Context, media_result: Any): """Set up successful media list fetch.""" self.setup_api_search_result(context, media_result) - + def setup_media_list_failure(self, context: Context): """Set up failed media list fetch.""" self.setup_api_search_failure(context) - + def create_mock_media_result(self, num_items: int = 1): """Create a mock media search result with specified number of items.""" - from fastanime.libs.api.types import MediaSearchResult, MediaItem - + from fastanime.libs.api.types import MediaItem, MediaSearchResult + media_items = [] for i in range(num_items): - media_items.append(MediaItem( - id=i + 1, - title=f"Test Anime {i + 1}", - description=f"Description for test anime {i + 1}", - cover_image=f"https://example.com/cover{i + 1}.jpg", - banner_image=f"https://example.com/banner{i + 1}.jpg", - status="RELEASING", - episodes=12, - duration=24, - genres=["Action", "Adventure"], - mean_score=85 + i, - popularity=1000 + i * 100, - start_date="2024-01-01", - end_date=None - )) - + media_items.append( + MediaItem( + id=i + 1, + title=f"Test Anime {i + 1}", + description=f"Description for test anime {i + 1}", + cover_image=f"https://example.com/cover{i + 1}.jpg", + banner_image=f"https://example.com/banner{i + 1}.jpg", + status="RELEASING", + episodes=12, + duration=24, + genres=["Action", "Adventure"], + mean_score=85 + i, + popularity=1000 + i * 100, + start_date="2024-01-01", + end_date=None, + ) + ) + return MediaSearchResult( media=media_items, page_info={ @@ -239,6 +249,6 @@ class MediaMenuTestMixin(MenuTestMixin): "current_page": 1, "last_page": 1, "has_next_page": False, - "per_page": 20 - } + "per_page": 20, + }, ) diff --git a/tests/cli/interactive/menus/test_additional_menus.py b/tests/cli/interactive/menus/test_additional_menus.py index 6645381..2b13b75 100644 --- a/tests/cli/interactive/menus/test_additional_menus.py +++ b/tests/cli/interactive/menus/test_additional_menus.py @@ -3,10 +3,15 @@ Tests for remaining interactive menus. Tests servers, provider search, and player controls menus. """ -import pytest from unittest.mock import Mock, patch -from fastanime.cli.interactive.state import State, ControlFlow, ProviderState, MediaApiState +import pytest +from fastanime.cli.interactive.state import ( + ControlFlow, + MediaApiState, + ProviderState, + State, +) from fastanime.libs.providers.anime.types import Server from .base_test import BaseMenuTest, MediaMenuTestMixin @@ -14,69 +19,66 @@ from .base_test import BaseMenuTest, MediaMenuTestMixin class TestServersMenu(BaseMenuTest, MediaMenuTestMixin): """Test cases for the servers menu.""" - + @pytest.fixture def mock_servers(self): """Create mock server list.""" return [ Server(name="Server 1", url="https://server1.com/stream"), Server(name="Server 2", url="https://server2.com/stream"), - Server(name="Server 3", url="https://server3.com/stream") + Server(name="Server 3", url="https://server3.com/stream"), ] - + @pytest.fixture def servers_state(self, mock_provider_anime, mock_media_item, mock_servers): """Create state with servers data.""" return State( menu_name="SERVERS", provider=ProviderState( - anime=mock_provider_anime, - selected_episode="5", - servers=mock_servers + anime=mock_provider_anime, selected_episode="5", servers=mock_servers ), - media_api=MediaApiState(anime=mock_media_item) + media_api=MediaApiState(anime=mock_media_item), ) - + def test_servers_menu_no_servers_goes_back(self, mock_context, basic_state): """Test that no servers returns BACK.""" from fastanime.cli.interactive.menus.servers import servers - + state_no_servers = State( - menu_name="SERVERS", - provider=ProviderState(servers=[]) + menu_name="SERVERS", provider=ProviderState(servers=[]) ) - + result = servers(mock_context, state_no_servers) - + self.assert_back_behavior(result) self.assert_console_cleared() - + def test_servers_menu_server_selection(self, mock_context, servers_state): """Test server selection and stream playback.""" from fastanime.cli.interactive.menus.servers import servers - + self.setup_selector_choice(mock_context, "Server 1") - + # Mock successful stream extraction mock_context.provider.get_stream_url.return_value = "https://stream.url" mock_context.player.play.return_value = Mock() - + result = servers(mock_context, servers_state) - + # Should return to episodes or continue based on playback result assert isinstance(result, (State, ControlFlow)) self.assert_console_cleared() - + def test_servers_menu_auto_select_best_server(self, mock_context, servers_state): """Test auto-selecting best quality server.""" from fastanime.cli.interactive.menus.servers import servers - + mock_context.config.stream.auto_select_server = True mock_context.provider.get_stream_url.return_value = "https://stream.url" mock_context.player.play.return_value = Mock() - + result = servers(mock_context, servers_state) - + # Should auto-select and play assert isinstance(result, (State, ControlFlow)) self.assert_console_cleared() @@ -84,44 +86,44 @@ class TestServersMenu(BaseMenuTest, MediaMenuTestMixin): class TestProviderSearchMenu(BaseMenuTest, MediaMenuTestMixin): """Test cases for the provider search menu.""" - + def test_provider_search_no_choice_goes_back(self, mock_context, basic_state): """Test that no choice returns BACK.""" from fastanime.cli.interactive.menus.provider_search import provider_search - + self.setup_selector_choice(mock_context, None) - + result = provider_search(mock_context, basic_state) - + self.assert_back_behavior(result) self.assert_console_cleared() - + def test_provider_search_success(self, mock_context, state_with_media_data): """Test successful provider search.""" from fastanime.cli.interactive.menus.provider_search import provider_search - from fastanime.libs.providers.anime.types import SearchResults, Anime - + from fastanime.libs.providers.anime.types import Anime, SearchResults + # Mock search results mock_anime = Mock(spec=Anime) mock_search_results = Mock(spec=SearchResults) mock_search_results.results = [mock_anime] - + mock_context.provider.search.return_value = mock_search_results self.setup_selector_choice(mock_context, "Test Anime Result") - + result = provider_search(mock_context, state_with_media_data) - + self.assert_menu_transition(result, "EPISODES") self.assert_console_cleared() - + def test_provider_search_no_results(self, mock_context, state_with_media_data): """Test provider search with no results.""" from fastanime.cli.interactive.menus.provider_search import provider_search - + mock_context.provider.search.return_value = None - + result = provider_search(mock_context, state_with_media_data) - + self.assert_continue_behavior(result) self.assert_console_cleared() self.assert_feedback_error_called("No results found") @@ -129,65 +131,67 @@ class TestProviderSearchMenu(BaseMenuTest, MediaMenuTestMixin): class TestPlayerControlsMenu(BaseMenuTest): """Test cases for the player controls menu.""" - - def test_player_controls_no_active_player_goes_back(self, mock_context, basic_state): + + def test_player_controls_no_active_player_goes_back( + self, mock_context, basic_state + ): """Test that no active player returns BACK.""" from fastanime.cli.interactive.menus.player_controls import player_controls - + mock_context.player.is_active = False - + result = player_controls(mock_context, basic_state) - + self.assert_back_behavior(result) self.assert_console_cleared() - + def test_player_controls_pause_resume(self, mock_context, basic_state): """Test pause/resume controls.""" from fastanime.cli.interactive.menus.player_controls import player_controls - + mock_context.player.is_active = True mock_context.player.is_paused = False self.setup_selector_choice(mock_context, "⏸️ Pause") - + result = player_controls(mock_context, basic_state) - + self.assert_continue_behavior(result) mock_context.player.pause.assert_called_once() - + def test_player_controls_seek(self, mock_context, basic_state): """Test seek controls.""" from fastanime.cli.interactive.menus.player_controls import player_controls - + mock_context.player.is_active = True self.setup_selector_choice(mock_context, "⏩ Seek Forward") - + result = player_controls(mock_context, basic_state) - + self.assert_continue_behavior(result) mock_context.player.seek.assert_called_once() - + def test_player_controls_volume(self, mock_context, basic_state): """Test volume controls.""" from fastanime.cli.interactive.menus.player_controls import player_controls - + mock_context.player.is_active = True self.setup_selector_choice(mock_context, "πŸ”Š Volume Up") - + result = player_controls(mock_context, basic_state) - + self.assert_continue_behavior(result) mock_context.player.volume_up.assert_called_once() - + def test_player_controls_stop(self, mock_context, basic_state): """Test stop playback.""" from fastanime.cli.interactive.menus.player_controls import player_controls - + mock_context.player.is_active = True self.setup_selector_choice(mock_context, "⏹️ Stop") self.setup_feedback_confirm(True) # Confirm stop - + result = player_controls(mock_context, basic_state) - + self.assert_back_behavior(result) mock_context.player.stop.assert_called_once() @@ -195,85 +199,95 @@ class TestPlayerControlsMenu(BaseMenuTest): # Integration tests for menu flow class TestMenuIntegration(BaseMenuTest, MediaMenuTestMixin): """Integration tests for menu navigation flow.""" - + def test_full_navigation_flow(self, mock_context, mock_media_search_result): """Test complete navigation from main to watching anime.""" from fastanime.cli.interactive.menus.main import main - from fastanime.cli.interactive.menus.results import results from fastanime.cli.interactive.menus.media_actions import media_actions from fastanime.cli.interactive.menus.provider_search import provider_search - + from fastanime.cli.interactive.menus.results import results + # Start from main menu main_state = State(menu_name="MAIN") - + # Mock main menu choice - trending self.setup_selector_choice(mock_context, "πŸ”₯ Trending") self.setup_media_list_success(mock_context, mock_media_search_result) - + # Should go to results result = main(mock_context, main_state) self.assert_menu_transition(result, "RESULTS") - + # Now test results menu results_state = result anime_title = f"{mock_media_search_result.media[0].title} ({mock_media_search_result.media[0].status})" - - with patch('fastanime.cli.interactive.menus.results._format_anime_choice', return_value=anime_title): + + with patch( + "fastanime.cli.interactive.menus.results._format_anime_choice", + return_value=anime_title, + ): self.setup_selector_choice(mock_context, anime_title) - + result = results(mock_context, results_state) self.assert_menu_transition(result, "MEDIA_ACTIONS") - + # Test media actions actions_state = result self.setup_selector_choice(mock_context, "πŸ” Search Providers") - + result = media_actions(mock_context, actions_state) self.assert_menu_transition(result, "PROVIDER_SEARCH") - + def test_error_recovery_flow(self, mock_context, basic_state): """Test error recovery in menu navigation.""" from fastanime.cli.interactive.menus.main import main - + # Mock API failure self.setup_selector_choice(mock_context, "πŸ”₯ Trending") self.setup_media_list_failure(mock_context) - + result = main(mock_context, basic_state) - + # Should continue (show error and stay in menu) self.assert_continue_behavior(result) self.assert_feedback_error_called("Failed to fetch data") - - def test_authentication_flow_integration(self, mock_unauthenticated_context, basic_state): + + def test_authentication_flow_integration( + self, mock_unauthenticated_context, basic_state + ): """Test authentication-dependent features.""" - from fastanime.cli.interactive.menus.main import main from fastanime.cli.interactive.menus.auth import auth - + from fastanime.cli.interactive.menus.main import main + # Try to access user list without auth self.setup_selector_choice(mock_unauthenticated_context, "πŸ“Ί Watching") - + # Should either redirect to auth or show error result = main(mock_unauthenticated_context, basic_state) - + # Result depends on implementation - could be CONTINUE with error or AUTH redirect assert isinstance(result, (State, ControlFlow)) - - @pytest.mark.parametrize("menu_choice,expected_transition", [ - ("πŸ”§ Session Management", "SESSION_MANAGEMENT"), - ("πŸ” Authentication", "AUTH"), - ("πŸ“– Local Watch History", "WATCH_HISTORY"), - ("❌ Exit", ControlFlow.EXIT), - ("πŸ“ Edit Config", ControlFlow.RELOAD_CONFIG), - ]) - def test_main_menu_navigation_paths(self, mock_context, basic_state, menu_choice, expected_transition): + + @pytest.mark.parametrize( + "menu_choice,expected_transition", + [ + ("πŸ”§ Session Management", "SESSION_MANAGEMENT"), + ("πŸ” Authentication", "AUTH"), + ("πŸ“– Local Watch History", "WATCH_HISTORY"), + ("❌ Exit", ControlFlow.EXIT), + ("πŸ“ Edit Config", ControlFlow.CONFIG_EDIT), + ], + ) + def test_main_menu_navigation_paths( + self, mock_context, basic_state, menu_choice, expected_transition + ): """Test various navigation paths from main menu.""" from fastanime.cli.interactive.menus.main import main - + self.setup_selector_choice(mock_context, menu_choice) - + result = main(mock_context, basic_state) - + if isinstance(expected_transition, str): self.assert_menu_transition(result, expected_transition) else: diff --git a/tests/cli/interactive/test_session.py b/tests/cli/interactive/test_session.py index b18db17..c88b0a0 100644 --- a/tests/cli/interactive/test_session.py +++ b/tests/cli/interactive/test_session.py @@ -3,12 +3,12 @@ Tests for the interactive session management. Tests session lifecycle, state management, and menu loading. """ -import pytest -from unittest.mock import Mock, patch, MagicMock from pathlib import Path +from unittest.mock import MagicMock, Mock, patch -from fastanime.cli.interactive.session import Session, Context, session -from fastanime.cli.interactive.state import State, ControlFlow +import pytest +from fastanime.cli.interactive.session import Context, Session, session +from fastanime.cli.interactive.state import ControlFlow, State from fastanime.core.config import AppConfig from .base_test import BaseMenuTest @@ -16,203 +16,289 @@ from .base_test import BaseMenuTest class TestSession(BaseMenuTest): """Test cases for the Session class.""" - + @pytest.fixture def session_instance(self): """Create a fresh session instance for testing.""" return Session() - + def test_session_initialization(self, session_instance): """Test session initialization.""" assert session_instance._context is None assert session_instance._history == [] assert session_instance._menus == {} assert session_instance._auto_save_enabled is True - + def test_session_menu_decorator(self, session_instance): """Test menu decorator registration.""" + @session_instance.menu def test_menu(ctx, state): return ControlFlow.EXIT - + assert "TEST_MENU" in session_instance._menus assert session_instance._menus["TEST_MENU"].name == "TEST_MENU" assert session_instance._menus["TEST_MENU"].execute == test_menu - + def test_session_load_context(self, session_instance, mock_config): """Test context loading with dependencies.""" - with patch('fastanime.libs.api.factory.create_api_client') as mock_api: - with patch('fastanime.libs.providers.anime.provider.create_provider') as mock_provider: - with patch('fastanime.libs.selectors.create_selector') as mock_selector: - with patch('fastanime.libs.players.create_player') as mock_player: - + with patch("fastanime.libs.api.factory.create_api_client") as mock_api: + with patch( + "fastanime.libs.providers.anime.provider.create_provider" + ) as mock_provider: + with patch("fastanime.libs.selectors.create_selector") as mock_selector: + with patch("fastanime.libs.players.create_player") as mock_player: mock_api.return_value = Mock() mock_provider.return_value = Mock() mock_selector.return_value = Mock() mock_player.return_value = Mock() - + session_instance._load_context(mock_config) - + assert session_instance._context is not None assert isinstance(session_instance._context, Context) - + # Verify all dependencies were created mock_api.assert_called_once() mock_provider.assert_called_once() mock_selector.assert_called_once() mock_player.assert_called_once() - + def test_session_run_basic_flow(self, session_instance, mock_config): """Test basic session run flow.""" + # Register a simple test menu @session_instance.menu def main(ctx, state): return ControlFlow.EXIT - - with patch.object(session_instance, '_load_context'): - with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False): - with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False): - with patch.object(session_instance._session_manager, 'create_crash_backup'): - with patch.object(session_instance._session_manager, 'clear_auto_save'): - with patch.object(session_instance._session_manager, 'clear_crash_backup'): - + + with patch.object(session_instance, "_load_context"): + with patch.object( + session_instance._session_manager, + "has_crash_backup", + return_value=False, + ): + with patch.object( + session_instance._session_manager, + "has_auto_save", + return_value=False, + ): + with patch.object( + session_instance._session_manager, "create_crash_backup" + ): + with patch.object( + session_instance._session_manager, "clear_auto_save" + ): + with patch.object( + session_instance._session_manager, "clear_crash_backup" + ): session_instance.run(mock_config) - + # Should have started with MAIN menu assert len(session_instance._history) >= 1 assert session_instance._history[0].menu_name == "MAIN" - + def test_session_run_with_resume_path(self, session_instance, mock_config): """Test session run with resume path.""" resume_path = Path("/test/session.json") mock_history = [State(menu_name="TEST")] - - with patch.object(session_instance, '_load_context'): - with patch.object(session_instance, 'resume', return_value=True): - with patch.object(session_instance._session_manager, 'create_crash_backup'): - with patch.object(session_instance._session_manager, 'clear_auto_save'): - with patch.object(session_instance._session_manager, 'clear_crash_backup'): - + + with patch.object(session_instance, "_load_context"): + with patch.object(session_instance, "resume", return_value=True): + with patch.object( + session_instance._session_manager, "create_crash_backup" + ): + with patch.object( + session_instance._session_manager, "clear_auto_save" + ): + with patch.object( + session_instance._session_manager, "clear_crash_backup" + ): # Mock a simple menu to exit immediately @session_instance.menu def test(ctx, state): return ControlFlow.EXIT - + session_instance._history = mock_history session_instance.run(mock_config, resume_path) - + # Verify resume was called - session_instance.resume.assert_called_once_with(resume_path, session_instance._load_context) - + session_instance.resume.assert_called_once_with( + resume_path, session_instance._load_context + ) + def test_session_run_with_crash_backup(self, session_instance, mock_config): """Test session run with crash backup recovery.""" mock_history = [State(menu_name="RECOVERED")] - - with patch.object(session_instance, '_load_context'): - with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=True): - with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False): - with patch.object(session_instance._session_manager, 'load_crash_backup', return_value=mock_history): - with patch.object(session_instance._session_manager, 'clear_crash_backup'): - with patch('fastanime.cli.utils.feedback.create_feedback_manager') as mock_feedback: + + with patch.object(session_instance, "_load_context"): + with patch.object( + session_instance._session_manager, "has_crash_backup", return_value=True + ): + with patch.object( + session_instance._session_manager, + "has_auto_save", + return_value=False, + ): + with patch.object( + session_instance._session_manager, + "load_crash_backup", + return_value=mock_history, + ): + with patch.object( + session_instance._session_manager, "clear_crash_backup" + ): + with patch( + "fastanime.cli.utils.feedback.create_feedback_manager" + ) as mock_feedback: feedback = Mock() feedback.confirm.return_value = True # Accept recovery mock_feedback.return_value = feedback - + # Mock menu to exit @session_instance.menu def recovered(ctx, state): return ControlFlow.EXIT - + session_instance.run(mock_config) - + # Should have recovered history assert session_instance._history == mock_history - + def test_session_run_with_auto_save_recovery(self, session_instance, mock_config): """Test session run with auto-save recovery.""" mock_history = [State(menu_name="AUTO_SAVED")] - - with patch.object(session_instance, '_load_context'): - with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False): - with patch.object(session_instance._session_manager, 'has_auto_save', return_value=True): - with patch.object(session_instance._session_manager, 'load_auto_save', return_value=mock_history): - with patch('fastanime.cli.utils.feedback.create_feedback_manager') as mock_feedback: + + with patch.object(session_instance, "_load_context"): + with patch.object( + session_instance._session_manager, + "has_crash_backup", + return_value=False, + ): + with patch.object( + session_instance._session_manager, + "has_auto_save", + return_value=True, + ): + with patch.object( + session_instance._session_manager, + "load_auto_save", + return_value=mock_history, + ): + with patch( + "fastanime.cli.utils.feedback.create_feedback_manager" + ) as mock_feedback: feedback = Mock() feedback.confirm.return_value = True # Accept recovery mock_feedback.return_value = feedback - + # Mock menu to exit @session_instance.menu def auto_saved(ctx, state): return ControlFlow.EXIT - + session_instance.run(mock_config) - + # Should have recovered history assert session_instance._history == mock_history - + def test_session_keyboard_interrupt_handling(self, session_instance, mock_config): """Test session keyboard interrupt handling.""" + @session_instance.menu def main(ctx, state): raise KeyboardInterrupt() - - with patch.object(session_instance, '_load_context'): - with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False): - with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False): - with patch.object(session_instance._session_manager, 'create_crash_backup'): - with patch.object(session_instance._session_manager, 'auto_save_session'): - with patch('fastanime.cli.utils.feedback.create_feedback_manager') as mock_feedback: + + with patch.object(session_instance, "_load_context"): + with patch.object( + session_instance._session_manager, + "has_crash_backup", + return_value=False, + ): + with patch.object( + session_instance._session_manager, + "has_auto_save", + return_value=False, + ): + with patch.object( + session_instance._session_manager, "create_crash_backup" + ): + with patch.object( + session_instance._session_manager, "auto_save_session" + ): + with patch( + "fastanime.cli.utils.feedback.create_feedback_manager" + ) as mock_feedback: feedback = Mock() mock_feedback.return_value = feedback - + session_instance.run(mock_config) - + # Should have saved session on interrupt session_instance._session_manager.auto_save_session.assert_called_once() - + def test_session_exception_handling(self, session_instance, mock_config): """Test session exception handling.""" + @session_instance.menu def main(ctx, state): raise Exception("Test error") - - with patch.object(session_instance, '_load_context'): - with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False): - with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False): - with patch.object(session_instance._session_manager, 'create_crash_backup'): - with patch('fastanime.cli.utils.feedback.create_feedback_manager') as mock_feedback: + + with patch.object(session_instance, "_load_context"): + with patch.object( + session_instance._session_manager, + "has_crash_backup", + return_value=False, + ): + with patch.object( + session_instance._session_manager, + "has_auto_save", + return_value=False, + ): + with patch.object( + session_instance._session_manager, "create_crash_backup" + ): + with patch( + "fastanime.cli.utils.feedback.create_feedback_manager" + ) as mock_feedback: feedback = Mock() mock_feedback.return_value = feedback - + with pytest.raises(Exception, match="Test error"): session_instance.run(mock_config) - + def test_session_save_and_resume(self, session_instance): """Test session save and resume functionality.""" test_path = Path("/test/session.json") test_history = [State(menu_name="TEST1"), State(menu_name="TEST2")] session_instance._history = test_history - - with patch.object(session_instance._session_manager, 'save_session', return_value=True) as mock_save: - with patch.object(session_instance._session_manager, 'load_session', return_value=test_history) as mock_load: - + + with patch.object( + session_instance._session_manager, "save_session", return_value=True + ) as mock_save: + with patch.object( + session_instance._session_manager, + "load_session", + return_value=test_history, + ) as mock_load: # Test save - result = session_instance.save(test_path, "test_session", "Test description") + result = session_instance.save( + test_path, "test_session", "Test description" + ) assert result is True mock_save.assert_called_once() - + # Test resume session_instance._history = [] # Clear history result = session_instance.resume(test_path) assert result is True assert session_instance._history == test_history mock_load.assert_called_once() - + def test_session_auto_save_functionality(self, session_instance, mock_config): """Test auto-save functionality during session run.""" call_count = 0 - + @session_instance.menu def main(ctx, state): nonlocal call_count @@ -220,57 +306,74 @@ class TestSession(BaseMenuTest): if call_count < 6: # Trigger auto-save after 5 calls return State(menu_name="MAIN") return ControlFlow.EXIT - - with patch.object(session_instance, '_load_context'): - with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False): - with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False): - with patch.object(session_instance._session_manager, 'create_crash_backup'): - with patch.object(session_instance._session_manager, 'auto_save_session') as mock_auto_save: - with patch.object(session_instance._session_manager, 'clear_auto_save'): - with patch.object(session_instance._session_manager, 'clear_crash_backup'): - + + with patch.object(session_instance, "_load_context"): + with patch.object( + session_instance._session_manager, + "has_crash_backup", + return_value=False, + ): + with patch.object( + session_instance._session_manager, + "has_auto_save", + return_value=False, + ): + with patch.object( + session_instance._session_manager, "create_crash_backup" + ): + with patch.object( + session_instance._session_manager, "auto_save_session" + ) as mock_auto_save: + with patch.object( + session_instance._session_manager, "clear_auto_save" + ): + with patch.object( + session_instance._session_manager, + "clear_crash_backup", + ): session_instance.run(mock_config) - + # Auto-save should have been called (every 5 state changes) mock_auto_save.assert_called() - + def test_session_menu_loading_from_folder(self, session_instance): """Test loading menus from folder.""" test_menus_dir = Path("/test/menus") - - with patch('os.listdir', return_value=['menu1.py', 'menu2.py', '__init__.py']): - with patch('importlib.util.spec_from_file_location') as mock_spec: - with patch('importlib.util.module_from_spec') as mock_module: - + + with patch("os.listdir", return_value=["menu1.py", "menu2.py", "__init__.py"]): + with patch("importlib.util.spec_from_file_location") as mock_spec: + with patch("importlib.util.module_from_spec") as mock_module: # Mock successful module loading spec = Mock() spec.loader = Mock() mock_spec.return_value = spec mock_module.return_value = Mock() - + session_instance.load_menus_from_folder(test_menus_dir) - + # Should have attempted to load 2 menu files (excluding __init__.py) assert mock_spec.call_count == 2 assert spec.loader.exec_module.call_count == 2 - + def test_session_menu_loading_error_handling(self, session_instance): """Test error handling during menu loading.""" test_menus_dir = Path("/test/menus") - - with patch('os.listdir', return_value=['broken_menu.py']): - with patch('importlib.util.spec_from_file_location', side_effect=Exception("Import error")): - + + with patch("os.listdir", return_value=["broken_menu.py"]): + with patch( + "importlib.util.spec_from_file_location", + side_effect=Exception("Import error"), + ): # Should not raise exception, just log error session_instance.load_menus_from_folder(test_menus_dir) - + # Menu should not be registered assert "BROKEN_MENU" not in session_instance._menus - + def test_session_control_flow_handling(self, session_instance, mock_config): """Test various control flow scenarios.""" state_count = 0 - + @session_instance.menu def main(ctx, state): nonlocal state_count @@ -280,91 +383,123 @@ class TestSession(BaseMenuTest): elif state_count == 2: return ControlFlow.CONTINUE # Should re-run current state elif state_count == 3: - return ControlFlow.RELOAD_CONFIG # Should trigger config edit + return ControlFlow.CONFIG_EDIT # Should trigger config edit else: return ControlFlow.EXIT - + @session_instance.menu def other(ctx, state): return State(menu_name="MAIN") - - with patch.object(session_instance, '_load_context'): - with patch.object(session_instance, '_edit_config'): - with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False): - with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False): - with patch.object(session_instance._session_manager, 'create_crash_backup'): - with patch.object(session_instance._session_manager, 'clear_auto_save'): - with patch.object(session_instance._session_manager, 'clear_crash_backup'): - + + with patch.object(session_instance, "_load_context"): + with patch.object(session_instance, "_edit_config"): + with patch.object( + session_instance._session_manager, + "has_crash_backup", + return_value=False, + ): + with patch.object( + session_instance._session_manager, + "has_auto_save", + return_value=False, + ): + with patch.object( + session_instance._session_manager, "create_crash_backup" + ): + with patch.object( + session_instance._session_manager, "clear_auto_save" + ): + with patch.object( + session_instance._session_manager, + "clear_crash_backup", + ): # Add an initial state to test BACK behavior - session_instance._history = [State(menu_name="OTHER"), State(menu_name="MAIN")] - + session_instance._history = [ + State(menu_name="OTHER"), + State(menu_name="MAIN"), + ] + session_instance.run(mock_config) - + # Should have called edit config session_instance._edit_config.assert_called_once() - + def test_session_get_stats(self, session_instance): """Test session statistics retrieval.""" session_instance._history = [State(menu_name="MAIN"), State(menu_name="TEST")] session_instance._auto_save_enabled = True - - with patch.object(session_instance._session_manager, 'has_auto_save', return_value=True): - with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False): - + + with patch.object( + session_instance._session_manager, "has_auto_save", return_value=True + ): + with patch.object( + session_instance._session_manager, + "has_crash_backup", + return_value=False, + ): stats = session_instance.get_session_stats() - + assert stats["current_states"] == 2 assert stats["current_menu"] == "TEST" assert stats["auto_save_enabled"] is True assert stats["has_auto_save"] is True assert stats["has_crash_backup"] is False - + def test_session_manual_backup(self, session_instance): """Test manual backup creation.""" session_instance._history = [State(menu_name="TEST")] - - with patch.object(session_instance._session_manager, 'save_session', return_value=True): + + with patch.object( + session_instance._session_manager, "save_session", return_value=True + ): result = session_instance.create_manual_backup("test_backup") - + assert result is True session_instance._session_manager.save_session.assert_called_once() - + def test_session_auto_save_toggle(self, session_instance): """Test auto-save enable/disable.""" # Test enabling session_instance.enable_auto_save(True) assert session_instance._auto_save_enabled is True - + # Test disabling session_instance.enable_auto_save(False) assert session_instance._auto_save_enabled is False - + def test_session_cleanup_old_sessions(self, session_instance): """Test cleanup of old sessions.""" - with patch.object(session_instance._session_manager, 'cleanup_old_sessions', return_value=3): + with patch.object( + session_instance._session_manager, "cleanup_old_sessions", return_value=3 + ): result = session_instance.cleanup_old_sessions(max_sessions=10) - + assert result == 3 - session_instance._session_manager.cleanup_old_sessions.assert_called_once_with(10) - + session_instance._session_manager.cleanup_old_sessions.assert_called_once_with( + 10 + ) + def test_session_list_saved_sessions(self, session_instance): """Test listing saved sessions.""" mock_sessions = [ {"name": "session1", "created": "2024-01-01"}, - {"name": "session2", "created": "2024-01-02"} + {"name": "session2", "created": "2024-01-02"}, ] - - with patch.object(session_instance._session_manager, 'list_saved_sessions', return_value=mock_sessions): + + with patch.object( + session_instance._session_manager, + "list_saved_sessions", + return_value=mock_sessions, + ): result = session_instance.list_saved_sessions() - + assert result == mock_sessions session_instance._session_manager.list_saved_sessions.assert_called_once() - + def test_global_session_instance(self): """Test that the global session instance is properly initialized.""" from fastanime.cli.interactive.session import session - + assert isinstance(session, Session) assert session._context is None assert session._history == []