diff --git a/Furious/Service/LogManager.py b/Furious/Service/LogManager.py index c778fbf..ee23994 100644 --- a/Furious/Service/LogManager.py +++ b/Furious/Service/LogManager.py @@ -322,11 +322,13 @@ class LogManager(QtCore.QObject): self._lock = threading.RLock() self._categories: dict[str, LogCategory] = {} + self._maximumEntries = maximumEntries self._maximumCharacters = maximumCharacters self._maximumEntryCharacters = maximumEntryCharacters self._autoClearMaximumEntries = autoClearMaximumEntries self._autoClearEnabled = bool(autoClearEnabled) + self._sequence = 0 self._retainedEntryCount = 0 self._retainedCharacters = 0 diff --git a/Furious/Service/ProfileTesting.py b/Furious/Service/ProfileTesting.py index 108f575..34e3873 100644 --- a/Furious/Service/ProfileTesting.py +++ b/Furious/Service/ProfileTesting.py @@ -651,6 +651,7 @@ class _LatencyScheduler(QtCore.QObject): def shutdown(self): """Stop the dedicated TCPing event loop exactly once.""" self.shuttingDown = True + self.cancelAll() try: @@ -1333,6 +1334,7 @@ class ProfileTestManager(QtCore.QObject): portRange=self.SerialDownloadPorts, parent=self, ) + self._concurrentDownloadScheduler = _DownloadSpeedScheduler( self.resolveTarget, self.applyResult, diff --git a/Furious/Service/SubscriptionManager.py b/Furious/Service/SubscriptionManager.py index 41de7fd..dc5e2af 100644 --- a/Furious/Service/SubscriptionManager.py +++ b/Furious/Service/SubscriptionManager.py @@ -437,6 +437,7 @@ class SubscriptionManager(HttpGetManager): for batchId, state in self._batches.items() for unique, version in state.pending ) + groups = [] for unique, value in Storage.UserSubs().items(): @@ -556,6 +557,7 @@ class SubscriptionManager(HttpGetManager): context.get('remark', ''), context.get('decoderId'), ) + importer = self.importer filterResult = type(self)._filterImportResult profileFilter = str(context.get('filter', '')) diff --git a/Furious/Service/TcpingService.py b/Furious/Service/TcpingService.py index 89c9b03..88cc2fa 100644 --- a/Furious/Service/TcpingService.py +++ b/Furious/Service/TcpingService.py @@ -103,6 +103,7 @@ class TcpingProbe(QtCore.QObject): self.elapsedTimer = QtCore.QElapsedTimer() self.socket = QTcpSocket(self) + self.timeoutTimer = QtCore.QTimer(self) self.timeoutTimer.setSingleShot(True) @@ -118,6 +119,7 @@ class TcpingProbe(QtCore.QObject): 'handleSocketError', sender=self.socket, ) + connectWeakly( self.timeoutTimer.timeout, self, @@ -260,6 +262,7 @@ class TcpingEngine(QtCore.QObject): if not cancelled: self.recordOutcome(deadlineExpired) + self.postResult(requestId, result) self.drain() diff --git a/tests/test_architecture_refactors.py b/tests/test_architecture_refactors.py index a12ccf5..d003004 100644 --- a/tests/test_architecture_refactors.py +++ b/tests/test_architecture_refactors.py @@ -126,6 +126,7 @@ class ApplicationLifecycleTransactionTest(TestCase): _exitRequested=False, _exitCode=ApplicationRunner.ExitCode.ExitSuccess.value, ) + application.addEnviron = mock.Mock( side_effect=( RuntimeError('plugins failed') @@ -1161,6 +1162,7 @@ class ApplicationLifecycleTransactionTest(TestCase): ) as daemonOff, ): DesktopApplication._initializeSystemIntegration(application) + cleanupStack.close() proxyOff.assert_called_once_with() diff --git a/tests/test_connection_startup_async.py b/tests/test_connection_startup_async.py index 2310a79..eb4545d 100644 --- a/tests/test_connection_startup_async.py +++ b/tests/test_connection_startup_async.py @@ -544,6 +544,7 @@ class ConnectionStartupAsyncTest(TestCase): primary = _Runtime() tun = _Runtime() tun.cleanup = None + events = [] deviceChecks = 0 @@ -673,6 +674,7 @@ class ConnectionStartupAsyncTest(TestCase): primary = _Runtime() tun = _Runtime() tun.cleanup = None + events = [] def tunFactory(*_args, **kwargs): @@ -796,6 +798,7 @@ class ConnectionStartupAsyncTest(TestCase): primary = _Runtime() tun = _Runtime() tun.cleanup = None + events = [] def tunFactory(*_args, **kwargs): diff --git a/tests/test_external_core.py b/tests/test_external_core.py index 28161b2..93db789 100644 --- a/tests/test_external_core.py +++ b/tests/test_external_core.py @@ -217,15 +217,18 @@ class ExternalCoreProcessTest(unittest.TestCase): 'print("stderr fixture",file=sys.stderr,flush=True); ' 'time.sleep(60)' ) + config = self.configuration( ['-u', '-c', code, str(resultPath), payload], directory, {'FURIOUS_EXTERNAL_TEST': 'Unicode ✓'}, ) + messages = [] runtime = ExternalCoreProcess(config, msgCallback=messages.append) runtime.start() + self.assertTrue(self.waitFor(resultPath.exists)) self.assertTrue( self.waitFor( @@ -444,8 +447,10 @@ class ExternalCoreProcessTest(unittest.TestCase): registry = mock.Mock() registry.prepareTUN.return_value = False registry.usesApplicationTun2socks.return_value = True + dnsResolver = mock.Mock() dnsResolver.resolve.return_value = (True, []) + tunRuntime = mock.Mock(spec=CoreRuntime) manager = NoCoreRuntimeConnectionManager(dnsResolver=dnsResolver) diff --git a/tests/test_log_manager_generation.py b/tests/test_log_manager_generation.py index 2f64609..2bde100 100644 --- a/tests/test_log_manager_generation.py +++ b/tests/test_log_manager_generation.py @@ -1744,30 +1744,39 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): capacity = 2_000 manager = LogManager(maximumEntries=capacity, autoClearEnabled=False) manager.RetiredCleanupBudget = 1 + maximumRetired = 0 maximumPhysical = 0 maximumBatches = 0 + for cycle in range(100): for index in range(capacity): manager.append(f'{cycle}:{index}', CORE_LOG_CATEGORY) + maximumRetired = max(maximumRetired, manager.retiredEntryCount) maximumPhysical = max( maximumPhysical, manager.entryCount() + manager.retiredEntryCount, ) + manager.clear(runtimeOnly=True) + maximumRetired = max(maximumRetired, manager.retiredEntryCount) maximumPhysical = max( maximumPhysical, manager.entryCount() + manager.retiredEntryCount, ) maximumBatches = max(maximumBatches, len(manager._retiredBatches)) + self.assertLessEqual(maximumPhysical, capacity) + while manager.retiredEntryCount: with manager._lock: manager._cleanupRetiredLocked() + self.assertEqual(manager.retiredCharacters, 0) self.assertEqual(len(manager._retiredBatches), 0) + self.report( 'adversarial-backlog', operations=capacity * 100, @@ -1782,6 +1791,7 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): """Sustain mixed operations while checking invariants and RSS plateaus.""" seed = 0xF017105 randomizer = random.Random(seed) + manager = LogManager( maximumEntries=2_000, maximumCharacters=200_000, @@ -1791,6 +1801,7 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): manager.RetiredCleanupBudget = 64 manager.registerComponent('soak.runtime', 'Soak runtime', runtime=True) manager.registerComponent('soak.other', 'Soak other', runtime=False) + categories = ( APPLICATION_LOG_CATEGORY, CORE_LOG_CATEGORY, @@ -1798,15 +1809,19 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): 'soak.runtime', 'soak.other', ) + rssSamples = [] maximumRetired = 0 maximumRetiredCharacters = 0 maximumBatches = 0 maximumLive = 0 maximumPhysical = 0 + started = time.perf_counter() + for index in range(250_000): operation = randomizer.randrange(100) + if operation < 70: manager.append( f'{index}:{randomizer.randrange(10**9)}', @@ -1833,6 +1848,7 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): maximumPhysical, manager.entryCount() + manager.retiredEntryCount, ) + if (index + 1) % 10_000 == 0: _assertManagerInvariants(self, manager) rssSamples.append(resourceSnapshot()['rss']) @@ -1840,10 +1856,13 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): while manager.retiredEntryCount: with manager._lock: manager._cleanupRetiredLocked() + _assertManagerInvariants(self, manager) self.assertLessEqual(maximumRetired, manager.maximumEntries) + rssValues = [value for value in rssSamples if value is not None] rssGrowth = rssValues[-1] - rssValues[0] if len(rssValues) > 1 else None + self.report( 'soak', seed=seed, @@ -1868,30 +1887,39 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): maximumEntryCharacters=8, autoClearEnabled=False, ) + categories = ( APPLICATION_LOG_CATEGORY, CORE_LOG_CATEGORY, TUN2SOCKS_LOG_CATEGORY, ) + for index in range(count): manager.append(str(index), categories[index % 3]) + started = time.perf_counter_ns() entries = manager.entries() snapshotUs = (time.perf_counter_ns() - started) / 1_000 + self.assertEqual( tuple(entry.sequence for entry in entries), tuple(range(1, count + 1)) ) + watched = [] + for generation in manager._activeGenerationsLocked(): index = _ObservedEntries(generation.entries) generation.entries = index watched.append(index) + started = time.perf_counter_ns() manager.clear() clearUs = (time.perf_counter_ns() - started) / 1_000 + self.assertEqual(sum(index.iterations for index in watched), 0) self.assertEqual(manager.entries(), tuple()) self.assertEqual(manager.retiredEntryCount, count) + self.report( 'large-merge-clear', entries=count, diff --git a/tests/test_models_and_services.py b/tests/test_models_and_services.py index 42feef2..58b0518 100644 --- a/tests/test_models_and_services.py +++ b/tests/test_models_and_services.py @@ -337,6 +337,7 @@ class IsolatedRepositoryTest(unittest.TestCase): }, ) ) + repository.sync() restored = UserSubs() @@ -345,6 +346,7 @@ class IsolatedRepositoryTest(unittest.TestCase): self.assertEqual(tuple(group.id for group in groups), ('first', 'second')) self.assertFalse(groups[0].enabled) self.assertEqual(groups[1].extras['futureField'], 'two') + self.assertEqual(restored.removeGroup('first').remark, 'Alpha') self.assertIsNone(restored.group('first')) @@ -714,6 +716,7 @@ class LogManagerTest(unittest.TestCase): sum(index.oldestRemovals for index in observedIndexes), 0, ) + self.assertEqual( manager.retiredEntryCount, coreCount + tunCount, @@ -729,6 +732,7 @@ class LogManagerTest(unittest.TestCase): tuple(f'application {index}' for index in range(applicationCount)) + ('new core after clear',), ) + self._assertIndexesConsistent(manager) def testWholeGenerationClearDoesNoPhysicalEntryWorkOnCaller(self): diff --git a/tests/test_profile_test_jobs.py b/tests/test_profile_test_jobs.py index b16fca0..998fe0c 100644 --- a/tests/test_profile_test_jobs.py +++ b/tests/test_profile_test_jobs.py @@ -759,18 +759,23 @@ class ProfileTestServiceTest(unittest.TestCase): """Invalidate a TCPing group before a network result reaches profiles.""" profile = self._profile('profile', '192.0.2.1', 9) profile.metadata.subscriptionSource = 'group-a' + manager = self._manager((profile,)) scheduler = manager._latencyScheduler manager.testTcping((profile,)) + group = next(iter(scheduler.tcpingRequests.values())) job = group.jobs[0] + scheduler.invalidateSubscriptions({'group-a'}) self.assertIs(job.state, ProfileTestJobState.Cancelled) self.assertFalse(scheduler.tcpingRequests) self.assertFalse(scheduler.tcpingEndpointRequests) + processQtEvents() + self.assertEqual(profile.metadata.latency, '') def testTcpingSharedResultFanOutIsBoundedPerGuiBatch(self): @@ -1082,6 +1087,7 @@ class ProfileTestServiceTest(unittest.TestCase): 30000, DownloadSpeedTestOptions(5000, 'https://example.test'), ) + lease = mock.Mock() worker._runtimeLease = lease worker.finished.connect(lambda current, _result: current.deleteLater()) @@ -1090,10 +1096,12 @@ class ProfileTestServiceTest(unittest.TestCase): lease.release.assert_called_once_with() self.assertTrue(waitFor(lambda: not isValid(worker))) + worker.runtimeExited( profile.connection, RuntimeExit(1, RuntimeExitReason.Unexpected), ) + self.assertEqual(profile.metadata.speed, '') def testServerTableRepaintsOnlyTheCellCommittedByTheService(self): diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index 02efba6..310467b 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -1208,6 +1208,7 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): routing = _RoutingControllerFixture( (RoutingOption('default', 'Default'),), 'default' ) + try: with self._home(settings, connection, routing) as home: table = home.userServersQTableWidget @@ -1215,18 +1216,23 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): ServerTableQtInteractionTest._profile(name) for name in ('alpha', 'beta') ] + for profile in profiles: table.appendNewItemByFactory(profile) + home.show() home.activateWindow() table.setFocus() processQtEvents() + QTest.keyClick(table, QtCore.Qt.Key_F, QtCore.Qt.ControlModifier) self.assertTrue(waitFor(home.searchLineEdit.hasFocus)) + with mock.patch.object( table, 'search', wraps=table.search ) as search: QTest.keyClicks(home.searchLineEdit, 'alpha') + self.assertEqual(search.call_count, 0) self.assertTrue( waitFor(lambda: table.proxyModel.rowCount() == 1) @@ -1236,23 +1242,32 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): table.sourceRowFromProxyIndex(table.proxyModel.index(0, 0)), 0, ) + home.searchLineEdit.setText('beta') home.searchLineEdit.clear() + self.assertEqual(table.proxyModel.rowCount(), 2) self.assertFalse(home._searchTimer.isActive()) + home.searchLineEdit.setText('beta') QTest.keyClick(home.searchLineEdit, QtCore.Qt.Key_Return) + self.assertEqual(table.proxyModel.rowCount(), 1) self.assertFalse(home._searchTimer.isActive()) self.assertEqual( table.sourceRowFromProxyIndex(table.proxyModel.index(0, 0)), 1, ) + self.assertEqual(list(Storage.UserServers()), profiles) + home.searchLineEdit.setText('alpha') home.hide() + self.assertFalse(home._searchTimer.isActive()) + home.show() + self.assertEqual(table.proxyModel.rowCount(), 1) self.assertEqual( table.sourceRowFromProxyIndex(table.proxyModel.index(0, 0)), 0 @@ -1270,30 +1285,36 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): routing = _RoutingControllerFixture( (RoutingOption('default', 'Default'),), 'default' ) + profiles = [ ServerTableQtInteractionTest._profile(name) for name in ('match zeta', 'hidden', 'match alpha') ] Storage.UserServers().extend(profiles) + try: with self._home(settings, connection, routing) as home: home.resize(1000, 600) home.show() home.activateWindow() + table = home.userServersQTableWidget home.searchLineEdit.setText('match') home.applySearch() table.sortByColumn(0, QtCore.Qt.AscendingOrder) processQtEvents() + expectedIds = { p.metadata.profileId for p in (profiles[0], profiles[2]) } + for theme in (AppStyleSheet.Light, AppStyleSheet.Dark): with self.subTest(theme=theme), mock.patch.object( application(), 'theme', return_value=theme ): home.setStyleSheet(AppStyleSheet.forTheme(theme)) table.setFocus() + QTest.mouseClick( table.viewport(), QtCore.Qt.LeftButton, @@ -1308,27 +1329,33 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): ) QTest.keyRelease(table, QtCore.Qt.Key_Control) processQtEvents() + rect = table.visualRect(table.proxyModel.index(0, 0)) sample = QtCore.QPoint(rect.right() - 12, rect.center().y()) before = ( table.viewport().grab().toImage().pixelColor(sample) ) + self.assertEqual( before, QtGui.QColor( AppStyleSheet.paletteForTheme(theme)['selection'] ), ) + # A real click can paint between press and release. QTest.mousePress(home.testButton, QtCore.Qt.LeftButton) processQtEvents() + self.assertFalse(home.testMenu.isVisible()) self.assertEqual( table.viewport().grab().toImage().pixelColor(sample), before, ) + QTest.mouseRelease(home.testButton, QtCore.Qt.LeftButton) processQtEvents() + self.assertTrue(home.testMenu.isVisible()) self.assertEqual( { @@ -1341,12 +1368,14 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): table.viewport().grab().toImage().pixelColor(sample), before, ) + with mock.patch.object( table.profileTestManager, 'testPing' ) as testPing: home.testMenu.setActiveAction(table.testActions[0]) QTest.keyClick(home.testMenu, QtCore.Qt.Key_Return) processQtEvents() + testPing.assert_called_once() self.assertEqual( { @@ -1355,11 +1384,13 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): }, expectedIds, ) + self.assertTrue(home.testButton.hasFocus()) self.assertEqual( table.viewport().grab().toImage().pixelColor(sample), before, ) + # A cancelled click must not open a menu or leave a # highlight override after focus moves elsewhere. QTest.mousePress(home.testButton, QtCore.Qt.LeftButton) @@ -1369,9 +1400,12 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): pos=QtCore.QPoint(-10, -10), ) processQtEvents() + self.assertFalse(home.testMenu.isVisible()) + home.searchLineEdit.setFocus() processQtEvents() + self.assertFalse(table.property('keepSelectionHighlighted')) self.assertEqual( table.viewport().grab().toImage().pixelColor(sample), @@ -1383,19 +1417,28 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): # Opening and dismissing from the keyboard returns to the button. home.testButton.setFocus(QtCore.Qt.TabFocusReason) processQtEvents() + before = table.viewport().grab().toImage().pixelColor(sample) + QTest.keyPress(home.testButton, QtCore.Qt.Key_Space) processQtEvents() + self.assertEqual( table.viewport().grab().toImage().pixelColor(sample), before ) + QTest.keyRelease(home.testButton, QtCore.Qt.Key_Space) processQtEvents() + self.assertTrue(home.testMenu.isVisible()) + QTest.keyClick(home.testMenu, QtCore.Qt.Key_Escape) processQtEvents() + self.assertTrue(home.testButton.hasFocus()) + home.searchLineEdit.setFocus() + with mock.patch.object( table.profileTestManager, 'testPing' ) as testPing: @@ -1406,6 +1449,7 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): ) QTest.keyRelease(home.searchLineEdit, QtCore.Qt.Key_Control) processQtEvents() + testPing.assert_not_called() finally: settings.deleteLater() diff --git a/tests/test_repository_contracts.py b/tests/test_repository_contracts.py index 151375a..b987f76 100644 --- a/tests/test_repository_contracts.py +++ b/tests/test_repository_contracts.py @@ -390,8 +390,10 @@ class RepositoryContractTest(unittest.TestCase): repository = repositoryType() self.assertEqual(repository.data(), empty) + repository.cleanup() repository.cleanup() + self.assertEqual(AppSettings.get(setting), encoded) def testRecordHydrationFailureDoesNotLogPrivateInput(self): diff --git a/tests/test_subscription_manager.py b/tests/test_subscription_manager.py index 074925a..f189fdf 100644 --- a/tests/test_subscription_manager.py +++ b/tests/test_subscription_manager.py @@ -419,8 +419,10 @@ class SubscriptionManagerTest(TestCase): 'enabled': True, } } + manager = self._manager(subscriptions) manager._requestVersions['group-a'] = 1 + profile = SimpleNamespace(itemRemark='profile') manager.importer = SimpleNamespace( importPayload=mock.Mock( @@ -431,6 +433,7 @@ class SubscriptionManagerTest(TestCase): ) ) ) + successful = [] failed = [] @@ -464,6 +467,7 @@ class SubscriptionManagerTest(TestCase): ) upsert.assert_not_called() + manager.deleteLater() def testOneSynchronizationFailureDoesNotAbortOtherGroups(self): @@ -1223,10 +1227,12 @@ class SubscriptionManagerTest(TestCase): ): manager = SubscriptionManager() page = SubscriptionPage(SimpleNamespace(subsManager=manager)) + replies = [] contexts = [] completed = [] manager.updateCompleted.connect(completed.append) + timerIds = { key: timer.timerId() for key, timer in manager._autoUpdateTimers.items() } @@ -1253,6 +1259,7 @@ class SubscriptionManagerTest(TestCase): subscriptions['group-a']['lastSyncStatus'] = 'success' subscriptions['group-a']['lastUpdated'] = 'preserved timestamp' + manager._finishOperation( contexts[0], successful=contexts[0], structural=True ) @@ -1273,12 +1280,14 @@ class SubscriptionManagerTest(TestCase): self.assertEqual( subscriptions['group-b']['lastSyncStatus'], 'cancelled' ) + self.assertFalse(replies[1].isOpen()) self.assertEqual(manager._batches, {}) self.assertEqual(len(completed), 1) self.assertEqual(completed[0].successful, (contexts[0],)) self.assertEqual(completed[0].failed, ()) self.assertFalse(manager._isCurrentRequest(contexts[1])) + self.assertEqual( timerIds, { @@ -1291,6 +1300,7 @@ class SubscriptionManagerTest(TestCase): manager, '_startImportPreparation' ) as prepare: manager.successCallback(replies[1], **contexts[1]) + prepare.assert_not_called() manager.updateSubscriptions(('group-b',)) diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 792cfd5..0843bf6 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -591,6 +591,7 @@ class ServerTableBehaviorTest(unittest.TestCase): Storage._UserServersStorage.cache_clear() Storage.UserServers().extend(self._profile(name) for name in names) AppSettings.set('ActivatedItemIndex', '0') + table = ServerTableView( configurationEditorFactory=QWidget, qrCodeWindowFactory=QWidget, @@ -600,6 +601,7 @@ class ServerTableBehaviorTest(unittest.TestCase): table.search('^visible') table.show() table.activateWindow() + table.selectMultipleRows(selected, True) table.selectionModel().setCurrentIndex( table.proxyIndexFromSourceRow(selected[0]),