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
+135
View File
@@ -0,0 +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()