From e3deb28d26911c7a686e98655c3efc615b883f19 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 15 Jul 2025 22:36:08 +0300 Subject: [PATCH] chore:cleanup --- test_auth_display.py | 84 --- test_auth_flow.py | 135 ---- test_feedback.py | 75 --- test_session_management.py | 142 ----- test_watch_history.py | 116 ---- tests/__init__.py | 1 + tests/api/anilist/__init__.py | 0 tests/api/anilist/mock_data/__init__.py | 0 .../anilist/mock_data/search_one_piece.json | 37 -- .../anilist/mock_data/user_list_watching.json | 43 -- tests/api/anilist/test_anilist_api.py | 181 ------ .../anilist/test_anilist_api_intergration.py | 87 --- tests/interactive/menus/README.md | 334 ---------- tests/interactive/menus/__init__.py | 1 - tests/interactive/menus/conftest.py | 270 -------- tests/interactive/menus/run_tests.py | 84 --- tests/interactive/menus/test_auth.py | 374 ----------- tests/interactive/menus/test_episodes.py | 324 ---------- tests/interactive/menus/test_main.py | 440 ------------- tests/interactive/menus/test_media_actions.py | 409 ------------ .../interactive/menus/test_player_controls.py | 479 -------------- .../interactive/menus/test_provider_search.py | 465 -------------- tests/interactive/menus/test_results.py | 368 ----------- tests/interactive/menus/test_servers.py | 435 ------------- .../menus/test_session_management.py | 463 -------------- tests/interactive/menus/test_watch_history.py | 590 ------------------ tests/test_all_commands.py | 158 ----- tests/test_config_loader.py | 279 --------- 28 files changed, 1 insertion(+), 6373 deletions(-) delete mode 100644 test_auth_display.py delete mode 100644 test_auth_flow.py delete mode 100644 test_feedback.py delete mode 100644 test_session_management.py delete mode 100644 test_watch_history.py create mode 100644 tests/__init__.py delete mode 100644 tests/api/anilist/__init__.py delete mode 100644 tests/api/anilist/mock_data/__init__.py delete mode 100644 tests/api/anilist/mock_data/search_one_piece.json delete mode 100644 tests/api/anilist/mock_data/user_list_watching.json delete mode 100644 tests/api/anilist/test_anilist_api.py delete mode 100644 tests/api/anilist/test_anilist_api_intergration.py delete mode 100644 tests/interactive/menus/README.md delete mode 100644 tests/interactive/menus/__init__.py delete mode 100644 tests/interactive/menus/conftest.py delete mode 100644 tests/interactive/menus/run_tests.py delete mode 100644 tests/interactive/menus/test_auth.py delete mode 100644 tests/interactive/menus/test_episodes.py delete mode 100644 tests/interactive/menus/test_main.py delete mode 100644 tests/interactive/menus/test_media_actions.py delete mode 100644 tests/interactive/menus/test_player_controls.py delete mode 100644 tests/interactive/menus/test_provider_search.py delete mode 100644 tests/interactive/menus/test_results.py delete mode 100644 tests/interactive/menus/test_servers.py delete mode 100644 tests/interactive/menus/test_session_management.py delete mode 100644 tests/interactive/menus/test_watch_history.py delete mode 100644 tests/test_all_commands.py delete mode 100644 tests/test_config_loader.py diff --git a/test_auth_display.py b/test_auth_display.py deleted file mode 100644 index 8836205..0000000 --- a/test_auth_display.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -Test script to verify the authentication system works correctly. -This tests the auth utilities and their integration with the feedback system. -""" - -import sys -from pathlib import Path - -# Add the project root to the path so we can import fastanime modules -project_root = Path(__file__).parent.parent.parent -sys.path.insert(0, str(project_root)) - -from fastanime.cli.utils.auth_utils import ( - get_auth_status_indicator, - format_user_info_header, - check_authentication_required, - format_auth_menu_header, - prompt_for_authentication, -) -from fastanime.cli.utils.feedback import create_feedback_manager -from fastanime.libs.api.types import UserProfile - - -class MockApiClient: - """Mock API client for testing authentication utilities.""" - - def __init__(self, authenticated=False): - if authenticated: - self.user_profile = UserProfile( - id=12345, - name="TestUser", - avatar_url="https://example.com/avatar.jpg", - banner_url="https://example.com/banner.jpg", - ) - else: - self.user_profile = None - - -def test_auth_status_display(): - """Test authentication status display functionality.""" - print("=== Testing Authentication Status Display ===\n") - - feedback = create_feedback_manager(icons_enabled=True) - - print("1. Testing authentication status when NOT logged in:") - mock_api_not_auth = MockApiClient(authenticated=False) - status_text, user_profile = get_auth_status_indicator(mock_api_not_auth, True) - print(f" Status: {status_text}") - print(f" User Profile: {user_profile}") - - print("\n2. Testing authentication status when logged in:") - mock_api_auth = MockApiClient(authenticated=True) - status_text, user_profile = get_auth_status_indicator(mock_api_auth, True) - print(f" Status: {status_text}") - print(f" User Profile: {user_profile}") - - print("\n3. Testing user info header formatting:") - header = format_user_info_header(user_profile, True) - print(f" Header: {header}") - - print("\n4. Testing menu header formatting:") - auth_header = format_auth_menu_header(mock_api_auth, "Test Menu", True) - print(f" Auth Header:\n{auth_header}") - - print("\n5. Testing authentication check (not authenticated):") - is_auth = check_authentication_required( - mock_api_not_auth, feedback, "test operation" - ) - print(f" Authentication passed: {is_auth}") - - print("\n6. Testing authentication check (authenticated):") - is_auth = check_authentication_required(mock_api_auth, feedback, "test operation") - print(f" Authentication passed: {is_auth}") - - print("\n7. Testing authentication prompt:") - # Note: This will show interactive prompts if run in a terminal - # prompt_for_authentication(feedback, "access your anime list") - print(" Skipped interactive prompt test - uncomment to test manually") - - print("\n=== Authentication Tests Completed! ===") - - -if __name__ == "__main__": - test_auth_status_display() diff --git a/test_auth_flow.py b/test_auth_flow.py deleted file mode 100644 index 9a13166..0000000 --- a/test_auth_flow.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for the Step 5: AniList Authentication Flow implementation. -This tests the interactive authentication menu and its functionalities. -""" - -import sys -from pathlib import Path - -# Add the project root to the path so we can import fastanime modules -project_root = Path(__file__).parent -sys.path.insert(0, str(project_root)) - -from fastanime.cli.interactive.menus.auth import ( - _display_auth_status, - _display_user_profile_details, - _display_token_help -) -from fastanime.libs.api.types import UserProfile -from rich.console import Console - - -def test_auth_status_display(): - """Test authentication status display functions.""" - console = Console() - print("=== Testing Authentication Status Display ===\n") - - # Test without authentication - print("1. Testing unauthenticated status:") - _display_auth_status(console, None, True) - - # Test with authentication - print("\n2. Testing authenticated status:") - mock_user = UserProfile( - id=12345, - name="TestUser", - avatar_url="https://example.com/avatar.jpg", - banner_url="https://example.com/banner.jpg" - ) - _display_auth_status(console, mock_user, True) - - -def test_profile_details(): - """Test user profile details display.""" - console = Console() - print("\n\n=== Testing Profile Details Display ===\n") - - mock_user = UserProfile( - id=12345, - name="TestUser", - avatar_url="https://example.com/avatar.jpg", - banner_url="https://example.com/banner.jpg" - ) - - _display_user_profile_details(console, mock_user, True) - - -def test_token_help(): - """Test token help display.""" - console = Console() - print("\n\n=== Testing Token Help Display ===\n") - - _display_token_help(console, True) - - -def test_auth_utils(): - """Test authentication utility functions.""" - print("\n\n=== Testing Authentication Utilities ===\n") - - from fastanime.cli.utils.auth_utils import ( - get_auth_status_indicator, - format_login_success_message, - format_logout_success_message - ) - - # Mock API client - class MockApiClient: - def __init__(self, user_profile=None): - self.user_profile = user_profile - - # Test without authentication - mock_api_unauthenticated = MockApiClient() - status_text, profile = get_auth_status_indicator(mock_api_unauthenticated, True) - print(f"Unauthenticated status: {status_text}") - print(f"Profile: {profile}") - - # Test with authentication - mock_user = UserProfile( - id=12345, - name="TestUser", - avatar_url="https://example.com/avatar.jpg", - banner_url="https://example.com/banner.jpg" - ) - mock_api_authenticated = MockApiClient(mock_user) - status_text, profile = get_auth_status_indicator(mock_api_authenticated, True) - print(f"\nAuthenticated status: {status_text}") - print(f"Profile: {profile.name if profile else None}") - - # Test success messages - print(f"\nLogin success message: {format_login_success_message('TestUser', True)}") - print(f"Logout success message: {format_logout_success_message(True)}") - - -def main(): - """Run all authentication tests.""" - print("๐Ÿ” Testing Step 5: AniList Authentication Flow Implementation\n") - print("=" * 70) - - try: - test_auth_status_display() - test_profile_details() - test_token_help() - test_auth_utils() - - print("\n" + "=" * 70) - print("โœ… All authentication flow tests completed successfully!") - print("\nFeatures implemented:") - print("โ€ข Interactive OAuth login process") - print("โ€ข Logout functionality with confirmation") - print("โ€ข User profile viewing menu") - print("โ€ข Authentication status display") - print("โ€ข Token help and instructions") - print("โ€ข Enhanced user feedback") - - except Exception as e: - print(f"\nโŒ Test failed with error: {e}") - import traceback - traceback.print_exc() - return 1 - - return 0 - - -if __name__ == "__main__": - exit(main()) diff --git a/test_feedback.py b/test_feedback.py deleted file mode 100644 index 46dac50..0000000 --- a/test_feedback.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Test script to verify the feedback system works correctly. -Run this to see the feedback system in action. -""" - -import sys -import time -from pathlib import Path - -# Add the project root to the path so we can import fastanime modules -project_root = Path(__file__).parent.parent.parent -sys.path.insert(0, str(project_root)) - -from fastanime.cli.utils.feedback import create_feedback_manager, execute_with_feedback - - -def test_feedback_system(): - """Test all feedback system components.""" - print("=== Testing FastAnime Enhanced Feedback System ===\n") - - # Test with icons enabled - feedback = create_feedback_manager(icons_enabled=True) - - print("1. Testing success message:") - feedback.success("Operation completed successfully", "All data has been processed") - time.sleep(1) - - print("\n2. Testing error message:") - feedback.error("Failed to connect to server", "Network timeout after 30 seconds") - time.sleep(1) - - print("\n3. Testing warning message:") - feedback.warning( - "Anime not found on provider", "Try searching with a different title" - ) - time.sleep(1) - - print("\n4. Testing info message:") - feedback.info("Loading anime data", "This may take a few moments") - time.sleep(1) - - print("\n5. Testing loading operation:") - - def mock_long_operation(): - time.sleep(2) - return "Operation result" - - success, result = execute_with_feedback( - mock_long_operation, - feedback, - "fetch anime data", - loading_msg="Fetching anime from AniList", - success_msg="Anime data loaded successfully", - ) - - print(f"Operation success: {success}, Result: {result}") - - print("\n6. Testing confirmation dialog:") - if feedback.confirm("Do you want to continue with the test?", default=True): - feedback.success("User confirmed to continue") - else: - feedback.info("User chose to stop") - - print("\n7. Testing detailed panel:") - feedback.show_detailed_panel( - "Anime Information", - "Title: Attack on Titan\nGenres: Action, Drama\nStatus: Completed\nEpisodes: 25", - "cyan", - ) - - print("\n=== Test completed! ===") - - -if __name__ == "__main__": - test_feedback_system() diff --git a/test_session_management.py b/test_session_management.py deleted file mode 100644 index 29c9691..0000000 --- a/test_session_management.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -Test script to verify the session management system works correctly. -This tests session save/resume functionality and crash recovery. -""" -import json -import sys -import tempfile -from datetime import datetime -from pathlib import Path - -# Add the project root to the path so we can import fastanime modules -project_root = Path(__file__).parent.parent.parent -sys.path.insert(0, str(project_root)) - -from fastanime.cli.utils.session_manager import SessionManager, SessionMetadata, SessionData -from fastanime.cli.utils.feedback import create_feedback_manager -from fastanime.cli.interactive.state import State, MediaApiState - - -def test_session_management(): - """Test the session management system.""" - print("=== Testing Session Management System ===\n") - - feedback = create_feedback_manager(icons_enabled=True) - session_manager = SessionManager() - - # Create test session states - test_states = [ - State(menu_name="MAIN"), - State(menu_name="RESULTS", media_api=MediaApiState()), - State(menu_name="MEDIA_ACTIONS", media_api=MediaApiState()) - ] - - print("1. Testing session metadata creation:") - metadata = SessionMetadata( - session_name="Test Session", - description="This is a test session for validation", - state_count=len(test_states) - ) - print(f" Metadata: {metadata.session_name} - {metadata.description}") - print(f" States: {metadata.state_count}, Created: {metadata.created_at}") - - print("\n2. Testing session data serialization:") - session_data = SessionData(test_states, metadata) - data_dict = session_data.to_dict() - print(f" Serialized keys: {list(data_dict.keys())}") - print(f" Format version: {data_dict['format_version']}") - - print("\n3. Testing session data deserialization:") - restored_session = SessionData.from_dict(data_dict) - print(f" Restored states: {len(restored_session.history)}") - print(f" Restored metadata: {restored_session.metadata.session_name}") - - print("\n4. Testing session save:") - with tempfile.TemporaryDirectory() as temp_dir: - test_file = Path(temp_dir) / "test_session.json" - success = session_manager.save_session( - test_states, - test_file, - session_name="Test Session Save", - description="Testing save functionality", - feedback=feedback - ) - print(f" Save success: {success}") - print(f" File exists: {test_file.exists()}") - - if test_file.exists(): - print(f" File size: {test_file.stat().st_size} bytes") - - print("\n5. Testing session load:") - loaded_states = session_manager.load_session(test_file, feedback) - if loaded_states: - print(f" Loaded states: {len(loaded_states)}") - print(f" First state menu: {loaded_states[0].menu_name}") - print(f" Last state menu: {loaded_states[-1].menu_name}") - - print("\n6. Testing session file content:") - with open(test_file, 'r') as f: - file_content = json.load(f) - print(f" JSON keys: {list(file_content.keys())}") - print(f" History length: {len(file_content['history'])}") - print(f" Session name: {file_content['metadata']['session_name']}") - - print("\n7. Testing auto-save functionality:") - auto_save_success = session_manager.auto_save_session(test_states) - print(f" Auto-save success: {auto_save_success}") - print(f" Has auto-save: {session_manager.has_auto_save()}") - - print("\n8. Testing crash backup:") - crash_backup_success = session_manager.create_crash_backup(test_states) - print(f" Crash backup success: {crash_backup_success}") - print(f" Has crash backup: {session_manager.has_crash_backup()}") - - print("\n9. Testing session listing:") - saved_sessions = session_manager.list_saved_sessions() - print(f" Found {len(saved_sessions)} saved sessions") - for i, sess in enumerate(saved_sessions[:3]): # Show first 3 - print(f" Session {i+1}: {sess['name']} ({sess['state_count']} states)") - - print("\n10. Testing cleanup functions:") - print(f" Can clear auto-save: {session_manager.clear_auto_save()}") - print(f" Can clear crash backup: {session_manager.clear_crash_backup()}") - print(f" Auto-save exists after clear: {session_manager.has_auto_save()}") - print(f" Crash backup exists after clear: {session_manager.has_crash_backup()}") - - print("\n=== Session Management Tests Completed! ===") - - -def test_session_error_handling(): - """Test error handling in session management.""" - print("\n=== Testing Error Handling ===\n") - - feedback = create_feedback_manager(icons_enabled=True) - session_manager = SessionManager() - - print("1. Testing load of non-existent file:") - non_existent = Path("/tmp/non_existent_session.json") - result = session_manager.load_session(non_existent, feedback) - print(f" Result for non-existent file: {result}") - - print("\n2. Testing load of corrupted file:") - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: - f.write("{ invalid json content }") - corrupted_file = Path(f.name) - - try: - result = session_manager.load_session(corrupted_file, feedback) - print(f" Result for corrupted file: {result}") - finally: - corrupted_file.unlink() # Clean up - - print("\n3. Testing save to read-only location:") - readonly_path = Path("/tmp/readonly_session.json") - # This test would need actual readonly permissions to be meaningful - print(" Skipped - requires permission setup") - - print("\n=== Error Handling Tests Completed! ===") - - -if __name__ == "__main__": - test_session_management() - test_session_error_handling() diff --git a/test_watch_history.py b/test_watch_history.py deleted file mode 100644 index e99ae59..0000000 --- a/test_watch_history.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for watch history management implementation. -Tests basic functionality without requiring full interactive session. -""" - -import sys -from pathlib import Path - -# Add the project root to Python path -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from fastanime.cli.utils.watch_history_manager import WatchHistoryManager -from fastanime.cli.utils.watch_history_tracker import WatchHistoryTracker -from fastanime.libs.api.types import MediaItem, MediaTitle, MediaImage - - -def test_watch_history(): - """Test basic watch history functionality.""" - print("Testing Watch History Management System") - print("=" * 50) - - # Create test media item - test_anime = MediaItem( - id=123456, - id_mal=12345, - title=MediaTitle( - english="Test Anime", - romaji="Test Anime Romaji", - native="ใƒ†ใ‚นใƒˆใ‚ขใƒ‹ใƒก" - ), - episodes=24, - cover_image=MediaImage( - large="https://example.com/cover.jpg", - medium="https://example.com/cover_medium.jpg" - ), - genres=["Action", "Adventure"], - average_score=85.0 - ) - - # Test watch history manager - print("\n1. Testing WatchHistoryManager...") - history_manager = WatchHistoryManager() - - # Add anime to history - success = history_manager.add_or_update_entry( - test_anime, - episode=5, - progress=0.8, - status="watching", - notes="Great anime so far!" - ) - print(f" Added anime to history: {success}") - - # Get entry back - entry = history_manager.get_entry(123456) - if entry: - print(f" Retrieved entry: {entry.get_display_title()}") - print(f" Progress: {entry.get_progress_display()}") - print(f" Status: {entry.status}") - print(f" Notes: {entry.notes}") - else: - print(" Failed to retrieve entry") - - # Test tracker - print("\n2. Testing WatchHistoryTracker...") - tracker = WatchHistoryTracker() - - # Track episode viewing - success = tracker.track_episode_start(test_anime, 6) - print(f" Started tracking episode 6: {success}") - - # Complete episode - success = tracker.track_episode_completion(123456, 6) - print(f" Completed episode 6: {success}") - - # Get progress - progress = tracker.get_watch_progress(123456) - if progress: - print(f" Current progress: Episode {progress['last_episode']}") - print(f" Next episode: {progress['next_episode']}") - print(f" Status: {progress['status']}") - - # Test stats - print("\n3. Testing Statistics...") - stats = history_manager.get_stats() - print(f" Total entries: {stats['total_entries']}") - print(f" Watching: {stats['watching']}") - print(f" Total episodes watched: {stats['total_episodes_watched']}") - - # Test search - print("\n4. Testing Search...") - search_results = history_manager.search_entries("Test") - print(f" Search results for 'Test': {len(search_results)} found") - - # Test status updates - print("\n5. Testing Status Updates...") - success = history_manager.change_status(123456, "completed") - print(f" Changed status to completed: {success}") - - # Verify status change - entry = history_manager.get_entry(123456) - if entry: - print(f" New status: {entry.status}") - - print("\n" + "=" * 50) - print("Watch History Test Complete!") - - # Cleanup test data - history_manager.remove_entry(123456) - print("Test data cleaned up.") - - -if __name__ == "__main__": - test_watch_history() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d53402c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for FastAnime.""" diff --git a/tests/api/anilist/__init__.py b/tests/api/anilist/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/api/anilist/mock_data/__init__.py b/tests/api/anilist/mock_data/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/api/anilist/mock_data/search_one_piece.json b/tests/api/anilist/mock_data/search_one_piece.json deleted file mode 100644 index 5dc2e46..0000000 --- a/tests/api/anilist/mock_data/search_one_piece.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "data": { - "Page": { - "pageInfo": { - "total": 1, - "currentPage": 1, - "hasNextPage": false, - "perPage": 1 - }, - "media": [ - { - "id": 21, - "idMal": 21, - "title": { - "romaji": "ONE PIECE", - "english": "ONE PIECE", - "native": "ONE PIECE" - }, - "status": "RELEASING", - "episodes": null, - "averageScore": 87, - "popularity": 250000, - "favourites": 220000, - "genres": [ - "Action", - "Adventure", - "Fantasy" - ], - "coverImage": { - "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21-tXMN3Y20wTlH.jpg" - }, - "mediaListEntry": null - } - ] - } - } -} diff --git a/tests/api/anilist/mock_data/user_list_watching.json b/tests/api/anilist/mock_data/user_list_watching.json deleted file mode 100644 index ed4e03d..0000000 --- a/tests/api/anilist/mock_data/user_list_watching.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "data": { - "Page": { - "pageInfo": { - "total": 1, - "currentPage": 1, - "hasNextPage": false, - "perPage": 1 - }, - "mediaList": [ - { - "media": { - "id": 16498, - "idMal": 16498, - "title": { - "romaji": "Shingeki no Kyojin", - "english": "Attack on Titan", - "native": "้€ฒๆ’ƒใฎๅทจไบบ" - }, - "status": "FINISHED", - "episodes": 25, - "averageScore": 85, - "popularity": 300000, - "favourites": 200000, - "genres": [ - "Action", - "Drama", - "Mystery" - ], - "coverImage": { - "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx16498-C6FPmWm59CyP.jpg" - }, - "mediaListEntry": { - "status": "CURRENT", - "progress": 10, - "score": 9.0 - } - } - } - ] - } - } -} diff --git a/tests/api/anilist/test_anilist_api.py b/tests/api/anilist/test_anilist_api.py deleted file mode 100644 index eb26317..0000000 --- a/tests/api/anilist/test_anilist_api.py +++ /dev/null @@ -1,181 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import TYPE_CHECKING - -import pytest -from fastanime.libs.api.anilist.api import AniListApi -from fastanime.libs.api.base import ApiSearchParams, UserListParams -from fastanime.libs.api.types import MediaItem, MediaSearchResult, UserProfile -from httpx import Response - -if TYPE_CHECKING: - from fastanime.core.config import AnilistConfig - from httpx import Client - from pytest_httpx import HTTPXMock - - -# --- Fixtures --- - - -@pytest.fixture -def mock_anilist_config() -> AnilistConfig: - """Provides a default AnilistConfig instance for tests.""" - from fastanime.core.config import AnilistConfig - - return AnilistConfig() - - -@pytest.fixture -def mock_data_path() -> Path: - """Provides the path to the mock_data directory.""" - return Path(__file__).parent / "mock_data" - - -@pytest.fixture -def anilist_client( - mock_anilist_config: AnilistConfig, httpx_mock: HTTPXMock -) -> AniListApi: - """ - Provides an instance of AniListApi with a mocked HTTP client. - Note: We pass the httpx_mock fixture which is the mocked client. - """ - return AniListApi(config=mock_anilist_config, client=httpx_mock) - - -# --- Test Cases --- - - -def test_search_media_success( - anilist_client: AniListApi, httpx_mock: HTTPXMock, mock_data_path: Path -): - """ - GIVEN a search query for 'one piece' - WHEN search_media is called - THEN it should return a MediaSearchResult with one correctly mapped MediaItem. - """ - # ARRANGE: Load mock response and configure the mock HTTP client. - mock_response_json = json.loads( - (mock_data_path / "search_one_piece.json").read_text() - ) - httpx_mock.add_response(url="https://graphql.anilist.co", json=mock_response_json) - - params = ApiSearchParams(query="one piece") - - # ACT - result = anilist_client.search_media(params) - - # ASSERT - assert result is not None - assert isinstance(result, MediaSearchResult) - assert len(result.media) == 1 - - one_piece = result.media[0] - assert isinstance(one_piece, MediaItem) - assert one_piece.id == 21 - assert one_piece.title.english == "ONE PIECE" - assert one_piece.status == "RELEASING" - assert "Action" in one_piece.genres - assert one_piece.average_score == 8.7 # Mapper should convert 87 -> 8.7 - - -def test_fetch_user_list_success( - anilist_client: AniListApi, httpx_mock: HTTPXMock, mock_data_path: Path -): - """ - GIVEN an authenticated client - WHEN fetch_user_list is called for the 'CURRENT' list - THEN it should return a MediaSearchResult with a correctly mapped MediaItem - that includes user-specific progress. - """ - # ARRANGE - mock_response_json = json.loads( - (mock_data_path / "user_list_watching.json").read_text() - ) - httpx_mock.add_response(url="https://graphql.anilist.co", json=mock_response_json) - - # Simulate being logged in - anilist_client.user_profile = UserProfile(id=12345, name="testuser") - - params = UserListParams(status="CURRENT") - - # ACT - result = anilist_client.fetch_user_list(params) - - # ASSERT - assert result is not None - assert isinstance(result, MediaSearchResult) - assert len(result.media) == 1 - - attack_on_titan = result.media[0] - assert isinstance(attack_on_titan, MediaItem) - assert attack_on_titan.id == 16498 - assert attack_on_titan.title.english == "Attack on Titan" - - # Assert that user-specific data was mapped correctly - assert attack_on_titan.user_list_status is not None - assert attack_on_titan.user_list_status.status == "CURRENT" - assert attack_on_titan.user_list_status.progress == 10 - assert attack_on_titan.user_list_status.score == 9.0 - - -def test_update_list_entry_sends_correct_mutation( - anilist_client: AniListApi, httpx_mock: HTTPXMock -): - """ - GIVEN an authenticated client - WHEN update_list_entry is called - THEN it should send a POST request with the correct GraphQL mutation and variables. - """ - # ARRANGE - httpx_mock.add_response( - url="https://graphql.anilist.co", - json={"data": {"SaveMediaListEntry": {"id": 54321}}}, - ) - anilist_client.token = "fake-token" # Simulate authentication - - params = UpdateListEntryParams(media_id=16498, progress=11, status="CURRENT") - - # ACT - success = anilist_client.update_list_entry(params) - - # ASSERT - assert success is True - - # Verify the request content - request = httpx_mock.get_request() - assert request is not None - assert request.method == "POST" - - request_body = json.loads(request.content) - assert "SaveMediaListEntry" in request_body["query"] - assert request_body["variables"]["mediaId"] == 16498 - assert request_body["variables"]["progress"] == 11 - assert request_body["variables"]["status"] == "CURRENT" - assert ( - "scoreRaw" not in request_body["variables"] - ) # Ensure None values are excluded - - -def test_api_calls_fail_gracefully_on_http_error( - anilist_client: AniListApi, httpx_mock: HTTPXMock -): - """ - GIVEN the AniList API returns a 500 server error - WHEN any API method is called - THEN it should return None or False and log an error without crashing. - """ - # ARRANGE - httpx_mock.add_response(url="https://graphql.anilist.co", status_code=500) - - # ACT & ASSERT - with pytest.logs("fastanime.libs.api.anilist.api", level="ERROR") as caplog: - search_result = anilist_client.search_media(ApiSearchParams(query="test")) - assert search_result is None - assert "AniList API request failed" in caplog.text - - update_result = anilist_client.update_list_entry( - UpdateListEntryParams(media_id=1) - ) - assert update_result is False # Mutations should return bool diff --git a/tests/api/anilist/test_anilist_api_intergration.py b/tests/api/anilist/test_anilist_api_intergration.py deleted file mode 100644 index 20f0907..0000000 --- a/tests/api/anilist/test_anilist_api_intergration.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import os - -import pytest -from fastanime.core.config import AnilistConfig, AppConfig -from fastanime.libs.api.base import ApiSearchParams -from fastanime.libs.api.factory import create_api_client -from fastanime.libs.api.types import MediaItem, MediaSearchResult -from httpx import Client - -# Mark the entire module as 'integration'. This test will only run if you explicitly ask for it. -pytestmark = pytest.mark.integration - - -@pytest.fixture(scope="module") -def live_api_client() -> AniListApi: - """ - Creates an API client that makes REAL network requests. - This fixture has 'module' scope so it's created only once for all tests in this file. - """ - # We create a dummy AppConfig to pass to the factory - # Note: For authenticated tests, you would load a real token from env vars here. - config = AppConfig() - return create_api_client("anilist", config) - - -def test_search_media_live(live_api_client: AniListApi): - """ - GIVEN a live connection to the AniList API - WHEN search_media is called with a common query - THEN it should return a valid and non-empty MediaSearchResult. - """ - # ARRANGE - params = ApiSearchParams(query="Cowboy Bebop", per_page=1) - - # ACT - result = live_api_client.search_media(params) - - # ASSERT - assert result is not None - assert isinstance(result, MediaSearchResult) - assert len(result.media) > 0 - - cowboy_bebop = result.media[0] - assert isinstance(cowboy_bebop, MediaItem) - assert cowboy_bebop.id == 1 # Cowboy Bebop's AniList ID - assert "Cowboy Bebop" in cowboy_bebop.title.english - assert "Action" in cowboy_bebop.genres - - -@pytest.mark.skipif( - not os.getenv("ANILIST_TOKEN"), reason="ANILIST_TOKEN environment variable not set" -) -def test_authenticated_fetch_user_list_live(): - """ - GIVEN a valid ANILIST_TOKEN is set as an environment variable - WHEN fetching the user's 'CURRENT' list - THEN it should succeed and return a MediaSearchResult. - """ - # ARRANGE - # For authenticated tests, we create a client inside the test - # so we can configure it with a real token. - token = os.getenv("ANILIST_TOKEN") - config = AppConfig() # Dummy config - - # Create a real client and authenticate it - from fastanime.libs.api.anilist.api import AniListApi - - real_http_client = Client() - live_auth_client = AniListApi(config.anilist, real_http_client) - profile = live_auth_client.authenticate(token) - - assert profile is not None, "Authentication failed with the provided ANILIST_TOKEN" - - # ACT - from fastanime.libs.api.base import UserListParams - - params = UserListParams(status="CURRENT", per_page=5) - result = live_auth_client.fetch_user_list(params) - - # ASSERT - # We can't know the exact content, but we can check the structure. - assert result is not None - assert isinstance(result, MediaSearchResult) - # It's okay if the list is empty, but the call should succeed. - assert isinstance(result.media, list) diff --git a/tests/interactive/menus/README.md b/tests/interactive/menus/README.md deleted file mode 100644 index b180ff0..0000000 --- a/tests/interactive/menus/README.md +++ /dev/null @@ -1,334 +0,0 @@ -# Interactive Menu Tests - -This directory contains comprehensive test suites for all interactive menu functionality in FastAnime. - -## Test Structure - -``` -tests/interactive/menus/ -โ”œโ”€โ”€ conftest.py # Shared fixtures and utilities -โ”œโ”€โ”€ __init__.py # Package marker -โ”œโ”€โ”€ run_tests.py # Test runner script -โ”œโ”€โ”€ README.md # This file -โ”œโ”€โ”€ test_main.py # Tests for main menu -โ”œโ”€โ”€ test_results.py # Tests for results menu -โ”œโ”€โ”€ test_auth.py # Tests for authentication menu -โ”œโ”€โ”€ test_media_actions.py # Tests for media actions menu -โ”œโ”€โ”€ test_episodes.py # Tests for episodes menu -โ”œโ”€โ”€ test_servers.py # Tests for servers menu -โ”œโ”€โ”€ test_player_controls.py # Tests for player controls menu -โ”œโ”€โ”€ test_provider_search.py # Tests for provider search menu -โ”œโ”€โ”€ test_session_management.py # Tests for session management menu -โ””โ”€โ”€ test_watch_history.py # Tests for watch history menu -``` - -## Test Categories - -### Unit Tests - -Each menu has its own comprehensive test file that covers: - -- Menu display and option rendering -- User interaction handling -- State transitions -- Error handling -- Configuration options (icons, preferences) -- Helper function testing - -### Integration Tests - -Tests marked with `@pytest.mark.integration` require network connectivity and test: - -- Real API interactions -- Authentication flows -- Data fetching and processing - -## Test Coverage - -Each test file covers the following aspects: - -### Main Menu Tests (`test_main.py`) - -- Option display with/without icons -- Navigation to different categories (trending, popular, etc.) -- Search functionality -- User list access (authenticated/unauthenticated) -- Authentication and session management -- Configuration editing -- Helper function testing - -### Results Menu Tests (`test_results.py`) - -- Search result display -- Pagination handling -- Anime selection -- Preview functionality -- Authentication status display -- Helper function testing - -### Authentication Menu Tests (`test_auth.py`) - -- Login/logout flows -- OAuth authentication -- Token input handling -- Profile display -- Authentication status management -- Helper function testing - -### Media Actions Menu Tests (`test_media_actions.py`) - -- Action menu display -- Streaming initiation -- Trailer playback -- List management -- Scoring functionality -- Local history tracking -- Information display -- Helper function testing - -### Episodes Menu Tests (`test_episodes.py`) - -- Episode list display -- Watch history continuation -- Episode selection -- Translation type handling -- Progress tracking -- Helper function testing - -### Servers Menu Tests (`test_servers.py`) - -- Server fetching and display -- Server selection -- Quality filtering -- Auto-server selection -- Player integration -- Error handling -- Helper function testing - -### Player Controls Menu Tests (`test_player_controls.py`) - -- Post-playback options -- Next episode handling -- Auto-next functionality -- Progress tracking -- Replay functionality -- Server switching -- Helper function testing - -### Provider Search Menu Tests (`test_provider_search.py`) - -- Provider anime search -- Auto-selection based on similarity -- Manual selection handling -- Preview integration -- Error handling -- Helper function testing - -### Session Management Menu Tests (`test_session_management.py`) - -- Session saving/loading -- Session listing and statistics -- Session deletion -- Auto-save configuration -- Backup creation -- Helper function testing - -### Watch History Menu Tests (`test_watch_history.py`) - -- History display and navigation -- History management (clear, export, import) -- Statistics calculation -- Anime selection from history -- Helper function testing - -## Fixtures and Utilities - -### Shared Fixtures (`conftest.py`) - -- `mock_config`: Mock application configuration -- `mock_provider`: Mock anime provider -- `mock_selector`: Mock UI selector -- `mock_player`: Mock media player -- `mock_media_api`: Mock API client -- `mock_context`: Complete mock context -- `sample_media_item`: Sample AniList anime data -- `sample_provider_anime`: Sample provider anime data -- `sample_search_results`: Sample search results -- Various state fixtures for different scenarios - -### Test Utilities - -- `assert_state_transition()`: Assert proper state transitions -- `assert_control_flow()`: Assert control flow returns -- `setup_selector_choices()`: Configure mock selector choices -- `setup_selector_inputs()`: Configure mock selector inputs - -## Running Tests - -### Run All Menu Tests - -```bash -python tests/interactive/menus/run_tests.py -``` - -### Run Specific Menu Tests - -```bash -python tests/interactive/menus/run_tests.py --menu main -python tests/interactive/menus/run_tests.py --menu auth -python tests/interactive/menus/run_tests.py --menu episodes -``` - -### Run with Coverage - -```bash -python tests/interactive/menus/run_tests.py --coverage -``` - -### Run Integration Tests Only - -```bash -python tests/interactive/menus/run_tests.py --integration -``` - -### Using pytest directly - -```bash -# Run all menu tests -pytest tests/interactive/menus/ -v - -# Run specific test file -pytest tests/interactive/menus/test_main.py -v - -# Run with coverage -pytest tests/interactive/menus/ --cov=fastanime.cli.interactive.menus --cov-report=html - -# Run integration tests only -pytest tests/interactive/menus/ -m integration - -# Run specific test class -pytest tests/interactive/menus/test_main.py::TestMainMenu -v - -# Run specific test method -pytest tests/interactive/menus/test_main.py::TestMainMenu::test_main_menu_displays_options -v -``` - -## Test Patterns - -### Menu Function Testing - -```python -def test_menu_function(self, mock_context, test_state): - """Test the menu function with specific setup.""" - # Setup - mock_context.selector.choose.return_value = "Expected Choice" - - # Execute - result = menu_function(mock_context, test_state) - - # Assert - assert isinstance(result, State) - assert result.menu_name == "EXPECTED_STATE" -``` - -### Error Handling Testing - -```python -def test_menu_error_handling(self, mock_context, test_state): - """Test menu handles errors gracefully.""" - # Setup error condition - mock_context.provider.some_method.side_effect = Exception("Test error") - - # Execute - result = menu_function(mock_context, test_state) - - # Assert error handling - assert result == ControlFlow.BACK # or appropriate error response -``` - -### State Transition Testing - -```python -def test_state_transition(self, mock_context, initial_state): - """Test proper state transitions.""" - # Setup - mock_context.selector.choose.return_value = "Next State Option" - - # Execute - result = menu_function(mock_context, initial_state) - - # Assert state transition - assert_state_transition(result, "NEXT_STATE") - assert result.media_api.anime == initial_state.media_api.anime # State preservation -``` - -## Mocking Strategies - -### API Mocking - -```python -# Mock successful API calls -mock_context.media_api.search_media.return_value = sample_search_results - -# Mock API failures -mock_context.media_api.search_media.side_effect = Exception("API Error") -``` - -### User Input Mocking - -```python -# Mock menu selection -mock_context.selector.choose.return_value = "Selected Option" - -# Mock text input -mock_context.selector.ask.return_value = "User Input" - -# Mock cancelled selections -mock_context.selector.choose.return_value = None -``` - -### Configuration Mocking - -```python -# Mock configuration options -mock_context.config.general.icons = True -mock_context.config.stream.auto_next = False -mock_context.config.anilist.per_page = 15 -``` - -## Adding New Tests - -When adding tests for new menus: - -1. Create a new test file: `test_[menu_name].py` -2. Import the menu function and required fixtures -3. Create test classes for the main menu and helper functions -4. Follow the established patterns for testing: - - Menu display and options - - User interactions and selections - - State transitions - - Error handling - - Configuration variations - - Helper functions -5. Add the menu name to the choices in `run_tests.py` -6. Update this README with the new test coverage - -## Best Practices - -1. **Test Isolation**: Each test should be independent and not rely on other tests -2. **Clear Naming**: Test names should clearly describe what is being tested -3. **Comprehensive Coverage**: Test both happy paths and error conditions -4. **Realistic Mocks**: Mock data should represent realistic scenarios -5. **State Verification**: Always verify that state transitions are correct -6. **Error Testing**: Test error handling and edge cases -7. **Configuration Testing**: Test menu behavior with different configuration options -8. **Documentation**: Document complex test scenarios and mock setups - -## Continuous Integration - -These tests are designed to run in CI environments: - -- Unit tests run without external dependencies -- Integration tests can be skipped in CI if needed -- Coverage reports help maintain code quality -- Fast execution for quick feedback loops diff --git a/tests/interactive/menus/__init__.py b/tests/interactive/menus/__init__.py deleted file mode 100644 index 05a45eb..0000000 --- a/tests/interactive/menus/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Test package for interactive menu tests diff --git a/tests/interactive/menus/conftest.py b/tests/interactive/menus/conftest.py deleted file mode 100644 index a2e12c8..0000000 --- a/tests/interactive/menus/conftest.py +++ /dev/null @@ -1,270 +0,0 @@ -""" -Shared test fixtures and utilities for menu testing. -""" - -from unittest.mock import Mock, MagicMock -from pathlib import Path -import pytest -from typing import Iterator, List, Optional - -from fastanime.core.config.model import AppConfig, GeneralConfig, StreamConfig, AnilistConfig -from fastanime.cli.interactive.session import Context -from fastanime.cli.interactive.state import State, ProviderState, MediaApiState, ControlFlow -from fastanime.libs.api.types import MediaItem, MediaSearchResult, UserProfile, MediaTitle, MediaImage, Studio -from fastanime.libs.api.types import PageInfo as ApiPageInfo -from fastanime.libs.api.params import ApiSearchParams, UserListParams -from fastanime.libs.providers.anime.types import Anime, SearchResults, Server, PageInfo, SearchResult, AnimeEpisodes -from fastanime.libs.players.types import PlayerResult - - -@pytest.fixture -def mock_config(): - """Create a mock configuration object.""" - return AppConfig( - general=GeneralConfig( - icons=True, - provider="allanime", - selector="fzf", - api_client="anilist", - preview="text", - auto_select_anime_result=True, - cache_requests=True, - normalize_titles=True, - discord=False, - recent=50 - ), - stream=StreamConfig( - player="mpv", - quality="1080", - translation_type="sub", - server="TOP", - auto_next=False, - continue_from_watch_history=True, - preferred_watch_history="local" - ), - anilist=AnilistConfig( - per_page=15, - sort_by="SEARCH_MATCH", - preferred_language="english" - ) - ) - - -@pytest.fixture -def mock_provider(): - """Create a mock anime provider.""" - provider = Mock() - provider.search_anime.return_value = SearchResults( - page_info=PageInfo( - total=1, - per_page=15, - current_page=1 - ), - results=[ - SearchResult( - id="anime1", - title="Test Anime 1", - episodes=AnimeEpisodes(sub=["1", "2", "3"]), - poster="https://example.com/poster1.jpg" - ) - ] - ) - return provider - - -@pytest.fixture -def mock_selector(): - """Create a mock selector.""" - selector = Mock() - selector.choose.return_value = "Test Choice" - selector.ask.return_value = "Test Input" - return selector - - -@pytest.fixture -def mock_player(): - """Create a mock player.""" - player = Mock() - player.play.return_value = PlayerResult(stop_time="00:15:30", total_time="00:23:45") - return player - - -@pytest.fixture -def mock_media_api(): - """Create a mock media API client.""" - api = Mock() - - # Mock user profile - api.user_profile = UserProfile( - id=12345, - name="TestUser", - avatar_url="https://example.com/avatar.jpg" - ) - - # Mock search results - api.search_media.return_value = MediaSearchResult( - media=[ - MediaItem( - id=1, - title=MediaTitle(english="Test Anime", romaji="Test Anime"), - status="FINISHED", - episodes=12, - description="A test anime", - cover_image=MediaImage(large="https://example.com/cover.jpg"), - banner_image="https://example.com/banner.jpg", - genres=["Action", "Adventure"], - studios=[Studio(name="Test Studio")] - ) - ], - page_info=ApiPageInfo( - total=1, - per_page=15, - current_page=1, - has_next_page=False - ) - ) - - # Mock user list - api.fetch_user_list.return_value = api.search_media.return_value - - # Mock authentication methods - api.is_authenticated.return_value = True - api.authenticate.return_value = True - - return api - - -@pytest.fixture -def mock_context(mock_config, mock_provider, mock_selector, mock_player, mock_media_api): - """Create a mock context object.""" - return Context( - config=mock_config, - provider=mock_provider, - selector=mock_selector, - player=mock_player, - media_api=mock_media_api - ) - - -@pytest.fixture -def sample_media_item(): - """Create a sample MediaItem for testing.""" - return MediaItem( - id=1, - title=MediaTitle(english="Test Anime", romaji="Test Anime"), - status="FINISHED", - episodes=12, - description="A test anime", - cover_image=MediaImage(large="https://example.com/cover.jpg"), - banner_image="https://example.com/banner.jpg", - genres=["Action", "Adventure"], - studios=[Studio(name="Test Studio")] - ) - - -@pytest.fixture -def sample_provider_anime(): - """Create a sample provider Anime for testing.""" - return Anime( - id="test-anime", - title="Test Anime", - episodes=AnimeEpisodes(sub=["1", "2", "3"]), - poster="https://example.com/poster.jpg" - ) - - -@pytest.fixture -def sample_search_results(sample_media_item): - """Create sample search results.""" - return MediaSearchResult( - media=[sample_media_item], - page_info=ApiPageInfo( - total=1, - per_page=15, - current_page=1, - has_next_page=False - ) - ) - - -@pytest.fixture -def empty_state(): - """Create an empty state.""" - return State(menu_name="TEST") - - -@pytest.fixture -def state_with_media_api(sample_search_results, sample_media_item): - """Create a state with media API data.""" - return State( - menu_name="TEST", - media_api=MediaApiState( - search_results=sample_search_results, - anime=sample_media_item - ) - ) - - -@pytest.fixture -def state_with_provider(sample_provider_anime): - """Create a state with provider data.""" - return State( - menu_name="TEST", - provider=ProviderState( - anime=sample_provider_anime, - episode_number="1" - ) - ) - - -@pytest.fixture -def full_state(sample_search_results, sample_media_item, sample_provider_anime): - """Create a state with both media API and provider data.""" - return State( - menu_name="TEST", - media_api=MediaApiState( - search_results=sample_search_results, - anime=sample_media_item - ), - provider=ProviderState( - anime=sample_provider_anime, - episode_number="1" - ) - ) - - -# Test utilities - -def assert_state_transition(result, expected_menu_name: str): - """Assert that a menu function returned a proper state transition.""" - assert isinstance(result, State) - assert result.menu_name == expected_menu_name - - -def assert_control_flow(result, expected_flow: ControlFlow): - """Assert that a menu function returned the expected control flow.""" - assert isinstance(result, ControlFlow) - assert result == expected_flow - - -def setup_selector_choices(mock_selector, choices: List[str]): - """Setup mock selector to return specific choices in sequence.""" - mock_selector.choose.side_effect = choices - - -def setup_selector_inputs(mock_selector, inputs: List[str]): - """Setup mock selector to return specific inputs in sequence.""" - mock_selector.ask.side_effect = inputs - - -# Mock feedback manager -@pytest.fixture -def mock_feedback(): - """Create a mock feedback manager.""" - feedback = Mock() - feedback.success.return_value = None - feedback.error.return_value = None - feedback.info.return_value = None - feedback.confirm.return_value = True - feedback.pause_for_user.return_value = None - return feedback diff --git a/tests/interactive/menus/run_tests.py b/tests/interactive/menus/run_tests.py deleted file mode 100644 index ffbf58d..0000000 --- a/tests/interactive/menus/run_tests.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -Test runner for all interactive menu tests. -This file can be used to run all menu tests at once or specific test suites. -""" - -import pytest -import sys -from pathlib import Path - -# Add the project root to the Python path -project_root = Path(__file__).parent.parent.parent.parent -sys.path.insert(0, str(project_root)) - - -def run_all_menu_tests(): - """Run all menu tests.""" - test_dir = Path(__file__).parent - return pytest.main([str(test_dir), "-v"]) - - -def run_specific_menu_test(menu_name: str): - """Run tests for a specific menu.""" - test_file = Path(__file__).parent / f"test_{menu_name}.py" - if test_file.exists(): - return pytest.main([str(test_file), "-v"]) - else: - print(f"Test file for menu '{menu_name}' not found.") - return 1 - - -def run_menu_test_with_coverage(): - """Run menu tests with coverage report.""" - test_dir = Path(__file__).parent - return pytest.main([ - str(test_dir), - "--cov=fastanime.cli.interactive.menus", - "--cov-report=html", - "--cov-report=term-missing", - "-v" - ]) - - -def run_integration_tests(): - """Run integration tests that require network connectivity.""" - test_dir = Path(__file__).parent - return pytest.main([str(test_dir), "-m", "integration", "-v"]) - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser(description="Run interactive menu tests") - parser.add_argument( - "--menu", - help="Run tests for a specific menu", - choices=[ - "main", "results", "auth", "media_actions", "episodes", - "servers", "player_controls", "provider_search", - "session_management", "watch_history" - ] - ) - parser.add_argument( - "--coverage", - action="store_true", - help="Run tests with coverage report" - ) - parser.add_argument( - "--integration", - action="store_true", - help="Run integration tests only" - ) - - args = parser.parse_args() - - if args.integration: - exit_code = run_integration_tests() - elif args.coverage: - exit_code = run_menu_test_with_coverage() - elif args.menu: - exit_code = run_specific_menu_test(args.menu) - else: - exit_code = run_all_menu_tests() - - sys.exit(exit_code) diff --git a/tests/interactive/menus/test_auth.py b/tests/interactive/menus/test_auth.py deleted file mode 100644 index d31a0dc..0000000 --- a/tests/interactive/menus/test_auth.py +++ /dev/null @@ -1,374 +0,0 @@ -""" -Tests for the authentication menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch, MagicMock - -from fastanime.cli.interactive.menus.auth import auth -from fastanime.cli.interactive.state import ControlFlow, State -from fastanime.libs.api.types import UserProfile - - -class TestAuthMenu: - """Test cases for the authentication menu.""" - - def test_auth_menu_not_authenticated(self, mock_context, empty_state): - """Test auth menu when user is not authenticated.""" - # User not authenticated - mock_context.media_api.user_profile = None - mock_context.selector.choose.return_value = None - - result = auth(mock_context, empty_state) - - # Should go back when no choice is made - assert result == ControlFlow.BACK - - # Verify selector was called with login options - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Should contain login options - login_options = ["Login to AniList", "How to Get Token", "Back to Main Menu"] - for option in login_options: - assert any(option in choice for choice in choices) - - def test_auth_menu_authenticated(self, mock_context, empty_state): - """Test auth menu when user is authenticated.""" - # User authenticated - mock_context.media_api.user_profile = UserProfile( - id=12345, - name="TestUser", - avatar="https://example.com/avatar.jpg" - ) - mock_context.selector.choose.return_value = None - - result = auth(mock_context, empty_state) - - # Should go back when no choice is made - assert result == ControlFlow.BACK - - # Verify selector was called with authenticated options - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Should contain authenticated user options - auth_options = ["View Profile Details", "Logout", "Back to Main Menu"] - for option in auth_options: - assert any(option in choice for choice in choices) - - def test_auth_menu_login_selection(self, mock_context, empty_state): - """Test selecting login from auth menu.""" - mock_context.media_api.user_profile = None - - # Setup selector to return login choice - login_choice = "๐Ÿ” Login to AniList" - mock_context.selector.choose.return_value = login_choice - - with patch('fastanime.cli.interactive.menus.auth._handle_login') as mock_login: - mock_login.return_value = State(menu_name="MAIN") - - result = auth(mock_context, empty_state) - - # Should call login handler - mock_login.assert_called_once() - assert isinstance(result, State) - - def test_auth_menu_logout_selection(self, mock_context, empty_state): - """Test selecting logout from auth menu.""" - mock_context.media_api.user_profile = UserProfile( - id=12345, - name="TestUser", - avatar="https://example.com/avatar.jpg" - ) - - # Setup selector to return logout choice - logout_choice = "๐Ÿ”“ Logout" - mock_context.selector.choose.return_value = logout_choice - - with patch('fastanime.cli.interactive.menus.auth._handle_logout') as mock_logout: - mock_logout.return_value = ControlFlow.CONTINUE - - result = auth(mock_context, empty_state) - - # Should call logout handler - mock_logout.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_auth_menu_view_profile_selection(self, mock_context, empty_state): - """Test selecting view profile from auth menu.""" - mock_context.media_api.user_profile = UserProfile( - id=12345, - name="TestUser", - avatar="https://example.com/avatar.jpg" - ) - - # Setup selector to return profile choice - profile_choice = "๐Ÿ‘ค View Profile Details" - mock_context.selector.choose.return_value = profile_choice - - with patch('fastanime.cli.interactive.menus.auth._display_user_profile_details') as mock_display: - with patch('fastanime.cli.interactive.menus.auth.create_feedback_manager') as mock_feedback: - mock_feedback_obj = Mock() - mock_feedback.return_value = mock_feedback_obj - - result = auth(mock_context, empty_state) - - # Should display profile details and continue - mock_display.assert_called_once() - mock_feedback_obj.pause_for_user.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_auth_menu_token_help_selection(self, mock_context, empty_state): - """Test selecting token help from auth menu.""" - mock_context.media_api.user_profile = None - - # Setup selector to return help choice - help_choice = "โ“ How to Get Token" - mock_context.selector.choose.return_value = help_choice - - with patch('fastanime.cli.interactive.menus.auth._display_token_help') as mock_help: - with patch('fastanime.cli.interactive.menus.auth.create_feedback_manager') as mock_feedback: - mock_feedback_obj = Mock() - mock_feedback.return_value = mock_feedback_obj - - result = auth(mock_context, empty_state) - - # Should display token help and continue - mock_help.assert_called_once() - mock_feedback_obj.pause_for_user.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_auth_menu_back_selection(self, mock_context, empty_state): - """Test selecting back from auth menu.""" - mock_context.media_api.user_profile = None - - # Setup selector to return back choice - back_choice = "โ†ฉ๏ธ Back to Main Menu" - mock_context.selector.choose.return_value = back_choice - - result = auth(mock_context, empty_state) - - assert result == ControlFlow.BACK - - def test_auth_menu_icons_enabled(self, mock_context, empty_state): - """Test auth menu with icons enabled.""" - mock_context.config.general.icons = True - mock_context.media_api.user_profile = None - mock_context.selector.choose.return_value = None - - result = auth(mock_context, empty_state) - - # Should work with icons enabled - assert result == ControlFlow.BACK - - def test_auth_menu_icons_disabled(self, mock_context, empty_state): - """Test auth menu with icons disabled.""" - mock_context.config.general.icons = False - mock_context.media_api.user_profile = None - mock_context.selector.choose.return_value = None - - result = auth(mock_context, empty_state) - - # Should work with icons disabled - assert result == ControlFlow.BACK - - -class TestAuthMenuHelperFunctions: - """Test the helper functions in auth menu.""" - - def test_display_auth_status_authenticated(self, mock_context): - """Test displaying auth status when authenticated.""" - from fastanime.cli.interactive.menus.auth import _display_auth_status - - console = Mock() - user_profile = UserProfile( - id=12345, - name="TestUser", - avatar_url="https://example.com/avatar.jpg" - ) - - _display_auth_status(console, user_profile, True) - - # Should print panel with user info - console.print.assert_called() - # Check that panel was created and the user's name appears in the content - call_args = console.print.call_args_list[0][0][0] # Get the Panel object - assert "TestUser" in call_args.renderable - assert "12345" in call_args.renderable - - def test_display_auth_status_not_authenticated(self, mock_context): - """Test displaying auth status when not authenticated.""" - from fastanime.cli.interactive.menus.auth import _display_auth_status - - console = Mock() - - _display_auth_status(console, None, True) - - # Should print panel with login info - console.print.assert_called() - # Check that panel was created with login information - call_args = console.print.call_args_list[0][0][0] # Get the Panel object - assert "Log in to access" in call_args.renderable - - def test_handle_login_success(self, mock_context): - """Test successful login process.""" - from fastanime.cli.interactive.menus.auth import _handle_login - - auth_manager = Mock() - feedback = Mock() - - # Mock successful confirmation for browser opening - feedback.confirm.return_value = True - - # Mock token input - mock_context.selector.ask.return_value = "valid_token" - - # Mock successful authentication - mock_profile = UserProfile(id=123, name="TestUser") - mock_context.media_api.authenticate.return_value = mock_profile - - with patch('fastanime.cli.interactive.menus.auth.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_profile) - - result = _handle_login(mock_context, auth_manager, feedback, True) - - # Should return CONTINUE on success - assert result == ControlFlow.CONTINUE - - def test_handle_login_empty_token(self, mock_context): - """Test login with empty token.""" - from fastanime.cli.interactive.menus.auth import _handle_login - - auth_manager = Mock() - feedback = Mock() - - # Mock confirmation for browser opening - feedback.confirm.return_value = True - - # Mock empty token input - mock_context.selector.ask.return_value = "" - - result = _handle_login(mock_context, auth_manager, feedback, True) - - # Should return CONTINUE when no token provided - assert result == ControlFlow.CONTINUE - - def test_handle_login_failed_auth(self, mock_context): - """Test login with failed authentication.""" - from fastanime.cli.interactive.menus.auth import _handle_login - - auth_manager = Mock() - feedback = Mock() - - # Mock successful confirmation for browser opening - feedback.confirm.return_value = True - - # Mock token input - mock_context.selector.ask.return_value = "invalid_token" - - # Mock failed authentication - mock_context.media_api.authenticate.return_value = None - - with patch('fastanime.cli.interactive.menus.auth.execute_with_feedback') as mock_execute: - mock_execute.return_value = (False, None) - - result = _handle_login(mock_context, auth_manager, feedback, True) - - # Should return CONTINUE on failed auth - assert result == ControlFlow.CONTINUE - - def test_handle_login_back_selection(self, mock_context): - """Test handling login with back selection.""" - from fastanime.cli.interactive.menus.auth import _handle_login - - auth_manager = Mock() - feedback = Mock() - - # Mock selector to choose back - mock_context.selector.choose.return_value = "โ†ฉ๏ธ Back" - - result = _handle_login(mock_context, auth_manager, feedback, True) - - # Should return CONTINUE (stay in auth menu) - assert result == ControlFlow.CONTINUE - - def test_handle_logout_success(self, mock_context): - """Test successful logout.""" - from fastanime.cli.interactive.menus.auth import _handle_logout - - auth_manager = Mock() - feedback = Mock() - - # Mock successful logout - auth_manager.logout.return_value = True - feedback.confirm.return_value = True - - result = _handle_logout(mock_context, auth_manager, feedback, True) - - # Should logout and reload context - auth_manager.logout.assert_called_once() - assert result == ControlFlow.RELOAD_CONFIG - - def test_handle_logout_cancelled(self, mock_context): - """Test cancelled logout.""" - from fastanime.cli.interactive.menus.auth import _handle_logout - - auth_manager = Mock() - feedback = Mock() - - # Mock cancelled logout - feedback.confirm.return_value = False - - result = _handle_logout(mock_context, auth_manager, feedback, True) - - # Should not logout and continue - auth_manager.logout.assert_not_called() - assert result == ControlFlow.CONTINUE - - def test_handle_logout_failure(self, mock_context): - """Test failed logout.""" - from fastanime.cli.interactive.menus.auth import _handle_logout - - auth_manager = Mock() - feedback = Mock() - - # Mock failed logout - feedback.confirm.return_value = True - - with patch('fastanime.cli.interactive.menus.auth.execute_with_feedback') as mock_execute: - mock_execute.return_value = (False, None) - - result = _handle_logout(mock_context, auth_manager, feedback, True) - - # Should return RELOAD_CONFIG even on failure because execute_with_feedback handles the error - assert result == ControlFlow.RELOAD_CONFIG - - def test_display_user_profile_details(self, mock_context): - """Test displaying user profile details.""" - from fastanime.cli.interactive.menus.auth import _display_user_profile_details - - console = Mock() - user_profile = UserProfile( - id=12345, - name="TestUser", - avatar_url="https://example.com/avatar.jpg" - ) - - _display_user_profile_details(console, user_profile, True) - - # Should print table with user details - console.print.assert_called() - - def test_display_token_help(self, mock_context): - """Test displaying token help information.""" - from fastanime.cli.interactive.menus.auth import _display_token_help - - console = Mock() - - _display_token_help(console, True) - - # Should print help information - console.print.assert_called() diff --git a/tests/interactive/menus/test_episodes.py b/tests/interactive/menus/test_episodes.py deleted file mode 100644 index 4d154d9..0000000 --- a/tests/interactive/menus/test_episodes.py +++ /dev/null @@ -1,324 +0,0 @@ -""" -Tests for the episodes menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch - -from fastanime.cli.interactive.menus.episodes import episodes -from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState -from fastanime.libs.providers.anime.types import Anime, AnimeEpisodes - - -class TestEpisodesMenu: - """Test cases for the episodes menu.""" - - def test_episodes_menu_missing_anime_data(self, mock_context, empty_state): - """Test episodes menu with missing anime data.""" - # State without provider or media API anime - result = episodes(mock_context, empty_state) - - # Should go back when anime data is missing - assert result == ControlFlow.BACK - - def test_episodes_menu_missing_provider_anime(self, mock_context, state_with_media_api): - """Test episodes menu with missing provider anime.""" - result = episodes(mock_context, state_with_media_api) - - # Should go back when provider anime is missing - assert result == ControlFlow.BACK - - def test_episodes_menu_missing_media_api_anime(self, mock_context, state_with_provider): - """Test episodes menu with missing media API anime.""" - result = episodes(mock_context, state_with_provider) - - # Should go back when media API anime is missing - assert result == ControlFlow.BACK - - def test_episodes_menu_no_episodes_available(self, mock_context, full_state): - """Test episodes menu when no episodes are available for translation type.""" - # Mock provider anime with no sub episodes - provider_anime = Anime( - id="test-anime", - title="Test Anime", - episodes=AnimeEpisodes(sub=[], dub=["1", "2", "3"]), # No sub episodes - poster="https://example.com/poster.jpg" - ) - - state_no_sub = State( - menu_name="EPISODES", - media_api=full_state.media_api, - provider=ProviderState(anime=provider_anime) - ) - - # Config set to sub but no sub episodes available - mock_context.config.stream.translation_type = "sub" - - result = episodes(mock_context, state_no_sub) - - # Should go back when no episodes available for translation type - assert result == ControlFlow.BACK - - def test_episodes_menu_continue_from_local_history(self, mock_context, full_state): - """Test episodes menu with local watch history continuation.""" - # Setup provider anime with episodes - provider_anime = Anime( - title="Test Anime", - - id="test-anime", - poster="https://example.com/poster.jpg", - episodes=AnimeEpisodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) - ) - - state_with_episodes = State( - menu_name="EPISODES", - media_api=full_state.media_api, - provider=ProviderState(anime=provider_anime) - ) - - # Enable continue from watch history with local preference - mock_context.config.stream.continue_from_watch_history = True - mock_context.config.stream.preferred_watch_history = "local" - - with patch('fastanime.cli.utils.watch_history_tracker.get_continue_episode') as mock_continue: - mock_continue.return_value = "2" # Continue from episode 2 - - with patch('fastanime.cli.interactive.menus.episodes.click.echo'): - result = episodes(mock_context, state_with_episodes) - - # Should transition to SERVERS state with the continue episode - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - assert result.provider.episode_number == "2" - - def test_episodes_menu_continue_from_anilist_progress(self, mock_context, full_state): - """Test episodes menu with AniList progress continuation.""" - # Setup provider anime with episodes - provider_anime = Anime( - title="Test Anime", - - id="test-anime", - poster="https://example.com/poster.jpg", - episodes=AnimeEpisodes(sub=["1", "2", "3", "4", "5"], dub=["1", "2", "3", "4", "5"]) - ) - - # Setup media API anime with progress - media_anime = full_state.media_api.anime - # Set up user status with progress - if not media_anime.user_status: - from fastanime.libs.api.types import UserListStatus - media_anime.user_status = UserListStatus(id=1, progress=3) - else: - media_anime.user_status.progress = 3 # Watched 3 episodes - - state_with_episodes = State( - menu_name="EPISODES", - media_api=MediaApiState(anime=media_anime), - provider=ProviderState(anime=provider_anime) - ) - - # Enable continue from watch history with remote preference - mock_context.config.stream.continue_from_watch_history = True - mock_context.config.stream.preferred_watch_history = "remote" - - with patch('fastanime.cli.utils.watch_history_tracker.get_continue_episode') as mock_continue: - mock_continue.return_value = None # No local history - - with patch('fastanime.cli.interactive.menus.episodes.click.echo'): - result = episodes(mock_context, state_with_episodes) - - # Should transition to SERVERS state with next episode (4) - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - assert result.provider.episode_number == "4" - - def test_episodes_menu_manual_selection(self, mock_context, full_state): - """Test episodes menu with manual episode selection.""" - # Setup provider anime with episodes - provider_anime = Anime( - title="Test Anime", - - id="test-anime", - poster="https://example.com/poster.jpg", - episodes=AnimeEpisodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) - ) - - state_with_episodes = State( - menu_name="EPISODES", - media_api=full_state.media_api, - provider=ProviderState(anime=provider_anime) - ) - - # Disable continue from watch history - mock_context.config.stream.continue_from_watch_history = False - # Mock user selection - mock_context.selector.choose.return_value = "2" # Direct episode number - - result = episodes(mock_context, state_with_episodes) - - # Should transition to SERVERS state with selected episode - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - assert result.provider.episode_number == "2" - - def test_episodes_menu_no_selection_made(self, mock_context, full_state): - """Test episodes menu when no selection is made.""" - # Setup provider anime with episodes - provider_anime = Anime( - title="Test Anime", - - id="test-anime", - poster="https://example.com/poster.jpg", - episodes=AnimeEpisodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) - ) - - state_with_episodes = State( - menu_name="EPISODES", - media_api=full_state.media_api, - provider=ProviderState(anime=provider_anime) - ) - - # Disable continue from watch history - mock_context.config.stream.continue_from_watch_history = False - - # Mock no selection - mock_context.selector.choose.return_value = None - - result = episodes(mock_context, state_with_episodes) - - # Should go back when no selection is made - assert result == ControlFlow.BACK - - def test_episodes_menu_back_selection(self, mock_context, full_state): - """Test episodes menu back selection.""" - # Setup provider anime with episodes - provider_anime = Anime( - title="Test Anime", - - id="test-anime", - poster="https://example.com/poster.jpg", - episodes=AnimeEpisodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) - ) - - state_with_episodes = State( - menu_name="EPISODES", - media_api=full_state.media_api, - provider=ProviderState(anime=provider_anime) - ) - - # Disable continue from watch history - mock_context.config.stream.continue_from_watch_history = False - - # Mock back selection - mock_context.selector.choose.return_value = "Back" - - result = episodes(mock_context, state_with_episodes) - - # Should go back - assert result == ControlFlow.BACK - - def test_episodes_menu_invalid_episode_selection(self, mock_context, full_state): - """Test episodes menu with invalid episode selection.""" - # Setup provider anime with episodes - provider_anime = Anime( - title="Test Anime", - - id="test-anime", - poster="https://example.com/poster.jpg", - episodes=AnimeEpisodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) - ) - - state_with_episodes = State( - menu_name="EPISODES", - media_api=full_state.media_api, - provider=ProviderState(anime=provider_anime) - ) - - # Disable continue from watch history - mock_context.config.stream.continue_from_watch_history = False - - # Mock invalid selection (not in episode map) - mock_context.selector.choose.return_value = "Invalid Episode" - - result = episodes(mock_context, state_with_episodes) - - # Current implementation doesn't validate episode selection, - # so it will proceed to SERVERS state with the invalid episode - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - assert result.provider.episode_number == "Invalid Episode" - - def test_episodes_menu_dub_translation_type(self, mock_context, full_state): - """Test episodes menu with dub translation type.""" - # Setup provider anime with both sub and dub episodes - provider_anime = Anime( - title="Test Anime", - - id="test-anime", - poster="https://example.com/poster.jpg", - episodes=AnimeEpisodes(sub=["1", "2", "3"], dub=["1", "2"]) # Only 2 dub episodes - ) - - state_with_episodes = State( - menu_name="EPISODES", - media_api=full_state.media_api, - provider=ProviderState(anime=provider_anime) - ) - - # Set translation type to dub - mock_context.config.stream.translation_type = "dub" - mock_context.config.stream.continue_from_watch_history = False - - # Mock user selection - mock_context.selector.choose.return_value = "1" - - result = episodes(mock_context, state_with_episodes) - - # Should use dub episodes and transition to SERVERS - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - assert result.provider.episode_number == "1" - - # Verify that dub episodes were used (only 2 available) - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - # Should have only 2 dub episodes plus "Back" - assert len(choices) == 3 # "1", "2", "Back" - - def test_episodes_menu_track_episode_viewing(self, mock_context, full_state): - """Test that episode viewing is tracked when selected.""" - # Setup provider anime with episodes - provider_anime = Anime( - title="Test Anime", - - id="test-anime", - poster="https://example.com/poster.jpg", - episodes=AnimeEpisodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) - ) - - state_with_episodes = State( - menu_name="EPISODES", - media_api=full_state.media_api, - provider=ProviderState(anime=provider_anime) - ) - - # Enable tracking (need both continue_from_watch_history and local preference) - mock_context.config.stream.continue_from_watch_history = True - mock_context.config.stream.preferred_watch_history = "local" - mock_context.selector.choose.return_value = "2" - - with patch('fastanime.cli.utils.watch_history_tracker.get_continue_episode') as mock_continue: - mock_continue.return_value = None # No history, fall back to manual selection - with patch('fastanime.cli.utils.watch_history_tracker.track_episode_viewing') as mock_track: - result = episodes(mock_context, state_with_episodes) - - # Should track episode viewing - mock_track.assert_called_once() - - # Should transition to SERVERS - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - assert result.provider.episode_number == "2" - - diff --git a/tests/interactive/menus/test_main.py b/tests/interactive/menus/test_main.py deleted file mode 100644 index 01c1c50..0000000 --- a/tests/interactive/menus/test_main.py +++ /dev/null @@ -1,440 +0,0 @@ -""" -Tests for the main menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch, MagicMock - -from fastanime.cli.interactive.menus.main import main -from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState -from fastanime.libs.api.types import MediaSearchResult, PageInfo as ApiPageInfo - - -class TestMainMenu: - """Test cases for the main menu.""" - - def test_main_menu_displays_options(self, mock_context, empty_state): - """Test that the main menu displays all expected options.""" - # Setup selector to return None (exit) - mock_context.selector.choose.return_value = None - - result = main(mock_context, empty_state) - - # Should return EXIT when no choice is made - assert result == ControlFlow.EXIT - - # Verify selector was called with expected options - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Check that key options are present - expected_options = [ - "Trending", "Popular", "Favourites", "Top Scored", - "Upcoming", "Recently Updated", "Random", "Search", - "Watching", "Planned", "Completed", "Paused", "Dropped", "Rewatching", - "Local Watch History", "Authentication", "Session Management", - "Edit Config", "Exit" - ] - - for option in expected_options: - assert any(option in choice for choice in choices) - - def test_main_menu_trending_selection(self, mock_context, empty_state): - """Test selecting trending anime from main menu.""" - # Setup selector to return trending choice - trending_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Trending" in choice) - mock_context.selector.choose.return_value = trending_choice - - # Mock successful API call - mock_search_result = MediaSearchResult( - media=[], - page_info=ApiPageInfo( - total=0, - current_page=1, - has_next_page=False, - per_page=15 - ) - ) - mock_context.media_api.search_media.return_value = mock_search_result - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_search_result) - - result = main(mock_context, empty_state) - - # Should transition to RESULTS state - assert isinstance(result, State) - assert result.menu_name == "RESULTS" - assert result.media_api.search_results == mock_search_result - - def test_main_menu_search_selection(self, mock_context, empty_state): - """Test selecting search from main menu.""" - search_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Search" in choice) - mock_context.selector.choose.return_value = search_choice - mock_context.selector.ask.return_value = "test query" - - # Mock successful API call - mock_search_result = MediaSearchResult( - media=[], - page_info=ApiPageInfo( - total=0, - current_page=1, - has_next_page=False, - per_page=15 - ) - ) - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_search_result) - - result = main(mock_context, empty_state) - - # Should transition to RESULTS state - assert isinstance(result, State) - assert result.menu_name == "RESULTS" - assert result.media_api.search_results == mock_search_result - - def test_main_menu_search_empty_query(self, mock_context, empty_state): - """Test search with empty query returns to menu.""" - search_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Search" in choice) - mock_context.selector.choose.return_value = search_choice - mock_context.selector.ask.return_value = "" # Empty query - - result = main(mock_context, empty_state) - - # Should return CONTINUE when search query is empty - assert result == ControlFlow.CONTINUE - - def test_main_menu_user_list_authenticated(self, mock_context, empty_state): - """Test accessing user list when authenticated.""" - watching_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Watching" in choice) - mock_context.selector.choose.return_value = watching_choice - - # Ensure user is authenticated - mock_context.media_api.is_authenticated.return_value = True - - mock_search_result = MediaSearchResult( - media=[], - page_info=ApiPageInfo( - total=0, - current_page=1, - has_next_page=False, - per_page=15 - ) - ) - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_search_result) - - result = main(mock_context, empty_state) - - # Should transition to RESULTS state - assert isinstance(result, State) - assert result.menu_name == "RESULTS" - - def test_main_menu_user_list_not_authenticated(self, mock_context, empty_state): - """Test accessing user list when not authenticated.""" - watching_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Watching" in choice) - mock_context.selector.choose.return_value = watching_choice - - # User not authenticated - mock_context.media_api.is_authenticated.return_value = False - - with patch('fastanime.cli.interactive.menus.main.check_authentication_required') as mock_auth: - mock_auth.return_value = False # Authentication check fails - - result = main(mock_context, empty_state) - - # Should return CONTINUE when authentication is required but not provided - assert result == ControlFlow.CONTINUE - - def test_main_menu_exit_selection(self, mock_context, empty_state): - """Test selecting exit from main menu.""" - exit_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Exit" in choice) - mock_context.selector.choose.return_value = exit_choice - - result = main(mock_context, empty_state) - - assert result == ControlFlow.EXIT - - def test_main_menu_config_edit_selection(self, mock_context, empty_state): - """Test selecting config edit from main menu.""" - config_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Edit Config" in choice) - mock_context.selector.choose.return_value = config_choice - - result = main(mock_context, empty_state) - - assert result == ControlFlow.RELOAD_CONFIG - - def test_main_menu_session_management_selection(self, mock_context, empty_state): - """Test selecting session management from main menu.""" - session_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Session Management" in choice) - mock_context.selector.choose.return_value = session_choice - - result = main(mock_context, empty_state) - - assert isinstance(result, State) - assert result.menu_name == "SESSION_MANAGEMENT" - - def test_main_menu_auth_selection(self, mock_context, empty_state): - """Test selecting authentication from main menu.""" - auth_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Authentication" in choice) - mock_context.selector.choose.return_value = auth_choice - - result = main(mock_context, empty_state) - - assert isinstance(result, State) - assert result.menu_name == "AUTH" - - def test_main_menu_watch_history_selection(self, mock_context, empty_state): - """Test selecting local watch history from main menu.""" - history_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Local Watch History" in choice) - mock_context.selector.choose.return_value = history_choice - - result = main(mock_context, empty_state) - - assert isinstance(result, State) - assert result.menu_name == "WATCH_HISTORY" - - def test_main_menu_api_failure(self, mock_context, empty_state): - """Test handling API failures in main menu.""" - trending_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Trending" in choice) - mock_context.selector.choose.return_value = trending_choice - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (False, None) # API failure - - result = main(mock_context, empty_state) - - # Should return CONTINUE on API failure - assert result == ControlFlow.CONTINUE - - def test_main_menu_random_selection(self, mock_context, empty_state): - """Test selecting random anime from main menu.""" - random_choice = next(choice for choice in self._get_menu_choices(mock_context) - if "Random" in choice) - mock_context.selector.choose.return_value = random_choice - - mock_search_result = MediaSearchResult( - media=[], - page_info=ApiPageInfo( - total=0, - current_page=1, - has_next_page=False, - per_page=15 - ) - ) - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_search_result) - - result = main(mock_context, empty_state) - - # Should transition to RESULTS state - assert isinstance(result, State) - assert result.menu_name == "RESULTS" - assert result.media_api.search_results == mock_search_result - - def test_main_menu_icons_enabled(self, mock_context, empty_state): - """Test main menu with icons enabled.""" - mock_context.config.general.icons = True - - # Just ensure menu doesn't crash with icons enabled - mock_context.selector.choose.return_value = None - - result = main(mock_context, empty_state) - assert result == ControlFlow.EXIT - - def test_main_menu_icons_disabled(self, mock_context, empty_state): - """Test main menu with icons disabled.""" - mock_context.config.general.icons = False - - # Just ensure menu doesn't crash with icons disabled - mock_context.selector.choose.return_value = None - - result = main(mock_context, empty_state) - assert result == ControlFlow.EXIT - - def _get_menu_choices(self, mock_context): - """Helper to get the menu choices from a mock call.""" - # Temporarily call the menu to get choices - mock_context.selector.choose.return_value = None - main(mock_context, State(menu_name="TEST")) - - # Extract choices from the call - call_args = mock_context.selector.choose.call_args - return call_args[1]['choices'] - - -class TestMainMenuHelperFunctions: - """Test the helper functions in main menu.""" - - def test_create_media_list_action_success(self, mock_context): - """Test creating a media list action that succeeds.""" - from fastanime.cli.interactive.menus.main import _create_media_list_action - - action = _create_media_list_action(mock_context, "TRENDING_DESC") - - mock_search_result = MediaSearchResult( - media=[], - page_info=ApiPageInfo( - total=0, - current_page=1, - has_next_page=False, - per_page=15 - ) - ) - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_search_result) - - menu_name, result, api_params, user_list_params = action() - - assert menu_name == "RESULTS" - assert result == mock_search_result - assert api_params is not None - assert user_list_params is None - - def test_create_media_list_action_failure(self, mock_context): - """Test creating a media list action that fails.""" - from fastanime.cli.interactive.menus.main import _create_media_list_action - - action = _create_media_list_action(mock_context, "TRENDING_DESC") - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (False, None) - - menu_name, result, api_params, user_list_params = action() - - assert menu_name == "CONTINUE" - assert result is None - assert api_params is None - assert user_list_params is None - - def test_create_user_list_action_authenticated(self, mock_context): - """Test creating a user list action when authenticated.""" - from fastanime.cli.interactive.menus.main import _create_user_list_action - - action = _create_user_list_action(mock_context, "CURRENT") - - mock_search_result = MediaSearchResult( - media=[], - page_info=ApiPageInfo( - total=0, - current_page=1, - has_next_page=False, - per_page=15 - ) - ) - - with patch('fastanime.cli.interactive.menus.main.check_authentication_required') as mock_auth: - mock_auth.return_value = True - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_search_result) - - menu_name, result, api_params, user_list_params = action() - - assert menu_name == "RESULTS" - assert result == mock_search_result - assert api_params is None - assert user_list_params is not None - - def test_create_user_list_action_not_authenticated(self, mock_context): - """Test creating a user list action when not authenticated.""" - from fastanime.cli.interactive.menus.main import _create_user_list_action - - action = _create_user_list_action(mock_context, "CURRENT") - - with patch('fastanime.cli.interactive.menus.main.check_authentication_required') as mock_auth: - mock_auth.return_value = False - - menu_name, result, api_params, user_list_params = action() - - assert menu_name == "CONTINUE" - assert result is None - assert api_params is None - assert user_list_params is None - - def test_create_search_media_list_with_query(self, mock_context): - """Test creating a search media list action with a query.""" - from fastanime.cli.interactive.menus.main import _create_search_media_list - - action = _create_search_media_list(mock_context) - - mock_context.selector.ask.return_value = "test query" - mock_search_result = MediaSearchResult( - media=[], - page_info=ApiPageInfo( - total=0, - current_page=1, - has_next_page=False, - per_page=15 - ) - ) - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_search_result) - - menu_name, result, api_params, user_list_params = action() - - assert menu_name == "RESULTS" - assert result == mock_search_result - assert api_params is not None - assert user_list_params is None - - def test_create_search_media_list_no_query(self, mock_context): - """Test creating a search media list action without a query.""" - from fastanime.cli.interactive.menus.main import _create_search_media_list - - action = _create_search_media_list(mock_context) - - mock_context.selector.ask.return_value = "" # Empty query - - menu_name, result, api_params, user_list_params = action() - - assert menu_name == "CONTINUE" - assert result is None - assert api_params is None - assert user_list_params is None - - def test_create_random_media_list(self, mock_context): - """Test creating a random media list action.""" - from fastanime.cli.interactive.menus.main import _create_random_media_list - - action = _create_random_media_list(mock_context) - - mock_search_result = MediaSearchResult( - media=[], - page_info=ApiPageInfo( - total=0, - current_page=1, - has_next_page=False, - per_page=15 - ) - ) - - with patch('fastanime.cli.interactive.menus.main.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_search_result) - - menu_name, result, api_params, user_list_params = action() - - assert menu_name == "RESULTS" - assert result == mock_search_result - assert api_params is not None - assert user_list_params is None - # Check that random IDs were used - assert api_params.id_in is not None - assert len(api_params.id_in) == 50 diff --git a/tests/interactive/menus/test_media_actions.py b/tests/interactive/menus/test_media_actions.py deleted file mode 100644 index 98a0778..0000000 --- a/tests/interactive/menus/test_media_actions.py +++ /dev/null @@ -1,409 +0,0 @@ -""" -Tests for the media actions menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch - -from fastanime.cli.interactive.menus.media_actions import media_actions -from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState -from fastanime.libs.api.types import MediaItem, MediaTitle, MediaTrailer -from fastanime.libs.players.types import PlayerResult - - -class TestMediaActionsMenu: - """Test cases for the media actions menu.""" - - def test_media_actions_menu_display(self, mock_context, state_with_media_api): - """Test that media actions menu displays correctly.""" - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Results" - - with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: - mock_auth.return_value = ("๐ŸŸข Authenticated", Mock()) - - result = media_actions(mock_context, state_with_media_api) - - # Should go back when "Back to Results" is selected - assert result == ControlFlow.BACK - - # Verify selector was called with expected options - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Check that key options are present - expected_options = [ - "Stream", "Watch Trailer", "Add/Update List", - "Score Anime", "Add to Local History", "View Info", "Back to Results" - ] - - for option in expected_options: - assert any(option in choice for choice in choices) - - def test_media_actions_stream_selection(self, mock_context, state_with_media_api): - """Test selecting stream from media actions.""" - mock_context.selector.choose.return_value = "โ–ถ๏ธ Stream" - - with patch('fastanime.cli.interactive.menus.media_actions._stream') as mock_stream: - mock_action = Mock() - mock_action.return_value = State(menu_name="PROVIDER_SEARCH") - mock_stream.return_value = mock_action - - result = media_actions(mock_context, state_with_media_api) - - # Should call stream function - mock_stream.assert_called_once_with(mock_context, state_with_media_api) - # Should return state transition - assert isinstance(result, State) - assert result.menu_name == "PROVIDER_SEARCH" - - def test_media_actions_trailer_selection(self, mock_context, state_with_media_api): - """Test selecting watch trailer from media actions.""" - mock_context.selector.choose.return_value = "๐Ÿ“ผ Watch Trailer" - - with patch('fastanime.cli.interactive.menus.media_actions._watch_trailer') as mock_trailer: - mock_action = Mock() - mock_action.return_value = ControlFlow.CONTINUE - mock_trailer.return_value = mock_action - - result = media_actions(mock_context, state_with_media_api) - - # Should call trailer function - mock_trailer.assert_called_once_with(mock_context, state_with_media_api) - assert result == ControlFlow.CONTINUE - - def test_media_actions_add_to_list_selection(self, mock_context, state_with_media_api): - """Test selecting add/update list from media actions.""" - mock_context.selector.choose.return_value = "โž• Add/Update List" - - with patch('fastanime.cli.interactive.menus.media_actions._add_to_list') as mock_add: - mock_action = Mock() - mock_action.return_value = ControlFlow.CONTINUE - mock_add.return_value = mock_action - - result = media_actions(mock_context, state_with_media_api) - - # Should call add to list function - mock_add.assert_called_once_with(mock_context, state_with_media_api) - assert result == ControlFlow.CONTINUE - - def test_media_actions_score_selection(self, mock_context, state_with_media_api): - """Test selecting score anime from media actions.""" - mock_context.selector.choose.return_value = "โญ Score Anime" - - with patch('fastanime.cli.interactive.menus.media_actions._score_anime') as mock_score: - mock_action = Mock() - mock_action.return_value = ControlFlow.CONTINUE - mock_score.return_value = mock_action - - result = media_actions(mock_context, state_with_media_api) - - # Should call score function - mock_score.assert_called_once_with(mock_context, state_with_media_api) - assert result == ControlFlow.CONTINUE - - def test_media_actions_local_history_selection(self, mock_context, state_with_media_api): - """Test selecting add to local history from media actions.""" - mock_context.selector.choose.return_value = "๐Ÿ“š Add to Local History" - - with patch('fastanime.cli.interactive.menus.media_actions._add_to_local_history') as mock_history: - mock_action = Mock() - mock_action.return_value = ControlFlow.CONTINUE - mock_history.return_value = mock_action - - result = media_actions(mock_context, state_with_media_api) - - # Should call local history function - mock_history.assert_called_once_with(mock_context, state_with_media_api) - assert result == ControlFlow.CONTINUE - - def test_media_actions_view_info_selection(self, mock_context, state_with_media_api): - """Test selecting view info from media actions.""" - mock_context.selector.choose.return_value = "โ„น๏ธ View Info" - - with patch('fastanime.cli.interactive.menus.media_actions._view_info') as mock_info: - mock_action = Mock() - mock_action.return_value = ControlFlow.CONTINUE - mock_info.return_value = mock_action - - result = media_actions(mock_context, state_with_media_api) - - # Should call view info function - mock_info.assert_called_once_with(mock_context, state_with_media_api) - assert result == ControlFlow.CONTINUE - - def test_media_actions_back_selection(self, mock_context, state_with_media_api): - """Test selecting back from media actions.""" - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Results" - - with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: - mock_auth.return_value = ("Auth Status", Mock()) - - result = media_actions(mock_context, state_with_media_api) - - assert result == ControlFlow.BACK - - def test_media_actions_no_choice(self, mock_context, state_with_media_api): - """Test media actions menu when no choice is made.""" - mock_context.selector.choose.return_value = None - - with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: - mock_auth.return_value = ("Auth Status", Mock()) - - result = media_actions(mock_context, state_with_media_api) - - # Should return BACK when no choice is made - assert result == ControlFlow.BACK - - def test_media_actions_unknown_choice(self, mock_context, state_with_media_api): - """Test media actions menu with unknown choice.""" - mock_context.selector.choose.return_value = "Unknown Option" - - with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: - mock_auth.return_value = ("Auth Status", Mock()) - - result = media_actions(mock_context, state_with_media_api) - - # Should return BACK for unknown choices - assert result == ControlFlow.BACK - - def test_media_actions_header_content(self, mock_context, state_with_media_api): - """Test that media actions header contains anime title and auth status.""" - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Results" - - with patch('fastanime.cli.interactive.menus.media_actions.get_auth_status_indicator') as mock_auth: - mock_auth.return_value = ("๐ŸŸข Authenticated", Mock()) - - result = media_actions(mock_context, state_with_media_api) - - # Verify header contains anime title and auth status - call_args = mock_context.selector.choose.call_args - header = call_args[1]['header'] - assert "Test Anime" in header - assert "๐ŸŸข Authenticated" in header - - def test_media_actions_icons_enabled(self, mock_context, state_with_media_api): - """Test media actions menu with icons enabled.""" - mock_context.config.general.icons = True - mock_context.selector.choose.return_value = "โ–ถ๏ธ Stream" - - with patch('fastanime.cli.interactive.menus.media_actions._stream') as mock_stream: - mock_action = Mock() - mock_action.return_value = State(menu_name="PROVIDER_SEARCH") - mock_stream.return_value = mock_action - - result = media_actions(mock_context, state_with_media_api) - - # Should work with icons enabled - assert isinstance(result, State) - assert result.menu_name == "PROVIDER_SEARCH" - - def test_media_actions_icons_disabled(self, mock_context, state_with_media_api): - """Test media actions menu with icons disabled.""" - mock_context.config.general.icons = False - mock_context.selector.choose.return_value = "Stream" - - with patch('fastanime.cli.interactive.menus.media_actions._stream') as mock_stream: - mock_action = Mock() - mock_action.return_value = State(menu_name="PROVIDER_SEARCH") - mock_stream.return_value = mock_action - - result = media_actions(mock_context, state_with_media_api) - - # Should work with icons disabled - assert isinstance(result, State) - assert result.menu_name == "PROVIDER_SEARCH" - - -class TestMediaActionsHelperFunctions: - """Test the helper functions in media actions menu.""" - - def test_stream_function(self, mock_context, state_with_media_api): - """Test the stream helper function.""" - from fastanime.cli.interactive.menus.media_actions import _stream - - stream_func = _stream(mock_context, state_with_media_api) - - # Should return a function that transitions to PROVIDER_SEARCH - result = stream_func() - assert isinstance(result, State) - assert result.menu_name == "PROVIDER_SEARCH" - # Should preserve media API state - assert result.media_api.anime == state_with_media_api.media_api.anime - - def test_watch_trailer_success(self, mock_context, state_with_media_api): - """Test watching trailer successfully.""" - from fastanime.cli.interactive.menus.media_actions import _watch_trailer - - # Mock anime with trailer URL - anime_with_trailer = MediaItem( - id=1, - title=MediaTitle(english="Test Anime", romaji="Test Anime"), - status="FINISHED", - episodes=12, - trailer=MediaTrailer(id="test", site="youtube") - ) - - state_with_trailer = State( - menu_name="MEDIA_ACTIONS", - media_api=MediaApiState(anime=anime_with_trailer) - ) - - trailer_func = _watch_trailer(mock_context, state_with_trailer) - - # Mock successful player result - mock_context.player.play.return_value = PlayerResult() - - with patch('fastanime.cli.interactive.menus.media_actions.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = trailer_func() - - # Should play trailer and continue - mock_context.player.play.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_watch_trailer_no_url(self, mock_context, state_with_media_api): - """Test watching trailer when no trailer URL available.""" - from fastanime.cli.interactive.menus.media_actions import _watch_trailer - - trailer_func = _watch_trailer(mock_context, state_with_media_api) - - with patch('fastanime.cli.interactive.menus.media_actions.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = trailer_func() - - # Should show warning and continue - feedback_obj.warning.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_add_to_list_authenticated(self, mock_context, state_with_media_api): - """Test adding to list when authenticated.""" - from fastanime.cli.interactive.menus.media_actions import _add_to_list - - add_func = _add_to_list(mock_context, state_with_media_api) - - # Mock authentication check - with patch('fastanime.cli.interactive.menus.media_actions.check_authentication_required') as mock_auth: - mock_auth.return_value = True - - # Mock status selection - mock_context.selector.choose.return_value = "CURRENT" - - # Mock successful API call - with patch('fastanime.cli.interactive.menus.media_actions.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, None) - - result = add_func() - - # Should call API and continue - mock_execute.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_add_to_list_not_authenticated(self, mock_context, state_with_media_api): - """Test adding to list when not authenticated.""" - from fastanime.cli.interactive.menus.media_actions import _add_to_list - - add_func = _add_to_list(mock_context, state_with_media_api) - - # Mock authentication check failure - with patch('fastanime.cli.interactive.menus.media_actions.check_authentication_required') as mock_auth: - mock_auth.return_value = False - - result = add_func() - - # Should continue without API call - assert result == ControlFlow.CONTINUE - - def test_score_anime_authenticated(self, mock_context, state_with_media_api): - """Test scoring anime when authenticated.""" - from fastanime.cli.interactive.menus.media_actions import _score_anime - - score_func = _score_anime(mock_context, state_with_media_api) - - # Mock authentication check - with patch('fastanime.cli.interactive.menus.media_actions.check_authentication_required') as mock_auth: - mock_auth.return_value = True - - # Mock score input - mock_context.selector.ask.return_value = "8.5" - - # Mock successful API call - with patch('fastanime.cli.interactive.menus.media_actions.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, None) - - result = score_func() - - # Should call API and continue - mock_execute.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_score_anime_invalid_score(self, mock_context, state_with_media_api): - """Test scoring anime with invalid score.""" - from fastanime.cli.interactive.menus.media_actions import _score_anime - - score_func = _score_anime(mock_context, state_with_media_api) - - # Mock authentication check - with patch('fastanime.cli.interactive.menus.media_actions.check_authentication_required') as mock_auth: - mock_auth.return_value = True - - # Mock invalid score input - mock_context.selector.ask.return_value = "invalid" - - with patch('fastanime.cli.interactive.menus.media_actions.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = score_func() - - # Should show error and continue - feedback_obj.error.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_add_to_local_history(self, mock_context, state_with_media_api): - """Test adding anime to local history.""" - from fastanime.cli.interactive.menus.media_actions import _add_to_local_history - - history_func = _add_to_local_history(mock_context, state_with_media_api) - - with patch('fastanime.cli.utils.watch_history_tracker.watch_tracker') as mock_tracker: - mock_tracker.add_anime_to_history.return_value = True - mock_context.selector.choose.return_value = "Watching" - mock_context.selector.ask.return_value = "5" - - with patch('fastanime.cli.utils.watch_history_manager.WatchHistoryManager') as mock_history_manager: - mock_manager_instance = Mock() - mock_history_manager.return_value = mock_manager_instance - mock_manager_instance.get_entry.return_value = None - - with patch('fastanime.cli.interactive.menus.media_actions.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = history_func() - - # Should add to history successfully - mock_tracker.add_anime_to_history.assert_called_once() - feedback_obj.success.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_view_info(self, mock_context, state_with_media_api): - """Test viewing anime information.""" - from fastanime.cli.interactive.menus.media_actions import _view_info - - info_func = _view_info(mock_context, state_with_media_api) - - with patch('fastanime.cli.interactive.menus.media_actions.Console') as mock_console: - mock_context.selector.ask.return_value = "" - - result = info_func() - - # Should create console and display info - mock_console.assert_called_once() - # Should ask user to continue - mock_context.selector.ask.assert_called_once_with("Press Enter to continue...") - assert result == ControlFlow.CONTINUE diff --git a/tests/interactive/menus/test_player_controls.py b/tests/interactive/menus/test_player_controls.py deleted file mode 100644 index 1b015f1..0000000 --- a/tests/interactive/menus/test_player_controls.py +++ /dev/null @@ -1,479 +0,0 @@ -""" -Tests for the player controls menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch, MagicMock -import threading - -from fastanime.cli.interactive.menus.player_controls import player_controls -from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState -from fastanime.libs.players.types import PlayerResult -from fastanime.libs.providers.anime.types import Server, EpisodeStream -from fastanime.libs.api.types import MediaItem - - -class TestPlayerControlsMenu: - """Test cases for the player controls menu.""" - - def test_player_controls_menu_missing_data(self, mock_context, empty_state): - """Test player controls menu with missing data.""" - result = player_controls(mock_context, empty_state) - - # Should go back when required data is missing - assert result == ControlFlow.BACK - - def test_player_controls_menu_successful_playback(self, mock_context, full_state): - """Test player controls menu after successful playback.""" - # Setup state with player result - state_with_result = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - # Mock user choice to go back - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Episodes" - - result = player_controls(mock_context, state_with_result) - - # Should go back to episodes - assert result == ControlFlow.BACK - - def test_player_controls_menu_playback_failure(self, mock_context, full_state): - """Test player controls menu after playback failure.""" - # Setup state with failed player result - state_with_failure = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1", - last_player_result=PlayerResult(success=False, exit_code=1) - ) - ) - - # Mock user choice to retry - mock_context.selector.choose.return_value = "๐Ÿ”„ Try Different Server" - - result = player_controls(mock_context, state_with_failure) - - # Should transition back to SERVERS state - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - - def test_player_controls_next_episode_available(self, mock_context, full_state): - """Test next episode option when available.""" - # Mock anime with multiple episodes - from fastanime.libs.providers.anime.types import Episodes - provider_anime = full_state.provider.anime - provider_anime.episodes = Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) - - state_with_next = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=provider_anime, - episode_number="1", # Currently on episode 1, so 2 is available - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - # Mock user choice to play next episode - mock_context.selector.choose.return_value = "โ–ถ๏ธ Next Episode (2)" - - result = player_controls(mock_context, state_with_next) - - # Should transition to SERVERS state with next episode - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - assert result.provider.episode_number == "2" - - def test_player_controls_no_next_episode(self, mock_context, full_state): - """Test when no next episode is available.""" - # Mock anime with only one episode - from fastanime.libs.providers.anime.types import Episodes - provider_anime = full_state.provider.anime - provider_anime.episodes = Episodes(sub=["1"], dub=["1"]) - - state_last_episode = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=provider_anime, - episode_number="1", # Last episode - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - # Mock back selection since no next episode - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Episodes" - - result = player_controls(mock_context, state_last_episode) - - # Should go back - assert result == ControlFlow.BACK - - # Verify next episode option is not in choices - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - next_episode_options = [choice for choice in choices if "Next Episode" in choice] - assert len(next_episode_options) == 0 - - def test_player_controls_replay_episode(self, mock_context, full_state): - """Test replaying current episode.""" - state_with_result = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - # Mock user choice to replay - mock_context.selector.choose.return_value = "๐Ÿ”„ Replay Episode" - - result = player_controls(mock_context, state_with_result) - - # Should transition back to SERVERS state with same episode - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - assert result.provider.episode_number == "1" - - def test_player_controls_change_server(self, mock_context, full_state): - """Test changing server option.""" - state_with_result = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - # Mock user choice to try different server - mock_context.selector.choose.return_value = "๐Ÿ”„ Try Different Server" - - result = player_controls(mock_context, state_with_result) - - # Should transition back to SERVERS state - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - - def test_player_controls_mark_as_watched(self, mock_context, full_state): - """Test marking episode as watched.""" - state_with_result = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - # Mock authenticated user - mock_context.media_api.is_authenticated.return_value = True - - # Mock user choice to mark as watched - mock_context.selector.choose.return_value = "โœ… Mark as Watched" - - with patch('fastanime.cli.interactive.menus.player_controls._update_progress_in_background') as mock_update: - result = player_controls(mock_context, state_with_result) - - # Should update progress in background - mock_update.assert_called_once() - - # Should continue - assert result == ControlFlow.CONTINUE - - def test_player_controls_not_authenticated_no_mark_option(self, mock_context, full_state): - """Test that mark as watched option is not shown when not authenticated.""" - state_with_result = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - # Mock unauthenticated user - mock_context.media_api.is_authenticated.return_value = False - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Episodes" - - result = player_controls(mock_context, state_with_result) - - # Verify mark as watched option is not in choices - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - mark_options = [choice for choice in choices if "Mark as Watched" in choice] - assert len(mark_options) == 0 - - def test_player_controls_auto_next_enabled(self, mock_context, full_state): - """Test auto next episode when enabled in config.""" - # Enable auto next in config - mock_context.config.stream.auto_next = True - - # Mock anime with multiple episodes - from fastanime.libs.providers.anime.types import Episodes - provider_anime = full_state.provider.anime - provider_anime.episodes = Episodes(sub=["1", "2", "3"], dub=["1", "2", "3"]) - - state_with_auto_next = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=provider_anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - result = player_controls(mock_context, state_with_auto_next) - - # Should automatically transition to next episode - assert isinstance(result, State) - assert result.menu_name == "SERVERS" - assert result.provider.episode_number == "2" - - # Selector should not be called for auto next - mock_context.selector.choose.assert_not_called() - - def test_player_controls_auto_next_last_episode(self, mock_context, full_state): - """Test auto next when on last episode.""" - # Enable auto next in config - mock_context.config.stream.auto_next = True - - # Mock anime with only one episode - from fastanime.libs.providers.anime.types import Episodes - provider_anime = full_state.provider.anime - provider_anime.episodes = Episodes(sub=["1"], dub=["1"]) - - state_last_episode = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=provider_anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - # Mock back selection since auto next can't proceed - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Episodes" - - result = player_controls(mock_context, state_last_episode) - - # Should show menu when auto next can't proceed - assert result == ControlFlow.BACK - - def test_player_controls_no_choice_made(self, mock_context, full_state): - """Test player controls when no choice is made.""" - state_with_result = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - # Mock no selection - mock_context.selector.choose.return_value = None - - result = player_controls(mock_context, state_with_result) - - # Should go back when no selection is made - assert result == ControlFlow.BACK - - def test_player_controls_icons_enabled(self, mock_context, full_state): - """Test player controls menu with icons enabled.""" - mock_context.config.general.icons = True - - state_with_result = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Episodes" - - result = player_controls(mock_context, state_with_result) - - # Should work with icons enabled - assert result == ControlFlow.BACK - - def test_player_controls_icons_disabled(self, mock_context, full_state): - """Test player controls menu with icons disabled.""" - mock_context.config.general.icons = False - - state_with_result = State( - menu_name="PLAYER_CONTROLS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1", - last_player_result=PlayerResult(success=True, exit_code=0) - ) - ) - - mock_context.selector.choose.return_value = "Back to Episodes" - - result = player_controls(mock_context, state_with_result) - - # Should work with icons disabled - assert result == ControlFlow.BACK - - -class TestPlayerControlsHelperFunctions: - """Test the helper functions in player controls menu.""" - - def test_calculate_completion_valid_times(self): - """Test calculating completion percentage with valid times.""" - from fastanime.cli.interactive.menus.player_controls import _calculate_completion - - # 30 minutes out of 60 minutes = 50% - result = _calculate_completion("00:30:00", "01:00:00") - - assert result == 50.0 - - def test_calculate_completion_zero_duration(self): - """Test calculating completion with zero duration.""" - from fastanime.cli.interactive.menus.player_controls import _calculate_completion - - result = _calculate_completion("00:30:00", "00:00:00") - - assert result == 0 - - def test_calculate_completion_invalid_format(self): - """Test calculating completion with invalid time format.""" - from fastanime.cli.interactive.menus.player_controls import _calculate_completion - - result = _calculate_completion("invalid", "01:00:00") - - assert result == 0 - - def test_calculate_completion_partial_episode(self): - """Test calculating completion for partial episode viewing.""" - from fastanime.cli.interactive.menus.player_controls import _calculate_completion - - # 15 minutes out of 24 minutes = 62.5% - result = _calculate_completion("00:15:00", "00:24:00") - - assert result == 62.5 - - def test_update_progress_in_background_authenticated(self, mock_context): - """Test updating progress in background when authenticated.""" - from fastanime.cli.interactive.menus.player_controls import _update_progress_in_background - - # Mock authenticated user - mock_context.media_api.user_profile = Mock() - mock_context.media_api.update_list_entry = Mock() - - # Call the function - _update_progress_in_background(mock_context, 123, 5) - - # Give the thread a moment to execute - import time - time.sleep(0.1) - - # Should call update_list_entry - mock_context.media_api.update_list_entry.assert_called_once() - - def test_update_progress_in_background_not_authenticated(self, mock_context): - """Test updating progress in background when not authenticated.""" - from fastanime.cli.interactive.menus.player_controls import _update_progress_in_background - - # Mock unauthenticated user - mock_context.media_api.user_profile = None - mock_context.media_api.update_list_entry = Mock() - - # Call the function - _update_progress_in_background(mock_context, 123, 5) - - # Give the thread a moment to execute - import time - time.sleep(0.1) - - # Should still call update_list_entry (comment suggests it should) - mock_context.media_api.update_list_entry.assert_called_once() - - def test_get_next_episode_number(self): - """Test getting next episode number.""" - from fastanime.cli.interactive.menus.player_controls import _get_next_episode_number - - available_episodes = ["1", "2", "3", "4", "5"] - current_episode = "3" - - result = _get_next_episode_number(available_episodes, current_episode) - - assert result == "4" - - def test_get_next_episode_number_last_episode(self): - """Test getting next episode when on last episode.""" - from fastanime.cli.interactive.menus.player_controls import _get_next_episode_number - - available_episodes = ["1", "2", "3"] - current_episode = "3" - - result = _get_next_episode_number(available_episodes, current_episode) - - assert result is None - - def test_get_next_episode_number_not_found(self): - """Test getting next episode when current episode not found.""" - from fastanime.cli.interactive.menus.player_controls import _get_next_episode_number - - available_episodes = ["1", "2", "3"] - current_episode = "5" # Not in the list - - result = _get_next_episode_number(available_episodes, current_episode) - - assert result is None - - def test_should_show_mark_as_watched_authenticated(self, mock_context): - """Test should show mark as watched when authenticated.""" - from fastanime.cli.interactive.menus.player_controls import _should_show_mark_as_watched - - mock_context.media_api.is_authenticated.return_value = True - player_result = PlayerResult(success=True, exit_code=0) - - result = _should_show_mark_as_watched(mock_context, player_result) - - assert result is True - - def test_should_show_mark_as_watched_not_authenticated(self, mock_context): - """Test should not show mark as watched when not authenticated.""" - from fastanime.cli.interactive.menus.player_controls import _should_show_mark_as_watched - - mock_context.media_api.is_authenticated.return_value = False - player_result = PlayerResult(success=True, exit_code=0) - - result = _should_show_mark_as_watched(mock_context, player_result) - - assert result is False - - def test_should_show_mark_as_watched_playback_failed(self, mock_context): - """Test should not show mark as watched when playback failed.""" - from fastanime.cli.interactive.menus.player_controls import _should_show_mark_as_watched - - mock_context.media_api.is_authenticated.return_value = True - player_result = PlayerResult(success=False, exit_code=1) - - result = _should_show_mark_as_watched(mock_context, player_result) - - assert result is False diff --git a/tests/interactive/menus/test_provider_search.py b/tests/interactive/menus/test_provider_search.py deleted file mode 100644 index bdbbaf2..0000000 --- a/tests/interactive/menus/test_provider_search.py +++ /dev/null @@ -1,465 +0,0 @@ -""" -Tests for the provider search menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch - -from fastanime.cli.interactive.menus.provider_search import provider_search -from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState -from fastanime.libs.providers.anime.types import Anime, SearchResults -from fastanime.libs.api.types import MediaItem - - -class TestProviderSearchMenu: - """Test cases for the provider search menu.""" - - def test_provider_search_no_anilist_anime(self, mock_context, empty_state): - """Test provider search with no AniList anime selected.""" - result = provider_search(mock_context, empty_state) - - # Should go back when no anime is selected - assert result == ControlFlow.BACK - - def test_provider_search_no_title(self, mock_context, empty_state): - """Test provider search with anime having no title.""" - # Create anime with no title - anime_no_title = MediaItem( - id=1, - title={"english": None, "romaji": None}, - status="FINISHED", - episodes=12 - ) - - state_no_title = State( - menu_name="PROVIDER_SEARCH", - media_api=MediaApiState(anime=anime_no_title) - ) - - result = provider_search(mock_context, state_no_title) - - # Should go back when anime has no searchable title - assert result == ControlFlow.BACK - - def test_provider_search_successful_search(self, mock_context, state_with_media_api): - """Test successful provider search with results.""" - # Mock provider search results - search_results = SearchResults( - anime=[ - Anime( - name="Test Anime", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ), - Anime( - name="Test Anime Season 2", - url="https://example.com/anime2", - id="anime2", - poster="https://example.com/poster2.jpg" - ) - ] - ) - - # Mock user selection - mock_context.selector.choose.return_value = "Test Anime" - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, search_results) - - result = provider_search(mock_context, state_with_media_api) - - # Should transition to EPISODES state - assert isinstance(result, State) - assert result.menu_name == "EPISODES" - assert result.provider.anime.name == "Test Anime" - - def test_provider_search_no_results(self, mock_context, state_with_media_api): - """Test provider search with no results.""" - # Mock empty search results - empty_results = SearchResults(anime=[]) - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, empty_results) - - result = provider_search(mock_context, state_with_media_api) - - # Should go back when no results found - assert result == ControlFlow.BACK - - def test_provider_search_api_failure(self, mock_context, state_with_media_api): - """Test provider search when API fails.""" - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (False, None) - - result = provider_search(mock_context, state_with_media_api) - - # Should go back when API fails - assert result == ControlFlow.BACK - - def test_provider_search_auto_select_enabled(self, mock_context, state_with_media_api): - """Test provider search with auto select enabled.""" - # Enable auto select in config - mock_context.config.general.auto_select_anime_result = True - - # Mock search results with high similarity match - search_results = SearchResults( - anime=[ - Anime( - name="Test Anime", # Exact match with AniList title - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - ] - ) - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, search_results) - - with patch('fastanime.cli.interactive.menus.provider_search.fuzz.ratio') as mock_fuzz: - mock_fuzz.return_value = 95 # High similarity score - - result = provider_search(mock_context, state_with_media_api) - - # Should auto-select and transition to EPISODES - assert isinstance(result, State) - assert result.menu_name == "EPISODES" - - # Selector should not be called for auto selection - mock_context.selector.choose.assert_not_called() - - def test_provider_search_auto_select_low_similarity(self, mock_context, state_with_media_api): - """Test provider search with auto select but low similarity.""" - # Enable auto select in config - mock_context.config.general.auto_select_anime_result = True - - # Mock search results with low similarity - search_results = SearchResults( - anime=[ - Anime( - name="Different Anime", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - ] - ) - - mock_context.selector.choose.return_value = "Different Anime" - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, search_results) - - with patch('fastanime.cli.interactive.menus.provider_search.fuzz.ratio') as mock_fuzz: - mock_fuzz.return_value = 60 # Low similarity score - - result = provider_search(mock_context, state_with_media_api) - - # Should show manual selection - mock_context.selector.choose.assert_called_once() - assert isinstance(result, State) - assert result.menu_name == "EPISODES" - - def test_provider_search_manual_selection_cancelled(self, mock_context, state_with_media_api): - """Test provider search when manual selection is cancelled.""" - # Disable auto select - mock_context.config.general.auto_select_anime_result = False - - search_results = SearchResults( - anime=[ - Anime( - name="Test Anime", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - ] - ) - - # Mock cancelled selection - mock_context.selector.choose.return_value = None - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, search_results) - - result = provider_search(mock_context, state_with_media_api) - - # Should go back when selection is cancelled - assert result == ControlFlow.BACK - - def test_provider_search_back_selection(self, mock_context, state_with_media_api): - """Test provider search back selection.""" - search_results = SearchResults( - anime=[ - Anime( - name="Test Anime", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - ] - ) - - # Mock back selection - mock_context.selector.choose.return_value = "Back" - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, search_results) - - result = provider_search(mock_context, state_with_media_api) - - # Should go back - assert result == ControlFlow.BACK - - def test_provider_search_invalid_selection(self, mock_context, state_with_media_api): - """Test provider search with invalid selection.""" - search_results = SearchResults( - anime=[ - Anime( - name="Test Anime", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - ] - ) - - # Mock invalid selection (not in results) - mock_context.selector.choose.return_value = "Invalid Anime" - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, search_results) - - result = provider_search(mock_context, state_with_media_api) - - # Should go back for invalid selection - assert result == ControlFlow.BACK - - def test_provider_search_with_preview(self, mock_context, state_with_media_api): - """Test provider search with preview enabled.""" - mock_context.config.general.preview = "text" - - search_results = SearchResults( - anime=[ - Anime( - name="Test Anime", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - ] - ) - - mock_context.selector.choose.return_value = "Test Anime" - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, search_results) - - with patch('fastanime.cli.interactive.menus.provider_search.get_anime_preview') as mock_preview: - mock_preview.return_value = "preview_command" - - result = provider_search(mock_context, state_with_media_api) - - # Should call preview function - mock_preview.assert_called_once() - - # Verify preview was passed to selector - call_args = mock_context.selector.choose.call_args - assert call_args[1]['preview'] == "preview_command" - - def test_provider_search_english_title_preference(self, mock_context, empty_state): - """Test provider search using English title when available.""" - # Create anime with both English and Romaji titles - anime_dual_titles = MediaItem( - id=1, - title={"english": "English Title", "romaji": "Romaji Title"}, - status="FINISHED", - episodes=12 - ) - - state_dual_titles = State( - menu_name="PROVIDER_SEARCH", - media_api=MediaApiState(anime=anime_dual_titles) - ) - - search_results = SearchResults( - anime=[ - Anime( - name="English Title", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - ] - ) - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, search_results) - - mock_context.selector.choose.return_value = "English Title" - - result = provider_search(mock_context, state_dual_titles) - - # Should search using English title - mock_context.provider.search.assert_called_once() - search_params = mock_context.provider.search.call_args[0][0] - assert search_params.query == "English Title" - - def test_provider_search_romaji_title_fallback(self, mock_context, empty_state): - """Test provider search falling back to Romaji title when English not available.""" - # Create anime with only Romaji title - anime_romaji_only = MediaItem( - id=1, - title={"english": None, "romaji": "Romaji Title"}, - status="FINISHED", - episodes=12 - ) - - state_romaji_only = State( - menu_name="PROVIDER_SEARCH", - media_api=MediaApiState(anime=anime_romaji_only) - ) - - search_results = SearchResults( - anime=[ - Anime( - name="Romaji Title", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - ] - ) - - with patch('fastanime.cli.interactive.menus.provider_search.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, search_results) - - mock_context.selector.choose.return_value = "Romaji Title" - - result = provider_search(mock_context, state_romaji_only) - - # Should search using Romaji title - mock_context.provider.search.assert_called_once() - search_params = mock_context.provider.search.call_args[0][0] - assert search_params.query == "Romaji Title" - - -class TestProviderSearchHelperFunctions: - """Test the helper functions in provider search menu.""" - - def test_format_provider_anime_choice(self, mock_config): - """Test formatting provider anime choice for display.""" - from fastanime.cli.interactive.menus.provider_search import _format_provider_anime_choice - - anime = Anime( - name="Test Anime", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - - mock_config.general.icons = True - - result = _format_provider_anime_choice(anime, mock_config) - - assert "Test Anime" in result - - def test_format_provider_anime_choice_no_icons(self, mock_config): - """Test formatting provider anime choice without icons.""" - from fastanime.cli.interactive.menus.provider_search import _format_provider_anime_choice - - anime = Anime( - name="Test Anime", - url="https://example.com/anime1", - id="anime1", - poster="https://example.com/poster1.jpg" - ) - - mock_config.general.icons = False - - result = _format_provider_anime_choice(anime, mock_config) - - assert "Test Anime" in result - assert "๐Ÿ“บ" not in result # No icons should be present - - def test_get_best_match_high_similarity(self): - """Test getting best match with high similarity.""" - from fastanime.cli.interactive.menus.provider_search import _get_best_match - - anilist_title = "Test Anime" - search_results = SearchResults( - anime=[ - Anime(name="Test Anime", url="https://example.com/1", id="1", poster=""), - Anime(name="Different Anime", url="https://example.com/2", id="2", poster="") - ] - ) - - with patch('fastanime.cli.interactive.menus.provider_search.fuzz.ratio') as mock_fuzz: - mock_fuzz.side_effect = [95, 60] # High similarity for first anime - - result = _get_best_match(anilist_title, search_results, threshold=80) - - assert result.name == "Test Anime" - - def test_get_best_match_low_similarity(self): - """Test getting best match with low similarity.""" - from fastanime.cli.interactive.menus.provider_search import _get_best_match - - anilist_title = "Test Anime" - search_results = SearchResults( - anime=[ - Anime(name="Different Show", url="https://example.com/1", id="1", poster=""), - Anime(name="Another Show", url="https://example.com/2", id="2", poster="") - ] - ) - - with patch('fastanime.cli.interactive.menus.provider_search.fuzz.ratio') as mock_fuzz: - mock_fuzz.side_effect = [60, 50] # Low similarity for all - - result = _get_best_match(anilist_title, search_results, threshold=80) - - assert result is None - - def test_get_best_match_empty_results(self): - """Test getting best match with empty results.""" - from fastanime.cli.interactive.menus.provider_search import _get_best_match - - anilist_title = "Test Anime" - empty_results = SearchResults(anime=[]) - - result = _get_best_match(anilist_title, empty_results, threshold=80) - - assert result is None - - def test_should_auto_select_enabled_high_similarity(self, mock_config): - """Test should auto select when enabled and high similarity.""" - from fastanime.cli.interactive.menus.provider_search import _should_auto_select - - mock_config.general.auto_select_anime_result = True - best_match = Anime(name="Test Anime", url="https://example.com/1", id="1", poster="") - - result = _should_auto_select(mock_config, best_match) - - assert result is True - - def test_should_auto_select_disabled(self, mock_config): - """Test should not auto select when disabled.""" - from fastanime.cli.interactive.menus.provider_search import _should_auto_select - - mock_config.general.auto_select_anime_result = False - best_match = Anime(name="Test Anime", url="https://example.com/1", id="1", poster="") - - result = _should_auto_select(mock_config, best_match) - - assert result is False - - def test_should_auto_select_no_match(self, mock_config): - """Test should not auto select when no good match.""" - from fastanime.cli.interactive.menus.provider_search import _should_auto_select - - mock_config.general.auto_select_anime_result = True - - result = _should_auto_select(mock_config, None) - - assert result is False diff --git a/tests/interactive/menus/test_results.py b/tests/interactive/menus/test_results.py deleted file mode 100644 index 87c1d73..0000000 --- a/tests/interactive/menus/test_results.py +++ /dev/null @@ -1,368 +0,0 @@ -""" -Tests for the results menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch - -from fastanime.cli.interactive.menus.results import results -from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState -from fastanime.libs.api.types import MediaItem, MediaSearchResult, PageInfo, MediaTitle, MediaImage, Studio - - -class TestResultsMenu: - """Test cases for the results menu.""" - - def test_results_menu_no_search_results(self, mock_context, empty_state): - """Test results menu with no search results.""" - # State with no search results - state_no_results = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=None) - ) - - result = results(mock_context, state_no_results) - - # Should go back when no results - assert result == ControlFlow.BACK - - def test_results_menu_empty_media_list(self, mock_context, empty_state): - """Test results menu with empty media list.""" - # State with empty search results - empty_search_results = MediaSearchResult( - media=[], - page_info=PageInfo( - total=0, - per_page=15, - current_page=1, - has_next_page=False - ) - ) - state_empty_results = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=empty_search_results) - ) - - result = results(mock_context, state_empty_results) - - # Should go back when no media found - assert result == ControlFlow.BACK - - def test_results_menu_display_anime_list(self, mock_context, state_with_media_api): - """Test results menu displays anime list correctly.""" - mock_context.selector.choose.return_value = "Back" - - result = results(mock_context, state_with_media_api) - - # Should go back when "Back" is selected - assert result == ControlFlow.BACK - - # Verify selector was called with anime choices - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Should contain Back option - assert "Back" in choices - # Should contain formatted anime titles - assert len(choices) >= 2 # At least anime + Back - - def test_results_menu_select_anime(self, mock_context, state_with_media_api, sample_media_item): - """Test selecting an anime from results.""" - # Mock the format function to return a predictable title - with patch('fastanime.cli.interactive.menus.results._format_anime_choice') as mock_format: - mock_format.return_value = "Test Anime" - mock_context.selector.choose.return_value = "Test Anime" - - result = results(mock_context, state_with_media_api) - - # Should transition to MEDIA_ACTIONS state - assert isinstance(result, State) - assert result.menu_name == "MEDIA_ACTIONS" - assert result.media_api.anime == sample_media_item - - def test_results_menu_pagination_next_page(self, mock_context, empty_state): - """Test pagination - next page navigation.""" - # Create search results with next page available - search_results = MediaSearchResult( - media=[ - MediaItem( - id=1, - title={"english": "Test Anime", "romaji": "Test Anime"}, - status="FINISHED", - episodes=12 - ) - ], - page_info=PageInfo( - total=30, - per_page=15, - current_page=1, - has_next_page=True - ) - ) - - state_with_pagination = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=search_results) - ) - - mock_context.selector.choose.return_value = "Next Page (Page 2)" - - with patch('fastanime.cli.interactive.menus.results._handle_pagination') as mock_pagination: - mock_pagination.return_value = State(menu_name="RESULTS") - - result = results(mock_context, state_with_pagination) - - # Should call pagination handler - mock_pagination.assert_called_once_with(mock_context, state_with_pagination, 1) - - def test_results_menu_pagination_previous_page(self, mock_context, empty_state): - """Test pagination - previous page navigation.""" - # Create search results on page 2 - search_results = MediaSearchResult( - media=[ - MediaItem( - id=1, - title={"english": "Test Anime", "romaji": "Test Anime"}, - status="FINISHED", - episodes=12 - ) - ], - page_info=PageInfo( - total=30, - per_page=15, - current_page=2, - has_next_page=False - ) - ) - - state_with_pagination = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=search_results) - ) - - mock_context.selector.choose.return_value = "Previous Page (Page 1)" - - with patch('fastanime.cli.interactive.menus.results._handle_pagination') as mock_pagination: - mock_pagination.return_value = State(menu_name="RESULTS") - - result = results(mock_context, state_with_pagination) - - # Should call pagination handler - mock_pagination.assert_called_once_with(mock_context, state_with_pagination, -1) - - def test_results_menu_no_choice_made(self, mock_context, state_with_media_api): - """Test results menu when no choice is made (exit).""" - mock_context.selector.choose.return_value = None - - result = results(mock_context, state_with_media_api) - - assert result == ControlFlow.EXIT - - def test_results_menu_with_preview(self, mock_context, state_with_media_api): - """Test results menu with preview enabled.""" - mock_context.config.general.preview = "text" - mock_context.selector.choose.return_value = "Back" - - with patch('fastanime.cli.utils.previews.get_anime_preview') as mock_preview: - mock_preview.return_value = "preview_command" - - result = results(mock_context, state_with_media_api) - - # Should call preview function when preview is enabled - mock_preview.assert_called_once() - - # Verify preview was passed to selector - call_args = mock_context.selector.choose.call_args - assert call_args[1]['preview'] == "preview_command" - - def test_results_menu_no_preview(self, mock_context, state_with_media_api): - """Test results menu with preview disabled.""" - mock_context.config.general.preview = "none" - mock_context.selector.choose.return_value = "Back" - - result = results(mock_context, state_with_media_api) - - # Verify no preview was passed to selector - call_args = mock_context.selector.choose.call_args - assert call_args[1]['preview'] is None - - def test_results_menu_auth_status_display(self, mock_context, state_with_media_api): - """Test that authentication status is displayed in header.""" - mock_context.selector.choose.return_value = "Back" - - with patch('fastanime.cli.interactive.menus.results.get_auth_status_indicator') as mock_auth: - mock_auth.return_value = ("๐ŸŸข Authenticated", Mock()) - - result = results(mock_context, state_with_media_api) - - # Should call auth status function - mock_auth.assert_called_once_with(mock_context.media_api, mock_context.config.general.icons) - - # Verify header contains auth status - call_args = mock_context.selector.choose.call_args - header = call_args[1]['header'] - assert "๐ŸŸข Authenticated" in header - - def test_results_menu_pagination_info_in_header(self, mock_context, empty_state): - """Test that pagination info is displayed in header.""" - search_results = MediaSearchResult( - media=[ - MediaItem( - id=1, - title={"english": "Test Anime", "romaji": "Test Anime"}, - status="FINISHED", - episodes=12 - ) - ], - page_info=PageInfo( - total=30, - per_page=15, - current_page=2, - has_next_page=True - ) - ) - - state_with_pagination = State( - menu_name="RESULTS", - media_api=MediaApiState(search_results=search_results) - ) - - mock_context.selector.choose.return_value = "Back" - - with patch('fastanime.cli.interactive.menus.results.get_auth_status_indicator') as mock_auth: - mock_auth.return_value = ("Auth Status", Mock()) - - result = results(mock_context, state_with_pagination) - - # Verify header contains pagination info - call_args = mock_context.selector.choose.call_args - header = call_args[1]['header'] - assert "Page 2" in header - assert "~2" in header # Total pages - - def test_results_menu_unknown_choice_fallback(self, mock_context, state_with_media_api): - """Test results menu with unknown choice returns CONTINUE.""" - mock_context.selector.choose.return_value = "Unknown Choice" - - with patch('fastanime.cli.interactive.menus.results._format_anime_choice') as mock_format: - mock_format.return_value = "Test Anime" - - result = results(mock_context, state_with_media_api) - - # Should return CONTINUE for unknown choices - assert result == ControlFlow.CONTINUE - - -class TestResultsMenuHelperFunctions: - """Test the helper functions in results menu.""" - - def test_format_anime_choice(self, mock_config, sample_media_item): - """Test formatting anime choice for display.""" - from fastanime.cli.interactive.menus.results import _format_anime_choice - - # Test with English title preferred - mock_config.anilist.preferred_language = "english" - result = _format_anime_choice(sample_media_item, mock_config) - - assert "Test Anime" in result - assert "12" in result # Episode count - - def test_format_anime_choice_romaji(self, mock_config, sample_media_item): - """Test formatting anime choice with romaji preference.""" - from fastanime.cli.interactive.menus.results import _format_anime_choice - - # Test with Romaji title preferred - mock_config.anilist.preferred_language = "romaji" - result = _format_anime_choice(sample_media_item, mock_config) - - assert "Test Anime" in result - - def test_format_anime_choice_no_episodes(self, mock_config): - """Test formatting anime choice with no episode count.""" - from fastanime.cli.interactive.menus.results import _format_anime_choice - - anime_no_episodes = MediaItem( - id=1, - title={"english": "Test Anime", "romaji": "Test Anime"}, - status="FINISHED", - episodes=None - ) - - result = _format_anime_choice(anime_no_episodes, mock_config) - - assert "Test Anime" in result - assert "?" in result # Unknown episode count - - def test_handle_pagination_next_page(self, mock_context, sample_media_item): - """Test pagination handler for next page.""" - from fastanime.cli.interactive.menus.results import _handle_pagination - from fastanime.libs.api.params import ApiSearchParams - - # Create a state with has_next_page=True and original API params - state_with_next_page = State( - menu_name="RESULTS", - media_api=MediaApiState( - search_results=MediaSearchResult( - media=[sample_media_item], - page_info=PageInfo(total=25, per_page=15, current_page=1, has_next_page=True) - ), - original_api_params=ApiSearchParams(sort="TRENDING_DESC") - ) - ) - - # Mock API search parameters from state - mock_context.media_api.search_media.return_value = MediaSearchResult( - media=[], page_info=PageInfo(total=25, per_page=15, current_page=2, has_next_page=False) - ) - - with patch('fastanime.cli.interactive.menus.results.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_context.media_api.search_media.return_value) - - result = _handle_pagination(mock_context, state_with_next_page, 1) - - # Should return new state with updated results - assert isinstance(result, State) - assert result.menu_name == "RESULTS" - - def test_handle_pagination_api_failure(self, mock_context, state_with_media_api): - """Test pagination handler when API fails.""" - from fastanime.cli.interactive.menus.results import _handle_pagination - - with patch('fastanime.cli.interactive.menus.results.execute_with_feedback') as mock_execute: - mock_execute.return_value = (False, None) - - result = _handle_pagination(mock_context, state_with_media_api, 1) - - # Should return CONTINUE on API failure - assert result == ControlFlow.CONTINUE - - def test_handle_pagination_user_list_params(self, mock_context, empty_state): - """Test pagination with user list parameters.""" - from fastanime.cli.interactive.menus.results import _handle_pagination - from fastanime.libs.api.params import UserListParams - - # State with user list params and has_next_page=True - state_with_user_list = State( - menu_name="RESULTS", - media_api=MediaApiState( - search_results=MediaSearchResult( - media=[], - page_info=PageInfo(total=0, per_page=15, current_page=1, has_next_page=True) - ), - original_user_list_params=UserListParams(status="CURRENT", per_page=15) - ) - ) - - mock_context.media_api.fetch_user_list.return_value = MediaSearchResult( - media=[], page_info=PageInfo(total=0, per_page=15, current_page=2, has_next_page=False) - ) - - with patch('fastanime.cli.interactive.menus.results.execute_with_feedback') as mock_execute: - mock_execute.return_value = (True, mock_context.media_api.fetch_user_list.return_value) - - result = _handle_pagination(mock_context, state_with_user_list, 1) - - # Should call fetch_user_list instead of search_media - assert isinstance(result, State) - assert result.menu_name == "RESULTS" diff --git a/tests/interactive/menus/test_servers.py b/tests/interactive/menus/test_servers.py deleted file mode 100644 index f9c177e..0000000 --- a/tests/interactive/menus/test_servers.py +++ /dev/null @@ -1,435 +0,0 @@ -""" -Tests for the servers menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch, MagicMock - -from fastanime.cli.interactive.menus.servers import servers -from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState, ProviderState -from fastanime.libs.providers.anime.types import Anime, Server, EpisodeStream -from fastanime.libs.players.types import PlayerResult - - -class TestServersMenu: - """Test cases for the servers menu.""" - - def test_servers_menu_missing_anime_data(self, mock_context, empty_state): - """Test servers menu with missing anime data.""" - result = servers(mock_context, empty_state) - - # Should go back when anime data is missing - assert result == ControlFlow.BACK - - def test_servers_menu_missing_episode_number(self, mock_context, state_with_provider): - """Test servers menu with missing episode number.""" - # Create state with anime but no episode number - state_no_episode = State( - menu_name="SERVERS", - provider=ProviderState(anime=state_with_provider.provider.anime) - ) - - result = servers(mock_context, state_no_episode) - - # Should go back when episode number is missing - assert result == ControlFlow.BACK - - def test_servers_menu_successful_server_selection(self, mock_context, full_state): - """Test successful server selection and playback.""" - # Setup state with episode number - state_with_episode = State( - menu_name="SERVERS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1" - ) - ) - - # Mock server streams - mock_servers = [ - Server( - name="Server 1", - links=[ - EpisodeStream(link="https://example.com/stream1.m3u8", quality="1080", format="m3u8") - ] - ), - Server( - name="Server 2", - links=[ - EpisodeStream(link="https://example.com/stream2.m3u8", quality="720", format="m3u8") - ] - ) - ] - - # Mock provider episode streams - mock_context.provider.episode_streams.return_value = iter(mock_servers) - - # Mock server selection - mock_context.selector.choose.return_value = "Server 1" - - # Mock successful player result - mock_context.player.play.return_value = PlayerResult(success=True, exit_code=0) - - result = servers(mock_context, state_with_episode) - - # Should transition to PLAYER_CONTROLS state - assert isinstance(result, State) - assert result.menu_name == "PLAYER_CONTROLS" - assert result.provider.last_player_result.success == True - - def test_servers_menu_no_servers_available(self, mock_context, full_state): - """Test servers menu when no servers are available.""" - # Setup state with episode number - state_with_episode = State( - menu_name="SERVERS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1" - ) - ) - - # Mock empty server streams - mock_context.provider.episode_streams.return_value = iter([]) - - result = servers(mock_context, state_with_episode) - - # Should go back when no servers are available - assert result == ControlFlow.BACK - - def test_servers_menu_server_selection_cancelled(self, mock_context, full_state): - """Test servers menu when server selection is cancelled.""" - # Setup state with episode number - state_with_episode = State( - menu_name="SERVERS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1" - ) - ) - - # Mock server streams - mock_servers = [ - Server( - name="Server 1", - links=[EpisodeStream(link="https://example.com/stream1.m3u8", quality="1080", format="m3u8")] - ) - ] - - mock_context.provider.episode_streams.return_value = iter(mock_servers) - - # Mock no selection (cancelled) - mock_context.selector.choose.return_value = None - - result = servers(mock_context, state_with_episode) - - # Should go back when selection is cancelled - assert result == ControlFlow.BACK - - def test_servers_menu_back_selection(self, mock_context, full_state): - """Test servers menu back selection.""" - # Setup state with episode number - state_with_episode = State( - menu_name="SERVERS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1" - ) - ) - - # Mock server streams - mock_servers = [ - Server( - name="Server 1", - links=[EpisodeStream(link="https://example.com/stream1.m3u8", quality="1080", format="m3u8")] - ) - ] - - mock_context.provider.episode_streams.return_value = iter(mock_servers) - - # Mock back selection - mock_context.selector.choose.return_value = "Back" - - result = servers(mock_context, state_with_episode) - - # Should go back - assert result == ControlFlow.BACK - - def test_servers_menu_auto_server_selection(self, mock_context, full_state): - """Test automatic server selection when configured.""" - # Setup state with episode number - state_with_episode = State( - menu_name="SERVERS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1" - ) - ) - - # Mock server streams with specific server name - mock_servers = [ - Server( - name="TOP", # Matches config server preference - links=[EpisodeStream(link="https://example.com/stream1.m3u8", quality="1080", format="m3u8")] - ) - ] - - mock_context.provider.episode_streams.return_value = iter(mock_servers) - mock_context.config.stream.server = "TOP" # Auto-select TOP server - - # Mock successful player result - mock_context.player.play.return_value = PlayerResult(success=True, exit_code=0) - - result = servers(mock_context, state_with_episode) - - # Should auto-select and transition to PLAYER_CONTROLS - assert isinstance(result, State) - assert result.menu_name == "PLAYER_CONTROLS" - - # Selector should not be called for server selection - mock_context.selector.choose.assert_not_called() - - def test_servers_menu_quality_filtering(self, mock_context, full_state): - """Test quality filtering for server links.""" - # Setup state with episode number - state_with_episode = State( - menu_name="SERVERS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1" - ) - ) - - # Mock server with multiple quality links - mock_servers = [ - Server( - name="Server 1", - links=[ - EpisodeStream(link="https://example.com/stream_720.m3u8", quality="720", format="m3u8"), - EpisodeStream(link="https://example.com/stream_1080.m3u8", quality="1080", format="m3u8"), - EpisodeStream(link="https://example.com/stream_480.m3u8", quality="480", format="m3u8") - ] - ) - ] - - mock_context.provider.episode_streams.return_value = iter(mock_servers) - mock_context.config.stream.quality = "720" # Prefer 720p - - # Mock server selection - mock_context.selector.choose.return_value = "Server 1" - - # Mock successful player result - mock_context.player.play.return_value = PlayerResult(success=True, exit_code=0) - - result = servers(mock_context, state_with_episode) - - # Should use the 720p link based on quality preference - mock_context.player.play.assert_called_once() - player_params = mock_context.player.play.call_args[0][0] - assert "stream_720.m3u8" in player_params.url - - def test_servers_menu_player_failure(self, mock_context, full_state): - """Test handling player failure.""" - # Setup state with episode number - state_with_episode = State( - menu_name="SERVERS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1" - ) - ) - - # Mock server streams - mock_servers = [ - Server( - name="Server 1", - links=[EpisodeStream(link="https://example.com/stream1.m3u8", quality="1080", format="m3u8")] - ) - ] - - mock_context.provider.episode_streams.return_value = iter(mock_servers) - mock_context.selector.choose.return_value = "Server 1" - - # Mock failed player result - mock_context.player.play.return_value = PlayerResult(success=False, exit_code=1) - - result = servers(mock_context, state_with_episode) - - # Should still transition to PLAYER_CONTROLS state with failure result - assert isinstance(result, State) - assert result.menu_name == "PLAYER_CONTROLS" - assert result.provider.last_player_result.success == False - - def test_servers_menu_server_with_no_links(self, mock_context, full_state): - """Test handling server with no streaming links.""" - # Setup state with episode number - state_with_episode = State( - menu_name="SERVERS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1" - ) - ) - - # Mock server with no links - mock_servers = [ - Server( - name="Server 1", - links=[] # No streaming links - ) - ] - - mock_context.provider.episode_streams.return_value = iter(mock_servers) - mock_context.selector.choose.return_value = "Server 1" - - result = servers(mock_context, state_with_episode) - - # Should go back when no links are available - assert result == ControlFlow.BACK - - def test_servers_menu_episode_streams_exception(self, mock_context, full_state): - """Test handling exception during episode streams fetch.""" - # Setup state with episode number - state_with_episode = State( - menu_name="SERVERS", - media_api=full_state.media_api, - provider=ProviderState( - anime=full_state.provider.anime, - episode_number="1" - ) - ) - - # Mock exception during episode streams fetch - mock_context.provider.episode_streams.side_effect = Exception("Network error") - - result = servers(mock_context, state_with_episode) - - # Should go back on exception - assert result == ControlFlow.BACK - - -class TestServersMenuHelperFunctions: - """Test the helper functions in servers menu.""" - - def test_filter_by_quality_exact_match(self): - """Test filtering links by exact quality match.""" - from fastanime.cli.interactive.menus.servers import _filter_by_quality - - links = [ - EpisodeStream(link="https://example.com/480.m3u8", quality="480", format="m3u8"), - EpisodeStream(link="https://example.com/720.m3u8", quality="720", format="m3u8"), - EpisodeStream(link="https://example.com/1080.m3u8", quality="1080", format="m3u8") - ] - - result = _filter_by_quality(links, "720") - - assert result.quality == 720 - assert "720.m3u8" in result.url - - def test_filter_by_quality_no_match(self): - """Test filtering links when no quality match is found.""" - from fastanime.cli.interactive.menus.servers import _filter_by_quality - - links = [ - EpisodeStream(link="https://example.com/480.m3u8", quality="480", format="m3u8"), - EpisodeStream(link="https://example.com/720.m3u8", quality="720", format="m3u8") - ] - - result = _filter_by_quality(links, "1080") # Quality not available - - # Should return first link when no match - assert result.quality == 480 - assert "480.m3u8" in result.url - - def test_filter_by_quality_empty_links(self): - """Test filtering with empty links list.""" - from fastanime.cli.interactive.menus.servers import _filter_by_quality - - result = _filter_by_quality([], "720") - - # Should return None for empty list - assert result is None - - def test_format_server_choice_with_quality(self, mock_config): - """Test formatting server choice with quality information.""" - from fastanime.cli.interactive.menus.servers import _format_server_choice - - server = Server( - name="Test Server", - links=[ - EpisodeStream(link="https://example.com/720.m3u8", quality="720", format="m3u8"), - EpisodeStream(link="https://example.com/1080.m3u8", quality="1080", format="m3u8") - ] - ) - - mock_config.general.icons = True - - result = _format_server_choice(server, mock_config) - - assert "Test Server" in result - assert "720p" in result or "1080p" in result # Should show available qualities - - def test_format_server_choice_no_icons(self, mock_config): - """Test formatting server choice without icons.""" - from fastanime.cli.interactive.menus.servers import _format_server_choice - - server = Server( - name="Test Server", - links=[EpisodeStream(link="https://example.com/720.m3u8", quality="720", format="m3u8")] - ) - - mock_config.general.icons = False - - result = _format_server_choice(server, mock_config) - - assert "Test Server" in result - assert "๐ŸŽฌ" not in result # No icons should be present - - def test_get_auto_selected_server_match(self): - """Test getting auto-selected server when match is found.""" - from fastanime.cli.interactive.menus.servers import _get_auto_selected_server - - servers = [ - Server(name="Server 1", url="https://example.com/1", links=[]), - Server(name="TOP", url="https://example.com/top", links=[]), - Server(name="Server 2", url="https://example.com/2", links=[]) - ] - - result = _get_auto_selected_server(servers, "TOP") - - assert result.name == "TOP" - - def test_get_auto_selected_server_no_match(self): - """Test getting auto-selected server when no match is found.""" - from fastanime.cli.interactive.menus.servers import _get_auto_selected_server - - servers = [ - Server(name="Server 1", url="https://example.com/1", links=[]), - Server(name="Server 2", url="https://example.com/2", links=[]) - ] - - result = _get_auto_selected_server(servers, "NonExistent") - - # Should return first server when no match - assert result.name == "Server 1" - - def test_get_auto_selected_server_top_preference(self): - """Test getting auto-selected server with TOP preference.""" - from fastanime.cli.interactive.menus.servers import _get_auto_selected_server - - servers = [ - Server(name="Server 1", url="https://example.com/1", links=[]), - Server(name="Server 2", url="https://example.com/2", links=[]) - ] - - result = _get_auto_selected_server(servers, "TOP") - - # Should return first server for TOP preference - assert result.name == "Server 1" diff --git a/tests/interactive/menus/test_session_management.py b/tests/interactive/menus/test_session_management.py deleted file mode 100644 index 6095b78..0000000 --- a/tests/interactive/menus/test_session_management.py +++ /dev/null @@ -1,463 +0,0 @@ -""" -Tests for the session management menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch, MagicMock -from pathlib import Path -from datetime import datetime - -from fastanime.cli.interactive.menus.session_management import session_management -from fastanime.cli.interactive.state import ControlFlow, State - - -class TestSessionManagementMenu: - """Test cases for the session management menu.""" - - def test_session_management_menu_display(self, mock_context, empty_state): - """Test that session management menu displays correctly.""" - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Main Menu" - - result = session_management(mock_context, empty_state) - - # Should go back when "Back to Main Menu" is selected - assert result == ControlFlow.BACK - - # Verify selector was called with expected options - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Check that key options are present - expected_options = [ - "Save Session", "Load Session", "List Saved Sessions", - "Delete Session", "Session Statistics", "Auto-save Settings", - "Back to Main Menu" - ] - - for option in expected_options: - assert any(option in choice for choice in choices) - - def test_session_management_save_session(self, mock_context, empty_state): - """Test saving a session.""" - mock_context.selector.choose.return_value = "๐Ÿ’พ Save Session" - mock_context.selector.ask.side_effect = ["test_session", "Test session description"] - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.save.return_value = True - - result = session_management(mock_context, empty_state) - - # Should save session and continue - mock_session.save.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_session_management_save_session_cancelled(self, mock_context, empty_state): - """Test saving a session when cancelled.""" - mock_context.selector.choose.return_value = "๐Ÿ’พ Save Session" - mock_context.selector.ask.return_value = "" # Empty session name - - result = session_management(mock_context, empty_state) - - # Should continue without saving - assert result == ControlFlow.CONTINUE - - def test_session_management_load_session(self, mock_context, empty_state): - """Test loading a session.""" - mock_context.selector.choose.return_value = "๐Ÿ“‚ Load Session" - - # Mock available sessions - mock_sessions = [ - {"name": "session1.json", "created": "2023-01-01", "size": "1.2KB"}, - {"name": "session2.json", "created": "2023-01-02", "size": "1.5KB"} - ] - - mock_context.selector.choose.side_effect = [ - "๐Ÿ“‚ Load Session", - "session1.json" - ] - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - mock_session.resume.return_value = True - - result = session_management(mock_context, empty_state) - - # Should load session and reload config - mock_session.resume.assert_called_once() - assert result == ControlFlow.RELOAD_CONFIG - - def test_session_management_load_session_no_sessions(self, mock_context, empty_state): - """Test loading a session when no sessions exist.""" - mock_context.selector.choose.return_value = "๐Ÿ“‚ Load Session" - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.list_saved_sessions.return_value = [] - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = session_management(mock_context, empty_state) - - # Should show info message and continue - feedback_obj.info.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_session_management_load_session_cancelled(self, mock_context, empty_state): - """Test loading a session when selection is cancelled.""" - mock_context.selector.choose.side_effect = [ - "๐Ÿ“‚ Load Session", - None # Cancelled selection - ] - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.list_saved_sessions.return_value = [ - {"name": "session1.json", "created": "2023-01-01", "size": "1.2KB"} - ] - - result = session_management(mock_context, empty_state) - - # Should continue without loading - assert result == ControlFlow.CONTINUE - - def test_session_management_list_sessions(self, mock_context, empty_state): - """Test listing saved sessions.""" - mock_context.selector.choose.return_value = "๐Ÿ“‹ List Saved Sessions" - - mock_sessions = [ - { - "name": "session1.json", - "created": "2023-01-01 12:00:00", - "size": "1.2KB", - "session_name": "Test Session 1", - "description": "Test description 1" - }, - { - "name": "session2.json", - "created": "2023-01-02 13:00:00", - "size": "1.5KB", - "session_name": "Test Session 2", - "description": "Test description 2" - } - ] - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = session_management(mock_context, empty_state) - - # Should display session list and pause - feedback_obj.pause_for_user.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_session_management_list_sessions_empty(self, mock_context, empty_state): - """Test listing sessions when none exist.""" - mock_context.selector.choose.return_value = "๐Ÿ“‹ List Saved Sessions" - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.list_saved_sessions.return_value = [] - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = session_management(mock_context, empty_state) - - # Should show info message - feedback_obj.info.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_session_management_delete_session(self, mock_context, empty_state): - """Test deleting a session.""" - mock_context.selector.choose.side_effect = [ - "๐Ÿ—‘๏ธ Delete Session", - "session1.json" - ] - - mock_sessions = [ - {"name": "session1.json", "created": "2023-01-01", "size": "1.2KB"} - ] - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - feedback_obj.confirm.return_value = True - mock_feedback.return_value = feedback_obj - - with patch('fastanime.cli.interactive.menus.session_management.Path.unlink') as mock_unlink: - result = session_management(mock_context, empty_state) - - # Should delete session file - mock_unlink.assert_called_once() - feedback_obj.success.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_session_management_delete_session_cancelled(self, mock_context, empty_state): - """Test deleting a session when cancelled.""" - mock_context.selector.choose.side_effect = [ - "๐Ÿ—‘๏ธ Delete Session", - "session1.json" - ] - - mock_sessions = [ - {"name": "session1.json", "created": "2023-01-01", "size": "1.2KB"} - ] - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.list_saved_sessions.return_value = mock_sessions - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - feedback_obj.confirm.return_value = False # User cancels deletion - mock_feedback.return_value = feedback_obj - - result = session_management(mock_context, empty_state) - - # Should not delete and continue - assert result == ControlFlow.CONTINUE - - def test_session_management_session_statistics(self, mock_context, empty_state): - """Test viewing session statistics.""" - mock_context.selector.choose.return_value = "๐Ÿ“Š Session Statistics" - - mock_stats = { - "current_states": 5, - "current_menu": "MAIN", - "auto_save_enabled": True, - "has_auto_save": False, - "has_crash_backup": False - } - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.get_session_stats.return_value = mock_stats - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = session_management(mock_context, empty_state) - - # Should display stats and pause - feedback_obj.pause_for_user.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_session_management_toggle_auto_save(self, mock_context, empty_state): - """Test toggling auto-save settings.""" - mock_context.selector.choose.return_value = "โš™๏ธ Auto-save Settings" - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.get_session_stats.return_value = {"auto_save_enabled": True} - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - feedback_obj.confirm.return_value = True - mock_feedback.return_value = feedback_obj - - result = session_management(mock_context, empty_state) - - # Should toggle auto-save - mock_session.enable_auto_save.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_session_management_cleanup_old_sessions(self, mock_context, empty_state): - """Test cleaning up old sessions.""" - mock_context.selector.choose.return_value = "๐Ÿงน Cleanup Old Sessions" - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.cleanup_old_sessions.return_value = 3 # 3 sessions cleaned - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - feedback_obj.confirm.return_value = True - mock_feedback.return_value = feedback_obj - - result = session_management(mock_context, empty_state) - - # Should cleanup and show success - mock_session.cleanup_old_sessions.assert_called_once() - feedback_obj.success.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_session_management_create_backup(self, mock_context, empty_state): - """Test creating manual backup.""" - mock_context.selector.choose.return_value = "๐Ÿ’พ Create Manual Backup" - mock_context.selector.ask.return_value = "my_backup" - - with patch('fastanime.cli.interactive.menus.session_management.session') as mock_session: - mock_session.create_manual_backup.return_value = True - - result = session_management(mock_context, empty_state) - - # Should create backup - mock_session.create_manual_backup.assert_called_once_with("my_backup") - assert result == ControlFlow.CONTINUE - - def test_session_management_back_selection(self, mock_context, empty_state): - """Test selecting back from session management.""" - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Main Menu" - - result = session_management(mock_context, empty_state) - - assert result == ControlFlow.BACK - - def test_session_management_no_choice(self, mock_context, empty_state): - """Test session management when no choice is made.""" - mock_context.selector.choose.return_value = None - - result = session_management(mock_context, empty_state) - - # Should go back when no choice is made - assert result == ControlFlow.BACK - - def test_session_management_icons_enabled(self, mock_context, empty_state): - """Test session management menu with icons enabled.""" - mock_context.config.general.icons = True - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Main Menu" - - result = session_management(mock_context, empty_state) - - # Should work with icons enabled - assert result == ControlFlow.BACK - - def test_session_management_icons_disabled(self, mock_context, empty_state): - """Test session management menu with icons disabled.""" - mock_context.config.general.icons = False - mock_context.selector.choose.return_value = "Back to Main Menu" - - result = session_management(mock_context, empty_state) - - # Should work with icons disabled - assert result == ControlFlow.BACK - - -class TestSessionManagementHelperFunctions: - """Test the helper functions in session management menu.""" - - def test_format_session_info(self): - """Test formatting session information for display.""" - from fastanime.cli.interactive.menus.session_management import _format_session_info - - session_info = { - "name": "test_session.json", - "created": "2023-01-01 12:00:00", - "size": "1.2KB", - "session_name": "Test Session", - "description": "Test description" - } - - result = _format_session_info(session_info, True) # With icons - - assert "Test Session" in result - assert "test_session.json" in result - assert "2023-01-01" in result - - def test_format_session_info_no_icons(self): - """Test formatting session information without icons.""" - from fastanime.cli.interactive.menus.session_management import _format_session_info - - session_info = { - "name": "test_session.json", - "created": "2023-01-01 12:00:00", - "size": "1.2KB", - "session_name": "Test Session", - "description": "Test description" - } - - result = _format_session_info(session_info, False) # Without icons - - assert "Test Session" in result - assert "๐Ÿ“" not in result # No icons should be present - - def test_display_session_statistics(self): - """Test displaying session statistics.""" - from fastanime.cli.interactive.menus.session_management import _display_session_statistics - - console = Mock() - stats = { - "current_states": 5, - "current_menu": "MAIN", - "auto_save_enabled": True, - "has_auto_save": False, - "has_crash_backup": False - } - - _display_session_statistics(console, stats, True) - - # Should print table with statistics - console.print.assert_called() - - def test_get_session_file_path(self): - """Test getting session file path.""" - from fastanime.cli.interactive.menus.session_management import _get_session_file_path - - session_name = "test_session" - - result = _get_session_file_path(session_name) - - assert isinstance(result, Path) - assert result.name == "test_session.json" - - def test_validate_session_name_valid(self): - """Test validating valid session name.""" - from fastanime.cli.interactive.menus.session_management import _validate_session_name - - result = _validate_session_name("valid_session_name") - - assert result is True - - def test_validate_session_name_invalid(self): - """Test validating invalid session name.""" - from fastanime.cli.interactive.menus.session_management import _validate_session_name - - # Test with invalid characters - result = _validate_session_name("invalid/session:name") - - assert result is False - - def test_validate_session_name_empty(self): - """Test validating empty session name.""" - from fastanime.cli.interactive.menus.session_management import _validate_session_name - - result = _validate_session_name("") - - assert result is False - - def test_confirm_session_deletion(self, mock_context): - """Test confirming session deletion.""" - from fastanime.cli.interactive.menus.session_management import _confirm_session_deletion - - session_name = "test_session.json" - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - feedback_obj.confirm.return_value = True - mock_feedback.return_value = feedback_obj - - result = _confirm_session_deletion(session_name, True) - - # Should confirm deletion - feedback_obj.confirm.assert_called_once() - assert result is True - - def test_confirm_session_deletion_cancelled(self, mock_context): - """Test confirming session deletion when cancelled.""" - from fastanime.cli.interactive.menus.session_management import _confirm_session_deletion - - session_name = "test_session.json" - - with patch('fastanime.cli.interactive.menus.session_management.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - feedback_obj.confirm.return_value = False - mock_feedback.return_value = feedback_obj - - result = _confirm_session_deletion(session_name, True) - - # Should not confirm deletion - assert result is False diff --git a/tests/interactive/menus/test_watch_history.py b/tests/interactive/menus/test_watch_history.py deleted file mode 100644 index 70ae86d..0000000 --- a/tests/interactive/menus/test_watch_history.py +++ /dev/null @@ -1,590 +0,0 @@ -""" -Tests for the watch history menu functionality. -""" - -import pytest -from unittest.mock import Mock, patch, MagicMock - -from fastanime.cli.interactive.menus.watch_history import watch_history -from fastanime.cli.interactive.state import ControlFlow, State, MediaApiState -from fastanime.libs.api.types import MediaItem - - -class TestWatchHistoryMenu: - """Test cases for the watch history menu.""" - - def test_watch_history_menu_display(self, mock_context, empty_state): - """Test that watch history menu displays correctly.""" - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Main Menu" - - # Mock watch history - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - }, - { - "anilist_id": 2, - "title": "Test Anime 2", - "last_watched": "2023-01-02 13:00:00", - "episode": 3, - "total_episodes": 24 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - result = watch_history(mock_context, empty_state) - - # Should go back when "Back to Main Menu" is selected - assert result == ControlFlow.BACK - - # Verify selector was called with history items - mock_context.selector.choose.assert_called_once() - call_args = mock_context.selector.choose.call_args - choices = call_args[1]['choices'] - - # Should contain anime from history plus control options - history_items = [choice for choice in choices if "Test Anime" in choice] - assert len(history_items) == 2 - - def test_watch_history_menu_empty_history(self, mock_context, empty_state): - """Test watch history menu with empty history.""" - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = [] - - with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = watch_history(mock_context, empty_state) - - # Should show info message and go back - feedback_obj.info.assert_called_once() - assert result == ControlFlow.BACK - - def test_watch_history_select_anime(self, mock_context, empty_state): - """Test selecting an anime from watch history.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - # Mock AniList anime lookup - mock_anime = MediaItem( - id=1, - title={"english": "Test Anime 1", "romaji": "Test Anime 1"}, - status="FINISHED", - episodes=12 - ) - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - with patch('fastanime.cli.interactive.menus.watch_history._format_history_item') as mock_format: - mock_format.return_value = "Test Anime 1 - Episode 5/12" - mock_context.selector.choose.return_value = "Test Anime 1 - Episode 5/12" - - # Mock successful AniList lookup - mock_context.media_api.get_media_by_id.return_value = mock_anime - - result = watch_history(mock_context, empty_state) - - # Should transition to MEDIA_ACTIONS state - assert isinstance(result, State) - assert result.menu_name == "MEDIA_ACTIONS" - assert result.media_api.anime == mock_anime - - def test_watch_history_anime_lookup_failure(self, mock_context, empty_state): - """Test watch history when anime lookup fails.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - with patch('fastanime.cli.interactive.menus.watch_history._format_history_item') as mock_format: - mock_format.return_value = "Test Anime 1 - Episode 5/12" - mock_context.selector.choose.return_value = "Test Anime 1 - Episode 5/12" - - # Mock failed AniList lookup - mock_context.media_api.get_media_by_id.return_value = None - - with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = watch_history(mock_context, empty_state) - - # Should show error and continue - feedback_obj.error.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_watch_history_clear_history(self, mock_context, empty_state): - """Test clearing watch history.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = "๐Ÿ—‘๏ธ Clear History" - - with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - feedback_obj.confirm.return_value = True - mock_feedback.return_value = feedback_obj - - with patch('fastanime.cli.interactive.menus.watch_history.clear_watch_history') as mock_clear: - result = watch_history(mock_context, empty_state) - - # Should clear history and continue - mock_clear.assert_called_once() - feedback_obj.success.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_watch_history_clear_history_cancelled(self, mock_context, empty_state): - """Test clearing watch history when cancelled.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = "๐Ÿ—‘๏ธ Clear History" - - with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - feedback_obj.confirm.return_value = False # User cancels - mock_feedback.return_value = feedback_obj - - result = watch_history(mock_context, empty_state) - - # Should not clear and continue - assert result == ControlFlow.CONTINUE - - def test_watch_history_export_history(self, mock_context, empty_state): - """Test exporting watch history.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = "๐Ÿ“ค Export History" - mock_context.selector.ask.return_value = "/path/to/export.json" - - with patch('fastanime.cli.interactive.menus.watch_history.export_watch_history') as mock_export: - mock_export.return_value = True - - with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = watch_history(mock_context, empty_state) - - # Should export history and continue - mock_export.assert_called_once() - feedback_obj.success.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_watch_history_export_history_no_path(self, mock_context, empty_state): - """Test exporting watch history with no path provided.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = "๐Ÿ“ค Export History" - mock_context.selector.ask.return_value = "" # Empty path - - result = watch_history(mock_context, empty_state) - - # Should continue without exporting - assert result == ControlFlow.CONTINUE - - def test_watch_history_import_history(self, mock_context, empty_state): - """Test importing watch history.""" - mock_history = [] # Start with empty history - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = "๐Ÿ“ฅ Import History" - mock_context.selector.ask.return_value = "/path/to/import.json" - - with patch('fastanime.cli.interactive.menus.watch_history.import_watch_history') as mock_import: - mock_import.return_value = True - - with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = watch_history(mock_context, empty_state) - - # Should import history and continue - mock_import.assert_called_once() - feedback_obj.success.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_watch_history_view_statistics(self, mock_context, empty_state): - """Test viewing watch history statistics.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - }, - { - "anilist_id": 2, - "title": "Test Anime 2", - "last_watched": "2023-01-02 13:00:00", - "episode": 24, - "total_episodes": 24 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = "๐Ÿ“Š View Statistics" - - with patch('fastanime.cli.interactive.menus.watch_history.create_feedback_manager') as mock_feedback: - feedback_obj = Mock() - mock_feedback.return_value = feedback_obj - - result = watch_history(mock_context, empty_state) - - # Should display statistics and pause - feedback_obj.pause_for_user.assert_called_once() - assert result == ControlFlow.CONTINUE - - def test_watch_history_back_selection(self, mock_context, empty_state): - """Test selecting back from watch history.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Main Menu" - - result = watch_history(mock_context, empty_state) - - assert result == ControlFlow.BACK - - def test_watch_history_no_choice(self, mock_context, empty_state): - """Test watch history when no choice is made.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = None - - result = watch_history(mock_context, empty_state) - - # Should go back when no choice is made - assert result == ControlFlow.BACK - - def test_watch_history_invalid_selection(self, mock_context, empty_state): - """Test watch history with invalid selection.""" - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - with patch('fastanime.cli.interactive.menus.watch_history._format_history_item') as mock_format: - mock_format.return_value = "Test Anime 1 - Episode 5/12" - mock_context.selector.choose.return_value = "Invalid Selection" - - result = watch_history(mock_context, empty_state) - - # Should continue for invalid selection - assert result == ControlFlow.CONTINUE - - def test_watch_history_icons_enabled(self, mock_context, empty_state): - """Test watch history menu with icons enabled.""" - mock_context.config.general.icons = True - - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = "๐Ÿ”™ Back to Main Menu" - - result = watch_history(mock_context, empty_state) - - # Should work with icons enabled - assert result == ControlFlow.BACK - - def test_watch_history_icons_disabled(self, mock_context, empty_state): - """Test watch history menu with icons disabled.""" - mock_context.config.general.icons = False - - mock_history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - ] - - with patch('fastanime.cli.interactive.menus.watch_history.get_watch_history') as mock_get_history: - mock_get_history.return_value = mock_history - - mock_context.selector.choose.return_value = "Back to Main Menu" - - result = watch_history(mock_context, empty_state) - - # Should work with icons disabled - assert result == ControlFlow.BACK - - -class TestWatchHistoryHelperFunctions: - """Test the helper functions in watch history menu.""" - - def test_format_history_item(self): - """Test formatting history item for display.""" - from fastanime.cli.interactive.menus.watch_history import _format_history_item - - history_item = { - "anilist_id": 1, - "title": "Test Anime", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - - result = _format_history_item(history_item, True) # With icons - - assert "Test Anime" in result - assert "5/12" in result # Episode progress - assert "2023-01-01" in result - - def test_format_history_item_no_icons(self): - """Test formatting history item without icons.""" - from fastanime.cli.interactive.menus.watch_history import _format_history_item - - history_item = { - "anilist_id": 1, - "title": "Test Anime", - "last_watched": "2023-01-01 12:00:00", - "episode": 5, - "total_episodes": 12 - } - - result = _format_history_item(history_item, False) # Without icons - - assert "Test Anime" in result - assert "๐Ÿ“บ" not in result # No icons should be present - - def test_format_history_item_completed(self): - """Test formatting completed anime in history.""" - from fastanime.cli.interactive.menus.watch_history import _format_history_item - - history_item = { - "anilist_id": 1, - "title": "Test Anime", - "last_watched": "2023-01-01 12:00:00", - "episode": 12, - "total_episodes": 12 - } - - result = _format_history_item(history_item, True) - - assert "Test Anime" in result - assert "12/12" in result # Completed - assert "โœ…" in result or "Completed" in result - - def test_calculate_watch_statistics(self): - """Test calculating watch history statistics.""" - from fastanime.cli.interactive.menus.watch_history import _calculate_watch_statistics - - history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "episode": 12, - "total_episodes": 12 - }, - { - "anilist_id": 2, - "title": "Test Anime 2", - "episode": 5, - "total_episodes": 24 - }, - { - "anilist_id": 3, - "title": "Test Anime 3", - "episode": 1, - "total_episodes": 12 - } - ] - - stats = _calculate_watch_statistics(history) - - assert stats["total_anime"] == 3 - assert stats["completed_anime"] == 1 - assert stats["in_progress_anime"] == 2 - assert stats["total_episodes_watched"] == 18 - - def test_calculate_watch_statistics_empty(self): - """Test calculating statistics with empty history.""" - from fastanime.cli.interactive.menus.watch_history import _calculate_watch_statistics - - stats = _calculate_watch_statistics([]) - - assert stats["total_anime"] == 0 - assert stats["completed_anime"] == 0 - assert stats["in_progress_anime"] == 0 - assert stats["total_episodes_watched"] == 0 - - def test_display_watch_statistics(self): - """Test displaying watch statistics.""" - from fastanime.cli.interactive.menus.watch_history import _display_watch_statistics - - console = Mock() - stats = { - "total_anime": 10, - "completed_anime": 5, - "in_progress_anime": 3, - "total_episodes_watched": 120 - } - - _display_watch_statistics(console, stats, True) - - # Should print table with statistics - console.print.assert_called() - - def test_get_history_item_by_selection(self): - """Test getting history item by user selection.""" - from fastanime.cli.interactive.menus.watch_history import _get_history_item_by_selection - - history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "episode": 5, - "total_episodes": 12 - }, - { - "anilist_id": 2, - "title": "Test Anime 2", - "episode": 10, - "total_episodes": 24 - } - ] - - formatted_choices = [ - "Test Anime 1 - Episode 5/12", - "Test Anime 2 - Episode 10/24" - ] - - selection = "Test Anime 1 - Episode 5/12" - - result = _get_history_item_by_selection(history, formatted_choices, selection) - - assert result["anilist_id"] == 1 - assert result["title"] == "Test Anime 1" - - def test_get_history_item_by_selection_not_found(self): - """Test getting history item when selection is not found.""" - from fastanime.cli.interactive.menus.watch_history import _get_history_item_by_selection - - history = [ - { - "anilist_id": 1, - "title": "Test Anime 1", - "episode": 5, - "total_episodes": 12 - } - ] - - formatted_choices = ["Test Anime 1 - Episode 5/12"] - selection = "Non-existent Selection" - - result = _get_history_item_by_selection(history, formatted_choices, selection) - - assert result is None diff --git a/tests/test_all_commands.py b/tests/test_all_commands.py deleted file mode 100644 index 2708714..0000000 --- a/tests/test_all_commands.py +++ /dev/null @@ -1,158 +0,0 @@ -from unittest.mock import patch - -import pytest -from click.testing import CliRunner - -from fastanime.cli import run_cli - - -@pytest.fixture -def runner(): - return CliRunner(env={"FASTANIME_CACHE_REQUESTS": "false"}) - - -def test_main_help(runner: CliRunner): - result = runner.invoke(run_cli, ["--help"]) - assert result.exit_code == 0 - - -def test_config_help(runner: CliRunner): - result = runner.invoke(run_cli, ["config", "--help"]) - assert result.exit_code == 0 - - -def test_config_path(runner: CliRunner): - result = runner.invoke(run_cli, ["config", "--path"]) - assert result.exit_code == 0 - - -def test_downloads_help(runner: CliRunner): - result = runner.invoke(run_cli, ["downloads", "--help"]) - assert result.exit_code == 0 - - -def test_downloads_path(runner: CliRunner): - result = runner.invoke(run_cli, ["downloads", "--path"]) - assert result.exit_code == 0 - - -def test_download_help(runner: CliRunner): - result = runner.invoke(run_cli, ["download", "--help"]) - assert result.exit_code == 0 - - -def test_search_help(runner: CliRunner): - result = runner.invoke(run_cli, ["search", "--help"]) - assert result.exit_code == 0 - - -def test_cache_help(runner: CliRunner): - result = runner.invoke(run_cli, ["cache", "--help"]) - assert result.exit_code == 0 - - -def test_completions_help(runner: CliRunner): - result = runner.invoke(run_cli, ["completions", "--help"]) - assert result.exit_code == 0 - - -def test_update_help(runner: CliRunner): - result = runner.invoke(run_cli, ["update", "--help"]) - assert result.exit_code == 0 - - -def test_grab_help(runner: CliRunner): - result = runner.invoke(run_cli, ["grab", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_completed_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "completed", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_dropped_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "dropped", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_favourites_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "favourites", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_login_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "login", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_notifier_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "notifier", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_paused_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "paused", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_planning_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "planning", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_popular_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "popular", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_random_anime_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "random", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_recent_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "recent", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_rewatching_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "rewatching", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_scores_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "scores", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_search_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "search", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_trending_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "trending", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_upcoming_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "upcoming", "--help"]) - assert result.exit_code == 0 - - -def test_anilist_watching_help(runner: CliRunner): - result = runner.invoke(run_cli, ["anilist", "watching", "--help"]) - assert result.exit_code == 0 - - -def test_check_for_updates_not_called_on_completions(runner): - with patch("fastanime.cli.app_updater.check_for_updates") as mock_check_for_updates: - result = runner.invoke(run_cli, ["completions"]) - assert result.exit_code == 0 - mock_check_for_updates.assert_not_called() diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py deleted file mode 100644 index bc3b695..0000000 --- a/tests/test_config_loader.py +++ /dev/null @@ -1,279 +0,0 @@ -from pathlib import Path -from unittest.mock import patch - -import pytest -from fastanime.cli.config.loader import ConfigLoader -from fastanime.cli.config.model import AppConfig, GeneralConfig -from fastanime.core.exceptions import ConfigError - -# ============================================================================== -# Pytest Fixtures -# ============================================================================== - - -@pytest.fixture -def temp_config_dir(tmp_path: Path) -> Path: - """Creates a temporary directory for config files for each test.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - return config_dir - - -@pytest.fixture -def valid_config_content() -> str: - """Provides the content for a valid, complete config.ini file.""" - return """ -[general] -provider = hianime -selector = fzf -auto_select_anime_result = false -icons = true -preview = text -image_renderer = icat -preferred_language = romaji -sub_lang = jpn -manga_viewer = feh -downloads_dir = ~/MyAnimeDownloads -check_for_updates = false -cache_requests = false -max_cache_lifetime = 01:00:00 -normalize_titles = false -discord = true - -[stream] -player = vlc -quality = 720 -translation_type = dub -server = gogoanime -auto_next = true -continue_from_watch_history = false -preferred_watch_history = remote -auto_skip = true -episode_complete_at = 95 -ytdlp_format = best - -[anilist] -per_page = 25 -sort_by = TRENDING_DESC -default_media_list_tracking = track -force_forward_tracking = false -recent = 10 - -[fzf] -opts = --reverse --height=80% -header_color = 255,0,0 -preview_header_color = 0,255,0 -preview_separator_color = 0,0,255 - -[rofi] -theme_main = /path/to/main.rasi -theme_preview = /path/to/preview.rasi -theme_confirm = /path/to/confirm.rasi -theme_input = /path/to/input.rasi - -[mpv] -args = --fullscreen -pre_args = -disable_popen = false -use_python_mpv = true -""" - - -@pytest.fixture -def partial_config_content() -> str: - """Provides content for a partial config file to test default value handling.""" - return """ -[general] -provider = hianime - -[stream] -quality = 720 -""" - - -@pytest.fixture -def malformed_ini_content() -> str: - """Provides content with invalid .ini syntax that configparser will fail on.""" - return "[general\nkey = value" - - -# ============================================================================== -# Test Class for ConfigLoader -# ============================================================================== - - -class TestConfigLoader: - def test_load_creates_and_loads_default_config(self, temp_config_dir: Path): - """ - GIVEN no config file exists. - WHEN the ConfigLoader loads configuration. - THEN it should create a default config file and load default values. - """ - # ARRANGE - config_path = temp_config_dir / "config.ini" - assert not config_path.exists() - loader = ConfigLoader(config_path=config_path) - - # ACT: Mock click.echo to prevent printing during tests - with patch("click.echo"): - config = loader.load() - - # ASSERT: File creation and content - assert config_path.exists() - created_content = config_path.read_text(encoding="utf-8") - assert "[general]" in created_content - assert "# Configuration for general application behavior" in created_content - - # ASSERT: Loaded object has default values. - # Direct object comparison can be brittle, so we test key attributes. - default_config = AppConfig.model_validate({}) - assert config.general.provider == default_config.general.provider - assert config.stream.quality == default_config.stream.quality - assert config.anilist.per_page == default_config.anilist.per_page - # A full comparison might fail due to how Path objects or multi-line strings - # are instantiated vs. read from a file. Testing key values is more robust. - - def test_load_from_valid_full_config( - self, temp_config_dir: Path, valid_config_content: str - ): - """ - GIVEN a valid and complete config file exists. - WHEN the ConfigLoader loads it. - THEN it should return a correctly parsed AppConfig object with overridden values. - """ - # ARRANGE - config_path = temp_config_dir / "config.ini" - config_path.write_text(valid_config_content) - loader = ConfigLoader(config_path=config_path) - - # ACT - config = loader.load() - - # ASSERT - assert isinstance(config, AppConfig) - assert config.general.provider == "hianime" - assert config.general.auto_select_anime_result is False - assert config.general.downloads_dir == Path("~/MyAnimeDownloads") - assert config.stream.quality == "720" - assert config.stream.player == "vlc" - assert config.anilist.per_page == 25 - assert config.fzf.opts == "--reverse --height=80%" - assert config.mpv.use_python_mpv is True - - def test_load_from_partial_config( - self, temp_config_dir: Path, partial_config_content: str - ): - """ - GIVEN a partial config file exists. - WHEN the ConfigLoader loads it. - THEN it should load specified values and use defaults for missing ones. - """ - # ARRANGE - config_path = temp_config_dir / "config.ini" - config_path.write_text(partial_config_content) - loader = ConfigLoader(config_path=config_path) - - # ACT - config = loader.load() - - # ASSERT: Specified values are loaded correctly - assert config.general.provider == "hianime" - assert config.stream.quality == "720" - - # ASSERT: Other values fall back to defaults - default_general = GeneralConfig() - assert config.general.selector == default_general.selector - assert config.general.icons is False - assert config.stream.player == "mpv" - assert config.anilist.per_page == 15 - - @pytest.mark.parametrize( - "value, expected", - [ - ("true", True), - ("false", False), - ("yes", True), - ("no", False), - ("on", True), - ("off", False), - ("1", True), - ("0", False), - ], - ) - def test_boolean_value_handling( - self, temp_config_dir: Path, value: str, expected: bool - ): - """ - GIVEN a config file with various boolean string representations. - WHEN the ConfigLoader loads it. - THEN pydantic should correctly parse them into boolean values. - """ - # ARRANGE - content = f"[general]\nauto_select_anime_result = {value}\n" - config_path = temp_config_dir / "config.ini" - config_path.write_text(content) - loader = ConfigLoader(config_path=config_path) - - # ACT - config = loader.load() - - # ASSERT - assert config.general.auto_select_anime_result is expected - - def test_load_raises_error_for_malformed_ini( - self, temp_config_dir: Path, malformed_ini_content: str - ): - """ - GIVEN a config file has invalid .ini syntax that configparser will reject. - WHEN the ConfigLoader loads it. - THEN it should raise a ConfigError. - """ - # ARRANGE - config_path = temp_config_dir / "config.ini" - config_path.write_text(malformed_ini_content) - loader = ConfigLoader(config_path=config_path) - - # ACT & ASSERT - with pytest.raises(ConfigError, match="Error parsing configuration file"): - loader.load() - - def test_load_raises_error_for_invalid_value(self, temp_config_dir: Path): - """ - GIVEN a config file contains a value that fails model validation. - WHEN the ConfigLoader loads it. - THEN it should raise a ConfigError with a helpful message. - """ - # ARRANGE - invalid_content = "[stream]\nquality = 9001\n" - config_path = temp_config_dir / "config.ini" - config_path.write_text(invalid_content) - loader = ConfigLoader(config_path=config_path) - - # ACT & ASSERT - with pytest.raises(ConfigError) as exc_info: - loader.load() - - # Check for a user-friendly error message - assert "Configuration error" in str(exc_info.value) - assert "stream.quality" in str(exc_info.value) - - def test_load_raises_error_if_default_config_cannot_be_written( - self, temp_config_dir: Path - ): - """ - GIVEN the default config file cannot be written due to permissions. - WHEN the ConfigLoader attempts to create it. - THEN it should raise a ConfigError. - """ - # ARRANGE - config_path = temp_config_dir / "unwritable_dir" / "config.ini" - loader = ConfigLoader(config_path=config_path) - - # ACT & ASSERT: Mock Path.write_text to simulate a permissions error - with patch("pathlib.Path.write_text", side_effect=PermissionError): - with patch("click.echo"): # Mock echo to keep test output clean - with pytest.raises(ConfigError) as exc_info: - loader.load() - - assert "Could not create default configuration file" in str(exc_info.value) - assert "Please check permissions" in str(exc_info.value)