Add profile favorites and batch duplication

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-07 09:47:08 +08:00
parent 8789f22b9a
commit 58b36b63f3
12 changed files with 638 additions and 18 deletions
+48
View File
@@ -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"
}
}
+10
View File
@@ -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']};
+14
View File
@@ -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],
+5
View File
@@ -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."""
+6
View File
@@ -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.
+201 -13
View File
@@ -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()
+11
View File
@@ -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)
+1 -1
View File
@@ -133,7 +133,7 @@ class MainWindow(AppQMainWindow):
'subscription',
self.subscriptionPage,
'Subscription',
'star.svg',
'collection.svg',
)
self.pluginNavigationManager = PluginNavigationManager()
+1 -1
View File
@@ -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. |
+281 -3
View File
@@ -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()
+31
View File
@@ -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():
+29
View File
@@ -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."""