diff --git a/Furious/Controllers/ConnectionController.py b/Furious/Controllers/ConnectionController.py index 087c299..6e1beee 100644 --- a/Furious/Controllers/ConnectionController.py +++ b/Furious/Controllers/ConnectionController.py @@ -397,6 +397,7 @@ class ConnectionController(QtCore.QObject): ) self._finishConnecting() + self.notificationRequested.emit( f'{configuration.coreName()}: ' + _('Connected') ) @@ -419,6 +420,7 @@ class ConnectionController(QtCore.QObject): return self._startOperation = None + self._emitRuntimesChanged() while not self._actionQueue.empty(): @@ -443,6 +445,7 @@ class ConnectionController(QtCore.QObject): return self._startOperation = None + self._emitRuntimesChanged() configuration = self.activeProfile @@ -461,6 +464,7 @@ class ConnectionController(QtCore.QObject): return self._startOperation = None + self._emitRuntimesChanged() self._reset() diff --git a/Furious/Service/ProfileTesting.py b/Furious/Service/ProfileTesting.py index 9ee0331..2682b3c 100644 --- a/Furious/Service/ProfileTesting.py +++ b/Furious/Service/ProfileTesting.py @@ -348,6 +348,7 @@ class _LatencyScheduler(QtCore.QObject): _LatencyTestJob(ProfileTestTarget.capture(profile), options) for profile in profiles ) + self.scheduleDrain() def ensureTcpingEngine(self): diff --git a/Furious/Service/SubscriptionManager.py b/Furious/Service/SubscriptionManager.py index 0a412a2..a5b9a3f 100644 --- a/Furious/Service/SubscriptionManager.py +++ b/Furious/Service/SubscriptionManager.py @@ -501,6 +501,7 @@ class SubscriptionManager(HttpGetManager): ) self._preparationJobs[jobId] = job + self._preparationPool.start(job) return jobId @@ -681,6 +682,7 @@ class SubscriptionManager(HttpGetManager): self.subscriptionCommitted.emit(context['unique']) self._recordGroupSuccess(committed, result) + self.subscriptionStateChanged.emit((context['unique'],)) self._finishOperation(committed, successful=committed, structural=True) @@ -696,6 +698,7 @@ class SubscriptionManager(HttpGetManager): if self._isCurrentRequest(context): self._recordGroupFailure(failed) + self.subscriptionStateChanged.emit((context.get('unique', ''),)) self._finishOperation(context, failed=failed) diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 5eddb25..a31d02d 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -1454,6 +1454,7 @@ class ServerTableView( # Refresh index """Refresh all.""" self.sourceModel.refreshIndexes() + self.sourceModel.emitAllChanged() def _profileIdsForSourceRows(self, rows) -> list[str]: @@ -1998,6 +1999,7 @@ class ServerTableView( ) mbox.setText(mbox.customText()) mbox.setColumnMinWidth() + mbox.open() def appendNewItemByFactory(self, factory: CoreConfiguration | ServerProfile): diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index 7e9a035..0077bf7 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -654,6 +654,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): qrCodeWindowFactory=QRCodeWindow, importActionsFactory=self.serverImportActions, ) + pluginRegistry = getPluginRegistry() # These two settings dialogs intentionally remain parent-owned and are @@ -693,6 +694,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): if serverActions: serverActions.append(AppQSeparator()) + serverActions.append( AppQAction( _('New Empty Configuration'), @@ -748,6 +750,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.activateSelectedServerForConnection, parent=self, ) + self.routingSelector = RoutingSelector(parent=self) self.systemProxyComboBox = AppQComboBox(parent=self) @@ -780,6 +783,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): ) self.searchButton = SearchButton() + self.subscriptionFilterComboBox = AppQComboBox() self.subscriptionFilterComboBox.setContentWidthAdjustable() self.subscriptionFilterComboBox.setMinimumWidth(190) @@ -817,12 +821,16 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self._layout.addLayout(self.headerLayout) self._layout.addLayout(self.connectionLayout) self._layout.addLayout(self.actionLayout) + self.emptyState = QWidget(parent=self) emptyLayout = QHBoxLayout(self.emptyState) emptyLayout.setContentsMargins(0, 0, 0, 0) + self.emptyStateLabel = AppQLabel(translatable=False, parent=self.emptyState) self.emptyStateLabel.setWordWrap(True) + emptyLayout.addWidget(self.emptyStateLabel, 1) + self._layout.addWidget(self.emptyState) self._layout.addWidget(self.userServersQTableWidget, 1) @@ -837,6 +845,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): model.layoutChanged, ): connectWeakly(signal, self, 'refreshEmptyState', sender=model) + self.refreshEmptyState() self.searchButton.clicked.connect( @@ -888,6 +897,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): """Explain empty storage separately from an empty filtered view.""" table = self.userServersQTableWidget empty = table.proxyModel.rowCount() == 0 + self.emptyState.setVisible(empty) if empty: self.emptyStateLabel.setText( diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index 69e671b..9fe9673 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -216,6 +216,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed, ) + self._searchRegex = None self.textBrowser = DraculaTextBrowser( @@ -292,10 +293,12 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self.emptyState = QWidget(parent=self) emptyLayout = QHBoxLayout(self.emptyState) emptyLayout.setContentsMargins(0, 0, 0, 0) + self.emptyStateLabel = AppQLabel( _('No logs match the current filters.'), parent=self.emptyState ) self.emptyStateLabel.setWordWrap(True) + emptyLayout.addWidget(self.emptyStateLabel, 1) self.emptyState.hide() @@ -711,6 +714,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): or self._renderedCategoryId != selectedCategoryId else self._entryCursor ) + batch = self.manager.entriesSince(cursor, selectedCategoryId) if self._searchRegex is not None: @@ -739,6 +743,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self._entryCursor = batch.cursor self._renderedSequence = batch.cursor.sequence self._entriesDirty = False + self.emptyState.setVisible( not self._renderedEntries and (self._searchRegex is not None or selectedCategoryId != ALL_LOGS_FILTER) diff --git a/tests/test_profile_test_jobs.py b/tests/test_profile_test_jobs.py index 8354ab1..a2c9402 100644 --- a/tests/test_profile_test_jobs.py +++ b/tests/test_profile_test_jobs.py @@ -315,25 +315,32 @@ class ProfileTestServiceTest(unittest.TestCase): """Cancel active and queued work across all schedulers without shutting down.""" profiles = [self._profile(str(i), f'{i}.example') for i in range(3)] profiles[0].metadata.latency = 'old latency' + manager = self._manager(profiles) scheduler = manager._latencyScheduler scheduler.threadPool = pool = _ControlledThreadPool() scheduler.pingWorkerFactory = _ControlledLatencyWorker + manager.testPing(profiles) manager.testDownloadSpeed(profiles, concurrent=False) manager.testDownloadSpeed(profiles, concurrent=True) processQtEvents() + workers = list(_ControlledDownloadWorker.instances) workers[0].publish('partial speed') + manager.cancelAll() manager.cancelAll() + pool.started[0].finish('late latency') processQtEvents() + self.assertEqual(profiles[0].metadata.latency, 'old latency') self.assertEqual(profiles[0].metadata.speed, 'partial speed') self.assertTrue(all(worker.cancelCount == 1 for worker in workers)) self.assertFalse(scheduler.queue) self.assertFalse(scheduler.activeJobs) + for downloads in ( manager._serialDownloadScheduler, manager._concurrentDownloadScheduler, @@ -341,10 +348,13 @@ class ProfileTestServiceTest(unittest.TestCase): self.assertFalse(downloads.queue) self.assertFalse(downloads.activeJobs) self.assertFalse(downloads.activePorts) + manager.testPing(profiles[:1]) processQtEvents() + pool.started[-1].finish('new latency') processQtEvents() + self.assertEqual(profiles[0].metadata.latency, 'new latency') def testCancelAllDropsBufferedTcpingFanoutAndAcceptsNewGeneration(self): @@ -354,22 +364,29 @@ class ProfileTestServiceTest(unittest.TestCase): scheduler = manager._latencyScheduler sink = QtCore.QObject() scheduler.tcpingEngine = sink + try: manager.testTcping(profiles) + oldRequest = next(iter(scheduler.tcpingRequests)) scheduler.handleTcpingResult(oldRequest, '5ms') scheduler.drainTcpingResults() + manager.cancelAll() processQtEvents() + self.assertEqual(sum(p.metadata.latency == '5ms' for p in profiles), 64) self.assertFalse(scheduler.tcpingRequests) self.assertFalse(scheduler.tcpingEndpointRequests) self.assertFalse(scheduler.tcpingCompletionQueue) + manager.testTcping(profiles[-1:]) scheduler.handleTcpingResult(oldRequest, 'late') + newRequest = next(iter(scheduler.tcpingRequests)) scheduler.handleTcpingResult(newRequest, '9ms') processQtEvents() + self.assertEqual(profiles[-1].metadata.latency, '9ms') finally: scheduler.tcpingEngine = None @@ -379,7 +396,9 @@ class ProfileTestServiceTest(unittest.TestCase): """A retained shutdown manager cannot silently acquire new work.""" profile = self._profile('profile', 'example.test') manager = self._manager((profile,)) + manager.shutdown() + with mock.patch.object( manager._latencyScheduler, 'enqueue' ) as latency, mock.patch.object( @@ -388,6 +407,7 @@ class ProfileTestServiceTest(unittest.TestCase): manager.testPing((profile,)) manager.testTcping((profile,)) manager.testDownloadSpeed((profile,)) + latency.assert_not_called() download.assert_not_called() diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index 82ac324..d8c92eb 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -1008,12 +1008,14 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): routing = _RoutingControllerFixture( (RoutingOption('default', 'Default'),), 'default' ) + imported = [] action = AppQAction( 'Fixture import', callback=lambda: imported.append(True), translatable=False, ) + try: with self._home( settings, connection, routing, importActions=(action,) @@ -1022,54 +1024,71 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): home.show() home.activateWindow() processQtEvents() + self.assertTrue(home.emptyState.isVisible()) self.assertIn('No profiles yet', home.emptyStateLabel.text()) self.assertIs(home.importMenu.actions()[0], action) self.assertIn( action, home.userServersQTableWidget.contextMenu.actions() ) + QTest.mouseClick(home.importButton, QtCore.Qt.LeftButton) processQtEvents() + QTest.keyClick(home.importMenu, QtCore.Qt.Key_Down) QTest.keyClick(home.importMenu, QtCore.Qt.Key_Return) processQtEvents() + self.assertEqual(imported, [True]) + profile = ServerTableQtInteractionTest._profile('alpha') home.userServersQTableWidget.appendNewItemByFactory(profile) processQtEvents() + self.assertFalse(home.emptyState.isVisible()) + home.searchLineEdit.setFocus() QTest.keyClicks(home.searchLineEdit, 'missing') QTest.keyClick(home.searchLineEdit, QtCore.Qt.Key_Return) processQtEvents() + self.assertTrue(home.emptyState.isVisible()) + QTest.mouseClick( home.searchLineEdit.findChild(QToolButton), QtCore.Qt.LeftButton ) processQtEvents() + self.assertFalse(home.emptyState.isVisible()) self.assertTrue(home.searchLineEdit.hasFocus()) self.assertEqual(home.searchLineEdit.text(), '') self.assertIs(Storage.UserServers()[0], profile) + home.subscriptionFilterComboBox.addItem( 'Empty group', 'missing-group' ) home.subscriptionFilterComboBox.setCurrentIndex(2) processQtEvents() + self.assertTrue(home.emptyState.isVisible()) + home.subscriptionFilterComboBox.setFocus() QTest.keyClick(home.subscriptionFilterComboBox, QtCore.Qt.Key_Home) processQtEvents() + self.assertEqual(home.subscriptionFilterComboBox.currentIndex(), 0) self.assertEqual( home.userServersQTableWidget.proxyModel.rowCount(), 1 ) + for testAction in home.userServersQTableWidget.testActions: self.assertIn( testAction, home.userServersQTableWidget.contextMenu.actions(), ) + manager = home.userServersQTableWidget.profileTestManager + with mock.patch.object( manager._latencyScheduler, 'cancelAll' ) as cancel: @@ -1080,11 +1099,14 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): menu.setActiveAction(table.testActions[-1]) QTest.keyClick(menu, QtCore.Qt.Key_Return) processQtEvents() + cancel.assert_called_once_with() + home.userServersQTableWidget.deleteItemByIndex( [0], showTrayMessage=False, showProgress=False ) processQtEvents() + self.assertTrue(home.emptyState.isVisible()) self.assertIn('No profiles yet', home.emptyStateLabel.text()) finally: diff --git a/tests/test_stylesheet_states.py b/tests/test_stylesheet_states.py index f80548a..5a58c02 100644 --- a/tests/test_stylesheet_states.py +++ b/tests/test_stylesheet_states.py @@ -88,12 +88,14 @@ class StyleSheetStateRenderingTest(unittest.TestCase): """Keep embedded buttons centered, theme-correct and bounded after restyling.""" app = application() originalStyleSheet = app.styleSheet() + edit = AppQLineEdit() edit.setClearButtonEnabled(True) edit.resize(340, 36) edit.show() edit.activateWindow() edit.setFocus() + try: for theme in (AppStyleSheet.Light, AppStyleSheet.Dark) * 3: for direction in (QtCore.Qt.LeftToRight, QtCore.Qt.RightToLeft): @@ -101,9 +103,12 @@ class StyleSheetStateRenderingTest(unittest.TestCase): edit.setLayoutDirection(direction) edit.setText('example search') edit.setSelection(0, 7) + app.setStyleSheet(AppStyleSheet.forTheme(theme)) processQtEvents() + self.assertEqual(edit.selectedText(), 'example') + buttons = edit.findChildren(QToolButton) self.assertEqual(len(buttons), 1) button = buttons[0] @@ -115,6 +120,7 @@ class StyleSheetStateRenderingTest(unittest.TestCase): ), 1, ) + self.assertFalse(button.icon().isNull()) pixmap = button.icon().pixmap(16, 16).toImage() colors = [ @@ -123,6 +129,7 @@ class StyleSheetStateRenderingTest(unittest.TestCase): for y in range(pixmap.height()) if pixmap.pixelColor(x, y).alpha() > 128 ] + self.assertTrue(colors) self.assertTrue( all( @@ -131,25 +138,32 @@ class StyleSheetStateRenderingTest(unittest.TestCase): for color in colors ) ) + QTest.mouseClick(button, QtCore.Qt.LeftButton) + self.assertEqual(edit.text(), '') self.assertTrue(edit.hasFocus()) self.assertTrue(waitFor(lambda: not button.isVisible())) + edit.setText('read only') edit.setReadOnly(True) app.setStyleSheet(AppStyleSheet.forTheme(AppStyleSheet.Light)) processQtEvents() + self.assertEqual(edit.text(), 'read only') self.assertFalse(edit.findChild(QToolButton).isEnabled()) + app.setStyleSheet(AppStyleSheet.forTheme(AppStyleSheet.Dark)) edit.setClearButtonEnabled(False) processQtEvents() + self.assertFalse(edit.isClearButtonEnabled()) self.assertFalse(edit.findChildren(QToolButton)) finally: edit.close() edit.deleteLater() processQtEvents() + app.setStyleSheet(originalStyleSheet) def testFlatAndLinkButtonsRetainFocusedOutlineDuringHover(self): diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index e603bfc..a34ef18 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -1654,6 +1654,7 @@ class UnifiedLogPageTest(unittest.TestCase): page.searchLineEdit.setText('[literal') self.assertRendered(page) + self.assertEqual(page.plainText(), 'application beta [literal]') self.assertTrue(page.searchLineEdit.toolTip()) @@ -1667,8 +1668,10 @@ class UnifiedLogPageTest(unittest.TestCase): clearButton = page.searchLineEdit.findChild(QToolButton) self.assertIsNotNone(clearButton) + QTest.mouseClick(clearButton, QtCore.Qt.MouseButton.LeftButton) self.assertRendered(page) + self.assertEqual(page.searchLineEdit.text(), '') self.assertFalse(page.searchLineEdit.toolTip()) self.assertEqual( @@ -1692,24 +1695,30 @@ class UnifiedLogPageTest(unittest.TestCase): manager = LogManager(maximumEntries=5) page = LogPage(manager=manager) self.addCleanup(self.disposePage, page) + page.show() self.assertRendered(page) + self.assertFalse(page.emptyState.isVisible()) manager.append('application one', APPLICATION_LOG_CATEGORY) self.assertRendered(page) + page.filterComboBox.setCurrentIndex( page.filterComboBox.findData(CORE_LOG_CATEGORY) ) self.assertRendered(page) + self.assertTrue(page.emptyState.isVisible()) manager.append('core one', CORE_LOG_CATEGORY) self.assertRendered(page) + self.assertFalse(page.emptyState.isVisible()) page.searchLineEdit.setText('[missing') self.assertRendered(page) + self.assertTrue(page.emptyState.isVisible()) self.assertEqual(page.plainText(), '') self.assertTrue(page.searchLineEdit.toolTip()) @@ -1719,15 +1728,18 @@ class UnifiedLogPageTest(unittest.TestCase): QtCore.Qt.MouseButton.LeftButton, ) self.assertRendered(page) + self.assertFalse(page.emptyState.isVisible()) self.assertEqual(page.searchLineEdit.text(), '') self.assertFalse(page.searchLineEdit.toolTip()) self.assertEqual(page.filterComboBox.currentData(), CORE_LOG_CATEGORY) self.assertEqual(page.plainText(), 'core one') self.assertTrue(page.searchLineEdit.hasFocus()) + page.filterComboBox.setFocus() QTest.keyClick(page.filterComboBox, QtCore.Qt.Key.Key_Home) self.assertRendered(page) + self.assertEqual(page.filterComboBox.currentData(), ALL_LOGS_FILTER) self.assertEqual( AppSettings.get('LogViewerSelectedCategory'), ALL_LOGS_FILTER @@ -1742,23 +1754,29 @@ class UnifiedLogPageTest(unittest.TestCase): manager = LogManager(maximumEntries=2) page = LogPage(manager=manager) self.addCleanup(self.disposePage, page) + manager.append('match first') page.searchLineEdit.setText('match') + page.show() self.assertRendered(page) + self.assertFalse(page.emptyState.isVisible()) manager.append('other one') manager.append('other two') self.assertRendered(page) + self.assertTrue(page.emptyState.isVisible()) self.assertEqual(page.plainText(), '') page.hide() manager.append('match live') processQtEvents() + page.show() self.assertRendered(page) + self.assertFalse(page.emptyState.isVisible()) self.assertEqual(page.plainText(), 'match live')