Improve profile actions and empty-state guidance

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-09-08 15:59:34 +08:00
parent 9b9d93fee1
commit 3132ecd498
11 changed files with 456 additions and 43 deletions
+34 -1
View File
@@ -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"
}
}
+2
View File
@@ -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.
+15
View File
@@ -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 = (
+45 -35
View File
@@ -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'),
+2
View File
@@ -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
+50 -1
View File
@@ -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()
+17
View File
@@ -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."""
+3 -2
View File
@@ -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` |
+80
View File
@@ -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)
+110 -4
View File
@@ -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 (
+98
View File
@@ -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():