"""
Shared test fixtures for LocalVoiceMode tests.
Provides mocks for audio, TTS, ASR, and skill components to enable
testing without loading real models or requiring audio hardware.
"""
import sys
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
# =============================================================================
# Audio Fixtures
# =============================================================================
@pytest.fixture
def mock_audio_data() -> np.ndarray:
"""Generate synthetic audio data for testing.
Creates a 1-second sine wave at 440Hz (A4 note), 16kHz sample rate, float32.
This is a standard format for speech processing pipelines.
Returns:
np.ndarray: Float32 audio data with shape (16000,)
"""
sample_rate = 16000
duration = 1.0
frequency = 440.0
t = np.linspace(0, duration, int(sample_rate * duration), dtype=np.float32)
audio = np.sin(2 * np.pi * frequency * t).astype(np.float32)
return audio
@pytest.fixture
def mock_stereo_audio_data(mock_audio_data: np.ndarray) -> np.ndarray:
"""Generate stereo audio data for testing.
Returns:
np.ndarray: Float32 stereo audio data with shape (16000, 2)
"""
return np.stack([mock_audio_data, mock_audio_data], axis=-1)
@pytest.fixture
def mock_sounddevice(mocker):
"""Mock sounddevice.InputStream for audio input testing.
Returns a mock InputStream that supports context manager protocol
and provides a controllable read() method.
"""
mock_stream = MagicMock()
mock_stream.__enter__ = MagicMock(return_value=mock_stream)
mock_stream.__exit__ = MagicMock(return_value=False)
mock_stream.read = MagicMock(return_value=(np.zeros(1600, dtype=np.float32), False))
mock_module = MagicMock()
mock_module.InputStream = MagicMock(return_value=mock_stream)
mock_module.query_devices = MagicMock(return_value=[
{"name": "Test Input", "max_input_channels": 2, "default_samplerate": 16000},
{"name": "Test Output", "max_output_channels": 2, "default_samplerate": 16000},
])
mock_module.default = MagicMock()
mock_module.default.device = (0, 1)
mocker.patch.dict(sys.modules, {"sounddevice": mock_module})
return mock_module
# =============================================================================
# ASR (Speech Recognition) Fixtures
# =============================================================================
@pytest.fixture
def mock_onnx_model(mocker):
"""Mock onnx_asr.load_model for ASR testing.
Returns a mock model that provides a recognize() method
returning predictable transcription text.
"""
mock_model = MagicMock()
mock_model.recognize = MagicMock(return_value="transcribed text")
mock_model.sample_rate = 16000
mock_load = MagicMock(return_value=mock_model)
mock_module = MagicMock()
mock_module.load_model = mock_load
mocker.patch.dict(sys.modules, {"onnx_asr": mock_module})
return mock_module
# =============================================================================
# TTS (Text-to-Speech) Fixtures
# =============================================================================
@pytest.fixture
def mock_tts_model(mocker):
"""Mock pocket_tts.TTSModel for TTS testing.
Returns a mock TTS model with:
- sample_rate = 24000 (standard for Pocket TTS)
- generate_audio() returns mock tensor with numpy() method
"""
mock_audio_output = MagicMock()
mock_audio_output.numpy = MagicMock(return_value=np.zeros(24000, dtype=np.float32))
mock_model = MagicMock()
mock_model.sample_rate = 24000
mock_model.generate_audio = MagicMock(return_value=mock_audio_output)
mock_tts_model_class = MagicMock()
mock_tts_model_class.load_model = MagicMock(return_value=mock_model)
mock_module = MagicMock()
mock_module.TTSModel = mock_tts_model_class
mocker.patch.dict(sys.modules, {"pocket_tts": mock_module})
return mock_module
# =============================================================================
# Skill Fixtures
# =============================================================================
@pytest.fixture
def sample_skill_data() -> dict:
"""Sample skill data for testing skill loading and parsing.
Returns a dict representing a parsed SKILL.md file with all
standard fields populated.
"""
return {
"id": "test-assistant",
"name": "Test Assistant",
"display_name": "Test Assistant",
"description": "A test assistant for unit testing",
"system_prompt": "You are a helpful test assistant.",
"voice": None,
"avatar": None,
"metadata": {
"setting": "Testing environment",
"greeting": "Hello! I'm ready for testing.",
},
"initial_memories": [],
"personality_traits": ["helpful", "precise"],
"speech_patterns": [],
"allowed_tools": [],
}
@pytest.fixture
def sample_skill_yaml() -> str:
"""Sample SKILL.md content for testing skill file parsing."""
return '''---
id: test-assistant
name: Test Assistant
display_name: "Test Assistant"
description: A test assistant for unit testing
voice: null
avatar: null
metadata:
setting: "Testing environment"
greeting: "Hello! I'm ready for testing."
initial_memories: []
personality_traits:
- helpful
- precise
speech_patterns: []
allowed_tools: []
---
# Test Assistant
## System Prompt
You are a helpful test assistant.
'''
# =============================================================================
# LLM Client Fixtures
# =============================================================================
@pytest.fixture
def mock_httpx_client(mocker):
"""Mock httpx.AsyncClient for LLM API testing.
Provides a mock client that returns predictable responses
for OpenAI-compatible chat completion endpoints.
"""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json = MagicMock(return_value={
"id": "chatcmpl-test123",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "This is a test response.",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
})
mock_response.raise_for_status = MagicMock()
mock_client = MagicMock()
mock_client.post = MagicMock(return_value=mock_response)
mock_client.__aenter__ = MagicMock(return_value=mock_client)
mock_client.__aexit__ = MagicMock(return_value=False)
mocker.patch("httpx.AsyncClient", return_value=mock_client)
return mock_client
# =============================================================================
# Environment and State Fixtures
# =============================================================================
@pytest.fixture(autouse=True)
def reset_globals():
"""Reset module-level globals after each test.
This autouse fixture runs after every test to prevent
test pollution from cached state.
"""
yield
# Post-test cleanup
# Clear any module-level caches that might exist in voice_client
# This will be expanded as we identify specific globals to reset
@pytest.fixture
def clean_env(mocker):
"""Provide a clean environment for testing env var handling.
Clears voice-related environment variables.
"""
env_vars_to_clear = [
"VOICE_API_URL",
"VOICE_API_KEY",
"VOICE_MODEL",
"VOICE_PROVIDER",
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"VOICE_TTS_VOICE",
"VOICE_DEVICE",
"VOICE_VAD_BACKEND",
"VOICE_SMART_TURN_THRESHOLD",
]
for var in env_vars_to_clear:
mocker.patch.dict("os.environ", {}, clear=False)
if var in mocker.patch.dict("os.environ", {}):
del mocker.patch.dict("os.environ", {})[var]
return mocker
@pytest.fixture
def temp_skill_dir(tmp_path):
"""Create a temporary skill directory structure for testing.
Returns path to a temp directory with skill structure.
"""
skill_dir = tmp_path / "skills" / "test-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "references").mkdir()
(skill_dir / "scripts").mkdir()
return skill_dir