Files
voculinux/tests/test_settings_dialog.py
T
dca937fc21 feat(dictionary): combined custom dictionary with corrections (#890)
* feat(dictionary): combined custom dictionary with corrections

Rebased onto latest main (conflict-resolution squash for PR #818).

* fix(dictionary): stabilize the terms and corrections file contracts

- corrections_path() stays at the fixed config_dir() location promised by the file contract instead of following a custom or --dictionary-file terms path, which could strand or duplicate corrections; a leftover file beside the terms file is still read once and copied over.

- _atomic_write() preserves the replaced file's permission mode instead of resetting it to the process umask.

- build_initial_prompt() streams terms and stops at the configured cap instead of reading and processing the whole file for every segment.

* fix(settings): restore the selected dictionary pane after a search

Searching can auto-switch the Custom Dictionary management pane to reveal a match, but clearing the query only restored widget visibility. Record the pane selection in the search baseline snapshot and restore it alongside the previous page so users return to the pane they chose.

* fix(dictionary): close the remaining custom dictionary file-contract gaps

- A --dictionary-file session override no longer imports a corrections
  file found beside the override into the standard config directory;
  the override scopes to terms only.
- Terms reads validate the whole file before yielding: corrupt UTF-8
  mid-file now yields no terms at all, matching the invalid status
  Settings reports instead of leaking a partial dictionary to
  recognition.
- The terms read is bounded by MAX_TERMS_FILE_BYTES (1 MiB); a larger
  file is invalid everywhere with a dedicated status instead of being
  read on every segment.

* fix(dictionary): keep usable terms prefix on oversized files and legacy corrections under overrides

* fix(dictionary): cap yielded terms and keep added terms visible on oversized files

* fix(dictionary): keep added terms visible without dropping scanner-owned lines

* fix(dictionary): keep oversized terms manageable and honor mid-search pane switches

- terms list gains a Show all / Show first 500 toggle; adding a term
  beyond the cap auto-expands so the new row renders with a Remove
  button instead of being invisible until the file is hand-edited
- a manual dictionary pane switch during an active search drops the
  saved pre-search pane so clearing the query no longer rolls back the
  user's newer selection
- annotate the new settings tests and type model_info so the parakeet
  branch stops adding fresh mypy errors

* fix(settings): do not auto-expand terms on add past display cap

Rebuilding up to thousands of term rows on the UI thread stalls the
dialog; the overflow button already shows the true count, so the added
term stays one click away.

* test(dictionary): stub segment_callbacks on bare manager for history dispatch

* fix(dictionary): stream oversized terms files when building the prompt

build_initial_prompt decoded the whole 1 MiB read window before every
transcription segment even though it only needs the first max_words
terms. _iter_terms_for_prompt now reads oversized files line by line and
stops at the prompt limit, keeping the same contract: complete leading
lines only, invalid UTF-8 inside the usable window yields nothing.

* fix(history): only the ended session's worker may extend its snippet

A late segment could extend the newest history entry regardless of which
session produced it, so a worker finishing after the next dictation
session leaked its text into that session's entry. Track the worker that
owned the ended session and require late segments to arrive on the same
thread before merging; anything else forms its own snippet.

* fix(speech): transcribe queued segments the reload worker never drained

The recognition worker can exit during stop_recognition's bounded
audio-thread join, so the released key's final buffer lands in the queue
after the worker is gone and was silently dropped when the reload
teardown replaced the queue. Drain whatever remains before swapping it
— except on capture failure or an explicit cancel, which still discard
the session's leftovers.

* fix(settings): build term rows in idle slices and pin hidden additions

Two problems shared one cause. Rebuilding up to 4096 rows synchronously
stalled the dialog, and a term appended past the 500-row cap reported
success while staying invisible. Rows now build in bounded GLib idle
slices guarded by a refresh token, and a saved term outside the visible
window is pinned to the top of the capped list until it disappears or
becomes visible on its own. Adding under "show all" scrolls to the new
term instead.

* test(engine): annotate new test method return types

* fix(ci): match a candidate row that leads the latest track block

snapcraft status normally prints candidate as a continuation line under
the latest track (channel in column 1). If candidate ever leads the
track block it rides the track line instead (channel in column 3), and
the resolver missed it and refused to promote. Match both layouts in
the resolve and the pre-release re-verify.

* fix(dictionary): bound prompt reads and scroll to landed terms

- _iter_terms_for_prompt now reads each line via readline() capped by the
  remaining byte window, so an overlong line can never pull more than
  MAX_TERMS_FILE_BYTES into memory
- the prompt path keeps decoding every complete line inside the usable
  window after the term limit is collected, so invalid UTF-8 anywhere in
  the window still yields no terms and matches the reader's invalid status
- under "show all" Settings scrolls to the index where an added term
  landed (prepended or appended), verified against the row's label,
  instead of always scrolling to the end

* fix(history,reload): keep late-segment ownership and drain stamped tuples

- Track session_workers_seen so a worker from an earlier session can
  never merge into a newer snippet while a session is open
- Unpack (segment, language) queue tuples in _transcribe_queued_segments;
  passing the tuple as audio raised TypeError and lost final speech
- Refuse 'delete that' when a manual pad edit blurred segment boundaries
- Translate term-row coordinates into the scrolled card's space so the
  scroll actually reaches rows below the fold
- Type _on_dictionary_pane_changed's varargs

* style: drop unneeded nonlocal for mutated worker set

---------

Co-authored-by: Graham <graham@thegeeky.ninja>
2026-09-30 02:33:49 -07:00

1602 lines
67 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.advanced_box.pack_start(self.gateway_embed_group", source_code)
self.assertNotIn("self.content_box.pack_start(self.gateway_embed_group", 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_whisper_model_ceils_fractional_ram(self):
"""~7.4 GiB must count as 8 GiB so the CPU path recommends small."""
from vocalinux.ui.settings_dialog import _get_recommended_whisper_model
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value = MagicMock(total=int(7.4 * (1024**3)))
torch_mock = MagicMock()
torch_mock.cuda.is_available.return_value = False
with patch.dict("sys.modules", {"psutil": mock_psutil, "torch": torch_mock}):
model, reason = _get_recommended_whisper_model()
self.assertEqual(model, "small")
self.assertIn("8GB", reason)
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)
# TinyDiarize is catalogued for the file-transcription flow but must
# never appear as a dictation size-bucket choice.
self.assertIn("small.en-tdrz", WHISPERCPP_MODEL_INFO)
from vocalinux.utils.whispercpp_model_info import NON_DICTATION_MODELS
self.assertIn("small.en-tdrz", NON_DICTATION_MODELS)
def test_faster_whisper_picker_lists_english_only_catalog_ids(self):
"""Settings must list the same .en ids the tray can persist."""
from vocalinux.ui.settings_dialog import ENGINE_MODELS, FASTER_WHISPER_MODEL_INFO
fw = ENGINE_MODELS["faster_whisper"]
for model_id in ("tiny.en", "base.en", "small.en", "medium.en"):
self.assertIn(model_id, fw)
self.assertIn(model_id, FASTER_WHISPER_MODEL_INFO)
self.assertIn("large-v3", fw)
self.assertNotIn("large-v3.en", fw)
self.assertNotIn("large-v3.en", FASTER_WHISPER_MODEL_INFO)
for model_id in fw:
self.assertIn(model_id, FASTER_WHISPER_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("Search the list", LANGUAGE_TOOLTIP)
self.assertNotIn("limit this list to English", 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"),
)
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_faster_whisper_recommendation_uses_language_for_english_variant(self):
"""English language nudges Faster Whisper recommendations to .en variants."""
from vocalinux.ui.settings_dialog import _recommended_faster_whisper_variant_for_language
self.assertEqual(
_recommended_faster_whisper_variant_for_language(
"small",
"mock hardware reason",
"en-us",
),
("small.en", "mock hardware reason"),
)
self.assertEqual(
_recommended_faster_whisper_variant_for_language(
"tiny",
"mock hardware reason",
"de",
),
("tiny", "mock hardware reason"),
)
self.assertEqual(
_recommended_faster_whisper_variant_for_language(
"large-v3",
"mock hardware reason",
"en-us",
),
("large-v3", "mock hardware reason"),
)
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")
self.assertEqual(_whispercpp_variant_for_language("small.en", "pl"), "small")
# 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_english_only_model_does_not_hide_other_languages(self):
"""Picking .en used to filter the language list, so Polish could not be chosen."""
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()
populate = source_code.split("def _populate_language_options")[1].split("\n def ")[0]
default = source_code.split("def _default_language_for_engine")[1].split("\n def ")[0]
self.assertNotIn("english_only_whispercpp", populate)
self.assertNotIn('lang_info.get("whisper") != "en"', populate)
self.assertNotIn("_is_selected_whispercpp_model_english_only", default)
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('"Unused downloads"', source_code)
self.assertIn("self.content_box.pack_start(self.unused_island", 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("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_unused_island_follows_nested_group_during_search(self):
"""The Unused downloads expander header must not leak next to unrelated hits."""
source = self._settings_source()
search_body = source.split("def _on_search_changed(self, entry)")[1].split("def ")[0]
self.assertIn("self.unused_island.set_visible", search_body)
self.assertIn("self.unused_models_group.get_visible()", search_body)
snapshot_body = source.split("def _snapshot_search_baseline")[1].split("def ")[0]
self.assertIn("self.unused_island", snapshot_body)
restore_body = source.split("def _restore_search_baseline")[1].split("def ")[0]
self.assertIn("widget.set_visible(visible)", restore_body)
self.assertIn('self.unused_models_group.title = "Unused downloads"', 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) -> None:
for name, title in [
("dictation", "Dictation"),
("dictionary", "Custom Dictionary"),
("model", "Speech Model"),
("audio", "Audio"),
("performance", "Performance"),
("application", "Application"),
("advanced", "Advanced"),
]:
self.assertIn(f'SettingsPage("{name}", "{title}"', self.source_code)
def test_custom_dictionary_page_has_both_accessible_subsections(self) -> None:
"""The dictionary page keeps terms and corrections distinct and accessible."""
body = self.source_code.split("def _build_dictionary_section")[1].split("\n def ")[0]
self.assertIn('title="Custom terms"', body)
self.assertIn('title="Transcript corrections"', body)
self.assertIn("Vocabulary bias works with Whisper, whisper.cpp, and Faster Whisper", body)
self.assertIn("self.dictionary_management_switcher = Gtk.StackSwitcher()", body)
self.assertIn(
"self.dictionary_tab.pack_start(self.dictionary_management_stack, True, True, 0)",
body,
)
self.assertIn('terms_scroller, "terms", "Custom terms"', body)
self.assertIn('corrections_scroller, "corrections", "Corrections"', body)
for accessible_name in [
"Custom terms file",
"Custom term",
"Heard phrase",
"Replacement text",
"Custom dictionary status",
]:
self.assertIn(accessible_name, body)
# Shared status lives on the page, not only in the Corrections stack child.
self.assertIn(
"self.dictionary_tab.pack_start(self.dictionary_feedback_label, False, False, 0)",
body,
)
switcher_pack = body.find(
"self.dictionary_tab.pack_start(self.dictionary_management_switcher"
)
feedback_pack = body.find("self.dictionary_tab.pack_start(self.dictionary_feedback_label")
stack_pack = body.find("self.dictionary_tab.pack_start(self.dictionary_management_stack")
self.assertLess(switcher_pack, feedback_pack)
self.assertLess(feedback_pack, stack_pack)
corrections_child = body[
body.find("corrections_group = PreferencesGroup") : body.find(
'corrections_scroller, "corrections"'
)
]
self.assertNotIn("dictionary_feedback_label", corrections_child)
def test_custom_dictionary_cards_preserve_rounded_bottom_corners(self) -> None:
"""Transparent list backgrounds do not cover the card's lower radius."""
self.assertIn(".preferences-group-list", self.source_code)
self.assertIn('add_class("preferences-group-list")', self.source_code)
self.assertIn("border-radius: 0 0 11px 11px", self.source_code)
def test_custom_dictionary_path_and_persistence_handlers_are_present(self) -> None:
"""Path chooser and failure feedback keep existing settings safe."""
self.assertIn('Gtk.FileChooserButton(title="Choose Terms File")', self.source_code)
self.assertIn("def _on_dictionary_terms_path_changed", self.source_code)
self.assertIn("set_terms_path", self.source_code)
self.assertIn("Could not save that custom terms path", self.source_code)
self.assertIn("add_term(term)", self.source_code)
self.assertIn("remove_term(term)", self.source_code)
def test_correction_add_validates_candidate_before_save(self) -> None:
"""Invalid replacements must be rejected before the list is rewritten."""
body = self.source_code.split("def _on_dictionary_add_correction")[1].split("\n def ")[0]
self.assertIn("normalize_corrections", body)
self.assertLess(body.find("normalize_corrections"), body.find("save_corrections"))
self.assertLess(body.find("normalize_corrections"), body.find("entries.append"))
def test_terms_list_builds_in_bounded_idle_slices_with_pinned_adds(self) -> None:
"""Large term files never build all their GTK rows synchronously.
The stall finding requires two guards to hold together: the row
build is sliced through GLib idle callbacks, and a saved term that
lands past the display cap is pinned so success is never reported
for an invisible term.
"""
self.assertIn("_TERMS_ROWS_PER_IDLE", self.source_code)
refresh_body = self.source_code.split("def _refresh_dictionary_ui")[1].split("\n def ")[
0
]
self.assertNotIn("for term in visible_terms:", refresh_body)
self.assertIn("self._build_term_rows(", refresh_body)
build_body = self.source_code.split("def _build_term_rows")[1].split("\n def ")[0]
self.assertIn("GLib.idle_add", build_body)
self.assertIn("_TERMS_ROWS_PER_IDLE", build_body)
self.assertIn("self._terms_build_token", build_body)
add_body = self.source_code.split("def _on_dictionary_add_term")[1].split("\n def ")[0]
self.assertIn("self._pinned_terms.append(term)", add_body)
self.assertIn("self._pinned_terms", self.source_code)
def test_show_all_add_scrolls_to_the_landed_term(self) -> None:
"""Under "show all" the rebuild scrolls to the new term's own row.
Oversized or yield-capped files prepend the term, so scrolling to
the bottom would hide it; the target is the term's index in the
refreshed list and is verified against the row's label.
"""
add_body = self.source_code.split("def _on_dictionary_add_term")[1].split("\n def ")[0]
self.assertIn("self._show_all_terms", add_body)
self.assertIn("self._scroll_terms_to_row", add_body)
self.assertIn("terms_now[new_index]", add_body)
finish_body = self.source_code.split("def _finish_term_rows")[1].split("\n def ")[0]
self.assertIn("self._scroll_terms_to_row", finish_body)
self.assertIn("get_row_at_index", finish_body)
self.assertIn("_term_row_text", finish_body)
self.assertIn("_scroll_terms_row_into_view", finish_body)
self.assertNotIn("_scroll_terms_to_end", 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_test_dictation_scrolled_window_has_no_shadow(self) -> None:
"""GTK3 ScrolledWindow default shadow is a left stripe on GNOME/Wayland (#847)."""
footer_body = self.source_code.split("def _build_sidebar_footer")[1].split("\n def ")[0]
scrolled_block = footer_body.split("scrolled_window = Gtk.ScrolledWindow()")[1].split(
"self.test_textview = Gtk.TextView()"
)[0]
self.assertIn("scrolled_window.set_shadow_type(Gtk.ShadowType.NONE)", scrolled_block)
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)
def test_sidebar_category_list_is_scrollable(self) -> None:
"""The category list sits in a ScrolledWindow so the dialog can shrink
vertically without clipping categories (#678)."""
class_body = self.source_code.split("class SettingsDialog(Gtk.Dialog):")[1]
init_body = class_body.split("def __init__")[1].split("\n def ")[0]
self.assertIn("sidebar_scroller = Gtk.ScrolledWindow()", init_body)
self.assertIn(
"sidebar_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)",
init_body,
)
self.assertIn("sidebar_scroller.set_shadow_type(Gtk.ShadowType.NONE)", init_body)
self.assertIn("sidebar_scroller.add(self.sidebar_listbox)", init_body)
self.assertIn("sidebar_box.pack_start(sidebar_scroller", init_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("Windows, unsigned beta", 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://vocawin.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 vocawin.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-windows", 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,
VOCAWIN_SITE_URL,
_can_open_url,
)
for url in (
VOCALINUX_SITE_URL,
VOCAHQ_SITE_URL,
VOCAMAC_SITE_URL,
VOCAWIN_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-windows", "Windows"),
("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()