# ABOUTME: Tests for the Moonlock application entry point. # ABOUTME: Covers logging setup, defensive window creation, and CSS error handling. import logging import sys from pathlib import Path from unittest.mock import patch, MagicMock import pytest @pytest.fixture(autouse=True) def _mock_gtk(monkeypatch): """Prevent GTK from requiring a display during test collection.""" mock_gi = MagicMock() mock_gtk = MagicMock() mock_gdk = MagicMock() mock_session_lock = MagicMock() # Pre-populate gi.repository with our mocks modules = { "gi": mock_gi, "gi.repository": MagicMock(Gtk=mock_gtk, Gdk=mock_gdk, Gtk4SessionLock=mock_session_lock), } with monkeypatch.context() as m: # Only patch missing/problematic modules if not already loaded for mod_name, mod in modules.items(): if mod_name not in sys.modules: m.setitem(sys.modules, mod_name, mod) yield def _import_main(): """Import main module lazily after GTK mocking is in place.""" from moonlock.main import MoonlockApp, _setup_logging return MoonlockApp, _setup_logging class TestSetupLogging: """Tests for the logging infrastructure.""" def test_setup_logging_adds_stderr_handler(self): """_setup_logging() should add a StreamHandler to the root logger.""" _, _setup_logging = _import_main() root = logging.getLogger() handlers_before = len(root.handlers) _setup_logging() assert len(root.handlers) > handlers_before # Clean up handlers we added for handler in root.handlers[handlers_before:]: root.removeHandler(handler) def test_setup_logging_sets_info_level(self): """_setup_logging() should set root logger to INFO level.""" _, _setup_logging = _import_main() root = logging.getLogger() original_level = root.level _setup_logging() assert root.level == logging.INFO # Restore root.setLevel(original_level) for handler in root.handlers[:]: root.removeHandler(handler) @patch("moonlock.main._LOG_DIR") @patch("logging.FileHandler") def test_setup_logging_adds_file_handler_when_dir_exists(self, mock_fh, mock_log_dir): """_setup_logging() should add a FileHandler when log directory exists.""" _, _setup_logging = _import_main() mock_log_dir.is_dir.return_value = True root = logging.getLogger() handlers_before = len(root.handlers) _setup_logging() mock_fh.assert_called_once() # Clean up for handler in root.handlers[handlers_before:]: root.removeHandler(handler) @patch("moonlock.main._LOG_DIR") def test_setup_logging_skips_file_handler_when_dir_missing(self, mock_log_dir): """_setup_logging() should not fail when log directory doesn't exist.""" _, _setup_logging = _import_main() mock_log_dir.is_dir.return_value = False root = logging.getLogger() handlers_before = len(root.handlers) _setup_logging() assert len(root.handlers) >= handlers_before # Clean up for handler in root.handlers[handlers_before:]: root.removeHandler(handler) class TestCssErrorHandling: """Tests for CSS loading error handling.""" @patch("moonlock.main.Gdk.Display.get_default") @patch("moonlock.main.Gtk.CssProvider") @patch("moonlock.main.files") def test_load_css_logs_error_on_exception(self, mock_files, mock_css_cls, mock_display): """CSS loading errors should be logged, not raised.""" MoonlockApp, _ = _import_main() mock_files.return_value.__truediv__ = MagicMock(return_value=Path("/nonexistent")) mock_css = MagicMock() mock_css.load_from_path.side_effect = Exception("CSS parse error") mock_css_cls.return_value = mock_css app = MoonlockApp.__new__(MoonlockApp) app._config = MagicMock() # Should not raise with patch("moonlock.main.logger") as mock_logger: app._load_css() mock_logger.exception.assert_called_once() class TestDefensiveWindowCreation: """Tests for defensive window creation in session lock mode.""" @patch("moonlock.main.LockscreenWindow") @patch("moonlock.main.FingerprintListener") @patch("moonlock.main.Gdk.Display.get_default") @patch("moonlock.main.Gtk4SessionLock") def test_single_window_failure_does_not_stop_other_windows( self, mock_session_lock, mock_display, mock_fp, mock_window_cls ): """If one window fails, others should still be created.""" MoonlockApp, _ = _import_main() app = MoonlockApp.__new__(MoonlockApp) app._config = MagicMock() app._windows = [] # Two monitors monitor1 = MagicMock() monitor2 = MagicMock() monitors = MagicMock() monitors.get_n_items.return_value = 2 monitors.get_item.side_effect = [monitor1, monitor2] mock_display.return_value.get_monitors.return_value = monitors lock_instance = MagicMock() mock_session_lock.Instance.new.return_value = lock_instance app._lock_instance = lock_instance # First window creation fails, second succeeds window_ok = MagicMock() mock_window_cls.side_effect = [Exception("GTK error"), window_ok] with patch("moonlock.main.logger"): app._activate_with_session_lock() # One window should have been created despite the first failure assert len(app._windows) == 1 @patch("moonlock.main.LockscreenWindow") @patch("moonlock.main.FingerprintListener") @patch("moonlock.main.Gdk.Display.get_default") @patch("moonlock.main.Gtk4SessionLock") def test_all_windows_fail_unlocks_session( self, mock_session_lock, mock_display, mock_fp, mock_window_cls ): """If ALL windows fail to create, session should be unlocked to prevent lockout.""" MoonlockApp, _ = _import_main() app = MoonlockApp.__new__(MoonlockApp) app._config = MagicMock() app._windows = [] # One monitor monitors = MagicMock() monitors.get_n_items.return_value = 1 monitors.get_item.return_value = MagicMock() mock_display.return_value.get_monitors.return_value = monitors lock_instance = MagicMock() mock_session_lock.Instance.new.return_value = lock_instance app._lock_instance = lock_instance # Window creation fails mock_window_cls.side_effect = Exception("GTK error") with patch("moonlock.main.logger"): app._activate_with_session_lock() # Session should have been unlocked to prevent permanent lockout lock_instance.unlock.assert_called_once() class TestCrashGuard: """Tests for the global exception handler that prevents lockout on crash.""" def test_crash_guard_unlocks_session_on_unhandled_exception(self): """sys.excepthook should unlock the session when an exception reaches it.""" MoonlockApp, _ = _import_main() from moonlock.main import _install_crash_guard app = MoonlockApp.__new__(MoonlockApp) app._lock_instance = MagicMock() original_hook = sys.excepthook _install_crash_guard(app) try: # Simulate an unhandled exception reaching the hook with patch("moonlock.main.logger"): sys.excepthook(RuntimeError, RuntimeError("segfault"), None) app._lock_instance.unlock.assert_called_once() finally: sys.excepthook = original_hook def test_crash_guard_survives_unlock_failure(self): """Crash guard should not raise even if unlock() itself fails.""" MoonlockApp, _ = _import_main() from moonlock.main import _install_crash_guard app = MoonlockApp.__new__(MoonlockApp) app._lock_instance = MagicMock() app._lock_instance.unlock.side_effect = Exception("protocol error") original_hook = sys.excepthook _install_crash_guard(app) try: with patch("moonlock.main.logger"): # Should not raise despite unlock failure sys.excepthook(RuntimeError, RuntimeError("crash"), None) finally: sys.excepthook = original_hook