Files
voculinux/tests/test_config_manager.py
T
Jatin K MalikandGitHub 7a6d0d4e2b feat: flexible Whisper installation options for different system configurations (#63)
* fix: VOSK-only install and CLI arg handling for low-memory systems

Fixes several issues reported by users on low-memory systems (8GB RAM):

1. Install script: Don't auto-install Whisper in non-interactive mode
   - Previously, running 'curl | bash' would install Whisper by default
   - Now requires explicit --with-whisper flag to install Whisper
   - Prevents OOM errors on systems with limited RAM

2. CLI arguments: Command-line flags now properly override saved config
   - --engine vosk now works even if config file has whisper set
   - --model flag also properly overrides saved model setting
   - Checks sys.argv to detect if user explicitly provided arguments

3. Default engine: Changed from 'whisper' to 'vosk'
   - VOSK works on low-memory systems without OOM issues
   - Users can still install and use Whisper with --with-whisper flag
   - Existing configs with 'whisper' engine are still respected

Reported issues:
- OOM during installation on 8GB RAM system
- vocalinux --engine vosk being ignored
- Config file not respected when trying to force VOSK

* fix: keep whisper as default engine, improve test robustness

- Reverted default engine back to 'whisper' for better accuracy
- Install script still defaults to VOSK-only install (--with-whisper for Whisper)
- CLI args still properly override saved config
- Improved test_config_manager tests to handle module caching issues

* fix: default to Whisper install, --no-whisper for VOSK-only on low-RAM systems

Updated install behavior:
- Default (curl|bash): Installs Whisper for best experience
- --no-whisper flag: Skips Whisper AND creates config with VOSK as default engine

This allows low-RAM users (8GB) to run:
  curl -fsSL .../install.sh | bash -s -- --no-whisper

Which will:
1. Skip installing torch/whisper (avoids OOM)
2. Create ~/.config/vocalinux/config.json with engine='vosk'
3. App will use VOSK by default without needing CLI flags

* docs: clarify --no-whisper is for low-RAM systems (8GB or less)

Updated README and website to explain that --no-whisper flag:
- Is intended for systems with 8GB RAM or less
- Avoids out-of-memory errors during installation
- Configures VOSK as the default speech recognition engine

* feat: add --whisper-cpu flag for CPU-only PyTorch installation

New installation option for users without NVIDIA GPU or with limited RAM:

Install script changes:
- Added --whisper-cpu flag: Installs CPU-only PyTorch (~200MB vs ~2.3GB)
- Uses https://download.pytorch.org/whl/cpu index for smaller torch package
- Interactive mode now offers 3 options: GPU, CPU-only, or skip Whisper
- Updated help text to explain all Whisper installation options

Documentation updates:
- README: Added --whisper-cpu example with explanation
- Website: Added new 'Whisper CPU-only' install option section

Installation options summary:
- Default (curl|bash): Whisper + GPU/CUDA support (~2.3GB, 5-10 min)
- --whisper-cpu: Whisper + CPU-only PyTorch (~200MB, 3-4 min)
- --no-whisper: VOSK only, no Whisper (~2-3 min)
2026-01-20 19:19:09 -08:00

203 lines
7.9 KiB
Python

"""
Tests for the config manager functionality.
"""
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
# Update import path to use the new package structure
from vocalinux.ui.config_manager import CONFIG_DIR, CONFIG_FILE, 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
config_manager = 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