Michael Hu commited on
Commit
5009cb8
·
1 Parent(s): aaa0814

refactor based on DDD

Browse files
.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