* feat(text-injection): let config.json pin the injection backend (#476) Autodetection has to infer whether IBus commits actually reach the focused application, and it cannot verify that: commit_text() reports success even when the compositor drops the text. When the inference is wrong the failure is silent, and dictation appears to work in XWayland windows while doing nothing in native ones. VOCALINUX_FORCE_BACKEND already provides an escape hatch, but it is an environment variable: it covers a single run, is undocumented outside the test suite, and a user whose compositor is misdetected has to wrap the launcher in a shell script to make the choice stick. Add a persistent `text_injection.backend` setting alongside it, accepting the same ibus/wtype/ydotool/auto values. The environment variable still wins so a backend can be A/B-tested for one run without touching the saved configuration, and the startup log now names which of the two pinned the choice so a stale setting is diagnosable from --debug output. Reads the file directly rather than through ConfigManager, keeping this package independent of the UI layer, exactly as _should_copy_to_clipboard already does. An unreadable or corrupt config falls back to autodetection instead of blocking startup, and an unrecognised value is ignored with a warning so a typo cannot silently pin the wrong backend. Defaults to "auto", so existing installs behave exactly as before. * fix(text-injection): tell an explicit VOCALINUX_FORCE_BACKEND=auto from unset The variable is meant to override the saved text_injection.backend pin for a single run, which is what makes the two paths A/B-testable without editing config. Setting it to auto -- the value that means "autodetect this time" -- did not do that: the reader collapsed unset and an explicit auto into the same "auto" return, so the caller read both as "nothing was forced" and fell through to the saved pin. The pin won, and there was no way to ask for autodetection for one run short of editing the config file the variable exists to avoid. Read the variable as three states instead: unset, an explicit auto, or a backend name. Only the unset state consults config, so an explicit auto now short-circuits to autodetection. An unrecognised value is deliberately treated as unset rather than as auto. A typo means the user failed to override their preference, not that they asked to discard it, so falling through to a valid saved pin is the kinder reading. _forced_backend() keeps its signature and its collapsed return, so its existing callers and tests are untouched; the new three-state reading lives alongside it and is what the preference resolver consumes. * feat(text-injection): accept an xdotool pin and warn when ibus overrides the denylist Two gaps in the pinnable backend set. xdotool was not accepted, even though on X11 it is the tool that actually does the typing. Users were told to pin a *Wayland* backend to mean "not IBus", and get xdotool as a side effect. Naming it directly is what they were reaching for. On Wayland it needs no special handling: the value skips IBus like any other non-ibus pin and falls through to the existing XWayland fallback, so no branch is added to the selection chain. The accepted values now live in one tuple, because four places listed them -- both readers and both "expected ..." messages -- and a fifth value would have had to be added to all four. The two messages derive from the tuple, so they cannot fall behind it. Pinning ibus also silently bypasses the compositor denylist. That check exists because a denylisted compositor makes IBus report a commit as delivered while the text never arrives (#478, #485), so a pin is the one setting that can put a user back into a failure with no symptom other than nothing happening. The pin is still honoured -- an override that ignores you is worse than one that warns -- but it now says so at warning level, names the compositor, and gives the remedy. It stays quiet when the compositor genuinely bridges IBus, including a denylisted one running the ibus-wayland bridge, and on X11 where the denylist does not apply. The check is evaluated once. The guard chain short-circuits on the pin, so a pinned run never reaches the existing compositor branch, and this is its first and only call -- worth knowing because the check can shell out to pgrep or gdbus. A test asserts the call count so that stays true. Documents xdotool in the value table and rewrites the X11 paragraph, which described xdotool as the implicit result of pinning something else rather than as a value you can name. * fix(text-injection): say so when the pinned backend is not the one in use A pin could be quietly ignored. Pinning a tool that is not installed fell through to autodetection and typed with whatever it found, under a log line that had already announced the pin was overriding autodetection. Pinning xdotool on Wayland did nothing at all, since the selection chain reaches ydotool and wtype first. Pinning ibus where IBus support is unavailable skipped the whole IBus branch. In each case the user set a preference, saw no complaint, and got something else. These are one condition, not three: whatever ended up doing the typing is not what was pinned. The check compares the pin against the backend read back from final state, so a cause nobody has thought of yet is covered without another branch. It runs at the end of construction rather than inside _check_dependencies, which returns early on two paths and which the wtype probe can still override afterwards. The reason clause is added only where a generic check answers it -- the binary is absent, or IBus is unavailable -- and omitted otherwise rather than guessing. Reporting the source separately was letting the environment variable and the config setting disagree. _check_dependencies resolved the backend, then re-derived which source had won by asking again -- so the environment was parsed twice per construction, and a malformed value was reported to the user twice. Resolution now returns the backend and its source together, which fixes the duplicate warning and means the source named in a log line is the one that actually chose the backend. _backend_preference() keeps its signature and reads the first element. Two log lines also claimed VOCALINUX_FORCE_BACKEND regardless of where the pin came from, so a config-set backend was reported as an environment variable the user had never set. They now name the real source. The new warning is a startup diagnostic, not a live invariant: a tool switched later by _try_recover_from_fallback is not reported again. Noted in the docstring so the boundary is deliberate. * docs(user-guide): file the injection-backend section under Troubleshooting The section sat under Customization, directly beneath GPU Acceleration -- where the paragraph immediately above it reads "To check which backend is being used" about Vulkan and CUDA. The word means something else there, and two adjacent sections using it for different things is a bad place to explain either. It also is not a customization option. It exists because a compositor can make IBus report a commit as delivered while nothing arrives (#478, #485), and someone hitting that reads Troubleshooting, not Customization. The section already opens on the symptom, so it needs no rewriting to belong there. Pure reorder: no wording changes, and the heading text is unchanged so any existing link still resolves. * test(text-injection): cover the pin paths that skip the selection chain _check_dependencies had three uncovered branches, all reachable by pinning a backend and none of them exercised: the two early returns that hand over to IBus before a tool is chosen, and the daemonless-ydotool fallback a wtype pin lands on when wtype is absent. The early returns matter most, since the pin check runs at the end of construction precisely so those paths cannot skip it, and nothing proved that. Adds the ydotool counterpart to the missing-binary case, so a check keyed on one tool name cannot pass by coincidence; an X11 test for the guide's claim that any non-ibus pin turns the IBus path off, with a control asserting IBus is constructed without a pin so the assertion is the pin's doing; and an end-to-end explicit-auto case, which until now was only proven at the resolver rather than through selection. The pin log line said it was "overriding backend autodetection" before anything had been checked. An unavailable pin falls through to autodetection, so that line announced an outcome it could not know and then read as contradicting the "was not applied" warning. It now states the request only. _run_with_pin gains ydotoold_ready, defaulting to the previous behaviour so existing callers are unchanged. * fix(text-injection): report a config file the backend pin cannot be read from A corrupt config.json disappeared into logger.debug. The setting is hand-edited -- that is the workflow the user guide documents -- so a stray comma is a likely way to reach this, and staying quiet left the user with autodetection and the silent IBus miss the pin was set to avoid, with nothing to tell that apart from the pin not working. Unknown values already warned; an unusable file now does too, naming the file and the parse error. The bare handler was also catching AttributeError by accident. Valid JSON that is not an object -- a bare list, string, number or null -- reaches the lookup and raises there rather than at parse time, and so does a text_injection section that is not an object, where the top level is a mapping and the section is not. Both are now shape checks rather than exception handling, which is what lets the handler narrow to OSError and ValueError without those five shapes escaping it. Locating the file moves out of the try. It fixes the handler being unable to name the path it failed on, and it is a deliberate boundary: config_dir() resolving a path is not a failure this should paper over, so it is no longer guarded here at all. A missing config.json stays silent, which is the default install. * test(text-injection): stop the ibus-pin tests depending on an installed ydotoold The bridged-compositor and X11 cases asserted that a run logs no warnings at all. That is broader than what they are about, and _run_ibus_pin patches shutil.which to claim every tool exists without patching the probe that then shells out to one, so the real _ensure_ydotoold ran and warned whenever /usr/bin/ydotoold was absent. The tests passed on a machine that happens to have it installed and failed on the CI runners that do not. The assertions now name the warning they are about, and the helper patches _ensure_ydotoold the way its sibling _run_with_pin already did, so nothing in this class of test reads the host's installed packages. * fix(text-injection): report pending IBus initialization state * docs(text-injection): clarify backend pin behavior * fix(config): preserve a hand-edited backend across unrelated saves ConfigManager writes its full in-memory configuration on every save. A user who edits text_injection.backend in config.json while Vocalinux is running therefore loses that choice when an unrelated Settings or tray save writes the stale default back. Remember only the backend value observed at load or after a successful save. If the in-memory value is unchanged, preserve the current value from disk; if it changed through ConfigManager, treat that as the intentional write. Missing and explicit auto both reset to auto. The save aborts rather than overwriting a file whose JSON or text_injection shape cannot be read safely. This is deliberately per-key: no recursive configuration merge or injection-layer dependency is added. * style(text-injection): remove redundant typing suppression * fix(config): keep invalid UTF-8 config from blocking startup * fix(config): preserve malformed backend sections on unrelated saves * fix(config): retain text setting changes after malformed config * test(text-injection): annotate backend pin test helpers
889 lines
37 KiB
Python
889 lines
37 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_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())
|
|
|
|
|
|
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_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.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")
|