diff --git a/Furious/Controllers/ConnectionController.py b/Furious/Controllers/ConnectionController.py index 89abd05..2319e23 100644 --- a/Furious/Controllers/ConnectionController.py +++ b/Furious/Controllers/ConnectionController.py @@ -482,6 +482,7 @@ class ConnectionController(QtCore.QObject): operation = self._startOperation self._startOperation = None + self._setState(ConnectionState.Disconnecting) self._actionTimer.stop() diff --git a/Furious/Service/EndpointInfoService.py b/Furious/Service/EndpointInfoService.py index dc74917..8d40201 100644 --- a/Furious/Service/EndpointInfoService.py +++ b/Furious/Service/EndpointInfoService.py @@ -496,6 +496,7 @@ class EndpointInfoService(QtCore.QObject): logger.debug(f'{provider.name} IPv{family} endpoint lookup failed: {ex}') self._providerIndex += 1 + self._requestIPProvider() return @@ -518,6 +519,7 @@ class EndpointInfoService(QtCore.QObject): self._family = 6 self._providerIndex = 0 + self._requestIPProvider() return diff --git a/Furious/Service/MetricsHistory.py b/Furious/Service/MetricsHistory.py index 9e087f6..805b4f6 100644 --- a/Furious/Service/MetricsHistory.py +++ b/Furious/Service/MetricsHistory.py @@ -182,6 +182,7 @@ class MetricsHistory(QtCore.QObject): if changed: self._samples = retainedSamples + self.historyChanged.emit() @QtCore.Slot() diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index 9983f6d..719c6f9 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -449,6 +449,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self.searchLineEdit.textChanged.connect(self._searchChanged) self.autoScrollSwitch.toggled.connect(self._autoScrollChanged) self.autoClearSwitch.toggled.connect(self._autoClearChanged) + self.manager.categoryRegistered.connect(self._categoryRegistered) # Do not drive the document directly from entryAdded: cross-thread Qt diff --git a/Furious/Window/SettingsPage.py b/Furious/Window/SettingsPage.py index fcf1583..b29185c 100644 --- a/Furious/Window/SettingsPage.py +++ b/Furious/Window/SettingsPage.py @@ -120,15 +120,18 @@ class _SettingsCard(Mixins.ThemeAware, QFrame): self.setObjectName('SettingsCard') self.iconFileName = '' + self.iconLabel = QLabel(parent=self) self.iconLabel.setObjectName('SettingsCardIcon') self.iconLabel.setFixedSize(self.IconSize) + self.titleLabel = AppQLabel( title, translatable=translatable, parent=self, ) self.titleLabel.setObjectName('SettingsCardTitle') + self.descriptionLabel = AppQLabel( description, translatable=translatable, @@ -136,6 +139,7 @@ class _SettingsCard(Mixins.ThemeAware, QFrame): ) self.descriptionLabel.setObjectName('SettingsCardDescription') self.descriptionLabel.setWordWrap(True) + self.control = control self.control.setParent(self) diff --git a/tests/support.py b/tests/support.py index 7d52548..e163e4a 100644 --- a/tests/support.py +++ b/tests/support.py @@ -70,6 +70,7 @@ class TestApplication(QApplication): # MainWindow minimum on all hosts. Give geometry tests a real-sized # virtual desktop without altering any physical display. configuration = Path(__file__).with_name('fixtures') / 'offscreen.json' + # Platform arguments are colon-separated, including on Windows. configuration = os.path.relpath(configuration) diff --git a/tests/test_external_core.py b/tests/test_external_core.py index 10fb2a6..fa40e4e 100644 --- a/tests/test_external_core.py +++ b/tests/test_external_core.py @@ -170,6 +170,7 @@ class ExternalCoreProcessTest(unittest.TestCase): for child in children: if child.poll() is None: child.kill() + child.wait(timeout=5) for thread in threads: @@ -182,6 +183,7 @@ class ExternalCoreProcessTest(unittest.TestCase): stream.close() runtime._watcherThread = None + runtime.dispose() def testWindowsTaskkillHasBoundedWait(self): @@ -601,6 +603,7 @@ class ExternalCoreProcessTest(unittest.TestCase): prefix='furious executable path ', dir=Path.cwd() ) as directory: executable = Path(directory) / 'python executable.exe' + # The interpreter and checkout can be on different Windows volumes. # Copy the base interpreter (not a venv redirector) and its DLLs; # PYTHONHOME supplies its existing standard library without installing @@ -616,11 +619,13 @@ class ExternalCoreProcessTest(unittest.TestCase): environment={'PYTHONHOME': sys.base_prefix}, ) config['executable'] = str(executable) + messages = [] runtime = ExternalCoreProcess(config, msgCallback=messages.append) try: runtime.start() + self.assertTrue( self.waitFor( lambda: any('ready' in message for message in messages) diff --git a/tests/test_hysteria2_compatibility.py b/tests/test_hysteria2_compatibility.py index b29c9ed..735633c 100644 --- a/tests/test_hysteria2_compatibility.py +++ b/tests/test_hysteria2_compatibility.py @@ -406,13 +406,17 @@ class Hysteria2CompatibilityTest(unittest.TestCase): ), ('dual', 'v4', 'v6'), ) + ipMode.setText('v4') + chromeParrot = self.binding(editor, ('quic', 'disableChromeParrot')) chromeParrot.setChecked(True) + mimicEnabled = self.binding(editor, ('mimic', 'enabled')) mimicEnabled.setChecked(True) self.assertTrue(editor.inputToFactory(profile)) + self.assertEqual(profile.connection['realm']['ipMode'], 'v4') self.assertTrue(profile.connection['mimic']['enabled']) self.assertTrue(profile.connection['quic']['disableChromeParrot']) diff --git a/tests/test_log_manager_generation.py b/tests/test_log_manager_generation.py index b60f8e9..9c3cd00 100644 --- a/tests/test_log_manager_generation.py +++ b/tests/test_log_manager_generation.py @@ -179,6 +179,7 @@ def _assertManagerInvariants(testCase, manager, model=None): """ with manager._lock: generations = manager._activeGenerationsLocked() + testCase.assertEqual(len(generations), 3) testCase.assertEqual(len({id(item) for item in generations}), 3) testCase.assertEqual(len({item.identifier for item in generations}), 3) @@ -193,6 +194,7 @@ def _assertManagerInvariants(testCase, manager, model=None): for generation in generations: chronological = tuple(generation.entries.values()) + testCase.assertEqual( tuple(entry.sequence for entry in chronological), tuple(sorted(entry.sequence for entry in chronological)), @@ -203,11 +205,13 @@ def _assertManagerInvariants(testCase, manager, model=None): ) indexedSequences = [] + for categoryId, categoryEntries in generation.entriesByCategory.items(): category = manager._categories[categoryId] testCase.assertIs( manager._generationForCategoryLocked(category), generation ) + indexed = tuple(categoryEntries.entries.values()) testCase.assertTrue(indexed) testCase.assertEqual( @@ -221,6 +225,7 @@ def _assertManagerInvariants(testCase, manager, model=None): categoryEntries.characterCount, sum(len(entry.message) for entry in indexed), ) + indexedSequences.extend(entry.sequence for entry in indexed) categoryTruth[categoryId].extend(indexed) @@ -228,12 +233,15 @@ def _assertManagerInvariants(testCase, manager, model=None): indexedSequences, (entry.sequence for entry in chronological), ) + for entry in chronological: testCase.assertNotIn(id(entry), liveObjectIds) liveObjectIds.add(id(entry)) + liveEntries.extend(chronological) liveEntries.sort(key=lambda entry: entry.sequence) + testCase.assertEqual( tuple(entry.sequence for entry in liveEntries), tuple(sorted({entry.sequence for entry in liveEntries})), @@ -247,15 +255,19 @@ def _assertManagerInvariants(testCase, manager, model=None): testCase.assertGreaterEqual(manager._retainedCharacters, 0) retiredEntries = [] + for batch in manager._retiredBatches: entries = _batchEntries(batch) + testCase.assertEqual( batch.characterCount, sum(len(entry.message) for entry in entries), ) + retiredEntries.extend(entries) retiredIds = {id(entry) for entry in retiredEntries} + testCase.assertTrue(liveObjectIds.isdisjoint(retiredIds)) testCase.assertEqual(len(retiredEntries), manager._retiredEntryCount) testCase.assertEqual( @@ -266,6 +278,7 @@ def _assertManagerInvariants(testCase, manager, model=None): testCase.assertGreaterEqual(manager._retiredCharacters, 0) publicEntries = manager.entries() + testCase.assertEqual(publicEntries, tuple(liveEntries)) testCase.assertTrue(retiredIds.isdisjoint(id(entry) for entry in publicEntries)) testCase.assertEqual(manager.entryCount(), len(liveEntries)) @@ -276,6 +289,7 @@ def _assertManagerInvariants(testCase, manager, model=None): for category in manager.categories(): expected = tuple(categoryTruth[category.id]) + testCase.assertEqual(manager.entries(category.id), expected) testCase.assertEqual(manager.entryCount(category.id), len(expected)) @@ -526,6 +540,7 @@ class GenerationLogManagerContractTest(unittest.TestCase): maximumEntryCharacters=100, autoClearEnabled=False, ) + for categoryId in ( APPLICATION_LOG_CATEGORY, CORE_LOG_CATEGORY, @@ -535,30 +550,38 @@ class GenerationLogManagerContractTest(unittest.TestCase): globalIndexes = [] categoryIndexes = [] + for generation in manager._activeGenerationsLocked(): observed = _ObservedEntries(generation.entries) generation.entries = observed globalIndexes.append(observed) + for categoryEntries in generation.entriesByCategory.values(): observed = _ObservedEntries(categoryEntries.entries) categoryEntries.entries = observed categoryIndexes.append(observed) manager.snapshot(CORE_LOG_CATEGORY) + self.assertEqual(sum(index.iterations for index in globalIndexes), 0) self.assertEqual(sum(index.iterations for index in categoryIndexes), 1) + for index in (*globalIndexes, *categoryIndexes): index.iterations = 0 manager.snapshot() + self.assertEqual(sum(index.iterations for index in globalIndexes), 3) self.assertEqual(sum(index.iterations for index in categoryIndexes), 0) + for index in globalIndexes: index.iterations = 0 manager.append('evict', CORE_LOG_CATEGORY) + self.assertLessEqual(sum(index.iterations for index in globalIndexes), 3) self.assertEqual(sum(index.oldestRemovals for index in globalIndexes), 1) + _assertManagerInvariants(self, manager) def testIncrementalBatchesReturnOnlyOrderedMissingEntries(self): @@ -922,6 +945,7 @@ class GenerationLogManagerContractTest(unittest.TestCase): maximumCharacters=20_000, autoClearEnabled=False, ) + manager.appendMany(tuple(f'initial {index}' for index in range(200))) fullReads = [] @@ -971,6 +995,7 @@ class GenerationLogManagerContractTest(unittest.TestCase): for index in range(backlog): manager.append(str(index), CORE_LOG_CATEGORY) + if index in (1, 4): manager.clear(runtimeOnly=True) @@ -987,6 +1012,7 @@ class GenerationLogManagerContractTest(unittest.TestCase): self.assertEqual(released, min(budget, before)) self.assertEqual(manager.retiredEntryCount, before - released) + if manager._retiredBatches and identifiers: self.assertIn( getattr(manager._retiredBatches[0], 'identifier', None), @@ -996,6 +1022,7 @@ class GenerationLogManagerContractTest(unittest.TestCase): _assertManagerInvariants(self, manager) manager = self.makeManager() + manager.append('retired', CORE_LOG_CATEGORY) manager.clear(runtimeOnly=True) @@ -1003,6 +1030,7 @@ class GenerationLogManagerContractTest(unittest.TestCase): self.assertEqual(manager._cleanupRetiredLocked(0), 0) manager.RetiredCleanupBudget = 0 + with manager._lock: self.assertEqual(manager._cleanupRetiredLocked(), 1) @@ -1105,6 +1133,7 @@ class GenerationLogManagerContractTest(unittest.TestCase): autoClearEnabled=False, ) manager._sequence = 10**40 + categories = ( APPLICATION_LOG_CATEGORY, CORE_LOG_CATEGORY, @@ -1115,17 +1144,21 @@ class GenerationLogManagerContractTest(unittest.TestCase): CORE_LOG_CATEGORY, ) expected = [] + for index, categoryId in enumerate(categories): entry = manager.append(str(index) * (index % 4 + 1), categoryId) expected.append(entry) + while ( len(expected) > manager.maximumEntries or sum(len(item.message) for item in expected) > manager.maximumCharacters ): del expected[0] + self.assertEqual(manager.entries(), tuple(expected)) _assertManagerInvariants(self, manager) + self.assertTrue(all(entry.sequence > 10**40 for entry in manager.entries())) def testSelectiveClearAndRegistrationStress(self): diff --git a/tests/test_models_and_services.py b/tests/test_models_and_services.py index 58b0518..d9bd078 100644 --- a/tests/test_models_and_services.py +++ b/tests/test_models_and_services.py @@ -936,8 +936,10 @@ class LogManagerTest(unittest.TestCase): def testQueuedCleanupDoesNotOutliveDestroyedManager(self): """Discard pending self-delivery at the manager's QObject boundary.""" application() + manager = LogManager(maximumEntries=100, autoClearEnabled=False) manager.RetiredCleanupBudget = 1 + destroyed = [] manager.destroyed.connect(lambda: destroyed.append(True)) @@ -947,6 +949,7 @@ class LogManagerTest(unittest.TestCase): manager.clear(runtimeOnly=True) managerReference = weakref.ref(manager) + manager.deleteLater() del manager diff --git a/tests/test_profile_test_jobs.py b/tests/test_profile_test_jobs.py index 764cb47..90e494a 100644 --- a/tests/test_profile_test_jobs.py +++ b/tests/test_profile_test_jobs.py @@ -565,10 +565,12 @@ class ProfileTestServiceTest(unittest.TestCase): concurrent=False, ) processQtEvents() + firstWorker = _ControlledDownloadWorker.instances[0] self._setProfiles((secondProfile, firstProfile)) firstProfile.metadata.subscriptionSource = 'new-subscription' + manager.reconcileProfiles() self.assertEqual(firstWorker.cancelCount, 0) @@ -576,6 +578,7 @@ class ProfileTestServiceTest(unittest.TestCase): firstWorker.finish('1.00 MiB/s') processQtEvents() + secondWorker = _ControlledDownloadWorker.instances[1] secondWorker.finish('2.00 MiB/s') processQtEvents() @@ -1114,7 +1117,9 @@ class ProfileTestServiceTest(unittest.TestCase): profile = self._profile('profile', 'profile.example') Storage.UserServers().append(profile) profile.index = 0 + AppSettings.set('ActivatedItemIndex', '-1') + table = ServerTableView( configurationEditorFactory=QWidget, qrCodeWindowFactory=QWidget, @@ -1142,9 +1147,11 @@ class ProfileTestServiceTest(unittest.TestCase): finally: table.cleanup() table.deleteLater() + Storage.UserServers().clear() Storage._UserServersStorage.cache_clear() Storage._UserSubsStorage.cache_clear() + processQtEvents() diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index a89b0f6..502a98a 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -1482,6 +1482,7 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): """Use existing menus, search clear and group selection to recover profiles.""" with isolatedSettings(): AppSettings.set('Language', 'EN') + settings = SettingsController() connection = _ConnectionControllerFixture() routing = _RoutingControllerFixture( @@ -1524,9 +1525,12 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): self.assertEqual(imported, [True]) table = home.userServersQTableWidget + home.activateWindow() table.setFocus() + self.assertTrue(waitFor(table.hasFocus)) + QTest.keyClick(table, QtCore.Qt.Key_V, QtCore.Qt.ControlModifier) processQtEvents() @@ -1537,6 +1541,7 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): ) profile = ServerTableQtInteractionTest._profile('alpha') + home.userServersQTableWidget.appendNewItemByFactory(profile) processQtEvents() @@ -1606,6 +1611,7 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): ) self.assertTrue(testActions[-1].icon().isNull()) self.assertIs(home.testButton.popupMenu(), home.testMenu) + for testAction in testActions: self.assertNotIn(testAction, contextActions) self.assertIn(testAction, table.actions()) @@ -1632,8 +1638,10 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): ) as cancel: table = home.userServersQTableWidget menu = home.testMenu + QTest.mouseClick(home.testButton, QtCore.Qt.LeftButton) processQtEvents() + menu.setActiveAction(table.testActions[-1]) QTest.keyClick(menu, QtCore.Qt.Key_Return) processQtEvents() diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index 8ceb08f..f41e827 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -211,10 +211,12 @@ class QtLifetimeTest(unittest.TestCase): box.setStandardButtons(box.StandardButton.Yes) button = box.button(box.StandardButton.Yes) references.append(weakref.ref(button)) + box.setDefaultButton(button) box.setEscapeButton(button) box.setStandardButtons(box.StandardButton.No) + del button processQtEvents() diff --git a/tests/test_service_runtime.py b/tests/test_service_runtime.py index 99cdb0c..9cfee10 100644 --- a/tests/test_service_runtime.py +++ b/tests/test_service_runtime.py @@ -252,6 +252,7 @@ class HttpGetManagerLifetimeTest(unittest.TestCase): manager = managerType() references = [] destroyed = [] + self.addCleanup(manager.deleteLater) for _ in range(30): @@ -269,6 +270,7 @@ class HttpGetManagerLifetimeTest(unittest.TestCase): manager.request('https://invalid.test', payload) del payload + reply.deleteLater() processQtEvents() diff --git a/tests/test_stylesheet_states.py b/tests/test_stylesheet_states.py index 3ed4aa0..db43a30 100644 --- a/tests/test_stylesheet_states.py +++ b/tests/test_stylesheet_states.py @@ -114,12 +114,15 @@ class StyleSheetStateRenderingTest(unittest.TestCase): frame = QImage(actual.size(), QImage.Format.Format_ARGB32_Premultiplied) frame.setDevicePixelRatio(actual.devicePixelRatio()) frame.fill(QtCore.Qt.transparent) + view.render(frame, renderFlags=QWidget.RenderFlag.DrawWindowBackground) + panel = QColor(AppStyleSheet.Palettes[theme]['panel']) paddingPoint = QtCore.QPoint(view.width() // 2, 2) host = view.parentWidget() hostImage = host.grab().toImage() hostPoint = view.mapTo(host, paddingPoint) + self.assertEqual( hostImage.pixelColor( round(hostPoint.x() * hostImage.devicePixelRatio()), @@ -127,6 +130,7 @@ class StyleSheetStateRenderingTest(unittest.TestCase): ), QColor(AppStyleSheet.Palettes[theme]['window']), ) + cornerSize = round(8 * actual.devicePixelRatio()) for left in (0, actual.width() - cornerSize): @@ -244,6 +248,7 @@ class StyleSheetStateRenderingTest(unittest.TestCase): submenu = AppQMenu(parent=menu) submenu.setTitle('More') submenu.addAction('Another example') + menu.addMenu(submenu) try: @@ -257,6 +262,7 @@ class StyleSheetStateRenderingTest(unittest.TestCase): shadow = QGraphicsDropShadowEffect(menu) shadow.setBlurRadius(3) shadow.setOffset(3, 3) + menu.setGraphicsEffect(shadow) menu.setLayoutDirection(direction) menu.popup(QtCore.QPoint(20, 20)) @@ -271,15 +277,19 @@ class StyleSheetStateRenderingTest(unittest.TestCase): if direction == QtCore.Qt.LeftToRight else QtCore.Qt.Key_Left ) + QTest.keyClick(menu, openKey) waitFor(submenu.isVisible) + self.assertRoundedPopup(submenu) QTest.keyClick(submenu, QtCore.Qt.Key_Escape) + self.assertFalse(submenu.isVisible()) menu.setActiveAction(action) wasChecked = action.isChecked() + QTest.keyClick(menu, QtCore.Qt.Key_Return) self.assertEqual(action.isChecked(), not wasChecked) @@ -288,6 +298,7 @@ class StyleSheetStateRenderingTest(unittest.TestCase): menu.close() menu.deleteLater() processQtEvents() + app.setStyleSheet(originalStyleSheet) def testComboPopupCornersStayTransparentAcrossThemesAndReopens(self): diff --git a/tests/test_subscription_manager.py b/tests/test_subscription_manager.py index bd342fe..02a4793 100644 --- a/tests/test_subscription_manager.py +++ b/tests/test_subscription_manager.py @@ -713,6 +713,7 @@ class SubscriptionManagerTest(TestCase): lifecycleLog.assert_not_called() subscriptions['group-a']['autoupdate'] = 'Every 5 mins' + manager.configureAutoUpdate('group-a') self.assertTrue(timer.isActive()) @@ -720,12 +721,14 @@ class SubscriptionManagerTest(TestCase): self.assertIn('start auto update job', lifecycleLog.call_args.args[0]) activeTimerId = timer.timerId() + manager.configureAutoUpdate('group-a') self.assertEqual(timer.timerId(), activeTimerId) self.assertEqual(lifecycleLog.call_count, 1) subscriptions['group-a']['autoupdate'] = 'Every 10 mins' + manager.configureAutoUpdate('group-a') self.assertIs(manager._autoUpdateTimers['group-a'], timer) @@ -733,6 +736,7 @@ class SubscriptionManagerTest(TestCase): self.assertIn('reschedule auto update job', lifecycleLog.call_args.args[0]) subscriptions['group-a']['enabled'] = False + manager.configureAutoUpdate('group-a') self.assertFalse(timer.isActive()) @@ -743,6 +747,7 @@ class SubscriptionManagerTest(TestCase): self.assertEqual(lifecycleLog.call_count, 3) subscriptions['group-a']['enabled'] = True + manager.configureAutoUpdate('group-a') self.assertTrue(timer.isActive()) diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 919d89e..fb921ef 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -918,6 +918,7 @@ class QRCodeWindowBehaviorTest(unittest.TestCase): firstPage.destroyed.connect(lambda *_args: firstPageDestroyed.append(True)) window.handleTabCloseRequested(0) + del firstPage collectAtBoundary() @@ -931,6 +932,7 @@ class QRCodeWindowBehaviorTest(unittest.TestCase): window.destroyed.connect(lambda *_args: windowDestroyed.append(True)) window.handleTabCloseRequested(0) + del window collectAtBoundary() @@ -2552,11 +2554,13 @@ class RoutingChangeNoticeTest(unittest.TestCase): self.view.setEnabled(1, 'Disabled') self.connection.isConnected.return_value = False + self.view.setDomainStrategy(0, 'AsIs') self.view.setEnabled(0, 'Enabled') self.connection.isConnected.return_value = True self.routing.routing = 'Global' + self.view.setDomainStrategy(0, 'IPIfNonMatch') self.notice.assert_not_called()