Files
LorenEteval_Furious/tests/test_main_window_geometry.py
T
Loren Eteval db3587ace7 Harden desktop startup and singleton election
Use an atomic lock-backed election for primary-instance startup,
distinguish live peers from stale endpoints, and bound handoff retries.
Keep the application usable when a desktop has no system tray and cover
first-launch races, stale recovery, and visible-window behavior.

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
2026-08-24 18:40:52 +08:00

549 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 AppQMainWindow lifecycle and persisted geometry semantics."""
from __future__ import annotations
from Furious.Backends.Xray.AssetWindow import XrayAssetWindow
from Furious.Backends.Xray.RoutingWindow import XrayRoutingWindow
from Furious.Controllers.ConnectionController import ConnectionController
from Furious.Controllers.RoutingController import RoutingController
from Furious.Controllers.SettingsController import SettingsController
from Furious.Frozenlib import AppSettings
from Furious.Service.LogManager import LogManager
from Furious.Qt import AppQMainWindow
from Furious.Widget.NavigationView import NavigationView
from Furious.Window.HomePage import HomePage
from Furious.Window.LogPage import LogPage
from Furious.Window.MainWindow import MainWindow
from Furious.Window.QRCodeWindow import QRCodeWindow
from Furious.Window.TextEditorWindow import TextEditorWindow
from PySide6 import QtCore
from PySide6.QtWidgets import QWidget
from tests.support import (
application,
collectAtBoundary,
isolatedSettings,
processQtEvents,
)
import unittest
from unittest.mock import patch
class _LifecycleWindow(AppQMainWindow):
"""Record the shared first-show lifecycle without application pages."""
DEFAULT_WINDOW_SIZE = QtCore.QSize(731, 517)
def __init__(self):
"""Finish composition after the base constructor returns."""
self.composed = False
self.prepareCalls = 0
self.preparedAfterComposition = False
super().__init__()
self.setCentralWidget(QWidget(parent=self))
self.composed = True
def prepareInitialGeometry(self):
"""Record preparation order before applying the declarative default."""
self.prepareCalls += 1
self.preparedAfterComposition = self.composed
super().prepareInitialGeometry()
class _NeverCenterWindow(_LifecycleWindow):
"""Exercise the explicit initial-centering opt-out."""
CENTER_ON_INITIAL_SHOW = False
class _GeometryWindow(AppQMainWindow):
"""Exercise MainWindow's geometry policy without constructing app pages."""
DEFAULT_WINDOW_SIZE = QtCore.QSize(700, 500)
QT_FALLBACK_WINDOW_SIZE = MainWindow.QT_FALLBACK_WINDOW_SIZE
_applyDefaultWindowSize = MainWindow._applyDefaultWindowSize
_restoreLegacyWindowSize = MainWindow._restoreLegacyWindowSize
_restoreMainWindowState = MainWindow._restoreMainWindowState
cleanup = MainWindow.cleanup
prepareInitialGeometry = MainWindow.prepareInitialGeometry
def __init__(self):
"""Compose the minimal child hierarchy used by restoreState()."""
super().__init__()
self.setCentralWidget(QWidget(parent=self))
class AppQMainWindowLifecycleTest(unittest.TestCase):
"""Keep shared first-show mechanics deterministic and subclass-safe."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def tearDown(self):
"""Destroy windows and drain deferred deletion between tests."""
for window in list(AppQMainWindow._openWindows.values()):
window.close()
window.deleteLater()
collectAtBoundary()
def testPreparationRunsAfterCompositionAndOnlyOnce(self):
"""Never call subclass lifecycle hooks from the base constructor."""
window = _LifecycleWindow()
self.assertEqual(window.prepareCalls, 0)
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
self.assertEqual(window.prepareCalls, 1)
self.assertTrue(window.preparedAfterComposition)
self.assertEqual(window.size(), window.DEFAULT_WINDOW_SIZE)
moveToCenter.assert_called_once_with(window)
window.move(37, 41)
movedPosition = QtCore.QPoint(window.pos())
window.hide()
window.show()
self.assertEqual(window.prepareCalls, 1)
self.assertEqual(window.pos(), movedPosition)
moveToCenter.assert_called_once_with(window)
window.close()
window.deleteLater()
def testRestoredGeometryOwnsItsInitialPosition(self):
"""Do not center over a position supplied by valid saved geometry."""
source = _LifecycleWindow()
source.setGeometry(45, 55, 640, 480)
savedGeometry = source.saveGeometry()
source.close()
source.deleteLater()
window = _LifecycleWindow()
self.assertTrue(window.restoreInitialGeometry(savedGeometry))
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
moveToCenter.assert_not_called()
window.close()
window.deleteLater()
def testSubclassCanOptOutOfInitialCentering(self):
"""Honor the declarative centering policy without platform checks."""
window = _NeverCenterWindow()
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
moveToCenter.assert_not_called()
window.close()
window.deleteLater()
def testConcreteSubclassDefaultsAndRepeatedShows(self):
"""Apply every non-persistent subclass default once and preserve moves."""
cases = (
(QRCodeWindow, QRCodeWindow.DEFAULT_WINDOW_SIZE),
(TextEditorWindow, TextEditorWindow.DEFAULT_WINDOW_SIZE),
(XrayAssetWindow, XrayAssetWindow.DEFAULT_WINDOW_SIZE),
)
with isolatedSettings():
for windowType, expectedSize in cases:
with self.subTest(windowType=windowType.__name__):
window = windowType()
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
self.assertEqual(window.size(), expectedSize)
moveToCenter.assert_called_once_with(window)
window.move(61, 73)
movedPosition = QtCore.QPoint(window.pos())
window.hide()
window.show()
self.assertEqual(window.pos(), movedPosition)
moveToCenter.assert_called_once_with(window)
window.close()
window.deleteLater()
collectAtBoundary()
class MainWindowGeometryTest(unittest.TestCase):
"""Keep restoration driven by persisted-state validity, not dimensions."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def tearDown(self):
"""Destroy windows and drain deferred deletion between tests."""
for window in list(AppQMainWindow._openWindows.values()):
window.close()
window.deleteLater()
collectAtBoundary()
@staticmethod
def _saveGeometry(rect: QtCore.QRect) -> QtCore.QByteArray:
"""Return valid Qt geometry for one deterministic client rectangle."""
source = _GeometryWindow()
source.setGeometry(rect)
savedGeometry = source.saveGeometry()
source.close()
source.deleteLater()
return savedGeometry
def testRealMainWindowUsesSharedLifecycleAfterComposition(self):
"""Construct the complete page hierarchy before first-show preparation."""
app = application()
oldServices = (
app.connectionController,
app.routingController,
app.settingsController,
app.logManager,
app.logPage,
)
class _InertManager:
"""Expose only the idle runtime collection used by the controller."""
runtimes = ()
connectionController = ConnectionController(
parent=app,
coreManager=_InertManager(),
updatesManager=_InertManager(),
)
logManager = LogManager(parent=app)
routingController = None
window = None
try:
with isolatedSettings():
app.connectionController = connectionController
routingController = RoutingController(parent=app)
app.routingController = routingController
app.settingsController = SettingsController()
app.logManager = logManager
app.logPage = LogPage(manager=logManager)
with patch.object(HomePage, 'serverImportActions', return_value=()):
window = MainWindow()
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
self.assertEqual(
sum(
isinstance(widget, MainWindow)
for widget in app.topLevelWidgets()
),
1,
)
self.assertEqual(len(window.findChildren(NavigationView)), 1)
self.assertIs(window.centralWidget(), window.navigationView)
self.assertEqual(window.size(), window.DEFAULT_WINDOW_SIZE)
moveToCenter.assert_called_once_with(window)
window.move(83, 97)
movedPosition = QtCore.QPoint(window.pos())
window.hide()
window.show()
self.assertEqual(window.pos(), movedPosition)
moveToCenter.assert_called_once_with(window)
expectedPages = {
'home': window.homePage,
'log': window.logPage,
'subscription': window.subscriptionPage,
'metrics': window.metricsPage,
'settings': window.settingsPage,
}
for pageId, page in expectedPages.items():
with self.subTest(pageId=pageId):
window.showPage(pageId)
processQtEvents()
self.assertEqual(
window.navigationView.currentPageId(),
pageId,
)
self.assertIs(window.navigationView.page(pageId), page)
self.assertIs(
window.navigationView.pageStack.currentWidget(),
page,
)
window.close()
window.deleteLater()
window = None
collectAtBoundary()
finally:
if window is not None:
window.close()
window.deleteLater()
(
app.connectionController,
app.routingController,
app.settingsController,
app.logManager,
app.logPage,
) = oldServices
if routingController is not None:
routingController.deleteLater()
connectionController.deleteLater()
logManager.deleteLater()
collectAtBoundary()
def testFirstLaunchUsesCanonicalDefault(self):
"""Use the product default when modern and legacy settings are absent."""
with isolatedSettings(), patch(
'Furious.Qt.QtWidgets.moveToCenter'
) as moveToCenter:
window = _GeometryWindow()
window.show()
self.assertEqual(window.size(), window.DEFAULT_WINDOW_SIZE)
moveToCenter.assert_called_once_with(window)
window.close()
window.deleteLater()
def testInvalidModernGeometryUsesCanonicalDefault(self):
"""Honor restoreGeometry's false result rather than inspecting size."""
for savedGeometry in (
QtCore.QByteArray(),
QtCore.QByteArray(b'broken'),
):
with self.subTest(savedGeometry=bytes(savedGeometry)), isolatedSettings():
AppSettings.set('AppMainWindowGeometry', savedGeometry)
window = _GeometryWindow()
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
self.assertEqual(window.size(), window.DEFAULT_WINDOW_SIZE)
moveToCenter.assert_called_once_with(window)
window.close()
window.deleteLater()
def testDarwinRestoredQtFallbackSizeUsesCanonicalDefault(self):
"""Replace macOS Qt fallback dimensions with the product default."""
with isolatedSettings():
expected = QtCore.QRect(40, 50, 640, 480)
AppSettings.set(
'AppMainWindowGeometry',
self._saveGeometry(expected),
)
window = _GeometryWindow()
with patch('Furious.Window.MainWindow.PLATFORM', 'Darwin'), patch(
'Furious.Qt.QtWidgets.moveToCenter'
) as moveToCenter:
window.show()
self.assertEqual(window.size(), window.DEFAULT_WINDOW_SIZE)
moveToCenter.assert_not_called()
window.close()
window.deleteLater()
def testNonDarwinRestored640By480GeometryIsPreserved(self):
"""Preserve a successful 640 by 480 restoration outside macOS."""
with isolatedSettings():
expected = QtCore.QRect(40, 50, 640, 480)
AppSettings.set(
'AppMainWindowGeometry',
self._saveGeometry(expected),
)
window = _GeometryWindow()
with patch('Furious.Window.MainWindow.PLATFORM', 'Windows'), patch(
'Furious.Qt.QtWidgets.moveToCenter'
) as moveToCenter:
window.show()
self.assertEqual(window.size(), expected.size())
moveToCenter.assert_not_called()
window.close()
window.deleteLater()
def testValidGeometrySurvivesInvalidQMainWindowState(self):
"""Keep geometry and QMainWindow layout-state validity independent."""
with isolatedSettings():
expected = QtCore.QRect(30, 35, 720, 520)
AppSettings.set(
'AppMainWindowGeometry',
self._saveGeometry(expected),
)
AppSettings.set('AppMainWindowState', QtCore.QByteArray(b'broken'))
window = _GeometryWindow()
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
self.assertEqual(window.size(), expected.size())
moveToCenter.assert_not_called()
window.close()
window.deleteLater()
def testLegacyCustomAndHistoricallySuspiciousSizesRemainUserData(self):
"""Migrate every positive legacy size without dimension heuristics."""
for legacySize, expected in (
('1111,700', QtCore.QSize(1111, 700)),
('640,480', QtCore.QSize(640, 480)),
('702,480', QtCore.QSize(702, 480)),
):
with self.subTest(legacySize=legacySize), isolatedSettings():
AppSettings.set('ServerWidgetWindowSize', legacySize)
window = _GeometryWindow()
window.show()
self.assertEqual(window.size(), expected)
window.close()
window.deleteLater()
def testInvalidLegacySizeUsesCanonicalDefault(self):
"""Reject malformed and non-positive legacy values deterministically."""
for legacySize in ('', '640', 'wide,high', '0,480', '-1,480'):
with self.subTest(legacySize=legacySize), isolatedSettings():
AppSettings.set('ServerWidgetWindowSize', legacySize)
window = _GeometryWindow()
window.show()
self.assertEqual(window.size(), window.DEFAULT_WINDOW_SIZE)
window.close()
window.deleteLater()
def testFallbackSavedAtCleanupRestoresOnNextLaunch(self):
"""Persist the product fallback instead of a transient Qt initial size."""
with isolatedSettings():
AppSettings.set('AppMainWindowGeometry', QtCore.QByteArray(b'broken'))
firstWindow = _GeometryWindow()
firstWindow.show()
firstWindow.cleanup()
secondWindow = _GeometryWindow()
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
secondWindow.show()
self.assertEqual(secondWindow.size(), firstWindow.DEFAULT_WINDOW_SIZE)
moveToCenter.assert_not_called()
firstWindow.close()
firstWindow.deleteLater()
secondWindow.close()
secondWindow.deleteLater()
def testMaximizedGeometryRestoresWithoutDefaultFallback(self):
"""Preserve Qt's maximized state encoded in valid saved geometry."""
with isolatedSettings():
source = _GeometryWindow()
source.showMaximized()
processQtEvents()
savedGeometry = source.saveGeometry()
source.close()
source.deleteLater()
AppSettings.set('AppMainWindowGeometry', savedGeometry)
window = _GeometryWindow()
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
self.assertTrue(window.isMaximized())
moveToCenter.assert_not_called()
window.close()
window.deleteLater()
def testRoutingWindowRestoresCrossPlatformAndPreservesMove(self):
"""Give the routing editor the same restoration semantics everywhere."""
with isolatedSettings():
expected = QtCore.QRect(50, 60, 820, 540)
AppSettings.set(
'UserRoutingWindowGeometry',
self._saveGeometry(expected),
)
window = XrayRoutingWindow()
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
# Qt may clamp restored geometry to the available screen; the
# successful restore is authoritative even when dimensions shift.
self.assertNotEqual(window.size(), window.DEFAULT_WINDOW_SIZE)
moveToCenter.assert_not_called()
window.move(77, 89)
movedPosition = QtCore.QPoint(window.pos())
window.hide()
window.show()
self.assertEqual(window.pos(), movedPosition)
moveToCenter.assert_not_called()
window.close()
window.deleteLater()
if __name__ == '__main__':
unittest.main()