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