Spaces:
Sleeping
Sleeping
Michael Hu
commited on
Commit
·
5009cb8
1
Parent(s):
aaa0814
refactor based on DDD
Browse files- .gitignore +3 -1
- src/application/__init__.py +3 -0
- src/domain/__init__.py +23 -0
- src/domain/exceptions.py +41 -0
- src/domain/interfaces/__init__.py +13 -0
- src/domain/interfaces/audio_processing.py +36 -0
- src/domain/interfaces/speech_recognition.py +29 -0
- src/domain/interfaces/speech_synthesis.py +44 -0
- src/domain/interfaces/translation.py +28 -0
- src/domain/models/__init__.py +15 -0
- src/domain/models/audio_content.py +55 -0
- src/domain/models/speech_synthesis_request.py +93 -0
- src/domain/models/text_content.py +81 -0
- src/domain/models/translation_request.py +89 -0
- src/domain/models/voice_settings.py +97 -0
- src/domain/services/__init__.py +3 -0
- src/infrastructure/__init__.py +3 -0
- src/presentation/__init__.py +3 -0
- tests/unit/domain/models/test_audio_content.py +229 -0
- tests/unit/domain/models/test_speech_synthesis_request.py +349 -0
- tests/unit/domain/models/test_text_content.py +240 -0
- tests/unit/domain/models/test_translation_request.py +272 -0
- tests/unit/domain/models/test_voice_settings.py +408 -0
.gitignore
CHANGED
|
@@ -44,4 +44,6 @@ htmlcov/
|
|
| 44 |
.DS_Store
|
| 45 |
|
| 46 |
#Secrets
|
| 47 |
-
*.key
|
|
|
|
|
|
|
|
|
| 44 |
.DS_Store
|
| 45 |
|
| 46 |
#Secrets
|
| 47 |
+
*.key
|
| 48 |
+
|
| 49 |
+
.kiro/
|
src/application/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application layer package."""
|
| 2 |
+
|
| 3 |
+
# Application services will be added in subsequent tasks
|
src/domain/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain layer package."""
|
| 2 |
+
|
| 3 |
+
from .exceptions import (
|
| 4 |
+
DomainException,
|
| 5 |
+
InvalidAudioFormatException,
|
| 6 |
+
InvalidTextContentException,
|
| 7 |
+
TranslationFailedException,
|
| 8 |
+
SpeechRecognitionException,
|
| 9 |
+
SpeechSynthesisException,
|
| 10 |
+
InvalidVoiceSettingsException,
|
| 11 |
+
AudioProcessingException
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
'DomainException',
|
| 16 |
+
'InvalidAudioFormatException',
|
| 17 |
+
'InvalidTextContentException',
|
| 18 |
+
'TranslationFailedException',
|
| 19 |
+
'SpeechRecognitionException',
|
| 20 |
+
'SpeechSynthesisException',
|
| 21 |
+
'InvalidVoiceSettingsException',
|
| 22 |
+
'AudioProcessingException'
|
| 23 |
+
]
|
src/domain/exceptions.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain-specific exceptions for the TTS application."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class DomainException(Exception):
|
| 5 |
+
"""Base exception for domain-related errors."""
|
| 6 |
+
pass
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class InvalidAudioFormatException(DomainException):
|
| 10 |
+
"""Raised when audio format is not supported."""
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class InvalidTextContentException(DomainException):
|
| 15 |
+
"""Raised when text content is invalid."""
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TranslationFailedException(DomainException):
|
| 20 |
+
"""Raised when translation fails."""
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SpeechRecognitionException(DomainException):
|
| 25 |
+
"""Raised when speech recognition fails."""
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class SpeechSynthesisException(DomainException):
|
| 30 |
+
"""Raised when TTS generation fails."""
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class InvalidVoiceSettingsException(DomainException):
|
| 35 |
+
"""Raised when voice settings are invalid."""
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class AudioProcessingException(DomainException):
|
| 40 |
+
"""Raised when audio processing fails."""
|
| 41 |
+
pass
|
src/domain/interfaces/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain interfaces package."""
|
| 2 |
+
|
| 3 |
+
from .speech_recognition import ISpeechRecognitionService
|
| 4 |
+
from .translation import ITranslationService
|
| 5 |
+
from .speech_synthesis import ISpeechSynthesisService
|
| 6 |
+
from .audio_processing import IAudioProcessingService
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
'ISpeechRecognitionService',
|
| 10 |
+
'ITranslationService',
|
| 11 |
+
'ISpeechSynthesisService',
|
| 12 |
+
'IAudioProcessingService'
|
| 13 |
+
]
|
src/domain/interfaces/audio_processing.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio processing service interface."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import TYPE_CHECKING
|
| 5 |
+
|
| 6 |
+
if TYPE_CHECKING:
|
| 7 |
+
from ..models.audio_content import AudioContent
|
| 8 |
+
from ..models.voice_settings import VoiceSettings
|
| 9 |
+
from ..models.processing_result import ProcessingResult
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class IAudioProcessingService(ABC):
|
| 13 |
+
"""Interface for audio processing pipeline orchestration."""
|
| 14 |
+
|
| 15 |
+
@abstractmethod
|
| 16 |
+
def process_audio_pipeline(
|
| 17 |
+
self,
|
| 18 |
+
audio: 'AudioContent',
|
| 19 |
+
target_language: str,
|
| 20 |
+
voice_settings: 'VoiceSettings'
|
| 21 |
+
) -> 'ProcessingResult':
|
| 22 |
+
"""
|
| 23 |
+
Process audio through the complete pipeline: STT -> Translation -> TTS.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
audio: The input audio content
|
| 27 |
+
target_language: The target language for translation
|
| 28 |
+
voice_settings: Voice settings for TTS synthesis
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
ProcessingResult: The result of the complete processing pipeline
|
| 32 |
+
|
| 33 |
+
Raises:
|
| 34 |
+
AudioProcessingException: If any step in the pipeline fails
|
| 35 |
+
"""
|
| 36 |
+
pass
|
src/domain/interfaces/speech_recognition.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Speech recognition service interface."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import TYPE_CHECKING
|
| 5 |
+
|
| 6 |
+
if TYPE_CHECKING:
|
| 7 |
+
from ..models.audio_content import AudioContent
|
| 8 |
+
from ..models.text_content import TextContent
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ISpeechRecognitionService(ABC):
|
| 12 |
+
"""Interface for speech recognition services."""
|
| 13 |
+
|
| 14 |
+
@abstractmethod
|
| 15 |
+
def transcribe(self, audio: 'AudioContent', model: str) -> 'TextContent':
|
| 16 |
+
"""
|
| 17 |
+
Transcribe audio content to text.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
audio: The audio content to transcribe
|
| 21 |
+
model: The STT model to use for transcription
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
TextContent: The transcribed text
|
| 25 |
+
|
| 26 |
+
Raises:
|
| 27 |
+
SpeechRecognitionException: If transcription fails
|
| 28 |
+
"""
|
| 29 |
+
pass
|
src/domain/interfaces/speech_synthesis.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Speech synthesis service interface."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import Iterator, TYPE_CHECKING
|
| 5 |
+
|
| 6 |
+
if TYPE_CHECKING:
|
| 7 |
+
from ..models.speech_synthesis_request import SpeechSynthesisRequest
|
| 8 |
+
from ..models.audio_content import AudioContent, AudioChunk
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ISpeechSynthesisService(ABC):
|
| 12 |
+
"""Interface for speech synthesis services."""
|
| 13 |
+
|
| 14 |
+
@abstractmethod
|
| 15 |
+
def synthesize(self, request: 'SpeechSynthesisRequest') -> 'AudioContent':
|
| 16 |
+
"""
|
| 17 |
+
Synthesize speech from text.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
request: The speech synthesis request containing text and voice settings
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
AudioContent: The synthesized audio
|
| 24 |
+
|
| 25 |
+
Raises:
|
| 26 |
+
SpeechSynthesisException: If synthesis fails
|
| 27 |
+
"""
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
@abstractmethod
|
| 31 |
+
def synthesize_stream(self, request: 'SpeechSynthesisRequest') -> Iterator['AudioChunk']:
|
| 32 |
+
"""
|
| 33 |
+
Synthesize speech from text as a stream.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
request: The speech synthesis request containing text and voice settings
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
Iterator[AudioChunk]: Stream of audio chunks
|
| 40 |
+
|
| 41 |
+
Raises:
|
| 42 |
+
SpeechSynthesisException: If synthesis fails
|
| 43 |
+
"""
|
| 44 |
+
pass
|
src/domain/interfaces/translation.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Translation service interface."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import TYPE_CHECKING
|
| 5 |
+
|
| 6 |
+
if TYPE_CHECKING:
|
| 7 |
+
from ..models.translation_request import TranslationRequest
|
| 8 |
+
from ..models.text_content import TextContent
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ITranslationService(ABC):
|
| 12 |
+
"""Interface for translation services."""
|
| 13 |
+
|
| 14 |
+
@abstractmethod
|
| 15 |
+
def translate(self, request: 'TranslationRequest') -> 'TextContent':
|
| 16 |
+
"""
|
| 17 |
+
Translate text from source language to target language.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
request: The translation request containing text and language info
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
TextContent: The translated text
|
| 24 |
+
|
| 25 |
+
Raises:
|
| 26 |
+
TranslationFailedException: If translation fails
|
| 27 |
+
"""
|
| 28 |
+
pass
|
src/domain/models/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain models package for value objects and entities."""
|
| 2 |
+
|
| 3 |
+
from .audio_content import AudioContent
|
| 4 |
+
from .text_content import TextContent
|
| 5 |
+
from .voice_settings import VoiceSettings
|
| 6 |
+
from .translation_request import TranslationRequest
|
| 7 |
+
from .speech_synthesis_request import SpeechSynthesisRequest
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
'AudioContent',
|
| 11 |
+
'TextContent',
|
| 12 |
+
'VoiceSettings',
|
| 13 |
+
'TranslationRequest',
|
| 14 |
+
'SpeechSynthesisRequest',
|
| 15 |
+
]
|
src/domain/models/audio_content.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AudioContent value object for representing audio data with validation."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass(frozen=True)
|
| 8 |
+
class AudioContent:
|
| 9 |
+
"""Value object representing audio content with metadata and validation."""
|
| 10 |
+
|
| 11 |
+
data: bytes
|
| 12 |
+
format: str
|
| 13 |
+
sample_rate: int
|
| 14 |
+
duration: float
|
| 15 |
+
filename: Optional[str] = None
|
| 16 |
+
|
| 17 |
+
def __post_init__(self):
|
| 18 |
+
"""Validate audio content after initialization."""
|
| 19 |
+
self._validate()
|
| 20 |
+
|
| 21 |
+
def _validate(self):
|
| 22 |
+
"""Validate audio content properties."""
|
| 23 |
+
if not self.data:
|
| 24 |
+
raise ValueError("Audio data cannot be empty")
|
| 25 |
+
|
| 26 |
+
if not isinstance(self.data, bytes):
|
| 27 |
+
raise TypeError("Audio data must be bytes")
|
| 28 |
+
|
| 29 |
+
if self.format not in ['wav', 'mp3', 'flac', 'ogg']:
|
| 30 |
+
raise ValueError(f"Unsupported audio format: {self.format}. Supported formats: wav, mp3, flac, ogg")
|
| 31 |
+
|
| 32 |
+
if self.sample_rate <= 0:
|
| 33 |
+
raise ValueError("Sample rate must be positive")
|
| 34 |
+
|
| 35 |
+
if self.sample_rate < 8000 or self.sample_rate > 192000:
|
| 36 |
+
raise ValueError("Sample rate must be between 8000 and 192000 Hz")
|
| 37 |
+
|
| 38 |
+
if self.duration <= 0:
|
| 39 |
+
raise ValueError("Duration must be positive")
|
| 40 |
+
|
| 41 |
+
if self.duration > 3600: # 1 hour limit
|
| 42 |
+
raise ValueError("Audio duration cannot exceed 1 hour")
|
| 43 |
+
|
| 44 |
+
if self.filename is not None and not self.filename.strip():
|
| 45 |
+
raise ValueError("Filename cannot be empty string")
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def size_bytes(self) -> int:
|
| 49 |
+
"""Get the size of audio data in bytes."""
|
| 50 |
+
return len(self.data)
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def is_valid_format(self) -> bool:
|
| 54 |
+
"""Check if the audio format is valid."""
|
| 55 |
+
return self.format in ['wav', 'mp3', 'flac', 'ogg']
|
src/domain/models/speech_synthesis_request.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SpeechSynthesisRequest value object for TTS synthesis requests."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from .text_content import TextContent
|
| 6 |
+
from .voice_settings import VoiceSettings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass(frozen=True)
|
| 10 |
+
class SpeechSynthesisRequest:
|
| 11 |
+
"""Value object representing a speech synthesis request."""
|
| 12 |
+
|
| 13 |
+
text: TextContent
|
| 14 |
+
voice_settings: VoiceSettings
|
| 15 |
+
output_format: str = 'wav'
|
| 16 |
+
sample_rate: Optional[int] = None
|
| 17 |
+
|
| 18 |
+
def __post_init__(self):
|
| 19 |
+
"""Validate speech synthesis request after initialization."""
|
| 20 |
+
self._validate()
|
| 21 |
+
|
| 22 |
+
def _validate(self):
|
| 23 |
+
"""Validate speech synthesis request properties."""
|
| 24 |
+
if not isinstance(self.text, TextContent):
|
| 25 |
+
raise TypeError("Text must be a TextContent instance")
|
| 26 |
+
|
| 27 |
+
if not isinstance(self.voice_settings, VoiceSettings):
|
| 28 |
+
raise TypeError("Voice settings must be a VoiceSettings instance")
|
| 29 |
+
|
| 30 |
+
if not isinstance(self.output_format, str):
|
| 31 |
+
raise TypeError("Output format must be a string")
|
| 32 |
+
|
| 33 |
+
if self.output_format not in ['wav', 'mp3', 'flac', 'ogg']:
|
| 34 |
+
raise ValueError(f"Unsupported output format: {self.output_format}. Supported formats: wav, mp3, flac, ogg")
|
| 35 |
+
|
| 36 |
+
if self.sample_rate is not None:
|
| 37 |
+
if not isinstance(self.sample_rate, int):
|
| 38 |
+
raise TypeError("Sample rate must be an integer")
|
| 39 |
+
|
| 40 |
+
if self.sample_rate <= 0:
|
| 41 |
+
raise ValueError("Sample rate must be positive")
|
| 42 |
+
|
| 43 |
+
if self.sample_rate < 8000 or self.sample_rate > 192000:
|
| 44 |
+
raise ValueError("Sample rate must be between 8000 and 192000 Hz")
|
| 45 |
+
|
| 46 |
+
# Validate that text and voice settings have compatible languages
|
| 47 |
+
if self.text.language != self.voice_settings.language:
|
| 48 |
+
raise ValueError(f"Text language ({self.text.language}) must match voice language ({self.voice_settings.language})")
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def estimated_duration_seconds(self) -> float:
|
| 52 |
+
"""Estimate the duration of synthesized speech in seconds."""
|
| 53 |
+
# Rough estimation: average speaking rate is about 150-200 words per minute
|
| 54 |
+
# Adjusted by speed setting
|
| 55 |
+
words_per_minute = 175 / self.voice_settings.speed
|
| 56 |
+
return (self.text.word_count / words_per_minute) * 60
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def is_long_text(self) -> bool:
|
| 60 |
+
"""Check if the text is considered long for TTS processing."""
|
| 61 |
+
return self.text.character_count > 5000
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def effective_sample_rate(self) -> int:
|
| 65 |
+
"""Get the effective sample rate (default 22050 if not specified)."""
|
| 66 |
+
return self.sample_rate if self.sample_rate is not None else 22050
|
| 67 |
+
|
| 68 |
+
def with_output_format(self, output_format: str) -> 'SpeechSynthesisRequest':
|
| 69 |
+
"""Create a new SpeechSynthesisRequest with different output format."""
|
| 70 |
+
return SpeechSynthesisRequest(
|
| 71 |
+
text=self.text,
|
| 72 |
+
voice_settings=self.voice_settings,
|
| 73 |
+
output_format=output_format,
|
| 74 |
+
sample_rate=self.sample_rate
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
def with_sample_rate(self, sample_rate: Optional[int]) -> 'SpeechSynthesisRequest':
|
| 78 |
+
"""Create a new SpeechSynthesisRequest with different sample rate."""
|
| 79 |
+
return SpeechSynthesisRequest(
|
| 80 |
+
text=self.text,
|
| 81 |
+
voice_settings=self.voice_settings,
|
| 82 |
+
output_format=self.output_format,
|
| 83 |
+
sample_rate=sample_rate
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
def with_voice_settings(self, voice_settings: VoiceSettings) -> 'SpeechSynthesisRequest':
|
| 87 |
+
"""Create a new SpeechSynthesisRequest with different voice settings."""
|
| 88 |
+
return SpeechSynthesisRequest(
|
| 89 |
+
text=self.text,
|
| 90 |
+
voice_settings=voice_settings,
|
| 91 |
+
output_format=self.output_format,
|
| 92 |
+
sample_rate=self.sample_rate
|
| 93 |
+
)
|
src/domain/models/text_content.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TextContent value object for representing text data with language and encoding validation."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Optional
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class TextContent:
|
| 10 |
+
"""Value object representing text content with language and encoding information."""
|
| 11 |
+
|
| 12 |
+
text: str
|
| 13 |
+
language: str
|
| 14 |
+
encoding: str = 'utf-8'
|
| 15 |
+
|
| 16 |
+
def __post_init__(self):
|
| 17 |
+
"""Validate text content after initialization."""
|
| 18 |
+
self._validate()
|
| 19 |
+
|
| 20 |
+
def _validate(self):
|
| 21 |
+
"""Validate text content properties."""
|
| 22 |
+
if not isinstance(self.text, str):
|
| 23 |
+
raise TypeError("Text must be a string")
|
| 24 |
+
|
| 25 |
+
if not self.text.strip():
|
| 26 |
+
raise ValueError("Text content cannot be empty or whitespace only")
|
| 27 |
+
|
| 28 |
+
if len(self.text) > 50000: # Reasonable limit for TTS processing
|
| 29 |
+
raise ValueError("Text content too long (maximum 50,000 characters)")
|
| 30 |
+
|
| 31 |
+
if not isinstance(self.language, str):
|
| 32 |
+
raise TypeError("Language must be a string")
|
| 33 |
+
|
| 34 |
+
if not self.language.strip():
|
| 35 |
+
raise ValueError("Language cannot be empty")
|
| 36 |
+
|
| 37 |
+
# Validate language code format (ISO 639-1 or ISO 639-3)
|
| 38 |
+
if not re.match(r'^[a-z]{2,3}(-[A-Z]{2})?$', self.language):
|
| 39 |
+
raise ValueError(f"Invalid language code format: {self.language}. Expected format: 'en', 'en-US', etc.")
|
| 40 |
+
|
| 41 |
+
if not isinstance(self.encoding, str):
|
| 42 |
+
raise TypeError("Encoding must be a string")
|
| 43 |
+
|
| 44 |
+
if self.encoding not in ['utf-8', 'utf-16', 'ascii', 'latin-1']:
|
| 45 |
+
raise ValueError(f"Unsupported encoding: {self.encoding}. Supported: utf-8, utf-16, ascii, latin-1")
|
| 46 |
+
|
| 47 |
+
# Validate that text can be encoded with specified encoding
|
| 48 |
+
try:
|
| 49 |
+
self.text.encode(self.encoding)
|
| 50 |
+
except UnicodeEncodeError:
|
| 51 |
+
raise ValueError(f"Text cannot be encoded with {self.encoding} encoding")
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def word_count(self) -> int:
|
| 55 |
+
"""Get the approximate word count of the text."""
|
| 56 |
+
return len(self.text.split())
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def character_count(self) -> int:
|
| 60 |
+
"""Get the character count of the text."""
|
| 61 |
+
return len(self.text)
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def is_empty(self) -> bool:
|
| 65 |
+
"""Check if the text content is effectively empty."""
|
| 66 |
+
return not self.text.strip()
|
| 67 |
+
|
| 68 |
+
def truncate(self, max_length: int) -> 'TextContent':
|
| 69 |
+
"""Create a new TextContent with truncated text."""
|
| 70 |
+
if max_length <= 0:
|
| 71 |
+
raise ValueError("Max length must be positive")
|
| 72 |
+
|
| 73 |
+
if len(self.text) <= max_length:
|
| 74 |
+
return self
|
| 75 |
+
|
| 76 |
+
truncated_text = self.text[:max_length].rstrip()
|
| 77 |
+
return TextContent(
|
| 78 |
+
text=truncated_text,
|
| 79 |
+
language=self.language,
|
| 80 |
+
encoding=self.encoding
|
| 81 |
+
)
|
src/domain/models/translation_request.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TranslationRequest value object for translation service requests."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Optional
|
| 5 |
+
import re
|
| 6 |
+
from .text_content import TextContent
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass(frozen=True)
|
| 10 |
+
class TranslationRequest:
|
| 11 |
+
"""Value object representing a translation request with source and target languages."""
|
| 12 |
+
|
| 13 |
+
source_text: TextContent
|
| 14 |
+
target_language: str
|
| 15 |
+
source_language: Optional[str] = None
|
| 16 |
+
|
| 17 |
+
def __post_init__(self):
|
| 18 |
+
"""Validate translation request after initialization."""
|
| 19 |
+
self._validate()
|
| 20 |
+
|
| 21 |
+
def _validate(self):
|
| 22 |
+
"""Validate translation request properties."""
|
| 23 |
+
if not isinstance(self.source_text, TextContent):
|
| 24 |
+
raise TypeError("Source text must be a TextContent instance")
|
| 25 |
+
|
| 26 |
+
if not isinstance(self.target_language, str):
|
| 27 |
+
raise TypeError("Target language must be a string")
|
| 28 |
+
|
| 29 |
+
if not self.target_language.strip():
|
| 30 |
+
raise ValueError("Target language cannot be empty")
|
| 31 |
+
|
| 32 |
+
# Validate target language code format (ISO 639-1 or ISO 639-3)
|
| 33 |
+
if not re.match(r'^[a-z]{2,3}(-[A-Z]{2})?$', self.target_language):
|
| 34 |
+
raise ValueError(f"Invalid target language code format: {self.target_language}. Expected format: 'en', 'en-US', etc.")
|
| 35 |
+
|
| 36 |
+
if self.source_language is not None:
|
| 37 |
+
if not isinstance(self.source_language, str):
|
| 38 |
+
raise TypeError("Source language must be a string")
|
| 39 |
+
|
| 40 |
+
if not self.source_language.strip():
|
| 41 |
+
raise ValueError("Source language cannot be empty string")
|
| 42 |
+
|
| 43 |
+
# Validate source language code format
|
| 44 |
+
if not re.match(r'^[a-z]{2,3}(-[A-Z]{2})?$', self.source_language):
|
| 45 |
+
raise ValueError(f"Invalid source language code format: {self.source_language}. Expected format: 'en', 'en-US', etc.")
|
| 46 |
+
|
| 47 |
+
# Check if source and target languages are the same
|
| 48 |
+
if self.source_language == self.target_language:
|
| 49 |
+
raise ValueError("Source and target languages cannot be the same")
|
| 50 |
+
|
| 51 |
+
# If source language is not specified, use the language from TextContent
|
| 52 |
+
if self.source_language is None and self.source_text.language == self.target_language:
|
| 53 |
+
raise ValueError("Source and target languages cannot be the same")
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def effective_source_language(self) -> str:
|
| 57 |
+
"""Get the effective source language (from parameter or TextContent)."""
|
| 58 |
+
return self.source_language if self.source_language is not None else self.source_text.language
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def is_auto_detect_source(self) -> bool:
|
| 62 |
+
"""Check if source language should be auto-detected."""
|
| 63 |
+
return self.source_language is None
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def text_length(self) -> int:
|
| 67 |
+
"""Get the length of source text."""
|
| 68 |
+
return len(self.source_text.text)
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def word_count(self) -> int:
|
| 72 |
+
"""Get the word count of source text."""
|
| 73 |
+
return self.source_text.word_count
|
| 74 |
+
|
| 75 |
+
def with_target_language(self, target_language: str) -> 'TranslationRequest':
|
| 76 |
+
"""Create a new TranslationRequest with different target language."""
|
| 77 |
+
return TranslationRequest(
|
| 78 |
+
source_text=self.source_text,
|
| 79 |
+
target_language=target_language,
|
| 80 |
+
source_language=self.source_language
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
def with_source_language(self, source_language: Optional[str]) -> 'TranslationRequest':
|
| 84 |
+
"""Create a new TranslationRequest with different source language."""
|
| 85 |
+
return TranslationRequest(
|
| 86 |
+
source_text=self.source_text,
|
| 87 |
+
target_language=self.target_language,
|
| 88 |
+
source_language=source_language
|
| 89 |
+
)
|
src/domain/models/voice_settings.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VoiceSettings value object for TTS voice configuration with validation."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Optional
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class VoiceSettings:
|
| 10 |
+
"""Value object representing voice settings for text-to-speech synthesis."""
|
| 11 |
+
|
| 12 |
+
voice_id: str
|
| 13 |
+
speed: float
|
| 14 |
+
language: str
|
| 15 |
+
pitch: Optional[float] = None
|
| 16 |
+
volume: Optional[float] = None
|
| 17 |
+
|
| 18 |
+
def __post_init__(self):
|
| 19 |
+
"""Validate voice settings after initialization."""
|
| 20 |
+
self._validate()
|
| 21 |
+
|
| 22 |
+
def _validate(self):
|
| 23 |
+
"""Validate voice settings properties."""
|
| 24 |
+
if not isinstance(self.voice_id, str):
|
| 25 |
+
raise TypeError("Voice ID must be a string")
|
| 26 |
+
|
| 27 |
+
if not self.voice_id.strip():
|
| 28 |
+
raise ValueError("Voice ID cannot be empty")
|
| 29 |
+
|
| 30 |
+
# Voice ID should be alphanumeric with possible underscores/hyphens
|
| 31 |
+
if not re.match(r'^[a-zA-Z0-9_-]+$', self.voice_id):
|
| 32 |
+
raise ValueError(f"Invalid voice ID format: {self.voice_id}. Must contain only letters, numbers, underscores, and hyphens")
|
| 33 |
+
|
| 34 |
+
if not isinstance(self.speed, (int, float)):
|
| 35 |
+
raise TypeError("Speed must be a number")
|
| 36 |
+
|
| 37 |
+
if not 0.1 <= self.speed <= 3.0:
|
| 38 |
+
raise ValueError(f"Speed must be between 0.1 and 3.0, got {self.speed}")
|
| 39 |
+
|
| 40 |
+
if not isinstance(self.language, str):
|
| 41 |
+
raise TypeError("Language must be a string")
|
| 42 |
+
|
| 43 |
+
if not self.language.strip():
|
| 44 |
+
raise ValueError("Language cannot be empty")
|
| 45 |
+
|
| 46 |
+
# Validate language code format (ISO 639-1 or ISO 639-3)
|
| 47 |
+
if not re.match(r'^[a-z]{2,3}(-[A-Z]{2})?$', self.language):
|
| 48 |
+
raise ValueError(f"Invalid language code format: {self.language}. Expected format: 'en', 'en-US', etc.")
|
| 49 |
+
|
| 50 |
+
if self.pitch is not None:
|
| 51 |
+
if not isinstance(self.pitch, (int, float)):
|
| 52 |
+
raise TypeError("Pitch must be a number")
|
| 53 |
+
|
| 54 |
+
if not -2.0 <= self.pitch <= 2.0:
|
| 55 |
+
raise ValueError(f"Pitch must be between -2.0 and 2.0, got {self.pitch}")
|
| 56 |
+
|
| 57 |
+
if self.volume is not None:
|
| 58 |
+
if not isinstance(self.volume, (int, float)):
|
| 59 |
+
raise TypeError("Volume must be a number")
|
| 60 |
+
|
| 61 |
+
if not 0.0 <= self.volume <= 2.0:
|
| 62 |
+
raise ValueError(f"Volume must be between 0.0 and 2.0, got {self.volume}")
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def is_default_speed(self) -> bool:
|
| 66 |
+
"""Check if speed is at default value (1.0)."""
|
| 67 |
+
return abs(self.speed - 1.0) < 0.01
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def is_default_pitch(self) -> bool:
|
| 71 |
+
"""Check if pitch is at default value (0.0 or None)."""
|
| 72 |
+
return self.pitch is None or abs(self.pitch) < 0.01
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def is_default_volume(self) -> bool:
|
| 76 |
+
"""Check if volume is at default value (1.0 or None)."""
|
| 77 |
+
return self.volume is None or abs(self.volume - 1.0) < 0.01
|
| 78 |
+
|
| 79 |
+
def with_speed(self, speed: float) -> 'VoiceSettings':
|
| 80 |
+
"""Create a new VoiceSettings with different speed."""
|
| 81 |
+
return VoiceSettings(
|
| 82 |
+
voice_id=self.voice_id,
|
| 83 |
+
speed=speed,
|
| 84 |
+
language=self.language,
|
| 85 |
+
pitch=self.pitch,
|
| 86 |
+
volume=self.volume
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
def with_pitch(self, pitch: Optional[float]) -> 'VoiceSettings':
|
| 90 |
+
"""Create a new VoiceSettings with different pitch."""
|
| 91 |
+
return VoiceSettings(
|
| 92 |
+
voice_id=self.voice_id,
|
| 93 |
+
speed=self.speed,
|
| 94 |
+
language=self.language,
|
| 95 |
+
pitch=pitch,
|
| 96 |
+
volume=self.volume
|
| 97 |
+
)
|
src/domain/services/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain services package."""
|
| 2 |
+
|
| 3 |
+
# Services will be added in subsequent tasks
|
src/infrastructure/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Infrastructure layer package."""
|
| 2 |
+
|
| 3 |
+
# Infrastructure implementations will be added in subsequent tasks
|
src/presentation/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Presentation layer package."""
|
| 2 |
+
|
| 3 |
+
# Presentation components will be added in subsequent tasks
|
tests/unit/domain/models/test_audio_content.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for AudioContent value object."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from src.domain.models.audio_content import AudioContent
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TestAudioContent:
|
| 8 |
+
"""Test cases for AudioContent value object."""
|
| 9 |
+
|
| 10 |
+
def test_valid_audio_content_creation(self):
|
| 11 |
+
"""Test creating valid AudioContent instance."""
|
| 12 |
+
audio_data = b"fake_audio_data"
|
| 13 |
+
audio = AudioContent(
|
| 14 |
+
data=audio_data,
|
| 15 |
+
format="wav",
|
| 16 |
+
sample_rate=44100,
|
| 17 |
+
duration=10.5,
|
| 18 |
+
filename="test.wav"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
assert audio.data == audio_data
|
| 22 |
+
assert audio.format == "wav"
|
| 23 |
+
assert audio.sample_rate == 44100
|
| 24 |
+
assert audio.duration == 10.5
|
| 25 |
+
assert audio.filename == "test.wav"
|
| 26 |
+
assert audio.size_bytes == len(audio_data)
|
| 27 |
+
assert audio.is_valid_format is True
|
| 28 |
+
|
| 29 |
+
def test_audio_content_without_filename(self):
|
| 30 |
+
"""Test creating AudioContent without filename."""
|
| 31 |
+
audio = AudioContent(
|
| 32 |
+
data=b"fake_audio_data",
|
| 33 |
+
format="mp3",
|
| 34 |
+
sample_rate=22050,
|
| 35 |
+
duration=5.0
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
assert audio.filename is None
|
| 39 |
+
assert audio.format == "mp3"
|
| 40 |
+
|
| 41 |
+
def test_empty_audio_data_raises_error(self):
|
| 42 |
+
"""Test that empty audio data raises ValueError."""
|
| 43 |
+
with pytest.raises(ValueError, match="Audio data cannot be empty"):
|
| 44 |
+
AudioContent(
|
| 45 |
+
data=b"",
|
| 46 |
+
format="wav",
|
| 47 |
+
sample_rate=44100,
|
| 48 |
+
duration=10.0
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
def test_non_bytes_audio_data_raises_error(self):
|
| 52 |
+
"""Test that non-bytes audio data raises TypeError."""
|
| 53 |
+
with pytest.raises(TypeError, match="Audio data must be bytes"):
|
| 54 |
+
AudioContent(
|
| 55 |
+
data="not_bytes", # type: ignore
|
| 56 |
+
format="wav",
|
| 57 |
+
sample_rate=44100,
|
| 58 |
+
duration=10.0
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
def test_unsupported_format_raises_error(self):
|
| 62 |
+
"""Test that unsupported format raises ValueError."""
|
| 63 |
+
with pytest.raises(ValueError, match="Unsupported audio format: xyz"):
|
| 64 |
+
AudioContent(
|
| 65 |
+
data=b"fake_audio_data",
|
| 66 |
+
format="xyz",
|
| 67 |
+
sample_rate=44100,
|
| 68 |
+
duration=10.0
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
def test_supported_formats(self):
|
| 72 |
+
"""Test all supported audio formats."""
|
| 73 |
+
supported_formats = ['wav', 'mp3', 'flac', 'ogg']
|
| 74 |
+
|
| 75 |
+
for fmt in supported_formats:
|
| 76 |
+
audio = AudioContent(
|
| 77 |
+
data=b"fake_audio_data",
|
| 78 |
+
format=fmt,
|
| 79 |
+
sample_rate=44100,
|
| 80 |
+
duration=10.0
|
| 81 |
+
)
|
| 82 |
+
assert audio.format == fmt
|
| 83 |
+
assert audio.is_valid_format is True
|
| 84 |
+
|
| 85 |
+
def test_negative_sample_rate_raises_error(self):
|
| 86 |
+
"""Test that negative sample rate raises ValueError."""
|
| 87 |
+
with pytest.raises(ValueError, match="Sample rate must be positive"):
|
| 88 |
+
AudioContent(
|
| 89 |
+
data=b"fake_audio_data",
|
| 90 |
+
format="wav",
|
| 91 |
+
sample_rate=-1,
|
| 92 |
+
duration=10.0
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
def test_zero_sample_rate_raises_error(self):
|
| 96 |
+
"""Test that zero sample rate raises ValueError."""
|
| 97 |
+
with pytest.raises(ValueError, match="Sample rate must be positive"):
|
| 98 |
+
AudioContent(
|
| 99 |
+
data=b"fake_audio_data",
|
| 100 |
+
format="wav",
|
| 101 |
+
sample_rate=0,
|
| 102 |
+
duration=10.0
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
def test_sample_rate_too_low_raises_error(self):
|
| 106 |
+
"""Test that sample rate below 8000 raises ValueError."""
|
| 107 |
+
with pytest.raises(ValueError, match="Sample rate must be between 8000 and 192000 Hz"):
|
| 108 |
+
AudioContent(
|
| 109 |
+
data=b"fake_audio_data",
|
| 110 |
+
format="wav",
|
| 111 |
+
sample_rate=7999,
|
| 112 |
+
duration=10.0
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
def test_sample_rate_too_high_raises_error(self):
|
| 116 |
+
"""Test that sample rate above 192000 raises ValueError."""
|
| 117 |
+
with pytest.raises(ValueError, match="Sample rate must be between 8000 and 192000 Hz"):
|
| 118 |
+
AudioContent(
|
| 119 |
+
data=b"fake_audio_data",
|
| 120 |
+
format="wav",
|
| 121 |
+
sample_rate=192001,
|
| 122 |
+
duration=10.0
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
def test_valid_sample_rate_boundaries(self):
|
| 126 |
+
"""Test valid sample rate boundaries."""
|
| 127 |
+
# Test minimum valid sample rate
|
| 128 |
+
audio_min = AudioContent(
|
| 129 |
+
data=b"fake_audio_data",
|
| 130 |
+
format="wav",
|
| 131 |
+
sample_rate=8000,
|
| 132 |
+
duration=10.0
|
| 133 |
+
)
|
| 134 |
+
assert audio_min.sample_rate == 8000
|
| 135 |
+
|
| 136 |
+
# Test maximum valid sample rate
|
| 137 |
+
audio_max = AudioContent(
|
| 138 |
+
data=b"fake_audio_data",
|
| 139 |
+
format="wav",
|
| 140 |
+
sample_rate=192000,
|
| 141 |
+
duration=10.0
|
| 142 |
+
)
|
| 143 |
+
assert audio_max.sample_rate == 192000
|
| 144 |
+
|
| 145 |
+
def test_negative_duration_raises_error(self):
|
| 146 |
+
"""Test that negative duration raises ValueError."""
|
| 147 |
+
with pytest.raises(ValueError, match="Duration must be positive"):
|
| 148 |
+
AudioContent(
|
| 149 |
+
data=b"fake_audio_data",
|
| 150 |
+
format="wav",
|
| 151 |
+
sample_rate=44100,
|
| 152 |
+
duration=-1.0
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
def test_zero_duration_raises_error(self):
|
| 156 |
+
"""Test that zero duration raises ValueError."""
|
| 157 |
+
with pytest.raises(ValueError, match="Duration must be positive"):
|
| 158 |
+
AudioContent(
|
| 159 |
+
data=b"fake_audio_data",
|
| 160 |
+
format="wav",
|
| 161 |
+
sample_rate=44100,
|
| 162 |
+
duration=0.0
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
def test_duration_too_long_raises_error(self):
|
| 166 |
+
"""Test that duration over 1 hour raises ValueError."""
|
| 167 |
+
with pytest.raises(ValueError, match="Audio duration cannot exceed 1 hour"):
|
| 168 |
+
AudioContent(
|
| 169 |
+
data=b"fake_audio_data",
|
| 170 |
+
format="wav",
|
| 171 |
+
sample_rate=44100,
|
| 172 |
+
duration=3601.0 # 1 hour + 1 second
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
def test_valid_duration_boundary(self):
|
| 176 |
+
"""Test valid duration boundary (exactly 1 hour)."""
|
| 177 |
+
audio = AudioContent(
|
| 178 |
+
data=b"fake_audio_data",
|
| 179 |
+
format="wav",
|
| 180 |
+
sample_rate=44100,
|
| 181 |
+
duration=3600.0 # Exactly 1 hour
|
| 182 |
+
)
|
| 183 |
+
assert audio.duration == 3600.0
|
| 184 |
+
|
| 185 |
+
def test_empty_filename_raises_error(self):
|
| 186 |
+
"""Test that empty filename raises ValueError."""
|
| 187 |
+
with pytest.raises(ValueError, match="Filename cannot be empty string"):
|
| 188 |
+
AudioContent(
|
| 189 |
+
data=b"fake_audio_data",
|
| 190 |
+
format="wav",
|
| 191 |
+
sample_rate=44100,
|
| 192 |
+
duration=10.0,
|
| 193 |
+
filename=""
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
def test_whitespace_filename_raises_error(self):
|
| 197 |
+
"""Test that whitespace-only filename raises ValueError."""
|
| 198 |
+
with pytest.raises(ValueError, match="Filename cannot be empty string"):
|
| 199 |
+
AudioContent(
|
| 200 |
+
data=b"fake_audio_data",
|
| 201 |
+
format="wav",
|
| 202 |
+
sample_rate=44100,
|
| 203 |
+
duration=10.0,
|
| 204 |
+
filename=" "
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
def test_audio_content_is_immutable(self):
|
| 208 |
+
"""Test that AudioContent is immutable (frozen dataclass)."""
|
| 209 |
+
audio = AudioContent(
|
| 210 |
+
data=b"fake_audio_data",
|
| 211 |
+
format="wav",
|
| 212 |
+
sample_rate=44100,
|
| 213 |
+
duration=10.0
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
with pytest.raises(AttributeError):
|
| 217 |
+
audio.format = "mp3" # type: ignore
|
| 218 |
+
|
| 219 |
+
def test_size_bytes_property(self):
|
| 220 |
+
"""Test size_bytes property returns correct value."""
|
| 221 |
+
test_data = b"test_audio_data_123"
|
| 222 |
+
audio = AudioContent(
|
| 223 |
+
data=test_data,
|
| 224 |
+
format="wav",
|
| 225 |
+
sample_rate=44100,
|
| 226 |
+
duration=10.0
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
assert audio.size_bytes == len(test_data)
|
tests/unit/domain/models/test_speech_synthesis_request.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for SpeechSynthesisRequest value object."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from src.domain.models.speech_synthesis_request import SpeechSynthesisRequest
|
| 5 |
+
from src.domain.models.text_content import TextContent
|
| 6 |
+
from src.domain.models.voice_settings import VoiceSettings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TestSpeechSynthesisRequest:
|
| 10 |
+
"""Test cases for SpeechSynthesisRequest value object."""
|
| 11 |
+
|
| 12 |
+
def test_valid_speech_synthesis_request_creation(self):
|
| 13 |
+
"""Test creating valid SpeechSynthesisRequest instance."""
|
| 14 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 15 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.2, language="en")
|
| 16 |
+
|
| 17 |
+
request = SpeechSynthesisRequest(
|
| 18 |
+
text=text,
|
| 19 |
+
voice_settings=voice_settings,
|
| 20 |
+
output_format="wav",
|
| 21 |
+
sample_rate=44100
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
assert request.text == text
|
| 25 |
+
assert request.voice_settings == voice_settings
|
| 26 |
+
assert request.output_format == "wav"
|
| 27 |
+
assert request.sample_rate == 44100
|
| 28 |
+
assert request.effective_sample_rate == 44100
|
| 29 |
+
|
| 30 |
+
def test_speech_synthesis_request_with_defaults(self):
|
| 31 |
+
"""Test creating SpeechSynthesisRequest with default values."""
|
| 32 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 33 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 34 |
+
|
| 35 |
+
request = SpeechSynthesisRequest(
|
| 36 |
+
text=text,
|
| 37 |
+
voice_settings=voice_settings
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
assert request.output_format == "wav"
|
| 41 |
+
assert request.sample_rate is None
|
| 42 |
+
assert request.effective_sample_rate == 22050 # Default
|
| 43 |
+
|
| 44 |
+
def test_non_text_content_raises_error(self):
|
| 45 |
+
"""Test that non-TextContent text raises TypeError."""
|
| 46 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 47 |
+
|
| 48 |
+
with pytest.raises(TypeError, match="Text must be a TextContent instance"):
|
| 49 |
+
SpeechSynthesisRequest(
|
| 50 |
+
text="Hello, world!", # type: ignore
|
| 51 |
+
voice_settings=voice_settings
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
def test_non_voice_settings_raises_error(self):
|
| 55 |
+
"""Test that non-VoiceSettings voice_settings raises TypeError."""
|
| 56 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 57 |
+
|
| 58 |
+
with pytest.raises(TypeError, match="Voice settings must be a VoiceSettings instance"):
|
| 59 |
+
SpeechSynthesisRequest(
|
| 60 |
+
text=text,
|
| 61 |
+
voice_settings={"voice_id": "en_male_001", "speed": 1.0} # type: ignore
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def test_non_string_output_format_raises_error(self):
|
| 65 |
+
"""Test that non-string output_format raises TypeError."""
|
| 66 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 67 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 68 |
+
|
| 69 |
+
with pytest.raises(TypeError, match="Output format must be a string"):
|
| 70 |
+
SpeechSynthesisRequest(
|
| 71 |
+
text=text,
|
| 72 |
+
voice_settings=voice_settings,
|
| 73 |
+
output_format=123 # type: ignore
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
def test_unsupported_output_format_raises_error(self):
|
| 77 |
+
"""Test that unsupported output_format raises ValueError."""
|
| 78 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 79 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 80 |
+
|
| 81 |
+
with pytest.raises(ValueError, match="Unsupported output format: xyz"):
|
| 82 |
+
SpeechSynthesisRequest(
|
| 83 |
+
text=text,
|
| 84 |
+
voice_settings=voice_settings,
|
| 85 |
+
output_format="xyz"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def test_supported_output_formats(self):
|
| 89 |
+
"""Test all supported output formats."""
|
| 90 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 91 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 92 |
+
supported_formats = ['wav', 'mp3', 'flac', 'ogg']
|
| 93 |
+
|
| 94 |
+
for fmt in supported_formats:
|
| 95 |
+
request = SpeechSynthesisRequest(
|
| 96 |
+
text=text,
|
| 97 |
+
voice_settings=voice_settings,
|
| 98 |
+
output_format=fmt
|
| 99 |
+
)
|
| 100 |
+
assert request.output_format == fmt
|
| 101 |
+
|
| 102 |
+
def test_non_integer_sample_rate_raises_error(self):
|
| 103 |
+
"""Test that non-integer sample_rate raises TypeError."""
|
| 104 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 105 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 106 |
+
|
| 107 |
+
with pytest.raises(TypeError, match="Sample rate must be an integer"):
|
| 108 |
+
SpeechSynthesisRequest(
|
| 109 |
+
text=text,
|
| 110 |
+
voice_settings=voice_settings,
|
| 111 |
+
sample_rate=44100.5 # type: ignore
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
def test_negative_sample_rate_raises_error(self):
|
| 115 |
+
"""Test that negative sample_rate raises ValueError."""
|
| 116 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 117 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 118 |
+
|
| 119 |
+
with pytest.raises(ValueError, match="Sample rate must be positive"):
|
| 120 |
+
SpeechSynthesisRequest(
|
| 121 |
+
text=text,
|
| 122 |
+
voice_settings=voice_settings,
|
| 123 |
+
sample_rate=-1
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
def test_zero_sample_rate_raises_error(self):
|
| 127 |
+
"""Test that zero sample_rate raises ValueError."""
|
| 128 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 129 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 130 |
+
|
| 131 |
+
with pytest.raises(ValueError, match="Sample rate must be positive"):
|
| 132 |
+
SpeechSynthesisRequest(
|
| 133 |
+
text=text,
|
| 134 |
+
voice_settings=voice_settings,
|
| 135 |
+
sample_rate=0
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
def test_sample_rate_too_low_raises_error(self):
|
| 139 |
+
"""Test that sample rate below 8000 raises ValueError."""
|
| 140 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 141 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 142 |
+
|
| 143 |
+
with pytest.raises(ValueError, match="Sample rate must be between 8000 and 192000 Hz"):
|
| 144 |
+
SpeechSynthesisRequest(
|
| 145 |
+
text=text,
|
| 146 |
+
voice_settings=voice_settings,
|
| 147 |
+
sample_rate=7999
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
def test_sample_rate_too_high_raises_error(self):
|
| 151 |
+
"""Test that sample rate above 192000 raises ValueError."""
|
| 152 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 153 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 154 |
+
|
| 155 |
+
with pytest.raises(ValueError, match="Sample rate must be between 8000 and 192000 Hz"):
|
| 156 |
+
SpeechSynthesisRequest(
|
| 157 |
+
text=text,
|
| 158 |
+
voice_settings=voice_settings,
|
| 159 |
+
sample_rate=192001
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
def test_valid_sample_rate_boundaries(self):
|
| 163 |
+
"""Test valid sample rate boundaries."""
|
| 164 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 165 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 166 |
+
|
| 167 |
+
# Test minimum valid sample rate
|
| 168 |
+
request_min = SpeechSynthesisRequest(
|
| 169 |
+
text=text,
|
| 170 |
+
voice_settings=voice_settings,
|
| 171 |
+
sample_rate=8000
|
| 172 |
+
)
|
| 173 |
+
assert request_min.sample_rate == 8000
|
| 174 |
+
|
| 175 |
+
# Test maximum valid sample rate
|
| 176 |
+
request_max = SpeechSynthesisRequest(
|
| 177 |
+
text=text,
|
| 178 |
+
voice_settings=voice_settings,
|
| 179 |
+
sample_rate=192000
|
| 180 |
+
)
|
| 181 |
+
assert request_max.sample_rate == 192000
|
| 182 |
+
|
| 183 |
+
def test_mismatched_languages_raises_error(self):
|
| 184 |
+
"""Test that mismatched text and voice languages raise ValueError."""
|
| 185 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 186 |
+
voice_settings = VoiceSettings(voice_id="fr_male_001", speed=1.0, language="fr")
|
| 187 |
+
|
| 188 |
+
with pytest.raises(ValueError, match="Text language \\(en\\) must match voice language \\(fr\\)"):
|
| 189 |
+
SpeechSynthesisRequest(
|
| 190 |
+
text=text,
|
| 191 |
+
voice_settings=voice_settings
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
def test_matching_languages_success(self):
|
| 195 |
+
"""Test that matching text and voice languages work correctly."""
|
| 196 |
+
text = TextContent(text="Bonjour le monde!", language="fr")
|
| 197 |
+
voice_settings = VoiceSettings(voice_id="fr_male_001", speed=1.0, language="fr")
|
| 198 |
+
|
| 199 |
+
request = SpeechSynthesisRequest(
|
| 200 |
+
text=text,
|
| 201 |
+
voice_settings=voice_settings
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
assert request.text.language == "fr"
|
| 205 |
+
assert request.voice_settings.language == "fr"
|
| 206 |
+
|
| 207 |
+
def test_estimated_duration_seconds_property(self):
|
| 208 |
+
"""Test estimated_duration_seconds property calculation."""
|
| 209 |
+
text = TextContent(text="Hello world test", language="en") # 3 words
|
| 210 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 211 |
+
|
| 212 |
+
request = SpeechSynthesisRequest(
|
| 213 |
+
text=text,
|
| 214 |
+
voice_settings=voice_settings
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# 3 words at 175 words per minute = 3/175 * 60 ≈ 1.03 seconds
|
| 218 |
+
estimated = request.estimated_duration_seconds
|
| 219 |
+
assert 1.0 <= estimated <= 1.1
|
| 220 |
+
|
| 221 |
+
def test_estimated_duration_with_speed_adjustment(self):
|
| 222 |
+
"""Test estimated duration with different speed settings."""
|
| 223 |
+
text = TextContent(text="Hello world test", language="en") # 3 words
|
| 224 |
+
voice_settings_slow = VoiceSettings(voice_id="en_male_001", speed=0.5, language="en")
|
| 225 |
+
voice_settings_fast = VoiceSettings(voice_id="en_male_001", speed=2.0, language="en")
|
| 226 |
+
|
| 227 |
+
request_slow = SpeechSynthesisRequest(text=text, voice_settings=voice_settings_slow)
|
| 228 |
+
request_fast = SpeechSynthesisRequest(text=text, voice_settings=voice_settings_fast)
|
| 229 |
+
|
| 230 |
+
# Slower speed should result in longer duration
|
| 231 |
+
assert request_slow.estimated_duration_seconds > request_fast.estimated_duration_seconds
|
| 232 |
+
|
| 233 |
+
def test_is_long_text_property(self):
|
| 234 |
+
"""Test is_long_text property."""
|
| 235 |
+
short_text = TextContent(text="Hello world", language="en")
|
| 236 |
+
long_text = TextContent(text="a" * 5001, language="en")
|
| 237 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 238 |
+
|
| 239 |
+
request_short = SpeechSynthesisRequest(text=short_text, voice_settings=voice_settings)
|
| 240 |
+
request_long = SpeechSynthesisRequest(text=long_text, voice_settings=voice_settings)
|
| 241 |
+
|
| 242 |
+
assert request_short.is_long_text is False
|
| 243 |
+
assert request_long.is_long_text is True
|
| 244 |
+
|
| 245 |
+
def test_effective_sample_rate_property(self):
|
| 246 |
+
"""Test effective_sample_rate property."""
|
| 247 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 248 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 249 |
+
|
| 250 |
+
# With explicit sample rate
|
| 251 |
+
request_explicit = SpeechSynthesisRequest(
|
| 252 |
+
text=text,
|
| 253 |
+
voice_settings=voice_settings,
|
| 254 |
+
sample_rate=44100
|
| 255 |
+
)
|
| 256 |
+
assert request_explicit.effective_sample_rate == 44100
|
| 257 |
+
|
| 258 |
+
# Without explicit sample rate (default)
|
| 259 |
+
request_default = SpeechSynthesisRequest(
|
| 260 |
+
text=text,
|
| 261 |
+
voice_settings=voice_settings
|
| 262 |
+
)
|
| 263 |
+
assert request_default.effective_sample_rate == 22050
|
| 264 |
+
|
| 265 |
+
def test_with_output_format_method(self):
|
| 266 |
+
"""Test with_output_format method creates new instance."""
|
| 267 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 268 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 269 |
+
|
| 270 |
+
original = SpeechSynthesisRequest(
|
| 271 |
+
text=text,
|
| 272 |
+
voice_settings=voice_settings,
|
| 273 |
+
output_format="wav",
|
| 274 |
+
sample_rate=44100
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
new_request = original.with_output_format("mp3")
|
| 278 |
+
|
| 279 |
+
assert new_request.output_format == "mp3"
|
| 280 |
+
assert new_request.text == original.text
|
| 281 |
+
assert new_request.voice_settings == original.voice_settings
|
| 282 |
+
assert new_request.sample_rate == original.sample_rate
|
| 283 |
+
assert new_request is not original # Different instances
|
| 284 |
+
|
| 285 |
+
def test_with_sample_rate_method(self):
|
| 286 |
+
"""Test with_sample_rate method creates new instance."""
|
| 287 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 288 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 289 |
+
|
| 290 |
+
original = SpeechSynthesisRequest(
|
| 291 |
+
text=text,
|
| 292 |
+
voice_settings=voice_settings,
|
| 293 |
+
sample_rate=44100
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
new_request = original.with_sample_rate(22050)
|
| 297 |
+
|
| 298 |
+
assert new_request.sample_rate == 22050
|
| 299 |
+
assert new_request.text == original.text
|
| 300 |
+
assert new_request.voice_settings == original.voice_settings
|
| 301 |
+
assert new_request.output_format == original.output_format
|
| 302 |
+
assert new_request is not original # Different instances
|
| 303 |
+
|
| 304 |
+
def test_with_sample_rate_none(self):
|
| 305 |
+
"""Test with_sample_rate method with None value."""
|
| 306 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 307 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 308 |
+
|
| 309 |
+
original = SpeechSynthesisRequest(
|
| 310 |
+
text=text,
|
| 311 |
+
voice_settings=voice_settings,
|
| 312 |
+
sample_rate=44100
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
new_request = original.with_sample_rate(None)
|
| 316 |
+
assert new_request.sample_rate is None
|
| 317 |
+
assert new_request.effective_sample_rate == 22050
|
| 318 |
+
|
| 319 |
+
def test_with_voice_settings_method(self):
|
| 320 |
+
"""Test with_voice_settings method creates new instance."""
|
| 321 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 322 |
+
original_voice = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 323 |
+
new_voice = VoiceSettings(voice_id="en_female_001", speed=1.5, language="en")
|
| 324 |
+
|
| 325 |
+
original = SpeechSynthesisRequest(
|
| 326 |
+
text=text,
|
| 327 |
+
voice_settings=original_voice
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
new_request = original.with_voice_settings(new_voice)
|
| 331 |
+
|
| 332 |
+
assert new_request.voice_settings == new_voice
|
| 333 |
+
assert new_request.text == original.text
|
| 334 |
+
assert new_request.output_format == original.output_format
|
| 335 |
+
assert new_request.sample_rate == original.sample_rate
|
| 336 |
+
assert new_request is not original # Different instances
|
| 337 |
+
|
| 338 |
+
def test_speech_synthesis_request_is_immutable(self):
|
| 339 |
+
"""Test that SpeechSynthesisRequest is immutable (frozen dataclass)."""
|
| 340 |
+
text = TextContent(text="Hello, world!", language="en")
|
| 341 |
+
voice_settings = VoiceSettings(voice_id="en_male_001", speed=1.0, language="en")
|
| 342 |
+
|
| 343 |
+
request = SpeechSynthesisRequest(
|
| 344 |
+
text=text,
|
| 345 |
+
voice_settings=voice_settings
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
with pytest.raises(AttributeError):
|
| 349 |
+
request.output_format = "mp3" # type: ignore
|
tests/unit/domain/models/test_text_content.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for TextContent value object."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from src.domain.models.text_content import TextContent
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TestTextContent:
|
| 8 |
+
"""Test cases for TextContent value object."""
|
| 9 |
+
|
| 10 |
+
def test_valid_text_content_creation(self):
|
| 11 |
+
"""Test creating valid TextContent instance."""
|
| 12 |
+
text = TextContent(
|
| 13 |
+
text="Hello, world!",
|
| 14 |
+
language="en",
|
| 15 |
+
encoding="utf-8"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
assert text.text == "Hello, world!"
|
| 19 |
+
assert text.language == "en"
|
| 20 |
+
assert text.encoding == "utf-8"
|
| 21 |
+
assert text.word_count == 2
|
| 22 |
+
assert text.character_count == 13
|
| 23 |
+
assert text.is_empty is False
|
| 24 |
+
|
| 25 |
+
def test_text_content_with_default_encoding(self):
|
| 26 |
+
"""Test creating TextContent with default encoding."""
|
| 27 |
+
text = TextContent(
|
| 28 |
+
text="Hello, world!",
|
| 29 |
+
language="en"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
assert text.encoding == "utf-8"
|
| 33 |
+
|
| 34 |
+
def test_non_string_text_raises_error(self):
|
| 35 |
+
"""Test that non-string text raises TypeError."""
|
| 36 |
+
with pytest.raises(TypeError, match="Text must be a string"):
|
| 37 |
+
TextContent(
|
| 38 |
+
text=123, # type: ignore
|
| 39 |
+
language="en"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
def test_empty_text_raises_error(self):
|
| 43 |
+
"""Test that empty text raises ValueError."""
|
| 44 |
+
with pytest.raises(ValueError, match="Text content cannot be empty or whitespace only"):
|
| 45 |
+
TextContent(
|
| 46 |
+
text="",
|
| 47 |
+
language="en"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
def test_whitespace_only_text_raises_error(self):
|
| 51 |
+
"""Test that whitespace-only text raises ValueError."""
|
| 52 |
+
with pytest.raises(ValueError, match="Text content cannot be empty or whitespace only"):
|
| 53 |
+
TextContent(
|
| 54 |
+
text=" \n\t ",
|
| 55 |
+
language="en"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
def test_text_too_long_raises_error(self):
|
| 59 |
+
"""Test that text over 50,000 characters raises ValueError."""
|
| 60 |
+
long_text = "a" * 50001
|
| 61 |
+
with pytest.raises(ValueError, match="Text content too long"):
|
| 62 |
+
TextContent(
|
| 63 |
+
text=long_text,
|
| 64 |
+
language="en"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
def test_text_at_max_length(self):
|
| 68 |
+
"""Test text at maximum allowed length."""
|
| 69 |
+
max_text = "a" * 50000
|
| 70 |
+
text = TextContent(
|
| 71 |
+
text=max_text,
|
| 72 |
+
language="en"
|
| 73 |
+
)
|
| 74 |
+
assert len(text.text) == 50000
|
| 75 |
+
|
| 76 |
+
def test_non_string_language_raises_error(self):
|
| 77 |
+
"""Test that non-string language raises TypeError."""
|
| 78 |
+
with pytest.raises(TypeError, match="Language must be a string"):
|
| 79 |
+
TextContent(
|
| 80 |
+
text="Hello",
|
| 81 |
+
language=123 # type: ignore
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
def test_empty_language_raises_error(self):
|
| 85 |
+
"""Test that empty language raises ValueError."""
|
| 86 |
+
with pytest.raises(ValueError, match="Language cannot be empty"):
|
| 87 |
+
TextContent(
|
| 88 |
+
text="Hello",
|
| 89 |
+
language=""
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
def test_whitespace_language_raises_error(self):
|
| 93 |
+
"""Test that whitespace-only language raises ValueError."""
|
| 94 |
+
with pytest.raises(ValueError, match="Language cannot be empty"):
|
| 95 |
+
TextContent(
|
| 96 |
+
text="Hello",
|
| 97 |
+
language=" "
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
def test_invalid_language_code_format_raises_error(self):
|
| 101 |
+
"""Test that invalid language code format raises ValueError."""
|
| 102 |
+
invalid_codes = ["e", "ENG", "en-us", "en-USA", "123", "en_US"]
|
| 103 |
+
|
| 104 |
+
for code in invalid_codes:
|
| 105 |
+
with pytest.raises(ValueError, match="Invalid language code format"):
|
| 106 |
+
TextContent(
|
| 107 |
+
text="Hello",
|
| 108 |
+
language=code
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
def test_valid_language_codes(self):
|
| 112 |
+
"""Test valid language code formats."""
|
| 113 |
+
valid_codes = ["en", "fr", "de", "es", "zh", "ja", "en-US", "fr-FR", "zh-CN"]
|
| 114 |
+
|
| 115 |
+
for code in valid_codes:
|
| 116 |
+
text = TextContent(
|
| 117 |
+
text="Hello",
|
| 118 |
+
language=code
|
| 119 |
+
)
|
| 120 |
+
assert text.language == code
|
| 121 |
+
|
| 122 |
+
def test_non_string_encoding_raises_error(self):
|
| 123 |
+
"""Test that non-string encoding raises TypeError."""
|
| 124 |
+
with pytest.raises(TypeError, match="Encoding must be a string"):
|
| 125 |
+
TextContent(
|
| 126 |
+
text="Hello",
|
| 127 |
+
language="en",
|
| 128 |
+
encoding=123 # type: ignore
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
def test_unsupported_encoding_raises_error(self):
|
| 132 |
+
"""Test that unsupported encoding raises ValueError."""
|
| 133 |
+
with pytest.raises(ValueError, match="Unsupported encoding: xyz"):
|
| 134 |
+
TextContent(
|
| 135 |
+
text="Hello",
|
| 136 |
+
language="en",
|
| 137 |
+
encoding="xyz"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
def test_supported_encodings(self):
|
| 141 |
+
"""Test all supported encodings."""
|
| 142 |
+
supported_encodings = ['utf-8', 'utf-16', 'ascii', 'latin-1']
|
| 143 |
+
|
| 144 |
+
for encoding in supported_encodings:
|
| 145 |
+
text = TextContent(
|
| 146 |
+
text="Hello",
|
| 147 |
+
language="en",
|
| 148 |
+
encoding=encoding
|
| 149 |
+
)
|
| 150 |
+
assert text.encoding == encoding
|
| 151 |
+
|
| 152 |
+
def test_text_encoding_compatibility(self):
|
| 153 |
+
"""Test that text is compatible with specified encoding."""
|
| 154 |
+
# ASCII text with UTF-8 encoding should work
|
| 155 |
+
text = TextContent(
|
| 156 |
+
text="Hello",
|
| 157 |
+
language="en",
|
| 158 |
+
encoding="ascii"
|
| 159 |
+
)
|
| 160 |
+
assert text.encoding == "ascii"
|
| 161 |
+
|
| 162 |
+
# Unicode text with ASCII encoding should fail
|
| 163 |
+
with pytest.raises(ValueError, match="Text cannot be encoded with ascii encoding"):
|
| 164 |
+
TextContent(
|
| 165 |
+
text="Héllo", # Contains non-ASCII character
|
| 166 |
+
language="en",
|
| 167 |
+
encoding="ascii"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
def test_word_count_property(self):
|
| 171 |
+
"""Test word_count property calculation."""
|
| 172 |
+
test_cases = [
|
| 173 |
+
("Hello world", 2),
|
| 174 |
+
("Hello", 1),
|
| 175 |
+
("Hello world test", 3),
|
| 176 |
+
("Hello, world! Test.", 3), # Multiple spaces and punctuation
|
| 177 |
+
("", 1), # Empty string split returns ['']
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
for text_str, expected_count in test_cases:
|
| 181 |
+
if text_str: # Skip empty string test as it would fail validation
|
| 182 |
+
text = TextContent(text=text_str, language="en")
|
| 183 |
+
assert text.word_count == expected_count
|
| 184 |
+
|
| 185 |
+
def test_character_count_property(self):
|
| 186 |
+
"""Test character_count property."""
|
| 187 |
+
text_str = "Hello, world!"
|
| 188 |
+
text = TextContent(text=text_str, language="en")
|
| 189 |
+
assert text.character_count == len(text_str)
|
| 190 |
+
|
| 191 |
+
def test_is_empty_property(self):
|
| 192 |
+
"""Test is_empty property."""
|
| 193 |
+
# Non-empty text
|
| 194 |
+
text = TextContent(text="Hello", language="en")
|
| 195 |
+
assert text.is_empty is False
|
| 196 |
+
|
| 197 |
+
# Text with only meaningful content
|
| 198 |
+
text2 = TextContent(text=" Hello ", language="en")
|
| 199 |
+
assert text2.is_empty is False
|
| 200 |
+
|
| 201 |
+
def test_truncate_method(self):
|
| 202 |
+
"""Test truncate method."""
|
| 203 |
+
text = TextContent(text="Hello, world! This is a test.", language="en")
|
| 204 |
+
|
| 205 |
+
# Truncate to shorter length
|
| 206 |
+
truncated = text.truncate(10)
|
| 207 |
+
assert len(truncated.text) <= 10
|
| 208 |
+
assert truncated.language == text.language
|
| 209 |
+
assert truncated.encoding == text.encoding
|
| 210 |
+
assert isinstance(truncated, TextContent)
|
| 211 |
+
|
| 212 |
+
# Truncate to longer length (should return same)
|
| 213 |
+
not_truncated = text.truncate(100)
|
| 214 |
+
assert not_truncated.text == text.text
|
| 215 |
+
|
| 216 |
+
def test_truncate_with_invalid_length(self):
|
| 217 |
+
"""Test truncate with invalid max_length."""
|
| 218 |
+
text = TextContent(text="Hello", language="en")
|
| 219 |
+
|
| 220 |
+
with pytest.raises(ValueError, match="Max length must be positive"):
|
| 221 |
+
text.truncate(0)
|
| 222 |
+
|
| 223 |
+
with pytest.raises(ValueError, match="Max length must be positive"):
|
| 224 |
+
text.truncate(-1)
|
| 225 |
+
|
| 226 |
+
def test_text_content_is_immutable(self):
|
| 227 |
+
"""Test that TextContent is immutable (frozen dataclass)."""
|
| 228 |
+
text = TextContent(text="Hello", language="en")
|
| 229 |
+
|
| 230 |
+
with pytest.raises(AttributeError):
|
| 231 |
+
text.text = "Goodbye" # type: ignore
|
| 232 |
+
|
| 233 |
+
def test_truncate_preserves_word_boundaries(self):
|
| 234 |
+
"""Test that truncate method preserves word boundaries by rstripping."""
|
| 235 |
+
text = TextContent(text="Hello world test", language="en")
|
| 236 |
+
|
| 237 |
+
# Truncate in middle of word
|
| 238 |
+
truncated = text.truncate(12) # "Hello world " -> "Hello world" after rstrip
|
| 239 |
+
assert not truncated.text.endswith(" ")
|
| 240 |
+
assert truncated.text == "Hello world"
|
tests/unit/domain/models/test_translation_request.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for TranslationRequest value object."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from src.domain.models.translation_request import TranslationRequest
|
| 5 |
+
from src.domain.models.text_content import TextContent
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TestTranslationRequest:
|
| 9 |
+
"""Test cases for TranslationRequest value object."""
|
| 10 |
+
|
| 11 |
+
def test_valid_translation_request_creation(self):
|
| 12 |
+
"""Test creating valid TranslationRequest instance."""
|
| 13 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 14 |
+
request = TranslationRequest(
|
| 15 |
+
source_text=source_text,
|
| 16 |
+
target_language="fr",
|
| 17 |
+
source_language="en"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
assert request.source_text == source_text
|
| 21 |
+
assert request.target_language == "fr"
|
| 22 |
+
assert request.source_language == "en"
|
| 23 |
+
assert request.effective_source_language == "en"
|
| 24 |
+
assert request.is_auto_detect_source is False
|
| 25 |
+
|
| 26 |
+
def test_translation_request_without_source_language(self):
|
| 27 |
+
"""Test creating TranslationRequest without explicit source language."""
|
| 28 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 29 |
+
request = TranslationRequest(
|
| 30 |
+
source_text=source_text,
|
| 31 |
+
target_language="fr"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
assert request.source_language is None
|
| 35 |
+
assert request.effective_source_language == "en" # From TextContent
|
| 36 |
+
assert request.is_auto_detect_source is True
|
| 37 |
+
|
| 38 |
+
def test_non_text_content_source_raises_error(self):
|
| 39 |
+
"""Test that non-TextContent source_text raises TypeError."""
|
| 40 |
+
with pytest.raises(TypeError, match="Source text must be a TextContent instance"):
|
| 41 |
+
TranslationRequest(
|
| 42 |
+
source_text="Hello, world!", # type: ignore
|
| 43 |
+
target_language="fr"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
def test_non_string_target_language_raises_error(self):
|
| 47 |
+
"""Test that non-string target_language raises TypeError."""
|
| 48 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 49 |
+
with pytest.raises(TypeError, match="Target language must be a string"):
|
| 50 |
+
TranslationRequest(
|
| 51 |
+
source_text=source_text,
|
| 52 |
+
target_language=123 # type: ignore
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def test_empty_target_language_raises_error(self):
|
| 56 |
+
"""Test that empty target_language raises ValueError."""
|
| 57 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 58 |
+
with pytest.raises(ValueError, match="Target language cannot be empty"):
|
| 59 |
+
TranslationRequest(
|
| 60 |
+
source_text=source_text,
|
| 61 |
+
target_language=""
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def test_whitespace_target_language_raises_error(self):
|
| 65 |
+
"""Test that whitespace-only target_language raises ValueError."""
|
| 66 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 67 |
+
with pytest.raises(ValueError, match="Target language cannot be empty"):
|
| 68 |
+
TranslationRequest(
|
| 69 |
+
source_text=source_text,
|
| 70 |
+
target_language=" "
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def test_invalid_target_language_format_raises_error(self):
|
| 74 |
+
"""Test that invalid target language format raises ValueError."""
|
| 75 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 76 |
+
invalid_codes = ["f", "FRA", "fr-us", "fr-USA", "123", "fr_FR"]
|
| 77 |
+
|
| 78 |
+
for code in invalid_codes:
|
| 79 |
+
with pytest.raises(ValueError, match="Invalid target language code format"):
|
| 80 |
+
TranslationRequest(
|
| 81 |
+
source_text=source_text,
|
| 82 |
+
target_language=code
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def test_valid_target_language_codes(self):
|
| 86 |
+
"""Test valid target language code formats."""
|
| 87 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 88 |
+
valid_codes = ["fr", "de", "es", "zh", "ja", "fr-FR", "de-DE", "zh-CN"]
|
| 89 |
+
|
| 90 |
+
for code in valid_codes:
|
| 91 |
+
request = TranslationRequest(
|
| 92 |
+
source_text=source_text,
|
| 93 |
+
target_language=code
|
| 94 |
+
)
|
| 95 |
+
assert request.target_language == code
|
| 96 |
+
|
| 97 |
+
def test_non_string_source_language_raises_error(self):
|
| 98 |
+
"""Test that non-string source_language raises TypeError."""
|
| 99 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 100 |
+
with pytest.raises(TypeError, match="Source language must be a string"):
|
| 101 |
+
TranslationRequest(
|
| 102 |
+
source_text=source_text,
|
| 103 |
+
target_language="fr",
|
| 104 |
+
source_language=123 # type: ignore
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
def test_empty_source_language_raises_error(self):
|
| 108 |
+
"""Test that empty source_language raises ValueError."""
|
| 109 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 110 |
+
with pytest.raises(ValueError, match="Source language cannot be empty string"):
|
| 111 |
+
TranslationRequest(
|
| 112 |
+
source_text=source_text,
|
| 113 |
+
target_language="fr",
|
| 114 |
+
source_language=""
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def test_whitespace_source_language_raises_error(self):
|
| 118 |
+
"""Test that whitespace-only source_language raises ValueError."""
|
| 119 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 120 |
+
with pytest.raises(ValueError, match="Source language cannot be empty string"):
|
| 121 |
+
TranslationRequest(
|
| 122 |
+
source_text=source_text,
|
| 123 |
+
target_language="fr",
|
| 124 |
+
source_language=" "
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
def test_invalid_source_language_format_raises_error(self):
|
| 128 |
+
"""Test that invalid source language format raises ValueError."""
|
| 129 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 130 |
+
invalid_codes = ["e", "ENG", "en-us", "en-USA", "123", "en_US"]
|
| 131 |
+
|
| 132 |
+
for code in invalid_codes:
|
| 133 |
+
with pytest.raises(ValueError, match="Invalid source language code format"):
|
| 134 |
+
TranslationRequest(
|
| 135 |
+
source_text=source_text,
|
| 136 |
+
target_language="fr",
|
| 137 |
+
source_language=code
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
def test_same_source_and_target_language_raises_error(self):
|
| 141 |
+
"""Test that same source and target languages raise ValueError."""
|
| 142 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 143 |
+
|
| 144 |
+
# Explicit source language same as target
|
| 145 |
+
with pytest.raises(ValueError, match="Source and target languages cannot be the same"):
|
| 146 |
+
TranslationRequest(
|
| 147 |
+
source_text=source_text,
|
| 148 |
+
target_language="en",
|
| 149 |
+
source_language="en"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# Implicit source language (from TextContent) same as target
|
| 153 |
+
with pytest.raises(ValueError, match="Source and target languages cannot be the same"):
|
| 154 |
+
TranslationRequest(
|
| 155 |
+
source_text=source_text,
|
| 156 |
+
target_language="en"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
def test_effective_source_language_property(self):
|
| 160 |
+
"""Test effective_source_language property."""
|
| 161 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 162 |
+
|
| 163 |
+
# With explicit source language
|
| 164 |
+
request_explicit = TranslationRequest(
|
| 165 |
+
source_text=source_text,
|
| 166 |
+
target_language="fr",
|
| 167 |
+
source_language="de"
|
| 168 |
+
)
|
| 169 |
+
assert request_explicit.effective_source_language == "de"
|
| 170 |
+
|
| 171 |
+
# Without explicit source language (uses TextContent language)
|
| 172 |
+
request_implicit = TranslationRequest(
|
| 173 |
+
source_text=source_text,
|
| 174 |
+
target_language="fr"
|
| 175 |
+
)
|
| 176 |
+
assert request_implicit.effective_source_language == "en"
|
| 177 |
+
|
| 178 |
+
def test_text_length_property(self):
|
| 179 |
+
"""Test text_length property."""
|
| 180 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 181 |
+
request = TranslationRequest(
|
| 182 |
+
source_text=source_text,
|
| 183 |
+
target_language="fr"
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
assert request.text_length == len("Hello, world!")
|
| 187 |
+
|
| 188 |
+
def test_word_count_property(self):
|
| 189 |
+
"""Test word_count property."""
|
| 190 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 191 |
+
request = TranslationRequest(
|
| 192 |
+
source_text=source_text,
|
| 193 |
+
target_language="fr"
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
assert request.word_count == source_text.word_count
|
| 197 |
+
|
| 198 |
+
def test_with_target_language_method(self):
|
| 199 |
+
"""Test with_target_language method creates new instance."""
|
| 200 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 201 |
+
original = TranslationRequest(
|
| 202 |
+
source_text=source_text,
|
| 203 |
+
target_language="fr",
|
| 204 |
+
source_language="en"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
new_request = original.with_target_language("de")
|
| 208 |
+
|
| 209 |
+
assert new_request.target_language == "de"
|
| 210 |
+
assert new_request.source_text == original.source_text
|
| 211 |
+
assert new_request.source_language == original.source_language
|
| 212 |
+
assert new_request is not original # Different instances
|
| 213 |
+
|
| 214 |
+
def test_with_source_language_method(self):
|
| 215 |
+
"""Test with_source_language method creates new instance."""
|
| 216 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 217 |
+
original = TranslationRequest(
|
| 218 |
+
source_text=source_text,
|
| 219 |
+
target_language="fr",
|
| 220 |
+
source_language="en"
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
new_request = original.with_source_language("de")
|
| 224 |
+
|
| 225 |
+
assert new_request.source_language == "de"
|
| 226 |
+
assert new_request.target_language == original.target_language
|
| 227 |
+
assert new_request.source_text == original.source_text
|
| 228 |
+
assert new_request is not original # Different instances
|
| 229 |
+
|
| 230 |
+
def test_with_source_language_none(self):
|
| 231 |
+
"""Test with_source_language method with None value."""
|
| 232 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 233 |
+
original = TranslationRequest(
|
| 234 |
+
source_text=source_text,
|
| 235 |
+
target_language="fr",
|
| 236 |
+
source_language="en"
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
new_request = original.with_source_language(None)
|
| 240 |
+
assert new_request.source_language is None
|
| 241 |
+
assert new_request.is_auto_detect_source is True
|
| 242 |
+
|
| 243 |
+
def test_translation_request_is_immutable(self):
|
| 244 |
+
"""Test that TranslationRequest is immutable (frozen dataclass)."""
|
| 245 |
+
source_text = TextContent(text="Hello, world!", language="en")
|
| 246 |
+
request = TranslationRequest(
|
| 247 |
+
source_text=source_text,
|
| 248 |
+
target_language="fr"
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
with pytest.raises(AttributeError):
|
| 252 |
+
request.target_language = "de" # type: ignore
|
| 253 |
+
|
| 254 |
+
def test_valid_language_combinations(self):
|
| 255 |
+
"""Test various valid language combinations."""
|
| 256 |
+
test_cases = [
|
| 257 |
+
("en", "fr", "en"), # English to French with explicit source
|
| 258 |
+
("en", "de", None), # English to German with auto-detect
|
| 259 |
+
("fr-FR", "en-US", "fr"), # Regional codes
|
| 260 |
+
("zh", "ja", "zh-CN"), # Asian languages
|
| 261 |
+
]
|
| 262 |
+
|
| 263 |
+
for text_lang, target_lang, source_lang in test_cases:
|
| 264 |
+
source_text = TextContent(text="Test text", language=text_lang)
|
| 265 |
+
request = TranslationRequest(
|
| 266 |
+
source_text=source_text,
|
| 267 |
+
target_language=target_lang,
|
| 268 |
+
source_language=source_lang
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
assert request.target_language == target_lang
|
| 272 |
+
assert request.source_language == source_lang
|
tests/unit/domain/models/test_voice_settings.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for VoiceSettings value object."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from src.domain.models.voice_settings import VoiceSettings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TestVoiceSettings:
|
| 8 |
+
"""Test cases for VoiceSettings value object."""
|
| 9 |
+
|
| 10 |
+
def test_valid_voice_settings_creation(self):
|
| 11 |
+
"""Test creating valid VoiceSettings instance."""
|
| 12 |
+
settings = VoiceSettings(
|
| 13 |
+
voice_id="en_male_001",
|
| 14 |
+
speed=1.2,
|
| 15 |
+
language="en",
|
| 16 |
+
pitch=0.1,
|
| 17 |
+
volume=0.8
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
assert settings.voice_id == "en_male_001"
|
| 21 |
+
assert settings.speed == 1.2
|
| 22 |
+
assert settings.language == "en"
|
| 23 |
+
assert settings.pitch == 0.1
|
| 24 |
+
assert settings.volume == 0.8
|
| 25 |
+
|
| 26 |
+
def test_voice_settings_with_optional_none(self):
|
| 27 |
+
"""Test creating VoiceSettings with optional parameters as None."""
|
| 28 |
+
settings = VoiceSettings(
|
| 29 |
+
voice_id="en_male_001",
|
| 30 |
+
speed=1.0,
|
| 31 |
+
language="en"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
assert settings.pitch is None
|
| 35 |
+
assert settings.volume is None
|
| 36 |
+
|
| 37 |
+
def test_non_string_voice_id_raises_error(self):
|
| 38 |
+
"""Test that non-string voice_id raises TypeError."""
|
| 39 |
+
with pytest.raises(TypeError, match="Voice ID must be a string"):
|
| 40 |
+
VoiceSettings(
|
| 41 |
+
voice_id=123, # type: ignore
|
| 42 |
+
speed=1.0,
|
| 43 |
+
language="en"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
def test_empty_voice_id_raises_error(self):
|
| 47 |
+
"""Test that empty voice_id raises ValueError."""
|
| 48 |
+
with pytest.raises(ValueError, match="Voice ID cannot be empty"):
|
| 49 |
+
VoiceSettings(
|
| 50 |
+
voice_id="",
|
| 51 |
+
speed=1.0,
|
| 52 |
+
language="en"
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def test_whitespace_voice_id_raises_error(self):
|
| 56 |
+
"""Test that whitespace-only voice_id raises ValueError."""
|
| 57 |
+
with pytest.raises(ValueError, match="Voice ID cannot be empty"):
|
| 58 |
+
VoiceSettings(
|
| 59 |
+
voice_id=" ",
|
| 60 |
+
speed=1.0,
|
| 61 |
+
language="en"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def test_invalid_voice_id_format_raises_error(self):
|
| 65 |
+
"""Test that invalid voice_id format raises ValueError."""
|
| 66 |
+
invalid_ids = ["voice id", "voice@id", "voice.id", "voice/id", "voice\\id"]
|
| 67 |
+
|
| 68 |
+
for voice_id in invalid_ids:
|
| 69 |
+
with pytest.raises(ValueError, match="Invalid voice ID format"):
|
| 70 |
+
VoiceSettings(
|
| 71 |
+
voice_id=voice_id,
|
| 72 |
+
speed=1.0,
|
| 73 |
+
language="en"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
def test_valid_voice_id_formats(self):
|
| 77 |
+
"""Test valid voice_id formats."""
|
| 78 |
+
valid_ids = ["voice1", "voice_1", "voice-1", "en_male_001", "female-voice", "Voice123"]
|
| 79 |
+
|
| 80 |
+
for voice_id in valid_ids:
|
| 81 |
+
settings = VoiceSettings(
|
| 82 |
+
voice_id=voice_id,
|
| 83 |
+
speed=1.0,
|
| 84 |
+
language="en"
|
| 85 |
+
)
|
| 86 |
+
assert settings.voice_id == voice_id
|
| 87 |
+
|
| 88 |
+
def test_non_numeric_speed_raises_error(self):
|
| 89 |
+
"""Test that non-numeric speed raises TypeError."""
|
| 90 |
+
with pytest.raises(TypeError, match="Speed must be a number"):
|
| 91 |
+
VoiceSettings(
|
| 92 |
+
voice_id="voice1",
|
| 93 |
+
speed="fast", # type: ignore
|
| 94 |
+
language="en"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
def test_speed_too_low_raises_error(self):
|
| 98 |
+
"""Test that speed below 0.1 raises ValueError."""
|
| 99 |
+
with pytest.raises(ValueError, match="Speed must be between 0.1 and 3.0"):
|
| 100 |
+
VoiceSettings(
|
| 101 |
+
voice_id="voice1",
|
| 102 |
+
speed=0.05,
|
| 103 |
+
language="en"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
def test_speed_too_high_raises_error(self):
|
| 107 |
+
"""Test that speed above 3.0 raises ValueError."""
|
| 108 |
+
with pytest.raises(ValueError, match="Speed must be between 0.1 and 3.0"):
|
| 109 |
+
VoiceSettings(
|
| 110 |
+
voice_id="voice1",
|
| 111 |
+
speed=3.1,
|
| 112 |
+
language="en"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
def test_valid_speed_boundaries(self):
|
| 116 |
+
"""Test valid speed boundaries."""
|
| 117 |
+
# Test minimum valid speed
|
| 118 |
+
settings_min = VoiceSettings(
|
| 119 |
+
voice_id="voice1",
|
| 120 |
+
speed=0.1,
|
| 121 |
+
language="en"
|
| 122 |
+
)
|
| 123 |
+
assert settings_min.speed == 0.1
|
| 124 |
+
|
| 125 |
+
# Test maximum valid speed
|
| 126 |
+
settings_max = VoiceSettings(
|
| 127 |
+
voice_id="voice1",
|
| 128 |
+
speed=3.0,
|
| 129 |
+
language="en"
|
| 130 |
+
)
|
| 131 |
+
assert settings_max.speed == 3.0
|
| 132 |
+
|
| 133 |
+
def test_non_string_language_raises_error(self):
|
| 134 |
+
"""Test that non-string language raises TypeError."""
|
| 135 |
+
with pytest.raises(TypeError, match="Language must be a string"):
|
| 136 |
+
VoiceSettings(
|
| 137 |
+
voice_id="voice1",
|
| 138 |
+
speed=1.0,
|
| 139 |
+
language=123 # type: ignore
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
def test_empty_language_raises_error(self):
|
| 143 |
+
"""Test that empty language raises ValueError."""
|
| 144 |
+
with pytest.raises(ValueError, match="Language cannot be empty"):
|
| 145 |
+
VoiceSettings(
|
| 146 |
+
voice_id="voice1",
|
| 147 |
+
speed=1.0,
|
| 148 |
+
language=""
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
def test_invalid_language_code_format_raises_error(self):
|
| 152 |
+
"""Test that invalid language code format raises ValueError."""
|
| 153 |
+
invalid_codes = ["e", "ENG", "en-us", "en-USA", "123", "en_US"]
|
| 154 |
+
|
| 155 |
+
for code in invalid_codes:
|
| 156 |
+
with pytest.raises(ValueError, match="Invalid language code format"):
|
| 157 |
+
VoiceSettings(
|
| 158 |
+
voice_id="voice1",
|
| 159 |
+
speed=1.0,
|
| 160 |
+
language=code
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
def test_valid_language_codes(self):
|
| 164 |
+
"""Test valid language code formats."""
|
| 165 |
+
valid_codes = ["en", "fr", "de", "es", "zh", "ja", "en-US", "fr-FR", "zh-CN"]
|
| 166 |
+
|
| 167 |
+
for code in valid_codes:
|
| 168 |
+
settings = VoiceSettings(
|
| 169 |
+
voice_id="voice1",
|
| 170 |
+
speed=1.0,
|
| 171 |
+
language=code
|
| 172 |
+
)
|
| 173 |
+
assert settings.language == code
|
| 174 |
+
|
| 175 |
+
def test_non_numeric_pitch_raises_error(self):
|
| 176 |
+
"""Test that non-numeric pitch raises TypeError."""
|
| 177 |
+
with pytest.raises(TypeError, match="Pitch must be a number"):
|
| 178 |
+
VoiceSettings(
|
| 179 |
+
voice_id="voice1",
|
| 180 |
+
speed=1.0,
|
| 181 |
+
language="en",
|
| 182 |
+
pitch="high" # type: ignore
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
def test_pitch_too_low_raises_error(self):
|
| 186 |
+
"""Test that pitch below -2.0 raises ValueError."""
|
| 187 |
+
with pytest.raises(ValueError, match="Pitch must be between -2.0 and 2.0"):
|
| 188 |
+
VoiceSettings(
|
| 189 |
+
voice_id="voice1",
|
| 190 |
+
speed=1.0,
|
| 191 |
+
language="en",
|
| 192 |
+
pitch=-2.1
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
def test_pitch_too_high_raises_error(self):
|
| 196 |
+
"""Test that pitch above 2.0 raises ValueError."""
|
| 197 |
+
with pytest.raises(ValueError, match="Pitch must be between -2.0 and 2.0"):
|
| 198 |
+
VoiceSettings(
|
| 199 |
+
voice_id="voice1",
|
| 200 |
+
speed=1.0,
|
| 201 |
+
language="en",
|
| 202 |
+
pitch=2.1
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
def test_valid_pitch_boundaries(self):
|
| 206 |
+
"""Test valid pitch boundaries."""
|
| 207 |
+
# Test minimum valid pitch
|
| 208 |
+
settings_min = VoiceSettings(
|
| 209 |
+
voice_id="voice1",
|
| 210 |
+
speed=1.0,
|
| 211 |
+
language="en",
|
| 212 |
+
pitch=-2.0
|
| 213 |
+
)
|
| 214 |
+
assert settings_min.pitch == -2.0
|
| 215 |
+
|
| 216 |
+
# Test maximum valid pitch
|
| 217 |
+
settings_max = VoiceSettings(
|
| 218 |
+
voice_id="voice1",
|
| 219 |
+
speed=1.0,
|
| 220 |
+
language="en",
|
| 221 |
+
pitch=2.0
|
| 222 |
+
)
|
| 223 |
+
assert settings_max.pitch == 2.0
|
| 224 |
+
|
| 225 |
+
def test_non_numeric_volume_raises_error(self):
|
| 226 |
+
"""Test that non-numeric volume raises TypeError."""
|
| 227 |
+
with pytest.raises(TypeError, match="Volume must be a number"):
|
| 228 |
+
VoiceSettings(
|
| 229 |
+
voice_id="voice1",
|
| 230 |
+
speed=1.0,
|
| 231 |
+
language="en",
|
| 232 |
+
volume="loud" # type: ignore
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
def test_volume_too_low_raises_error(self):
|
| 236 |
+
"""Test that volume below 0.0 raises ValueError."""
|
| 237 |
+
with pytest.raises(ValueError, match="Volume must be between 0.0 and 2.0"):
|
| 238 |
+
VoiceSettings(
|
| 239 |
+
voice_id="voice1",
|
| 240 |
+
speed=1.0,
|
| 241 |
+
language="en",
|
| 242 |
+
volume=-0.1
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
def test_volume_too_high_raises_error(self):
|
| 246 |
+
"""Test that volume above 2.0 raises ValueError."""
|
| 247 |
+
with pytest.raises(ValueError, match="Volume must be between 0.0 and 2.0"):
|
| 248 |
+
VoiceSettings(
|
| 249 |
+
voice_id="voice1",
|
| 250 |
+
speed=1.0,
|
| 251 |
+
language="en",
|
| 252 |
+
volume=2.1
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
def test_valid_volume_boundaries(self):
|
| 256 |
+
"""Test valid volume boundaries."""
|
| 257 |
+
# Test minimum valid volume
|
| 258 |
+
settings_min = VoiceSettings(
|
| 259 |
+
voice_id="voice1",
|
| 260 |
+
speed=1.0,
|
| 261 |
+
language="en",
|
| 262 |
+
volume=0.0
|
| 263 |
+
)
|
| 264 |
+
assert settings_min.volume == 0.0
|
| 265 |
+
|
| 266 |
+
# Test maximum valid volume
|
| 267 |
+
settings_max = VoiceSettings(
|
| 268 |
+
voice_id="voice1",
|
| 269 |
+
speed=1.0,
|
| 270 |
+
language="en",
|
| 271 |
+
volume=2.0
|
| 272 |
+
)
|
| 273 |
+
assert settings_max.volume == 2.0
|
| 274 |
+
|
| 275 |
+
def test_is_default_speed_property(self):
|
| 276 |
+
"""Test is_default_speed property."""
|
| 277 |
+
# Default speed (1.0)
|
| 278 |
+
settings_default = VoiceSettings(
|
| 279 |
+
voice_id="voice1",
|
| 280 |
+
speed=1.0,
|
| 281 |
+
language="en"
|
| 282 |
+
)
|
| 283 |
+
assert settings_default.is_default_speed is True
|
| 284 |
+
|
| 285 |
+
# Non-default speed
|
| 286 |
+
settings_non_default = VoiceSettings(
|
| 287 |
+
voice_id="voice1",
|
| 288 |
+
speed=1.5,
|
| 289 |
+
language="en"
|
| 290 |
+
)
|
| 291 |
+
assert settings_non_default.is_default_speed is False
|
| 292 |
+
|
| 293 |
+
def test_is_default_pitch_property(self):
|
| 294 |
+
"""Test is_default_pitch property."""
|
| 295 |
+
# Default pitch (None)
|
| 296 |
+
settings_none = VoiceSettings(
|
| 297 |
+
voice_id="voice1",
|
| 298 |
+
speed=1.0,
|
| 299 |
+
language="en"
|
| 300 |
+
)
|
| 301 |
+
assert settings_none.is_default_pitch is True
|
| 302 |
+
|
| 303 |
+
# Default pitch (0.0)
|
| 304 |
+
settings_zero = VoiceSettings(
|
| 305 |
+
voice_id="voice1",
|
| 306 |
+
speed=1.0,
|
| 307 |
+
language="en",
|
| 308 |
+
pitch=0.0
|
| 309 |
+
)
|
| 310 |
+
assert settings_zero.is_default_pitch is True
|
| 311 |
+
|
| 312 |
+
# Non-default pitch
|
| 313 |
+
settings_non_default = VoiceSettings(
|
| 314 |
+
voice_id="voice1",
|
| 315 |
+
speed=1.0,
|
| 316 |
+
language="en",
|
| 317 |
+
pitch=0.5
|
| 318 |
+
)
|
| 319 |
+
assert settings_non_default.is_default_pitch is False
|
| 320 |
+
|
| 321 |
+
def test_is_default_volume_property(self):
|
| 322 |
+
"""Test is_default_volume property."""
|
| 323 |
+
# Default volume (None)
|
| 324 |
+
settings_none = VoiceSettings(
|
| 325 |
+
voice_id="voice1",
|
| 326 |
+
speed=1.0,
|
| 327 |
+
language="en"
|
| 328 |
+
)
|
| 329 |
+
assert settings_none.is_default_volume is True
|
| 330 |
+
|
| 331 |
+
# Default volume (1.0)
|
| 332 |
+
settings_one = VoiceSettings(
|
| 333 |
+
voice_id="voice1",
|
| 334 |
+
speed=1.0,
|
| 335 |
+
language="en",
|
| 336 |
+
volume=1.0
|
| 337 |
+
)
|
| 338 |
+
assert settings_one.is_default_volume is True
|
| 339 |
+
|
| 340 |
+
# Non-default volume
|
| 341 |
+
settings_non_default = VoiceSettings(
|
| 342 |
+
voice_id="voice1",
|
| 343 |
+
speed=1.0,
|
| 344 |
+
language="en",
|
| 345 |
+
volume=0.5
|
| 346 |
+
)
|
| 347 |
+
assert settings_non_default.is_default_volume is False
|
| 348 |
+
|
| 349 |
+
def test_with_speed_method(self):
|
| 350 |
+
"""Test with_speed method creates new instance."""
|
| 351 |
+
original = VoiceSettings(
|
| 352 |
+
voice_id="voice1",
|
| 353 |
+
speed=1.0,
|
| 354 |
+
language="en",
|
| 355 |
+
pitch=0.1,
|
| 356 |
+
volume=0.8
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
new_settings = original.with_speed(1.5)
|
| 360 |
+
|
| 361 |
+
assert new_settings.speed == 1.5
|
| 362 |
+
assert new_settings.voice_id == original.voice_id
|
| 363 |
+
assert new_settings.language == original.language
|
| 364 |
+
assert new_settings.pitch == original.pitch
|
| 365 |
+
assert new_settings.volume == original.volume
|
| 366 |
+
assert new_settings is not original # Different instances
|
| 367 |
+
|
| 368 |
+
def test_with_pitch_method(self):
|
| 369 |
+
"""Test with_pitch method creates new instance."""
|
| 370 |
+
original = VoiceSettings(
|
| 371 |
+
voice_id="voice1",
|
| 372 |
+
speed=1.0,
|
| 373 |
+
language="en",
|
| 374 |
+
pitch=0.1,
|
| 375 |
+
volume=0.8
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
new_settings = original.with_pitch(0.5)
|
| 379 |
+
|
| 380 |
+
assert new_settings.pitch == 0.5
|
| 381 |
+
assert new_settings.voice_id == original.voice_id
|
| 382 |
+
assert new_settings.speed == original.speed
|
| 383 |
+
assert new_settings.language == original.language
|
| 384 |
+
assert new_settings.volume == original.volume
|
| 385 |
+
assert new_settings is not original # Different instances
|
| 386 |
+
|
| 387 |
+
def test_with_pitch_none(self):
|
| 388 |
+
"""Test with_pitch method with None value."""
|
| 389 |
+
original = VoiceSettings(
|
| 390 |
+
voice_id="voice1",
|
| 391 |
+
speed=1.0,
|
| 392 |
+
language="en",
|
| 393 |
+
pitch=0.1
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
new_settings = original.with_pitch(None)
|
| 397 |
+
assert new_settings.pitch is None
|
| 398 |
+
|
| 399 |
+
def test_voice_settings_is_immutable(self):
|
| 400 |
+
"""Test that VoiceSettings is immutable (frozen dataclass)."""
|
| 401 |
+
settings = VoiceSettings(
|
| 402 |
+
voice_id="voice1",
|
| 403 |
+
speed=1.0,
|
| 404 |
+
language="en"
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
with pytest.raises(AttributeError):
|
| 408 |
+
settings.speed = 1.5 # type: ignore
|