* feat(settings): make the language picker searchable while its list is open Entry completion only ever covered typing into the closed entry. Open the dropdown and the keyboard goes to GTK's own first-letter jump, so nothing typed reaches a filter, and with thirty-odd languages scrolling is the only way through. The first attempt at fixing this (a filtered ComboBox model) had the same limitation for the same reason. Replace the combo with a picker that opens a popover holding a search entry, focused on open, above the list. Every keystroke narrows the rows, Enter takes the first match, a click takes that row. It keeps the GtkComboBoxText methods the dialog calls, mirrors the store's column order in base_model for the shared row helpers, and emits "changed" only when the selection actually changes, so re-selecting the saved language on open is not taken for a user edit. Visibility uses the matcher the typed-text resolver already uses, so the list and Enter cannot disagree. The completion helper and the combo styling skip the picker: it filters its own list, and the ComboBox-only calls do not exist on a Box. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * feat(settings): add a simple mode that derives the model from the language The Speech Model panel asked four questions, but only two of them can be answered without knowing the hardware: what language you speak and whether you want it faster or more accurate. Size follows from the machine and the specialization follows from the language, and the app already computed both — it just printed the answer beside the pickers instead of using it. Simple mode asks for the main language, whether whole texts are also dictated in other languages, and a priority. There is deliberately no second language field: whisper takes one language or none, so a pair cannot be passed, and with a language pinned the multilingual weights already handle inserted foreign words. The switch is the "none" case. Priority moves relative to what hardware detection recommends rather than naming an absolute size, so "most accurate" means something different on a laptop than on a workstation. Opening simple mode reads the priority back from the size already configured, so switching modes never changes the loaded model on its own. Simple mode steers the existing controls rather than writing the configuration itself, so applying, downloading and the info card keep going through one path. Advanced mode is the previous panel, unchanged. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): stop simple mode reloading the engine four times per pick Reported against the installed build: choosing a language locked the window for a moment. Simple mode steers the engine, language, size and variant controls, and every one of those change handlers ends in _auto_apply_settings, so a single pick reconfigured the speech engine up to four times on the UI thread, reloading the model each time. Hold a guard across the steering and apply exactly once when it is done. The simple language box was also a plain list with no entry, so it could not be typed into — a step backwards from the advanced row, and unusable with over thirty languages. It now uses the same searchable combo, with the same commit and restore behaviour for text that resolves to nothing. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * refactor(settings): make simple the panel and Advanced a reveal The Simple/Advanced dropdown framed the two as alternatives, which put a mode decision in front of the user before any of the questions they can answer. They are not alternatives: simple is the panel, and advanced is more detail about the same choice. The mode selector is gone. The simple questions are always on screen, with an Advanced switch that slides the engine, size and specialization rows out underneath. Because simple mode steers those rows, they also work as a readout of what the simple answers resolved to, which the old either/or hid. The stored settings_mode becomes a show_advanced flag, remembering whether the detail was left open. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * refactor(settings): second language list, and Advanced in its own window Reworked to what the panel is actually for. The main language comes from the keyboard layout. Turning on "I also dictate whole texts in other languages" reveals a second list rather than silently flipping to detection, so the choice is visible. Advanced is a button that opens the detailed controls in their own window, instead of an inline reveal that pushed the page around. Naming a second language resolves to automatic detection, because whisper takes one language or none. The one case where both can still be honoured is two English entries — en-US plus en-IN — where English stays pinned and the English-only weights, same size and better at English, remain available. Also fixes a name collision the reveal had: advanced_revealer already belonged to the power-user toggle on the Advanced page, and the section built later won the attribute. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * feat(settings): use the filtering picker for both simple language lists The main language and the second language are the same thirty-odd-row lists as the advanced one, so they get the same dropdown filtering rather than a completion popup that only reacts while the entry has focus. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): make simple mode do what it says on screen Four things reported from the installed build, all real. The second language list never appeared: the row is flagged no_show_all, and show_all() is a no-op on such a widget, so the flag has to be cleared first — the dance _set_custom_shortcut_row_visible already does. There was no multilingual answer. "Any language (auto-detect)" is now the first entry of the second list and the default when the switch is turned on, so the switch alone visibly does something. Changing the priority showed nothing, because the info card — the only surface that says which model was picked and what it costs — had been moved into the advanced window. It is back on the page under the simple questions. Opening Advanced crashed the compositor. KWin 6.7.4 (Wayland) died with unbounded recursion in KWin::Window::findModal(), a cycle in its transient-for chain, when the advanced window — a long-lived toplevel transient for this dialog, hidden on close and shown again with present() — was opened. Coredumps at 2026-09-06 20:20:10 and 20:39:49; the crashing state is kept as tag kwin-crash-repro. The window now stands on its own, is built on every open and destroyed on close with the controls re-parented out first. KDE Bug 483265. Refs #779 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): bring the advanced window up in front of the dialog Reported: it opened behind Settings. Dropping the transient parent (which crashed KWin) also dropped the only reason the compositor had to stack it over the dialog, and on Wayland a toplevel mapped without an activation token lands behind the focused window. Present it with the timestamp of the click that opened it: that asks the compositor for focus and stacking, and needs no transient link. Refs #779 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * refactor(settings): Advanced as an island under the simple card, not a window A second toplevel went wrong twice on KWin 6.7 / Wayland. Transient for the dialog it crashed the compositor (unbounded recursion in KWin::Window::findModal, tag kwin-crash-repro, KDE Bug 483265). Standing on its own it could not be raised above the dialog at all, not even by hand. Advanced is now its own card under the simple questions: collapsed to a header by default, expanding in place, remembered in show_advanced. Both cards are on screen at once, so the expanded rows double as a readout of what the simple answers resolved to, and a change made in the advanced rows is read back into the simple ones — skipped while simple mode is itself steering them. The info card sits between the two, where a priority or language change shows its effect and its cost. Refs #779 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): let a same-size weight on disk stand in before downloading Reported: turning the "I also dictate whole texts in other languages" switch off went from base to base.en — same size, different weights — and sat the user through a modal 141 MB download for it, with the window blocked behind the progress dialog. Turning it on had done the same in the other direction. Simple mode now looks at the disk before settling on a variant. A downloaded weight of the same size that can serve the language stands in: English-only first when English is wanted, the plain multilingual next, quantized ones last. English-only weights never stand in for another language or for detection. When nothing of that size is on disk the derived variant stays and the download runs as before. The info card still offers the better variant to download; it just no longer forces it. Refs #779 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): keep Advanced language pin over stale simple multi _sync_simple_from_advanced restored the other-languages switch and persisted simple_second_language even when Advanced had a pinned language. The next simple edit then decoded to auto and silently replaced the pin. Clear/resync simple multi on a pin, refresh that readout when Advanced language changes, and drop the second-list Enter hook that double-applied through SearchablePicker. Refs #779 * fix(settings): keep two-English simple multi across Advanced pin sync Clearing every leftover second language on a pinned Advanced readout was too broad: en-US plus en-IN still pins English, and wiping that answer forgot the second English choice on the next open. Restore multi only when the stored second would keep the pin; otherwise drop it so a later simple edit cannot decode to auto. Refs #779 * fix(settings): restore simple multi only when it matches live language The pin-side keep left the auto branch restoring any stored second. Two English over Advanced auto still decodes to en-us, so the next simple edit silently replaced detection. Keep leftover multi only when it still decodes to the live Advanced language either way. Refs #779 * fix(settings): keep language-change tests honest after the main merge Mock dialogs treated _initializing as truthy and skipped the handler. Also call _refresh_simple_readout explicitly so the simple card updates. * fix(settings): type the language-entry GTK callbacks Greptile flagged missing param and return annotations on the new activate and focus-out handlers. Match the surrounding Any/bool style. Refs #779 * fix(settings): sort imports for isort after Parakeet merge --------- Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1371 lines
55 KiB
Python
1371 lines
55 KiB
Python
"""
|
|
Tests for the SettingsDialog.
|
|
|
|
Since SettingsDialog inherits from Gtk.Dialog which is mocked during tests,
|
|
we can't easily test the actual class methods. Instead, we test the core
|
|
logic that the settings dialog is supposed to execute.
|
|
|
|
UX Design Notes tested:
|
|
- Instant-apply pattern: settings apply immediately when changed
|
|
- No action buttons - uses title bar close (GNOME HIG)
|
|
"""
|
|
|
|
import sys
|
|
import time
|
|
import unittest
|
|
from unittest.mock import MagicMock, Mock, call, patch
|
|
|
|
# Mock GTK before importing anything that might use it
|
|
sys.modules["gi"] = MagicMock()
|
|
sys.modules["gi.repository"] = MagicMock()
|
|
sys.modules["gi.repository.Gtk"] = MagicMock()
|
|
sys.modules["gi.repository.GLib"] = MagicMock()
|
|
sys.modules["gi.repository.Gdk"] = MagicMock()
|
|
sys.modules["gi.repository.Pango"] = MagicMock()
|
|
|
|
from vocalinux.common_types import RecognitionState # noqa: E402
|
|
|
|
# Create mock for speech engine
|
|
mock_speech_engine = Mock()
|
|
mock_speech_engine.state = RecognitionState.IDLE
|
|
mock_speech_engine.reconfigure = Mock()
|
|
mock_speech_engine.start_recognition = Mock()
|
|
mock_speech_engine.stop_recognition = Mock()
|
|
mock_speech_engine.register_text_callback = Mock()
|
|
mock_speech_engine.unregister_text_callback = Mock()
|
|
|
|
# Create mock for config manager
|
|
mock_config_manager = Mock()
|
|
mock_config_manager.get = Mock(
|
|
return_value={
|
|
"speech_recognition": {
|
|
"engine": "vosk",
|
|
"language": "en-us",
|
|
"model_size": "small",
|
|
"vad_sensitivity": 3,
|
|
"silence_timeout": 2.0,
|
|
}
|
|
}
|
|
)
|
|
mock_config_manager.update_speech_recognition_settings = Mock()
|
|
mock_config_manager.set = Mock()
|
|
mock_config_manager.save_settings = Mock()
|
|
|
|
|
|
def apply_settings_internal(dialog, settings: dict) -> bool:
|
|
"""
|
|
Simplified version of SettingsDialog._apply_settings_internal for testing.
|
|
This is a test helper that mirrors the real implementation behavior.
|
|
"""
|
|
try:
|
|
# 1. Reconfigure Speech Engine
|
|
# Stop engine before reconfiguring if it's running
|
|
was_running = dialog.speech_engine.state != RecognitionState.IDLE
|
|
if was_running:
|
|
dialog.speech_engine.stop_recognition()
|
|
# Give it a moment to fully stop
|
|
time.sleep(0.01) # Shortened for tests
|
|
|
|
dialog.speech_engine.reconfigure(**settings)
|
|
|
|
# 2. Update Config Manager, only once the engine accepted the settings
|
|
sr_settings = {k: v for k, v in settings.items() if not k.startswith("whispercpp_")}
|
|
advanced_settings = {k: v for k, v in settings.items() if k.startswith("whispercpp_")}
|
|
|
|
dialog.config_manager.update_speech_recognition_settings(sr_settings)
|
|
for key, value in advanced_settings.items():
|
|
dialog.config_manager.set("advanced", key, value)
|
|
dialog.config_manager.save_settings()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
class TestSettingsDialog(unittest.TestCase):
|
|
"""Test cases for the settings dialog behavior."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
# Reset mocks before each test
|
|
mock_speech_engine.reset_mock()
|
|
mock_config_manager.reset_mock()
|
|
mock_speech_engine.state = RecognitionState.IDLE
|
|
|
|
# Create a mock dialog object directly
|
|
self.dialog = Mock()
|
|
|
|
# Set mock attributes on dialog
|
|
self.dialog.config_manager = mock_config_manager
|
|
self.dialog.speech_engine = mock_speech_engine
|
|
|
|
# Default test settings
|
|
self.test_settings = {
|
|
"engine": "vosk",
|
|
"model_size": "small",
|
|
"vad_sensitivity": 3,
|
|
"silence_timeout": 2.0,
|
|
}
|
|
|
|
def test_apply_settings_success(self):
|
|
"""Test the apply_settings method calls config and engine methods."""
|
|
# Use larger model to test settings actually change
|
|
settings = {
|
|
"engine": "vosk",
|
|
"language": "en-us",
|
|
"model_size": "large",
|
|
"vad_sensitivity": 3,
|
|
"silence_timeout": 2.0,
|
|
}
|
|
|
|
# Ensure reconfigure doesn't raise an exception
|
|
mock_speech_engine.reconfigure.side_effect = None
|
|
|
|
# Call the method under test
|
|
result = apply_settings_internal(self.dialog, settings)
|
|
|
|
# Verify the result
|
|
self.assertTrue(result)
|
|
|
|
# Verify mocks were called with the right parameters
|
|
mock_config_manager.update_speech_recognition_settings.assert_called_once_with(settings)
|
|
mock_config_manager.save_settings.assert_called_once()
|
|
mock_speech_engine.reconfigure.assert_called_once_with(**settings)
|
|
|
|
def test_apply_settings_persists_whispercpp_settings_to_advanced_section(self):
|
|
"""Test whisper.cpp settings are saved outside speech_recognition config."""
|
|
settings = {
|
|
"engine": "whisper_cpp",
|
|
"language": "auto",
|
|
"model_size": "tiny",
|
|
"vad_sensitivity": 3,
|
|
"silence_timeout": 2.0,
|
|
"whispercpp_no_timestamps": False,
|
|
"whispercpp_temperature": 0.5,
|
|
"whispercpp_initial_prompt": "Meeting notes",
|
|
}
|
|
|
|
mock_speech_engine.reconfigure.side_effect = None
|
|
|
|
result = apply_settings_internal(self.dialog, settings)
|
|
|
|
self.assertTrue(result)
|
|
mock_config_manager.update_speech_recognition_settings.assert_called_once_with(
|
|
{
|
|
"engine": "whisper_cpp",
|
|
"language": "auto",
|
|
"model_size": "tiny",
|
|
"vad_sensitivity": 3,
|
|
"silence_timeout": 2.0,
|
|
}
|
|
)
|
|
mock_config_manager.set.assert_has_calls(
|
|
[
|
|
call("advanced", "whispercpp_no_timestamps", False),
|
|
call("advanced", "whispercpp_temperature", 0.5),
|
|
call("advanced", "whispercpp_initial_prompt", "Meeting notes"),
|
|
],
|
|
any_order=True,
|
|
)
|
|
mock_config_manager.save_settings.assert_called_once()
|
|
mock_speech_engine.reconfigure.assert_called_once_with(**settings)
|
|
|
|
def test_apply_settings_stops_engine_if_running(self):
|
|
"""Test apply_settings stops the engine if it was running."""
|
|
# Set the engine state to running
|
|
mock_speech_engine.state = RecognitionState.LISTENING
|
|
|
|
# Ensure reconfigure doesn't raise an exception
|
|
mock_speech_engine.reconfigure.side_effect = None
|
|
|
|
# Call the method under test
|
|
result = apply_settings_internal(self.dialog, self.test_settings)
|
|
|
|
# Verify the result
|
|
self.assertTrue(result)
|
|
|
|
# Verify engine was stopped before reconfigure
|
|
mock_speech_engine.stop_recognition.assert_called_once()
|
|
mock_speech_engine.reconfigure.assert_called_once()
|
|
|
|
def test_apply_settings_failure_reconfigure(self):
|
|
"""A failed reconfigure must leave the saved settings untouched."""
|
|
# Set up the reconfigure method to raise an exception
|
|
mock_speech_engine.reconfigure.side_effect = Exception("Model load failed")
|
|
|
|
# Call the method under test
|
|
result = apply_settings_internal(self.dialog, self.test_settings)
|
|
|
|
# Verify the result
|
|
self.assertFalse(result)
|
|
|
|
# The engine was asked, and refused: the config must still describe the
|
|
# settings that are actually running, not the ones that failed.
|
|
mock_speech_engine.reconfigure.assert_called_once()
|
|
mock_config_manager.update_speech_recognition_settings.assert_not_called()
|
|
mock_config_manager.save_settings.assert_not_called()
|
|
|
|
|
|
class TestSettingsDialogCSS(unittest.TestCase):
|
|
"""Test cases for SettingsDialog CSS styling."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
# Clear any cached imports
|
|
if "vocalinux.ui.settings_dialog" in sys.modules:
|
|
del sys.modules["vocalinux.ui.settings_dialog"]
|
|
|
|
def test_settings_css_exists(self):
|
|
"""Test that SETTINGS_CSS constant is defined."""
|
|
from vocalinux.ui.settings_dialog import SETTINGS_CSS
|
|
|
|
self.assertIsInstance(SETTINGS_CSS, str)
|
|
|
|
def test_settings_css_has_dialog_class(self):
|
|
"""Test that CSS includes settings-dialog class."""
|
|
from vocalinux.ui.settings_dialog import SETTINGS_CSS
|
|
|
|
self.assertIn(".settings-dialog", SETTINGS_CSS)
|
|
|
|
def test_settings_css_has_preferences_group(self):
|
|
"""Test that CSS includes preferences-group class."""
|
|
from vocalinux.ui.settings_dialog import SETTINGS_CSS
|
|
|
|
self.assertIn(".preferences-group", SETTINGS_CSS)
|
|
|
|
def test_settings_css_has_preference_row(self):
|
|
"""Test that CSS includes preference-row class."""
|
|
from vocalinux.ui.settings_dialog import SETTINGS_CSS
|
|
|
|
self.assertIn(".preference-row", SETTINGS_CSS)
|
|
|
|
def test_settings_css_uses_theme_variables(self):
|
|
"""Test that CSS uses GTK theme variables."""
|
|
from vocalinux.ui.settings_dialog import SETTINGS_CSS
|
|
|
|
# Should use theme variables for proper light/dark mode support
|
|
self.assertIn("@theme_bg_color", SETTINGS_CSS)
|
|
self.assertIn("@theme_base_color", SETTINGS_CSS)
|
|
|
|
def test_settings_css_has_status_classes(self):
|
|
"""Test that CSS includes status indicator classes."""
|
|
from vocalinux.ui.settings_dialog import SETTINGS_CSS
|
|
|
|
self.assertIn(".status-success", SETTINGS_CSS)
|
|
self.assertIn(".status-warning", SETTINGS_CSS)
|
|
self.assertIn(".status-error", SETTINGS_CSS)
|
|
|
|
def test_settings_css_info_box_is_flat(self):
|
|
"""Info notices use a flat border, not a left accent strip."""
|
|
from vocalinux.ui.settings_dialog import SETTINGS_CSS
|
|
|
|
self.assertIn(".info-box", SETTINGS_CSS)
|
|
self.assertIn("border: 1px solid", SETTINGS_CSS)
|
|
self.assertNotIn("border-left:", SETTINGS_CSS)
|
|
self.assertNotIn("border-left-color:", SETTINGS_CSS)
|
|
|
|
def test_settings_css_styles_combobox_entry(self):
|
|
"""Searchable combos need the inner entry to match button height."""
|
|
from vocalinux.ui.settings_dialog import SETTINGS_CSS
|
|
|
|
self.assertIn("combobox entry", SETTINGS_CSS)
|
|
|
|
|
|
class TestSettingsDialogClasses(unittest.TestCase):
|
|
"""Test cases for SettingsDialog helper classes."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
if "vocalinux.ui.settings_dialog" in sys.modules:
|
|
del sys.modules["vocalinux.ui.settings_dialog"]
|
|
|
|
def test_preferences_group_class_exists(self):
|
|
"""Test that PreferencesGroup class exists."""
|
|
from vocalinux.ui.settings_dialog import PreferencesGroup
|
|
|
|
self.assertTrue(callable(PreferencesGroup))
|
|
|
|
def test_preference_row_class_exists(self):
|
|
"""Test that PreferenceRow class exists."""
|
|
from vocalinux.ui.settings_dialog import PreferenceRow
|
|
|
|
self.assertTrue(callable(PreferenceRow))
|
|
|
|
def test_model_download_dialog_class_exists(self):
|
|
"""Test that ModelDownloadDialog class exists."""
|
|
from vocalinux.ui.settings_dialog import ModelDownloadDialog
|
|
|
|
self.assertTrue(callable(ModelDownloadDialog))
|
|
|
|
|
|
class TestSettingsDialogInstantApply(unittest.TestCase):
|
|
"""Test cases for instant-apply behavior (no action buttons)."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
if "vocalinux.ui.settings_dialog" in sys.modules:
|
|
del sys.modules["vocalinux.ui.settings_dialog"]
|
|
|
|
def test_settings_dialog_has_auto_apply_method(self):
|
|
"""Test that SettingsDialog has _auto_apply_settings method in source."""
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertIn("def _auto_apply_settings(self", source_code)
|
|
|
|
def test_settings_dialog_has_close_button_only(self):
|
|
"""Test that SettingsDialog has a Close button but no Apply button.
|
|
|
|
A Close button is required for window managers that hide the title bar
|
|
close button on Gtk.Dialog windows (fixes #323). The instant-apply
|
|
pattern means settings are applied immediately, so no Apply button is
|
|
needed.
|
|
"""
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
# Should have a Close button for WM compatibility
|
|
self.assertIn("ResponseType.CLOSE", source_code)
|
|
|
|
# Should NOT have Apply button - uses instant-apply pattern
|
|
self.assertNotIn("_Apply", source_code)
|
|
self.assertNotIn("ResponseType.APPLY", source_code)
|
|
|
|
def test_settings_dialog_no_revert_settings(self):
|
|
"""Test that SettingsDialog does NOT have _revert_settings (removed)."""
|
|
from vocalinux.ui.settings_dialog import SettingsDialog
|
|
|
|
# _revert_settings was removed as part of no-action-buttons pattern
|
|
self.assertFalse(hasattr(SettingsDialog, "_revert_settings"))
|
|
|
|
def test_settings_dialog_no_show_applied_message(self):
|
|
"""Test that SettingsDialog does NOT have _show_settings_applied_message (removed)."""
|
|
from vocalinux.ui.settings_dialog import SettingsDialog
|
|
|
|
# _show_settings_applied_message was removed as part of instant-apply pattern
|
|
self.assertFalse(hasattr(SettingsDialog, "_show_settings_applied_message"))
|
|
|
|
def test_advanced_initial_prompt_defers_auto_apply_while_typing(self):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
# The initial prompt must not auto-apply on focus-out; that fights typing.
|
|
# Language combo search may restore a valid selection on focus-out.
|
|
advanced_body = source_code.split("def _build_advanced_section")[1].split("\n def ")[0]
|
|
self.assertNotIn("focus-out-event", advanced_body)
|
|
self.assertNotIn(
|
|
'advanced_initial_prompt_buffer.connect("changed", self._on_advanced_param_changed)',
|
|
source_code,
|
|
)
|
|
self.assertIn(
|
|
'advanced_initial_prompt_buffer.connect("changed", self._on_advanced_prompt_changed)',
|
|
source_code,
|
|
)
|
|
self.assertIn("def _flush_advanced_prompt_if_dirty", source_code)
|
|
self.assertIn("self._advanced_prompt_dirty = True", source_code)
|
|
self.assertIn("self._flush_advanced_prompt_if_dirty()", source_code)
|
|
|
|
def test_advanced_initial_prompt_has_help_tooltip(self):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertIn("initial_prompt_help", source_code)
|
|
self.assertIn("Leave blank for normal dictation.", source_code)
|
|
self.assertIn("prompt_scrolled.set_tooltip_text(initial_prompt_help)", source_code)
|
|
self.assertIn("initial_prompt_row.set_tooltip_text(initial_prompt_help)", source_code)
|
|
|
|
def test_advanced_panel_has_reset_to_defaults_button(self):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertIn('Gtk.Button(label="Reset to Defaults")', source_code)
|
|
self.assertIn('"clicked", self._on_reset_advanced_clicked', source_code)
|
|
self.assertIn("def _on_reset_advanced_clicked(self, widget):", source_code)
|
|
self.assertIn('defaults = DEFAULT_CONFIG["advanced"]', source_code)
|
|
self.assertIn("self.advanced_reset_button", source_code)
|
|
|
|
def test_advanced_reset_button_is_in_page_action(self):
|
|
"""Reset to Defaults lives inside the Advanced page (gated with the
|
|
controls it resets), not in a footer that changes per page."""
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertIn("reset_row.pack_start(self.advanced_reset_button", source_code)
|
|
self.assertIn("controls_box.pack_start(reset_row", source_code)
|
|
# Not a floating action-area button anymore
|
|
self.assertNotIn("action_area.pack_start(self.advanced_reset_button", source_code)
|
|
self.assertNotIn("set_child_secondary(self.advanced_reset_button", source_code)
|
|
|
|
def test_advanced_panel_omits_unsupported_non_speech_token_setting(self):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertNotIn("Suppress Non-Speech Tokens", source_code)
|
|
self.assertNotIn("advanced_suppress_nst_switch", source_code)
|
|
|
|
def test_close_button_lives_in_sidebar_footer(self):
|
|
"""The in-window Close button is anchored in the sidebar footer and
|
|
closes through the normal response path, not an orphan action-area
|
|
row below the content (#323 keeps an in-window Close for WMs that
|
|
hide the title-bar button)."""
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertNotIn('self.add_button("Close"', source_code)
|
|
self.assertIn('close_button = Gtk.Button(label="Close")', source_code)
|
|
self.assertIn('close_button.connect("clicked", self._on_close_clicked)', source_code)
|
|
self.assertIn("self.response(Gtk.ResponseType.CLOSE)", source_code)
|
|
|
|
def test_advanced_disclaimer_appears_before_controls(self):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertLess(
|
|
source_code.index("controls_box.pack_start(info_box"),
|
|
source_code.index("controls_box.pack_start(group"),
|
|
)
|
|
|
|
def test_remote_api_section_in_speech_engine_tab(self):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
# Reached from the Speech Model page: the detailed controls now live in the
|
|
# window the Advanced button opens, next to the engine picker that selects
|
|
# remote_api. The point of this test is that they are not exiled to the
|
|
# separate Advanced page, which the advanced_tab assertions below still guard.
|
|
self.assertIn("self.advanced_box.pack_start(self.remote_server_group", source_code)
|
|
self.assertIn("self.advanced_box.pack_start(self.remote_status_label", source_code)
|
|
self.assertIn("self.remote_api_model_entry", source_code)
|
|
self.assertIn("OpenAI/FunASR", source_code)
|
|
self.assertNotIn("advanced_tab.pack_start(self.remote_server_group", source_code)
|
|
self.assertNotIn("advanced_tab.pack_start(self.remote_status_label", source_code)
|
|
self.assertNotIn("self.use_remote_switch", source_code)
|
|
|
|
def test_connection_test_uses_session(self):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertIn("session = requests.Session()", source_code)
|
|
self.assertIn("session.close()", source_code)
|
|
|
|
|
|
class TestSettingsDialogHelperFunctions(unittest.TestCase):
|
|
"""Test cases for settings dialog helper functions."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
if "vocalinux.ui.settings_dialog" in sys.modules:
|
|
del sys.modules["vocalinux.ui.settings_dialog"]
|
|
|
|
def test_format_size_function_exists(self):
|
|
"""Test that _format_size function exists."""
|
|
from vocalinux.ui.settings_dialog import _format_size
|
|
|
|
self.assertTrue(callable(_format_size))
|
|
|
|
def test_format_size_mb(self):
|
|
"""Test _format_size with MB values."""
|
|
from vocalinux.ui.settings_dialog import _format_size
|
|
|
|
self.assertEqual(_format_size(100), "100 MB")
|
|
self.assertEqual(_format_size(500), "500 MB")
|
|
|
|
def test_format_size_gb(self):
|
|
"""Test _format_size with GB values."""
|
|
from vocalinux.ui.settings_dialog import _format_size
|
|
|
|
self.assertEqual(_format_size(1000), "1.0 GB")
|
|
self.assertEqual(_format_size(2500), "2.5 GB")
|
|
|
|
def test_is_whisper_model_downloaded_function_exists(self):
|
|
"""Test that _is_whisper_model_downloaded function exists."""
|
|
from vocalinux.ui.settings_dialog import _is_whisper_model_downloaded
|
|
|
|
self.assertTrue(callable(_is_whisper_model_downloaded))
|
|
|
|
def test_is_vosk_model_downloaded_function_exists(self):
|
|
"""Test that _is_vosk_model_downloaded function exists."""
|
|
from vocalinux.ui.settings_dialog import _is_vosk_model_downloaded
|
|
|
|
self.assertTrue(callable(_is_vosk_model_downloaded))
|
|
|
|
def test_get_recommended_whisper_model_function_exists(self):
|
|
"""Test that _get_recommended_whisper_model function exists."""
|
|
from vocalinux.ui.settings_dialog import _get_recommended_whisper_model
|
|
|
|
self.assertTrue(callable(_get_recommended_whisper_model))
|
|
|
|
def test_get_recommended_vosk_model_function_exists(self):
|
|
"""Test that _get_recommended_vosk_model function exists."""
|
|
from vocalinux.ui.settings_dialog import _get_recommended_vosk_model
|
|
|
|
self.assertTrue(callable(_get_recommended_vosk_model))
|
|
|
|
def test_whispercpp_settings_use_size_buckets(self):
|
|
"""Test that whisper.cpp settings split size from specialization."""
|
|
from vocalinux.ui.settings_dialog import ENGINE_MODELS, WHISPERCPP_MODEL_INFO
|
|
|
|
self.assertEqual(
|
|
ENGINE_MODELS["whisper_cpp"],
|
|
["tiny", "base", "small", "medium", "large"],
|
|
)
|
|
self.assertIn("large-v3-turbo", WHISPERCPP_MODEL_INFO)
|
|
self.assertIn("large-v3-turbo-q5_0", WHISPERCPP_MODEL_INFO)
|
|
self.assertNotIn("small.en-tdrz", WHISPERCPP_MODEL_INFO)
|
|
|
|
def test_model_display_name_large_v3_turbo(self):
|
|
"""Test display labels for whisper.cpp model variants."""
|
|
from vocalinux.ui.settings_dialog import _model_display_name
|
|
|
|
self.assertEqual(_model_display_name("large"), "Large v3")
|
|
self.assertEqual(_model_display_name("large-v3-turbo"), "Large v3 Turbo")
|
|
self.assertEqual(_model_display_name("large-v3-turbo-q5_0"), "Large v3 Turbo Q5_0")
|
|
self.assertEqual(_model_display_name("tiny.en-q5_1"), "Tiny EN Q5_1")
|
|
|
|
def test_model_specialization_display_name(self):
|
|
"""Test concise specialization labels for the second whisper.cpp dropdown."""
|
|
from vocalinux.ui.settings_dialog import _model_specialization_display_name
|
|
|
|
self.assertEqual(_model_specialization_display_name("medium"), "Standard multilingual")
|
|
self.assertEqual(_model_specialization_display_name("medium.en"), "English-only")
|
|
self.assertEqual(
|
|
_model_specialization_display_name("medium.en-q5_0"),
|
|
"English-only Q5_0",
|
|
)
|
|
self.assertEqual(_model_specialization_display_name("large"), "Standard v3")
|
|
self.assertEqual(_model_specialization_display_name("large-v3-turbo"), "Turbo")
|
|
self.assertEqual(_model_specialization_display_name("large-v2-q5_0"), "v2 Q5_0")
|
|
self.assertEqual(_model_specialization_display_name("large-v3-q5_0"), "v3 Q5_0")
|
|
|
|
def test_model_picker_tooltips_explain_when_to_choose_variants(self):
|
|
"""Test hover guidance for model picker choices."""
|
|
from vocalinux.ui.settings_dialog import (
|
|
LANGUAGE_TOOLTIP,
|
|
MODEL_SIZE_TOOLTIP,
|
|
MODEL_SPECIALIZATION_TOOLTIP,
|
|
_model_specialization_tooltip,
|
|
)
|
|
|
|
self.assertIn("largest model", MODEL_SIZE_TOOLTIP)
|
|
self.assertIn("Standard multilingual", MODEL_SPECIALIZATION_TOOLTIP)
|
|
self.assertIn("English-only", LANGUAGE_TOOLTIP)
|
|
self.assertIn("Type to search", LANGUAGE_TOOLTIP)
|
|
self.assertIn("only in English", _model_specialization_tooltip("medium.en"))
|
|
self.assertIn("lower-memory systems", _model_specialization_tooltip("medium-q5_0"))
|
|
self.assertIn("Turbo", _model_specialization_tooltip("large-v3-turbo"))
|
|
self.assertIn("legacy large model", _model_specialization_tooltip("large-v2"))
|
|
self.assertIn("most users", _model_specialization_tooltip("small"))
|
|
|
|
def test_model_picker_rows_have_hover_tooltips(self):
|
|
"""Test that model picker rows expose the guidance as hover text."""
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertIn("self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP)", source_code)
|
|
self.assertIn("self.model_row.set_tooltip_text(MODEL_SIZE_TOOLTIP)", source_code)
|
|
self.assertIn(
|
|
"self.model_variant_row.set_tooltip_text(specialization_tooltip)",
|
|
source_code,
|
|
)
|
|
self.assertIn("self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP)", source_code)
|
|
self.assertIn("self.language_combo = SearchablePicker()", source_code)
|
|
# SearchablePicker opens a popover with a search entry above the list,
|
|
# so typing works while the list is open, which no combo dropdown allows.
|
|
self.assertIn("_attach_language_combo_search", source_code)
|
|
|
|
def test_whispercpp_recommendation_uses_language_for_specialization(self):
|
|
"""Test that English language nudges recommendations to .en variants."""
|
|
from vocalinux.ui.settings_dialog import (
|
|
_default_whispercpp_variant_for_size,
|
|
_recommended_whispercpp_variant_for_language,
|
|
)
|
|
|
|
self.assertEqual(
|
|
_recommended_whispercpp_variant_for_language(
|
|
"medium",
|
|
"mock hardware reason",
|
|
"en-us",
|
|
),
|
|
("medium.en", "mock hardware reason; English language selected"),
|
|
)
|
|
self.assertEqual(_default_whispercpp_variant_for_size("medium", "en-us"), "medium.en")
|
|
|
|
self.assertEqual(
|
|
_recommended_whispercpp_variant_for_language(
|
|
"medium",
|
|
"mock hardware reason",
|
|
"auto",
|
|
),
|
|
("medium", "mock hardware reason"),
|
|
)
|
|
self.assertEqual(_default_whispercpp_variant_for_size("medium", "auto"), "medium")
|
|
|
|
def test_whispercpp_variant_for_language_retargets_ambiguous_size_ids(self):
|
|
"""Bare size ids must follow language, not stick on multilingual."""
|
|
from vocalinux.ui.settings_dialog import _whispercpp_variant_for_language
|
|
|
|
# Default config stores "tiny"/"medium" which are also multilingual ids.
|
|
self.assertEqual(_whispercpp_variant_for_language("medium", "en-us"), "medium.en")
|
|
self.assertEqual(_whispercpp_variant_for_language("tiny", "en-us"), "tiny.en")
|
|
self.assertEqual(_whispercpp_variant_for_language("small", "en-in"), "small.en")
|
|
self.assertEqual(_whispercpp_variant_for_language("medium", "auto"), "medium")
|
|
self.assertEqual(_whispercpp_variant_for_language("medium.en", "auto"), "medium")
|
|
self.assertEqual(_whispercpp_variant_for_language("medium.en", "fr"), "medium")
|
|
|
|
# Quantized pairs mirror with language when a match exists.
|
|
self.assertEqual(
|
|
_whispercpp_variant_for_language("medium-q5_0", "en-us"),
|
|
"medium.en-q5_0",
|
|
)
|
|
self.assertEqual(
|
|
_whispercpp_variant_for_language("medium.en-q5_0", "auto"),
|
|
"medium-q5_0",
|
|
)
|
|
|
|
# No English pair: leave the deliberate specialization alone.
|
|
self.assertEqual(
|
|
_whispercpp_variant_for_language("large-v3-turbo", "en-us"),
|
|
"large-v3-turbo",
|
|
)
|
|
self.assertEqual(_whispercpp_variant_for_language("large", "en-us"), "large")
|
|
|
|
def test_whisper_delete_unknown_model(self):
|
|
"""Test deleting an unknown Whisper model raises ValueError."""
|
|
from vocalinux.ui.settings_dialog import _delete_whisper_model
|
|
|
|
with self.assertRaises(ValueError):
|
|
_delete_whisper_model("not-a-whisper-model")
|
|
|
|
def test_unused_downloads_group_exists_in_source(self):
|
|
"""Test that unused downloads live in their own group, not Speech Engine."""
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as handle:
|
|
source_code = handle.read()
|
|
|
|
self.assertIn('label="Unused downloads"', source_code)
|
|
self.assertIn("self.unused_expander", source_code)
|
|
self.assertIn("self._on_unused_download_delete_clicked", source_code)
|
|
self.assertNotIn('title="Remove Model"', source_code)
|
|
self.assertIn("self._refresh_unused_downloads()", source_code)
|
|
|
|
|
|
class TestLanguageComboSearch(unittest.TestCase):
|
|
"""Searchable language ComboBox matching and commit helpers."""
|
|
|
|
def setUp(self):
|
|
if "vocalinux.ui.settings_dialog" in sys.modules:
|
|
del sys.modules["vocalinux.ui.settings_dialog"]
|
|
|
|
def test_combo_text_matches_query_name_id_and_case(self):
|
|
from vocalinux.ui.settings_dialog import _combo_text_matches_query
|
|
|
|
self.assertTrue(_combo_text_matches_query("span", "es", "Spanish"))
|
|
self.assertTrue(_combo_text_matches_query("SPAN", "es", "Spanish"))
|
|
self.assertTrue(_combo_text_matches_query("es", "es", "Spanish"))
|
|
self.assertTrue(_combo_text_matches_query("", "es", "Spanish"))
|
|
self.assertFalse(_combo_text_matches_query("span", "fr", "French"))
|
|
|
|
def test_resolve_combo_text_query_unique_exact_and_ambiguous(self):
|
|
from vocalinux.ui.settings_dialog import _resolve_combo_text_query
|
|
from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES
|
|
|
|
rows = [(code, info["name"]) for code, info in SUPPORTED_LANGUAGES.items()]
|
|
self.assertEqual(_resolve_combo_text_query("span", rows), "es")
|
|
self.assertEqual(_resolve_combo_text_query("English (US)", rows), "en-us")
|
|
self.assertEqual(_resolve_combo_text_query("auto", rows), "auto")
|
|
self.assertEqual(_resolve_combo_text_query("ja", rows), "ja")
|
|
self.assertIsNone(_resolve_combo_text_query("en", rows))
|
|
self.assertIsNone(_resolve_combo_text_query("zzzz", rows))
|
|
self.assertIsNone(_resolve_combo_text_query("", rows))
|
|
|
|
def test_resolve_prefers_exact_name_over_substring(self):
|
|
from vocalinux.ui.settings_dialog import _resolve_combo_text_query
|
|
|
|
rows = [("xx", "Spanish"), ("es", "Not Spanish")]
|
|
self.assertEqual(_resolve_combo_text_query("Spanish", rows), "xx")
|
|
|
|
def test_language_combo_is_searchable_in_source(self):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertIn("self.language_combo = SearchablePicker()", source_code)
|
|
self.assertIn("Gtk.EntryCompletion()", source_code)
|
|
self.assertIn("completion.set_text_column(0)", source_code)
|
|
self.assertIn("Search languages…", source_code)
|
|
self.assertIn("def _commit_or_restore_language_entry", source_code)
|
|
self.assertIn("Type to search, or pick from the list", source_code)
|
|
|
|
def test_dictation_tone_picker_in_source(self):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertIn("SOUND_EFFECT_TONES", source_code)
|
|
self.assertIn('title="Dictation Tone"', source_code)
|
|
self.assertIn("def _on_preview_tone_clicked", source_code)
|
|
self.assertIn("preview_tone_cue(tone_id, kind)", source_code)
|
|
self.assertIn("if not preview_tone_cue(tone_id, kind):", source_code)
|
|
self.assertIn("self._tone_preview_kind", source_code)
|
|
self.assertNotIn("fifth", source_code)
|
|
self.assertNotIn("01-linux-glide", source_code)
|
|
self.assertNotIn('title="Preview Tone"', source_code)
|
|
|
|
|
|
class TestSettingsSearch(unittest.TestCase):
|
|
"""Test cases for the settings search feature."""
|
|
|
|
def setUp(self):
|
|
if "vocalinux.ui.settings_dialog" in sys.modules:
|
|
del sys.modules["vocalinux.ui.settings_dialog"]
|
|
|
|
def test_row_matches_query_title(self):
|
|
from vocalinux.ui.settings_dialog import _row_matches_query
|
|
|
|
self.assertTrue(_row_matches_query("clip", "Copy to Clipboard"))
|
|
self.assertTrue(_row_matches_query("COPY", "Copy to Clipboard"))
|
|
self.assertFalse(_row_matches_query("gpu", "Copy to Clipboard"))
|
|
|
|
def test_row_matches_query_subtitle_and_keywords(self):
|
|
from vocalinux.ui.settings_dialog import _row_matches_query
|
|
|
|
self.assertTrue(
|
|
_row_matches_query("pasting", "Copy to Clipboard", "copy text for easy pasting")
|
|
)
|
|
self.assertTrue(_row_matches_query("vulkan", "GPU Device", "", ("vulkan", "graphics")))
|
|
self.assertFalse(_row_matches_query("audio", "GPU Device", "", ("vulkan",)))
|
|
|
|
def test_row_matches_query_empty_query_matches(self):
|
|
from vocalinux.ui.settings_dialog import _row_matches_query
|
|
|
|
self.assertTrue(_row_matches_query("", "Anything"))
|
|
self.assertTrue(_row_matches_query(" ", "Anything"))
|
|
|
|
def test_search_wiring_in_source(self):
|
|
"""The dialog wires a live search entry that filters all pages."""
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
source_code = f.read()
|
|
|
|
self.assertIn("self.search_entry = Gtk.SearchEntry()", source_code)
|
|
self.assertIn('"search-changed", self._on_search_changed', source_code)
|
|
self.assertIn("def _snapshot_search_baseline", source_code)
|
|
self.assertIn("def _restore_search_baseline", source_code)
|
|
# Clearing the search restores engine-driven visibility
|
|
restore_body = source_code.split("def _restore_search_baseline")[1].split("def ")[0]
|
|
self.assertIn("self._update_engine_specific_ui()", restore_body)
|
|
|
|
def test_search_indexes_nested_preference_groups(self):
|
|
"""Unlocked settings inside a revealer remain discoverable."""
|
|
source = self._settings_source()
|
|
self.assertIn("collect_groups", source)
|
|
self.assertIn("Gtk.Container", source)
|
|
self.assertIn("widget.get_children()", source)
|
|
|
|
def test_search_respects_closed_revealers(self):
|
|
"""A locked Advanced section must not leak controls into search."""
|
|
source = self._settings_source()
|
|
self.assertIn("Gtk.Revealer", source)
|
|
self.assertIn("parent.get_reveal_child()", source)
|
|
|
|
def test_clearing_search_restores_previous_page(self):
|
|
"""Clearing a no-results search must leave a real page selected."""
|
|
source = self._settings_source()
|
|
self.assertIn("self._search_previous_page = visible_page", source)
|
|
self.assertIn("self.sidebar_listbox.select_row(page.sidebar_row)", source)
|
|
self.assertIn("self.settings_stack.set_visible_child_name(page.name)", source)
|
|
|
|
def test_failed_update_check_clears_pending_before_hiding_badge(self):
|
|
"""Failed About lookup must clear _pending_update so search cannot revive New."""
|
|
source = self._settings_source()
|
|
fail_block = source.split("if release is None:")[1].split("return False")[0]
|
|
self.assertIn("self._pending_update = None", fail_block)
|
|
self.assertIn("self._set_about_update_badge(False)", fail_block)
|
|
# Search restore must follow _pending_update via the badge helper.
|
|
restore_body = source.split("def _restore_search_baseline")[1].split("def ")[0]
|
|
self.assertIn(
|
|
"self._set_about_update_badge(self._pending_update is not None)",
|
|
restore_body,
|
|
)
|
|
|
|
@staticmethod
|
|
def _settings_source():
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as source_file:
|
|
return source_file.read()
|
|
|
|
|
|
class TestSettingsNavigation(unittest.TestCase):
|
|
"""Test cases for the sidebar + stack navigation shell."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
import os
|
|
|
|
source_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"settings_dialog.py",
|
|
)
|
|
with open(source_path, "r") as f:
|
|
cls.source_code = f.read()
|
|
|
|
def test_sidebar_and_stack_replace_notebook(self):
|
|
self.assertIn("self.settings_stack = Gtk.Stack()", self.source_code)
|
|
self.assertIn("self.sidebar_listbox = Gtk.ListBox()", self.source_code)
|
|
self.assertNotIn("Gtk.Notebook()", self.source_code)
|
|
|
|
def test_topic_pages_exist(self):
|
|
for name, title in [
|
|
("dictation", "Dictation"),
|
|
("model", "Speech Model"),
|
|
("audio", "Audio"),
|
|
("performance", "Performance"),
|
|
("application", "Application"),
|
|
("advanced", "Advanced"),
|
|
]:
|
|
self.assertIn(f'SettingsPage("{name}", "{title}"', self.source_code)
|
|
|
|
def test_application_page_has_tray_warning_toggle(self):
|
|
self.assertIn('PreferencesGroup(title="General")', self.source_code)
|
|
self.assertIn("self.missing_tray_warning_switch = Gtk.Switch()", self.source_code)
|
|
self.assertIn('title="Warn if tray support is not detected"', self.source_code)
|
|
self.assertIn('"show_missing_tray_warning", enabled', self.source_code)
|
|
self.assertIn(
|
|
'ui_settings.get("show_missing_tray_warning", True)',
|
|
self.source_code,
|
|
)
|
|
|
|
def test_status_controls_live_in_sidebar_footer(self):
|
|
"""Status, mic level, test, and Close live in the sidebar footer so
|
|
they stay visible from every page without a bottom strip."""
|
|
self.assertIn("def _build_sidebar_footer(self, sidebar_box", self.source_code)
|
|
footer_body = self.source_code.split("def _build_sidebar_footer")[1].split("\n def ")[0]
|
|
self.assertIn("sidebar_box.pack_start(footer", footer_body)
|
|
# One shared level bar for recognition + mic test
|
|
self.assertIn("self.recognition_audio_level = self.audio_level_bar", footer_body)
|
|
self.assertIn('self.test_button = Gtk.Button(label="Test Dictation")', footer_body)
|
|
self.assertIn('close_button = Gtk.Button(label="Close")', footer_body)
|
|
# No persistent bottom strip below the pages
|
|
self.assertNotIn("def _build_status_strip(self):", self.source_code)
|
|
|
|
def test_close_button_separated_from_dictation_test(self):
|
|
"""Close sits below a separator so it reads as dialog chrome (#651)."""
|
|
footer_body = self.source_code.split("def _build_sidebar_footer")[1].split("\n def ")[0]
|
|
after_test = footer_body.split("footer.pack_start(self.test_output_revealer")[1]
|
|
before_close = after_test.split('close_button = Gtk.Button(label="Close")')[0]
|
|
self.assertIn("Gtk.Separator", before_close)
|
|
self.assertIn("footer.pack_start(close_separator", before_close)
|
|
|
|
def test_sidebar_icons_use_adwaita_names(self):
|
|
"""Sidebar icons must resolve in the stock Adwaita theme."""
|
|
for icon in [
|
|
"input-keyboard-symbolic",
|
|
"audio-input-microphone-symbolic",
|
|
"audio-speakers-symbolic",
|
|
"power-profile-performance-symbolic",
|
|
"preferences-system-symbolic",
|
|
"applications-engineering-symbolic",
|
|
]:
|
|
self.assertIn(f'"{icon}"', self.source_code)
|
|
# utilities-system-monitor-symbolic only ships with Ubuntu's Yaru theme
|
|
self.assertNotIn("utilities-system-monitor-symbolic", self.source_code)
|
|
self.assertNotIn("audio-card-symbolic", self.source_code)
|
|
|
|
def test_gpu_selection_is_not_power_user_gated(self):
|
|
"""GPU device selection lives on the Performance page, ungated."""
|
|
self.assertIn("def _build_gpu_section(self):", self.source_code)
|
|
gpu_body = self.source_code.split("def _build_gpu_section")[1].split("\n def ")[0]
|
|
self.assertIn("self.power_tab.pack_start(gpu_group", gpu_body)
|
|
self.assertIn("_gpu_acceleration_subtitle", gpu_body)
|
|
advanced_body = self.source_code.split("def _build_advanced_section")[1].split(
|
|
"\n def "
|
|
)[0]
|
|
self.assertNotIn("gpu", advanced_body.lower())
|
|
|
|
def test_dialog_keyboard_shortcuts(self):
|
|
"""Ctrl+F focuses search; Esc clears an active search."""
|
|
self.assertIn("def _on_dialog_key_press(self, widget, event):", self.source_code)
|
|
body = self.source_code.split("def _on_dialog_key_press")[1].split("\n def ")[0]
|
|
self.assertIn('"f"', body)
|
|
self.assertIn("self.search_entry.grab_focus()", body)
|
|
self.assertIn('"escape"', body)
|
|
|
|
|
|
class TestAboutPage(unittest.TestCase):
|
|
"""About page: this app, VocaHQ family, talk-to-us. Updater stays."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.source = TestSettingsSearch._settings_source()
|
|
cls.about = cls.source.split("def _build_about_section")[1].split("\n def ")[0]
|
|
|
|
def test_three_groups_keep_updater(self):
|
|
self.assertIn('title="Vocalinux"', self.about)
|
|
self.assertIn('title="Part of VocaHQ"', self.about)
|
|
self.assertIn('title="Talk to us"', self.about)
|
|
self.assertIn('title="Updates"', self.about)
|
|
self.assertIn("What's New", self.about)
|
|
self.assertIn("_on_check_updates_clicked", self.about)
|
|
self.assertIn("self.update_channel_combo", self.about)
|
|
|
|
def test_product_status_and_honest_network_copy(self):
|
|
self.assertIn("Available now on Linux (X11 and Wayland)", self.about)
|
|
self.assertIn("After a model is downloaded", self.about)
|
|
self.assertIn("Private dictation for Linux, Mac, Windows, and phone.", self.about)
|
|
self.assertIn("Android beta / iOS TestFlight", self.source)
|
|
self.assertIn("Self-hosted, headless", self.source)
|
|
self.assertNotIn("iOS source build", self.source)
|
|
self.assertNotIn("SmartScreen", self.about)
|
|
self.assertNotIn("100% offline", self.about)
|
|
self.assertNotIn("fully offline", self.about)
|
|
self.assertNotIn("Coming soon", self.about)
|
|
self.assertNotIn("releases/download", self.about)
|
|
# License is named once, on the License row, not in the intro or links.
|
|
self.assertEqual(self.about.count("GNU AGPL v3"), 1)
|
|
|
|
def test_family_and_talk_urls(self):
|
|
for url in (
|
|
"https://vocalinux.com",
|
|
"https://vocahq.com",
|
|
"https://vocamac.com",
|
|
"https://vocaphone.vocahq.com",
|
|
"https://vocagateway.vocahq.com",
|
|
"https://github.com/VocaHQ/vocalinux/issues",
|
|
"https://discord.gg/t6muquAJbm",
|
|
"https://x.com/vocahq",
|
|
"mailto:hello@vocahq.com",
|
|
):
|
|
self.assertIn(url, self.source)
|
|
self.assertIn('label="Report a bug or idea"', self.about)
|
|
self.assertIn("Join Discord", self.about)
|
|
self.assertIn("Follow on X", self.about)
|
|
self.assertIn("Email us", self.about)
|
|
self.assertIn("https://github.com/VocaHQ/vocalinux/issues", self.about)
|
|
self.assertIn("https://discord.gg/t6muquAJbm", self.source)
|
|
self.assertIn("https://x.com/vocahq", self.source)
|
|
self.assertIn("mailto:hello@vocahq.com", self.source)
|
|
|
|
def test_open_buttons_have_unique_accessible_names(self):
|
|
helper = self.source.split("def _about_open_button")[1].split("\n def ")[0]
|
|
self.assertIn("_set_accessible_name(button, tooltip)", helper)
|
|
self.assertIn('if label == "Open"', helper)
|
|
for name in (
|
|
"Open vocalinux.com website",
|
|
"Open vocahq.com",
|
|
"Open vocalinux.com",
|
|
"Open vocamac.com",
|
|
"Open vocaphone.vocahq.com",
|
|
"Open vocagateway.vocahq.com",
|
|
"Open the Vocalinux GitHub repository",
|
|
"Open the latest release page",
|
|
):
|
|
self.assertIn(name, self.source)
|
|
self.assertIn("_set_accessible_name", self.about)
|
|
|
|
def test_website_is_product_site_not_only_github(self):
|
|
self.assertIn("VOCALINUX_SITE_URL", self.about)
|
|
self.assertIn("Product site at vocalinux.com", self.about)
|
|
self.assertNotIn("Open the Vocalinux GitHub page", self.about)
|
|
|
|
def test_source_code_row_sits_beside_website(self):
|
|
self.assertIn('label="Website"', self.about)
|
|
self.assertIn('label="Source code"', self.about)
|
|
self.assertIn("GITHUB_REPO_URL", self.about)
|
|
self.assertIn("Open the Vocalinux GitHub repository", self.about)
|
|
self.assertLess(self.about.find('label="Website"'), self.about.find('label="Source code"'))
|
|
|
|
def test_talk_to_us_sits_above_updates(self):
|
|
self.assertLess(self.about.find('title="Talk to us"'), self.about.find('title="Updates"'))
|
|
self.assertLess(
|
|
self.about.find("self.about_tab.pack_start(talk_group"),
|
|
self.about.find("self.about_tab.pack_start(updates_group"),
|
|
)
|
|
|
|
def test_channel_check_shares_one_row(self):
|
|
self.assertIn("_combo_with_suffix", self.about)
|
|
self.assertIn("self.check_updates_btn", self.about)
|
|
self.assertNotIn('title="Status"', self.about)
|
|
self.assertNotIn('title="Latest Release"', self.about)
|
|
self.assertIn("self.latest_release_row = self.release_notes_row", self.about)
|
|
|
|
def test_family_uses_platform_icons(self):
|
|
self.assertIn("def _family_tile", self.source)
|
|
self.assertIn("platform-linux", self.source)
|
|
self.assertIn("platform-apple", self.source)
|
|
self.assertIn("platform-android", self.source)
|
|
self.assertIn("platform-server", self.source)
|
|
self.assertIn("platform-home", self.source)
|
|
self.assertIn('("platform-android", "platform-apple")', self.source)
|
|
self.assertIn("family_grid.attach", self.about)
|
|
self.assertIn("image.set_margin_end(8)", self.source)
|
|
helper = self.source.split("def _family_tile")[1].split("\n def ")[0]
|
|
self.assertIn("spacing=16", helper)
|
|
|
|
def test_can_open_about_urls(self):
|
|
from vocalinux.ui.settings_dialog import (
|
|
GITHUB_ISSUES_URL,
|
|
GITHUB_REPO_URL,
|
|
VOCAGATEWAY_SITE_URL,
|
|
VOCAHQ_DISCORD_URL,
|
|
VOCAHQ_MAILTO_URL,
|
|
VOCAHQ_SITE_URL,
|
|
VOCAHQ_X_URL,
|
|
VOCALINUX_SITE_URL,
|
|
VOCAMAC_SITE_URL,
|
|
VOCAPHONE_SITE_URL,
|
|
_can_open_url,
|
|
)
|
|
|
|
for url in (
|
|
VOCALINUX_SITE_URL,
|
|
VOCAHQ_SITE_URL,
|
|
VOCAMAC_SITE_URL,
|
|
VOCAPHONE_SITE_URL,
|
|
VOCAGATEWAY_SITE_URL,
|
|
GITHUB_REPO_URL,
|
|
GITHUB_ISSUES_URL,
|
|
VOCAHQ_DISCORD_URL,
|
|
VOCAHQ_X_URL,
|
|
VOCAHQ_MAILTO_URL,
|
|
"https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
|
|
):
|
|
self.assertTrue(_can_open_url(url), url)
|
|
self.assertFalse(_can_open_url("https://evil.example/payload"))
|
|
self.assertFalse(_can_open_url("mailto:evil@example.com"))
|
|
self.assertFalse(_can_open_url(""))
|
|
|
|
def test_talk_marks_are_official_currentcolor_svgs(self):
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
# VocaHQ/.github 61c8eee brand/vocahq/social/{discord,x,github,mail}.svg
|
|
expected_sha256 = {
|
|
"discord": "195092f3091352d662ceb1e9580877b03a943da0e1ec94a19f6aabac7dbf6dd7",
|
|
"x": "0897f679c620010f09771464d39341d91f5a30a1370e126338b9a03bef47df34",
|
|
"github": "1c1dd44a826db8b7d589cc48e9e8af2f395e960632c3ad978df8a00b2a56fc36",
|
|
"mail": "32fd07b258990f4cba26476fb4f7b4649b9d5e0317000a8a183fa3fe29609972",
|
|
}
|
|
icons = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "src"
|
|
/ "vocalinux"
|
|
/ "resources"
|
|
/ "icons"
|
|
/ "scalable"
|
|
)
|
|
root_icons = Path(__file__).resolve().parents[1] / "resources" / "icons" / "scalable"
|
|
for name, title in (
|
|
("discord", "Discord"),
|
|
("x", "X"),
|
|
("github", "GitHub"),
|
|
("mail", "Email"),
|
|
):
|
|
packaged_path = icons / f"{name}.svg"
|
|
shipped_path = root_icons / f"{name}.svg"
|
|
packaged = packaged_path.read_text(encoding="utf-8")
|
|
shipped = shipped_path.read_text(encoding="utf-8")
|
|
self.assertEqual(packaged, shipped)
|
|
self.assertEqual(
|
|
hashlib.sha256(packaged_path.read_bytes()).hexdigest(),
|
|
expected_sha256[name],
|
|
name,
|
|
)
|
|
self.assertEqual(
|
|
hashlib.sha256(shipped_path.read_bytes()).hexdigest(),
|
|
expected_sha256[name],
|
|
name,
|
|
)
|
|
self.assertIn('viewBox="0 0 24 24"', packaged)
|
|
self.assertIn('fill="currentColor"', packaged)
|
|
self.assertIn(f"<title>{title}</title>", packaged)
|
|
self.assertNotIn("#5865F2", packaged)
|
|
self.assertNotIn("web-browser-symbolic", self.about)
|
|
self.assertNotIn("mail-send-symbolic", self.about)
|
|
|
|
def test_launcher_icon_is_circular_family_logo(self):
|
|
"""App/About icon is the approved teal circle lockup, not the square fill."""
|
|
from pathlib import Path
|
|
|
|
packaged = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "src"
|
|
/ "vocalinux"
|
|
/ "resources"
|
|
/ "icons"
|
|
/ "scalable"
|
|
/ "vocalinux.svg"
|
|
)
|
|
shipped = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "resources"
|
|
/ "icons"
|
|
/ "scalable"
|
|
/ "vocalinux.svg"
|
|
)
|
|
packaged_svg = packaged.read_text(encoding="utf-8")
|
|
shipped_svg = shipped.read_text(encoding="utf-8")
|
|
self.assertEqual(packaged_svg, shipped_svg)
|
|
self.assertIn('<circle cx="229" cy="231.5" r="221.5" fill="#0F6B57"/>', packaged_svg)
|
|
self.assertIn('aria-label="Vocalinux"', packaged_svg)
|
|
self.assertNotIn("<rect", packaged_svg)
|
|
self.assertIn('get_icon_path("vocalinux")', self.about)
|
|
|
|
def test_platform_marks_are_currentcolor_svgs(self):
|
|
from pathlib import Path
|
|
|
|
icons = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "src"
|
|
/ "vocalinux"
|
|
/ "resources"
|
|
/ "icons"
|
|
/ "scalable"
|
|
)
|
|
root_icons = Path(__file__).resolve().parents[1] / "resources" / "icons" / "scalable"
|
|
for name, title in (
|
|
("platform-linux", "Linux"),
|
|
("platform-apple", "Apple"),
|
|
("platform-android", "Android"),
|
|
("platform-server", "Server"),
|
|
("platform-home", "Home"),
|
|
):
|
|
packaged_path = icons / f"{name}.svg"
|
|
shipped_path = root_icons / f"{name}.svg"
|
|
packaged = packaged_path.read_text(encoding="utf-8")
|
|
shipped = shipped_path.read_text(encoding="utf-8")
|
|
self.assertEqual(packaged, shipped, name)
|
|
self.assertIn('viewBox="0 0 24 24"', packaged, name)
|
|
self.assertIn('fill="currentColor"', packaged, name)
|
|
self.assertIn(f"<title>{title}</title>", packaged, name)
|
|
|
|
def test_about_marks_use_theme_aware_ink(self):
|
|
self.assertIn("_ABOUT_INK_ON_DARK", self.source)
|
|
self.assertIn("def _about_ink_hex(", self.source)
|
|
self.assertIn("def _about_surface_is_dark(", self.source)
|
|
self.assertIn('replace("currentColor", ink)', self.source)
|
|
self.assertIn("_FAMILY_ICON_PX", self.source)
|
|
|
|
|
|
class TestAboutInk(unittest.TestCase):
|
|
"""Ink for About marks follows light vs dark surfaces."""
|
|
|
|
def test_dark_theme_pref_picks_light_ink(self):
|
|
from vocalinux.ui.settings_dialog import (
|
|
_ABOUT_INK,
|
|
_ABOUT_INK_ON_DARK,
|
|
_about_ink_hex,
|
|
_about_surface_is_dark,
|
|
)
|
|
|
|
self.assertNotEqual(_ABOUT_INK, _ABOUT_INK_ON_DARK)
|
|
self.assertTrue(_ABOUT_INK_ON_DARK.startswith("#"))
|
|
if _about_surface_is_dark():
|
|
self.assertEqual(_about_ink_hex(), _ABOUT_INK_ON_DARK)
|
|
else:
|
|
self.assertIn(_about_ink_hex(), {_ABOUT_INK, _ABOUT_INK_ON_DARK})
|
|
|
|
|
|
class TestSettingsControlColumn(unittest.TestCase):
|
|
"""Dropdowns, steppers, and action buttons share one right-hand column."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.source = TestSettingsSearch._settings_source()
|
|
|
|
def test_style_helpers_exist(self):
|
|
self.assertIn("def _style_combo(", self.source)
|
|
self.assertIn("def _style_spin(", self.source)
|
|
self.assertIn("def _combo_with_suffix(", self.source)
|
|
self.assertIn("ellipsize", self.source)
|
|
self.assertIn("Pango.EllipsizeMode.END", self.source)
|
|
|
|
def test_combos_use_style_helper(self):
|
|
for name in (
|
|
"self.engine_combo",
|
|
"self.model_combo",
|
|
"self.model_variant_combo",
|
|
"self.language_combo",
|
|
"self.paste_shortcut_combo",
|
|
"self.shortcut_mode_combo",
|
|
"self.shortcut_combo",
|
|
"self.gpu_device_combo",
|
|
"self.model_keepalive_timeout_combo",
|
|
):
|
|
self.assertIn(f"_style_combo({name})", self.source, name)
|
|
|
|
def test_model_card_has_no_legend(self):
|
|
self.assertNotIn("✓ Downloaded ↓ Will download ★ Recommended", self.source)
|
|
self.assertNotIn("Tip: <b>", self.source)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|