* fix: implement microphone reconnection and audio buffer limits for reliability - Add microphone reconnection logic with exponential backoff - Implement audio buffer limits to prevent memory issues - Add configurable buffer size limits with clamping - Improve error handling for audio device failures - Add buffer statistics and monitoring capabilities - Graceful stream cleanup and resource management Resolves #92 Changes made: - Added _max_buffer_size, _reconnection_attempts, _max_reconnection_attempts attributes - Implemented _attempt_audio_reconnection() method with exponential backoff - Enhanced _record_audio() method with buffer limit checking and reconnection - Added set_buffer_limit() and get_buffer_stats() methods - Improved error handling for IOError/OSError during audio recording - Added test scripts to validate reliability improvements The implementation prevents memory issues by limiting audio buffer size and automatically reconnecting to audio devices when they fail, with proper exponential backoff to prevent rapid reconnection attempts. * fix: Address code review feedback for PR #109 - Add thread lock (_buffer_lock) for audio_buffer to prevent race conditions - Fix _last_audio_error_time reset in cleanup - Protect buffer access in stop_recognition and get_buffer_stats with lock - Fix test_max_reconnection_attempts to match actual counter behavior Addresses review feedback for microphone reconnection and audio buffer limits * style: fix lint errors * fix: add missing sys import in audio_feedback.py - Add sys import required by _is_ci_mode() function - Remove temporary test_reliability scripts * style: fix black formatting --------- Co-authored-by: OpenClaw Reviewer <review@openclaw.ai>
381 lines
15 KiB
Python
381 lines
15 KiB
Python
"""
|
|
Tests for the config manager functionality.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
# Update import path to use the new package structure
|
|
from vocalinux.ui.config_manager import DEFAULT_CONFIG, ConfigManager
|
|
|
|
|
|
class TestConfigManager(unittest.TestCase):
|
|
"""Test cases for the configuration manager."""
|
|
|
|
def setUp(self):
|
|
"""Set up test environment."""
|
|
# Create a temporary directory for configuration
|
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
self.temp_config_dir = os.path.join(self.temp_dir.name, ".config/vocalinux")
|
|
os.makedirs(self.temp_config_dir, exist_ok=True)
|
|
self.temp_config_file = os.path.join(self.temp_config_dir, "config.json")
|
|
|
|
# Patch the config paths to use our temporary directory
|
|
self.config_dir_patcher = patch(
|
|
"vocalinux.ui.config_manager.CONFIG_DIR", self.temp_config_dir
|
|
)
|
|
self.config_file_patcher = patch(
|
|
"vocalinux.ui.config_manager.CONFIG_FILE", self.temp_config_file
|
|
)
|
|
|
|
self.config_dir_patcher.start()
|
|
self.config_file_patcher.start()
|
|
|
|
# Patch logging to avoid actual logging
|
|
self.logger_patcher = patch("vocalinux.ui.config_manager.logger")
|
|
self.mock_logger = self.logger_patcher.start()
|
|
|
|
def tearDown(self):
|
|
"""Clean up after tests."""
|
|
self.config_dir_patcher.stop()
|
|
self.config_file_patcher.stop()
|
|
self.logger_patcher.stop()
|
|
self.temp_dir.cleanup()
|
|
|
|
def test_init_default_config(self):
|
|
"""Test initialization with default configuration."""
|
|
config_manager = ConfigManager()
|
|
self.assertEqual(config_manager.config, DEFAULT_CONFIG)
|
|
self.mock_logger.info.assert_called_with(
|
|
f"Config file not found at {self.temp_config_file}. Using defaults."
|
|
)
|
|
self.assertTrue(os.path.exists(self.temp_config_dir))
|
|
|
|
def test_ensure_config_dir(self):
|
|
"""Test that _ensure_config_dir creates the directory."""
|
|
# Delete the config directory to test creation
|
|
os.rmdir(self.temp_config_dir)
|
|
self.assertFalse(os.path.exists(self.temp_config_dir))
|
|
|
|
# Create config manager, which should create the directory
|
|
ConfigManager()
|
|
self.assertTrue(os.path.exists(self.temp_config_dir))
|
|
|
|
def test_load_config(self):
|
|
"""Test loading configuration from file."""
|
|
# Create a test config file
|
|
test_config = {
|
|
"speech_recognition": {
|
|
"engine": "whisper",
|
|
"model_size": "large",
|
|
},
|
|
"ui": {
|
|
"start_minimized": True,
|
|
},
|
|
}
|
|
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(test_config, f)
|
|
|
|
# Load the config
|
|
config_manager = ConfigManager()
|
|
|
|
# Verify it merged with defaults correctly
|
|
self.assertEqual(config_manager.config["speech_recognition"]["engine"], "whisper")
|
|
self.assertEqual(config_manager.config["speech_recognition"]["model_size"], "large")
|
|
self.assertEqual(
|
|
config_manager.config["speech_recognition"]["vad_sensitivity"], 3
|
|
) # From defaults
|
|
self.assertEqual(config_manager.config["ui"]["start_minimized"], True)
|
|
self.assertEqual(config_manager.config["ui"]["show_notifications"], True) # From defaults
|
|
|
|
def test_load_config_file_error(self):
|
|
"""Test handling of errors when loading config file."""
|
|
# Create a broken config file
|
|
with open(self.temp_config_file, "w") as f:
|
|
f.write("{broken json")
|
|
|
|
# Load the config - with broken JSON, it should use defaults
|
|
config_manager = ConfigManager()
|
|
|
|
# Verify it used defaults - check key structure exists
|
|
self.assertIn("speech_recognition", config_manager.config)
|
|
self.assertIn("engine", config_manager.config["speech_recognition"])
|
|
self.assertIn("ui", config_manager.config)
|
|
|
|
# Verify logger.error was called for the broken JSON
|
|
self.mock_logger.error.assert_called()
|
|
|
|
def test_save_config(self):
|
|
"""Test saving configuration to file."""
|
|
config_manager = ConfigManager()
|
|
|
|
# Modify config
|
|
config_manager.config["speech_recognition"]["engine"] = "whisper"
|
|
config_manager.config["ui"]["start_minimized"] = True
|
|
|
|
# Save config
|
|
result = config_manager.save_config()
|
|
self.assertTrue(result)
|
|
|
|
# Verify file was created with correct content
|
|
self.assertTrue(os.path.exists(self.temp_config_file))
|
|
with open(self.temp_config_file, "r") as f:
|
|
saved_config = json.load(f)
|
|
|
|
self.assertEqual(saved_config["speech_recognition"]["engine"], "whisper")
|
|
self.assertEqual(saved_config["ui"]["start_minimized"], True)
|
|
|
|
def test_save_config_error(self):
|
|
"""Test handling of errors when saving config file."""
|
|
config_manager = ConfigManager()
|
|
|
|
# Mock open to raise an exception
|
|
with patch("builtins.open", side_effect=PermissionError("Permission denied")):
|
|
result = config_manager.save_config()
|
|
self.assertFalse(result)
|
|
self.mock_logger.error.assert_called()
|
|
|
|
def test_get_existing_value(self):
|
|
"""Test getting an existing configuration value from defaults."""
|
|
# Test that DEFAULT_CONFIG constant has the expected default engine
|
|
# Import directly to get the current value (not cached from earlier imports)
|
|
import importlib
|
|
|
|
import vocalinux.ui.config_manager as cm
|
|
|
|
importlib.reload(cm)
|
|
self.assertEqual(
|
|
cm.DEFAULT_CONFIG["speech_recognition"]["engine"], "whisper"
|
|
) # Default is whisper for better accuracy
|
|
|
|
# Also test the get method works - it should return a valid engine value
|
|
config_manager = ConfigManager()
|
|
value = config_manager.get("speech_recognition", "engine")
|
|
self.assertIn(value, ["vosk", "whisper"]) # Should be one of valid engines
|
|
|
|
def test_get_nonexistent_value(self):
|
|
"""Test getting a nonexistent configuration value."""
|
|
config_manager = ConfigManager()
|
|
value = config_manager.get("nonexistent", "key", "default_value")
|
|
self.assertEqual(value, "default_value")
|
|
|
|
def test_set_existing_section(self):
|
|
"""Test setting a value in an existing section."""
|
|
config_manager = ConfigManager()
|
|
result = config_manager.set("speech_recognition", "engine", "vosk")
|
|
self.assertTrue(result)
|
|
self.assertEqual(config_manager.config["speech_recognition"]["engine"], "vosk")
|
|
|
|
def test_set_new_section(self):
|
|
"""Test setting a value in a new section."""
|
|
config_manager = ConfigManager()
|
|
result = config_manager.set("new_section", "key", "value")
|
|
self.assertTrue(result)
|
|
self.assertEqual(config_manager.config["new_section"]["key"], "value")
|
|
|
|
def test_set_error(self):
|
|
"""Test handling of errors when setting a value."""
|
|
config_manager = ConfigManager()
|
|
config_manager.config = 1 # Not a dict, will cause error
|
|
result = config_manager.set("section", "key", "value")
|
|
self.assertFalse(result)
|
|
self.mock_logger.error.assert_called()
|
|
|
|
def test_update_dict_recursive(self):
|
|
"""Test recursive dictionary update."""
|
|
target = {"a": {"b": 1, "c": 2}, "d": 3}
|
|
|
|
source = {"a": {"b": 10, "e": 20}, "f": 30}
|
|
|
|
config_manager = ConfigManager()
|
|
config_manager._update_dict_recursive(target, source)
|
|
|
|
# Check that values were updated correctly
|
|
self.assertEqual(target["a"]["b"], 10) # Updated
|
|
self.assertEqual(target["a"]["c"], 2) # Unchanged
|
|
self.assertEqual(target["a"]["e"], 20) # Added
|
|
self.assertEqual(target["d"], 3) # Unchanged
|
|
self.assertEqual(target["f"], 30) # Added
|
|
|
|
def test_get_model_size_for_engine(self):
|
|
"""Test getting model size for a specific engine."""
|
|
config_manager = ConfigManager()
|
|
|
|
# Test default values
|
|
self.assertEqual(config_manager.get_model_size_for_engine("vosk"), "small")
|
|
self.assertEqual(config_manager.get_model_size_for_engine("whisper"), "tiny")
|
|
|
|
# Set specific model sizes
|
|
config_manager.set_model_size_for_engine("vosk", "medium")
|
|
config_manager.set_model_size_for_engine("whisper", "small")
|
|
|
|
# Verify they are returned correctly
|
|
self.assertEqual(config_manager.get_model_size_for_engine("vosk"), "medium")
|
|
self.assertEqual(config_manager.get_model_size_for_engine("whisper"), "small")
|
|
|
|
def test_set_model_size_for_engine(self):
|
|
"""Test setting model size for a specific engine."""
|
|
config_manager = ConfigManager()
|
|
|
|
# Set model size for vosk
|
|
config_manager.set_model_size_for_engine("vosk", "large")
|
|
self.assertEqual(config_manager.config["speech_recognition"]["vosk_model_size"], "large")
|
|
self.assertEqual(config_manager.config["speech_recognition"]["model_size"], "large")
|
|
|
|
# Set model size for whisper
|
|
config_manager.set_model_size_for_engine("whisper", "medium")
|
|
self.assertEqual(
|
|
config_manager.config["speech_recognition"]["whisper_model_size"], "medium"
|
|
)
|
|
self.assertEqual(config_manager.config["speech_recognition"]["model_size"], "medium")
|
|
|
|
def test_migrate_old_config(self):
|
|
"""Test migration of old config format without per-engine model sizes."""
|
|
# Create an old-style config file without per-engine model sizes
|
|
old_config = {
|
|
"speech_recognition": {
|
|
"engine": "vosk",
|
|
"model_size": "medium",
|
|
"vad_sensitivity": 3,
|
|
"silence_timeout": 2.0,
|
|
}
|
|
}
|
|
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(old_config, f)
|
|
|
|
# Load the config (should trigger migration)
|
|
config_manager = ConfigManager()
|
|
|
|
# Verify migration added per-engine model sizes
|
|
self.assertEqual(config_manager.config["speech_recognition"]["vosk_model_size"], "medium")
|
|
self.assertEqual(config_manager.config["speech_recognition"]["whisper_model_size"], "tiny")
|
|
|
|
def test_update_speech_recognition_settings_per_engine(self):
|
|
"""Test that update_speech_recognition_settings saves per-engine model sizes."""
|
|
config_manager = ConfigManager()
|
|
|
|
# Update settings for vosk
|
|
config_manager.update_speech_recognition_settings({"engine": "vosk", "model_size": "large"})
|
|
|
|
self.assertEqual(config_manager.config["speech_recognition"]["vosk_model_size"], "large")
|
|
|
|
# Update settings for whisper
|
|
config_manager.update_speech_recognition_settings(
|
|
{"engine": "whisper", "model_size": "small"}
|
|
)
|
|
|
|
self.assertEqual(config_manager.config["speech_recognition"]["whisper_model_size"], "small")
|
|
|
|
# Verify the vosk setting wasn't changed
|
|
self.assertEqual(config_manager.config["speech_recognition"]["vosk_model_size"], "large")
|
|
|
|
def test_save_settings(self):
|
|
"""Test the save_settings method (alias for save_config)."""
|
|
config_manager = ConfigManager()
|
|
|
|
# Modify config
|
|
config_manager.config["speech_recognition"]["engine"] = "vosk"
|
|
|
|
# Save settings
|
|
result = config_manager.save_settings()
|
|
self.assertTrue(result)
|
|
|
|
# Verify file was saved
|
|
self.assertTrue(os.path.exists(self.temp_config_file))
|
|
with open(self.temp_config_file, "r") as f:
|
|
saved_config = json.load(f)
|
|
self.assertEqual(saved_config["speech_recognition"]["engine"], "vosk")
|
|
|
|
def test_get_settings(self):
|
|
"""Test the get_settings method returns the entire config."""
|
|
config_manager = ConfigManager()
|
|
|
|
# Get settings
|
|
settings = config_manager.get_settings()
|
|
|
|
# Verify it returns the config
|
|
self.assertEqual(settings, config_manager.config)
|
|
self.assertIn("speech_recognition", settings)
|
|
self.assertIn("ui", settings)
|
|
|
|
def test_get_model_size_for_engine_with_engine_specific_key(self):
|
|
"""Test getting model size when engine-specific key exists."""
|
|
# Create config with engine-specific model sizes
|
|
test_config = {
|
|
"speech_recognition": {
|
|
"engine": "whisper",
|
|
"vosk_model_size": "medium",
|
|
"whisper_model_size": "small",
|
|
"model_size": "large", # Generic fallback
|
|
}
|
|
}
|
|
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(test_config, f)
|
|
|
|
config_manager = ConfigManager()
|
|
|
|
# Should use engine-specific size, not generic
|
|
self.assertEqual(config_manager.get_model_size_for_engine("vosk"), "medium")
|
|
self.assertEqual(config_manager.get_model_size_for_engine("whisper"), "small")
|
|
|
|
def test_set_model_size_for_engine_new_section(self):
|
|
"""Test setting model size when speech_recognition section doesn't exist."""
|
|
config_manager = ConfigManager()
|
|
|
|
# Remove the speech_recognition section
|
|
del config_manager.config["speech_recognition"]
|
|
|
|
# Set model size - should create the section
|
|
config_manager.set_model_size_for_engine("vosk", "large")
|
|
|
|
# Verify section was created and value was set
|
|
self.assertIn("speech_recognition", config_manager.config)
|
|
self.assertEqual(config_manager.config["speech_recognition"]["vosk_model_size"], "large")
|
|
|
|
def test_get_model_size_for_engine_fallback_to_generic(self):
|
|
"""Test fallback to generic model_size when no engine-specific key exists."""
|
|
# Create config with only generic model_size (no engine-specific)
|
|
test_config = {
|
|
"speech_recognition": {
|
|
"engine": "vosk",
|
|
"model_size": "large",
|
|
# No vosk_model_size or whisper_model_size
|
|
}
|
|
}
|
|
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(test_config, f)
|
|
|
|
config_manager = ConfigManager()
|
|
|
|
# Should fall back to generic model_size
|
|
# Note: migration might add engine-specific keys, so we test this directly
|
|
del config_manager.config["speech_recognition"]["vosk_model_size"]
|
|
del config_manager.config["speech_recognition"]["whisper_model_size"]
|
|
|
|
self.assertEqual(config_manager.get_model_size_for_engine("vosk"), "large")
|
|
self.assertEqual(config_manager.get_model_size_for_engine("whisper"), "large")
|
|
|
|
def test_update_speech_recognition_settings_new_section(self):
|
|
"""Test update_speech_recognition_settings when section doesn't exist."""
|
|
config_manager = ConfigManager()
|
|
|
|
# Remove the speech_recognition section
|
|
del config_manager.config["speech_recognition"]
|
|
|
|
# Update settings - should create the section
|
|
config_manager.update_speech_recognition_settings(
|
|
{"engine": "whisper", "vad_sensitivity": 5}
|
|
)
|
|
|
|
# Verify section was created
|
|
self.assertIn("speech_recognition", config_manager.config)
|
|
self.assertEqual(config_manager.config["speech_recognition"]["engine"], "whisper")
|
|
self.assertEqual(config_manager.config["speech_recognition"]["vad_sensitivity"], 5)
|