Refine application dialog presentation lifecycle

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-23 07:27:38 +08:00
parent 97ee907e8c
commit f4a9410707
9 changed files with 463 additions and 64 deletions
+2 -4
View File
@@ -133,6 +133,8 @@ def importURIs(*uris, failureCallback: Union[Callable[[], None], None] = None):
class ImportURIsProgressDialog(AppQTransientDialog):
"""Present progress and cancellation controls for import ur is."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(420, 150)
def __init__(
self,
uris: Tuple[str, ...],
@@ -182,10 +184,6 @@ class ImportURIsProgressDialog(AppQTransientDialog):
self.updateStatus()
def setWidthAndHeight(self):
"""Apply the default size for the import ur is progress dialog."""
self.resize(420, 150)
def open(self):
"""Open the import ur is progress dialog asynchronously."""
result = super().open()
+12 -24
View File
@@ -134,6 +134,8 @@ def routingObjectFromProfile(routingProfile: dict):
class RoutingPreviewDialog(AppQTransientDialog):
"""Present the routing preview dialog."""
FIXED_DIALOG_SIZE = QtCore.QSize(400, int(400 * GOLDEN_RATIO))
def __init__(self, routingProfile: dict, parent=None):
"""Initialize the RoutingPreviewDialog."""
super().__init__(parent)
@@ -153,14 +155,12 @@ class RoutingPreviewDialog(AppQTransientDialog):
self.setLayout(layout)
def setWidthAndHeight(self):
"""Apply the default size for the routing preview dialog."""
self.setFixedSize(400, int(400 * GOLDEN_RATIO))
class RoutingTextEditDialog(AppQTransientDialog):
"""Present the routing text edit dialog."""
FIXED_DIALOG_SIZE = QtCore.QSize(760, 470)
def __init__(self, text='', parent=None):
"""Initialize the RoutingTextEditDialog."""
super().__init__(parent)
@@ -185,10 +185,6 @@ class RoutingTextEditDialog(AppQTransientDialog):
self.setLayout(layout)
def setWidthAndHeight(self):
"""Apply the default size for the routing text edit dialog."""
self.setFixedSize(760, 470)
def text(self):
"""Return the text value."""
return self.textEdit.toPlainText()
@@ -351,6 +347,8 @@ class RoutingProfilesModel(QtCore.QAbstractTableModel):
class RoutingRuleEditDialog(AppQTransientDialog):
"""Present the routing rule edit dialog."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(int(800 * GOLDEN_RATIO), 800)
MatchInputHeight = 72
ShortInputWidth = 240
@@ -536,10 +534,6 @@ class RoutingRuleEditDialog(AppQTransientDialog):
self.setLayout(layout)
def setWidthAndHeight(self):
"""Apply the default size for the routing rule edit dialog."""
self.resize(int(800 * GOLDEN_RATIO), 800)
def routingRule(self):
"""Return the routing rule value used by the routing rule edit dialog."""
rule = {
@@ -580,6 +574,8 @@ class RoutingRuleEditDialog(AppQTransientDialog):
class RoutingRemarkEditDialog(AppQTransientDialog):
"""Present the routing remark edit dialog."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(420, 120)
def __init__(self, remark: str, parent=None):
"""Initialize the RoutingRemarkEditDialog."""
super().__init__(parent)
@@ -604,10 +600,6 @@ class RoutingRemarkEditDialog(AppQTransientDialog):
self.setLayout(layout)
def setWidthAndHeight(self):
"""Apply the default size for the routing remark edit dialog."""
self.resize(420, 120)
def remark(self):
"""Return the remark value used by the routing remark edit dialog."""
return self.remarkEdit.text().strip()
@@ -616,6 +608,8 @@ class RoutingRemarkEditDialog(AppQTransientDialog):
class RoutingProfileEditDialog(AppQTransientDialog):
"""Present the routing profile edit dialog."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(460, 160)
def __init__(self, parent=None):
"""Initialize the RoutingProfileEditDialog."""
super().__init__(parent)
@@ -653,10 +647,6 @@ class RoutingProfileEditDialog(AppQTransientDialog):
self.setLayout(layout)
def setWidthAndHeight(self):
"""Apply the default size for the routing profile edit dialog."""
self.resize(460, 160)
def routing(self):
"""Return the routing value used by the routing profile edit dialog."""
return {
@@ -751,6 +741,8 @@ class RoutingRulesListView(AppQListView):
class RoutingRulesDialog(AppQTransientDialog):
"""Present the routing rules dialog."""
FIXED_DIALOG_SIZE = QtCore.QSize(760, 470)
def __init__(self, routing: dict, parent=None):
"""Initialize the RoutingRulesDialog."""
super().__init__(parent)
@@ -804,10 +796,6 @@ class RoutingRulesDialog(AppQTransientDialog):
self.setLayout(layout)
def setWidthAndHeight(self):
"""Apply the default size for the routing rules dialog."""
self.setFixedSize(760, 470)
def addRule(self):
"""Add rule."""
rule = {'type': 'field', 'outboundTag': 'proxy', 'ruleTag': 'New Rule'}
+4
View File
@@ -22,6 +22,10 @@ Use the `manage-qt-pyside6-lifetimes` skill for any Qt ownership or lifecycle ch
## Ownership and destruction
- `AppQDialog` owns platform-neutral presentation geometry: simple subclasses declare `DEFAULT_DIALOG_SIZE` or
`FIXED_DIALOG_SIZE`; procedural `prepareInitialGeometry()` runs only at first presentation after subclass construction.
Do not call subclass geometry hooks from constructors or manipulate private first-show state. Dialogs retain the
established center-on-each-presentation behavior unless a specialized class explicitly owns another centering policy.
- `AppQDialog` provides an asynchronous open-dialog registry. `AppQMessageBox` has an additional registry cleanup layer;
it is redundant but harmless. Keep both registries balanced if either implementation changes.
- `AppQTransientDialog` is delete-on-close. Connect completion before `open()` and never access it after destruction.
+47 -22
View File
@@ -321,6 +321,11 @@ class AppQComboBox(Mixins.QTranslatable, QComboBox):
class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
"""Present the app Qt dialog."""
DEFAULT_DIALOG_SIZE, FIXED_DIALOG_SIZE = (
QtCore.QSize(),
QtCore.QSize(),
)
_openDialogs = {}
@staticmethod
@@ -337,7 +342,7 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
# ID reused by a newer dialog must not let the older destroyed signal
# evict that newer dialog from the asynchronous lifetime registry.
self._lifetimeKey = object()
self._firstShowPending = True
self._initialGeometryPrepared = False
# Do not store a nested closure that captures this dialog on the dialog
# itself. Such a self-cycle delays wrapper collection and is especially
@@ -350,18 +355,30 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
self.finished.connect(release)
self.destroyed.connect(release)
if PLATFORM != 'Darwin':
self.setWidthAndHeight()
self.setWindowIcon(AppHue.currentWindowIcon())
def setWidthAndHeight(self):
"""Apply the default size for the app Qt dialog."""
pass
def prepareInitialGeometry(self):
"""Apply declarative geometry before the first native presentation."""
if self.FIXED_DIALOG_SIZE.isValid():
self.setFixedSize(self.FIXED_DIALOG_SIZE)
elif self.DEFAULT_DIALOG_SIZE.isValid():
self.resize(self.DEFAULT_DIALOG_SIZE)
def _prepareInitialGeometry(self):
"""Prepare initial geometry exactly once after subclass construction."""
if self._initialGeometryPrepared:
return
self.prepareInitialGeometry()
self._initialGeometryPrepared = True
def centerForPresentation(self):
"""Center this dialog after each native presentation."""
moveToCenter(self)
def exec(self):
"""Show and execute the app Qt dialog modally."""
self.show()
"""Prepare and execute the app Qt dialog modally."""
self._prepareInitialGeometry()
return super().exec()
@@ -371,7 +388,7 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
AppQDialog._openDialogs[key] = self
try:
self.show()
self._prepareInitialGeometry()
return super().open()
except Exception:
@@ -382,14 +399,16 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
raise
def show(self):
"""Show and position the app Qt dialog."""
"""Prepare and show the app Qt dialog."""
self._prepareInitialGeometry()
super().show()
if PLATFORM == 'Darwin' and self._firstShowPending:
self._firstShowPending = False
self.setWidthAndHeight()
def showEvent(self, event):
"""Center the dialog after each native show on every platform."""
super().showEvent(event)
moveToCenter(self)
self.centerForPresentation()
def retranslate(self):
"""Refresh translated text for the app Qt dialog."""
@@ -1740,19 +1759,25 @@ class AppQMessageBox(AppQTransientDialog):
return self
def show(self):
"""Show the app q message box."""
def centerForPresentation(self):
"""Center the message box over its owning widget."""
self.moveToCenter()
def _prepareForPresentation(self):
"""Refresh adaptive content and owner mask before each presentation."""
self._ensureButtons()
self._updateDialogSize()
self._showWindowMask()
def show(self):
"""Prepare and show the app q message box."""
self._prepareForPresentation()
QDialog.show(self)
self.moveToCenter()
def exec(self):
"""Show and execute the app q message box modally."""
self.show()
"""Prepare and execute the app q message box modally."""
self._prepareForPresentation()
return QDialog.exec(self)
@@ -1762,7 +1787,7 @@ class AppQMessageBox(AppQTransientDialog):
AppQMessageBox._openMessageBoxes[key] = self
try:
self.show()
self._prepareForPresentation()
return QDialog.open(self)
except Exception:
+2 -4
View File
@@ -644,6 +644,8 @@ class DownloadSpeedTestScheduler(QtCore.QObject):
class DeleteServersProgressDialog(AppQTransientDialog):
"""Present progress and cancellation controls for delete servers."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(420, 150)
def __init__(self, table, indexes, showTrayMessage=True, parent=None):
"""Initialize the DeleteServersProgressDialog."""
super().__init__(parent)
@@ -690,10 +692,6 @@ class DeleteServersProgressDialog(AppQTransientDialog):
self.updateStatus()
def setWidthAndHeight(self):
"""Apply the default size for the delete servers progress dialog."""
self.resize(420, 150)
def open(self):
"""Open the delete servers progress dialog asynchronously."""
self.spinner.start()
+2 -4
View File
@@ -39,6 +39,8 @@ registerAppSettings('CustomNetworkConnectivityTestURL')
class NetworkTestDialog(AppQDialog):
"""Present the GUI customize network test dialog."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(656, 180)
def __init__(self, *args, **kwargs):
"""Initialize the network test settings dialog."""
super().__init__(*args, **kwargs)
@@ -109,10 +111,6 @@ class NetworkTestDialog(AppQDialog):
"""Restore the default connectivity-test URL."""
self.connectivityEdit.setText(NETWORK_CONNECTIVITY_TEST_URL)
def setWidthAndHeight(self):
"""Apply the default size for the GUI customize network test dialog."""
self.resize(656, 180)
def handleResultCode(self, code):
"""Handle result code."""
for widget, info, settingsName, reset in [
+2 -4
View File
@@ -36,6 +36,8 @@ logger = logging.getLogger(__name__)
class ProxyBypassDialog(AppQDialog):
"""Present the GUI customize proxy bypass dialog."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(656, 125)
def __init__(self, *args, **kwargs):
"""Initialize the proxy bypass settings dialog."""
super().__init__(*args, **kwargs)
@@ -80,10 +82,6 @@ class ProxyBypassDialog(AppQDialog):
self.finished.connect(self.handleResultCode)
def setWidthAndHeight(self):
"""Apply the default size for the GUI customize proxy bypass dialog."""
self.resize(656, 125)
def handleResultCode(self, code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted):
+3 -2
View File
@@ -28,6 +28,7 @@ clients, or real proxy cores.
| Frozenlib state helpers and mocked platform-operation boundaries | `test_frozenlib.py` |
| Settings sandbox and navigation overlay behavior | `test_isolation_and_navigation.py` |
| AppQMainWindow lifecycle, subclass policies, geometry restoration, and migration | `test_main_window_geometry.py` |
| AppQDialog first-presentation geometry, native show paths, centering, and async lifetime | `test_dialog_geometry.py` |
| Editor mappings, lazy log rendering, routing/message-box/connection UI | `test_ui_behavior.py` |
| Direct Qt ownership and destruction across independent UI families | `test_qt_lifetime.py` |
| Batched real/probe Qt object, handle, Python allocation, and RSS trends | `test_qt_stress.py` |
@@ -77,7 +78,7 @@ Then run the desired test tier.
python -m unittest discover -s tests -v
# Regular logic, persistence, plugin, controller, codec, and UI regressions
python -m unittest tests.test_interface tests.test_models_and_services tests.test_architecture_refactors tests.test_plugin_architecture tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_ui_behavior -v
python -m unittest tests.test_interface tests.test_models_and_services tests.test_architecture_refactors tests.test_plugin_architecture tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_dialog_geometry tests.test_ui_behavior -v
# Direct Qt/process integration and destruction/lifetime checks
python -m unittest tests.test_external_core tests.test_qt_lifetime -v
@@ -86,7 +87,7 @@ python -m unittest tests.test_external_core tests.test_qt_lifetime -v
python -m unittest tests.test_qt_stress tests.test_process_stress -v
# Shared-state order-independence spot check
python -m unittest tests.test_ui_behavior tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_plugin_architecture tests.test_architecture_refactors tests.test_models_and_services tests.test_interface -v
python -m unittest tests.test_ui_behavior tests.test_dialog_geometry tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_plugin_architecture tests.test_architecture_refactors tests.test_models_and_services tests.test_interface -v
python -m unittest discover -s tests -v
```
+389
View File
@@ -0,0 +1,389 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Protect the platform-neutral application dialog presentation lifecycle."""
from __future__ import annotations
from Furious.Backends.Xray.RoutingWindow import (
RoutingRemarkEditDialog,
RoutingTextEditDialog,
)
from Furious.Qt import (
AppQDialog,
AppQMessageBox,
AppQTransientDialog,
)
from PySide6 import QtCore
from PySide6.QtWidgets import QWidget
from tests.support import (
application,
collectAtBoundary,
processQtEvents,
waitFor,
)
from unittest import mock
import unittest
import weakref
class LifecycleProbeDialog(AppQDialog):
"""Record the ordering of geometry preparation and native presentation."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(321, 123)
def __init__(self):
"""Initialize state that the geometry hook may safely consume."""
self.constructed = False
self.events = []
super().__init__()
self.constructed = True
def prepareInitialGeometry(self):
"""Record geometry preparation preconditions."""
self.events.append(
(
'prepare',
self.constructed,
self.isVisible(),
)
)
super().prepareInitialGeometry()
def showEvent(self, event):
"""Record each native presentation."""
self.events.append(('show', self.size()))
super().showEvent(event)
class FixedProbeDialog(AppQDialog):
"""Expose the declarative fixed-size policy."""
FIXED_DIALOG_SIZE = QtCore.QSize(360, 180)
class FailingProbeDialog(AppQDialog):
"""Raise at the geometry boundary to verify open-registry cleanup."""
def prepareInitialGeometry(self):
"""Reject presentation before Qt shows the dialog."""
raise RuntimeError('fixture geometry failure')
class TransientProbeDialog(AppQTransientDialog):
"""Expose one-shot deletion with declarative geometry."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(300, 150)
class CountingMessageBox(AppQMessageBox):
"""Publish native show events outside the delete-on-close wrapper."""
def __init__(self, showEvents, *args, **kwargs):
"""Retain the external event list used after native deletion."""
self._showEvents = showEvents
super().__init__(*args, **kwargs)
def showEvent(self, event):
"""Record one native presentation before base centering."""
self._showEvents.append(True)
super().showEvent(event)
class DialogGeometryTest(unittest.TestCase):
"""Verify sizing, centering, presentation, and lifetime as one contract."""
@classmethod
def setUpClass(cls):
"""Create the one QApplication used by the suite."""
application()
def tearDown(self):
"""Drain deferred deletion and require both async registries to settle."""
collectAtBoundary()
self.assertEqual(AppQDialog._openDialogs, {})
self.assertEqual(AppQMessageBox._openMessageBoxes, {})
def dispose(self, dialog):
"""Destroy a reusable fixture after its assertion scope."""
if dialog.isVisible():
dialog.close()
dialog.deleteLater()
collectAtBoundary()
def testConstructorDoesNotCallSubclassGeometryHook(self):
"""Wait until complete subclass construction before geometry preparation."""
dialog = LifecycleProbeDialog()
self.assertEqual(dialog.events, [])
self.assertFalse(dialog._initialGeometryPrepared)
dialog.show()
processQtEvents()
self.assertEqual(dialog.events[0], ('prepare', True, False))
self.assertEqual(dialog.events[1], ('show', dialog.DEFAULT_DIALOG_SIZE))
self.assertTrue(dialog._initialGeometryPrepared)
self.dispose(dialog)
def testRepeatedShowKeepsLiveSizeAndCentersEveryPresentation(self):
"""Prepare once while preserving the established per-show centering UX."""
dialog = LifecycleProbeDialog()
with mock.patch('Furious.Qt.QtWidgets.moveToCenter') as center:
dialog.show()
processQtEvents()
dialog.hide()
processQtEvents()
liveSize = QtCore.QSize(444, 222)
dialog.resize(liveSize)
dialog.show()
processQtEvents()
self.assertEqual(
[event for event in dialog.events if event[0] == 'prepare'],
[('prepare', True, False)],
)
self.assertEqual(
len([event for event in dialog.events if event[0] == 'show']),
2,
)
self.assertEqual(dialog.size(), liveSize)
self.assertEqual(center.call_count, 2)
self.dispose(dialog)
def testOpenPresentsOnceAndBalancesLifetimeRegistry(self):
"""Let QDialog.open perform the sole native show and release its wrapper."""
dialog = LifecycleProbeDialog()
key = dialog._lifetimeKey
dialog.open()
processQtEvents()
self.assertEqual(
len([event for event in dialog.events if event[0] == 'show']),
1,
)
self.assertIn(key, AppQDialog._openDialogs)
dialog.reject()
processQtEvents()
self.assertNotIn(key, AppQDialog._openDialogs)
self.dispose(dialog)
def testExecPresentsOnceWithPreparedGeometry(self):
"""Let QDialog.exec perform the sole blocking native presentation."""
dialog = LifecycleProbeDialog()
QtCore.QTimer.singleShot(0, dialog.accept)
result = dialog.exec()
self.assertEqual(
result,
int(AppQDialog.DialogCode.Accepted),
)
self.assertEqual(
len([event for event in dialog.events if event[0] == 'prepare']),
1,
)
self.assertEqual(
len([event for event in dialog.events if event[0] == 'show']),
1,
)
self.dispose(dialog)
def testOpenGeometryFailureDoesNotShowOrRetainDialog(self):
"""Release async ownership when initial geometry preparation raises."""
dialog = FailingProbeDialog()
key = dialog._lifetimeKey
with self.assertRaisesRegex(RuntimeError, 'fixture geometry failure'):
dialog.open()
self.assertFalse(dialog.isVisible())
self.assertFalse(dialog._initialGeometryPrepared)
self.assertNotIn(key, AppQDialog._openDialogs)
self.dispose(dialog)
def testDeclarativeFixedSizeIsAppliedAtFirstPresentation(self):
"""Apply both fixed dimensions without constructor-time virtual calls."""
dialog = FixedProbeDialog()
dialog.show()
processQtEvents()
self.assertEqual(dialog.size(), dialog.FIXED_DIALOG_SIZE)
self.assertEqual(dialog.minimumSize(), dialog.FIXED_DIALOG_SIZE)
self.assertEqual(dialog.maximumSize(), dialog.FIXED_DIALOG_SIZE)
self.dispose(dialog)
def testTransientDialogDeletesAfterAsyncCompletion(self):
"""Delete the Qt object and release the Python wrapper after open/accept."""
dialog = TransientProbeDialog()
reference = weakref.ref(dialog)
key = dialog._lifetimeKey
dialog.open()
processQtEvents()
dialog.accept()
del dialog
collectAtBoundary()
self.assertTrue(waitFor(lambda: reference() is None))
self.assertNotIn(key, AppQDialog._openDialogs)
def testRepresentativeDialogsKeepTheirProductDimensions(self):
"""Preserve one resizable and one fixed real dialog size."""
remarkDialog = RoutingRemarkEditDialog('Fixture')
remarkDialog.show()
processQtEvents()
self.assertEqual(
remarkDialog.size(),
RoutingRemarkEditDialog.DEFAULT_DIALOG_SIZE,
)
textDialog = RoutingTextEditDialog('Fixture')
textDialog.show()
processQtEvents()
self.assertEqual(
textDialog.size(),
RoutingTextEditDialog.FIXED_DIALOG_SIZE,
)
self.assertEqual(
textDialog.minimumSize(),
RoutingTextEditDialog.FIXED_DIALOG_SIZE,
)
remarkDialog.close()
textDialog.close()
collectAtBoundary()
def testMessageBoxOpenAndExecEachPresentOnce(self):
"""Keep specialized async and sync paths to one native show event."""
owner = QWidget()
owner.resize(800, 600)
owner.show()
openEvents = []
openBox = CountingMessageBox(
openEvents,
parent=owner,
text='Async fixture',
buttons=AppQMessageBox.StandardButton.Ok,
)
key = openBox._lifetimeKey
openBox.open()
processQtEvents()
self.assertEqual(openEvents, [True])
self.assertIn(key, AppQMessageBox._openMessageBoxes)
openBox.accept()
collectAtBoundary()
self.assertNotIn(key, AppQMessageBox._openMessageBoxes)
execEvents = []
execBox = CountingMessageBox(
execEvents,
parent=owner,
text='Sync fixture',
buttons=AppQMessageBox.StandardButton.Ok,
)
QtCore.QTimer.singleShot(
0,
execBox.button(AppQMessageBox.StandardButton.Ok).click,
)
result = execBox.exec()
self.assertEqual(
result,
int(AppQMessageBox.StandardButton.Ok),
)
self.assertEqual(execEvents, [True])
owner.close()
owner.deleteLater()
collectAtBoundary()
def testMessageBoxRetainsAdaptivePerShowSizingAndParentCentering(self):
"""Keep message-box sizing specialized while sharing show-event centering."""
owner = QWidget()
owner.resize(800, 600)
owner.show()
messageBox = AppQMessageBox(
parent=owner,
text='Ready',
buttons=AppQMessageBox.StandardButton.Ok,
)
with mock.patch('Furious.Qt.QtWidgets.moveToCenter') as center:
messageBox.show()
processQtEvents()
singleButtonWidth = messageBox.width()
messageBox.hide()
messageBox.setStandardButtons(
AppQMessageBox.StandardButton.Save
| AppQMessageBox.StandardButton.Discard
| AppQMessageBox.StandardButton.Cancel
)
messageBox.show()
processQtEvents()
self.assertGreater(messageBox.width(), singleButtonWidth)
self.assertEqual(center.call_count, 2)
self.assertTrue(
all(call.args == (messageBox, owner) for call in center.call_args_list)
)
messageBox.close()
owner.close()
owner.deleteLater()
collectAtBoundary()
if __name__ == '__main__':
unittest.main()