diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index b3c2ecb..f4a1d34 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -102,7 +102,8 @@ TRANSLATION = { }, "Import": { "source": [ - "Furious.Actions.Import" + "Furious.Actions.Import", + "Furious.Window.HomePage" ], "RU": "Импорт", "ZH": "导入", @@ -3307,5 +3308,37 @@ TRANSLATION = { "RU": "Трафик / Срок", "ZH": "用量 / 到期", "isReviewed": "True" + }, + "Stop All Tests": { + "source": [ + "Furious.Widget.ServerTableView" + ], + "RU": "Остановить все тесты", + "ZH": "停止所有测试", + "isReviewed": "True" + }, + "No profiles yet. Use Server to add a profile, Import to load profiles, or Subscriptions to add a subscription.": { + "source": [ + "Furious.Window.HomePage" + ], + "RU": "Профилей пока нет. Добавьте профиль через меню «Сервер», загрузите профили через «Импорт» или добавьте подписку в разделе «Подписки».", + "ZH": "暂无配置。通过“服务器”添加配置,通过“导入”加载配置,或在“订阅”中添加订阅。", + "isReviewed": "True" + }, + "No profiles match the current filters.": { + "source": [ + "Furious.Window.HomePage" + ], + "RU": "Нет профилей, соответствующих текущим фильтрам.", + "ZH": "没有符合当前筛选条件的配置。", + "isReviewed": "True" + }, + "No logs match the current filters.": { + "source": [ + "Furious.Window.LogPage" + ], + "RU": "Нет записей журнала, соответствующих текущим фильтрам.", + "ZH": "没有符合当前筛选条件的日志。", + "isReviewed": "True" } } diff --git a/Furious/Service/AGENTS.md b/Furious/Service/AGENTS.md index bf33b7b..02a4212 100644 --- a/Furious/Service/AGENTS.md +++ b/Furious/Service/AGENTS.md @@ -65,6 +65,8 @@ for lifetime primitives. This scope owns multi-stage workflows and temporary res fingerprint, snapshot, ownership, and explicit options; workers return values and the manager resolves the current target before mutating latency/speed. Freshness currently resolves ID plus connection fingerprint; subscription ownership drives explicit group invalidation, not an implicit row or metadata equality test. +- User-requested test cancellation preserves received results, suppresses late cancelled results, and leaves the + manager available for new work. Shutdown separately closes admission and releases owned execution resources. - Repository changes reconcile queued/running jobs. A successful subscription commit cancels that group's pending and active tests, stale-marks non-cancellable calls, clears only that group's current results, and leaves manual/other-group work untouched. diff --git a/Furious/Service/ProfileTesting.py b/Furious/Service/ProfileTesting.py index 92d1284..9ee0331 100644 --- a/Furious/Service/ProfileTesting.py +++ b/Furious/Service/ProfileTesting.py @@ -1343,6 +1343,9 @@ class ProfileTestManager(QtCore.QObject): def testPing(self, profiles, *, timeoutMilliseconds=2000): """Queue ICMP latency tests for immutable profile snapshots.""" + if self._shuttingDown: + return + self._latencyScheduler.enqueue( profiles, LatencyTestOptions(LatencyTestType.Ping, timeoutMilliseconds), @@ -1350,6 +1353,9 @@ class ProfileTestManager(QtCore.QObject): def testTcping(self, profiles, *, timeoutMilliseconds=2000): """Queue coalesced asynchronous TCP latency tests.""" + if self._shuttingDown: + return + self._latencyScheduler.enqueue( profiles, LatencyTestOptions(LatencyTestType.Tcping, timeoutMilliseconds), @@ -1365,6 +1371,9 @@ class ProfileTestManager(QtCore.QObject): logActionMessage=False, ): """Queue serial or concurrent downloads with explicit operation options.""" + if self._shuttingDown: + return + if testUrl is None: try: configuredUrl = AppSettings.get('CustomNetworkSpeedTestURL') @@ -1391,6 +1400,12 @@ class ProfileTestManager(QtCore.QObject): scheduler.enqueue(profiles, options) + def cancelAll(self): + """Stop current tests without clearing results or closing the service.""" + self._latencyScheduler.cancelAll() + self._serialDownloadScheduler.cancelAll() + self._concurrentDownloadScheduler.cancelAll() + def clearResults(self, profiles): """Clear both presentation-compatible result fields for current profiles.""" results = ( diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index cffd2e5..5eddb25 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -910,40 +910,8 @@ class ServerTableView( ) self._subscriptionActions = [] - contextMenuActions = [ - self.moveActionRef, - AppQAction( - _('Duplicate'), - callback=lambda: self.duplicateSelectedItem(), - ), - AppQAction( - _('Delete'), - callback=lambda: self.deleteSelectedItem(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.Key.Key_Delete, - ), - ), - self.moveToSubscriptionActionRef, - AppQSeparator(), - AppQAction( - _('Select All'), - callback=lambda: self.selectAll(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_A, - ), - ), - AppQSeparator(), - self.activateSelectedServerActionRef, - AppQAction( - _('Scroll To Activated Server'), - callback=lambda: self.scrollToActivatedItem(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_G, - ), - ), - AppQSeparator(), + self.importActions = tuple(importActionsFactory()) + self.testActions = ( AppQAction( _('Test Ping Latency'), callback=lambda: self.testSelectedItemPingLatency(), @@ -979,10 +947,52 @@ class ServerTableView( QtCore.Qt.Key.Key_R, ), ), + AppQAction( + _('Stop All Tests'), + callback=self.profileTestManager.cancelAll, + parent=self, + ), + ) + + contextMenuActions = [ + self.moveActionRef, + AppQAction( + _('Duplicate'), + callback=lambda: self.duplicateSelectedItem(), + ), + AppQAction( + _('Delete'), + callback=lambda: self.deleteSelectedItem(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.Key.Key_Delete, + ), + ), + self.moveToSubscriptionActionRef, + AppQSeparator(), + AppQAction( + _('Select All'), + callback=lambda: self.selectAll(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_A, + ), + ), + AppQSeparator(), + self.activateSelectedServerActionRef, + AppQAction( + _('Scroll To Activated Server'), + callback=lambda: self.scrollToActivatedItem(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_G, + ), + ), + AppQSeparator(), + *self.testActions, AppQSeparator(), self.advancedActionRef, AppQSeparator(), - *importActionsFactory(), + *self.importActions, AppQSeparator(), AppQAction( _('Export Share Link To Clipboard'), diff --git a/Furious/Window/AGENTS.md b/Furious/Window/AGENTS.md index c44181b..f14a5e5 100644 --- a/Furious/Window/AGENTS.md +++ b/Furious/Window/AGENTS.md @@ -29,6 +29,8 @@ top-level presentation, not shared domain state. - One-shot editors/prompts use managed transient dialogs and weak compiled-safe continuations. Reusable text/editor windows and retained settings dialogs need an explicit owner and reopen policy. A settings label or Qt parent does not determine lifetime: check the actual base class and close/accept/reject path before changing deletion policy. +- Empty-state presentation distinguishes an empty repository from a filtered view with no matches. Recovery changes + view filters only; reuse existing import/edit/test actions instead of creating page-specific workflow owners. - Use normal layouts and `AppQ*` controls. Restore top-level geometry only after persistent composition and through the canonical first-show path; never-shown Qt fallback geometry must not overwrite a prior user decision. - QR export captures capped independent profile snapshots before deferred work. Incremental generation is owned by diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index dad5b87..7e9a035 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -27,6 +27,7 @@ from Furious.Repository import * from Furious.Plugins import getPluginRegistry from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Qt.Signals import connectWeakly from Furious.Service import ( ConnectivityManager, TrafficStatsManager, @@ -706,6 +707,17 @@ class HomePage(Mixins.QTranslatable, QMainWindow): popupMenu=self.serverMenu, ) + self.importMenu = AppQMenu( + *self.userServersQTableWidget.importActions, parent=self + ) + self.importButton = AppQMenuPushButton( + _('Import'), + icon=bootstrapIcon('lightning-charge.svg'), + popupMenu=self.importMenu, + parent=self, + ) + self.importButton.setEnabled(bool(self.userServersQTableWidget.importActions)) + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu) # TODO: Custom status tip @@ -754,6 +766,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.tunModeSwitch.syncChecked(AppSettings.isStateON_('VPNMode')) self.searchLineEdit = AppQLineEdit() + self.searchLineEdit.setClearButtonEnabled(True) self.searchLineEdit.setPlaceholderText( _( 'Search servers with text or regex, e.g. trojan, hk|jp, ^vmess, (us|sg).*tls' @@ -797,14 +810,35 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.actionLayout.setContentsMargins(0, 0, 0, 0) self.actionLayout.setSpacing(8) self.actionLayout.addWidget(self.serverButton) + self.actionLayout.addWidget(self.importButton) self.actionLayout.addStretch(1) self.actionLayout.addWidget(self.subscriptionFilterComboBox) 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) + for model in ( + self.userServersQTableWidget.sourceModel, + self.userServersQTableWidget.proxyModel, + ): + for signal in ( + model.rowsInserted, + model.rowsRemoved, + model.modelReset, + model.layoutChanged, + ): + connectWeakly(signal, self, 'refreshEmptyState', sender=model) + self.refreshEmptyState() + self.searchButton.clicked.connect( lambda: self.userServersQTableWidget.search(self.searchLineEdit.text()) ) @@ -849,6 +883,21 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.setCentralWidget(self._widget) + @QtCore.Slot() + def refreshEmptyState(self, *_args): + """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( + _( + 'No profiles yet. Use Server to add a profile, Import to load profiles, or Subscriptions to add a subscription.' + ) + if table.sourceModel.rowCount() == 0 + else _('No profiles match the current filters.') + ) + @QtCore.Slot(str) def _syncSystemProxyMode(self, mode: str): """Select the shared system-proxy preference without writing it again.""" @@ -1057,4 +1106,4 @@ class HomePage(Mixins.QTranslatable, QMainWindow): def retranslate(self): """Refresh text owned directly by the home page.""" - pass + self.refreshEmptyState() diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index 9d52906..69e671b 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -206,6 +206,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self.filterComboBox.setMinimumWidth(180) self.searchLineEdit = AppQLineEdit() + self.searchLineEdit.setClearButtonEnabled(True) self.searchLineEdit.setPlaceholderText( _('Search logs with text or regular expressions') ) @@ -288,10 +289,21 @@ class LogPage(Mixins.QTranslatable, QMainWindow): filterLayout.addWidget(self.filterLabel) filterLayout.addWidget(self.filterComboBox) + 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() + centralLayout = QVBoxLayout() centralLayout.setContentsMargins(20, 18, 20, 20) centralLayout.setSpacing(14) centralLayout.addLayout(filterLayout) + centralLayout.addWidget(self.emptyState) centralLayout.addWidget(self.textBrowser) centralWidget = QWidget() @@ -475,6 +487,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): if invalidate: self._representationInvalid = True + self.emptyState.hide() if not self._pageCanRender(): return @@ -726,6 +739,10 @@ 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) + ) def _scheduleHighlight(self, firstBlock: int): """Coalesce incremental highlighting from the earliest changed block.""" diff --git a/tests/README.md b/tests/README.md index 1663e33..e2ff5bd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -45,7 +45,7 @@ strategy in an individual test. | Controller state and error transitions with injected runtimes | `test_controllers.py` | | SOCKS and SIP002 Shadowsocks codecs and generated round trips | `test_socks_uri.py`, `test_shadowsocks_uri.py` | | Subscription workflow, worker responsiveness, stale requests, reconciliation, and deterministic scale | `test_subscription_manager.py`, `test_subscription_sync.py`, `test_subscription_scalability.py` | -| Service-first profile-test identity, explicit results, endpoint deduplication, adaptive Tcping, cancellation, late callbacks, and worker/thread lifetime | `test_profile_test_jobs.py` | +| Service-first profile-test identity, explicit results, endpoint deduplication, adaptive Tcping, reusable Stop All cancellation, shutdown admission, late callbacks, and worker/thread lifetime | `test_profile_test_jobs.py` | | External process launch, output, shutdown, threads, TUN metadata | `test_external_core.py` | | Backend structured-editor observational load and unknown-value preservation | `test_backend_editor_contract.py` | | Xray asset checksum validation and atomic replacement | `test_xray_asset_download.py` | @@ -55,12 +55,13 @@ strategy in an individual test. | Bounded service work, update validation, plugin UI, and worker lifetime | `test_service_runtime.py` | | Frozenlib state helpers and mocked platform-operation boundaries | `test_frozenlib.py` | | Settings sandbox, navigation overlay behavior, public exports, and scale/theme isolation | `test_isolation_and_navigation.py`, `test_public_api.py`, `test_layout_matrix.py` | +| Shared Fluent visual states and native line-edit clear-button alignment, theme changes, and interaction | `test_stylesheet_states.py` | | Theme cross-fade activation, interruption, multi-window cleanup, and animation policy | `test_theme_transition.py` | | AppQMainWindow lifecycle, subclass policies, geometry restoration, and migration | `test_main_window_geometry.py` | | AppQDialog first-presentation geometry, native show paths, centering, and async lifetime | `test_dialog_geometry.py` | | Editor mappings, lazy log rendering, routing/message-box/connection UI | `test_ui_behavior.py` | | Bounded, incremental, cancellable QR export and snapshot/lifetime safety | `test_qr_export_scalability.py` | -| Real keyboard/mouse/focus, proxy mapping, shared Home/Settings state, and transient editor input | `test_qt_interactions.py` | +| Real keyboard/mouse/focus, proxy mapping, shared Home/Settings state, Home empty/filter recovery and shared menus, and transient editor input | `test_qt_interactions.py` | | Direct Qt ownership and destruction across independent UI families | `test_qt_lifetime.py` | | Batched real/probe Qt object, QR rendering/window lifecycle, handle, Python allocation, and RSS trends | `test_qt_stress.py` | | Repeated harmless subprocess, pipe, thread, handle, and RSS trends | `test_process_stress.py` | diff --git a/tests/test_profile_test_jobs.py b/tests/test_profile_test_jobs.py index d6225b6..8354ab1 100644 --- a/tests/test_profile_test_jobs.py +++ b/tests/test_profile_test_jobs.py @@ -311,6 +311,86 @@ class ProfileTestServiceTest(unittest.TestCase): return manager + def testCancelAllPreservesResultsRejectsLatePingAndAllowsNewTests(self): + """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, + ): + 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): + """Stop shared results between GUI batches without losing completed values.""" + profiles = [self._profile(str(i), 'shared.example') for i in range(130)] + manager = self._manager(profiles) + 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 + sink.deleteLater() + + def testShutdownClosesTestAdmission(self): + """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( + manager._concurrentDownloadScheduler, 'enqueue' + ) as download: + manager.testPing((profile,)) + manager.testTcping((profile,)) + manager.testDownloadSpeed((profile,)) + latency.assert_not_called() + download.assert_not_called() + def testDefaultConcurrencyKeepsBlockingPingInPrivateHalfCpuPool(self): """Keep blocking Ping off shared workers with the requested default limit.""" manager = ProfileTestManager(profilesProvider=lambda: self.profiles) diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index 19d31d6..82ac324 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -25,7 +25,7 @@ from Furious.Frozenlib import AppBuiltinProxyMode, AppSettings, Mixins from Furious.Models import CoreConfiguration, ServerProfile from Furious.Plugins.API import RoutingOption from Furious.Repository import Storage, SubscriptionGroup -from Furious.Qt import AppHue, AppQDialog, AppQSwitch, gettext +from Furious.Qt import AppQAction, AppHue, AppQDialog, AppQSwitch, gettext from Furious.Widget.RoutingSelector import RoutingSelector from Furious.Widget.ServerTableView import ServerTableView from Furious.Widget.SubscriptionTableView import SubscriptionTableView @@ -38,7 +38,7 @@ from Furious.Window.SubscriptionPage import _SubscriptionEditorDialog from PySide6 import QtCore, QtGui from PySide6.QtTest import QSignalSpy, QTest -from PySide6.QtWidgets import QLineEdit, QVBoxLayout, QWidget +from PySide6.QtWidgets import QLineEdit, QToolButton, QVBoxLayout, QWidget from shiboken6 import isValid @@ -947,7 +947,14 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): collectAtBoundary() @contextmanager - def _home(self, settingsController, connectionController, routingController): + def _home( + self, + settingsController, + connectionController, + routingController, + *, + importActions=(), + ): """Build the smallest side-effect-free real Home composition.""" registry = mock.Mock() registry.protocolDescriptors.return_value = () @@ -955,6 +962,10 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): with ExitStack() as stack: for target, value in ( ('Furious.Window.HomePage.AppSettingsController', settingsController), + ( + 'Furious.Widget.ServerTableView.AppConnectionController', + connectionController, + ), ( 'Furious.Window.HomePage.AppConnectionController', connectionController, @@ -973,7 +984,9 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): stack.enter_context(mock.patch(target, return_value=value)) stack.enter_context( - mock.patch.object(HomePage, 'serverImportActions', return_value=()) + mock.patch.object( + HomePage, 'serverImportActions', return_value=importActions + ) ) home = HomePage() @@ -986,6 +999,99 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): home.close() home.deleteLater() + def testHomeEmptyStateRecoversFilteredProfilesAndReusesActions(self): + """Use existing menus, search clear and group selection to recover profiles.""" + with isolatedSettings(): + AppSettings.set('Language', 'EN') + settings = SettingsController() + connection = _ConnectionControllerFixture() + 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,) + ) as home: + home.resize(1000, 600) + 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: + table = home.userServersQTableWidget + menu = table.contextMenu + menu.popup(table.viewport().mapToGlobal(QtCore.QPoint(20, 20))) + processQtEvents() + 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: + settings.deleteLater() + connection.deleteLater() + routing.deleteLater() + def testHomeTunModeLabelExplainsMissingAdministratorPrivilege(self): """Use the same privilege-aware TUN presentation as Settings.""" with ( diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 5ff973d..e603bfc 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -97,6 +97,7 @@ from Furious.Qt import ( AppStyleSheet, gettext as _, ) +from Furious.Service.LogManager import ALL_LOGS_FILTER from Furious.Service import ( APPLICATION_LOG_CATEGORY, CORE_LOG_CATEGORY, @@ -123,6 +124,7 @@ from PySide6 import QtCore from PySide6.QtGui import QImage from PySide6.QtTest import QTest from PySide6.QtWidgets import ( + QToolButton, QComboBox, QHBoxLayout, QLabel, @@ -1553,6 +1555,7 @@ class UnifiedLogPageTest(unittest.TestCase): (page.autoScrollLabel, 'Auto Scroll Down'), (page.autoClearLabel, 'Auto Clear Log'), (page.highlightStatusLabel, 'Processing...'), + (page.emptyStateLabel, 'No logs match the current filters.'), ) AppSettings.set('Language', 'ZH') @@ -1662,8 +1665,103 @@ class UnifiedLogPageTest(unittest.TestCase): ['application beta [literal]', 'application [literal] live'], ) + 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( + page.filterComboBox.currentData(), APPLICATION_LOG_CATEGORY + ) + self.assertEqual( + page.plainText().splitlines(), + [ + 'application alpha', + 'application beta [literal]', + 'application [literal] live', + 'application ignored', + ], + ) + self.disposePage(page) + def testNoMatchGuidanceUsesExistingFilterControls(self): + """Recover filtered logs by clearing search and selecting the desired category.""" + with isolatedSettings(): + 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()) + + QTest.mouseClick( + page.searchLineEdit.findChild(QToolButton), + 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 + ) + self.assertEqual( + page.plainText().splitlines(), ['application one', 'core one'] + ) + + def testNoMatchGuidanceTracksEvictionAndHiddenCatchUp(self): + """Refresh guidance when the last match is evicted or arrives while hidden.""" + with isolatedSettings(): + 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') + def testSearchPrunesEvictedMatchingEntriesIncrementally(self): """Remove a matching rendered prefix when manager retention evicts it.""" with isolatedSettings():