""" End-to-end tests for CLI flows. Tests complete CLI workflows including progress bar functionality, retry logic, user interactions, and error scenarios. """ import os import sys import tempfile from unittest.mock import Mock, patch import pytest # 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()