Some checks failed
Make distro packages the single source of truth for GTK/X11 Python bindings instead of advertising them as wheel-managed runtime dependencies. Update the uv, CI, and packaging workflows to use system site packages, regenerate uv.lock, and keep portable and Arch metadata aligned with that contract. Pull runtime policy, audio probing, and page builders out of config_ui.py so the settings window becomes a coordinator instead of a single large mixed-concern module. Rename the config serialization and logging helpers, and stop startup logging from exposing raw vocabulary entries or custom model paths. Remove stale helper aliases and add regression coverage for safe startup logging, packaging metadata and module drift, portable requirements, and the extracted audio helper behavior. Validated with uv lock, python3 -m compileall -q src tests, python3 -m unittest discover -s tests -p 'test_*.py', make build, and make package-arch.
328 lines
13 KiB
Python
328 lines
13 KiB
Python
import json
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SRC = ROOT / "src"
|
|
if str(SRC) not in sys.path:
|
|
sys.path.insert(0, str(SRC))
|
|
|
|
from config import CURRENT_CONFIG_VERSION, Config, config_as_dict, config_log_payload, load
|
|
|
|
|
|
class ConfigTests(unittest.TestCase):
|
|
def test_defaults_when_file_missing(self):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
missing = Path(td) / "nested" / "config.json"
|
|
cfg = load(str(missing))
|
|
|
|
self.assertEqual(cfg.daemon.hotkey, "Cmd+m")
|
|
self.assertEqual(cfg.config_version, CURRENT_CONFIG_VERSION)
|
|
self.assertEqual(cfg.recording.input, "")
|
|
self.assertEqual(cfg.stt.provider, "local_whisper")
|
|
self.assertEqual(cfg.stt.model, "base")
|
|
self.assertEqual(cfg.stt.device, "cpu")
|
|
self.assertEqual(cfg.stt.language, "auto")
|
|
self.assertFalse(cfg.models.allow_custom_models)
|
|
self.assertEqual(cfg.models.whisper_model_path, "")
|
|
self.assertEqual(cfg.injection.backend, "clipboard")
|
|
self.assertFalse(cfg.injection.remove_transcription_from_clipboard)
|
|
self.assertTrue(cfg.safety.enabled)
|
|
self.assertFalse(cfg.safety.strict)
|
|
self.assertEqual(cfg.ux.profile, "default")
|
|
self.assertTrue(cfg.ux.show_notifications)
|
|
self.assertTrue(cfg.advanced.strict_startup)
|
|
self.assertEqual(cfg.vocabulary.replacements, [])
|
|
self.assertEqual(cfg.vocabulary.terms, [])
|
|
|
|
self.assertTrue(missing.exists())
|
|
written = json.loads(missing.read_text(encoding="utf-8"))
|
|
self.assertEqual(written, config_as_dict(cfg))
|
|
|
|
def test_loads_nested_config(self):
|
|
payload = {
|
|
"config_version": CURRENT_CONFIG_VERSION,
|
|
"daemon": {"hotkey": "Ctrl+space"},
|
|
"recording": {"input": 3},
|
|
"stt": {
|
|
"provider": "local_whisper",
|
|
"model": "small",
|
|
"device": "cuda",
|
|
"language": "English",
|
|
},
|
|
"models": {"allow_custom_models": False},
|
|
"injection": {
|
|
"backend": "injection",
|
|
"remove_transcription_from_clipboard": True,
|
|
},
|
|
"safety": {
|
|
"enabled": True,
|
|
"strict": True,
|
|
},
|
|
"vocabulary": {
|
|
"replacements": [
|
|
{"from": "Martha", "to": "Marta"},
|
|
{"from": "docker", "to": "Docker"},
|
|
],
|
|
"terms": ["Systemd", "Kubernetes"],
|
|
},
|
|
}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
cfg = load(str(path))
|
|
|
|
self.assertEqual(cfg.config_version, CURRENT_CONFIG_VERSION)
|
|
self.assertEqual(cfg.daemon.hotkey, "Ctrl+space")
|
|
self.assertEqual(cfg.recording.input, 3)
|
|
self.assertEqual(cfg.stt.provider, "local_whisper")
|
|
self.assertEqual(cfg.stt.model, "small")
|
|
self.assertEqual(cfg.stt.device, "cuda")
|
|
self.assertEqual(cfg.stt.language, "en")
|
|
self.assertEqual(cfg.injection.backend, "injection")
|
|
self.assertTrue(cfg.injection.remove_transcription_from_clipboard)
|
|
self.assertTrue(cfg.safety.enabled)
|
|
self.assertTrue(cfg.safety.strict)
|
|
self.assertEqual(len(cfg.vocabulary.replacements), 2)
|
|
self.assertEqual(cfg.vocabulary.replacements[0].source, "Martha")
|
|
self.assertEqual(cfg.vocabulary.replacements[0].target, "Marta")
|
|
self.assertEqual(cfg.vocabulary.terms, ["Systemd", "Kubernetes"])
|
|
|
|
def test_super_modifier_hotkey_is_valid(self):
|
|
payload = {"daemon": {"hotkey": "Super+m"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
cfg = load(str(path))
|
|
|
|
self.assertEqual(cfg.daemon.hotkey, "Super+m")
|
|
|
|
def test_invalid_hotkey_missing_key_raises(self):
|
|
payload = {"daemon": {"hotkey": "Ctrl+Alt"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "daemon.hotkey: is invalid: missing key"):
|
|
load(str(path))
|
|
|
|
def test_invalid_hotkey_multiple_keys_raises(self):
|
|
payload = {"daemon": {"hotkey": "Ctrl+a+b"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(
|
|
ValueError, "daemon.hotkey: is invalid: must include exactly one non-modifier key"
|
|
):
|
|
load(str(path))
|
|
|
|
def test_invalid_injection_backend_raises(self):
|
|
payload = {"injection": {"backend": "invalid"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "injection.backend"):
|
|
load(str(path))
|
|
|
|
def test_invalid_clipboard_remove_option_raises(self):
|
|
payload = {"injection": {"remove_transcription_from_clipboard": "yes"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "injection.remove_transcription_from_clipboard"):
|
|
load(str(path))
|
|
|
|
def test_invalid_safety_enabled_option_raises(self):
|
|
payload = {"safety": {"enabled": "yes"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "safety.enabled"):
|
|
load(str(path))
|
|
|
|
def test_invalid_safety_strict_option_raises(self):
|
|
payload = {"safety": {"strict": "yes"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "safety.strict"):
|
|
load(str(path))
|
|
|
|
def test_unknown_safety_fields_raise(self):
|
|
payload = {"safety": {"enabled": True, "mode": "strict"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "safety.mode: unknown config field"):
|
|
load(str(path))
|
|
|
|
def test_unknown_top_level_fields_raise(self):
|
|
payload = {
|
|
"custom_a": {"enabled": True},
|
|
"custom_b": {"nested": "value"},
|
|
"custom_c": 123,
|
|
}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "custom_a: unknown config field"):
|
|
load(str(path))
|
|
|
|
def test_conflicting_replacements_raise(self):
|
|
payload = {
|
|
"vocabulary": {
|
|
"replacements": [
|
|
{"from": "Martha", "to": "Marta"},
|
|
{"from": "martha", "to": "Martha"},
|
|
]
|
|
}
|
|
}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "conflicting"):
|
|
load(str(path))
|
|
|
|
def test_duplicate_rules_and_terms_are_deduplicated(self):
|
|
payload = {
|
|
"vocabulary": {
|
|
"replacements": [
|
|
{"from": "docker", "to": "Docker"},
|
|
{"from": "DOCKER", "to": "Docker"},
|
|
],
|
|
"terms": ["Systemd", "systemd"],
|
|
}
|
|
}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
cfg = load(str(path))
|
|
|
|
self.assertEqual(len(cfg.vocabulary.replacements), 1)
|
|
self.assertEqual(cfg.vocabulary.replacements[0].source, "docker")
|
|
self.assertEqual(cfg.vocabulary.replacements[0].target, "Docker")
|
|
self.assertEqual(cfg.vocabulary.terms, ["Systemd"])
|
|
|
|
def test_wildcard_term_raises(self):
|
|
payload = {
|
|
"vocabulary": {
|
|
"terms": ["Dock*"],
|
|
}
|
|
}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "wildcard"):
|
|
load(str(path))
|
|
|
|
def test_unknown_vocabulary_fields_raise(self):
|
|
payload = {"vocabulary": {"custom_limit": 100, "custom_extra": 200, "terms": ["Docker"]}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "vocabulary.custom_limit: unknown config field"):
|
|
load(str(path))
|
|
|
|
def test_stt_language_accepts_auto(self):
|
|
payload = {"stt": {"model": "base", "device": "cpu", "language": "auto"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
cfg = load(str(path))
|
|
|
|
self.assertEqual(cfg.stt.language, "auto")
|
|
|
|
def test_invalid_stt_language_raises(self):
|
|
payload = {"stt": {"model": "base", "device": "cpu", "language": "klingon"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "stt.language: unsupported language"):
|
|
load(str(path))
|
|
|
|
def test_non_string_stt_language_raises(self):
|
|
payload = {"stt": {"model": "base", "device": "cpu", "language": 123}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "stt.language: must be a string"):
|
|
load(str(path))
|
|
|
|
def test_unknown_nested_stt_field_raises(self):
|
|
payload = {"stt": {"model": "base", "device": "cpu", "custom": "value"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "stt.custom: unknown config field"):
|
|
load(str(path))
|
|
|
|
def test_invalid_ux_profile_raises(self):
|
|
payload = {"ux": {"profile": "unknown"}}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ValueError, "ux.profile: must be one of"):
|
|
load(str(path))
|
|
|
|
def test_missing_config_version_is_migrated_to_current(self):
|
|
payload = {
|
|
"daemon": {"hotkey": "Super+m"},
|
|
"stt": {"model": "base", "device": "cpu"},
|
|
}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
cfg = load(str(path))
|
|
|
|
self.assertEqual(cfg.config_version, CURRENT_CONFIG_VERSION)
|
|
|
|
def test_legacy_llm_config_fields_raise(self):
|
|
payload = {
|
|
"llm": {"provider": "local_llama"},
|
|
}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "config.json"
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(
|
|
ValueError,
|
|
"llm: unknown config field",
|
|
):
|
|
load(str(path))
|
|
|
|
def test_config_log_payload_omits_vocabulary_and_custom_model_path(self):
|
|
cfg = Config()
|
|
cfg.models.allow_custom_models = True
|
|
cfg.models.whisper_model_path = "/tmp/custom-whisper.bin"
|
|
cfg.vocabulary.terms = ["SensitiveTerm"]
|
|
|
|
payload = config_log_payload(cfg)
|
|
|
|
self.assertTrue(payload["custom_whisper_path_configured"])
|
|
self.assertNotIn("vocabulary", payload)
|
|
self.assertNotIn("whisper_model_path", payload)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|