# 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 . """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.Service.EndpointInfoService import ProxyEndpointHttpClient from Furious.Qt.HttpGetManager import HttpGetManager from Furious.Service.PluginUIManager import PluginNavigationManager from Furious.Service.SubscriptionManager import SubscriptionManager from Furious.Service.DnsResolver import DnsResolver from Furious.Repository import Storage from Furious.Service.TrafficStatsManager import TrafficStatsManager from Furious.Service.UpdateManager import UpdateManager from Furious.Controllers.ConnectionController import ConnectionController from PySide6 import QtCore from PySide6.QtNetwork import QNetworkReply from PySide6.QtWidgets import QWidget from shiboken6 import isValid, delete as deleteQObject from tests.support import processQtEvents, application, collectAtBoundary, waitFor from types import SimpleNamespace from unittest.mock import Mock, 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() def testDefaultUpdateManagerDiesWithItsController(self): """An invalid controller wrapper cannot keep its update request alive.""" controller = ConnectionController(coreManager=SimpleNamespace(runtimes=[])) manager = controller._updatesManager reply = _ManagedReply(manager) payload = _ResponseBody(b'fixture') reference = weakref.ref(payload) try: with patch.object(manager, 'get', lambda _request: reply): manager.webGET('https://invalid.test', payload=payload) del payload deleteQObject(controller) self.assertFalse(isValid(manager)) self.assertFalse(isValid(reply)) self.assertFalse(manager._replyContexts) self.assertIsNone(reference()) finally: if isValid(manager): deleteQObject(manager) if isValid(controller): deleteQObject(controller) def testInjectedUpdateManagerKeepsItsExistingOwner(self): """Controller composition must not steal a borrowed network manager.""" owner = QtCore.QObject() manager = UpdateManager(owner) controller = ConnectionController( coreManager=SimpleNamespace(runtimes=[]), updatesManager=manager ) try: deleteQObject(controller) self.assertTrue(isValid(manager)) self.assertIs(manager.parent(), owner) finally: deleteQObject(owner) 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.""" def testDnsOperationOwnerDestructionAbortsItsRequest(self): """A resolver outliving a request must not outlive that request's owner.""" application() resolver = DnsResolver() self.addCleanup(resolver.dispose) for _ in range(30): parent = QtCore.QObject() reply = _ManagedReply(resolver) operation = resolver.resolveAsync('example.test', parent=parent) results, errors = [], [] operation.finished.connect(lambda *_args: results.append(True)) def abort(): reply.setError( QNetworkReply.NetworkError.OperationCanceledError, 'cancelled' ) reply.setFinished(True) reply.finished.emit() reply.abort = abort try: with ( patch.object(resolver, 'get', lambda _request: reply), patch('Furious.Service.DnsResolver.logger.error'), patch('sys.excepthook', lambda *args: errors.append(args)), ): operation.start() deleteQObject(parent) self.assertFalse(isValid(operation)) self.assertFalse(isValid(operation._timer)) self.assertTrue(reply.isFinished()) self.assertFalse(resolver._replyContexts) self.assertFalse(results) self.assertFalse(errors) processQtEvents() self.assertFalse(isValid(reply)) finally: if isValid(reply): reply.abort() if isValid(parent): deleteQObject(parent) def testDnsDisposalToleratesNativeDestructionDuringAbort(self): """Reply abort may destroy the resolver before its deferred delete.""" application() for _ in range(30): resolver = DnsResolver() reply = _ManagedReply(resolver) reference = weakref.ref(reply) resolver._trackReplyContext(reply, resolver._replyContexts, {}) reply.abort = lambda: deleteQObject(resolver) resolver.dispose() self.assertFalse(isValid(resolver)) self.assertFalse(isValid(reply)) self.assertFalse(resolver._replyContexts) del reply self.assertIsNone(reference()) @classmethod def setUpClass(cls): application() def testCompletionMayDestroyReplyOrManager(self): """Real finished delivery tolerates native deletion from user callbacks.""" for managerType, contextAttribute in ( (HttpGetManager, '_replyContexts'), (ProxyEndpointHttpClient, '_pendingRequests'), ): for deleteManager in (False, True): with self.subTest(manager=managerType.__name__, owner=deleteManager): for _ in range(20): manager = managerType() reply = _ManagedReply(manager) destroyed = [] reply.destroyed.connect(lambda *_a: destroyed.append(True)) completion = [] def destroyFromCallback(*_args, **_kwargs): deleteQObject(manager if deleteManager else reply) try: with patch.object(manager, 'get', lambda _request: reply): if isinstance(manager, HttpGetManager): manager.webGET( 'https://invalid.test', logActionMessage=False ) manager.successCallback = destroyFromCallback manager.completionCallback = ( lambda **_k: completion.append(True) ) else: manager.request('https://invalid.test', 'fixture') manager.completed.connect(destroyFromCallback) with patch('sys.excepthook') as exceptionHook: reply.finished.emit() processQtEvents() exceptionHook.assert_not_called() self.assertEqual(destroyed, [True]) self.assertFalse(isValid(reply)) self.assertFalse(getattr(manager, contextAttribute)) if isinstance(manager, HttpGetManager): self.assertEqual( completion, [] if deleteManager else [True] ) finally: if isValid(manager): deleteQObject(manager) processQtEvents() def testEndpointCancellationToleratesOwnerDestructionDuringAbort(self): """An abort listener may delete the manager and its other pending replies.""" manager = ProxyEndpointHttpClient() replies = [_ManagedReply(manager), _ManagedReply(manager)] for index, reply in enumerate(replies): with patch.object(manager, 'get', lambda _request: reply): manager.request('https://invalid.test', index) replies[0].abort = lambda: deleteQObject(manager) manager.cancelAll() self.assertFalse(isValid(manager)) self.assertTrue(all(not isValid(reply) for reply in replies)) self.assertEqual(manager._pendingRequests, {}) def testSubscriptionRepliesReleaseTrackingOnNativeDestruction(self): """Early deletion releases all reply keys before later cancellation.""" with patch.object(Storage, 'UserSubs', return_value={}): manager = SubscriptionManager() references = [] destroyed = [] try: for index in range(30): reply = _ManagedReply(manager) references.append(weakref.ref(reply)) reply.destroyed.connect(lambda *_args: destroyed.append(True)) with patch.object(manager, 'get', return_value=reply): manager.updateSubsByWebGET( webURL='https://invalid.test', unique=str(index) ) self.assertIn(reply, manager._activeReplies) reply.deleteLater() processQtEvents() self.assertFalse(isValid(reply)) del reply self.assertFalse(manager._replyContexts) self.assertFalse(manager._activeReplies) self.assertFalse(manager._replySubscriptions) manager.cancelUpdates() self.assertEqual(len(destroyed), 30) collectAtBoundary() self.assertTrue(all(reference() is None for reference in references)) finally: manager.shutdown() manager.deleteLater() processQtEvents() def testSubscriptionCancellationToleratesOwnerDestructionDuringAbort(self): """Reentrant destruction invalidates every remaining snapshot reply.""" for method in ('cancelUpdates', 'shutdown'): with self.subTest(method=method): self._cancelSubscriptionWithReentrantDestruction(method) def _cancelSubscriptionWithReentrantDestruction(self, method): """Keep both public teardown entry points on the real native boundary.""" with patch.object(Storage, 'UserSubs', return_value={}): manager = SubscriptionManager() replies = [_ManagedReply(manager), _ManagedReply(manager)] for index, reply in enumerate(replies): with patch.object(manager, 'get', return_value=reply): manager.updateSubsByWebGET( webURL='https://invalid.test', unique=str(index) ) replies[0].abort = lambda: deleteQObject(manager) getattr(manager, method)() self.assertFalse(isValid(manager)) self.assertTrue(all(not isValid(reply) for reply in replies)) self.assertFalse(manager._replyContexts) self.assertFalse(manager._activeReplies) self.assertFalse(manager._replySubscriptions) 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() def testEarlyReplyDestructionReleasesContextAcrossRepeatedRequests(self): """Native deletion without finished releases payloads and reply wrappers.""" for managerType, contextAttribute in ( (HttpGetManager, '_replyContexts'), (ProxyEndpointHttpClient, '_pendingRequests'), ): with self.subTest(manager=managerType.__name__): manager = managerType() references = [] destroyed = [] self.addCleanup(manager.deleteLater) for _ in range(30): payload = _ResponseBody(b'fixture') references.append(weakref.ref(payload)) reply = _ManagedReply(manager) references.append(weakref.ref(reply)) reply.destroyed.connect(lambda *_args: destroyed.append(True)) with patch.object(manager, 'get', lambda _request: reply): if isinstance(manager, HttpGetManager): manager.webGET('https://invalid.test', payload=payload) else: manager.request('https://invalid.test', payload) del payload reply.deleteLater() processQtEvents() self.assertFalse(isValid(reply)) self.assertFalse(getattr(manager, contextAttribute)) del reply self.assertEqual(len(destroyed), 30) self.assertTrue(all(reference() is None for reference in references)) def testManagerDestructionReleasesPendingContextWithRetainedWrappers(self): """Surviving invalid wrappers cannot keep operation payloads alive.""" for managerType, contextAttribute in ( (HttpGetManager, '_replyContexts'), (ProxyEndpointHttpClient, '_pendingRequests'), ): with self.subTest(manager=managerType.__name__): manager = managerType() payload = _ResponseBody(b'fixture') reference = weakref.ref(payload) reply = _ManagedReply(manager) with patch.object(manager, 'get', lambda _request: reply): if isinstance(manager, HttpGetManager): manager.webGET('https://invalid.test', payload=payload) else: manager.request('https://invalid.test', payload) del payload manager.deleteLater() processQtEvents() self.assertFalse(isValid(manager)) self.assertFalse(isValid(reply)) self.assertFalse(getattr(manager, contextAttribute)) self.assertIsNone(reference()) def testRepeatedFinishedSignalPublishesOnlyOnce(self): """A completed reply cannot invoke hooks again before deferred deletion.""" manager = _CapturingHttpGetManager() self.addCleanup(manager.deleteLater) reply = manager.webGET('https://invalid.test', marker='fixture') with patch.object(manager, 'successCallback') as completed: reply.finished.emit() reply.finished.emit() completed.assert_called_once_with(reply, marker='fixture') processQtEvents() self.assertFalse(isValid(reply)) 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 testDestroyedActiveReplyReleasesProbeAndAllowsAnotherRequest(self): """Native deletion without finished must not strand the probe scheduler.""" manager = ConnectivityManager() self.addCleanup(manager.deleteLater) references = [] destroyed = [] with patch( 'Furious.Service.ConnectivityManager.AppSettings.get', return_value=None ): for _ in range(30): manager._testingEnabled = True reply = _ManagedReply(manager) references.append(weakref.ref(reply)) reply.destroyed.connect(lambda *_args: destroyed.append(True)) requests = [] def get(request): requests.append(request) return reply with patch.object(manager, 'get', get): manager.startSingleTest() manager.startSingleTest() self.assertEqual(len(requests), 1) deleteQObject(reply) self.assertIsNone(manager._activeReply) self.assertFalse(manager._replyContexts) self.assertFalse(manager.jobTimeoutTimer.isActive()) self.assertTrue(manager.jobArrangeTimer.isActive()) manager.stopTest() del reply self.assertEqual(len(destroyed), 30) self.assertTrue(all(reference() is None for reference in references)) def testOlderReplyDestructionDoesNotRetireTheCurrentProbe(self): """A deferred delete from a completed request cannot clear its replacement.""" manager = ConnectivityManager() self.addCleanup(manager.deleteLater) manager._testingEnabled = True oldReply = _ManagedReply(manager) currentReply = _ManagedReply(manager) with ( patch.object(manager, 'get', side_effect=[oldReply, currentReply]), patch( 'Furious.Service.ConnectivityManager.AppSettings.get', return_value=None ), ): manager.startSingleTest() oldReply.finished.emit() manager.startSingleTest() deleteQObject(oldReply) self.assertIs(manager._activeReply, currentReply) self.assertTrue(manager.jobTimeoutTimer.isActive()) manager.stopTest() def testStopAfterNativeReplyDestructionDoesNotAccessDeletedWrapper(self): """Disconnect after reply deletion is harmless even with a retained wrapper.""" manager = ConnectivityManager() self.addCleanup(manager.deleteLater) manager._testingEnabled = True reply = _ManagedReply(manager) with ( patch.object(manager, 'get', return_value=reply), patch( 'Furious.Service.ConnectivityManager.AppSettings.get', return_value=None ), ): manager.startSingleTest() deleteQObject(reply) manager.stopTest() self.assertIsNone(manager._activeReply) self.assertFalse(manager.jobTimeoutTimer.isActive()) self.assertFalse(manager.jobArrangeTimer.isActive()) def testRapidStartsReuseOneActiveRequest(self): """Do not accumulate probes or timeout timers during rapid calls.""" manager = ConnectivityManager() reply = _ManagedReply(manager) 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 blocked queries and reentrant notifications respect manager lifetime.""" def testCollectionEnableDoesNotResumeAfterProviderEndsItsOwner(self): """Monitor discovery may destroy, disconnect, or disable its requester.""" for action in ('destroy', 'disconnect', 'disable'): with self.subTest(action=action): manager = TrafficStatsManager() manager._connected = True manager._collectionEnabled = False monitor = TrafficStatsMonitor( query=lambda _target: TrafficCounters(1, 2), target=None ) def resolveMonitor(_runtimes): if action == 'destroy': deleteQObject(manager) elif action == 'disconnect': manager.disconnectedCallback() else: manager.setCollectionEnabled(False) return monitor registry = SimpleNamespace( trafficStatsMonitorForRuntimes=resolveMonitor ) try: with patch( 'Furious.Service.TrafficStatsManager.getPluginRegistry', return_value=registry, ): manager.setCollectionEnabled(True) self.assertIsNone(manager._monitor) self.assertIsNone(manager._executor) if isValid(manager): self.assertFalse(manager._sampleTimer.isActive()) finally: if isValid(manager): manager.cleanup() deleteQObject(manager) def testNativeOwnerDestructionClosesStatisticsExecutor(self): """Retaining an invalid wrapper must not leave its worker thread running.""" for blocked in (False, True): with self.subTest(blocked=blocked): for _ in range(20): parent = QtCore.QObject() manager = TrafficStatsManager(parent) started = threading.Event() release = threading.Event() updates = [] callbackErrors = [] def query(_target): started.set() if blocked: release.wait(3) return TrafficCounters(1, 2) manager.sampleChanged.connect(updates.append) manager._activateMonitor( TrafficStatsMonitor(query=query, target=None) ) executor = manager._executor try: self.assertTrue(started.wait(1)) threads = tuple(executor._threads) updates.clear() with patch( 'sys.excepthook', lambda *args: callbackErrors.append(args) ): deleteQObject(parent) self.assertFalse(isValid(manager)) self.assertFalse(isValid(manager._sampleTimer)) self.assertIsNone(manager._executor) self.assertIsNone(manager._future) self.assertIsNone(manager._monitor) self.assertTrue(executor._shutdown) release.set() for thread in threads: thread.join(2) processQtEvents() self.assertFalse(callbackErrors) self.assertFalse(updates) self.assertTrue( all(not thread.is_alive() for thread in threads) ) finally: release.set() executor.shutdown(wait=True, cancel_futures=True) if isValid(parent): deleteQObject(parent) def testReconnectPreparationStopsAfterResetOrProviderEndsItsOwner(self): """Do not reactivate statistics after reentrant disconnect or destruction.""" application() for boundary in ('reset', 'provider'): for action in ('destroy', 'disconnect'): with self.subTest(boundary=boundary, action=action): for _ in range(20): manager = TrafficStatsManager() manager._hasConnected = True manager._collectionEnabled = True def endConnection(*_args): if action == 'destroy': deleteQObject(manager) else: manager.disconnectedCallback() registry = SimpleNamespace( trafficStatsMonitorForRuntimes=Mock(return_value=None) ) if boundary == 'reset': manager.usageHistoryReset.connect(endConnection) else: monitor = TrafficStatsMonitor( query=lambda _target: TrafficCounters(1, 2), target=None ) def resolveMonitor(_runtimes): endConnection() return monitor registry.trafficStatsMonitorForRuntimes.side_effect = ( resolveMonitor ) try: with ( patch.object( manager, '_clearUsageOnReconnectEnabled', return_value=True, ), patch( 'Furious.Service.TrafficStatsManager.getPluginRegistry', return_value=registry, ), patch('sys.excepthook') as qtErrors, ): manager.connectedCallback() processQtEvents() qtErrors.assert_not_called() if boundary == 'reset': registry.trafficStatsMonitorForRuntimes.assert_not_called() if action == 'destroy': self.assertFalse(isValid(manager)) else: self.assertFalse(manager._connected) self.assertFalse(manager._sampleTimer.isActive()) self.assertIsNone(manager._monitor) self.assertIsNone(manager._executor) finally: if isValid(manager): manager.cleanup() deleteQObject(manager) def testSamplePublicationStopsWhenAnObserverDestroysOrDisconnectsManager(self): """A signal listener can end this sample before later signals are emitted.""" application() signalNames = ( 'usageHistoryReset', 'usageChanged', 'speedChanged', 'sampleChanged', ) for signalName in signalNames[:-1]: for action in ('destroy', 'disconnect'): with self.subTest(signal=signalName, action=action): for _ in range(20): manager = TrafficStatsManager() delivered = [] with patch.object( manager, '_clearUsageOnReconnectEnabled', return_value=True ): manager._consumeResult( manager._generation, TrafficCounters(100, 100), 1.0 ) for name in signalNames: getattr(manager, name).connect( lambda *_args, _name=name: delivered.append(_name) ) def endSample(*_args): if action == 'destroy': deleteQObject(manager) else: manager.disconnectedCallback() getattr(manager, signalName).connect(endSample) try: with patch('sys.excepthook') as qtErrors: manager._sampleReady.emit( manager._generation, TrafficCounters(10, 10), 2.0, ) processQtEvents() qtErrors.assert_not_called() expected = signalNames[ : signalNames.index(signalName) + 1 ] self.assertEqual(delivered, list(expected)) if action == 'destroy': self.assertFalse(isValid(manager)) self.assertFalse(isValid(manager._sampleTimer)) else: self.assertIsNone(manager._previousCounters) self.assertFalse(manager._sampleTimer.isActive()) finally: if isValid(manager): manager.cleanup() deleteQObject(manager) @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()