feat: update interactive session logic

This commit is contained in:
Benexl
2025-07-21 22:28:09 +03:00
parent 452c2cf764
commit 0e6aeeea18
16 changed files with 739 additions and 706 deletions

View File

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

View File

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