Files
abogen/tests/test_behavioral_regression.py
Artem Akymenko d8fcfb1cce chore: normalize line endings to LF, add .gitattributes
- Add .gitattributes with text=auto eol=lf for all text files
- Renormalize all files in index to LF line endings
- Fixes massive whitespace-only diffs between main and feature branch
2026-07-12 16:20:42 +03:00

1089 lines
41 KiB
Python

"""Behavioral Regression Tests for TTS Plugin Architecture.
These tests verify external user-facing behavior, NOT internal implementation.
They use only public API entry points available to application consumers.
Tested plugins: Kokoro, SuperTonic.
Public API Surface Tested:
- PluginManager: discover, list_plugins, has_plugin, create_engine, get_or_create_engine, dispose_all
- Engine: createSession, dispose
- EngineSession: synthesize, dispose
- VoiceLister: listVoices
- Pipeline (utils.py): __call__, dispose
- create_pipeline, get_voices, get_default_voice, is_plugin_registered, resolve_voice_to_plugin
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from unittest.mock import patch
import numpy as np
import pytest
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 PluginManifest, EngineManifest, VoiceManifest
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
from abogen.tts_plugin.utils import (
Pipeline,
create_pipeline,
get_default_voice,
get_voices,
is_plugin_registered,
resolve_voice_to_plugin,
)
# ──────────────────────────────────────────────────────────────
# Plugin Mock Infrastructure
# ──────────────────────────────────────────────────────────────
def _make_request(
text: str = "Hello",
voice: str = "voice1",
speed: float = 1.0,
) -> SynthesisRequest:
return SynthesisRequest(
text=text,
voice=VoiceSelection(source="builtin", key=voice),
parameters=ParameterValues(values={"speed": speed}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
class MockEngineSession:
"""Mock EngineSession that records calls."""
def __init__(self) -> None:
self._disposed = False
self.synthesize_calls: list[SynthesisRequest] = []
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
raise EngineError("Session disposed")
self.synthesize_calls.append(request)
return SynthesizedAudio(
data=b"\x00" * 1000,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def dispose(self) -> None:
self._disposed = True
class MockEngine:
"""Mock Engine with VoiceLister support."""
def __init__(
self,
voice_manifests: list[VoiceManifest] | None = None,
**kwargs: Any,
) -> None:
self._disposed = False
self._voice_manifests = voice_manifests or [
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
]
def createSession(self) -> EngineSession:
if self._disposed:
raise EngineError("Engine disposed")
return MockEngineSession()
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
if self._disposed:
raise EngineError("Engine disposed")
return self._voice_manifests
def dispose(self) -> None:
self._disposed = True
class MockEngineAcceptKwargs:
"""MockEngine that accepts arbitrary kwargs (for create_engine)."""
def __init__(self, **kwargs: Any) -> None:
self._disposed = False
self._kwargs = kwargs
def createSession(self) -> EngineSession:
if self._disposed:
raise EngineError("Engine disposed")
return MockEngineSession()
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
if self._disposed:
raise EngineError("Engine disposed")
return [
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
]
def dispose(self) -> None:
self._disposed = True
def _create_mock_plugin(
engine_class: type = MockEngine,
manifest_id: str = "mock_tts",
) -> dict:
manifest = PluginManifest(
id=manifest_id,
name="Mock TTS",
version="1.0.0",
api_version="1.0",
description="Mock TTS for testing",
author="Test",
capabilities=("voice_list",),
engine=EngineManifest(
voiceSources=(),
parameters=(),
audioFormats=(),
),
)
return {
"manifest": manifest,
"create_engine": lambda **kwargs: engine_class(**kwargs),
"module": None,
}
# ──────────────────────────────────────────────────────────────
# Kokoro / SuperTonic Plugin Fixtures
# ──────────────────────────────────────────────────────────────
def _kokoro_available() -> bool:
try:
from kokoro import KPipeline # type: ignore[import-not-found]
return True
except ImportError:
return False
def _supertonic_available() -> bool:
try:
from supertonic import TTS # type: ignore[import-not-found]
return True
except ImportError:
return False
class _KokoroMockEngine:
"""Simulates Kokoro Engine behavior for behavioral tests."""
def __init__(self, **kwargs: Any) -> None:
self._disposed = False
self._kwargs = kwargs
self._voices = [
VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")),
VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")),
VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")),
]
def createSession(self) -> EngineSession:
if self._disposed:
raise EngineError("Engine disposed")
return MockEngineSession()
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
if self._disposed:
raise EngineError("Engine disposed")
return self._voices
def dispose(self) -> None:
self._disposed = True
class _SuperTonicMockEngine:
"""Simulates SuperTonic Engine behavior for behavioral tests."""
def __init__(self, **kwargs: Any) -> None:
self._disposed = False
self._kwargs = kwargs
self._voices = [
VoiceManifest(id="M1", name="Male 1", tags=("en", "male")),
VoiceManifest(id="F1", name="Female 1", tags=("en", "female")),
VoiceManifest(id="M2", name="Male 2", tags=("en", "male")),
]
def createSession(self) -> EngineSession:
if self._disposed:
raise EngineError("Engine disposed")
return MockEngineSession()
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
if self._disposed:
raise EngineError("Engine disposed")
return self._voices
def dispose(self) -> None:
self._disposed = True
# Parametrize across both production plugins
_plugin_ids = ["kokoro", "supertonic"]
_plugin_engines = {
"kokoro": _KokoroMockEngine,
"supertonic": _SuperTonicMockEngine,
}
_plugin_default_voices = {
"kokoro": "af_nova",
"supertonic": "M1",
}
_plugin_all_voices = {
"kokoro": ["af_nova", "af_bella", "am_adam"],
"supertonic": ["M1", "F1", "M2"],
}
def _plugin_available(plugin_id: str) -> bool:
if plugin_id == "kokoro":
return _kokoro_available()
elif plugin_id == "supertonic":
return _supertonic_available()
return False
# ──────────────────────────────────────────────────────────────
# 1. SYNTHESIS SCENARIOS (parametrized per plugin)
# ──────────────────────────────────────────────────────────────
class TestSynthesisNormalText:
"""Synthesis with normal text input."""
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_short_text(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text="Hello world"))
assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_paragraph_text(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
text = "This is a longer paragraph with multiple sentences. It tests synthesis of more substantial text content."
result = session.synthesize(_make_request(text=text))
assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_with_punctuation(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
text = "Hello, world! How are you? I'm fine... Really?"
result = session.synthesize(_make_request(text=text))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
class TestSynthesisLongText:
"""Synthesis with long text input."""
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_very_long_text(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
text = "Word " * 10000
result = session.synthesize(_make_request(text=text))
assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_multiline_text(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
text = "\n".join([f"Line {i} of the text." for i in range(100)])
result = session.synthesize(_make_request(text=text))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
class TestSynthesisEmptyText:
"""Synthesis with empty text input."""
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_empty_string(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text=""))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_whitespace_only(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text=" \n\t "))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
class TestSynthesisUnicodeText:
"""Synthesis with Unicode text input."""
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_cyrillic(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text="Привет мир"))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_chinese(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text="你好世界"))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_emoji(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text="Hello 🌍"))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_mixed_scripts(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text="Hello 你好 Привет"))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_accented_characters(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text="Café résumé naïve"))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
# ──────────────────────────────────────────────────────────────
# 2. VOICE SCENARIOS (parametrized per plugin)
# ──────────────────────────────────────────────────────────────
class TestVoiceListing:
"""Voice listing via VoiceLister capability."""
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_list_voices_returns_manifests(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
voices = engine.listVoices("builtin")
assert isinstance(voices, list)
assert len(voices) > 0
for v in voices:
assert isinstance(v, VoiceManifest)
assert hasattr(v, "id")
assert hasattr(v, "name")
assert hasattr(v, "tags")
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_voices_have_required_fields(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
voices = engine.listVoices("builtin")
for v in voices:
assert isinstance(v.id, str)
assert len(v.id) > 0
assert isinstance(v.name, str)
assert len(v.name) > 0
assert isinstance(v.tags, tuple)
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_voice_ids_match_manifest(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
voices = engine.listVoices("builtin")
voice_ids = [v.id for v in voices]
for expected_id in _plugin_all_voices[plugin_id]:
assert expected_id in voice_ids
engine.dispose()
class TestVoiceSelection:
"""Using different voices for synthesis."""
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_with_each_voice(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
for voice_id in _plugin_all_voices[plugin_id]:
result = session.synthesize(
_make_request(text="Hello", voice=voice_id)
)
assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_with_invalid_voice(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(
_make_request(text="Hello", voice="nonexistent_voice")
)
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
# ──────────────────────────────────────────────────────────────
# 3. PARAMETER SCENARIOS (parametrized per plugin)
# ──────────────────────────────────────────────────────────────
class TestSpeedParameter:
"""Speed parameter behavior."""
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_with_speed_1_0(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text="Hello", speed=1.0))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_with_speed_0_5(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text="Hello", speed=0.5))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_with_speed_2_0(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
result = session.synthesize(_make_request(text="Hello", speed=2.0))
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_with_default_speed(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key=_plugin_default_voices[plugin_id]),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio)
session.dispose()
engine.dispose()
# ──────────────────────────────────────────────────────────────
# 4. ERROR SCENARIOS
# ──────────────────────────────────────────────────────────────
class TestUnknownPlugin:
"""Handling of unknown plugin IDs."""
def test_create_engine_unknown_plugin(self) -> None:
manager = PluginManager()
manager._loaded = True
with pytest.raises(KeyError, match="Plugin not found"):
manager.create_engine("nonexistent_plugin")
def test_has_plugin_unknown(self) -> None:
manager = PluginManager()
manager._loaded = True
assert manager.has_plugin("nonexistent_plugin") is False
def test_get_plugin_unknown(self) -> None:
manager = PluginManager()
manager._loaded = True
assert manager.get_plugin("nonexistent_plugin") is None
class TestPluginLoadingFailure:
"""Handling of plugin loading failures."""
def test_discover_nonexistent_directory(self) -> None:
manager = PluginManager()
manager.discover("/nonexistent/path")
assert manager.list_plugins() == []
def test_discover_empty_directory(self, tmp_path: Path) -> None:
manager = PluginManager()
manager.discover(str(tmp_path))
assert manager.list_plugins() == []
# ──────────────────────────────────────────────────────────────
# 5. LIFECYCLE SCENARIOS (parametrized per plugin)
# ──────────────────────────────────────────────────────────────
class TestMultipleSynthesis:
"""Multiple synthesis operations."""
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_sequential_synthesis(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
session = engine.createSession()
for i in range(10):
result = session.synthesize(_make_request(text=f"Text {i}"))
assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_multiple_sessions(self, plugin_id: str) -> None:
engine = _plugin_engines[plugin_id]()
sessions = [engine.createSession() for _ in range(5)]
for i, session in enumerate(sessions):
result = session.synthesize(_make_request(text=f"Session {i}"))
assert isinstance(result, SynthesizedAudio)
for session in sessions:
session.dispose()
engine.dispose()
@pytest.mark.parametrize("plugin_id", _plugin_ids)
def test_synthesize_after_failed_synthesize(self, plugin_id: str) -> None:
"""Session remains usable after synthesis failure."""
class FailingSession:
def __init__(self) -> None:
self._call_count = 0
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
raise EngineError("Session disposed")
self._call_count += 1
if self._call_count == 1:
raise EngineError("First call fails")
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
session = FailingSession()
with pytest.raises(EngineError):
session.synthesize(_make_request(text="Fail"))
result = session.synthesize(_make_request(text="Succeed"))
assert isinstance(result, SynthesizedAudio)
session.dispose()
class TestPipelineRecreation:
"""Pipeline creation and disposal."""
def test_create_and_dispose_pipeline(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
pipeline = create_pipeline("mock_tts")
assert isinstance(pipeline, Pipeline)
result = list(pipeline("Hello", voice="voice1", speed=1.0))
assert len(result) >= 1
pipeline.dispose()
def test_pipeline_dispose_idempotent(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
pipeline = create_pipeline("mock_tts")
pipeline.dispose()
pipeline.dispose() # Should not raise
class TestResourceCleanup:
"""Resource cleanup and disposal."""
def test_engine_dispose_is_idempotent(self) -> None:
engine = MockEngine()
engine.dispose()
engine.dispose() # Should not raise
def test_session_dispose_is_idempotent(self) -> None:
session = MockEngineSession()
session.dispose()
session.dispose() # Should not raise
def test_create_session_after_engine_dispose_raises(self) -> None:
engine = MockEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
def test_synthesize_after_session_dispose_raises(self) -> None:
session = MockEngineSession()
session.dispose()
with pytest.raises(EngineError):
session.synthesize(_make_request())
def test_dispose_all_engines(self) -> None:
manager = PluginManager()
mock_plugin = _create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
engine1 = manager.get_or_create_engine("mock_tts")
engine2 = manager.get_or_create_engine("mock_tts")
manager.dispose_all()
assert engine1._disposed is True
assert engine2._disposed is True
assert len(manager._engines) == 0
def test_no_exception_on_normal_termination(self) -> None:
"""Full lifecycle completes without unexpected exceptions."""
engine = MockEngine()
session = engine.createSession()
result = session.synthesize(_make_request(text="Hello"))
assert len(result.data) > 0
session.dispose()
engine.dispose()
# ──────────────────────────────────────────────────────────────
# 6. PLUGIN MANAGER SCENARIOS
# ──────────────────────────────────────────────────────────────
class TestPluginManagerDiscovery:
"""Plugin discovery and listing."""
def test_discover_with_valid_plugins(self) -> None:
manager = PluginManager()
manager._plugins["plugin_a"] = _create_mock_plugin(manifest_id="plugin_a")
manager._plugins["plugin_b"] = _create_mock_plugin(manifest_id="plugin_b")
manager._loaded = True
plugins = manager.list_plugins()
assert len(plugins) == 2
ids = [p.id for p in plugins]
assert "plugin_a" in ids
assert "plugin_b" in ids
def test_has_plugin(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
assert manager.has_plugin("mock_tts") is True
assert manager.has_plugin("other") is False
def test_get_plugin_returns_info(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
info = manager.get_plugin("mock_tts")
assert info is not None
assert "manifest" in info
assert "create_engine" in info
class TestPluginManagerEngineCreation:
"""Engine creation via PluginManager."""
def test_create_engine(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
engine = manager.create_engine("mock_tts")
assert isinstance(engine, MockEngine)
engine.dispose()
def test_get_or_create_engine_caches(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
engine1 = manager.get_or_create_engine("mock_tts")
engine2 = manager.get_or_create_engine("mock_tts")
assert engine1 is engine2
def test_create_engine_unknown_plugin_raises(self) -> None:
manager = PluginManager()
manager._loaded = True
with pytest.raises(KeyError):
manager.create_engine("nonexistent")
def test_create_engine_with_kwargs(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = {
"manifest": PluginManifest(
id="mock_tts", name="Mock TTS", version="1.0.0",
api_version="1.0", description="Mock TTS for testing",
author="Test", capabilities=("voice_list",),
engine=EngineManifest(voiceSources=(), parameters=(), audioFormats=()),
),
"create_engine": lambda **kwargs: MockEngineAcceptKwargs(**kwargs),
"module": None,
}
manager._loaded = True
engine = manager.create_engine("mock_tts", device="cpu")
assert isinstance(engine, MockEngineAcceptKwargs)
assert engine._kwargs["device"] == "cpu"
engine.dispose()
# ──────────────────────────────────────────────────────────────
# 7. VOICE RESOLUTION SCENARIOS
# ──────────────────────────────────────────────────────────────
class TestVoiceResolution:
"""Voice-to-plugin resolution."""
def test_resolve_empty_spec(self) -> None:
assert resolve_voice_to_plugin("", fallback="kokoro") == "kokoro"
def test_resolve_none_spec(self) -> None:
assert resolve_voice_to_plugin(None, fallback="kokoro") == "kokoro"
def test_resolve_formula_with_star(self) -> None:
assert resolve_voice_to_plugin("voice1*0.7") == "kokoro"
def test_resolve_formula_with_plus(self) -> None:
assert resolve_voice_to_plugin("voice1*0.7+voice2*0.3") == "kokoro"
def test_resolve_unknown_voice_returns_fallback(self) -> None:
assert resolve_voice_to_plugin("unknown_voice") == "kokoro"
class TestGetVoices:
"""Voice listing utility functions."""
def test_get_voices_registered_plugin(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin(engine_class=MockEngineAcceptKwargs)
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
voices = get_voices("mock_tts")
assert isinstance(voices, tuple)
assert len(voices) > 0
assert all(isinstance(v, str) for v in voices)
def test_get_voices_unregistered_plugin(self) -> None:
manager = PluginManager()
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
voices = get_voices("nonexistent")
assert voices == ()
def test_get_default_voice(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin(engine_class=MockEngineAcceptKwargs)
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
voice = get_default_voice("mock_tts")
assert isinstance(voice, str)
assert len(voice) > 0
def test_get_default_voice_unregistered(self) -> None:
manager = PluginManager()
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
voice = get_default_voice("nonexistent", fallback="default")
assert voice == "default"
def test_is_plugin_registered(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
assert is_plugin_registered("mock_tts") is True
assert is_plugin_registered("nonexistent") is False
# ──────────────────────────────────────────────────────────────
# 8. ERROR HIERARCHY BEHAVIORAL
# ──────────────────────────────────────────────────────────────
class TestErrorHierarchyBehavioral:
"""Error hierarchy behavioral tests."""
def test_all_errors_catchable_as_engine_error(self) -> None:
from abogen.tts_plugin.errors import (
CancelledError,
ConfigurationError,
InternalError,
InvalidInputError,
ModelLoadError,
ModelNotFoundError,
NetworkError,
)
error_classes = [
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for error_class in error_classes:
with pytest.raises(EngineError):
raise error_class("test")
def test_error_message_preserved(self) -> None:
from abogen.tts_plugin.errors import InvalidInputError
msg = "Model not found: bert-base"
with pytest.raises(EngineError, match=msg):
raise InvalidInputError(msg)
# ──────────────────────────────────────────────────────────────
# 9. VALUE OBJECT BEHAVIORAL
# ──────────────────────────────────────────────────────────────
class TestValueObjectsBehavioral:
"""Value object behavioral tests."""
def test_synthesis_request_immutability(self) -> None:
req = _make_request()
with pytest.raises(AttributeError):
req.text = "changed" # type: ignore[misc]
def test_voice_selection_immutability(self) -> None:
vs = VoiceSelection(source="builtin", key="voice1")
with pytest.raises(AttributeError):
vs.source = "changed" # type: ignore[misc]
def test_audio_format_equality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/wav", extension="wav")
assert af1 == af2
def test_synthesized_audio_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_engine_config_defaults(self) -> None:
from abogen.tts_plugin.types import EngineConfig
config = EngineConfig()
assert config.device == "cpu"
assert config.lang_code == "a"
def test_parameter_values_defaults(self) -> None:
pv = ParameterValues()
assert pv.values == {}
# ──────────────────────────────────────────────────────────────
# 10. ENGINE DISPOSAL BEHAVIORAL
# ──────────────────────────────────────────────────────────────
class TestEngineDisposalBehavioral:
"""Engine disposal behavioral tests."""
def test_dispose_prevents_new_sessions(self) -> None:
engine = MockEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
def test_dispose_all_engines(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
engine1 = manager.get_or_create_engine("mock_tts")
engine2 = manager.get_or_create_engine("mock_tts")
manager.dispose_all()
assert engine1._disposed is True
assert engine2._disposed is True
assert len(manager._engines) == 0
# ──────────────────────────────────────────────────────────────
# 11. SESSION DISPOSAL BEHAVIORAL
# ──────────────────────────────────────────────────────────────
class TestSessionDisposalBehavioral:
"""Session disposal behavioral tests."""
def test_dispose_prevents_synthesis(self) -> None:
session = MockEngineSession()
session.dispose()
with pytest.raises(EngineError):
session.synthesize(_make_request())
# ──────────────────────────────────────────────────────────────
# 12. CONCURRENT ACCESS (SEQUENTIAL SIMULATION)
# ──────────────────────────────────────────────────────────────
class TestConcurrentAccess:
"""Simulated concurrent access patterns."""
def test_multiple_engines_independent(self) -> None:
engine1 = MockEngine()
engine2 = MockEngine()
session1 = engine1.createSession()
session2 = engine2.createSession()
result1 = session1.synthesize(_make_request(text="Engine 1"))
result2 = session2.synthesize(_make_request(text="Engine 2"))
assert len(result1.data) > 0
assert len(result2.data) > 0
session1.dispose()
session2.dispose()
engine1.dispose()
engine2.dispose()
def test_session_per_thread_simulation(self) -> None:
engine = MockEngine()
sessions = [engine.createSession() for _ in range(5)]
for i, session in enumerate(sessions):
result = session.synthesize(_make_request(text=f"Thread {i}"))
assert len(result.data) > 0
for session in sessions:
session.dispose()
engine.dispose()
# ──────────────────────────────────────────────────────────────
# 13. PLUGIN MANAGER SINGLETON
# ──────────────────────────────────────────────────────────────
class TestPluginManagerSingleton:
"""PluginManager singleton behavior."""
def test_singleton_pattern(self) -> None:
reset_plugin_manager()
manager1 = get_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is manager2
reset_plugin_manager()
def test_reset_creates_new_instance(self) -> None:
reset_plugin_manager()
manager1 = get_plugin_manager()
reset_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is not manager2
reset_plugin_manager()
# ──────────────────────────────────────────────────────────────
# 14. PIPELINE UTILITY BEHAVIORAL
# ──────────────────────────────────────────────────────────────
class TestPipelineUtility:
"""Pipeline utility behavioral tests."""
def test_pipeline_callable(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
pipeline = create_pipeline("mock_tts")
result = list(pipeline("Hello world", voice="voice1", speed=1.0))
assert len(result) >= 1
segment = result[0]
assert segment.graphemes == "Hello world"
assert isinstance(segment.audio, np.ndarray)
assert segment.audio.dtype == np.float32
pipeline.dispose()
def test_pipeline_with_split_pattern(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
pipeline = create_pipeline("mock_tts")
result = list(pipeline("Hello", voice="voice1", split_pattern=r"\n+"))
assert len(result) >= 1
pipeline.dispose()
def test_pipeline_dispose(self) -> None:
manager = PluginManager()
manager._plugins["mock_tts"] = _create_mock_plugin()
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
pipeline = create_pipeline("mock_tts")
pipeline.dispose()
assert pipeline._session is None