feat: update interactive session logic

This commit is contained in:
Benexl
2025-07-21 22:28:09 +03:00
parent 452c2cf764
commit 0e6aeeea18
16 changed files with 739 additions and 706 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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),

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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]:

View File

@@ -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

View File

@@ -1 +1,3 @@
from .service import AuthService
__all__ = ["AuthService"]

View File

@@ -1,3 +1,3 @@
from .service import SessionService
from .service import SessionsService
__all__ = ["SessionService"]
__all__ = ["SessionsService"]

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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,
},
)

View File

@@ -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:

View File

@@ -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 == []