diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index d53402c..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test package for FastAnime.""" diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py deleted file mode 100644 index 0ed45f1..0000000 --- a/tests/cli/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test package for CLI module.""" diff --git a/tests/cli/interactive/README.md b/tests/cli/interactive/README.md deleted file mode 100644 index 2e1ccf7..0000000 --- a/tests/cli/interactive/README.md +++ /dev/null @@ -1,221 +0,0 @@ -# Interactive Menu Tests - -This directory contains comprehensive tests for FastAnime's interactive CLI menus. The test suite follows DRY principles and provides extensive coverage of all menu functionality. - -## Test Structure - -``` -tests/ -├── conftest.py # Shared fixtures and test configuration -├── cli/ -│ └── interactive/ -│ ├── test_session.py # Session management tests -│ └── menus/ -│ ├── base_test.py # Base test classes and utilities -│ ├── test_main.py # Main menu tests -│ ├── test_auth.py # Authentication menu tests -│ ├── test_session_management.py # Session management menu tests -│ ├── test_results.py # Results display menu tests -│ ├── test_episodes.py # Episodes selection menu tests -│ ├── test_watch_history.py # Watch history menu tests -│ ├── test_media_actions.py # Media actions menu tests -│ └── test_additional_menus.py # Additional menus (servers, provider search, etc.) -``` - -## Test Architecture - -### Base Classes - -- **`BaseMenuTest`**: Core test functionality for all menu tests - - Console clearing verification - - Control flow assertions (BACK, EXIT, CONTINUE, RELOAD_CONFIG) - - Menu transition assertions - - Feedback message verification - - Common setup patterns - -- **`MenuTestMixin`**: Additional utilities for specialized testing - - API result mocking - - Authentication state setup - - Provider search configuration - -- **Specialized Mixins**: - - `AuthMenuTestMixin`: Authentication-specific test utilities - - `SessionMenuTestMixin`: Session management test utilities - - `MediaMenuTestMixin`: Media-related test utilities - -### Fixtures - -**Core Fixtures** (in `conftest.py`): -- `mock_config`: Application configuration -- `mock_context`: Complete context with all dependencies -- `mock_unauthenticated_context`: Context without authentication -- `mock_user_profile`: Authenticated user data -- `mock_media_item`: Sample anime/media data -- `mock_media_search_result`: API search results -- `basic_state`: Basic menu state -- `state_with_media_data`: State with media information - -**Utility Fixtures**: -- `mock_feedback_manager`: User feedback system -- `mock_console`: Rich console output -- `menu_helper`: Helper methods for common test patterns - -## Test Categories - -### Unit Tests -Each menu has comprehensive unit tests covering: -- Navigation choices and transitions -- Error handling and edge cases -- Authentication requirements -- Configuration variations (icons enabled/disabled) -- Input validation -- API interaction patterns - -### Integration Tests -Tests covering menu flow and interaction: -- Complete navigation workflows -- Error recovery across menus -- Authentication flow integration -- Session state persistence - -### Test Patterns - -#### Navigation Testing -```python -def test_menu_navigation(self, mock_context, basic_state): - self.setup_selector_choice(mock_context, "Target Option") - result = menu_function(mock_context, basic_state) - self.assert_menu_transition(result, "TARGET_MENU") -``` - -#### Error Handling Testing -```python -def test_menu_error_handling(self, mock_context, basic_state): - self.setup_api_failure(mock_context) - result = menu_function(mock_context, basic_state) - self.assert_continue_behavior(result) - self.assert_feedback_error_called("Expected error message") -``` - -#### Authentication Testing -```python -def test_authenticated_vs_unauthenticated(self, mock_context, mock_unauthenticated_context, basic_state): - # Test authenticated behavior - result1 = menu_function(mock_context, basic_state) - # Test unauthenticated behavior - result2 = menu_function(mock_unauthenticated_context, basic_state) - # Assert different behaviors -``` - -## Running Tests - -### Quick Start -```bash -# Run all interactive menu tests -python -m pytest tests/cli/interactive/ -v - -# Run tests with coverage -python -m pytest tests/cli/interactive/ --cov=fastanime.cli.interactive --cov-report=html - -# Run specific menu tests -python -m pytest tests/cli/interactive/menus/test_main.py -v -``` - -### Using the Test Runner -```bash -# Quick unit tests -./run_tests.py --quick - -# Full test suite with coverage and linting -./run_tests.py --full - -# Test specific menu -./run_tests.py --menu main - -# Test with pattern matching -./run_tests.py --pattern "test_auth" --verbose - -# Generate coverage report only -./run_tests.py --coverage-only -``` - -### Test Runner Options -- `--quick`: Fast unit tests only -- `--full`: Complete suite with coverage and linting -- `--menu `: Test specific menu -- `--pattern `: Match test names -- `--coverage`: Generate coverage reports -- `--verbose`: Detailed output -- `--fail-fast`: Stop on first failure -- `--parallel `: Run tests in parallel -- `--lint`: Run code linting - -## Test Coverage Goals - -The test suite aims for comprehensive coverage of: - -- ✅ **Menu Navigation**: All menu choices and transitions -- ✅ **Error Handling**: API failures, invalid input, edge cases -- ✅ **Authentication Flow**: Authenticated vs unauthenticated behavior -- ✅ **Configuration Variations**: Icons, providers, preferences -- ✅ **User Input Validation**: Empty input, invalid formats, special characters -- ✅ **State Management**: Session state persistence and recovery -- ✅ **Control Flow**: BACK, EXIT, CONTINUE, RELOAD_CONFIG behaviors -- ✅ **Integration Points**: Menu-to-menu transitions and data flow - -## Adding New Tests - -### For New Menus -1. Create `test_.py` in `tests/cli/interactive/menus/` -2. Inherit from `BaseMenuTest` and appropriate mixins -3. Follow the established patterns for navigation, error handling, and authentication testing -4. Add fixtures specific to the menu's data requirements - -### For New Features -1. Add tests to existing menu test files -2. Create new fixtures in `conftest.py` if needed -3. Add new test patterns to `base_test.py` if reusable -4. Update this README with new patterns or conventions - -### Test Naming Conventions -- `test__`: Basic functionality tests -- `test___success`: Successful operation tests -- `test___failure`: Error condition tests -- `test___`: Conditional behavior tests - -## Debugging Tests - -### Common Issues -- **Import Errors**: Ensure all dependencies are properly mocked -- **State Errors**: Verify state fixtures have required data -- **Mock Configuration**: Check that mocks match actual interface contracts -- **Async Issues**: Ensure async operations are properly handled in tests - -### Debugging Tools -```bash -# Run specific test with debug output -python -m pytest tests/cli/interactive/menus/test_main.py::TestMainMenu::test_specific_case -v -s - -# Run with Python debugger -python -m pytest --pdb tests/cli/interactive/menus/test_main.py - -# Generate detailed coverage report -python -m pytest --cov=fastanime.cli.interactive --cov-report=html --cov-report=term-missing -v -``` - -## Continuous Integration - -The test suite is designed for CI/CD integration: -- Fast unit tests for quick feedback -- Comprehensive integration tests for release validation -- Coverage reporting for quality metrics -- Linting integration for code quality - -### CI Configuration Example -```yaml -# Run quick tests on every commit -pytest tests/cli/interactive/ -m unit --fail-fast - -# Run full suite on PR/release -pytest tests/cli/interactive/ --cov=fastanime.cli.interactive --cov-fail-under=90 -``` diff --git a/tests/cli/interactive/__init__.py b/tests/cli/interactive/__init__.py deleted file mode 100644 index 722f528..0000000 --- a/tests/cli/interactive/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test package for interactive CLI module.""" diff --git a/tests/cli/interactive/menus/__init__.py b/tests/cli/interactive/menus/__init__.py deleted file mode 100644 index 84ba0b1..0000000 --- a/tests/cli/interactive/menus/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test package for interactive menu modules.""" diff --git a/tests/cli/interactive/menus/base_test.py b/tests/cli/interactive/menus/base_test.py deleted file mode 100644 index d881424..0000000 --- a/tests/cli/interactive/menus/base_test.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -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, - }, - ) diff --git a/tests/cli/interactive/menus/test_additional_menus.py b/tests/cli/interactive/menus/test_additional_menus.py deleted file mode 100644 index 2b13b75..0000000 --- a/tests/cli/interactive/menus/test_additional_menus.py +++ /dev/null @@ -1,294 +0,0 @@ -""" -Tests for remaining interactive menus. -Tests servers, provider search, and player controls menus. -""" - -from unittest.mock import Mock, patch - -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 - - -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"), - ] - - @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 - ), - 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=[]) - ) - - 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() - - -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 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") - - -class TestPlayerControlsMenu(BaseMenuTest): - """Test cases for the player controls menu.""" - - 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() - - -# 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.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, - ): - 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 - ): - """Test authentication-dependent features.""" - 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.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: - assert result == expected_transition diff --git a/tests/cli/interactive/menus/test_auth.py b/tests/cli/interactive/menus/test_auth.py deleted file mode 100644 index ad86589..0000000 --- a/tests/cli/interactive/menus/test_auth.py +++ /dev/null @@ -1,296 +0,0 @@ -""" -Tests for the authentication menu. -Tests login, logout, profile viewing, and authentication flow. -""" - -import pytest -from unittest.mock import Mock, patch - -from fastanime.cli.interactive.menus.auth import auth -from fastanime.cli.interactive.state import State, ControlFlow - -from .base_test import BaseMenuTest, AuthMenuTestMixin -from ...conftest import TEST_AUTH_OPTIONS - - -class TestAuthMenu(BaseMenuTest, AuthMenuTestMixin): - """Test cases for the authentication menu.""" - - def test_auth_menu_no_choice_goes_back(self, mock_context, basic_state): - """Test that no choice selected results in BACK.""" - self.setup_selector_choice(mock_context, None) - - result = auth(mock_context, basic_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_auth_menu_back_choice(self, mock_context, basic_state): - """Test explicit back choice.""" - self.setup_selector_choice(mock_context, TEST_AUTH_OPTIONS['back']) - - result = auth(mock_context, basic_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_auth_menu_unauthenticated_options(self, mock_unauthenticated_context, basic_state): - """Test menu options when user is not authenticated.""" - self.setup_selector_choice(mock_unauthenticated_context, None) - - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_back_behavior(result) - # Verify correct options are shown for unauthenticated user - mock_unauthenticated_context.selector.choose.assert_called_once() - call_args = mock_unauthenticated_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Should include login and help options - assert any('Login' in choice for choice in choices) - assert any('How to Get Token' in choice for choice in choices) - assert any('Back' in choice for choice in choices) - # Should not include logout or profile options - assert not any('Logout' in choice for choice in choices) - assert not any('Profile Details' in choice for choice in choices) - - def test_auth_menu_authenticated_options(self, mock_context, basic_state, mock_user_profile): - """Test menu options when user is authenticated.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, None) - - result = auth(mock_context, basic_state) - - self.assert_back_behavior(result) - # Verify correct options are shown for authenticated user - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Should include logout and profile options - assert any('Logout' in choice for choice in choices) - assert any('Profile Details' in choice for choice in choices) - assert any('Back' in choice for choice in choices) - # Should not include login options - assert not any('Login' in choice for choice in choices) - assert not any('How to Get Token' in choice for choice in choices) - - def test_auth_menu_login_success(self, mock_unauthenticated_context, basic_state, mock_user_profile): - """Test successful login flow.""" - self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login']) - self.setup_selector_input(mock_unauthenticated_context, "test_token_123") - - # Mock successful authentication - mock_unauthenticated_context.media_api.authenticate.return_value = mock_user_profile - - with self.setup_auth_manager_mock() as mock_auth_manager: - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify authentication was attempted - mock_unauthenticated_context.media_api.authenticate.assert_called_once_with("test_token_123") - # Verify user profile was saved - mock_auth_manager.save_user_profile.assert_called_once() - self.assert_feedback_success_called("Successfully authenticated") - - def test_auth_menu_login_failure(self, mock_unauthenticated_context, basic_state): - """Test failed login flow.""" - self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login']) - self.setup_selector_input(mock_unauthenticated_context, "invalid_token") - - # Mock failed authentication - mock_unauthenticated_context.media_api.authenticate.return_value = None - - with self.setup_auth_manager_mock() as mock_auth_manager: - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify authentication was attempted - mock_unauthenticated_context.media_api.authenticate.assert_called_once_with("invalid_token") - # Verify user profile was not saved - mock_auth_manager.save_user_profile.assert_not_called() - self.assert_feedback_error_called("Authentication failed") - - def test_auth_menu_login_empty_token(self, mock_unauthenticated_context, basic_state): - """Test login with empty token.""" - self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login']) - self.setup_selector_input(mock_unauthenticated_context, "") # Empty token - - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Authentication should not be attempted with empty token - mock_unauthenticated_context.media_api.authenticate.assert_not_called() - self.assert_feedback_warning_called("Token cannot be empty") - - def test_auth_menu_logout_success(self, mock_context, basic_state, mock_user_profile): - """Test successful logout flow.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, TEST_AUTH_OPTIONS['logout']) - self.setup_feedback_confirm(True) # Confirm logout - - with self.setup_auth_manager_mock() as mock_auth_manager: - result = auth(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify logout confirmation was requested - self.mock_feedback.confirm.assert_called_once() - # Verify user profile was cleared - mock_auth_manager.clear_user_profile.assert_called_once() - # Verify API client was updated - assert mock_context.media_api.user_profile is None - self.assert_feedback_success_called("Successfully logged out") - - def test_auth_menu_logout_cancelled(self, mock_context, basic_state, mock_user_profile): - """Test cancelled logout flow.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, TEST_AUTH_OPTIONS['logout']) - self.setup_feedback_confirm(False) # Cancel logout - - with self.setup_auth_manager_mock() as mock_auth_manager: - result = auth(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify logout confirmation was requested - self.mock_feedback.confirm.assert_called_once() - # Verify user profile was not cleared - mock_auth_manager.clear_user_profile.assert_not_called() - # Verify API client still has user profile - assert mock_context.media_api.user_profile == mock_user_profile - self.assert_feedback_info_called("Logout cancelled") - - def test_auth_menu_view_profile(self, mock_context, basic_state, mock_user_profile): - """Test view profile details.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, TEST_AUTH_OPTIONS['profile']) - - result = auth(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify profile information was displayed - self.mock_feedback.pause_for_user.assert_called_once() - - def test_auth_menu_how_to_get_token(self, mock_unauthenticated_context, basic_state): - """Test how to get token help.""" - self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['how_to_token']) - - with self.setup_webbrowser_mock() as mock_browser: - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify browser was opened to AniList developer page - mock_browser.open.assert_called_once() - call_args = mock_browser.open.call_args[0] - assert "anilist.co" in call_args[0].lower() - - def test_auth_menu_icons_disabled(self, mock_unauthenticated_context, basic_state): - """Test menu display with icons disabled.""" - mock_unauthenticated_context.config.general.icons = False - self.setup_selector_choice(mock_unauthenticated_context, None) - - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_back_behavior(result) - # Verify options don't contain emoji icons - mock_unauthenticated_context.selector.choose.assert_called_once() - call_args = mock_unauthenticated_context.selector.choose.call_args - choices = call_args[1]['choices'] - - for choice in choices: - assert not any(char in choice for char in '🔐👤🔓❓↩️') - - def test_auth_menu_display_auth_status_authenticated(self, mock_context, basic_state, mock_user_profile): - """Test auth status display for authenticated user.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, None) - - result = auth(mock_context, basic_state) - - self.assert_back_behavior(result) - # Console should display user information - assert mock_context.media_api.user_profile == mock_user_profile - - def test_auth_menu_display_auth_status_unauthenticated(self, mock_unauthenticated_context, basic_state): - """Test auth status display for unauthenticated user.""" - self.setup_selector_choice(mock_unauthenticated_context, None) - - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_back_behavior(result) - # Should show not authenticated status - assert mock_unauthenticated_context.media_api.user_profile is None - - def test_auth_menu_login_with_whitespace_token(self, mock_unauthenticated_context, basic_state): - """Test login with token containing whitespace.""" - self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login']) - self.setup_selector_input(mock_unauthenticated_context, " test_token_123 ") # Token with spaces - - # Mock successful authentication - mock_unauthenticated_context.media_api.authenticate.return_value = Mock() - - with self.setup_auth_manager_mock(): - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_continue_behavior(result) - # Verify token was stripped of whitespace - mock_unauthenticated_context.media_api.authenticate.assert_called_once_with("test_token_123") - - def test_auth_menu_authentication_exception_handling(self, mock_unauthenticated_context, basic_state): - """Test handling of authentication exceptions.""" - self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login']) - self.setup_selector_input(mock_unauthenticated_context, "test_token") - - # Mock authentication raising an exception - mock_unauthenticated_context.media_api.authenticate.side_effect = Exception("API Error") - - with self.setup_auth_manager_mock(): - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_feedback_error_called("Authentication failed") - - def test_auth_menu_save_profile_failure(self, mock_unauthenticated_context, basic_state, mock_user_profile): - """Test handling of profile save failure after successful auth.""" - self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login']) - self.setup_selector_input(mock_unauthenticated_context, "test_token") - - # Mock successful authentication but failed save - mock_unauthenticated_context.media_api.authenticate.return_value = mock_user_profile - - with self.setup_auth_manager_mock() as mock_auth_manager: - mock_auth_manager.save_user_profile.return_value = False # Save failure - - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_continue_behavior(result) - # Should still show success for authentication even if save fails - self.assert_feedback_success_called("Successfully authenticated") - # Should show warning about save failure - self.assert_feedback_warning_called("Failed to save") - - @pytest.mark.parametrize("user_input", ["", " ", "\t", "\n"]) - def test_auth_menu_various_empty_tokens(self, mock_unauthenticated_context, basic_state, user_input): - """Test various forms of empty token input.""" - self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login']) - self.setup_selector_input(mock_unauthenticated_context, user_input) - - result = auth(mock_unauthenticated_context, basic_state) - - self.assert_continue_behavior(result) - # Should not attempt authentication with empty/whitespace-only tokens - mock_unauthenticated_context.media_api.authenticate.assert_not_called() - self.assert_feedback_warning_called("Token cannot be empty") diff --git a/tests/cli/interactive/menus/test_episodes.py b/tests/cli/interactive/menus/test_episodes.py deleted file mode 100644 index 9191dbd..0000000 --- a/tests/cli/interactive/menus/test_episodes.py +++ /dev/null @@ -1,366 +0,0 @@ -""" -Tests for the episodes menu. -Tests episode selection, watch history integration, and episode navigation. -""" - -import pytest -from unittest.mock import Mock, patch - -from fastanime.cli.interactive.menus.episodes import episodes -from fastanime.cli.interactive.state import State, ControlFlow, ProviderState, MediaApiState -from fastanime.libs.providers.anime.types import Anime, Episodes - -from .base_test import BaseMenuTest, MediaMenuTestMixin - - -class TestEpisodesMenu(BaseMenuTest, MediaMenuTestMixin): - """Test cases for the episodes menu.""" - - @pytest.fixture - def mock_provider_anime(self): - """Create a mock provider anime with episodes.""" - anime = Mock(spec=Anime) - anime.episodes = Mock(spec=Episodes) - anime.episodes.sub = ["1", "2", "3", "4", "5"] - anime.episodes.dub = ["1", "2", "3"] - anime.episodes.raw = [] - anime.title = "Test Anime" - return anime - - @pytest.fixture - def episodes_state(self, mock_provider_anime, mock_media_item): - """Create a state with provider anime and media api data.""" - return State( - menu_name="EPISODES", - provider=ProviderState(anime=mock_provider_anime), - media_api=MediaApiState(anime=mock_media_item) - ) - - def test_episodes_menu_missing_provider_anime_goes_back(self, mock_context, basic_state): - """Test that missing provider anime returns BACK.""" - # State with no provider anime - state_no_anime = State( - menu_name="EPISODES", - provider=ProviderState(anime=None), - media_api=MediaApiState() - ) - - result = episodes(mock_context, state_no_anime) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_episodes_menu_missing_media_api_anime_goes_back(self, mock_context, mock_provider_anime): - """Test that missing media api anime returns BACK.""" - # State with provider anime but no media api anime - state_no_media = State( - menu_name="EPISODES", - provider=ProviderState(anime=mock_provider_anime), - media_api=MediaApiState(anime=None) - ) - - result = episodes(mock_context, state_no_media) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_episodes_menu_no_episodes_available_goes_back(self, mock_context, episodes_state): - """Test that no available episodes returns BACK.""" - # Configure translation type that has no episodes - mock_context.config.stream.translation_type = "raw" - - result = episodes(mock_context, episodes_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_episodes_menu_no_choice_goes_back(self, mock_context, episodes_state): - """Test that no choice selected results in BACK.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, None) - - result = episodes(mock_context, episodes_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_episodes_menu_episode_selection(self, mock_context, episodes_state): - """Test normal episode selection.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "Episode 3") - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - self.assert_console_cleared() - # Verify the selected episode is stored in the new state - assert "3" in str(result.provider.selected_episode) - - def test_episodes_menu_continue_from_local_watch_history(self, mock_context, episodes_state): - """Test continuing from local watch history.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = True - mock_context.config.stream.preferred_watch_history = "local" - - with patch('fastanime.cli.utils.watch_history_tracker.get_continue_episode') as mock_get_continue: - mock_get_continue.return_value = "3" # Continue from episode 3 - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - self.assert_console_cleared() - - # Verify continue episode was retrieved - mock_get_continue.assert_called_once() - # Verify the continue episode is selected - assert "3" in str(result.provider.selected_episode) - - def test_episodes_menu_continue_from_anilist_progress(self, mock_context, episodes_state, mock_media_item): - """Test continuing from AniList progress.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = True - mock_context.config.stream.preferred_watch_history = "remote" - - # Mock AniList progress - mock_media_item.progress = 2 # Watched 2 episodes, continue from 3 - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - self.assert_console_cleared() - # Should continue from next episode after progress - assert "3" in str(result.provider.selected_episode) - - def test_episodes_menu_no_watch_history_fallback_to_manual(self, mock_context, episodes_state): - """Test fallback to manual selection when no watch history.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = True - mock_context.config.stream.preferred_watch_history = "local" - - with patch('fastanime.cli.utils.watch_history_tracker.get_continue_episode') as mock_get_continue: - mock_get_continue.return_value = None # No continue episode - self.setup_selector_choice(mock_context, "Episode 1") - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - self.assert_console_cleared() - - # Should fall back to manual selection - mock_context.selector.choose.assert_called_once() - - def test_episodes_menu_translation_type_sub(self, mock_context, episodes_state): - """Test with subtitle translation type.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "Episode 1") - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - mock_context.selector.choose.assert_called_once() - # Verify subtitle episodes are available - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - assert len([c for c in choices if "Episode" in c]) == 5 # 5 sub episodes - - def test_episodes_menu_translation_type_dub(self, mock_context, episodes_state): - """Test with dub translation type.""" - mock_context.config.stream.translation_type = "dub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "Episode 1") - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - mock_context.selector.choose.assert_called_once() - # Verify dub episodes are available - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - assert len([c for c in choices if "Episode" in c]) == 3 # 3 dub episodes - - def test_episodes_menu_range_selection(self, mock_context, episodes_state): - """Test episode range selection.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "📚 Select Range") - - # Mock range input - with patch.object(mock_context.selector, 'input', return_value="2-4"): - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - self.assert_console_cleared() - # Should handle range selection - mock_context.selector.input.assert_called_once() - - def test_episodes_menu_invalid_range_selection(self, mock_context, episodes_state): - """Test invalid episode range selection.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "📚 Select Range") - - # Mock invalid range input - with patch.object(mock_context.selector, 'input', return_value="invalid-range"): - result = episodes(mock_context, episodes_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Invalid range format") - - def test_episodes_menu_watch_all_episodes(self, mock_context, episodes_state): - """Test watch all episodes option.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "🎬 Watch All Episodes") - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - self.assert_console_cleared() - # Should set up for watching all episodes - - def test_episodes_menu_random_episode(self, mock_context, episodes_state): - """Test random episode selection.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "🎲 Random Episode") - - with patch('random.choice') as mock_random: - mock_random.return_value = "3" - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - self.assert_console_cleared() - mock_random.assert_called_once() - - def test_episodes_menu_icons_disabled(self, mock_context, episodes_state): - """Test menu display with icons disabled.""" - mock_context.config.general.icons = False - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, None) - - result = episodes(mock_context, episodes_state) - - self.assert_back_behavior(result) - # Verify options don't contain emoji icons - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - for choice in choices: - assert not any(char in choice for char in '📚🎬🎲') - - def test_episodes_menu_progress_indicator(self, mock_context, episodes_state, mock_media_item): - """Test episode progress indicators.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - mock_media_item.progress = 3 # Watched 3 episodes - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.watch_history_tracker.get_watched_episodes') as mock_watched: - mock_watched.return_value = ["1", "2", "3"] - - result = episodes(mock_context, episodes_state) - - self.assert_back_behavior(result) - # Verify progress indicators were applied - mock_watched.assert_called_once() - - def test_episodes_menu_large_episode_count(self, mock_context, episodes_state, mock_provider_anime): - """Test handling of anime with many episodes.""" - # Create anime with many episodes - mock_provider_anime.episodes.sub = [str(i) for i in range(1, 101)] # 100 episodes - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, None) - - result = episodes(mock_context, episodes_state) - - self.assert_back_behavior(result) - # Should handle large episode counts gracefully - mock_context.selector.choose.assert_called_once() - - def test_episodes_menu_zero_padded_episodes(self, mock_context, episodes_state, mock_provider_anime): - """Test handling of zero-padded episode numbers.""" - mock_provider_anime.episodes.sub = ["01", "02", "03", "04", "05"] - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "Episode 01") - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - # Should handle zero-padded episodes correctly - assert "01" in str(result.provider.selected_episode) - - def test_episodes_menu_special_episodes(self, mock_context, episodes_state, mock_provider_anime): - """Test handling of special episode formats.""" - mock_provider_anime.episodes.sub = ["1", "2", "3", "S1", "OVA1", "Movie"] - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "Episode S1") - - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - # Should handle special episode formats - assert "S1" in str(result.provider.selected_episode) - - def test_episodes_menu_watch_history_tracking(self, mock_context, episodes_state): - """Test that episode viewing is tracked.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, "Episode 2") - - with patch('fastanime.cli.utils.watch_history_tracker.track_episode_viewing') as mock_track: - result = episodes(mock_context, episodes_state) - - self.assert_menu_transition(result, "SERVERS") - # Verify episode viewing is tracked (if implemented in the menu) - # This depends on the actual implementation - - def test_episodes_menu_episode_metadata_display(self, mock_context, episodes_state): - """Test episode metadata in choices.""" - mock_context.config.stream.translation_type = "sub" - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, None) - - result = episodes(mock_context, episodes_state) - - self.assert_back_behavior(result) - # Verify episode choices include relevant metadata - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Episode choices should be formatted appropriately - episode_choices = [c for c in choices if "Episode" in c] - assert len(episode_choices) > 0 - - @pytest.mark.parametrize("translation_type,expected_count", [ - ("sub", 5), - ("dub", 3), - ("raw", 0), - ]) - def test_episodes_menu_translation_types(self, mock_context, episodes_state, translation_type, expected_count): - """Test various translation types.""" - mock_context.config.stream.translation_type = translation_type - mock_context.config.stream.continue_from_watch_history = False - self.setup_selector_choice(mock_context, None) - - result = episodes(mock_context, episodes_state) - - if expected_count == 0: - self.assert_back_behavior(result) - else: - self.assert_back_behavior(result) # Since no choice was made - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - episode_choices = [c for c in choices if "Episode" in c] - assert len(episode_choices) == expected_count diff --git a/tests/cli/interactive/menus/test_main.py b/tests/cli/interactive/menus/test_main.py deleted file mode 100644 index 86ac548..0000000 --- a/tests/cli/interactive/menus/test_main.py +++ /dev/null @@ -1,295 +0,0 @@ -""" -Tests for the main interactive menu. -Tests all navigation options and control flow logic. -""" - -import pytest -from unittest.mock import Mock, patch - -from fastanime.cli.interactive.menus.main import main -from fastanime.cli.interactive.state import State, ControlFlow -from fastanime.libs.api.types import MediaSearchResult - -from .base_test import BaseMenuTest, MediaMenuTestMixin -from ...conftest import TEST_MENU_OPTIONS - - -class TestMainMenu(BaseMenuTest, MediaMenuTestMixin): - """Test cases for the main interactive menu.""" - - def test_main_menu_no_choice_exits(self, mock_context, basic_state): - """Test that no choice selected results in EXIT.""" - # User cancels/exits the menu - self.setup_selector_choice(mock_context, None) - - result = main(mock_context, basic_state) - - self.assert_exit_behavior(result) - self.assert_console_cleared() - - def test_main_menu_exit_choice(self, mock_context, basic_state): - """Test explicit exit choice.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['exit']) - - result = main(mock_context, basic_state) - - self.assert_exit_behavior(result) - self.assert_console_cleared() - - def test_main_menu_reload_config_choice(self, mock_context, basic_state): - """Test config reload choice.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['edit_config']) - - result = main(mock_context, basic_state) - - self.assert_reload_config_behavior(result) - self.assert_console_cleared() - - def test_main_menu_session_management_choice(self, mock_context, basic_state): - """Test session management navigation.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['session_management']) - - result = main(mock_context, basic_state) - - self.assert_menu_transition(result, "SESSION_MANAGEMENT") - self.assert_console_cleared() - - def test_main_menu_auth_choice(self, mock_context, basic_state): - """Test authentication menu navigation.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['auth']) - - result = main(mock_context, basic_state) - - self.assert_menu_transition(result, "AUTH") - self.assert_console_cleared() - - def test_main_menu_watch_history_choice(self, mock_context, basic_state): - """Test watch history navigation.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['watch_history']) - - result = main(mock_context, basic_state) - - self.assert_menu_transition(result, "WATCH_HISTORY") - self.assert_console_cleared() - - @pytest.mark.parametrize("choice_key,expected_menu", [ - ("trending", "RESULTS"), - ("popular", "RESULTS"), - ("favourites", "RESULTS"), - ("top_scored", "RESULTS"), - ("upcoming", "RESULTS"), - ("recently_updated", "RESULTS"), - ]) - def test_main_menu_media_list_choices_success(self, mock_context, basic_state, choice_key, expected_menu, mock_media_search_result): - """Test successful media list navigation for various categories.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS[choice_key]) - self.setup_media_list_success(mock_context, mock_media_search_result) - - result = main(mock_context, basic_state) - - self.assert_menu_transition(result, expected_menu) - self.assert_console_cleared() - # Verify API was called - mock_context.media_api.search_media.assert_called_once() - - @pytest.mark.parametrize("choice_key", [ - "trending", - "popular", - "favourites", - "top_scored", - "upcoming", - "recently_updated", - ]) - def test_main_menu_media_list_choices_failure(self, mock_context, basic_state, choice_key): - """Test failed media list fetch shows error and continues.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS[choice_key]) - self.setup_media_list_failure(mock_context) - - result = main(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to fetch data") - - @pytest.mark.parametrize("choice_key,expected_menu", [ - ("watching", "RESULTS"), - ("planned", "RESULTS"), - ("completed", "RESULTS"), - ("paused", "RESULTS"), - ("dropped", "RESULTS"), - ("rewatching", "RESULTS"), - ]) - def test_main_menu_user_list_choices_success(self, mock_context, basic_state, choice_key, expected_menu, mock_media_search_result): - """Test successful user list navigation for authenticated users.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS[choice_key]) - self.setup_media_list_success(mock_context, mock_media_search_result) - - result = main(mock_context, basic_state) - - self.assert_menu_transition(result, expected_menu) - self.assert_console_cleared() - # Verify API was called - mock_context.media_api.get_user_media_list.assert_called_once() - - @pytest.mark.parametrize("choice_key", [ - "watching", - "planned", - "completed", - "paused", - "dropped", - "rewatching", - ]) - def test_main_menu_user_list_choices_failure(self, mock_context, basic_state, choice_key): - """Test failed user list fetch shows error and continues.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS[choice_key]) - mock_context.media_api.get_user_media_list.return_value = None - - result = main(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to fetch data") - - def test_main_menu_random_choice_success(self, mock_context, basic_state, mock_media_search_result): - """Test random anime selection success.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['random']) - self.setup_media_list_success(mock_context, mock_media_search_result) - - with patch('random.choice') as mock_random: - mock_random.return_value = "Action" # Mock random genre selection - - result = main(mock_context, basic_state) - - self.assert_menu_transition(result, "RESULTS") - self.assert_console_cleared() - mock_context.media_api.search_media.assert_called_once() - - def test_main_menu_random_choice_failure(self, mock_context, basic_state): - """Test random anime selection failure.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['random']) - self.setup_media_list_failure(mock_context) - - with patch('random.choice') as mock_random: - mock_random.return_value = "Action" - - result = main(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to fetch data") - - def test_main_menu_search_choice_success(self, mock_context, basic_state, mock_media_search_result): - """Test search functionality success.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['search']) - self.setup_selector_input(mock_context, "test anime") - self.setup_media_list_success(mock_context, mock_media_search_result) - - result = main(mock_context, basic_state) - - self.assert_menu_transition(result, "RESULTS") - self.assert_console_cleared() - mock_context.selector.input.assert_called_once() - mock_context.media_api.search_media.assert_called_once() - - def test_main_menu_search_choice_empty_query(self, mock_context, basic_state): - """Test search with empty query continues.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['search']) - self.setup_selector_input(mock_context, "") # Empty search query - - result = main(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - mock_context.selector.input.assert_called_once() - # API should not be called with empty query - mock_context.media_api.search_media.assert_not_called() - - def test_main_menu_search_choice_failure(self, mock_context, basic_state): - """Test search functionality failure.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['search']) - self.setup_selector_input(mock_context, "test anime") - self.setup_media_list_failure(mock_context) - - result = main(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to fetch data") - - def test_main_menu_icons_disabled(self, mock_context, basic_state): - """Test menu display with icons disabled.""" - mock_context.config.general.icons = False - self.setup_selector_choice(mock_context, None) # Exit immediately - - result = main(mock_context, basic_state) - - self.assert_exit_behavior(result) - # Verify selector was called with non-icon options - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - # Verify no emoji icons in choices - for choice in choices: - assert not any(char in choice for char in '🔥✨💖💯🎬🔔🎲🔎📺📑✅⏸️🚮🔁📖🔐🔧📝❌') - - def test_main_menu_authenticated_user_header(self, mock_context, basic_state, mock_user_profile): - """Test that authenticated user info appears in header.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, None) # Exit immediately - - result = main(mock_context, basic_state) - - self.assert_exit_behavior(result) - # Verify selector was called with header containing user info - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - header = call_args[1]['header'] - assert mock_user_profile.name in header - - def test_main_menu_unauthenticated_user_header(self, mock_unauthenticated_context, basic_state): - """Test that unauthenticated user gets appropriate header.""" - self.setup_selector_choice(mock_unauthenticated_context, None) # Exit immediately - - result = main(mock_unauthenticated_context, basic_state) - - self.assert_exit_behavior(result) - # Verify selector was called with appropriate header - mock_unauthenticated_context.selector.choose.assert_called_once() - call_args = mock_unauthenticated_context.selector.choose.call_args - header = call_args[1]['header'] - assert "Not authenticated" in header or "FastAnime Main Menu" in header - - def test_main_menu_user_list_authentication_required(self, mock_unauthenticated_context, basic_state): - """Test that user list options require authentication.""" - # Test that user list options either don't appear or show auth error - self.setup_selector_choice(mock_unauthenticated_context, TEST_MENU_OPTIONS['watching']) - - # This should either not be available or show an auth error - with patch('fastanime.cli.utils.auth_utils.check_authentication_required') as mock_auth_check: - mock_auth_check.return_value = False # Auth required but not authenticated - - result = main(mock_unauthenticated_context, basic_state) - - # Should continue (show error) or redirect to auth - assert isinstance(result, (ControlFlow, State)) - - @pytest.mark.parametrize("media_list_size", [0, 1, 5, 20]) - def test_main_menu_various_result_sizes(self, mock_context, basic_state, media_list_size): - """Test handling of various media list result sizes.""" - self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['trending']) - - if media_list_size == 0: - # Empty result - mock_result = MediaSearchResult(media=[], page_info={"total": 0, "current_page": 1, "last_page": 1, "has_next_page": False, "per_page": 20}) - else: - mock_result = self.create_mock_media_result(media_list_size) - - self.setup_media_list_success(mock_context, mock_result) - - result = main(mock_context, basic_state) - - if media_list_size == 0: - # Empty results might show a message and continue - assert isinstance(result, (State, ControlFlow)) - else: - self.assert_menu_transition(result, "RESULTS") diff --git a/tests/cli/interactive/menus/test_media_actions.py b/tests/cli/interactive/menus/test_media_actions.py deleted file mode 100644 index d7e0263..0000000 --- a/tests/cli/interactive/menus/test_media_actions.py +++ /dev/null @@ -1,360 +0,0 @@ -""" -Tests for the media actions menu. -Tests anime-specific actions like adding to list, searching providers, etc. -""" - -import pytest -from unittest.mock import Mock, patch - -from fastanime.cli.interactive.menus.media_actions import media_actions -from fastanime.cli.interactive.state import State, ControlFlow, MediaApiState - -from .base_test import BaseMenuTest, MediaMenuTestMixin - - -class TestMediaActionsMenu(BaseMenuTest, MediaMenuTestMixin): - """Test cases for the media actions menu.""" - - def test_media_actions_no_anime_goes_back(self, mock_context, basic_state): - """Test that missing anime data returns BACK.""" - # State with no anime data - state_no_anime = State( - menu_name="MEDIA_ACTIONS", - media_api=MediaApiState(anime=None) - ) - - result = media_actions(mock_context, state_no_anime) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_media_actions_no_choice_goes_back(self, mock_context, state_with_media_data): - """Test that no choice selected results in BACK.""" - self.setup_selector_choice(mock_context, None) - - result = media_actions(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_media_actions_back_choice(self, mock_context, state_with_media_data): - """Test explicit back choice.""" - self.setup_selector_choice(mock_context, "↩️ Back") - - result = media_actions(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_media_actions_search_providers(self, mock_context, state_with_media_data): - """Test searching providers for the anime.""" - self.setup_selector_choice(mock_context, "🔍 Search Providers") - - result = media_actions(mock_context, state_with_media_data) - - self.assert_menu_transition(result, "PROVIDER_SEARCH") - self.assert_console_cleared() - - def test_media_actions_add_to_list_authenticated(self, mock_context, state_with_media_data, mock_user_profile): - """Test adding anime to list when authenticated.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, "➕ Add to List") - - # Mock status selection - with patch.object(mock_context.selector, 'choose', side_effect=["WATCHING"]): - mock_context.media_api.update_list_entry.return_value = True - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify list update was attempted - mock_context.media_api.update_list_entry.assert_called_once() - self.assert_feedback_success_called("Added to list") - - def test_media_actions_add_to_list_unauthenticated(self, mock_unauthenticated_context, state_with_media_data): - """Test adding anime to list when not authenticated.""" - self.setup_selector_choice(mock_unauthenticated_context, "➕ Add to List") - - result = media_actions(mock_unauthenticated_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Authentication required") - - def test_media_actions_update_list_entry(self, mock_context, state_with_media_data, mock_user_profile): - """Test updating existing list entry.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, "✏️ Update List Entry") - - # Mock current status and new status selection - with patch.object(mock_context.selector, 'choose', side_effect=["COMPLETED"]): - mock_context.media_api.update_list_entry.return_value = True - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify list update was attempted - mock_context.media_api.update_list_entry.assert_called_once() - self.assert_feedback_success_called("List entry updated") - - def test_media_actions_remove_from_list(self, mock_context, state_with_media_data, mock_user_profile): - """Test removing anime from list.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, "🗑️ Remove from List") - self.setup_feedback_confirm(True) # Confirm removal - - mock_context.media_api.delete_list_entry.return_value = True - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify removal was attempted - mock_context.media_api.delete_list_entry.assert_called_once() - self.assert_feedback_success_called("Removed from list") - - def test_media_actions_remove_from_list_cancelled(self, mock_context, state_with_media_data, mock_user_profile): - """Test cancelled removal from list.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, "🗑️ Remove from List") - self.setup_feedback_confirm(False) # Cancel removal - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify removal was not attempted - mock_context.media_api.delete_list_entry.assert_not_called() - self.assert_feedback_info_called("Removal cancelled") - - def test_media_actions_view_details(self, mock_context, state_with_media_data): - """Test viewing anime details.""" - self.setup_selector_choice(mock_context, "📋 View Details") - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - # Should display details and pause for user - self.mock_feedback.pause_for_user.assert_called_once() - - def test_media_actions_view_characters(self, mock_context, state_with_media_data): - """Test viewing anime characters.""" - self.setup_selector_choice(mock_context, "👥 View Characters") - - # Mock character data - mock_characters = [ - {"name": "Character 1", "role": "MAIN"}, - {"name": "Character 2", "role": "SUPPORTING"} - ] - mock_context.media_api.get_anime_characters.return_value = mock_characters - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify characters were fetched - mock_context.media_api.get_anime_characters.assert_called_once() - self.mock_feedback.pause_for_user.assert_called_once() - - def test_media_actions_view_staff(self, mock_context, state_with_media_data): - """Test viewing anime staff.""" - self.setup_selector_choice(mock_context, "🎬 View Staff") - - # Mock staff data - mock_staff = [ - {"name": "Director Name", "role": "Director"}, - {"name": "Studio Name", "role": "Studio"} - ] - mock_context.media_api.get_anime_staff.return_value = mock_staff - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify staff were fetched - mock_context.media_api.get_anime_staff.assert_called_once() - self.mock_feedback.pause_for_user.assert_called_once() - - def test_media_actions_view_reviews(self, mock_context, state_with_media_data): - """Test viewing anime reviews.""" - self.setup_selector_choice(mock_context, "⭐ View Reviews") - - # Mock review data - mock_reviews = [ - {"author": "User1", "rating": 9, "summary": "Great anime!"}, - {"author": "User2", "rating": 7, "summary": "Pretty good."} - ] - mock_context.media_api.get_anime_reviews.return_value = mock_reviews - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify reviews were fetched - mock_context.media_api.get_anime_reviews.assert_called_once() - self.mock_feedback.pause_for_user.assert_called_once() - - def test_media_actions_view_recommendations(self, mock_context, state_with_media_data): - """Test viewing anime recommendations.""" - self.setup_selector_choice(mock_context, "💡 View Recommendations") - - # Mock recommendation data - mock_recommendations = self.create_mock_media_result(3) - mock_context.media_api.get_anime_recommendations.return_value = mock_recommendations - - result = media_actions(mock_context, state_with_media_data) - - self.assert_menu_transition(result, "RESULTS") - self.assert_console_cleared() - - # Verify recommendations were fetched - mock_context.media_api.get_anime_recommendations.assert_called_once() - - def test_media_actions_set_progress(self, mock_context, state_with_media_data, mock_user_profile): - """Test setting anime progress.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, "📊 Set Progress") - self.setup_selector_input(mock_context, "5") # Episode 5 - - mock_context.media_api.update_list_entry.return_value = True - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify progress update was attempted - mock_context.media_api.update_list_entry.assert_called_once() - self.assert_feedback_success_called("Progress updated") - - def test_media_actions_set_score(self, mock_context, state_with_media_data, mock_user_profile): - """Test setting anime score.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, "🌟 Set Score") - self.setup_selector_input(mock_context, "8") # Score of 8 - - mock_context.media_api.update_list_entry.return_value = True - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify score update was attempted - mock_context.media_api.update_list_entry.assert_called_once() - self.assert_feedback_success_called("Score updated") - - def test_media_actions_open_external_links(self, mock_context, state_with_media_data): - """Test opening external links.""" - self.setup_selector_choice(mock_context, "🔗 External Links") - - # Mock external links submenu - with patch.object(mock_context.selector, 'choose', side_effect=["AniList Page"]): - with patch('webbrowser.open') as mock_browser: - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify browser was opened - mock_browser.assert_called_once() - - def test_media_actions_icons_disabled(self, mock_context, state_with_media_data): - """Test menu display with icons disabled.""" - mock_context.config.general.icons = False - self.setup_selector_choice(mock_context, None) - - result = media_actions(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - # Verify options don't contain emoji icons - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - for choice in choices: - assert not any(char in choice for char in '🔍➕✏️🗑️📋👥🎬⭐💡📊🌟🔗↩️') - - def test_media_actions_api_failures(self, mock_context, state_with_media_data, mock_user_profile): - """Test handling of API failures.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, "➕ Add to List") - - # Mock API failure - mock_context.media_api.update_list_entry.return_value = False - - with patch.object(mock_context.selector, 'choose', side_effect=["WATCHING"]): - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to update list") - - def test_media_actions_invalid_input_handling(self, mock_context, state_with_media_data, mock_user_profile): - """Test handling of invalid user input.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, "📊 Set Progress") - self.setup_selector_input(mock_context, "invalid") # Invalid progress - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Invalid progress") - - @pytest.mark.parametrize("list_status", ["WATCHING", "COMPLETED", "PLANNING", "PAUSED", "DROPPED"]) - def test_media_actions_various_list_statuses(self, mock_context, state_with_media_data, mock_user_profile, list_status): - """Test adding anime to list with various statuses.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, "➕ Add to List") - - with patch.object(mock_context.selector, 'choose', side_effect=[list_status]): - mock_context.media_api.update_list_entry.return_value = True - - result = media_actions(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify the status was used - call_args = mock_context.media_api.update_list_entry.call_args - assert list_status in str(call_args) - - def test_media_actions_anime_details_display(self, mock_context, state_with_media_data, mock_media_item): - """Test anime details are properly displayed in header.""" - self.setup_selector_choice(mock_context, None) - - result = media_actions(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - # Verify anime details appear in header - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - header = call_args[1].get('header', '') - assert mock_media_item.title in header - - def test_media_actions_authentication_status_context(self, mock_unauthenticated_context, state_with_media_data): - """Test that authentication status affects available options.""" - self.setup_selector_choice(mock_unauthenticated_context, None) - - result = media_actions(mock_unauthenticated_context, state_with_media_data) - - self.assert_back_behavior(result) - # Verify authentication-dependent options are handled appropriately - mock_unauthenticated_context.selector.choose.assert_called_once() - call_args = mock_unauthenticated_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # List management options should either not appear or show auth prompts - list_actions = [c for c in choices if any(action in c for action in ["Add to List", "Update List", "Remove from List"])] - # These should either be absent or handled with auth checks diff --git a/tests/cli/interactive/menus/test_results.py b/tests/cli/interactive/menus/test_results.py deleted file mode 100644 index a0d8439..0000000 --- a/tests/cli/interactive/menus/test_results.py +++ /dev/null @@ -1,346 +0,0 @@ -""" -Tests for the results menu. -Tests anime result display, pagination, and selection. -""" - -import pytest -from unittest.mock import Mock, patch - -from fastanime.cli.interactive.menus.results import results -from fastanime.cli.interactive.state import State, ControlFlow, MediaApiState - -from .base_test import BaseMenuTest, MediaMenuTestMixin - - -class TestResultsMenu(BaseMenuTest, MediaMenuTestMixin): - """Test cases for the results menu.""" - - def test_results_menu_no_results_goes_back(self, mock_context, basic_state): - """Test that no results returns BACK.""" - # State with no search results - state_no_results = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=None) - ) - - result = results(mock_context, state_no_results) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_results_menu_empty_results_goes_back(self, mock_context, basic_state): - """Test that empty results returns BACK.""" - # State with empty search results - from fastanime.libs.api.types import MediaSearchResult - - empty_results = MediaSearchResult( - media=[], - page_info={"total": 0, "current_page": 1, "last_page": 1, "has_next_page": False, "per_page": 20} - ) - - state_empty = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=empty_results) - ) - - result = results(mock_context, state_empty) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_results_menu_no_choice_goes_back(self, mock_context, state_with_media_data): - """Test that no choice selected results in BACK.""" - self.setup_selector_choice(mock_context, None) - - result = results(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_results_menu_back_choice(self, mock_context, state_with_media_data): - """Test explicit back choice.""" - self.setup_selector_choice(mock_context, "↩️ Back") - - result = results(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_results_menu_anime_selection(self, mock_context, state_with_media_data, mock_media_item): - """Test selecting an anime transitions to media actions.""" - # Mock formatted anime title choice - formatted_title = f"{mock_media_item.title} ({mock_media_item.status})" - self.setup_selector_choice(mock_context, formatted_title) - - with patch('fastanime.cli.interactive.menus.results._format_anime_choice', return_value=formatted_title): - result = results(mock_context, state_with_media_data) - - self.assert_menu_transition(result, "MEDIA_ACTIONS") - self.assert_console_cleared() - # Verify the selected anime is stored in the new state - assert result.media_api.anime == mock_media_item - - def test_results_menu_next_page_navigation(self, mock_context, mock_media_search_result): - """Test next page navigation.""" - # Create results with next page available - mock_media_search_result.page_info["has_next_page"] = True - mock_media_search_result.page_info["current_page"] = 1 - - state_with_pagination = State( - menu_name="RESULTS", - media_api=MediaApiState( - search_results=mock_media_search_result, - original_api_params=Mock() - ) - ) - - self.setup_selector_choice(mock_context, "➡️ Next Page (Page 2)") - mock_context.media_api.search_media.return_value = mock_media_search_result - - result = results(mock_context, state_with_pagination) - - self.assert_menu_transition(result, "RESULTS") - self.assert_console_cleared() - # Verify API was called for next page - mock_context.media_api.search_media.assert_called_once() - - def test_results_menu_previous_page_navigation(self, mock_context, mock_media_search_result): - """Test previous page navigation.""" - # Create results with previous page available - mock_media_search_result.page_info["has_next_page"] = False - mock_media_search_result.page_info["current_page"] = 2 - - state_with_pagination = State( - menu_name="RESULTS", - media_api=MediaApiState( - search_results=mock_media_search_result, - original_api_params=Mock() - ) - ) - - self.setup_selector_choice(mock_context, "⬅️ Previous Page (Page 1)") - mock_context.media_api.search_media.return_value = mock_media_search_result - - result = results(mock_context, state_with_pagination) - - self.assert_menu_transition(result, "RESULTS") - self.assert_console_cleared() - # Verify API was called for previous page - mock_context.media_api.search_media.assert_called_once() - - def test_results_menu_pagination_failure(self, mock_context, mock_media_search_result): - """Test pagination request failure.""" - mock_media_search_result.page_info["has_next_page"] = True - mock_media_search_result.page_info["current_page"] = 1 - - state_with_pagination = State( - menu_name="RESULTS", - media_api=MediaApiState( - search_results=mock_media_search_result, - original_api_params=Mock() - ) - ) - - self.setup_selector_choice(mock_context, "➡️ Next Page (Page 2)") - mock_context.media_api.search_media.return_value = None # Pagination fails - - result = results(mock_context, state_with_pagination) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to load") - - def test_results_menu_icons_disabled(self, mock_context, state_with_media_data): - """Test menu display with icons disabled.""" - mock_context.config.general.icons = False - self.setup_selector_choice(mock_context, None) - - result = results(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - # Verify options don't contain emoji icons - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Navigation choices should not have emoji - navigation_choices = [choice for choice in choices if "Page" in choice or "Back" in choice] - for choice in navigation_choices: - assert not any(char in choice for char in '➡️⬅️↩️') - - def test_results_menu_preview_enabled(self, mock_context, state_with_media_data): - """Test that preview is set up when enabled.""" - mock_context.config.general.preview = "image" - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.previews.get_anime_preview') as mock_preview: - mock_preview.return_value = "preview_command" - - result = results(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - # Verify preview was set up - mock_preview.assert_called_once() - - def test_results_menu_preview_disabled(self, mock_context, state_with_media_data): - """Test that preview is not set up when disabled.""" - mock_context.config.general.preview = "none" - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.previews.get_anime_preview') as mock_preview: - result = results(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - # Verify preview was not set up - mock_preview.assert_not_called() - - def test_results_menu_new_search_option(self, mock_context, state_with_media_data): - """Test new search option.""" - self.setup_selector_choice(mock_context, "🔍 New Search") - - result = results(mock_context, state_with_media_data) - - self.assert_menu_transition(result, "PROVIDER_SEARCH") - self.assert_console_cleared() - - def test_results_menu_sort_and_filter_option(self, mock_context, state_with_media_data): - """Test sort and filter option.""" - self.setup_selector_choice(mock_context, "🔧 Sort & Filter") - - result = results(mock_context, state_with_media_data) - - self.assert_continue_behavior(result) # Usually shows sort/filter submenu - self.assert_console_cleared() - - @pytest.mark.parametrize("num_results", [1, 5, 20, 50]) - def test_results_menu_various_result_counts(self, mock_context, basic_state, num_results): - """Test handling of various result counts.""" - mock_result = self.create_mock_media_result(num_results) - - state_with_results = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=mock_result) - ) - - self.setup_selector_choice(mock_context, None) - - result = results(mock_context, state_with_results) - - if num_results > 0: - self.assert_back_behavior(result) - # Verify choices include all anime titles - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - # Should have anime choices plus navigation options - assert len([c for c in choices if "Page" not in c and "Back" not in c and "Search" not in c]) >= num_results - else: - self.assert_back_behavior(result) - - def test_results_menu_pagination_edge_cases(self, mock_context, mock_media_search_result): - """Test pagination edge cases (first page, last page).""" - # Test first page (no previous page option) - mock_media_search_result.page_info["current_page"] = 1 - mock_media_search_result.page_info["has_next_page"] = True - - state_first_page = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=mock_media_search_result) - ) - - self.setup_selector_choice(mock_context, None) - - result = results(mock_context, state_first_page) - - self.assert_back_behavior(result) - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Should have next page but no previous page - assert any("Next Page" in choice for choice in choices) - assert not any("Previous Page" in choice for choice in choices) - - def test_results_menu_last_page(self, mock_context, mock_media_search_result): - """Test last page (no next page option).""" - mock_media_search_result.page_info["current_page"] = 5 - mock_media_search_result.page_info["has_next_page"] = False - - state_last_page = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=mock_media_search_result) - ) - - self.setup_selector_choice(mock_context, None) - - result = results(mock_context, state_last_page) - - self.assert_back_behavior(result) - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Should have previous page but no next page - assert any("Previous Page" in choice for choice in choices) - assert not any("Next Page" in choice for choice in choices) - - def test_results_menu_anime_formatting(self, mock_context, state_with_media_data, mock_media_item): - """Test anime choice formatting.""" - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.interactive.menus.results._format_anime_choice') as mock_format: - expected_format = f"{mock_media_item.title} ({mock_media_item.status}) - Score: {mock_media_item.mean_score}" - mock_format.return_value = expected_format - - result = results(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - # Verify formatting function was called - mock_format.assert_called_once() - - def test_results_menu_auth_status_in_header(self, mock_context, state_with_media_data, mock_user_profile): - """Test that auth status appears in header.""" - mock_context.media_api.user_profile = mock_user_profile - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.auth_utils.get_auth_status_indicator') as mock_auth_status: - mock_auth_status.return_value = f"👤 {mock_user_profile.name}" - - result = results(mock_context, state_with_media_data) - - self.assert_back_behavior(result) - # Verify auth status was included - mock_auth_status.assert_called_once() - - def test_results_menu_error_handling_during_selection(self, mock_context, state_with_media_data): - """Test error handling during anime selection.""" - self.setup_selector_choice(mock_context, "Invalid Choice") - - result = results(mock_context, state_with_media_data) - - # Should handle invalid choice gracefully - assert isinstance(result, (State, ControlFlow)) - self.assert_console_cleared() - - def test_results_menu_user_list_context(self, mock_context, mock_media_search_result): - """Test results from user list context.""" - # State indicating results came from user list - state_user_list = State( - menu_name="RESULTS", - media_api=MediaApiState( - search_results=mock_media_search_result, - search_results_type="USER_MEDIA_LIST", - user_media_status="WATCHING" - ) - ) - - self.setup_selector_choice(mock_context, None) - - result = results(mock_context, state_user_list) - - self.assert_back_behavior(result) - # Header should indicate this is a user list - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - header = call_args[1].get('header', '') - # Should contain user list context information diff --git a/tests/cli/interactive/menus/test_session_management.py b/tests/cli/interactive/menus/test_session_management.py deleted file mode 100644 index eae73fd..0000000 --- a/tests/cli/interactive/menus/test_session_management.py +++ /dev/null @@ -1,380 +0,0 @@ -""" -Tests for the session management menu. -Tests saving, loading, and managing session state. -""" - -import pytest -from unittest.mock import Mock, patch -from pathlib import Path -from datetime import datetime - -from fastanime.cli.interactive.menus.session_management import session_management -from fastanime.cli.interactive.state import State, ControlFlow - -from .base_test import BaseMenuTest, SessionMenuTestMixin - - -class TestSessionManagementMenu(BaseMenuTest, SessionMenuTestMixin): - """Test cases for the session management menu.""" - - @pytest.fixture - def mock_session_manager(self): - """Create a mock session manager.""" - return self.setup_session_manager_mock() - - def test_session_menu_no_choice_goes_back(self, mock_context, basic_state): - """Test that no choice selected results in BACK.""" - self.setup_selector_choice(mock_context, None) - - result = session_management(mock_context, basic_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_session_menu_back_choice(self, mock_context, basic_state): - """Test explicit back choice.""" - self.setup_selector_choice(mock_context, "↩️ Back to Main Menu") - - result = session_management(mock_context, basic_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_session_menu_save_session_success(self, mock_context, basic_state): - """Test successful session save.""" - self.setup_selector_choice(mock_context, "💾 Save Current Session") - self.setup_selector_input(mock_context, "test_session") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.save.return_value = True - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify session save was attempted - mock_session.save.assert_called_once() - self.assert_feedback_success_called("Session saved") - - def test_session_menu_save_session_failure(self, mock_context, basic_state): - """Test failed session save.""" - self.setup_selector_choice(mock_context, "💾 Save Current Session") - self.setup_selector_input(mock_context, "test_session") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.save.return_value = False - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to save session") - - def test_session_menu_save_session_empty_name(self, mock_context, basic_state): - """Test session save with empty name.""" - self.setup_selector_choice(mock_context, "💾 Save Current Session") - self.setup_selector_input(mock_context, "") # Empty name - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_warning_called("Session name cannot be empty") - - def test_session_menu_load_session_success(self, mock_context, basic_state): - """Test successful session load.""" - # Mock available sessions - mock_sessions = [ - {"name": "session1", "file": "session1.json", "created": "2024-01-01"}, - {"name": "session2", "file": "session2.json", "created": "2024-01-02"} - ] - - self.setup_selector_choice(mock_context, "📂 Load Saved Session") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - mock_session.resume.return_value = True - - # Mock user selecting a session - with patch.object(mock_context.selector, 'choose', side_effect=["session1"]): - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - mock_session.list_saved_sessions.assert_called_once() - mock_session.resume.assert_called_once() - self.assert_feedback_success_called("Session loaded") - - def test_session_menu_load_session_no_sessions(self, mock_context, basic_state): - """Test load session with no saved sessions.""" - self.setup_selector_choice(mock_context, "📂 Load Saved Session") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.list_saved_sessions.return_value = [] - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_info_called("No saved sessions found") - - def test_session_menu_load_session_failure(self, mock_context, basic_state): - """Test failed session load.""" - mock_sessions = [{"name": "session1", "file": "session1.json"}] - - self.setup_selector_choice(mock_context, "📂 Load Saved Session") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - mock_session.resume.return_value = False - - # Mock user selecting a session - with patch.object(mock_context.selector, 'choose', side_effect=["session1"]): - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to load session") - - def test_session_menu_delete_session_success(self, mock_context, basic_state): - """Test successful session deletion.""" - mock_sessions = [{"name": "session1", "file": "session1.json"}] - - self.setup_selector_choice(mock_context, "🗑️ Delete Saved Session") - self.setup_feedback_confirm(True) # Confirm deletion - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - - with patch.object(mock_context.selector, 'choose', side_effect=["session1"]): - with self.setup_path_exists_mock(True): - with patch('pathlib.Path.unlink') as mock_unlink: - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - mock_unlink.assert_called_once() - self.assert_feedback_success_called("Session deleted") - - def test_session_menu_delete_session_cancelled(self, mock_context, basic_state): - """Test cancelled session deletion.""" - mock_sessions = [{"name": "session1", "file": "session1.json"}] - - self.setup_selector_choice(mock_context, "🗑️ Delete Saved Session") - self.setup_feedback_confirm(False) # Cancel deletion - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - - with patch.object(mock_context.selector, 'choose', side_effect=["session1"]): - with patch('pathlib.Path.unlink') as mock_unlink: - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - mock_unlink.assert_not_called() - self.assert_feedback_info_called("Deletion cancelled") - - def test_session_menu_cleanup_old_sessions(self, mock_context, basic_state): - """Test cleanup of old sessions.""" - self.setup_selector_choice(mock_context, "🧹 Cleanup Old Sessions") - self.setup_feedback_confirm(True) # Confirm cleanup - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.cleanup_old_sessions.return_value = 5 # 5 sessions cleaned - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - mock_session.cleanup_old_sessions.assert_called_once() - self.assert_feedback_success_called("Cleaned up 5 old sessions") - - def test_session_menu_cleanup_cancelled(self, mock_context, basic_state): - """Test cancelled cleanup.""" - self.setup_selector_choice(mock_context, "🧹 Cleanup Old Sessions") - self.setup_feedback_confirm(False) # Cancel cleanup - - with patch('fastanime.cli.interactive.session.session') as mock_session: - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - mock_session.cleanup_old_sessions.assert_not_called() - self.assert_feedback_info_called("Cleanup cancelled") - - def test_session_menu_view_session_stats(self, mock_context, basic_state): - """Test viewing session statistics.""" - self.setup_selector_choice(mock_context, "📊 View Session Statistics") - - mock_stats = { - "current_states": 3, - "current_menu": "MAIN", - "auto_save_enabled": True, - "has_auto_save": False, - "has_crash_backup": False - } - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.get_session_stats.return_value = mock_stats - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - mock_session.get_session_stats.assert_called_once() - self.mock_feedback.pause_for_user.assert_called_once() - - def test_session_menu_toggle_auto_save_enable(self, mock_context, basic_state): - """Test enabling auto-save.""" - self.setup_selector_choice(mock_context, "⚙️ Toggle Auto-Save") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session._auto_save_enabled = False # Currently disabled - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - mock_session.enable_auto_save.assert_called_once_with(True) - self.assert_feedback_success_called("Auto-save enabled") - - def test_session_menu_toggle_auto_save_disable(self, mock_context, basic_state): - """Test disabling auto-save.""" - self.setup_selector_choice(mock_context, "⚙️ Toggle Auto-Save") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session._auto_save_enabled = True # Currently enabled - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - mock_session.enable_auto_save.assert_called_once_with(False) - self.assert_feedback_success_called("Auto-save disabled") - - def test_session_menu_create_manual_backup(self, mock_context, basic_state): - """Test creating manual backup.""" - self.setup_selector_choice(mock_context, "💿 Create Manual Backup") - self.setup_selector_input(mock_context, "my_backup") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.create_manual_backup.return_value = True - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - mock_session.create_manual_backup.assert_called_once_with("my_backup") - self.assert_feedback_success_called("Manual backup created") - - def test_session_menu_create_manual_backup_failure(self, mock_context, basic_state): - """Test failed manual backup creation.""" - self.setup_selector_choice(mock_context, "💿 Create Manual Backup") - self.setup_selector_input(mock_context, "my_backup") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.create_manual_backup.return_value = False - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to create backup") - - def test_session_menu_icons_disabled(self, mock_context, basic_state): - """Test menu display with icons disabled.""" - mock_context.config.general.icons = False - self.setup_selector_choice(mock_context, None) - - result = session_management(mock_context, basic_state) - - self.assert_back_behavior(result) - # Verify options don't contain emoji icons - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - for choice in choices: - assert not any(char in choice for char in '💾📂🗑️🧹📊⚙️💿↩️') - - def test_session_menu_file_operations_with_invalid_paths(self, mock_context, basic_state): - """Test handling of invalid file paths during operations.""" - self.setup_selector_choice(mock_context, "🗑️ Delete Saved Session") - - # Mock a session with invalid path - mock_sessions = [{"name": "session1", "file": "/invalid/path/session1.json"}] - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - - with patch.object(mock_context.selector, 'choose', side_effect=["session1"]): - with self.setup_path_exists_mock(False): # File doesn't exist - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_feedback_error_called("Session file not found") - - @pytest.mark.parametrize("session_count", [0, 1, 5, 10]) - def test_session_menu_various_session_counts(self, mock_context, basic_state, session_count): - """Test handling of various numbers of saved sessions.""" - self.setup_selector_choice(mock_context, "📂 Load Saved Session") - - # Create mock sessions - mock_sessions = [] - for i in range(session_count): - mock_sessions.append({ - "name": f"session{i+1}", - "file": f"session{i+1}.json", - "created": f"2024-01-0{i+1 if i < 9 else '10'}" - }) - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - - if session_count == 0: - self.assert_feedback_info_called("No saved sessions found") - else: - # Should display sessions for selection - mock_context.selector.choose.assert_called() - - def test_session_menu_save_with_special_characters(self, mock_context, basic_state): - """Test session save with special characters in name.""" - self.setup_selector_choice(mock_context, "💾 Save Current Session") - self.setup_selector_input(mock_context, "test/session:with*special?chars") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.save.return_value = True - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - # Should handle special characters appropriately - mock_session.save.assert_called_once() - - def test_session_menu_exception_handling(self, mock_context, basic_state): - """Test handling of unexpected exceptions.""" - self.setup_selector_choice(mock_context, "💾 Save Current Session") - self.setup_selector_input(mock_context, "test_session") - - with patch('fastanime.cli.interactive.session.session') as mock_session: - mock_session.save.side_effect = Exception("Unexpected error") - - result = session_management(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_feedback_error_called("An error occurred") diff --git a/tests/cli/interactive/menus/test_watch_history.py b/tests/cli/interactive/menus/test_watch_history.py deleted file mode 100644 index df6f7d6..0000000 --- a/tests/cli/interactive/menus/test_watch_history.py +++ /dev/null @@ -1,416 +0,0 @@ -""" -Tests for the watch history menu. -Tests local watch history display, navigation, and management. -""" - -import pytest -from unittest.mock import Mock, patch -from datetime import datetime - -from fastanime.cli.interactive.menus.watch_history import watch_history -from fastanime.cli.interactive.state import State, ControlFlow - -from .base_test import BaseMenuTest - - -class TestWatchHistoryMenu(BaseMenuTest): - """Test cases for the watch history menu.""" - - @pytest.fixture - def mock_watch_history_entries(self): - """Create mock watch history entries.""" - return [ - { - "anime_title": "Test Anime 1", - "episode": "5", - "timestamp": datetime.now().isoformat(), - "provider": "test_provider", - "anilist_id": 12345 - }, - { - "anime_title": "Test Anime 2", - "episode": "12", - "timestamp": datetime.now().isoformat(), - "provider": "test_provider", - "anilist_id": 67890 - }, - { - "anime_title": "Test Anime 3", - "episode": "1", - "timestamp": datetime.now().isoformat(), - "provider": "test_provider", - "anilist_id": 11111 - } - ] - - def test_watch_history_menu_no_choice_goes_back(self, mock_context, basic_state): - """Test that no choice selected results in BACK.""" - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = [] - - result = watch_history(mock_context, basic_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_watch_history_menu_back_choice(self, mock_context, basic_state): - """Test explicit back choice.""" - self.setup_selector_choice(mock_context, "↩️ Back to Main Menu") - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = [] - - result = watch_history(mock_context, basic_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - def test_watch_history_menu_empty_history(self, mock_context, basic_state): - """Test display when watch history is empty.""" - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = [] - - result = watch_history(mock_context, basic_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - self.assert_feedback_info_called("No watch history found") - - def test_watch_history_menu_with_entries(self, mock_context, basic_state, mock_watch_history_entries): - """Test display with watch history entries.""" - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - result = watch_history(mock_context, basic_state) - - self.assert_back_behavior(result) - self.assert_console_cleared() - - # Verify history was retrieved - mock_get_history.assert_called_once() - - # Verify entries are displayed in selector - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Should have entries plus management options - history_choices = [c for c in choices if any(anime["anime_title"] in c for anime in mock_watch_history_entries)] - assert len(history_choices) == len(mock_watch_history_entries) - - def test_watch_history_menu_continue_watching(self, mock_context, basic_state, mock_watch_history_entries): - """Test continuing to watch from history entry.""" - entry_choice = f"Test Anime 1 - Episode 5" - self.setup_selector_choice(mock_context, entry_choice) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - # Mock API search for the anime - mock_context.media_api.search_media.return_value = Mock() - - result = watch_history(mock_context, basic_state) - - self.assert_menu_transition(result, "RESULTS") - self.assert_console_cleared() - - # Verify API search was called - mock_context.media_api.search_media.assert_called_once() - - def test_watch_history_menu_clear_history_success(self, mock_context, basic_state, mock_watch_history_entries): - """Test successful history clearing.""" - self.setup_selector_choice(mock_context, "🗑️ Clear All History") - self.setup_feedback_confirm(True) # Confirm clearing - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - with patch('fastanime.cli.utils.watch_history_manager.clear_watch_history') as mock_clear: - mock_clear.return_value = True - - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify confirmation was requested - self.mock_feedback.confirm.assert_called_once() - # Verify history was cleared - mock_clear.assert_called_once() - self.assert_feedback_success_called("Watch history cleared") - - def test_watch_history_menu_clear_history_cancelled(self, mock_context, basic_state, mock_watch_history_entries): - """Test cancelled history clearing.""" - self.setup_selector_choice(mock_context, "🗑️ Clear All History") - self.setup_feedback_confirm(False) # Cancel clearing - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - with patch('fastanime.cli.utils.watch_history_manager.clear_watch_history') as mock_clear: - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify confirmation was requested - self.mock_feedback.confirm.assert_called_once() - # Verify history was not cleared - mock_clear.assert_not_called() - self.assert_feedback_info_called("Clear cancelled") - - def test_watch_history_menu_clear_history_failure(self, mock_context, basic_state, mock_watch_history_entries): - """Test failed history clearing.""" - self.setup_selector_choice(mock_context, "🗑️ Clear All History") - self.setup_feedback_confirm(True) # Confirm clearing - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - with patch('fastanime.cli.utils.watch_history_manager.clear_watch_history') as mock_clear: - mock_clear.return_value = False - - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("Failed to clear history") - - def test_watch_history_menu_export_history(self, mock_context, basic_state, mock_watch_history_entries): - """Test exporting watch history.""" - self.setup_selector_choice(mock_context, "📤 Export History") - self.setup_selector_input(mock_context, "/path/to/export.json") - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - with patch('fastanime.cli.utils.watch_history_manager.export_watch_history') as mock_export: - mock_export.return_value = True - - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify export was attempted - mock_export.assert_called_once() - self.assert_feedback_success_called("History exported") - - def test_watch_history_menu_import_history(self, mock_context, basic_state): - """Test importing watch history.""" - self.setup_selector_choice(mock_context, "📥 Import History") - self.setup_selector_input(mock_context, "/path/to/import.json") - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = [] - - with patch('fastanime.cli.utils.watch_history_manager.import_watch_history') as mock_import: - mock_import.return_value = True - - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify import was attempted - mock_import.assert_called_once() - self.assert_feedback_success_called("History imported") - - def test_watch_history_menu_remove_single_entry(self, mock_context, basic_state, mock_watch_history_entries): - """Test removing a single history entry.""" - self.setup_selector_choice(mock_context, "🗑️ Remove Entry") - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - # Mock user selecting entry to remove - with patch.object(mock_context.selector, 'choose', side_effect=["Test Anime 1 - Episode 5"]): - with patch('fastanime.cli.utils.watch_history_manager.remove_watch_history_entry') as mock_remove: - mock_remove.return_value = True - - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify removal was attempted - mock_remove.assert_called_once() - self.assert_feedback_success_called("Entry removed") - - def test_watch_history_menu_search_history(self, mock_context, basic_state, mock_watch_history_entries): - """Test searching through watch history.""" - self.setup_selector_choice(mock_context, "🔍 Search History") - self.setup_selector_input(mock_context, "Test Anime 1") - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - with patch('fastanime.cli.utils.watch_history_manager.search_watch_history') as mock_search: - filtered_entries = [mock_watch_history_entries[0]] # Only first entry matches - mock_search.return_value = filtered_entries - - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - - # Verify search was performed - mock_search.assert_called_once_with("Test Anime 1") - - def test_watch_history_menu_sort_by_date(self, mock_context, basic_state, mock_watch_history_entries): - """Test sorting history by date.""" - self.setup_selector_choice(mock_context, "📅 Sort by Date") - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - # Should re-display with sorted entries - - def test_watch_history_menu_sort_by_anime_title(self, mock_context, basic_state, mock_watch_history_entries): - """Test sorting history by anime title.""" - self.setup_selector_choice(mock_context, "🔤 Sort by Title") - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - # Should re-display with sorted entries - - def test_watch_history_menu_icons_disabled(self, mock_context, basic_state, mock_watch_history_entries): - """Test menu display with icons disabled.""" - mock_context.config.general.icons = False - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - result = watch_history(mock_context, basic_state) - - self.assert_back_behavior(result) - # Verify options don't contain emoji icons - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - for choice in choices: - assert not any(char in choice for char in '🗑️📤📥🔍📅🔤↩️') - - def test_watch_history_menu_large_history(self, mock_context, basic_state): - """Test handling of large watch history.""" - # Create large history (100 entries) - large_history = [] - for i in range(100): - large_history.append({ - "anime_title": f"Test Anime {i}", - "episode": f"{i % 12 + 1}", - "timestamp": datetime.now().isoformat(), - "provider": "test_provider", - "anilist_id": 10000 + i - }) - - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = large_history - - result = watch_history(mock_context, basic_state) - - self.assert_back_behavior(result) - # Should handle large history gracefully - mock_context.selector.choose.assert_called_once() - - def test_watch_history_menu_entry_formatting(self, mock_context, basic_state, mock_watch_history_entries): - """Test proper formatting of history entries.""" - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - result = watch_history(mock_context, basic_state) - - self.assert_back_behavior(result) - - # Verify entries are formatted with title and episode - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Check that anime titles and episodes appear in choices - for entry in mock_watch_history_entries: - title_found = any(entry["anime_title"] in choice for choice in choices) - episode_found = any(f"Episode {entry['episode']}" in choice for choice in choices) - assert title_found and episode_found - - def test_watch_history_menu_provider_context(self, mock_context, basic_state, mock_watch_history_entries): - """Test that provider context is included in entries.""" - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_watch_history_entries - - result = watch_history(mock_context, basic_state) - - self.assert_back_behavior(result) - - # Should include provider information - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Provider info might be shown in choices or header - header = call_args[1].get('header', '') - # Provider context should be available somewhere - - @pytest.mark.parametrize("history_size", [0, 1, 5, 50, 100]) - def test_watch_history_menu_various_sizes(self, mock_context, basic_state, history_size): - """Test handling of various history sizes.""" - history_entries = [] - for i in range(history_size): - history_entries.append({ - "anime_title": f"Test Anime {i}", - "episode": f"{i % 12 + 1}", - "timestamp": datetime.now().isoformat(), - "provider": "test_provider", - "anilist_id": 10000 + i - }) - - self.setup_selector_choice(mock_context, None) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.return_value = history_entries - - result = watch_history(mock_context, basic_state) - - self.assert_back_behavior(result) - - if history_size == 0: - self.assert_feedback_info_called("No watch history found") - else: - mock_context.selector.choose.assert_called_once() - - def test_watch_history_menu_error_handling(self, mock_context, basic_state): - """Test error handling when watch history operations fail.""" - self.setup_selector_choice(mock_context, "🗑️ Clear All History") - self.setup_feedback_confirm(True) - - with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history: - mock_get_history.side_effect = Exception("History access error") - - result = watch_history(mock_context, basic_state) - - self.assert_continue_behavior(result) - self.assert_console_cleared() - self.assert_feedback_error_called("An error occurred") diff --git a/tests/cli/interactive/test_session.py b/tests/cli/interactive/test_session.py deleted file mode 100644 index c88b0a0..0000000 --- a/tests/cli/interactive/test_session.py +++ /dev/null @@ -1,506 +0,0 @@ -""" -Tests for the interactive session management. -Tests session lifecycle, state management, and menu loading. -""" - -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -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 - - -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: - 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" - ): - 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" - ): - # 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 - ) - - 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: - 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: - 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: - 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: - 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: - # Test save - 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 - call_count += 1 - 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", - ): - 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: - # 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"), - ): - # 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 - state_count += 1 - if state_count == 1: - return ControlFlow.BACK # Should pop state if history > 1 - elif state_count == 2: - return ControlFlow.CONTINUE # Should re-run current state - elif state_count == 3: - 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", - ): - # Add an initial state to test BACK behavior - 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, - ): - 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 - ): - 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 - ): - result = session_instance.cleanup_old_sessions(max_sessions=10) - - assert result == 3 - 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"}, - ] - - 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 == [] - assert session._menus == {} diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index d8f23c6..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,299 +0,0 @@ -""" -Pytest configuration and shared fixtures for FastAnime tests. -Provides common mocks and test utilities following DRY principles. -""" - -import pytest -from unittest.mock import Mock, MagicMock, patch -from pathlib import Path -from typing import Dict, Any, Optional - -from fastanime.core.config import AppConfig, GeneralConfig, AnilistConfig -from fastanime.cli.interactive.session import Context -from fastanime.cli.interactive.state import State, ControlFlow, ProviderState, MediaApiState -from fastanime.libs.api.types import UserProfile, MediaSearchResult, MediaItem -from fastanime.libs.api.base import BaseApiClient -from fastanime.libs.providers.anime.base import BaseAnimeProvider -from fastanime.libs.selectors.base import BaseSelector -from fastanime.libs.players.base import BasePlayer - - -@pytest.fixture -def mock_config(): - """Create a mock AppConfig with default settings.""" - config = Mock(spec=AppConfig) - config.general = Mock(spec=GeneralConfig) - config.general.icons = True - config.general.provider = "test_provider" - config.general.api_client = "anilist" - config.anilist = Mock(spec=AnilistConfig) - return config - - -@pytest.fixture -def mock_user_profile(): - """Create a mock user profile for authenticated tests.""" - return UserProfile( - id=12345, - name="TestUser", - avatar="https://example.com/avatar.jpg" - ) - - -@pytest.fixture -def mock_media_item(): - """Create a mock media item for testing.""" - return MediaItem( - id=1, - title="Test Anime", - description="A test anime description", - cover_image="https://example.com/cover.jpg", - banner_image="https://example.com/banner.jpg", - status="RELEASING", - episodes=12, - duration=24, - genres=["Action", "Adventure"], - mean_score=85, - popularity=1000, - start_date="2024-01-01", - end_date=None - ) - - -@pytest.fixture -def mock_media_search_result(mock_media_item): - """Create a mock media search result.""" - return MediaSearchResult( - media=[mock_media_item], - page_info={ - "total": 1, - "current_page": 1, - "last_page": 1, - "has_next_page": False, - "per_page": 20 - } - ) - - -@pytest.fixture -def mock_api_client(mock_user_profile): - """Create a mock API client.""" - client = Mock(spec=BaseApiClient) - client.user_profile = mock_user_profile - client.authenticate.return_value = mock_user_profile - client.get_viewer_profile.return_value = mock_user_profile - client.search_media.return_value = None - return client - - -@pytest.fixture -def mock_unauthenticated_api_client(): - """Create a mock API client without authentication.""" - client = Mock(spec=BaseApiClient) - client.user_profile = None - client.authenticate.return_value = None - client.get_viewer_profile.return_value = None - client.search_media.return_value = None - return client - - -@pytest.fixture -def mock_provider(): - """Create a mock anime provider.""" - provider = Mock(spec=BaseAnimeProvider) - provider.search.return_value = None - provider.get_anime.return_value = None - provider.get_servers.return_value = [] - return provider - - -@pytest.fixture -def mock_selector(): - """Create a mock selector for user input.""" - selector = Mock(spec=BaseSelector) - selector.choose.return_value = None - selector.input.return_value = "" - selector.confirm.return_value = False - return selector - - -@pytest.fixture -def mock_player(): - """Create a mock player.""" - player = Mock(spec=BasePlayer) - player.play.return_value = None - return player - - -@pytest.fixture -def mock_context(mock_config, mock_provider, mock_selector, mock_player, mock_api_client): - """Create a mock context with all dependencies.""" - return Context( - config=mock_config, - provider=mock_provider, - selector=mock_selector, - player=mock_player, - media_api=mock_api_client - ) - - -@pytest.fixture -def mock_unauthenticated_context(mock_config, mock_provider, mock_selector, mock_player, mock_unauthenticated_api_client): - """Create a mock context without authentication.""" - return Context( - config=mock_config, - provider=mock_provider, - selector=mock_selector, - player=mock_player, - media_api=mock_unauthenticated_api_client - ) - - -@pytest.fixture -def basic_state(): - """Create a basic state for testing.""" - return State(menu_name="TEST") - - -@pytest.fixture -def state_with_media_data(mock_media_search_result, mock_media_item): - """Create a state with media data.""" - return State( - menu_name="TEST", - media_api=MediaApiState( - search_results=mock_media_search_result, - anime=mock_media_item - ) - ) - - -@pytest.fixture -def mock_feedback_manager(): - """Create a mock feedback manager.""" - feedback = Mock() - feedback.info = Mock() - feedback.error = Mock() - feedback.warning = Mock() - feedback.success = Mock() - feedback.confirm.return_value = False - feedback.pause_for_user = Mock() - return feedback - - -@pytest.fixture -def mock_console(): - """Create a mock Rich console.""" - console = Mock() - console.clear = Mock() - console.print = Mock() - return console - - -class MenuTestHelper: - """Helper class for common menu testing patterns.""" - - @staticmethod - def assert_control_flow(result: Any, expected: ControlFlow): - """Assert that the result is the expected ControlFlow.""" - assert isinstance(result, ControlFlow) - assert result == expected - - @staticmethod - def assert_state_transition(result: Any, expected_menu: str): - """Assert that the result is a State with the expected menu name.""" - assert isinstance(result, State) - assert result.menu_name == expected_menu - - @staticmethod - def setup_selector_choice(mock_selector, choice: Optional[str]): - """Helper to set up selector choice return value.""" - mock_selector.choose.return_value = choice - - @staticmethod - def setup_selector_confirm(mock_selector, confirm: bool): - """Helper to set up selector confirm return value.""" - mock_selector.confirm.return_value = confirm - - @staticmethod - def setup_feedback_confirm(mock_feedback, confirm: bool): - """Helper to set up feedback confirm return value.""" - mock_feedback.confirm.return_value = confirm - - -@pytest.fixture -def menu_helper(): - """Provide the MenuTestHelper class.""" - return MenuTestHelper - - -# Patches for external dependencies -@pytest.fixture -def mock_create_feedback_manager(mock_feedback_manager): - """Mock the create_feedback_manager function.""" - with patch('fastanime.cli.utils.feedback.create_feedback_manager', return_value=mock_feedback_manager): - yield mock_feedback_manager - - -@pytest.fixture -def mock_rich_console(mock_console): - """Mock the Rich Console class.""" - with patch('rich.console.Console', return_value=mock_console): - yield mock_console - - -@pytest.fixture -def mock_click_edit(): - """Mock the click.edit function.""" - with patch('click.edit') as mock_edit: - yield mock_edit - - -@pytest.fixture -def mock_webbrowser_open(): - """Mock the webbrowser.open function.""" - with patch('webbrowser.open') as mock_open: - yield mock_open - - -@pytest.fixture -def mock_auth_manager(): - """Mock the AuthManager class.""" - 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 - yield auth_instance - - -# Common test data -TEST_MENU_OPTIONS = { - 'trending': '🔥 Trending', - 'popular': '✨ Popular', - 'favourites': '💖 Favourites', - 'top_scored': '💯 Top Scored', - 'upcoming': '🎬 Upcoming', - 'recently_updated': '🔔 Recently Updated', - 'random': '🎲 Random', - 'search': '🔎 Search', - 'watching': '📺 Watching', - 'planned': '📑 Planned', - 'completed': '✅ Completed', - 'paused': '⏸️ Paused', - 'dropped': '🚮 Dropped', - 'rewatching': '🔁 Rewatching', - 'watch_history': '📖 Local Watch History', - 'auth': '🔐 Authentication', - 'session_management': '🔧 Session Management', - 'edit_config': '📝 Edit Config', - 'exit': '❌ Exit' -} - -TEST_AUTH_OPTIONS = { - 'login': '🔐 Login to AniList', - 'logout': '🔓 Logout', - 'profile': '👤 View Profile Details', - 'how_to_token': '❓ How to Get Token', - 'back': '↩️ Back to Main Menu' -}