feat: add contract test suite for Plugin API

Create reusable contract tests for TTS Plugin Architecture:
- conftest.py: shared fixtures and stubs (FakeEngine, FakeSession, etc.)
- test_types_contract.py: value object contracts (frozen, immutability, equality)
- test_errors_contract.py: error hierarchy contracts
- test_manifest_contract.py: manifest type contracts
- test_engine_contract.py: Engine protocol contracts (lifecycle, dispose)
- test_session_contract.py: EngineSession protocol contracts
- test_capabilities_contract.py: capability protocol contracts
- test_host_context_contract.py: HostContext contracts
- test_plugin_contract.py: plugin contract (exports, create_engine)

124 tests covering all public API contracts.
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:05 +03:00
parent 79b3d26f66
commit 0f568120f4
10 changed files with 1477 additions and 0 deletions
+231
View File
@@ -0,0 +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,
)