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
+5 -5
View File
@@ -1,5 +1,5 @@
"""Contract tests for the TTS Plugin API.
This package contains reusable contract tests that any TTS plugin implementation
must satisfy. Tests use only the public API and are engine-agnostic.
"""
"""Contract tests for the TTS Plugin API.
This package contains reusable contract tests that any TTS plugin implementation
must satisfy. Tests use only the public API and are engine-agnostic.
"""
+231 -231
View File
@@ -1,231 +1,231 @@
"""Shared fixtures and stubs for contract tests.
This module provides minimal stub implementations that satisfy the public API
for testing purposes. These stubs do NOT contain real business logic.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Iterator
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class FakeHttpClient:
"""Stub HTTP client that satisfies the HttpClient protocol."""
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
class FakeEngineSession:
"""Stub EngineSession for testing protocol compliance."""
def __init__(self) -> None:
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def dispose(self) -> None:
self._disposed = True
class FakeStreamingSession:
"""Stub EngineSession with StreamingSynthesizer capability."""
def __init__(self) -> None:
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
for i in range(3):
yield b"\x00" * 50
def dispose(self) -> None:
self._disposed = True
class FakeCancelableSession:
"""Stub EngineSession with CancelableSession capability."""
def __init__(self) -> None:
self._disposed = False
self._cancelled = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
if self._cancelled:
from abogen.tts_plugin.errors import CancelledError
raise CancelledError("Cancelled")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def cancel(self) -> None:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
self._cancelled = True
def dispose(self) -> None:
self._disposed = True
class FakeEngine:
"""Stub Engine for testing protocol compliance."""
def __init__(self, session_class: type = FakeEngineSession) -> None:
self._disposed = False
self._session_class = session_class
def createSession(self) -> EngineSession:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed")
return self._session_class()
def dispose(self) -> None:
self._disposed = True
class FakeVoiceListerEngine:
"""Stub Engine that also implements VoiceLister."""
def __init__(self) -> None:
self._disposed = False
def createSession(self) -> EngineSession:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed")
return FakeEngineSession()
def listVoices(self, sourceId: str) -> list:
from abogen.tts_plugin.manifest import VoiceManifest
return [
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
]
def dispose(self) -> None:
self._disposed = True
class FakePreviewEngine:
"""Stub Engine that also implements PreviewGenerator."""
def __init__(self) -> None:
self._disposed = False
def createSession(self) -> EngineSession:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed")
return FakeEngineSession()
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
return SynthesizedAudio(
data=b"\x00" * 50,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.5),
)
def dispose(self) -> None:
self._disposed = True
@pytest.fixture
def fake_http_client() -> FakeHttpClient:
return FakeHttpClient()
@pytest.fixture
def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
return HostContext(
config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=fake_http_client,
)
@pytest.fixture
def fake_engine() -> FakeEngine:
return FakeEngine()
@pytest.fixture
def fake_session() -> FakeEngineSession:
return FakeEngineSession()
@pytest.fixture
def default_voice() -> VoiceSelection:
return VoiceSelection(source="builtin", key="af_nova")
@pytest.fixture
def default_format() -> AudioFormat:
return AudioFormat(mime="audio/wav", extension="wav")
@pytest.fixture
def default_request(
default_voice: VoiceSelection, default_format: AudioFormat
) -> SynthesisRequest:
return SynthesisRequest(
text="Hello, world!",
voice=default_voice,
parameters=ParameterValues(values={}),
format=default_format,
)
"""Shared fixtures and stubs for contract tests.
This module provides minimal stub implementations that satisfy the public API
for testing purposes. These stubs do NOT contain real business logic.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Iterator
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class FakeHttpClient:
"""Stub HTTP client that satisfies the HttpClient protocol."""
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
class FakeEngineSession:
"""Stub EngineSession for testing protocol compliance."""
def __init__(self) -> None:
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def dispose(self) -> None:
self._disposed = True
class FakeStreamingSession:
"""Stub EngineSession with StreamingSynthesizer capability."""
def __init__(self) -> None:
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
for i in range(3):
yield b"\x00" * 50
def dispose(self) -> None:
self._disposed = True
class FakeCancelableSession:
"""Stub EngineSession with CancelableSession capability."""
def __init__(self) -> None:
self._disposed = False
self._cancelled = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
if self._cancelled:
from abogen.tts_plugin.errors import CancelledError
raise CancelledError("Cancelled")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def cancel(self) -> None:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
self._cancelled = True
def dispose(self) -> None:
self._disposed = True
class FakeEngine:
"""Stub Engine for testing protocol compliance."""
def __init__(self, session_class: type = FakeEngineSession) -> None:
self._disposed = False
self._session_class = session_class
def createSession(self) -> EngineSession:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed")
return self._session_class()
def dispose(self) -> None:
self._disposed = True
class FakeVoiceListerEngine:
"""Stub Engine that also implements VoiceLister."""
def __init__(self) -> None:
self._disposed = False
def createSession(self) -> EngineSession:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed")
return FakeEngineSession()
def listVoices(self, sourceId: str) -> list:
from abogen.tts_plugin.manifest import VoiceManifest
return [
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
]
def dispose(self) -> None:
self._disposed = True
class FakePreviewEngine:
"""Stub Engine that also implements PreviewGenerator."""
def __init__(self) -> None:
self._disposed = False
def createSession(self) -> EngineSession:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed")
return FakeEngineSession()
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
return SynthesizedAudio(
data=b"\x00" * 50,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.5),
)
def dispose(self) -> None:
self._disposed = True
@pytest.fixture
def fake_http_client() -> FakeHttpClient:
return FakeHttpClient()
@pytest.fixture
def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
return HostContext(
config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=fake_http_client,
)
@pytest.fixture
def fake_engine() -> FakeEngine:
return FakeEngine()
@pytest.fixture
def fake_session() -> FakeEngineSession:
return FakeEngineSession()
@pytest.fixture
def default_voice() -> VoiceSelection:
return VoiceSelection(source="builtin", key="af_nova")
@pytest.fixture
def default_format() -> AudioFormat:
return AudioFormat(mime="audio/wav", extension="wav")
@pytest.fixture
def default_request(
default_voice: VoiceSelection, default_format: AudioFormat
) -> SynthesisRequest:
return SynthesisRequest(
text="Hello, world!",
voice=default_voice,
parameters=ParameterValues(values={}),
format=default_format,
)
+120 -120
View File
@@ -1,120 +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()
"""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()
+183 -183
View File
@@ -1,183 +1,183 @@
"""Contract tests for capability interfaces.
These tests verify that capability interfaces satisfy the architectural requirements:
- VoiceLister: lists voices for a source
- PreviewGenerator: generates preview audio
- StreamingSynthesizer: yields audio chunks
- CancelableSession: cancels in-progress synthesis
"""
import pytest
from abogen.tts_plugin.capabilities import (
CancelableSession,
PreviewGenerator,
StreamingSynthesizer,
VoiceLister,
)
from abogen.tts_plugin.errors import CancelledError, EngineError
from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
class TestVoiceListerProtocolContract:
"""Contract tests for VoiceLister protocol."""
def test_voice_lister_is_protocol(self) -> None:
assert hasattr(VoiceLister, "__protocol_attrs__")
def test_voice_lister_satisfied_by_engine(self) -> None:
engine = FakeVoiceListerEngine()
assert isinstance(engine, VoiceLister)
def test_list_voices_returns_list(self) -> None:
engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin")
assert isinstance(voices, list)
def test_list_voices_returns_voice_manifests(self) -> None:
engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin")
for voice in voices:
assert isinstance(voice, VoiceManifest)
def test_list_voices_has_required_fields(self) -> None:
engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin")
for voice in voices:
assert hasattr(voice, "id")
assert hasattr(voice, "name")
assert hasattr(voice, "tags")
class TestPreviewGeneratorProtocolContract:
"""Contract tests for PreviewGenerator protocol."""
def test_preview_generator_is_protocol(self) -> None:
assert hasattr(PreviewGenerator, "__protocol_attrs__")
def test_preview_generator_satisfied_by_engine(self) -> None:
from .conftest import FakePreviewEngine
engine = FakePreviewEngine()
assert isinstance(engine, PreviewGenerator)
def test_generate_preview_returns_synthesized_audio(self) -> None:
from .conftest import FakePreviewEngine
engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello")
assert isinstance(result, SynthesizedAudio)
def test_generate_preview_has_valid_data(self) -> None:
from .conftest import FakePreviewEngine
engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello")
assert isinstance(result.data, bytes)
assert len(result.data) > 0
class TestStreamingSynthesizerProtocolContract:
"""Contract tests for StreamingSynthesizer protocol."""
def test_streaming_synthesizer_is_protocol(self) -> None:
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
def test_streaming_session_satisfies_protocol(self) -> None:
session = FakeStreamingSession()
assert isinstance(session, StreamingSynthesizer)
def test_synthesize_stream_yields_bytes(self) -> None:
session = FakeStreamingSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
chunks = list(session.synthesizeStream(request))
assert len(chunks) > 0
for chunk in chunks:
assert isinstance(chunk, bytes)
def test_streaming_iterator_exhaustion(self) -> None:
"""Architecture spec: Iterator exhaustion = synthesis complete."""
session = FakeStreamingSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
chunks = list(session.synthesizeStream(request))
assert len(chunks) == 3
def test_streaming_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), methods raise EngineError."""
session = FakeStreamingSession()
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):
list(session.synthesizeStream(request))
class TestCancelableSessionProtocolContract:
"""Contract tests for CancelableSession protocol."""
def test_cancelable_session_is_protocol(self) -> None:
assert hasattr(CancelableSession, "__protocol_attrs__")
def test_cancelable_session_satisfies_protocol(self) -> None:
session = FakeCancelableSession()
assert isinstance(session, CancelableSession)
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
session = FakeCancelableSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# Cancel
session.cancel()
# synthesize should raise CancelledError
with pytest.raises(CancelledError):
session.synthesize(request)
def test_cancel_after_dispose_raises(self) -> None:
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
session = FakeCancelableSession()
session.dispose()
with pytest.raises(EngineError):
session.cancel()
def test_session_usable_after_cancel(self) -> None:
"""Architecture spec: EngineSession remains usable after cancellation."""
session = FakeCancelableSession()
# Cancel
session.cancel()
# Dispose and create new session for synthesis
session.dispose()
"""Contract tests for capability interfaces.
These tests verify that capability interfaces satisfy the architectural requirements:
- VoiceLister: lists voices for a source
- PreviewGenerator: generates preview audio
- StreamingSynthesizer: yields audio chunks
- CancelableSession: cancels in-progress synthesis
"""
import pytest
from abogen.tts_plugin.capabilities import (
CancelableSession,
PreviewGenerator,
StreamingSynthesizer,
VoiceLister,
)
from abogen.tts_plugin.errors import CancelledError, EngineError
from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
class TestVoiceListerProtocolContract:
"""Contract tests for VoiceLister protocol."""
def test_voice_lister_is_protocol(self) -> None:
assert hasattr(VoiceLister, "__protocol_attrs__")
def test_voice_lister_satisfied_by_engine(self) -> None:
engine = FakeVoiceListerEngine()
assert isinstance(engine, VoiceLister)
def test_list_voices_returns_list(self) -> None:
engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin")
assert isinstance(voices, list)
def test_list_voices_returns_voice_manifests(self) -> None:
engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin")
for voice in voices:
assert isinstance(voice, VoiceManifest)
def test_list_voices_has_required_fields(self) -> None:
engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin")
for voice in voices:
assert hasattr(voice, "id")
assert hasattr(voice, "name")
assert hasattr(voice, "tags")
class TestPreviewGeneratorProtocolContract:
"""Contract tests for PreviewGenerator protocol."""
def test_preview_generator_is_protocol(self) -> None:
assert hasattr(PreviewGenerator, "__protocol_attrs__")
def test_preview_generator_satisfied_by_engine(self) -> None:
from .conftest import FakePreviewEngine
engine = FakePreviewEngine()
assert isinstance(engine, PreviewGenerator)
def test_generate_preview_returns_synthesized_audio(self) -> None:
from .conftest import FakePreviewEngine
engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello")
assert isinstance(result, SynthesizedAudio)
def test_generate_preview_has_valid_data(self) -> None:
from .conftest import FakePreviewEngine
engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello")
assert isinstance(result.data, bytes)
assert len(result.data) > 0
class TestStreamingSynthesizerProtocolContract:
"""Contract tests for StreamingSynthesizer protocol."""
def test_streaming_synthesizer_is_protocol(self) -> None:
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
def test_streaming_session_satisfies_protocol(self) -> None:
session = FakeStreamingSession()
assert isinstance(session, StreamingSynthesizer)
def test_synthesize_stream_yields_bytes(self) -> None:
session = FakeStreamingSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
chunks = list(session.synthesizeStream(request))
assert len(chunks) > 0
for chunk in chunks:
assert isinstance(chunk, bytes)
def test_streaming_iterator_exhaustion(self) -> None:
"""Architecture spec: Iterator exhaustion = synthesis complete."""
session = FakeStreamingSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
chunks = list(session.synthesizeStream(request))
assert len(chunks) == 3
def test_streaming_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), methods raise EngineError."""
session = FakeStreamingSession()
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):
list(session.synthesizeStream(request))
class TestCancelableSessionProtocolContract:
"""Contract tests for CancelableSession protocol."""
def test_cancelable_session_is_protocol(self) -> None:
assert hasattr(CancelableSession, "__protocol_attrs__")
def test_cancelable_session_satisfies_protocol(self) -> None:
session = FakeCancelableSession()
assert isinstance(session, CancelableSession)
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
session = FakeCancelableSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# Cancel
session.cancel()
# synthesize should raise CancelledError
with pytest.raises(CancelledError):
session.synthesize(request)
def test_cancel_after_dispose_raises(self) -> None:
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
session = FakeCancelableSession()
session.dispose()
with pytest.raises(EngineError):
session.cancel()
def test_session_usable_after_cancel(self) -> None:
"""Architecture spec: EngineSession remains usable after cancellation."""
session = FakeCancelableSession()
# Cancel
session.cancel()
# Dispose and create new session for synthesis
session.dispose()
+106 -106
View File
@@ -1,106 +1,106 @@
"""Contract tests for Engine protocol.
These tests verify that Engine implementations satisfy the architectural requirements:
- createSession() returns EngineSession
- dispose() is idempotent
- After dispose(), createSession() raises EngineError
- Engine is thread-safe for createSession()
"""
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from .conftest import FakeEngine, FakeEngineSession
class TestEngineProtocolContract:
"""Contract tests for the Engine protocol itself."""
def test_engine_is_protocol(self) -> None:
assert hasattr(Engine, "__protocol_attrs__")
def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_engine_satisfies_protocol(self) -> None:
engine = FakeEngine()
assert isinstance(engine, Engine)
def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession()
assert isinstance(session, EngineSession)
class TestEngineCreateSessionContract:
"""Contract tests for Engine.createSession()."""
def test_create_session_returns_engine_session(self) -> None:
engine = FakeEngine()
session = engine.createSession()
assert isinstance(session, EngineSession)
def test_create_session_returns_new_instance(self) -> None:
engine = FakeEngine()
session1 = engine.createSession()
session2 = engine.createSession()
assert session1 is not session2
def test_create_session_ownership_transfers(self) -> None:
"""Architecture spec: Ownership transfers to caller."""
engine = FakeEngine()
session = engine.createSession()
assert isinstance(session, EngineSession)
class TestEngineDisposeContract:
"""Contract tests for Engine.dispose()."""
def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent."""
engine = FakeEngine()
engine.dispose()
engine.dispose() # Should not raise
def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions."""
engine = FakeEngine()
engine.dispose() # Should not raise
def test_create_session_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
class TestEngineLifecycleContract:
"""Contract tests for Engine lifecycle."""
def test_full_lifecycle(self) -> None:
"""Test complete engine lifecycle: create -> sessions -> dispose."""
engine = FakeEngine()
# Create sessions
session1 = engine.createSession()
session2 = engine.createSession()
# Use sessions
assert isinstance(session1, EngineSession)
assert isinstance(session2, EngineSession)
# Dispose sessions
session1.dispose()
session2.dispose()
# Dispose engine
engine.dispose()
def test_engine_disposed_session_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
"""Contract tests for Engine protocol.
These tests verify that Engine implementations satisfy the architectural requirements:
- createSession() returns EngineSession
- dispose() is idempotent
- After dispose(), createSession() raises EngineError
- Engine is thread-safe for createSession()
"""
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from .conftest import FakeEngine, FakeEngineSession
class TestEngineProtocolContract:
"""Contract tests for the Engine protocol itself."""
def test_engine_is_protocol(self) -> None:
assert hasattr(Engine, "__protocol_attrs__")
def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_engine_satisfies_protocol(self) -> None:
engine = FakeEngine()
assert isinstance(engine, Engine)
def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession()
assert isinstance(session, EngineSession)
class TestEngineCreateSessionContract:
"""Contract tests for Engine.createSession()."""
def test_create_session_returns_engine_session(self) -> None:
engine = FakeEngine()
session = engine.createSession()
assert isinstance(session, EngineSession)
def test_create_session_returns_new_instance(self) -> None:
engine = FakeEngine()
session1 = engine.createSession()
session2 = engine.createSession()
assert session1 is not session2
def test_create_session_ownership_transfers(self) -> None:
"""Architecture spec: Ownership transfers to caller."""
engine = FakeEngine()
session = engine.createSession()
assert isinstance(session, EngineSession)
class TestEngineDisposeContract:
"""Contract tests for Engine.dispose()."""
def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent."""
engine = FakeEngine()
engine.dispose()
engine.dispose() # Should not raise
def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions."""
engine = FakeEngine()
engine.dispose() # Should not raise
def test_create_session_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
class TestEngineLifecycleContract:
"""Contract tests for Engine lifecycle."""
def test_full_lifecycle(self) -> None:
"""Test complete engine lifecycle: create -> sessions -> dispose."""
engine = FakeEngine()
# Create sessions
session1 = engine.createSession()
session2 = engine.createSession()
# Use sessions
assert isinstance(session1, EngineSession)
assert isinstance(session2, EngineSession)
# Dispose sessions
session1.dispose()
session2.dispose()
# Dispose engine
engine.dispose()
def test_engine_disposed_session_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
+85 -85
View File
@@ -1,85 +1,85 @@
"""Contract tests for error hierarchy.
These tests verify that the error hierarchy satisfies the architectural requirements:
- All errors inherit from EngineError
- EngineError inherits from Exception
- Each error type is properly classified
"""
import pytest
from abogen.tts_plugin.errors import (
CancelledError,
ConfigurationError,
EngineError,
InternalError,
InvalidInputError,
ModelLoadError,
ModelNotFoundError,
NetworkError,
)
class TestErrorHierarchyContract:
"""Contract tests for the error hierarchy."""
def test_engine_error_is_exception(self) -> None:
assert issubclass(EngineError, Exception)
def test_all_errors_inherit_from_engine_error(self) -> None:
error_classes = [
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for error_class in error_classes:
assert issubclass(error_class, EngineError), (
f"{error_class.__name__} must inherit from EngineError"
)
def test_all_errors_are_catchable(self) -> None:
error_classes = [
EngineError,
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for error_class in error_classes:
with pytest.raises(EngineError):
raise error_class("test message")
def test_error_message_preserved(self) -> None:
msg = "Model not found: bert-base"
with pytest.raises(ModelNotFoundError, match=msg):
raise ModelNotFoundError(msg)
def test_error_can_be_caught_as_engine_error(self) -> None:
with pytest.raises(EngineError):
raise ModelNotFoundError("test")
def test_cancelled_error_is_engine_error(self) -> None:
"""CancelledError is a subtype of EngineError per architecture spec."""
assert issubclass(CancelledError, EngineError)
def test_error_hierarchy_no_cycles(self) -> None:
"""Verify no circular inheritance."""
error_classes = [
EngineError,
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for cls in error_classes:
assert cls not in cls.__bases__
"""Contract tests for error hierarchy.
These tests verify that the error hierarchy satisfies the architectural requirements:
- All errors inherit from EngineError
- EngineError inherits from Exception
- Each error type is properly classified
"""
import pytest
from abogen.tts_plugin.errors import (
CancelledError,
ConfigurationError,
EngineError,
InternalError,
InvalidInputError,
ModelLoadError,
ModelNotFoundError,
NetworkError,
)
class TestErrorHierarchyContract:
"""Contract tests for the error hierarchy."""
def test_engine_error_is_exception(self) -> None:
assert issubclass(EngineError, Exception)
def test_all_errors_inherit_from_engine_error(self) -> None:
error_classes = [
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for error_class in error_classes:
assert issubclass(error_class, EngineError), (
f"{error_class.__name__} must inherit from EngineError"
)
def test_all_errors_are_catchable(self) -> None:
error_classes = [
EngineError,
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for error_class in error_classes:
with pytest.raises(EngineError):
raise error_class("test message")
def test_error_message_preserved(self) -> None:
msg = "Model not found: bert-base"
with pytest.raises(ModelNotFoundError, match=msg):
raise ModelNotFoundError(msg)
def test_error_can_be_caught_as_engine_error(self) -> None:
with pytest.raises(EngineError):
raise ModelNotFoundError("test")
def test_cancelled_error_is_engine_error(self) -> None:
"""CancelledError is a subtype of EngineError per architecture spec."""
assert issubclass(CancelledError, EngineError)
def test_error_hierarchy_no_cycles(self) -> None:
"""Verify no circular inheritance."""
error_classes = [
EngineError,
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for cls in error_classes:
assert cls not in cls.__bases__
+89 -89
View File
@@ -1,89 +1,89 @@
"""Contract tests for HostContext.
These tests verify that HostContext satisfies the architectural requirements:
- Minimal (3 fields maximum)
- Frozen dataclass
- config_dir: Path
- logger: Logger
- http_client: HttpClient protocol
"""
import logging
from pathlib import Path
import pytest
from abogen.tts_plugin.host_context import HttpClient, HostContext
class TestHostContextContract:
"""Contract tests for HostContext dataclass."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(HostContext, "__dataclass_params__")
assert HostContext.__dataclass_params__.frozen is True
def test_required_fields(self, tmp_path: Path) -> None:
logger = logging.getLogger("test")
class FakeClient:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
ctx = HostContext(
config_dir=tmp_path,
logger=logger,
http_client=FakeClient(),
)
assert ctx.config_dir == tmp_path
assert ctx.logger is logger
def test_immutability(self, tmp_path: Path) -> None:
class FakeClient:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
ctx = HostContext(
config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=FakeClient(),
)
with pytest.raises(AttributeError):
ctx.config_dir = Path("/other") # type: ignore[misc]
def test_max_three_fields(self) -> None:
"""Architecture spec: HostContext is minimal (3 fields max)."""
import dataclasses
fields = dataclasses.fields(HostContext)
assert len(fields) <= 3
class TestHttpClientProtocolContract:
"""Contract tests for HttpClient protocol."""
def test_http_client_is_protocol(self) -> None:
assert hasattr(HttpClient, "__protocol_attrs__")
def test_http_client_has_get(self) -> None:
assert hasattr(HttpClient, "get")
def test_http_client_has_post(self) -> None:
assert hasattr(HttpClient, "post")
def test_http_client_satisfied(self) -> None:
class FakeClient:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
client = FakeClient()
assert isinstance(client, HttpClient)
"""Contract tests for HostContext.
These tests verify that HostContext satisfies the architectural requirements:
- Minimal (3 fields maximum)
- Frozen dataclass
- config_dir: Path
- logger: Logger
- http_client: HttpClient protocol
"""
import logging
from pathlib import Path
import pytest
from abogen.tts_plugin.host_context import HttpClient, HostContext
class TestHostContextContract:
"""Contract tests for HostContext dataclass."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(HostContext, "__dataclass_params__")
assert HostContext.__dataclass_params__.frozen is True
def test_required_fields(self, tmp_path: Path) -> None:
logger = logging.getLogger("test")
class FakeClient:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
ctx = HostContext(
config_dir=tmp_path,
logger=logger,
http_client=FakeClient(),
)
assert ctx.config_dir == tmp_path
assert ctx.logger is logger
def test_immutability(self, tmp_path: Path) -> None:
class FakeClient:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
ctx = HostContext(
config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=FakeClient(),
)
with pytest.raises(AttributeError):
ctx.config_dir = Path("/other") # type: ignore[misc]
def test_max_three_fields(self) -> None:
"""Architecture spec: HostContext is minimal (3 fields max)."""
import dataclasses
fields = dataclasses.fields(HostContext)
assert len(fields) <= 3
class TestHttpClientProtocolContract:
"""Contract tests for HttpClient protocol."""
def test_http_client_is_protocol(self) -> None:
assert hasattr(HttpClient, "__protocol_attrs__")
def test_http_client_has_get(self) -> None:
assert hasattr(HttpClient, "get")
def test_http_client_has_post(self) -> None:
assert hasattr(HttpClient, "post")
def test_http_client_satisfied(self) -> None:
class FakeClient:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
client = FakeClient()
assert isinstance(client, HttpClient)
+420 -420
View File
@@ -1,420 +1,420 @@
"""Integration tests for the TTS Plugin Architecture.
These tests verify:
1. Consumer Flow: consumer → plugin → engine → session → synthesis → result
2. Lifecycle: dispose, no leaks, error handling
3. Regression: old path vs new path equivalence
Tests use mock plugins to avoid requiring real TTS dependencies.
"""
import pytest
from typing import Any, Iterator
from unittest.mock import MagicMock, patch
import numpy as np
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import Pipeline, create_pipeline
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class MockEngineSession:
"""Mock EngineSession that records calls for verification."""
def __init__(self):
self._disposed = False
self.synthesize_calls = []
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
raise EngineError("Session disposed")
self.synthesize_calls.append(request)
# Return fake audio
audio = np.ones(1000, dtype=np.float32) * 0.5
return SynthesizedAudio(
data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1000 / 24000),
)
def dispose(self) -> None:
self._disposed = True
class MockEngine:
"""Mock Engine that creates MockEngineSessions."""
def __init__(self, **kwargs):
self.kwargs = kwargs
self._disposed = False
self.sessions_created = []
def createSession(self) -> MockEngineSession:
if self._disposed:
raise EngineError("Engine disposed")
session = MockEngineSession()
self.sessions_created.append(session)
return session
def dispose(self) -> None:
self._disposed = True
def create_mock_plugin(create_engine_func=None):
"""Helper to create a mock plugin module."""
if create_engine_func is None:
create_engine_func = lambda **kwargs: MockEngine(**kwargs)
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
manifest = PluginManifest(
id="mock_tts",
name="Mock TTS",
version="1.0.0",
api_version="1.0",
description="Mock TTS for testing",
author="Test",
capabilities=(),
requires=None,
engine=EngineManifest(
voiceSources=(),
parameters=(),
audioFormats=(),
),
)
return {
"PLUGIN_MANIFEST": manifest,
"MODEL_REQUIREMENTS": [],
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
}
def create_mock_plugin_engine(**kwargs):
"""Default mock plugin engine factory."""
return MockEngine(**kwargs)
class TestConsumerFlow:
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
def test_full_consumer_flow(self):
"""Verify complete flow from consumer to audio output."""
manager = PluginManager()
# Register mock plugin
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# Step 1: Consumer gets plugin
assert manager.has_plugin("mock_tts") is True
# Step 2: Plugin creates engine
engine = manager.create_engine("mock_tts")
assert engine is not None
assert isinstance(engine, MockEngine)
# Step 3: Engine creates session
session = engine.createSession()
assert session is not None
assert isinstance(session, MockEngineSession)
# Step 4: Session synthesizes
request = SynthesisRequest(
text="Hello world",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
# Step 5: Result returned
assert result is not None
assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0
assert result.format.mime == "audio/wav"
assert result.duration.seconds > 0
def test_consumer_flow_via_pipeline(self):
"""Verify flow through Pipeline utility matches direct flow."""
manager = PluginManager()
# Register mock plugin
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# Use Pipeline utility
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts")
# Call like old TTSBackend
segments = list(backend("Hello world", voice="default", speed=1.0))
# Verify result
assert len(segments) >= 1
segment = segments[0]
assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world"
class TestLifecycle:
"""Lifecycle Test: dispose, no leaks, error handling"""
def test_session_dispose_is_idempotent(self):
"""dispose() can be called multiple times safely."""
session = MockEngineSession()
session.dispose()
session.dispose() # Should not raise
assert session._disposed is True
def test_session_synthesize_after_dispose_raises(self):
"""synthesize() after dispose() raises EngineError."""
session = MockEngineSession()
session.dispose()
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError):
session.synthesize(request)
def test_engine_dispose_is_idempotent(self):
"""Engine dispose() can be called multiple times safely."""
engine = MockEngine()
engine.dispose()
engine.dispose() # Should not raise
assert engine._disposed is True
def test_engine_create_session_after_dispose_raises(self):
"""createSession() after dispose() raises EngineError."""
engine = MockEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
def test_full_lifecycle(self):
"""Test complete lifecycle: create → use → dispose."""
engine = MockEngine()
# Create and use session
session = engine.createSession()
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert len(result.data) > 0
# Dispose session
session.dispose()
assert session._disposed is True
# Dispose engine
engine.dispose()
assert engine._disposed is True
def test_no_session_leak_on_engine_dispose(self):
"""Engine can be disposed even if sessions were created."""
engine = MockEngine()
# Create multiple sessions
session1 = engine.createSession()
session2 = engine.createSession()
# Use sessions
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
session1.synthesize(request)
session2.synthesize(request)
# Dispose engine (sessions still exist but engine is disposed)
engine.dispose()
assert engine._disposed is True
# Sessions can still be used (they hold reference to pipeline)
result = session1.synthesize(request)
assert len(result.data) > 0
def test_error_handling_in_synthesis(self):
"""Error during synthesis is handled correctly."""
class FailingSession:
def synthesize(self, request):
raise EngineError("Synthesis failed")
def dispose(self):
pass
session = FailingSession()
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError, match="Synthesis failed"):
session.synthesize(request)
class TestRegression:
"""Regression Test: old path vs new path equivalence"""
def test_old_path_vs_new_path_same_result(self):
"""Both paths should produce equivalent results."""
# Setup mock plugin
manager = PluginManager()
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# New path: Plugin Manager → Engine → Session → Synthesis
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
new_backend = create_pipeline("mock_tts")
new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
# Old path: Direct MockEngine (simulating old registry)
old_engine = MockEngine()
old_session = old_engine.createSession()
request = SynthesisRequest(
text="Hello world",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
old_result = old_session.synthesize(request)
# Compare results
# New path returns segments, old path returns SynthesizedAudio
# But both should have valid audio data
assert len(new_segments) >= 1
assert len(old_result.data) > 0
# Both should have same format
assert new_segments[0].audio.dtype == np.float32
def test_pipeline_matches_old_interface(self):
"""Pipeline utility should match old TTSBackend interface."""
manager = PluginManager()
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
segments = list(backend(
"Hello world",
voice="af_heart",
speed=1.0,
split_pattern=r"\n+"
))
# Should return segments with graphemes and audio
assert len(segments) >= 1
segment = segments[0]
assert segment.graphemes == "Hello world"
assert isinstance(segment.audio, np.ndarray)
assert segment.audio.dtype == np.float32
assert len(segment.audio) > 0
class TestPluginManagerIntegration:
"""Integration tests for PluginManager."""
def test_plugin_manager_singleton_pattern(self):
"""Global plugin manager follows singleton pattern."""
reset_plugin_manager()
manager1 = get_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is manager2
reset_plugin_manager()
manager3 = get_plugin_manager()
assert manager1 is not manager3
def test_plugin_manager_discover_plugins(self):
"""Plugin manager can discover plugins from directory."""
manager = PluginManager()
# Discover from test plugins directory
manager.discover("tests/plugins")
# Should find valid_plugin
# (This depends on test plugins existing)
plugins = manager.list_plugins()
assert isinstance(plugins, list)
def test_plugin_manager_dispose_all(self):
"""Plugin manager can dispose all cached engines."""
manager = PluginManager()
# Register mock plugin
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# Create engines
engine1 = manager.get_or_create_engine("mock_tts")
engine2 = manager.get_or_create_engine("mock_tts")
# Dispose all
manager.dispose_all()
# Engines should be disposed
assert engine1._disposed is True
assert engine2._disposed is True
# Cache should be empty
assert len(manager._engines) == 0
class TestNoCompatLayer:
"""Regression: confirm the compatibility layer has been removed."""
def test_compat_module_does_not_exist(self):
"""abogen.tts_plugin.compat must not be importable."""
import importlib
with pytest.raises((ImportError, ModuleNotFoundError)):
importlib.import_module("abogen.tts_plugin.compat")
def test_consumers_use_plugin_architecture_directly(self):
"""Key consumers import from abogen.tts_plugin.utils, not compat."""
import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache):
source = inspect.getsource(mod)
assert "tts_plugin.compat" not in source, (
f"{mod.__name__} still references tts_plugin.compat"
)
"""Integration tests for the TTS Plugin Architecture.
These tests verify:
1. Consumer Flow: consumer → plugin → engine → session → synthesis → result
2. Lifecycle: dispose, no leaks, error handling
3. Regression: old path vs new path equivalence
Tests use mock plugins to avoid requiring real TTS dependencies.
"""
import pytest
from typing import Any, Iterator
from unittest.mock import MagicMock, patch
import numpy as np
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import Pipeline, create_pipeline
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class MockEngineSession:
"""Mock EngineSession that records calls for verification."""
def __init__(self):
self._disposed = False
self.synthesize_calls = []
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
raise EngineError("Session disposed")
self.synthesize_calls.append(request)
# Return fake audio
audio = np.ones(1000, dtype=np.float32) * 0.5
return SynthesizedAudio(
data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1000 / 24000),
)
def dispose(self) -> None:
self._disposed = True
class MockEngine:
"""Mock Engine that creates MockEngineSessions."""
def __init__(self, **kwargs):
self.kwargs = kwargs
self._disposed = False
self.sessions_created = []
def createSession(self) -> MockEngineSession:
if self._disposed:
raise EngineError("Engine disposed")
session = MockEngineSession()
self.sessions_created.append(session)
return session
def dispose(self) -> None:
self._disposed = True
def create_mock_plugin(create_engine_func=None):
"""Helper to create a mock plugin module."""
if create_engine_func is None:
create_engine_func = lambda **kwargs: MockEngine(**kwargs)
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
manifest = PluginManifest(
id="mock_tts",
name="Mock TTS",
version="1.0.0",
api_version="1.0",
description="Mock TTS for testing",
author="Test",
capabilities=(),
requires=None,
engine=EngineManifest(
voiceSources=(),
parameters=(),
audioFormats=(),
),
)
return {
"PLUGIN_MANIFEST": manifest,
"MODEL_REQUIREMENTS": [],
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
}
def create_mock_plugin_engine(**kwargs):
"""Default mock plugin engine factory."""
return MockEngine(**kwargs)
class TestConsumerFlow:
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
def test_full_consumer_flow(self):
"""Verify complete flow from consumer to audio output."""
manager = PluginManager()
# Register mock plugin
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# Step 1: Consumer gets plugin
assert manager.has_plugin("mock_tts") is True
# Step 2: Plugin creates engine
engine = manager.create_engine("mock_tts")
assert engine is not None
assert isinstance(engine, MockEngine)
# Step 3: Engine creates session
session = engine.createSession()
assert session is not None
assert isinstance(session, MockEngineSession)
# Step 4: Session synthesizes
request = SynthesisRequest(
text="Hello world",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
# Step 5: Result returned
assert result is not None
assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0
assert result.format.mime == "audio/wav"
assert result.duration.seconds > 0
def test_consumer_flow_via_pipeline(self):
"""Verify flow through Pipeline utility matches direct flow."""
manager = PluginManager()
# Register mock plugin
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# Use Pipeline utility
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts")
# Call like old TTSBackend
segments = list(backend("Hello world", voice="default", speed=1.0))
# Verify result
assert len(segments) >= 1
segment = segments[0]
assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world"
class TestLifecycle:
"""Lifecycle Test: dispose, no leaks, error handling"""
def test_session_dispose_is_idempotent(self):
"""dispose() can be called multiple times safely."""
session = MockEngineSession()
session.dispose()
session.dispose() # Should not raise
assert session._disposed is True
def test_session_synthesize_after_dispose_raises(self):
"""synthesize() after dispose() raises EngineError."""
session = MockEngineSession()
session.dispose()
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError):
session.synthesize(request)
def test_engine_dispose_is_idempotent(self):
"""Engine dispose() can be called multiple times safely."""
engine = MockEngine()
engine.dispose()
engine.dispose() # Should not raise
assert engine._disposed is True
def test_engine_create_session_after_dispose_raises(self):
"""createSession() after dispose() raises EngineError."""
engine = MockEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
def test_full_lifecycle(self):
"""Test complete lifecycle: create → use → dispose."""
engine = MockEngine()
# Create and use session
session = engine.createSession()
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert len(result.data) > 0
# Dispose session
session.dispose()
assert session._disposed is True
# Dispose engine
engine.dispose()
assert engine._disposed is True
def test_no_session_leak_on_engine_dispose(self):
"""Engine can be disposed even if sessions were created."""
engine = MockEngine()
# Create multiple sessions
session1 = engine.createSession()
session2 = engine.createSession()
# Use sessions
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
session1.synthesize(request)
session2.synthesize(request)
# Dispose engine (sessions still exist but engine is disposed)
engine.dispose()
assert engine._disposed is True
# Sessions can still be used (they hold reference to pipeline)
result = session1.synthesize(request)
assert len(result.data) > 0
def test_error_handling_in_synthesis(self):
"""Error during synthesis is handled correctly."""
class FailingSession:
def synthesize(self, request):
raise EngineError("Synthesis failed")
def dispose(self):
pass
session = FailingSession()
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError, match="Synthesis failed"):
session.synthesize(request)
class TestRegression:
"""Regression Test: old path vs new path equivalence"""
def test_old_path_vs_new_path_same_result(self):
"""Both paths should produce equivalent results."""
# Setup mock plugin
manager = PluginManager()
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# New path: Plugin Manager → Engine → Session → Synthesis
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
new_backend = create_pipeline("mock_tts")
new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
# Old path: Direct MockEngine (simulating old registry)
old_engine = MockEngine()
old_session = old_engine.createSession()
request = SynthesisRequest(
text="Hello world",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
old_result = old_session.synthesize(request)
# Compare results
# New path returns segments, old path returns SynthesizedAudio
# But both should have valid audio data
assert len(new_segments) >= 1
assert len(old_result.data) > 0
# Both should have same format
assert new_segments[0].audio.dtype == np.float32
def test_pipeline_matches_old_interface(self):
"""Pipeline utility should match old TTSBackend interface."""
manager = PluginManager()
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
segments = list(backend(
"Hello world",
voice="af_heart",
speed=1.0,
split_pattern=r"\n+"
))
# Should return segments with graphemes and audio
assert len(segments) >= 1
segment = segments[0]
assert segment.graphemes == "Hello world"
assert isinstance(segment.audio, np.ndarray)
assert segment.audio.dtype == np.float32
assert len(segment.audio) > 0
class TestPluginManagerIntegration:
"""Integration tests for PluginManager."""
def test_plugin_manager_singleton_pattern(self):
"""Global plugin manager follows singleton pattern."""
reset_plugin_manager()
manager1 = get_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is manager2
reset_plugin_manager()
manager3 = get_plugin_manager()
assert manager1 is not manager3
def test_plugin_manager_discover_plugins(self):
"""Plugin manager can discover plugins from directory."""
manager = PluginManager()
# Discover from test plugins directory
manager.discover("tests/plugins")
# Should find valid_plugin
# (This depends on test plugins existing)
plugins = manager.list_plugins()
assert isinstance(plugins, list)
def test_plugin_manager_dispose_all(self):
"""Plugin manager can dispose all cached engines."""
manager = PluginManager()
# Register mock plugin
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# Create engines
engine1 = manager.get_or_create_engine("mock_tts")
engine2 = manager.get_or_create_engine("mock_tts")
# Dispose all
manager.dispose_all()
# Engines should be disposed
assert engine1._disposed is True
assert engine2._disposed is True
# Cache should be empty
assert len(manager._engines) == 0
class TestNoCompatLayer:
"""Regression: confirm the compatibility layer has been removed."""
def test_compat_module_does_not_exist(self):
"""abogen.tts_plugin.compat must not be importable."""
import importlib
with pytest.raises((ImportError, ModuleNotFoundError)):
importlib.import_module("abogen.tts_plugin.compat")
def test_consumers_use_plugin_architecture_directly(self):
"""Key consumers import from abogen.tts_plugin.utils, not compat."""
import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache):
source = inspect.getsource(mod)
assert "tts_plugin.compat" not in source, (
f"{mod.__name__} still references tts_plugin.compat"
)
+436 -436
View File
@@ -1,436 +1,436 @@
"""Comprehensive tests for the plugin loader infrastructure.
These tests verify that the loader correctly:
- Discovers plugins in directories
- Imports plugin modules
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
- Validates api_version compatibility
- Validates capabilities
- Provides diagnostic messages for errors
- Rejects invalid plugins
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from abogen.tts_plugin.loader import (
HOST_API_VERSION,
PluginLoadError,
PluginLoadResult,
_check_api_version_compatibility,
_parse_api_version,
_validate_api_version,
_validate_capabilities,
_validate_manifest,
discover_plugins,
load_plugin,
load_plugin_from_dir,
)
from abogen.tts_plugin.manifest import (
EngineManifest,
ModelManifest,
PluginManifest,
)
# ──────────────────────────────────────────────────────────────
# Path fixtures
# ──────────────────────────────────────────────────────────────
@pytest.fixture
def plugins_dir() -> Path:
return Path(__file__).parent.parent / "plugins"
@pytest.fixture
def fake_plugin_dir(plugins_dir: Path) -> Path:
return plugins_dir / "fake_plugin"
@pytest.fixture
def missing_manifest_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_manifest"
@pytest.fixture
def invalid_api_version_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_api_version"
@pytest.fixture
def invalid_capabilities_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_capabilities"
@pytest.fixture
def missing_create_engine_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_create_engine"
@pytest.fixture
def import_error_dir(plugins_dir: Path) -> Path:
return plugins_dir / "import_error"
@pytest.fixture
def missing_model_requirements_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_model_requirements"
# ──────────────────────────────────────────────────────────────
# Unit tests: _parse_api_version
# ──────────────────────────────────────────────────────────────
class TestParseApiVersion:
def test_valid_version(self) -> None:
assert _parse_api_version("1.0") == (1, 0)
assert _parse_api_version("2.5") == (2, 5)
assert _parse_api_version("10.20") == (10, 20)
def test_invalid_format(self) -> None:
assert _parse_api_version("1") is None
assert _parse_api_version("1.0.0") is None
assert _parse_api_version("abc") is None
assert _parse_api_version("") is None
assert _parse_api_version("1.x") is None
# ──────────────────────────────────────────────────────────────
# Unit tests: _check_api_version_compatibility
# ──────────────────────────────────────────────────────────────
class TestCheckApiVersionCompatibility:
def test_compatible_version(self) -> None:
assert _check_api_version_compatibility("1.0") is None
assert _check_api_version_compatibility("1.5") is None
def test_major_mismatch(self) -> None:
error = _check_api_version_compatibility("2.0")
assert error is not None
assert "major mismatch" in error
def test_invalid_format(self) -> None:
error = _check_api_version_compatibility("invalid")
assert error is not None
assert "Invalid api_version format" in error
# ──────────────────────────────────────────────────────────────
# Unit tests: _validate_manifest
# ──────────────────────────────────────────────────────────────
class TestValidateManifest:
def test_valid_manifest(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert errors == []
def test_missing_manifest(self) -> None:
class FakeModule:
MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
def test_wrong_manifest_type(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = "not a manifest"
MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("PluginManifest instance" in e for e in errors)
def test_missing_model_requirements(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
def test_wrong_model_requirements_type(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS = "not a list"
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be a list" in e for e in errors)
def test_invalid_model_requirements_item(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS = ["not a model manifest"]
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("ModelManifest instance" in e for e in errors)
def test_missing_create_engine(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS: list = []
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing create_engine" in e for e in errors)
def test_create_engine_not_callable(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS: list = []
create_engine = "not callable"
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be callable" in e for e in errors)
# ──────────────────────────────────────────────────────────────
# Unit tests: _validate_capabilities
# ──────────────────────────────────────────────────────────────
class TestValidateCapabilities:
def test_valid_capabilities(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "preview"),
)
errors = _validate_capabilities(manifest)
assert errors == []
def test_unknown_capability(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "unknown_cap"),
)
errors = _validate_capabilities(manifest)
assert any("unknown_cap" in e for e in errors)
def test_empty_capabilities(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
capabilities=(),
)
errors = _validate_capabilities(manifest)
assert errors == []
# ──────────────────────────────────────────────────────────────
# Unit tests: _validate_api_version
# ──────────────────────────────────────────────────────────────
class TestValidateApiVersion:
def test_compatible(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
errors = _validate_api_version(manifest)
assert errors == []
def test_incompatible(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="2.0", description="Test", author="Test",
)
errors = _validate_api_version(manifest)
assert len(errors) > 0
# ──────────────────────────────────────────────────────────────
# Integration tests: load_plugin_from_dir
# ──────────────────────────────────────────────────────────────
class TestLoadPluginFromDir:
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True
assert result.manifest is not None
assert result.manifest.id == "fake_plugin"
assert result.model_requirements is not None
assert result.create_engine is not None
assert result.module is not None
assert result.error is None
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext
import logging
result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True
# Create engine using the loaded create_engine function
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
)
engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
def test_nonexistent_directory(self, tmp_path: Path) -> None:
result = load_plugin_from_dir(tmp_path / "nonexistent")
assert result.success is False
assert result.error is not None
assert "does not exist" in result.error.errors[0]
def test_missing_init_file(self, tmp_path: Path) -> None:
plugin_dir = tmp_path / "no_init"
plugin_dir.mkdir()
result = load_plugin_from_dir(plugin_dir)
assert result.success is False
assert result.error is not None
assert "__init__.py" in result.error.errors[0]
def test_import_error(self, import_error_dir: Path) -> None:
result = load_plugin_from_dir(import_error_dir)
assert result.success is False
assert result.error is not None
assert "Failed to import" in result.error.errors[0]
# ──────────────────────────────────────────────────────────────
# Integration tests: invalid plugins
# ──────────────────────────────────────────────────────────────
class TestInvalidPlugins:
def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False
assert result.error is not None
assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
result = load_plugin_from_dir(invalid_api_version_dir)
assert result.success is False
assert result.error is not None
assert any("major mismatch" in e for e in result.error.errors)
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
result = load_plugin_from_dir(invalid_capabilities_dir)
assert result.success is False
assert result.error is not None
assert any("Unknown capability" in e for e in result.error.errors)
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
result = load_plugin_from_dir(missing_create_engine_dir)
assert result.success is False
assert result.error is not None
assert any("Missing create_engine" in e for e in result.error.errors)
def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
result = load_plugin_from_dir(missing_model_requirements_dir)
assert result.success is False
assert result.error is not None
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
# ──────────────────────────────────────────────────────────────
# Integration tests: discover_plugins
# ──────────────────────────────────────────────────────────────
class TestDiscoverPlugins:
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir])
# Should find multiple plugins (valid and invalid)
assert len(results) > 0
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir])
valid = [r for r in results if r.success]
assert len(valid) >= 1
assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir])
invalid = [r for r in results if not r.success]
assert len(invalid) >= 1
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
results = discover_plugins([tmp_path / "nonexistent"])
assert results == []
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
assert len(results) > 0
# ──────────────────────────────────────────────────────────────
# Diagnostic messages tests
# ──────────────────────────────────────────────────────────────
class TestDiagnosticMessages:
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None
assert result.error.plugin_id == "missing_manifest"
def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None
assert result.error.path == missing_manifest_dir
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None
assert len(result.error.errors) > 0
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
# This plugin has multiple issues
result = load_plugin_from_dir(invalid_api_version_dir)
assert result.error is not None
# Should have at least the api_version error
assert len(result.error.errors) >= 1
# ──────────────────────────────────────────────────────────────
# No partial registration tests
# ──────────────────────────────────────────────────────────────
class TestNoPartialRegistration:
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
"""After failed load, module should not remain in sys.modules."""
result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False
# Module should not be registered
module_name = f"abogen.tts_plugin._loaded.missing_manifest"
assert module_name not in sys.modules
def test_import_error_no_registration(self, import_error_dir: Path) -> None:
"""After import error, module should not remain in sys.modules."""
result = load_plugin_from_dir(import_error_dir)
assert result.success is False
module_name = f"abogen.tts_plugin._loaded.import_error"
assert module_name not in sys.modules
"""Comprehensive tests for the plugin loader infrastructure.
These tests verify that the loader correctly:
- Discovers plugins in directories
- Imports plugin modules
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
- Validates api_version compatibility
- Validates capabilities
- Provides diagnostic messages for errors
- Rejects invalid plugins
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from abogen.tts_plugin.loader import (
HOST_API_VERSION,
PluginLoadError,
PluginLoadResult,
_check_api_version_compatibility,
_parse_api_version,
_validate_api_version,
_validate_capabilities,
_validate_manifest,
discover_plugins,
load_plugin,
load_plugin_from_dir,
)
from abogen.tts_plugin.manifest import (
EngineManifest,
ModelManifest,
PluginManifest,
)
# ──────────────────────────────────────────────────────────────
# Path fixtures
# ──────────────────────────────────────────────────────────────
@pytest.fixture
def plugins_dir() -> Path:
return Path(__file__).parent.parent / "plugins"
@pytest.fixture
def fake_plugin_dir(plugins_dir: Path) -> Path:
return plugins_dir / "fake_plugin"
@pytest.fixture
def missing_manifest_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_manifest"
@pytest.fixture
def invalid_api_version_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_api_version"
@pytest.fixture
def invalid_capabilities_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_capabilities"
@pytest.fixture
def missing_create_engine_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_create_engine"
@pytest.fixture
def import_error_dir(plugins_dir: Path) -> Path:
return plugins_dir / "import_error"
@pytest.fixture
def missing_model_requirements_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_model_requirements"
# ──────────────────────────────────────────────────────────────
# Unit tests: _parse_api_version
# ──────────────────────────────────────────────────────────────
class TestParseApiVersion:
def test_valid_version(self) -> None:
assert _parse_api_version("1.0") == (1, 0)
assert _parse_api_version("2.5") == (2, 5)
assert _parse_api_version("10.20") == (10, 20)
def test_invalid_format(self) -> None:
assert _parse_api_version("1") is None
assert _parse_api_version("1.0.0") is None
assert _parse_api_version("abc") is None
assert _parse_api_version("") is None
assert _parse_api_version("1.x") is None
# ──────────────────────────────────────────────────────────────
# Unit tests: _check_api_version_compatibility
# ──────────────────────────────────────────────────────────────
class TestCheckApiVersionCompatibility:
def test_compatible_version(self) -> None:
assert _check_api_version_compatibility("1.0") is None
assert _check_api_version_compatibility("1.5") is None
def test_major_mismatch(self) -> None:
error = _check_api_version_compatibility("2.0")
assert error is not None
assert "major mismatch" in error
def test_invalid_format(self) -> None:
error = _check_api_version_compatibility("invalid")
assert error is not None
assert "Invalid api_version format" in error
# ──────────────────────────────────────────────────────────────
# Unit tests: _validate_manifest
# ──────────────────────────────────────────────────────────────
class TestValidateManifest:
def test_valid_manifest(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert errors == []
def test_missing_manifest(self) -> None:
class FakeModule:
MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
def test_wrong_manifest_type(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = "not a manifest"
MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("PluginManifest instance" in e for e in errors)
def test_missing_model_requirements(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
def test_wrong_model_requirements_type(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS = "not a list"
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be a list" in e for e in errors)
def test_invalid_model_requirements_item(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS = ["not a model manifest"]
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("ModelManifest instance" in e for e in errors)
def test_missing_create_engine(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS: list = []
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing create_engine" in e for e in errors)
def test_create_engine_not_callable(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS: list = []
create_engine = "not callable"
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be callable" in e for e in errors)
# ──────────────────────────────────────────────────────────────
# Unit tests: _validate_capabilities
# ──────────────────────────────────────────────────────────────
class TestValidateCapabilities:
def test_valid_capabilities(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "preview"),
)
errors = _validate_capabilities(manifest)
assert errors == []
def test_unknown_capability(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "unknown_cap"),
)
errors = _validate_capabilities(manifest)
assert any("unknown_cap" in e for e in errors)
def test_empty_capabilities(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
capabilities=(),
)
errors = _validate_capabilities(manifest)
assert errors == []
# ──────────────────────────────────────────────────────────────
# Unit tests: _validate_api_version
# ──────────────────────────────────────────────────────────────
class TestValidateApiVersion:
def test_compatible(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
errors = _validate_api_version(manifest)
assert errors == []
def test_incompatible(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="2.0", description="Test", author="Test",
)
errors = _validate_api_version(manifest)
assert len(errors) > 0
# ──────────────────────────────────────────────────────────────
# Integration tests: load_plugin_from_dir
# ──────────────────────────────────────────────────────────────
class TestLoadPluginFromDir:
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True
assert result.manifest is not None
assert result.manifest.id == "fake_plugin"
assert result.model_requirements is not None
assert result.create_engine is not None
assert result.module is not None
assert result.error is None
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext
import logging
result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True
# Create engine using the loaded create_engine function
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
)
engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
def test_nonexistent_directory(self, tmp_path: Path) -> None:
result = load_plugin_from_dir(tmp_path / "nonexistent")
assert result.success is False
assert result.error is not None
assert "does not exist" in result.error.errors[0]
def test_missing_init_file(self, tmp_path: Path) -> None:
plugin_dir = tmp_path / "no_init"
plugin_dir.mkdir()
result = load_plugin_from_dir(plugin_dir)
assert result.success is False
assert result.error is not None
assert "__init__.py" in result.error.errors[0]
def test_import_error(self, import_error_dir: Path) -> None:
result = load_plugin_from_dir(import_error_dir)
assert result.success is False
assert result.error is not None
assert "Failed to import" in result.error.errors[0]
# ──────────────────────────────────────────────────────────────
# Integration tests: invalid plugins
# ──────────────────────────────────────────────────────────────
class TestInvalidPlugins:
def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False
assert result.error is not None
assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
result = load_plugin_from_dir(invalid_api_version_dir)
assert result.success is False
assert result.error is not None
assert any("major mismatch" in e for e in result.error.errors)
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
result = load_plugin_from_dir(invalid_capabilities_dir)
assert result.success is False
assert result.error is not None
assert any("Unknown capability" in e for e in result.error.errors)
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
result = load_plugin_from_dir(missing_create_engine_dir)
assert result.success is False
assert result.error is not None
assert any("Missing create_engine" in e for e in result.error.errors)
def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
result = load_plugin_from_dir(missing_model_requirements_dir)
assert result.success is False
assert result.error is not None
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
# ──────────────────────────────────────────────────────────────
# Integration tests: discover_plugins
# ──────────────────────────────────────────────────────────────
class TestDiscoverPlugins:
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir])
# Should find multiple plugins (valid and invalid)
assert len(results) > 0
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir])
valid = [r for r in results if r.success]
assert len(valid) >= 1
assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir])
invalid = [r for r in results if not r.success]
assert len(invalid) >= 1
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
results = discover_plugins([tmp_path / "nonexistent"])
assert results == []
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
assert len(results) > 0
# ──────────────────────────────────────────────────────────────
# Diagnostic messages tests
# ──────────────────────────────────────────────────────────────
class TestDiagnosticMessages:
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None
assert result.error.plugin_id == "missing_manifest"
def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None
assert result.error.path == missing_manifest_dir
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None
assert len(result.error.errors) > 0
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
# This plugin has multiple issues
result = load_plugin_from_dir(invalid_api_version_dir)
assert result.error is not None
# Should have at least the api_version error
assert len(result.error.errors) >= 1
# ──────────────────────────────────────────────────────────────
# No partial registration tests
# ──────────────────────────────────────────────────────────────
class TestNoPartialRegistration:
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
"""After failed load, module should not remain in sys.modules."""
result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False
# Module should not be registered
module_name = f"abogen.tts_plugin._loaded.missing_manifest"
assert module_name not in sys.modules
def test_import_error_no_registration(self, import_error_dir: Path) -> None:
"""After import error, module should not remain in sys.modules."""
result = load_plugin_from_dir(import_error_dir)
assert result.success is False
module_name = f"abogen.tts_plugin._loaded.import_error"
assert module_name not in sys.modules
+290 -290
View File
@@ -1,290 +1,290 @@
"""Contract tests for plugin manifest types.
These tests verify that manifest types satisfy the architectural requirements:
- All required fields are present
- api_version follows semver format
- capabilities are properly defined
- engine manifest describes the engine correctly
"""
import re
import pytest
from abogen.tts_plugin.manifest import (
AudioFormatManifest,
EngineManifest,
EnumOption,
GpuRequirement,
ModelManifest,
ParameterManifest,
PluginManifest,
RequirementManifest,
VoiceManifest,
VoiceSourceManifest,
)
class TestPluginManifestContract:
"""Contract tests for PluginManifest."""
def test_required_fields(self) -> None:
manifest = PluginManifest(
id="test-plugin",
name="Test Plugin",
version="1.0.0",
api_version="1.0",
description="A test plugin",
author="Test Author",
)
assert manifest.id == "test-plugin"
assert manifest.name == "Test Plugin"
assert manifest.version == "1.0.0"
assert manifest.api_version == "1.0"
assert manifest.description == "A test plugin"
assert manifest.author == "Test Author"
def test_api_version_semver_format(self) -> None:
"""Architecture spec: api_version format is semver (MAJOR.MINOR)."""
valid_versions = ["1.0", "2.1", "10.5"]
for version in valid_versions:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version=version,
description="Test",
author="Test",
)
assert re.match(r"^\d+\.\d+$", manifest.api_version)
def test_capabilities_default_empty(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
)
assert manifest.capabilities == ()
def test_capabilities_tuple(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
capabilities=("voice_list", "preview"),
)
assert "voice_list" in manifest.capabilities
assert "preview" in manifest.capabilities
def test_requires_default(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
)
assert isinstance(manifest.requires, RequirementManifest)
def test_engine_default(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
)
assert isinstance(manifest.engine, EngineManifest)
class TestEngineManifestContract:
"""Contract tests for EngineManifest."""
def test_required_fields(self) -> None:
manifest = EngineManifest(
voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
),
parameters=(
ParameterManifest(
id="speed", name="Speed", description="Speed", type="float", default=1.0
),
),
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
)
assert len(manifest.voiceSources) == 1
assert len(manifest.parameters) == 1
assert len(manifest.audioFormats) == 1
def test_defaults_empty(self) -> None:
manifest = EngineManifest()
assert manifest.voiceSources == ()
assert manifest.parameters == ()
assert manifest.audioFormats == ()
class TestVoiceSourceManifestContract:
"""Contract tests for VoiceSourceManifest."""
def test_required_fields(self) -> None:
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
assert vs.id == "builtin"
assert vs.name == "Builtin"
assert vs.type == "list"
def test_valid_types(self) -> None:
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
for vtype in valid_types:
vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
assert vs.type == vtype
def test_config_optional(self) -> None:
vs = VoiceSourceManifest(id="test", name="Test", type="list")
assert vs.config is None
def test_config_any(self) -> None:
config = {"voices": ["af_nova", "af_sky"]}
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
assert vs.config == config
class TestVoiceManifestContract:
"""Contract tests for VoiceManifest."""
def test_required_fields(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova")
assert v.id == "af_nova"
assert v.name == "Nova"
def test_tags_default_empty(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova")
assert v.tags == ()
def test_tags_tuple(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
assert "en" in v.tags
assert "female" in v.tags
class TestParameterManifestContract:
"""Contract tests for ParameterManifest."""
def test_required_fields(self) -> None:
p = ParameterManifest(
id="speed", name="Speed", description="Speech speed", type="float", default=1.0
)
assert p.id == "speed"
assert p.name == "Speed"
assert p.description == "Speech speed"
assert p.type == "float"
assert p.default == 1.0
def test_valid_types(self) -> None:
valid_types = ["float", "int", "string", "boolean", "enum"]
for ptype in valid_types:
p = ParameterManifest(
id="test", name="Test", description="Test", type=ptype, default=None
)
assert p.type == ptype
def test_optional_numeric_bounds(self) -> None:
p = ParameterManifest(
id="speed",
name="Speed",
description="Speed",
type="float",
default=1.0,
min=0.5,
max=2.0,
step=0.1,
)
assert p.min == 0.5
assert p.max == 2.0
assert p.step == 0.1
def test_enum_options(self) -> None:
options = (
EnumOption(value="low", label="Low"),
EnumOption(value="high", label="High"),
)
p = ParameterManifest(
id="quality",
name="Quality",
description="Quality",
type="enum",
default="low",
options=options,
)
assert len(p.options) == 2
assert p.options[0].value == "low"
class TestAudioFormatManifestContract:
"""Contract tests for AudioFormatManifest."""
def test_required_fields(self) -> None:
af = AudioFormatManifest(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav"
assert af.extension == "wav"
class TestEnumOptionContract:
"""Contract tests for EnumOption."""
def test_required_fields(self) -> None:
opt = EnumOption(value="low", label="Low Quality")
assert opt.value == "low"
assert opt.label == "Low Quality"
class TestRequirementManifestContract:
"""Contract tests for RequirementManifest."""
def test_defaults(self) -> None:
req = RequirementManifest()
assert req.gpu is None
assert req.memory is None
assert req.internet is None
def test_with_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
req = RequirementManifest(gpu=gpu)
assert req.gpu.required is True
assert req.gpu.type == "cuda"
assert req.gpu.memory == 8.0
def test_with_internet(self) -> None:
req = RequirementManifest(internet=True)
assert req.internet is True
class TestGpuRequirementContract:
"""Contract tests for GpuRequirement."""
def test_defaults(self) -> None:
gpu = GpuRequirement()
assert gpu.required is False
assert gpu.type is None
assert gpu.memory is None
def test_required_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
assert gpu.required is True
class TestModelManifestContract:
"""Contract tests for ModelManifest."""
def test_required_fields(self) -> None:
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
assert m.id == "xtts_v2"
assert m.name == "XTTS v2"
assert m.size == "2GB"
"""Contract tests for plugin manifest types.
These tests verify that manifest types satisfy the architectural requirements:
- All required fields are present
- api_version follows semver format
- capabilities are properly defined
- engine manifest describes the engine correctly
"""
import re
import pytest
from abogen.tts_plugin.manifest import (
AudioFormatManifest,
EngineManifest,
EnumOption,
GpuRequirement,
ModelManifest,
ParameterManifest,
PluginManifest,
RequirementManifest,
VoiceManifest,
VoiceSourceManifest,
)
class TestPluginManifestContract:
"""Contract tests for PluginManifest."""
def test_required_fields(self) -> None:
manifest = PluginManifest(
id="test-plugin",
name="Test Plugin",
version="1.0.0",
api_version="1.0",
description="A test plugin",
author="Test Author",
)
assert manifest.id == "test-plugin"
assert manifest.name == "Test Plugin"
assert manifest.version == "1.0.0"
assert manifest.api_version == "1.0"
assert manifest.description == "A test plugin"
assert manifest.author == "Test Author"
def test_api_version_semver_format(self) -> None:
"""Architecture spec: api_version format is semver (MAJOR.MINOR)."""
valid_versions = ["1.0", "2.1", "10.5"]
for version in valid_versions:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version=version,
description="Test",
author="Test",
)
assert re.match(r"^\d+\.\d+$", manifest.api_version)
def test_capabilities_default_empty(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
)
assert manifest.capabilities == ()
def test_capabilities_tuple(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
capabilities=("voice_list", "preview"),
)
assert "voice_list" in manifest.capabilities
assert "preview" in manifest.capabilities
def test_requires_default(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
)
assert isinstance(manifest.requires, RequirementManifest)
def test_engine_default(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
)
assert isinstance(manifest.engine, EngineManifest)
class TestEngineManifestContract:
"""Contract tests for EngineManifest."""
def test_required_fields(self) -> None:
manifest = EngineManifest(
voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
),
parameters=(
ParameterManifest(
id="speed", name="Speed", description="Speed", type="float", default=1.0
),
),
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
)
assert len(manifest.voiceSources) == 1
assert len(manifest.parameters) == 1
assert len(manifest.audioFormats) == 1
def test_defaults_empty(self) -> None:
manifest = EngineManifest()
assert manifest.voiceSources == ()
assert manifest.parameters == ()
assert manifest.audioFormats == ()
class TestVoiceSourceManifestContract:
"""Contract tests for VoiceSourceManifest."""
def test_required_fields(self) -> None:
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
assert vs.id == "builtin"
assert vs.name == "Builtin"
assert vs.type == "list"
def test_valid_types(self) -> None:
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
for vtype in valid_types:
vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
assert vs.type == vtype
def test_config_optional(self) -> None:
vs = VoiceSourceManifest(id="test", name="Test", type="list")
assert vs.config is None
def test_config_any(self) -> None:
config = {"voices": ["af_nova", "af_sky"]}
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
assert vs.config == config
class TestVoiceManifestContract:
"""Contract tests for VoiceManifest."""
def test_required_fields(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova")
assert v.id == "af_nova"
assert v.name == "Nova"
def test_tags_default_empty(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova")
assert v.tags == ()
def test_tags_tuple(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
assert "en" in v.tags
assert "female" in v.tags
class TestParameterManifestContract:
"""Contract tests for ParameterManifest."""
def test_required_fields(self) -> None:
p = ParameterManifest(
id="speed", name="Speed", description="Speech speed", type="float", default=1.0
)
assert p.id == "speed"
assert p.name == "Speed"
assert p.description == "Speech speed"
assert p.type == "float"
assert p.default == 1.0
def test_valid_types(self) -> None:
valid_types = ["float", "int", "string", "boolean", "enum"]
for ptype in valid_types:
p = ParameterManifest(
id="test", name="Test", description="Test", type=ptype, default=None
)
assert p.type == ptype
def test_optional_numeric_bounds(self) -> None:
p = ParameterManifest(
id="speed",
name="Speed",
description="Speed",
type="float",
default=1.0,
min=0.5,
max=2.0,
step=0.1,
)
assert p.min == 0.5
assert p.max == 2.0
assert p.step == 0.1
def test_enum_options(self) -> None:
options = (
EnumOption(value="low", label="Low"),
EnumOption(value="high", label="High"),
)
p = ParameterManifest(
id="quality",
name="Quality",
description="Quality",
type="enum",
default="low",
options=options,
)
assert len(p.options) == 2
assert p.options[0].value == "low"
class TestAudioFormatManifestContract:
"""Contract tests for AudioFormatManifest."""
def test_required_fields(self) -> None:
af = AudioFormatManifest(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav"
assert af.extension == "wav"
class TestEnumOptionContract:
"""Contract tests for EnumOption."""
def test_required_fields(self) -> None:
opt = EnumOption(value="low", label="Low Quality")
assert opt.value == "low"
assert opt.label == "Low Quality"
class TestRequirementManifestContract:
"""Contract tests for RequirementManifest."""
def test_defaults(self) -> None:
req = RequirementManifest()
assert req.gpu is None
assert req.memory is None
assert req.internet is None
def test_with_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
req = RequirementManifest(gpu=gpu)
assert req.gpu.required is True
assert req.gpu.type == "cuda"
assert req.gpu.memory == 8.0
def test_with_internet(self) -> None:
req = RequirementManifest(internet=True)
assert req.internet is True
class TestGpuRequirementContract:
"""Contract tests for GpuRequirement."""
def test_defaults(self) -> None:
gpu = GpuRequirement()
assert gpu.required is False
assert gpu.type is None
assert gpu.memory is None
def test_required_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
assert gpu.required is True
class TestModelManifestContract:
"""Contract tests for ModelManifest."""
def test_required_fields(self) -> None:
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
assert m.id == "xtts_v2"
assert m.name == "XTTS v2"
assert m.size == "2GB"
+146 -146
View File
@@ -1,146 +1,146 @@
"""Contract tests for plugin contract.
These tests verify that plugin modules satisfy the architectural requirements:
- Must export PLUGIN_MANIFEST: PluginManifest
- Must export MODEL_REQUIREMENTS: list[ModelManifest]
- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
- create_engine() must be atomic
"""
import logging
from pathlib import Path
from typing import Any
import pytest
from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
from abogen.tts_plugin.plugin import Plugin
from abogen.tts_plugin.types import EngineConfig
from .conftest import FakeEngine
class FakePluginModule:
"""Stub plugin module that satisfies the plugin contract."""
PLUGIN_MANIFEST = PluginManifest(
id="fake-plugin",
name="Fake Plugin",
version="1.0.0",
api_version="1.0",
description="A fake plugin for testing",
author="Test Author",
capabilities=(),
engine=EngineManifest(),
)
MODEL_REQUIREMENTS: list[ModelManifest] = []
@staticmethod
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
return FakeEngine()
class TestPluginProtocolContract:
"""Contract tests for the Plugin protocol."""
def test_plugin_is_protocol(self) -> None:
assert hasattr(Plugin, "__protocol_attrs__")
class TestPluginExportsContract:
"""Contract tests for required plugin exports."""
def test_plugin_has_plugin_manifest(self) -> None:
"""Architecture spec: Plugin must export PLUGIN_MANIFEST."""
assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
def test_plugin_has_model_requirements(self) -> None:
"""Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_plugin_has_create_engine(self) -> None:
"""Architecture spec: Plugin must export create_engine."""
assert hasattr(FakePluginModule, "create_engine")
assert callable(FakePluginModule.create_engine)
def test_plugin_manifest_required_fields(self) -> None:
"""Architecture spec: PluginManifest has required fields."""
manifest = FakePluginModule.PLUGIN_MANIFEST
assert manifest.id
assert manifest.name
assert manifest.version
assert manifest.api_version
assert manifest.description
assert manifest.author
def test_plugin_manifest_capabilities_is_tuple(self) -> None:
manifest = FakePluginModule.PLUGIN_MANIFEST
assert isinstance(manifest.capabilities, tuple)
class TestCreateEngineContract:
"""Contract tests for create_engine() function."""
def test_create_engine_returns_engine(self) -> None:
"""Architecture spec: create_engine() returns Engine."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine)
def test_create_engine_atomic(self) -> None:
"""Architecture spec: create_engine() is atomic (all-or-nothing)."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
def test_create_engine_with_none_model_path(self) -> None:
"""Architecture spec: model_path can be None for cloud/no-model engines."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
def test_create_engine_with_model_path(self) -> None:
"""Architecture spec: model_path is Path | None."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
class TestModelRequirementsContract:
"""Contract tests for MODEL_REQUIREMENTS."""
def test_model_requirements_is_list(self) -> None:
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_model_requirements_contains_model_manifests(self) -> None:
"""If non-empty, each item must be a ModelManifest."""
for req in FakePluginModule.MODEL_REQUIREMENTS:
assert isinstance(req, ModelManifest)
"""Contract tests for plugin contract.
These tests verify that plugin modules satisfy the architectural requirements:
- Must export PLUGIN_MANIFEST: PluginManifest
- Must export MODEL_REQUIREMENTS: list[ModelManifest]
- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
- create_engine() must be atomic
"""
import logging
from pathlib import Path
from typing import Any
import pytest
from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
from abogen.tts_plugin.plugin import Plugin
from abogen.tts_plugin.types import EngineConfig
from .conftest import FakeEngine
class FakePluginModule:
"""Stub plugin module that satisfies the plugin contract."""
PLUGIN_MANIFEST = PluginManifest(
id="fake-plugin",
name="Fake Plugin",
version="1.0.0",
api_version="1.0",
description="A fake plugin for testing",
author="Test Author",
capabilities=(),
engine=EngineManifest(),
)
MODEL_REQUIREMENTS: list[ModelManifest] = []
@staticmethod
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
return FakeEngine()
class TestPluginProtocolContract:
"""Contract tests for the Plugin protocol."""
def test_plugin_is_protocol(self) -> None:
assert hasattr(Plugin, "__protocol_attrs__")
class TestPluginExportsContract:
"""Contract tests for required plugin exports."""
def test_plugin_has_plugin_manifest(self) -> None:
"""Architecture spec: Plugin must export PLUGIN_MANIFEST."""
assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
def test_plugin_has_model_requirements(self) -> None:
"""Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_plugin_has_create_engine(self) -> None:
"""Architecture spec: Plugin must export create_engine."""
assert hasattr(FakePluginModule, "create_engine")
assert callable(FakePluginModule.create_engine)
def test_plugin_manifest_required_fields(self) -> None:
"""Architecture spec: PluginManifest has required fields."""
manifest = FakePluginModule.PLUGIN_MANIFEST
assert manifest.id
assert manifest.name
assert manifest.version
assert manifest.api_version
assert manifest.description
assert manifest.author
def test_plugin_manifest_capabilities_is_tuple(self) -> None:
manifest = FakePluginModule.PLUGIN_MANIFEST
assert isinstance(manifest.capabilities, tuple)
class TestCreateEngineContract:
"""Contract tests for create_engine() function."""
def test_create_engine_returns_engine(self) -> None:
"""Architecture spec: create_engine() returns Engine."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine)
def test_create_engine_atomic(self) -> None:
"""Architecture spec: create_engine() is atomic (all-or-nothing)."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
def test_create_engine_with_none_model_path(self) -> None:
"""Architecture spec: model_path can be None for cloud/no-model engines."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
def test_create_engine_with_model_path(self) -> None:
"""Architecture spec: model_path is Path | None."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
class TestModelRequirementsContract:
"""Contract tests for MODEL_REQUIREMENTS."""
def test_model_requirements_is_list(self) -> None:
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_model_requirements_contains_model_manifests(self) -> None:
"""If non-empty, each item must be a ModelManifest."""
for req in FakePluginModule.MODEL_REQUIREMENTS:
assert isinstance(req, ModelManifest)
+274 -264
View File
@@ -1,264 +1,274 @@
"""Integration tests for Plugin Manager and direct utility functions."""
import pytest
from unittest.mock import MagicMock, patch
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import Pipeline, create_pipeline
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
class FakeEngine:
"""Fake Engine for testing."""
def __init__(self, **kwargs):
self.kwargs = kwargs
self._disposed = False
def createSession(self):
return FakeEngineSession()
def dispose(self):
self._disposed = True
@property
def manifest(self):
return MagicMock()
class FakeEngineSession:
"""Fake EngineSession for testing."""
def __init__(self):
self._disposed = False
def synthesize(self, request):
# Return fake audio
import numpy as np
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
audio = np.zeros(1000, dtype=np.float32)
return SynthesizedAudio(
data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
)
def dispose(self):
self._disposed = True
class TestPluginManager:
"""Test PluginManager functionality."""
def test_plugin_manager_creation(self):
"""PluginManager can be created."""
manager = PluginManager()
assert manager is not None
def test_plugin_manager_list_discovers_plugins(self):
"""PluginManager discovers plugins from plugins directory."""
manager = PluginManager()
manager.discover("plugins")
plugins = manager.list_plugins()
# Should discover kokoro plugin if it exists
assert isinstance(plugins, list)
def test_plugin_manager_has_plugin_after_discover(self):
"""PluginManager reports plugins after discovery."""
manager = PluginManager()
manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists
# This is expected behavior
assert isinstance(manager._plugins, dict)
def test_plugin_manager_get_plugin_after_discover(self):
"""PluginManager returns plugin info after discovery."""
manager = PluginManager()
manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists
assert isinstance(manager._plugins, dict)
def test_plugin_manager_create_engine_not_found(self):
"""PluginManager raises KeyError for unknown plugins."""
manager = PluginManager()
with pytest.raises(KeyError, match="Plugin not found"):
manager.create_engine("nonexistent")
def test_plugin_manager_discover_with_empty_dir(self):
"""PluginManager handles missing plugins directory."""
manager = PluginManager()
manager.discover("/nonexistent/path")
plugins = manager.list_plugins()
assert plugins == []
def test_global_plugin_manager_singleton(self):
"""Global PluginManager is a singleton."""
reset_plugin_manager()
manager1 = get_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is manager2
reset_plugin_manager()
def test_reset_plugin_manager(self):
"""reset_plugin_manager clears the singleton."""
manager1 = get_plugin_manager()
reset_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is not manager2
reset_plugin_manager()
class TestPipeline:
"""Test Pipeline functionality."""
def test_pipeline_creation(self):
"""Pipeline can be created."""
engine = FakeEngine()
backend = Pipeline(engine)
assert backend is not None
def test_pipeline_callable(self):
"""Pipeline is callable like old TTSBackend."""
engine = FakeEngine()
backend = Pipeline(engine)
# Should be callable
assert callable(backend)
def test_pipeline_synthesize(self):
"""Pipeline can synthesize text."""
engine = FakeEngine()
backend = Pipeline(engine)
# Call the backend
segments = list(backend("Hello world", voice="default", speed=1.0))
# Should return at least one segment
assert len(segments) >= 1
# Segment should have graphemes and audio
segment = segments[0]
assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world"
def test_pipeline_dispose(self):
"""Pipeline can be disposed."""
engine = FakeEngine()
backend = Pipeline(engine)
# Create a session by calling
list(backend("test"))
# Dispose should not raise
backend.dispose()
# Double dispose should be safe
backend.dispose()
class TestCreatePipelineCompat:
"""Test create_pipeline utility function."""
def test_create_pipeline_returns_callable(self):
"""create_pipeline returns a callable backend."""
# Mock the plugin manager
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock()
mock_get_manager.return_value = mock_manager
mock_engine = FakeEngine()
mock_manager.create_engine.return_value = mock_engine
backend = create_pipeline("kokoro", lang_code="a", device="cpu")
assert callable(backend)
mock_manager.create_engine.assert_called_once_with("kokoro", lang_code="a", device="cpu")
def test_create_pipeline_raises_for_unknown_plugin(self):
"""create_pipeline raises KeyError for unknown plugins."""
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock()
mock_get_manager.return_value = mock_manager
mock_manager.create_engine.side_effect = KeyError("Plugin not found")
with pytest.raises(KeyError):
create_pipeline("nonexistent")
class TestPluginManagerWithFakePlugins:
"""Test PluginManager with fake plugin loading."""
def test_plugin_manager_create_engine_from_plugin(self):
"""PluginManager creates engine from loaded plugin."""
manager = PluginManager()
# Manually add a fake plugin
def fake_create_engine(**kwargs):
return FakeEngine(**kwargs)
manager._plugins["fake"] = {
"manifest": MagicMock(),
"create_engine": fake_create_engine,
}
manager._loaded = True
# Create engine
engine = manager.create_engine("fake", param="value")
assert isinstance(engine, FakeEngine)
assert engine.kwargs == {"param": "value"}
def test_plugin_manager_get_or_create_engine(self):
"""PluginManager caches engines."""
manager = PluginManager()
call_count = 0
def fake_create_engine(**kwargs):
nonlocal call_count
call_count += 1
return FakeEngine(**kwargs)
manager._plugins["fake"] = {
"manifest": MagicMock(),
"create_engine": fake_create_engine,
}
manager._loaded = True
# Get engine twice
engine1 = manager.get_or_create_engine("fake")
engine2 = manager.get_or_create_engine("fake")
# Should be same instance
assert engine1 is engine2
assert call_count == 1
def test_plugin_manager_dispose_all(self):
"""PluginManager disposes all cached engines."""
manager = PluginManager()
def fake_create_engine(**kwargs):
return FakeEngine(**kwargs)
manager._plugins["fake"] = {
"manifest": MagicMock(),
"create_engine": fake_create_engine,
}
manager._loaded = True
# Create and cache engines
engine1 = manager.get_or_create_engine("fake")
engine2 = manager.get_or_create_engine("fake")
# Dispose all
manager.dispose_all()
# Engines should be disposed
assert engine1._disposed is True
assert engine2._disposed is True
# Cache should be empty
assert len(manager._engines) == 0
"""Integration tests for Plugin Manager and direct utility functions."""
import pytest
from unittest.mock import MagicMock, patch
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import Pipeline, create_pipeline
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
class FakeEngine:
"""Fake Engine for testing."""
def __init__(self, **kwargs):
self.kwargs = kwargs
self._disposed = False
def createSession(self):
return FakeEngineSession()
def dispose(self):
self._disposed = True
@property
def manifest(self):
return MagicMock()
class FakeEngineSession:
"""Fake EngineSession for testing."""
def __init__(self):
self._disposed = False
def synthesize(self, request):
# Return fake audio
import numpy as np
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
audio = np.zeros(1000, dtype=np.float32)
return SynthesizedAudio(
data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
)
def dispose(self):
self._disposed = True
class TestPluginManager:
"""Test PluginManager functionality."""
def test_plugin_manager_creation(self):
"""PluginManager can be created."""
manager = PluginManager()
assert manager is not None
def test_plugin_manager_list_discovers_plugins(self):
"""PluginManager discovers plugins from plugins directory."""
manager = PluginManager()
manager.discover("plugins")
plugins = manager.list_plugins()
# Should discover kokoro plugin if it exists
assert isinstance(plugins, list)
def test_plugin_manager_has_plugin_after_discover(self):
"""PluginManager reports plugins after discovery."""
manager = PluginManager()
manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists
# This is expected behavior
assert isinstance(manager._plugins, dict)
def test_plugin_manager_get_plugin_after_discover(self):
"""PluginManager returns plugin info after discovery."""
manager = PluginManager()
manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists
assert isinstance(manager._plugins, dict)
def test_plugin_manager_create_engine_not_found(self):
"""PluginManager raises KeyError for unknown plugins."""
manager = PluginManager()
with pytest.raises(KeyError, match="Plugin not found"):
manager.create_engine("nonexistent")
def test_plugin_manager_discover_with_empty_dir(self):
"""PluginManager handles missing plugins directory."""
manager = PluginManager()
manager.discover("/nonexistent/path")
plugins = manager.list_plugins()
assert plugins == []
def test_global_plugin_manager_singleton(self):
"""Global PluginManager is a singleton."""
reset_plugin_manager()
manager1 = get_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is manager2
reset_plugin_manager()
def test_reset_plugin_manager(self):
"""reset_plugin_manager clears the singleton."""
manager1 = get_plugin_manager()
reset_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is not manager2
reset_plugin_manager()
class TestPipeline:
"""Test Pipeline functionality."""
def test_pipeline_creation(self):
"""Pipeline can be created."""
engine = FakeEngine()
backend = Pipeline(engine)
assert backend is not None
def test_pipeline_callable(self):
"""Pipeline is callable like old TTSBackend."""
engine = FakeEngine()
backend = Pipeline(engine)
# Should be callable
assert callable(backend)
def test_pipeline_synthesize(self):
"""Pipeline can synthesize text."""
engine = FakeEngine()
backend = Pipeline(engine)
# Call the backend
segments = list(backend("Hello world", voice="default", speed=1.0))
# Should return at least one segment
assert len(segments) >= 1
# Segment should have graphemes and audio
segment = segments[0]
assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world"
def test_pipeline_dispose(self):
"""Pipeline can be disposed."""
engine = FakeEngine()
backend = Pipeline(engine)
# Create a session by calling
list(backend("test"))
# Dispose should not raise
backend.dispose()
# Double dispose should be safe
backend.dispose()
class TestCreatePipelineCompat:
"""Test create_pipeline utility function."""
def test_create_pipeline_returns_callable(self):
"""create_pipeline returns a callable backend."""
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig
# Mock the plugin manager
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock()
mock_get_manager.return_value = mock_manager
mock_engine = FakeEngine()
mock_manager.create_engine.return_value = mock_engine
backend = create_pipeline("kokoro", lang_code="a", device="cpu")
assert callable(backend)
mock_manager.create_engine.assert_called_once()
call_args = mock_manager.create_engine.call_args
assert call_args.args[0] == "kokoro"
assert isinstance(call_args.kwargs["context"], HostContext)
assert call_args.kwargs["model_path"] is None
assert isinstance(call_args.kwargs["config"], EngineConfig)
assert call_args.kwargs["config"].device == "cpu"
assert call_args.kwargs["config"].lang_code == "a"
def test_create_pipeline_raises_for_unknown_plugin(self):
"""create_pipeline raises KeyError for unknown plugins."""
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock()
mock_get_manager.return_value = mock_manager
mock_manager.create_engine.side_effect = KeyError("Plugin not found")
with pytest.raises(KeyError):
create_pipeline("nonexistent")
class TestPluginManagerWithFakePlugins:
"""Test PluginManager with fake plugin loading."""
def test_plugin_manager_create_engine_from_plugin(self):
"""PluginManager creates engine from loaded plugin."""
manager = PluginManager()
# Manually add a fake plugin
def fake_create_engine(**kwargs):
return FakeEngine(**kwargs)
manager._plugins["fake"] = {
"manifest": MagicMock(),
"create_engine": fake_create_engine,
}
manager._loaded = True
# Create engine
engine = manager.create_engine("fake", param="value")
assert isinstance(engine, FakeEngine)
assert engine.kwargs == {"param": "value"}
def test_plugin_manager_get_or_create_engine(self):
"""PluginManager caches engines."""
manager = PluginManager()
call_count = 0
def fake_create_engine(**kwargs):
nonlocal call_count
call_count += 1
return FakeEngine(**kwargs)
manager._plugins["fake"] = {
"manifest": MagicMock(),
"create_engine": fake_create_engine,
}
manager._loaded = True
# Get engine twice
engine1 = manager.get_or_create_engine("fake")
engine2 = manager.get_or_create_engine("fake")
# Should be same instance
assert engine1 is engine2
assert call_count == 1
def test_plugin_manager_dispose_all(self):
"""PluginManager disposes all cached engines."""
manager = PluginManager()
def fake_create_engine(**kwargs):
return FakeEngine(**kwargs)
manager._plugins["fake"] = {
"manifest": MagicMock(),
"create_engine": fake_create_engine,
}
manager._loaded = True
# Create and cache engines
engine1 = manager.get_or_create_engine("fake")
engine2 = manager.get_or_create_engine("fake")
# Dispose all
manager.dispose_all()
# Engines should be disposed
assert engine1._disposed is True
assert engine2._disposed is True
# Cache should be empty
assert len(manager._engines) == 0
+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()
+244 -207
View File
@@ -1,207 +1,244 @@
"""Contract tests for core domain value objects.
These tests verify that value objects satisfy the architectural requirements:
- Frozen (immutable) dataclasses
- Correct field definitions
- Proper equality behavior
"""
import pytest
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class TestAudioFormatContract:
"""Contract tests for AudioFormat value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(AudioFormat, "__dataclass_params__")
assert AudioFormat.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav"
assert af.extension == "wav"
def test_immutability(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav")
with pytest.raises(AttributeError):
af.mime = "audio/mpeg" # type: ignore[misc]
def test_equality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/wav", extension="wav")
assert af1 == af2
def test_inequality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
assert af1 != af2
def test_hashable(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav")
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
class TestDurationContract:
"""Contract tests for Duration value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(Duration, "__dataclass_params__")
assert Duration.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
d = Duration(seconds=1.5)
assert d.seconds == 1.5
def test_immutability(self) -> None:
d = Duration(seconds=1.0)
with pytest.raises(AttributeError):
d.seconds = 2.0 # type: ignore[misc]
def test_equality(self) -> None:
d1 = Duration(seconds=1.0)
d2 = Duration(seconds=1.0)
assert d1 == d2
class TestVoiceSelectionContract:
"""Contract tests for VoiceSelection value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(VoiceSelection, "__dataclass_params__")
assert VoiceSelection.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.source == "builtin"
assert vs.key == "af_nova"
def test_payload_default_none(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.payload is None
def test_payload_optional(self) -> None:
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
assert vs.payload == b"audio_data"
def test_immutability(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova")
with pytest.raises(AttributeError):
vs.source = "other" # type: ignore[misc]
class TestParameterValuesContract:
"""Contract tests for ParameterValues value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(ParameterValues, "__dataclass_params__")
assert ParameterValues.__dataclass_params__.frozen is True
def test_default_empty(self) -> None:
pv = ParameterValues()
assert pv.values == {}
def test_with_values(self) -> None:
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
assert pv.values["speed"] == 1.0
assert pv.values["pitch"] == 0.5
def test_immutability(self) -> None:
pv = ParameterValues(values={"speed": 1.0})
with pytest.raises(AttributeError):
pv.values = {} # type: ignore[misc]
class TestSynthesisRequestContract:
"""Contract tests for SynthesisRequest value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesisRequest, "__dataclass_params__")
assert SynthesisRequest.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
req = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
assert req.text == "Hello"
assert req.voice.source == "builtin"
assert req.format.mime == "audio/wav"
def test_immutability(self) -> None:
req = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(AttributeError):
req.text = "World" # type: ignore[misc]
class TestSynthesizedAudioContract:
"""Contract tests for SynthesizedAudio value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesizedAudio, "__dataclass_params__")
assert SynthesizedAudio.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
audio = SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
assert audio.data == b"\x00" * 100
assert audio.format.mime == "audio/wav"
assert audio.duration.seconds == 1.0
def test_immutability(self) -> None:
audio = SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
with pytest.raises(AttributeError):
audio.data = b"\x00" # type: ignore[misc]
class TestEngineConfigContract:
"""Contract tests for EngineConfig value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(EngineConfig, "__dataclass_params__")
assert EngineConfig.__dataclass_params__.frozen is True
def test_default_device(self) -> None:
config = EngineConfig()
assert config.device == "cpu"
def test_custom_device(self) -> None:
config = EngineConfig(device="cuda:0")
assert config.device == "cuda:0"
def test_immutability(self) -> None:
config = EngineConfig()
with pytest.raises(AttributeError):
config.device = "cuda:0" # type: ignore[misc]
def test_unknown_keys_ignored_per_spec(self) -> None:
"""Architecture spec: Unknown keys are ignored (no error).
EngineConfig is frozen, so unknown keys cannot be set after creation.
This test verifies the default behavior matches the spec.
"""
config = EngineConfig()
assert config.device == "cpu"
"""Contract tests for core domain value objects.
These tests verify that value objects satisfy the architectural requirements:
- Frozen (immutable) dataclasses
- Correct field definitions
- Proper equality behavior
"""
import pytest
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class TestAudioFormatContract:
"""Contract tests for AudioFormat value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(AudioFormat, "__dataclass_params__")
assert AudioFormat.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav"
assert af.extension == "wav"
def test_immutability(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav")
with pytest.raises(AttributeError):
af.mime = "audio/mpeg" # type: ignore[misc]
def test_equality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/wav", extension="wav")
assert af1 == af2
def test_inequality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
assert af1 != af2
def test_hashable(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav")
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
class TestDurationContract:
"""Contract tests for Duration value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(Duration, "__dataclass_params__")
assert Duration.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
d = Duration(seconds=1.5)
assert d.seconds == 1.5
def test_immutability(self) -> None:
d = Duration(seconds=1.0)
with pytest.raises(AttributeError):
d.seconds = 2.0 # type: ignore[misc]
def test_equality(self) -> None:
d1 = Duration(seconds=1.0)
d2 = Duration(seconds=1.0)
assert d1 == d2
class TestVoiceSelectionContract:
"""Contract tests for VoiceSelection value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(VoiceSelection, "__dataclass_params__")
assert VoiceSelection.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.source == "builtin"
assert vs.key == "af_nova"
def test_payload_default_none(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.payload is None
def test_payload_optional(self) -> None:
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
assert vs.payload == b"audio_data"
def test_immutability(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova")
with pytest.raises(AttributeError):
vs.source = "other" # type: ignore[misc]
class TestParameterValuesContract:
"""Contract tests for ParameterValues value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(ParameterValues, "__dataclass_params__")
assert ParameterValues.__dataclass_params__.frozen is True
def test_default_empty(self) -> None:
pv = ParameterValues()
assert pv.values == {}
def test_with_values(self) -> None:
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
assert pv.values["speed"] == 1.0
assert pv.values["pitch"] == 0.5
def test_immutability(self) -> None:
pv = ParameterValues(values={"speed": 1.0})
with pytest.raises(AttributeError):
pv.values = {} # type: ignore[misc]
class TestSynthesisRequestContract:
"""Contract tests for SynthesisRequest value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesisRequest, "__dataclass_params__")
assert SynthesisRequest.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
req = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
assert req.text == "Hello"
assert req.voice.source == "builtin"
assert req.format.mime == "audio/wav"
def test_immutability(self) -> None:
req = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(AttributeError):
req.text = "World" # type: ignore[misc]
class TestSynthesizedAudioContract:
"""Contract tests for SynthesizedAudio value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesizedAudio, "__dataclass_params__")
assert SynthesizedAudio.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
audio = SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
assert audio.data == b"\x00" * 100
assert audio.format.mime == "audio/wav"
assert audio.duration.seconds == 1.0
def test_immutability(self) -> None:
audio = SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
with pytest.raises(AttributeError):
audio.data = b"\x00" # type: ignore[misc]
class TestEngineConfigContract:
"""Contract tests for EngineConfig value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(EngineConfig, "__dataclass_params__")
assert EngineConfig.__dataclass_params__.frozen is True
def test_default_device(self) -> None:
config = EngineConfig()
assert config.device == "cpu"
def test_custom_device(self) -> None:
config = EngineConfig(device="cuda:0")
assert config.device == "cuda:0"
def test_default_lang_code(self) -> None:
config = EngineConfig()
assert config.lang_code == "a"
def test_custom_lang_code(self) -> None:
config = EngineConfig(lang_code="j")
assert config.lang_code == "j"
def test_immutability(self) -> None:
config = EngineConfig()
with pytest.raises(AttributeError):
config.device = "cuda:0" # type: ignore[misc]
def test_immutability_lang_code(self) -> None:
config = EngineConfig()
with pytest.raises(AttributeError):
config.lang_code = "j" # type: ignore[misc]
def test_unknown_keys_ignored_per_spec(self) -> None:
"""Architecture spec: Unknown keys are ignored (no error).
EngineConfig is frozen, so unknown keys cannot be set after creation.
This test verifies the default behavior matches the spec.
"""
config = EngineConfig()
assert config.device == "cpu"
def test_plugins_may_ignore_irrelevant_fields(self) -> None:
"""Architecture Amendment #1: Plugins ignore unsupported fields.
EngineConfig may contain fields that are not relevant to every plugin.
Plugins MUST ignore fields they do not need, not raise on them.
"""
config = EngineConfig(device="cuda:0", lang_code="j")
assert config.device == "cuda:0"
assert config.lang_code == "j"
# A plugin that only needs device simply reads config.device
# and ignores config.lang_code — this must not raise.
def test_engine_config_contains_engine_instance_configuration(self) -> None:
"""Architecture Amendment #1: EngineConfig definition.
EngineConfig contains parameters that define how a particular
Engine instance is created and that remain constant throughout
the lifetime of that Engine.
"""
config = EngineConfig(device="cpu", lang_code="a")
# Both fields are init-time, immutable, engine-scoped.
assert config.device == "cpu"
assert config.lang_code == "a"
+92 -92
View File
@@ -1,92 +1,92 @@
"""Fake plugin for testing the plugin loader.
This is a minimal valid plugin that satisfies the Plugin API contract.
It does NOT perform any real TTS synthesis.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import (
AudioFormatManifest,
EngineManifest,
PluginManifest,
VoiceSourceManifest,
)
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
)
class FakeSession:
"""Minimal EngineSession implementation for testing."""
def __init__(self) -> None:
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
raise EngineError("Session disposed")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def dispose(self) -> None:
self._disposed = True
class FakeEngine:
"""Minimal Engine implementation for testing."""
def __init__(self) -> None:
self._disposed = False
def createSession(self) -> EngineSession:
if self._disposed:
raise EngineError("Engine disposed")
return FakeSession()
def dispose(self) -> None:
self._disposed = True
PLUGIN_MANIFEST = PluginManifest(
id="fake_plugin",
name="Fake Plugin",
version="1.0.0",
api_version="1.0",
description="A fake plugin for testing",
author="Test Author",
capabilities=(),
engine=EngineManifest(
voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
),
audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"),
),
),
)
MODEL_REQUIREMENTS: list[Any] = []
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
"""Create a fake engine instance."""
return FakeEngine()
"""Fake plugin for testing the plugin loader.
This is a minimal valid plugin that satisfies the Plugin API contract.
It does NOT perform any real TTS synthesis.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import (
AudioFormatManifest,
EngineManifest,
PluginManifest,
VoiceSourceManifest,
)
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
)
class FakeSession:
"""Minimal EngineSession implementation for testing."""
def __init__(self) -> None:
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
raise EngineError("Session disposed")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def dispose(self) -> None:
self._disposed = True
class FakeEngine:
"""Minimal Engine implementation for testing."""
def __init__(self) -> None:
self._disposed = False
def createSession(self) -> EngineSession:
if self._disposed:
raise EngineError("Engine disposed")
return FakeSession()
def dispose(self) -> None:
self._disposed = True
PLUGIN_MANIFEST = PluginManifest(
id="fake_plugin",
name="Fake Plugin",
version="1.0.0",
api_version="1.0",
description="A fake plugin for testing",
author="Test Author",
capabilities=(),
engine=EngineManifest(
voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
),
audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"),
),
),
)
MODEL_REQUIREMENTS: list[Any] = []
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
"""Create a fake engine instance."""
return FakeEngine()
+18 -18
View File
@@ -1,18 +1,18 @@
"""Invalid plugin: raises ImportError during import."""
from __future__ import annotations
# This plugin intentionally raises an ImportError
raise ImportError("Simulated import error for testing")
# The following code will never be reached, but is here for documentation
from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest(
id="import_error",
name="Import Error Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin that fails to import",
author="Test Author",
)
"""Invalid plugin: raises ImportError during import."""
from __future__ import annotations
# This plugin intentionally raises an ImportError
raise ImportError("Simulated import error for testing")
# The following code will never be reached, but is here for documentation
from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest(
id="import_error",
name="Import Error Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin that fails to import",
author="Test Author",
)
+29 -29
View File
@@ -1,29 +1,29 @@
"""Invalid plugin: incompatible api_version (major version mismatch)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig
# api_version "2.0" has major version 2, but host expects major version 1
PLUGIN_MANIFEST = PluginManifest(
id="invalid_api_version",
name="Invalid API Version Plugin",
version="1.0.0",
api_version="2.0", # Major version mismatch!
description="Plugin with incompatible api_version",
author="Test Author",
)
MODEL_REQUIREMENTS: list[Any] = []
def create_engine(
context: Any,
model_path: Path | None,
config: EngineConfig,
) -> Any:
raise NotImplementedError("This plugin is invalid")
"""Invalid plugin: incompatible api_version (major version mismatch)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig
# api_version "2.0" has major version 2, but host expects major version 1
PLUGIN_MANIFEST = PluginManifest(
id="invalid_api_version",
name="Invalid API Version Plugin",
version="1.0.0",
api_version="2.0", # Major version mismatch!
description="Plugin with incompatible api_version",
author="Test Author",
)
MODEL_REQUIREMENTS: list[Any] = []
def create_engine(
context: Any,
model_path: Path | None,
config: EngineConfig,
) -> Any:
raise NotImplementedError("This plugin is invalid")
+29 -29
View File
@@ -1,29 +1,29 @@
"""Invalid plugin: unknown capabilities."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest(
id="invalid_capabilities",
name="Invalid Capabilities Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin with unknown capabilities",
author="Test Author",
capabilities=("voice_list", "unknown_capability", "another_unknown"),
)
MODEL_REQUIREMENTS: list[Any] = []
def create_engine(
context: Any,
model_path: Path | None,
config: EngineConfig,
) -> Any:
raise NotImplementedError("This plugin is invalid")
"""Invalid plugin: unknown capabilities."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest(
id="invalid_capabilities",
name="Invalid Capabilities Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin with unknown capabilities",
author="Test Author",
capabilities=("voice_list", "unknown_capability", "another_unknown"),
)
MODEL_REQUIREMENTS: list[Any] = []
def create_engine(
context: Any,
model_path: Path | None,
config: EngineConfig,
) -> Any:
raise NotImplementedError("This plugin is invalid")
+18 -18
View File
@@ -1,18 +1,18 @@
"""Invalid plugin: missing create_engine function."""
from __future__ import annotations
from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest(
id="missing_create_engine",
name="Missing Create Engine Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin missing create_engine",
author="Test Author",
)
MODEL_REQUIREMENTS: list = []
# This plugin intentionally does NOT export create_engine
"""Invalid plugin: missing create_engine function."""
from __future__ import annotations
from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest(
id="missing_create_engine",
name="Missing Create Engine Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin missing create_engine",
author="Test Author",
)
MODEL_REQUIREMENTS: list = []
# This plugin intentionally does NOT export create_engine
+10 -10
View File
@@ -1,10 +1,10 @@
"""Invalid plugin: missing PLUGIN_MANIFEST."""
from __future__ import annotations
# This plugin intentionally does NOT export PLUGIN_MANIFEST
MODEL_REQUIREMENTS: list = []
def create_engine(context, model_path, config):
raise NotImplementedError("This plugin is invalid")
"""Invalid plugin: missing PLUGIN_MANIFEST."""
from __future__ import annotations
# This plugin intentionally does NOT export PLUGIN_MANIFEST
MODEL_REQUIREMENTS: list = []
def create_engine(context, model_path, config):
raise NotImplementedError("This plugin is invalid")
@@ -1,28 +1,28 @@
"""Invalid plugin: missing MODEL_REQUIREMENTS."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest(
id="missing_model_requirements",
name="Missing Model Requirements Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin missing MODEL_REQUIREMENTS",
author="Test Author",
)
# This plugin intentionally does NOT export MODEL_REQUIREMENTS
def create_engine(
context: Any,
model_path: Path | None,
config: EngineConfig,
) -> Any:
raise NotImplementedError("This plugin is invalid")
"""Invalid plugin: missing MODEL_REQUIREMENTS."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest(
id="missing_model_requirements",
name="Missing Model Requirements Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin missing MODEL_REQUIREMENTS",
author="Test Author",
)
# This plugin intentionally does NOT export MODEL_REQUIREMENTS
def create_engine(
context: Any,
model_path: Path | None,
config: EngineConfig,
) -> Any:
raise NotImplementedError("This plugin is invalid")
File diff suppressed because it is too large Load Diff
+52 -52
View File
@@ -1,52 +1,52 @@
from types import SimpleNamespace
from typing import cast
from abogen.tts_plugin.utils import get_voices
from abogen.webui.conversion_runner import (
_chapter_voice_spec,
_chunk_voice_spec,
_collect_required_voice_ids,
)
from abogen.webui.service import Job
def _sample_job(formula: str) -> Job:
return cast(
Job,
SimpleNamespace(
voice="__custom_mix",
speakers={
"narrator": {
"resolved_voice": formula,
}
},
chapters=[],
chunks=[{}],
),
)
def test_chapter_voice_spec_uses_resolved_formula():
formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula)
assert _chapter_voice_spec(job, None) == formula
def test_chunk_voice_fallback_uses_resolved_formula():
formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula)
result = _chunk_voice_spec(job, {}, "")
assert result == formula
def test_voice_collection_includes_formula_components():
formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula)
voices = _collect_required_voice_ids(job)
assert {"af_nova", "am_liam"}.issubset(voices)
assert voices.issuperset(get_voices("kokoro"))
from types import SimpleNamespace
from typing import cast
from abogen.tts_plugin.utils import get_voices
from abogen.webui.conversion_runner import (
_chapter_voice_spec,
_chunk_voice_spec,
_collect_required_voice_ids,
)
from abogen.webui.service import Job
def _sample_job(formula: str) -> Job:
return cast(
Job,
SimpleNamespace(
voice="__custom_mix",
speakers={
"narrator": {
"resolved_voice": formula,
}
},
chapters=[],
chunks=[{}],
),
)
def test_chapter_voice_spec_uses_resolved_formula():
formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula)
assert _chapter_voice_spec(job, None) == formula
def test_chunk_voice_fallback_uses_resolved_formula():
formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula)
result = _chunk_voice_spec(job, {}, "")
assert result == formula
def test_voice_collection_includes_formula_components():
formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula)
voices = _collect_required_voice_ids(job)
assert {"af_nova", "am_liam"}.issubset(voices)
assert voices.issuperset(get_voices("kokoro"))
+265 -257
View File
@@ -1,257 +1,265 @@
"""Tests for the SuperTonic TTS Plugin.
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 (via EngineContractMixin)
- Implements VoiceLister capability
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.loader import load_plugin_from_dir
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import (
AudioFormat,
EngineConfig,
ParameterValues,
SynthesisRequest,
VoiceSelection,
)
from tests.contracts.engine_contract import EngineContractMixin
# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────
def _supertonic_available() -> bool:
try:
from supertonic import TTS # type: ignore[import-not-found]
return True
except ImportError:
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:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
return HostContext(
config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=FakeHttpClient(),
)
@pytest.fixture
def engine() -> Engine:
return _make_mock_engine()
# ──────────────────────────────────────────────────────────────
# Plugin Loading Tests
# ──────────────────────────────────────────────────────────────
class TestSuperTonicPluginLoading:
def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True
assert result.manifest is not None
assert result.create_engine is not None
def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True
manifest = result.manifest
assert isinstance(manifest, PluginManifest)
assert manifest.id == "supertonic"
assert manifest.name == "SuperTonic"
assert manifest.api_version == "1.0"
def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True
assert result.model_requirements is not None
assert isinstance(result.model_requirements, tuple)
def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True
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)
assert result.success is True
engine_manifest = result.manifest.engine
assert len(engine_manifest.voiceSources) > 0
assert len(engine_manifest.audioFormats) > 0
assert len(engine_manifest.parameters) > 0
# ──────────────────────────────────────────────────────────────
# Engine Creation (real backend, skipped if not installed)
# ──────────────────────────────────────────────────────────────
class TestSuperTonicEngineCreation:
@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")
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 / Session Contract (inherited from base)
# ──────────────────────────────────────────────────────────────
class TestSuperTonicEngineContract(EngineContractMixin):
"""Every test from EngineContractMixin runs against SuperTonicEngine."""
@pytest.fixture
def default_voice(self) -> str:
return "M1"
# ──────────────────────────────────────────────────────────────
# VoiceLister Tests
# ──────────────────────────────────────────────────────────────
class TestSuperTonicVoiceLister:
def test_list_voices(self) -> None:
engine = _make_mock_engine()
voices = engine.listVoices("builtin")
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 = _make_mock_engine()
for voice in engine.listVoices("builtin"):
assert isinstance(voice.tags, tuple)
assert len(voice.tags) > 0
engine.dispose()
def test_male_voices_have_male_tag(self) -> None:
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 = _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 = _make_mock_engine()
engine.dispose()
with pytest.raises(EngineError):
engine.listVoices("builtin")
# ──────────────────────────────────────────────────────────────
# SuperTonic-specific parameter tests
# ──────────────────────────────────────────────────────────────
class TestSuperTonicParameters:
def test_speed_parameter(self) -> None:
engine = _make_mock_engine()
session = engine.createSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="M1"),
parameters=ParameterValues(values={"speed": 1.5}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result.data, bytes)
session.dispose()
engine.dispose()
def test_total_steps_parameter(self) -> None:
engine = _make_mock_engine()
session = engine.createSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="M1"),
parameters=ParameterValues(values={"total_steps": 10}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result.data, bytes)
session.dispose()
engine.dispose()
def test_default_parameters(self) -> None:
engine = _make_mock_engine()
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 isinstance(result.data, bytes)
session.dispose()
engine.dispose()
"""Tests for the SuperTonic TTS Plugin.
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 (via EngineContractMixin)
- Implements VoiceLister capability
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.loader import load_plugin_from_dir
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import (
AudioFormat,
EngineConfig,
ParameterValues,
SynthesisRequest,
VoiceSelection,
)
from tests.contracts.engine_contract import EngineContractMixin
# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────
def _supertonic_available() -> bool:
try:
from supertonic import TTS # type: ignore[import-not-found]
return True
except ImportError:
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()]
engine = SuperTonicEngine(MockPipeline())
# Override listVoices for testing (real engine reads from manifest)
from abogen.tts_plugin.manifest import VoiceManifest
engine.listVoices = lambda source_id: [
VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("male",)),
VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("female",)),
]
return engine
# ──────────────────────────────────────────────────────────────
# 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:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
return HostContext(
config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=FakeHttpClient(),
)
@pytest.fixture
def engine() -> Engine:
return _make_mock_engine()
# ──────────────────────────────────────────────────────────────
# Plugin Loading Tests
# ──────────────────────────────────────────────────────────────
class TestSuperTonicPluginLoading:
def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True
assert result.manifest is not None
assert result.create_engine is not None
def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True
manifest = result.manifest
assert isinstance(manifest, PluginManifest)
assert manifest.id == "supertonic"
assert manifest.name == "SuperTonic"
assert manifest.api_version == "1.0"
def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True
assert result.model_requirements is not None
assert isinstance(result.model_requirements, tuple)
def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True
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)
assert result.success is True
engine_manifest = result.manifest.engine
assert len(engine_manifest.voiceSources) > 0
assert len(engine_manifest.audioFormats) > 0
assert len(engine_manifest.parameters) > 0
# ──────────────────────────────────────────────────────────────
# Engine Creation (real backend, skipped if not installed)
# ──────────────────────────────────────────────────────────────
class TestSuperTonicEngineCreation:
@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")
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 / Session Contract (inherited from base)
# ──────────────────────────────────────────────────────────────
class TestSuperTonicEngineContract(EngineContractMixin):
"""Every test from EngineContractMixin runs against SuperTonicEngine."""
@pytest.fixture
def default_voice(self) -> str:
return "M1"
# ──────────────────────────────────────────────────────────────
# VoiceLister Tests
# ──────────────────────────────────────────────────────────────
class TestSuperTonicVoiceLister:
def test_list_voices(self) -> None:
engine = _make_mock_engine()
voices = engine.listVoices("builtin")
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 = _make_mock_engine()
for voice in engine.listVoices("builtin"):
assert isinstance(voice.tags, tuple)
assert len(voice.tags) > 0
engine.dispose()
def test_male_voices_have_male_tag(self) -> None:
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 = _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 = _make_mock_engine()
engine.dispose()
with pytest.raises(EngineError):
engine.listVoices("builtin")
# ──────────────────────────────────────────────────────────────
# SuperTonic-specific parameter tests
# ──────────────────────────────────────────────────────────────
class TestSuperTonicParameters:
def test_speed_parameter(self) -> None:
engine = _make_mock_engine()
session = engine.createSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="M1"),
parameters=ParameterValues(values={"speed": 1.5}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result.data, bytes)
session.dispose()
engine.dispose()
def test_total_steps_parameter(self) -> None:
engine = _make_mock_engine()
session = engine.createSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="M1"),
parameters=ParameterValues(values={"total_steps": 10}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result.data, bytes)
session.dispose()
engine.dispose()
def test_default_parameters(self) -> None:
engine = _make_mock_engine()
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 isinstance(result.data, bytes)
session.dispose()
engine.dispose()
+69 -69
View File
@@ -1,69 +1,69 @@
from types import SimpleNamespace
from typing import cast
import pytest
from abogen.tts_plugin.utils import get_voices
from abogen.voice_cache import (
LocalEntryNotFoundError,
_CACHED_VOICES,
ensure_voice_assets,
)
from abogen.webui.conversion_runner import _collect_required_voice_ids
from abogen.webui.service import Job
@pytest.fixture(autouse=True)
def clear_voice_cache():
_CACHED_VOICES.clear()
yield
_CACHED_VOICES.clear()
def test_ensure_voice_assets_downloads_missing(monkeypatch):
recorded = []
cached = set()
def fake_download(**kwargs):
filename = kwargs["filename"]
if kwargs.get("local_files_only"):
if filename in cached:
return f"/tmp/{filename}"
raise LocalEntryNotFoundError(f"{filename} missing")
recorded.append(filename)
cached.add(filename)
return f"/tmp/{filename}"
monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
assert downloaded == {"af_nova", "am_liam"}
assert errors == {}
assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
recorded.clear()
downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
assert downloaded_again == set()
assert errors_again == {}
assert recorded == []
def test_collect_required_voice_ids_includes_all():
job = SimpleNamespace(
voice="af_nova",
chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
chunks=[{"voice": "am_michael"}],
speakers={
"hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
"narrator": {"voice": "af_nova"},
},
)
voices = _collect_required_voice_ids(cast(Job, job))
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
assert voices.issuperset(get_voices("kokoro"))
from types import SimpleNamespace
from typing import cast
import pytest
from abogen.tts_plugin.utils import get_voices
from abogen.voice_cache import (
LocalEntryNotFoundError,
_CACHED_VOICES,
ensure_voice_assets,
)
from abogen.webui.conversion_runner import _collect_required_voice_ids
from abogen.webui.service import Job
@pytest.fixture(autouse=True)
def clear_voice_cache():
_CACHED_VOICES.clear()
yield
_CACHED_VOICES.clear()
def test_ensure_voice_assets_downloads_missing(monkeypatch):
recorded = []
cached = set()
def fake_download(**kwargs):
filename = kwargs["filename"]
if kwargs.get("local_files_only"):
if filename in cached:
return f"/tmp/{filename}"
raise LocalEntryNotFoundError(f"{filename} missing")
recorded.append(filename)
cached.add(filename)
return f"/tmp/{filename}"
monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
assert downloaded == {"af_nova", "am_liam"}
assert errors == {}
assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
recorded.clear()
downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
assert downloaded_again == set()
assert errors_again == {}
assert recorded == []
def test_collect_required_voice_ids_includes_all():
job = SimpleNamespace(
voice="af_nova",
chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
chunks=[{"voice": "am_michael"}],
speakers={
"hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
"narrator": {"voice": "af_nova"},
},
)
voices = _collect_required_voice_ids(cast(Job, job))
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
assert voices.issuperset(get_voices("kokoro"))