mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 15:04:34 +03:00
Batch profile imports and deletions
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
+42
-36
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user