mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat(tts-plugin): complete Plugin Architecture refactor
- Normalize Pipeline public API: create_pipeline(plugin_id, *, lang_code, device) - EngineConfig: add lang_code field per Architecture Amendment #1 - Kokoro plugin reads config.lang_code (fixes functional regression) - Static voice catalog in PluginManifest.voices (None = dynamic/VoiceLister) - get_voices() reads from manifest without creating Engine - Remove dead kwargs (sample_rate, auto_download, total_steps) from SuperTonic - Clean up unused imports and dead code in engine implementations - Fix test expectations for VoiceLister (mock overrides) - Add clear_preview_pipelines() for resource management
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""Contract tests for the TTS Plugin API.
|
||||
|
||||
This package contains reusable contract tests that any TTS plugin implementation
|
||||
must satisfy. Tests use only the public API and are engine-agnostic.
|
||||
"""
|
||||
"""Contract tests for the TTS Plugin API.
|
||||
|
||||
This package contains reusable contract tests that any TTS plugin implementation
|
||||
must satisfy. Tests use only the public API and are engine-agnostic.
|
||||
"""
|
||||
|
||||
+231
-231
@@ -1,231 +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,
|
||||
)
|
||||
"""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,
|
||||
)
|
||||
|
||||
+120
-120
@@ -1,120 +1,120 @@
|
||||
"""Base contract tests for Engine implementations.
|
||||
|
||||
Any new TTS plugin must inherit from these classes to verify
|
||||
it satisfies the Engine/EngineSession protocol.
|
||||
|
||||
Usage:
|
||||
from tests.contracts.engine_contract import EngineContractMixin
|
||||
|
||||
class TestMyEngine(EngineContractMixin):
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return create_my_engine()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class EngineContractMixin:
|
||||
"""Base contract tests for Engine implementations.
|
||||
|
||||
Subclasses must define a module-level ``engine`` fixture returning
|
||||
a fully initialized Engine instance. The tests below will use it
|
||||
via pytest's standard fixture resolution.
|
||||
"""
|
||||
|
||||
def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
|
||||
return SynthesisRequest(
|
||||
text=text,
|
||||
voice=VoiceSelection(source="builtin", key=voice or "default"),
|
||||
parameters=ParameterValues(values={}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# ── Engine protocol ──────────────────────────────────────
|
||||
|
||||
def test_engine_satisfies_protocol(self, engine: Engine) -> None:
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_create_session_returns_session(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
session.dispose()
|
||||
|
||||
def test_create_session_returns_new_instances(self, engine: Engine) -> None:
|
||||
s1 = engine.createSession()
|
||||
s2 = engine.createSession()
|
||||
assert s1 is not s2
|
||||
s1.dispose()
|
||||
s2.dispose()
|
||||
|
||||
def test_dispose_is_idempotent(self, engine: Engine) -> None:
|
||||
engine.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
# ── Session protocol ─────────────────────────────────────
|
||||
|
||||
def test_session_satisfies_protocol(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
result = session.synthesize(self._req())
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
session.dispose()
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(self._req())
|
||||
engine.dispose()
|
||||
|
||||
def test_session_multiple_synthesize(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
r1 = session.synthesize(self._req())
|
||||
r2 = session.synthesize(self._req())
|
||||
assert isinstance(r1.data, bytes)
|
||||
assert isinstance(r2.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────
|
||||
|
||||
def test_full_lifecycle(self, engine: Engine) -> None:
|
||||
s1 = engine.createSession()
|
||||
s2 = engine.createSession()
|
||||
s1.synthesize(self._req())
|
||||
s2.synthesize(self._req())
|
||||
s1.dispose()
|
||||
s2.dispose()
|
||||
engine.dispose()
|
||||
"""Base contract tests for Engine implementations.
|
||||
|
||||
Any new TTS plugin must inherit from these classes to verify
|
||||
it satisfies the Engine/EngineSession protocol.
|
||||
|
||||
Usage:
|
||||
from tests.contracts.engine_contract import EngineContractMixin
|
||||
|
||||
class TestMyEngine(EngineContractMixin):
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return create_my_engine()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class EngineContractMixin:
|
||||
"""Base contract tests for Engine implementations.
|
||||
|
||||
Subclasses must define a module-level ``engine`` fixture returning
|
||||
a fully initialized Engine instance. The tests below will use it
|
||||
via pytest's standard fixture resolution.
|
||||
"""
|
||||
|
||||
def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
|
||||
return SynthesisRequest(
|
||||
text=text,
|
||||
voice=VoiceSelection(source="builtin", key=voice or "default"),
|
||||
parameters=ParameterValues(values={}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# ── Engine protocol ──────────────────────────────────────
|
||||
|
||||
def test_engine_satisfies_protocol(self, engine: Engine) -> None:
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_create_session_returns_session(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
session.dispose()
|
||||
|
||||
def test_create_session_returns_new_instances(self, engine: Engine) -> None:
|
||||
s1 = engine.createSession()
|
||||
s2 = engine.createSession()
|
||||
assert s1 is not s2
|
||||
s1.dispose()
|
||||
s2.dispose()
|
||||
|
||||
def test_dispose_is_idempotent(self, engine: Engine) -> None:
|
||||
engine.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
# ── Session protocol ─────────────────────────────────────
|
||||
|
||||
def test_session_satisfies_protocol(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
result = session.synthesize(self._req())
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
session.dispose()
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(self._req())
|
||||
engine.dispose()
|
||||
|
||||
def test_session_multiple_synthesize(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
r1 = session.synthesize(self._req())
|
||||
r2 = session.synthesize(self._req())
|
||||
assert isinstance(r1.data, bytes)
|
||||
assert isinstance(r2.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────
|
||||
|
||||
def test_full_lifecycle(self, engine: Engine) -> None:
|
||||
s1 = engine.createSession()
|
||||
s2 = engine.createSession()
|
||||
s1.synthesize(self._req())
|
||||
s2.synthesize(self._req())
|
||||
s1.dispose()
|
||||
s2.dispose()
|
||||
engine.dispose()
|
||||
|
||||
@@ -1,183 +1,183 @@
|
||||
"""Contract tests for capability interfaces.
|
||||
|
||||
These tests verify that capability interfaces satisfy the architectural requirements:
|
||||
- VoiceLister: lists voices for a source
|
||||
- PreviewGenerator: generates preview audio
|
||||
- StreamingSynthesizer: yields audio chunks
|
||||
- CancelableSession: cancels in-progress synthesis
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.errors import CancelledError, EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
|
||||
|
||||
|
||||
class TestVoiceListerProtocolContract:
|
||||
"""Contract tests for VoiceLister protocol."""
|
||||
|
||||
def test_voice_lister_is_protocol(self) -> None:
|
||||
assert hasattr(VoiceLister, "__protocol_attrs__")
|
||||
|
||||
def test_voice_lister_satisfied_by_engine(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
assert isinstance(engine, VoiceLister)
|
||||
|
||||
def test_list_voices_returns_list(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert isinstance(voices, list)
|
||||
|
||||
def test_list_voices_returns_voice_manifests(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert isinstance(voice, VoiceManifest)
|
||||
|
||||
def test_list_voices_has_required_fields(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert hasattr(voice, "id")
|
||||
assert hasattr(voice, "name")
|
||||
assert hasattr(voice, "tags")
|
||||
|
||||
|
||||
class TestPreviewGeneratorProtocolContract:
|
||||
"""Contract tests for PreviewGenerator protocol."""
|
||||
|
||||
def test_preview_generator_is_protocol(self) -> None:
|
||||
assert hasattr(PreviewGenerator, "__protocol_attrs__")
|
||||
|
||||
def test_preview_generator_satisfied_by_engine(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
assert isinstance(engine, PreviewGenerator)
|
||||
|
||||
def test_generate_preview_returns_synthesized_audio(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
def test_generate_preview_has_valid_data(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
|
||||
|
||||
class TestStreamingSynthesizerProtocolContract:
|
||||
"""Contract tests for StreamingSynthesizer protocol."""
|
||||
|
||||
def test_streaming_synthesizer_is_protocol(self) -> None:
|
||||
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
|
||||
|
||||
def test_streaming_session_satisfies_protocol(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
assert isinstance(session, StreamingSynthesizer)
|
||||
|
||||
def test_synthesize_stream_yields_bytes(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) > 0
|
||||
for chunk in chunks:
|
||||
assert isinstance(chunk, bytes)
|
||||
|
||||
def test_streaming_iterator_exhaustion(self) -> None:
|
||||
"""Architecture spec: Iterator exhaustion = synthesis complete."""
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) == 3
|
||||
|
||||
def test_streaming_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), methods raise EngineError."""
|
||||
session = FakeStreamingSession()
|
||||
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):
|
||||
list(session.synthesizeStream(request))
|
||||
|
||||
|
||||
class TestCancelableSessionProtocolContract:
|
||||
"""Contract tests for CancelableSession protocol."""
|
||||
|
||||
def test_cancelable_session_is_protocol(self) -> None:
|
||||
assert hasattr(CancelableSession, "__protocol_attrs__")
|
||||
|
||||
def test_cancelable_session_satisfies_protocol(self) -> None:
|
||||
session = FakeCancelableSession()
|
||||
assert isinstance(session, CancelableSession)
|
||||
|
||||
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
|
||||
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
|
||||
session = FakeCancelableSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# synthesize should raise CancelledError
|
||||
with pytest.raises(CancelledError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_cancel_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
|
||||
session = FakeCancelableSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.cancel()
|
||||
|
||||
def test_session_usable_after_cancel(self) -> None:
|
||||
"""Architecture spec: EngineSession remains usable after cancellation."""
|
||||
session = FakeCancelableSession()
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# Dispose and create new session for synthesis
|
||||
session.dispose()
|
||||
"""Contract tests for capability interfaces.
|
||||
|
||||
These tests verify that capability interfaces satisfy the architectural requirements:
|
||||
- VoiceLister: lists voices for a source
|
||||
- PreviewGenerator: generates preview audio
|
||||
- StreamingSynthesizer: yields audio chunks
|
||||
- CancelableSession: cancels in-progress synthesis
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.errors import CancelledError, EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
|
||||
|
||||
|
||||
class TestVoiceListerProtocolContract:
|
||||
"""Contract tests for VoiceLister protocol."""
|
||||
|
||||
def test_voice_lister_is_protocol(self) -> None:
|
||||
assert hasattr(VoiceLister, "__protocol_attrs__")
|
||||
|
||||
def test_voice_lister_satisfied_by_engine(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
assert isinstance(engine, VoiceLister)
|
||||
|
||||
def test_list_voices_returns_list(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert isinstance(voices, list)
|
||||
|
||||
def test_list_voices_returns_voice_manifests(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert isinstance(voice, VoiceManifest)
|
||||
|
||||
def test_list_voices_has_required_fields(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert hasattr(voice, "id")
|
||||
assert hasattr(voice, "name")
|
||||
assert hasattr(voice, "tags")
|
||||
|
||||
|
||||
class TestPreviewGeneratorProtocolContract:
|
||||
"""Contract tests for PreviewGenerator protocol."""
|
||||
|
||||
def test_preview_generator_is_protocol(self) -> None:
|
||||
assert hasattr(PreviewGenerator, "__protocol_attrs__")
|
||||
|
||||
def test_preview_generator_satisfied_by_engine(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
assert isinstance(engine, PreviewGenerator)
|
||||
|
||||
def test_generate_preview_returns_synthesized_audio(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
def test_generate_preview_has_valid_data(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
|
||||
|
||||
class TestStreamingSynthesizerProtocolContract:
|
||||
"""Contract tests for StreamingSynthesizer protocol."""
|
||||
|
||||
def test_streaming_synthesizer_is_protocol(self) -> None:
|
||||
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
|
||||
|
||||
def test_streaming_session_satisfies_protocol(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
assert isinstance(session, StreamingSynthesizer)
|
||||
|
||||
def test_synthesize_stream_yields_bytes(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) > 0
|
||||
for chunk in chunks:
|
||||
assert isinstance(chunk, bytes)
|
||||
|
||||
def test_streaming_iterator_exhaustion(self) -> None:
|
||||
"""Architecture spec: Iterator exhaustion = synthesis complete."""
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) == 3
|
||||
|
||||
def test_streaming_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), methods raise EngineError."""
|
||||
session = FakeStreamingSession()
|
||||
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):
|
||||
list(session.synthesizeStream(request))
|
||||
|
||||
|
||||
class TestCancelableSessionProtocolContract:
|
||||
"""Contract tests for CancelableSession protocol."""
|
||||
|
||||
def test_cancelable_session_is_protocol(self) -> None:
|
||||
assert hasattr(CancelableSession, "__protocol_attrs__")
|
||||
|
||||
def test_cancelable_session_satisfies_protocol(self) -> None:
|
||||
session = FakeCancelableSession()
|
||||
assert isinstance(session, CancelableSession)
|
||||
|
||||
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
|
||||
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
|
||||
session = FakeCancelableSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# synthesize should raise CancelledError
|
||||
with pytest.raises(CancelledError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_cancel_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
|
||||
session = FakeCancelableSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.cancel()
|
||||
|
||||
def test_session_usable_after_cancel(self) -> None:
|
||||
"""Architecture spec: EngineSession remains usable after cancellation."""
|
||||
session = FakeCancelableSession()
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# Dispose and create new session for synthesis
|
||||
session.dispose()
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
"""Contract tests for Engine protocol.
|
||||
|
||||
These tests verify that Engine implementations satisfy the architectural requirements:
|
||||
- createSession() returns EngineSession
|
||||
- dispose() is idempotent
|
||||
- After dispose(), createSession() raises EngineError
|
||||
- Engine is thread-safe for createSession()
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
from .conftest import FakeEngine, FakeEngineSession
|
||||
|
||||
|
||||
class TestEngineProtocolContract:
|
||||
"""Contract tests for the Engine protocol itself."""
|
||||
|
||||
def test_engine_is_protocol(self) -> None:
|
||||
assert hasattr(Engine, "__protocol_attrs__")
|
||||
|
||||
def test_engine_session_is_protocol(self) -> None:
|
||||
assert hasattr(EngineSession, "__protocol_attrs__")
|
||||
|
||||
def test_fake_engine_satisfies_protocol(self) -> None:
|
||||
engine = FakeEngine()
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_fake_session_satisfies_protocol(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestEngineCreateSessionContract:
|
||||
"""Contract tests for Engine.createSession()."""
|
||||
|
||||
def test_create_session_returns_engine_session(self) -> None:
|
||||
engine = FakeEngine()
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
def test_create_session_returns_new_instance(self) -> None:
|
||||
engine = FakeEngine()
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
assert session1 is not session2
|
||||
|
||||
def test_create_session_ownership_transfers(self) -> None:
|
||||
"""Architecture spec: Ownership transfers to caller."""
|
||||
engine = FakeEngine()
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestEngineDisposeContract:
|
||||
"""Contract tests for Engine.dispose()."""
|
||||
|
||||
def test_dispose_is_idempotent(self) -> None:
|
||||
"""Architecture spec: dispose() is idempotent."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
engine.dispose() # Should not raise
|
||||
|
||||
def test_dispose_never_raises(self) -> None:
|
||||
"""Architecture spec: dispose() never raises exceptions."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose() # Should not raise
|
||||
|
||||
def test_create_session_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
|
||||
class TestEngineLifecycleContract:
|
||||
"""Contract tests for Engine lifecycle."""
|
||||
|
||||
def test_full_lifecycle(self) -> None:
|
||||
"""Test complete engine lifecycle: create -> sessions -> dispose."""
|
||||
engine = FakeEngine()
|
||||
|
||||
# Create sessions
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
|
||||
# Use sessions
|
||||
assert isinstance(session1, EngineSession)
|
||||
assert isinstance(session2, EngineSession)
|
||||
|
||||
# Dispose sessions
|
||||
session1.dispose()
|
||||
session2.dispose()
|
||||
|
||||
# Dispose engine
|
||||
engine.dispose()
|
||||
|
||||
def test_engine_disposed_session_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
"""Contract tests for Engine protocol.
|
||||
|
||||
These tests verify that Engine implementations satisfy the architectural requirements:
|
||||
- createSession() returns EngineSession
|
||||
- dispose() is idempotent
|
||||
- After dispose(), createSession() raises EngineError
|
||||
- Engine is thread-safe for createSession()
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
from .conftest import FakeEngine, FakeEngineSession
|
||||
|
||||
|
||||
class TestEngineProtocolContract:
|
||||
"""Contract tests for the Engine protocol itself."""
|
||||
|
||||
def test_engine_is_protocol(self) -> None:
|
||||
assert hasattr(Engine, "__protocol_attrs__")
|
||||
|
||||
def test_engine_session_is_protocol(self) -> None:
|
||||
assert hasattr(EngineSession, "__protocol_attrs__")
|
||||
|
||||
def test_fake_engine_satisfies_protocol(self) -> None:
|
||||
engine = FakeEngine()
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_fake_session_satisfies_protocol(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestEngineCreateSessionContract:
|
||||
"""Contract tests for Engine.createSession()."""
|
||||
|
||||
def test_create_session_returns_engine_session(self) -> None:
|
||||
engine = FakeEngine()
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
def test_create_session_returns_new_instance(self) -> None:
|
||||
engine = FakeEngine()
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
assert session1 is not session2
|
||||
|
||||
def test_create_session_ownership_transfers(self) -> None:
|
||||
"""Architecture spec: Ownership transfers to caller."""
|
||||
engine = FakeEngine()
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestEngineDisposeContract:
|
||||
"""Contract tests for Engine.dispose()."""
|
||||
|
||||
def test_dispose_is_idempotent(self) -> None:
|
||||
"""Architecture spec: dispose() is idempotent."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
engine.dispose() # Should not raise
|
||||
|
||||
def test_dispose_never_raises(self) -> None:
|
||||
"""Architecture spec: dispose() never raises exceptions."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose() # Should not raise
|
||||
|
||||
def test_create_session_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
|
||||
class TestEngineLifecycleContract:
|
||||
"""Contract tests for Engine lifecycle."""
|
||||
|
||||
def test_full_lifecycle(self) -> None:
|
||||
"""Test complete engine lifecycle: create -> sessions -> dispose."""
|
||||
engine = FakeEngine()
|
||||
|
||||
# Create sessions
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
|
||||
# Use sessions
|
||||
assert isinstance(session1, EngineSession)
|
||||
assert isinstance(session2, EngineSession)
|
||||
|
||||
# Dispose sessions
|
||||
session1.dispose()
|
||||
session2.dispose()
|
||||
|
||||
# Dispose engine
|
||||
engine.dispose()
|
||||
|
||||
def test_engine_disposed_session_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
"""Contract tests for error hierarchy.
|
||||
|
||||
These tests verify that the error hierarchy satisfies the architectural requirements:
|
||||
- All errors inherit from EngineError
|
||||
- EngineError inherits from Exception
|
||||
- Each error type is properly classified
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
|
||||
|
||||
class TestErrorHierarchyContract:
|
||||
"""Contract tests for the error hierarchy."""
|
||||
|
||||
def test_engine_error_is_exception(self) -> None:
|
||||
assert issubclass(EngineError, Exception)
|
||||
|
||||
def test_all_errors_inherit_from_engine_error(self) -> None:
|
||||
error_classes = [
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for error_class in error_classes:
|
||||
assert issubclass(error_class, EngineError), (
|
||||
f"{error_class.__name__} must inherit from EngineError"
|
||||
)
|
||||
|
||||
def test_all_errors_are_catchable(self) -> None:
|
||||
error_classes = [
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for error_class in error_classes:
|
||||
with pytest.raises(EngineError):
|
||||
raise error_class("test message")
|
||||
|
||||
def test_error_message_preserved(self) -> None:
|
||||
msg = "Model not found: bert-base"
|
||||
with pytest.raises(ModelNotFoundError, match=msg):
|
||||
raise ModelNotFoundError(msg)
|
||||
|
||||
def test_error_can_be_caught_as_engine_error(self) -> None:
|
||||
with pytest.raises(EngineError):
|
||||
raise ModelNotFoundError("test")
|
||||
|
||||
def test_cancelled_error_is_engine_error(self) -> None:
|
||||
"""CancelledError is a subtype of EngineError per architecture spec."""
|
||||
assert issubclass(CancelledError, EngineError)
|
||||
|
||||
def test_error_hierarchy_no_cycles(self) -> None:
|
||||
"""Verify no circular inheritance."""
|
||||
error_classes = [
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for cls in error_classes:
|
||||
assert cls not in cls.__bases__
|
||||
"""Contract tests for error hierarchy.
|
||||
|
||||
These tests verify that the error hierarchy satisfies the architectural requirements:
|
||||
- All errors inherit from EngineError
|
||||
- EngineError inherits from Exception
|
||||
- Each error type is properly classified
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
|
||||
|
||||
class TestErrorHierarchyContract:
|
||||
"""Contract tests for the error hierarchy."""
|
||||
|
||||
def test_engine_error_is_exception(self) -> None:
|
||||
assert issubclass(EngineError, Exception)
|
||||
|
||||
def test_all_errors_inherit_from_engine_error(self) -> None:
|
||||
error_classes = [
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for error_class in error_classes:
|
||||
assert issubclass(error_class, EngineError), (
|
||||
f"{error_class.__name__} must inherit from EngineError"
|
||||
)
|
||||
|
||||
def test_all_errors_are_catchable(self) -> None:
|
||||
error_classes = [
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for error_class in error_classes:
|
||||
with pytest.raises(EngineError):
|
||||
raise error_class("test message")
|
||||
|
||||
def test_error_message_preserved(self) -> None:
|
||||
msg = "Model not found: bert-base"
|
||||
with pytest.raises(ModelNotFoundError, match=msg):
|
||||
raise ModelNotFoundError(msg)
|
||||
|
||||
def test_error_can_be_caught_as_engine_error(self) -> None:
|
||||
with pytest.raises(EngineError):
|
||||
raise ModelNotFoundError("test")
|
||||
|
||||
def test_cancelled_error_is_engine_error(self) -> None:
|
||||
"""CancelledError is a subtype of EngineError per architecture spec."""
|
||||
assert issubclass(CancelledError, EngineError)
|
||||
|
||||
def test_error_hierarchy_no_cycles(self) -> None:
|
||||
"""Verify no circular inheritance."""
|
||||
error_classes = [
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for cls in error_classes:
|
||||
assert cls not in cls.__bases__
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
"""Contract tests for HostContext.
|
||||
|
||||
These tests verify that HostContext satisfies the architectural requirements:
|
||||
- Minimal (3 fields maximum)
|
||||
- Frozen dataclass
|
||||
- config_dir: Path
|
||||
- logger: Logger
|
||||
- http_client: HttpClient protocol
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
|
||||
|
||||
class TestHostContextContract:
|
||||
"""Contract tests for HostContext dataclass."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(HostContext, "__dataclass_params__")
|
||||
assert HostContext.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self, tmp_path: Path) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logger,
|
||||
http_client=FakeClient(),
|
||||
)
|
||||
assert ctx.config_dir == tmp_path
|
||||
assert ctx.logger is logger
|
||||
|
||||
def test_immutability(self, tmp_path: Path) -> None:
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=FakeClient(),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
ctx.config_dir = Path("/other") # type: ignore[misc]
|
||||
|
||||
def test_max_three_fields(self) -> None:
|
||||
"""Architecture spec: HostContext is minimal (3 fields max)."""
|
||||
import dataclasses
|
||||
|
||||
fields = dataclasses.fields(HostContext)
|
||||
assert len(fields) <= 3
|
||||
|
||||
|
||||
class TestHttpClientProtocolContract:
|
||||
"""Contract tests for HttpClient protocol."""
|
||||
|
||||
def test_http_client_is_protocol(self) -> None:
|
||||
assert hasattr(HttpClient, "__protocol_attrs__")
|
||||
|
||||
def test_http_client_has_get(self) -> None:
|
||||
assert hasattr(HttpClient, "get")
|
||||
|
||||
def test_http_client_has_post(self) -> None:
|
||||
assert hasattr(HttpClient, "post")
|
||||
|
||||
def test_http_client_satisfied(self) -> None:
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
client = FakeClient()
|
||||
assert isinstance(client, HttpClient)
|
||||
"""Contract tests for HostContext.
|
||||
|
||||
These tests verify that HostContext satisfies the architectural requirements:
|
||||
- Minimal (3 fields maximum)
|
||||
- Frozen dataclass
|
||||
- config_dir: Path
|
||||
- logger: Logger
|
||||
- http_client: HttpClient protocol
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
|
||||
|
||||
class TestHostContextContract:
|
||||
"""Contract tests for HostContext dataclass."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(HostContext, "__dataclass_params__")
|
||||
assert HostContext.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self, tmp_path: Path) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logger,
|
||||
http_client=FakeClient(),
|
||||
)
|
||||
assert ctx.config_dir == tmp_path
|
||||
assert ctx.logger is logger
|
||||
|
||||
def test_immutability(self, tmp_path: Path) -> None:
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=FakeClient(),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
ctx.config_dir = Path("/other") # type: ignore[misc]
|
||||
|
||||
def test_max_three_fields(self) -> None:
|
||||
"""Architecture spec: HostContext is minimal (3 fields max)."""
|
||||
import dataclasses
|
||||
|
||||
fields = dataclasses.fields(HostContext)
|
||||
assert len(fields) <= 3
|
||||
|
||||
|
||||
class TestHttpClientProtocolContract:
|
||||
"""Contract tests for HttpClient protocol."""
|
||||
|
||||
def test_http_client_is_protocol(self) -> None:
|
||||
assert hasattr(HttpClient, "__protocol_attrs__")
|
||||
|
||||
def test_http_client_has_get(self) -> None:
|
||||
assert hasattr(HttpClient, "get")
|
||||
|
||||
def test_http_client_has_post(self) -> None:
|
||||
assert hasattr(HttpClient, "post")
|
||||
|
||||
def test_http_client_satisfied(self) -> None:
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
client = FakeClient()
|
||||
assert isinstance(client, HttpClient)
|
||||
|
||||
+420
-420
@@ -1,420 +1,420 @@
|
||||
"""Integration tests for the TTS Plugin Architecture.
|
||||
|
||||
These tests verify:
|
||||
1. Consumer Flow: consumer → plugin → engine → session → synthesis → result
|
||||
2. Lifecycle: dispose, no leaks, error handling
|
||||
3. Regression: old path vs new path equivalence
|
||||
|
||||
Tests use mock plugins to avoid requiring real TTS dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Any, Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
|
||||
from abogen.tts_plugin.utils import Pipeline, create_pipeline
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class MockEngineSession:
|
||||
"""Mock EngineSession that records calls for verification."""
|
||||
|
||||
def __init__(self):
|
||||
self._disposed = False
|
||||
self.synthesize_calls = []
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
|
||||
self.synthesize_calls.append(request)
|
||||
|
||||
# Return fake audio
|
||||
audio = np.ones(1000, dtype=np.float32) * 0.5
|
||||
return SynthesizedAudio(
|
||||
data=audio.tobytes(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1000 / 24000),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class MockEngine:
|
||||
"""Mock Engine that creates MockEngineSessions."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._disposed = False
|
||||
self.sessions_created = []
|
||||
|
||||
def createSession(self) -> MockEngineSession:
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
session = MockEngineSession()
|
||||
self.sessions_created.append(session)
|
||||
return session
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
def create_mock_plugin(create_engine_func=None):
|
||||
"""Helper to create a mock plugin module."""
|
||||
if create_engine_func is None:
|
||||
create_engine_func = lambda **kwargs: MockEngine(**kwargs)
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
|
||||
|
||||
manifest = PluginManifest(
|
||||
id="mock_tts",
|
||||
name="Mock TTS",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Mock TTS for testing",
|
||||
author="Test",
|
||||
capabilities=(),
|
||||
requires=None,
|
||||
engine=EngineManifest(
|
||||
voiceSources=(),
|
||||
parameters=(),
|
||||
audioFormats=(),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"PLUGIN_MANIFEST": manifest,
|
||||
"MODEL_REQUIREMENTS": [],
|
||||
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
|
||||
}
|
||||
|
||||
|
||||
def create_mock_plugin_engine(**kwargs):
|
||||
"""Default mock plugin engine factory."""
|
||||
return MockEngine(**kwargs)
|
||||
|
||||
|
||||
class TestConsumerFlow:
|
||||
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
|
||||
|
||||
def test_full_consumer_flow(self):
|
||||
"""Verify complete flow from consumer to audio output."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Step 1: Consumer gets plugin
|
||||
assert manager.has_plugin("mock_tts") is True
|
||||
|
||||
# Step 2: Plugin creates engine
|
||||
engine = manager.create_engine("mock_tts")
|
||||
assert engine is not None
|
||||
assert isinstance(engine, MockEngine)
|
||||
|
||||
# Step 3: Engine creates session
|
||||
session = engine.createSession()
|
||||
assert session is not None
|
||||
assert isinstance(session, MockEngineSession)
|
||||
|
||||
# Step 4: Session synthesizes
|
||||
request = SynthesisRequest(
|
||||
text="Hello world",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(values={"speed": 1.0}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
|
||||
# Step 5: Result returned
|
||||
assert result is not None
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
assert len(result.data) > 0
|
||||
assert result.format.mime == "audio/wav"
|
||||
assert result.duration.seconds > 0
|
||||
|
||||
def test_consumer_flow_via_pipeline(self):
|
||||
"""Verify flow through Pipeline utility matches direct flow."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Use Pipeline utility
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
backend = create_pipeline("mock_tts")
|
||||
|
||||
# Call like old TTSBackend
|
||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Verify result
|
||||
assert len(segments) >= 1
|
||||
segment = segments[0]
|
||||
assert hasattr(segment, "graphemes")
|
||||
assert hasattr(segment, "audio")
|
||||
assert segment.graphemes == "Hello world"
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
"""Lifecycle Test: dispose, no leaks, error handling"""
|
||||
|
||||
def test_session_dispose_is_idempotent(self):
|
||||
"""dispose() can be called multiple times safely."""
|
||||
session = MockEngineSession()
|
||||
|
||||
session.dispose()
|
||||
session.dispose() # Should not raise
|
||||
assert session._disposed is True
|
||||
|
||||
def test_session_synthesize_after_dispose_raises(self):
|
||||
"""synthesize() after dispose() raises EngineError."""
|
||||
session = MockEngineSession()
|
||||
session.dispose()
|
||||
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_engine_dispose_is_idempotent(self):
|
||||
"""Engine dispose() can be called multiple times safely."""
|
||||
engine = MockEngine()
|
||||
|
||||
engine.dispose()
|
||||
engine.dispose() # Should not raise
|
||||
assert engine._disposed is True
|
||||
|
||||
def test_engine_create_session_after_dispose_raises(self):
|
||||
"""createSession() after dispose() raises EngineError."""
|
||||
engine = MockEngine()
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
"""Test complete lifecycle: create → use → dispose."""
|
||||
engine = MockEngine()
|
||||
|
||||
# Create and use session
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert len(result.data) > 0
|
||||
|
||||
# Dispose session
|
||||
session.dispose()
|
||||
assert session._disposed is True
|
||||
|
||||
# Dispose engine
|
||||
engine.dispose()
|
||||
assert engine._disposed is True
|
||||
|
||||
def test_no_session_leak_on_engine_dispose(self):
|
||||
"""Engine can be disposed even if sessions were created."""
|
||||
engine = MockEngine()
|
||||
|
||||
# Create multiple sessions
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
|
||||
# Use sessions
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
session1.synthesize(request)
|
||||
session2.synthesize(request)
|
||||
|
||||
# Dispose engine (sessions still exist but engine is disposed)
|
||||
engine.dispose()
|
||||
assert engine._disposed is True
|
||||
|
||||
# Sessions can still be used (they hold reference to pipeline)
|
||||
result = session1.synthesize(request)
|
||||
assert len(result.data) > 0
|
||||
|
||||
def test_error_handling_in_synthesis(self):
|
||||
"""Error during synthesis is handled correctly."""
|
||||
class FailingSession:
|
||||
def synthesize(self, request):
|
||||
raise EngineError("Synthesis failed")
|
||||
|
||||
def dispose(self):
|
||||
pass
|
||||
|
||||
session = FailingSession()
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
with pytest.raises(EngineError, match="Synthesis failed"):
|
||||
session.synthesize(request)
|
||||
|
||||
|
||||
class TestRegression:
|
||||
"""Regression Test: old path vs new path equivalence"""
|
||||
|
||||
def test_old_path_vs_new_path_same_result(self):
|
||||
"""Both paths should produce equivalent results."""
|
||||
# Setup mock plugin
|
||||
manager = PluginManager()
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# New path: Plugin Manager → Engine → Session → Synthesis
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
new_backend = create_pipeline("mock_tts")
|
||||
new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Old path: Direct MockEngine (simulating old registry)
|
||||
old_engine = MockEngine()
|
||||
old_session = old_engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello world",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(values={"speed": 1.0}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
old_result = old_session.synthesize(request)
|
||||
|
||||
# Compare results
|
||||
# New path returns segments, old path returns SynthesizedAudio
|
||||
# But both should have valid audio data
|
||||
assert len(new_segments) >= 1
|
||||
assert len(old_result.data) > 0
|
||||
|
||||
# Both should have same format
|
||||
assert new_segments[0].audio.dtype == np.float32
|
||||
|
||||
def test_pipeline_matches_old_interface(self):
|
||||
"""Pipeline utility should match old TTSBackend interface."""
|
||||
manager = PluginManager()
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
|
||||
|
||||
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
|
||||
segments = list(backend(
|
||||
"Hello world",
|
||||
voice="af_heart",
|
||||
speed=1.0,
|
||||
split_pattern=r"\n+"
|
||||
))
|
||||
|
||||
# Should return segments with graphemes and audio
|
||||
assert len(segments) >= 1
|
||||
segment = segments[0]
|
||||
assert segment.graphemes == "Hello world"
|
||||
assert isinstance(segment.audio, np.ndarray)
|
||||
assert segment.audio.dtype == np.float32
|
||||
assert len(segment.audio) > 0
|
||||
|
||||
|
||||
class TestPluginManagerIntegration:
|
||||
"""Integration tests for PluginManager."""
|
||||
|
||||
def test_plugin_manager_singleton_pattern(self):
|
||||
"""Global plugin manager follows singleton pattern."""
|
||||
reset_plugin_manager()
|
||||
|
||||
manager1 = get_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
|
||||
assert manager1 is manager2
|
||||
|
||||
reset_plugin_manager()
|
||||
|
||||
manager3 = get_plugin_manager()
|
||||
assert manager1 is not manager3
|
||||
|
||||
def test_plugin_manager_discover_plugins(self):
|
||||
"""Plugin manager can discover plugins from directory."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Discover from test plugins directory
|
||||
manager.discover("tests/plugins")
|
||||
|
||||
# Should find valid_plugin
|
||||
# (This depends on test plugins existing)
|
||||
plugins = manager.list_plugins()
|
||||
assert isinstance(plugins, list)
|
||||
|
||||
def test_plugin_manager_dispose_all(self):
|
||||
"""Plugin manager can dispose all cached engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Create engines
|
||||
engine1 = manager.get_or_create_engine("mock_tts")
|
||||
engine2 = manager.get_or_create_engine("mock_tts")
|
||||
|
||||
# Dispose all
|
||||
manager.dispose_all()
|
||||
|
||||
# Engines should be disposed
|
||||
assert engine1._disposed is True
|
||||
assert engine2._disposed is True
|
||||
|
||||
# Cache should be empty
|
||||
assert len(manager._engines) == 0
|
||||
|
||||
|
||||
class TestNoCompatLayer:
|
||||
"""Regression: confirm the compatibility layer has been removed."""
|
||||
|
||||
def test_compat_module_does_not_exist(self):
|
||||
"""abogen.tts_plugin.compat must not be importable."""
|
||||
import importlib
|
||||
with pytest.raises((ImportError, ModuleNotFoundError)):
|
||||
importlib.import_module("abogen.tts_plugin.compat")
|
||||
|
||||
def test_consumers_use_plugin_architecture_directly(self):
|
||||
"""Key consumers import from abogen.tts_plugin.utils, not compat."""
|
||||
import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
|
||||
|
||||
for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache):
|
||||
source = inspect.getsource(mod)
|
||||
assert "tts_plugin.compat" not in source, (
|
||||
f"{mod.__name__} still references tts_plugin.compat"
|
||||
)
|
||||
"""Integration tests for the TTS Plugin Architecture.
|
||||
|
||||
These tests verify:
|
||||
1. Consumer Flow: consumer → plugin → engine → session → synthesis → result
|
||||
2. Lifecycle: dispose, no leaks, error handling
|
||||
3. Regression: old path vs new path equivalence
|
||||
|
||||
Tests use mock plugins to avoid requiring real TTS dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Any, Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
|
||||
from abogen.tts_plugin.utils import Pipeline, create_pipeline
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class MockEngineSession:
|
||||
"""Mock EngineSession that records calls for verification."""
|
||||
|
||||
def __init__(self):
|
||||
self._disposed = False
|
||||
self.synthesize_calls = []
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
|
||||
self.synthesize_calls.append(request)
|
||||
|
||||
# Return fake audio
|
||||
audio = np.ones(1000, dtype=np.float32) * 0.5
|
||||
return SynthesizedAudio(
|
||||
data=audio.tobytes(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1000 / 24000),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class MockEngine:
|
||||
"""Mock Engine that creates MockEngineSessions."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._disposed = False
|
||||
self.sessions_created = []
|
||||
|
||||
def createSession(self) -> MockEngineSession:
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
session = MockEngineSession()
|
||||
self.sessions_created.append(session)
|
||||
return session
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
def create_mock_plugin(create_engine_func=None):
|
||||
"""Helper to create a mock plugin module."""
|
||||
if create_engine_func is None:
|
||||
create_engine_func = lambda **kwargs: MockEngine(**kwargs)
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
|
||||
|
||||
manifest = PluginManifest(
|
||||
id="mock_tts",
|
||||
name="Mock TTS",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Mock TTS for testing",
|
||||
author="Test",
|
||||
capabilities=(),
|
||||
requires=None,
|
||||
engine=EngineManifest(
|
||||
voiceSources=(),
|
||||
parameters=(),
|
||||
audioFormats=(),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"PLUGIN_MANIFEST": manifest,
|
||||
"MODEL_REQUIREMENTS": [],
|
||||
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
|
||||
}
|
||||
|
||||
|
||||
def create_mock_plugin_engine(**kwargs):
|
||||
"""Default mock plugin engine factory."""
|
||||
return MockEngine(**kwargs)
|
||||
|
||||
|
||||
class TestConsumerFlow:
|
||||
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
|
||||
|
||||
def test_full_consumer_flow(self):
|
||||
"""Verify complete flow from consumer to audio output."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Step 1: Consumer gets plugin
|
||||
assert manager.has_plugin("mock_tts") is True
|
||||
|
||||
# Step 2: Plugin creates engine
|
||||
engine = manager.create_engine("mock_tts")
|
||||
assert engine is not None
|
||||
assert isinstance(engine, MockEngine)
|
||||
|
||||
# Step 3: Engine creates session
|
||||
session = engine.createSession()
|
||||
assert session is not None
|
||||
assert isinstance(session, MockEngineSession)
|
||||
|
||||
# Step 4: Session synthesizes
|
||||
request = SynthesisRequest(
|
||||
text="Hello world",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(values={"speed": 1.0}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
|
||||
# Step 5: Result returned
|
||||
assert result is not None
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
assert len(result.data) > 0
|
||||
assert result.format.mime == "audio/wav"
|
||||
assert result.duration.seconds > 0
|
||||
|
||||
def test_consumer_flow_via_pipeline(self):
|
||||
"""Verify flow through Pipeline utility matches direct flow."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Use Pipeline utility
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
backend = create_pipeline("mock_tts")
|
||||
|
||||
# Call like old TTSBackend
|
||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Verify result
|
||||
assert len(segments) >= 1
|
||||
segment = segments[0]
|
||||
assert hasattr(segment, "graphemes")
|
||||
assert hasattr(segment, "audio")
|
||||
assert segment.graphemes == "Hello world"
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
"""Lifecycle Test: dispose, no leaks, error handling"""
|
||||
|
||||
def test_session_dispose_is_idempotent(self):
|
||||
"""dispose() can be called multiple times safely."""
|
||||
session = MockEngineSession()
|
||||
|
||||
session.dispose()
|
||||
session.dispose() # Should not raise
|
||||
assert session._disposed is True
|
||||
|
||||
def test_session_synthesize_after_dispose_raises(self):
|
||||
"""synthesize() after dispose() raises EngineError."""
|
||||
session = MockEngineSession()
|
||||
session.dispose()
|
||||
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_engine_dispose_is_idempotent(self):
|
||||
"""Engine dispose() can be called multiple times safely."""
|
||||
engine = MockEngine()
|
||||
|
||||
engine.dispose()
|
||||
engine.dispose() # Should not raise
|
||||
assert engine._disposed is True
|
||||
|
||||
def test_engine_create_session_after_dispose_raises(self):
|
||||
"""createSession() after dispose() raises EngineError."""
|
||||
engine = MockEngine()
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
"""Test complete lifecycle: create → use → dispose."""
|
||||
engine = MockEngine()
|
||||
|
||||
# Create and use session
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert len(result.data) > 0
|
||||
|
||||
# Dispose session
|
||||
session.dispose()
|
||||
assert session._disposed is True
|
||||
|
||||
# Dispose engine
|
||||
engine.dispose()
|
||||
assert engine._disposed is True
|
||||
|
||||
def test_no_session_leak_on_engine_dispose(self):
|
||||
"""Engine can be disposed even if sessions were created."""
|
||||
engine = MockEngine()
|
||||
|
||||
# Create multiple sessions
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
|
||||
# Use sessions
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
session1.synthesize(request)
|
||||
session2.synthesize(request)
|
||||
|
||||
# Dispose engine (sessions still exist but engine is disposed)
|
||||
engine.dispose()
|
||||
assert engine._disposed is True
|
||||
|
||||
# Sessions can still be used (they hold reference to pipeline)
|
||||
result = session1.synthesize(request)
|
||||
assert len(result.data) > 0
|
||||
|
||||
def test_error_handling_in_synthesis(self):
|
||||
"""Error during synthesis is handled correctly."""
|
||||
class FailingSession:
|
||||
def synthesize(self, request):
|
||||
raise EngineError("Synthesis failed")
|
||||
|
||||
def dispose(self):
|
||||
pass
|
||||
|
||||
session = FailingSession()
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
with pytest.raises(EngineError, match="Synthesis failed"):
|
||||
session.synthesize(request)
|
||||
|
||||
|
||||
class TestRegression:
|
||||
"""Regression Test: old path vs new path equivalence"""
|
||||
|
||||
def test_old_path_vs_new_path_same_result(self):
|
||||
"""Both paths should produce equivalent results."""
|
||||
# Setup mock plugin
|
||||
manager = PluginManager()
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# New path: Plugin Manager → Engine → Session → Synthesis
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
new_backend = create_pipeline("mock_tts")
|
||||
new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Old path: Direct MockEngine (simulating old registry)
|
||||
old_engine = MockEngine()
|
||||
old_session = old_engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello world",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(values={"speed": 1.0}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
old_result = old_session.synthesize(request)
|
||||
|
||||
# Compare results
|
||||
# New path returns segments, old path returns SynthesizedAudio
|
||||
# But both should have valid audio data
|
||||
assert len(new_segments) >= 1
|
||||
assert len(old_result.data) > 0
|
||||
|
||||
# Both should have same format
|
||||
assert new_segments[0].audio.dtype == np.float32
|
||||
|
||||
def test_pipeline_matches_old_interface(self):
|
||||
"""Pipeline utility should match old TTSBackend interface."""
|
||||
manager = PluginManager()
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
|
||||
|
||||
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
|
||||
segments = list(backend(
|
||||
"Hello world",
|
||||
voice="af_heart",
|
||||
speed=1.0,
|
||||
split_pattern=r"\n+"
|
||||
))
|
||||
|
||||
# Should return segments with graphemes and audio
|
||||
assert len(segments) >= 1
|
||||
segment = segments[0]
|
||||
assert segment.graphemes == "Hello world"
|
||||
assert isinstance(segment.audio, np.ndarray)
|
||||
assert segment.audio.dtype == np.float32
|
||||
assert len(segment.audio) > 0
|
||||
|
||||
|
||||
class TestPluginManagerIntegration:
|
||||
"""Integration tests for PluginManager."""
|
||||
|
||||
def test_plugin_manager_singleton_pattern(self):
|
||||
"""Global plugin manager follows singleton pattern."""
|
||||
reset_plugin_manager()
|
||||
|
||||
manager1 = get_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
|
||||
assert manager1 is manager2
|
||||
|
||||
reset_plugin_manager()
|
||||
|
||||
manager3 = get_plugin_manager()
|
||||
assert manager1 is not manager3
|
||||
|
||||
def test_plugin_manager_discover_plugins(self):
|
||||
"""Plugin manager can discover plugins from directory."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Discover from test plugins directory
|
||||
manager.discover("tests/plugins")
|
||||
|
||||
# Should find valid_plugin
|
||||
# (This depends on test plugins existing)
|
||||
plugins = manager.list_plugins()
|
||||
assert isinstance(plugins, list)
|
||||
|
||||
def test_plugin_manager_dispose_all(self):
|
||||
"""Plugin manager can dispose all cached engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Create engines
|
||||
engine1 = manager.get_or_create_engine("mock_tts")
|
||||
engine2 = manager.get_or_create_engine("mock_tts")
|
||||
|
||||
# Dispose all
|
||||
manager.dispose_all()
|
||||
|
||||
# Engines should be disposed
|
||||
assert engine1._disposed is True
|
||||
assert engine2._disposed is True
|
||||
|
||||
# Cache should be empty
|
||||
assert len(manager._engines) == 0
|
||||
|
||||
|
||||
class TestNoCompatLayer:
|
||||
"""Regression: confirm the compatibility layer has been removed."""
|
||||
|
||||
def test_compat_module_does_not_exist(self):
|
||||
"""abogen.tts_plugin.compat must not be importable."""
|
||||
import importlib
|
||||
with pytest.raises((ImportError, ModuleNotFoundError)):
|
||||
importlib.import_module("abogen.tts_plugin.compat")
|
||||
|
||||
def test_consumers_use_plugin_architecture_directly(self):
|
||||
"""Key consumers import from abogen.tts_plugin.utils, not compat."""
|
||||
import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
|
||||
|
||||
for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache):
|
||||
source = inspect.getsource(mod)
|
||||
assert "tts_plugin.compat" not in source, (
|
||||
f"{mod.__name__} still references tts_plugin.compat"
|
||||
)
|
||||
|
||||
@@ -1,436 +1,436 @@
|
||||
"""Comprehensive tests for the plugin loader infrastructure.
|
||||
|
||||
These tests verify that the loader correctly:
|
||||
- Discovers plugins in directories
|
||||
- Imports plugin modules
|
||||
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
|
||||
- Validates api_version compatibility
|
||||
- Validates capabilities
|
||||
- Provides diagnostic messages for errors
|
||||
- Rejects invalid plugins
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.loader import (
|
||||
HOST_API_VERSION,
|
||||
PluginLoadError,
|
||||
PluginLoadResult,
|
||||
_check_api_version_compatibility,
|
||||
_parse_api_version,
|
||||
_validate_api_version,
|
||||
_validate_capabilities,
|
||||
_validate_manifest,
|
||||
discover_plugins,
|
||||
load_plugin,
|
||||
load_plugin_from_dir,
|
||||
)
|
||||
from abogen.tts_plugin.manifest import (
|
||||
EngineManifest,
|
||||
ModelManifest,
|
||||
PluginManifest,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Path fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir() -> Path:
|
||||
return Path(__file__).parent.parent / "plugins"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_plugin_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "fake_plugin"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_manifest_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_manifest"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_api_version_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "invalid_api_version"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_capabilities_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "invalid_capabilities"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_create_engine_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_create_engine"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def import_error_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "import_error"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_model_requirements_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_model_requirements"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _parse_api_version
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseApiVersion:
|
||||
def test_valid_version(self) -> None:
|
||||
assert _parse_api_version("1.0") == (1, 0)
|
||||
assert _parse_api_version("2.5") == (2, 5)
|
||||
assert _parse_api_version("10.20") == (10, 20)
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
assert _parse_api_version("1") is None
|
||||
assert _parse_api_version("1.0.0") is None
|
||||
assert _parse_api_version("abc") is None
|
||||
assert _parse_api_version("") is None
|
||||
assert _parse_api_version("1.x") is None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _check_api_version_compatibility
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCheckApiVersionCompatibility:
|
||||
def test_compatible_version(self) -> None:
|
||||
assert _check_api_version_compatibility("1.0") is None
|
||||
assert _check_api_version_compatibility("1.5") is None
|
||||
|
||||
def test_major_mismatch(self) -> None:
|
||||
error = _check_api_version_compatibility("2.0")
|
||||
assert error is not None
|
||||
assert "major mismatch" in error
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
error = _check_api_version_compatibility("invalid")
|
||||
assert error is not None
|
||||
assert "Invalid api_version format" in error
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_manifest
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateManifest:
|
||||
def test_valid_manifest(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert errors == []
|
||||
|
||||
def test_missing_manifest(self) -> None:
|
||||
class FakeModule:
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
|
||||
|
||||
def test_wrong_manifest_type(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = "not a manifest"
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("PluginManifest instance" in e for e in errors)
|
||||
|
||||
def test_missing_model_requirements(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
|
||||
|
||||
def test_wrong_model_requirements_type(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS = "not a list"
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("must be a list" in e for e in errors)
|
||||
|
||||
def test_invalid_model_requirements_item(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS = ["not a model manifest"]
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("ModelManifest instance" in e for e in errors)
|
||||
|
||||
def test_missing_create_engine(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing create_engine" in e for e in errors)
|
||||
|
||||
def test_create_engine_not_callable(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = "not callable"
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("must be callable" in e for e in errors)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_capabilities
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateCapabilities:
|
||||
def test_valid_capabilities(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=("voice_list", "preview"),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert errors == []
|
||||
|
||||
def test_unknown_capability(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=("voice_list", "unknown_cap"),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert any("unknown_cap" in e for e in errors)
|
||||
|
||||
def test_empty_capabilities(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=(),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert errors == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_api_version
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateApiVersion:
|
||||
def test_compatible(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
errors = _validate_api_version(manifest)
|
||||
assert errors == []
|
||||
|
||||
def test_incompatible(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="2.0", description="Test", author="Test",
|
||||
)
|
||||
errors = _validate_api_version(manifest)
|
||||
assert len(errors) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: load_plugin_from_dir
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLoadPluginFromDir:
|
||||
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(fake_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.manifest is not None
|
||||
assert result.manifest.id == "fake_plugin"
|
||||
assert result.model_requirements is not None
|
||||
assert result.create_engine is not None
|
||||
assert result.module is not None
|
||||
assert result.error is None
|
||||
|
||||
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
import logging
|
||||
|
||||
result = load_plugin_from_dir(fake_plugin_dir)
|
||||
assert result.success is True
|
||||
|
||||
# Create engine using the loaded create_engine function
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
|
||||
)
|
||||
engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
def test_nonexistent_directory(self, tmp_path: Path) -> None:
|
||||
result = load_plugin_from_dir(tmp_path / "nonexistent")
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "does not exist" in result.error.errors[0]
|
||||
|
||||
def test_missing_init_file(self, tmp_path: Path) -> None:
|
||||
plugin_dir = tmp_path / "no_init"
|
||||
plugin_dir.mkdir()
|
||||
result = load_plugin_from_dir(plugin_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "__init__.py" in result.error.errors[0]
|
||||
|
||||
def test_import_error(self, import_error_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(import_error_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "Failed to import" in result.error.errors[0]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: invalid plugins
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestInvalidPlugins:
|
||||
def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
|
||||
|
||||
def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("major mismatch" in e for e in result.error.errors)
|
||||
|
||||
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(invalid_capabilities_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Unknown capability" in e for e in result.error.errors)
|
||||
|
||||
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_create_engine_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Missing create_engine" in e for e in result.error.errors)
|
||||
|
||||
def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_model_requirements_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: discover_plugins
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDiscoverPlugins:
|
||||
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
# Should find multiple plugins (valid and invalid)
|
||||
assert len(results) > 0
|
||||
|
||||
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
valid = [r for r in results if r.success]
|
||||
assert len(valid) >= 1
|
||||
assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
|
||||
|
||||
def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
invalid = [r for r in results if not r.success]
|
||||
assert len(invalid) >= 1
|
||||
|
||||
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
|
||||
results = discover_plugins([tmp_path / "nonexistent"])
|
||||
assert results == []
|
||||
|
||||
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
|
||||
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
|
||||
assert len(results) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Diagnostic messages tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDiagnosticMessages:
|
||||
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert result.error.plugin_id == "missing_manifest"
|
||||
|
||||
def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert result.error.path == missing_manifest_dir
|
||||
|
||||
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert len(result.error.errors) > 0
|
||||
|
||||
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
|
||||
# This plugin has multiple issues
|
||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
||||
assert result.error is not None
|
||||
# Should have at least the api_version error
|
||||
assert len(result.error.errors) >= 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# No partial registration tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestNoPartialRegistration:
|
||||
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
|
||||
"""After failed load, module should not remain in sys.modules."""
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.success is False
|
||||
# Module should not be registered
|
||||
module_name = f"abogen.tts_plugin._loaded.missing_manifest"
|
||||
assert module_name not in sys.modules
|
||||
|
||||
def test_import_error_no_registration(self, import_error_dir: Path) -> None:
|
||||
"""After import error, module should not remain in sys.modules."""
|
||||
result = load_plugin_from_dir(import_error_dir)
|
||||
assert result.success is False
|
||||
module_name = f"abogen.tts_plugin._loaded.import_error"
|
||||
assert module_name not in sys.modules
|
||||
"""Comprehensive tests for the plugin loader infrastructure.
|
||||
|
||||
These tests verify that the loader correctly:
|
||||
- Discovers plugins in directories
|
||||
- Imports plugin modules
|
||||
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
|
||||
- Validates api_version compatibility
|
||||
- Validates capabilities
|
||||
- Provides diagnostic messages for errors
|
||||
- Rejects invalid plugins
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.loader import (
|
||||
HOST_API_VERSION,
|
||||
PluginLoadError,
|
||||
PluginLoadResult,
|
||||
_check_api_version_compatibility,
|
||||
_parse_api_version,
|
||||
_validate_api_version,
|
||||
_validate_capabilities,
|
||||
_validate_manifest,
|
||||
discover_plugins,
|
||||
load_plugin,
|
||||
load_plugin_from_dir,
|
||||
)
|
||||
from abogen.tts_plugin.manifest import (
|
||||
EngineManifest,
|
||||
ModelManifest,
|
||||
PluginManifest,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Path fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir() -> Path:
|
||||
return Path(__file__).parent.parent / "plugins"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_plugin_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "fake_plugin"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_manifest_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_manifest"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_api_version_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "invalid_api_version"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_capabilities_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "invalid_capabilities"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_create_engine_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_create_engine"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def import_error_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "import_error"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_model_requirements_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_model_requirements"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _parse_api_version
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseApiVersion:
|
||||
def test_valid_version(self) -> None:
|
||||
assert _parse_api_version("1.0") == (1, 0)
|
||||
assert _parse_api_version("2.5") == (2, 5)
|
||||
assert _parse_api_version("10.20") == (10, 20)
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
assert _parse_api_version("1") is None
|
||||
assert _parse_api_version("1.0.0") is None
|
||||
assert _parse_api_version("abc") is None
|
||||
assert _parse_api_version("") is None
|
||||
assert _parse_api_version("1.x") is None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _check_api_version_compatibility
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCheckApiVersionCompatibility:
|
||||
def test_compatible_version(self) -> None:
|
||||
assert _check_api_version_compatibility("1.0") is None
|
||||
assert _check_api_version_compatibility("1.5") is None
|
||||
|
||||
def test_major_mismatch(self) -> None:
|
||||
error = _check_api_version_compatibility("2.0")
|
||||
assert error is not None
|
||||
assert "major mismatch" in error
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
error = _check_api_version_compatibility("invalid")
|
||||
assert error is not None
|
||||
assert "Invalid api_version format" in error
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_manifest
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateManifest:
|
||||
def test_valid_manifest(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert errors == []
|
||||
|
||||
def test_missing_manifest(self) -> None:
|
||||
class FakeModule:
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
|
||||
|
||||
def test_wrong_manifest_type(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = "not a manifest"
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("PluginManifest instance" in e for e in errors)
|
||||
|
||||
def test_missing_model_requirements(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
|
||||
|
||||
def test_wrong_model_requirements_type(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS = "not a list"
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("must be a list" in e for e in errors)
|
||||
|
||||
def test_invalid_model_requirements_item(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS = ["not a model manifest"]
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("ModelManifest instance" in e for e in errors)
|
||||
|
||||
def test_missing_create_engine(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing create_engine" in e for e in errors)
|
||||
|
||||
def test_create_engine_not_callable(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = "not callable"
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("must be callable" in e for e in errors)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_capabilities
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateCapabilities:
|
||||
def test_valid_capabilities(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=("voice_list", "preview"),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert errors == []
|
||||
|
||||
def test_unknown_capability(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=("voice_list", "unknown_cap"),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert any("unknown_cap" in e for e in errors)
|
||||
|
||||
def test_empty_capabilities(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=(),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert errors == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_api_version
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateApiVersion:
|
||||
def test_compatible(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
errors = _validate_api_version(manifest)
|
||||
assert errors == []
|
||||
|
||||
def test_incompatible(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="2.0", description="Test", author="Test",
|
||||
)
|
||||
errors = _validate_api_version(manifest)
|
||||
assert len(errors) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: load_plugin_from_dir
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLoadPluginFromDir:
|
||||
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(fake_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.manifest is not None
|
||||
assert result.manifest.id == "fake_plugin"
|
||||
assert result.model_requirements is not None
|
||||
assert result.create_engine is not None
|
||||
assert result.module is not None
|
||||
assert result.error is None
|
||||
|
||||
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
import logging
|
||||
|
||||
result = load_plugin_from_dir(fake_plugin_dir)
|
||||
assert result.success is True
|
||||
|
||||
# Create engine using the loaded create_engine function
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
|
||||
)
|
||||
engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
def test_nonexistent_directory(self, tmp_path: Path) -> None:
|
||||
result = load_plugin_from_dir(tmp_path / "nonexistent")
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "does not exist" in result.error.errors[0]
|
||||
|
||||
def test_missing_init_file(self, tmp_path: Path) -> None:
|
||||
plugin_dir = tmp_path / "no_init"
|
||||
plugin_dir.mkdir()
|
||||
result = load_plugin_from_dir(plugin_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "__init__.py" in result.error.errors[0]
|
||||
|
||||
def test_import_error(self, import_error_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(import_error_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "Failed to import" in result.error.errors[0]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: invalid plugins
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestInvalidPlugins:
|
||||
def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
|
||||
|
||||
def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("major mismatch" in e for e in result.error.errors)
|
||||
|
||||
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(invalid_capabilities_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Unknown capability" in e for e in result.error.errors)
|
||||
|
||||
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_create_engine_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Missing create_engine" in e for e in result.error.errors)
|
||||
|
||||
def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_model_requirements_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: discover_plugins
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDiscoverPlugins:
|
||||
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
# Should find multiple plugins (valid and invalid)
|
||||
assert len(results) > 0
|
||||
|
||||
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
valid = [r for r in results if r.success]
|
||||
assert len(valid) >= 1
|
||||
assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
|
||||
|
||||
def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
invalid = [r for r in results if not r.success]
|
||||
assert len(invalid) >= 1
|
||||
|
||||
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
|
||||
results = discover_plugins([tmp_path / "nonexistent"])
|
||||
assert results == []
|
||||
|
||||
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
|
||||
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
|
||||
assert len(results) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Diagnostic messages tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDiagnosticMessages:
|
||||
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert result.error.plugin_id == "missing_manifest"
|
||||
|
||||
def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert result.error.path == missing_manifest_dir
|
||||
|
||||
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert len(result.error.errors) > 0
|
||||
|
||||
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
|
||||
# This plugin has multiple issues
|
||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
||||
assert result.error is not None
|
||||
# Should have at least the api_version error
|
||||
assert len(result.error.errors) >= 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# No partial registration tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestNoPartialRegistration:
|
||||
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
|
||||
"""After failed load, module should not remain in sys.modules."""
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.success is False
|
||||
# Module should not be registered
|
||||
module_name = f"abogen.tts_plugin._loaded.missing_manifest"
|
||||
assert module_name not in sys.modules
|
||||
|
||||
def test_import_error_no_registration(self, import_error_dir: Path) -> None:
|
||||
"""After import error, module should not remain in sys.modules."""
|
||||
result = load_plugin_from_dir(import_error_dir)
|
||||
assert result.success is False
|
||||
module_name = f"abogen.tts_plugin._loaded.import_error"
|
||||
assert module_name not in sys.modules
|
||||
|
||||
@@ -1,290 +1,290 @@
|
||||
"""Contract tests for plugin manifest types.
|
||||
|
||||
These tests verify that manifest types satisfy the architectural requirements:
|
||||
- All required fields are present
|
||||
- api_version follows semver format
|
||||
- capabilities are properly defined
|
||||
- engine manifest describes the engine correctly
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
|
||||
|
||||
class TestPluginManifestContract:
|
||||
"""Contract tests for PluginManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test-plugin",
|
||||
name="Test Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="A test plugin",
|
||||
author="Test Author",
|
||||
)
|
||||
assert manifest.id == "test-plugin"
|
||||
assert manifest.name == "Test Plugin"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.api_version == "1.0"
|
||||
assert manifest.description == "A test plugin"
|
||||
assert manifest.author == "Test Author"
|
||||
|
||||
def test_api_version_semver_format(self) -> None:
|
||||
"""Architecture spec: api_version format is semver (MAJOR.MINOR)."""
|
||||
valid_versions = ["1.0", "2.1", "10.5"]
|
||||
for version in valid_versions:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version=version,
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert re.match(r"^\d+\.\d+$", manifest.api_version)
|
||||
|
||||
def test_capabilities_default_empty(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert manifest.capabilities == ()
|
||||
|
||||
def test_capabilities_tuple(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
capabilities=("voice_list", "preview"),
|
||||
)
|
||||
assert "voice_list" in manifest.capabilities
|
||||
assert "preview" in manifest.capabilities
|
||||
|
||||
def test_requires_default(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert isinstance(manifest.requires, RequirementManifest)
|
||||
|
||||
def test_engine_default(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert isinstance(manifest.engine, EngineManifest)
|
||||
|
||||
|
||||
class TestEngineManifestContract:
|
||||
"""Contract tests for EngineManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
manifest = EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
|
||||
),
|
||||
parameters=(
|
||||
ParameterManifest(
|
||||
id="speed", name="Speed", description="Speed", type="float", default=1.0
|
||||
),
|
||||
),
|
||||
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
|
||||
)
|
||||
assert len(manifest.voiceSources) == 1
|
||||
assert len(manifest.parameters) == 1
|
||||
assert len(manifest.audioFormats) == 1
|
||||
|
||||
def test_defaults_empty(self) -> None:
|
||||
manifest = EngineManifest()
|
||||
assert manifest.voiceSources == ()
|
||||
assert manifest.parameters == ()
|
||||
assert manifest.audioFormats == ()
|
||||
|
||||
|
||||
class TestVoiceSourceManifestContract:
|
||||
"""Contract tests for VoiceSourceManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
|
||||
assert vs.id == "builtin"
|
||||
assert vs.name == "Builtin"
|
||||
assert vs.type == "list"
|
||||
|
||||
def test_valid_types(self) -> None:
|
||||
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
|
||||
for vtype in valid_types:
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
|
||||
assert vs.type == vtype
|
||||
|
||||
def test_config_optional(self) -> None:
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type="list")
|
||||
assert vs.config is None
|
||||
|
||||
def test_config_any(self) -> None:
|
||||
config = {"voices": ["af_nova", "af_sky"]}
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
|
||||
assert vs.config == config
|
||||
|
||||
|
||||
class TestVoiceManifestContract:
|
||||
"""Contract tests for VoiceManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova")
|
||||
assert v.id == "af_nova"
|
||||
assert v.name == "Nova"
|
||||
|
||||
def test_tags_default_empty(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova")
|
||||
assert v.tags == ()
|
||||
|
||||
def test_tags_tuple(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
|
||||
assert "en" in v.tags
|
||||
assert "female" in v.tags
|
||||
|
||||
|
||||
class TestParameterManifestContract:
|
||||
"""Contract tests for ParameterManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
p = ParameterManifest(
|
||||
id="speed", name="Speed", description="Speech speed", type="float", default=1.0
|
||||
)
|
||||
assert p.id == "speed"
|
||||
assert p.name == "Speed"
|
||||
assert p.description == "Speech speed"
|
||||
assert p.type == "float"
|
||||
assert p.default == 1.0
|
||||
|
||||
def test_valid_types(self) -> None:
|
||||
valid_types = ["float", "int", "string", "boolean", "enum"]
|
||||
for ptype in valid_types:
|
||||
p = ParameterManifest(
|
||||
id="test", name="Test", description="Test", type=ptype, default=None
|
||||
)
|
||||
assert p.type == ptype
|
||||
|
||||
def test_optional_numeric_bounds(self) -> None:
|
||||
p = ParameterManifest(
|
||||
id="speed",
|
||||
name="Speed",
|
||||
description="Speed",
|
||||
type="float",
|
||||
default=1.0,
|
||||
min=0.5,
|
||||
max=2.0,
|
||||
step=0.1,
|
||||
)
|
||||
assert p.min == 0.5
|
||||
assert p.max == 2.0
|
||||
assert p.step == 0.1
|
||||
|
||||
def test_enum_options(self) -> None:
|
||||
options = (
|
||||
EnumOption(value="low", label="Low"),
|
||||
EnumOption(value="high", label="High"),
|
||||
)
|
||||
p = ParameterManifest(
|
||||
id="quality",
|
||||
name="Quality",
|
||||
description="Quality",
|
||||
type="enum",
|
||||
default="low",
|
||||
options=options,
|
||||
)
|
||||
assert len(p.options) == 2
|
||||
assert p.options[0].value == "low"
|
||||
|
||||
|
||||
class TestAudioFormatManifestContract:
|
||||
"""Contract tests for AudioFormatManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
af = AudioFormatManifest(mime="audio/wav", extension="wav")
|
||||
assert af.mime == "audio/wav"
|
||||
assert af.extension == "wav"
|
||||
|
||||
|
||||
class TestEnumOptionContract:
|
||||
"""Contract tests for EnumOption."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
opt = EnumOption(value="low", label="Low Quality")
|
||||
assert opt.value == "low"
|
||||
assert opt.label == "Low Quality"
|
||||
|
||||
|
||||
class TestRequirementManifestContract:
|
||||
"""Contract tests for RequirementManifest."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
req = RequirementManifest()
|
||||
assert req.gpu is None
|
||||
assert req.memory is None
|
||||
assert req.internet is None
|
||||
|
||||
def test_with_gpu(self) -> None:
|
||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
||||
req = RequirementManifest(gpu=gpu)
|
||||
assert req.gpu.required is True
|
||||
assert req.gpu.type == "cuda"
|
||||
assert req.gpu.memory == 8.0
|
||||
|
||||
def test_with_internet(self) -> None:
|
||||
req = RequirementManifest(internet=True)
|
||||
assert req.internet is True
|
||||
|
||||
|
||||
class TestGpuRequirementContract:
|
||||
"""Contract tests for GpuRequirement."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
gpu = GpuRequirement()
|
||||
assert gpu.required is False
|
||||
assert gpu.type is None
|
||||
assert gpu.memory is None
|
||||
|
||||
def test_required_gpu(self) -> None:
|
||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
||||
assert gpu.required is True
|
||||
|
||||
|
||||
class TestModelManifestContract:
|
||||
"""Contract tests for ModelManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
|
||||
assert m.id == "xtts_v2"
|
||||
assert m.name == "XTTS v2"
|
||||
assert m.size == "2GB"
|
||||
"""Contract tests for plugin manifest types.
|
||||
|
||||
These tests verify that manifest types satisfy the architectural requirements:
|
||||
- All required fields are present
|
||||
- api_version follows semver format
|
||||
- capabilities are properly defined
|
||||
- engine manifest describes the engine correctly
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
|
||||
|
||||
class TestPluginManifestContract:
|
||||
"""Contract tests for PluginManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test-plugin",
|
||||
name="Test Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="A test plugin",
|
||||
author="Test Author",
|
||||
)
|
||||
assert manifest.id == "test-plugin"
|
||||
assert manifest.name == "Test Plugin"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.api_version == "1.0"
|
||||
assert manifest.description == "A test plugin"
|
||||
assert manifest.author == "Test Author"
|
||||
|
||||
def test_api_version_semver_format(self) -> None:
|
||||
"""Architecture spec: api_version format is semver (MAJOR.MINOR)."""
|
||||
valid_versions = ["1.0", "2.1", "10.5"]
|
||||
for version in valid_versions:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version=version,
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert re.match(r"^\d+\.\d+$", manifest.api_version)
|
||||
|
||||
def test_capabilities_default_empty(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert manifest.capabilities == ()
|
||||
|
||||
def test_capabilities_tuple(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
capabilities=("voice_list", "preview"),
|
||||
)
|
||||
assert "voice_list" in manifest.capabilities
|
||||
assert "preview" in manifest.capabilities
|
||||
|
||||
def test_requires_default(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert isinstance(manifest.requires, RequirementManifest)
|
||||
|
||||
def test_engine_default(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert isinstance(manifest.engine, EngineManifest)
|
||||
|
||||
|
||||
class TestEngineManifestContract:
|
||||
"""Contract tests for EngineManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
manifest = EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
|
||||
),
|
||||
parameters=(
|
||||
ParameterManifest(
|
||||
id="speed", name="Speed", description="Speed", type="float", default=1.0
|
||||
),
|
||||
),
|
||||
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
|
||||
)
|
||||
assert len(manifest.voiceSources) == 1
|
||||
assert len(manifest.parameters) == 1
|
||||
assert len(manifest.audioFormats) == 1
|
||||
|
||||
def test_defaults_empty(self) -> None:
|
||||
manifest = EngineManifest()
|
||||
assert manifest.voiceSources == ()
|
||||
assert manifest.parameters == ()
|
||||
assert manifest.audioFormats == ()
|
||||
|
||||
|
||||
class TestVoiceSourceManifestContract:
|
||||
"""Contract tests for VoiceSourceManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
|
||||
assert vs.id == "builtin"
|
||||
assert vs.name == "Builtin"
|
||||
assert vs.type == "list"
|
||||
|
||||
def test_valid_types(self) -> None:
|
||||
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
|
||||
for vtype in valid_types:
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
|
||||
assert vs.type == vtype
|
||||
|
||||
def test_config_optional(self) -> None:
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type="list")
|
||||
assert vs.config is None
|
||||
|
||||
def test_config_any(self) -> None:
|
||||
config = {"voices": ["af_nova", "af_sky"]}
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
|
||||
assert vs.config == config
|
||||
|
||||
|
||||
class TestVoiceManifestContract:
|
||||
"""Contract tests for VoiceManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova")
|
||||
assert v.id == "af_nova"
|
||||
assert v.name == "Nova"
|
||||
|
||||
def test_tags_default_empty(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova")
|
||||
assert v.tags == ()
|
||||
|
||||
def test_tags_tuple(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
|
||||
assert "en" in v.tags
|
||||
assert "female" in v.tags
|
||||
|
||||
|
||||
class TestParameterManifestContract:
|
||||
"""Contract tests for ParameterManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
p = ParameterManifest(
|
||||
id="speed", name="Speed", description="Speech speed", type="float", default=1.0
|
||||
)
|
||||
assert p.id == "speed"
|
||||
assert p.name == "Speed"
|
||||
assert p.description == "Speech speed"
|
||||
assert p.type == "float"
|
||||
assert p.default == 1.0
|
||||
|
||||
def test_valid_types(self) -> None:
|
||||
valid_types = ["float", "int", "string", "boolean", "enum"]
|
||||
for ptype in valid_types:
|
||||
p = ParameterManifest(
|
||||
id="test", name="Test", description="Test", type=ptype, default=None
|
||||
)
|
||||
assert p.type == ptype
|
||||
|
||||
def test_optional_numeric_bounds(self) -> None:
|
||||
p = ParameterManifest(
|
||||
id="speed",
|
||||
name="Speed",
|
||||
description="Speed",
|
||||
type="float",
|
||||
default=1.0,
|
||||
min=0.5,
|
||||
max=2.0,
|
||||
step=0.1,
|
||||
)
|
||||
assert p.min == 0.5
|
||||
assert p.max == 2.0
|
||||
assert p.step == 0.1
|
||||
|
||||
def test_enum_options(self) -> None:
|
||||
options = (
|
||||
EnumOption(value="low", label="Low"),
|
||||
EnumOption(value="high", label="High"),
|
||||
)
|
||||
p = ParameterManifest(
|
||||
id="quality",
|
||||
name="Quality",
|
||||
description="Quality",
|
||||
type="enum",
|
||||
default="low",
|
||||
options=options,
|
||||
)
|
||||
assert len(p.options) == 2
|
||||
assert p.options[0].value == "low"
|
||||
|
||||
|
||||
class TestAudioFormatManifestContract:
|
||||
"""Contract tests for AudioFormatManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
af = AudioFormatManifest(mime="audio/wav", extension="wav")
|
||||
assert af.mime == "audio/wav"
|
||||
assert af.extension == "wav"
|
||||
|
||||
|
||||
class TestEnumOptionContract:
|
||||
"""Contract tests for EnumOption."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
opt = EnumOption(value="low", label="Low Quality")
|
||||
assert opt.value == "low"
|
||||
assert opt.label == "Low Quality"
|
||||
|
||||
|
||||
class TestRequirementManifestContract:
|
||||
"""Contract tests for RequirementManifest."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
req = RequirementManifest()
|
||||
assert req.gpu is None
|
||||
assert req.memory is None
|
||||
assert req.internet is None
|
||||
|
||||
def test_with_gpu(self) -> None:
|
||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
||||
req = RequirementManifest(gpu=gpu)
|
||||
assert req.gpu.required is True
|
||||
assert req.gpu.type == "cuda"
|
||||
assert req.gpu.memory == 8.0
|
||||
|
||||
def test_with_internet(self) -> None:
|
||||
req = RequirementManifest(internet=True)
|
||||
assert req.internet is True
|
||||
|
||||
|
||||
class TestGpuRequirementContract:
|
||||
"""Contract tests for GpuRequirement."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
gpu = GpuRequirement()
|
||||
assert gpu.required is False
|
||||
assert gpu.type is None
|
||||
assert gpu.memory is None
|
||||
|
||||
def test_required_gpu(self) -> None:
|
||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
||||
assert gpu.required is True
|
||||
|
||||
|
||||
class TestModelManifestContract:
|
||||
"""Contract tests for ModelManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
|
||||
assert m.id == "xtts_v2"
|
||||
assert m.name == "XTTS v2"
|
||||
assert m.size == "2GB"
|
||||
|
||||
@@ -1,146 +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)
|
||||
"""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)
|
||||
|
||||
@@ -1,264 +1,274 @@
|
||||
"""Integration tests for Plugin Manager and direct utility functions."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
|
||||
from abogen.tts_plugin.utils import Pipeline, create_pipeline
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Fake Engine for testing."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self):
|
||||
return FakeEngineSession()
|
||||
|
||||
def dispose(self):
|
||||
self._disposed = True
|
||||
|
||||
@property
|
||||
def manifest(self):
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class FakeEngineSession:
|
||||
"""Fake EngineSession for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request):
|
||||
# Return fake audio
|
||||
import numpy as np
|
||||
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
|
||||
|
||||
audio = np.zeros(1000, dtype=np.float32)
|
||||
return SynthesizedAudio(
|
||||
data=audio.tobytes(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
|
||||
)
|
||||
|
||||
def dispose(self):
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class TestPluginManager:
|
||||
"""Test PluginManager functionality."""
|
||||
|
||||
def test_plugin_manager_creation(self):
|
||||
"""PluginManager can be created."""
|
||||
manager = PluginManager()
|
||||
assert manager is not None
|
||||
|
||||
def test_plugin_manager_list_discovers_plugins(self):
|
||||
"""PluginManager discovers plugins from plugins directory."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
plugins = manager.list_plugins()
|
||||
# Should discover kokoro plugin if it exists
|
||||
assert isinstance(plugins, list)
|
||||
|
||||
def test_plugin_manager_has_plugin_after_discover(self):
|
||||
"""PluginManager reports plugins after discovery."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||
# This is expected behavior
|
||||
assert isinstance(manager._plugins, dict)
|
||||
|
||||
def test_plugin_manager_get_plugin_after_discover(self):
|
||||
"""PluginManager returns plugin info after discovery."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||
assert isinstance(manager._plugins, dict)
|
||||
|
||||
def test_plugin_manager_create_engine_not_found(self):
|
||||
"""PluginManager raises KeyError for unknown plugins."""
|
||||
manager = PluginManager()
|
||||
with pytest.raises(KeyError, match="Plugin not found"):
|
||||
manager.create_engine("nonexistent")
|
||||
|
||||
def test_plugin_manager_discover_with_empty_dir(self):
|
||||
"""PluginManager handles missing plugins directory."""
|
||||
manager = PluginManager()
|
||||
manager.discover("/nonexistent/path")
|
||||
plugins = manager.list_plugins()
|
||||
assert plugins == []
|
||||
|
||||
def test_global_plugin_manager_singleton(self):
|
||||
"""Global PluginManager is a singleton."""
|
||||
reset_plugin_manager()
|
||||
manager1 = get_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
assert manager1 is manager2
|
||||
reset_plugin_manager()
|
||||
|
||||
def test_reset_plugin_manager(self):
|
||||
"""reset_plugin_manager clears the singleton."""
|
||||
manager1 = get_plugin_manager()
|
||||
reset_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
assert manager1 is not manager2
|
||||
reset_plugin_manager()
|
||||
|
||||
|
||||
class TestPipeline:
|
||||
"""Test Pipeline functionality."""
|
||||
|
||||
def test_pipeline_creation(self):
|
||||
"""Pipeline can be created."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
assert backend is not None
|
||||
|
||||
def test_pipeline_callable(self):
|
||||
"""Pipeline is callable like old TTSBackend."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Should be callable
|
||||
assert callable(backend)
|
||||
|
||||
def test_pipeline_synthesize(self):
|
||||
"""Pipeline can synthesize text."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Call the backend
|
||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Should return at least one segment
|
||||
assert len(segments) >= 1
|
||||
|
||||
# Segment should have graphemes and audio
|
||||
segment = segments[0]
|
||||
assert hasattr(segment, "graphemes")
|
||||
assert hasattr(segment, "audio")
|
||||
assert segment.graphemes == "Hello world"
|
||||
|
||||
def test_pipeline_dispose(self):
|
||||
"""Pipeline can be disposed."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Create a session by calling
|
||||
list(backend("test"))
|
||||
|
||||
# Dispose should not raise
|
||||
backend.dispose()
|
||||
|
||||
# Double dispose should be safe
|
||||
backend.dispose()
|
||||
|
||||
|
||||
class TestCreatePipelineCompat:
|
||||
"""Test create_pipeline utility function."""
|
||||
|
||||
def test_create_pipeline_returns_callable(self):
|
||||
"""create_pipeline returns a callable backend."""
|
||||
# Mock the plugin manager
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
||||
mock_manager = MagicMock()
|
||||
mock_get_manager.return_value = mock_manager
|
||||
|
||||
mock_engine = FakeEngine()
|
||||
mock_manager.create_engine.return_value = mock_engine
|
||||
|
||||
backend = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||
|
||||
assert callable(backend)
|
||||
mock_manager.create_engine.assert_called_once_with("kokoro", lang_code="a", device="cpu")
|
||||
|
||||
def test_create_pipeline_raises_for_unknown_plugin(self):
|
||||
"""create_pipeline raises KeyError for unknown plugins."""
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
||||
mock_manager = MagicMock()
|
||||
mock_get_manager.return_value = mock_manager
|
||||
mock_manager.create_engine.side_effect = KeyError("Plugin not found")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
create_pipeline("nonexistent")
|
||||
|
||||
|
||||
class TestPluginManagerWithFakePlugins:
|
||||
"""Test PluginManager with fake plugin loading."""
|
||||
|
||||
def test_plugin_manager_create_engine_from_plugin(self):
|
||||
"""PluginManager creates engine from loaded plugin."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Manually add a fake plugin
|
||||
def fake_create_engine(**kwargs):
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Create engine
|
||||
engine = manager.create_engine("fake", param="value")
|
||||
|
||||
assert isinstance(engine, FakeEngine)
|
||||
assert engine.kwargs == {"param": "value"}
|
||||
|
||||
def test_plugin_manager_get_or_create_engine(self):
|
||||
"""PluginManager caches engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_create_engine(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Get engine twice
|
||||
engine1 = manager.get_or_create_engine("fake")
|
||||
engine2 = manager.get_or_create_engine("fake")
|
||||
|
||||
# Should be same instance
|
||||
assert engine1 is engine2
|
||||
assert call_count == 1
|
||||
|
||||
def test_plugin_manager_dispose_all(self):
|
||||
"""PluginManager disposes all cached engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
def fake_create_engine(**kwargs):
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Create and cache engines
|
||||
engine1 = manager.get_or_create_engine("fake")
|
||||
engine2 = manager.get_or_create_engine("fake")
|
||||
|
||||
# Dispose all
|
||||
manager.dispose_all()
|
||||
|
||||
# Engines should be disposed
|
||||
assert engine1._disposed is True
|
||||
assert engine2._disposed is True
|
||||
|
||||
# Cache should be empty
|
||||
assert len(manager._engines) == 0
|
||||
"""Integration tests for Plugin Manager and direct utility functions."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
|
||||
from abogen.tts_plugin.utils import Pipeline, create_pipeline
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Fake Engine for testing."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self):
|
||||
return FakeEngineSession()
|
||||
|
||||
def dispose(self):
|
||||
self._disposed = True
|
||||
|
||||
@property
|
||||
def manifest(self):
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class FakeEngineSession:
|
||||
"""Fake EngineSession for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request):
|
||||
# Return fake audio
|
||||
import numpy as np
|
||||
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
|
||||
|
||||
audio = np.zeros(1000, dtype=np.float32)
|
||||
return SynthesizedAudio(
|
||||
data=audio.tobytes(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
|
||||
)
|
||||
|
||||
def dispose(self):
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class TestPluginManager:
|
||||
"""Test PluginManager functionality."""
|
||||
|
||||
def test_plugin_manager_creation(self):
|
||||
"""PluginManager can be created."""
|
||||
manager = PluginManager()
|
||||
assert manager is not None
|
||||
|
||||
def test_plugin_manager_list_discovers_plugins(self):
|
||||
"""PluginManager discovers plugins from plugins directory."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
plugins = manager.list_plugins()
|
||||
# Should discover kokoro plugin if it exists
|
||||
assert isinstance(plugins, list)
|
||||
|
||||
def test_plugin_manager_has_plugin_after_discover(self):
|
||||
"""PluginManager reports plugins after discovery."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||
# This is expected behavior
|
||||
assert isinstance(manager._plugins, dict)
|
||||
|
||||
def test_plugin_manager_get_plugin_after_discover(self):
|
||||
"""PluginManager returns plugin info after discovery."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||
assert isinstance(manager._plugins, dict)
|
||||
|
||||
def test_plugin_manager_create_engine_not_found(self):
|
||||
"""PluginManager raises KeyError for unknown plugins."""
|
||||
manager = PluginManager()
|
||||
with pytest.raises(KeyError, match="Plugin not found"):
|
||||
manager.create_engine("nonexistent")
|
||||
|
||||
def test_plugin_manager_discover_with_empty_dir(self):
|
||||
"""PluginManager handles missing plugins directory."""
|
||||
manager = PluginManager()
|
||||
manager.discover("/nonexistent/path")
|
||||
plugins = manager.list_plugins()
|
||||
assert plugins == []
|
||||
|
||||
def test_global_plugin_manager_singleton(self):
|
||||
"""Global PluginManager is a singleton."""
|
||||
reset_plugin_manager()
|
||||
manager1 = get_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
assert manager1 is manager2
|
||||
reset_plugin_manager()
|
||||
|
||||
def test_reset_plugin_manager(self):
|
||||
"""reset_plugin_manager clears the singleton."""
|
||||
manager1 = get_plugin_manager()
|
||||
reset_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
assert manager1 is not manager2
|
||||
reset_plugin_manager()
|
||||
|
||||
|
||||
class TestPipeline:
|
||||
"""Test Pipeline functionality."""
|
||||
|
||||
def test_pipeline_creation(self):
|
||||
"""Pipeline can be created."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
assert backend is not None
|
||||
|
||||
def test_pipeline_callable(self):
|
||||
"""Pipeline is callable like old TTSBackend."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Should be callable
|
||||
assert callable(backend)
|
||||
|
||||
def test_pipeline_synthesize(self):
|
||||
"""Pipeline can synthesize text."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Call the backend
|
||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Should return at least one segment
|
||||
assert len(segments) >= 1
|
||||
|
||||
# Segment should have graphemes and audio
|
||||
segment = segments[0]
|
||||
assert hasattr(segment, "graphemes")
|
||||
assert hasattr(segment, "audio")
|
||||
assert segment.graphemes == "Hello world"
|
||||
|
||||
def test_pipeline_dispose(self):
|
||||
"""Pipeline can be disposed."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Create a session by calling
|
||||
list(backend("test"))
|
||||
|
||||
# Dispose should not raise
|
||||
backend.dispose()
|
||||
|
||||
# Double dispose should be safe
|
||||
backend.dispose()
|
||||
|
||||
|
||||
class TestCreatePipelineCompat:
|
||||
"""Test create_pipeline utility function."""
|
||||
|
||||
def test_create_pipeline_returns_callable(self):
|
||||
"""create_pipeline returns a callable backend."""
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
# Mock the plugin manager
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
||||
mock_manager = MagicMock()
|
||||
mock_get_manager.return_value = mock_manager
|
||||
|
||||
mock_engine = FakeEngine()
|
||||
mock_manager.create_engine.return_value = mock_engine
|
||||
|
||||
backend = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||
|
||||
assert callable(backend)
|
||||
mock_manager.create_engine.assert_called_once()
|
||||
call_args = mock_manager.create_engine.call_args
|
||||
assert call_args.args[0] == "kokoro"
|
||||
assert isinstance(call_args.kwargs["context"], HostContext)
|
||||
assert call_args.kwargs["model_path"] is None
|
||||
assert isinstance(call_args.kwargs["config"], EngineConfig)
|
||||
assert call_args.kwargs["config"].device == "cpu"
|
||||
assert call_args.kwargs["config"].lang_code == "a"
|
||||
|
||||
def test_create_pipeline_raises_for_unknown_plugin(self):
|
||||
"""create_pipeline raises KeyError for unknown plugins."""
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
||||
mock_manager = MagicMock()
|
||||
mock_get_manager.return_value = mock_manager
|
||||
mock_manager.create_engine.side_effect = KeyError("Plugin not found")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
create_pipeline("nonexistent")
|
||||
|
||||
|
||||
class TestPluginManagerWithFakePlugins:
|
||||
"""Test PluginManager with fake plugin loading."""
|
||||
|
||||
def test_plugin_manager_create_engine_from_plugin(self):
|
||||
"""PluginManager creates engine from loaded plugin."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Manually add a fake plugin
|
||||
def fake_create_engine(**kwargs):
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Create engine
|
||||
engine = manager.create_engine("fake", param="value")
|
||||
|
||||
assert isinstance(engine, FakeEngine)
|
||||
assert engine.kwargs == {"param": "value"}
|
||||
|
||||
def test_plugin_manager_get_or_create_engine(self):
|
||||
"""PluginManager caches engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_create_engine(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Get engine twice
|
||||
engine1 = manager.get_or_create_engine("fake")
|
||||
engine2 = manager.get_or_create_engine("fake")
|
||||
|
||||
# Should be same instance
|
||||
assert engine1 is engine2
|
||||
assert call_count == 1
|
||||
|
||||
def test_plugin_manager_dispose_all(self):
|
||||
"""PluginManager disposes all cached engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
def fake_create_engine(**kwargs):
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Create and cache engines
|
||||
engine1 = manager.get_or_create_engine("fake")
|
||||
engine2 = manager.get_or_create_engine("fake")
|
||||
|
||||
# Dispose all
|
||||
manager.dispose_all()
|
||||
|
||||
# Engines should be disposed
|
||||
assert engine1._disposed is True
|
||||
assert engine2._disposed is True
|
||||
|
||||
# Cache should be empty
|
||||
assert len(manager._engines) == 0
|
||||
|
||||
@@ -1,135 +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()
|
||||
"""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()
|
||||
|
||||
@@ -1,207 +1,244 @@
|
||||
"""Contract tests for core domain value objects.
|
||||
|
||||
These tests verify that value objects satisfy the architectural requirements:
|
||||
- Frozen (immutable) dataclasses
|
||||
- Correct field definitions
|
||||
- Proper equality behavior
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class TestAudioFormatContract:
|
||||
"""Contract tests for AudioFormat value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(AudioFormat, "__dataclass_params__")
|
||||
assert AudioFormat.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert af.mime == "audio/wav"
|
||||
assert af.extension == "wav"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
with pytest.raises(AttributeError):
|
||||
af.mime = "audio/mpeg" # type: ignore[misc]
|
||||
|
||||
def test_equality(self) -> None:
|
||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
af2 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert af1 == af2
|
||||
|
||||
def test_inequality(self) -> None:
|
||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
|
||||
assert af1 != af2
|
||||
|
||||
def test_hashable(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
|
||||
|
||||
|
||||
class TestDurationContract:
|
||||
"""Contract tests for Duration value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(Duration, "__dataclass_params__")
|
||||
assert Duration.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
d = Duration(seconds=1.5)
|
||||
assert d.seconds == 1.5
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
d = Duration(seconds=1.0)
|
||||
with pytest.raises(AttributeError):
|
||||
d.seconds = 2.0 # type: ignore[misc]
|
||||
|
||||
def test_equality(self) -> None:
|
||||
d1 = Duration(seconds=1.0)
|
||||
d2 = Duration(seconds=1.0)
|
||||
assert d1 == d2
|
||||
|
||||
|
||||
class TestVoiceSelectionContract:
|
||||
"""Contract tests for VoiceSelection value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(VoiceSelection, "__dataclass_params__")
|
||||
assert VoiceSelection.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
assert vs.source == "builtin"
|
||||
assert vs.key == "af_nova"
|
||||
|
||||
def test_payload_default_none(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
assert vs.payload is None
|
||||
|
||||
def test_payload_optional(self) -> None:
|
||||
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
|
||||
assert vs.payload == b"audio_data"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
with pytest.raises(AttributeError):
|
||||
vs.source = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestParameterValuesContract:
|
||||
"""Contract tests for ParameterValues value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(ParameterValues, "__dataclass_params__")
|
||||
assert ParameterValues.__dataclass_params__.frozen is True
|
||||
|
||||
def test_default_empty(self) -> None:
|
||||
pv = ParameterValues()
|
||||
assert pv.values == {}
|
||||
|
||||
def test_with_values(self) -> None:
|
||||
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
|
||||
assert pv.values["speed"] == 1.0
|
||||
assert pv.values["pitch"] == 0.5
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
pv = ParameterValues(values={"speed": 1.0})
|
||||
with pytest.raises(AttributeError):
|
||||
pv.values = {} # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSynthesisRequestContract:
|
||||
"""Contract tests for SynthesisRequest value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(SynthesisRequest, "__dataclass_params__")
|
||||
assert SynthesisRequest.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
req = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
assert req.text == "Hello"
|
||||
assert req.voice.source == "builtin"
|
||||
assert req.format.mime == "audio/wav"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
req = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
req.text = "World" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSynthesizedAudioContract:
|
||||
"""Contract tests for SynthesizedAudio value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(SynthesizedAudio, "__dataclass_params__")
|
||||
assert SynthesizedAudio.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_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_immutability(self) -> None:
|
||||
audio = SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
audio.data = b"\x00" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestEngineConfigContract:
|
||||
"""Contract tests for EngineConfig value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(EngineConfig, "__dataclass_params__")
|
||||
assert EngineConfig.__dataclass_params__.frozen is True
|
||||
|
||||
def test_default_device(self) -> None:
|
||||
config = EngineConfig()
|
||||
assert config.device == "cpu"
|
||||
|
||||
def test_custom_device(self) -> None:
|
||||
config = EngineConfig(device="cuda:0")
|
||||
assert config.device == "cuda:0"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
config = EngineConfig()
|
||||
with pytest.raises(AttributeError):
|
||||
config.device = "cuda:0" # type: ignore[misc]
|
||||
|
||||
def test_unknown_keys_ignored_per_spec(self) -> None:
|
||||
"""Architecture spec: Unknown keys are ignored (no error).
|
||||
|
||||
EngineConfig is frozen, so unknown keys cannot be set after creation.
|
||||
This test verifies the default behavior matches the spec.
|
||||
"""
|
||||
config = EngineConfig()
|
||||
assert config.device == "cpu"
|
||||
"""Contract tests for core domain value objects.
|
||||
|
||||
These tests verify that value objects satisfy the architectural requirements:
|
||||
- Frozen (immutable) dataclasses
|
||||
- Correct field definitions
|
||||
- Proper equality behavior
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class TestAudioFormatContract:
|
||||
"""Contract tests for AudioFormat value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(AudioFormat, "__dataclass_params__")
|
||||
assert AudioFormat.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert af.mime == "audio/wav"
|
||||
assert af.extension == "wav"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
with pytest.raises(AttributeError):
|
||||
af.mime = "audio/mpeg" # type: ignore[misc]
|
||||
|
||||
def test_equality(self) -> None:
|
||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
af2 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert af1 == af2
|
||||
|
||||
def test_inequality(self) -> None:
|
||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
|
||||
assert af1 != af2
|
||||
|
||||
def test_hashable(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
|
||||
|
||||
|
||||
class TestDurationContract:
|
||||
"""Contract tests for Duration value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(Duration, "__dataclass_params__")
|
||||
assert Duration.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
d = Duration(seconds=1.5)
|
||||
assert d.seconds == 1.5
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
d = Duration(seconds=1.0)
|
||||
with pytest.raises(AttributeError):
|
||||
d.seconds = 2.0 # type: ignore[misc]
|
||||
|
||||
def test_equality(self) -> None:
|
||||
d1 = Duration(seconds=1.0)
|
||||
d2 = Duration(seconds=1.0)
|
||||
assert d1 == d2
|
||||
|
||||
|
||||
class TestVoiceSelectionContract:
|
||||
"""Contract tests for VoiceSelection value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(VoiceSelection, "__dataclass_params__")
|
||||
assert VoiceSelection.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
assert vs.source == "builtin"
|
||||
assert vs.key == "af_nova"
|
||||
|
||||
def test_payload_default_none(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
assert vs.payload is None
|
||||
|
||||
def test_payload_optional(self) -> None:
|
||||
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
|
||||
assert vs.payload == b"audio_data"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
with pytest.raises(AttributeError):
|
||||
vs.source = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestParameterValuesContract:
|
||||
"""Contract tests for ParameterValues value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(ParameterValues, "__dataclass_params__")
|
||||
assert ParameterValues.__dataclass_params__.frozen is True
|
||||
|
||||
def test_default_empty(self) -> None:
|
||||
pv = ParameterValues()
|
||||
assert pv.values == {}
|
||||
|
||||
def test_with_values(self) -> None:
|
||||
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
|
||||
assert pv.values["speed"] == 1.0
|
||||
assert pv.values["pitch"] == 0.5
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
pv = ParameterValues(values={"speed": 1.0})
|
||||
with pytest.raises(AttributeError):
|
||||
pv.values = {} # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSynthesisRequestContract:
|
||||
"""Contract tests for SynthesisRequest value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(SynthesisRequest, "__dataclass_params__")
|
||||
assert SynthesisRequest.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
req = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
assert req.text == "Hello"
|
||||
assert req.voice.source == "builtin"
|
||||
assert req.format.mime == "audio/wav"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
req = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
req.text = "World" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSynthesizedAudioContract:
|
||||
"""Contract tests for SynthesizedAudio value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(SynthesizedAudio, "__dataclass_params__")
|
||||
assert SynthesizedAudio.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_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_immutability(self) -> None:
|
||||
audio = SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
audio.data = b"\x00" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestEngineConfigContract:
|
||||
"""Contract tests for EngineConfig value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(EngineConfig, "__dataclass_params__")
|
||||
assert EngineConfig.__dataclass_params__.frozen is True
|
||||
|
||||
def test_default_device(self) -> None:
|
||||
config = EngineConfig()
|
||||
assert config.device == "cpu"
|
||||
|
||||
def test_custom_device(self) -> None:
|
||||
config = EngineConfig(device="cuda:0")
|
||||
assert config.device == "cuda:0"
|
||||
|
||||
def test_default_lang_code(self) -> None:
|
||||
config = EngineConfig()
|
||||
assert config.lang_code == "a"
|
||||
|
||||
def test_custom_lang_code(self) -> None:
|
||||
config = EngineConfig(lang_code="j")
|
||||
assert config.lang_code == "j"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
config = EngineConfig()
|
||||
with pytest.raises(AttributeError):
|
||||
config.device = "cuda:0" # type: ignore[misc]
|
||||
|
||||
def test_immutability_lang_code(self) -> None:
|
||||
config = EngineConfig()
|
||||
with pytest.raises(AttributeError):
|
||||
config.lang_code = "j" # type: ignore[misc]
|
||||
|
||||
def test_unknown_keys_ignored_per_spec(self) -> None:
|
||||
"""Architecture spec: Unknown keys are ignored (no error).
|
||||
|
||||
EngineConfig is frozen, so unknown keys cannot be set after creation.
|
||||
This test verifies the default behavior matches the spec.
|
||||
"""
|
||||
config = EngineConfig()
|
||||
assert config.device == "cpu"
|
||||
|
||||
def test_plugins_may_ignore_irrelevant_fields(self) -> None:
|
||||
"""Architecture Amendment #1: Plugins ignore unsupported fields.
|
||||
|
||||
EngineConfig may contain fields that are not relevant to every plugin.
|
||||
Plugins MUST ignore fields they do not need, not raise on them.
|
||||
"""
|
||||
config = EngineConfig(device="cuda:0", lang_code="j")
|
||||
assert config.device == "cuda:0"
|
||||
assert config.lang_code == "j"
|
||||
# A plugin that only needs device simply reads config.device
|
||||
# and ignores config.lang_code — this must not raise.
|
||||
|
||||
def test_engine_config_contains_engine_instance_configuration(self) -> None:
|
||||
"""Architecture Amendment #1: EngineConfig definition.
|
||||
|
||||
EngineConfig contains parameters that define how a particular
|
||||
Engine instance is created and that remain constant throughout
|
||||
the lifetime of that Engine.
|
||||
"""
|
||||
config = EngineConfig(device="cpu", lang_code="a")
|
||||
# Both fields are init-time, immutable, engine-scoped.
|
||||
assert config.device == "cpu"
|
||||
assert config.lang_code == "a"
|
||||
|
||||
Reference in New Issue
Block a user