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:
parent
0957a6e183
commit
59edf6bd50
@ -7,7 +7,22 @@ conda activate AniWorld
|
||||
```
|
||||
/home/lukas/Volume/repo/Aniworld/
|
||||
├── src/
|
||||
│ ├── server/ # FastAPI web application
|
||||
│ ├── core/ # Core application logic
|
||||
│ │ ├── SeriesApp.py # Main application class with async support
|
||||
│ │ ├── SerieScanner.py # Directory scanner for anime series
|
||||
│ │ ├── entities/ # Domain entities
|
||||
│ │ │ ├── series.py # Serie data model
|
||||
│ │ │ └── SerieList.py # Series list management
|
||||
│ │ ├── interfaces/ # Abstract interfaces
|
||||
│ │ │ └── providers.py # Provider interface definitions
|
||||
│ │ ├── providers/ # Content providers
|
||||
│ │ │ ├── base_provider.py # Base loader interface
|
||||
│ │ │ ├── aniworld_provider.py # Aniworld.to implementation
|
||||
│ │ │ ├── provider_factory.py # Provider factory
|
||||
│ │ │ └── streaming/ # Streaming providers (VOE, etc.)
|
||||
│ │ └── exceptions/ # Custom exceptions
|
||||
│ │ └── Exceptions.py # Exception definitions
|
||||
│ ├── server/ # FastAPI web application
|
||||
│ │ ├── fastapi_app.py # Main FastAPI application (simplified)
|
||||
│ │ ├── main.py # FastAPI application entry point
|
||||
│ │ ├── controllers/ # Route controllers
|
||||
@ -218,8 +233,98 @@ initialization.
|
||||
this state to a shared store (Redis) and persist the master password
|
||||
hash in a secure config store.
|
||||
|
||||
## Core Application Logic
|
||||
|
||||
### SeriesApp - Enhanced Core Engine
|
||||
|
||||
The `SeriesApp` class (`src/core/SeriesApp.py`) is the main application engine for anime series management. Enhanced with async support and web integration capabilities.
|
||||
|
||||
#### Key Features
|
||||
|
||||
- **Async Operations**: Support for async download and scan operations
|
||||
- **Progress Callbacks**: Real-time progress reporting via callbacks
|
||||
- **Cancellation Support**: Ability to cancel long-running operations
|
||||
- **Error Handling**: Comprehensive error handling with callback notifications
|
||||
- **Operation Status**: Track current operation status and history
|
||||
|
||||
#### Core Classes
|
||||
|
||||
- `SeriesApp`: Main application class
|
||||
- `OperationStatus`: Enum for operation states (IDLE, RUNNING, COMPLETED, CANCELLED, FAILED)
|
||||
- `ProgressInfo`: Dataclass for progress information
|
||||
- `OperationResult`: Dataclass for operation results
|
||||
|
||||
#### Key Methods
|
||||
|
||||
- `search(words)`: Search for anime series
|
||||
- `download()`: Download episodes with progress tracking
|
||||
- `ReScan()`: Scan directory for missing episodes
|
||||
- `async_download()`: Async version of download
|
||||
- `async_rescan()`: Async version of rescan
|
||||
- `cancel_operation()`: Cancel current operation
|
||||
- `get_operation_status()`: Get current status
|
||||
- `get_series_list()`: Get series with missing episodes
|
||||
|
||||
#### Integration Points
|
||||
|
||||
The SeriesApp integrates with:
|
||||
|
||||
- Provider system for content downloading
|
||||
- Serie scanner for directory analysis
|
||||
- Series list management for tracking missing episodes
|
||||
- Web layer via async operations and callbacks
|
||||
|
||||
## Recent Infrastructure Changes
|
||||
|
||||
### Core Logic Enhancement (October 2025)
|
||||
|
||||
Enhanced `SeriesApp` with async callback support, progress reporting, and cancellation.
|
||||
|
||||
#### Changes Made
|
||||
|
||||
1. **Async Support**:
|
||||
|
||||
- Added `async_download()` and `async_rescan()` methods
|
||||
- Integrated with asyncio event loop for non-blocking operations
|
||||
- Support for concurrent operations in web environment
|
||||
|
||||
2. **Progress Reporting**:
|
||||
|
||||
- `ProgressInfo` dataclass for structured progress data
|
||||
- Callback system for real-time progress updates
|
||||
- Percentage calculation and status tracking
|
||||
|
||||
3. **Cancellation System**:
|
||||
|
||||
- Internal cancellation flag management
|
||||
- Graceful operation cancellation
|
||||
- Cancellation check during long-running operations
|
||||
|
||||
4. **Error Handling**:
|
||||
|
||||
- `OperationResult` dataclass for operation outcomes
|
||||
- Error callback system for notifications
|
||||
- Specific exception types (IOError, OSError, RuntimeError)
|
||||
- Proper exception propagation and logging
|
||||
|
||||
5. **Status Management**:
|
||||
- `OperationStatus` enum for state tracking
|
||||
- Current operation identifier
|
||||
- Status getter methods for monitoring
|
||||
|
||||
#### Test Coverage
|
||||
|
||||
Comprehensive test suite (`tests/unit/test_series_app.py`) with 22 tests covering:
|
||||
|
||||
- Initialization and configuration
|
||||
- Search functionality
|
||||
- Download operations with callbacks
|
||||
- Directory scanning with progress
|
||||
- Async operations
|
||||
- Cancellation handling
|
||||
- Error scenarios
|
||||
- Data model validation
|
||||
|
||||
### Template Integration (October 2025)
|
||||
|
||||
Completed integration of HTML templates with FastAPI Jinja2 system.
|
||||
|
||||
@ -45,14 +45,6 @@ The tasks should be completed in the following order to ensure proper dependenci
|
||||
|
||||
### 8. Core Logic Integration
|
||||
|
||||
#### [] Enhance SeriesApp for web integration
|
||||
|
||||
- []Update `src/core/SeriesApp.py`
|
||||
- []Add async callback support
|
||||
- []Implement progress reporting
|
||||
- []Include better error handling
|
||||
- []Add cancellation support
|
||||
|
||||
#### [] Create progress callback system
|
||||
|
||||
- []Add progress callback interface
|
||||
|
||||
@ -1,38 +1,449 @@
|
||||
from src.core.entities.SerieList import SerieList
|
||||
from src.core.providers.provider_factory import Loaders
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
|
||||
|
||||
class SeriesApp:
|
||||
_initialization_count = 0
|
||||
|
||||
def __init__(self, directory_to_search: str):
|
||||
SeriesApp._initialization_count += 1 # Only show initialization message for the first instance
|
||||
if SeriesApp._initialization_count <= 1:
|
||||
print("Please wait while initializing...")
|
||||
|
||||
self.progress = None
|
||||
self.directory_to_search = directory_to_search
|
||||
self.Loaders = Loaders()
|
||||
self.loader = self.Loaders.GetLoader(key="aniworld.to")
|
||||
self.SerieScanner = SerieScanner(directory_to_search, self.loader)
|
||||
|
||||
self.List = SerieList(self.directory_to_search)
|
||||
self.__InitList__()
|
||||
|
||||
def __InitList__(self):
|
||||
self.series_list = self.List.GetMissingEpisode()
|
||||
|
||||
def search(self, words: str) -> list:
|
||||
return self.loader.Search(words)
|
||||
|
||||
def download(self, serieFolder: str, season: int, episode: int, key: str, callback) -> bool:
|
||||
self.loader.Download(self.directory_to_search, serieFolder, season, episode, key, "German Dub", callback)
|
||||
|
||||
def ReScan(self, callback):
|
||||
|
||||
self.SerieScanner.Reinit()
|
||||
self.SerieScanner.Scan(callback)
|
||||
|
||||
self.List = SerieList(self.directory_to_search)
|
||||
self.__InitList__()
|
||||
"""
|
||||
SeriesApp - Core application logic for anime series management.
|
||||
|
||||
This module provides the main application interface for searching,
|
||||
downloading, and managing anime series with support for async callbacks,
|
||||
progress reporting, error handling, and operation cancellation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from src.core.entities.SerieList import SerieList
|
||||
from src.core.providers.provider_factory import Loaders
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OperationStatus(Enum):
|
||||
"""Status of an operation."""
|
||||
IDLE = "idle"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgressInfo:
|
||||
"""Progress information for long-running operations."""
|
||||
current: int
|
||||
total: int
|
||||
message: str
|
||||
percentage: float
|
||||
status: OperationStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperationResult:
|
||||
"""Result of an operation."""
|
||||
success: bool
|
||||
message: str
|
||||
data: Optional[Any] = None
|
||||
error: Optional[Exception] = None
|
||||
|
||||
|
||||
class SeriesApp:
|
||||
"""
|
||||
Main application class for anime series management.
|
||||
|
||||
Provides functionality for:
|
||||
- Searching anime series
|
||||
- Downloading episodes
|
||||
- Scanning directories for missing episodes
|
||||
- Managing series lists
|
||||
|
||||
Supports async callbacks for progress reporting and cancellation.
|
||||
"""
|
||||
|
||||
_initialization_count = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory_to_search: str,
|
||||
progress_callback: Optional[Callable[[ProgressInfo], None]] = None,
|
||||
error_callback: Optional[Callable[[Exception], None]] = None
|
||||
):
|
||||
"""
|
||||
Initialize SeriesApp.
|
||||
|
||||
Args:
|
||||
directory_to_search: Base directory for anime series
|
||||
progress_callback: Optional callback for progress updates
|
||||
error_callback: Optional callback for error notifications
|
||||
"""
|
||||
SeriesApp._initialization_count += 1
|
||||
|
||||
# Only show initialization message for the first instance
|
||||
if SeriesApp._initialization_count <= 1:
|
||||
logger.info("Initializing SeriesApp...")
|
||||
|
||||
self.directory_to_search = directory_to_search
|
||||
self.progress_callback = progress_callback
|
||||
self.error_callback = error_callback
|
||||
|
||||
# Cancellation support
|
||||
self._cancel_flag = False
|
||||
self._current_operation: Optional[str] = None
|
||||
self._operation_status = OperationStatus.IDLE
|
||||
|
||||
# Initialize components
|
||||
try:
|
||||
self.Loaders = Loaders()
|
||||
self.loader = self.Loaders.GetLoader(key="aniworld.to")
|
||||
self.SerieScanner = SerieScanner(
|
||||
directory_to_search, self.loader
|
||||
)
|
||||
self.List = SerieList(self.directory_to_search)
|
||||
self.__InitList__()
|
||||
|
||||
logger.info(
|
||||
"SeriesApp initialized for directory: %s",
|
||||
directory_to_search
|
||||
)
|
||||
except (IOError, OSError, RuntimeError) as e:
|
||||
logger.error("Failed to initialize SeriesApp: %s", e)
|
||||
self._handle_error(e)
|
||||
raise
|
||||
|
||||
def __InitList__(self):
|
||||
"""Initialize the series list with missing episodes."""
|
||||
try:
|
||||
self.series_list = self.List.GetMissingEpisode()
|
||||
logger.debug(
|
||||
"Loaded %d series with missing episodes",
|
||||
len(self.series_list)
|
||||
)
|
||||
except (IOError, OSError, RuntimeError) as e:
|
||||
logger.error("Failed to initialize series list: %s", e)
|
||||
self._handle_error(e)
|
||||
raise
|
||||
|
||||
def search(self, words: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for anime series.
|
||||
|
||||
Args:
|
||||
words: Search query
|
||||
|
||||
Returns:
|
||||
List of search results
|
||||
|
||||
Raises:
|
||||
RuntimeError: If search fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Searching for: %s", words)
|
||||
results = self.loader.Search(words)
|
||||
logger.info("Found %d results", len(results))
|
||||
return results
|
||||
except (IOError, OSError, RuntimeError) as e:
|
||||
logger.error("Search failed for '%s': %s", words, e)
|
||||
self._handle_error(e)
|
||||
raise
|
||||
|
||||
def download(
|
||||
self,
|
||||
serieFolder: str,
|
||||
season: int,
|
||||
episode: int,
|
||||
key: str,
|
||||
callback: Optional[Callable[[float], None]] = None,
|
||||
language: str = "German Dub"
|
||||
) -> OperationResult:
|
||||
"""
|
||||
Download an episode.
|
||||
|
||||
Args:
|
||||
serieFolder: Serie folder name
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
key: Serie key
|
||||
callback: Optional progress callback
|
||||
language: Language preference
|
||||
|
||||
Returns:
|
||||
OperationResult with download status
|
||||
"""
|
||||
self._current_operation = f"download_S{season:02d}E{episode:02d}"
|
||||
self._operation_status = OperationStatus.RUNNING
|
||||
self._cancel_flag = False
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
"Starting download: %s S%02dE%02d",
|
||||
serieFolder, season, episode
|
||||
)
|
||||
|
||||
# Check for cancellation before starting
|
||||
if self._is_cancelled():
|
||||
return OperationResult(
|
||||
success=False,
|
||||
message="Download cancelled before starting"
|
||||
)
|
||||
|
||||
# Wrap callback to check for cancellation
|
||||
def wrapped_callback(progress: float):
|
||||
if self._is_cancelled():
|
||||
raise InterruptedError("Download cancelled by user")
|
||||
if callback:
|
||||
callback(progress)
|
||||
|
||||
# Perform download
|
||||
self.loader.Download(
|
||||
self.directory_to_search,
|
||||
serieFolder,
|
||||
season,
|
||||
episode,
|
||||
key,
|
||||
language,
|
||||
wrapped_callback
|
||||
)
|
||||
|
||||
self._operation_status = OperationStatus.COMPLETED
|
||||
logger.info(
|
||||
"Download completed: %s S%02dE%02d",
|
||||
serieFolder, season, episode
|
||||
)
|
||||
|
||||
msg = f"Successfully downloaded S{season:02d}E{episode:02d}"
|
||||
return OperationResult(
|
||||
success=True,
|
||||
message=msg
|
||||
)
|
||||
|
||||
except InterruptedError as e:
|
||||
self._operation_status = OperationStatus.CANCELLED
|
||||
logger.warning("Download cancelled: %s", e)
|
||||
return OperationResult(
|
||||
success=False,
|
||||
message="Download cancelled",
|
||||
error=e
|
||||
)
|
||||
except (IOError, OSError, RuntimeError) as e:
|
||||
self._operation_status = OperationStatus.FAILED
|
||||
logger.error("Download failed: %s", e)
|
||||
self._handle_error(e)
|
||||
return OperationResult(
|
||||
success=False,
|
||||
message=f"Download failed: {str(e)}",
|
||||
error=e
|
||||
)
|
||||
finally:
|
||||
self._current_operation = None
|
||||
|
||||
def ReScan(
|
||||
self,
|
||||
callback: Optional[Callable[[str, int], None]] = None
|
||||
) -> OperationResult:
|
||||
"""
|
||||
Rescan directory for missing episodes.
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback (folder, current_count)
|
||||
|
||||
Returns:
|
||||
OperationResult with scan status
|
||||
"""
|
||||
self._current_operation = "rescan"
|
||||
self._operation_status = OperationStatus.RUNNING
|
||||
self._cancel_flag = False
|
||||
|
||||
try:
|
||||
logger.info("Starting directory rescan")
|
||||
|
||||
# Get total items to scan
|
||||
total_to_scan = self.SerieScanner.GetTotalToScan()
|
||||
logger.info("Total folders to scan: %d", total_to_scan)
|
||||
|
||||
# Reinitialize scanner
|
||||
self.SerieScanner.Reinit()
|
||||
|
||||
# Wrap callback for progress reporting and cancellation
|
||||
def wrapped_callback(folder: str, current: int):
|
||||
if self._is_cancelled():
|
||||
raise InterruptedError("Scan cancelled by user")
|
||||
|
||||
# Calculate progress
|
||||
if total_to_scan > 0:
|
||||
percentage = (current / total_to_scan * 100)
|
||||
else:
|
||||
percentage = 0
|
||||
|
||||
# Report progress
|
||||
if self.progress_callback:
|
||||
progress_info = ProgressInfo(
|
||||
current=current,
|
||||
total=total_to_scan,
|
||||
message=f"Scanning: {folder}",
|
||||
percentage=percentage,
|
||||
status=OperationStatus.RUNNING
|
||||
)
|
||||
self.progress_callback(progress_info)
|
||||
|
||||
# Call original callback if provided
|
||||
if callback:
|
||||
callback(folder, current)
|
||||
|
||||
# Perform scan
|
||||
self.SerieScanner.Scan(wrapped_callback)
|
||||
|
||||
# Reinitialize list
|
||||
self.List = SerieList(self.directory_to_search)
|
||||
self.__InitList__()
|
||||
|
||||
self._operation_status = OperationStatus.COMPLETED
|
||||
logger.info("Directory rescan completed successfully")
|
||||
|
||||
msg = (
|
||||
f"Scan completed. Found {len(self.series_list)} "
|
||||
f"series."
|
||||
)
|
||||
return OperationResult(
|
||||
success=True,
|
||||
message=msg,
|
||||
data={"series_count": len(self.series_list)}
|
||||
)
|
||||
|
||||
except InterruptedError as e:
|
||||
self._operation_status = OperationStatus.CANCELLED
|
||||
logger.warning("Scan cancelled: %s", e)
|
||||
return OperationResult(
|
||||
success=False,
|
||||
message="Scan cancelled",
|
||||
error=e
|
||||
)
|
||||
except (IOError, OSError, RuntimeError) as e:
|
||||
self._operation_status = OperationStatus.FAILED
|
||||
logger.error("Scan failed: %s", e)
|
||||
self._handle_error(e)
|
||||
return OperationResult(
|
||||
success=False,
|
||||
message=f"Scan failed: {str(e)}",
|
||||
error=e
|
||||
)
|
||||
finally:
|
||||
self._current_operation = None
|
||||
|
||||
async def async_download(
|
||||
self,
|
||||
serieFolder: str,
|
||||
season: int,
|
||||
episode: int,
|
||||
key: str,
|
||||
callback: Optional[Callable[[float], None]] = None,
|
||||
language: str = "German Dub"
|
||||
) -> OperationResult:
|
||||
"""
|
||||
Async version of download method.
|
||||
|
||||
Args:
|
||||
serieFolder: Serie folder name
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
key: Serie key
|
||||
callback: Optional progress callback
|
||||
language: Language preference
|
||||
|
||||
Returns:
|
||||
OperationResult with download status
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
self.download,
|
||||
serieFolder,
|
||||
season,
|
||||
episode,
|
||||
key,
|
||||
callback,
|
||||
language
|
||||
)
|
||||
|
||||
async def async_rescan(
|
||||
self,
|
||||
callback: Optional[Callable[[str, int], None]] = None
|
||||
) -> OperationResult:
|
||||
"""
|
||||
Async version of ReScan method.
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback
|
||||
|
||||
Returns:
|
||||
OperationResult with scan status
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
self.ReScan,
|
||||
callback
|
||||
)
|
||||
|
||||
def cancel_operation(self) -> bool:
|
||||
"""
|
||||
Cancel the current operation.
|
||||
|
||||
Returns:
|
||||
True if operation cancelled, False if no operation running
|
||||
"""
|
||||
if (self._current_operation and
|
||||
self._operation_status == OperationStatus.RUNNING):
|
||||
logger.info(
|
||||
"Cancelling operation: %s",
|
||||
self._current_operation
|
||||
)
|
||||
self._cancel_flag = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_cancelled(self) -> bool:
|
||||
"""Check if the current operation has been cancelled."""
|
||||
return self._cancel_flag
|
||||
|
||||
def _handle_error(self, error: Exception):
|
||||
"""
|
||||
Handle errors and notify via callback.
|
||||
|
||||
Args:
|
||||
error: Exception that occurred
|
||||
"""
|
||||
if self.error_callback:
|
||||
try:
|
||||
self.error_callback(error)
|
||||
except (RuntimeError, ValueError) as callback_error:
|
||||
logger.error(
|
||||
"Error in error callback: %s",
|
||||
callback_error
|
||||
)
|
||||
|
||||
def get_series_list(self) -> List[Any]:
|
||||
"""
|
||||
Get the current series list.
|
||||
|
||||
Returns:
|
||||
List of series with missing episodes
|
||||
"""
|
||||
return self.series_list
|
||||
|
||||
def get_operation_status(self) -> OperationStatus:
|
||||
"""
|
||||
Get the current operation status.
|
||||
|
||||
Returns:
|
||||
Current operation status
|
||||
"""
|
||||
return self._operation_status
|
||||
|
||||
def get_current_operation(self) -> Optional[str]:
|
||||
"""
|
||||
Get the current operation name.
|
||||
|
||||
Returns:
|
||||
Name of current operation or None
|
||||
"""
|
||||
return self._current_operation
|
||||
|
||||
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
|
||||
Loading…
x
Reference in New Issue
Block a user