mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
feat: tests
This commit is contained in:
334
tests/interactive/menus/README.md
Normal file
334
tests/interactive/menus/README.md
Normal 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
|
||||
1
tests/interactive/menus/__init__.py
Normal file
1
tests/interactive/menus/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test package for interactive menu tests
|
||||
264
tests/interactive/menus/conftest.py
Normal file
264
tests/interactive/menus/conftest.py
Normal 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
|
||||
84
tests/interactive/menus/run_tests.py
Normal file
84
tests/interactive/menus/run_tests.py
Normal 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)
|
||||
433
tests/interactive/menus/test_auth.py
Normal file
433
tests/interactive/menus/test_auth.py
Normal 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()
|
||||
410
tests/interactive/menus/test_episodes.py
Normal file
410
tests/interactive/menus/test_episodes.py
Normal 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
|
||||
376
tests/interactive/menus/test_main.py
Normal file
376
tests/interactive/menus/test_main.py
Normal 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
|
||||
383
tests/interactive/menus/test_media_actions.py
Normal file
383
tests/interactive/menus/test_media_actions.py
Normal 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
|
||||
479
tests/interactive/menus/test_player_controls.py
Normal file
479
tests/interactive/menus/test_player_controls.py
Normal 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
|
||||
465
tests/interactive/menus/test_provider_search.py
Normal file
465
tests/interactive/menus/test_provider_search.py
Normal 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
|
||||
355
tests/interactive/menus/test_results.py
Normal file
355
tests/interactive/menus/test_results.py
Normal 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"
|
||||
445
tests/interactive/menus/test_servers.py
Normal file
445
tests/interactive/menus/test_servers.py
Normal 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"
|
||||
463
tests/interactive/menus/test_session_management.py
Normal file
463
tests/interactive/menus/test_session_management.py
Normal 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
|
||||
590
tests/interactive/menus/test_watch_history.py
Normal file
590
tests/interactive/menus/test_watch_history.py
Normal 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
|
||||
Reference in New Issue
Block a user