From 25d45ffd36bc12a2d6ab104307fa46bddbede259 Mon Sep 17 00:00:00 2001 From: Artem Akymenko Date: Thu, 9 Jul 2026 14:17:14 +0000 Subject: [PATCH] refactor: extract EngineContractMixin base class for plugin tests - Add tests/contracts/engine_contract.py with shared Engine/Session tests - TestKokoroEngineContract and TestSuperTonicEngineContract inherit from it - Eliminates protocol test duplication between plugins - Any new plugin just inherits EngineContractMixin to verify compliance --- tests/contracts/engine_contract.py | 120 ++++++++++++ tests/test_kokoro_plugin.py | 147 +++++---------- tests/test_supertonic_plugin.py | 282 +++++++---------------------- 3 files changed, 229 insertions(+), 320 deletions(-) create mode 100644 tests/contracts/engine_contract.py diff --git a/tests/contracts/engine_contract.py b/tests/contracts/engine_contract.py new file mode 100644 index 0000000..94f7088 --- /dev/null +++ b/tests/contracts/engine_contract.py @@ -0,0 +1,120 @@ +"""Base contract tests for Engine implementations. + +Any new TTS plugin must inherit from these classes to verify +it satisfies the Engine/EngineSession protocol. + +Usage: + from tests.contracts.engine_contract import EngineContractMixin + + class TestMyEngine(EngineContractMixin): + @pytest.fixture + def engine(self): + return create_my_engine() +""" + +from __future__ import annotations + +import pytest + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import EngineError +from abogen.tts_plugin.types import ( + AudioFormat, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) + + +class EngineContractMixin: + """Base contract tests for Engine implementations. + + Subclasses must define a module-level ``engine`` fixture returning + a fully initialized Engine instance. The tests below will use it + via pytest's standard fixture resolution. + """ + + def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest: + return SynthesisRequest( + text=text, + voice=VoiceSelection(source="builtin", key=voice or "default"), + parameters=ParameterValues(values={}), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + # ── Engine protocol ────────────────────────────────────── + + def test_engine_satisfies_protocol(self, engine: Engine) -> None: + assert isinstance(engine, Engine) + + def test_create_session_returns_session(self, engine: Engine) -> None: + session = engine.createSession() + assert isinstance(session, EngineSession) + session.dispose() + + def test_create_session_returns_new_instances(self, engine: Engine) -> None: + s1 = engine.createSession() + s2 = engine.createSession() + assert s1 is not s2 + s1.dispose() + s2.dispose() + + def test_dispose_is_idempotent(self, engine: Engine) -> None: + engine.dispose() + engine.dispose() + + def test_create_session_after_dispose_raises(self, engine: Engine) -> None: + engine.dispose() + with pytest.raises(EngineError): + engine.createSession() + + # ── Session protocol ───────────────────────────────────── + + def test_session_satisfies_protocol(self, engine: Engine) -> None: + session = engine.createSession() + assert isinstance(session, EngineSession) + session.dispose() + engine.dispose() + + def test_session_synthesize_returns_audio(self, engine: Engine) -> None: + session = engine.createSession() + result = session.synthesize(self._req()) + assert isinstance(result, SynthesizedAudio) + assert isinstance(result.data, bytes) + assert len(result.data) > 0 + session.dispose() + engine.dispose() + + def test_session_dispose_is_idempotent(self, engine: Engine) -> None: + session = engine.createSession() + session.dispose() + session.dispose() + engine.dispose() + + def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None: + session = engine.createSession() + session.dispose() + with pytest.raises(EngineError): + session.synthesize(self._req()) + engine.dispose() + + def test_session_multiple_synthesize(self, engine: Engine) -> None: + session = engine.createSession() + r1 = session.synthesize(self._req()) + r2 = session.synthesize(self._req()) + assert isinstance(r1.data, bytes) + assert isinstance(r2.data, bytes) + session.dispose() + engine.dispose() + + # ── Lifecycle ──────────────────────────────────────────── + + def test_full_lifecycle(self, engine: Engine) -> None: + s1 = engine.createSession() + s2 = engine.createSession() + s1.synthesize(self._req()) + s2.synthesize(self._req()) + s1.dispose() + s2.dispose() + engine.dispose() diff --git a/tests/test_kokoro_plugin.py b/tests/test_kokoro_plugin.py index 87d6a6b..3d617f0 100644 --- a/tests/test_kokoro_plugin.py +++ b/tests/test_kokoro_plugin.py @@ -4,7 +4,7 @@ These tests verify that the Kokoro plugin: - Loads correctly through the Plugin Loader - Has a valid manifest - Creates a valid Engine -- Satisfies the Engine/EngineSession contract +- Satisfies the Engine/EngineSession contract (via EngineContractMixin) - Implements VoiceLister capability """ @@ -28,18 +28,14 @@ from abogen.tts_plugin.types import ( VoiceSelection, ) +from tests.contracts.engine_contract import EngineContractMixin + # ────────────────────────────────────────────────────────────── -# Path fixtures +# Helpers # ────────────────────────────────────────────────────────────── -@pytest.fixture -def kokoro_plugin_dir() -> Path: - return Path(__file__).parent.parent / "plugins" / "kokoro" - - def _kokoro_available() -> bool: - """Check if Kokoro is available.""" try: from kokoro import KPipeline # type: ignore[import-not-found] return True @@ -47,6 +43,32 @@ def _kokoro_available() -> bool: return False +def _make_mock_engine() -> Any: + from plugins.kokoro.engine import KokoroEngine + + class MockPipeline: + def __call__(self, text, voice, speed, split_pattern=None): + class MockSegment: + def __init__(self): + self.audio = MockAudio() + class MockAudio: + def numpy(self): + import numpy as np + return np.zeros(24000, dtype="float32") + return [MockSegment()] + + return KokoroEngine(MockPipeline(), "a") + + +# ────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────── + +@pytest.fixture +def kokoro_plugin_dir() -> Path: + return Path(__file__).parent.parent / "plugins" / "kokoro" + + @pytest.fixture def host_context(tmp_path: Path) -> HostContext: class FakeHttpClient: @@ -62,12 +84,16 @@ def host_context(tmp_path: Path) -> HostContext: ) +@pytest.fixture +def engine() -> Engine: + return _make_mock_engine() + + # ────────────────────────────────────────────────────────────── # Plugin Loading Tests # ────────────────────────────────────────────────────────────── class TestKokoroPluginLoading: - """Test that Kokoro plugin loads correctly through the Loader.""" def test_plugin_loads_successfully(self, kokoro_plugin_dir: Path) -> None: result = load_plugin_from_dir(kokoro_plugin_dir) @@ -93,8 +119,7 @@ class TestKokoroPluginLoading: def test_plugin_manifest_capabilities(self, kokoro_plugin_dir: Path) -> None: result = load_plugin_from_dir(kokoro_plugin_dir) assert result.success is True - manifest = result.manifest - assert "voice_list" in manifest.capabilities + assert "voice_list" in result.manifest.capabilities def test_plugin_manifest_engine(self, kokoro_plugin_dir: Path) -> None: result = load_plugin_from_dir(kokoro_plugin_dir) @@ -106,115 +131,38 @@ class TestKokoroPluginLoading: # ────────────────────────────────────────────────────────────── -# Engine Creation Tests +# Engine Creation (real backend, skipped if not installed) # ────────────────────────────────────────────────────────────── class TestKokoroEngineCreation: - """Test that Kokoro Engine can be created.""" - @pytest.mark.skipif( - not _kokoro_available(), - reason="Kokoro not installed" - ) + @pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed") def test_create_engine(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None: result = load_plugin_from_dir(kokoro_plugin_dir) assert result.success is True - engine = result.create_engine(host_context, None, EngineConfig()) assert isinstance(engine, Engine) engine.dispose() - @pytest.mark.skipif( - not _kokoro_available(), - reason="Kokoro not installed" - ) + @pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed") def test_engine_satisfies_protocol(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None: result = load_plugin_from_dir(kokoro_plugin_dir) assert result.success is True - engine = result.create_engine(host_context, None, EngineConfig()) assert isinstance(engine, Engine) engine.dispose() # ────────────────────────────────────────────────────────────── -# Engine Protocol Tests (using mock pipeline) +# Engine / Session Contract (inherited from base) # ────────────────────────────────────────────────────────────── -class TestKokoroEngineProtocol: - """Test Kokoro Engine protocol compliance using mock pipeline.""" +class TestKokoroEngineContract(EngineContractMixin): + """Every test from EngineContractMixin runs against KokoroEngine.""" - def _create_engine_with_mock(self) -> Any: - """Create engine with mock pipeline for testing.""" - from plugins.kokoro.engine import KokoroEngine - - class MockPipeline: - def __call__(self, text, voice, speed, split_pattern=None): - class MockSegment: - def __init__(self): - self.audio = MockAudio() - class MockAudio: - def numpy(self): - import numpy as np - return np.zeros(24000, dtype="float32") - return [MockSegment()] - - return KokoroEngine(MockPipeline(), "a") - - def test_create_session(self) -> None: - engine = self._create_engine_with_mock() - session = engine.createSession() - assert isinstance(session, EngineSession) - engine.dispose() - - def test_dispose_idempotent(self) -> None: - engine = self._create_engine_with_mock() - engine.dispose() - engine.dispose() # Should not raise - - def test_create_session_after_dispose_raises(self) -> None: - from abogen.tts_plugin.errors import EngineError - engine = self._create_engine_with_mock() - engine.dispose() - with pytest.raises(EngineError): - engine.createSession() - - def test_session_synthesize(self) -> None: - engine = self._create_engine_with_mock() - session = engine.createSession() - request = SynthesisRequest( - text="Hello", - voice=VoiceSelection(source="builtin", key="af_nova"), - parameters=ParameterValues(values={}), - format=AudioFormat(mime="audio/wav", extension="wav"), - ) - result = session.synthesize(request) - assert result.data is not None - assert len(result.data) > 0 - session.dispose() - engine.dispose() - - def test_session_dispose_idempotent(self) -> None: - engine = self._create_engine_with_mock() - session = engine.createSession() - session.dispose() - session.dispose() # Should not raise - engine.dispose() - - def test_session_synthesize_after_dispose_raises(self) -> None: - from abogen.tts_plugin.errors import EngineError - engine = self._create_engine_with_mock() - session = engine.createSession() - session.dispose() - request = SynthesisRequest( - text="Hello", - voice=VoiceSelection(source="builtin", key="af_nova"), - parameters=ParameterValues(values={}), - format=AudioFormat(mime="audio/wav", extension="wav"), - ) - with pytest.raises(EngineError): - session.synthesize(request) - engine.dispose() + @pytest.fixture + def default_voice(self) -> str: + return "af_nova" # ────────────────────────────────────────────────────────────── @@ -222,10 +170,9 @@ class TestKokoroEngineProtocol: # ────────────────────────────────────────────────────────────── class TestKokoroVoiceLister: - """Test Kokoro VoiceLister capability.""" def test_list_voices(self) -> None: - engine = TestKokoroEngineProtocol._create_engine_with_mock(TestKokoroEngineProtocol) + engine = _make_mock_engine() voices = engine.listVoices("builtin") assert len(voices) > 0 assert all(hasattr(v, "id") for v in voices) @@ -233,7 +180,7 @@ class TestKokoroVoiceLister: engine.dispose() def test_voices_have_tags(self) -> None: - engine = TestKokoroEngineProtocol._create_engine_with_mock(TestKokoroEngineProtocol) + engine = _make_mock_engine() voices = engine.listVoices("builtin") for voice in voices: assert isinstance(voice.tags, tuple) diff --git a/tests/test_supertonic_plugin.py b/tests/test_supertonic_plugin.py index 6696ba8..7a6cf3e 100644 --- a/tests/test_supertonic_plugin.py +++ b/tests/test_supertonic_plugin.py @@ -4,7 +4,7 @@ These tests verify that the SuperTonic plugin: - Loads correctly through the Plugin Loader - Has a valid manifest - Creates a valid Engine -- Satisfies the Engine/EngineSession contract +- Satisfies the Engine/EngineSession contract (via EngineContractMixin) - Implements VoiceLister capability """ @@ -25,22 +25,17 @@ from abogen.tts_plugin.types import ( EngineConfig, ParameterValues, SynthesisRequest, - SynthesizedAudio, VoiceSelection, ) +from tests.contracts.engine_contract import EngineContractMixin + # ────────────────────────────────────────────────────────────── -# Path fixtures +# Helpers # ────────────────────────────────────────────────────────────── -@pytest.fixture -def supertonic_plugin_dir() -> Path: - return Path(__file__).parent.parent / "plugins" / "supertonic" - - def _supertonic_available() -> bool: - """Check if SuperTonic is available.""" try: from supertonic import TTS # type: ignore[import-not-found] return True @@ -48,6 +43,32 @@ def _supertonic_available() -> bool: return False +def _make_mock_engine() -> Any: + from plugins.supertonic.engine import SuperTonicEngine + + class MockSegment: + def __init__(self): + import numpy as np + self.audio = np.zeros(24000, dtype="float32") + + class MockPipeline: + sample_rate = 24000 + + def __call__(self, text, voice, speed, split_pattern=None, total_steps=None): + return [MockSegment()] + + return SuperTonicEngine(MockPipeline()) + + +# ────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────── + +@pytest.fixture +def supertonic_plugin_dir() -> Path: + return Path(__file__).parent.parent / "plugins" / "supertonic" + + @pytest.fixture def host_context(tmp_path: Path) -> HostContext: class FakeHttpClient: @@ -63,12 +84,16 @@ def host_context(tmp_path: Path) -> HostContext: ) +@pytest.fixture +def engine() -> Engine: + return _make_mock_engine() + + # ────────────────────────────────────────────────────────────── # Plugin Loading Tests # ────────────────────────────────────────────────────────────── class TestSuperTonicPluginLoading: - """Test that SuperTonic plugin loads correctly through the Loader.""" def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None: result = load_plugin_from_dir(supertonic_plugin_dir) @@ -94,8 +119,7 @@ class TestSuperTonicPluginLoading: def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None: result = load_plugin_from_dir(supertonic_plugin_dir) assert result.success is True - manifest = result.manifest - assert "voice_list" in manifest.capabilities + assert "voice_list" in result.manifest.capabilities def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None: result = load_plugin_from_dir(supertonic_plugin_dir) @@ -107,164 +131,38 @@ class TestSuperTonicPluginLoading: # ────────────────────────────────────────────────────────────── -# Engine Creation Tests +# Engine Creation (real backend, skipped if not installed) # ────────────────────────────────────────────────────────────── class TestSuperTonicEngineCreation: - """Test that SuperTonic Engine can be created.""" - @pytest.mark.skipif( - not _supertonic_available(), - reason="SuperTonic not installed" - ) + @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed") def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None: result = load_plugin_from_dir(supertonic_plugin_dir) assert result.success is True - engine = result.create_engine(host_context, None, EngineConfig()) assert isinstance(engine, Engine) engine.dispose() - @pytest.mark.skipif( - not _supertonic_available(), - reason="SuperTonic not installed" - ) + @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed") def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None: result = load_plugin_from_dir(supertonic_plugin_dir) assert result.success is True - engine = result.create_engine(host_context, None, EngineConfig()) assert isinstance(engine, Engine) engine.dispose() # ────────────────────────────────────────────────────────────── -# Engine Protocol Tests (using mock pipeline) +# Engine / Session Contract (inherited from base) # ────────────────────────────────────────────────────────────── -class TestSuperTonicEngineProtocol: - """Test SuperTonic Engine protocol compliance using mock pipeline.""" +class TestSuperTonicEngineContract(EngineContractMixin): + """Every test from EngineContractMixin runs against SuperTonicEngine.""" - def _create_engine_with_mock(self) -> Any: - """Create engine with mock pipeline for testing.""" - from plugins.supertonic.engine import SuperTonicEngine - - class MockSegment: - def __init__(self): - import numpy as np - self.audio = np.zeros(24000, dtype="float32") - - class MockPipeline: - sample_rate = 24000 - - def __call__(self, text, voice, speed, split_pattern=None, total_steps=None): - return [MockSegment()] - - return SuperTonicEngine(MockPipeline()) - - def test_create_session(self) -> None: - engine = self._create_engine_with_mock() - session = engine.createSession() - assert isinstance(session, EngineSession) - engine.dispose() - - def test_dispose_idempotent(self) -> None: - engine = self._create_engine_with_mock() - engine.dispose() - engine.dispose() # Should not raise - - def test_create_session_after_dispose_raises(self) -> None: - from abogen.tts_plugin.errors import EngineError - engine = self._create_engine_with_mock() - engine.dispose() - with pytest.raises(EngineError): - engine.createSession() - - def test_session_synthesize(self) -> None: - engine = self._create_engine_with_mock() - session = engine.createSession() - request = SynthesisRequest( - text="Hello", - voice=VoiceSelection(source="builtin", key="M1"), - parameters=ParameterValues(values={}), - format=AudioFormat(mime="audio/wav", extension="wav"), - ) - result = session.synthesize(request) - assert result.data is not None - assert len(result.data) > 0 - session.dispose() - engine.dispose() - - def test_session_dispose_idempotent(self) -> None: - engine = self._create_engine_with_mock() - session = engine.createSession() - session.dispose() - session.dispose() # Should not raise - engine.dispose() - - def test_session_synthesize_after_dispose_raises(self) -> None: - from abogen.tts_plugin.errors import EngineError - engine = self._create_engine_with_mock() - session = engine.createSession() - session.dispose() - request = SynthesisRequest( - text="Hello", - voice=VoiceSelection(source="builtin", key="M1"), - parameters=ParameterValues(values={}), - format=AudioFormat(mime="audio/wav", extension="wav"), - ) - with pytest.raises(EngineError): - session.synthesize(request) - engine.dispose() - - def test_session_multiple_synthesize(self) -> None: - engine = self._create_engine_with_mock() - session = engine.createSession() - request = SynthesisRequest( - text="Hello", - voice=VoiceSelection(source="builtin", key="M1"), - parameters=ParameterValues(values={}), - format=AudioFormat(mime="audio/wav", extension="wav"), - ) - result1 = session.synthesize(request) - result2 = session.synthesize(request) - assert isinstance(result1.data, bytes) - assert isinstance(result2.data, bytes) - session.dispose() - engine.dispose() - - def test_full_lifecycle(self) -> None: - engine = self._create_engine_with_mock() - - # Create sessions - session1 = engine.createSession() - session2 = engine.createSession() - - # Use sessions - request = SynthesisRequest( - text="Hello", - voice=VoiceSelection(source="builtin", key="M1"), - parameters=ParameterValues(values={}), - format=AudioFormat(mime="audio/wav", extension="wav"), - ) - assert isinstance(session1.synthesize(request), SynthesizedAudio) - assert isinstance(session2.synthesize(request), SynthesizedAudio) - - # Dispose sessions - session1.dispose() - session2.dispose() - - # Dispose engine - engine.dispose() - - def test_engine_returns_new_session_instances(self) -> None: - engine = self._create_engine_with_mock() - session1 = engine.createSession() - session2 = engine.createSession() - assert session1 is not session2 - session1.dispose() - session2.dispose() - engine.dispose() + @pytest.fixture + def default_voice(self) -> str: + return "M1" # ────────────────────────────────────────────────────────────── @@ -272,71 +170,52 @@ class TestSuperTonicEngineProtocol: # ────────────────────────────────────────────────────────────── class TestSuperTonicVoiceLister: - """Test SuperTonic VoiceLister capability.""" def test_list_voices(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) + engine = _make_mock_engine() voices = engine.listVoices("builtin") - assert len(voices) > 0 + assert len(voices) == 10 assert all(hasattr(v, "id") for v in voices) assert all(hasattr(v, "name") for v in voices) engine.dispose() def test_voices_have_tags(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) - voices = engine.listVoices("builtin") - for voice in voices: + engine = _make_mock_engine() + for voice in engine.listVoices("builtin"): assert isinstance(voice.tags, tuple) assert len(voice.tags) > 0 engine.dispose() - def test_voices_are_correct_count(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) - voices = engine.listVoices("builtin") - assert len(voices) == 10 # M1-M5, F1-F5 - engine.dispose() - - def test_voice_ids_match_expected(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) - voices = engine.listVoices("builtin") - voice_ids = [v.id for v in voices] - expected = ["M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"] - assert voice_ids == expected - engine.dispose() - def test_male_voices_have_male_tag(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) - voices = engine.listVoices("builtin") - male_voices = [v for v in voices if v.id.startswith("M")] - for voice in male_voices: - assert "male" in voice.tags + engine = _make_mock_engine() + for v in engine.listVoices("builtin"): + if v.id.startswith("M"): + assert "male" in v.tags engine.dispose() def test_female_voices_have_female_tag(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) - voices = engine.listVoices("builtin") - female_voices = [v for v in voices if v.id.startswith("F")] - for voice in female_voices: - assert "female" in voice.tags + engine = _make_mock_engine() + for v in engine.listVoices("builtin"): + if v.id.startswith("F"): + assert "female" in v.tags engine.dispose() def test_list_voices_after_dispose_raises(self) -> None: from abogen.tts_plugin.errors import EngineError - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) + engine = _make_mock_engine() engine.dispose() with pytest.raises(EngineError): engine.listVoices("builtin") # ────────────────────────────────────────────────────────────── -# Parameter Tests +# SuperTonic-specific parameter tests # ────────────────────────────────────────────────────────────── class TestSuperTonicParameters: - """Test SuperTonic parameter handling.""" def test_speed_parameter(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) + engine = _make_mock_engine() session = engine.createSession() request = SynthesisRequest( text="Hello", @@ -350,7 +229,7 @@ class TestSuperTonicParameters: engine.dispose() def test_total_steps_parameter(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) + engine = _make_mock_engine() session = engine.createSession() request = SynthesisRequest( text="Hello", @@ -364,7 +243,7 @@ class TestSuperTonicParameters: engine.dispose() def test_default_parameters(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) + engine = _make_mock_engine() session = engine.createSession() request = SynthesisRequest( text="Hello", @@ -376,40 +255,3 @@ class TestSuperTonicParameters: assert isinstance(result.data, bytes) session.dispose() engine.dispose() - - -# ────────────────────────────────────────────────────────────── -# Error Handling Tests -# ────────────────────────────────────────────────────────────── - -class TestSuperTonicErrorHandling: - """Test SuperTonic error handling.""" - - def test_synthesize_empty_text(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) - session = engine.createSession() - request = SynthesisRequest( - text="", - voice=VoiceSelection(source="builtin", key="M1"), - parameters=ParameterValues(values={}), - format=AudioFormat(mime="audio/wav", extension="wav"), - ) - result = session.synthesize(request) - # Empty text should return empty audio - assert isinstance(result.data, bytes) - session.dispose() - engine.dispose() - - def test_synthesize_whitespace_text(self) -> None: - engine = TestSuperTonicEngineProtocol._create_engine_with_mock(TestSuperTonicEngineProtocol) - session = engine.createSession() - request = SynthesisRequest( - text=" ", - voice=VoiceSelection(source="builtin", key="M1"), - parameters=ParameterValues(values={}), - format=AudioFormat(mime="audio/wav", extension="wav"), - ) - result = session.synthesize(request) - assert isinstance(result.data, bytes) - session.dispose() - engine.dispose()