diff --git a/tests/interactive/menus/README.md b/tests/interactive/menus/README.md new file mode 100644 index 0000000..b180ff0 --- /dev/null +++ b/tests/interactive/menus/README.md @@ -0,0 +1,334 @@ +# Interactive Menu Tests + +This directory contains comprehensive test suites for all interactive menu functionality in FastAnime. + +## Test Structure + +``` +tests/interactive/menus/ +├── conftest.py # Shared fixtures and utilities +├── __init__.py # Package marker +├── run_tests.py # Test runner script +├── README.md # This file +├── test_main.py # Tests for main menu +├── test_results.py # Tests for results menu +├── test_auth.py # Tests for authentication menu +├── test_media_actions.py # Tests for media actions menu +├── test_episodes.py # Tests for episodes menu +├── test_servers.py # Tests for servers menu +├── test_player_controls.py # Tests for player controls menu +├── test_provider_search.py # Tests for provider search menu +├── test_session_management.py # Tests for session management menu +└── test_watch_history.py # Tests for watch history menu +``` + +## Test Categories + +### Unit Tests + +Each menu has its own comprehensive test file that covers: + +- Menu display and option rendering +- User interaction handling +- State transitions +- Error handling +- Configuration options (icons, preferences) +- Helper function testing + +### Integration Tests + +Tests marked with `@pytest.mark.integration` require network connectivity and test: + +- Real API interactions +- Authentication flows +- Data fetching and processing + +## Test Coverage + +Each test file covers the following aspects: + +### Main Menu Tests (`test_main.py`) + +- Option display with/without icons +- Navigation to different categories (trending, popular, etc.) +- Search functionality +- User list access (authenticated/unauthenticated) +- Authentication and session management +- Configuration editing +- Helper function testing + +### Results Menu Tests (`test_results.py`) + +- Search result display +- Pagination handling +- Anime selection +- Preview functionality +- Authentication status display +- Helper function testing + +### Authentication Menu Tests (`test_auth.py`) + +- Login/logout flows +- OAuth authentication +- Token input handling +- Profile display +- Authentication status management +- Helper function testing + +### Media Actions Menu Tests (`test_media_actions.py`) + +- Action menu display +- Streaming initiation +- Trailer playback +- List management +- Scoring functionality +- Local history tracking +- Information display +- Helper function testing + +### Episodes Menu Tests (`test_episodes.py`) + +- Episode list display +- Watch history continuation +- Episode selection +- Translation type handling +- Progress tracking +- Helper function testing + +### Servers Menu Tests (`test_servers.py`) + +- Server fetching and display +- Server selection +- Quality filtering +- Auto-server selection +- Player integration +- Error handling +- Helper function testing + +### Player Controls Menu Tests (`test_player_controls.py`) + +- Post-playback options +- Next episode handling +- Auto-next functionality +- Progress tracking +- Replay functionality +- Server switching +- Helper function testing + +### Provider Search Menu Tests (`test_provider_search.py`) + +- Provider anime search +- Auto-selection based on similarity +- Manual selection handling +- Preview integration +- Error handling +- Helper function testing + +### Session Management Menu Tests (`test_session_management.py`) + +- Session saving/loading +- Session listing and statistics +- Session deletion +- Auto-save configuration +- Backup creation +- Helper function testing + +### Watch History Menu Tests (`test_watch_history.py`) + +- History display and navigation +- History management (clear, export, import) +- Statistics calculation +- Anime selection from history +- Helper function testing + +## Fixtures and Utilities + +### Shared Fixtures (`conftest.py`) + +- `mock_config`: Mock application configuration +- `mock_provider`: Mock anime provider +- `mock_selector`: Mock UI selector +- `mock_player`: Mock media player +- `mock_media_api`: Mock API client +- `mock_context`: Complete mock context +- `sample_media_item`: Sample AniList anime data +- `sample_provider_anime`: Sample provider anime data +- `sample_search_results`: Sample search results +- Various state fixtures for different scenarios + +### Test Utilities + +- `assert_state_transition()`: Assert proper state transitions +- `assert_control_flow()`: Assert control flow returns +- `setup_selector_choices()`: Configure mock selector choices +- `setup_selector_inputs()`: Configure mock selector inputs + +## Running Tests + +### Run All Menu Tests + +```bash +python tests/interactive/menus/run_tests.py +``` + +### Run Specific Menu Tests + +```bash +python tests/interactive/menus/run_tests.py --menu main +python tests/interactive/menus/run_tests.py --menu auth +python tests/interactive/menus/run_tests.py --menu episodes +``` + +### Run with Coverage + +```bash +python tests/interactive/menus/run_tests.py --coverage +``` + +### Run Integration Tests Only + +```bash +python tests/interactive/menus/run_tests.py --integration +``` + +### Using pytest directly + +```bash +# Run all menu tests +pytest tests/interactive/menus/ -v + +# Run specific test file +pytest tests/interactive/menus/test_main.py -v + +# Run with coverage +pytest tests/interactive/menus/ --cov=fastanime.cli.interactive.menus --cov-report=html + +# Run integration tests only +pytest tests/interactive/menus/ -m integration + +# Run specific test class +pytest tests/interactive/menus/test_main.py::TestMainMenu -v + +# Run specific test method +pytest tests/interactive/menus/test_main.py::TestMainMenu::test_main_menu_displays_options -v +``` + +## Test Patterns + +### Menu Function Testing + +```python +def test_menu_function(self, mock_context, test_state): + """Test the menu function with specific setup.""" + # Setup + mock_context.selector.choose.return_value = "Expected Choice" + + # Execute + result = menu_function(mock_context, test_state) + + # Assert + assert isinstance(result, State) + assert result.menu_name == "EXPECTED_STATE" +``` + +### Error Handling Testing + +```python +def test_menu_error_handling(self, mock_context, test_state): + """Test menu handles errors gracefully.""" + # Setup error condition + mock_context.provider.some_method.side_effect = Exception("Test error") + + # Execute + result = menu_function(mock_context, test_state) + + # Assert error handling + assert result == ControlFlow.BACK # or appropriate error response +``` + +### State Transition Testing + +```python +def test_state_transition(self, mock_context, initial_state): + """Test proper state transitions.""" + # Setup + mock_context.selector.choose.return_value = "Next State Option" + + # Execute + result = menu_function(mock_context, initial_state) + + # Assert state transition + assert_state_transition(result, "NEXT_STATE") + assert result.media_api.anime == initial_state.media_api.anime # State preservation +``` + +## Mocking Strategies + +### API Mocking + +```python +# Mock successful API calls +mock_context.media_api.search_media.return_value = sample_search_results + +# Mock API failures +mock_context.media_api.search_media.side_effect = Exception("API Error") +``` + +### User Input Mocking + +```python +# Mock menu selection +mock_context.selector.choose.return_value = "Selected Option" + +# Mock text input +mock_context.selector.ask.return_value = "User Input" + +# Mock cancelled selections +mock_context.selector.choose.return_value = None +``` + +### Configuration Mocking + +```python +# Mock configuration options +mock_context.config.general.icons = True +mock_context.config.stream.auto_next = False +mock_context.config.anilist.per_page = 15 +``` + +## Adding New Tests + +When adding tests for new menus: + +1. Create a new test file: `test_[menu_name].py` +2. Import the menu function and required fixtures +3. Create test classes for the main menu and helper functions +4. Follow the established patterns for testing: + - Menu display and options + - User interactions and selections + - State transitions + - Error handling + - Configuration variations + - Helper functions +5. Add the menu name to the choices in `run_tests.py` +6. Update this README with the new test coverage + +## Best Practices + +1. **Test Isolation**: Each test should be independent and not rely on other tests +2. **Clear Naming**: Test names should clearly describe what is being tested +3. **Comprehensive Coverage**: Test both happy paths and error conditions +4. **Realistic Mocks**: Mock data should represent realistic scenarios +5. **State Verification**: Always verify that state transitions are correct +6. **Error Testing**: Test error handling and edge cases +7. **Configuration Testing**: Test menu behavior with different configuration options +8. **Documentation**: Document complex test scenarios and mock setups + +## Continuous Integration + +These tests are designed to run in CI environments: + +- Unit tests run without external dependencies +- Integration tests can be skipped in CI if needed +- Coverage reports help maintain code quality +- Fast execution for quick feedback loops diff --git a/tests/interactive/menus/__init__.py b/tests/interactive/menus/__init__.py new file mode 100644 index 0000000..05a45eb --- /dev/null +++ b/tests/interactive/menus/__init__.py @@ -0,0 +1 @@ +# Test package for interactive menu tests diff --git a/tests/interactive/menus/conftest.py b/tests/interactive/menus/conftest.py new file mode 100644 index 0000000..330a48a --- /dev/null +++ b/tests/interactive/menus/conftest.py @@ -0,0 +1,264 @@ +""" +Shared test fixtures and utilities for menu testing. +""" + +from unittest.mock import Mock, MagicMock +from pathlib import Path +import pytest +from typing import Iterator, List, Optional + +from fastanime.core.config.model import AppConfig, GeneralConfig, StreamConfig, AnilistConfig +from fastanime.cli.interactive.session import Context +from fastanime.cli.interactive.state import State, ProviderState, MediaApiState, ControlFlow +from fastanime.libs.api.types import MediaItem, MediaSearchResult, PageInfo, UserProfile +from fastanime.libs.api.params import ApiSearchParams, UserListParams +from fastanime.libs.providers.anime.types import Anime, SearchResults, Server +from fastanime.libs.players.types import PlayerResult + + +@pytest.fixture +def mock_config(): + """Create a mock configuration object.""" + return AppConfig( + general=GeneralConfig( + icons=True, + provider="allanime", + selector="fzf", + api_client="anilist", + preview="text", + auto_select_anime_result=True, + cache_requests=True, + normalize_titles=True, + discord=False, + recent=50 + ), + stream=StreamConfig( + player="mpv", + quality="1080", + translation_type="sub", + server="TOP", + auto_next=False, + continue_from_watch_history=True, + preferred_watch_history="local" + ), + anilist=AnilistConfig( + per_page=15, + sort_by="SEARCH_MATCH", + preferred_language="english" + ) + ) + + +@pytest.fixture +def mock_provider(): + """Create a mock anime provider.""" + provider = Mock() + provider.search_anime.return_value = SearchResults( + anime=[ + Anime( + name="Test Anime 1", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + ] + ) + return provider + + +@pytest.fixture +def mock_selector(): + """Create a mock selector.""" + selector = Mock() + selector.choose.return_value = "Test Choice" + selector.ask.return_value = "Test Input" + return selector + + +@pytest.fixture +def mock_player(): + """Create a mock player.""" + player = Mock() + player.play.return_value = PlayerResult(success=True, exit_code=0) + return player + + +@pytest.fixture +def mock_media_api(): + """Create a mock media API client.""" + api = Mock() + + # Mock user profile + api.user_profile = UserProfile( + id=12345, + name="TestUser", + avatar="https://example.com/avatar.jpg" + ) + + # Mock search results + api.search_media.return_value = MediaSearchResult( + media=[ + MediaItem( + id=1, + title={"english": "Test Anime", "romaji": "Test Anime"}, + status="FINISHED", + episodes=12, + description="A test anime", + cover_image="https://example.com/cover.jpg", + banner_image="https://example.com/banner.jpg", + genres=["Action", "Adventure"], + studios=[{"name": "Test Studio"}] + ) + ], + page_info=PageInfo( + total=1, + per_page=15, + current_page=1, + has_next_page=False + ) + ) + + # Mock user list + api.fetch_user_list.return_value = api.search_media.return_value + + # Mock authentication methods + api.is_authenticated.return_value = True + api.authenticate.return_value = True + + return api + + +@pytest.fixture +def mock_context(mock_config, mock_provider, mock_selector, mock_player, mock_media_api): + """Create a mock context object.""" + return Context( + config=mock_config, + provider=mock_provider, + selector=mock_selector, + player=mock_player, + media_api=mock_media_api + ) + + +@pytest.fixture +def sample_media_item(): + """Create a sample MediaItem for testing.""" + return MediaItem( + id=1, + title={"english": "Test Anime", "romaji": "Test Anime"}, + status="FINISHED", + episodes=12, + description="A test anime", + cover_image="https://example.com/cover.jpg", + banner_image="https://example.com/banner.jpg", + genres=["Action", "Adventure"], + studios=[{"name": "Test Studio"}] + ) + + +@pytest.fixture +def sample_provider_anime(): + """Create a sample provider Anime for testing.""" + return Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg" + ) + + +@pytest.fixture +def sample_search_results(sample_media_item): + """Create sample search results.""" + return MediaSearchResult( + media=[sample_media_item], + page_info=PageInfo( + total=1, + per_page=15, + current_page=1, + has_next_page=False + ) + ) + + +@pytest.fixture +def empty_state(): + """Create an empty state.""" + return State(menu_name="TEST") + + +@pytest.fixture +def state_with_media_api(sample_search_results, sample_media_item): + """Create a state with media API data.""" + return State( + menu_name="TEST", + media_api=MediaApiState( + search_results=sample_search_results, + anime=sample_media_item + ) + ) + + +@pytest.fixture +def state_with_provider(sample_provider_anime): + """Create a state with provider data.""" + return State( + menu_name="TEST", + provider=ProviderState( + anime=sample_provider_anime, + episode_number="1" + ) + ) + + +@pytest.fixture +def full_state(sample_search_results, sample_media_item, sample_provider_anime): + """Create a state with both media API and provider data.""" + return State( + menu_name="TEST", + media_api=MediaApiState( + search_results=sample_search_results, + anime=sample_media_item + ), + provider=ProviderState( + anime=sample_provider_anime, + episode_number="1" + ) + ) + + +# Test utilities + +def assert_state_transition(result, expected_menu_name: str): + """Assert that a menu function returned a proper state transition.""" + assert isinstance(result, State) + assert result.menu_name == expected_menu_name + + +def assert_control_flow(result, expected_flow: ControlFlow): + """Assert that a menu function returned the expected control flow.""" + assert isinstance(result, ControlFlow) + assert result == expected_flow + + +def setup_selector_choices(mock_selector, choices: List[str]): + """Setup mock selector to return specific choices in sequence.""" + mock_selector.choose.side_effect = choices + + +def setup_selector_inputs(mock_selector, inputs: List[str]): + """Setup mock selector to return specific inputs in sequence.""" + mock_selector.ask.side_effect = inputs + + +# Mock feedback manager +@pytest.fixture +def mock_feedback(): + """Create a mock feedback manager.""" + feedback = Mock() + feedback.success.return_value = None + feedback.error.return_value = None + feedback.info.return_value = None + feedback.confirm.return_value = True + feedback.pause_for_user.return_value = None + return feedback diff --git a/tests/interactive/menus/run_tests.py b/tests/interactive/menus/run_tests.py new file mode 100644 index 0000000..ffbf58d --- /dev/null +++ b/tests/interactive/menus/run_tests.py @@ -0,0 +1,84 @@ +""" +Test runner for all interactive menu tests. +This file can be used to run all menu tests at once or specific test suites. +""" + +import pytest +import sys +from pathlib import Path + +# Add the project root to the Python path +project_root = Path(__file__).parent.parent.parent.parent +sys.path.insert(0, str(project_root)) + + +def run_all_menu_tests(): + """Run all menu tests.""" + test_dir = Path(__file__).parent + return pytest.main([str(test_dir), "-v"]) + + +def run_specific_menu_test(menu_name: str): + """Run tests for a specific menu.""" + test_file = Path(__file__).parent / f"test_{menu_name}.py" + if test_file.exists(): + return pytest.main([str(test_file), "-v"]) + else: + print(f"Test file for menu '{menu_name}' not found.") + return 1 + + +def run_menu_test_with_coverage(): + """Run menu tests with coverage report.""" + test_dir = Path(__file__).parent + return pytest.main([ + str(test_dir), + "--cov=fastanime.cli.interactive.menus", + "--cov-report=html", + "--cov-report=term-missing", + "-v" + ]) + + +def run_integration_tests(): + """Run integration tests that require network connectivity.""" + test_dir = Path(__file__).parent + return pytest.main([str(test_dir), "-m", "integration", "-v"]) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Run interactive menu tests") + parser.add_argument( + "--menu", + help="Run tests for a specific menu", + choices=[ + "main", "results", "auth", "media_actions", "episodes", + "servers", "player_controls", "provider_search", + "session_management", "watch_history" + ] + ) + parser.add_argument( + "--coverage", + action="store_true", + help="Run tests with coverage report" + ) + parser.add_argument( + "--integration", + action="store_true", + help="Run integration tests only" + ) + + args = parser.parse_args() + + if args.integration: + exit_code = run_integration_tests() + elif args.coverage: + exit_code = run_menu_test_with_coverage() + elif args.menu: + exit_code = run_specific_menu_test(args.menu) + else: + exit_code = run_all_menu_tests() + + sys.exit(exit_code) diff --git a/tests/interactive/menus/test_auth.py b/tests/interactive/menus/test_auth.py new file mode 100644 index 0000000..04aaab2 --- /dev/null +++ b/tests/interactive/menus/test_auth.py @@ -0,0 +1,433 @@ +""" +Tests for the authentication menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + +from fastanime.cli.interactive.menus.auth import auth +from fastanime.cli.interactive.state import ControlFlow, State +from fastanime.libs.api.types import UserProfile + + +class TestAuthMenu: + """Test cases for the authentication menu.""" + + def test_auth_menu_not_authenticated(self, mock_context, empty_state): + """Test auth menu when user is not authenticated.""" + # User not authenticated + mock_context.media_api.user_profile = None + mock_context.selector.choose.return_value = None + + result = auth(mock_context, empty_state) + + # Should go back when no choice is made + assert result == ControlFlow.BACK + + # Verify selector was called with login options + mock_context.selector.choose.assert_called_once() + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + + # Should contain login options + login_options = ["Login to AniList", "How to Get Token", "Back to Main Menu"] + for option in login_options: + assert any(option in choice for choice in choices) + + def test_auth_menu_authenticated(self, mock_context, empty_state): + """Test auth menu when user is authenticated.""" + # User authenticated + mock_context.media_api.user_profile = UserProfile( + id=12345, + name="TestUser", + avatar="https://example.com/avatar.jpg" + ) + mock_context.selector.choose.return_value = None + + result = auth(mock_context, empty_state) + + # Should go back when no choice is made + assert result == ControlFlow.BACK + + # Verify selector was called with authenticated options + mock_context.selector.choose.assert_called_once() + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + + # Should contain authenticated user options + auth_options = ["View Profile Details", "Logout", "Back to Main Menu"] + for option in auth_options: + assert any(option in choice for choice in choices) + + def test_auth_menu_login_selection(self, mock_context, empty_state): + """Test selecting login from auth menu.""" + mock_context.media_api.user_profile = None + + # Setup selector to return login choice + login_choice = "🔐 Login to AniList" + mock_context.selector.choose.return_value = login_choice + + with patch('fastanime.cli.interactive.menus.auth._handle_login') as mock_login: + mock_login.return_value = State(menu_name="MAIN") + + result = auth(mock_context, empty_state) + + # Should call login handler + mock_login.assert_called_once() + assert isinstance(result, State) + + def test_auth_menu_logout_selection(self, mock_context, empty_state): + """Test selecting logout from auth menu.""" + mock_context.media_api.user_profile = UserProfile( + id=12345, + name="TestUser", + avatar="https://example.com/avatar.jpg" + ) + + # Setup selector to return logout choice + logout_choice = "🔓 Logout" + mock_context.selector.choose.return_value = logout_choice + + with patch('fastanime.cli.interactive.menus.auth._handle_logout') as mock_logout: + mock_logout.return_value = ControlFlow.CONTINUE + + result = auth(mock_context, empty_state) + + # Should call logout handler + mock_logout.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_auth_menu_view_profile_selection(self, mock_context, empty_state): + """Test selecting view profile from auth menu.""" + mock_context.media_api.user_profile = UserProfile( + id=12345, + name="TestUser", + avatar="https://example.com/avatar.jpg" + ) + + # Setup selector to return profile choice + profile_choice = "👤 View Profile Details" + mock_context.selector.choose.return_value = profile_choice + + with patch('fastanime.cli.interactive.menus.auth._display_user_profile_details') as mock_display: + with patch('fastanime.cli.interactive.menus.auth.create_feedback_manager') as mock_feedback: + mock_feedback_obj = Mock() + mock_feedback.return_value = mock_feedback_obj + + result = auth(mock_context, empty_state) + + # Should display profile details and continue + mock_display.assert_called_once() + mock_feedback_obj.pause_for_user.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_auth_menu_token_help_selection(self, mock_context, empty_state): + """Test selecting token help from auth menu.""" + mock_context.media_api.user_profile = None + + # Setup selector to return help choice + help_choice = "❓ How to Get Token" + mock_context.selector.choose.return_value = help_choice + + with patch('fastanime.cli.interactive.menus.auth._display_token_help') as mock_help: + with patch('fastanime.cli.interactive.menus.auth.create_feedback_manager') as mock_feedback: + mock_feedback_obj = Mock() + mock_feedback.return_value = mock_feedback_obj + + result = auth(mock_context, empty_state) + + # Should display token help and continue + mock_help.assert_called_once() + mock_feedback_obj.pause_for_user.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_auth_menu_back_selection(self, mock_context, empty_state): + """Test selecting back from auth menu.""" + mock_context.media_api.user_profile = None + + # Setup selector to return back choice + back_choice = "↩️ Back to Main Menu" + mock_context.selector.choose.return_value = back_choice + + result = auth(mock_context, empty_state) + + assert result == ControlFlow.BACK + + def test_auth_menu_icons_enabled(self, mock_context, empty_state): + """Test auth menu with icons enabled.""" + mock_context.config.general.icons = True + mock_context.media_api.user_profile = None + mock_context.selector.choose.return_value = None + + result = auth(mock_context, empty_state) + + # Should work with icons enabled + assert result == ControlFlow.BACK + + def test_auth_menu_icons_disabled(self, mock_context, empty_state): + """Test auth menu with icons disabled.""" + mock_context.config.general.icons = False + mock_context.media_api.user_profile = None + mock_context.selector.choose.return_value = None + + result = auth(mock_context, empty_state) + + # Should work with icons disabled + assert result == ControlFlow.BACK + + +class TestAuthMenuHelperFunctions: + """Test the helper functions in auth menu.""" + + def test_display_auth_status_authenticated(self, mock_context): + """Test displaying auth status when authenticated.""" + from fastanime.cli.interactive.menus.auth import _display_auth_status + + console = Mock() + user_profile = UserProfile( + id=12345, + name="TestUser", + avatar="https://example.com/avatar.jpg" + ) + + _display_auth_status(console, user_profile, True) + + # Should print panel with user info + console.print.assert_called() + # Check that panel was created with user information + panel_call = console.print.call_args_list[0][0][0] + assert "TestUser" in str(panel_call) + + def test_display_auth_status_not_authenticated(self, mock_context): + """Test displaying auth status when not authenticated.""" + from fastanime.cli.interactive.menus.auth import _display_auth_status + + console = Mock() + + _display_auth_status(console, None, True) + + # Should print panel with login info + console.print.assert_called() + # Check that panel was created with login information + panel_call = console.print.call_args_list[0][0][0] + assert "Log in to access" in str(panel_call) + + def test_handle_login_flow_selection(self, mock_context): + """Test handling login with flow selection.""" + from fastanime.cli.interactive.menus.auth import _handle_login + + auth_manager = Mock() + feedback = Mock() + + # Mock selector to choose OAuth flow + mock_context.selector.choose.return_value = "🔗 OAuth Browser Flow" + + with patch('fastanime.cli.interactive.menus.auth._handle_oauth_flow') as mock_oauth: + mock_oauth.return_value = ControlFlow.CONTINUE + + result = _handle_login(mock_context, auth_manager, feedback, True) + + # Should call OAuth flow handler + mock_oauth.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_handle_login_token_selection(self, mock_context): + """Test handling login with token input.""" + from fastanime.cli.interactive.menus.auth import _handle_login + + auth_manager = Mock() + feedback = Mock() + + # Mock selector to choose token input + mock_context.selector.choose.return_value = "🔑 Enter Access Token" + + with patch('fastanime.cli.interactive.menus.auth._handle_token_input') as mock_token: + mock_token.return_value = ControlFlow.CONTINUE + + result = _handle_login(mock_context, auth_manager, feedback, True) + + # Should call token input handler + mock_token.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_handle_login_back_selection(self, mock_context): + """Test handling login with back selection.""" + from fastanime.cli.interactive.menus.auth import _handle_login + + auth_manager = Mock() + feedback = Mock() + + # Mock selector to choose back + mock_context.selector.choose.return_value = "↩️ Back" + + result = _handle_login(mock_context, auth_manager, feedback, True) + + # Should return CONTINUE (stay in auth menu) + assert result == ControlFlow.CONTINUE + + def test_handle_logout_success(self, mock_context): + """Test successful logout.""" + from fastanime.cli.interactive.menus.auth import _handle_logout + + auth_manager = Mock() + feedback = Mock() + + # Mock successful logout + auth_manager.logout.return_value = True + feedback.confirm.return_value = True + + result = _handle_logout(mock_context, auth_manager, feedback, True) + + # Should logout and reload context + auth_manager.logout.assert_called_once() + assert result == ControlFlow.RELOAD_CONFIG + + def test_handle_logout_cancelled(self, mock_context): + """Test cancelled logout.""" + from fastanime.cli.interactive.menus.auth import _handle_logout + + auth_manager = Mock() + feedback = Mock() + + # Mock cancelled logout + feedback.confirm.return_value = False + + result = _handle_logout(mock_context, auth_manager, feedback, True) + + # Should not logout and continue + auth_manager.logout.assert_not_called() + assert result == ControlFlow.CONTINUE + + def test_handle_logout_failure(self, mock_context): + """Test failed logout.""" + from fastanime.cli.interactive.menus.auth import _handle_logout + + auth_manager = Mock() + feedback = Mock() + + # Mock failed logout + auth_manager.logout.return_value = False + feedback.confirm.return_value = True + + result = _handle_logout(mock_context, auth_manager, feedback, True) + + # Should try logout but continue on failure + auth_manager.logout.assert_called_once() + feedback.error.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_handle_oauth_flow_success(self, mock_context): + """Test successful OAuth flow.""" + from fastanime.cli.interactive.menus.auth import _handle_oauth_flow + + auth_manager = Mock() + feedback = Mock() + + # Mock successful OAuth + auth_manager.start_oauth_flow.return_value = ("auth_url", "device_code") + auth_manager.poll_for_token.return_value = True + + with patch('fastanime.cli.interactive.menus.auth.webbrowser.open') as mock_browser: + result = _handle_oauth_flow(mock_context, auth_manager, feedback, True) + + # Should open browser and reload config + mock_browser.assert_called_once() + auth_manager.start_oauth_flow.assert_called_once() + auth_manager.poll_for_token.assert_called_once() + assert result == ControlFlow.RELOAD_CONFIG + + def test_handle_oauth_flow_failure(self, mock_context): + """Test failed OAuth flow.""" + from fastanime.cli.interactive.menus.auth import _handle_oauth_flow + + auth_manager = Mock() + feedback = Mock() + + # Mock failed OAuth + auth_manager.start_oauth_flow.return_value = ("auth_url", "device_code") + auth_manager.poll_for_token.return_value = False + + with patch('fastanime.cli.interactive.menus.auth.webbrowser.open'): + result = _handle_oauth_flow(mock_context, auth_manager, feedback, True) + + # Should continue on failure + feedback.error.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_handle_token_input_success(self, mock_context): + """Test successful token input.""" + from fastanime.cli.interactive.menus.auth import _handle_token_input + + auth_manager = Mock() + feedback = Mock() + + # Mock token input + mock_context.selector.ask.return_value = "valid_token" + auth_manager.save_token.return_value = True + + result = _handle_token_input(mock_context, auth_manager, feedback, True) + + # Should save token and reload config + auth_manager.save_token.assert_called_once_with("valid_token") + assert result == ControlFlow.RELOAD_CONFIG + + def test_handle_token_input_empty(self, mock_context): + """Test empty token input.""" + from fastanime.cli.interactive.menus.auth import _handle_token_input + + auth_manager = Mock() + feedback = Mock() + + # Mock empty token input + mock_context.selector.ask.return_value = "" + + result = _handle_token_input(mock_context, auth_manager, feedback, True) + + # Should continue without saving + auth_manager.save_token.assert_not_called() + assert result == ControlFlow.CONTINUE + + def test_handle_token_input_failure(self, mock_context): + """Test failed token input.""" + from fastanime.cli.interactive.menus.auth import _handle_token_input + + auth_manager = Mock() + feedback = Mock() + + # Mock token input with save failure + mock_context.selector.ask.return_value = "invalid_token" + auth_manager.save_token.return_value = False + + result = _handle_token_input(mock_context, auth_manager, feedback, True) + + # Should continue on save failure + auth_manager.save_token.assert_called_once_with("invalid_token") + feedback.error.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_display_user_profile_details(self, mock_context): + """Test displaying user profile details.""" + from fastanime.cli.interactive.menus.auth import _display_user_profile_details + + console = Mock() + user_profile = UserProfile( + id=12345, + name="TestUser", + avatar="https://example.com/avatar.jpg" + ) + + _display_user_profile_details(console, user_profile, True) + + # Should print table with user details + console.print.assert_called() + + def test_display_token_help(self, mock_context): + """Test displaying token help information.""" + from fastanime.cli.interactive.menus.auth import _display_token_help + + console = Mock() + + _display_token_help(console, True) + + # Should print help information + console.print.assert_called() diff --git a/tests/interactive/menus/test_episodes.py b/tests/interactive/menus/test_episodes.py new file mode 100644 index 0000000..d877b54 --- /dev/null +++ b/tests/interactive/menus/test_episodes.py @@ -0,0 +1,410 @@ +""" +Tests for the episodes menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch + +from fastanime.cli.interactive.menus.episodes import episodes +from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState +from fastanime.libs.providers.anime.types import Anime, Episodes + + +class TestEpisodesMenu: + """Test cases for the episodes menu.""" + + def test_episodes_menu_missing_anime_data(self, mock_context, empty_state): + """Test episodes menu with missing anime data.""" + # State without provider or media API anime + result = episodes(mock_context, empty_state) + + # Should go back when anime data is missing + assert result == ControlFlow.BACK + + def test_episodes_menu_missing_provider_anime(self, mock_context, state_with_media_api): + """Test episodes menu with missing provider anime.""" + result = episodes(mock_context, state_with_media_api) + + # Should go back when provider anime is missing + assert result == ControlFlow.BACK + + def test_episodes_menu_missing_media_api_anime(self, mock_context, state_with_provider): + """Test episodes menu with missing media API anime.""" + result = episodes(mock_context, state_with_provider) + + # Should go back when media API anime is missing + assert result == ControlFlow.BACK + + def test_episodes_menu_no_episodes_available(self, mock_context, full_state): + """Test episodes menu when no episodes are available for translation type.""" + # Mock provider anime with no sub episodes + provider_anime = Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg", + episodes=Episodes(sub=[], dub=["1", "2", "3"]) # No sub episodes + ) + + state_no_sub = State( + menu_name="EPISODES", + media_api=full_state.media_api, + provider=ProviderState(anime=provider_anime) + ) + + # Config set to sub but no sub episodes available + mock_context.config.stream.translation_type = "sub" + + result = episodes(mock_context, state_no_sub) + + # Should go back when no episodes available for translation type + assert result == ControlFlow.BACK + + def test_episodes_menu_continue_from_local_history(self, mock_context, full_state): + """Test episodes menu with local watch history continuation.""" + # Setup provider anime with episodes + provider_anime = Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg", + episodes=Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) + ) + + state_with_episodes = State( + menu_name="EPISODES", + media_api=full_state.media_api, + provider=ProviderState(anime=provider_anime) + ) + + # Enable continue from watch history with local preference + mock_context.config.stream.continue_from_watch_history = True + mock_context.config.stream.preferred_watch_history = "local" + + with patch('fastanime.cli.interactive.menus.episodes.get_continue_episode') as mock_continue: + mock_continue.return_value = "2" # Continue from episode 2 + + with patch('fastanime.cli.interactive.menus.episodes.click.echo'): + result = episodes(mock_context, state_with_episodes) + + # Should transition to SERVERS state with the continue episode + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + assert result.provider.episode_number == "2" + + def test_episodes_menu_continue_from_anilist_progress(self, mock_context, full_state): + """Test episodes menu with AniList progress continuation.""" + # Setup provider anime with episodes + provider_anime = Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg", + episodes=Episodes(sub=["1", "2", "3", "4", "5"], dub=["1", "2", "3", "4", "5"]) + ) + + # Setup media API anime with progress + media_anime = full_state.media_api.anime + media_anime.progress = 3 # Watched 3 episodes + + state_with_episodes = State( + menu_name="EPISODES", + media_api=MediaApiState(anime=media_anime), + provider=ProviderState(anime=provider_anime) + ) + + # Enable continue from watch history with remote preference + mock_context.config.stream.continue_from_watch_history = True + mock_context.config.stream.preferred_watch_history = "remote" + + with patch('fastanime.cli.interactive.menus.episodes.get_continue_episode') as mock_continue: + mock_continue.return_value = None # No local history + + with patch('fastanime.cli.interactive.menus.episodes.click.echo'): + result = episodes(mock_context, state_with_episodes) + + # Should transition to SERVERS state with next episode (4) + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + assert result.provider.episode_number == "4" + + def test_episodes_menu_manual_selection(self, mock_context, full_state): + """Test episodes menu with manual episode selection.""" + # Setup provider anime with episodes + provider_anime = Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg", + episodes=Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) + ) + + state_with_episodes = State( + menu_name="EPISODES", + media_api=full_state.media_api, + provider=ProviderState(anime=provider_anime) + ) + + # Disable continue from watch history + mock_context.config.stream.continue_from_watch_history = False + + # Mock user selection + mock_context.selector.choose.return_value = "Episode 2" + + with patch('fastanime.cli.interactive.menus.episodes._format_episode_choice') as mock_format: + mock_format.side_effect = lambda ep, _: f"Episode {ep}" + + result = episodes(mock_context, state_with_episodes) + + # Should transition to SERVERS state with selected episode + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + assert result.provider.episode_number == "2" + + def test_episodes_menu_no_selection_made(self, mock_context, full_state): + """Test episodes menu when no selection is made.""" + # Setup provider anime with episodes + provider_anime = Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg", + episodes=Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) + ) + + state_with_episodes = State( + menu_name="EPISODES", + media_api=full_state.media_api, + provider=ProviderState(anime=provider_anime) + ) + + # Disable continue from watch history + mock_context.config.stream.continue_from_watch_history = False + + # Mock no selection + mock_context.selector.choose.return_value = None + + result = episodes(mock_context, state_with_episodes) + + # Should go back when no selection is made + assert result == ControlFlow.BACK + + def test_episodes_menu_back_selection(self, mock_context, full_state): + """Test episodes menu back selection.""" + # Setup provider anime with episodes + provider_anime = Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg", + episodes=Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) + ) + + state_with_episodes = State( + menu_name="EPISODES", + media_api=full_state.media_api, + provider=ProviderState(anime=provider_anime) + ) + + # Disable continue from watch history + mock_context.config.stream.continue_from_watch_history = False + + # Mock back selection + mock_context.selector.choose.return_value = "Back" + + result = episodes(mock_context, state_with_episodes) + + # Should go back + assert result == ControlFlow.BACK + + def test_episodes_menu_invalid_episode_selection(self, mock_context, full_state): + """Test episodes menu with invalid episode selection.""" + # Setup provider anime with episodes + provider_anime = Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg", + episodes=Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) + ) + + state_with_episodes = State( + menu_name="EPISODES", + media_api=full_state.media_api, + provider=ProviderState(anime=provider_anime) + ) + + # Disable continue from watch history + mock_context.config.stream.continue_from_watch_history = False + + # Mock invalid selection (not in episode map) + mock_context.selector.choose.return_value = "Invalid Episode" + + with patch('fastanime.cli.interactive.menus.episodes._format_episode_choice') as mock_format: + mock_format.side_effect = lambda ep, _: f"Episode {ep}" + + result = episodes(mock_context, state_with_episodes) + + # Should go back for invalid selection + assert result == ControlFlow.BACK + + def test_episodes_menu_dub_translation_type(self, mock_context, full_state): + """Test episodes menu with dub translation type.""" + # Setup provider anime with both sub and dub episodes + provider_anime = Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg", + episodes=Episodes(sub=["1", "2", "3"], dub=["1", "2"]) # Only 2 dub episodes + ) + + state_with_episodes = State( + menu_name="EPISODES", + media_api=full_state.media_api, + provider=ProviderState(anime=provider_anime) + ) + + # Set translation type to dub + mock_context.config.stream.translation_type = "dub" + mock_context.config.stream.continue_from_watch_history = False + + # Mock user selection + mock_context.selector.choose.return_value = "Episode 1" + + with patch('fastanime.cli.interactive.menus.episodes._format_episode_choice') as mock_format: + mock_format.side_effect = lambda ep, _: f"Episode {ep}" + + result = episodes(mock_context, state_with_episodes) + + # Should use dub episodes and transition to SERVERS + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + assert result.provider.episode_number == "1" + + # Verify that dub episodes were used (only 2 available) + mock_context.selector.choose.assert_called_once() + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + episode_choices = [choice for choice in choices if choice.startswith("Episode")] + assert len(episode_choices) == 2 # Only 2 dub episodes + + def test_episodes_menu_track_episode_viewing(self, mock_context, full_state): + """Test that episode viewing is tracked when selected.""" + # Setup provider anime with episodes + provider_anime = Anime( + name="Test Anime", + url="https://example.com/anime", + id="test-anime", + poster="https://example.com/poster.jpg", + episodes=Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) + ) + + state_with_episodes = State( + menu_name="EPISODES", + media_api=full_state.media_api, + provider=ProviderState(anime=provider_anime) + ) + + # Use manual selection + mock_context.config.stream.continue_from_watch_history = False + mock_context.selector.choose.return_value = "Episode 2" + + with patch('fastanime.cli.interactive.menus.episodes._format_episode_choice') as mock_format: + mock_format.side_effect = lambda ep, _: f"Episode {ep}" + + with patch('fastanime.cli.interactive.menus.episodes.track_episode_viewing') as mock_track: + result = episodes(mock_context, state_with_episodes) + + # Should track episode viewing + mock_track.assert_called_once() + + # Should transition to SERVERS + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + + +class TestEpisodesMenuHelperFunctions: + """Test the helper functions in episodes menu.""" + + def test_format_episode_choice(self, mock_config): + """Test formatting episode choice for display.""" + from fastanime.cli.interactive.menus.episodes import _format_episode_choice + + mock_config.general.icons = True + + result = _format_episode_choice("1", mock_config) + + assert "Episode 1" in result + assert "▶️" in result # Icon should be present + + def test_format_episode_choice_no_icons(self, mock_config): + """Test formatting episode choice without icons.""" + from fastanime.cli.interactive.menus.episodes import _format_episode_choice + + mock_config.general.icons = False + + result = _format_episode_choice("1", mock_config) + + assert "Episode 1" in result + assert "▶️" not in result # Icon should not be present + + def test_get_next_episode_from_progress(self, mock_config): + """Test getting next episode from AniList progress.""" + from fastanime.cli.interactive.menus.episodes import _get_next_episode_from_progress + + # Mock media item with progress + media_item = Mock() + media_item.progress = 5 # Watched 5 episodes + + available_episodes = ["1", "2", "3", "4", "5", "6", "7", "8"] + + result = _get_next_episode_from_progress(media_item, available_episodes) + + # Should return episode 6 (next after progress) + assert result == "6" + + def test_get_next_episode_from_progress_no_progress(self, mock_config): + """Test getting next episode when no progress is available.""" + from fastanime.cli.interactive.menus.episodes import _get_next_episode_from_progress + + # Mock media item with no progress + media_item = Mock() + media_item.progress = None + + available_episodes = ["1", "2", "3", "4", "5"] + + result = _get_next_episode_from_progress(media_item, available_episodes) + + # Should return episode 1 when no progress + assert result == "1" + + def test_get_next_episode_from_progress_beyond_available(self, mock_config): + """Test getting next episode when progress is beyond available episodes.""" + from fastanime.cli.interactive.menus.episodes import _get_next_episode_from_progress + + # Mock media item with progress beyond available episodes + media_item = Mock() + media_item.progress = 10 # Progress beyond available episodes + + available_episodes = ["1", "2", "3", "4", "5"] + + result = _get_next_episode_from_progress(media_item, available_episodes) + + # Should return None when progress is beyond available episodes + assert result is None + + def test_get_next_episode_from_progress_at_end(self, mock_config): + """Test getting next episode when at the end of available episodes.""" + from fastanime.cli.interactive.menus.episodes import _get_next_episode_from_progress + + # Mock media item with progress at the end + media_item = Mock() + media_item.progress = 5 # Watched all 5 episodes + + available_episodes = ["1", "2", "3", "4", "5"] + + result = _get_next_episode_from_progress(media_item, available_episodes) + + # Should return None when at the end + assert result is None diff --git a/tests/interactive/menus/test_main.py b/tests/interactive/menus/test_main.py new file mode 100644 index 0000000..d675c76 --- /dev/null +++ b/tests/interactive/menus/test_main.py @@ -0,0 +1,376 @@ +""" +Tests for the main menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + +from fastanime.cli.interactive.menus.main import main +from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState +from fastanime.libs.api.types import MediaSearchResult + + +class TestMainMenu: + """Test cases for the main menu.""" + + def test_main_menu_displays_options(self, mock_context, empty_state): + """Test that the main menu displays all expected options.""" + # Setup selector to return None (exit) + mock_context.selector.choose.return_value = None + + result = main(mock_context, empty_state) + + # Should return EXIT when no choice is made + assert result == ControlFlow.EXIT + + # Verify selector was called with expected options + mock_context.selector.choose.assert_called_once() + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + + # Check that key options are present + expected_options = [ + "Trending", "Popular", "Favourites", "Top Scored", + "Upcoming", "Recently Updated", "Random", "Search", + "Watching", "Planned", "Completed", "Paused", "Dropped", "Rewatching", + "Local Watch History", "Authentication", "Session Management", + "Edit Config", "Exit" + ] + + for option in expected_options: + assert any(option in choice for choice in choices) + + def test_main_menu_trending_selection(self, mock_context, empty_state): + """Test selecting trending anime from main menu.""" + # Setup selector to return trending choice + trending_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Trending" in choice) + mock_context.selector.choose.return_value = trending_choice + + # Mock successful API call + mock_search_result = MediaSearchResult(media=[], page_info=Mock()) + mock_context.media_api.search_media.return_value = mock_search_result + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_search_result) + + result = main(mock_context, empty_state) + + # Should transition to RESULTS state + assert isinstance(result, State) + assert result.menu_name == "RESULTS" + assert result.media_api.search_results == mock_search_result + + def test_main_menu_search_selection(self, mock_context, empty_state): + """Test selecting search from main menu.""" + search_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Search" in choice) + mock_context.selector.choose.return_value = search_choice + mock_context.selector.ask.return_value = "test query" + + # Mock successful API call + mock_search_result = MediaSearchResult(media=[], page_info=Mock()) + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_search_result) + + result = main(mock_context, empty_state) + + # Should transition to RESULTS state + assert isinstance(result, State) + assert result.menu_name == "RESULTS" + assert result.media_api.search_results == mock_search_result + + def test_main_menu_search_empty_query(self, mock_context, empty_state): + """Test search with empty query returns to menu.""" + search_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Search" in choice) + mock_context.selector.choose.return_value = search_choice + mock_context.selector.ask.return_value = "" # Empty query + + result = main(mock_context, empty_state) + + # Should return CONTINUE when search query is empty + assert result == ControlFlow.CONTINUE + + def test_main_menu_user_list_authenticated(self, mock_context, empty_state): + """Test accessing user list when authenticated.""" + watching_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Watching" in choice) + mock_context.selector.choose.return_value = watching_choice + + # Ensure user is authenticated + mock_context.media_api.is_authenticated.return_value = True + + mock_search_result = MediaSearchResult(media=[], page_info=Mock()) + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_search_result) + + result = main(mock_context, empty_state) + + # Should transition to RESULTS state + assert isinstance(result, State) + assert result.menu_name == "RESULTS" + + def test_main_menu_user_list_not_authenticated(self, mock_context, empty_state): + """Test accessing user list when not authenticated.""" + watching_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Watching" in choice) + mock_context.selector.choose.return_value = watching_choice + + # User not authenticated + mock_context.media_api.is_authenticated.return_value = False + + with patch('fastanime.cli.interactive.menus.main.check_authentication_required') as mock_auth: + mock_auth.return_value = False # Authentication check fails + + result = main(mock_context, empty_state) + + # Should return CONTINUE when authentication is required but not provided + assert result == ControlFlow.CONTINUE + + def test_main_menu_exit_selection(self, mock_context, empty_state): + """Test selecting exit from main menu.""" + exit_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Exit" in choice) + mock_context.selector.choose.return_value = exit_choice + + result = main(mock_context, empty_state) + + assert result == ControlFlow.EXIT + + def test_main_menu_config_edit_selection(self, mock_context, empty_state): + """Test selecting config edit from main menu.""" + config_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Edit Config" in choice) + mock_context.selector.choose.return_value = config_choice + + result = main(mock_context, empty_state) + + assert result == ControlFlow.RELOAD_CONFIG + + def test_main_menu_session_management_selection(self, mock_context, empty_state): + """Test selecting session management from main menu.""" + session_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Session Management" in choice) + mock_context.selector.choose.return_value = session_choice + + result = main(mock_context, empty_state) + + assert isinstance(result, State) + assert result.menu_name == "SESSION_MANAGEMENT" + + def test_main_menu_auth_selection(self, mock_context, empty_state): + """Test selecting authentication from main menu.""" + auth_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Authentication" in choice) + mock_context.selector.choose.return_value = auth_choice + + result = main(mock_context, empty_state) + + assert isinstance(result, State) + assert result.menu_name == "AUTH" + + def test_main_menu_watch_history_selection(self, mock_context, empty_state): + """Test selecting local watch history from main menu.""" + history_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Local Watch History" in choice) + mock_context.selector.choose.return_value = history_choice + + result = main(mock_context, empty_state) + + assert isinstance(result, State) + assert result.menu_name == "WATCH_HISTORY" + + def test_main_menu_api_failure(self, mock_context, empty_state): + """Test handling API failures in main menu.""" + trending_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Trending" in choice) + mock_context.selector.choose.return_value = trending_choice + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (False, None) # API failure + + result = main(mock_context, empty_state) + + # Should return CONTINUE on API failure + assert result == ControlFlow.CONTINUE + + def test_main_menu_random_selection(self, mock_context, empty_state): + """Test selecting random anime from main menu.""" + random_choice = next(choice for choice in self._get_menu_choices(mock_context) + if "Random" in choice) + mock_context.selector.choose.return_value = random_choice + + mock_search_result = MediaSearchResult(media=[], page_info=Mock()) + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_search_result) + + result = main(mock_context, empty_state) + + # Should transition to RESULTS state + assert isinstance(result, State) + assert result.menu_name == "RESULTS" + assert result.media_api.search_results == mock_search_result + + def test_main_menu_icons_enabled(self, mock_context, empty_state): + """Test main menu with icons enabled.""" + mock_context.config.general.icons = True + + # Just ensure menu doesn't crash with icons enabled + mock_context.selector.choose.return_value = None + + result = main(mock_context, empty_state) + assert result == ControlFlow.EXIT + + def test_main_menu_icons_disabled(self, mock_context, empty_state): + """Test main menu with icons disabled.""" + mock_context.config.general.icons = False + + # Just ensure menu doesn't crash with icons disabled + mock_context.selector.choose.return_value = None + + result = main(mock_context, empty_state) + assert result == ControlFlow.EXIT + + def _get_menu_choices(self, mock_context): + """Helper to get the menu choices from a mock call.""" + # Temporarily call the menu to get choices + mock_context.selector.choose.return_value = None + main(mock_context, State(menu_name="TEST")) + + # Extract choices from the call + call_args = mock_context.selector.choose.call_args + return call_args[1]['choices'] + + +class TestMainMenuHelperFunctions: + """Test the helper functions in main menu.""" + + def test_create_media_list_action_success(self, mock_context): + """Test creating a media list action that succeeds.""" + from fastanime.cli.interactive.menus.main import _create_media_list_action + + action = _create_media_list_action(mock_context, "TRENDING_DESC") + + mock_search_result = MediaSearchResult(media=[], page_info=Mock()) + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_search_result) + + menu_name, result, api_params, user_list_params = action() + + assert menu_name == "RESULTS" + assert result == mock_search_result + assert api_params is not None + assert user_list_params is None + + def test_create_media_list_action_failure(self, mock_context): + """Test creating a media list action that fails.""" + from fastanime.cli.interactive.menus.main import _create_media_list_action + + action = _create_media_list_action(mock_context, "TRENDING_DESC") + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (False, None) + + menu_name, result, api_params, user_list_params = action() + + assert menu_name == "CONTINUE" + assert result is None + assert api_params is None + assert user_list_params is None + + def test_create_user_list_action_authenticated(self, mock_context): + """Test creating a user list action when authenticated.""" + from fastanime.cli.interactive.menus.main import _create_user_list_action + + action = _create_user_list_action(mock_context, "CURRENT") + + mock_search_result = MediaSearchResult(media=[], page_info=Mock()) + + with patch('fastanime.cli.interactive.menus.main.check_authentication_required') as mock_auth: + mock_auth.return_value = True + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_search_result) + + menu_name, result, api_params, user_list_params = action() + + assert menu_name == "RESULTS" + assert result == mock_search_result + assert api_params is None + assert user_list_params is not None + + def test_create_user_list_action_not_authenticated(self, mock_context): + """Test creating a user list action when not authenticated.""" + from fastanime.cli.interactive.menus.main import _create_user_list_action + + action = _create_user_list_action(mock_context, "CURRENT") + + with patch('fastanime.cli.interactive.menus.main.check_authentication_required') as mock_auth: + mock_auth.return_value = False + + menu_name, result, api_params, user_list_params = action() + + assert menu_name == "CONTINUE" + assert result is None + assert api_params is None + assert user_list_params is None + + def test_create_search_media_list_with_query(self, mock_context): + """Test creating a search media list action with a query.""" + from fastanime.cli.interactive.menus.main import _create_search_media_list + + action = _create_search_media_list(mock_context) + + mock_context.selector.ask.return_value = "test query" + mock_search_result = MediaSearchResult(media=[], page_info=Mock()) + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_search_result) + + menu_name, result, api_params, user_list_params = action() + + assert menu_name == "RESULTS" + assert result == mock_search_result + assert api_params is not None + assert user_list_params is None + + def test_create_search_media_list_no_query(self, mock_context): + """Test creating a search media list action without a query.""" + from fastanime.cli.interactive.menus.main import _create_search_media_list + + action = _create_search_media_list(mock_context) + + mock_context.selector.ask.return_value = "" # Empty query + + menu_name, result, api_params, user_list_params = action() + + assert menu_name == "CONTINUE" + assert result is None + assert api_params is None + assert user_list_params is None + + def test_create_random_media_list(self, mock_context): + """Test creating a random media list action.""" + from fastanime.cli.interactive.menus.main import _create_random_media_list + + action = _create_random_media_list(mock_context) + + mock_search_result = MediaSearchResult(media=[], page_info=Mock()) + + with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_search_result) + + menu_name, result, api_params, user_list_params = action() + + assert menu_name == "RESULTS" + assert result == mock_search_result + assert api_params is not None + assert user_list_params is None + # Check that random IDs were used + assert api_params.id_in is not None + assert len(api_params.id_in) == 50 diff --git a/tests/interactive/menus/test_media_actions.py b/tests/interactive/menus/test_media_actions.py new file mode 100644 index 0000000..673343e --- /dev/null +++ b/tests/interactive/menus/test_media_actions.py @@ -0,0 +1,383 @@ +""" +Tests for the media actions menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch + +from fastanime.cli.interactive.menus.media_actions import media_actions +from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState +from fastanime.libs.api.types import MediaItem +from fastanime.libs.players.types import PlayerResult + + +class TestMediaActionsMenu: + """Test cases for the media actions menu.""" + + def test_media_actions_menu_display(self, mock_context, state_with_media_api): + """Test that media actions menu displays correctly.""" + mock_context.selector.choose.return_value = "🔙 Back to Results" + + with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: + mock_auth.return_value = ("🟢 Authenticated", Mock()) + + result = media_actions(mock_context, state_with_media_api) + + # Should go back when "Back to Results" is selected + assert result == ControlFlow.BACK + + # Verify selector was called with expected options + mock_context.selector.choose.assert_called_once() + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + + # Check that key options are present + expected_options = [ + "Stream", "Watch Trailer", "Add/Update List", + "Score Anime", "Add to Local History", "View Info", "Back to Results" + ] + + for option in expected_options: + assert any(option in choice for choice in choices) + + def test_media_actions_stream_selection(self, mock_context, state_with_media_api): + """Test selecting stream from media actions.""" + mock_context.selector.choose.return_value = "▶️ Stream" + + with patch('fastanime.cli.interactive.menus.media_actions._stream') as mock_stream: + mock_stream.return_value = lambda: State(menu_name="PROVIDER_SEARCH") + + result = media_actions(mock_context, state_with_media_api) + + # Should call stream function + mock_stream.assert_called_once_with(mock_context, state_with_media_api) + # Should return state transition + assert isinstance(result(), State) + assert result().menu_name == "PROVIDER_SEARCH" + + def test_media_actions_trailer_selection(self, mock_context, state_with_media_api): + """Test selecting watch trailer from media actions.""" + mock_context.selector.choose.return_value = "📼 Watch Trailer" + + with patch('fastanime.cli.interactive.menus.media_actions._watch_trailer') as mock_trailer: + mock_trailer.return_value = lambda: ControlFlow.CONTINUE + + result = media_actions(mock_context, state_with_media_api) + + # Should call trailer function + mock_trailer.assert_called_once_with(mock_context, state_with_media_api) + assert result() == ControlFlow.CONTINUE + + def test_media_actions_add_to_list_selection(self, mock_context, state_with_media_api): + """Test selecting add/update list from media actions.""" + mock_context.selector.choose.return_value = "➕ Add/Update List" + + with patch('fastanime.cli.interactive.menus.media_actions._add_to_list') as mock_add: + mock_add.return_value = lambda: ControlFlow.CONTINUE + + result = media_actions(mock_context, state_with_media_api) + + # Should call add to list function + mock_add.assert_called_once_with(mock_context, state_with_media_api) + assert result() == ControlFlow.CONTINUE + + def test_media_actions_score_selection(self, mock_context, state_with_media_api): + """Test selecting score anime from media actions.""" + mock_context.selector.choose.return_value = "⭐ Score Anime" + + with patch('fastanime.cli.interactive.menus.media_actions._score_anime') as mock_score: + mock_score.return_value = lambda: ControlFlow.CONTINUE + + result = media_actions(mock_context, state_with_media_api) + + # Should call score function + mock_score.assert_called_once_with(mock_context, state_with_media_api) + assert result() == ControlFlow.CONTINUE + + def test_media_actions_local_history_selection(self, mock_context, state_with_media_api): + """Test selecting add to local history from media actions.""" + mock_context.selector.choose.return_value = "📚 Add to Local History" + + with patch('fastanime.cli.interactive.menus.media_actions._add_to_local_history') as mock_history: + mock_history.return_value = lambda: ControlFlow.CONTINUE + + result = media_actions(mock_context, state_with_media_api) + + # Should call local history function + mock_history.assert_called_once_with(mock_context, state_with_media_api) + assert result() == ControlFlow.CONTINUE + + def test_media_actions_view_info_selection(self, mock_context, state_with_media_api): + """Test selecting view info from media actions.""" + mock_context.selector.choose.return_value = "ℹ️ View Info" + + with patch('fastanime.cli.interactive.menus.media_actions._view_info') as mock_info: + mock_info.return_value = lambda: ControlFlow.CONTINUE + + result = media_actions(mock_context, state_with_media_api) + + # Should call view info function + mock_info.assert_called_once_with(mock_context, state_with_media_api) + assert result() == ControlFlow.CONTINUE + + def test_media_actions_back_selection(self, mock_context, state_with_media_api): + """Test selecting back from media actions.""" + mock_context.selector.choose.return_value = "🔙 Back to Results" + + with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: + mock_auth.return_value = ("Auth Status", Mock()) + + result = media_actions(mock_context, state_with_media_api) + + assert result == ControlFlow.BACK + + def test_media_actions_no_choice(self, mock_context, state_with_media_api): + """Test media actions menu when no choice is made.""" + mock_context.selector.choose.return_value = None + + with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: + mock_auth.return_value = ("Auth Status", Mock()) + + result = media_actions(mock_context, state_with_media_api) + + # Should return None when no choice is made + assert result is None + + def test_media_actions_unknown_choice(self, mock_context, state_with_media_api): + """Test media actions menu with unknown choice.""" + mock_context.selector.choose.return_value = "Unknown Option" + + with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: + mock_auth.return_value = ("Auth Status", Mock()) + + result = media_actions(mock_context, state_with_media_api) + + # Should return None for unknown choices + assert result is None + + def test_media_actions_header_content(self, mock_context, state_with_media_api): + """Test that media actions header contains anime title and auth status.""" + mock_context.selector.choose.return_value = "🔙 Back to Results" + + with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: + mock_auth.return_value = ("🟢 Authenticated", Mock()) + + result = media_actions(mock_context, state_with_media_api) + + # Verify header contains anime title and auth status + call_args = mock_context.selector.choose.call_args + header = call_args[1]['header'] + assert "Test Anime" in header + assert "🟢 Authenticated" in header + + def test_media_actions_icons_enabled(self, mock_context, state_with_media_api): + """Test media actions menu with icons enabled.""" + mock_context.config.general.icons = True + mock_context.selector.choose.return_value = "▶️ Stream" + + with patch('fastanime.cli.interactive.menus.media_actions._stream') as mock_stream: + mock_stream.return_value = lambda: ControlFlow.CONTINUE + + result = media_actions(mock_context, state_with_media_api) + + # Should work with icons enabled + assert result() == ControlFlow.CONTINUE + + def test_media_actions_icons_disabled(self, mock_context, state_with_media_api): + """Test media actions menu with icons disabled.""" + mock_context.config.general.icons = False + mock_context.selector.choose.return_value = "Stream" + + with patch('fastanime.cli.interactive.menus.media_actions._stream') as mock_stream: + mock_stream.return_value = lambda: ControlFlow.CONTINUE + + result = media_actions(mock_context, state_with_media_api) + + # Should work with icons disabled + assert result() == ControlFlow.CONTINUE + + +class TestMediaActionsHelperFunctions: + """Test the helper functions in media actions menu.""" + + def test_stream_function(self, mock_context, state_with_media_api): + """Test the stream helper function.""" + from fastanime.cli.interactive.menus.media_actions import _stream + + stream_func = _stream(mock_context, state_with_media_api) + + # Should return a function that transitions to PROVIDER_SEARCH + result = stream_func() + assert isinstance(result, State) + assert result.menu_name == "PROVIDER_SEARCH" + # Should preserve media API state + assert result.media_api.anime == state_with_media_api.media_api.anime + + def test_watch_trailer_success(self, mock_context, state_with_media_api): + """Test watching trailer successfully.""" + from fastanime.cli.interactive.menus.media_actions import _watch_trailer + + # Mock anime with trailer URL + anime_with_trailer = MediaItem( + id=1, + title={"english": "Test Anime", "romaji": "Test Anime"}, + status="FINISHED", + episodes=12, + trailer="https://youtube.com/watch?v=test" + ) + + state_with_trailer = State( + menu_name="MEDIA_ACTIONS", + media_api=MediaApiState(anime=anime_with_trailer) + ) + + trailer_func = _watch_trailer(mock_context, state_with_trailer) + + # Mock successful player result + mock_context.player.play.return_value = PlayerResult(success=True, exit_code=0) + + with patch('fastanime.cli.interactive.menus.media_actions.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = trailer_func() + + # Should play trailer and continue + mock_context.player.play.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_watch_trailer_no_url(self, mock_context, state_with_media_api): + """Test watching trailer when no trailer URL available.""" + from fastanime.cli.interactive.menus.media_actions import _watch_trailer + + trailer_func = _watch_trailer(mock_context, state_with_media_api) + + with patch('fastanime.cli.interactive.menus.media_actions.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = trailer_func() + + # Should show error and continue + feedback_obj.error.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_add_to_list_authenticated(self, mock_context, state_with_media_api): + """Test adding to list when authenticated.""" + from fastanime.cli.interactive.menus.media_actions import _add_to_list + + add_func = _add_to_list(mock_context, state_with_media_api) + + # Mock authentication check + with patch('fastanime.cli.interactive.menus.media_actions.check_authentication_required') as mock_auth: + mock_auth.return_value = True + + # Mock status selection + mock_context.selector.choose.return_value = "CURRENT" + + # Mock successful API call + with patch('fastanime.cli.interactive.menus.media_actions.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, None) + + result = add_func() + + # Should call API and continue + mock_execute.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_add_to_list_not_authenticated(self, mock_context, state_with_media_api): + """Test adding to list when not authenticated.""" + from fastanime.cli.interactive.menus.media_actions import _add_to_list + + add_func = _add_to_list(mock_context, state_with_media_api) + + # Mock authentication check failure + with patch('fastanime.cli.interactive.menus.media_actions.check_authentication_required') as mock_auth: + mock_auth.return_value = False + + result = add_func() + + # Should continue without API call + assert result == ControlFlow.CONTINUE + + def test_score_anime_authenticated(self, mock_context, state_with_media_api): + """Test scoring anime when authenticated.""" + from fastanime.cli.interactive.menus.media_actions import _score_anime + + score_func = _score_anime(mock_context, state_with_media_api) + + # Mock authentication check + with patch('fastanime.cli.interactive.menus.media_actions.check_authentication_required') as mock_auth: + mock_auth.return_value = True + + # Mock score input + mock_context.selector.ask.return_value = "8.5" + + # Mock successful API call + with patch('fastanime.cli.interactive.menus.media_actions.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, None) + + result = score_func() + + # Should call API and continue + mock_execute.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_score_anime_invalid_score(self, mock_context, state_with_media_api): + """Test scoring anime with invalid score.""" + from fastanime.cli.interactive.menus.media_actions import _score_anime + + score_func = _score_anime(mock_context, state_with_media_api) + + # Mock authentication check + with patch('fastanime.cli.interactive.menus.media_actions.check_authentication_required') as mock_auth: + mock_auth.return_value = True + + # Mock invalid score input + mock_context.selector.ask.return_value = "invalid" + + with patch('fastanime.cli.interactive.menus.media_actions.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = score_func() + + # Should show error and continue + feedback_obj.error.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_add_to_local_history(self, mock_context, state_with_media_api): + """Test adding anime to local history.""" + from fastanime.cli.interactive.menus.media_actions import _add_to_local_history + + history_func = _add_to_local_history(mock_context, state_with_media_api) + + with patch('fastanime.cli.interactive.menus.media_actions.track_anime_in_history') as mock_track: + with patch('fastanime.cli.interactive.menus.media_actions.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = history_func() + + # Should track in history and continue + mock_track.assert_called_once() + feedback_obj.success.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_view_info(self, mock_context, state_with_media_api): + """Test viewing anime information.""" + from fastanime.cli.interactive.menus.media_actions import _view_info + + info_func = _view_info(mock_context, state_with_media_api) + + with patch('fastanime.cli.interactive.menus.media_actions.display_anime_info') as mock_display: + with patch('fastanime.cli.interactive.menus.media_actions.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = info_func() + + # Should display info and pause for user + mock_display.assert_called_once() + feedback_obj.pause_for_user.assert_called_once() + assert result == ControlFlow.CONTINUE diff --git a/tests/interactive/menus/test_player_controls.py b/tests/interactive/menus/test_player_controls.py new file mode 100644 index 0000000..75559bf --- /dev/null +++ b/tests/interactive/menus/test_player_controls.py @@ -0,0 +1,479 @@ +""" +Tests for the player controls menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import threading + +from fastanime.cli.interactive.menus.player_controls import player_controls +from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState +from fastanime.libs.players.types import PlayerResult +from fastanime.libs.providers.anime.types import Server, StreamLink +from fastanime.libs.api.types import MediaItem + + +class TestPlayerControlsMenu: + """Test cases for the player controls menu.""" + + def test_player_controls_menu_missing_data(self, mock_context, empty_state): + """Test player controls menu with missing data.""" + result = player_controls(mock_context, empty_state) + + # Should go back when required data is missing + assert result == ControlFlow.BACK + + def test_player_controls_menu_successful_playback(self, mock_context, full_state): + """Test player controls menu after successful playback.""" + # Setup state with player result + state_with_result = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + # Mock user choice to go back + mock_context.selector.choose.return_value = "🔙 Back to Episodes" + + result = player_controls(mock_context, state_with_result) + + # Should go back to episodes + assert result == ControlFlow.BACK + + def test_player_controls_menu_playback_failure(self, mock_context, full_state): + """Test player controls menu after playback failure.""" + # Setup state with failed player result + state_with_failure = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1", + last_player_result=PlayerResult(success=False, exit_code=1) + ) + ) + + # Mock user choice to retry + mock_context.selector.choose.return_value = "🔄 Try Different Server" + + result = player_controls(mock_context, state_with_failure) + + # Should transition back to SERVERS state + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + + def test_player_controls_next_episode_available(self, mock_context, full_state): + """Test next episode option when available.""" + # Mock anime with multiple episodes + from fastanime.libs.providers.anime.types import Episodes + provider_anime = full_state.provider.anime + provider_anime.episodes = Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) + + state_with_next = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=provider_anime, + episode_number="1", # Currently on episode 1, so 2 is available + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + # Mock user choice to play next episode + mock_context.selector.choose.return_value = "▶️ Next Episode (2)" + + result = player_controls(mock_context, state_with_next) + + # Should transition to SERVERS state with next episode + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + assert result.provider.episode_number == "2" + + def test_player_controls_no_next_episode(self, mock_context, full_state): + """Test when no next episode is available.""" + # Mock anime with only one episode + from fastanime.libs.providers.anime.types import Episodes + provider_anime = full_state.provider.anime + provider_anime.episodes = Episodes(sub=["1"], dub=["1"]) + + state_last_episode = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=provider_anime, + episode_number="1", # Last episode + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + # Mock back selection since no next episode + mock_context.selector.choose.return_value = "🔙 Back to Episodes" + + result = player_controls(mock_context, state_last_episode) + + # Should go back + assert result == ControlFlow.BACK + + # Verify next episode option is not in choices + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + next_episode_options = [choice for choice in choices if "Next Episode" in choice] + assert len(next_episode_options) == 0 + + def test_player_controls_replay_episode(self, mock_context, full_state): + """Test replaying current episode.""" + state_with_result = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + # Mock user choice to replay + mock_context.selector.choose.return_value = "🔄 Replay Episode" + + result = player_controls(mock_context, state_with_result) + + # Should transition back to SERVERS state with same episode + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + assert result.provider.episode_number == "1" + + def test_player_controls_change_server(self, mock_context, full_state): + """Test changing server option.""" + state_with_result = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + # Mock user choice to try different server + mock_context.selector.choose.return_value = "🔄 Try Different Server" + + result = player_controls(mock_context, state_with_result) + + # Should transition back to SERVERS state + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + + def test_player_controls_mark_as_watched(self, mock_context, full_state): + """Test marking episode as watched.""" + state_with_result = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + # Mock authenticated user + mock_context.media_api.is_authenticated.return_value = True + + # Mock user choice to mark as watched + mock_context.selector.choose.return_value = "✅ Mark as Watched" + + with patch('fastanime.cli.interactive.menus.player_controls._update_progress_in_background') as mock_update: + result = player_controls(mock_context, state_with_result) + + # Should update progress in background + mock_update.assert_called_once() + + # Should continue + assert result == ControlFlow.CONTINUE + + def test_player_controls_not_authenticated_no_mark_option(self, mock_context, full_state): + """Test that mark as watched option is not shown when not authenticated.""" + state_with_result = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + # Mock unauthenticated user + mock_context.media_api.is_authenticated.return_value = False + mock_context.selector.choose.return_value = "🔙 Back to Episodes" + + result = player_controls(mock_context, state_with_result) + + # Verify mark as watched option is not in choices + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + mark_options = [choice for choice in choices if "Mark as Watched" in choice] + assert len(mark_options) == 0 + + def test_player_controls_auto_next_enabled(self, mock_context, full_state): + """Test auto next episode when enabled in config.""" + # Enable auto next in config + mock_context.config.stream.auto_next = True + + # Mock anime with multiple episodes + from fastanime.libs.providers.anime.types import Episodes + provider_anime = full_state.provider.anime + provider_anime.episodes = Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) + + state_with_auto_next = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=provider_anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + result = player_controls(mock_context, state_with_auto_next) + + # Should automatically transition to next episode + assert isinstance(result, State) + assert result.menu_name == "SERVERS" + assert result.provider.episode_number == "2" + + # Selector should not be called for auto next + mock_context.selector.choose.assert_not_called() + + def test_player_controls_auto_next_last_episode(self, mock_context, full_state): + """Test auto next when on last episode.""" + # Enable auto next in config + mock_context.config.stream.auto_next = True + + # Mock anime with only one episode + from fastanime.libs.providers.anime.types import Episodes + provider_anime = full_state.provider.anime + provider_anime.episodes = Episodes(sub=["1"], dub=["1"]) + + state_last_episode = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=provider_anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + # Mock back selection since auto next can't proceed + mock_context.selector.choose.return_value = "🔙 Back to Episodes" + + result = player_controls(mock_context, state_last_episode) + + # Should show menu when auto next can't proceed + assert result == ControlFlow.BACK + + def test_player_controls_no_choice_made(self, mock_context, full_state): + """Test player controls when no choice is made.""" + state_with_result = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + # Mock no selection + mock_context.selector.choose.return_value = None + + result = player_controls(mock_context, state_with_result) + + # Should go back when no selection is made + assert result == ControlFlow.BACK + + def test_player_controls_icons_enabled(self, mock_context, full_state): + """Test player controls menu with icons enabled.""" + mock_context.config.general.icons = True + + state_with_result = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + mock_context.selector.choose.return_value = "🔙 Back to Episodes" + + result = player_controls(mock_context, state_with_result) + + # Should work with icons enabled + assert result == ControlFlow.BACK + + def test_player_controls_icons_disabled(self, mock_context, full_state): + """Test player controls menu with icons disabled.""" + mock_context.config.general.icons = False + + state_with_result = State( + menu_name="PLAYER_CONTROLS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1", + last_player_result=PlayerResult(success=True, exit_code=0) + ) + ) + + mock_context.selector.choose.return_value = "Back to Episodes" + + result = player_controls(mock_context, state_with_result) + + # Should work with icons disabled + assert result == ControlFlow.BACK + + +class TestPlayerControlsHelperFunctions: + """Test the helper functions in player controls menu.""" + + def test_calculate_completion_valid_times(self): + """Test calculating completion percentage with valid times.""" + from fastanime.cli.interactive.menus.player_controls import _calculate_completion + + # 30 minutes out of 60 minutes = 50% + result = _calculate_completion("00:30:00", "01:00:00") + + assert result == 50.0 + + def test_calculate_completion_zero_duration(self): + """Test calculating completion with zero duration.""" + from fastanime.cli.interactive.menus.player_controls import _calculate_completion + + result = _calculate_completion("00:30:00", "00:00:00") + + assert result == 0 + + def test_calculate_completion_invalid_format(self): + """Test calculating completion with invalid time format.""" + from fastanime.cli.interactive.menus.player_controls import _calculate_completion + + result = _calculate_completion("invalid", "01:00:00") + + assert result == 0 + + def test_calculate_completion_partial_episode(self): + """Test calculating completion for partial episode viewing.""" + from fastanime.cli.interactive.menus.player_controls import _calculate_completion + + # 15 minutes out of 24 minutes = 62.5% + result = _calculate_completion("00:15:00", "00:24:00") + + assert result == 62.5 + + def test_update_progress_in_background_authenticated(self, mock_context): + """Test updating progress in background when authenticated.""" + from fastanime.cli.interactive.menus.player_controls import _update_progress_in_background + + # Mock authenticated user + mock_context.media_api.user_profile = Mock() + mock_context.media_api.update_list_entry = Mock() + + # Call the function + _update_progress_in_background(mock_context, 123, 5) + + # Give the thread a moment to execute + import time + time.sleep(0.1) + + # Should call update_list_entry + mock_context.media_api.update_list_entry.assert_called_once() + + def test_update_progress_in_background_not_authenticated(self, mock_context): + """Test updating progress in background when not authenticated.""" + from fastanime.cli.interactive.menus.player_controls import _update_progress_in_background + + # Mock unauthenticated user + mock_context.media_api.user_profile = None + mock_context.media_api.update_list_entry = Mock() + + # Call the function + _update_progress_in_background(mock_context, 123, 5) + + # Give the thread a moment to execute + import time + time.sleep(0.1) + + # Should still call update_list_entry (comment suggests it should) + mock_context.media_api.update_list_entry.assert_called_once() + + def test_get_next_episode_number(self): + """Test getting next episode number.""" + from fastanime.cli.interactive.menus.player_controls import _get_next_episode_number + + available_episodes = ["1", "2", "3", "4", "5"] + current_episode = "3" + + result = _get_next_episode_number(available_episodes, current_episode) + + assert result == "4" + + def test_get_next_episode_number_last_episode(self): + """Test getting next episode when on last episode.""" + from fastanime.cli.interactive.menus.player_controls import _get_next_episode_number + + available_episodes = ["1", "2", "3"] + current_episode = "3" + + result = _get_next_episode_number(available_episodes, current_episode) + + assert result is None + + def test_get_next_episode_number_not_found(self): + """Test getting next episode when current episode not found.""" + from fastanime.cli.interactive.menus.player_controls import _get_next_episode_number + + available_episodes = ["1", "2", "3"] + current_episode = "5" # Not in the list + + result = _get_next_episode_number(available_episodes, current_episode) + + assert result is None + + def test_should_show_mark_as_watched_authenticated(self, mock_context): + """Test should show mark as watched when authenticated.""" + from fastanime.cli.interactive.menus.player_controls import _should_show_mark_as_watched + + mock_context.media_api.is_authenticated.return_value = True + player_result = PlayerResult(success=True, exit_code=0) + + result = _should_show_mark_as_watched(mock_context, player_result) + + assert result is True + + def test_should_show_mark_as_watched_not_authenticated(self, mock_context): + """Test should not show mark as watched when not authenticated.""" + from fastanime.cli.interactive.menus.player_controls import _should_show_mark_as_watched + + mock_context.media_api.is_authenticated.return_value = False + player_result = PlayerResult(success=True, exit_code=0) + + result = _should_show_mark_as_watched(mock_context, player_result) + + assert result is False + + def test_should_show_mark_as_watched_playback_failed(self, mock_context): + """Test should not show mark as watched when playback failed.""" + from fastanime.cli.interactive.menus.player_controls import _should_show_mark_as_watched + + mock_context.media_api.is_authenticated.return_value = True + player_result = PlayerResult(success=False, exit_code=1) + + result = _should_show_mark_as_watched(mock_context, player_result) + + assert result is False diff --git a/tests/interactive/menus/test_provider_search.py b/tests/interactive/menus/test_provider_search.py new file mode 100644 index 0000000..bdbbaf2 --- /dev/null +++ b/tests/interactive/menus/test_provider_search.py @@ -0,0 +1,465 @@ +""" +Tests for the provider search menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch + +from fastanime.cli.interactive.menus.provider_search import provider_search +from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState +from fastanime.libs.providers.anime.types import Anime, SearchResults +from fastanime.libs.api.types import MediaItem + + +class TestProviderSearchMenu: + """Test cases for the provider search menu.""" + + def test_provider_search_no_anilist_anime(self, mock_context, empty_state): + """Test provider search with no AniList anime selected.""" + result = provider_search(mock_context, empty_state) + + # Should go back when no anime is selected + assert result == ControlFlow.BACK + + def test_provider_search_no_title(self, mock_context, empty_state): + """Test provider search with anime having no title.""" + # Create anime with no title + anime_no_title = MediaItem( + id=1, + title={"english": None, "romaji": None}, + status="FINISHED", + episodes=12 + ) + + state_no_title = State( + menu_name="PROVIDER_SEARCH", + media_api=MediaApiState(anime=anime_no_title) + ) + + result = provider_search(mock_context, state_no_title) + + # Should go back when anime has no searchable title + assert result == ControlFlow.BACK + + def test_provider_search_successful_search(self, mock_context, state_with_media_api): + """Test successful provider search with results.""" + # Mock provider search results + search_results = SearchResults( + anime=[ + Anime( + name="Test Anime", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ), + Anime( + name="Test Anime Season 2", + url="https://example.com/anime2", + id="anime2", + poster="https://example.com/poster2.jpg" + ) + ] + ) + + # Mock user selection + mock_context.selector.choose.return_value = "Test Anime" + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, search_results) + + result = provider_search(mock_context, state_with_media_api) + + # Should transition to EPISODES state + assert isinstance(result, State) + assert result.menu_name == "EPISODES" + assert result.provider.anime.name == "Test Anime" + + def test_provider_search_no_results(self, mock_context, state_with_media_api): + """Test provider search with no results.""" + # Mock empty search results + empty_results = SearchResults(anime=[]) + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, empty_results) + + result = provider_search(mock_context, state_with_media_api) + + # Should go back when no results found + assert result == ControlFlow.BACK + + def test_provider_search_api_failure(self, mock_context, state_with_media_api): + """Test provider search when API fails.""" + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (False, None) + + result = provider_search(mock_context, state_with_media_api) + + # Should go back when API fails + assert result == ControlFlow.BACK + + def test_provider_search_auto_select_enabled(self, mock_context, state_with_media_api): + """Test provider search with auto select enabled.""" + # Enable auto select in config + mock_context.config.general.auto_select_anime_result = True + + # Mock search results with high similarity match + search_results = SearchResults( + anime=[ + Anime( + name="Test Anime", # Exact match with AniList title + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + ] + ) + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, search_results) + + with patch('fastanime.cli.interactive.menus.provider_search.fuzz.ratio') as mock_fuzz: + mock_fuzz.return_value = 95 # High similarity score + + result = provider_search(mock_context, state_with_media_api) + + # Should auto-select and transition to EPISODES + assert isinstance(result, State) + assert result.menu_name == "EPISODES" + + # Selector should not be called for auto selection + mock_context.selector.choose.assert_not_called() + + def test_provider_search_auto_select_low_similarity(self, mock_context, state_with_media_api): + """Test provider search with auto select but low similarity.""" + # Enable auto select in config + mock_context.config.general.auto_select_anime_result = True + + # Mock search results with low similarity + search_results = SearchResults( + anime=[ + Anime( + name="Different Anime", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + ] + ) + + mock_context.selector.choose.return_value = "Different Anime" + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, search_results) + + with patch('fastanime.cli.interactive.menus.provider_search.fuzz.ratio') as mock_fuzz: + mock_fuzz.return_value = 60 # Low similarity score + + result = provider_search(mock_context, state_with_media_api) + + # Should show manual selection + mock_context.selector.choose.assert_called_once() + assert isinstance(result, State) + assert result.menu_name == "EPISODES" + + def test_provider_search_manual_selection_cancelled(self, mock_context, state_with_media_api): + """Test provider search when manual selection is cancelled.""" + # Disable auto select + mock_context.config.general.auto_select_anime_result = False + + search_results = SearchResults( + anime=[ + Anime( + name="Test Anime", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + ] + ) + + # Mock cancelled selection + mock_context.selector.choose.return_value = None + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, search_results) + + result = provider_search(mock_context, state_with_media_api) + + # Should go back when selection is cancelled + assert result == ControlFlow.BACK + + def test_provider_search_back_selection(self, mock_context, state_with_media_api): + """Test provider search back selection.""" + search_results = SearchResults( + anime=[ + Anime( + name="Test Anime", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + ] + ) + + # Mock back selection + mock_context.selector.choose.return_value = "Back" + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, search_results) + + result = provider_search(mock_context, state_with_media_api) + + # Should go back + assert result == ControlFlow.BACK + + def test_provider_search_invalid_selection(self, mock_context, state_with_media_api): + """Test provider search with invalid selection.""" + search_results = SearchResults( + anime=[ + Anime( + name="Test Anime", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + ] + ) + + # Mock invalid selection (not in results) + mock_context.selector.choose.return_value = "Invalid Anime" + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, search_results) + + result = provider_search(mock_context, state_with_media_api) + + # Should go back for invalid selection + assert result == ControlFlow.BACK + + def test_provider_search_with_preview(self, mock_context, state_with_media_api): + """Test provider search with preview enabled.""" + mock_context.config.general.preview = "text" + + search_results = SearchResults( + anime=[ + Anime( + name="Test Anime", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + ] + ) + + mock_context.selector.choose.return_value = "Test Anime" + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, search_results) + + with patch('fastanime.cli.interactive.menus.provider_search.get_anime_preview') as mock_preview: + mock_preview.return_value = "preview_command" + + result = provider_search(mock_context, state_with_media_api) + + # Should call preview function + mock_preview.assert_called_once() + + # Verify preview was passed to selector + call_args = mock_context.selector.choose.call_args + assert call_args[1]['preview'] == "preview_command" + + def test_provider_search_english_title_preference(self, mock_context, empty_state): + """Test provider search using English title when available.""" + # Create anime with both English and Romaji titles + anime_dual_titles = MediaItem( + id=1, + title={"english": "English Title", "romaji": "Romaji Title"}, + status="FINISHED", + episodes=12 + ) + + state_dual_titles = State( + menu_name="PROVIDER_SEARCH", + media_api=MediaApiState(anime=anime_dual_titles) + ) + + search_results = SearchResults( + anime=[ + Anime( + name="English Title", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + ] + ) + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, search_results) + + mock_context.selector.choose.return_value = "English Title" + + result = provider_search(mock_context, state_dual_titles) + + # Should search using English title + mock_context.provider.search.assert_called_once() + search_params = mock_context.provider.search.call_args[0][0] + assert search_params.query == "English Title" + + def test_provider_search_romaji_title_fallback(self, mock_context, empty_state): + """Test provider search falling back to Romaji title when English not available.""" + # Create anime with only Romaji title + anime_romaji_only = MediaItem( + id=1, + title={"english": None, "romaji": "Romaji Title"}, + status="FINISHED", + episodes=12 + ) + + state_romaji_only = State( + menu_name="PROVIDER_SEARCH", + media_api=MediaApiState(anime=anime_romaji_only) + ) + + search_results = SearchResults( + anime=[ + Anime( + name="Romaji Title", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + ] + ) + + with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, search_results) + + mock_context.selector.choose.return_value = "Romaji Title" + + result = provider_search(mock_context, state_romaji_only) + + # Should search using Romaji title + mock_context.provider.search.assert_called_once() + search_params = mock_context.provider.search.call_args[0][0] + assert search_params.query == "Romaji Title" + + +class TestProviderSearchHelperFunctions: + """Test the helper functions in provider search menu.""" + + def test_format_provider_anime_choice(self, mock_config): + """Test formatting provider anime choice for display.""" + from fastanime.cli.interactive.menus.provider_search import _format_provider_anime_choice + + anime = Anime( + name="Test Anime", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + + mock_config.general.icons = True + + result = _format_provider_anime_choice(anime, mock_config) + + assert "Test Anime" in result + + def test_format_provider_anime_choice_no_icons(self, mock_config): + """Test formatting provider anime choice without icons.""" + from fastanime.cli.interactive.menus.provider_search import _format_provider_anime_choice + + anime = Anime( + name="Test Anime", + url="https://example.com/anime1", + id="anime1", + poster="https://example.com/poster1.jpg" + ) + + mock_config.general.icons = False + + result = _format_provider_anime_choice(anime, mock_config) + + assert "Test Anime" in result + assert "📺" not in result # No icons should be present + + def test_get_best_match_high_similarity(self): + """Test getting best match with high similarity.""" + from fastanime.cli.interactive.menus.provider_search import _get_best_match + + anilist_title = "Test Anime" + search_results = SearchResults( + anime=[ + Anime(name="Test Anime", url="https://example.com/1", id="1", poster=""), + Anime(name="Different Anime", url="https://example.com/2", id="2", poster="") + ] + ) + + with patch('fastanime.cli.interactive.menus.provider_search.fuzz.ratio') as mock_fuzz: + mock_fuzz.side_effect = [95, 60] # High similarity for first anime + + result = _get_best_match(anilist_title, search_results, threshold=80) + + assert result.name == "Test Anime" + + def test_get_best_match_low_similarity(self): + """Test getting best match with low similarity.""" + from fastanime.cli.interactive.menus.provider_search import _get_best_match + + anilist_title = "Test Anime" + search_results = SearchResults( + anime=[ + Anime(name="Different Show", url="https://example.com/1", id="1", poster=""), + Anime(name="Another Show", url="https://example.com/2", id="2", poster="") + ] + ) + + with patch('fastanime.cli.interactive.menus.provider_search.fuzz.ratio') as mock_fuzz: + mock_fuzz.side_effect = [60, 50] # Low similarity for all + + result = _get_best_match(anilist_title, search_results, threshold=80) + + assert result is None + + def test_get_best_match_empty_results(self): + """Test getting best match with empty results.""" + from fastanime.cli.interactive.menus.provider_search import _get_best_match + + anilist_title = "Test Anime" + empty_results = SearchResults(anime=[]) + + result = _get_best_match(anilist_title, empty_results, threshold=80) + + assert result is None + + def test_should_auto_select_enabled_high_similarity(self, mock_config): + """Test should auto select when enabled and high similarity.""" + from fastanime.cli.interactive.menus.provider_search import _should_auto_select + + mock_config.general.auto_select_anime_result = True + best_match = Anime(name="Test Anime", url="https://example.com/1", id="1", poster="") + + result = _should_auto_select(mock_config, best_match) + + assert result is True + + def test_should_auto_select_disabled(self, mock_config): + """Test should not auto select when disabled.""" + from fastanime.cli.interactive.menus.provider_search import _should_auto_select + + mock_config.general.auto_select_anime_result = False + best_match = Anime(name="Test Anime", url="https://example.com/1", id="1", poster="") + + result = _should_auto_select(mock_config, best_match) + + assert result is False + + def test_should_auto_select_no_match(self, mock_config): + """Test should not auto select when no good match.""" + from fastanime.cli.interactive.menus.provider_search import _should_auto_select + + mock_config.general.auto_select_anime_result = True + + result = _should_auto_select(mock_config, None) + + assert result is False diff --git a/tests/interactive/menus/test_results.py b/tests/interactive/menus/test_results.py new file mode 100644 index 0000000..1e85b42 --- /dev/null +++ b/tests/interactive/menus/test_results.py @@ -0,0 +1,355 @@ +""" +Tests for the results menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch + +from fastanime.cli.interactive.menus.results import results +from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState +from fastanime.libs.api.types import MediaItem, MediaSearchResult, PageInfo + + +class TestResultsMenu: + """Test cases for the results menu.""" + + def test_results_menu_no_search_results(self, mock_context, empty_state): + """Test results menu with no search results.""" + # 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) + + # Should go back when no results + assert result == ControlFlow.BACK + + def test_results_menu_empty_media_list(self, mock_context, empty_state): + """Test results menu with empty media list.""" + # State with empty search results + empty_search_results = MediaSearchResult( + media=[], + page_info=PageInfo( + total=0, + per_page=15, + current_page=1, + has_next_page=False + ) + ) + state_empty_results = State( + menu_name="RESULTS", + media_api=MediaApiState(search_results=empty_search_results) + ) + + result = results(mock_context, state_empty_results) + + # Should go back when no media found + assert result == ControlFlow.BACK + + def test_results_menu_display_anime_list(self, mock_context, state_with_media_api): + """Test results menu displays anime list correctly.""" + mock_context.selector.choose.return_value = "Back" + + result = results(mock_context, state_with_media_api) + + # Should go back when "Back" is selected + assert result == ControlFlow.BACK + + # Verify selector was called with anime choices + mock_context.selector.choose.assert_called_once() + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + + # Should contain Back option + assert "Back" in choices + # Should contain formatted anime titles + assert len(choices) >= 2 # At least anime + Back + + def test_results_menu_select_anime(self, mock_context, state_with_media_api, sample_media_item): + """Test selecting an anime from results.""" + # Mock the format function to return a predictable title + with patch('fastanime.cli.interactive.menus.results._format_anime_choice') as mock_format: + mock_format.return_value = "Test Anime" + mock_context.selector.choose.return_value = "Test Anime" + + result = results(mock_context, state_with_media_api) + + # Should transition to MEDIA_ACTIONS state + assert isinstance(result, State) + assert result.menu_name == "MEDIA_ACTIONS" + assert result.media_api.anime == sample_media_item + + def test_results_menu_pagination_next_page(self, mock_context, empty_state): + """Test pagination - next page navigation.""" + # Create search results with next page available + search_results = MediaSearchResult( + media=[ + MediaItem( + id=1, + title={"english": "Test Anime", "romaji": "Test Anime"}, + status="FINISHED", + episodes=12 + ) + ], + page_info=PageInfo( + total=30, + per_page=15, + current_page=1, + has_next_page=True + ) + ) + + state_with_pagination = State( + menu_name="RESULTS", + media_api=MediaApiState(search_results=search_results) + ) + + mock_context.selector.choose.return_value = "Next Page (Page 2)" + + with patch('fastanime.cli.interactive.menus.results._handle_pagination') as mock_pagination: + mock_pagination.return_value = State(menu_name="RESULTS") + + result = results(mock_context, state_with_pagination) + + # Should call pagination handler + mock_pagination.assert_called_once_with(mock_context, state_with_pagination, 1) + + def test_results_menu_pagination_previous_page(self, mock_context, empty_state): + """Test pagination - previous page navigation.""" + # Create search results on page 2 + search_results = MediaSearchResult( + media=[ + MediaItem( + id=1, + title={"english": "Test Anime", "romaji": "Test Anime"}, + status="FINISHED", + episodes=12 + ) + ], + page_info=PageInfo( + total=30, + per_page=15, + current_page=2, + has_next_page=False + ) + ) + + state_with_pagination = State( + menu_name="RESULTS", + media_api=MediaApiState(search_results=search_results) + ) + + mock_context.selector.choose.return_value = "Previous Page (Page 1)" + + with patch('fastanime.cli.interactive.menus.results._handle_pagination') as mock_pagination: + mock_pagination.return_value = State(menu_name="RESULTS") + + result = results(mock_context, state_with_pagination) + + # Should call pagination handler + mock_pagination.assert_called_once_with(mock_context, state_with_pagination, -1) + + def test_results_menu_no_choice_made(self, mock_context, state_with_media_api): + """Test results menu when no choice is made (exit).""" + mock_context.selector.choose.return_value = None + + result = results(mock_context, state_with_media_api) + + assert result == ControlFlow.EXIT + + def test_results_menu_with_preview(self, mock_context, state_with_media_api): + """Test results menu with preview enabled.""" + mock_context.config.general.preview = "text" + mock_context.selector.choose.return_value = "Back" + + with patch('fastanime.cli.interactive.menus.results.get_anime_preview') as mock_preview: + mock_preview.return_value = "preview_command" + + result = results(mock_context, state_with_media_api) + + # Should call preview function when preview is enabled + mock_preview.assert_called_once() + + # Verify preview was passed to selector + call_args = mock_context.selector.choose.call_args + assert call_args[1]['preview'] == "preview_command" + + def test_results_menu_no_preview(self, mock_context, state_with_media_api): + """Test results menu with preview disabled.""" + mock_context.config.general.preview = "none" + mock_context.selector.choose.return_value = "Back" + + result = results(mock_context, state_with_media_api) + + # Verify no preview was passed to selector + call_args = mock_context.selector.choose.call_args + assert call_args[1]['preview'] is None + + def test_results_menu_auth_status_display(self, mock_context, state_with_media_api): + """Test that authentication status is displayed in header.""" + mock_context.selector.choose.return_value = "Back" + + with patch('fastanime.cli.interactive.menus.results.get_auth_status_indicator') as mock_auth: + mock_auth.return_value = ("🟢 Authenticated", Mock()) + + result = results(mock_context, state_with_media_api) + + # Should call auth status function + mock_auth.assert_called_once_with(mock_context.media_api, mock_context.config.general.icons) + + # Verify header contains auth status + call_args = mock_context.selector.choose.call_args + header = call_args[1]['header'] + assert "🟢 Authenticated" in header + + def test_results_menu_pagination_info_in_header(self, mock_context, empty_state): + """Test that pagination info is displayed in header.""" + search_results = MediaSearchResult( + media=[ + MediaItem( + id=1, + title={"english": "Test Anime", "romaji": "Test Anime"}, + status="FINISHED", + episodes=12 + ) + ], + page_info=PageInfo( + total=30, + per_page=15, + current_page=2, + has_next_page=True + ) + ) + + state_with_pagination = State( + menu_name="RESULTS", + media_api=MediaApiState(search_results=search_results) + ) + + mock_context.selector.choose.return_value = "Back" + + with patch('fastanime.cli.interactive.menus.results.get_auth_status_indicator') as mock_auth: + mock_auth.return_value = ("Auth Status", Mock()) + + result = results(mock_context, state_with_pagination) + + # Verify header contains pagination info + call_args = mock_context.selector.choose.call_args + header = call_args[1]['header'] + assert "Page 2" in header + assert "~2" in header # Total pages + + def test_results_menu_unknown_choice_fallback(self, mock_context, state_with_media_api): + """Test results menu with unknown choice returns CONTINUE.""" + mock_context.selector.choose.return_value = "Unknown Choice" + + with patch('fastanime.cli.interactive.menus.results._format_anime_choice') as mock_format: + mock_format.return_value = "Test Anime" + + result = results(mock_context, state_with_media_api) + + # Should return CONTINUE for unknown choices + assert result == ControlFlow.CONTINUE + + +class TestResultsMenuHelperFunctions: + """Test the helper functions in results menu.""" + + def test_format_anime_choice(self, mock_config, sample_media_item): + """Test formatting anime choice for display.""" + from fastanime.cli.interactive.menus.results import _format_anime_choice + + # Test with English title preferred + mock_config.anilist.preferred_language = "english" + result = _format_anime_choice(sample_media_item, mock_config) + + assert "Test Anime" in result + assert "12" in result # Episode count + + def test_format_anime_choice_romaji(self, mock_config, sample_media_item): + """Test formatting anime choice with romaji preference.""" + from fastanime.cli.interactive.menus.results import _format_anime_choice + + # Test with Romaji title preferred + mock_config.anilist.preferred_language = "romaji" + result = _format_anime_choice(sample_media_item, mock_config) + + assert "Test Anime" in result + + def test_format_anime_choice_no_episodes(self, mock_config): + """Test formatting anime choice with no episode count.""" + from fastanime.cli.interactive.menus.results import _format_anime_choice + + anime_no_episodes = MediaItem( + id=1, + title={"english": "Test Anime", "romaji": "Test Anime"}, + status="FINISHED", + episodes=None + ) + + result = _format_anime_choice(anime_no_episodes, mock_config) + + assert "Test Anime" in result + assert "?" in result # Unknown episode count + + def test_handle_pagination_next_page(self, mock_context, state_with_media_api): + """Test pagination handler for next page.""" + from fastanime.cli.interactive.menus.results import _handle_pagination + + # Mock API search parameters from state + mock_context.media_api.search_media.return_value = MediaSearchResult( + media=[], page_info=PageInfo(total=0, per_page=15, current_page=2, has_next_page=False) + ) + + with patch('fastanime.cli.interactive.menus.results.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_context.media_api.search_media.return_value) + + result = _handle_pagination(mock_context, state_with_media_api, 1) + + # Should return new state with updated results + assert isinstance(result, State) + assert result.menu_name == "RESULTS" + + def test_handle_pagination_api_failure(self, mock_context, state_with_media_api): + """Test pagination handler when API fails.""" + from fastanime.cli.interactive.menus.results import _handle_pagination + + with patch('fastanime.cli.interactive.menus.results.execute_with_feedback') as mock_execute: + mock_execute.return_value = (False, None) + + result = _handle_pagination(mock_context, state_with_media_api, 1) + + # Should return CONTINUE on API failure + assert result == ControlFlow.CONTINUE + + def test_handle_pagination_user_list_params(self, mock_context, empty_state): + """Test pagination with user list parameters.""" + from fastanime.cli.interactive.menus.results import _handle_pagination + from fastanime.libs.api.params import UserListParams + + # State with user list params + state_with_user_list = State( + menu_name="RESULTS", + media_api=MediaApiState( + search_results=MediaSearchResult( + media=[], + page_info=PageInfo(total=0, per_page=15, current_page=1, has_next_page=False) + ), + original_user_list_params=UserListParams(status="CURRENT", per_page=15) + ) + ) + + mock_context.media_api.fetch_user_list.return_value = MediaSearchResult( + media=[], page_info=PageInfo(total=0, per_page=15, current_page=2, has_next_page=False) + ) + + with patch('fastanime.cli.interactive.menus.results.execute_with_feedback') as mock_execute: + mock_execute.return_value = (True, mock_context.media_api.fetch_user_list.return_value) + + result = _handle_pagination(mock_context, state_with_user_list, 1) + + # Should call fetch_user_list instead of search_media + assert isinstance(result, State) + assert result.menu_name == "RESULTS" diff --git a/tests/interactive/menus/test_servers.py b/tests/interactive/menus/test_servers.py new file mode 100644 index 0000000..2091c92 --- /dev/null +++ b/tests/interactive/menus/test_servers.py @@ -0,0 +1,445 @@ +""" +Tests for the servers menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + +from fastanime.cli.interactive.menus.servers import servers +from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState +from fastanime.libs.providers.anime.types import Anime, Server, StreamLink +from fastanime.libs.players.types import PlayerResult + + +class TestServersMenu: + """Test cases for the servers menu.""" + + def test_servers_menu_missing_anime_data(self, mock_context, empty_state): + """Test servers menu with missing anime data.""" + result = servers(mock_context, empty_state) + + # Should go back when anime data is missing + assert result == ControlFlow.BACK + + def test_servers_menu_missing_episode_number(self, mock_context, state_with_provider): + """Test servers menu with missing episode number.""" + # Create state with anime but no episode number + state_no_episode = State( + menu_name="SERVERS", + provider=ProviderState(anime=state_with_provider.provider.anime) + ) + + result = servers(mock_context, state_no_episode) + + # Should go back when episode number is missing + assert result == ControlFlow.BACK + + def test_servers_menu_successful_server_selection(self, mock_context, full_state): + """Test successful server selection and playback.""" + # Setup state with episode number + state_with_episode = State( + menu_name="SERVERS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1" + ) + ) + + # Mock server streams + mock_servers = [ + Server( + name="Server 1", + url="https://example.com/server1", + links=[ + StreamLink(url="https://example.com/stream1.m3u8", quality=1080, format="m3u8") + ] + ), + Server( + name="Server 2", + url="https://example.com/server2", + links=[ + StreamLink(url="https://example.com/stream2.m3u8", quality=720, format="m3u8") + ] + ) + ] + + # Mock provider episode streams + mock_context.provider.episode_streams.return_value = iter(mock_servers) + + # Mock server selection + mock_context.selector.choose.return_value = "Server 1" + + # Mock successful player result + mock_context.player.play.return_value = PlayerResult(success=True, exit_code=0) + + result = servers(mock_context, state_with_episode) + + # Should transition to PLAYER_CONTROLS state + assert isinstance(result, State) + assert result.menu_name == "PLAYER_CONTROLS" + assert result.provider.last_player_result.success == True + + def test_servers_menu_no_servers_available(self, mock_context, full_state): + """Test servers menu when no servers are available.""" + # Setup state with episode number + state_with_episode = State( + menu_name="SERVERS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1" + ) + ) + + # Mock empty server streams + mock_context.provider.episode_streams.return_value = iter([]) + + result = servers(mock_context, state_with_episode) + + # Should go back when no servers are available + assert result == ControlFlow.BACK + + def test_servers_menu_server_selection_cancelled(self, mock_context, full_state): + """Test servers menu when server selection is cancelled.""" + # Setup state with episode number + state_with_episode = State( + menu_name="SERVERS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1" + ) + ) + + # Mock server streams + mock_servers = [ + Server( + name="Server 1", + url="https://example.com/server1", + links=[StreamLink(url="https://example.com/stream1.m3u8", quality=1080, format="m3u8")] + ) + ] + + mock_context.provider.episode_streams.return_value = iter(mock_servers) + + # Mock no selection (cancelled) + mock_context.selector.choose.return_value = None + + result = servers(mock_context, state_with_episode) + + # Should go back when selection is cancelled + assert result == ControlFlow.BACK + + def test_servers_menu_back_selection(self, mock_context, full_state): + """Test servers menu back selection.""" + # Setup state with episode number + state_with_episode = State( + menu_name="SERVERS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1" + ) + ) + + # Mock server streams + mock_servers = [ + Server( + name="Server 1", + url="https://example.com/server1", + links=[StreamLink(url="https://example.com/stream1.m3u8", quality=1080, format="m3u8")] + ) + ] + + mock_context.provider.episode_streams.return_value = iter(mock_servers) + + # Mock back selection + mock_context.selector.choose.return_value = "Back" + + result = servers(mock_context, state_with_episode) + + # Should go back + assert result == ControlFlow.BACK + + def test_servers_menu_auto_server_selection(self, mock_context, full_state): + """Test automatic server selection when configured.""" + # Setup state with episode number + state_with_episode = State( + menu_name="SERVERS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1" + ) + ) + + # Mock server streams with specific server name + mock_servers = [ + Server( + name="TOP", # Matches config server preference + url="https://example.com/server1", + links=[StreamLink(url="https://example.com/stream1.m3u8", quality=1080, format="m3u8")] + ) + ] + + mock_context.provider.episode_streams.return_value = iter(mock_servers) + mock_context.config.stream.server = "TOP" # Auto-select TOP server + + # Mock successful player result + mock_context.player.play.return_value = PlayerResult(success=True, exit_code=0) + + result = servers(mock_context, state_with_episode) + + # Should auto-select and transition to PLAYER_CONTROLS + assert isinstance(result, State) + assert result.menu_name == "PLAYER_CONTROLS" + + # Selector should not be called for server selection + mock_context.selector.choose.assert_not_called() + + def test_servers_menu_quality_filtering(self, mock_context, full_state): + """Test quality filtering for server links.""" + # Setup state with episode number + state_with_episode = State( + menu_name="SERVERS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1" + ) + ) + + # Mock server with multiple quality links + mock_servers = [ + Server( + name="Server 1", + url="https://example.com/server1", + links=[ + StreamLink(url="https://example.com/stream_720.m3u8", quality=720, format="m3u8"), + StreamLink(url="https://example.com/stream_1080.m3u8", quality=1080, format="m3u8"), + StreamLink(url="https://example.com/stream_480.m3u8", quality=480, format="m3u8") + ] + ) + ] + + mock_context.provider.episode_streams.return_value = iter(mock_servers) + mock_context.config.stream.quality = "720" # Prefer 720p + + # Mock server selection + mock_context.selector.choose.return_value = "Server 1" + + # Mock successful player result + mock_context.player.play.return_value = PlayerResult(success=True, exit_code=0) + + result = servers(mock_context, state_with_episode) + + # Should use the 720p link based on quality preference + mock_context.player.play.assert_called_once() + player_params = mock_context.player.play.call_args[0][0] + assert "stream_720.m3u8" in player_params.url + + def test_servers_menu_player_failure(self, mock_context, full_state): + """Test handling player failure.""" + # Setup state with episode number + state_with_episode = State( + menu_name="SERVERS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1" + ) + ) + + # Mock server streams + mock_servers = [ + Server( + name="Server 1", + url="https://example.com/server1", + links=[StreamLink(url="https://example.com/stream1.m3u8", quality=1080, format="m3u8")] + ) + ] + + mock_context.provider.episode_streams.return_value = iter(mock_servers) + mock_context.selector.choose.return_value = "Server 1" + + # Mock failed player result + mock_context.player.play.return_value = PlayerResult(success=False, exit_code=1) + + result = servers(mock_context, state_with_episode) + + # Should still transition to PLAYER_CONTROLS state with failure result + assert isinstance(result, State) + assert result.menu_name == "PLAYER_CONTROLS" + assert result.provider.last_player_result.success == False + + def test_servers_menu_server_with_no_links(self, mock_context, full_state): + """Test handling server with no streaming links.""" + # Setup state with episode number + state_with_episode = State( + menu_name="SERVERS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1" + ) + ) + + # Mock server with no links + mock_servers = [ + Server( + name="Server 1", + url="https://example.com/server1", + links=[] # No streaming links + ) + ] + + mock_context.provider.episode_streams.return_value = iter(mock_servers) + mock_context.selector.choose.return_value = "Server 1" + + result = servers(mock_context, state_with_episode) + + # Should go back when no links are available + assert result == ControlFlow.BACK + + def test_servers_menu_episode_streams_exception(self, mock_context, full_state): + """Test handling exception during episode streams fetch.""" + # Setup state with episode number + state_with_episode = State( + menu_name="SERVERS", + media_api=full_state.media_api, + provider=ProviderState( + anime=full_state.provider.anime, + episode_number="1" + ) + ) + + # Mock exception during episode streams fetch + mock_context.provider.episode_streams.side_effect = Exception("Network error") + + result = servers(mock_context, state_with_episode) + + # Should go back on exception + assert result == ControlFlow.BACK + + +class TestServersMenuHelperFunctions: + """Test the helper functions in servers menu.""" + + def test_filter_by_quality_exact_match(self): + """Test filtering links by exact quality match.""" + from fastanime.cli.interactive.menus.servers import _filter_by_quality + + links = [ + StreamLink(url="https://example.com/480.m3u8", quality=480, format="m3u8"), + StreamLink(url="https://example.com/720.m3u8", quality=720, format="m3u8"), + StreamLink(url="https://example.com/1080.m3u8", quality=1080, format="m3u8") + ] + + result = _filter_by_quality(links, "720") + + assert result.quality == 720 + assert "720.m3u8" in result.url + + def test_filter_by_quality_no_match(self): + """Test filtering links when no quality match is found.""" + from fastanime.cli.interactive.menus.servers import _filter_by_quality + + links = [ + StreamLink(url="https://example.com/480.m3u8", quality=480, format="m3u8"), + StreamLink(url="https://example.com/720.m3u8", quality=720, format="m3u8") + ] + + result = _filter_by_quality(links, "1080") # Quality not available + + # Should return first link when no match + assert result.quality == 480 + assert "480.m3u8" in result.url + + def test_filter_by_quality_empty_links(self): + """Test filtering with empty links list.""" + from fastanime.cli.interactive.menus.servers import _filter_by_quality + + result = _filter_by_quality([], "720") + + # Should return None for empty list + assert result is None + + def test_format_server_choice_with_quality(self, mock_config): + """Test formatting server choice with quality information.""" + from fastanime.cli.interactive.menus.servers import _format_server_choice + + server = Server( + name="Test Server", + url="https://example.com/server", + links=[ + StreamLink(url="https://example.com/720.m3u8", quality=720, format="m3u8"), + StreamLink(url="https://example.com/1080.m3u8", quality=1080, format="m3u8") + ] + ) + + mock_config.general.icons = True + + result = _format_server_choice(server, mock_config) + + assert "Test Server" in result + assert "720p" in result or "1080p" in result # Should show available qualities + + def test_format_server_choice_no_icons(self, mock_config): + """Test formatting server choice without icons.""" + from fastanime.cli.interactive.menus.servers import _format_server_choice + + server = Server( + name="Test Server", + url="https://example.com/server", + links=[StreamLink(url="https://example.com/720.m3u8", quality=720, format="m3u8")] + ) + + mock_config.general.icons = False + + result = _format_server_choice(server, mock_config) + + assert "Test Server" in result + assert "🎬" not in result # No icons should be present + + def test_get_auto_selected_server_match(self): + """Test getting auto-selected server when match is found.""" + from fastanime.cli.interactive.menus.servers import _get_auto_selected_server + + servers = [ + Server(name="Server 1", url="https://example.com/1", links=[]), + Server(name="TOP", url="https://example.com/top", links=[]), + Server(name="Server 2", url="https://example.com/2", links=[]) + ] + + result = _get_auto_selected_server(servers, "TOP") + + assert result.name == "TOP" + + def test_get_auto_selected_server_no_match(self): + """Test getting auto-selected server when no match is found.""" + from fastanime.cli.interactive.menus.servers import _get_auto_selected_server + + servers = [ + Server(name="Server 1", url="https://example.com/1", links=[]), + Server(name="Server 2", url="https://example.com/2", links=[]) + ] + + result = _get_auto_selected_server(servers, "NonExistent") + + # Should return first server when no match + assert result.name == "Server 1" + + def test_get_auto_selected_server_top_preference(self): + """Test getting auto-selected server with TOP preference.""" + from fastanime.cli.interactive.menus.servers import _get_auto_selected_server + + servers = [ + Server(name="Server 1", url="https://example.com/1", links=[]), + Server(name="Server 2", url="https://example.com/2", links=[]) + ] + + result = _get_auto_selected_server(servers, "TOP") + + # Should return first server for TOP preference + assert result.name == "Server 1" diff --git a/tests/interactive/menus/test_session_management.py b/tests/interactive/menus/test_session_management.py new file mode 100644 index 0000000..6095b78 --- /dev/null +++ b/tests/interactive/menus/test_session_management.py @@ -0,0 +1,463 @@ +""" +Tests for the session management menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from pathlib import Path +from datetime import datetime + +from fastanime.cli.interactive.menus.session_management import session_management +from fastanime.cli.interactive.state import ControlFlow, State + + +class TestSessionManagementMenu: + """Test cases for the session management menu.""" + + def test_session_management_menu_display(self, mock_context, empty_state): + """Test that session management menu displays correctly.""" + mock_context.selector.choose.return_value = "🔙 Back to Main Menu" + + result = session_management(mock_context, empty_state) + + # Should go back when "Back to Main Menu" is selected + assert result == ControlFlow.BACK + + # Verify selector was called with expected options + mock_context.selector.choose.assert_called_once() + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + + # Check that key options are present + expected_options = [ + "Save Session", "Load Session", "List Saved Sessions", + "Delete Session", "Session Statistics", "Auto-save Settings", + "Back to Main Menu" + ] + + for option in expected_options: + assert any(option in choice for choice in choices) + + def test_session_management_save_session(self, mock_context, empty_state): + """Test saving a session.""" + mock_context.selector.choose.return_value = "💾 Save Session" + mock_context.selector.ask.side_effect = ["test_session", "Test session description"] + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.save.return_value = True + + result = session_management(mock_context, empty_state) + + # Should save session and continue + mock_session.save.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_session_management_save_session_cancelled(self, mock_context, empty_state): + """Test saving a session when cancelled.""" + mock_context.selector.choose.return_value = "💾 Save Session" + mock_context.selector.ask.return_value = "" # Empty session name + + result = session_management(mock_context, empty_state) + + # Should continue without saving + assert result == ControlFlow.CONTINUE + + def test_session_management_load_session(self, mock_context, empty_state): + """Test loading a session.""" + mock_context.selector.choose.return_value = "📂 Load Session" + + # Mock available sessions + mock_sessions = [ + {"name": "session1.json", "created": "2023-01-01", "size": "1.2KB"}, + {"name": "session2.json", "created": "2023-01-02", "size": "1.5KB"} + ] + + mock_context.selector.choose.side_effect = [ + "📂 Load Session", + "session1.json" + ] + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.list_saved_sessions.return_value = mock_sessions + mock_session.resume.return_value = True + + result = session_management(mock_context, empty_state) + + # Should load session and reload config + mock_session.resume.assert_called_once() + assert result == ControlFlow.RELOAD_CONFIG + + def test_session_management_load_session_no_sessions(self, mock_context, empty_state): + """Test loading a session when no sessions exist.""" + mock_context.selector.choose.return_value = "📂 Load Session" + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.list_saved_sessions.return_value = [] + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = session_management(mock_context, empty_state) + + # Should show info message and continue + feedback_obj.info.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_session_management_load_session_cancelled(self, mock_context, empty_state): + """Test loading a session when selection is cancelled.""" + mock_context.selector.choose.side_effect = [ + "📂 Load Session", + None # Cancelled selection + ] + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.list_saved_sessions.return_value = [ + {"name": "session1.json", "created": "2023-01-01", "size": "1.2KB"} + ] + + result = session_management(mock_context, empty_state) + + # Should continue without loading + assert result == ControlFlow.CONTINUE + + def test_session_management_list_sessions(self, mock_context, empty_state): + """Test listing saved sessions.""" + mock_context.selector.choose.return_value = "📋 List Saved Sessions" + + mock_sessions = [ + { + "name": "session1.json", + "created": "2023-01-01 12:00:00", + "size": "1.2KB", + "session_name": "Test Session 1", + "description": "Test description 1" + }, + { + "name": "session2.json", + "created": "2023-01-02 13:00:00", + "size": "1.5KB", + "session_name": "Test Session 2", + "description": "Test description 2" + } + ] + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.list_saved_sessions.return_value = mock_sessions + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = session_management(mock_context, empty_state) + + # Should display session list and pause + feedback_obj.pause_for_user.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_session_management_list_sessions_empty(self, mock_context, empty_state): + """Test listing sessions when none exist.""" + mock_context.selector.choose.return_value = "📋 List Saved Sessions" + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.list_saved_sessions.return_value = [] + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = session_management(mock_context, empty_state) + + # Should show info message + feedback_obj.info.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_session_management_delete_session(self, mock_context, empty_state): + """Test deleting a session.""" + mock_context.selector.choose.side_effect = [ + "🗑️ Delete Session", + "session1.json" + ] + + mock_sessions = [ + {"name": "session1.json", "created": "2023-01-01", "size": "1.2KB"} + ] + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.list_saved_sessions.return_value = mock_sessions + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + feedback_obj.confirm.return_value = True + mock_feedback.return_value = feedback_obj + + with patch('fastanime.cli.interactive.menus.session_management.Path.unlink') as mock_unlink: + result = session_management(mock_context, empty_state) + + # Should delete session file + mock_unlink.assert_called_once() + feedback_obj.success.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_session_management_delete_session_cancelled(self, mock_context, empty_state): + """Test deleting a session when cancelled.""" + mock_context.selector.choose.side_effect = [ + "🗑️ Delete Session", + "session1.json" + ] + + mock_sessions = [ + {"name": "session1.json", "created": "2023-01-01", "size": "1.2KB"} + ] + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.list_saved_sessions.return_value = mock_sessions + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + feedback_obj.confirm.return_value = False # User cancels deletion + mock_feedback.return_value = feedback_obj + + result = session_management(mock_context, empty_state) + + # Should not delete and continue + assert result == ControlFlow.CONTINUE + + def test_session_management_session_statistics(self, mock_context, empty_state): + """Test viewing session statistics.""" + mock_context.selector.choose.return_value = "📊 Session Statistics" + + mock_stats = { + "current_states": 5, + "current_menu": "MAIN", + "auto_save_enabled": True, + "has_auto_save": False, + "has_crash_backup": False + } + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.get_session_stats.return_value = mock_stats + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = session_management(mock_context, empty_state) + + # Should display stats and pause + feedback_obj.pause_for_user.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_session_management_toggle_auto_save(self, mock_context, empty_state): + """Test toggling auto-save settings.""" + mock_context.selector.choose.return_value = "⚙️ Auto-save Settings" + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.get_session_stats.return_value = {"auto_save_enabled": True} + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + feedback_obj.confirm.return_value = True + mock_feedback.return_value = feedback_obj + + result = session_management(mock_context, empty_state) + + # Should toggle auto-save + mock_session.enable_auto_save.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_session_management_cleanup_old_sessions(self, mock_context, empty_state): + """Test cleaning up old sessions.""" + mock_context.selector.choose.return_value = "🧹 Cleanup Old Sessions" + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.cleanup_old_sessions.return_value = 3 # 3 sessions cleaned + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + feedback_obj.confirm.return_value = True + mock_feedback.return_value = feedback_obj + + result = session_management(mock_context, empty_state) + + # Should cleanup and show success + mock_session.cleanup_old_sessions.assert_called_once() + feedback_obj.success.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_session_management_create_backup(self, mock_context, empty_state): + """Test creating manual backup.""" + mock_context.selector.choose.return_value = "💾 Create Manual Backup" + mock_context.selector.ask.return_value = "my_backup" + + with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: + mock_session.create_manual_backup.return_value = True + + result = session_management(mock_context, empty_state) + + # Should create backup + mock_session.create_manual_backup.assert_called_once_with("my_backup") + assert result == ControlFlow.CONTINUE + + def test_session_management_back_selection(self, mock_context, empty_state): + """Test selecting back from session management.""" + mock_context.selector.choose.return_value = "🔙 Back to Main Menu" + + result = session_management(mock_context, empty_state) + + assert result == ControlFlow.BACK + + def test_session_management_no_choice(self, mock_context, empty_state): + """Test session management when no choice is made.""" + mock_context.selector.choose.return_value = None + + result = session_management(mock_context, empty_state) + + # Should go back when no choice is made + assert result == ControlFlow.BACK + + def test_session_management_icons_enabled(self, mock_context, empty_state): + """Test session management menu with icons enabled.""" + mock_context.config.general.icons = True + mock_context.selector.choose.return_value = "🔙 Back to Main Menu" + + result = session_management(mock_context, empty_state) + + # Should work with icons enabled + assert result == ControlFlow.BACK + + def test_session_management_icons_disabled(self, mock_context, empty_state): + """Test session management menu with icons disabled.""" + mock_context.config.general.icons = False + mock_context.selector.choose.return_value = "Back to Main Menu" + + result = session_management(mock_context, empty_state) + + # Should work with icons disabled + assert result == ControlFlow.BACK + + +class TestSessionManagementHelperFunctions: + """Test the helper functions in session management menu.""" + + def test_format_session_info(self): + """Test formatting session information for display.""" + from fastanime.cli.interactive.menus.session_management import _format_session_info + + session_info = { + "name": "test_session.json", + "created": "2023-01-01 12:00:00", + "size": "1.2KB", + "session_name": "Test Session", + "description": "Test description" + } + + result = _format_session_info(session_info, True) # With icons + + assert "Test Session" in result + assert "test_session.json" in result + assert "2023-01-01" in result + + def test_format_session_info_no_icons(self): + """Test formatting session information without icons.""" + from fastanime.cli.interactive.menus.session_management import _format_session_info + + session_info = { + "name": "test_session.json", + "created": "2023-01-01 12:00:00", + "size": "1.2KB", + "session_name": "Test Session", + "description": "Test description" + } + + result = _format_session_info(session_info, False) # Without icons + + assert "Test Session" in result + assert "📁" not in result # No icons should be present + + def test_display_session_statistics(self): + """Test displaying session statistics.""" + from fastanime.cli.interactive.menus.session_management import _display_session_statistics + + console = Mock() + stats = { + "current_states": 5, + "current_menu": "MAIN", + "auto_save_enabled": True, + "has_auto_save": False, + "has_crash_backup": False + } + + _display_session_statistics(console, stats, True) + + # Should print table with statistics + console.print.assert_called() + + def test_get_session_file_path(self): + """Test getting session file path.""" + from fastanime.cli.interactive.menus.session_management import _get_session_file_path + + session_name = "test_session" + + result = _get_session_file_path(session_name) + + assert isinstance(result, Path) + assert result.name == "test_session.json" + + def test_validate_session_name_valid(self): + """Test validating valid session name.""" + from fastanime.cli.interactive.menus.session_management import _validate_session_name + + result = _validate_session_name("valid_session_name") + + assert result is True + + def test_validate_session_name_invalid(self): + """Test validating invalid session name.""" + from fastanime.cli.interactive.menus.session_management import _validate_session_name + + # Test with invalid characters + result = _validate_session_name("invalid/session:name") + + assert result is False + + def test_validate_session_name_empty(self): + """Test validating empty session name.""" + from fastanime.cli.interactive.menus.session_management import _validate_session_name + + result = _validate_session_name("") + + assert result is False + + def test_confirm_session_deletion(self, mock_context): + """Test confirming session deletion.""" + from fastanime.cli.interactive.menus.session_management import _confirm_session_deletion + + session_name = "test_session.json" + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + feedback_obj.confirm.return_value = True + mock_feedback.return_value = feedback_obj + + result = _confirm_session_deletion(session_name, True) + + # Should confirm deletion + feedback_obj.confirm.assert_called_once() + assert result is True + + def test_confirm_session_deletion_cancelled(self, mock_context): + """Test confirming session deletion when cancelled.""" + from fastanime.cli.interactive.menus.session_management import _confirm_session_deletion + + session_name = "test_session.json" + + with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + feedback_obj.confirm.return_value = False + mock_feedback.return_value = feedback_obj + + result = _confirm_session_deletion(session_name, True) + + # Should not confirm deletion + assert result is False diff --git a/tests/interactive/menus/test_watch_history.py b/tests/interactive/menus/test_watch_history.py new file mode 100644 index 0000000..70ae86d --- /dev/null +++ b/tests/interactive/menus/test_watch_history.py @@ -0,0 +1,590 @@ +""" +Tests for the watch history menu functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + +from fastanime.cli.interactive.menus.watch_history import watch_history +from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState +from fastanime.libs.api.types import MediaItem + + +class TestWatchHistoryMenu: + """Test cases for the watch history menu.""" + + def test_watch_history_menu_display(self, mock_context, empty_state): + """Test that watch history menu displays correctly.""" + mock_context.selector.choose.return_value = "🔙 Back to Main Menu" + + # Mock watch history + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + }, + { + "anilist_id": 2, + "title": "Test Anime 2", + "last_watched": "2023-01-02 13:00:00", + "episode": 3, + "total_episodes": 24 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + result = watch_history(mock_context, empty_state) + + # Should go back when "Back to Main Menu" is selected + assert result == ControlFlow.BACK + + # Verify selector was called with history items + mock_context.selector.choose.assert_called_once() + call_args = mock_context.selector.choose.call_args + choices = call_args[1]['choices'] + + # Should contain anime from history plus control options + history_items = [choice for choice in choices if "Test Anime" in choice] + assert len(history_items) == 2 + + def test_watch_history_menu_empty_history(self, mock_context, empty_state): + """Test watch history menu with empty history.""" + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = [] + + with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = watch_history(mock_context, empty_state) + + # Should show info message and go back + feedback_obj.info.assert_called_once() + assert result == ControlFlow.BACK + + def test_watch_history_select_anime(self, mock_context, empty_state): + """Test selecting an anime from watch history.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + # Mock AniList anime lookup + mock_anime = MediaItem( + id=1, + title={"english": "Test Anime 1", "romaji": "Test Anime 1"}, + status="FINISHED", + episodes=12 + ) + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + with patch('fastanime.cli.interactive.menus.watch_history._format_history_item') as mock_format: + mock_format.return_value = "Test Anime 1 - Episode 5/12" + mock_context.selector.choose.return_value = "Test Anime 1 - Episode 5/12" + + # Mock successful AniList lookup + mock_context.media_api.get_media_by_id.return_value = mock_anime + + result = watch_history(mock_context, empty_state) + + # Should transition to MEDIA_ACTIONS state + assert isinstance(result, State) + assert result.menu_name == "MEDIA_ACTIONS" + assert result.media_api.anime == mock_anime + + def test_watch_history_anime_lookup_failure(self, mock_context, empty_state): + """Test watch history when anime lookup fails.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + with patch('fastanime.cli.interactive.menus.watch_history._format_history_item') as mock_format: + mock_format.return_value = "Test Anime 1 - Episode 5/12" + mock_context.selector.choose.return_value = "Test Anime 1 - Episode 5/12" + + # Mock failed AniList lookup + mock_context.media_api.get_media_by_id.return_value = None + + with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = watch_history(mock_context, empty_state) + + # Should show error and continue + feedback_obj.error.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_watch_history_clear_history(self, mock_context, empty_state): + """Test clearing watch history.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = "🗑️ Clear History" + + with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + feedback_obj.confirm.return_value = True + mock_feedback.return_value = feedback_obj + + with patch('fastanime.cli.interactive.menus.watch_history.clear_watch_history') as mock_clear: + result = watch_history(mock_context, empty_state) + + # Should clear history and continue + mock_clear.assert_called_once() + feedback_obj.success.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_watch_history_clear_history_cancelled(self, mock_context, empty_state): + """Test clearing watch history when cancelled.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = "🗑️ Clear History" + + with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + feedback_obj.confirm.return_value = False # User cancels + mock_feedback.return_value = feedback_obj + + result = watch_history(mock_context, empty_state) + + # Should not clear and continue + assert result == ControlFlow.CONTINUE + + def test_watch_history_export_history(self, mock_context, empty_state): + """Test exporting watch history.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = "📤 Export History" + mock_context.selector.ask.return_value = "/path/to/export.json" + + with patch('fastanime.cli.interactive.menus.watch_history.export_watch_history') as mock_export: + mock_export.return_value = True + + with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = watch_history(mock_context, empty_state) + + # Should export history and continue + mock_export.assert_called_once() + feedback_obj.success.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_watch_history_export_history_no_path(self, mock_context, empty_state): + """Test exporting watch history with no path provided.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = "📤 Export History" + mock_context.selector.ask.return_value = "" # Empty path + + result = watch_history(mock_context, empty_state) + + # Should continue without exporting + assert result == ControlFlow.CONTINUE + + def test_watch_history_import_history(self, mock_context, empty_state): + """Test importing watch history.""" + mock_history = [] # Start with empty history + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = "📥 Import History" + mock_context.selector.ask.return_value = "/path/to/import.json" + + with patch('fastanime.cli.interactive.menus.watch_history.import_watch_history') as mock_import: + mock_import.return_value = True + + with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = watch_history(mock_context, empty_state) + + # Should import history and continue + mock_import.assert_called_once() + feedback_obj.success.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_watch_history_view_statistics(self, mock_context, empty_state): + """Test viewing watch history statistics.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + }, + { + "anilist_id": 2, + "title": "Test Anime 2", + "last_watched": "2023-01-02 13:00:00", + "episode": 24, + "total_episodes": 24 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = "📊 View Statistics" + + with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: + feedback_obj = Mock() + mock_feedback.return_value = feedback_obj + + result = watch_history(mock_context, empty_state) + + # Should display statistics and pause + feedback_obj.pause_for_user.assert_called_once() + assert result == ControlFlow.CONTINUE + + def test_watch_history_back_selection(self, mock_context, empty_state): + """Test selecting back from watch history.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = "🔙 Back to Main Menu" + + result = watch_history(mock_context, empty_state) + + assert result == ControlFlow.BACK + + def test_watch_history_no_choice(self, mock_context, empty_state): + """Test watch history when no choice is made.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = None + + result = watch_history(mock_context, empty_state) + + # Should go back when no choice is made + assert result == ControlFlow.BACK + + def test_watch_history_invalid_selection(self, mock_context, empty_state): + """Test watch history with invalid selection.""" + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + with patch('fastanime.cli.interactive.menus.watch_history._format_history_item') as mock_format: + mock_format.return_value = "Test Anime 1 - Episode 5/12" + mock_context.selector.choose.return_value = "Invalid Selection" + + result = watch_history(mock_context, empty_state) + + # Should continue for invalid selection + assert result == ControlFlow.CONTINUE + + def test_watch_history_icons_enabled(self, mock_context, empty_state): + """Test watch history menu with icons enabled.""" + mock_context.config.general.icons = True + + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = "🔙 Back to Main Menu" + + result = watch_history(mock_context, empty_state) + + # Should work with icons enabled + assert result == ControlFlow.BACK + + def test_watch_history_icons_disabled(self, mock_context, empty_state): + """Test watch history menu with icons disabled.""" + mock_context.config.general.icons = False + + mock_history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + ] + + with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: + mock_get_history.return_value = mock_history + + mock_context.selector.choose.return_value = "Back to Main Menu" + + result = watch_history(mock_context, empty_state) + + # Should work with icons disabled + assert result == ControlFlow.BACK + + +class TestWatchHistoryHelperFunctions: + """Test the helper functions in watch history menu.""" + + def test_format_history_item(self): + """Test formatting history item for display.""" + from fastanime.cli.interactive.menus.watch_history import _format_history_item + + history_item = { + "anilist_id": 1, + "title": "Test Anime", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + + result = _format_history_item(history_item, True) # With icons + + assert "Test Anime" in result + assert "5/12" in result # Episode progress + assert "2023-01-01" in result + + def test_format_history_item_no_icons(self): + """Test formatting history item without icons.""" + from fastanime.cli.interactive.menus.watch_history import _format_history_item + + history_item = { + "anilist_id": 1, + "title": "Test Anime", + "last_watched": "2023-01-01 12:00:00", + "episode": 5, + "total_episodes": 12 + } + + result = _format_history_item(history_item, False) # Without icons + + assert "Test Anime" in result + assert "📺" not in result # No icons should be present + + def test_format_history_item_completed(self): + """Test formatting completed anime in history.""" + from fastanime.cli.interactive.menus.watch_history import _format_history_item + + history_item = { + "anilist_id": 1, + "title": "Test Anime", + "last_watched": "2023-01-01 12:00:00", + "episode": 12, + "total_episodes": 12 + } + + result = _format_history_item(history_item, True) + + assert "Test Anime" in result + assert "12/12" in result # Completed + assert "✅" in result or "Completed" in result + + def test_calculate_watch_statistics(self): + """Test calculating watch history statistics.""" + from fastanime.cli.interactive.menus.watch_history import _calculate_watch_statistics + + history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "episode": 12, + "total_episodes": 12 + }, + { + "anilist_id": 2, + "title": "Test Anime 2", + "episode": 5, + "total_episodes": 24 + }, + { + "anilist_id": 3, + "title": "Test Anime 3", + "episode": 1, + "total_episodes": 12 + } + ] + + stats = _calculate_watch_statistics(history) + + assert stats["total_anime"] == 3 + assert stats["completed_anime"] == 1 + assert stats["in_progress_anime"] == 2 + assert stats["total_episodes_watched"] == 18 + + def test_calculate_watch_statistics_empty(self): + """Test calculating statistics with empty history.""" + from fastanime.cli.interactive.menus.watch_history import _calculate_watch_statistics + + stats = _calculate_watch_statistics([]) + + assert stats["total_anime"] == 0 + assert stats["completed_anime"] == 0 + assert stats["in_progress_anime"] == 0 + assert stats["total_episodes_watched"] == 0 + + def test_display_watch_statistics(self): + """Test displaying watch statistics.""" + from fastanime.cli.interactive.menus.watch_history import _display_watch_statistics + + console = Mock() + stats = { + "total_anime": 10, + "completed_anime": 5, + "in_progress_anime": 3, + "total_episodes_watched": 120 + } + + _display_watch_statistics(console, stats, True) + + # Should print table with statistics + console.print.assert_called() + + def test_get_history_item_by_selection(self): + """Test getting history item by user selection.""" + from fastanime.cli.interactive.menus.watch_history import _get_history_item_by_selection + + history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "episode": 5, + "total_episodes": 12 + }, + { + "anilist_id": 2, + "title": "Test Anime 2", + "episode": 10, + "total_episodes": 24 + } + ] + + formatted_choices = [ + "Test Anime 1 - Episode 5/12", + "Test Anime 2 - Episode 10/24" + ] + + selection = "Test Anime 1 - Episode 5/12" + + result = _get_history_item_by_selection(history, formatted_choices, selection) + + assert result["anilist_id"] == 1 + assert result["title"] == "Test Anime 1" + + def test_get_history_item_by_selection_not_found(self): + """Test getting history item when selection is not found.""" + from fastanime.cli.interactive.menus.watch_history import _get_history_item_by_selection + + history = [ + { + "anilist_id": 1, + "title": "Test Anime 1", + "episode": 5, + "total_episodes": 12 + } + ] + + formatted_choices = ["Test Anime 1 - Episode 5/12"] + selection = "Non-existent Selection" + + result = _get_history_item_by_selection(history, formatted_choices, selection) + + assert result is None