Spaces:
Build error
Build error
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
|