Add comprehensive CLI tool tests
- Unit tests for CLI commands (scan, search, download, rescan, display) - Tests for user input handling, selection validation, and retry logic - E2E tests for complete CLI workflows from user perspective - Progress bar functionality and user feedback testing - Error recovery and network failure handling tests - Keyboard interrupt and invalid input scenario testing - Environment variable configuration testing
This commit is contained in:
parent
8f720443a4
commit
9bf8957a50
406
src/tests/e2e/test_cli_flows.py
Normal file
406
src/tests/e2e/test_cli_flows.py
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for CLI flows.
|
||||||
|
|
||||||
|
Tests complete CLI workflows including progress bar functionality,
|
||||||
|
retry logic, user interactions, and error scenarios.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Add source directory to path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||||
|
|
||||||
|
# Import after path setup
|
||||||
|
from src.cli.Main import SeriesApp # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_directory():
|
||||||
|
"""Create a temporary directory for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
yield temp_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
class TestCLICompleteWorkflows:
|
||||||
|
"""Test complete CLI workflows from user perspective."""
|
||||||
|
|
||||||
|
def test_search_and_download_workflow(self, temp_directory):
|
||||||
|
"""Test complete search -> select -> download workflow."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
|
||||||
|
# Mock search results
|
||||||
|
mock_search_results = [
|
||||||
|
{"name": "Test Anime", "link": "test_link"}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mock series for download
|
||||||
|
mock_episode_dict = {1: [1, 2, 3], 2: [1, 2]}
|
||||||
|
mock_series = Mock(
|
||||||
|
episodeDict=mock_episode_dict,
|
||||||
|
folder="test_anime",
|
||||||
|
key="test_key"
|
||||||
|
)
|
||||||
|
app.series_list = [mock_series]
|
||||||
|
|
||||||
|
# Mock loader
|
||||||
|
mock_loader = Mock()
|
||||||
|
mock_loader.Search.return_value = mock_search_results
|
||||||
|
mock_loader.IsLanguage.return_value = True
|
||||||
|
mock_loader.Download.return_value = None
|
||||||
|
app.Loaders.GetLoader.return_value = mock_loader
|
||||||
|
|
||||||
|
# Test search workflow
|
||||||
|
with patch('builtins.input', side_effect=['test query', '1']), \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
app.search_mode()
|
||||||
|
|
||||||
|
# Should have called search and add
|
||||||
|
mock_loader.Search.assert_called_with('test query')
|
||||||
|
app.List.add.assert_called_once()
|
||||||
|
|
||||||
|
# Test download workflow
|
||||||
|
with patch('rich.progress.Progress') as mock_progress_class, \
|
||||||
|
patch('time.sleep'), \
|
||||||
|
patch('builtins.input', return_value='1'):
|
||||||
|
|
||||||
|
mock_progress = Mock()
|
||||||
|
mock_progress_class.return_value = mock_progress
|
||||||
|
|
||||||
|
selected_series = app.get_user_selection()
|
||||||
|
assert selected_series is not None
|
||||||
|
|
||||||
|
app.download_series(selected_series)
|
||||||
|
|
||||||
|
# Should have set up progress tracking
|
||||||
|
mock_progress.start.assert_called_once()
|
||||||
|
mock_progress.stop.assert_called_once()
|
||||||
|
|
||||||
|
# Should have attempted downloads for all episodes
|
||||||
|
expected_downloads = sum(len(episodes) for episodes in mock_episode_dict.values())
|
||||||
|
assert mock_loader.Download.call_count == expected_downloads
|
||||||
|
|
||||||
|
def test_init_and_rescan_workflow(self, temp_directory):
|
||||||
|
"""Test initialization and rescanning workflow."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner') as mock_scanner_class, \
|
||||||
|
patch('src.cli.Main.SerieList') as mock_list_class:
|
||||||
|
|
||||||
|
mock_scanner = Mock()
|
||||||
|
mock_scanner_class.return_value = mock_scanner
|
||||||
|
mock_list = Mock()
|
||||||
|
mock_list_class.return_value = mock_list
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
app.SerieScanner = mock_scanner
|
||||||
|
|
||||||
|
# Test rescan workflow
|
||||||
|
with patch('rich.progress.Progress') as mock_progress_class, \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
mock_progress = Mock()
|
||||||
|
mock_progress_class.return_value = mock_progress
|
||||||
|
|
||||||
|
# Simulate init action
|
||||||
|
app.progress = mock_progress
|
||||||
|
app.task1 = "task1_id"
|
||||||
|
|
||||||
|
# Call reinit workflow
|
||||||
|
app.SerieScanner.Reinit()
|
||||||
|
app.SerieScanner.Scan(app.updateFromReinit)
|
||||||
|
|
||||||
|
# Should have called scanner methods
|
||||||
|
mock_scanner.Reinit.assert_called_once()
|
||||||
|
mock_scanner.Scan.assert_called_once()
|
||||||
|
|
||||||
|
def test_error_recovery_workflow(self, temp_directory):
|
||||||
|
"""Test error recovery in CLI workflows."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
|
||||||
|
# Test retry mechanism with eventual success
|
||||||
|
mock_func = Mock(side_effect=[
|
||||||
|
Exception("First failure"),
|
||||||
|
Exception("Second failure"),
|
||||||
|
None # Success on third try
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch('time.sleep'), patch('builtins.print'):
|
||||||
|
result = app.retry(mock_func, max_retries=3, delay=0)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_func.call_count == 3
|
||||||
|
|
||||||
|
# Test retry mechanism with persistent failure
|
||||||
|
mock_func_fail = Mock(side_effect=Exception("Persistent error"))
|
||||||
|
|
||||||
|
with patch('time.sleep'), patch('builtins.print'):
|
||||||
|
result = app.retry(mock_func_fail, max_retries=2, delay=0)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert mock_func_fail.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
class TestCLIUserInteractionFlows:
|
||||||
|
"""Test CLI user interaction flows."""
|
||||||
|
|
||||||
|
def test_user_selection_validation_flow(self, temp_directory):
|
||||||
|
"""Test user selection with various invalid inputs before success."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
app.series_list = [
|
||||||
|
Mock(name="Anime 1", folder="anime1"),
|
||||||
|
Mock(name="Anime 2", folder="anime2")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Test sequence: invalid text -> invalid number -> valid selection
|
||||||
|
input_sequence = ['invalid_text', '999', '1']
|
||||||
|
|
||||||
|
with patch('builtins.input', side_effect=input_sequence), \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
selected = app.get_user_selection()
|
||||||
|
|
||||||
|
assert selected is not None
|
||||||
|
assert len(selected) == 1
|
||||||
|
assert selected[0].name == "Anime 1"
|
||||||
|
|
||||||
|
def test_search_interaction_flow(self, temp_directory):
|
||||||
|
"""Test search interaction with various user inputs."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
|
||||||
|
mock_search_results = [
|
||||||
|
{"name": "Result 1", "link": "link1"},
|
||||||
|
{"name": "Result 2", "link": "link2"}
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_loader = Mock()
|
||||||
|
mock_loader.Search.return_value = mock_search_results
|
||||||
|
app.Loaders.GetLoader.return_value = mock_loader
|
||||||
|
|
||||||
|
# Test sequence: search -> invalid selection -> valid selection
|
||||||
|
with patch('builtins.input', side_effect=['test search', '999', '1']), \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
app.search_mode()
|
||||||
|
|
||||||
|
# Should have added the selected item
|
||||||
|
app.List.add.assert_called_once()
|
||||||
|
|
||||||
|
def test_main_loop_interaction_flow(self, temp_directory):
|
||||||
|
"""Test main application loop with user interactions."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
app.series_list = [Mock(name="Test Anime", folder="test")]
|
||||||
|
|
||||||
|
# Mock various components
|
||||||
|
with patch.object(app, 'search_mode') as mock_search, \
|
||||||
|
patch.object(app, 'get_user_selection', return_value=[Mock()]), \
|
||||||
|
patch.object(app, 'download_series') as mock_download, \
|
||||||
|
patch('rich.progress.Progress'), \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
# Test sequence: search -> download -> exit
|
||||||
|
with patch('builtins.input', side_effect=['s', 'd', KeyboardInterrupt()]):
|
||||||
|
try:
|
||||||
|
app.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_search.assert_called_once()
|
||||||
|
mock_download.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
class TestCLIProgressAndFeedback:
|
||||||
|
"""Test CLI progress indicators and user feedback."""
|
||||||
|
|
||||||
|
def test_download_progress_flow(self, temp_directory):
|
||||||
|
"""Test download progress tracking throughout workflow."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
|
||||||
|
# Mock series with episodes
|
||||||
|
mock_series = [
|
||||||
|
Mock(
|
||||||
|
episodeDict={1: [1, 2], 2: [1]},
|
||||||
|
folder="anime1",
|
||||||
|
key="key1"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mock loader
|
||||||
|
mock_loader = Mock()
|
||||||
|
mock_loader.IsLanguage.return_value = True
|
||||||
|
mock_loader.Download.return_value = None
|
||||||
|
app.Loaders.GetLoader.return_value = mock_loader
|
||||||
|
|
||||||
|
with patch('rich.progress.Progress') as mock_progress_class, \
|
||||||
|
patch('time.sleep'):
|
||||||
|
|
||||||
|
mock_progress = Mock()
|
||||||
|
mock_progress_class.return_value = mock_progress
|
||||||
|
|
||||||
|
app.download_series(mock_series)
|
||||||
|
|
||||||
|
# Verify progress setup
|
||||||
|
assert mock_progress.add_task.call_count >= 3 # At least 3 tasks
|
||||||
|
mock_progress.start.assert_called_once()
|
||||||
|
mock_progress.stop.assert_called_once()
|
||||||
|
|
||||||
|
# Verify progress updates
|
||||||
|
assert mock_progress.update.call_count > 0
|
||||||
|
|
||||||
|
def test_progress_callback_integration(self, temp_directory):
|
||||||
|
"""Test progress callback integration with download system."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
app.progress = Mock()
|
||||||
|
app.task3 = "download_task"
|
||||||
|
|
||||||
|
# Test various progress states
|
||||||
|
progress_states = [
|
||||||
|
{
|
||||||
|
'status': 'downloading',
|
||||||
|
'total_bytes': 1000000,
|
||||||
|
'downloaded_bytes': 250000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'status': 'downloading',
|
||||||
|
'total_bytes': 1000000,
|
||||||
|
'downloaded_bytes': 750000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'status': 'finished'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for state in progress_states:
|
||||||
|
app.print_Download_Progress(state)
|
||||||
|
|
||||||
|
# Should have updated progress for each state
|
||||||
|
assert app.progress.update.call_count == len(progress_states)
|
||||||
|
|
||||||
|
# Last call should indicate completion
|
||||||
|
last_call = app.progress.update.call_args_list[-1]
|
||||||
|
assert last_call[1].get('completed') == 100
|
||||||
|
|
||||||
|
def test_scan_progress_integration(self, temp_directory):
|
||||||
|
"""Test scanning progress integration."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
app.progress = Mock()
|
||||||
|
app.task1 = "scan_task"
|
||||||
|
|
||||||
|
# Simulate scan progress updates
|
||||||
|
for i in range(5):
|
||||||
|
app.updateFromReinit("folder", i)
|
||||||
|
|
||||||
|
# Should have updated progress for each folder
|
||||||
|
assert app.progress.update.call_count == 5
|
||||||
|
|
||||||
|
# Each call should advance by 1
|
||||||
|
for call in app.progress.update.call_args_list:
|
||||||
|
assert call[1].get('advance') == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
class TestCLIErrorScenarios:
|
||||||
|
"""Test CLI error scenarios and recovery."""
|
||||||
|
|
||||||
|
def test_network_error_recovery(self, temp_directory):
|
||||||
|
"""Test recovery from network errors during operations."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
|
||||||
|
# Mock network failures
|
||||||
|
network_error = Exception("Network connection failed")
|
||||||
|
mock_func = Mock(side_effect=[network_error, network_error, None])
|
||||||
|
|
||||||
|
with patch('time.sleep'), patch('builtins.print'):
|
||||||
|
result = app.retry(mock_func, max_retries=3, delay=0)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_func.call_count == 3
|
||||||
|
|
||||||
|
def test_invalid_directory_handling(self):
|
||||||
|
"""Test handling of invalid directory paths."""
|
||||||
|
invalid_directory = "/nonexistent/path/that/does/not/exist"
|
||||||
|
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
# Should not raise exception during initialization
|
||||||
|
app = SeriesApp(invalid_directory)
|
||||||
|
assert app.directory_to_search == invalid_directory
|
||||||
|
|
||||||
|
def test_empty_search_results_handling(self, temp_directory):
|
||||||
|
"""Test handling of empty search results."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
|
||||||
|
# Mock empty search results
|
||||||
|
mock_loader = Mock()
|
||||||
|
mock_loader.Search.return_value = []
|
||||||
|
app.Loaders.GetLoader.return_value = mock_loader
|
||||||
|
|
||||||
|
with patch('builtins.input', return_value='nonexistent anime'), \
|
||||||
|
patch('builtins.print') as mock_print:
|
||||||
|
|
||||||
|
app.search_mode()
|
||||||
|
|
||||||
|
# Should print "No results found" message
|
||||||
|
print_calls = [call[0][0] for call in mock_print.call_args_list]
|
||||||
|
assert any("No results found" in call for call in print_calls)
|
||||||
|
|
||||||
|
def test_keyboard_interrupt_handling(self, temp_directory):
|
||||||
|
"""Test graceful handling of keyboard interrupts."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
|
||||||
|
app = SeriesApp(temp_directory)
|
||||||
|
|
||||||
|
# Test that KeyboardInterrupt propagates correctly
|
||||||
|
with patch('builtins.input', side_effect=KeyboardInterrupt()):
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
app.run()
|
||||||
424
src/tests/unit/test_cli_commands.py
Normal file
424
src/tests/unit/test_cli_commands.py
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for CLI commands and functionality.
|
||||||
|
|
||||||
|
Tests CLI commands (scan, search, download, rescan, display series),
|
||||||
|
user input handling, and command-line interface logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
# Add source directory to path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||||
|
|
||||||
|
# Import after path setup
|
||||||
|
from src.cli.Main import SeriesApp, NoKeyFoundException, MatchNotFoundError # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_series_app():
|
||||||
|
"""Create a mock SeriesApp instance for testing."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'):
|
||||||
|
app = SeriesApp("/test/directory")
|
||||||
|
app.series_list = [
|
||||||
|
Mock(name="Test Anime 1", folder="test_anime_1"),
|
||||||
|
Mock(name="Test Anime 2", folder="test_anime_2"),
|
||||||
|
Mock(name=None, folder="unknown_anime")
|
||||||
|
]
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCLICommands:
|
||||||
|
"""Test CLI command functionality."""
|
||||||
|
|
||||||
|
def test_display_series_with_names(self, mock_series_app, capsys):
|
||||||
|
"""Test displaying series with proper names."""
|
||||||
|
mock_series_app.display_series()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
output = captured.out
|
||||||
|
|
||||||
|
assert "Current result:" in output
|
||||||
|
assert "1. Test Anime 1" in output
|
||||||
|
assert "2. Test Anime 2" in output
|
||||||
|
assert "3. unknown_anime" in output # Should show folder name when name is None
|
||||||
|
|
||||||
|
def test_search_command(self, mock_series_app):
|
||||||
|
"""Test search command functionality."""
|
||||||
|
mock_loader = Mock()
|
||||||
|
mock_loader.Search.return_value = [
|
||||||
|
{"name": "Found Anime 1", "link": "link1"},
|
||||||
|
{"name": "Found Anime 2", "link": "link2"}
|
||||||
|
]
|
||||||
|
mock_series_app.Loaders.GetLoader.return_value = mock_loader
|
||||||
|
|
||||||
|
results = mock_series_app.search("test query")
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0]["name"] == "Found Anime 1"
|
||||||
|
mock_loader.Search.assert_called_once_with("test query")
|
||||||
|
|
||||||
|
def test_search_no_results(self, mock_series_app):
|
||||||
|
"""Test search command with no results."""
|
||||||
|
mock_loader = Mock()
|
||||||
|
mock_loader.Search.return_value = []
|
||||||
|
mock_series_app.Loaders.GetLoader.return_value = mock_loader
|
||||||
|
|
||||||
|
results = mock_series_app.search("nonexistent")
|
||||||
|
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
def test_user_selection_single(self, mock_series_app):
|
||||||
|
"""Test user selection with single series."""
|
||||||
|
with patch('builtins.input', return_value='1'):
|
||||||
|
selected = mock_series_app.get_user_selection()
|
||||||
|
|
||||||
|
assert selected is not None
|
||||||
|
assert len(selected) == 1
|
||||||
|
assert selected[0].name == "Test Anime 1"
|
||||||
|
|
||||||
|
def test_user_selection_multiple(self, mock_series_app):
|
||||||
|
"""Test user selection with multiple series."""
|
||||||
|
with patch('builtins.input', return_value='1,2'):
|
||||||
|
selected = mock_series_app.get_user_selection()
|
||||||
|
|
||||||
|
assert selected is not None
|
||||||
|
assert len(selected) == 2
|
||||||
|
assert selected[0].name == "Test Anime 1"
|
||||||
|
assert selected[1].name == "Test Anime 2"
|
||||||
|
|
||||||
|
def test_user_selection_all(self, mock_series_app):
|
||||||
|
"""Test user selection with 'all' option."""
|
||||||
|
with patch('builtins.input', return_value='all'):
|
||||||
|
selected = mock_series_app.get_user_selection()
|
||||||
|
|
||||||
|
assert selected is not None
|
||||||
|
assert len(selected) == 3 # All series
|
||||||
|
|
||||||
|
def test_user_selection_exit(self, mock_series_app):
|
||||||
|
"""Test user selection with exit command."""
|
||||||
|
with patch('builtins.input', return_value='exit'):
|
||||||
|
selected = mock_series_app.get_user_selection()
|
||||||
|
|
||||||
|
assert selected is None
|
||||||
|
|
||||||
|
def test_user_selection_invalid_input(self, mock_series_app):
|
||||||
|
"""Test user selection with invalid input followed by valid input."""
|
||||||
|
with patch('builtins.input', side_effect=['invalid', 'abc', '1']):
|
||||||
|
with patch('builtins.print'): # Suppress print output
|
||||||
|
selected = mock_series_app.get_user_selection()
|
||||||
|
|
||||||
|
assert selected is not None
|
||||||
|
assert len(selected) == 1
|
||||||
|
|
||||||
|
def test_retry_mechanism_success(self, mock_series_app):
|
||||||
|
"""Test retry mechanism with successful operation."""
|
||||||
|
mock_func = Mock()
|
||||||
|
|
||||||
|
result = mock_series_app.retry(mock_func, "arg1", max_retries=3, delay=0, key="value")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_func.assert_called_once_with("arg1", key="value")
|
||||||
|
|
||||||
|
def test_retry_mechanism_eventual_success(self, mock_series_app):
|
||||||
|
"""Test retry mechanism with failure then success."""
|
||||||
|
mock_func = Mock(side_effect=[Exception("Error"), Exception("Error"), None])
|
||||||
|
|
||||||
|
with patch('time.sleep'): # Speed up test
|
||||||
|
result = mock_series_app.retry(mock_func, max_retries=3, delay=0)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_func.call_count == 3
|
||||||
|
|
||||||
|
def test_retry_mechanism_failure(self, mock_series_app):
|
||||||
|
"""Test retry mechanism with persistent failure."""
|
||||||
|
mock_func = Mock(side_effect=Exception("Persistent error"))
|
||||||
|
|
||||||
|
with patch('time.sleep'), patch('builtins.print'): # Speed up test and suppress error output
|
||||||
|
result = mock_series_app.retry(mock_func, max_retries=3, delay=0)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert mock_func.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCLISearchMode:
|
||||||
|
"""Test CLI search mode functionality."""
|
||||||
|
|
||||||
|
def test_search_mode_with_results(self, mock_series_app):
|
||||||
|
"""Test search mode with search results."""
|
||||||
|
mock_results = [
|
||||||
|
{"name": "Anime 1", "link": "link1"},
|
||||||
|
{"name": "Anime 2", "link": "link2"}
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch('builtins.input', side_effect=['test search', '1']), \
|
||||||
|
patch.object(mock_series_app, 'search', return_value=mock_results), \
|
||||||
|
patch.object(mock_series_app.List, 'add') as mock_add, \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
mock_series_app.search_mode()
|
||||||
|
|
||||||
|
mock_add.assert_called_once()
|
||||||
|
|
||||||
|
def test_search_mode_no_results(self, mock_series_app):
|
||||||
|
"""Test search mode with no results."""
|
||||||
|
with patch('builtins.input', return_value='nonexistent'), \
|
||||||
|
patch.object(mock_series_app, 'search', return_value=[]), \
|
||||||
|
patch('builtins.print') as mock_print:
|
||||||
|
|
||||||
|
mock_series_app.search_mode()
|
||||||
|
|
||||||
|
# Should print "No results found"
|
||||||
|
print_calls = [call[0][0] for call in mock_print.call_args_list]
|
||||||
|
assert any("No results found" in call for call in print_calls)
|
||||||
|
|
||||||
|
def test_search_mode_empty_selection(self, mock_series_app):
|
||||||
|
"""Test search mode with empty selection (return)."""
|
||||||
|
mock_results = [{"name": "Anime 1", "link": "link1"}]
|
||||||
|
|
||||||
|
with patch('builtins.input', side_effect=['test', '']), \
|
||||||
|
patch.object(mock_series_app, 'search', return_value=mock_results), \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
# Should return without error
|
||||||
|
mock_series_app.search_mode()
|
||||||
|
|
||||||
|
def test_search_mode_invalid_selection(self, mock_series_app):
|
||||||
|
"""Test search mode with invalid then valid selection."""
|
||||||
|
mock_results = [{"name": "Anime 1", "link": "link1"}]
|
||||||
|
|
||||||
|
with patch('builtins.input', side_effect=['test', '999', '1']), \
|
||||||
|
patch.object(mock_series_app, 'search', return_value=mock_results), \
|
||||||
|
patch.object(mock_series_app.List, 'add'), \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
mock_series_app.search_mode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCLIDownloadFunctionality:
|
||||||
|
"""Test CLI download functionality."""
|
||||||
|
|
||||||
|
def test_download_series_setup(self, mock_series_app):
|
||||||
|
"""Test download series initialization."""
|
||||||
|
mock_series = [
|
||||||
|
Mock(episodeDict={1: [1, 2], 2: [1]}, folder="anime1", key="key1"),
|
||||||
|
Mock(episodeDict={1: [1]}, folder="anime2", key="key2")
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch('rich.progress.Progress') as mock_progress_class, \
|
||||||
|
patch('time.sleep'):
|
||||||
|
|
||||||
|
mock_progress = Mock()
|
||||||
|
mock_progress_class.return_value = mock_progress
|
||||||
|
|
||||||
|
mock_series_app.download_series(mock_series)
|
||||||
|
|
||||||
|
# Should create progress tracking
|
||||||
|
mock_progress.add_task.assert_called()
|
||||||
|
mock_progress.start.assert_called_once()
|
||||||
|
mock_progress.stop.assert_called_once()
|
||||||
|
|
||||||
|
def test_download_progress_callback(self, mock_series_app):
|
||||||
|
"""Test download progress callback functionality."""
|
||||||
|
mock_series_app.progress = Mock()
|
||||||
|
mock_series_app.task3 = "task3_id"
|
||||||
|
|
||||||
|
# Test downloading status
|
||||||
|
download_data = {
|
||||||
|
'status': 'downloading',
|
||||||
|
'total_bytes': 1000,
|
||||||
|
'downloaded_bytes': 500
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_series_app.print_Download_Progress(download_data)
|
||||||
|
|
||||||
|
mock_series_app.progress.update.assert_called()
|
||||||
|
|
||||||
|
# Test finished status
|
||||||
|
download_data['status'] = 'finished'
|
||||||
|
mock_series_app.print_Download_Progress(download_data)
|
||||||
|
|
||||||
|
# Should update progress to 100%
|
||||||
|
update_calls = mock_series_app.progress.update.call_args_list
|
||||||
|
assert any(call[1].get('completed') == 100 for call in update_calls)
|
||||||
|
|
||||||
|
def test_download_progress_no_total(self, mock_series_app):
|
||||||
|
"""Test download progress with no total bytes."""
|
||||||
|
mock_series_app.progress = Mock()
|
||||||
|
mock_series_app.task3 = "task3_id"
|
||||||
|
|
||||||
|
download_data = {
|
||||||
|
'status': 'downloading',
|
||||||
|
'downloaded_bytes': 5242880 # 5MB
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_series_app.print_Download_Progress(download_data)
|
||||||
|
|
||||||
|
# Should handle case where total_bytes is not available
|
||||||
|
mock_series_app.progress.update.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCLIMainLoop:
|
||||||
|
"""Test CLI main application loop."""
|
||||||
|
|
||||||
|
def test_main_loop_search_action(self, mock_series_app):
|
||||||
|
"""Test main loop with search action."""
|
||||||
|
with patch('builtins.input', side_effect=['s', KeyboardInterrupt()]), \
|
||||||
|
patch.object(mock_series_app, 'search_mode') as mock_search:
|
||||||
|
|
||||||
|
try:
|
||||||
|
mock_series_app.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_search.assert_called_once()
|
||||||
|
|
||||||
|
def test_main_loop_init_action(self, mock_series_app):
|
||||||
|
"""Test main loop with init action."""
|
||||||
|
with patch('builtins.input', side_effect=['i', KeyboardInterrupt()]), \
|
||||||
|
patch('rich.progress.Progress'), \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
mock_series_app.SerieScanner = Mock()
|
||||||
|
mock_series_app.List = Mock()
|
||||||
|
|
||||||
|
try:
|
||||||
|
mock_series_app.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_series_app.SerieScanner.Reinit.assert_called_once()
|
||||||
|
mock_series_app.SerieScanner.Scan.assert_called_once()
|
||||||
|
|
||||||
|
def test_main_loop_download_action(self, mock_series_app):
|
||||||
|
"""Test main loop with download action."""
|
||||||
|
mock_selected = [Mock()]
|
||||||
|
|
||||||
|
with patch('builtins.input', side_effect=['d', KeyboardInterrupt()]), \
|
||||||
|
patch.object(mock_series_app, 'get_user_selection', return_value=mock_selected), \
|
||||||
|
patch.object(mock_series_app, 'download_series') as mock_download:
|
||||||
|
|
||||||
|
try:
|
||||||
|
mock_series_app.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_download.assert_called_once_with(mock_selected)
|
||||||
|
|
||||||
|
def test_main_loop_download_action_no_selection(self, mock_series_app):
|
||||||
|
"""Test main loop with download action but no series selected."""
|
||||||
|
with patch('builtins.input', side_effect=['d', KeyboardInterrupt()]), \
|
||||||
|
patch.object(mock_series_app, 'get_user_selection', return_value=None), \
|
||||||
|
patch.object(mock_series_app, 'download_series') as mock_download:
|
||||||
|
|
||||||
|
try:
|
||||||
|
mock_series_app.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCLIExceptions:
|
||||||
|
"""Test CLI exception handling."""
|
||||||
|
|
||||||
|
def test_no_key_found_exception(self):
|
||||||
|
"""Test NoKeyFoundException creation and usage."""
|
||||||
|
exception = NoKeyFoundException("Test message")
|
||||||
|
assert str(exception) == "Test message"
|
||||||
|
assert isinstance(exception, Exception)
|
||||||
|
|
||||||
|
def test_match_not_found_error(self):
|
||||||
|
"""Test MatchNotFoundError creation and usage."""
|
||||||
|
error = MatchNotFoundError("No match found")
|
||||||
|
assert str(error) == "No match found"
|
||||||
|
assert isinstance(error, Exception)
|
||||||
|
|
||||||
|
def test_exception_handling_in_retry(self, mock_series_app):
|
||||||
|
"""Test exception handling in retry mechanism."""
|
||||||
|
def failing_function():
|
||||||
|
raise NoKeyFoundException("Key not found")
|
||||||
|
|
||||||
|
with patch('time.sleep'), patch('builtins.print'):
|
||||||
|
result = mock_series_app.retry(failing_function, max_retries=2, delay=0)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCLIInitialization:
|
||||||
|
"""Test CLI application initialization."""
|
||||||
|
|
||||||
|
def test_series_app_initialization(self):
|
||||||
|
"""Test SeriesApp initialization."""
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'), \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
app = SeriesApp("/test/directory")
|
||||||
|
|
||||||
|
assert app.directory_to_search == "/test/directory"
|
||||||
|
assert app.progress is None
|
||||||
|
assert hasattr(app, 'Loaders')
|
||||||
|
assert hasattr(app, 'SerieScanner')
|
||||||
|
assert hasattr(app, 'List')
|
||||||
|
|
||||||
|
def test_initialization_count_tracking(self):
|
||||||
|
"""Test that initialization count is tracked properly."""
|
||||||
|
initial_count = SeriesApp._initialization_count
|
||||||
|
|
||||||
|
with patch('src.cli.Main.Loaders'), \
|
||||||
|
patch('src.cli.Main.SerieScanner'), \
|
||||||
|
patch('src.cli.Main.SerieList'), \
|
||||||
|
patch('builtins.print'):
|
||||||
|
|
||||||
|
SeriesApp("/test1")
|
||||||
|
SeriesApp("/test2")
|
||||||
|
|
||||||
|
assert SeriesApp._initialization_count == initial_count + 2
|
||||||
|
|
||||||
|
def test_init_list_method(self, mock_series_app):
|
||||||
|
"""Test __InitList__ method."""
|
||||||
|
mock_missing_episodes = [Mock(), Mock()]
|
||||||
|
mock_series_app.List.GetMissingEpisode.return_value = mock_missing_episodes
|
||||||
|
|
||||||
|
mock_series_app._SeriesApp__InitList__()
|
||||||
|
|
||||||
|
assert mock_series_app.series_list == mock_missing_episodes
|
||||||
|
mock_series_app.List.GetMissingEpisode.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCLIEnvironmentVariables:
|
||||||
|
"""Test CLI environment variable handling."""
|
||||||
|
|
||||||
|
def test_default_anime_directory(self):
|
||||||
|
"""Test default ANIME_DIRECTORY handling."""
|
||||||
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
|
patch('src.cli.Main.SeriesApp') as mock_app:
|
||||||
|
|
||||||
|
# Import and run main module simulation
|
||||||
|
import src.cli.Main
|
||||||
|
|
||||||
|
# The default should be the hardcoded path
|
||||||
|
default_path = "\\\\sshfs.r\\ubuntu@192.168.178.43\\media\\serien\\Serien"
|
||||||
|
result = os.getenv("ANIME_DIRECTORY", default_path)
|
||||||
|
assert result == default_path
|
||||||
|
|
||||||
|
def test_custom_anime_directory(self):
|
||||||
|
"""Test custom ANIME_DIRECTORY from environment."""
|
||||||
|
custom_path = "/custom/anime/directory"
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {'ANIME_DIRECTORY': custom_path}):
|
||||||
|
result = os.getenv("ANIME_DIRECTORY", "default")
|
||||||
|
assert result == custom_path
|
||||||
Loading…
x
Reference in New Issue
Block a user