mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-08 22:59:48 +03:00
Add profile favorites and batch duplication
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Vendored
+48
@@ -3661,5 +3661,53 @@ TRANSLATION = {
|
||||
"RU": "Нет настроек, соответствующих поиску.",
|
||||
"ZH": "没有与搜索条件匹配的设置。",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Duplicating": {
|
||||
"source": [
|
||||
"Furious.Widget.ServerTableView"
|
||||
],
|
||||
"RU": "Создание копий",
|
||||
"ZH": "正在创建副本",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Add to Favorites": {
|
||||
"source": [
|
||||
"Furious.Widget.ServerTableView"
|
||||
],
|
||||
"RU": "Добавить в избранное",
|
||||
"ZH": "添加到收藏",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Remove from Favorites": {
|
||||
"source": [
|
||||
"Furious.Widget.ServerTableView"
|
||||
],
|
||||
"RU": "Удалить из избранного",
|
||||
"ZH": "从收藏中移除",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Could not duplicate profiles. Completed batches were kept.": {
|
||||
"source": [
|
||||
"Furious.Widget.ServerTableView"
|
||||
],
|
||||
"RU": "Не удалось создать копии профилей. Уже созданные копии сохранены.",
|
||||
"ZH": "无法创建配置副本。已完成的副本已保留。",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Favorites": {
|
||||
"source": [
|
||||
"Furious.Window.HomePage"
|
||||
],
|
||||
"RU": "Избранное",
|
||||
"ZH": "收藏",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Show favorite profiles only": {
|
||||
"source": [
|
||||
"Furious.Window.HomePage"
|
||||
],
|
||||
"RU": "Показывать только избранные профили",
|
||||
"ZH": "仅显示已收藏的配置",
|
||||
"isReviewed": "True"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,16 @@ def controlStyleSheet(
|
||||
background-color: {palette['pressed']};
|
||||
}}
|
||||
|
||||
QPushButton#HomeFavoritesButton:checked {{
|
||||
border-color: {palette['accent']};
|
||||
background-color: {palette['accent_soft']};
|
||||
color: {palette['text_strong']};
|
||||
}}
|
||||
|
||||
QPushButton#HomeFavoritesButton:checked:hover {{
|
||||
background-color: {palette['accent_soft_hover']};
|
||||
}}
|
||||
|
||||
QToolButton:checked {{
|
||||
border-color: {palette['accent']};
|
||||
background-color: {palette['accent_soft']};
|
||||
|
||||
@@ -202,6 +202,20 @@ class UserServers(Mixins.CleanupOnExit, StorageBackend):
|
||||
"""Return one profile's stable repository identity."""
|
||||
return profile.metadata.profileId
|
||||
|
||||
def setProfilesFavorite(self, profileIds, favorite: bool) -> list[str]:
|
||||
"""Update local preference metadata for the surviving captured targets."""
|
||||
selected = set(profileIds)
|
||||
changed = []
|
||||
|
||||
for profile in self._list:
|
||||
unique = self._profileId(profile)
|
||||
|
||||
if unique in selected and profile.metadata.favorite != favorite:
|
||||
profile.metadata.favorite = favorite
|
||||
changed.append(unique)
|
||||
|
||||
return changed
|
||||
|
||||
def _replaceVisibleOrder(
|
||||
self,
|
||||
visibleProfileIds: set[str],
|
||||
|
||||
@@ -128,6 +128,11 @@ class Storage:
|
||||
"""Return the user servers value."""
|
||||
return Storage._UserServersStorage().data()
|
||||
|
||||
@staticmethod
|
||||
def setUserServersFavorite(profileIds, favorite: bool) -> list[str]:
|
||||
"""Commit favorite metadata through the server repository owner."""
|
||||
return Storage._UserServersStorage().setProfilesFavorite(profileIds, favorite)
|
||||
|
||||
@staticmethod
|
||||
def moveUserServers(profileIds, visibleProfileIds, position: str) -> bool:
|
||||
"""Move selected servers within the caller's visible repository scope."""
|
||||
|
||||
@@ -53,6 +53,12 @@ view an independent workflow authority.
|
||||
observers see completed removal. Forward bulk insert/delete operations through the model instead of replaying a
|
||||
single-item notification/reconciliation path for every profile. Small direct operations and deferred large ones
|
||||
share these identity rules; batch yields and throttled progress updates serve different responsiveness purposes.
|
||||
Duplication captures selected IDs, resolves surviving sources at each batch, and uses independent manual copies;
|
||||
cancellation preserves completed batches, and explicit table cleanup stops pending copies before service shutdown.
|
||||
Favorite commands use the repository's local metadata mutation and
|
||||
keep connection/remote ownership intact. Favorites, search, and subscription filters intersect in the existing
|
||||
proxy model; a favorite mark is persisted metadata, while the filter is presentation state. The profile mutation
|
||||
and Home workflow cases in `tests/test_qt_interactions.py` cover these boundaries.
|
||||
- Endpoint lookup belongs to `EndpointInfoService`; the map renders validated results and has a no-WebEngine
|
||||
fallback. Optional WebEngine import failure must not prevent importing the widget/package, and hidden presentation
|
||||
must not retarget a queued lookup.
|
||||
|
||||
@@ -44,6 +44,8 @@ from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
from shiboken6 import isValid
|
||||
|
||||
from typing import Callable, Union
|
||||
|
||||
import re
|
||||
@@ -282,6 +284,134 @@ class DeleteServersProgressDialog(AppQTransientDialog):
|
||||
self.updateStatus()
|
||||
|
||||
|
||||
class DuplicateServersProgressDialog(AppQTransientDialog):
|
||||
"""Copy captured profile identities in cancellable GUI-thread batches."""
|
||||
|
||||
DEFAULT_DIALOG_SIZE = QtCore.QSize(420, 150)
|
||||
SmallOperationLimit = 64
|
||||
BatchSize = 128
|
||||
BatchTimeBudget = 0.008
|
||||
ProgressUpdateInterval = 0.1
|
||||
|
||||
def __init__(self, table, profileIds):
|
||||
super().__init__(table)
|
||||
|
||||
self.table = table
|
||||
self.profileIds = profileIds
|
||||
self.nextIndex = 0
|
||||
self.copiedCount = 0
|
||||
self.canceled = False
|
||||
self.finishedDuplication = False
|
||||
self.lastStatusUpdate = 0.0
|
||||
|
||||
self.setWindowTitle(_('Duplicate'))
|
||||
self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
|
||||
|
||||
self.statusLabel = AppQLabel(parent=self)
|
||||
self.cancelButton = AppQPushButton(_('Cancel'), parent=self)
|
||||
|
||||
connectWeakly(self.cancelButton.clicked, self, 'cancel')
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.statusLabel)
|
||||
layout.addWidget(self.cancelButton)
|
||||
|
||||
self.updateStatus()
|
||||
|
||||
def open(self):
|
||||
result = super().open()
|
||||
singleShotWeakly(0, self, 'duplicateNext')
|
||||
|
||||
return result
|
||||
|
||||
def reject(self):
|
||||
self.cancel()
|
||||
|
||||
def cancel(self, *_args):
|
||||
self.canceled = True
|
||||
self.cancelButton.setEnabled(False)
|
||||
|
||||
def updateStatus(self):
|
||||
self.lastStatusUpdate = time.monotonic()
|
||||
self.statusLabel.setText(
|
||||
_('Duplicating') + f'... {self.copiedCount}/{len(self.profileIds)}'
|
||||
)
|
||||
|
||||
def duplicateNext(self):
|
||||
if self.finishedDuplication:
|
||||
return
|
||||
|
||||
if self.canceled or self.nextIndex >= len(self.profileIds):
|
||||
self.finishDuplication()
|
||||
|
||||
return
|
||||
|
||||
profilesById = {
|
||||
profile.metadata.profileId: profile for profile in Storage.UserServers()
|
||||
}
|
||||
stop = min(self.nextIndex + self.BatchSize, len(self.profileIds))
|
||||
deadline = time.monotonic() + self.BatchTimeBudget
|
||||
copies = []
|
||||
|
||||
try:
|
||||
while self.nextIndex < stop:
|
||||
profile = profilesById.get(self.profileIds[self.nextIndex])
|
||||
self.nextIndex += 1
|
||||
|
||||
if profile is not None and profile.isValid():
|
||||
copies.append(profile.independentCopy())
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
|
||||
self.table.appendNewItemsByFactories(copies)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error('profile duplication failed (%s)', type(ex).__name__)
|
||||
|
||||
if not isValid(self):
|
||||
return
|
||||
|
||||
mbox = AppQMessageBox(parent=self.table, icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.setWindowTitle(_('Duplicate'))
|
||||
mbox.setText(
|
||||
_('Could not duplicate profiles. Completed batches were kept.')
|
||||
)
|
||||
mbox.open()
|
||||
|
||||
self.finishDuplication()
|
||||
|
||||
return
|
||||
|
||||
if not isValid(self):
|
||||
return
|
||||
|
||||
self.copiedCount += len(copies)
|
||||
|
||||
if self.nextIndex >= len(self.profileIds):
|
||||
self.updateStatus()
|
||||
self.finishDuplication()
|
||||
else:
|
||||
if time.monotonic() - self.lastStatusUpdate >= self.ProgressUpdateInterval:
|
||||
self.updateStatus()
|
||||
|
||||
singleShotWeakly(0, self, 'duplicateNext')
|
||||
|
||||
def finishDuplication(self):
|
||||
if self.finishedDuplication:
|
||||
return
|
||||
|
||||
self.finishedDuplication = True
|
||||
self.profileIds = []
|
||||
self.accept()
|
||||
|
||||
def retranslate(self):
|
||||
super().retranslate()
|
||||
|
||||
self.updateStatus()
|
||||
|
||||
|
||||
class ServerTableHorizontalHeader(AppQHeaderView):
|
||||
"""Provide the user servers Qt table view horizontal table header."""
|
||||
|
||||
@@ -389,6 +519,13 @@ class UserServersTableModel(QtCore.QAbstractTableModel):
|
||||
header = self.headers[column]
|
||||
text = header(server)
|
||||
|
||||
if (
|
||||
role == QtCore.Qt.ItemDataRole.DisplayRole
|
||||
and str(header) == 'Remark'
|
||||
and server.metadata.favorite
|
||||
):
|
||||
return '\u2605 ' + text
|
||||
|
||||
if (
|
||||
role == QtCore.Qt.ItemDataRole.DisplayRole
|
||||
or role == QtCore.Qt.ItemDataRole.ToolTipRole
|
||||
@@ -589,6 +726,7 @@ class UserServersSortFilterProxyModel(QtCore.QSortFilterProxyModel):
|
||||
self.searchUseRegex = True
|
||||
self.searchRegex = None
|
||||
self.subscriptionFilter = None
|
||||
self.favoritesOnly = False
|
||||
self.sortSuspended = False
|
||||
|
||||
self.setSortRole(UserServersTableModel.SortRole)
|
||||
@@ -668,6 +806,9 @@ class UserServersSortFilterProxyModel(QtCore.QSortFilterProxyModel):
|
||||
if 0 <= sourceRow < len(Storage.UserServers()):
|
||||
profile = Storage.UserServers()[sourceRow]
|
||||
|
||||
if self.favoritesOnly and not profile.metadata.favorite:
|
||||
return False
|
||||
|
||||
if self.subscriptionFilter == '':
|
||||
if profile.itemSubscriptionManaged:
|
||||
return False
|
||||
@@ -963,6 +1104,15 @@ class ServerTableView(
|
||||
),
|
||||
),
|
||||
AppQSeparator(),
|
||||
AppQAction(
|
||||
_('Add to Favorites'),
|
||||
callback=functools.partial(self.setSelectedProfilesFavorite, True),
|
||||
),
|
||||
AppQAction(
|
||||
_('Remove from Favorites'),
|
||||
callback=functools.partial(self.setSelectedProfilesFavorite, False),
|
||||
),
|
||||
AppQSeparator(),
|
||||
self.advancedActionRef,
|
||||
AppQSeparator(),
|
||||
AppQAction(
|
||||
@@ -1332,6 +1482,37 @@ class ServerTableView(
|
||||
"""Show all, manual, or one subscription group's profiles."""
|
||||
self.proxyModel.setSubscriptionFilter(unique)
|
||||
|
||||
def filterFavorites(self, enabled: bool):
|
||||
"""Intersect local favorites with the existing search and group filters."""
|
||||
self.proxyModel.favoritesOnly = enabled
|
||||
self.proxyModel.invalidateFilter()
|
||||
|
||||
def setSelectedProfilesFavorite(self, favorite: bool):
|
||||
"""Change metadata without replacing profiles or disturbing live runtimes."""
|
||||
selected = self._selectedProfileIds()
|
||||
current = self._currentProfileId()
|
||||
changed = set(Storage.setUserServersFavorite(selected, favorite))
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
rows = [
|
||||
row
|
||||
for row, profile in enumerate(Storage.UserServers())
|
||||
if profile.metadata.profileId in changed
|
||||
]
|
||||
self.sourceModel.dataChanged.emit(
|
||||
self.sourceModel.index(rows[0], 0),
|
||||
self.sourceModel.index(rows[-1], self.sourceModel.columnCount() - 1),
|
||||
[QtCore.Qt.ItemDataRole.DisplayRole],
|
||||
)
|
||||
|
||||
if not isValid(self):
|
||||
return
|
||||
|
||||
self.proxyModel.invalidateFilter()
|
||||
self._restoreProfileSelection(selected, current)
|
||||
|
||||
def addServerViaGui(self, protocol, **kwargs):
|
||||
"""Add server via GUI."""
|
||||
factory = blankProfile(protocol)
|
||||
@@ -1670,23 +1851,23 @@ class ServerTableView(
|
||||
self._restoreProfileSelection(selectedProfileIds, currentProfileId)
|
||||
|
||||
def duplicateSelectedItem(self):
|
||||
"""Handle duplicate selected item for the user servers Qt table view."""
|
||||
indexes = self.selectedIndex
|
||||
"""Create independent manual copies without per-row reconciliation."""
|
||||
profileIds = self._selectedProfileIds()
|
||||
|
||||
if len(indexes) == 0:
|
||||
# Nothing selected. Do nothing
|
||||
if not profileIds:
|
||||
return
|
||||
|
||||
for index in indexes:
|
||||
if 0 <= index < len(Storage.UserServers()):
|
||||
deepcopy = Storage.UserServers()[index].independentCopy()
|
||||
if len(profileIds) > DuplicateServersProgressDialog.SmallOperationLimit:
|
||||
DuplicateServersProgressDialog(self, profileIds).open()
|
||||
else:
|
||||
selected = set(profileIds)
|
||||
copies = [
|
||||
profile.independentCopy()
|
||||
for profile in Storage.UserServers()
|
||||
if profile.metadata.profileId in selected and profile.isValid()
|
||||
]
|
||||
|
||||
# A duplicate is a new manual profile, not another profile
|
||||
# managed by the source subscription.
|
||||
self.appendNewItem(
|
||||
remark=deepcopy.itemRemark,
|
||||
config=deepcopy,
|
||||
)
|
||||
self.appendNewItemsByFactories(copies)
|
||||
|
||||
def deleteItemByIndex(
|
||||
self, indexes, showTrayMessage=True, showProgress=True
|
||||
@@ -1924,6 +2105,13 @@ class ServerTableView(
|
||||
|
||||
def cleanup(self):
|
||||
"""Release resources owned by the user servers Qt table view."""
|
||||
for dialog in self.findChildren(DuplicateServersProgressDialog):
|
||||
dialog.canceled = True
|
||||
dialog.finishDuplication()
|
||||
|
||||
if not isValid(self):
|
||||
return
|
||||
|
||||
self.subsManager.shutdown()
|
||||
self.profileTestManager.shutdown()
|
||||
self._clearSubscriptionActions()
|
||||
|
||||
@@ -828,6 +828,16 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
|
||||
QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
|
||||
self.favoritesButton = AppQPushButton(
|
||||
_('Favorites'), icon=bootstrapIcon('star.svg'), parent=self
|
||||
)
|
||||
self.favoritesButton.setObjectName('HomeFavoritesButton')
|
||||
self.favoritesButton.setCheckable(True)
|
||||
self.favoritesButton.setToolTip(_('Show favorite profiles only'))
|
||||
self.favoritesButton.toggled.connect(
|
||||
self.userServersQTableWidget.filterFavorites
|
||||
)
|
||||
|
||||
self.headerLayout = QHBoxLayout()
|
||||
self.headerLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.headerLayout.setSpacing(8)
|
||||
@@ -855,6 +865,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
|
||||
self.actionLayout.addWidget(self.importButton)
|
||||
self.actionLayout.addWidget(self.testButton)
|
||||
self.actionLayout.addStretch(1)
|
||||
self.actionLayout.addWidget(self.favoritesButton)
|
||||
self.actionLayout.addWidget(self.subscriptionFilterComboBox)
|
||||
|
||||
self._layout.addLayout(self.headerLayout)
|
||||
|
||||
@@ -133,7 +133,7 @@ class MainWindow(AppQMainWindow):
|
||||
'subscription',
|
||||
self.subscriptionPage,
|
||||
'Subscription',
|
||||
'star.svg',
|
||||
'collection.svg',
|
||||
)
|
||||
|
||||
self.pluginNavigationManager = PluginNavigationManager()
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ worker. Choose tests by the changed contract rather than by filename alone.
|
||||
| [test_dialog_geometry.py](test_dialog_geometry.py) | Dialog show/open/exec preparation, sizing/centering, failure cleanup, specialized message-box geometry and transient destruction. |
|
||||
| [test_layout_matrix.py](test_layout_matrix.py) | Navigation and message-box layouts in fresh processes at scale factors 1, 1.25, 1.5, and 2, under both themes. |
|
||||
| [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings construction visibility/organization/search/platform gates, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes and connection-error copy/log navigation, shared Home/tray state. |
|
||||
| [test_qt_interactions.py](test_qt_interactions.py) | Real keyboard/mouse/focus and proxy mapping, scoped shortcuts, sorting/selection, stable subscription deletion confirmations, debounced Home search, Tests-menu selection color, shared settings, batched profile mutation and cancellation. |
|
||||
| [test_qt_interactions.py](test_qt_interactions.py) | Real keyboard/mouse/focus and proxy mapping, scoped shortcuts, sorting/selection, stable subscription deletion confirmations, debounced Home search, favorite actions and intersecting filters, Tests-menu selection color, shared settings, batched import/delete/duplication, cancellation, preparation failure, and owner destruction. |
|
||||
| [test_qr_export_scalability.py](test_qr_export_scalability.py) | Production capture cap, immediate single export, incremental yielding, failure/cancel/close paths, immutable snapshots, window-owned state destruction. |
|
||||
| [test_stylesheet_states.py](test_stylesheet_states.py) | Targeted rendering/alpha/geometry assertions for table/list insets, popup corners, clear buttons, focus/disabled states, and stylesheet composition. |
|
||||
| [test_theme_transition.py](test_theme_transition.py) | Real cross-fades, immediate theme activation, interruption, per-window resize/destruction, coordinator teardown, animation policy, native resize/deletion probes, deferred completion delivery, replacement/stop flushing and owner-first cancellation. |
|
||||
|
||||
@@ -28,6 +28,7 @@ from Furious.Models import CoreConfiguration, ServerProfile
|
||||
from Furious.Plugins import PluginRegistry
|
||||
from Furious.Plugins.API import RoutingOption
|
||||
from Furious.Repository import Storage, SubscriptionGroup
|
||||
from Furious.Repository.Servers import UserServers
|
||||
from Furious.Service import ProfileTestField, ProfileTestResult
|
||||
from Furious.Service.ProfileTesting import ProfileTestTarget
|
||||
from Furious.Qt import (
|
||||
@@ -41,6 +42,7 @@ from Furious.Qt import (
|
||||
from Furious.Widget.RoutingSelector import RoutingSelector
|
||||
from Furious.Widget.ServerTableView import (
|
||||
DeleteServersProgressDialog,
|
||||
DuplicateServersProgressDialog,
|
||||
MBoxQuestionDelete,
|
||||
ServerTableView,
|
||||
)
|
||||
@@ -1285,6 +1287,45 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase):
|
||||
connection.deleteLater()
|
||||
routing.deleteLater()
|
||||
|
||||
def testHomeFavoritesButtonFiltersWithoutChangingActivation(self):
|
||||
with isolatedSettings():
|
||||
settings = SettingsController()
|
||||
connection = _ConnectionControllerFixture()
|
||||
routing = _RoutingControllerFixture(
|
||||
(RoutingOption('default', 'Default'),), 'default'
|
||||
)
|
||||
|
||||
try:
|
||||
with self._home(settings, connection, routing) as home:
|
||||
table = home.userServersQTableWidget
|
||||
profiles = [
|
||||
ServerTableQtInteractionTest._profile(name)
|
||||
for name in ('alpha', 'beta')
|
||||
]
|
||||
profiles[1].metadata.favorite = True
|
||||
table.appendNewItemsByFactories(profiles)
|
||||
activated = Storage.UserActivatedItemIndex()
|
||||
home.show()
|
||||
home.favoritesButton.setFocus()
|
||||
QTest.keyClick(home.favoritesButton, QtCore.Qt.Key_Space)
|
||||
|
||||
self.assertTrue(home.favoritesButton.isChecked())
|
||||
self.assertEqual(
|
||||
table._visibleProfileIds(), [profiles[1].metadata.profileId]
|
||||
)
|
||||
self.assertEqual(Storage.UserActivatedItemIndex(), activated)
|
||||
table.search('alpha')
|
||||
self.assertEqual(table.proxyModel.rowCount(), 0)
|
||||
self.assertTrue(home.emptyState.isVisible())
|
||||
QTest.keyClick(home.favoritesButton, QtCore.Qt.Key_Space)
|
||||
self.assertEqual(
|
||||
table._visibleProfileIds(), [profiles[0].metadata.profileId]
|
||||
)
|
||||
finally:
|
||||
settings.deleteLater()
|
||||
connection.deleteLater()
|
||||
routing.deleteLater()
|
||||
|
||||
def testHomeTestsMenuPreservesSelectionAndHighlight(self):
|
||||
"""Keep mouse-opened tests tied to visibly selected, mapped profiles."""
|
||||
with isolatedSettings():
|
||||
@@ -2060,11 +2101,248 @@ class ProfileMutationBatchTest(unittest.TestCase):
|
||||
try:
|
||||
yield table, controller
|
||||
finally:
|
||||
table.cleanup()
|
||||
table.close()
|
||||
table.deleteLater()
|
||||
if isValid(table):
|
||||
table.cleanup()
|
||||
table.close()
|
||||
table.deleteLater()
|
||||
processQtEvents()
|
||||
|
||||
def testFavoriteActionsPreserveIdentityAndComposeFilters(self):
|
||||
with self.table(4) as (table, controller):
|
||||
profiles = list(Storage.UserServers())
|
||||
repository = UserServers()
|
||||
repository.data().extend(profiles)
|
||||
profiles[0].metadata.subscriptionSource = 'group-a'
|
||||
profiles[0].metadata.subscriptionManaged = True
|
||||
profiles[0].metadata.subscriptionProfileKey = 'remote-key'
|
||||
profiles[1].metadata.subscriptionSource = 'group-b'
|
||||
table.selectAll()
|
||||
metadataChanged = QSignalSpy(table.sourceModel.dataChanged)
|
||||
|
||||
with mock.patch.object(
|
||||
Storage, '_UserServersStorage', return_value=repository
|
||||
):
|
||||
add = next(
|
||||
action
|
||||
for action in table.contextMenu.actions()
|
||||
if not action.isSeparator()
|
||||
and action.textCompare('Add to Favorites')
|
||||
)
|
||||
add.trigger()
|
||||
self.assertTrue(all(profile.metadata.favorite for profile in profiles))
|
||||
self.assertEqual(metadataChanged.count(), 1)
|
||||
add.trigger()
|
||||
self.assertEqual(metadataChanged.count(), 1)
|
||||
table.filterFavorites(True)
|
||||
table.filterBySubscription('group-a')
|
||||
table.search('profile-0000')
|
||||
self.assertEqual(
|
||||
table._visibleProfileIds(), [profiles[0].metadata.profileId]
|
||||
)
|
||||
table.selectAll()
|
||||
remove = next(
|
||||
action
|
||||
for action in table.contextMenu.actions()
|
||||
if not action.isSeparator()
|
||||
and action.textCompare('Remove from Favorites')
|
||||
)
|
||||
remove.trigger()
|
||||
self.assertEqual(table.proxyModel.rowCount(), 0)
|
||||
self.assertEqual(table._selectedProfileIds(), [])
|
||||
self.assertIs(Storage.UserServers()[0], profiles[0])
|
||||
self.assertEqual(
|
||||
profiles[0].metadata.subscriptionProfileKey, 'remote-key'
|
||||
)
|
||||
self.assertEqual(Storage.UserActivatedItemIndex(), 3)
|
||||
controller.startReconnection.assert_not_called()
|
||||
table.filterFavorites(False)
|
||||
self.assertEqual(
|
||||
table._visibleProfileIds(), [profiles[0].metadata.profileId]
|
||||
)
|
||||
|
||||
def testSmallDuplicationPublishesOneBatchOfIndependentManualCopies(self):
|
||||
with self.table(4) as (table, controller):
|
||||
originals = list(Storage.UserServers())
|
||||
for profile in originals:
|
||||
profile.metadata.subscriptionSource = 'group-a'
|
||||
profile.metadata.subscriptionManaged = True
|
||||
profile.metadata.subscriptionProfileKey = profile.metadata.profileId
|
||||
originals[0].metadata.favorite = True
|
||||
table.sourceModel.sort(0, QtCore.Qt.DescendingOrder)
|
||||
table.search('000[01]')
|
||||
table.selectAll()
|
||||
selected = table._selectedProfileIds()
|
||||
inserted = QSignalSpy(table.sourceModel.rowsInserted)
|
||||
|
||||
with mock.patch.object(
|
||||
table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs
|
||||
) as reconcile:
|
||||
table.duplicateSelectedItem()
|
||||
self.assertEqual(reconcile.call_count, 1)
|
||||
|
||||
self.assertEqual(inserted.count(), 1)
|
||||
self.assertEqual(inserted.at(0)[1:], [4, 5])
|
||||
copies = Storage.UserServers()[4:]
|
||||
self.assertEqual(
|
||||
{profile.itemRemark for profile in copies},
|
||||
{'profile-0000', 'profile-0001'},
|
||||
)
|
||||
self.assertTrue(
|
||||
all(profile.metadata.profileId not in selected for profile in copies)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
not profile.itemSubscriptionManaged and not profile.itemSubscription
|
||||
for profile in copies
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(not profile.metadata.subscriptionProfileKey for profile in copies)
|
||||
)
|
||||
self.assertTrue(
|
||||
next(
|
||||
profile
|
||||
for profile in copies
|
||||
if profile.itemRemark == 'profile-0000'
|
||||
).metadata.favorite
|
||||
)
|
||||
copies[0].connection['changed'] = True
|
||||
self.assertTrue(
|
||||
all('changed' not in profile.connection for profile in originals)
|
||||
)
|
||||
self.assertEqual(table._selectedProfileIds(), selected)
|
||||
controller.startDisconnection.assert_not_called()
|
||||
|
||||
def testDuplicationKeepsCapturedTargetsAcrossReorderRemovalAndCancel(self):
|
||||
with self.table(7) as (table, controller), mock.patch(
|
||||
'Furious.Widget.ServerTableView.singleShotWeakly'
|
||||
):
|
||||
originals = list(Storage.UserServers())
|
||||
dialog = DuplicateServersProgressDialog(
|
||||
table, [profile.metadata.profileId for profile in originals]
|
||||
)
|
||||
dialog.BatchSize = 2
|
||||
dialog.BatchTimeBudget = 10
|
||||
dialog.duplicateNext()
|
||||
table.sourceModel.sort(0, QtCore.Qt.DescendingOrder)
|
||||
table.deleteItemByIndex(
|
||||
[Storage.UserServers().index(originals[2])], showProgress=False
|
||||
)
|
||||
table.clearSelection()
|
||||
table.appendNewItemByFactory(self.profile('newcomer'))
|
||||
dialog.duplicateNext()
|
||||
dialog.cancel()
|
||||
dialog.duplicateNext()
|
||||
dialog.duplicateNext()
|
||||
|
||||
self.assertEqual(dialog.copiedCount, 3)
|
||||
self.assertTrue(dialog.finishedDuplication)
|
||||
self.assertEqual(dialog.profileIds, [])
|
||||
originalIds = {profile.metadata.profileId for profile in originals}
|
||||
copies = [
|
||||
profile
|
||||
for profile in Storage.UserServers()
|
||||
if profile.metadata.profileId not in originalIds
|
||||
and profile.itemRemark != 'newcomer'
|
||||
]
|
||||
self.assertEqual(
|
||||
sorted(profile.itemRemark for profile in copies),
|
||||
['profile-0000', 'profile-0001', 'profile-0003'],
|
||||
)
|
||||
|
||||
def testLargeDuplicationCoalescesInsertionsAndSkipsInvalidProfiles(self):
|
||||
with self.table(1000) as (table, controller), mock.patch(
|
||||
'Furious.Widget.ServerTableView.singleShotWeakly'
|
||||
), mock.patch(
|
||||
'Furious.Widget.ServerTableView.time.monotonic', return_value=10.0
|
||||
), mock.patch.object(
|
||||
table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs
|
||||
) as reconcile:
|
||||
originals = list(Storage.UserServers())
|
||||
originals[500].connection.clear()
|
||||
dialog = DuplicateServersProgressDialog(
|
||||
table, [profile.metadata.profileId for profile in originals]
|
||||
)
|
||||
inserted = QSignalSpy(table.sourceModel.rowsInserted)
|
||||
|
||||
for _batch in range(8):
|
||||
dialog.duplicateNext()
|
||||
|
||||
self.assertEqual(dialog.copiedCount, 999)
|
||||
self.assertEqual(len(Storage.UserServers()), 1999)
|
||||
self.assertEqual(inserted.count(), 8)
|
||||
self.assertEqual(reconcile.call_count, 8)
|
||||
self.assertEqual(
|
||||
len({profile.metadata.profileId for profile in Storage.UserServers()}),
|
||||
1999,
|
||||
)
|
||||
self.assertIs(
|
||||
Storage.UserServers()[Storage.UserActivatedItemIndex()], originals[-1]
|
||||
)
|
||||
|
||||
def testDuplicationPreparationFailureKeepsOnlyCompletedBatches(self):
|
||||
with self.table(5) as (table, controller), mock.patch(
|
||||
'Furious.Widget.ServerTableView.singleShotWeakly'
|
||||
), mock.patch('Furious.Widget.ServerTableView.AppQMessageBox') as message:
|
||||
originals = list(Storage.UserServers())
|
||||
dialog = DuplicateServersProgressDialog(
|
||||
table, [profile.metadata.profileId for profile in originals]
|
||||
)
|
||||
dialog.BatchSize = 2
|
||||
dialog.BatchTimeBudget = 10
|
||||
dialog.duplicateNext()
|
||||
|
||||
with mock.patch.object(
|
||||
originals[3],
|
||||
'independentCopy',
|
||||
side_effect=ValueError('secret fixture'),
|
||||
), self.assertLogs(
|
||||
'Furious.Widget.ServerTableView', level='ERROR'
|
||||
) as logged:
|
||||
dialog.duplicateNext()
|
||||
|
||||
self.assertEqual(len(Storage.UserServers()), 7)
|
||||
self.assertEqual(dialog.copiedCount, 2)
|
||||
self.assertTrue(dialog.finishedDuplication)
|
||||
self.assertNotIn('secret fixture', '\n'.join(logged.output))
|
||||
message.return_value.open.assert_called_once()
|
||||
|
||||
def testLargeDuplicationYieldsToRealCancelAndOwnerDestruction(self):
|
||||
with self.table(1000) as (table, controller), mock.patch.object(
|
||||
DuplicateServersProgressDialog, 'BatchSize', 1
|
||||
):
|
||||
table.selectAll()
|
||||
table.duplicateSelectedItem()
|
||||
dialog = table.findChild(DuplicateServersProgressDialog)
|
||||
self.assertIsNotNone(dialog)
|
||||
self.assertTrue(waitFor(lambda: dialog.copiedCount > 0))
|
||||
QTest.mouseClick(dialog.cancelButton, QtCore.Qt.LeftButton)
|
||||
self.assertTrue(waitFor(lambda: not isValid(dialog)))
|
||||
self.assertLess(len(Storage.UserServers()), 2000)
|
||||
count = len(Storage.UserServers())
|
||||
table.duplicateSelectedItem()
|
||||
pending = table.findChild(DuplicateServersProgressDialog)
|
||||
self.assertIsNotNone(pending)
|
||||
table.deleteLater()
|
||||
QtCore.QCoreApplication.sendPostedEvents(
|
||||
table, QtCore.QEvent.DeferredDelete
|
||||
)
|
||||
processQtEvents()
|
||||
self.assertFalse(isValid(pending))
|
||||
self.assertEqual(len(Storage.UserServers()), count)
|
||||
|
||||
def testTableCleanupStopsPendingDuplicationBeforeRepositoryShutdown(self):
|
||||
with self.table(1000) as (table, controller):
|
||||
table.selectAll()
|
||||
table.duplicateSelectedItem()
|
||||
dialog = table.findChild(DuplicateServersProgressDialog)
|
||||
self.assertIsNotNone(dialog)
|
||||
table.cleanup()
|
||||
processQtEvents()
|
||||
|
||||
self.assertFalse(isValid(dialog))
|
||||
self.assertEqual(len(Storage.UserServers()), 1000)
|
||||
|
||||
def testImportCoalescesRowsAndProgressWithoutLosingInvalidInputPositions(self):
|
||||
profiles = [self.profile(str(index)) for index in range(600)]
|
||||
invalid = mock.Mock()
|
||||
|
||||
@@ -82,6 +82,37 @@ class RepositoryContractTest(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def testFavoriteMutationResolvesIdsAndPersistsLocalMetadata(self):
|
||||
with isolatedSettings():
|
||||
repository = UserServers()
|
||||
profiles = [self._profile(name) for name in ('first', 'second')]
|
||||
repository.data().extend(reversed(profiles))
|
||||
unique = profiles[0].metadata.profileId
|
||||
originalConnection = dict(profiles[0].connection)
|
||||
|
||||
with mock.patch.object(
|
||||
Storage, '_UserServersStorage', return_value=repository
|
||||
):
|
||||
self.assertEqual(
|
||||
Storage.setUserServersFavorite([unique, unique, 'missing'], True),
|
||||
[unique],
|
||||
)
|
||||
self.assertEqual(Storage.setUserServersFavorite([unique], True), [])
|
||||
|
||||
self.assertIs(repository.data()[1], profiles[0])
|
||||
self.assertEqual(dict(profiles[0].connection), originalConnection)
|
||||
self.assertFalse(profiles[1].metadata.favorite)
|
||||
repository.sync()
|
||||
restored = UserServers().data()
|
||||
self.assertEqual(
|
||||
[profile.metadata.profileId for profile in restored],
|
||||
[profile.metadata.profileId for profile in repository.data()],
|
||||
)
|
||||
self.assertEqual(
|
||||
[profile.metadata.favorite for profile in restored], [False, True]
|
||||
)
|
||||
self.assertEqual(repository.setProfilesFavorite([unique], False), [unique])
|
||||
|
||||
def testRoutingRepositoryRoundTripPreservesUnknownDocuments(self):
|
||||
"""Persist arbitrary core-owned routing fields without normalization."""
|
||||
with isolatedSettings():
|
||||
|
||||
@@ -24,6 +24,7 @@ from Furious.Qt import (
|
||||
AppQLineEdit,
|
||||
AppQListView,
|
||||
AppQMenu,
|
||||
AppQPushButton,
|
||||
AppQTableView,
|
||||
AppStyleSheet,
|
||||
)
|
||||
@@ -64,6 +65,34 @@ class StyleSheetStateRenderingTest(unittest.TestCase):
|
||||
"""Finish deferred widget deletion between tests."""
|
||||
collectAtBoundary()
|
||||
|
||||
def testFavoritesFilterHasVisibleCheckedStateInBothThemes(self):
|
||||
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
|
||||
with self.subTest(theme=theme):
|
||||
button = AppQPushButton('Favorites')
|
||||
button.setObjectName('HomeFavoritesButton')
|
||||
button.setCheckable(True)
|
||||
button.setStyleSheet(AppStyleSheet.forTheme(theme))
|
||||
button.resize(160, 40)
|
||||
|
||||
try:
|
||||
button.show()
|
||||
processQtEvents()
|
||||
point = (button.width() - 12, button.height() // 2)
|
||||
unchecked = button.grab().toImage().pixelColor(*point)
|
||||
button.setChecked(True)
|
||||
checked = button.grab().toImage().pixelColor(*point)
|
||||
self.assertNotEqual(checked, unchecked)
|
||||
self.assertEqual(
|
||||
checked, QColor(AppStyleSheet.Palettes[theme]['accent_soft'])
|
||||
)
|
||||
button.setChecked(False)
|
||||
self.assertEqual(
|
||||
button.grab().toImage().pixelColor(*point), unchecked
|
||||
)
|
||||
finally:
|
||||
button.close()
|
||||
button.deleteLater()
|
||||
|
||||
@staticmethod
|
||||
def focusedEdgeColors(button):
|
||||
"""Render the four straight edge centers of a focused, hovered button."""
|
||||
|
||||
Reference in New Issue
Block a user