From f4a9410707e1c5ae29ef5e5feb2664596ff4d28b Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sun, 23 Aug 2026 07:27:38 +0800 Subject: [PATCH] Refine application dialog presentation lifecycle Signed-off-by: Loren Eteval --- Furious/Actions/Import.py | 6 +- Furious/Backends/Xray/RoutingWindow.py | 36 +-- Furious/Qt/AGENTS.md | 4 + Furious/Qt/QtWidgets.py | 69 +++-- Furious/Widget/ServerTableView.py | 6 +- Furious/Window/NetworkTestDialog.py | 6 +- Furious/Window/ProxyBypassDialog.py | 6 +- tests/README.md | 5 +- tests/test_dialog_geometry.py | 389 +++++++++++++++++++++++++ 9 files changed, 463 insertions(+), 64 deletions(-) create mode 100644 tests/test_dialog_geometry.py diff --git a/Furious/Actions/Import.py b/Furious/Actions/Import.py index 80da44b..c744dfb 100644 --- a/Furious/Actions/Import.py +++ b/Furious/Actions/Import.py @@ -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() diff --git a/Furious/Backends/Xray/RoutingWindow.py b/Furious/Backends/Xray/RoutingWindow.py index 9bd421b..c8a6c41 100644 --- a/Furious/Backends/Xray/RoutingWindow.py +++ b/Furious/Backends/Xray/RoutingWindow.py @@ -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'} diff --git a/Furious/Qt/AGENTS.md b/Furious/Qt/AGENTS.md index 0ee8372..5ff38fe 100644 --- a/Furious/Qt/AGENTS.md +++ b/Furious/Qt/AGENTS.md @@ -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. diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 91eee80..d826e8b 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -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: diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 1920d0f..66cab0e 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -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() diff --git a/Furious/Window/NetworkTestDialog.py b/Furious/Window/NetworkTestDialog.py index c16a580..cae14c2 100644 --- a/Furious/Window/NetworkTestDialog.py +++ b/Furious/Window/NetworkTestDialog.py @@ -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 [ diff --git a/Furious/Window/ProxyBypassDialog.py b/Furious/Window/ProxyBypassDialog.py index 0216252..9529f32 100644 --- a/Furious/Window/ProxyBypassDialog.py +++ b/Furious/Window/ProxyBypassDialog.py @@ -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): diff --git a/tests/README.md b/tests/README.md index 2447317..22dee00 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 ``` diff --git a/tests/test_dialog_geometry.py b/tests/test_dialog_geometry.py new file mode 100644 index 0000000..4f99f02 --- /dev/null +++ b/tests/test_dialog_geometry.py @@ -0,0 +1,389 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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()