Files
voculinux/tests/test_config_manager.py
T
Jatin K MalikandGitHub 65de3427fb perf: faster Settings, typing and dictation; mypy in CI, engine interface, architecture docs (#968)
* fix(perf): reduce startup and inference overhead

* fix(perf): report effective whisper.cpp fallback settings

* fix(injection): add opt-in X11 paste for long dictations

* perf(vad): lower default silence timeout to 1.2s

* fix(types): resolve mypy errors across application source

* ci: run mypy in the lint job

* ci: include mypy in lint dependency group

* fix(types): narrow evdev timestamp comparison

* refactor(recognition): add SpeechEngine protocol and engine dispatch table

* refactor(settings): move About section and engine tables out of settings_dialog

* docs: add ARCHITECTURE.md and refresh docs index and AGENTS layout

* fix(settings): preserve GTK dialog compatibility after extraction

* style: format touched Python modules

* fix(types): keep local typecheck from syncing dependencies

* test(vad): align main startup expectation with new default

* style: sort engine registry and protocol test imports

* test(settings): follow extracted About builder attribute alias

* test(settings): isolate GTK dialog base in About test

* test(settings): isolate About color scheme build test

* test(settings): restore GTK base after About scheme test

* test(settings): isolate About scheme check from GTK mocks

* test(settings): support extracted dialog mixin methods

* docs: add before/after performance benchmark charts

* fix(injection): type instead of pasting when the clipboard holds non-text data
2026-10-20 20:23:08 -07:00

905 lines
38 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):
"""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_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")