feat(tts-plugin): complete Plugin Architecture refactor

- Normalize Pipeline public API: create_pipeline(plugin_id, *, lang_code, device)
- EngineConfig: add lang_code field per Architecture Amendment #1
- Kokoro plugin reads config.lang_code (fixes functional regression)
- Static voice catalog in PluginManifest.voices (None = dynamic/VoiceLister)
- get_voices() reads from manifest without creating Engine
- Remove dead kwargs (sample_rate, auto_download, total_steps) from SuperTonic
- Clean up unused imports and dead code in engine implementations
- Fix test expectations for VoiceLister (mock overrides)
- Add clear_preview_pipelines() for resource management
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:20 +03:00
parent 735098d7cd
commit c094b94704
49 changed files with 18052 additions and 17985 deletions
+135 -135
View File
@@ -1,135 +1,135 @@
"""Contract tests for EngineSession protocol.
These tests verify that EngineSession implementations satisfy the architectural requirements:
- synthesize() returns SynthesizedAudio
- dispose() is idempotent
- After dispose(), synthesize() raises EngineError
- Session remains usable after synthesize() failure
"""
import pytest
from abogen.tts_plugin.engine import EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
from .conftest import FakeEngineSession
class TestEngineSessionProtocolContract:
"""Contract tests for the EngineSession protocol itself."""
def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession()
assert isinstance(session, EngineSession)
class TestSessionSynthesizeContract:
"""Contract tests for EngineSession.synthesize()."""
def test_synthesize_returns_synthesized_audio(self) -> None:
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio)
def test_synthesize_returns_valid_audio_data(self) -> None:
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result.data, bytes)
assert len(result.data) > 0
assert isinstance(result.format, AudioFormat)
assert isinstance(result.duration, Duration)
class TestSessionDisposeContract:
"""Contract tests for EngineSession.dispose()."""
def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent."""
session = FakeEngineSession()
session.dispose()
session.dispose() # Should not raise
def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions."""
session = FakeEngineSession()
session.dispose() # Should not raise
class TestSessionAfterDisposeContract:
"""Contract tests for behavior after dispose()."""
def test_synthesize_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
session = FakeEngineSession()
session.dispose()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError):
session.synthesize(request)
class TestSessionLifecycleContract:
"""Contract tests for EngineSession lifecycle."""
def test_full_lifecycle(self) -> None:
"""Test complete session lifecycle: create -> synthesize -> dispose."""
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# Synthesize
result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio)
# Dispose
session.dispose()
def test_multiple_synthesize_before_dispose(self) -> None:
"""Architecture spec: Session remains usable after synthesize() failure."""
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# Multiple synthesize calls
result1 = session.synthesize(request)
result2 = session.synthesize(request)
assert isinstance(result1, SynthesizedAudio)
assert isinstance(result2, SynthesizedAudio)
# Dispose
session.dispose()
"""Contract tests for EngineSession protocol.
These tests verify that EngineSession implementations satisfy the architectural requirements:
- synthesize() returns SynthesizedAudio
- dispose() is idempotent
- After dispose(), synthesize() raises EngineError
- Session remains usable after synthesize() failure
"""
import pytest
from abogen.tts_plugin.engine import EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
from .conftest import FakeEngineSession
class TestEngineSessionProtocolContract:
"""Contract tests for the EngineSession protocol itself."""
def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession()
assert isinstance(session, EngineSession)
class TestSessionSynthesizeContract:
"""Contract tests for EngineSession.synthesize()."""
def test_synthesize_returns_synthesized_audio(self) -> None:
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio)
def test_synthesize_returns_valid_audio_data(self) -> None:
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result.data, bytes)
assert len(result.data) > 0
assert isinstance(result.format, AudioFormat)
assert isinstance(result.duration, Duration)
class TestSessionDisposeContract:
"""Contract tests for EngineSession.dispose()."""
def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent."""
session = FakeEngineSession()
session.dispose()
session.dispose() # Should not raise
def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions."""
session = FakeEngineSession()
session.dispose() # Should not raise
class TestSessionAfterDisposeContract:
"""Contract tests for behavior after dispose()."""
def test_synthesize_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
session = FakeEngineSession()
session.dispose()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError):
session.synthesize(request)
class TestSessionLifecycleContract:
"""Contract tests for EngineSession lifecycle."""
def test_full_lifecycle(self) -> None:
"""Test complete session lifecycle: create -> synthesize -> dispose."""
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# Synthesize
result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio)
# Dispose
session.dispose()
def test_multiple_synthesize_before_dispose(self) -> None:
"""Architecture spec: Session remains usable after synthesize() failure."""
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# Multiple synthesize calls
result1 = session.synthesize(request)
result2 = session.synthesize(request)
assert isinstance(result1, SynthesizedAudio)
assert isinstance(result2, SynthesizedAudio)
# Dispose
session.dispose()