mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
- Create tests/plugins/ with auto-discovery fixtures and generic tests - Add conftest.py with plugin_ids, loaded_plugin, host_context fixtures - Add test_all_plugins.py with 3 test classes: - TestAllPluginsManifest: validates manifest structure - TestAllPluginsEngine: validates engine lifecycle contract - TestAllPluginsCapabilities: validates capability implementation - Update docs/testing.md with auto-discovery documentation - Plugin-specific tests remain in tests/test_*_plugin.py for integration New plugins in plugins/ are now automatically tested without manual test creation.
235 lines
9.6 KiB
Python
235 lines
9.6 KiB
Python
"""Auto-discovery tests for all TTS plugins.
|
|
|
|
These tests automatically run for every plugin in the plugins/ directory.
|
|
Tests are grouped into three categories:
|
|
|
|
1. TestAllPluginsManifest - Validates manifest structure
|
|
- Required fields (id, name, version, api_version, etc.)
|
|
- API version format (semver MAJOR.MINOR)
|
|
- Voices field (optional)
|
|
|
|
2. TestAllPluginsEngine - Validates engine lifecycle
|
|
- create_engine returns valid Engine
|
|
- dispose is idempotent
|
|
- dispose → createSession raises EngineError
|
|
|
|
3. TestAllPluginsCapabilities - Validates capability implementation
|
|
- Declared capabilities are implemented
|
|
- voice_list → VoiceLister interface
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
from abogen.tts_plugin.errors import EngineError
|
|
from abogen.tts_plugin.manifest import PluginManifest
|
|
from abogen.tts_plugin.types import EngineConfig, VoiceSelection, AudioFormat, ParameterValues, SynthesisRequest
|
|
|
|
|
|
class TestAllPluginsManifest:
|
|
"""Test that all plugins have valid manifest structure."""
|
|
|
|
def test_manifest_has_required_fields(self, loaded_plugin):
|
|
"""Verify manifest has all required fields."""
|
|
manifest = loaded_plugin.manifest
|
|
assert manifest is not None
|
|
|
|
# Required fields
|
|
assert manifest.id, "Plugin id must not be empty"
|
|
assert manifest.name, "Plugin name must not be empty"
|
|
assert manifest.version, "Plugin version must not be empty"
|
|
assert manifest.api_version, "Plugin api_version must not be empty"
|
|
assert manifest.description, "Plugin description must not be empty"
|
|
assert manifest.author, "Plugin author must not be empty"
|
|
|
|
def test_manifest_id_matches_directory(self, loaded_plugin, plugin_dir):
|
|
"""Verify manifest id matches the plugin directory name."""
|
|
manifest = loaded_plugin.manifest
|
|
assert manifest.id == plugin_dir.name, \
|
|
f"Manifest id '{manifest.id}' must match directory name '{plugin_dir.name}'"
|
|
|
|
def test_api_version_format(self, loaded_plugin):
|
|
"""Verify api_version follows semver format (MAJOR.MINOR)."""
|
|
manifest = loaded_plugin.manifest
|
|
api_version = manifest.api_version
|
|
|
|
# Must match MAJOR.MINOR format
|
|
pattern = r"^\d+\.\d+$"
|
|
assert re.match(pattern, api_version), \
|
|
f"api_version '{api_version}' must be in format MAJOR.MINOR (e.g., 1.0)"
|
|
|
|
def test_api_version_compatibility(self, loaded_plugin):
|
|
"""Verify api_version major version matches host API version."""
|
|
from abogen.tts_plugin.loader import HOST_API_VERSION
|
|
|
|
manifest = loaded_plugin.manifest
|
|
plugin_ver = manifest.api_version
|
|
host_ver = HOST_API_VERSION
|
|
|
|
# Extract major versions
|
|
plugin_major = int(plugin_ver.split(".")[0])
|
|
host_major = int(host_ver.split(".")[0])
|
|
|
|
assert plugin_major == host_major, \
|
|
f"API version major mismatch: plugin={plugin_major}, host={host_major}"
|
|
|
|
def test_voices_field_is_optional(self, loaded_plugin):
|
|
"""Verify voices field is optional (can be None or tuple)."""
|
|
manifest = loaded_plugin.manifest
|
|
voices = manifest.voices
|
|
|
|
# voices can be None (not declared) or tuple (empty or with voices)
|
|
assert voices is None or isinstance(voices, tuple), \
|
|
f"voices must be None or tuple, got {type(voices).__name__}"
|
|
|
|
def test_capabilities_is_tuple(self, loaded_plugin):
|
|
"""Verify capabilities is a tuple."""
|
|
manifest = loaded_plugin.manifest
|
|
assert isinstance(manifest.capabilities, tuple), \
|
|
f"capabilities must be a tuple, got {type(manifest.capabilities).__name__}"
|
|
|
|
def test_engine_manifest_exists(self, loaded_plugin):
|
|
"""Verify engine manifest exists and has required fields."""
|
|
manifest = loaded_plugin.manifest
|
|
engine_manifest = manifest.engine
|
|
|
|
assert engine_manifest is not None
|
|
assert isinstance(engine_manifest.voiceSources, tuple)
|
|
assert isinstance(engine_manifest.parameters, tuple)
|
|
assert isinstance(engine_manifest.audioFormats, tuple)
|
|
|
|
|
|
class TestAllPluginsEngine:
|
|
"""Test that all plugins satisfy the Engine contract."""
|
|
|
|
def test_create_engine_returns_engine(self, loaded_plugin, host_context, engine_config):
|
|
"""Verify create_engine returns an Engine instance."""
|
|
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
assert isinstance(engine, Engine), \
|
|
f"create_engine must return Engine instance, got {type(engine).__name__}"
|
|
engine.dispose()
|
|
|
|
def test_dispose_is_idempotent(self, loaded_plugin, host_context, engine_config):
|
|
"""Verify dispose can be called multiple times without error."""
|
|
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
|
|
# First dispose
|
|
engine.dispose()
|
|
|
|
# Second dispose should not raise
|
|
engine.dispose()
|
|
|
|
def test_create_session_after_dispose_raises(self, loaded_plugin, host_context, engine_config):
|
|
"""Verify createSession raises EngineError after dispose."""
|
|
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
engine.dispose()
|
|
|
|
with pytest.raises(EngineError):
|
|
engine.createSession()
|
|
|
|
def test_dispose_after_dispose_raises(self, loaded_plugin, host_context, engine_config):
|
|
"""Verify dispose after dispose does not raise (idempotent)."""
|
|
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
engine.dispose()
|
|
|
|
# Should not raise
|
|
engine.dispose()
|
|
|
|
def test_create_session_returns_valid_session(self, loaded_plugin, host_context, engine_config):
|
|
"""Verify createSession returns an EngineSession instance."""
|
|
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
session = engine.createSession()
|
|
|
|
assert isinstance(session, EngineSession), \
|
|
f"createSession must return EngineSession instance, got {type(session).__name__}"
|
|
session.dispose()
|
|
engine.dispose()
|
|
|
|
def test_session_dispose_is_idempotent(self, loaded_plugin, host_context, engine_config):
|
|
"""Verify session dispose can be called multiple times."""
|
|
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
session = engine.createSession()
|
|
|
|
# First dispose
|
|
session.dispose()
|
|
|
|
# Second dispose should not raise
|
|
session.dispose()
|
|
engine.dispose()
|
|
|
|
def test_session_synthesize_after_dispose_raises(self, loaded_plugin, host_context, engine_config):
|
|
"""Verify session.synthesize raises EngineError after dispose."""
|
|
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
session = engine.createSession()
|
|
session.dispose()
|
|
|
|
# Create a minimal request (won't actually synthesize, just test error)
|
|
request = SynthesisRequest(
|
|
text="test",
|
|
voice=VoiceSelection(source="test", key="test"),
|
|
parameters=ParameterValues(values={}),
|
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
)
|
|
|
|
with pytest.raises(EngineError):
|
|
session.synthesize(request)
|
|
engine.dispose()
|
|
|
|
|
|
class TestAllPluginsCapabilities:
|
|
"""Test that all plugins implement their declared capabilities."""
|
|
|
|
def test_voice_list_capability(self, loaded_plugin, host_context, engine_config):
|
|
"""Verify plugins with 'voice_list' capability implement VoiceLister."""
|
|
manifest = loaded_plugin.manifest
|
|
|
|
if "voice_list" not in manifest.capabilities:
|
|
pytest.skip("Plugin does not declare 'voice_list' capability")
|
|
|
|
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
|
|
# Check if listVoices method exists
|
|
assert hasattr(engine, "listVoices"), \
|
|
"Plugin with 'voice_list' capability must have listVoices method"
|
|
|
|
# listVoices should be callable
|
|
assert callable(engine.listVoices), "listVoices must be callable"
|
|
|
|
# listVoices should return a list/tuple of voices
|
|
# Get first voice source from manifest
|
|
if manifest.engine.voiceSources:
|
|
source_id = manifest.engine.voiceSources[0].id
|
|
voices = engine.listVoices(source_id)
|
|
|
|
assert isinstance(voices, (list, tuple)), \
|
|
f"listVoices must return list or tuple, got {type(voices).__name__}"
|
|
|
|
# Each voice should have id, name, tags
|
|
for voice in voices:
|
|
assert hasattr(voice, "id"), "Voice must have 'id' attribute"
|
|
assert hasattr(voice, "name"), "Voice must have 'name' attribute"
|
|
assert hasattr(voice, "tags"), "Voice must have 'tags' attribute"
|
|
assert isinstance(voice.tags, tuple), "Voice tags must be a tuple"
|
|
|
|
engine.dispose()
|
|
|
|
def test_known_capabilities_are_valid(self, loaded_plugin):
|
|
"""Verify all declared capabilities are known."""
|
|
manifest = loaded_plugin.manifest
|
|
|
|
known_capabilities = frozenset({
|
|
"voice_list",
|
|
"preview",
|
|
"voice_clone",
|
|
"voice_blend",
|
|
"streaming",
|
|
"cancel",
|
|
})
|
|
|
|
for cap in manifest.capabilities:
|
|
assert cap in known_capabilities, \
|
|
f"Unknown capability: '{cap}'. Known capabilities: {known_capabilities}" |