From 58b36b63f34e50b3cfbf65c2ed0dae22a892535f Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Wed, 7 Oct 2026 09:47:08 +0800 Subject: [PATCH] Add profile favorites and batch duplication Signed-off-by: Loren Eteval --- Furious/Externals/GenTranslation.py | 48 +++++ Furious/Qt/StyleSheets/Controls.py | 10 + Furious/Repository/Servers.py | 14 ++ Furious/Repository/Storage.py | 5 + Furious/Widget/AGENTS.md | 6 + Furious/Widget/ServerTableView.py | 214 +++++++++++++++++++-- Furious/Window/HomePage.py | 11 ++ Furious/Window/MainWindow.py | 2 +- tests/README.md | 2 +- tests/test_qt_interactions.py | 284 +++++++++++++++++++++++++++- tests/test_repository_contracts.py | 31 +++ tests/test_stylesheet_states.py | 29 +++ 12 files changed, 638 insertions(+), 18 deletions(-) diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 9984eca1..ed64d785 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -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" } } diff --git a/Furious/Qt/StyleSheets/Controls.py b/Furious/Qt/StyleSheets/Controls.py index bf968e68..98ba6125 100644 --- a/Furious/Qt/StyleSheets/Controls.py +++ b/Furious/Qt/StyleSheets/Controls.py @@ -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']}; diff --git a/Furious/Repository/Servers.py b/Furious/Repository/Servers.py index a35b604e..c937137c 100644 --- a/Furious/Repository/Servers.py +++ b/Furious/Repository/Servers.py @@ -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], diff --git a/Furious/Repository/Storage.py b/Furious/Repository/Storage.py index d964477c..bb47e10a 100644 --- a/Furious/Repository/Storage.py +++ b/Furious/Repository/Storage.py @@ -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.""" diff --git a/Furious/Widget/AGENTS.md b/Furious/Widget/AGENTS.md index d47fe58b..2732fd4c 100644 --- a/Furious/Widget/AGENTS.md +++ b/Furious/Widget/AGENTS.md @@ -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. diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 8942ab12..d1fbca56 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -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() diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index 11f9d69a..5dfdfb41 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -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) diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 6c95e110..427d8d8d 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -133,7 +133,7 @@ class MainWindow(AppQMainWindow): 'subscription', self.subscriptionPage, 'Subscription', - 'star.svg', + 'collection.svg', ) self.pluginNavigationManager = PluginNavigationManager() diff --git a/tests/README.md b/tests/README.md index 524d9ae7..6e658ea6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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. | diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index 502a98af..d5b2bc98 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -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() diff --git a/tests/test_repository_contracts.py b/tests/test_repository_contracts.py index dd3a1d59..8ea1445d 100644 --- a/tests/test_repository_contracts.py +++ b/tests/test_repository_contracts.py @@ -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(): diff --git a/tests/test_stylesheet_states.py b/tests/test_stylesheet_states.py index db43a30d..41086e08 100644 --- a/tests/test_stylesheet_states.py +++ b/tests/test_stylesheet_states.py @@ -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."""