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

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