diff --git a/abogen/tts_plugin/utils.py b/abogen/tts_plugin/utils.py index acfb592..0812fca 100644 --- a/abogen/tts_plugin/utils.py +++ b/abogen/tts_plugin/utils.py @@ -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"). diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index 4cf7b9c..cb5ce22 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -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, diff --git a/docs/architecture-amendment-001.md b/docs/architecture-amendment-001.md deleted file mode 100644 index 51d60ac..0000000 --- a/docs/architecture-amendment-001.md +++ /dev/null @@ -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` | diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..1b281bf --- /dev/null +++ b/docs/contributing.md @@ -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 diff --git a/docs/developer-guide.md b/docs/developer-guide.md new file mode 100644 index 0000000..4b6d829 --- /dev/null +++ b/docs/developer-guide.md @@ -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` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..1a95800 --- /dev/null +++ b/docs/getting-started.md @@ -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 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..c7de656 --- /dev/null +++ b/docs/testing.md @@ -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___` \ No newline at end of file