feat: Enhance SeriesApp with async callback support, progress reporting, and cancellation
- Add async_download() and async_rescan() methods for non-blocking operations - Implement ProgressInfo dataclass for structured progress reporting - Add OperationResult dataclass for operation outcomes - Introduce OperationStatus enum for state tracking - Add cancellation support with cancel_operation() method - Implement comprehensive error handling with callbacks - Add progress_callback and error_callback support in constructor - Create 22 comprehensive unit tests for all functionality - Update infrastructure.md with core logic documentation - Remove completed task from instructions.md This enhancement enables web integration with real-time progress updates, graceful cancellation, and better error handling for long-running operations.
This commit is contained in:
556
tests/unit/test_series_app.py
Normal file
556
tests/unit/test_series_app.py
Normal file
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
Unit tests for enhanced SeriesApp with async callback support.
|
||||
|
||||
Tests the functionality of SeriesApp including:
|
||||
- Initialization and configuration
|
||||
- Search functionality
|
||||
- Download with progress callbacks
|
||||
- Directory scanning with progress reporting
|
||||
- Async versions of operations
|
||||
- Cancellation support
|
||||
- Error handling
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.SeriesApp import OperationResult, OperationStatus, ProgressInfo, SeriesApp
|
||||
|
||||
|
||||
class TestSeriesAppInitialization:
|
||||
"""Test SeriesApp initialization."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_init_success(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test successful initialization."""
|
||||
test_dir = "/test/anime"
|
||||
|
||||
# Create app
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Verify initialization
|
||||
assert app.directory_to_search == test_dir
|
||||
assert app._operation_status == OperationStatus.IDLE
|
||||
assert app._cancel_flag is False
|
||||
assert app._current_operation is None
|
||||
mock_loaders.assert_called_once()
|
||||
mock_scanner.assert_called_once()
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_init_with_callbacks(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test initialization with progress and error callbacks."""
|
||||
test_dir = "/test/anime"
|
||||
progress_callback = Mock()
|
||||
error_callback = Mock()
|
||||
|
||||
# Create app with callbacks
|
||||
app = SeriesApp(
|
||||
test_dir,
|
||||
progress_callback=progress_callback,
|
||||
error_callback=error_callback
|
||||
)
|
||||
|
||||
# Verify callbacks are stored
|
||||
assert app.progress_callback == progress_callback
|
||||
assert app.error_callback == error_callback
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
def test_init_failure_calls_error_callback(self, mock_loaders):
|
||||
"""Test that initialization failure triggers error callback."""
|
||||
test_dir = "/test/anime"
|
||||
error_callback = Mock()
|
||||
|
||||
# Make Loaders raise an exception
|
||||
mock_loaders.side_effect = RuntimeError("Init failed")
|
||||
|
||||
# Create app should raise but call error callback
|
||||
with pytest.raises(RuntimeError):
|
||||
SeriesApp(test_dir, error_callback=error_callback)
|
||||
|
||||
# Verify error callback was called
|
||||
error_callback.assert_called_once()
|
||||
assert isinstance(
|
||||
error_callback.call_args[0][0],
|
||||
RuntimeError
|
||||
)
|
||||
|
||||
|
||||
class TestSeriesAppSearch:
|
||||
"""Test search functionality."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_search_success(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test successful search."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Mock search results
|
||||
expected_results = [
|
||||
{"key": "anime1", "name": "Anime 1"},
|
||||
{"key": "anime2", "name": "Anime 2"}
|
||||
]
|
||||
app.loader.Search = Mock(return_value=expected_results)
|
||||
|
||||
# Perform search
|
||||
results = app.search("test anime")
|
||||
|
||||
# Verify results
|
||||
assert results == expected_results
|
||||
app.loader.Search.assert_called_once_with("test anime")
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_search_failure_calls_error_callback(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test search failure triggers error callback."""
|
||||
test_dir = "/test/anime"
|
||||
error_callback = Mock()
|
||||
app = SeriesApp(test_dir, error_callback=error_callback)
|
||||
|
||||
# Make search raise an exception
|
||||
app.loader.Search = Mock(
|
||||
side_effect=RuntimeError("Search failed")
|
||||
)
|
||||
|
||||
# Search should raise and call error callback
|
||||
with pytest.raises(RuntimeError):
|
||||
app.search("test")
|
||||
|
||||
error_callback.assert_called_once()
|
||||
|
||||
|
||||
class TestSeriesAppDownload:
|
||||
"""Test download functionality."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_download_success(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test successful download."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Mock download
|
||||
app.loader.Download = Mock()
|
||||
|
||||
# Perform download
|
||||
result = app.download(
|
||||
"anime_folder",
|
||||
season=1,
|
||||
episode=1,
|
||||
key="anime_key"
|
||||
)
|
||||
|
||||
# Verify result
|
||||
assert result.success is True
|
||||
assert "Successfully downloaded" in result.message
|
||||
# After successful completion, finally block resets operation
|
||||
assert app._current_operation is None
|
||||
app.loader.Download.assert_called_once()
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_download_with_progress_callback(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test download with progress callback."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Mock download that calls progress callback
|
||||
def mock_download(*args, **kwargs):
|
||||
callback = args[-1] if len(args) > 6 else kwargs.get('callback')
|
||||
if callback:
|
||||
callback(0.5)
|
||||
callback(1.0)
|
||||
|
||||
app.loader.Download = Mock(side_effect=mock_download)
|
||||
progress_callback = Mock()
|
||||
|
||||
# Perform download
|
||||
result = app.download(
|
||||
"anime_folder",
|
||||
season=1,
|
||||
episode=1,
|
||||
key="anime_key",
|
||||
callback=progress_callback
|
||||
)
|
||||
|
||||
# Verify progress callback was called
|
||||
assert result.success is True
|
||||
assert progress_callback.call_count == 2
|
||||
progress_callback.assert_any_call(0.5)
|
||||
progress_callback.assert_any_call(1.0)
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_download_cancellation(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test download cancellation during operation."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Mock download that raises InterruptedError for cancellation
|
||||
def mock_download_cancelled(*args, **kwargs):
|
||||
# Simulate cancellation by raising InterruptedError
|
||||
raise InterruptedError("Download cancelled by user")
|
||||
|
||||
app.loader.Download = Mock(side_effect=mock_download_cancelled)
|
||||
|
||||
# Set cancel flag before calling (will be reset by download())
|
||||
# but the mock will raise InterruptedError anyway
|
||||
app._cancel_flag = True
|
||||
|
||||
# Perform download - should catch InterruptedError
|
||||
result = app.download(
|
||||
"anime_folder",
|
||||
season=1,
|
||||
episode=1,
|
||||
key="anime_key"
|
||||
)
|
||||
|
||||
# Verify cancellation was handled
|
||||
assert result.success is False
|
||||
assert "cancelled" in result.message.lower()
|
||||
assert app._current_operation is None
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_download_failure(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test download failure handling."""
|
||||
test_dir = "/test/anime"
|
||||
error_callback = Mock()
|
||||
app = SeriesApp(test_dir, error_callback=error_callback)
|
||||
|
||||
# Make download fail
|
||||
app.loader.Download = Mock(
|
||||
side_effect=RuntimeError("Download failed")
|
||||
)
|
||||
|
||||
# Perform download
|
||||
result = app.download(
|
||||
"anime_folder",
|
||||
season=1,
|
||||
episode=1,
|
||||
key="anime_key"
|
||||
)
|
||||
|
||||
# Verify failure
|
||||
assert result.success is False
|
||||
assert "failed" in result.message.lower()
|
||||
assert result.error is not None
|
||||
# After failure, finally block resets operation
|
||||
assert app._current_operation is None
|
||||
error_callback.assert_called_once()
|
||||
|
||||
|
||||
class TestSeriesAppReScan:
|
||||
"""Test directory scanning functionality."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_rescan_success(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test successful directory rescan."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Mock scanner
|
||||
app.SerieScanner.GetTotalToScan = Mock(return_value=5)
|
||||
app.SerieScanner.Reinit = Mock()
|
||||
app.SerieScanner.Scan = Mock()
|
||||
|
||||
# Perform rescan
|
||||
result = app.ReScan()
|
||||
|
||||
# Verify result
|
||||
assert result.success is True
|
||||
assert "completed" in result.message.lower()
|
||||
# After successful completion, finally block resets operation
|
||||
assert app._current_operation is None
|
||||
app.SerieScanner.Reinit.assert_called_once()
|
||||
app.SerieScanner.Scan.assert_called_once()
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_rescan_with_progress_callback(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test rescan with progress callbacks."""
|
||||
test_dir = "/test/anime"
|
||||
progress_callback = Mock()
|
||||
app = SeriesApp(test_dir, progress_callback=progress_callback)
|
||||
|
||||
# Mock scanner
|
||||
app.SerieScanner.GetTotalToScan = Mock(return_value=3)
|
||||
app.SerieScanner.Reinit = Mock()
|
||||
|
||||
def mock_scan(callback):
|
||||
callback("folder1", 1)
|
||||
callback("folder2", 2)
|
||||
callback("folder3", 3)
|
||||
|
||||
app.SerieScanner.Scan = Mock(side_effect=mock_scan)
|
||||
|
||||
# Perform rescan
|
||||
result = app.ReScan()
|
||||
|
||||
# Verify progress callbacks were called
|
||||
assert result.success is True
|
||||
assert progress_callback.call_count == 3
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_rescan_cancellation(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test rescan cancellation."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Mock scanner
|
||||
app.SerieScanner.GetTotalToScan = Mock(return_value=3)
|
||||
app.SerieScanner.Reinit = Mock()
|
||||
|
||||
def mock_scan(callback):
|
||||
app._cancel_flag = True
|
||||
callback("folder1", 1)
|
||||
|
||||
app.SerieScanner.Scan = Mock(side_effect=mock_scan)
|
||||
|
||||
# Perform rescan
|
||||
result = app.ReScan()
|
||||
|
||||
# Verify cancellation
|
||||
assert result.success is False
|
||||
assert "cancelled" in result.message.lower()
|
||||
|
||||
|
||||
class TestSeriesAppAsync:
|
||||
"""Test async operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
async def test_async_download(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test async download."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Mock download
|
||||
app.loader.Download = Mock()
|
||||
|
||||
# Perform async download
|
||||
result = await app.async_download(
|
||||
"anime_folder",
|
||||
season=1,
|
||||
episode=1,
|
||||
key="anime_key"
|
||||
)
|
||||
|
||||
# Verify result
|
||||
assert isinstance(result, OperationResult)
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
async def test_async_rescan(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test async rescan."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Mock scanner
|
||||
app.SerieScanner.GetTotalToScan = Mock(return_value=5)
|
||||
app.SerieScanner.Reinit = Mock()
|
||||
app.SerieScanner.Scan = Mock()
|
||||
|
||||
# Perform async rescan
|
||||
result = await app.async_rescan()
|
||||
|
||||
# Verify result
|
||||
assert isinstance(result, OperationResult)
|
||||
assert result.success is True
|
||||
|
||||
|
||||
class TestSeriesAppCancellation:
|
||||
"""Test operation cancellation."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_cancel_operation_when_running(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test cancelling a running operation."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Set operation as running
|
||||
app._current_operation = "test_operation"
|
||||
app._operation_status = OperationStatus.RUNNING
|
||||
|
||||
# Cancel operation
|
||||
result = app.cancel_operation()
|
||||
|
||||
# Verify cancellation
|
||||
assert result is True
|
||||
assert app._cancel_flag is True
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_cancel_operation_when_idle(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test cancelling when no operation is running."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Cancel operation (none running)
|
||||
result = app.cancel_operation()
|
||||
|
||||
# Verify no cancellation occurred
|
||||
assert result is False
|
||||
assert app._cancel_flag is False
|
||||
|
||||
|
||||
class TestSeriesAppGetters:
|
||||
"""Test getter methods."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_get_series_list(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test getting series list."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Get series list
|
||||
series_list = app.get_series_list()
|
||||
|
||||
# Verify
|
||||
assert series_list is not None
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_get_operation_status(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test getting operation status."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Get status
|
||||
status = app.get_operation_status()
|
||||
|
||||
# Verify
|
||||
assert status == OperationStatus.IDLE
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
def test_get_current_operation(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test getting current operation."""
|
||||
test_dir = "/test/anime"
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Get current operation
|
||||
operation = app.get_current_operation()
|
||||
|
||||
# Verify
|
||||
assert operation is None
|
||||
|
||||
# Set an operation
|
||||
app._current_operation = "test_op"
|
||||
operation = app.get_current_operation()
|
||||
assert operation == "test_op"
|
||||
|
||||
|
||||
class TestProgressInfo:
|
||||
"""Test ProgressInfo dataclass."""
|
||||
|
||||
def test_progress_info_creation(self):
|
||||
"""Test creating ProgressInfo."""
|
||||
info = ProgressInfo(
|
||||
current=5,
|
||||
total=10,
|
||||
message="Processing...",
|
||||
percentage=50.0,
|
||||
status=OperationStatus.RUNNING
|
||||
)
|
||||
|
||||
assert info.current == 5
|
||||
assert info.total == 10
|
||||
assert info.message == "Processing..."
|
||||
assert info.percentage == 50.0
|
||||
assert info.status == OperationStatus.RUNNING
|
||||
|
||||
|
||||
class TestOperationResult:
|
||||
"""Test OperationResult dataclass."""
|
||||
|
||||
def test_operation_result_success(self):
|
||||
"""Test creating successful OperationResult."""
|
||||
result = OperationResult(
|
||||
success=True,
|
||||
message="Operation completed",
|
||||
data={"key": "value"}
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message == "Operation completed"
|
||||
assert result.data == {"key": "value"}
|
||||
assert result.error is None
|
||||
|
||||
def test_operation_result_failure(self):
|
||||
"""Test creating failed OperationResult."""
|
||||
error = RuntimeError("Test error")
|
||||
result = OperationResult(
|
||||
success=False,
|
||||
message="Operation failed",
|
||||
error=error
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "Operation failed"
|
||||
assert result.error == error
|
||||
assert result.data is None
|
||||
Reference in New Issue
Block a user