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/
|
/home/lukas/Volume/repo/Aniworld/
|
||||||
├── src/
|
├── 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)
|
│ │ ├── fastapi_app.py # Main FastAPI application (simplified)
|
||||||
│ │ ├── main.py # FastAPI application entry point
|
│ │ ├── main.py # FastAPI application entry point
|
||||||
│ │ ├── controllers/ # Route controllers
|
│ │ ├── controllers/ # Route controllers
|
||||||
@ -218,8 +233,98 @@ initialization.
|
|||||||
this state to a shared store (Redis) and persist the master password
|
this state to a shared store (Redis) and persist the master password
|
||||||
hash in a secure config store.
|
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
|
## 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)
|
### Template Integration (October 2025)
|
||||||
|
|
||||||
Completed integration of HTML templates with FastAPI Jinja2 system.
|
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
|
### 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
|
#### [] Create progress callback system
|
||||||
|
|
||||||
- []Add progress callback interface
|
- []Add progress callback interface
|
||||||
|
|||||||
@ -1,38 +1,449 @@
|
|||||||
from src.core.entities.SerieList import SerieList
|
"""
|
||||||
from src.core.providers.provider_factory import Loaders
|
SeriesApp - Core application logic for anime series management.
|
||||||
from src.core.SerieScanner import SerieScanner
|
|
||||||
|
This module provides the main application interface for searching,
|
||||||
|
downloading, and managing anime series with support for async callbacks,
|
||||||
class SeriesApp:
|
progress reporting, error handling, and operation cancellation.
|
||||||
_initialization_count = 0
|
"""
|
||||||
|
|
||||||
def __init__(self, directory_to_search: str):
|
import asyncio
|
||||||
SeriesApp._initialization_count += 1 # Only show initialization message for the first instance
|
import logging
|
||||||
if SeriesApp._initialization_count <= 1:
|
from dataclasses import dataclass
|
||||||
print("Please wait while initializing...")
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
self.progress = None
|
|
||||||
self.directory_to_search = directory_to_search
|
from src.core.entities.SerieList import SerieList
|
||||||
self.Loaders = Loaders()
|
from src.core.providers.provider_factory import Loaders
|
||||||
self.loader = self.Loaders.GetLoader(key="aniworld.to")
|
from src.core.SerieScanner import SerieScanner
|
||||||
self.SerieScanner = SerieScanner(directory_to_search, self.loader)
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
self.List = SerieList(self.directory_to_search)
|
|
||||||
self.__InitList__()
|
|
||||||
|
class OperationStatus(Enum):
|
||||||
def __InitList__(self):
|
"""Status of an operation."""
|
||||||
self.series_list = self.List.GetMissingEpisode()
|
IDLE = "idle"
|
||||||
|
RUNNING = "running"
|
||||||
def search(self, words: str) -> list:
|
COMPLETED = "completed"
|
||||||
return self.loader.Search(words)
|
CANCELLED = "cancelled"
|
||||||
|
FAILED = "failed"
|
||||||
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)
|
|
||||||
|
@dataclass
|
||||||
def ReScan(self, callback):
|
class ProgressInfo:
|
||||||
|
"""Progress information for long-running operations."""
|
||||||
self.SerieScanner.Reinit()
|
current: int
|
||||||
self.SerieScanner.Scan(callback)
|
total: int
|
||||||
|
message: str
|
||||||
self.List = SerieList(self.directory_to_search)
|
percentage: float
|
||||||
self.__InitList__()
|
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