feat: tests

This commit is contained in:
Benexl
2025-07-15 00:02:55 +03:00
parent 26f9c3b8de
commit 41ed56f395
14 changed files with 5082 additions and 0 deletions

View File

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

View File

@@ -0,0 +1 @@
# Test package for interactive menu tests

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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