mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
Improve profile actions and empty-state guidance
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Vendored
+34
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user