diff --git a/abogen/application/conversion_request.py b/abogen/application/conversion_request.py index 0193ac2..d7dd826 100644 --- a/abogen/application/conversion_request.py +++ b/abogen/application/conversion_request.py @@ -9,6 +9,7 @@ into a ConversionRequest before calling ConversionService.run(). from __future__ import annotations +import dataclasses from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional @@ -16,12 +17,37 @@ from typing import Any, Dict, List, Optional from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode +class ConversionRequestError(ValueError): + """Raised when ConversionRequest has invalid field values.""" + + +# Numeric field constraints: attr -> (min, max) +_NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = { + "max_subtitle_words": (1, 500), + "speed": (0.5, 3.0), + "supertonic_total_steps": (2, 15), + "silence_between_chapters": (0.0, None), + "chapter_intro_delay": (0.0, None), +} + +# Enum-like fields that must be in allowed set +_ENUM_CONSTRAINTS: dict[str, tuple[str, ...]] = { + "chunk_level": ("paragraph", "sentence"), + "speaker_mode": ("single", "multi"), +} + + @dataclass class ConversionRequest: """Normalized request for a conversion job. Only contains fields that describe the conversion task itself. UI-only fields (display, logging, user prompts) stay in adapters. + + Validation runs on creation via __post_init__: + - None values → replaced with field default (from declaration) + - Numeric fields → clamped to valid range + - String enums → validated against allowed set """ # --- Source --- @@ -83,3 +109,48 @@ class ConversionRequest: cover_image_path: Optional[Path] = None cover_image_mime: Optional[str] = None generate_epub3: bool = False + + def __post_init__(self) -> None: + """Resolve None → default, then validate and clamp.""" + _apply_none_defaults(self) + if not self.tts_provider: + self.tts_provider = "kokoro" + _clamp_numerics(self) + _validate_enums(self) + + +def _apply_none_defaults(obj: ConversionRequest) -> None: + """Replace None values with field defaults from dataclass declaration.""" + for f in dataclasses.fields(obj): + if getattr(obj, f.name) is not None: + continue + if f.default is not dataclasses.MISSING: + setattr(obj, f.name, f.default) + elif f.default_factory is not dataclasses.MISSING: + setattr(obj, f.name, f.default_factory()) + + +def _clamp_numerics(obj: ConversionRequest) -> None: + """Clamp numeric fields to valid ranges.""" + for attr, (min_v, max_v) in _NUMERIC_CONSTRAINTS.items(): + val = getattr(obj, attr) + if val is None: + continue + if not isinstance(val, (int, float)): + raise ConversionRequestError( + f"{attr} must be a number, got {type(val).__name__}" + ) + clamped = max(min_v, float(val)) + if max_v is not None: + clamped = min(max_v, clamped) + setattr(obj, attr, clamped) + + +def _validate_enums(obj: ConversionRequest) -> None: + """Validate string enum fields against allowed values.""" + for attr, allowed in _ENUM_CONSTRAINTS.items(): + val = getattr(obj, attr) + if val not in allowed: + raise ConversionRequestError( + f"{attr} must be one of {allowed}, got {val!r}" + ) diff --git a/abogen/pyqt/conversion_adapter.py b/abogen/pyqt/conversion_adapter.py index 0b94b86..e1adfd8 100644 --- a/abogen/pyqt/conversion_adapter.py +++ b/abogen/pyqt/conversion_adapter.py @@ -60,28 +60,28 @@ def build_conversion_request_from_thread(thread: Any) -> ConversionRequest: direct_text=thread.file_name if is_direct_text else None, original_filename=original_filename, # TTS Settings - language=thread.lang_code or "a", + language=thread.lang_code, tts_provider="kokoro", # PyQt uses Kokoro by default - voice=thread.voice or "M1", + voice=thread.voice, voice_profile=getattr(thread, "voice_profile", None), - speed=thread.speed or 1.0, + speed=thread.speed, use_gpu=thread.use_gpu, - supertonic_total_steps=getattr(thread, "supertonic_total_steps", None) or 5, + supertonic_total_steps=getattr(thread, "supertonic_total_steps", 5), # Output Format - output_format=thread.output_format or "wav", - subtitle_mode=thread.subtitle_mode or "Disabled", + output_format=thread.output_format, + subtitle_mode=thread.subtitle_mode, subtitle_format=getattr(thread, "subtitle_format", "srt"), - max_subtitle_words=getattr(thread, "max_subtitle_words", None) or 50, + max_subtitle_words=getattr(thread, "max_subtitle_words", 50), # Save Options - save_mode=thread.save_option or "save_next_to_input", + save_mode=thread.save_option, output_folder=output_folder, save_chapters_separately=getattr(thread, "save_chapters_separately", False), merge_chapters_at_end=getattr(thread, "merge_chapters_at_end", True), separate_chapters_format=getattr(thread, "separate_chapters_format", "wav"), save_as_project=getattr(thread, "save_as_project", False), # Timing - silence_between_chapters=getattr(thread, "silence_duration", None) or 2.0, - chapter_intro_delay=getattr(thread, "chapter_intro_delay", None) or 0.0, + silence_between_chapters=getattr(thread, "silence_duration", 2.0), + chapter_intro_delay=getattr(thread, "chapter_intro_delay", 0.0), # Content Processing replace_single_newlines=getattr(thread, "replace_single_newlines", False), read_title_intro=getattr(thread, "read_title_intro", False), diff --git a/abogen/webui/conversion_adapter.py b/abogen/webui/conversion_adapter.py index 8b125da..ab8ed5b 100644 --- a/abogen/webui/conversion_adapter.py +++ b/abogen/webui/conversion_adapter.py @@ -38,28 +38,28 @@ def build_conversion_request_from_job(job: Any) -> ConversionRequest: source_path=Path(job.stored_path) if job.stored_path else None, original_filename=job.original_filename, # TTS Settings - language=job.language or "a", - tts_provider=job.tts_provider or "kokoro", - voice=job.voice or "M1", + language=job.language, + tts_provider=job.tts_provider, + voice=job.voice, voice_profile=job.voice_profile, - speed=job.speed or 1.0, + speed=job.speed, use_gpu=job.use_gpu, - supertonic_total_steps=job.supertonic_total_steps or 5, + supertonic_total_steps=job.supertonic_total_steps, # Output Format - output_format=job.output_format or "wav", - subtitle_mode=job.subtitle_mode or "Disabled", - subtitle_format=job.subtitle_format or "srt", - max_subtitle_words=job.max_subtitle_words or 50, + output_format=job.output_format, + subtitle_mode=job.subtitle_mode, + subtitle_format=job.subtitle_format, + max_subtitle_words=job.max_subtitle_words, # Save Options - save_mode=job.save_mode or "save_next_to_input", + save_mode=job.save_mode, output_folder=Path(job.output_folder) if job.output_folder else None, save_chapters_separately=job.save_chapters_separately, merge_chapters_at_end=job.merge_chapters_at_end, - separate_chapters_format=job.separate_chapters_format or "wav", + separate_chapters_format=job.separate_chapters_format, save_as_project=job.save_as_project, # Timing - silence_between_chapters=job.silence_between_chapters or 2.0, - chapter_intro_delay=job.chapter_intro_delay or 0.0, + silence_between_chapters=job.silence_between_chapters, + chapter_intro_delay=job.chapter_intro_delay, # Content Processing replace_single_newlines=job.replace_single_newlines, read_title_intro=job.read_title_intro, @@ -74,8 +74,8 @@ def build_conversion_request_from_job(job: Any) -> ConversionRequest: # Chapter/Chunk Configuration chapter_overrides=job.chapters or [], chunks=job.chunks or [], - chunk_level=job.chunk_level or "paragraph", - speaker_mode=job.speaker_mode or "single", + chunk_level=job.chunk_level, + speaker_mode=job.speaker_mode, speakers=job.speakers or {}, # Metadata metadata_tags=job.metadata_tags or {}, diff --git a/tests/test_conversion_adapters.py b/tests/test_conversion_adapters.py index a65151d..7e066a1 100644 --- a/tests/test_conversion_adapters.py +++ b/tests/test_conversion_adapters.py @@ -146,6 +146,7 @@ class TestWebUIAdapter: def test_none_defaults_handled(self): from abogen.webui.conversion_adapter import build_conversion_request_from_job + from abogen.domain.enums import Language, OutputFormat, SubtitleMode, SaveMode job = self._make_job( language=None, @@ -161,12 +162,13 @@ class TestWebUIAdapter: ) req = build_conversion_request_from_job(job) - assert req.language == "a" - assert req.voice == "M1" + # None values pass through adapter; ConversionRequest.__post_init__ + # applies defaults and clamping for numeric fields. + assert req.language == Language.EN_US assert req.speed == 1.0 - assert req.output_format == "wav" - assert req.subtitle_mode == "Disabled" - assert req.save_mode == "save_next_to_input" + assert req.output_format == OutputFormat.WAV + assert req.subtitle_mode == SubtitleMode.DISABLED + assert req.save_mode == SaveMode.SAVE_NEXT_TO_INPUT assert req.silence_between_chapters == 2.0 assert req.chapter_intro_delay == 0.0 assert req.supertonic_total_steps == 5 @@ -369,6 +371,7 @@ class TestPyQtAdapter: def test_none_defaults_handled(self): from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread + from abogen.domain.enums import Language, OutputFormat, SubtitleMode, SaveMode thread = self._make_thread( lang_code=None, @@ -384,12 +387,13 @@ class TestPyQtAdapter: ) req = build_conversion_request_from_thread(thread) - assert req.language == "a" - assert req.voice == "M1" + # None values pass through adapter; ConversionRequest.__post_init__ + # applies defaults and clamping for numeric fields. + assert req.language == Language.EN_US assert req.speed == 1.0 - assert req.output_format == "wav" - assert req.subtitle_mode == "Disabled" - assert req.save_mode == "save_next_to_input" + assert req.output_format == OutputFormat.WAV + assert req.subtitle_mode == SubtitleMode.DISABLED + assert req.save_mode == SaveMode.SAVE_NEXT_TO_INPUT assert req.silence_between_chapters == 2.0 assert req.chapter_intro_delay == 0.0 assert req.supertonic_total_steps == 5 diff --git a/tests/test_conversion_request.py b/tests/test_conversion_request.py index b67b18a..5244cac 100644 --- a/tests/test_conversion_request.py +++ b/tests/test_conversion_request.py @@ -15,6 +15,8 @@ import pytest from pathlib import Path from unittest.mock import MagicMock +from abogen.application.conversion_request import ConversionRequest, ConversionRequestError +from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode from abogen.domain.normalization import TTSContext from abogen.domain.settings_core import settings_defaults from abogen.domain.split_pattern import get_split_pattern @@ -192,3 +194,93 @@ class TestLoggingProtocol: progress_fn(100, 1000, "0:05:00") assert len(progress_calls) == 1 assert progress_calls[0] == (100, 1000, "0:05:00") + + +class TestConversionRequestValidation: + """Verify __post_init__ validation on ConversionRequest.""" + + def test_defaults_are_valid(self): + req = ConversionRequest() + assert req.max_subtitle_words == 50 + assert req.speed == 1.0 + assert req.supertonic_total_steps == 5 + assert req.output_format == OutputFormat.WAV + assert req.subtitle_mode == SubtitleMode.DISABLED + + def test_max_subtitle_words_clamped_below_min(self): + req = ConversionRequest(max_subtitle_words=0) + assert req.max_subtitle_words == 1 + + def test_max_subtitle_words_clamped_above_max(self): + req = ConversionRequest(max_subtitle_words=999) + assert req.max_subtitle_words == 500 + + def test_max_subtitle_words_valid(self): + req = ConversionRequest(max_subtitle_words=100) + assert req.max_subtitle_words == 100 + + def test_speed_clamped_below_min(self): + req = ConversionRequest(speed=0.1) + assert req.speed == 0.5 + + def test_speed_clamped_above_max(self): + req = ConversionRequest(speed=10.0) + assert req.speed == 3.0 + + def test_speed_valid(self): + req = ConversionRequest(speed=1.5) + assert req.speed == 1.5 + + def test_supertonic_steps_clamped_below_min(self): + req = ConversionRequest(supertonic_total_steps=0) + assert req.supertonic_total_steps == 2 + + def test_supertonic_steps_clamped_above_max(self): + req = ConversionRequest(supertonic_total_steps=100) + assert req.supertonic_total_steps == 15 + + def test_silence_between_chapters_clamped(self): + req = ConversionRequest(silence_between_chapters=-5.0) + assert req.silence_between_chapters == 0.0 + + def test_chapter_intro_delay_clamped(self): + req = ConversionRequest(chapter_intro_delay=-1.0) + assert req.chapter_intro_delay == 0.0 + + def test_invalid_chunk_level_raises(self): + with pytest.raises(ConversionRequestError, match="chunk_level"): + ConversionRequest(chunk_level="invalid") + + def test_invalid_speaker_mode_raises(self): + with pytest.raises(ConversionRequestError, match="speaker_mode"): + ConversionRequest(speaker_mode="invalid") + + def test_invalid_max_subtitle_words_type_raises(self): + with pytest.raises(ConversionRequestError, match="max_subtitle_words"): + ConversionRequest(max_subtitle_words="not_a_number") + + def test_invalid_speed_type_raises(self): + with pytest.raises(ConversionRequestError, match="speed"): + ConversionRequest(speed="fast") + + def test_invalid_silence_type_raises(self): + with pytest.raises(ConversionRequestError, match="silence_between_chapters"): + ConversionRequest(silence_between_chapters="loud") + + def test_empty_tts_provider_defaults_to_kokoro(self): + req = ConversionRequest(tts_provider="") + assert req.tts_provider == "kokoro" + + def test_enum_fields_accept_valid_values(self): + req = ConversionRequest( + language=Language.FR, + output_format=OutputFormat.MP3, + subtitle_mode=SubtitleMode.SENTENCE, + subtitle_format=SubtitleFormat.ASS, + save_mode=SaveMode.CUSTOM_FOLDER, + ) + assert req.language == Language.FR + assert req.output_format == OutputFormat.MP3 + assert req.subtitle_mode == SubtitleMode.SENTENCE + assert req.subtitle_format == SubtitleFormat.ASS + assert req.save_mode == SaveMode.CUSTOM_FOLDER