Use the running build channel for background checks and add a preference to disable them. Fixes #977
914 lines
39 KiB
Python
914 lines
39 KiB
Python
"""
|
|
Tests for the config manager functionality.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
# Update import path to use the new package structure
|
|
from vocalinux.ui.config_manager import DEFAULT_CONFIG, ConfigManager
|
|
|
|
|
|
def _ensure_test_config_dir(path: str):
|
|
"""Create the temp config directory without relying on recursive makedirs patches."""
|
|
parent_dir = os.path.dirname(path)
|
|
if not os.path.exists(parent_dir):
|
|
os.mkdir(parent_dir)
|
|
if not os.path.exists(path):
|
|
os.mkdir(path)
|
|
|
|
|
|
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")
|
|
_ensure_test_config_dir(self.temp_config_dir)
|
|
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.makedirs_patcher = patch(
|
|
"vocalinux.ui.config_manager.os.makedirs",
|
|
side_effect=lambda path, exist_ok=True: _ensure_test_config_dir(path),
|
|
)
|
|
|
|
self.config_dir_patcher.start()
|
|
self.config_file_patcher.start()
|
|
self.makedirs_patcher.start()
|
|
|
|
# Recreate after patching so each test starts from a known config path.
|
|
_ensure_test_config_dir(self.temp_config_dir)
|
|
|
|
# Every test here starts without a config file, which is a first run, so
|
|
# language detection would shell out to localectl and make the result depend
|
|
# on the machine's keyboard layout. The seeding itself is covered in
|
|
# tests/test_system_language.py.
|
|
self.detect_patcher = patch(
|
|
"vocalinux.utils.system_language.detect_system_language", return_value=None
|
|
)
|
|
self.detect_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.makedirs_patcher.stop()
|
|
self.detect_patcher.stop()
|
|
self.logger_patcher.stop()
|
|
self.temp_dir.cleanup()
|
|
|
|
def test_init_default_config(self) -> None:
|
|
"""Test initialization with default configuration."""
|
|
config_manager = ConfigManager()
|
|
self.assertEqual(config_manager.config, DEFAULT_CONFIG)
|
|
self.assertTrue(DEFAULT_CONFIG["updates"]["check_automatically"])
|
|
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_old_update_channel_config_loads_with_automatic_checks_enabled(self) -> None:
|
|
self._write_config({"updates": {"channel": "nightly"}})
|
|
|
|
config_manager = ConfigManager()
|
|
|
|
self.assertTrue(config_manager.get_bool("updates", "check_automatically"))
|
|
self.assertEqual(config_manager.config["updates"]["channel"], "nightly")
|
|
|
|
def test_paste_long_text_defaults_to_off_and_can_be_changed(self) -> None:
|
|
config_manager = ConfigManager()
|
|
self.assertFalse(DEFAULT_CONFIG["text_injection"]["paste_long_text"])
|
|
self.assertFalse(config_manager.get_paste_long_text())
|
|
config_manager.set_paste_long_text(True)
|
|
self.assertTrue(config_manager.get_paste_long_text())
|
|
config_manager.set_paste_long_text(False)
|
|
self.assertFalse(config_manager.get_paste_long_text())
|
|
|
|
def test_ensure_config_dir(self):
|
|
"""Test that _ensure_config_dir creates the directory."""
|
|
# Delete the config directory to test creation
|
|
shutil.rmtree(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
|
|
self.assertEqual(
|
|
config_manager.config["ui"]["show_missing_tray_warning"], 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_load_config_invalid_utf8_uses_defaults(self):
|
|
"""A config saved with invalid bytes must not prevent application startup."""
|
|
with open(self.temp_config_file, "wb") as handle:
|
|
handle.write(b"\xff\xfe{")
|
|
|
|
config_manager = ConfigManager()
|
|
|
|
self.assertEqual(config_manager.config, DEFAULT_CONFIG)
|
|
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 _write_raw_config(self, content: str) -> None:
|
|
with open(self.temp_config_file, "w") as handle:
|
|
handle.write(content)
|
|
|
|
def _write_config(self, config: Any) -> None:
|
|
with open(self.temp_config_file, "w") as handle:
|
|
json.dump(config, handle)
|
|
|
|
def _read_config(self) -> dict[str, Any]:
|
|
with open(self.temp_config_file) as handle:
|
|
return json.load(handle)
|
|
|
|
def test_unrelated_save_preserves_first_external_backend_edit(self):
|
|
"""A documented hand-edit must survive a Settings-style save."""
|
|
config_manager = ConfigManager()
|
|
self._write_config({"text_injection": {"backend": "wtype"}})
|
|
|
|
config_manager.set("general", "autostart", True)
|
|
|
|
self.assertTrue(config_manager.save_config())
|
|
saved = self._read_config()
|
|
self.assertEqual(saved["text_injection"]["backend"], "wtype")
|
|
self.assertTrue(saved["general"]["autostart"])
|
|
|
|
def test_second_external_backend_edit_survives_repeated_saves(self):
|
|
"""Remembering the first edit must not make the second one stale again."""
|
|
self._write_config({"text_injection": {"backend": "wtype"}})
|
|
config_manager = ConfigManager()
|
|
|
|
self._write_config({"text_injection": {"backend": "ydotool"}})
|
|
config_manager.set("general", "autostart", True)
|
|
self.assertTrue(config_manager.save_config())
|
|
self.assertEqual(self._read_config()["text_injection"]["backend"], "ydotool")
|
|
|
|
self._write_config({"text_injection": {"backend": "xdotool"}})
|
|
config_manager.set("ui", "start_minimized", True)
|
|
self.assertTrue(config_manager.save_config())
|
|
saved = self._read_config()
|
|
self.assertEqual(saved["text_injection"]["backend"], "xdotool")
|
|
self.assertTrue(saved["ui"]["start_minimized"])
|
|
|
|
def test_programmatic_backend_change_wins_over_external_edit(self):
|
|
"""An intentional in-process backend change is not discarded as stale."""
|
|
self._write_config({"text_injection": {"backend": "wtype"}})
|
|
config_manager = ConfigManager()
|
|
|
|
self._write_config({"text_injection": {"backend": "ydotool"}})
|
|
config_manager.set("text_injection", "backend", "xdotool")
|
|
|
|
self.assertTrue(config_manager.save_config())
|
|
self.assertEqual(self._read_config()["text_injection"]["backend"], "xdotool")
|
|
|
|
def test_external_auto_or_key_removal_resets_backend(self):
|
|
"""Both documented reset forms mean auto after an unrelated save."""
|
|
self._write_config({"text_injection": {"backend": "wtype"}})
|
|
config_manager = ConfigManager()
|
|
|
|
for external in (
|
|
{"text_injection": {"backend": "auto"}},
|
|
{"text_injection": {}},
|
|
None,
|
|
):
|
|
with self.subTest(external=external):
|
|
if external is None:
|
|
os.remove(self.temp_config_file)
|
|
else:
|
|
self._write_config(external)
|
|
config_manager.set("general", "autostart", True)
|
|
self.assertTrue(config_manager.save_config())
|
|
self.assertEqual(self._read_config()["text_injection"]["backend"], "auto")
|
|
|
|
def test_save_does_not_overwrite_unreadable_or_invalid_external_config(self):
|
|
"""A Settings toggle must not repair a hand-edited file by deleting it."""
|
|
config_manager = ConfigManager()
|
|
|
|
for raw in ("{broken json", "[]"):
|
|
with self.subTest(raw=raw):
|
|
self._write_raw_config(raw)
|
|
config_manager.set("general", "autostart", True)
|
|
|
|
self.assertFalse(config_manager.save_config())
|
|
with open(self.temp_config_file) as handle:
|
|
self.assertEqual(handle.read(), raw)
|
|
|
|
def test_unrelated_save_preserves_malformed_text_injection_section(self):
|
|
"""A bad backend section must not prevent unrelated Settings changes."""
|
|
for malformed_section in ([], "wtype", None):
|
|
with self.subTest(section=malformed_section):
|
|
self._write_config({"text_injection": malformed_section})
|
|
config_manager = ConfigManager()
|
|
|
|
self.assertEqual(config_manager.get("text_injection", "backend"), "auto")
|
|
self.assertTrue(config_manager.get_bool("text_injection", "auto_capitalize"))
|
|
config_manager.set("general", "autostart", True)
|
|
|
|
self.assertTrue(config_manager.save_config())
|
|
saved = self._read_config()
|
|
self.assertEqual(saved["text_injection"], malformed_section)
|
|
self.assertTrue(saved["general"]["autostart"])
|
|
|
|
def test_programmatic_backend_change_repairs_malformed_text_injection_section(self):
|
|
"""An intentional backend change can replace a section that cannot hold one."""
|
|
self._write_config({"text_injection": []})
|
|
config_manager = ConfigManager()
|
|
config_manager.set("text_injection", "backend", "wtype")
|
|
|
|
self.assertTrue(config_manager.save_config())
|
|
self.assertEqual(self._read_config()["text_injection"]["backend"], "wtype")
|
|
|
|
def test_text_injection_change_repairs_malformed_section(self):
|
|
"""A Settings change in that section must not disappear after a successful save."""
|
|
for key, value in (
|
|
("auto_capitalize", False),
|
|
("append_trailing_space", False),
|
|
("paste_shortcut", "ctrl+shift+v"),
|
|
):
|
|
with self.subTest(key=key):
|
|
self._write_config({"text_injection": []})
|
|
config_manager = ConfigManager()
|
|
config_manager.set("text_injection", key, value)
|
|
|
|
self.assertTrue(config_manager.save_config())
|
|
self.assertEqual(self._read_config()["text_injection"][key], value)
|
|
|
|
def test_failed_write_does_not_advance_text_injection_snapshot(self):
|
|
"""A later save must still persist a text-setting change after a failed write."""
|
|
config_manager = ConfigManager()
|
|
config_manager.set("text_injection", "auto_capitalize", False)
|
|
|
|
with patch("vocalinux.ui.config_manager.json.dump", side_effect=OSError("disk full")):
|
|
self.assertFalse(config_manager.save_config())
|
|
|
|
self._write_config({"text_injection": []})
|
|
self.assertTrue(config_manager.save_config())
|
|
self.assertFalse(self._read_config()["text_injection"]["auto_capitalize"])
|
|
|
|
def test_save_does_not_overwrite_invalid_utf8_external_config(self):
|
|
"""A decoding failure is just as unsafe to replace as malformed JSON."""
|
|
config_manager = ConfigManager()
|
|
raw = b"\xff\xfe{"
|
|
with open(self.temp_config_file, "wb") as handle:
|
|
handle.write(raw)
|
|
|
|
config_manager.set("general", "autostart", True)
|
|
|
|
self.assertFalse(config_manager.save_config())
|
|
with open(self.temp_config_file, "rb") as handle:
|
|
self.assertEqual(handle.read(), raw)
|
|
|
|
def test_failed_write_does_not_advance_backend_snapshot(self):
|
|
"""A failed save cannot make a later external edit look stale."""
|
|
config_manager = ConfigManager()
|
|
self._write_config({"text_injection": {"backend": "wtype"}})
|
|
|
|
with patch("vocalinux.ui.config_manager.json.dump", side_effect=OSError("disk full")):
|
|
self.assertFalse(config_manager.save_config())
|
|
self.assertEqual(config_manager.get("text_injection", "backend"), "auto")
|
|
|
|
self._write_config({"text_injection": {"backend": "ydotool"}})
|
|
config_manager.set("general", "autostart", True)
|
|
self.assertTrue(config_manager.save_config())
|
|
self.assertEqual(self._read_config()["text_injection"]["backend"], "ydotool")
|
|
|
|
def test_invalid_text_injection_shapes_do_not_break_loading(self):
|
|
"""A hand-edited non-object section leaves text-injection defaults usable."""
|
|
for config in ([], {"text_injection": []}, {"text_injection": "wtype"}):
|
|
with self.subTest(config=config):
|
|
self._write_config(config)
|
|
config_manager = ConfigManager()
|
|
|
|
self.assertEqual(config_manager.get("text_injection", "backend"), "auto")
|
|
self.assertTrue(config_manager.get_bool("text_injection", "auto_capitalize"))
|
|
|
|
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_cpp"
|
|
) # Default is whisper_cpp for best performance with Vulkan support
|
|
|
|
# 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", "whisper_cpp", "parakeet", "faster_whisper", "remote_api"],
|
|
)
|
|
self.assertEqual(
|
|
config_manager.get("speech_recognition", "remote_api_model"),
|
|
"whisper-1",
|
|
)
|
|
|
|
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_migrate_copies_model_size_to_whisper_cpp(self):
|
|
"""A whisper.cpp config that predates the per-engine keys keeps its model.
|
|
|
|
DEFAULT_CONFIG already ships whisper_cpp_model_size, so the generic
|
|
fallback inside get_model_size_for_engine() can never fire for this
|
|
engine: after the merge the key is always present. Only the migration
|
|
can carry the user's own model_size across (#681).
|
|
"""
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump({"speech_recognition": {"engine": "whisper_cpp", "model_size": "small"}}, f)
|
|
|
|
config_manager = ConfigManager()
|
|
|
|
self.assertEqual(
|
|
config_manager.config["speech_recognition"]["whisper_cpp_model_size"], "small"
|
|
)
|
|
self.assertEqual(config_manager.get_model_size_for_engine("whisper_cpp"), "small")
|
|
|
|
def test_migrate_leaves_other_engines_on_their_defaults(self):
|
|
"""The migration must not hand one engine's model to the others."""
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump({"speech_recognition": {"engine": "vosk", "model_size": "large"}}, f)
|
|
|
|
config_manager = ConfigManager()
|
|
|
|
self.assertEqual(config_manager.get_model_size_for_engine("vosk"), "large")
|
|
self.assertEqual(config_manager.get_model_size_for_engine("whisper_cpp"), "tiny")
|
|
self.assertEqual(config_manager.get_model_size_for_engine("whisper"), "tiny")
|
|
|
|
def test_resolver_prefers_the_engine_key_over_the_generic_one(self):
|
|
"""The generic key holds whichever engine saved last; it must not win.
|
|
|
|
This is the startup bug from #681: model_size still says "medium" from
|
|
the last VOSK save while the configured engine is whisper.cpp.
|
|
"""
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(
|
|
{
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "medium",
|
|
"vosk_model_size": "medium",
|
|
"whisper_cpp_model_size": "small",
|
|
}
|
|
},
|
|
f,
|
|
)
|
|
|
|
config_manager = ConfigManager()
|
|
|
|
self.assertEqual(config_manager.get_model_size_for_engine("whisper_cpp"), "small")
|
|
self.assertEqual(config_manager.get_model_size_for_engine("vosk"), "medium")
|
|
|
|
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")
|
|
|
|
@pytest.mark.filterwarnings("ignore:save_settings\\(\\) is deprecated:DeprecationWarning")
|
|
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)
|
|
|
|
def test_migrate_super_shortcut_to_ctrl(self):
|
|
test_config = {
|
|
"shortcuts": {
|
|
"toggle_recognition": "super+super",
|
|
"mode": "toggle",
|
|
}
|
|
}
|
|
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(test_config, f)
|
|
|
|
config_manager = ConfigManager()
|
|
self.assertEqual(config_manager.config["shortcuts"]["toggle_recognition"], "ctrl+ctrl")
|
|
|
|
def test_non_super_shortcut_unchanged(self):
|
|
test_config = {
|
|
"shortcuts": {
|
|
"toggle_recognition": "alt+alt",
|
|
"mode": "push_to_talk",
|
|
}
|
|
}
|
|
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(test_config, f)
|
|
|
|
config_manager = ConfigManager()
|
|
self.assertEqual(config_manager.config["shortcuts"]["toggle_recognition"], "alt+alt")
|
|
self.assertEqual(config_manager.config["shortcuts"]["mode"], "push_to_talk")
|
|
|
|
def test_existing_ctrl_toggle_config_preserved(self):
|
|
"""Existing installs keep saved Ctrl toggle defaults after default change."""
|
|
test_config = {
|
|
"shortcuts": {
|
|
"toggle_recognition": "ctrl+ctrl",
|
|
"mode": "toggle",
|
|
}
|
|
}
|
|
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(test_config, f)
|
|
|
|
config_manager = ConfigManager()
|
|
self.assertEqual(config_manager.config["shortcuts"]["toggle_recognition"], "ctrl+ctrl")
|
|
self.assertEqual(config_manager.config["shortcuts"]["mode"], "toggle")
|
|
|
|
def test_legacy_shortcut_without_mode_stays_toggle(self):
|
|
"""Installer/old seeds with only toggle_recognition keep toggle mode."""
|
|
test_config = {
|
|
"shortcuts": {
|
|
"toggle_recognition": "ctrl+ctrl",
|
|
}
|
|
}
|
|
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(test_config, f)
|
|
|
|
config_manager = ConfigManager()
|
|
self.assertEqual(config_manager.config["shortcuts"]["toggle_recognition"], "ctrl+ctrl")
|
|
self.assertEqual(config_manager.config["shortcuts"]["mode"], "toggle")
|
|
|
|
def test_legacy_config_without_shortcuts_section_keeps_ctrl_toggle(self):
|
|
"""Configs that never stored shortcuts keep legacy ctrl+ctrl toggle."""
|
|
test_config = {
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
}
|
|
}
|
|
|
|
with open(self.temp_config_file, "w") as f:
|
|
json.dump(test_config, f)
|
|
|
|
config_manager = ConfigManager()
|
|
self.assertEqual(config_manager.config["shortcuts"]["toggle_recognition"], "ctrl+ctrl")
|
|
self.assertEqual(config_manager.config["shortcuts"]["mode"], "toggle")
|
|
|
|
def test_sound_effects_enabled_by_default(self):
|
|
"""Test that sound effects are enabled by default."""
|
|
config_manager = ConfigManager()
|
|
self.assertTrue(config_manager.is_sound_effects_enabled())
|
|
|
|
def test_sound_effects_tone_defaults_to_voca(self):
|
|
"""New installs and missing tone keys resolve to voca."""
|
|
from vocalinux.ui.config_manager import DEFAULT_SOUND_EFFECT_TONE
|
|
|
|
config_manager = ConfigManager()
|
|
self.assertEqual(DEFAULT_SOUND_EFFECT_TONE, "voca")
|
|
self.assertEqual(config_manager.get_sound_effects_tone(), "voca")
|
|
self.assertEqual(config_manager.config["sound_effects"]["tone"], "voca")
|
|
|
|
def test_missing_tray_warning_enabled_by_default(self):
|
|
"""Test that the missing tray support warning is enabled by default."""
|
|
config_manager = ConfigManager()
|
|
self.assertTrue(config_manager.get_bool("ui", "show_missing_tray_warning", False))
|
|
|
|
def test_set_sound_effects_enabled(self):
|
|
"""Test setting sound effects enabled state."""
|
|
config_manager = ConfigManager()
|
|
config_manager.set_sound_effects_enabled(False)
|
|
self.assertFalse(config_manager.is_sound_effects_enabled())
|
|
config_manager.set_sound_effects_enabled(True)
|
|
self.assertTrue(config_manager.is_sound_effects_enabled())
|
|
|
|
def test_sound_effects_config_persistence(self):
|
|
"""Test that sound effects setting persists across ConfigManager instances."""
|
|
config_manager = ConfigManager()
|
|
config_manager.set_sound_effects_enabled(False)
|
|
config_manager.save_config()
|
|
|
|
new_config_manager = ConfigManager()
|
|
self.assertFalse(new_config_manager.is_sound_effects_enabled())
|
|
|
|
def test_post_processing_script_path_empty_by_default(self) -> None:
|
|
config_manager = ConfigManager()
|
|
self.assertEqual(config_manager.get_str("post_processing", "script_path", ""), "")
|
|
|
|
|
|
class TestTypedAccessors(unittest.TestCase):
|
|
"""Tests for typed config accessors."""
|
|
|
|
def setUp(self):
|
|
self.config_manager = ConfigManager.__new__(ConfigManager)
|
|
self.config_manager.config = {
|
|
"general": {"autostart": True, "language": "en"},
|
|
"audio": {"sample_rate": 16000, "device_index": 2, "gain": 1.5},
|
|
"shortcuts": {"toggle_recognition": "ctrl+ctrl", "mode": "toggle"},
|
|
}
|
|
|
|
def test_get_str_returns_string(self):
|
|
result = self.config_manager.get_str("shortcuts", "mode", "hold")
|
|
assert result == "toggle"
|
|
assert isinstance(result, str)
|
|
|
|
def test_get_str_default(self):
|
|
result = self.config_manager.get_str("shortcuts", "nonexistent", "default")
|
|
assert result == "default"
|
|
|
|
def test_get_str_converts_non_string(self):
|
|
result = self.config_manager.get_str("audio", "sample_rate", "0")
|
|
assert result == "16000"
|
|
assert isinstance(result, str)
|
|
|
|
def test_get_str_none_returns_default(self):
|
|
self.config_manager.config["audio"]["device_index"] = None
|
|
result = self.config_manager.get_str("audio", "device_index", "fallback")
|
|
assert result == "fallback"
|
|
|
|
def test_get_bool_returns_bool(self):
|
|
result = self.config_manager.get_bool("general", "autostart", False)
|
|
assert result is True
|
|
|
|
def test_get_bool_default(self):
|
|
result = self.config_manager.get_bool("general", "nonexistent", True)
|
|
assert result is True
|
|
|
|
def test_get_int_returns_int(self):
|
|
result = self.config_manager.get_int("audio", "sample_rate", 0)
|
|
assert result == 16000
|
|
assert isinstance(result, int)
|
|
|
|
def test_get_int_default_on_invalid(self):
|
|
self.config_manager.config["audio"]["sample_rate"] = "not_a_number"
|
|
result = self.config_manager.get_int("audio", "sample_rate", 44100)
|
|
assert result == 44100
|
|
|
|
def test_get_int_default_on_none(self):
|
|
result = self.config_manager.get_int("audio", "nonexistent", 99)
|
|
assert result == 99
|
|
|
|
def test_get_float_returns_float(self):
|
|
result = self.config_manager.get_float("audio", "gain", 0.0)
|
|
assert result == 1.5
|
|
assert isinstance(result, float)
|
|
|
|
def test_get_float_default_on_invalid(self):
|
|
self.config_manager.config["audio"]["gain"] = "bad"
|
|
result = self.config_manager.get_float("audio", "gain", 2.0)
|
|
assert result == 2.0
|
|
|
|
def test_get_optional_int_returns_int(self):
|
|
result = self.config_manager.get_optional_int("audio", "device_index", None)
|
|
assert result == 2
|
|
assert isinstance(result, int)
|
|
|
|
def test_get_optional_int_returns_none(self):
|
|
self.config_manager.config["audio"]["device_index"] = None
|
|
result = self.config_manager.get_optional_int("audio", "device_index", None)
|
|
assert result is None
|
|
|
|
def test_get_optional_int_default_on_missing(self):
|
|
result = self.config_manager.get_optional_int("audio", "nonexistent", None)
|
|
assert result is None
|
|
|
|
def test_get_optional_int_default_on_invalid(self):
|
|
self.config_manager.config["audio"]["device_index"] = "not_int"
|
|
result = self.config_manager.get_optional_int("audio", "device_index", 5)
|
|
assert result == 5
|
|
|
|
def test_whispercpp_advanced_defaults(self):
|
|
"""Test that whisper.cpp advanced parameters have correct defaults."""
|
|
advanced = DEFAULT_CONFIG["advanced"]
|
|
self.assertFalse(advanced["power_user_mode"])
|
|
self.assertTrue(advanced["whispercpp_no_timestamps"])
|
|
self.assertTrue(advanced["whispercpp_no_context"])
|
|
self.assertEqual(advanced["whispercpp_initial_prompt"], "")
|
|
self.assertEqual(advanced["whispercpp_language_candidates"], "")
|
|
self.assertEqual(advanced["whispercpp_temperature"], 0.0)
|
|
self.assertEqual(advanced["whispercpp_temperature_inc"], -1.0)
|
|
self.assertEqual(advanced["whispercpp_entropy_thold"], 2.4)
|
|
self.assertEqual(advanced["whispercpp_logprob_thold"], -1.0)
|
|
self.assertEqual(advanced["whispercpp_no_speech_thold"], 0.6)
|
|
self.assertEqual(advanced["whispercpp_n_threads"], 0)
|
|
|
|
def test_whispercpp_advanced_persistence(self):
|
|
# This class skips ConfigManager.__init__, so unlike TestConfigManager
|
|
# nothing has redirected CONFIG_FILE: without the patches below this
|
|
# save_config() overwrites the developer's real ~/.config/vocalinux/
|
|
# config.json with the synthetic fixture from setUp.
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_file = os.path.join(temp_dir, "config.json")
|
|
with (
|
|
patch("vocalinux.ui.config_manager.CONFIG_DIR", temp_dir),
|
|
patch("vocalinux.ui.config_manager.CONFIG_FILE", temp_file),
|
|
):
|
|
self.config_manager.set("advanced", "whispercpp_temperature", 0.5)
|
|
self.config_manager.set("advanced", "whispercpp_no_timestamps", False)
|
|
self.config_manager.set("advanced", "whispercpp_initial_prompt", "Meeting notes")
|
|
self.config_manager.set("advanced", "whispercpp_language_candidates", "en,es")
|
|
self.config_manager.save_config()
|
|
|
|
new_manager = ConfigManager()
|
|
advanced = new_manager.get_settings().get("advanced", {})
|
|
self.assertEqual(advanced["whispercpp_temperature"], 0.5)
|
|
self.assertFalse(advanced["whispercpp_no_timestamps"])
|
|
self.assertEqual(advanced["whispercpp_initial_prompt"], "Meeting notes")
|
|
self.assertEqual(advanced["whispercpp_language_candidates"], "en,es")
|