feat: add pytest tests for piper plugin
Add 15 test cases covering manifest validation, monkey-patch logic, idempotency, error handling, lifecycle hooks, and plugin registration. Update README with test instructions and corrected install URL.
This commit is contained in:
297
tests/test_piper_plugin.py
Normal file
297
tests/test_piper_plugin.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Tests for the Hermes Piper TTS plugin.
|
||||
|
||||
Tests cover:
|
||||
- Manifest structure (plugin.yaml)
|
||||
- Monkey-patch application and idempotency
|
||||
- Error handling when tts_tool is not importable
|
||||
- register() context wiring
|
||||
- on_session_start hook behavior
|
||||
|
||||
Run with:
|
||||
cd /opt/data/projects/hermes-piper-plugin
|
||||
python -m pytest tests/ -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import yaml
|
||||
import pytest
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _make_tools_tts(provider: str = "edge") -> types.ModuleType:
|
||||
"""Create a mock tools.tts_tool submodule and set up sys.modules.
|
||||
|
||||
The parent 'tools' module must have ``tts_tool`` as an attribute because
|
||||
Python's submodule import binds on the parent object.
|
||||
"""
|
||||
tts_mod = types.ModuleType("tools.tts_tool")
|
||||
tts_mod.__package__ = "tools"
|
||||
tts_mod.__name__ = "tools.tts_tool"
|
||||
tts_mod.DEFAULT_PROVIDER = provider
|
||||
|
||||
tools_pkg = types.ModuleType("tools")
|
||||
tools_pkg.__path__ = ["/tmp/fake-tools"]
|
||||
tools_pkg.__package__ = "tools"
|
||||
tools_pkg.__name__ = "tools"
|
||||
tools_pkg.tts_tool = tts_mod
|
||||
|
||||
sys.modules["tools"] = tools_pkg
|
||||
sys.modules["tools.tts_tool"] = tts_mod
|
||||
return tts_mod
|
||||
|
||||
|
||||
def _clean_tools_tts():
|
||||
"""Remove mock tools/tools.tts_tool from sys.modules."""
|
||||
for key in ("tools", "tools.tts_tool", "tools.tts_tool.fallback"):
|
||||
sys.modules.pop(key, None)
|
||||
|
||||
|
||||
def _load_plugin_module(name: str) -> types.ModuleType:
|
||||
"""Load the plugin __init__.py as a fresh module with given name."""
|
||||
source = Path(__file__).resolve().parent.parent / "__init__.py"
|
||||
assert source.exists(), f"Plugin init not found at {source}"
|
||||
spec = importlib.util.spec_from_file_location(name, str(source))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
# Reset _PATCHED for test isolation (register() isn't called at module load)
|
||||
mod._PATCHED = False
|
||||
mod.logger = logging.getLogger(f"test_{name}")
|
||||
return mod
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manifest(plugin_dir: Path) -> dict:
|
||||
manifest_path = plugin_dir / "plugin.yaml"
|
||||
assert manifest_path.exists(), f"plugin.yaml not found at {manifest_path}"
|
||||
with open(manifest_path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Manifest tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestManifest:
|
||||
"""plugin.yaml must be present and structurally valid."""
|
||||
|
||||
def test_manifest_exists(self, manifest):
|
||||
"""plugin.yaml parses as valid YAML."""
|
||||
assert isinstance(manifest, dict)
|
||||
|
||||
def test_manifest_required_fields(self, manifest):
|
||||
"""Required fields: name, version, description, hooks."""
|
||||
assert "name" in manifest and manifest["name"]
|
||||
assert "version" in manifest and manifest["version"]
|
||||
assert "description" in manifest and manifest["description"]
|
||||
|
||||
def test_manifest_hooks(self, manifest):
|
||||
"""Plugin must declare on_session_start hook."""
|
||||
hooks = manifest.get("hooks", [])
|
||||
assert isinstance(hooks, list)
|
||||
assert "on_session_start" in hooks
|
||||
|
||||
def test_manifest_requires_pip(self, manifest):
|
||||
"""Piper-tts should be listed as a pip dependency."""
|
||||
deps = manifest.get("requires_pip", [])
|
||||
assert "piper-tts" in deps, (
|
||||
"piper-tts must be listed in requires_pip so the plugin system "
|
||||
"can prompt users to install it"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Monkey-patch tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPiperPatch:
|
||||
"""Test the core monkey-patch logic."""
|
||||
|
||||
def teardown_method(self):
|
||||
_clean_tools_tts()
|
||||
|
||||
def test_patch_changes_default_provider(self):
|
||||
"""_apply_piper_patch() must set DEFAULT_PROVIDER to 'piper'."""
|
||||
mock_tts = _make_tools_tts("edge")
|
||||
mod = _load_plugin_module("test_patch_provider")
|
||||
|
||||
mod._apply_piper_patch()
|
||||
|
||||
assert mock_tts.DEFAULT_PROVIDER == "piper", (
|
||||
f"Expected DEFAULT_PROVIDER='piper', got "
|
||||
f"'{mock_tts.DEFAULT_PROVIDER}'"
|
||||
)
|
||||
assert mod._PATCHED is True
|
||||
|
||||
def test_patch_idempotent(self):
|
||||
"""Calling _apply_piper_patch() twice should not error."""
|
||||
mock_tts = _make_tools_tts("edge")
|
||||
mod = _load_plugin_module("test_idempotent")
|
||||
|
||||
# First call
|
||||
mod._apply_piper_patch()
|
||||
assert mod._PATCHED is True
|
||||
assert mock_tts.DEFAULT_PROVIDER == "piper"
|
||||
|
||||
# Modify back to test second call is no-op
|
||||
mock_tts.DEFAULT_PROVIDER = "elevenlabs"
|
||||
|
||||
# Second call — should short-circuit on _PATCHED
|
||||
mod._apply_piper_patch()
|
||||
|
||||
# Should NOT have changed (flag prevents reapply)
|
||||
assert mock_tts.DEFAULT_PROVIDER == "elevenlabs"
|
||||
|
||||
def test_patch_import_error_safe(self):
|
||||
"""If tools.tts_tool is not importable, patch must not crash.
|
||||
|
||||
We simulate the failure by patching the import inside the function
|
||||
to raise ImportError. This verifies the ``except ImportError``
|
||||
branch is reached and _PATCHED stays False.
|
||||
"""
|
||||
mod = _load_plugin_module("test_import_error")
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def broken_import(name, *args, **kw):
|
||||
if name == "tools.tts_tool":
|
||||
raise ImportError(f"No module named {name}")
|
||||
return original_import(name, *args, **kw)
|
||||
|
||||
with patch("builtins.__import__", side_effect=broken_import):
|
||||
mod._apply_piper_patch()
|
||||
assert mod._PATCHED is False # patch was deferred
|
||||
|
||||
def test_patch_generic_exception_safe(self):
|
||||
"""If something goes wrong during patching, catch and log."""
|
||||
mock_tts = _make_tools_tts("edge")
|
||||
mod = _load_plugin_module("test_exception")
|
||||
|
||||
# Should not raise — the patch uses getattr with default and setattr
|
||||
mod._apply_piper_patch()
|
||||
|
||||
# With our mock, the patch should succeed
|
||||
assert mod._PATCHED is True
|
||||
assert mock_tts.DEFAULT_PROVIDER == "piper"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# on_session_start hook tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSessionStartHook:
|
||||
"""Test the on_session_start lifecycle hook."""
|
||||
|
||||
def teardown_method(self):
|
||||
_clean_tools_tts()
|
||||
|
||||
def test_hook_does_not_raise(self):
|
||||
"""_on_session_start must not raise even if tts_tool is missing."""
|
||||
mod = _load_plugin_module("test_hook_no_raise")
|
||||
mod._on_session_start() # should not raise
|
||||
|
||||
def test_hook_with_patch_applied(self):
|
||||
"""_on_session_start should work with patch already applied."""
|
||||
mock_tts = _make_tools_tts("edge")
|
||||
mod = _load_plugin_module("test_hook_applied")
|
||||
mod._apply_piper_patch()
|
||||
|
||||
assert mock_tts.DEFAULT_PROVIDER == "piper"
|
||||
|
||||
# Hook should run without error
|
||||
mod._on_session_start()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# register() tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestRegister:
|
||||
"""Test the plugin's register() entry point."""
|
||||
|
||||
def teardown_method(self):
|
||||
_clean_tools_tts()
|
||||
|
||||
def test_register_applies_patch(self):
|
||||
"""register() should apply the piper patch immediately."""
|
||||
mock_tts = _make_tools_tts("edge")
|
||||
mod = _load_plugin_module("test_register_applies")
|
||||
ctx = MagicMock()
|
||||
|
||||
mod.register(ctx)
|
||||
|
||||
assert mock_tts.DEFAULT_PROVIDER == "piper"
|
||||
ctx.register_hook.assert_called_once_with(
|
||||
"on_session_start", mod._on_session_start
|
||||
)
|
||||
|
||||
def test_register_no_error_if_missing_deps(self):
|
||||
"""register() must not crash if tools.tts_tool is not available."""
|
||||
mod = _load_plugin_module("test_register_nodeps")
|
||||
ctx = MagicMock()
|
||||
|
||||
# Should not raise
|
||||
mod.register(ctx)
|
||||
|
||||
# Should still have registered the hook even if patch was deferred
|
||||
ctx.register_hook.assert_called_once()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Plugin structure tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPluginStructure:
|
||||
"""Verify the plugin directory has all required files."""
|
||||
|
||||
def test_required_files_exist(self, plugin_dir):
|
||||
"""Plugin must have plugin.yaml, __init__.py, and README.md."""
|
||||
assert (plugin_dir / "plugin.yaml").exists(), "Missing plugin.yaml"
|
||||
assert (plugin_dir / "__init__.py").exists(), "Missing __init__.py"
|
||||
assert (plugin_dir / "README.md").exists(), "Missing README.md"
|
||||
|
||||
def test_init_has_register_function(self, plugin_dir):
|
||||
"""__init__.py must expose a register() function."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"test_register_check", str(plugin_dir / "__init__.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
assert hasattr(mod, "register"), (
|
||||
"__init__.py must define a register(ctx) function"
|
||||
)
|
||||
assert callable(mod.register), "register must be callable"
|
||||
|
||||
def test_plugin_yaml_valid(self, manifest):
|
||||
"""plugin.yaml must contain valid plugin metadata."""
|
||||
assert isinstance(manifest.get("name"), str)
|
||||
assert isinstance(manifest.get("version"), str)
|
||||
assert isinstance(manifest.get("hooks"), list)
|
||||
Reference in New Issue
Block a user