mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
docs: rewrite developer-guide as architectural reference
- Remove implementation details that will rot (code templates, tutorials) - Keep only stable contracts: ownership, lifecycle, protocols, error semantics - 270 lines → reference that only changes when architecture changes
This commit is contained in:
@@ -201,7 +201,7 @@ def create_pipeline(
|
||||
|
||||
Builds a proper HostContext and EngineConfig, then delegates to the
|
||||
PluginManager to create the engine. Returns a :class:`Pipeline` whose
|
||||
``__call__`` interface matches the legacy ``TTSBackend`` callable protocol.
|
||||
``__call__`` interface matches the callable protocol used by consumers.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
|
||||
|
||||
@@ -1585,7 +1585,7 @@ def run_conversion_job(job: Job) -> None:
|
||||
device = "cpu"
|
||||
if not disable_gpu:
|
||||
device = _select_device()
|
||||
# Create KPipeline instance directly (conforms to TTSBackend protocol)
|
||||
# Create KPipeline instance directly (uses new Plugin Architecture)
|
||||
pipelines[provider_norm] = create_pipeline(
|
||||
"kokoro",
|
||||
lang_code=job.language,
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# Architecture Amendment #1: EngineConfig — `lang_code` field
|
||||
|
||||
**Date:** 2026-07-12
|
||||
**Status:** Accepted
|
||||
**PR:** #12 (Normalize Pipeline Public API)
|
||||
|
||||
## Summary
|
||||
|
||||
Add `lang_code: str = "a"` to `EngineConfig` and update its definition to clarify the architectural contract.
|
||||
|
||||
## Background
|
||||
|
||||
During migration from the old `KokoroBackend` to the Plugin Architecture, the `lang_code` parameter became a dead argument. The old backend read it from `**kwargs` and passed it to `KPipeline(lang_code=...)`. The new `KokoroPlugin.create_engine()` hardcodes `lang_code="a"`, ignoring the config entirely. Callers continued passing `lang_code` to `create_pipeline()`, unaware it had no effect.
|
||||
|
||||
This is a functional regression relative to the pre-Plugin Architecture behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Updated EngineConfig definition
|
||||
|
||||
**Before:**
|
||||
```
|
||||
Immutable value object for engine initialization settings.
|
||||
Contains only engine-specific settings, no resource references.
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
Immutable configuration of an Engine instance.
|
||||
Contains parameters that define how a particular Engine instance is
|
||||
created and that remain constant throughout the lifetime of that Engine.
|
||||
Plugin implementations may ignore fields that are not applicable to them.
|
||||
```
|
||||
|
||||
### 2. New field
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class EngineConfig:
|
||||
device: str = "cpu"
|
||||
lang_code: str = "a"
|
||||
```
|
||||
|
||||
### 3. Architectural rules
|
||||
|
||||
- **Fields in EngineConfig are optional unless explicitly required by a plugin.**
|
||||
- **Plugins MUST ignore unsupported EngineConfig fields.**
|
||||
- **All parameters that may vary between individual synthesis requests must remain in `SynthesisRequest.parameters`.**
|
||||
|
||||
## Rationale
|
||||
|
||||
Analysis of real TTS engines (Kokoro, Piper, XTTS, Coqui, StyleTTS2, Fish Speech) confirmed:
|
||||
|
||||
| Parameter type | Where it belongs | Example |
|
||||
|---------------|-----------------|---------|
|
||||
| Engine instance config (immutable) | `EngineConfig` | `device`, `lang_code` |
|
||||
| Synthesis parameters (per-request) | `SynthesisRequest.parameters` | `speed`, `split_pattern`, `total_steps` |
|
||||
|
||||
`lang_code` determines the engine's behavior at creation time and cannot be changed during the engine's lifetime. It is not a synthesis parameter.
|
||||
|
||||
## Impact on existing plugins
|
||||
|
||||
| Plugin | `device` | `lang_code` | Notes |
|
||||
|--------|----------|-------------|-------|
|
||||
| Kokoro | Reads ✓ | Reads ✓ (was hardcoded, now from config) | Regression fixed |
|
||||
| SuperTonic | Ignores | Ignores | No change — no language concept |
|
||||
| Future plugins | May read | May ignore | Field-ignoring rule applies |
|
||||
|
||||
## Contract tests added
|
||||
|
||||
```python
|
||||
class TestEngineConfigContract:
|
||||
def test_default_lang_code(self) # EngineConfig().lang_code == "a"
|
||||
def test_custom_lang_code(self) # EngineConfig(lang_code="j").lang_code == "j"
|
||||
def test_immutability_lang_code(self) # frozen — cannot reassign
|
||||
def test_plugins_may_ignore_irrelevant_fields(self) # field-ignoring rule
|
||||
def test_engine_config_contains_engine_instance_configuration(self) # definition
|
||||
```
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `abogen/tts_plugin/types.py` | Updated docstring, added `lang_code: str = "a"` |
|
||||
| `plugins/kokoro/__init__.py` | Reads `config.lang_code` instead of hardcoded `"a"` |
|
||||
| `abogen/tts_plugin/utils.py` | `create_pipeline()` passes `lang_code` to `EngineConfig` |
|
||||
| `tests/contracts/test_types_contract.py` | 5 new contract tests |
|
||||
| `tests/contracts/test_plugin_manager_contract.py` | Updated assertion for `lang_code` |
|
||||
| `tests/test_behavioral_regression.py` | Updated `test_engine_config_defaults` |
|
||||
@@ -0,0 +1,55 @@
|
||||
# Contributing to Abogen
|
||||
|
||||
We welcome contributions to Abogen!
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a branch for your feature
|
||||
3. Make your changes
|
||||
4. Write tests
|
||||
5. Submit a pull request
|
||||
|
||||
## Code Standards
|
||||
|
||||
- Follow PEP 8 for Python
|
||||
- Use TypeScript for JavaScript
|
||||
- Type hints required for new Python code
|
||||
- Document complex logic with comments
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
When contributing TTS engines, implement the **Plugin Architecture** contract.
|
||||
|
||||
See [Developer Guide](developer-guide.md#5-adding-a-new-plugin) for:
|
||||
- Required exports (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`)
|
||||
- Engine / EngineSession contracts
|
||||
- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.)
|
||||
- Step-by-step plugin creation guide
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
pytest
|
||||
|
||||
# Contract tests (architectural compliance)
|
||||
pytest tests/contracts/
|
||||
|
||||
# Behavioral regression tests
|
||||
pytest tests/test_behavioral_regression.py
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- Update relevant docs in `docs/` when changing architecture or APIs
|
||||
- Add docstrings to all public functions/classes
|
||||
- Follow existing documentation style
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
- [ ] Tests pass (`pytest`)
|
||||
- [ ] Code follows style guide (`ruff check`, `ruff format`)
|
||||
- [ ] Documentation updated
|
||||
- [ ] No legacy architecture references (`TTSBackend`, `register_backend`, `TTSBackendRegistry`)
|
||||
- [ ] Uses new Plugin Architecture patterns
|
||||
@@ -0,0 +1,270 @@
|
||||
# TTS Plugin Architecture — Architectural Reference
|
||||
|
||||
This document describes the **stable architectural contracts** of the TTS Plugin Architecture. It documents invariants that only change when the architecture itself changes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host Application │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
||||
│ │ Plugin │ │ HostContext │ │ Plugin Discovery │ │
|
||||
│ │ Manager │──│ (config_dir, │ │ (plugin directories) │ │
|
||||
│ │ │ │ logger, │ │ │ │
|
||||
│ │ - discover │ │ http_client)│ └────────────────────────┘ │
|
||||
│ │ - validate │ └──────────────┘ │ │
|
||||
│ │ - activate │ ▼ │
|
||||
│ │ - dispose │ ┌─────────────────────────────────────────┐ │
|
||||
│ └──────┬──────┘ │ Plugin Package │ │
|
||||
│ │ │ ┌──────────────┐ ┌─────────────────┐ │ │
|
||||
│ ▼ │ │ PLUGIN_ │ │ MODEL_ │ │ │
|
||||
│ ┌────────────┐ │ │ MANIFEST │ │ REQUIREMENTS │ │ │
|
||||
│ │ Engine │◄──┤ │ create_engine│ │ │ │ │
|
||||
│ └──────┬─────┘ │ └──────────────┘ └─────────────────┘ │ │
|
||||
│ │ └─────────────────────────────────────────┘ │
|
||||
│ │ createSession() │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │EngineSession│ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ synthesize() │
|
||||
│ ▼ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │SynthesizedAudio│ │
|
||||
│ └────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
| Component | Responsibility |
|
||||
|-----------|----------------|
|
||||
| **PluginManifest** | Static metadata: id, name, version, api_version, capabilities, engine manifest |
|
||||
| **EngineManifest** | Voice sources, parameters, audio formats |
|
||||
| **HostContext** | Minimal host services: config_dir, logger, http_client |
|
||||
| **Engine** | Stateless factory for sessions; thread-safe `createSession()` |
|
||||
| **EngineSession** | Owns mutable execution state; not thread-safe |
|
||||
| **PluginManager** | Discovers, validates, and manages plugin lifecycle |
|
||||
| **Capabilities** | Optional interfaces: VoiceLister, PreviewGenerator, StreamingSynthesizer, CancelableSession |
|
||||
|
||||
---
|
||||
|
||||
## 2. Ownership Model
|
||||
|
||||
### Engine Ownership
|
||||
```
|
||||
PluginManager.create_engine() → Engine
|
||||
```
|
||||
- **PluginManager** creates and caches engines
|
||||
- **Caller** receives `Engine` instance
|
||||
- **Caller** must dispose all sessions **before** disposing engine
|
||||
- **Engine.dispose()** releases engine resources
|
||||
- After `Engine.dispose()`: all methods except `dispose()` raise `EngineError`
|
||||
|
||||
### Session Ownership
|
||||
```
|
||||
Engine.createSession() → EngineSession
|
||||
```
|
||||
- **Engine** creates session
|
||||
- **Ownership transfers to caller** immediately
|
||||
- **Caller** is responsible for `session.dispose()`
|
||||
- **Engine does NOT track sessions** — no registry, no callbacks
|
||||
- After `session.dispose()`: all methods except `dispose()` raise `EngineError`
|
||||
|
||||
### Disposal Order (Invariant)
|
||||
```python
|
||||
# Correct
|
||||
engine = manager.create_engine("id")
|
||||
session = engine.createSession()
|
||||
try:
|
||||
audio = session.synthesize(request)
|
||||
finally:
|
||||
session.dispose() # 1. Sessions FIRST
|
||||
engine.dispose() # 2. Then engine
|
||||
|
||||
# INCORRECT — violates contract (undefined behavior)
|
||||
engine.dispose()
|
||||
session.synthesize(request) # EngineError
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Lifecycle State Machine
|
||||
|
||||
```
|
||||
DISCOVERY
|
||||
PluginManager.discover(plugin_dirs)
|
||||
→ Loads PLUGIN_MANIFEST, MODEL_REQUIREMENTS
|
||||
→ Validates api_version (major must match)
|
||||
→ Validates declared capabilities are implemented
|
||||
|
||||
MODEL_DOWNLOAD (if MODEL_REQUIREMENTS non-empty)
|
||||
Host reads MODEL_REQUIREMENTS
|
||||
Downloads/caches models
|
||||
Resolves model_path
|
||||
|
||||
ACTIVATION
|
||||
create_engine(context, model_path, config)
|
||||
→ Atomic: succeeds fully or raises EngineError
|
||||
→ Returns Engine
|
||||
|
||||
SESSION_CREATION
|
||||
engine.createSession() → EngineSession
|
||||
→ Ownership transfers to caller
|
||||
→ Raises EngineError on failure
|
||||
→ Never returns partial session
|
||||
|
||||
SYNTHESIS
|
||||
session.synthesize(request)
|
||||
→ Returns SynthesizedAudio
|
||||
→ Raises EngineError on failure
|
||||
→ Session remains usable after error
|
||||
|
||||
SESSION_DISPOSAL
|
||||
session.dispose()
|
||||
→ Idempotent, never raises
|
||||
→ After: all methods raise EngineError
|
||||
|
||||
DEACTIVATION
|
||||
engine.dispose()
|
||||
→ Caller MUST dispose all sessions first
|
||||
→ Idempotent, never raises
|
||||
→ After: all methods raise EngineError
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Protocol Contracts
|
||||
|
||||
### Engine (Protocol)
|
||||
```python
|
||||
@runtime_checkable
|
||||
class Engine(Protocol):
|
||||
def createSession(self) -> EngineSession:
|
||||
"""Create a new session. Thread-safe. Transfers ownership."""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release engine resources.
|
||||
Caller must dispose all sessions first.
|
||||
Idempotent, never raises.
|
||||
After: all methods except dispose() raise EngineError."""
|
||||
...
|
||||
```
|
||||
|
||||
### EngineSession (Protocol)
|
||||
```python
|
||||
@runtime_checkable
|
||||
class EngineSession(Protocol):
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
"""Synthesize audio.
|
||||
Returns SynthesizedAudio or raises EngineError.
|
||||
Session remains usable after error."""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release session resources.
|
||||
Idempotent, never raises.
|
||||
After: all methods except dispose() raise EngineError."""
|
||||
...
|
||||
```
|
||||
|
||||
### Capability Protocols (Optional)
|
||||
- **VoiceLister**: `listVoices(source_id: str) -> list[VoiceManifest]`
|
||||
- **PreviewGenerator**: `generatePreview(voice: VoiceSelection, text: str) -> SynthesizedAudio`
|
||||
- **StreamingSynthesizer**: `synthesizeStream(request: SynthesisRequest) -> Iterator[bytes]`
|
||||
- **CancelableSession**: `cancel() -> None` (causes in-flight synthesize to raise `CancelledError`)
|
||||
|
||||
---
|
||||
|
||||
## 5. Error Semantics
|
||||
|
||||
```
|
||||
EngineError (base)
|
||||
├── ModelNotFoundError # Required model not found
|
||||
├── ModelLoadError # Model failed to load
|
||||
├── NetworkError # Network operation failed
|
||||
├── InvalidInputError # Request validation failed
|
||||
├── ConfigurationError # Invalid configuration
|
||||
├── CancelledError # Operation cancelled via CancelableSession
|
||||
└── InternalError # Unexpected internal failure
|
||||
```
|
||||
|
||||
### When Each Is Raised
|
||||
| Error | Raised By | Conditions |
|
||||
|-------|-----------|------------|
|
||||
| `ModelNotFoundError` | `create_engine()` | Required model not found at `model_path` |
|
||||
| `ModelLoadError` | `create_engine()` | Model exists but fails to load |
|
||||
| `NetworkError` | `synthesize()`, `create_engine()` | Network call fails (cloud engines) |
|
||||
| `InvalidInputError` | `synthesize()` | Request validation fails (empty text, invalid voice, etc.) |
|
||||
| `ConfigurationError` | `create_engine()` | Config values invalid for this engine |
|
||||
| `CancelledError` | `synthesize()`, `synthesizeStream()` | `CancelableSession.cancel()` called |
|
||||
| `InternalError` | Any | Unexpected internal failure (bug) |
|
||||
|
||||
### Dispose Contract
|
||||
- `dispose()` is **idempotent** and **never raises**
|
||||
- After `dispose()`: all methods except `dispose()` raise `EngineError`
|
||||
- Engine: caller must dispose all sessions first; violating this is undefined behavior
|
||||
|
||||
---
|
||||
|
||||
## 6. Capabilities
|
||||
|
||||
| Capability | Interface | Enables |
|
||||
|------------|-----------|---------|
|
||||
| `voice_list` | `VoiceLister` | `listVoices(source_id)` — enumerate available voices |
|
||||
| `preview` | `PreviewGenerator` | `generatePreview(voice, text)` — preview without session |
|
||||
| `streaming` | `StreamingSynthesizer` | `synthesizeStream(request)` — chunked audio output |
|
||||
| `cancel` | `CancelableSession` | `cancel()` — interrupt in-flight synthesis |
|
||||
|
||||
Plugins declare capabilities in `PluginManifest.capabilities`. Host validates at load time.
|
||||
|
||||
---
|
||||
|
||||
## 7. Contract Tests
|
||||
|
||||
**Location**: `tests/contracts/`
|
||||
|
||||
**Purpose**: Verify every plugin satisfies the architectural contracts.
|
||||
|
||||
**Guarantees**:
|
||||
- Required exports exist (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`)
|
||||
- `create_engine` is atomic
|
||||
- `Engine.createSession()` transfers ownership, never returns partial
|
||||
- `dispose()` is idempotent on Engine and EngineSession
|
||||
- After `dispose()`, methods raise `EngineError`
|
||||
- `synthesize()` raises typed `EngineError` subtypes, session remains usable
|
||||
- Declared capabilities are actually implemented
|
||||
- Plugin loader validates manifest, api_version, capabilities
|
||||
|
||||
**Run**: `pytest tests/contracts/ -v`
|
||||
|
||||
---
|
||||
|
||||
## 8. Behavioral Tests
|
||||
|
||||
**Location**: `tests/test_behavioral_regression.py`
|
||||
|
||||
**Purpose**: Verify user-facing behavior via public API only (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`).
|
||||
|
||||
**Scope**:
|
||||
- Synthesis with various inputs (short, long, empty, unicode, mixed scripts)
|
||||
- Voice selection and listing
|
||||
- Parameter handling (speed, etc.)
|
||||
- Error scenarios (unknown plugin, disposal, etc.)
|
||||
- Resource cleanup (dispose idempotency, no leaks)
|
||||
- Pipeline utility (`create_pipeline`)
|
||||
|
||||
**Run**: `pytest tests/test_behavioral_regression.py -v`
|
||||
|
||||
---
|
||||
|
||||
## 9. Reference
|
||||
|
||||
- **Architecture Spec**: `docs/architecture-final-v2.md`
|
||||
- **Amendment (lang_code)**: `docs/architecture-amendment-001.md`
|
||||
- **Migration Roadmap**: `docs/migration-roadmap.md`
|
||||
- **Plugin Examples**: `plugins/kokoro/`, `plugins/supertonic/`
|
||||
- **Protocol Definitions**: `abogen/tts_plugin/engine.py`, `abogen/tts_plugin/capabilities.py`
|
||||
@@ -0,0 +1,68 @@
|
||||
# Getting Started
|
||||
|
||||
Quickstart for developers working on Abogen.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
- Git
|
||||
- Docker (optional)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Development install with all extras
|
||||
pip install -e .[dev]
|
||||
|
||||
# Or with uv
|
||||
uv pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
```bash
|
||||
# Desktop GUI
|
||||
abogen
|
||||
|
||||
# Web UI
|
||||
abogen-web
|
||||
|
||||
# CLI
|
||||
abogen-cli
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
abogen/
|
||||
├── pyqt/ - PyQt6 desktop GUI
|
||||
├── webui/ - Flask web UI
|
||||
├── tts_plugin/ - Plugin Architecture (Engine, EngineSession, Manifest)
|
||||
└── plugins/ - Built-in plugins (kokoro, supertonic)
|
||||
tests/
|
||||
├── contracts/ - Contract compliance tests
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
pytest
|
||||
|
||||
# Contract tests (architectural compliance)
|
||||
pytest tests/contracts/
|
||||
|
||||
# Behavioral regression tests
|
||||
pytest tests/test_behavioral_regression.py
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [Developer Guide](developer-guide.md) for Plugin Architecture details:
|
||||
- Engine / EngineSession lifecycle
|
||||
- Plugin contract (PLUGIN_MANIFEST, create_engine)
|
||||
- Adding new plugins
|
||||
- Capability interfaces
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
# Testing Guide
|
||||
|
||||
This document describes the testing strategy for Abogen's Plugin Architecture.
|
||||
|
||||
## Test Categories
|
||||
|
||||
### 1. Contract Tests (`tests/contracts/`)
|
||||
|
||||
**Purpose**: Verify that every plugin satisfies the architectural contract. These tests ensure the Plugin Architecture's invariants are maintained.
|
||||
|
||||
**What They Guarantee**:
|
||||
- Every plugin exports `PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`
|
||||
- `create_engine` is atomic (succeeds fully or raises and cleans up)
|
||||
- `Engine.createSession()` returns valid `EngineSession`, transfers ownership
|
||||
- `Engine.dispose()` is idempotent, never raises
|
||||
- After `dispose()`, all methods raise `EngineError`
|
||||
- `EngineSession.synthesize()` returns `SynthesizedAudio` or raises `EngineError` (session remains usable)
|
||||
- `EngineSession.dispose()` is idempotent, never raises
|
||||
- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.) are correctly implemented
|
||||
- Plugin Loader discovers, validates, and loads plugins correctly
|
||||
- Plugin Manager creates, caches, and disposes engines correctly
|
||||
- Value objects are immutable and have correct equality semantics
|
||||
- Error hierarchy is preserved (`EngineError` base with subtypes)
|
||||
|
||||
**Why They Exist**:
|
||||
- Provide **compile-time-like guarantees** for a dynamic plugin system
|
||||
- Enable **safe plugin ecosystem** — host can trust any loaded plugin
|
||||
- Catch **architectural violations** early (missing dispose, wrong return types, etc.)
|
||||
- Document the **contract** in executable form
|
||||
|
||||
**What Every New Plugin Must Pass**:
|
||||
```bash
|
||||
pytest tests/contracts/ -v
|
||||
# All tests must pass
|
||||
```
|
||||
|
||||
**Running Contract Tests**:
|
||||
```bash
|
||||
# All contract tests
|
||||
pytest tests/contracts/
|
||||
|
||||
# Specific contract
|
||||
pytest tests/contracts/test_engine_contract.py
|
||||
|
||||
# With coverage
|
||||
pytest tests/contracts/ --cov=abogen.tts_plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Behavioral Tests (`tests/test_behavioral_regression.py`)
|
||||
|
||||
**Purpose**: Verify external user-facing behavior using only public API. These tests are **not coupled to internal implementation**.
|
||||
|
||||
**What They Test**:
|
||||
- Synthesis with various inputs (short, long, empty, unicode, mixed scripts)
|
||||
- Voice selection and listing
|
||||
- Parameter handling (speed, etc.)
|
||||
- Error scenarios (unknown plugin, disposal, etc.)
|
||||
- Resource cleanup (dispose idempotency, no leaks)
|
||||
- Pipeline utility (`create_pipeline`)
|
||||
|
||||
**Why They Test Public Behavior Only**:
|
||||
- **Refactoring safety**: Internal changes don't break tests
|
||||
- **Real-world usage**: Tests match how consumers actually use the API
|
||||
- **Plugin agnostic**: Parametrized across Kokoro, SuperTonic, and mock plugins
|
||||
- **Regression detection**: Catch behavioral regressions regardless of implementation
|
||||
|
||||
**What They Don't Test**:
|
||||
- Internal class structure
|
||||
- Private methods
|
||||
- Implementation details (how audio is generated, model loading internals)
|
||||
|
||||
**Running Behavioral Tests**:
|
||||
```bash
|
||||
# All behavioral tests
|
||||
pytest tests/test_behavioral_regression.py -v
|
||||
|
||||
# With specific plugin (if installed)
|
||||
pytest tests/test_behavioral_regression.py -v -k "kokoro"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Unit Tests (`tests/`)
|
||||
|
||||
**Purpose**: Test individual modules in isolation.
|
||||
|
||||
**Examples**:
|
||||
- `test_book_parser.py` — EPUB/PDF/text parsing
|
||||
- `test_text_normalization.py` — Text preprocessing
|
||||
- `test_chunk_helpers.py` — Text chunking logic
|
||||
- `test_voice_cache.py` — Voice caching
|
||||
|
||||
---
|
||||
|
||||
### 4. Integration Tests
|
||||
|
||||
**Purpose**: Test cross-component interactions.
|
||||
|
||||
**Examples**:
|
||||
- `test_kokoro_plugin.py` — Full Kokoro plugin integration
|
||||
- `test_supertonic_plugin.py` — Full SuperTonic plugin integration
|
||||
- `test_conversion_series.py` — End-to-end conversion pipeline
|
||||
|
||||
---
|
||||
|
||||
## Test Architecture
|
||||
|
||||
```
|
||||
tests/
|
||||
├── contracts/ # Contract tests (architectural compliance)
|
||||
│ ├── conftest.py # Shared fixtures (FakeEngine, FakeSession)
|
||||
│ ├── test_manifest_contract.py
|
||||
│ ├── test_plugin_contract.py
|
||||
│ ├── test_engine_contract.py
|
||||
│ ├── test_session_contract.py
|
||||
│ ├── test_capabilities_contract.py
|
||||
│ ├── test_loader_contract.py
|
||||
│ ├── test_plugin_manager_contract.py
|
||||
│ ├── test_types_contract.py
|
||||
│ ├── test_errors_contract.py
|
||||
│ ├── test_host_context_contract.py
|
||||
│ └── test_integration.py
|
||||
├── test_behavioral_regression.py # Behavioral tests (public API)
|
||||
├── test_kokoro_plugin.py # Kokoro integration
|
||||
├── test_supertonic_plugin.py # SuperTonic integration
|
||||
└── ... # Other unit/integration tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding Tests for a New Plugin
|
||||
|
||||
### Contract Tests (Required)
|
||||
|
||||
Create `tests/contracts/test_your_plugin.py`:
|
||||
|
||||
```python
|
||||
"""Contract tests for YourPlugin — verifies architectural compliance."""
|
||||
|
||||
from pathlib import Path
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
|
||||
def test_your_plugin_loads():
|
||||
result = load_plugin_from_dir(Path("plugins/your_tts"))
|
||||
assert result.success
|
||||
assert isinstance(result.manifest, PluginManifest)
|
||||
assert result.manifest.id == "your_tts"
|
||||
assert callable(result.create_engine)
|
||||
|
||||
def test_your_plugin_creates_engine():
|
||||
result = load_plugin_from_dir(Path("plugins/your_tts"))
|
||||
# ... create HostContext, EngineConfig
|
||||
engine = result.create_engine(ctx, None, EngineConfig(device="cpu"))
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
def test_your_plugin_capabilities():
|
||||
"""If plugin declares capabilities, verify they're implemented."""
|
||||
result = load_plugin_from_dir(Path("plugins/your_tts"))
|
||||
# Check VoiceLister, PreviewGenerator, etc.
|
||||
...
|
||||
```
|
||||
|
||||
### Behavioral Tests (Recommended)
|
||||
|
||||
Add parametrized tests to `tests/test_behavioral_regression.py`:
|
||||
|
||||
```python
|
||||
# In _plugin_ids list, add your plugin
|
||||
_plugin_ids = ["kokoro", "supertonic", "your_tts"]
|
||||
_plugin_engines["your_tts"] = _YourMockEngine
|
||||
_plugin_default_voices["your_tts"] = "voice1"
|
||||
_plugin_all_voices["your_tts"] = ["voice1", "voice2"]
|
||||
```
|
||||
|
||||
All existing behavioral tests will automatically run against your plugin.
|
||||
|
||||
---
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
- name: Contract Tests
|
||||
run: pytest tests/contracts/ -v
|
||||
|
||||
- name: Behavioral Tests
|
||||
run: pytest tests/test_behavioral_regression.py -v
|
||||
|
||||
- name: Unit & Integration Tests
|
||||
run: pytest tests/ -v --ignore=tests/test_behavioral_regression.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Design Principles
|
||||
|
||||
### Contract Tests
|
||||
- **No mocks** for the system under test (test real plugin loading)
|
||||
- **Strict assertions** on types and behavior
|
||||
- **Document architecture** in test names and docstrings
|
||||
- **Fail fast** on architectural violations
|
||||
|
||||
### Behavioral Tests
|
||||
- **Only public API** (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`)
|
||||
- **Parametrized** across plugins
|
||||
- **Realistic scenarios** (long text, unicode, mixed scripts)
|
||||
- **No implementation coupling** (test behavior, not internals)
|
||||
|
||||
### General
|
||||
- **Fast**: Unit tests < 1s, Contract tests < 5s, Behavioral < 30s
|
||||
- **Isolated**: No shared state between tests
|
||||
- **Deterministic**: Same input → same output
|
||||
- **Descriptive names**: `test_<component>_<scenario>_<expected>`
|
||||
Reference in New Issue
Block a user