Files
FastAnime/tests/cli/interactive/test_session.py
2025-07-21 22:28:09 +03:00

507 lines
21 KiB
Python

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