""" 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_with( self.tray_indicator._icon_keys["active"], "Microphone on" ) 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_with( self.tray_indicator._icon_keys["processing"], "Processing speech" ) 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_with( self.tray_indicator._icon_keys["default"], "Error" ) 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() def test_check_status_notifier_watcher_true_when_present(self): mock_proxy = MagicMock() mock_names_variant = MagicMock() mock_names_variant.unpack.return_value = ( ["org.freedesktop.DBus", "org.kde.StatusNotifierWatcher"], ) mock_proxy.call_sync.return_value = mock_names_variant with patch( "vocalinux.ui.tray_indicator.Gio.DBusProxy.new_for_bus_sync", return_value=mock_proxy, ): assert self.tray_indicator._check_status_notifier_watcher() is True def test_check_status_notifier_watcher_false_when_missing(self): mock_proxy = MagicMock() mock_names_variant = MagicMock() mock_names_variant.unpack.return_value = (["org.freedesktop.DBus"],) mock_proxy.call_sync.return_value = mock_names_variant with patch( "vocalinux.ui.tray_indicator.Gio.DBusProxy.new_for_bus_sync", return_value=mock_proxy, ): assert self.tray_indicator._check_status_notifier_watcher() is False def test_check_status_notifier_watcher_true_on_exception(self): with patch( "vocalinux.ui.tray_indicator.Gio.DBusProxy.new_for_bus_sync", side_effect=RuntimeError("dbus error"), ): assert self.tray_indicator._check_status_notifier_watcher() is True def test_init_indicator_missing_watcher_shows_dialog(self): with patch.object( self.tray_indicator, "_check_status_notifier_watcher", return_value=False, ): with patch.object(self.tray_indicator, "_show_missing_watcher_dialog") as mock_dialog: result = self.tray_indicator._init_indicator() self.assertEqual(result, False) mock_dialog.assert_called_once() def test_init_indicator_creation_failure_shows_error_dialog(self): with patch( "vocalinux.ui.tray_indicator.AppIndicator3.Indicator.new_with_path", side_effect=Exception("boom"), ): with patch.object( self.tray_indicator, "_show_appindicator_error_dialog", ) as mock_error_dialog: result = self.tray_indicator._init_indicator() self.assertEqual(result, False) mock_error_dialog.assert_called_once_with("boom") def test_init_indicator_creation_failure_without_prior_init_state(self): from vocalinux.ui.tray_indicator import TrayIndicator tray = TrayIndicator.__new__(TrayIndicator) tray.icon_paths = { "default": "/tmp/fake-off.svg", "active": "/tmp/fake-on.svg", "processing": "/tmp/fake-process.svg", } tray._icon_keys = dict(tray.icon_paths) tray._show_appindicator_error_dialog = MagicMock(return_value=False) with patch("vocalinux.ui.tray_indicator.os.path.exists", return_value=False): with patch( "vocalinux.ui.tray_indicator.AppIndicator3.Indicator.new_with_path", side_effect=Exception("fresh-boom"), ): with patch( "vocalinux.ui.tray_indicator.GLib.idle_add", side_effect=lambda func, *args: func(*args), ): result = TrayIndicator._init_indicator(tray) self.assertEqual(result, False) tray._show_appindicator_error_dialog.assert_called_once_with("fresh-boom") self.assertFalse(hasattr(tray, "indicator")) def test_update_ui_returns_false_when_indicator_missing(self): from vocalinux.common_types import RecognitionState if hasattr(self.tray_indicator, "indicator"): delattr(self.tray_indicator, "indicator") result = self.tray_indicator._update_ui(RecognitionState.IDLE) self.assertEqual(result, False) def test_set_menu_item_enabled_noop_when_menu_missing(self): if hasattr(self.tray_indicator, "menu"): delattr(self.tray_indicator, "menu") self.tray_indicator._set_menu_item_enabled("Start Voice Typing", True) class TestTrayIndicatorFlatpakIcons(unittest.TestCase): """Tray icon names must adapt to the Flatpak runtime. Inside a Flatpak the StatusNotifier host runs outside the sandbox and resolves icons from the exported ``-*`` theme, so the indicator must reference those names rather than the bundled ``vocalinux-*`` names. """ def setUp(self): sys.modules["gi"] = mock_gi sys.modules["gi.repository"] = mock_gi_repository for mod in [k for k in list(sys.modules.keys()) if "tray_indicator" in k]: del sys.modules[mod] def test_themed_icon_names_outside_flatpak(self): import vocalinux.ui.tray_indicator as tray with patch.object(tray, "FLATPAK_ID", None): names = tray._themed_icon_names() self.assertEqual(names["default"], "vocalinux-microphone-off") self.assertEqual(names["active"], "vocalinux-microphone") self.assertEqual(names["processing"], "vocalinux-microphone-process") def test_themed_icon_names_inside_flatpak(self): import vocalinux.ui.tray_indicator as tray with patch.object(tray, "FLATPAK_ID", "com.vocalinux.Vocalinux"): names = tray._themed_icon_names() self.assertEqual(names["default"], "com.vocalinux.Vocalinux-microphone-off") self.assertEqual(names["active"], "com.vocalinux.Vocalinux-microphone") self.assertEqual(names["processing"], "com.vocalinux.Vocalinux-microphone-process")