mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
chore:cleanup
This commit is contained in:
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test package for FastAnime."""
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
# Test package for interactive menu tests
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user