From 24d3d29af97fbf67643493871f73abd4557c2eb0 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Wed, 9 Sep 2026 01:00:02 +0800 Subject: [PATCH] Batch profile imports and deletions Signed-off-by: Loren Eteval --- Furious/Actions/Import.py | 78 ++--- Furious/Widget/ServerTableView.py | 218 ++++++-------- Furious/Window/HomePage.py | 4 + Furious/Window/MainWindow.py | 4 + tests/README.md | 2 +- tests/test_qt_interactions.py | 484 +++++++++++++++++++++++++++++- tests/test_qt_lifetime.py | 3 +- 7 files changed, 635 insertions(+), 158 deletions(-) diff --git a/Furious/Actions/Import.py b/Furious/Actions/Import.py index eb31710..794f44c 100644 --- a/Furious/Actions/Import.py +++ b/Furious/Actions/Import.py @@ -26,17 +26,16 @@ from Furious.Repository import * from Furious.Qt import * from Furious.Qt.Signals import connectWeakly, singleShotWeakly from Furious.Qt import gettext as _ -from Furious.Widget.WaitingSpinner import * from PySide6 import QtCore -from PySide6.QtGui import QColor -from PySide6.QtWidgets import QApplication, QFileDialog, QHBoxLayout, QVBoxLayout +from PySide6.QtWidgets import QApplication, QFileDialog, QVBoxLayout from PIL import Image from typing import Callable, Tuple, Union import os +import time import mss import zxingcpp import logging @@ -88,7 +87,7 @@ def importURIFromClipboard(clipboard: str): def importURIs(*uris, failureCallback: Union[Callable[[], None], None] = None): """Import ur is.""" - if len(uris) > 1: + if len(uris) > ImportURIsProgressDialog.SmallOperationLimit: dialog = ImportURIsProgressDialog( uris, failureCallback=failureCallback, @@ -99,16 +98,19 @@ def importURIs(*uris, failureCallback: Union[Callable[[], None], None] = None): return imported = list() + factories = [] rowIndex = len(Storage.UserServers()) for uri in uris: factory = profileFromAny(uri.strip()) if factory.isValid(): - AppMainWindow().appendNewItemByFactory(factory) - + factories.append(factory) imported.append(factory.itemRemark) + if factories: + AppMainWindow().appendNewItemsByFactories(factories) + if len(imported) == 0: if callable(failureCallback): failureCallback() @@ -135,6 +137,10 @@ class ImportURIsProgressDialog(AppQTransientDialog): """Present progress and cancellation controls for import ur is.""" DEFAULT_DIALOG_SIZE = QtCore.QSize(420, 150) + SmallOperationLimit = 64 + BatchSize = 128 + BatchTimeBudget = 0.008 + ProgressUpdateInterval = 0.1 def __init__( self, @@ -153,19 +159,11 @@ class ImportURIsProgressDialog(AppQTransientDialog): self.currentRemark = '' self.canceled = False self.finishedImport = False + self.lastStatusUpdate = 0.0 self.setWindowTitle(_('Import')) self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) - self.spinner = WaitingSpinner( - self, - center_on_parent=False, - lines=12, - line_length=7, - line_width=3, - radius=7, - color=QColor(96, 160, 255), - ) self.statusLabel = AppQLabel() self.detailLabel = AppQLabel() self.detailLabel.setWordWrap(True) @@ -178,12 +176,8 @@ class ImportURIsProgressDialog(AppQTransientDialog): sender=self.cancelButton, ) - statusLayout = QHBoxLayout() - statusLayout.addWidget(self.spinner) - statusLayout.addWidget(self.statusLabel, 1) - layout = QVBoxLayout() - layout.addLayout(statusLayout) + layout.addWidget(self.statusLabel) layout.addWidget(self.detailLabel) layout.addWidget(self.cancelButton) @@ -195,8 +189,6 @@ class ImportURIsProgressDialog(AppQTransientDialog): """Open the import ur is progress dialog asynchronously.""" result = super().open() - self.spinner.start() - singleShotWeakly(0, self, 'importNext') return result @@ -213,6 +205,7 @@ class ImportURIsProgressDialog(AppQTransientDialog): def updateStatus(self): """Update status.""" + self.lastStatusUpdate = time.monotonic() total = len(self.uris) processed = min(self.currentIndex, total) @@ -237,31 +230,45 @@ class ImportURIsProgressDialog(AppQTransientDialog): return remark[:117] + '...' def importNext(self): - """Import next.""" + """Parse a bounded batch and publish one model insertion before yielding.""" + if self.finishedImport: + return + if self.canceled or self.currentIndex >= len(self.uris): self.finishImport() return - uri = self.uris[self.currentIndex] - self.currentIndex += 1 + deadline = time.monotonic() + self.BatchTimeBudget + stop = min(self.currentIndex + self.BatchSize, len(self.uris)) + factories = [] - factory = profileFromAny(uri.strip()) + while self.currentIndex < stop and not self.canceled: + uri = self.uris[self.currentIndex] + self.currentIndex += 1 + factory = profileFromAny(uri.strip()) - if factory.isValid(): - remark = factory.itemRemark + if factory.isValid(): + factories.append(factory) + self.currentRemark = self.limitedRemark(factory.itemRemark) + else: + self.currentRemark = _('Invalid data') - self.currentRemark = self.limitedRemark(remark) + if time.monotonic() >= deadline: + break - AppMainWindow().appendNewItemByFactory(factory) + if factories: + AppMainWindow().appendNewItemsByFactories(factories) + self.imported.extend(factory.itemRemark for factory in factories) - self.imported.append(remark) + if self.canceled or self.currentIndex >= len(self.uris): + self.updateStatus() + self.finishImport() else: - self.currentRemark = _('Invalid data') + if time.monotonic() - self.lastStatusUpdate >= self.ProgressUpdateInterval: + self.updateStatus() - self.updateStatus() - - singleShotWeakly(0, self, 'importNext') + singleShotWeakly(0, self, 'importNext') def finishImport(self): """Handle finish import for the import ur is progress dialog.""" @@ -269,7 +276,6 @@ class ImportURIsProgressDialog(AppQTransientDialog): return self.finishedImport = True - self.spinner.stop() self.accept() if self.canceled: diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 138b462..fe31dbe 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -39,7 +39,6 @@ from Furious.Service import ( SubscriptionManager, SubscriptionUpdateBatch, ) -from Furious.Widget.WaitingSpinner import WaitingSpinner from PySide6 import QtCore from PySide6.QtGui import * @@ -48,6 +47,7 @@ from PySide6.QtWidgets import * from typing import Callable, Union import re +import time import logging import functools @@ -135,34 +135,33 @@ class DeleteServersProgressDialog(AppQTransientDialog): """Present progress and cancellation controls for delete servers.""" DEFAULT_DIALOG_SIZE = QtCore.QSize(420, 150) + SmallOperationLimit = 64 + BatchSize = 128 + ProgressUpdateInterval = 0.1 def __init__(self, table, indexes, showTrayMessage=True, parent=None): """Initialize the DeleteServersProgressDialog.""" super().__init__(parent) self.table = table - self.indexes = list(indexes) + profiles = Storage.UserServers() + self.profileIds = [ + profiles[index].metadata.profileId + for index in sorted(set(indexes)) + if 0 <= index < len(profiles) + ] self.showTrayMessage = showTrayMessage - self.total = len(self.indexes) + self.total = len(self.profileIds) self.nextIndex = 0 self.deletedCount = 0 - self.deletedActivated = False self.canceled = False self.finishedDeletion = False + self.lastStatusUpdate = 0.0 self.currentRemark = '' self.setWindowTitle(_('Delete')) self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) - self.spinner = WaitingSpinner( - self, - center_on_parent=False, - lines=12, - line_length=7, - line_width=3, - radius=7, - color=QColor(96, 160, 255), - ) self.statusLabel = AppQLabel() self.detailLabel = AppQLabel() self.detailLabel.setWordWrap(True) @@ -175,12 +174,8 @@ class DeleteServersProgressDialog(AppQTransientDialog): sender=self.cancelButton, ) - statusLayout = QHBoxLayout() - statusLayout.addWidget(self.spinner) - statusLayout.addWidget(self.statusLabel, 1) - layout = QVBoxLayout() - layout.addLayout(statusLayout) + layout.addWidget(self.statusLabel) layout.addWidget(self.detailLabel) layout.addWidget(self.cancelButton) @@ -190,11 +185,11 @@ class DeleteServersProgressDialog(AppQTransientDialog): def open(self): """Open the delete servers progress dialog asynchronously.""" - self.spinner.start() + result = super().open() singleShotWeakly(0, self, 'deleteNext') - return super().open() + return result def reject(self): """Reject the current delete servers progress dialog values.""" @@ -208,6 +203,7 @@ class DeleteServersProgressDialog(AppQTransientDialog): def updateStatus(self): """Update status.""" + self.lastStatusUpdate = time.monotonic() if self.canceled: self.statusLabel.setText( _('Canceling delete') + f'... {self.deletedCount}/{self.total}' @@ -233,79 +229,46 @@ class DeleteServersProgressDialog(AppQTransientDialog): return remark[:117] + '...' def deleteNext(self): - """Delete next.""" + """Resolve captured identities and remove one bounded batch.""" + if self.finishedDeletion: + return + if self.canceled or self.nextIndex >= self.total: self.finishDeletion() return - originalIndex = self.indexes[self.nextIndex] - self.nextIndex += 1 - deleteIndex = originalIndex - self.deletedCount + stop = min(self.nextIndex + self.BatchSize, self.total) + profileIds = set(self.profileIds[self.nextIndex : stop]) + profiles = Storage.UserServers() + indexes = [ + index + for index, profile in enumerate(profiles) + if profile.metadata.profileId in profileIds + ] + self.nextIndex = stop - if deleteIndex < 0 or deleteIndex >= len(Storage.UserServers()): + if indexes: + self.currentRemark = self.limitedRemark(profiles[indexes[-1]].itemRemark) + self.deletedCount += self.table.deleteItemByIndex( + indexes, showTrayMessage=self.showTrayMessage, showProgress=False + ) + + if self.canceled or self.nextIndex >= self.total: self.updateStatus() + self.finishDeletion() + else: + if time.monotonic() - self.lastStatusUpdate >= self.ProgressUpdateInterval: + self.updateStatus() singleShotWeakly(0, self, 'deleteNext') - return - - factory = Storage.UserServers()[deleteIndex] - - self.currentRemark = self.limitedRemark(factory.itemRemark) - - if originalIndex == Storage.UserActivatedItemIndex(): - self.deletedActivated = True - - self.table.sourceModel.beginRemoveRows( - QtCore.QModelIndex(), - deleteIndex, - deleteIndex, - ) - - factory.deleted = True - - Storage.UserServers().pop(deleteIndex) - - self.table.sourceModel.endRemoveRows() - - self.table.reconcileProfileTestJobs() - - if not self.deletedActivated and deleteIndex < Storage.UserActivatedItemIndex(): - AppSettings.set( - 'ActivatedItemIndex', str(Storage.UserActivatedItemIndex() - 1) - ) - - self.deletedCount += 1 - - self.updateStatus() - - singleShotWeakly(0, self, 'deleteNext') - def finishDeletion(self): - """Handle finish deletion for the delete servers progress dialog.""" + """Stop the completed or cancelled operation exactly once.""" if self.finishedDeletion: return self.finishedDeletion = True - self.spinner.stop() - - self.table.sourceModel.refreshIndexes() - self.table.sourceModel.emitAllChanged() - - if self.deletedActivated: - # Set invalid first - AppSettings.set('ActivatedItemIndex', str(-1)) - - self.table.activeServerChanged.emit() - - controller = AppConnectionController() - - if controller.isConnected(): - controller.startDisconnection( - _('Disconnected') if self.showTrayMessage else '' - ) - self.accept() def retranslate(self): @@ -1716,13 +1679,17 @@ class ServerTableView( self, indexes, showTrayMessage=True, showProgress=True ) -> int: """Delete item by index.""" - indexes = sorted(set(indexes)) + profiles = Storage.UserServers() + indexes = sorted({index for index in indexes if 0 <= index < len(profiles)}) if len(indexes) == 0: # Nothing selected. Do nothing return 0 - if showProgress and len(indexes) > 1: + if ( + showProgress + and len(indexes) > DeleteServersProgressDialog.SmallOperationLimit + ): dialog = DeleteServersProgressDialog( self, indexes, @@ -1734,31 +1701,35 @@ class ServerTableView( return 0 - if Storage.UserActivatedItemIndex() in indexes: - deleteActivated = True - else: - deleteActivated = False + activatedIndex = Storage.UserActivatedItemIndex() + deleteActivated = activatedIndex in indexes + ranges = [] - # Note: param indexes must be sorted - for i in range(len(indexes)): - deleteIndex = indexes[i] - i + for index in indexes: + if ranges and index == ranges[-1][1] + 1: + ranges[-1] = (ranges[-1][0], index) + else: + ranges.append((index, index)) - self.sourceModel.beginRemoveRows( - QtCore.QModelIndex(), - deleteIndex, - deleteIndex, - ) + # Descending ranges preserve the remaining source rows and Qt selections. + for first, last in reversed(ranges): + self.sourceModel.beginRemoveRows(QtCore.QModelIndex(), first, last) - Storage.UserServers()[deleteIndex].deleted = True - Storage.UserServers().pop(deleteIndex) + for profile in profiles[first : last + 1]: + profile.deleted = True + + del profiles[first : last + 1] + + if first <= activatedIndex <= last: + activatedIndex = -1 + elif last < activatedIndex: + activatedIndex -= last - first + 1 + + if activatedIndex != Storage.UserActivatedItemIndex(): + AppSettings.set('ActivatedItemIndex', str(activatedIndex)) self.sourceModel.endRemoveRows() - if not deleteActivated and deleteIndex < Storage.UserActivatedItemIndex(): - AppSettings.set( - 'ActivatedItemIndex', str(Storage.UserActivatedItemIndex() - 1) - ) - self.reconcileProfileTestJobs() # Refresh index @@ -1766,9 +1737,6 @@ class ServerTableView( self.sourceModel.emitAllChanged() if deleteActivated: - # Set invalid first - AppSettings.set('ActivatedItemIndex', str(-1)) - self.activeServerChanged.emit() controller = AppConnectionController() @@ -1788,12 +1756,19 @@ class ServerTableView( # Nothing selected. Do nothing return - def handleResultCode(_indexes, code): + profileIds = self._profileIdsForSourceRows(indexes) + + def handleResultCode(_profileIds, code): """Handle result code.""" if code == PySide6Legacy.enumValueWrapper( AppQMessageBox.StandardButton.Yes ): - self.deleteItemByIndex(_indexes) + targets = set(_profileIds) + self.deleteItemByIndex( + index + for index, profile in enumerate(Storage.UserServers()) + if profile.metadata.profileId in targets + ) else: pass @@ -1812,7 +1787,7 @@ class ServerTableView( f'{indexes[0] + 1} - ' + Storage.UserServers()[indexes[0]].itemRemark ) mbox.setText(mbox.customText()) - mbox.finished.connect(functools.partial(handleResultCode, indexes)) + mbox.finished.connect(functools.partial(handleResultCode, profileIds)) # Show the MessageBox asynchronously mbox.open() @@ -2003,29 +1978,36 @@ class ServerTableView( mbox.open() def appendNewItemByFactory(self, factory: CoreConfiguration | ServerProfile): - """Append new item by factory.""" - factory = ensureProfile(factory) - index = len(Storage.UserServers()) + """Append one profile through the shared insertion boundary.""" + self.appendNewItemsByFactories((factory,)) - # Set index - factory.index = index + def appendNewItemsByFactories(self, factories): + """Append prepared profiles with one structural notification and reconciliation.""" + factories = [ensureProfile(factory) for factory in factories] - self.sourceModel.beginInsertRows(QtCore.QModelIndex(), index, index) + if not factories: + return - Storage.UserServers().append(factory) + profiles = Storage.UserServers() + first = len(profiles) + for index, factory in enumerate(factories, first): + factory.index = index + + self.sourceModel.beginInsertRows( + QtCore.QModelIndex(), first, first + len(factories) - 1 + ) + profiles.extend(factories) self.sourceModel.endInsertRows() - self.sourceModel.refreshIndexes() + self.reconcileProfileTestJobs() - self.flushRow(index, factory) + if first <= Storage.UserActivatedItemIndex() < first + len(factories): + self.activeServerChanged.emit() - if index == 0: - # The first one. Click it + if first == 0: self.setCurrentIndex(self.proxyIndexFromSourceRow(0)) - # Try to be user-friendly in some extreme cases if not AppConnectionController().isConnected(): - # Activate automatically self.activateItemByIndex(0, True) def appendNewItem(self, **kwargs): diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index 02104a3..87289ac 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -1015,6 +1015,10 @@ class HomePage(Mixins.QTranslatable, QMainWindow): """Append new item by factory.""" self.userServersQTableWidget.appendNewItemByFactory(factory) + def appendNewItemsByFactories(self, factories): + """Forward a batch of prepared profiles to the server table.""" + self.userServersQTableWidget.appendNewItemsByFactories(factories) + def flushRow(self, row: int, item: ServerProfile): """Refresh row.""" self.userServersQTableWidget.flushRow(row, item) diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 42b285e..4d7ba16 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -185,6 +185,10 @@ class MainWindow(AppQMainWindow): """Forward a new server profile to the home page.""" self.homePage.appendNewItemByFactory(factory) + def appendNewItemsByFactories(self, factories): + """Forward a batch of prepared profiles to the server table.""" + self.homePage.appendNewItemsByFactories(factories) + def flushRow(self, row: int, item: ServerProfile): """Forward a server-row refresh to the home page.""" self.homePage.flushRow(row, item) diff --git a/tests/README.md b/tests/README.md index 3fc9866..08b2f4d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -61,7 +61,7 @@ strategy in an individual test. | AppQDialog first-presentation geometry, native show paths, centering, and async lifetime | `test_dialog_geometry.py` | | Editor mappings, lazy log rendering, routing/message-box/connection UI | `test_ui_behavior.py` | | Bounded, incremental, cancellable QR export and snapshot/lifetime safety | `test_qr_export_scalability.py` | -| Real keyboard/mouse/focus, proxy mapping, shared Home/Settings state, Home empty/filter recovery and shared menus, and transient editor input | `test_qt_interactions.py` | +| Real keyboard/mouse/focus, proxy mapping, shared Home/Settings state, Home empty/filter recovery and shared menus, direct small profile operations and batched imports/deletions with throttled progress and stable cancellation targets, and transient editor input | `test_qt_interactions.py` | | Direct Qt ownership and destruction across independent UI families | `test_qt_lifetime.py` | | Batched real/probe Qt object, QR rendering/window lifecycle, handle, Python allocation, and RSS trends | `test_qt_stress.py` | | Repeated harmless subprocess, pipe, thread, handle, and RSS trends | `test_process_stress.py` | diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index c4f53f1..4f7f53b 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -19,15 +19,24 @@ from __future__ import annotations +from Furious.Actions.Import import ImportURIsProgressDialog, importURIs +from Furious.Backends.Xray.Plugin import XrayPlugin from Furious.Controllers import ConnectionState from Furious.Controllers.SettingsController import SettingsController from Furious.Frozenlib import AppBuiltinProxyMode, AppSettings, Mixins 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.Service import ProfileTestField, ProfileTestResult +from Furious.Service.ProfileTesting import ProfileTestTarget from Furious.Qt import AppQAction, AppHue, AppQDialog, AppQSwitch, gettext from Furious.Widget.RoutingSelector import RoutingSelector -from Furious.Widget.ServerTableView import ServerTableView +from Furious.Widget.ServerTableView import ( + DeleteServersProgressDialog, + MBoxQuestionDelete, + ServerTableView, +) from Furious.Widget.SubscriptionTableView import SubscriptionTableView from Furious.Window.HomePage import HomePage from Furious.Window.SettingsPage import ( @@ -1531,5 +1540,478 @@ class SubscriptionEditorQtInteractionTest(unittest.TestCase): self.assertIsNone(reference()) +class ProfileMutationBatchTest(unittest.TestCase): + """Exercise real model mutations with bounded progress and stable targets.""" + + @classmethod + def setUpClass(cls): + application() + + def tearDown(self): + collectAtBoundary() + + @staticmethod + def profile(name): + return ServerProfile.fromConfiguration( + CoreConfiguration({'type': 'fixture'}), {'displayName': name} + ) + + @contextmanager + def table(self, count=0): + controller = mock.Mock() + controller.isConnected.return_value = False + + with ( + isolatedSettings(), + mock.patch.object(Storage, 'UserServers', return_value=[]), + mock.patch( + 'Furious.Widget.ServerTableView.AppConnectionController', + return_value=controller, + ), + ): + Storage.UserServers().extend( + self.profile(f'profile-{index:04}') for index in range(count) + ) + AppSettings.set('ActivatedItemIndex', str(count - 1)) + table = ServerTableView( + configurationEditorFactory=QWidget, + qrCodeWindowFactory=QWidget, + importActionsFactory=tuple, + ) + table.sourceModel.refreshIndexes() + + try: + yield table, controller + finally: + table.cleanup() + table.close() + table.deleteLater() + processQtEvents() + + def testImportCoalescesRowsAndProgressWithoutLosingInvalidInputPositions(self): + profiles = [self.profile(str(index)) for index in range(600)] + invalid = mock.Mock() + invalid.isValid.return_value = False + parsed = [ + invalid if index in (200, 501) else item + for index, item in enumerate(profiles) + ] + expected = [item for item in parsed if item is not invalid] + + with ( + self.table() as (table, controller), + mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), + mock.patch('Furious.Actions.Import.profileFromAny', side_effect=parsed), + mock.patch.object(ServerProfile, 'isValid', return_value=True), + mock.patch('Furious.Actions.Import.time') as clock, + mock.patch('Furious.Actions.Import.singleShotWeakly') as schedule, + mock.patch('Furious.Actions.Import.MBoxImportMultiSuccess') as success, + mock.patch.object( + table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs + ) as reconcile, + mock.patch.object( + table.sourceModel, + 'refreshIndexes', + wraps=table.sourceModel.refreshIndexes, + ) as refresh, + ): + clock.monotonic.return_value = 10.0 + failure = mock.Mock() + dialog = ImportURIsProgressDialog( + tuple('input' for _ in parsed), failure, parent=table + ) + inserted = QSignalSpy(table.sourceModel.rowsInserted) + + with mock.patch.object( + dialog, 'updateStatus', wraps=dialog.updateStatus + ) as status: + dialog.importNext() + self.assertEqual(dialog.currentIndex, 128) + self.assertEqual(len(Storage.UserServers()), 128) + status.assert_not_called() + clock.monotonic.return_value = 10.101 + dialog.importNext() + self.assertEqual(dialog.currentIndex, 256) + self.assertEqual(status.call_count, 1) + self.assertIn('256/600', dialog.statusLabel.text()) + for _index in range(4): + dialog.importNext() + self.assertEqual(status.call_count, 2) + + self.assertEqual(Storage.UserServers(), expected) + self.assertEqual(dialog.imported, [item.itemRemark for item in expected]) + self.assertEqual( + [item.index for item in expected], list(range(len(expected))) + ) + self.assertEqual(inserted.count(), 5) + self.assertEqual( + [(inserted.at(i)[1], inserted.at(i)[2]) for i in range(5)], + [(0, 127), (128, 254), (255, 382), (383, 509), (510, 597)], + ) + self.assertEqual(reconcile.call_count, 5) + refresh.assert_not_called() + self.assertEqual(schedule.call_count, 4) + success.return_value.open.assert_called_once() + failure.assert_not_called() + self.assertEqual(Storage.UserActivatedItemIndex(), 0) + + def testSlowParserYieldsAndCancellationPreservesCommittedBatch(self): + with ( + self.table() as (table, controller), + mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), + mock.patch.object(ServerProfile, 'isValid', return_value=True), + mock.patch('Furious.Actions.Import.time') as clock, + mock.patch('Furious.Actions.Import.singleShotWeakly'), + mock.patch('Furious.Actions.Import.MBoxImportSuccess') as success, + ): + clock.monotonic.return_value = 10.0 + profile = self.profile('first') + + def parse(_uri): + clock.monotonic.return_value += 0.010 + return profile + + with mock.patch( + 'Furious.Actions.Import.profileFromAny', side_effect=parse + ) as parser: + dialog = ImportURIsProgressDialog( + ('first', 'second', 'third'), parent=table + ) + dialog.importNext() + self.assertEqual(dialog.currentIndex, 1) + self.assertEqual(Storage.UserServers(), [profile]) + dialog.cancel() + dialog.importNext() + dialog.importNext() + self.assertTrue(dialog.finishedImport) + parser.assert_called_once() + success.assert_not_called() + + def testInvalidImportReportsFailureOnce(self): + invalid = mock.Mock() + invalid.isValid.return_value = False + + with ( + self.table() as (table, controller), + mock.patch('Furious.Actions.Import.profileFromAny', return_value=invalid), + mock.patch('Furious.Actions.Import.AppMainWindow') as window, + ): + failure = mock.Mock() + dialog = ImportURIsProgressDialog( + ('invalid', 'invalid'), failure, parent=table + ) + dialog.importNext() + dialog.importNext() + failure.assert_called_once_with() + window.assert_not_called() + self.assertEqual(Storage.UserServers(), []) + + def testDeletionCoalescesContiguousRowsAndDisconnectsActiveProfileOnce(self): + with ( + self.table(1000) as (table, controller), + mock.patch('Furious.Widget.ServerTableView.time') as clock, + mock.patch('Furious.Widget.ServerTableView.singleShotWeakly') as schedule, + mock.patch.object( + table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs + ) as reconcile, + ): + clock.monotonic.return_value = 10.0 + controller.isConnected.return_value = True + profiles = list(Storage.UserServers()) + removed = QSignalSpy(table.sourceModel.rowsRemoved) + activated = QSignalSpy(table.activeServerChanged) + dialog = DeleteServersProgressDialog(table, range(1000), parent=table) + + with mock.patch.object( + dialog, 'updateStatus', wraps=dialog.updateStatus + ) as status: + dialog.deleteNext() + self.assertIs( + Storage.UserServers()[Storage.UserActivatedItemIndex()], + profiles[-1], + ) + self.assertEqual(dialog.deletedCount, 128) + controller.startDisconnection.assert_not_called() + status.assert_not_called() + clock.monotonic.return_value = 10.101 + dialog.deleteNext() + self.assertIn('256/1000', dialog.statusLabel.text()) + for _index in range(7): + dialog.deleteNext() + self.assertEqual(status.call_count, 2) + + self.assertEqual(Storage.UserServers(), []) + self.assertTrue(all(profile.deleted for profile in profiles)) + self.assertEqual(removed.count(), 8) + self.assertEqual(reconcile.call_count, 8) + self.assertEqual(schedule.call_count, 7) + self.assertEqual(Storage.UserActivatedItemIndex(), -1) + self.assertEqual(activated.count(), 1) + controller.startDisconnection.assert_called_once() + + def testDeletionKeepsCapturedTargetsAcrossSortRemovalAndCancellation(self): + with ( + self.table(6) as (table, controller), + mock.patch('Furious.Widget.ServerTableView.singleShotWeakly'), + ): + profiles = list(Storage.UserServers()) + dialog = DeleteServersProgressDialog(table, range(5), parent=table) + dialog.BatchSize = 2 + dialog.deleteNext() + self.assertEqual(Storage.UserServers(), profiles[2:]) + table.sourceModel.sort(0, QtCore.Qt.DescendingOrder) + table.deleteItemByIndex([3], showProgress=False) + newcomer = self.profile('new') + table.appendNewItemByFactory(newcomer) + dialog.deleteNext() + dialog.cancel() + dialog.deleteNext() + + self.assertEqual(dialog.deletedCount, 3) + self.assertEqual( + Storage.UserServers(), [profiles[5], profiles[4], newcomer] + ) + self.assertEqual( + [profile.index for profile in Storage.UserServers()], [0, 1, 2] + ) + self.assertIs( + Storage.UserServers()[Storage.UserActivatedItemIndex()], profiles[5] + ) + controller.startDisconnection.assert_not_called() + + def testSparseDeletionPreservesPersistentIndexesAndIgnoresInvalidRows(self): + with self.table(10) as (table, controller): + profiles = list(Storage.UserServers()) + survivor = QtCore.QPersistentModelIndex(table.sourceModel.index(5, 0)) + removed = QSignalSpy(table.sourceModel.rowsRemoved) + count = table.deleteItemByIndex( + [-1, 2, 3, 3, 6, 7, 8, 100], showProgress=False + ) + self.assertEqual(count, 5) + self.assertEqual( + Storage.UserServers(), [profiles[index] for index in (0, 1, 4, 5, 9)] + ) + self.assertEqual( + [(removed.at(i)[1], removed.at(i)[2]) for i in range(2)], + [(6, 8), (2, 3)], + ) + self.assertTrue(survivor.isValid()) + self.assertEqual(survivor.row(), 3) + self.assertEqual(Storage.UserActivatedItemIndex(), 4) + + def testRealCancelInputStopsImportBetweenBatchesAndDestroysDialog(self): + with ( + self.table() as (table, controller), + mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), + mock.patch( + 'Furious.Actions.Import.profileFromAny', + side_effect=lambda _uri: self.profile('input'), + ), + mock.patch.object(ServerProfile, 'isValid', return_value=True), + mock.patch('Furious.Actions.Import.MBoxImportMultiSuccess') as success, + ): + dialog = ImportURIsProgressDialog( + tuple('input' for _ in range(2000)), parent=table + ) + destroyed = QSignalSpy(dialog.destroyed) + dialog.open() + QtCore.QTimer.singleShot( + 0, lambda: QTest.mouseClick(dialog.cancelButton, QtCore.Qt.LeftButton) + ) + self.assertTrue(waitFor(lambda: destroyed.count() == 1)) + self.assertGreater(len(Storage.UserServers()), 0) + self.assertLessEqual(len(Storage.UserServers()), dialog.BatchSize) + success.assert_not_called() + + def testRealParserImportsValidProfilesAmongInvalidInputs(self): + registry = PluginRegistry() + registry.register(XrayPlugin()) + + with ( + mock.patch( + 'Furious.Plugins.Profile.getPluginRegistry', return_value=registry + ), + self.table() as (table, controller), + mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), + mock.patch('Furious.Actions.Import.MBoxImportMultiSuccess') as success, + ): + dialog = ImportURIsProgressDialog( + ( + 'socks://example.test:1080#first', + 'invalid', + 'socks://example.test:1081#second', + ), + parent=table, + ) + done = QSignalSpy(dialog.finished) + dialog.open() + self.assertTrue(waitFor(lambda: done.count() == 1)) + self.assertEqual( + [profile.itemRemark for profile in Storage.UserServers()], + ['first', 'second'], + ) + self.assertTrue(all(profile.isValid() for profile in Storage.UserServers())) + success.return_value.open.assert_called_once() + + def testInsertionPreservesInitialActiveNotificationAndRefreshesTestTargets(self): + with self.table() as (table, controller): + AppSettings.set('ActivatedItemIndex', '0') + first, second = self.profile('first'), self.profile('second') + target = ProfileTestTarget.capture(first) + activated = QSignalSpy(table.activeServerChanged) + table.appendNewItemsByFactories((first, second)) + self.assertEqual(activated.count(), 1) + self.assertIs(table.profileTestManager.resolveTarget(target), first) + table.deleteItemByIndex((0,), showProgress=False) + self.assertIsNone(table.profileTestManager.resolveTarget(target)) + self.assertFalse( + table.profileTestManager.applyResult( + target, ProfileTestResult(ProfileTestField.Latency, 'stale') + ) + ) + self.assertEqual(first.metadata.latency, '') + + def testDeleteConfirmationKeepsOriginalTargetAfterSorting(self): + with self.table(4) as (table, controller): + profiles = list(Storage.UserServers()) + table.setCurrentIndex(table.proxyIndexFromSourceRow(0)) + confirmation = MBoxQuestionDelete(parent=table) + + with mock.patch( + 'Furious.Widget.ServerTableView.MBoxQuestionDelete', + return_value=confirmation, + ): + table.deleteSelectedItem() + table.sourceModel.sort(0, QtCore.Qt.DescendingOrder) + confirmation.done(int(confirmation.StandardButton.Yes)) + self.assertTrue(profiles[0].deleted) + self.assertEqual( + Storage.UserServers(), [profiles[3], profiles[2], profiles[1]] + ) + self.assertIs( + Storage.UserServers()[Storage.UserActivatedItemIndex()], profiles[3] + ) + + def testBatchPreparationFailureDoesNotPublishPartialRows(self): + with self.table(1) as (table, controller): + original = list(Storage.UserServers()) + inserted = QSignalSpy(table.sourceModel.rowsInserted) + + with self.assertRaises(TypeError): + table.appendNewItemsByFactories((self.profile('valid'), object())) + + self.assertEqual(Storage.UserServers(), original) + self.assertEqual(inserted.count(), 0) + + def testSmallImportsCompleteDirectlyWithOneInsertion(self): + for count in (1, 64): + with ( + self.subTest(count=count), + self.table(2) as (table, controller), + mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), + mock.patch.object(ServerProfile, 'isValid', return_value=True), + mock.patch('Furious.Actions.Import.MBoxImportSuccess') as singleSuccess, + mock.patch( + 'Furious.Actions.Import.MBoxImportMultiSuccess' + ) as multiSuccess, + mock.patch.object(ImportURIsProgressDialog, 'open') as progress, + mock.patch.object( + table, + 'reconcileProfileTestJobs', + wraps=table.reconcileProfileTestJobs, + ) as reconcile, + ): + imported = [self.profile(f'new-{index}') for index in range(count)] + inserted = QSignalSpy(table.sourceModel.rowsInserted) + with mock.patch( + 'Furious.Actions.Import.profileFromAny', side_effect=imported + ): + importURIs(*('input' for _ in range(count))) + + self.assertEqual(Storage.UserServers()[2:], imported) + self.assertEqual(inserted.count(), 1) + self.assertEqual((inserted.at(0)[1], inserted.at(0)[2]), (2, count + 1)) + reconcile.assert_called_once_with() + progress.assert_not_called() + if count == 1: + singleSuccess.return_value.open.assert_called_once() + else: + multiSuccess.return_value.open.assert_called_once() + self.assertEqual(multiSuccess.return_value.rowIndex, 2) + + def testSmallImportRetainsInvalidInputAndFailureBehavior(self): + invalid = mock.Mock() + invalid.isValid.return_value = False + with ( + self.table() as (table, controller), + mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), + mock.patch('Furious.Actions.Import.MBoxImportSuccess') as success, + mock.patch.object(ImportURIsProgressDialog, 'open') as progress, + ): + failure = mock.Mock() + with mock.patch( + 'Furious.Actions.Import.profileFromAny', return_value=invalid + ): + importURIs('invalid', 'invalid', failureCallback=failure) + failure.assert_called_once_with() + self.assertFalse(Storage.UserServers()) + valid = self.profile('valid') + with ( + mock.patch( + 'Furious.Actions.Import.profileFromAny', + side_effect=(invalid, valid), + ), + mock.patch.object(ServerProfile, 'isValid', return_value=True), + ): + importURIs('invalid', 'valid', failureCallback=failure) + self.assertEqual(Storage.UserServers(), [valid]) + failure.assert_called_once_with() + success.return_value.open.assert_called_once() + progress.assert_not_called() + + def testImportsAboveCutoffUseTheExistingProgressBatches(self): + with ( + self.table() as (table, controller), + mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), + mock.patch('Furious.Actions.Import.profileFromAny') as parser, + mock.patch.object( + ImportURIsProgressDialog, 'open', autospec=True + ) as progress, + ): + uris = tuple('input' for _ in range(65)) + importURIs(*uris) + progress.assert_called_once() + dialog = progress.call_args.args[0] + self.assertEqual(dialog.uris, uris) + self.assertEqual(dialog.BatchSize, 128) + self.assertEqual(dialog.currentIndex, 0) + parser.assert_not_called() + self.assertFalse(Storage.UserServers()) + + def testDeletionCutoffCountsOnlyDistinctValidTargets(self): + for count in (1, 64, 65): + with ( + self.subTest(count=count), + self.table(70) as (table, controller), + mock.patch.object( + DeleteServersProgressDialog, 'open', autospec=True + ) as progress, + ): + profiles = list(Storage.UserServers()) + indexes = [*range(count), *range(count), -1, 1000] + deleted = table.deleteItemByIndex(indexes) + if count <= 64: + self.assertEqual(deleted, count) + self.assertEqual(Storage.UserServers(), profiles[count:]) + progress.assert_not_called() + else: + self.assertEqual(deleted, 0) + self.assertEqual(Storage.UserServers(), profiles) + progress.assert_called_once() + dialog = progress.call_args.args[0] + self.assertEqual(dialog.total, 65) + self.assertEqual(dialog.BatchSize, 128) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index 4eb7422..522e1f6 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -487,8 +487,7 @@ raise SystemExit(application.exec()) collectAtBoundary() self.assertAllDestroyed(references, destroyed, iterations) - self.assertEqual(table.sourceModel.refreshIndexes.call_count, iterations) - self.assertEqual(table.sourceModel.emitAllChanged.call_count, iterations) + table.deleteItemByIndex.assert_not_called() def testTextEditorIndentCompletionReceivesExactTransientDialog(self): """Apply indentation through explicit weak sender forwarding."""