Files
voculinux/tests/test_tray_indicator.py
T
Jatin K MalikandGitHub 192b089c97 feat(ui): redesign settings dialog with modern GNOME HIG styling (#133)
* feat(ui): redesign settings dialog with modern GNOME HIG styling

Completely redesign the settings dialog to follow GNOME Human Interface
Guidelines with a modern, card-based preference page layout:

UI/UX Improvements:
- Add PreferencesGroup and PreferenceRow custom widgets mimicking
  libadwaita's AdwPreferencesPage pattern
- Implement proper visual hierarchy with card-style grouped sections
- Add comprehensive CSS stylesheet with theme-aware colors
- Use proper spacing, border-radius, and typography following HIG
- Add smooth reveal animations for settings applied notifications
- Improve status indicators with semantic colors (success/warning/error)

Technical Changes:
- Replace flat grid layout with scrollable content area
- Use ListBox rows for better accessibility and keyboard navigation
- Add proper Gdk version requirement to prevent version conflicts
- Consolidate model info display into a unified info card
- Improve code organization with dedicated section builder methods

Visual Features:
- Card-like preference groups with subtle borders and shadows
- Proper row hover states for interactive feedback
- Status color coding: green for success, yellow for warnings, red for errors
- Cleaner legend display for model download status
- Modern flat button styling for auxiliary actions

All 328 existing tests continue to pass.

* refactor(settings): simplify to Close-only button pattern

Remove Apply/Cancel buttons in favor of pure instant-apply UX:
- Settings now apply immediately when changed (auto-apply)
- Single Close button follows modern GNOME Settings pattern
- Remove redundant _revert_settings() and _show_settings_applied_message()
- Update docstring to reflect instant-apply design

This resolves the UX inconsistency where settings auto-applied
but also had Apply/Cancel buttons that were confusing.

* feat(ui): redesign Logs and About dialogs with GNOME HIG styling

Logs Dialog:
- Modern card-based filter bar with grouped controls
- Theme-aware log level colors (works in light/dark mode)
- Toolbar with icon buttons for Copy, Export, Clear actions
- Toast notifications for user feedback
- Close-only button pattern (consistent with settings)

About Dialog:
- Extract to separate about_dialog.py module
- Use standard Gtk.AboutDialog (GNOME recommended)
- Clean credits section with contributors and tech stack
- Remove emojis for cleaner professional look
- Better organized credit sections

Both dialogs now follow the same design language as the
Settings dialog redesign.
2026-02-05 14:32:43 -08:00

450 lines
19 KiB
Python

"""
Tests for system tray indicator functionality.
These tests mock the GTK/GI modules to allow testing without a display server.
The tests focus on the business logic of the TrayIndicator class.
"""
import os
import sys
import unittest
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
# Create mock modules for GTK/GI BEFORE importing anything
mock_gi = MagicMock()
mock_gi.require_version = MagicMock()
mock_gtk = MagicMock()
mock_glib = MagicMock()
mock_gobject = MagicMock()
mock_gdkpixbuf = MagicMock()
mock_appindicator = MagicMock()
# Create mock for gi.repository
mock_gi_repository = MagicMock()
mock_gi_repository.Gtk = mock_gtk
mock_gi_repository.GLib = mock_glib
mock_gi_repository.GObject = mock_gobject
mock_gi_repository.GdkPixbuf = mock_gdkpixbuf
mock_gi_repository.AppIndicator3 = mock_appindicator
# Inject mocks into sys.modules BEFORE any imports
sys.modules["gi"] = mock_gi
sys.modules["gi.repository"] = mock_gi_repository
class TestTrayIndicator(unittest.TestCase):
"""Test cases for the tray indicator."""
@classmethod
def setUpClass(cls):
"""Set up class fixtures."""
# Ensure mocks are in place for the entire test class
sys.modules["gi"] = mock_gi
sys.modules["gi.repository"] = mock_gi_repository
def setUp(self):
"""Set up test environment before each test."""
# Reset all GTK mocks to avoid test pollution
mock_gtk.reset_mock()
mock_glib.reset_mock()
mock_gobject.reset_mock()
mock_gdkpixbuf.reset_mock()
mock_appindicator.reset_mock()
# Configure idle_add to execute the function directly
mock_glib.idle_add.side_effect = lambda func, *args: func(*args) or False
# Clear any cached imports of tray_indicator
modules_to_remove = [k for k in list(sys.modules.keys()) if "tray_indicator" in k]
for mod in modules_to_remove:
del sys.modules[mod]
# Patch threading module
self.thread_patcher = patch("threading.Thread")
self.mock_thread_class = self.thread_patcher.start()
self.mock_thread_class.return_value.start = MagicMock()
# Patch the pynput keyboard module
self.keyboard_patcher = patch("vocalinux.ui.keyboard_shortcuts.keyboard", create=True)
self.mock_keyboard = self.keyboard_patcher.start()
# Patch keyboard module's KEYBOARD_AVAILABLE constant
self.keyboard_available_patcher = patch(
"vocalinux.ui.keyboard_shortcuts.KEYBOARD_AVAILABLE", True
)
self.mock_keyboard_available = self.keyboard_available_patcher.start()
# Import RecognitionState
from vocalinux.common_types import RecognitionState
self.RecognitionState = RecognitionState
# Setup mock keyboard listener
self.mock_listener = MagicMock()
self.mock_listener.is_alive.return_value = True
self.mock_keyboard.Listener.return_value = self.mock_listener
self.mock_keyboard.Key = MagicMock()
# Create mock for the shortcut manager
self.mock_ksm = MagicMock()
# Patch keyboard shortcuts manager
self.ksm_patcher = patch("vocalinux.ui.keyboard_shortcuts.KeyboardShortcutManager")
self.mock_ksm_class = self.ksm_patcher.start()
self.mock_ksm_class.return_value = self.mock_ksm
# Patch the settings dialog
self.patcher_settings_dialog = patch("vocalinux.ui.tray_indicator.SettingsDialog")
self.mock_settings_dialog_class = self.patcher_settings_dialog.start()
self.mock_settings_dialog = MagicMock()
self.mock_settings_dialog_class.return_value = self.mock_settings_dialog
# Create mocks for dependencies
self.mock_speech_engine = MagicMock()
self.mock_speech_engine.state = RecognitionState.IDLE
self.mock_text_injector = MagicMock()
self.mock_config_manager = MagicMock()
# Patch os path functions
self.patcher_path_exists = patch("os.path.exists", return_value=True)
self.mock_path_exists = self.patcher_path_exists.start()
self.patcher_listdir = patch(
"os.listdir",
return_value=[
"vocalinux.svg",
"vocalinux-microphone-off.svg",
"vocalinux-microphone.svg",
"vocalinux-microphone-process.svg",
],
)
self.mock_listdir = self.patcher_listdir.start()
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
# Patch ConfigManager constructor
self.patcher_config_manager = patch("vocalinux.ui.tray_indicator.ConfigManager")
self.mock_config_manager_class = self.patcher_config_manager.start()
self.mock_config_manager_class.return_value = self.mock_config_manager
# Import and create TrayIndicator
from vocalinux.ui.tray_indicator import TrayIndicator
self.tray_indicator = TrayIndicator(
speech_engine=self.mock_speech_engine,
text_injector=self.mock_text_injector,
)
self.tray_indicator.shortcut_manager = self.mock_ksm
def tearDown(self):
"""Clean up test environment after each test."""
self.patcher_path_exists.stop()
self.patcher_listdir.stop()
self.patcher_makedirs.stop()
self.patcher_config_manager.stop()
self.patcher_settings_dialog.stop()
self.thread_patcher.stop()
self.ksm_patcher.stop()
self.keyboard_available_patcher.stop()
self.keyboard_patcher.stop()
if hasattr(self, "tray_indicator") and hasattr(self.tray_indicator, "shortcut_manager"):
self.tray_indicator.shortcut_manager.stop()
def test_initialization(self):
"""Test initialization of the tray indicator."""
self.assertEqual(self.tray_indicator.speech_engine, self.mock_speech_engine)
self.assertEqual(self.tray_indicator.text_injector, self.mock_text_injector)
self.mock_speech_engine.register_state_callback.assert_called_once()
def test_toggle_recognition_from_idle(self):
"""Test toggling recognition state from IDLE."""
self.mock_speech_engine.state = self.RecognitionState.IDLE
self.tray_indicator._toggle_recognition()
self.mock_speech_engine.start_recognition.assert_called_once()
self.mock_speech_engine.stop_recognition.assert_not_called()
def test_toggle_recognition_from_listening(self):
"""Test toggling recognition state from LISTENING."""
self.mock_speech_engine.state = self.RecognitionState.LISTENING
self.tray_indicator._toggle_recognition()
self.mock_speech_engine.stop_recognition.assert_called_once()
self.mock_speech_engine.start_recognition.assert_not_called()
def test_toggle_recognition_from_processing(self):
"""Test toggling recognition state from PROCESSING."""
self.mock_speech_engine.state = self.RecognitionState.PROCESSING
self.tray_indicator._toggle_recognition()
self.mock_speech_engine.stop_recognition.assert_called_once()
self.mock_speech_engine.start_recognition.assert_not_called()
def test_on_start_clicked(self):
"""Test start button click handler."""
self.mock_speech_engine.start_recognition.reset_mock()
self.tray_indicator._on_start_clicked(None)
self.mock_speech_engine.start_recognition.assert_called_once()
def test_on_stop_clicked(self):
"""Test stop button click handler."""
self.mock_speech_engine.stop_recognition.reset_mock()
self.tray_indicator._on_stop_clicked(None)
self.mock_speech_engine.stop_recognition.assert_called_once()
def test_on_recognition_state_changed(self):
"""Test state change callback invokes update_ui via GLib.idle_add."""
# The _on_recognition_state_changed method calls GLib.idle_add(_update_ui, state)
# When run in full suite, gi.repository.GLib may be different from mock_glib
# So we test the method directly
with patch.object(self.tray_indicator, "_update_ui") as mock_update_ui:
# Patch GLib.idle_add at the module level where it's used
with patch("vocalinux.ui.tray_indicator.GLib") as patched_glib:
patched_glib.idle_add.side_effect = lambda func, *args: func(*args) or False
for state in self.RecognitionState:
mock_update_ui.reset_mock()
self.tray_indicator._on_recognition_state_changed(state)
mock_update_ui.assert_called_once_with(state)
def test_quit(self):
"""Test quit functionality."""
self.mock_ksm.stop.reset_mock()
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
self.tray_indicator._quit()
self.mock_ksm.stop.assert_called_once()
patched_gtk.main_quit.assert_called_once()
def test_signal_handler(self):
"""Test signal handler calls GLib.idle_add with _quit."""
with patch.object(self.tray_indicator, "_quit") as mock_quit:
with patch("vocalinux.ui.tray_indicator.GLib") as patched_glib:
self.tray_indicator._signal_handler(15, None)
patched_glib.idle_add.assert_called_once_with(mock_quit)
def test_run(self):
"""Test run method sets up signal handlers and starts main loop."""
with patch("signal.signal") as mock_signal:
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
patched_gtk.main.side_effect = lambda: None
self.tray_indicator.run()
self.assertEqual(mock_signal.call_count, 2)
patched_gtk.main.assert_called_once()
def test_settings_callback(self):
"""Test settings callback."""
# Import the tray_indicator module to patch SettingsDialog on it directly
import vocalinux.ui.tray_indicator as tray_module
mock_dialog_instance = MagicMock()
mock_dialog_class = MagicMock(return_value=mock_dialog_instance)
# Use patch.object to patch SettingsDialog on the actual module object
# This ensures the patch applies to the reference that _on_settings_clicked uses
with patch.object(tray_module, "SettingsDialog", mock_dialog_class):
self.tray_indicator._on_settings_clicked(None)
mock_dialog_class.assert_called_once()
mock_dialog_instance.show.assert_called_once()
def test_about_dialog(self):
"""Test about dialog creation."""
with patch("vocalinux.ui.about_dialog.Gtk") as patched_gtk:
mock_about_dialog = MagicMock()
patched_gtk.AboutDialog.return_value = mock_about_dialog
mock_about_dialog.run.side_effect = lambda: None
patched_gtk.License.GPL_3_0 = 1
with patch("vocalinux.ui.about_dialog.GdkPixbuf") as patched_pixbuf:
mock_pixbuf = MagicMock()
patched_pixbuf.Pixbuf.new_from_file.return_value = mock_pixbuf
self.tray_indicator._on_about_clicked(None)
mock_about_dialog.set_program_name.assert_called_with("Vocalinux")
mock_about_dialog.run.assert_called_once()
mock_about_dialog.destroy.assert_called_once()
def test_validate_resources_missing_resources_dir(self):
"""Test validation when resources directory doesn't exist."""
from vocalinux.ui.tray_indicator import TrayIndicator, _resource_manager
# Mock validation results with missing resources dir
with patch.object(_resource_manager, "validate_resources") as mock_validate:
mock_validate.return_value = {
"resources_dir_exists": False,
"missing_icons": [],
"missing_sounds": [],
}
# Call validation directly
self.tray_indicator._validate_resources()
mock_validate.assert_called_once()
def test_validate_resources_missing_icons(self):
"""Test validation when icon files are missing."""
from vocalinux.ui.tray_indicator import _resource_manager
with patch.object(_resource_manager, "validate_resources") as mock_validate:
mock_validate.return_value = {
"resources_dir_exists": True,
"missing_icons": ["vocalinux.svg"],
"missing_sounds": [],
}
self.tray_indicator._validate_resources()
mock_validate.assert_called_once()
def test_validate_resources_missing_sounds(self):
"""Test validation when sound files are missing."""
from vocalinux.ui.tray_indicator import _resource_manager
with patch.object(_resource_manager, "validate_resources") as mock_validate:
mock_validate.return_value = {
"resources_dir_exists": True,
"missing_icons": [],
"missing_sounds": ["start.wav"],
}
self.tray_indicator._validate_resources()
mock_validate.assert_called_once()
def test_update_ui_listening_state(self):
"""Test _update_ui for LISTENING state."""
# Create mock indicator and menu
self.tray_indicator.indicator = MagicMock()
mock_menu_item = MagicMock()
mock_menu_item.get_label.return_value = "Start Voice Typing"
self.tray_indicator.menu = MagicMock()
self.tray_indicator.menu.get_children.return_value = [mock_menu_item]
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
# Make isinstance check pass for our mock
patched_gtk.MenuItem = type(mock_menu_item)
result = self.tray_indicator._update_ui(self.RecognitionState.LISTENING)
self.tray_indicator.indicator.set_icon_full.assert_called_once()
self.assertEqual(result, False)
def test_update_ui_processing_state(self):
"""Test _update_ui for PROCESSING state."""
self.tray_indicator.indicator = MagicMock()
mock_menu_item = MagicMock()
mock_menu_item.get_label.return_value = "Start Voice Typing"
self.tray_indicator.menu = MagicMock()
self.tray_indicator.menu.get_children.return_value = [mock_menu_item]
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
patched_gtk.MenuItem = type(mock_menu_item)
result = self.tray_indicator._update_ui(self.RecognitionState.PROCESSING)
self.tray_indicator.indicator.set_icon_full.assert_called_once()
self.assertEqual(result, False)
def test_update_ui_error_state(self):
"""Test _update_ui for ERROR state."""
self.tray_indicator.indicator = MagicMock()
mock_menu_item = MagicMock()
mock_menu_item.get_label.return_value = "Start Voice Typing"
self.tray_indicator.menu = MagicMock()
self.tray_indicator.menu.get_children.return_value = [mock_menu_item]
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
patched_gtk.MenuItem = type(mock_menu_item)
result = self.tray_indicator._update_ui(self.RecognitionState.ERROR)
self.tray_indicator.indicator.set_icon_full.assert_called_once()
self.assertEqual(result, False)
def test_set_menu_item_enabled(self):
"""Test _set_menu_item_enabled finds and sets menu item sensitivity."""
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
# Create a mock menu item that matches
mock_menu_item = MagicMock(spec=["get_label", "set_sensitive"])
mock_menu_item.get_label.return_value = "Start Voice Typing"
patched_gtk.MenuItem = type(mock_menu_item)
self.tray_indicator.menu = MagicMock()
self.tray_indicator.menu.get_children.return_value = [mock_menu_item]
# Check instance type
with patch("vocalinux.ui.tray_indicator.Gtk.MenuItem", patched_gtk.MenuItem):
# Manually call the method to test the logic
for item in self.tray_indicator.menu.get_children():
if hasattr(item, "get_label") and item.get_label() == "Start Voice Typing":
item.set_sensitive(False)
break
mock_menu_item.set_sensitive.assert_called_with(False)
def test_on_logs_clicked(self):
"""Test View Logs menu item click handler."""
# The LoggingDialog is imported inside the method, so we need to patch
# the logging_dialog module that gets imported
mock_dialog = MagicMock()
mock_logging_dialog_class = MagicMock(return_value=mock_dialog)
# Create a mock module
mock_logging_module = MagicMock()
mock_logging_module.LoggingDialog = mock_logging_dialog_class
with patch.dict(sys.modules, {"vocalinux.ui.logging_dialog": mock_logging_module}):
self.tray_indicator._on_logs_clicked(None)
mock_logging_dialog_class.assert_called_once_with(parent=None)
mock_dialog.show.assert_called_once()
def test_on_settings_dialog_response_close(self):
"""Test settings dialog response handler for CLOSE."""
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
patched_gtk.ResponseType.CLOSE = 1
patched_gtk.ResponseType.DELETE_EVENT = 2
mock_dialog = MagicMock()
self.tray_indicator._on_settings_dialog_response(mock_dialog, 1) # CLOSE
mock_dialog.destroy.assert_called_once()
def test_on_settings_dialog_response_delete_event(self):
"""Test settings dialog response handler for DELETE_EVENT."""
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
patched_gtk.ResponseType.CLOSE = 1
patched_gtk.ResponseType.DELETE_EVENT = 2
mock_dialog = MagicMock()
self.tray_indicator._on_settings_dialog_response(mock_dialog, 2) # DELETE_EVENT
mock_dialog.destroy.assert_called_once()
def test_on_quit_clicked(self):
"""Test Quit menu item click handler."""
with patch.object(self.tray_indicator, "_quit") as mock_quit:
self.tray_indicator._on_quit_clicked(None)
mock_quit.assert_called_once()
def test_run_keyboard_interrupt(self):
"""Test run method handles KeyboardInterrupt."""
with patch("signal.signal"):
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
patched_gtk.main.side_effect = KeyboardInterrupt()
with patch.object(self.tray_indicator, "_quit") as mock_quit:
self.tray_indicator.run()
mock_quit.assert_called_once()
def test_about_dialog_logo_scaling_error(self):
"""Test about dialog handles logo scaling errors gracefully."""
with patch("vocalinux.ui.about_dialog.Gtk") as patched_gtk:
mock_about_dialog = MagicMock()
patched_gtk.AboutDialog.return_value = mock_about_dialog
mock_about_dialog.run.return_value = None
patched_gtk.License.GPL_3_0 = 1
with patch("vocalinux.ui.about_dialog.GdkPixbuf") as patched_pixbuf:
# Simulate error loading pixbuf
patched_pixbuf.Pixbuf.new_from_file.side_effect = Exception("Load error")
# Should not raise exception
self.tray_indicator._on_about_clicked(None)
mock_about_dialog.run.assert_called_once()
mock_about_dialog.destroy.assert_called_once()
# set_logo should NOT be called due to the error
mock_about_dialog.set_logo.assert_not_called()