chore: leave testing for later

This commit is contained in:
Benexl
2025-07-15 22:53:10 +03:00
parent e3deb28d26
commit 5dde02570a
17 changed files with 3924 additions and 44 deletions

View File

@@ -1,44 +0,0 @@
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
quantity: int
def __post_init__(self):
if not isinstance(self.name, str):
raise TypeError(f"Expected 'name' to be a string, got {type(self.name).__name__}")
if not isinstance(self.price, (int, float)):
raise TypeError(f"Expected 'price' to be a number, got {type(self.price).__name__}")
if not isinstance(self.quantity, int):
raise TypeError(f"Expected 'quantity' to be an integer, got {type(self.quantity).__name__}")
if self.price < 0:
raise ValueError("Price cannot be negative.")
if self.quantity < 0:
raise ValueError("Quantity cannot be negative.")
# Valid usage
try:
p1 = Product(name="Laptop", price=1200.50, quantity=10)
print(p1)
except (TypeError, ValueError) as e:
print(f"Error creating product: {e}")
print("-" * 20)
# Invalid type for price
try:
p2 = Product(name="Mouse", price="fifty", quantity=5)
print(p2)
except (TypeError, ValueError) as e:
print(f"Error creating product: {e}")
print("-" * 20)
# Invalid value for quantity
try:
p3 = Product(name="Keyboard", price=75.00, quantity=-2)
print(p3)
except (TypeError, ValueError) as e:
print(f"Error creating product: {e}")

47
pytest.ini Normal file
View File

@@ -0,0 +1,47 @@
[tool.pytest.ini_options]
minversion = "6.0"
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
"--cov=fastanime.cli.interactive",
"--cov-report=term-missing",
"--cov-report=html:htmlcov",
"--cov-report=xml",
"-v"
]
testpaths = [
"tests",
]
python_files = [
"test_*.py",
"*_test.py",
]
python_classes = [
"Test*",
]
python_functions = [
"test_*",
]
markers = [
"unit: Unit tests",
"integration: Integration tests",
"slow: Slow running tests",
"network: Tests requiring network access",
"auth: Tests requiring authentication",
]
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning",
]
# Test discovery patterns
collect_ignore = [
"setup.py",
]
# Pytest plugins
required_plugins = [
"pytest-cov",
"pytest-mock",
]

1
tests/cli/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Test package for CLI module."""

View File

@@ -0,0 +1,221 @@
# Interactive Menu Tests
This directory contains comprehensive tests for FastAnime's interactive CLI menus. The test suite follows DRY principles and provides extensive coverage of all menu functionality.
## Test Structure
```
tests/
├── conftest.py # Shared fixtures and test configuration
├── cli/
│ └── interactive/
│ ├── test_session.py # Session management tests
│ └── menus/
│ ├── base_test.py # Base test classes and utilities
│ ├── test_main.py # Main menu tests
│ ├── test_auth.py # Authentication menu tests
│ ├── test_session_management.py # Session management menu tests
│ ├── test_results.py # Results display menu tests
│ ├── test_episodes.py # Episodes selection menu tests
│ ├── test_watch_history.py # Watch history menu tests
│ ├── test_media_actions.py # Media actions menu tests
│ └── test_additional_menus.py # Additional menus (servers, provider search, etc.)
```
## Test Architecture
### Base Classes
- **`BaseMenuTest`**: Core test functionality for all menu tests
- Console clearing verification
- Control flow assertions (BACK, EXIT, CONTINUE, RELOAD_CONFIG)
- Menu transition assertions
- Feedback message verification
- Common setup patterns
- **`MenuTestMixin`**: Additional utilities for specialized testing
- API result mocking
- Authentication state setup
- Provider search configuration
- **Specialized Mixins**:
- `AuthMenuTestMixin`: Authentication-specific test utilities
- `SessionMenuTestMixin`: Session management test utilities
- `MediaMenuTestMixin`: Media-related test utilities
### Fixtures
**Core Fixtures** (in `conftest.py`):
- `mock_config`: Application configuration
- `mock_context`: Complete context with all dependencies
- `mock_unauthenticated_context`: Context without authentication
- `mock_user_profile`: Authenticated user data
- `mock_media_item`: Sample anime/media data
- `mock_media_search_result`: API search results
- `basic_state`: Basic menu state
- `state_with_media_data`: State with media information
**Utility Fixtures**:
- `mock_feedback_manager`: User feedback system
- `mock_console`: Rich console output
- `menu_helper`: Helper methods for common test patterns
## Test Categories
### Unit Tests
Each menu has comprehensive unit tests covering:
- Navigation choices and transitions
- Error handling and edge cases
- Authentication requirements
- Configuration variations (icons enabled/disabled)
- Input validation
- API interaction patterns
### Integration Tests
Tests covering menu flow and interaction:
- Complete navigation workflows
- Error recovery across menus
- Authentication flow integration
- Session state persistence
### Test Patterns
#### Navigation Testing
```python
def test_menu_navigation(self, mock_context, basic_state):
self.setup_selector_choice(mock_context, "Target Option")
result = menu_function(mock_context, basic_state)
self.assert_menu_transition(result, "TARGET_MENU")
```
#### Error Handling Testing
```python
def test_menu_error_handling(self, mock_context, basic_state):
self.setup_api_failure(mock_context)
result = menu_function(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_feedback_error_called("Expected error message")
```
#### Authentication Testing
```python
def test_authenticated_vs_unauthenticated(self, mock_context, mock_unauthenticated_context, basic_state):
# Test authenticated behavior
result1 = menu_function(mock_context, basic_state)
# Test unauthenticated behavior
result2 = menu_function(mock_unauthenticated_context, basic_state)
# Assert different behaviors
```
## Running Tests
### Quick Start
```bash
# Run all interactive menu tests
python -m pytest tests/cli/interactive/ -v
# Run tests with coverage
python -m pytest tests/cli/interactive/ --cov=fastanime.cli.interactive --cov-report=html
# Run specific menu tests
python -m pytest tests/cli/interactive/menus/test_main.py -v
```
### Using the Test Runner
```bash
# Quick unit tests
./run_tests.py --quick
# Full test suite with coverage and linting
./run_tests.py --full
# Test specific menu
./run_tests.py --menu main
# Test with pattern matching
./run_tests.py --pattern "test_auth" --verbose
# Generate coverage report only
./run_tests.py --coverage-only
```
### Test Runner Options
- `--quick`: Fast unit tests only
- `--full`: Complete suite with coverage and linting
- `--menu <name>`: Test specific menu
- `--pattern <pattern>`: Match test names
- `--coverage`: Generate coverage reports
- `--verbose`: Detailed output
- `--fail-fast`: Stop on first failure
- `--parallel <n>`: Run tests in parallel
- `--lint`: Run code linting
## Test Coverage Goals
The test suite aims for comprehensive coverage of:
-**Menu Navigation**: All menu choices and transitions
-**Error Handling**: API failures, invalid input, edge cases
-**Authentication Flow**: Authenticated vs unauthenticated behavior
-**Configuration Variations**: Icons, providers, preferences
-**User Input Validation**: Empty input, invalid formats, special characters
-**State Management**: Session state persistence and recovery
-**Control Flow**: BACK, EXIT, CONTINUE, RELOAD_CONFIG behaviors
-**Integration Points**: Menu-to-menu transitions and data flow
## Adding New Tests
### For New Menus
1. Create `test_<menu_name>.py` in `tests/cli/interactive/menus/`
2. Inherit from `BaseMenuTest` and appropriate mixins
3. Follow the established patterns for navigation, error handling, and authentication testing
4. Add fixtures specific to the menu's data requirements
### For New Features
1. Add tests to existing menu test files
2. Create new fixtures in `conftest.py` if needed
3. Add new test patterns to `base_test.py` if reusable
4. Update this README with new patterns or conventions
### Test Naming Conventions
- `test_<menu>_<scenario>`: Basic functionality tests
- `test_<menu>_<action>_success`: Successful operation tests
- `test_<menu>_<action>_failure`: Error condition tests
- `test_<menu>_<condition>_<behavior>`: Conditional behavior tests
## Debugging Tests
### Common Issues
- **Import Errors**: Ensure all dependencies are properly mocked
- **State Errors**: Verify state fixtures have required data
- **Mock Configuration**: Check that mocks match actual interface contracts
- **Async Issues**: Ensure async operations are properly handled in tests
### Debugging Tools
```bash
# Run specific test with debug output
python -m pytest tests/cli/interactive/menus/test_main.py::TestMainMenu::test_specific_case -v -s
# Run with Python debugger
python -m pytest --pdb tests/cli/interactive/menus/test_main.py
# Generate detailed coverage report
python -m pytest --cov=fastanime.cli.interactive --cov-report=html --cov-report=term-missing -v
```
## Continuous Integration
The test suite is designed for CI/CD integration:
- Fast unit tests for quick feedback
- Comprehensive integration tests for release validation
- Coverage reporting for quality metrics
- Linting integration for code quality
### CI Configuration Example
```yaml
# Run quick tests on every commit
pytest tests/cli/interactive/ -m unit --fail-fast
# Run full suite on PR/release
pytest tests/cli/interactive/ --cov=fastanime.cli.interactive --cov-fail-under=90
```

View File

@@ -0,0 +1 @@
"""Test package for interactive CLI module."""

View File

@@ -0,0 +1 @@
"""Test package for interactive menu modules."""

View File

@@ -0,0 +1,244 @@
"""
Base test utilities for interactive menu testing.
Provides common patterns and utilities following DRY principles.
"""
import pytest
from unittest.mock import Mock, patch
from typing import Any, Optional, Dict, List
from fastanime.cli.interactive.state import State, ControlFlow
from fastanime.cli.interactive.session import Context
class BaseMenuTest:
"""
Base class for menu tests providing common testing patterns and utilities.
Follows DRY principles by centralizing common test logic.
"""
@pytest.fixture(autouse=True)
def setup_base_mocks(self, mock_create_feedback_manager, mock_rich_console):
"""Automatically set up common mocks for all menu tests."""
self.mock_feedback = mock_create_feedback_manager
self.mock_console = mock_rich_console
def assert_exit_behavior(self, result: Any):
"""Assert that the menu returned EXIT control flow."""
assert isinstance(result, ControlFlow)
assert result == ControlFlow.EXIT
def assert_back_behavior(self, result: Any):
"""Assert that the menu returned BACK control flow."""
assert isinstance(result, ControlFlow)
assert result == ControlFlow.BACK
def assert_continue_behavior(self, result: Any):
"""Assert that the menu returned CONTINUE control flow."""
assert isinstance(result, ControlFlow)
assert result == ControlFlow.CONTINUE
def assert_reload_config_behavior(self, result: Any):
"""Assert that the menu returned RELOAD_CONFIG control flow."""
assert isinstance(result, ControlFlow)
assert result == ControlFlow.RELOAD_CONFIG
def assert_menu_transition(self, result: Any, expected_menu: str):
"""Assert that the menu transitioned to the expected menu state."""
assert isinstance(result, State)
assert result.menu_name == expected_menu
def setup_selector_choice(self, context: Context, choice: Optional[str]):
"""Helper to configure selector choice return value."""
context.selector.choose.return_value = choice
def setup_selector_input(self, context: Context, input_value: str):
"""Helper to configure selector input return value."""
context.selector.input.return_value = input_value
def setup_selector_confirm(self, context: Context, confirm: bool):
"""Helper to configure selector confirm return value."""
context.selector.confirm.return_value = confirm
def setup_feedback_confirm(self, confirm: bool):
"""Helper to configure feedback confirm return value."""
self.mock_feedback.confirm.return_value = confirm
def assert_console_cleared(self):
"""Assert that the console was cleared."""
self.mock_console.clear.assert_called_once()
def assert_feedback_error_called(self, message_contains: str = None):
"""Assert that feedback.error was called, optionally with specific message."""
self.mock_feedback.error.assert_called()
if message_contains:
call_args = self.mock_feedback.error.call_args
assert message_contains in str(call_args)
def assert_feedback_info_called(self, message_contains: str = None):
"""Assert that feedback.info was called, optionally with specific message."""
self.mock_feedback.info.assert_called()
if message_contains:
call_args = self.mock_feedback.info.call_args
assert message_contains in str(call_args)
def assert_feedback_warning_called(self, message_contains: str = None):
"""Assert that feedback.warning was called, optionally with specific message."""
self.mock_feedback.warning.assert_called()
if message_contains:
call_args = self.mock_feedback.warning.call_args
assert message_contains in str(call_args)
def assert_feedback_success_called(self, message_contains: str = None):
"""Assert that feedback.success was called, optionally with specific message."""
self.mock_feedback.success.assert_called()
if message_contains:
call_args = self.mock_feedback.success.call_args
assert message_contains in str(call_args)
def create_test_options_dict(self, base_options: Dict[str, str], icons: bool = True) -> Dict[str, str]:
"""
Helper to create options dictionary with or without icons.
Useful for testing both icon and non-icon configurations.
"""
if not icons:
# Remove emoji icons from options
return {key: value.split(' ', 1)[-1] if ' ' in value else value
for key, value in base_options.items()}
return base_options
def get_menu_choices(self, options_dict: Dict[str, str]) -> List[str]:
"""Extract the choice strings from an options dictionary."""
return list(options_dict.values())
def simulate_user_choice(self, context: Context, choice_key: str, options_dict: Dict[str, str]):
"""Simulate a user making a specific choice from the menu options."""
choice_value = options_dict.get(choice_key)
if choice_value:
self.setup_selector_choice(context, choice_value)
return choice_value
class MenuTestMixin:
"""
Mixin providing additional test utilities that can be combined with BaseMenuTest.
Useful for specialized menu testing scenarios.
"""
def setup_api_search_result(self, context: Context, search_result: Any):
"""Configure the API client to return a specific search result."""
context.media_api.search_media.return_value = search_result
def setup_api_search_failure(self, context: Context):
"""Configure the API client to fail search requests."""
context.media_api.search_media.return_value = None
def setup_provider_search_result(self, context: Context, search_result: Any):
"""Configure the provider to return a specific search result."""
context.provider.search.return_value = search_result
def setup_provider_search_failure(self, context: Context):
"""Configure the provider to fail search requests."""
context.provider.search.return_value = None
def setup_authenticated_user(self, context: Context, user_profile: Any):
"""Configure the context for an authenticated user."""
context.media_api.user_profile = user_profile
def setup_unauthenticated_user(self, context: Context):
"""Configure the context for an unauthenticated user."""
context.media_api.user_profile = None
def verify_selector_called_with_choices(self, context: Context, expected_choices: List[str]):
"""Verify that the selector was called with the expected choices."""
context.selector.choose.assert_called_once()
call_args = context.selector.choose.call_args
actual_choices = call_args[1]['choices'] # Get choices from kwargs
assert actual_choices == expected_choices
def verify_selector_prompt(self, context: Context, expected_prompt: str):
"""Verify that the selector was called with the expected prompt."""
context.selector.choose.assert_called_once()
call_args = context.selector.choose.call_args
actual_prompt = call_args[1]['prompt'] # Get prompt from kwargs
assert actual_prompt == expected_prompt
class AuthMenuTestMixin(MenuTestMixin):
"""Specialized mixin for authentication menu tests."""
def setup_auth_manager_mock(self):
"""Set up AuthManager mock for authentication tests."""
with patch('fastanime.cli.auth.manager.AuthManager') as mock_auth:
auth_instance = Mock()
auth_instance.load_user_profile.return_value = None
auth_instance.save_user_profile.return_value = True
auth_instance.clear_user_profile.return_value = True
mock_auth.return_value = auth_instance
return auth_instance
def setup_webbrowser_mock(self):
"""Set up webbrowser.open mock for authentication tests."""
return patch('webbrowser.open')
class SessionMenuTestMixin(MenuTestMixin):
"""Specialized mixin for session management menu tests."""
def setup_session_manager_mock(self):
"""Set up session manager mock for session tests."""
session_manager = Mock()
session_manager.list_saved_sessions.return_value = []
session_manager.save_session.return_value = True
session_manager.load_session.return_value = []
session_manager.cleanup_old_sessions.return_value = 0
return session_manager
def setup_path_exists_mock(self, exists: bool = True):
"""Set up Path.exists mock for file system tests."""
return patch('pathlib.Path.exists', return_value=exists)
class MediaMenuTestMixin(MenuTestMixin):
"""Specialized mixin for media-related menu tests."""
def setup_media_list_success(self, context: Context, media_result: Any):
"""Set up successful media list fetch."""
self.setup_api_search_result(context, media_result)
def setup_media_list_failure(self, context: Context):
"""Set up failed media list fetch."""
self.setup_api_search_failure(context)
def create_mock_media_result(self, num_items: int = 1):
"""Create a mock media search result with specified number of items."""
from fastanime.libs.api.types import MediaSearchResult, MediaItem
media_items = []
for i in range(num_items):
media_items.append(MediaItem(
id=i + 1,
title=f"Test Anime {i + 1}",
description=f"Description for test anime {i + 1}",
cover_image=f"https://example.com/cover{i + 1}.jpg",
banner_image=f"https://example.com/banner{i + 1}.jpg",
status="RELEASING",
episodes=12,
duration=24,
genres=["Action", "Adventure"],
mean_score=85 + i,
popularity=1000 + i * 100,
start_date="2024-01-01",
end_date=None
))
return MediaSearchResult(
media=media_items,
page_info={
"total": num_items,
"current_page": 1,
"last_page": 1,
"has_next_page": False,
"per_page": 20
}
)

View File

@@ -0,0 +1,280 @@
"""
Tests for remaining interactive menus.
Tests servers, provider search, and player controls menus.
"""
import pytest
from unittest.mock import Mock, patch
from fastanime.cli.interactive.state import State, ControlFlow, ProviderState, MediaApiState
from fastanime.libs.providers.anime.types import Server
from .base_test import BaseMenuTest, MediaMenuTestMixin
class TestServersMenu(BaseMenuTest, MediaMenuTestMixin):
"""Test cases for the servers menu."""
@pytest.fixture
def mock_servers(self):
"""Create mock server list."""
return [
Server(name="Server 1", url="https://server1.com/stream"),
Server(name="Server 2", url="https://server2.com/stream"),
Server(name="Server 3", url="https://server3.com/stream")
]
@pytest.fixture
def servers_state(self, mock_provider_anime, mock_media_item, mock_servers):
"""Create state with servers data."""
return State(
menu_name="SERVERS",
provider=ProviderState(
anime=mock_provider_anime,
selected_episode="5",
servers=mock_servers
),
media_api=MediaApiState(anime=mock_media_item)
)
def test_servers_menu_no_servers_goes_back(self, mock_context, basic_state):
"""Test that no servers returns BACK."""
from fastanime.cli.interactive.menus.servers import servers
state_no_servers = State(
menu_name="SERVERS",
provider=ProviderState(servers=[])
)
result = servers(mock_context, state_no_servers)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_servers_menu_server_selection(self, mock_context, servers_state):
"""Test server selection and stream playback."""
from fastanime.cli.interactive.menus.servers import servers
self.setup_selector_choice(mock_context, "Server 1")
# Mock successful stream extraction
mock_context.provider.get_stream_url.return_value = "https://stream.url"
mock_context.player.play.return_value = Mock()
result = servers(mock_context, servers_state)
# Should return to episodes or continue based on playback result
assert isinstance(result, (State, ControlFlow))
self.assert_console_cleared()
def test_servers_menu_auto_select_best_server(self, mock_context, servers_state):
"""Test auto-selecting best quality server."""
from fastanime.cli.interactive.menus.servers import servers
mock_context.config.stream.auto_select_server = True
mock_context.provider.get_stream_url.return_value = "https://stream.url"
mock_context.player.play.return_value = Mock()
result = servers(mock_context, servers_state)
# Should auto-select and play
assert isinstance(result, (State, ControlFlow))
self.assert_console_cleared()
class TestProviderSearchMenu(BaseMenuTest, MediaMenuTestMixin):
"""Test cases for the provider search menu."""
def test_provider_search_no_choice_goes_back(self, mock_context, basic_state):
"""Test that no choice returns BACK."""
from fastanime.cli.interactive.menus.provider_search import provider_search
self.setup_selector_choice(mock_context, None)
result = provider_search(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_provider_search_success(self, mock_context, state_with_media_data):
"""Test successful provider search."""
from fastanime.cli.interactive.menus.provider_search import provider_search
from fastanime.libs.providers.anime.types import SearchResults, Anime
# Mock search results
mock_anime = Mock(spec=Anime)
mock_search_results = Mock(spec=SearchResults)
mock_search_results.results = [mock_anime]
mock_context.provider.search.return_value = mock_search_results
self.setup_selector_choice(mock_context, "Test Anime Result")
result = provider_search(mock_context, state_with_media_data)
self.assert_menu_transition(result, "EPISODES")
self.assert_console_cleared()
def test_provider_search_no_results(self, mock_context, state_with_media_data):
"""Test provider search with no results."""
from fastanime.cli.interactive.menus.provider_search import provider_search
mock_context.provider.search.return_value = None
result = provider_search(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("No results found")
class TestPlayerControlsMenu(BaseMenuTest):
"""Test cases for the player controls menu."""
def test_player_controls_no_active_player_goes_back(self, mock_context, basic_state):
"""Test that no active player returns BACK."""
from fastanime.cli.interactive.menus.player_controls import player_controls
mock_context.player.is_active = False
result = player_controls(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_player_controls_pause_resume(self, mock_context, basic_state):
"""Test pause/resume controls."""
from fastanime.cli.interactive.menus.player_controls import player_controls
mock_context.player.is_active = True
mock_context.player.is_paused = False
self.setup_selector_choice(mock_context, "⏸️ Pause")
result = player_controls(mock_context, basic_state)
self.assert_continue_behavior(result)
mock_context.player.pause.assert_called_once()
def test_player_controls_seek(self, mock_context, basic_state):
"""Test seek controls."""
from fastanime.cli.interactive.menus.player_controls import player_controls
mock_context.player.is_active = True
self.setup_selector_choice(mock_context, "⏩ Seek Forward")
result = player_controls(mock_context, basic_state)
self.assert_continue_behavior(result)
mock_context.player.seek.assert_called_once()
def test_player_controls_volume(self, mock_context, basic_state):
"""Test volume controls."""
from fastanime.cli.interactive.menus.player_controls import player_controls
mock_context.player.is_active = True
self.setup_selector_choice(mock_context, "🔊 Volume Up")
result = player_controls(mock_context, basic_state)
self.assert_continue_behavior(result)
mock_context.player.volume_up.assert_called_once()
def test_player_controls_stop(self, mock_context, basic_state):
"""Test stop playback."""
from fastanime.cli.interactive.menus.player_controls import player_controls
mock_context.player.is_active = True
self.setup_selector_choice(mock_context, "⏹️ Stop")
self.setup_feedback_confirm(True) # Confirm stop
result = player_controls(mock_context, basic_state)
self.assert_back_behavior(result)
mock_context.player.stop.assert_called_once()
# Integration tests for menu flow
class TestMenuIntegration(BaseMenuTest, MediaMenuTestMixin):
"""Integration tests for menu navigation flow."""
def test_full_navigation_flow(self, mock_context, mock_media_search_result):
"""Test complete navigation from main to watching anime."""
from fastanime.cli.interactive.menus.main import main
from fastanime.cli.interactive.menus.results import results
from fastanime.cli.interactive.menus.media_actions import media_actions
from fastanime.cli.interactive.menus.provider_search import provider_search
# Start from main menu
main_state = State(menu_name="MAIN")
# Mock main menu choice - trending
self.setup_selector_choice(mock_context, "🔥 Trending")
self.setup_media_list_success(mock_context, mock_media_search_result)
# Should go to results
result = main(mock_context, main_state)
self.assert_menu_transition(result, "RESULTS")
# Now test results menu
results_state = result
anime_title = f"{mock_media_search_result.media[0].title} ({mock_media_search_result.media[0].status})"
with patch('fastanime.cli.interactive.menus.results._format_anime_choice', return_value=anime_title):
self.setup_selector_choice(mock_context, anime_title)
result = results(mock_context, results_state)
self.assert_menu_transition(result, "MEDIA_ACTIONS")
# Test media actions
actions_state = result
self.setup_selector_choice(mock_context, "🔍 Search Providers")
result = media_actions(mock_context, actions_state)
self.assert_menu_transition(result, "PROVIDER_SEARCH")
def test_error_recovery_flow(self, mock_context, basic_state):
"""Test error recovery in menu navigation."""
from fastanime.cli.interactive.menus.main import main
# Mock API failure
self.setup_selector_choice(mock_context, "🔥 Trending")
self.setup_media_list_failure(mock_context)
result = main(mock_context, basic_state)
# Should continue (show error and stay in menu)
self.assert_continue_behavior(result)
self.assert_feedback_error_called("Failed to fetch data")
def test_authentication_flow_integration(self, mock_unauthenticated_context, basic_state):
"""Test authentication-dependent features."""
from fastanime.cli.interactive.menus.main import main
from fastanime.cli.interactive.menus.auth import auth
# Try to access user list without auth
self.setup_selector_choice(mock_unauthenticated_context, "📺 Watching")
# Should either redirect to auth or show error
result = main(mock_unauthenticated_context, basic_state)
# Result depends on implementation - could be CONTINUE with error or AUTH redirect
assert isinstance(result, (State, ControlFlow))
@pytest.mark.parametrize("menu_choice,expected_transition", [
("🔧 Session Management", "SESSION_MANAGEMENT"),
("🔐 Authentication", "AUTH"),
("📖 Local Watch History", "WATCH_HISTORY"),
("❌ Exit", ControlFlow.EXIT),
("📝 Edit Config", ControlFlow.RELOAD_CONFIG),
])
def test_main_menu_navigation_paths(self, mock_context, basic_state, menu_choice, expected_transition):
"""Test various navigation paths from main menu."""
from fastanime.cli.interactive.menus.main import main
self.setup_selector_choice(mock_context, menu_choice)
result = main(mock_context, basic_state)
if isinstance(expected_transition, str):
self.assert_menu_transition(result, expected_transition)
else:
assert result == expected_transition

View File

@@ -0,0 +1,296 @@
"""
Tests for the authentication menu.
Tests login, logout, profile viewing, and authentication flow.
"""
import pytest
from unittest.mock import Mock, patch
from fastanime.cli.interactive.menus.auth import auth
from fastanime.cli.interactive.state import State, ControlFlow
from .base_test import BaseMenuTest, AuthMenuTestMixin
from ...conftest import TEST_AUTH_OPTIONS
class TestAuthMenu(BaseMenuTest, AuthMenuTestMixin):
"""Test cases for the authentication menu."""
def test_auth_menu_no_choice_goes_back(self, mock_context, basic_state):
"""Test that no choice selected results in BACK."""
self.setup_selector_choice(mock_context, None)
result = auth(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_auth_menu_back_choice(self, mock_context, basic_state):
"""Test explicit back choice."""
self.setup_selector_choice(mock_context, TEST_AUTH_OPTIONS['back'])
result = auth(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_auth_menu_unauthenticated_options(self, mock_unauthenticated_context, basic_state):
"""Test menu options when user is not authenticated."""
self.setup_selector_choice(mock_unauthenticated_context, None)
result = auth(mock_unauthenticated_context, basic_state)
self.assert_back_behavior(result)
# Verify correct options are shown for unauthenticated user
mock_unauthenticated_context.selector.choose.assert_called_once()
call_args = mock_unauthenticated_context.selector.choose.call_args
choices = call_args[1]['choices']
# Should include login and help options
assert any('Login' in choice for choice in choices)
assert any('How to Get Token' in choice for choice in choices)
assert any('Back' in choice for choice in choices)
# Should not include logout or profile options
assert not any('Logout' in choice for choice in choices)
assert not any('Profile Details' in choice for choice in choices)
def test_auth_menu_authenticated_options(self, mock_context, basic_state, mock_user_profile):
"""Test menu options when user is authenticated."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, None)
result = auth(mock_context, basic_state)
self.assert_back_behavior(result)
# Verify correct options are shown for authenticated user
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Should include logout and profile options
assert any('Logout' in choice for choice in choices)
assert any('Profile Details' in choice for choice in choices)
assert any('Back' in choice for choice in choices)
# Should not include login options
assert not any('Login' in choice for choice in choices)
assert not any('How to Get Token' in choice for choice in choices)
def test_auth_menu_login_success(self, mock_unauthenticated_context, basic_state, mock_user_profile):
"""Test successful login flow."""
self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login'])
self.setup_selector_input(mock_unauthenticated_context, "test_token_123")
# Mock successful authentication
mock_unauthenticated_context.media_api.authenticate.return_value = mock_user_profile
with self.setup_auth_manager_mock() as mock_auth_manager:
result = auth(mock_unauthenticated_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify authentication was attempted
mock_unauthenticated_context.media_api.authenticate.assert_called_once_with("test_token_123")
# Verify user profile was saved
mock_auth_manager.save_user_profile.assert_called_once()
self.assert_feedback_success_called("Successfully authenticated")
def test_auth_menu_login_failure(self, mock_unauthenticated_context, basic_state):
"""Test failed login flow."""
self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login'])
self.setup_selector_input(mock_unauthenticated_context, "invalid_token")
# Mock failed authentication
mock_unauthenticated_context.media_api.authenticate.return_value = None
with self.setup_auth_manager_mock() as mock_auth_manager:
result = auth(mock_unauthenticated_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify authentication was attempted
mock_unauthenticated_context.media_api.authenticate.assert_called_once_with("invalid_token")
# Verify user profile was not saved
mock_auth_manager.save_user_profile.assert_not_called()
self.assert_feedback_error_called("Authentication failed")
def test_auth_menu_login_empty_token(self, mock_unauthenticated_context, basic_state):
"""Test login with empty token."""
self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login'])
self.setup_selector_input(mock_unauthenticated_context, "") # Empty token
result = auth(mock_unauthenticated_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Authentication should not be attempted with empty token
mock_unauthenticated_context.media_api.authenticate.assert_not_called()
self.assert_feedback_warning_called("Token cannot be empty")
def test_auth_menu_logout_success(self, mock_context, basic_state, mock_user_profile):
"""Test successful logout flow."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, TEST_AUTH_OPTIONS['logout'])
self.setup_feedback_confirm(True) # Confirm logout
with self.setup_auth_manager_mock() as mock_auth_manager:
result = auth(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify logout confirmation was requested
self.mock_feedback.confirm.assert_called_once()
# Verify user profile was cleared
mock_auth_manager.clear_user_profile.assert_called_once()
# Verify API client was updated
assert mock_context.media_api.user_profile is None
self.assert_feedback_success_called("Successfully logged out")
def test_auth_menu_logout_cancelled(self, mock_context, basic_state, mock_user_profile):
"""Test cancelled logout flow."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, TEST_AUTH_OPTIONS['logout'])
self.setup_feedback_confirm(False) # Cancel logout
with self.setup_auth_manager_mock() as mock_auth_manager:
result = auth(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify logout confirmation was requested
self.mock_feedback.confirm.assert_called_once()
# Verify user profile was not cleared
mock_auth_manager.clear_user_profile.assert_not_called()
# Verify API client still has user profile
assert mock_context.media_api.user_profile == mock_user_profile
self.assert_feedback_info_called("Logout cancelled")
def test_auth_menu_view_profile(self, mock_context, basic_state, mock_user_profile):
"""Test view profile details."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, TEST_AUTH_OPTIONS['profile'])
result = auth(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify profile information was displayed
self.mock_feedback.pause_for_user.assert_called_once()
def test_auth_menu_how_to_get_token(self, mock_unauthenticated_context, basic_state):
"""Test how to get token help."""
self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['how_to_token'])
with self.setup_webbrowser_mock() as mock_browser:
result = auth(mock_unauthenticated_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify browser was opened to AniList developer page
mock_browser.open.assert_called_once()
call_args = mock_browser.open.call_args[0]
assert "anilist.co" in call_args[0].lower()
def test_auth_menu_icons_disabled(self, mock_unauthenticated_context, basic_state):
"""Test menu display with icons disabled."""
mock_unauthenticated_context.config.general.icons = False
self.setup_selector_choice(mock_unauthenticated_context, None)
result = auth(mock_unauthenticated_context, basic_state)
self.assert_back_behavior(result)
# Verify options don't contain emoji icons
mock_unauthenticated_context.selector.choose.assert_called_once()
call_args = mock_unauthenticated_context.selector.choose.call_args
choices = call_args[1]['choices']
for choice in choices:
assert not any(char in choice for char in '🔐👤🔓❓↩️')
def test_auth_menu_display_auth_status_authenticated(self, mock_context, basic_state, mock_user_profile):
"""Test auth status display for authenticated user."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, None)
result = auth(mock_context, basic_state)
self.assert_back_behavior(result)
# Console should display user information
assert mock_context.media_api.user_profile == mock_user_profile
def test_auth_menu_display_auth_status_unauthenticated(self, mock_unauthenticated_context, basic_state):
"""Test auth status display for unauthenticated user."""
self.setup_selector_choice(mock_unauthenticated_context, None)
result = auth(mock_unauthenticated_context, basic_state)
self.assert_back_behavior(result)
# Should show not authenticated status
assert mock_unauthenticated_context.media_api.user_profile is None
def test_auth_menu_login_with_whitespace_token(self, mock_unauthenticated_context, basic_state):
"""Test login with token containing whitespace."""
self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login'])
self.setup_selector_input(mock_unauthenticated_context, " test_token_123 ") # Token with spaces
# Mock successful authentication
mock_unauthenticated_context.media_api.authenticate.return_value = Mock()
with self.setup_auth_manager_mock():
result = auth(mock_unauthenticated_context, basic_state)
self.assert_continue_behavior(result)
# Verify token was stripped of whitespace
mock_unauthenticated_context.media_api.authenticate.assert_called_once_with("test_token_123")
def test_auth_menu_authentication_exception_handling(self, mock_unauthenticated_context, basic_state):
"""Test handling of authentication exceptions."""
self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login'])
self.setup_selector_input(mock_unauthenticated_context, "test_token")
# Mock authentication raising an exception
mock_unauthenticated_context.media_api.authenticate.side_effect = Exception("API Error")
with self.setup_auth_manager_mock():
result = auth(mock_unauthenticated_context, basic_state)
self.assert_continue_behavior(result)
self.assert_feedback_error_called("Authentication failed")
def test_auth_menu_save_profile_failure(self, mock_unauthenticated_context, basic_state, mock_user_profile):
"""Test handling of profile save failure after successful auth."""
self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login'])
self.setup_selector_input(mock_unauthenticated_context, "test_token")
# Mock successful authentication but failed save
mock_unauthenticated_context.media_api.authenticate.return_value = mock_user_profile
with self.setup_auth_manager_mock() as mock_auth_manager:
mock_auth_manager.save_user_profile.return_value = False # Save failure
result = auth(mock_unauthenticated_context, basic_state)
self.assert_continue_behavior(result)
# Should still show success for authentication even if save fails
self.assert_feedback_success_called("Successfully authenticated")
# Should show warning about save failure
self.assert_feedback_warning_called("Failed to save")
@pytest.mark.parametrize("user_input", ["", " ", "\t", "\n"])
def test_auth_menu_various_empty_tokens(self, mock_unauthenticated_context, basic_state, user_input):
"""Test various forms of empty token input."""
self.setup_selector_choice(mock_unauthenticated_context, TEST_AUTH_OPTIONS['login'])
self.setup_selector_input(mock_unauthenticated_context, user_input)
result = auth(mock_unauthenticated_context, basic_state)
self.assert_continue_behavior(result)
# Should not attempt authentication with empty/whitespace-only tokens
mock_unauthenticated_context.media_api.authenticate.assert_not_called()
self.assert_feedback_warning_called("Token cannot be empty")

View File

@@ -0,0 +1,366 @@
"""
Tests for the episodes menu.
Tests episode selection, watch history integration, and episode navigation.
"""
import pytest
from unittest.mock import Mock, patch
from fastanime.cli.interactive.menus.episodes import episodes
from fastanime.cli.interactive.state import State, ControlFlow, ProviderState, MediaApiState
from fastanime.libs.providers.anime.types import Anime, Episodes
from .base_test import BaseMenuTest, MediaMenuTestMixin
class TestEpisodesMenu(BaseMenuTest, MediaMenuTestMixin):
"""Test cases for the episodes menu."""
@pytest.fixture
def mock_provider_anime(self):
"""Create a mock provider anime with episodes."""
anime = Mock(spec=Anime)
anime.episodes = Mock(spec=Episodes)
anime.episodes.sub = ["1", "2", "3", "4", "5"]
anime.episodes.dub = ["1", "2", "3"]
anime.episodes.raw = []
anime.title = "Test Anime"
return anime
@pytest.fixture
def episodes_state(self, mock_provider_anime, mock_media_item):
"""Create a state with provider anime and media api data."""
return State(
menu_name="EPISODES",
provider=ProviderState(anime=mock_provider_anime),
media_api=MediaApiState(anime=mock_media_item)
)
def test_episodes_menu_missing_provider_anime_goes_back(self, mock_context, basic_state):
"""Test that missing provider anime returns BACK."""
# State with no provider anime
state_no_anime = State(
menu_name="EPISODES",
provider=ProviderState(anime=None),
media_api=MediaApiState()
)
result = episodes(mock_context, state_no_anime)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_episodes_menu_missing_media_api_anime_goes_back(self, mock_context, mock_provider_anime):
"""Test that missing media api anime returns BACK."""
# State with provider anime but no media api anime
state_no_media = State(
menu_name="EPISODES",
provider=ProviderState(anime=mock_provider_anime),
media_api=MediaApiState(anime=None)
)
result = episodes(mock_context, state_no_media)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_episodes_menu_no_episodes_available_goes_back(self, mock_context, episodes_state):
"""Test that no available episodes returns BACK."""
# Configure translation type that has no episodes
mock_context.config.stream.translation_type = "raw"
result = episodes(mock_context, episodes_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_episodes_menu_no_choice_goes_back(self, mock_context, episodes_state):
"""Test that no choice selected results in BACK."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, None)
result = episodes(mock_context, episodes_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_episodes_menu_episode_selection(self, mock_context, episodes_state):
"""Test normal episode selection."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "Episode 3")
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
self.assert_console_cleared()
# Verify the selected episode is stored in the new state
assert "3" in str(result.provider.selected_episode)
def test_episodes_menu_continue_from_local_watch_history(self, mock_context, episodes_state):
"""Test continuing from local watch history."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = True
mock_context.config.stream.preferred_watch_history = "local"
with patch('fastanime.cli.utils.watch_history_tracker.get_continue_episode') as mock_get_continue:
mock_get_continue.return_value = "3" # Continue from episode 3
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
self.assert_console_cleared()
# Verify continue episode was retrieved
mock_get_continue.assert_called_once()
# Verify the continue episode is selected
assert "3" in str(result.provider.selected_episode)
def test_episodes_menu_continue_from_anilist_progress(self, mock_context, episodes_state, mock_media_item):
"""Test continuing from AniList progress."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = True
mock_context.config.stream.preferred_watch_history = "remote"
# Mock AniList progress
mock_media_item.progress = 2 # Watched 2 episodes, continue from 3
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
self.assert_console_cleared()
# Should continue from next episode after progress
assert "3" in str(result.provider.selected_episode)
def test_episodes_menu_no_watch_history_fallback_to_manual(self, mock_context, episodes_state):
"""Test fallback to manual selection when no watch history."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = True
mock_context.config.stream.preferred_watch_history = "local"
with patch('fastanime.cli.utils.watch_history_tracker.get_continue_episode') as mock_get_continue:
mock_get_continue.return_value = None # No continue episode
self.setup_selector_choice(mock_context, "Episode 1")
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
self.assert_console_cleared()
# Should fall back to manual selection
mock_context.selector.choose.assert_called_once()
def test_episodes_menu_translation_type_sub(self, mock_context, episodes_state):
"""Test with subtitle translation type."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "Episode 1")
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
mock_context.selector.choose.assert_called_once()
# Verify subtitle episodes are available
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
assert len([c for c in choices if "Episode" in c]) == 5 # 5 sub episodes
def test_episodes_menu_translation_type_dub(self, mock_context, episodes_state):
"""Test with dub translation type."""
mock_context.config.stream.translation_type = "dub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "Episode 1")
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
mock_context.selector.choose.assert_called_once()
# Verify dub episodes are available
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
assert len([c for c in choices if "Episode" in c]) == 3 # 3 dub episodes
def test_episodes_menu_range_selection(self, mock_context, episodes_state):
"""Test episode range selection."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "📚 Select Range")
# Mock range input
with patch.object(mock_context.selector, 'input', return_value="2-4"):
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
self.assert_console_cleared()
# Should handle range selection
mock_context.selector.input.assert_called_once()
def test_episodes_menu_invalid_range_selection(self, mock_context, episodes_state):
"""Test invalid episode range selection."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "📚 Select Range")
# Mock invalid range input
with patch.object(mock_context.selector, 'input', return_value="invalid-range"):
result = episodes(mock_context, episodes_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Invalid range format")
def test_episodes_menu_watch_all_episodes(self, mock_context, episodes_state):
"""Test watch all episodes option."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "🎬 Watch All Episodes")
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
self.assert_console_cleared()
# Should set up for watching all episodes
def test_episodes_menu_random_episode(self, mock_context, episodes_state):
"""Test random episode selection."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "🎲 Random Episode")
with patch('random.choice') as mock_random:
mock_random.return_value = "3"
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
self.assert_console_cleared()
mock_random.assert_called_once()
def test_episodes_menu_icons_disabled(self, mock_context, episodes_state):
"""Test menu display with icons disabled."""
mock_context.config.general.icons = False
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, None)
result = episodes(mock_context, episodes_state)
self.assert_back_behavior(result)
# Verify options don't contain emoji icons
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
for choice in choices:
assert not any(char in choice for char in '📚🎬🎲')
def test_episodes_menu_progress_indicator(self, mock_context, episodes_state, mock_media_item):
"""Test episode progress indicators."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
mock_media_item.progress = 3 # Watched 3 episodes
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.watch_history_tracker.get_watched_episodes') as mock_watched:
mock_watched.return_value = ["1", "2", "3"]
result = episodes(mock_context, episodes_state)
self.assert_back_behavior(result)
# Verify progress indicators were applied
mock_watched.assert_called_once()
def test_episodes_menu_large_episode_count(self, mock_context, episodes_state, mock_provider_anime):
"""Test handling of anime with many episodes."""
# Create anime with many episodes
mock_provider_anime.episodes.sub = [str(i) for i in range(1, 101)] # 100 episodes
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, None)
result = episodes(mock_context, episodes_state)
self.assert_back_behavior(result)
# Should handle large episode counts gracefully
mock_context.selector.choose.assert_called_once()
def test_episodes_menu_zero_padded_episodes(self, mock_context, episodes_state, mock_provider_anime):
"""Test handling of zero-padded episode numbers."""
mock_provider_anime.episodes.sub = ["01", "02", "03", "04", "05"]
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "Episode 01")
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
# Should handle zero-padded episodes correctly
assert "01" in str(result.provider.selected_episode)
def test_episodes_menu_special_episodes(self, mock_context, episodes_state, mock_provider_anime):
"""Test handling of special episode formats."""
mock_provider_anime.episodes.sub = ["1", "2", "3", "S1", "OVA1", "Movie"]
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "Episode S1")
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
# Should handle special episode formats
assert "S1" in str(result.provider.selected_episode)
def test_episodes_menu_watch_history_tracking(self, mock_context, episodes_state):
"""Test that episode viewing is tracked."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, "Episode 2")
with patch('fastanime.cli.utils.watch_history_tracker.track_episode_viewing') as mock_track:
result = episodes(mock_context, episodes_state)
self.assert_menu_transition(result, "SERVERS")
# Verify episode viewing is tracked (if implemented in the menu)
# This depends on the actual implementation
def test_episodes_menu_episode_metadata_display(self, mock_context, episodes_state):
"""Test episode metadata in choices."""
mock_context.config.stream.translation_type = "sub"
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, None)
result = episodes(mock_context, episodes_state)
self.assert_back_behavior(result)
# Verify episode choices include relevant metadata
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Episode choices should be formatted appropriately
episode_choices = [c for c in choices if "Episode" in c]
assert len(episode_choices) > 0
@pytest.mark.parametrize("translation_type,expected_count", [
("sub", 5),
("dub", 3),
("raw", 0),
])
def test_episodes_menu_translation_types(self, mock_context, episodes_state, translation_type, expected_count):
"""Test various translation types."""
mock_context.config.stream.translation_type = translation_type
mock_context.config.stream.continue_from_watch_history = False
self.setup_selector_choice(mock_context, None)
result = episodes(mock_context, episodes_state)
if expected_count == 0:
self.assert_back_behavior(result)
else:
self.assert_back_behavior(result) # Since no choice was made
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
episode_choices = [c for c in choices if "Episode" in c]
assert len(episode_choices) == expected_count

View File

@@ -0,0 +1,295 @@
"""
Tests for the main interactive menu.
Tests all navigation options and control flow logic.
"""
import pytest
from unittest.mock import Mock, patch
from fastanime.cli.interactive.menus.main import main
from fastanime.cli.interactive.state import State, ControlFlow
from fastanime.libs.api.types import MediaSearchResult
from .base_test import BaseMenuTest, MediaMenuTestMixin
from ...conftest import TEST_MENU_OPTIONS
class TestMainMenu(BaseMenuTest, MediaMenuTestMixin):
"""Test cases for the main interactive menu."""
def test_main_menu_no_choice_exits(self, mock_context, basic_state):
"""Test that no choice selected results in EXIT."""
# User cancels/exits the menu
self.setup_selector_choice(mock_context, None)
result = main(mock_context, basic_state)
self.assert_exit_behavior(result)
self.assert_console_cleared()
def test_main_menu_exit_choice(self, mock_context, basic_state):
"""Test explicit exit choice."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['exit'])
result = main(mock_context, basic_state)
self.assert_exit_behavior(result)
self.assert_console_cleared()
def test_main_menu_reload_config_choice(self, mock_context, basic_state):
"""Test config reload choice."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['edit_config'])
result = main(mock_context, basic_state)
self.assert_reload_config_behavior(result)
self.assert_console_cleared()
def test_main_menu_session_management_choice(self, mock_context, basic_state):
"""Test session management navigation."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['session_management'])
result = main(mock_context, basic_state)
self.assert_menu_transition(result, "SESSION_MANAGEMENT")
self.assert_console_cleared()
def test_main_menu_auth_choice(self, mock_context, basic_state):
"""Test authentication menu navigation."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['auth'])
result = main(mock_context, basic_state)
self.assert_menu_transition(result, "AUTH")
self.assert_console_cleared()
def test_main_menu_watch_history_choice(self, mock_context, basic_state):
"""Test watch history navigation."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['watch_history'])
result = main(mock_context, basic_state)
self.assert_menu_transition(result, "WATCH_HISTORY")
self.assert_console_cleared()
@pytest.mark.parametrize("choice_key,expected_menu", [
("trending", "RESULTS"),
("popular", "RESULTS"),
("favourites", "RESULTS"),
("top_scored", "RESULTS"),
("upcoming", "RESULTS"),
("recently_updated", "RESULTS"),
])
def test_main_menu_media_list_choices_success(self, mock_context, basic_state, choice_key, expected_menu, mock_media_search_result):
"""Test successful media list navigation for various categories."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS[choice_key])
self.setup_media_list_success(mock_context, mock_media_search_result)
result = main(mock_context, basic_state)
self.assert_menu_transition(result, expected_menu)
self.assert_console_cleared()
# Verify API was called
mock_context.media_api.search_media.assert_called_once()
@pytest.mark.parametrize("choice_key", [
"trending",
"popular",
"favourites",
"top_scored",
"upcoming",
"recently_updated",
])
def test_main_menu_media_list_choices_failure(self, mock_context, basic_state, choice_key):
"""Test failed media list fetch shows error and continues."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS[choice_key])
self.setup_media_list_failure(mock_context)
result = main(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to fetch data")
@pytest.mark.parametrize("choice_key,expected_menu", [
("watching", "RESULTS"),
("planned", "RESULTS"),
("completed", "RESULTS"),
("paused", "RESULTS"),
("dropped", "RESULTS"),
("rewatching", "RESULTS"),
])
def test_main_menu_user_list_choices_success(self, mock_context, basic_state, choice_key, expected_menu, mock_media_search_result):
"""Test successful user list navigation for authenticated users."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS[choice_key])
self.setup_media_list_success(mock_context, mock_media_search_result)
result = main(mock_context, basic_state)
self.assert_menu_transition(result, expected_menu)
self.assert_console_cleared()
# Verify API was called
mock_context.media_api.get_user_media_list.assert_called_once()
@pytest.mark.parametrize("choice_key", [
"watching",
"planned",
"completed",
"paused",
"dropped",
"rewatching",
])
def test_main_menu_user_list_choices_failure(self, mock_context, basic_state, choice_key):
"""Test failed user list fetch shows error and continues."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS[choice_key])
mock_context.media_api.get_user_media_list.return_value = None
result = main(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to fetch data")
def test_main_menu_random_choice_success(self, mock_context, basic_state, mock_media_search_result):
"""Test random anime selection success."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['random'])
self.setup_media_list_success(mock_context, mock_media_search_result)
with patch('random.choice') as mock_random:
mock_random.return_value = "Action" # Mock random genre selection
result = main(mock_context, basic_state)
self.assert_menu_transition(result, "RESULTS")
self.assert_console_cleared()
mock_context.media_api.search_media.assert_called_once()
def test_main_menu_random_choice_failure(self, mock_context, basic_state):
"""Test random anime selection failure."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['random'])
self.setup_media_list_failure(mock_context)
with patch('random.choice') as mock_random:
mock_random.return_value = "Action"
result = main(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to fetch data")
def test_main_menu_search_choice_success(self, mock_context, basic_state, mock_media_search_result):
"""Test search functionality success."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['search'])
self.setup_selector_input(mock_context, "test anime")
self.setup_media_list_success(mock_context, mock_media_search_result)
result = main(mock_context, basic_state)
self.assert_menu_transition(result, "RESULTS")
self.assert_console_cleared()
mock_context.selector.input.assert_called_once()
mock_context.media_api.search_media.assert_called_once()
def test_main_menu_search_choice_empty_query(self, mock_context, basic_state):
"""Test search with empty query continues."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['search'])
self.setup_selector_input(mock_context, "") # Empty search query
result = main(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_context.selector.input.assert_called_once()
# API should not be called with empty query
mock_context.media_api.search_media.assert_not_called()
def test_main_menu_search_choice_failure(self, mock_context, basic_state):
"""Test search functionality failure."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['search'])
self.setup_selector_input(mock_context, "test anime")
self.setup_media_list_failure(mock_context)
result = main(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to fetch data")
def test_main_menu_icons_disabled(self, mock_context, basic_state):
"""Test menu display with icons disabled."""
mock_context.config.general.icons = False
self.setup_selector_choice(mock_context, None) # Exit immediately
result = main(mock_context, basic_state)
self.assert_exit_behavior(result)
# Verify selector was called with non-icon options
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Verify no emoji icons in choices
for choice in choices:
assert not any(char in choice for char in '🔥✨💖💯🎬🔔🎲🔎📺📑✅⏸️🚮🔁📖🔐🔧📝❌')
def test_main_menu_authenticated_user_header(self, mock_context, basic_state, mock_user_profile):
"""Test that authenticated user info appears in header."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, None) # Exit immediately
result = main(mock_context, basic_state)
self.assert_exit_behavior(result)
# Verify selector was called with header containing user info
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
header = call_args[1]['header']
assert mock_user_profile.name in header
def test_main_menu_unauthenticated_user_header(self, mock_unauthenticated_context, basic_state):
"""Test that unauthenticated user gets appropriate header."""
self.setup_selector_choice(mock_unauthenticated_context, None) # Exit immediately
result = main(mock_unauthenticated_context, basic_state)
self.assert_exit_behavior(result)
# Verify selector was called with appropriate header
mock_unauthenticated_context.selector.choose.assert_called_once()
call_args = mock_unauthenticated_context.selector.choose.call_args
header = call_args[1]['header']
assert "Not authenticated" in header or "FastAnime Main Menu" in header
def test_main_menu_user_list_authentication_required(self, mock_unauthenticated_context, basic_state):
"""Test that user list options require authentication."""
# Test that user list options either don't appear or show auth error
self.setup_selector_choice(mock_unauthenticated_context, TEST_MENU_OPTIONS['watching'])
# This should either not be available or show an auth error
with patch('fastanime.cli.utils.auth_utils.check_authentication_required') as mock_auth_check:
mock_auth_check.return_value = False # Auth required but not authenticated
result = main(mock_unauthenticated_context, basic_state)
# Should continue (show error) or redirect to auth
assert isinstance(result, (ControlFlow, State))
@pytest.mark.parametrize("media_list_size", [0, 1, 5, 20])
def test_main_menu_various_result_sizes(self, mock_context, basic_state, media_list_size):
"""Test handling of various media list result sizes."""
self.setup_selector_choice(mock_context, TEST_MENU_OPTIONS['trending'])
if media_list_size == 0:
# Empty result
mock_result = MediaSearchResult(media=[], page_info={"total": 0, "current_page": 1, "last_page": 1, "has_next_page": False, "per_page": 20})
else:
mock_result = self.create_mock_media_result(media_list_size)
self.setup_media_list_success(mock_context, mock_result)
result = main(mock_context, basic_state)
if media_list_size == 0:
# Empty results might show a message and continue
assert isinstance(result, (State, ControlFlow))
else:
self.assert_menu_transition(result, "RESULTS")

View File

@@ -0,0 +1,360 @@
"""
Tests for the media actions menu.
Tests anime-specific actions like adding to list, searching providers, etc.
"""
import pytest
from unittest.mock import Mock, patch
from fastanime.cli.interactive.menus.media_actions import media_actions
from fastanime.cli.interactive.state import State, ControlFlow, MediaApiState
from .base_test import BaseMenuTest, MediaMenuTestMixin
class TestMediaActionsMenu(BaseMenuTest, MediaMenuTestMixin):
"""Test cases for the media actions menu."""
def test_media_actions_no_anime_goes_back(self, mock_context, basic_state):
"""Test that missing anime data returns BACK."""
# State with no anime data
state_no_anime = State(
menu_name="MEDIA_ACTIONS",
media_api=MediaApiState(anime=None)
)
result = media_actions(mock_context, state_no_anime)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_media_actions_no_choice_goes_back(self, mock_context, state_with_media_data):
"""Test that no choice selected results in BACK."""
self.setup_selector_choice(mock_context, None)
result = media_actions(mock_context, state_with_media_data)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_media_actions_back_choice(self, mock_context, state_with_media_data):
"""Test explicit back choice."""
self.setup_selector_choice(mock_context, "↩️ Back")
result = media_actions(mock_context, state_with_media_data)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_media_actions_search_providers(self, mock_context, state_with_media_data):
"""Test searching providers for the anime."""
self.setup_selector_choice(mock_context, "🔍 Search Providers")
result = media_actions(mock_context, state_with_media_data)
self.assert_menu_transition(result, "PROVIDER_SEARCH")
self.assert_console_cleared()
def test_media_actions_add_to_list_authenticated(self, mock_context, state_with_media_data, mock_user_profile):
"""Test adding anime to list when authenticated."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, " Add to List")
# Mock status selection
with patch.object(mock_context.selector, 'choose', side_effect=["WATCHING"]):
mock_context.media_api.update_list_entry.return_value = True
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify list update was attempted
mock_context.media_api.update_list_entry.assert_called_once()
self.assert_feedback_success_called("Added to list")
def test_media_actions_add_to_list_unauthenticated(self, mock_unauthenticated_context, state_with_media_data):
"""Test adding anime to list when not authenticated."""
self.setup_selector_choice(mock_unauthenticated_context, " Add to List")
result = media_actions(mock_unauthenticated_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Authentication required")
def test_media_actions_update_list_entry(self, mock_context, state_with_media_data, mock_user_profile):
"""Test updating existing list entry."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, "✏️ Update List Entry")
# Mock current status and new status selection
with patch.object(mock_context.selector, 'choose', side_effect=["COMPLETED"]):
mock_context.media_api.update_list_entry.return_value = True
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify list update was attempted
mock_context.media_api.update_list_entry.assert_called_once()
self.assert_feedback_success_called("List entry updated")
def test_media_actions_remove_from_list(self, mock_context, state_with_media_data, mock_user_profile):
"""Test removing anime from list."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, "🗑️ Remove from List")
self.setup_feedback_confirm(True) # Confirm removal
mock_context.media_api.delete_list_entry.return_value = True
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify removal was attempted
mock_context.media_api.delete_list_entry.assert_called_once()
self.assert_feedback_success_called("Removed from list")
def test_media_actions_remove_from_list_cancelled(self, mock_context, state_with_media_data, mock_user_profile):
"""Test cancelled removal from list."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, "🗑️ Remove from List")
self.setup_feedback_confirm(False) # Cancel removal
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify removal was not attempted
mock_context.media_api.delete_list_entry.assert_not_called()
self.assert_feedback_info_called("Removal cancelled")
def test_media_actions_view_details(self, mock_context, state_with_media_data):
"""Test viewing anime details."""
self.setup_selector_choice(mock_context, "📋 View Details")
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Should display details and pause for user
self.mock_feedback.pause_for_user.assert_called_once()
def test_media_actions_view_characters(self, mock_context, state_with_media_data):
"""Test viewing anime characters."""
self.setup_selector_choice(mock_context, "👥 View Characters")
# Mock character data
mock_characters = [
{"name": "Character 1", "role": "MAIN"},
{"name": "Character 2", "role": "SUPPORTING"}
]
mock_context.media_api.get_anime_characters.return_value = mock_characters
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify characters were fetched
mock_context.media_api.get_anime_characters.assert_called_once()
self.mock_feedback.pause_for_user.assert_called_once()
def test_media_actions_view_staff(self, mock_context, state_with_media_data):
"""Test viewing anime staff."""
self.setup_selector_choice(mock_context, "🎬 View Staff")
# Mock staff data
mock_staff = [
{"name": "Director Name", "role": "Director"},
{"name": "Studio Name", "role": "Studio"}
]
mock_context.media_api.get_anime_staff.return_value = mock_staff
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify staff were fetched
mock_context.media_api.get_anime_staff.assert_called_once()
self.mock_feedback.pause_for_user.assert_called_once()
def test_media_actions_view_reviews(self, mock_context, state_with_media_data):
"""Test viewing anime reviews."""
self.setup_selector_choice(mock_context, "⭐ View Reviews")
# Mock review data
mock_reviews = [
{"author": "User1", "rating": 9, "summary": "Great anime!"},
{"author": "User2", "rating": 7, "summary": "Pretty good."}
]
mock_context.media_api.get_anime_reviews.return_value = mock_reviews
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify reviews were fetched
mock_context.media_api.get_anime_reviews.assert_called_once()
self.mock_feedback.pause_for_user.assert_called_once()
def test_media_actions_view_recommendations(self, mock_context, state_with_media_data):
"""Test viewing anime recommendations."""
self.setup_selector_choice(mock_context, "💡 View Recommendations")
# Mock recommendation data
mock_recommendations = self.create_mock_media_result(3)
mock_context.media_api.get_anime_recommendations.return_value = mock_recommendations
result = media_actions(mock_context, state_with_media_data)
self.assert_menu_transition(result, "RESULTS")
self.assert_console_cleared()
# Verify recommendations were fetched
mock_context.media_api.get_anime_recommendations.assert_called_once()
def test_media_actions_set_progress(self, mock_context, state_with_media_data, mock_user_profile):
"""Test setting anime progress."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, "📊 Set Progress")
self.setup_selector_input(mock_context, "5") # Episode 5
mock_context.media_api.update_list_entry.return_value = True
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify progress update was attempted
mock_context.media_api.update_list_entry.assert_called_once()
self.assert_feedback_success_called("Progress updated")
def test_media_actions_set_score(self, mock_context, state_with_media_data, mock_user_profile):
"""Test setting anime score."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, "🌟 Set Score")
self.setup_selector_input(mock_context, "8") # Score of 8
mock_context.media_api.update_list_entry.return_value = True
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify score update was attempted
mock_context.media_api.update_list_entry.assert_called_once()
self.assert_feedback_success_called("Score updated")
def test_media_actions_open_external_links(self, mock_context, state_with_media_data):
"""Test opening external links."""
self.setup_selector_choice(mock_context, "🔗 External Links")
# Mock external links submenu
with patch.object(mock_context.selector, 'choose', side_effect=["AniList Page"]):
with patch('webbrowser.open') as mock_browser:
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify browser was opened
mock_browser.assert_called_once()
def test_media_actions_icons_disabled(self, mock_context, state_with_media_data):
"""Test menu display with icons disabled."""
mock_context.config.general.icons = False
self.setup_selector_choice(mock_context, None)
result = media_actions(mock_context, state_with_media_data)
self.assert_back_behavior(result)
# Verify options don't contain emoji icons
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
for choice in choices:
assert not any(char in choice for char in '🔍➕✏️🗑️📋👥🎬⭐💡📊🌟🔗↩️')
def test_media_actions_api_failures(self, mock_context, state_with_media_data, mock_user_profile):
"""Test handling of API failures."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, " Add to List")
# Mock API failure
mock_context.media_api.update_list_entry.return_value = False
with patch.object(mock_context.selector, 'choose', side_effect=["WATCHING"]):
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to update list")
def test_media_actions_invalid_input_handling(self, mock_context, state_with_media_data, mock_user_profile):
"""Test handling of invalid user input."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, "📊 Set Progress")
self.setup_selector_input(mock_context, "invalid") # Invalid progress
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Invalid progress")
@pytest.mark.parametrize("list_status", ["WATCHING", "COMPLETED", "PLANNING", "PAUSED", "DROPPED"])
def test_media_actions_various_list_statuses(self, mock_context, state_with_media_data, mock_user_profile, list_status):
"""Test adding anime to list with various statuses."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, " Add to List")
with patch.object(mock_context.selector, 'choose', side_effect=[list_status]):
mock_context.media_api.update_list_entry.return_value = True
result = media_actions(mock_context, state_with_media_data)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify the status was used
call_args = mock_context.media_api.update_list_entry.call_args
assert list_status in str(call_args)
def test_media_actions_anime_details_display(self, mock_context, state_with_media_data, mock_media_item):
"""Test anime details are properly displayed in header."""
self.setup_selector_choice(mock_context, None)
result = media_actions(mock_context, state_with_media_data)
self.assert_back_behavior(result)
# Verify anime details appear in header
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
header = call_args[1].get('header', '')
assert mock_media_item.title in header
def test_media_actions_authentication_status_context(self, mock_unauthenticated_context, state_with_media_data):
"""Test that authentication status affects available options."""
self.setup_selector_choice(mock_unauthenticated_context, None)
result = media_actions(mock_unauthenticated_context, state_with_media_data)
self.assert_back_behavior(result)
# Verify authentication-dependent options are handled appropriately
mock_unauthenticated_context.selector.choose.assert_called_once()
call_args = mock_unauthenticated_context.selector.choose.call_args
choices = call_args[1]['choices']
# List management options should either not appear or show auth prompts
list_actions = [c for c in choices if any(action in c for action in ["Add to List", "Update List", "Remove from List"])]
# These should either be absent or handled with auth checks

View File

@@ -0,0 +1,346 @@
"""
Tests for the results menu.
Tests anime result display, pagination, and selection.
"""
import pytest
from unittest.mock import Mock, patch
from fastanime.cli.interactive.menus.results import results
from fastanime.cli.interactive.state import State, ControlFlow, MediaApiState
from .base_test import BaseMenuTest, MediaMenuTestMixin
class TestResultsMenu(BaseMenuTest, MediaMenuTestMixin):
"""Test cases for the results menu."""
def test_results_menu_no_results_goes_back(self, mock_context, basic_state):
"""Test that no results returns BACK."""
# State with no search results
state_no_results = State(
menu_name="RESULTS",
media_api=MediaApiState(search_results=None)
)
result = results(mock_context, state_no_results)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_results_menu_empty_results_goes_back(self, mock_context, basic_state):
"""Test that empty results returns BACK."""
# State with empty search results
from fastanime.libs.api.types import MediaSearchResult
empty_results = MediaSearchResult(
media=[],
page_info={"total": 0, "current_page": 1, "last_page": 1, "has_next_page": False, "per_page": 20}
)
state_empty = State(
menu_name="RESULTS",
media_api=MediaApiState(search_results=empty_results)
)
result = results(mock_context, state_empty)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_results_menu_no_choice_goes_back(self, mock_context, state_with_media_data):
"""Test that no choice selected results in BACK."""
self.setup_selector_choice(mock_context, None)
result = results(mock_context, state_with_media_data)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_results_menu_back_choice(self, mock_context, state_with_media_data):
"""Test explicit back choice."""
self.setup_selector_choice(mock_context, "↩️ Back")
result = results(mock_context, state_with_media_data)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_results_menu_anime_selection(self, mock_context, state_with_media_data, mock_media_item):
"""Test selecting an anime transitions to media actions."""
# Mock formatted anime title choice
formatted_title = f"{mock_media_item.title} ({mock_media_item.status})"
self.setup_selector_choice(mock_context, formatted_title)
with patch('fastanime.cli.interactive.menus.results._format_anime_choice', return_value=formatted_title):
result = results(mock_context, state_with_media_data)
self.assert_menu_transition(result, "MEDIA_ACTIONS")
self.assert_console_cleared()
# Verify the selected anime is stored in the new state
assert result.media_api.anime == mock_media_item
def test_results_menu_next_page_navigation(self, mock_context, mock_media_search_result):
"""Test next page navigation."""
# Create results with next page available
mock_media_search_result.page_info["has_next_page"] = True
mock_media_search_result.page_info["current_page"] = 1
state_with_pagination = State(
menu_name="RESULTS",
media_api=MediaApiState(
search_results=mock_media_search_result,
original_api_params=Mock()
)
)
self.setup_selector_choice(mock_context, "➡️ Next Page (Page 2)")
mock_context.media_api.search_media.return_value = mock_media_search_result
result = results(mock_context, state_with_pagination)
self.assert_menu_transition(result, "RESULTS")
self.assert_console_cleared()
# Verify API was called for next page
mock_context.media_api.search_media.assert_called_once()
def test_results_menu_previous_page_navigation(self, mock_context, mock_media_search_result):
"""Test previous page navigation."""
# Create results with previous page available
mock_media_search_result.page_info["has_next_page"] = False
mock_media_search_result.page_info["current_page"] = 2
state_with_pagination = State(
menu_name="RESULTS",
media_api=MediaApiState(
search_results=mock_media_search_result,
original_api_params=Mock()
)
)
self.setup_selector_choice(mock_context, "⬅️ Previous Page (Page 1)")
mock_context.media_api.search_media.return_value = mock_media_search_result
result = results(mock_context, state_with_pagination)
self.assert_menu_transition(result, "RESULTS")
self.assert_console_cleared()
# Verify API was called for previous page
mock_context.media_api.search_media.assert_called_once()
def test_results_menu_pagination_failure(self, mock_context, mock_media_search_result):
"""Test pagination request failure."""
mock_media_search_result.page_info["has_next_page"] = True
mock_media_search_result.page_info["current_page"] = 1
state_with_pagination = State(
menu_name="RESULTS",
media_api=MediaApiState(
search_results=mock_media_search_result,
original_api_params=Mock()
)
)
self.setup_selector_choice(mock_context, "➡️ Next Page (Page 2)")
mock_context.media_api.search_media.return_value = None # Pagination fails
result = results(mock_context, state_with_pagination)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to load")
def test_results_menu_icons_disabled(self, mock_context, state_with_media_data):
"""Test menu display with icons disabled."""
mock_context.config.general.icons = False
self.setup_selector_choice(mock_context, None)
result = results(mock_context, state_with_media_data)
self.assert_back_behavior(result)
# Verify options don't contain emoji icons
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Navigation choices should not have emoji
navigation_choices = [choice for choice in choices if "Page" in choice or "Back" in choice]
for choice in navigation_choices:
assert not any(char in choice for char in '➡️⬅️↩️')
def test_results_menu_preview_enabled(self, mock_context, state_with_media_data):
"""Test that preview is set up when enabled."""
mock_context.config.general.preview = "image"
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.previews.get_anime_preview') as mock_preview:
mock_preview.return_value = "preview_command"
result = results(mock_context, state_with_media_data)
self.assert_back_behavior(result)
# Verify preview was set up
mock_preview.assert_called_once()
def test_results_menu_preview_disabled(self, mock_context, state_with_media_data):
"""Test that preview is not set up when disabled."""
mock_context.config.general.preview = "none"
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.previews.get_anime_preview') as mock_preview:
result = results(mock_context, state_with_media_data)
self.assert_back_behavior(result)
# Verify preview was not set up
mock_preview.assert_not_called()
def test_results_menu_new_search_option(self, mock_context, state_with_media_data):
"""Test new search option."""
self.setup_selector_choice(mock_context, "🔍 New Search")
result = results(mock_context, state_with_media_data)
self.assert_menu_transition(result, "PROVIDER_SEARCH")
self.assert_console_cleared()
def test_results_menu_sort_and_filter_option(self, mock_context, state_with_media_data):
"""Test sort and filter option."""
self.setup_selector_choice(mock_context, "🔧 Sort & Filter")
result = results(mock_context, state_with_media_data)
self.assert_continue_behavior(result) # Usually shows sort/filter submenu
self.assert_console_cleared()
@pytest.mark.parametrize("num_results", [1, 5, 20, 50])
def test_results_menu_various_result_counts(self, mock_context, basic_state, num_results):
"""Test handling of various result counts."""
mock_result = self.create_mock_media_result(num_results)
state_with_results = State(
menu_name="RESULTS",
media_api=MediaApiState(search_results=mock_result)
)
self.setup_selector_choice(mock_context, None)
result = results(mock_context, state_with_results)
if num_results > 0:
self.assert_back_behavior(result)
# Verify choices include all anime titles
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Should have anime choices plus navigation options
assert len([c for c in choices if "Page" not in c and "Back" not in c and "Search" not in c]) >= num_results
else:
self.assert_back_behavior(result)
def test_results_menu_pagination_edge_cases(self, mock_context, mock_media_search_result):
"""Test pagination edge cases (first page, last page)."""
# Test first page (no previous page option)
mock_media_search_result.page_info["current_page"] = 1
mock_media_search_result.page_info["has_next_page"] = True
state_first_page = State(
menu_name="RESULTS",
media_api=MediaApiState(search_results=mock_media_search_result)
)
self.setup_selector_choice(mock_context, None)
result = results(mock_context, state_first_page)
self.assert_back_behavior(result)
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Should have next page but no previous page
assert any("Next Page" in choice for choice in choices)
assert not any("Previous Page" in choice for choice in choices)
def test_results_menu_last_page(self, mock_context, mock_media_search_result):
"""Test last page (no next page option)."""
mock_media_search_result.page_info["current_page"] = 5
mock_media_search_result.page_info["has_next_page"] = False
state_last_page = State(
menu_name="RESULTS",
media_api=MediaApiState(search_results=mock_media_search_result)
)
self.setup_selector_choice(mock_context, None)
result = results(mock_context, state_last_page)
self.assert_back_behavior(result)
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Should have previous page but no next page
assert any("Previous Page" in choice for choice in choices)
assert not any("Next Page" in choice for choice in choices)
def test_results_menu_anime_formatting(self, mock_context, state_with_media_data, mock_media_item):
"""Test anime choice formatting."""
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.interactive.menus.results._format_anime_choice') as mock_format:
expected_format = f"{mock_media_item.title} ({mock_media_item.status}) - Score: {mock_media_item.mean_score}"
mock_format.return_value = expected_format
result = results(mock_context, state_with_media_data)
self.assert_back_behavior(result)
# Verify formatting function was called
mock_format.assert_called_once()
def test_results_menu_auth_status_in_header(self, mock_context, state_with_media_data, mock_user_profile):
"""Test that auth status appears in header."""
mock_context.media_api.user_profile = mock_user_profile
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.auth_utils.get_auth_status_indicator') as mock_auth_status:
mock_auth_status.return_value = f"👤 {mock_user_profile.name}"
result = results(mock_context, state_with_media_data)
self.assert_back_behavior(result)
# Verify auth status was included
mock_auth_status.assert_called_once()
def test_results_menu_error_handling_during_selection(self, mock_context, state_with_media_data):
"""Test error handling during anime selection."""
self.setup_selector_choice(mock_context, "Invalid Choice")
result = results(mock_context, state_with_media_data)
# Should handle invalid choice gracefully
assert isinstance(result, (State, ControlFlow))
self.assert_console_cleared()
def test_results_menu_user_list_context(self, mock_context, mock_media_search_result):
"""Test results from user list context."""
# State indicating results came from user list
state_user_list = State(
menu_name="RESULTS",
media_api=MediaApiState(
search_results=mock_media_search_result,
search_results_type="USER_MEDIA_LIST",
user_media_status="WATCHING"
)
)
self.setup_selector_choice(mock_context, None)
result = results(mock_context, state_user_list)
self.assert_back_behavior(result)
# Header should indicate this is a user list
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
header = call_args[1].get('header', '')
# Should contain user list context information

View File

@@ -0,0 +1,380 @@
"""
Tests for the session management menu.
Tests saving, loading, and managing session state.
"""
import pytest
from unittest.mock import Mock, patch
from pathlib import Path
from datetime import datetime
from fastanime.cli.interactive.menus.session_management import session_management
from fastanime.cli.interactive.state import State, ControlFlow
from .base_test import BaseMenuTest, SessionMenuTestMixin
class TestSessionManagementMenu(BaseMenuTest, SessionMenuTestMixin):
"""Test cases for the session management menu."""
@pytest.fixture
def mock_session_manager(self):
"""Create a mock session manager."""
return self.setup_session_manager_mock()
def test_session_menu_no_choice_goes_back(self, mock_context, basic_state):
"""Test that no choice selected results in BACK."""
self.setup_selector_choice(mock_context, None)
result = session_management(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_session_menu_back_choice(self, mock_context, basic_state):
"""Test explicit back choice."""
self.setup_selector_choice(mock_context, "↩️ Back to Main Menu")
result = session_management(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_session_menu_save_session_success(self, mock_context, basic_state):
"""Test successful session save."""
self.setup_selector_choice(mock_context, "💾 Save Current Session")
self.setup_selector_input(mock_context, "test_session")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.save.return_value = True
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify session save was attempted
mock_session.save.assert_called_once()
self.assert_feedback_success_called("Session saved")
def test_session_menu_save_session_failure(self, mock_context, basic_state):
"""Test failed session save."""
self.setup_selector_choice(mock_context, "💾 Save Current Session")
self.setup_selector_input(mock_context, "test_session")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.save.return_value = False
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to save session")
def test_session_menu_save_session_empty_name(self, mock_context, basic_state):
"""Test session save with empty name."""
self.setup_selector_choice(mock_context, "💾 Save Current Session")
self.setup_selector_input(mock_context, "") # Empty name
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_warning_called("Session name cannot be empty")
def test_session_menu_load_session_success(self, mock_context, basic_state):
"""Test successful session load."""
# Mock available sessions
mock_sessions = [
{"name": "session1", "file": "session1.json", "created": "2024-01-01"},
{"name": "session2", "file": "session2.json", "created": "2024-01-02"}
]
self.setup_selector_choice(mock_context, "📂 Load Saved Session")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.list_saved_sessions.return_value = mock_sessions
mock_session.resume.return_value = True
# Mock user selecting a session
with patch.object(mock_context.selector, 'choose', side_effect=["session1"]):
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_session.list_saved_sessions.assert_called_once()
mock_session.resume.assert_called_once()
self.assert_feedback_success_called("Session loaded")
def test_session_menu_load_session_no_sessions(self, mock_context, basic_state):
"""Test load session with no saved sessions."""
self.setup_selector_choice(mock_context, "📂 Load Saved Session")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.list_saved_sessions.return_value = []
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_info_called("No saved sessions found")
def test_session_menu_load_session_failure(self, mock_context, basic_state):
"""Test failed session load."""
mock_sessions = [{"name": "session1", "file": "session1.json"}]
self.setup_selector_choice(mock_context, "📂 Load Saved Session")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.list_saved_sessions.return_value = mock_sessions
mock_session.resume.return_value = False
# Mock user selecting a session
with patch.object(mock_context.selector, 'choose', side_effect=["session1"]):
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to load session")
def test_session_menu_delete_session_success(self, mock_context, basic_state):
"""Test successful session deletion."""
mock_sessions = [{"name": "session1", "file": "session1.json"}]
self.setup_selector_choice(mock_context, "🗑️ Delete Saved Session")
self.setup_feedback_confirm(True) # Confirm deletion
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.list_saved_sessions.return_value = mock_sessions
with patch.object(mock_context.selector, 'choose', side_effect=["session1"]):
with self.setup_path_exists_mock(True):
with patch('pathlib.Path.unlink') as mock_unlink:
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_unlink.assert_called_once()
self.assert_feedback_success_called("Session deleted")
def test_session_menu_delete_session_cancelled(self, mock_context, basic_state):
"""Test cancelled session deletion."""
mock_sessions = [{"name": "session1", "file": "session1.json"}]
self.setup_selector_choice(mock_context, "🗑️ Delete Saved Session")
self.setup_feedback_confirm(False) # Cancel deletion
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.list_saved_sessions.return_value = mock_sessions
with patch.object(mock_context.selector, 'choose', side_effect=["session1"]):
with patch('pathlib.Path.unlink') as mock_unlink:
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_unlink.assert_not_called()
self.assert_feedback_info_called("Deletion cancelled")
def test_session_menu_cleanup_old_sessions(self, mock_context, basic_state):
"""Test cleanup of old sessions."""
self.setup_selector_choice(mock_context, "🧹 Cleanup Old Sessions")
self.setup_feedback_confirm(True) # Confirm cleanup
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.cleanup_old_sessions.return_value = 5 # 5 sessions cleaned
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_session.cleanup_old_sessions.assert_called_once()
self.assert_feedback_success_called("Cleaned up 5 old sessions")
def test_session_menu_cleanup_cancelled(self, mock_context, basic_state):
"""Test cancelled cleanup."""
self.setup_selector_choice(mock_context, "🧹 Cleanup Old Sessions")
self.setup_feedback_confirm(False) # Cancel cleanup
with patch('fastanime.cli.interactive.session.session') as mock_session:
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_session.cleanup_old_sessions.assert_not_called()
self.assert_feedback_info_called("Cleanup cancelled")
def test_session_menu_view_session_stats(self, mock_context, basic_state):
"""Test viewing session statistics."""
self.setup_selector_choice(mock_context, "📊 View Session Statistics")
mock_stats = {
"current_states": 3,
"current_menu": "MAIN",
"auto_save_enabled": True,
"has_auto_save": False,
"has_crash_backup": False
}
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.get_session_stats.return_value = mock_stats
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_session.get_session_stats.assert_called_once()
self.mock_feedback.pause_for_user.assert_called_once()
def test_session_menu_toggle_auto_save_enable(self, mock_context, basic_state):
"""Test enabling auto-save."""
self.setup_selector_choice(mock_context, "⚙️ Toggle Auto-Save")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session._auto_save_enabled = False # Currently disabled
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_session.enable_auto_save.assert_called_once_with(True)
self.assert_feedback_success_called("Auto-save enabled")
def test_session_menu_toggle_auto_save_disable(self, mock_context, basic_state):
"""Test disabling auto-save."""
self.setup_selector_choice(mock_context, "⚙️ Toggle Auto-Save")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session._auto_save_enabled = True # Currently enabled
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_session.enable_auto_save.assert_called_once_with(False)
self.assert_feedback_success_called("Auto-save disabled")
def test_session_menu_create_manual_backup(self, mock_context, basic_state):
"""Test creating manual backup."""
self.setup_selector_choice(mock_context, "💿 Create Manual Backup")
self.setup_selector_input(mock_context, "my_backup")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.create_manual_backup.return_value = True
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
mock_session.create_manual_backup.assert_called_once_with("my_backup")
self.assert_feedback_success_called("Manual backup created")
def test_session_menu_create_manual_backup_failure(self, mock_context, basic_state):
"""Test failed manual backup creation."""
self.setup_selector_choice(mock_context, "💿 Create Manual Backup")
self.setup_selector_input(mock_context, "my_backup")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.create_manual_backup.return_value = False
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to create backup")
def test_session_menu_icons_disabled(self, mock_context, basic_state):
"""Test menu display with icons disabled."""
mock_context.config.general.icons = False
self.setup_selector_choice(mock_context, None)
result = session_management(mock_context, basic_state)
self.assert_back_behavior(result)
# Verify options don't contain emoji icons
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
for choice in choices:
assert not any(char in choice for char in '💾📂🗑️🧹📊⚙️💿↩️')
def test_session_menu_file_operations_with_invalid_paths(self, mock_context, basic_state):
"""Test handling of invalid file paths during operations."""
self.setup_selector_choice(mock_context, "🗑️ Delete Saved Session")
# Mock a session with invalid path
mock_sessions = [{"name": "session1", "file": "/invalid/path/session1.json"}]
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.list_saved_sessions.return_value = mock_sessions
with patch.object(mock_context.selector, 'choose', side_effect=["session1"]):
with self.setup_path_exists_mock(False): # File doesn't exist
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_feedback_error_called("Session file not found")
@pytest.mark.parametrize("session_count", [0, 1, 5, 10])
def test_session_menu_various_session_counts(self, mock_context, basic_state, session_count):
"""Test handling of various numbers of saved sessions."""
self.setup_selector_choice(mock_context, "📂 Load Saved Session")
# Create mock sessions
mock_sessions = []
for i in range(session_count):
mock_sessions.append({
"name": f"session{i+1}",
"file": f"session{i+1}.json",
"created": f"2024-01-0{i+1 if i < 9 else '10'}"
})
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.list_saved_sessions.return_value = mock_sessions
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
if session_count == 0:
self.assert_feedback_info_called("No saved sessions found")
else:
# Should display sessions for selection
mock_context.selector.choose.assert_called()
def test_session_menu_save_with_special_characters(self, mock_context, basic_state):
"""Test session save with special characters in name."""
self.setup_selector_choice(mock_context, "💾 Save Current Session")
self.setup_selector_input(mock_context, "test/session:with*special?chars")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.save.return_value = True
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
# Should handle special characters appropriately
mock_session.save.assert_called_once()
def test_session_menu_exception_handling(self, mock_context, basic_state):
"""Test handling of unexpected exceptions."""
self.setup_selector_choice(mock_context, "💾 Save Current Session")
self.setup_selector_input(mock_context, "test_session")
with patch('fastanime.cli.interactive.session.session') as mock_session:
mock_session.save.side_effect = Exception("Unexpected error")
result = session_management(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_feedback_error_called("An error occurred")

View File

@@ -0,0 +1,416 @@
"""
Tests for the watch history menu.
Tests local watch history display, navigation, and management.
"""
import pytest
from unittest.mock import Mock, patch
from datetime import datetime
from fastanime.cli.interactive.menus.watch_history import watch_history
from fastanime.cli.interactive.state import State, ControlFlow
from .base_test import BaseMenuTest
class TestWatchHistoryMenu(BaseMenuTest):
"""Test cases for the watch history menu."""
@pytest.fixture
def mock_watch_history_entries(self):
"""Create mock watch history entries."""
return [
{
"anime_title": "Test Anime 1",
"episode": "5",
"timestamp": datetime.now().isoformat(),
"provider": "test_provider",
"anilist_id": 12345
},
{
"anime_title": "Test Anime 2",
"episode": "12",
"timestamp": datetime.now().isoformat(),
"provider": "test_provider",
"anilist_id": 67890
},
{
"anime_title": "Test Anime 3",
"episode": "1",
"timestamp": datetime.now().isoformat(),
"provider": "test_provider",
"anilist_id": 11111
}
]
def test_watch_history_menu_no_choice_goes_back(self, mock_context, basic_state):
"""Test that no choice selected results in BACK."""
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = []
result = watch_history(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_watch_history_menu_back_choice(self, mock_context, basic_state):
"""Test explicit back choice."""
self.setup_selector_choice(mock_context, "↩️ Back to Main Menu")
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = []
result = watch_history(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
def test_watch_history_menu_empty_history(self, mock_context, basic_state):
"""Test display when watch history is empty."""
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = []
result = watch_history(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
self.assert_feedback_info_called("No watch history found")
def test_watch_history_menu_with_entries(self, mock_context, basic_state, mock_watch_history_entries):
"""Test display with watch history entries."""
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
result = watch_history(mock_context, basic_state)
self.assert_back_behavior(result)
self.assert_console_cleared()
# Verify history was retrieved
mock_get_history.assert_called_once()
# Verify entries are displayed in selector
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Should have entries plus management options
history_choices = [c for c in choices if any(anime["anime_title"] in c for anime in mock_watch_history_entries)]
assert len(history_choices) == len(mock_watch_history_entries)
def test_watch_history_menu_continue_watching(self, mock_context, basic_state, mock_watch_history_entries):
"""Test continuing to watch from history entry."""
entry_choice = f"Test Anime 1 - Episode 5"
self.setup_selector_choice(mock_context, entry_choice)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
# Mock API search for the anime
mock_context.media_api.search_media.return_value = Mock()
result = watch_history(mock_context, basic_state)
self.assert_menu_transition(result, "RESULTS")
self.assert_console_cleared()
# Verify API search was called
mock_context.media_api.search_media.assert_called_once()
def test_watch_history_menu_clear_history_success(self, mock_context, basic_state, mock_watch_history_entries):
"""Test successful history clearing."""
self.setup_selector_choice(mock_context, "🗑️ Clear All History")
self.setup_feedback_confirm(True) # Confirm clearing
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
with patch('fastanime.cli.utils.watch_history_manager.clear_watch_history') as mock_clear:
mock_clear.return_value = True
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify confirmation was requested
self.mock_feedback.confirm.assert_called_once()
# Verify history was cleared
mock_clear.assert_called_once()
self.assert_feedback_success_called("Watch history cleared")
def test_watch_history_menu_clear_history_cancelled(self, mock_context, basic_state, mock_watch_history_entries):
"""Test cancelled history clearing."""
self.setup_selector_choice(mock_context, "🗑️ Clear All History")
self.setup_feedback_confirm(False) # Cancel clearing
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
with patch('fastanime.cli.utils.watch_history_manager.clear_watch_history') as mock_clear:
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify confirmation was requested
self.mock_feedback.confirm.assert_called_once()
# Verify history was not cleared
mock_clear.assert_not_called()
self.assert_feedback_info_called("Clear cancelled")
def test_watch_history_menu_clear_history_failure(self, mock_context, basic_state, mock_watch_history_entries):
"""Test failed history clearing."""
self.setup_selector_choice(mock_context, "🗑️ Clear All History")
self.setup_feedback_confirm(True) # Confirm clearing
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
with patch('fastanime.cli.utils.watch_history_manager.clear_watch_history') as mock_clear:
mock_clear.return_value = False
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("Failed to clear history")
def test_watch_history_menu_export_history(self, mock_context, basic_state, mock_watch_history_entries):
"""Test exporting watch history."""
self.setup_selector_choice(mock_context, "📤 Export History")
self.setup_selector_input(mock_context, "/path/to/export.json")
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
with patch('fastanime.cli.utils.watch_history_manager.export_watch_history') as mock_export:
mock_export.return_value = True
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify export was attempted
mock_export.assert_called_once()
self.assert_feedback_success_called("History exported")
def test_watch_history_menu_import_history(self, mock_context, basic_state):
"""Test importing watch history."""
self.setup_selector_choice(mock_context, "📥 Import History")
self.setup_selector_input(mock_context, "/path/to/import.json")
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = []
with patch('fastanime.cli.utils.watch_history_manager.import_watch_history') as mock_import:
mock_import.return_value = True
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify import was attempted
mock_import.assert_called_once()
self.assert_feedback_success_called("History imported")
def test_watch_history_menu_remove_single_entry(self, mock_context, basic_state, mock_watch_history_entries):
"""Test removing a single history entry."""
self.setup_selector_choice(mock_context, "🗑️ Remove Entry")
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
# Mock user selecting entry to remove
with patch.object(mock_context.selector, 'choose', side_effect=["Test Anime 1 - Episode 5"]):
with patch('fastanime.cli.utils.watch_history_manager.remove_watch_history_entry') as mock_remove:
mock_remove.return_value = True
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify removal was attempted
mock_remove.assert_called_once()
self.assert_feedback_success_called("Entry removed")
def test_watch_history_menu_search_history(self, mock_context, basic_state, mock_watch_history_entries):
"""Test searching through watch history."""
self.setup_selector_choice(mock_context, "🔍 Search History")
self.setup_selector_input(mock_context, "Test Anime 1")
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
with patch('fastanime.cli.utils.watch_history_manager.search_watch_history') as mock_search:
filtered_entries = [mock_watch_history_entries[0]] # Only first entry matches
mock_search.return_value = filtered_entries
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Verify search was performed
mock_search.assert_called_once_with("Test Anime 1")
def test_watch_history_menu_sort_by_date(self, mock_context, basic_state, mock_watch_history_entries):
"""Test sorting history by date."""
self.setup_selector_choice(mock_context, "📅 Sort by Date")
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Should re-display with sorted entries
def test_watch_history_menu_sort_by_anime_title(self, mock_context, basic_state, mock_watch_history_entries):
"""Test sorting history by anime title."""
self.setup_selector_choice(mock_context, "🔤 Sort by Title")
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
# Should re-display with sorted entries
def test_watch_history_menu_icons_disabled(self, mock_context, basic_state, mock_watch_history_entries):
"""Test menu display with icons disabled."""
mock_context.config.general.icons = False
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
result = watch_history(mock_context, basic_state)
self.assert_back_behavior(result)
# Verify options don't contain emoji icons
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
for choice in choices:
assert not any(char in choice for char in '🗑️📤📥🔍📅🔤↩️')
def test_watch_history_menu_large_history(self, mock_context, basic_state):
"""Test handling of large watch history."""
# Create large history (100 entries)
large_history = []
for i in range(100):
large_history.append({
"anime_title": f"Test Anime {i}",
"episode": f"{i % 12 + 1}",
"timestamp": datetime.now().isoformat(),
"provider": "test_provider",
"anilist_id": 10000 + i
})
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = large_history
result = watch_history(mock_context, basic_state)
self.assert_back_behavior(result)
# Should handle large history gracefully
mock_context.selector.choose.assert_called_once()
def test_watch_history_menu_entry_formatting(self, mock_context, basic_state, mock_watch_history_entries):
"""Test proper formatting of history entries."""
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
result = watch_history(mock_context, basic_state)
self.assert_back_behavior(result)
# Verify entries are formatted with title and episode
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Check that anime titles and episodes appear in choices
for entry in mock_watch_history_entries:
title_found = any(entry["anime_title"] in choice for choice in choices)
episode_found = any(f"Episode {entry['episode']}" in choice for choice in choices)
assert title_found and episode_found
def test_watch_history_menu_provider_context(self, mock_context, basic_state, mock_watch_history_entries):
"""Test that provider context is included in entries."""
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = mock_watch_history_entries
result = watch_history(mock_context, basic_state)
self.assert_back_behavior(result)
# Should include provider information
mock_context.selector.choose.assert_called_once()
call_args = mock_context.selector.choose.call_args
choices = call_args[1]['choices']
# Provider info might be shown in choices or header
header = call_args[1].get('header', '')
# Provider context should be available somewhere
@pytest.mark.parametrize("history_size", [0, 1, 5, 50, 100])
def test_watch_history_menu_various_sizes(self, mock_context, basic_state, history_size):
"""Test handling of various history sizes."""
history_entries = []
for i in range(history_size):
history_entries.append({
"anime_title": f"Test Anime {i}",
"episode": f"{i % 12 + 1}",
"timestamp": datetime.now().isoformat(),
"provider": "test_provider",
"anilist_id": 10000 + i
})
self.setup_selector_choice(mock_context, None)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.return_value = history_entries
result = watch_history(mock_context, basic_state)
self.assert_back_behavior(result)
if history_size == 0:
self.assert_feedback_info_called("No watch history found")
else:
mock_context.selector.choose.assert_called_once()
def test_watch_history_menu_error_handling(self, mock_context, basic_state):
"""Test error handling when watch history operations fail."""
self.setup_selector_choice(mock_context, "🗑️ Clear All History")
self.setup_feedback_confirm(True)
with patch('fastanime.cli.utils.watch_history_manager.get_watch_history') as mock_get_history:
mock_get_history.side_effect = Exception("History access error")
result = watch_history(mock_context, basic_state)
self.assert_continue_behavior(result)
self.assert_console_cleared()
self.assert_feedback_error_called("An error occurred")

View File

@@ -0,0 +1,371 @@
"""
Tests for the interactive session management.
Tests session lifecycle, state management, and menu loading.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
from fastanime.cli.interactive.session import Session, Context, session
from fastanime.cli.interactive.state import State, ControlFlow
from fastanime.core.config import AppConfig
from .base_test import BaseMenuTest
class TestSession(BaseMenuTest):
"""Test cases for the Session class."""
@pytest.fixture
def session_instance(self):
"""Create a fresh session instance for testing."""
return Session()
def test_session_initialization(self, session_instance):
"""Test session initialization."""
assert session_instance._context is None
assert session_instance._history == []
assert session_instance._menus == {}
assert session_instance._auto_save_enabled is True
def test_session_menu_decorator(self, session_instance):
"""Test menu decorator registration."""
@session_instance.menu
def test_menu(ctx, state):
return ControlFlow.EXIT
assert "TEST_MENU" in session_instance._menus
assert session_instance._menus["TEST_MENU"].name == "TEST_MENU"
assert session_instance._menus["TEST_MENU"].execute == test_menu
def test_session_load_context(self, session_instance, mock_config):
"""Test context loading with dependencies."""
with patch('fastanime.libs.api.factory.create_api_client') as mock_api:
with patch('fastanime.libs.providers.anime.provider.create_provider') as mock_provider:
with patch('fastanime.libs.selectors.create_selector') as mock_selector:
with patch('fastanime.libs.players.create_player') as mock_player:
mock_api.return_value = Mock()
mock_provider.return_value = Mock()
mock_selector.return_value = Mock()
mock_player.return_value = Mock()
session_instance._load_context(mock_config)
assert session_instance._context is not None
assert isinstance(session_instance._context, Context)
# Verify all dependencies were created
mock_api.assert_called_once()
mock_provider.assert_called_once()
mock_selector.assert_called_once()
mock_player.assert_called_once()
def test_session_run_basic_flow(self, session_instance, mock_config):
"""Test basic session run flow."""
# Register a simple test menu
@session_instance.menu
def main(ctx, state):
return ControlFlow.EXIT
with patch.object(session_instance, '_load_context'):
with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False):
with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False):
with patch.object(session_instance._session_manager, 'create_crash_backup'):
with patch.object(session_instance._session_manager, 'clear_auto_save'):
with patch.object(session_instance._session_manager, 'clear_crash_backup'):
session_instance.run(mock_config)
# Should have started with MAIN menu
assert len(session_instance._history) >= 1
assert session_instance._history[0].menu_name == "MAIN"
def test_session_run_with_resume_path(self, session_instance, mock_config):
"""Test session run with resume path."""
resume_path = Path("/test/session.json")
mock_history = [State(menu_name="TEST")]
with patch.object(session_instance, '_load_context'):
with patch.object(session_instance, 'resume', return_value=True):
with patch.object(session_instance._session_manager, 'create_crash_backup'):
with patch.object(session_instance._session_manager, 'clear_auto_save'):
with patch.object(session_instance._session_manager, 'clear_crash_backup'):
# Mock a simple menu to exit immediately
@session_instance.menu
def test(ctx, state):
return ControlFlow.EXIT
session_instance._history = mock_history
session_instance.run(mock_config, resume_path)
# Verify resume was called
session_instance.resume.assert_called_once_with(resume_path, session_instance._load_context)
def test_session_run_with_crash_backup(self, session_instance, mock_config):
"""Test session run with crash backup recovery."""
mock_history = [State(menu_name="RECOVERED")]
with patch.object(session_instance, '_load_context'):
with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=True):
with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False):
with patch.object(session_instance._session_manager, 'load_crash_backup', return_value=mock_history):
with patch.object(session_instance._session_manager, 'clear_crash_backup'):
with patch('fastanime.cli.utils.feedback.create_feedback_manager') as mock_feedback:
feedback = Mock()
feedback.confirm.return_value = True # Accept recovery
mock_feedback.return_value = feedback
# Mock menu to exit
@session_instance.menu
def recovered(ctx, state):
return ControlFlow.EXIT
session_instance.run(mock_config)
# Should have recovered history
assert session_instance._history == mock_history
def test_session_run_with_auto_save_recovery(self, session_instance, mock_config):
"""Test session run with auto-save recovery."""
mock_history = [State(menu_name="AUTO_SAVED")]
with patch.object(session_instance, '_load_context'):
with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False):
with patch.object(session_instance._session_manager, 'has_auto_save', return_value=True):
with patch.object(session_instance._session_manager, 'load_auto_save', return_value=mock_history):
with patch('fastanime.cli.utils.feedback.create_feedback_manager') as mock_feedback:
feedback = Mock()
feedback.confirm.return_value = True # Accept recovery
mock_feedback.return_value = feedback
# Mock menu to exit
@session_instance.menu
def auto_saved(ctx, state):
return ControlFlow.EXIT
session_instance.run(mock_config)
# Should have recovered history
assert session_instance._history == mock_history
def test_session_keyboard_interrupt_handling(self, session_instance, mock_config):
"""Test session keyboard interrupt handling."""
@session_instance.menu
def main(ctx, state):
raise KeyboardInterrupt()
with patch.object(session_instance, '_load_context'):
with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False):
with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False):
with patch.object(session_instance._session_manager, 'create_crash_backup'):
with patch.object(session_instance._session_manager, 'auto_save_session'):
with patch('fastanime.cli.utils.feedback.create_feedback_manager') as mock_feedback:
feedback = Mock()
mock_feedback.return_value = feedback
session_instance.run(mock_config)
# Should have saved session on interrupt
session_instance._session_manager.auto_save_session.assert_called_once()
def test_session_exception_handling(self, session_instance, mock_config):
"""Test session exception handling."""
@session_instance.menu
def main(ctx, state):
raise Exception("Test error")
with patch.object(session_instance, '_load_context'):
with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False):
with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False):
with patch.object(session_instance._session_manager, 'create_crash_backup'):
with patch('fastanime.cli.utils.feedback.create_feedback_manager') as mock_feedback:
feedback = Mock()
mock_feedback.return_value = feedback
with pytest.raises(Exception, match="Test error"):
session_instance.run(mock_config)
def test_session_save_and_resume(self, session_instance):
"""Test session save and resume functionality."""
test_path = Path("/test/session.json")
test_history = [State(menu_name="TEST1"), State(menu_name="TEST2")]
session_instance._history = test_history
with patch.object(session_instance._session_manager, 'save_session', return_value=True) as mock_save:
with patch.object(session_instance._session_manager, 'load_session', return_value=test_history) as mock_load:
# Test save
result = session_instance.save(test_path, "test_session", "Test description")
assert result is True
mock_save.assert_called_once()
# Test resume
session_instance._history = [] # Clear history
result = session_instance.resume(test_path)
assert result is True
assert session_instance._history == test_history
mock_load.assert_called_once()
def test_session_auto_save_functionality(self, session_instance, mock_config):
"""Test auto-save functionality during session run."""
call_count = 0
@session_instance.menu
def main(ctx, state):
nonlocal call_count
call_count += 1
if call_count < 6: # Trigger auto-save after 5 calls
return State(menu_name="MAIN")
return ControlFlow.EXIT
with patch.object(session_instance, '_load_context'):
with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False):
with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False):
with patch.object(session_instance._session_manager, 'create_crash_backup'):
with patch.object(session_instance._session_manager, 'auto_save_session') as mock_auto_save:
with patch.object(session_instance._session_manager, 'clear_auto_save'):
with patch.object(session_instance._session_manager, 'clear_crash_backup'):
session_instance.run(mock_config)
# Auto-save should have been called (every 5 state changes)
mock_auto_save.assert_called()
def test_session_menu_loading_from_folder(self, session_instance):
"""Test loading menus from folder."""
test_menus_dir = Path("/test/menus")
with patch('os.listdir', return_value=['menu1.py', 'menu2.py', '__init__.py']):
with patch('importlib.util.spec_from_file_location') as mock_spec:
with patch('importlib.util.module_from_spec') as mock_module:
# Mock successful module loading
spec = Mock()
spec.loader = Mock()
mock_spec.return_value = spec
mock_module.return_value = Mock()
session_instance.load_menus_from_folder(test_menus_dir)
# Should have attempted to load 2 menu files (excluding __init__.py)
assert mock_spec.call_count == 2
assert spec.loader.exec_module.call_count == 2
def test_session_menu_loading_error_handling(self, session_instance):
"""Test error handling during menu loading."""
test_menus_dir = Path("/test/menus")
with patch('os.listdir', return_value=['broken_menu.py']):
with patch('importlib.util.spec_from_file_location', side_effect=Exception("Import error")):
# Should not raise exception, just log error
session_instance.load_menus_from_folder(test_menus_dir)
# Menu should not be registered
assert "BROKEN_MENU" not in session_instance._menus
def test_session_control_flow_handling(self, session_instance, mock_config):
"""Test various control flow scenarios."""
state_count = 0
@session_instance.menu
def main(ctx, state):
nonlocal state_count
state_count += 1
if state_count == 1:
return ControlFlow.BACK # Should pop state if history > 1
elif state_count == 2:
return ControlFlow.CONTINUE # Should re-run current state
elif state_count == 3:
return ControlFlow.RELOAD_CONFIG # Should trigger config edit
else:
return ControlFlow.EXIT
@session_instance.menu
def other(ctx, state):
return State(menu_name="MAIN")
with patch.object(session_instance, '_load_context'):
with patch.object(session_instance, '_edit_config'):
with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False):
with patch.object(session_instance._session_manager, 'has_auto_save', return_value=False):
with patch.object(session_instance._session_manager, 'create_crash_backup'):
with patch.object(session_instance._session_manager, 'clear_auto_save'):
with patch.object(session_instance._session_manager, 'clear_crash_backup'):
# Add an initial state to test BACK behavior
session_instance._history = [State(menu_name="OTHER"), State(menu_name="MAIN")]
session_instance.run(mock_config)
# Should have called edit config
session_instance._edit_config.assert_called_once()
def test_session_get_stats(self, session_instance):
"""Test session statistics retrieval."""
session_instance._history = [State(menu_name="MAIN"), State(menu_name="TEST")]
session_instance._auto_save_enabled = True
with patch.object(session_instance._session_manager, 'has_auto_save', return_value=True):
with patch.object(session_instance._session_manager, 'has_crash_backup', return_value=False):
stats = session_instance.get_session_stats()
assert stats["current_states"] == 2
assert stats["current_menu"] == "TEST"
assert stats["auto_save_enabled"] is True
assert stats["has_auto_save"] is True
assert stats["has_crash_backup"] is False
def test_session_manual_backup(self, session_instance):
"""Test manual backup creation."""
session_instance._history = [State(menu_name="TEST")]
with patch.object(session_instance._session_manager, 'save_session', return_value=True):
result = session_instance.create_manual_backup("test_backup")
assert result is True
session_instance._session_manager.save_session.assert_called_once()
def test_session_auto_save_toggle(self, session_instance):
"""Test auto-save enable/disable."""
# Test enabling
session_instance.enable_auto_save(True)
assert session_instance._auto_save_enabled is True
# Test disabling
session_instance.enable_auto_save(False)
assert session_instance._auto_save_enabled is False
def test_session_cleanup_old_sessions(self, session_instance):
"""Test cleanup of old sessions."""
with patch.object(session_instance._session_manager, 'cleanup_old_sessions', return_value=3):
result = session_instance.cleanup_old_sessions(max_sessions=10)
assert result == 3
session_instance._session_manager.cleanup_old_sessions.assert_called_once_with(10)
def test_session_list_saved_sessions(self, session_instance):
"""Test listing saved sessions."""
mock_sessions = [
{"name": "session1", "created": "2024-01-01"},
{"name": "session2", "created": "2024-01-02"}
]
with patch.object(session_instance._session_manager, 'list_saved_sessions', return_value=mock_sessions):
result = session_instance.list_saved_sessions()
assert result == mock_sessions
session_instance._session_manager.list_saved_sessions.assert_called_once()
def test_global_session_instance(self):
"""Test that the global session instance is properly initialized."""
from fastanime.cli.interactive.session import session
assert isinstance(session, Session)
assert session._context is None
assert session._history == []
assert session._menus == {}

299
tests/conftest.py Normal file
View File

@@ -0,0 +1,299 @@
"""
Pytest configuration and shared fixtures for FastAnime tests.
Provides common mocks and test utilities following DRY principles.
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from pathlib import Path
from typing import Dict, Any, Optional
from fastanime.core.config import AppConfig, GeneralConfig, AnilistConfig
from fastanime.cli.interactive.session import Context
from fastanime.cli.interactive.state import State, ControlFlow, ProviderState, MediaApiState
from fastanime.libs.api.types import UserProfile, MediaSearchResult, MediaItem
from fastanime.libs.api.base import BaseApiClient
from fastanime.libs.providers.anime.base import BaseAnimeProvider
from fastanime.libs.selectors.base import BaseSelector
from fastanime.libs.players.base import BasePlayer
@pytest.fixture
def mock_config():
"""Create a mock AppConfig with default settings."""
config = Mock(spec=AppConfig)
config.general = Mock(spec=GeneralConfig)
config.general.icons = True
config.general.provider = "test_provider"
config.general.api_client = "anilist"
config.anilist = Mock(spec=AnilistConfig)
return config
@pytest.fixture
def mock_user_profile():
"""Create a mock user profile for authenticated tests."""
return UserProfile(
id=12345,
name="TestUser",
avatar="https://example.com/avatar.jpg"
)
@pytest.fixture
def mock_media_item():
"""Create a mock media item for testing."""
return MediaItem(
id=1,
title="Test Anime",
description="A test anime description",
cover_image="https://example.com/cover.jpg",
banner_image="https://example.com/banner.jpg",
status="RELEASING",
episodes=12,
duration=24,
genres=["Action", "Adventure"],
mean_score=85,
popularity=1000,
start_date="2024-01-01",
end_date=None
)
@pytest.fixture
def mock_media_search_result(mock_media_item):
"""Create a mock media search result."""
return MediaSearchResult(
media=[mock_media_item],
page_info={
"total": 1,
"current_page": 1,
"last_page": 1,
"has_next_page": False,
"per_page": 20
}
)
@pytest.fixture
def mock_api_client(mock_user_profile):
"""Create a mock API client."""
client = Mock(spec=BaseApiClient)
client.user_profile = mock_user_profile
client.authenticate.return_value = mock_user_profile
client.get_viewer_profile.return_value = mock_user_profile
client.search_media.return_value = None
return client
@pytest.fixture
def mock_unauthenticated_api_client():
"""Create a mock API client without authentication."""
client = Mock(spec=BaseApiClient)
client.user_profile = None
client.authenticate.return_value = None
client.get_viewer_profile.return_value = None
client.search_media.return_value = None
return client
@pytest.fixture
def mock_provider():
"""Create a mock anime provider."""
provider = Mock(spec=BaseAnimeProvider)
provider.search.return_value = None
provider.get_anime.return_value = None
provider.get_servers.return_value = []
return provider
@pytest.fixture
def mock_selector():
"""Create a mock selector for user input."""
selector = Mock(spec=BaseSelector)
selector.choose.return_value = None
selector.input.return_value = ""
selector.confirm.return_value = False
return selector
@pytest.fixture
def mock_player():
"""Create a mock player."""
player = Mock(spec=BasePlayer)
player.play.return_value = None
return player
@pytest.fixture
def mock_context(mock_config, mock_provider, mock_selector, mock_player, mock_api_client):
"""Create a mock context with all dependencies."""
return Context(
config=mock_config,
provider=mock_provider,
selector=mock_selector,
player=mock_player,
media_api=mock_api_client
)
@pytest.fixture
def mock_unauthenticated_context(mock_config, mock_provider, mock_selector, mock_player, mock_unauthenticated_api_client):
"""Create a mock context without authentication."""
return Context(
config=mock_config,
provider=mock_provider,
selector=mock_selector,
player=mock_player,
media_api=mock_unauthenticated_api_client
)
@pytest.fixture
def basic_state():
"""Create a basic state for testing."""
return State(menu_name="TEST")
@pytest.fixture
def state_with_media_data(mock_media_search_result, mock_media_item):
"""Create a state with media data."""
return State(
menu_name="TEST",
media_api=MediaApiState(
search_results=mock_media_search_result,
anime=mock_media_item
)
)
@pytest.fixture
def mock_feedback_manager():
"""Create a mock feedback manager."""
feedback = Mock()
feedback.info = Mock()
feedback.error = Mock()
feedback.warning = Mock()
feedback.success = Mock()
feedback.confirm.return_value = False
feedback.pause_for_user = Mock()
return feedback
@pytest.fixture
def mock_console():
"""Create a mock Rich console."""
console = Mock()
console.clear = Mock()
console.print = Mock()
return console
class MenuTestHelper:
"""Helper class for common menu testing patterns."""
@staticmethod
def assert_control_flow(result: Any, expected: ControlFlow):
"""Assert that the result is the expected ControlFlow."""
assert isinstance(result, ControlFlow)
assert result == expected
@staticmethod
def assert_state_transition(result: Any, expected_menu: str):
"""Assert that the result is a State with the expected menu name."""
assert isinstance(result, State)
assert result.menu_name == expected_menu
@staticmethod
def setup_selector_choice(mock_selector, choice: Optional[str]):
"""Helper to set up selector choice return value."""
mock_selector.choose.return_value = choice
@staticmethod
def setup_selector_confirm(mock_selector, confirm: bool):
"""Helper to set up selector confirm return value."""
mock_selector.confirm.return_value = confirm
@staticmethod
def setup_feedback_confirm(mock_feedback, confirm: bool):
"""Helper to set up feedback confirm return value."""
mock_feedback.confirm.return_value = confirm
@pytest.fixture
def menu_helper():
"""Provide the MenuTestHelper class."""
return MenuTestHelper
# Patches for external dependencies
@pytest.fixture
def mock_create_feedback_manager(mock_feedback_manager):
"""Mock the create_feedback_manager function."""
with patch('fastanime.cli.utils.feedback.create_feedback_manager', return_value=mock_feedback_manager):
yield mock_feedback_manager
@pytest.fixture
def mock_rich_console(mock_console):
"""Mock the Rich Console class."""
with patch('rich.console.Console', return_value=mock_console):
yield mock_console
@pytest.fixture
def mock_click_edit():
"""Mock the click.edit function."""
with patch('click.edit') as mock_edit:
yield mock_edit
@pytest.fixture
def mock_webbrowser_open():
"""Mock the webbrowser.open function."""
with patch('webbrowser.open') as mock_open:
yield mock_open
@pytest.fixture
def mock_auth_manager():
"""Mock the AuthManager class."""
with patch('fastanime.cli.auth.manager.AuthManager') as mock_auth:
auth_instance = Mock()
auth_instance.load_user_profile.return_value = None
auth_instance.save_user_profile.return_value = True
auth_instance.clear_user_profile.return_value = True
mock_auth.return_value = auth_instance
yield auth_instance
# Common test data
TEST_MENU_OPTIONS = {
'trending': '🔥 Trending',
'popular': '✨ Popular',
'favourites': '💖 Favourites',
'top_scored': '💯 Top Scored',
'upcoming': '🎬 Upcoming',
'recently_updated': '🔔 Recently Updated',
'random': '🎲 Random',
'search': '🔎 Search',
'watching': '📺 Watching',
'planned': '📑 Planned',
'completed': '✅ Completed',
'paused': '⏸️ Paused',
'dropped': '🚮 Dropped',
'rewatching': '🔁 Rewatching',
'watch_history': '📖 Local Watch History',
'auth': '🔐 Authentication',
'session_management': '🔧 Session Management',
'edit_config': '📝 Edit Config',
'exit': '❌ Exit'
}
TEST_AUTH_OPTIONS = {
'login': '🔐 Login to AniList',
'logout': '🔓 Logout',
'profile': '👤 View Profile Details',
'how_to_token': '❓ How to Get Token',
'back': '↩️ Back to Main Menu'
}