""" Base test utilities for interactive menu testing. Provides common patterns and utilities following DRY principles. """ from typing import Any, Dict, List, Optional from unittest.mock import Mock, patch import pytest from fastanime.cli.interactive.session import Context from fastanime.cli.interactive.state import ControlFlow, State 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.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]: """ 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 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] ): """Simulate a user making a specific choice from the menu options.""" choice_value = options_dict.get(choice_key) if choice_value: self.setup_selector_choice(context, choice_value) return choice_value 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] ): """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 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 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: 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") 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() session_manager.list_saved_sessions.return_value = [] session_manager.save_session.return_value = True 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) 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 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, ) ) return MediaSearchResult( media=media_items, page_info={ "total": num_items, "current_page": 1, "last_page": 1, "has_next_page": False, "per_page": 20, }, )