362 lines
16 KiB
Python
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'])
|