Files
LorenEteval_Furious/tests/test_service_runtime.py
T
2026-09-09 15:58:31 +08:00

390 lines
12 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) 2024present 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/>.
"""Exercise bounded service work, validation, and plugin UI ownership."""
from __future__ import annotations
from Furious.Plugins import (
CapabilityKind,
NavigationPageDescriptor,
TrafficCounters,
TrafficStatsMonitor,
)
from Furious.Service.ConnectivityManager import ConnectivityManager
from Furious.Qt.HttpGetManager import HttpGetManager
from Furious.Service.PluginUIManager import PluginNavigationManager
from Furious.Service.TrafficStatsManager import TrafficStatsManager
from Furious.Service.UpdateManager import UpdateManager
from PySide6 import QtCore
from PySide6.QtNetwork import QNetworkReply
from PySide6.QtWidgets import QWidget
from shiboken6 import isValid
from tests.support import processQtEvents, application, collectAtBoundary, waitFor
from types import SimpleNamespace
from unittest.mock import patch
import json
import unittest
import weakref
import threading
class _ResponseBody:
"""Provide the QByteArray-compatible API consumed by UpdateManager."""
def __init__(self, data):
"""Store one deterministic response body."""
self._data = data
def data(self):
"""Return the stored response bytes."""
return self._data
class _Response:
"""Expose one complete fake network response."""
def __init__(self, payload):
"""Encode *payload* as one JSON response."""
self._data = json.dumps(payload).encode('utf-8')
def readAll(self):
"""Return a QByteArray-compatible response wrapper."""
return _ResponseBody(self._data)
class UpdateManagerTest(unittest.TestCase):
"""Verify update data is validated before it reaches UI callbacks."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def testMalformedSuccessfulResponseUsesControlledFailurePath(self):
"""Reject missing and untrusted release fields without raw exceptions."""
manager = UpdateManager()
parent = QWidget()
versions = []
with patch.object(manager, 'showErrorMessageBox') as showError:
manager.successCallback(
_Response(
{
'tag_name': '999.0.0',
'html_url': 'javascript:alert(1)',
}
),
parent=parent,
hasNewVersionCallback=versions.append,
)
showError.assert_called_once_with(parent)
self.assertEqual(versions, [])
manager.deleteLater()
parent.deleteLater()
def testFailureForwardsDialogParent(self):
"""Keep update failures modal to the initiating window when supplied."""
manager = UpdateManager()
parent = QWidget()
with patch.object(manager, 'showErrorMessageBox') as showError:
manager.failureCallback(None, parent=parent)
showError.assert_called_once_with(parent)
manager.deleteLater()
parent.deleteLater()
class _ManagedReply(QNetworkReply):
"""Provide a hermetic reply object with real Qt lifecycle signals."""
def __init__(self, parent=None):
super().__init__(parent)
self.open(QtCore.QIODevice.OpenModeFlag.ReadOnly)
def abort(self):
self.setFinished(True)
def readData(self, maximumLength):
return bytes()
class _CapturingHttpGetManager(HttpGetManager):
"""Capture normalized requests without performing network I/O."""
def __init__(self):
super().__init__()
self.reply = _ManagedReply(self)
self.request = None
def get(self, request):
self.request = request
return self.reply
class HttpGetManagerLifetimeTest(unittest.TestCase):
"""Verify every request receives a timeout and releases exact context."""
@classmethod
def setUpClass(cls):
application()
def testRequestHasFiniteTimeoutAndTerminalPathDropsContext(self):
manager = _CapturingHttpGetManager()
reply = manager.webGET('https://invalid.test/resource', marker='fixture')
self.assertIs(reply, manager.reply)
self.assertEqual(manager.request.transferTimeout(), 60_000)
self.assertEqual(manager._replyContexts[reply], {'marker': 'fixture'})
with patch.object(manager, 'handleFinishedByNetworkReply') as finished:
reply.finished.emit()
self.assertEqual(manager._replyContexts, {})
finished.assert_called_once_with(reply, marker='fixture')
manager.deleteLater()
capabilityId = 'fixture.navigation'
class _NavigationProvider:
"""Return one valid page and one invalid parented QObject."""
def __init__(self):
"""Initialize construction counters used by idempotence assertions."""
self.validCalls = 0
self.invalidCalls = 0
self.invalidPage = None
def _validPage(self, parent=None):
"""Return one host-owned QWidget."""
self.validCalls += 1
return QWidget(parent)
def _invalidPage(self, parent=None):
"""Return one parented QObject that must be rejected and destroyed."""
self.invalidCalls += 1
self.invalidPage = QtCore.QObject(parent)
return self.invalidPage
def pageDescriptors(self):
"""Return deterministic page descriptors."""
return (
NavigationPageDescriptor(
'valid',
'Valid',
'valid.svg',
self._validPage,
),
NavigationPageDescriptor(
'invalid',
'Invalid',
'invalid.svg',
self._invalidPage,
),
)
class _NavigationRegistry:
"""Expose the registry subset consumed by PluginNavigationManager."""
def __init__(self, provider):
"""Store the only provider returned by this fixture."""
self.provider = provider
self.plugin = object()
def plugins(self):
"""Return one deterministic plugin token."""
return (self.plugin,)
def metadataFor(self, plugin):
"""Return stable plugin metadata."""
return SimpleNamespace(id='fixture')
def capabilities(self, kind, plugin):
"""Return navigation capability only for the requested plugin."""
if kind is CapabilityKind.NavigationPage and plugin is self.plugin:
return (self.provider,)
return tuple()
class _NavigationHost(QWidget):
"""Record pages registered through the host navigation API."""
def __init__(self):
"""Initialize an empty registration list."""
super().__init__()
self.registrations = []
def addPage(self, *args, **kwargs):
"""Record one page registration."""
self.registrations.append((args, kwargs))
class PluginNavigationManagerTest(unittest.TestCase):
"""Protect startup idempotence and invalid QObject cleanup."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def testDestroyedFactoryResultsAreRejectedBeforeRegistration(self):
"""A retained Python wrapper is not proof of a valid plugin page."""
for pageType in (QtCore.QObject, QWidget):
with self.subTest(pageType=pageType):
page = pageType()
page.deleteLater()
processQtEvents()
provider = _NavigationProvider()
provider._invalidPage = lambda parent=None: page
host = _NavigationHost()
manager = PluginNavigationManager(_NavigationRegistry(provider))
try:
with self.assertLogs(
'Furious.Service.PluginUIManager', level='ERROR'
):
pages = manager.registerPages(host)
self.assertEqual(len(pages), 1)
self.assertEqual(len(host.registrations), 1)
finally:
host.deleteLater()
processQtEvents()
def testRegistrationIsIdempotentAndDeletesInvalidQObject(self):
"""Construct each descriptor once and destroy rejected Qt objects."""
provider = _NavigationProvider()
host = _NavigationHost()
manager = PluginNavigationManager(_NavigationRegistry(provider))
first, second = (
manager.registerPages(host),
manager.registerPages(host),
)
self.assertEqual(first, second)
self.assertEqual(provider.validCalls, 1)
self.assertEqual(provider.invalidCalls, 1)
self.assertEqual(len(host.registrations), 1)
collectAtBoundary()
self.assertFalse(isValid(provider.invalidPage))
host.deleteLater()
class ConnectivityManagerTest(unittest.TestCase):
"""Verify one bounded request is active at a time without live networking."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def testRapidStartsReuseOneActiveRequest(self):
"""Do not accumulate probes or timeout timers during rapid calls."""
manager = ConnectivityManager()
reply = object()
manager._testingEnabled = True
with (
patch.object(manager, 'webGET', return_value=reply) as webGet,
patch(
'Furious.Service.ConnectivityManager.AppSettings.get', return_value=None
),
):
manager.startSingleTest()
manager.startSingleTest()
webGet.assert_called_once()
self.assertIs(manager._activeReply, reply)
self.assertTrue(manager.jobTimeoutTimer.isActive())
manager.successCallback(reply)
self.assertIsNone(manager._activeReply)
self.assertFalse(manager.jobTimeoutTimer.isActive())
self.assertTrue(manager.jobArrangeTimer.isActive())
manager.stopTest()
manager.deleteLater()
class TrafficStatsManagerTest(unittest.TestCase):
"""Verify a blocked provider cannot retain its Qt manager on cleanup."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def testBlockedQueryCompletionCallbackDoesNotRetainManager(self):
"""Keep a running plugin call detached from the manager's lifetime."""
started = threading.Event()
release = threading.Event()
def query(_target):
"""Block until the test has checked the manager weak reference."""
started.set()
release.wait(2)
return TrafficCounters(uplink=1, downlink=2)
manager = TrafficStatsManager()
manager._activateMonitor(TrafficStatsMonitor(query=query, target=None))
self.assertTrue(started.wait(1))
reference = weakref.ref(manager)
manager.cleanup()
manager.deleteLater()
del manager
collectAtBoundary()
try:
self.assertTrue(waitFor(lambda: reference() is None))
finally:
release.set()
if __name__ == '__main__':
unittest.main()