Keep navigation state session-local

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-25 11:09:02 +08:00
parent cf0ed3045e
commit bdc32aaf72
4 changed files with 251 additions and 122 deletions
+1 -1
View File
@@ -215,7 +215,7 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
translatable: bool = True,
placement: str = 'top',
):
"""Register a page and create its navigation control."""
"""Register a page, selecting the first registered page initially."""
if not isinstance(pageId, str) or not pageId:
raise ValueError('pageId must be a non-empty string')
+2
View File
@@ -2,6 +2,8 @@
- `MainWindow` owns long-lived built-in pages, navigation, page-level managers, and compatibility aliases. Pages remain
stable for the window lifetime; plugin pages are owned by navigation plus `PluginNavigationManager`.
- Main-window page selection and navigation expansion are session-local. Register Home first as the canonical initial
page and keep new navigation views collapsed; never persist or restore either state from a previous process.
- Pages and dialogs adapt controllers/services/repositories to user interaction. Do not create a competing state
authority; prefer controller/service APIs over new cross-page reach-through.
- Long-lived pages create controls, timers, and signal connections once. Show/hide activates lazy rendering or refresh
+2 -25
View File
@@ -45,8 +45,6 @@ logger = logging.getLogger(__name__)
registerAppSettings('ServerWidgetWindowSize')
registerAppSettings('AppMainWindowGeometry')
registerAppSettings('AppMainWindowState')
registerAppSettings('AppMainWindowSelectedPage', default='home')
registerAppSettings('AppMainWindowNavigationExpanded', isBinary=True)
_TRANSLATABLE_NAVIGATION_LABELS = (
_('Home'),
@@ -114,6 +112,8 @@ class MainWindow(AppQMainWindow):
if not isinstance(self.logPage, LogPage):
raise TypeError('application log page must be a LogPage')
# NavigationView selects the first registered page. Keep Home first
# so every new window session starts from the canonical product page.
self.navigationView.addPage(
'home',
self.homePage,
@@ -147,16 +147,6 @@ class MainWindow(AppQMainWindow):
'gear-wide-connected.svg',
placement='bottom',
)
self.navigationView.pageChanged.connect(self._pageChanged)
self.navigationView.expandedChanged.connect(self._navigationExpandedChanged)
self.navigationView.setExpanded(
AppSettings.isStateON_('AppMainWindowNavigationExpanded'),
animated=False,
)
self.navigationView.setCurrentPage(
str(AppSettings.get('AppMainWindowSelectedPage'))
)
self.setCentralWidget(self.navigationView)
# Preserve the established application-facing server-management API.
@@ -169,19 +159,6 @@ class MainWindow(AppQMainWindow):
self.retranslate()
@QtCore.Slot(str)
def _pageChanged(self, pageId: str):
"""Persist the current page selected through navigation."""
AppSettings.set('AppMainWindowSelectedPage', pageId)
@QtCore.Slot(bool)
def _navigationExpandedChanged(self, expanded: bool):
"""Persist the navigation menu expansion state."""
if expanded:
AppSettings.turnON_('AppMainWindowNavigationExpanded')
else:
AppSettings.turnOFF('AppMainWindowNavigationExpanded')
def showPage(self, pageId: str):
"""Navigate to a registered application page."""
self.navigationView.setCurrentPage(pageId)
+246 -96
View File
@@ -25,7 +25,7 @@ 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.Service import LogManager, PluginNavigationManager
from Furious.Qt import AppQMainWindow
from Furious.Widget.NavigationView import NavigationView
from Furious.Window.HomePage import HomePage
@@ -44,6 +44,8 @@ from tests.support import (
processQtEvents,
)
from contextlib import contextmanager
import unittest
from unittest.mock import patch
@@ -97,6 +99,66 @@ class _GeometryWindow(AppQMainWindow):
self.setCentralWidget(QWidget(parent=self))
class _InertManager:
"""Expose only the idle runtime collection used by controllers in UI tests."""
runtimes = ()
@contextmanager
def _realMainWindow():
"""Own one fully composed MainWindow and its isolated application services."""
app = application()
oldServices = (
app.connectionController,
app.routingController,
app.settingsController,
app.logManager,
app.logPage,
)
connectionController = ConnectionController(
parent=app,
coreManager=_InertManager(),
updatesManager=_InertManager(),
)
logManager = LogManager(parent=app)
routingController = None
window = None
try:
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()
yield window
finally:
if window is not None:
window.close()
window.deleteLater()
collectAtBoundary()
(
app.connectionController,
app.routingController,
app.settingsController,
app.logManager,
app.logPage,
) = oldServices
if routingController is not None:
routingController.deleteLater()
connectionController.deleteLater()
logManager.deleteLater()
collectAtBoundary()
class AppQMainWindowLifecycleTest(unittest.TestCase):
"""Keep shared first-show mechanics deterministic and subclass-safe."""
@@ -202,6 +264,151 @@ class AppQMainWindowLifecycleTest(unittest.TestCase):
collectAtBoundary()
class MainWindowNavigationSessionTest(unittest.TestCase):
"""Keep page and expansion state live in one window, not in settings."""
FormerPageKey = 'AppMainWindowSelectedPage'
FormerExpansionKey = 'AppMainWindowNavigationExpanded'
@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 assertCanonicalPage(self, window: MainWindow):
"""Assert the navigation selection and stack both point to Home."""
self.assertEqual(window.navigationView.currentPageId(), 'home')
self.assertIs(window.navigationView.page('home'), window.homePage)
self.assertIs(
window.navigationView.pageStack.currentWidget(),
window.homePage,
)
def testNewSessionUsesCanonicalPageAndIgnoresFormerValues(self):
"""Ignore every representative value stored by the retired setting."""
self.assertNotIn(self.FormerPageKey, AppSettings.SettingsPool)
self.assertNotIn(self.FormerExpansionKey, AppSettings.SettingsPool)
formerValues = (
None,
'settings',
'log',
'metrics',
'plugin:example:status',
'removed-page',
)
for formerValue in formerValues:
with self.subTest(formerValue=formerValue), isolatedSettings() as settings:
if formerValue is not None:
settings.setValue(self.FormerPageKey, formerValue)
with _realMainWindow() as window:
self.assertCanonicalPage(window)
self.assertFalse(window.navigationView.isExpanded())
def testNavigationDoesNotCreateOrUpdateFormerSetting(self):
"""Keep both absent and stale obsolete keys untouched by navigation."""
with isolatedSettings() as settings, _realMainWindow() as window:
window.showPage('settings')
window.navigationView.setExpanded(True, animated=False)
window.cleanup()
self.assertFalse(settings.contains(self.FormerPageKey))
self.assertFalse(settings.contains(self.FormerExpansionKey))
with isolatedSettings() as settings:
settings.setValue(self.FormerPageKey, 'log')
settings.setValue(self.FormerExpansionKey, '1')
with _realMainWindow() as window:
self.assertCanonicalPage(window)
self.assertFalse(window.navigationView.isExpanded())
window.showPage('settings')
window.navigationView.setExpanded(True, animated=False)
window.cleanup()
self.assertEqual(settings.value(self.FormerPageKey), 'log')
self.assertEqual(settings.value(self.FormerExpansionKey), '1')
def testNewWindowResetsSessionStateButRestoresGeometry(self):
"""Reset page and expansion while retaining persistent geometry."""
expectedSize = QtCore.QSize(760, 600)
with isolatedSettings() as settings:
with _realMainWindow() as firstWindow:
firstWindow.show()
firstWindow.resize(expectedSize)
firstWindow.navigationView.setExpanded(True, animated=False)
firstWindow.showPage('settings')
firstWindow.cleanup()
self.assertEqual(
firstWindow.navigationView.currentPageId(),
'settings',
)
self.assertTrue(firstWindow.navigationView.isExpanded())
self.assertFalse(settings.contains(self.FormerPageKey))
self.assertFalse(settings.contains(self.FormerExpansionKey))
with _realMainWindow() as secondWindow:
secondWindow.show()
self.assertCanonicalPage(secondWindow)
self.assertFalse(secondWindow.navigationView.isExpanded())
self.assertEqual(secondWindow.size(), expectedSize)
def testHideAndShowPreservesLivePage(self):
"""Do not reset the page while reusing the same MainWindow instance."""
with isolatedSettings(), _realMainWindow() as window:
window.show()
window.showPage('settings')
window.hide()
window.show()
processQtEvents()
self.assertEqual(window.navigationView.currentPageId(), 'settings')
self.assertIs(
window.navigationView.pageStack.currentWidget(),
window.settingsPage,
)
def testPluginRegistrationCannotReplaceCanonicalPage(self):
"""Keep Home selected even when a stale plugin page is registered."""
def registerPluginPage(_manager, navigationView):
pluginPage = QWidget(parent=navigationView)
navigationView.addPage(
'plugin:example:status',
pluginPage,
'Plugin',
'house-door.svg',
)
with isolatedSettings() as settings:
settings.setValue(self.FormerPageKey, 'plugin:example:status')
with patch.object(
PluginNavigationManager,
'registerPages',
autospec=True,
side_effect=registerPluginPage,
), _realMainWindow() as window:
self.assertCanonicalPage(window)
self.assertIsNotNone(
window.navigationView.page('plugin:example:status')
)
class MainWindowGeometryTest(unittest.TestCase):
"""Keep restoration driven by persisted-state validity, not dimensions."""
@@ -232,110 +439,53 @@ class MainWindowGeometryTest(unittest.TestCase):
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,
)
with isolatedSettings(), _realMainWindow() as window:
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
class _InertManager:
"""Expose only the idle runtime collection used by the controller."""
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)
runtimes = ()
self.assertEqual(window.size(), window.DEFAULT_WINDOW_SIZE)
moveToCenter.assert_called_once_with(window)
connectionController = ConnectionController(
parent=app,
coreManager=_InertManager(),
updatesManager=_InertManager(),
)
logManager = LogManager(parent=app)
routingController = None
window = None
window.move(83, 97)
movedPosition = QtCore.QPoint(window.pos())
window.hide()
window.show()
try:
with isolatedSettings():
app.connectionController = connectionController
routingController = RoutingController(parent=app)
app.routingController = routingController
app.settingsController = SettingsController()
app.logManager = logManager
app.logPage = LogPage(manager=logManager)
self.assertEqual(window.pos(), movedPosition)
moveToCenter.assert_called_once_with(window)
with patch.object(HomePage, 'serverImportActions', return_value=()):
window = MainWindow()
expectedPages = {
'home': window.homePage,
'log': window.logPage,
'subscription': window.subscriptionPage,
'metrics': window.metricsPage,
'settings': window.settingsPage,
}
with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter:
window.show()
for pageId, page in expectedPages.items():
with self.subTest(pageId=pageId):
window.showPage(pageId)
processQtEvents()
self.assertEqual(
sum(
isinstance(widget, MainWindow)
for widget in app.topLevelWidgets()
),
1,
window.navigationView.currentPageId(),
pageId,
)
self.assertIs(window.navigationView.page(pageId), page)
self.assertIs(
window.navigationView.pageStack.currentWidget(),
page,
)
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."""