Files
kefu/wechat_rpa/test_qt_tray_lifecycle.py
T
2026-09-21 10:34:06 +08:00

362 lines
16 KiB
Python

"""Offscreen lifecycle regressions; no real tray, login, listener or user state."""
import queue
import sys
from types import SimpleNamespace
from unittest import TestCase, mock
from PySide6.QtCore import QEvent, Qt
from PySide6.QtWidgets import QApplication, QDialog, QMainWindow, QStackedWidget, QWidget
import wechat_gui_qt as qt
class TrayLifecycleTest(TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def setUp(self):
window = self.window = qt.MainWindow.__new__(qt.MainWindow)
QMainWindow.__init__(window)
window.resize(900, 700)
window.runtime_settings = {'minimize_on_close': True, 'clear_clipboard_on_exit': False}
window._running = False
window._thread = mock.Mock()
window._thread.is_alive.return_value = True
window._exit_requested = False
window._shutdown_complete = False
window._tray_hidden = False
window._close_minimized = False
window._capsule_minimize_pending = False
window._saved_geometry = None
window._was_maximized = False
window._portal_was_visible = False
window._console_was_visible = False
window._stdout_proxy = None
window._original_stdout = sys.stdout
window._tray_icon = mock.Mock()
window._tray_icon.isVisible.return_value = True
window.capsule = qt.CapsuleWindow()
window.stack = QStackedWidget()
page = QWidget()
window.stack.addWidget(page)
window.setCentralWidget(window.stack)
window.portal_page = SimpleNamespace(view=None)
window.console_shell = None
window.save_runtime_settings = mock.Mock()
window._ensure_system_tray = mock.Mock(return_value=True)
window.show()
self.app.processEvents()
def tearDown(self):
self.window.capsule.close()
self.window.hide()
self.window.deleteLater()
self.app.processEvents()
def test_stopped_window_closes_to_tray_without_stopping_worker(self):
self.window.close()
self.assertFalse(self.window.isVisible())
self.assertTrue(self.window._tray_hidden)
self.window._thread.stop.assert_not_called()
self.window.save_runtime_settings.assert_not_called()
self.assertFalse(self.window._shutdown_complete)
def test_running_window_closes_to_tray_without_capsule_or_stop(self):
self.window._running = True
self.window.close()
self.assertTrue(self.window._running)
self.assertFalse(self.window.capsule.isVisible())
self.window._thread.stop.assert_not_called()
def test_tray_setting_outweighs_legacy_keep_background_false(self):
self.window.runtime_settings['keep_background'] = False
self.window._running = True
self.window.close()
self.assertTrue(self.window._tray_hidden)
self.assertTrue(self.window._running)
def test_tray_restore_reopens_without_restarting_listener(self):
self.window._running = True
self.window.close()
self.window.restore_from_tray()
self.assertTrue(self.window.isVisible())
self.assertFalse(self.window.isMinimized())
self.assertFalse(self.window._tray_hidden)
self.assertTrue(self.window._running)
self.window._thread.stop.assert_not_called()
def test_tray_restore_preserves_maximized_window(self):
self.window.showMaximized()
self.app.processEvents()
self.window.close()
self.window.restore_from_tray()
self.assertTrue(self.window.isMaximized())
def test_repeated_activation_keeps_current_visible_geometry(self):
self.window._saved_geometry = b'stale'
self.window.restoreGeometry = mock.Mock()
self.window.restore_from_tray()
self.window.restoreGeometry.assert_not_called()
def test_running_status_does_not_reopen_capsule_after_tray_close(self):
self.window._running = True
self.window.close()
self.window.enter_capsule_mode()
self.assertFalse(self.window.capsule.isVisible())
self.assertFalse(self.window.isVisible())
def test_capsule_closes_to_tray_and_restores_original_geometry(self):
self.window._running = True
self.window.enter_capsule_mode()
geometry = self.window._saved_geometry
self.window.close()
self.assertFalse(self.window.capsule.isVisible())
self.assertEqual(self.window._saved_geometry, geometry)
self.window.restore_from_tray()
self.assertTrue(self.window.isVisible())
def test_no_tray_falls_back_to_taskbar_even_when_running(self):
self.window._running = True
self.window._ensure_system_tray.return_value = False
self.window.close()
self.app.processEvents()
self.assertTrue(self.window.isVisible())
self.assertTrue(self.window.isMinimized())
self.assertFalse(self.window.capsule.isVisible())
self.assertFalse(self.window._tray_hidden)
self.window._thread.stop.assert_not_called()
self.window.restore_from_tray()
self.assertTrue(self.window.isVisible())
self.assertFalse(self.window.isMinimized())
def test_taskbar_native_restore_clears_fallback_mode(self):
self.window._ensure_system_tray.return_value = False
self.window.close()
self.window.showNormal()
self.assertFalse(self.window._close_minimized)
def test_no_tray_fallback_restores_capsule_hidden_surfaces(self):
self.window.console_shell = QWidget(self.window)
self.window.console_shell.show()
self.window.enter_capsule_mode()
self.window._ensure_system_tray.return_value = False
self.window.close()
self.assertTrue(self.window.isMinimized())
self.assertFalse(self.window.console_shell.isHidden())
def test_about_to_quit_is_wired_to_cleanup(self):
self.window._shutdown_application = mock.Mock()
with mock.patch.object(sys, 'argv', ['test', '--qt-smoke-test']):
self.window._initialize_system_tray()
try:
self.app.aboutToQuit.emit()
self.window._shutdown_application.assert_called_once()
finally:
self.app.aboutToQuit.disconnect(self.window._shutdown_application)
self.app.removeEventFilter(self.window._quit_filter)
self.window._quit_filter.deleteLater()
def test_explicit_disable_close_to_tray_quits(self):
self.window.runtime_settings['minimize_on_close'] = False
self.window._shutdown_application = mock.Mock()
with mock.patch.object(QApplication, 'quit') as quit_app:
self.window.close()
self.window._shutdown_application.assert_called_once()
quit_app.assert_called_once()
self.window._ensure_system_tray.assert_not_called()
def test_forced_relogin_restores_tray_hidden_web_surface(self):
self.window.console_shell = QWidget(self.window)
self.window.console_shell.show()
self.window.close()
self.window._forced_logout_handled = False
self.window.desktop_session_timer = mock.Mock()
self.window.stop_monitoring = mock.Mock()
self.window.account_menu = mock.Mock()
self.window._check_desktop_session = mock.Mock()
self.window.persona_page = mock.Mock()
modules = {'backend_client': SimpleNamespace(desktop_logout=mock.Mock()),
'desktop_login': SimpleNamespace(ensure_desktop_login=mock.Mock(return_value={'nickname': 'test'}))}
with mock.patch.dict(sys.modules, modules):
with mock.patch.object(qt.QMessageBox, 'warning'), mock.patch.object(qt.QTimer, 'singleShot'):
self.window._handle_forced_desktop_logout('test logout')
self.assertFalse(self.window._tray_hidden)
self.assertTrue(self.window.isVisible())
self.assertFalse(self.window.console_shell.isHidden())
def test_system_logoff_does_not_close_to_tray(self):
self.window._shutdown_application = mock.Mock()
with mock.patch.object(QApplication, 'isSavingSession', return_value=True):
with mock.patch.object(QApplication, 'quit'):
self.window.close()
self.assertTrue(self.window._exit_requested)
self.window._shutdown_application.assert_called_once()
self.window._ensure_system_tray.assert_not_called()
def test_exit_menu_bypasses_close_to_tray(self):
self.window._shutdown_application = mock.Mock()
with mock.patch.object(QApplication, 'quit') as quit_app:
self.window.request_exit()
self.assertTrue(self.window._exit_requested)
self.window._shutdown_application.assert_called_once()
self.window._ensure_system_tray.assert_not_called()
self.assertTrue(quit_app.called)
def test_cleanup_stops_listener_and_backup_once(self):
backup = mock.Mock()
desk = mock.Mock()
self.window.queue_timer = mock.Mock()
self.window._stdout_proxy = mock.Mock()
proxy = self.window._stdout_proxy
with mock.patch.dict(sys.modules, {'archive_auto_backup': SimpleNamespace(stop_auto_backup=backup),
'dsh_agent': SimpleNamespace(close_desk=desk)}):
self.window._shutdown_application()
self.window._shutdown_application()
self.window._thread.stop.assert_called_once()
self.window.save_runtime_settings.assert_called_once()
self.window.queue_timer.stop.assert_called_once()
proxy.close.assert_called_once()
backup.assert_called_once()
desk.assert_called_once()
self.window._tray_icon.hide.assert_called_once()
def test_save_failure_does_not_prevent_worker_shutdown(self):
self.window.save_runtime_settings.side_effect = OSError('test disk failure')
with mock.patch.dict(sys.modules, {'archive_auto_backup': SimpleNamespace(stop_auto_backup=mock.Mock()),
'dsh_agent': SimpleNamespace(close_desk=mock.Mock())}):
self.window._shutdown_application()
self.window._thread.stop.assert_called_once()
def test_application_quit_marks_window_for_real_close(self):
event_filter = qt._ApplicationQuitFilter(self.window)
self.assertFalse(event_filter.eventFilter(self.app, QEvent(QEvent.Type.Quit)))
self.assertTrue(self.window._exit_requested)
def test_single_and_double_click_restore_but_context_menu_does_not(self):
self.window.restore_from_tray = mock.Mock()
reasons = qt.QSystemTrayIcon.ActivationReason
self.window._tray_activated(reasons.Context)
self.window.restore_from_tray.assert_not_called()
self.window._tray_activated(reasons.Trigger)
self.window._tray_activated(reasons.DoubleClick)
self.assertEqual(self.window.restore_from_tray.call_count, 2)
def test_tray_menu_has_explicit_open_and_exit_actions(self):
self.window._tray_icon = None
self.window.restore_from_tray = mock.Mock()
self.window.request_exit = mock.Mock()
with mock.patch.object(qt, 'QSystemTrayIcon') as tray_class:
tray_class.isSystemTrayAvailable.return_value = True
tray_class.return_value.isVisible.return_value = True
self.assertTrue(qt.MainWindow._ensure_system_tray(self.window))
actions = self.window._tray_menu.actions()
self.assertEqual([action.text() for action in actions if not action.isSeparator()],
['打开主界面', '退出程序'])
actions[0].trigger()
actions[-1].trigger()
self.window.restore_from_tray.assert_called_once()
self.window.request_exit.assert_called_once()
def test_console_interrupt_uses_real_exit_not_window_close(self):
with mock.patch.object(qt.signal, 'signal'), mock.patch.object(qt.QTimer, 'singleShot') as single:
shutdown = qt.install_console_shutdown_handler(self.app, self.window)
shutdown()
shutdown()
single.assert_called_once_with(0, self.window.request_exit)
class InstanceActivationTest(TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def test_activation_waits_until_ui_exists(self):
app = SimpleNamespace(activeModalWidget=lambda: None, activeWindow=lambda: None,
topLevelWidgets=lambda: [])
guard = mock.Mock()
qt._consume_instance_activation(app, guard)
guard.consume_activation.assert_not_called()
def test_login_modal_activated_before_main_window(self):
dialog = QDialog()
self.addCleanup(dialog.deleteLater)
dialog.showMinimized()
app = SimpleNamespace(activeModalWidget=lambda: dialog)
window = mock.Mock()
guard = mock.Mock()
guard.consume_activation.return_value = True
qt._consume_instance_activation(app, guard, window)
self.assertTrue(dialog.isVisible())
self.assertFalse(dialog.isMinimized())
window.restore_from_tray.assert_not_called()
dialog.hide()
def test_no_signal_does_not_change_window(self):
dialog = QDialog()
self.addCleanup(dialog.deleteLater)
dialog.showMinimized()
app = SimpleNamespace(activeModalWidget=lambda: dialog)
guard = mock.Mock()
guard.consume_activation.return_value = False
qt._consume_instance_activation(app, guard)
self.assertTrue(dialog.isMinimized())
dialog.hide()
def _main(self, *, primary=True, exception=None, flags=(), session_error=None):
guard = mock.Mock()
guard.acquire.return_value = primary
guard.acquire.side_effect = exception
constructor = mock.Mock(return_value=guard)
session = mock.Mock(side_effect=session_error)
with mock.patch.dict(sys.modules, {'desktop_instance': SimpleNamespace(DesktopInstanceGuard=constructor)}), \
mock.patch.object(sys, 'argv', ['wechat_gui.py', *flags]), \
mock.patch.object(qt, '_run_desktop_session', session), \
mock.patch.object(qt.QMessageBox, 'critical') as error:
try:
qt.main()
except ValueError:
pass
return guard, constructor, session, error
def test_secondary_does_not_enter_login_or_construct_main_window(self):
guard, _, session, _ = self._main(primary=False)
session.assert_not_called()
guard.request_activation.assert_called_once()
guard.close.assert_called_once()
def test_primary_enters_session_and_releases_guard(self):
guard, _, session, _ = self._main()
session.assert_called_once()
guard.request_activation.assert_not_called()
guard.close.assert_called_once()
def test_guard_failure_never_starts_unguarded_client(self):
guard, _, session, error = self._main(exception=OSError('mock unavailable'))
session.assert_not_called()
error.assert_called_once()
guard.close.assert_called_once()
def test_guard_constructor_failure_is_reported_without_login(self):
constructor = mock.Mock(side_effect=OSError('mock token unavailable'))
with mock.patch.dict(sys.modules, {'desktop_instance': SimpleNamespace(DesktopInstanceGuard=constructor)}):
with mock.patch.object(sys, 'argv', ['wechat_gui.py']), mock.patch.object(qt, '_run_desktop_session') as session:
with mock.patch.object(qt.QMessageBox, 'critical') as error:
qt.main()
error.assert_called_once()
session.assert_not_called()
def test_session_exception_releases_guard(self):
guard, _, _, _ = self._main(session_error=ValueError('test'))
guard.close.assert_called_once()
def test_smoke_test_does_not_contact_real_instance(self):
guard, constructor, session, _ = self._main(flags=['--qt-smoke-test'])
constructor.assert_not_called()
guard.acquire.assert_not_called()
session.assert_called_once()
def test_packaging_selfcheck_does_not_contact_real_instance(self):
with mock.patch.object(qt, 'run_packaging_self_check', return_value=0):
with self.assertRaises(SystemExit):
self._main(flags=['--packaging-self-check'])