Batch profile imports and deletions

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-09-09 01:00:02 +08:00
parent b455c4cf54
commit 24d3d29af9
7 changed files with 635 additions and 158 deletions
+42 -36
View File
@@ -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:
+100 -118
View File
@@ -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):
+4
View File
@@ -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)
+4
View File
@@ -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)
+1 -1
View File
@@ -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` |
+483 -1
View File
@@ -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()
+1 -2
View File
@@ -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."""