mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
Scale QR export without a progress dialog
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -2060,10 +2060,8 @@ class ServerTableView(
|
||||
return
|
||||
|
||||
window = self.qrCodeWindowFactory()
|
||||
window.initTabByIndex(indexes)
|
||||
|
||||
if window.tabCount() > 0:
|
||||
window.show()
|
||||
return window.startExportByIndex(indexes)
|
||||
|
||||
def exportSelectedItemJSON(self):
|
||||
"""Export selected item JSON."""
|
||||
|
||||
+224
-35
@@ -20,9 +20,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Frozenlib import APPLICATION_NAME
|
||||
from Furious.Models import ServerProfile
|
||||
from Furious.Plugins import exportConfiguration
|
||||
from Furious.Repository import Storage
|
||||
from Furious.Qt import AppQMainWindow, AppQTabWidget, connectWeakly
|
||||
from Furious.Qt import (
|
||||
AppQMainWindow,
|
||||
AppQTabWidget,
|
||||
connectWeakly,
|
||||
)
|
||||
from Furious.Qt import gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
@@ -32,10 +37,19 @@ from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
import segno
|
||||
import logging
|
||||
|
||||
__all__ = ['QRCodeWindow']
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = [
|
||||
'MAXIMUM_QR_EXPORT_PROFILES',
|
||||
'QRCodeExportItem',
|
||||
'QRCodeWindow',
|
||||
'captureQRCodeExportItems',
|
||||
]
|
||||
|
||||
_QR_ERROR_CORRECTION = 'H'
|
||||
_QR_PAGE_MARGIN = 28
|
||||
MAXIMUM_QR_EXPORT_PROFILES = 50
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,6 +75,62 @@ def createQRCodeImage(data: str) -> QImage:
|
||||
return image
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QRCodeExportItem:
|
||||
"""Capture one profile exactly as selected for later QR export."""
|
||||
|
||||
position: int
|
||||
remark: str
|
||||
profile: ServerProfile
|
||||
|
||||
|
||||
def captureQRCodeExportItems(
|
||||
indexes: Iterable[int],
|
||||
profiles: Sequence[ServerProfile] | None = None,
|
||||
) -> tuple[QRCodeExportItem, ...]:
|
||||
"""Snapshot at most the first eligible selected profiles in caller order."""
|
||||
sourceProfiles = Storage.UserServers() if profiles is None else profiles
|
||||
items = []
|
||||
|
||||
for rawIndex in indexes:
|
||||
if len(items) >= MAXIMUM_QR_EXPORT_PROFILES:
|
||||
break
|
||||
|
||||
try:
|
||||
index = int(rawIndex)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if index < 0 or index >= len(sourceProfiles):
|
||||
continue
|
||||
|
||||
profile = sourceProfiles[index]
|
||||
|
||||
try:
|
||||
snapshot = profile.deepcopy()
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
# A malformed plugin profile must not abort the remaining export.
|
||||
logger.warning(
|
||||
'unable to snapshot profile %d for QR presentation (%s)',
|
||||
index + 1,
|
||||
type(ex).__name__,
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
items.append(
|
||||
QRCodeExportItem(
|
||||
position=index + 1,
|
||||
remark=str(profile.itemRemark),
|
||||
profile=snapshot,
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(items)
|
||||
|
||||
|
||||
class _QRCodePage(QWidget):
|
||||
"""Own and responsively present one logical QR image inside a tab page."""
|
||||
|
||||
@@ -163,7 +233,7 @@ class _QRCodePage(QWidget):
|
||||
|
||||
|
||||
class QRCodeWindow(AppQMainWindow):
|
||||
"""Present exported configurations in independent QR-code tabs."""
|
||||
"""Incrementally present exported configurations in QR-code tabs."""
|
||||
|
||||
DEFAULT_WINDOW_SIZE = QtCore.QSize(720, 680)
|
||||
MINIMUM_WINDOW_SIZE = QtCore.QSize(520, 520)
|
||||
@@ -176,6 +246,20 @@ class QRCodeWindow(AppQMainWindow):
|
||||
self.setMinimumSize(self.MINIMUM_WINDOW_SIZE)
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||
|
||||
self._exportItems = tuple()
|
||||
self._exportProcessedCount = 0
|
||||
self._exportGeneratedCount = 0
|
||||
self._exporting = False
|
||||
self._exportTimer = QtCore.QTimer(self)
|
||||
self._exportTimer.setSingleShot(True)
|
||||
|
||||
connectWeakly(
|
||||
self._exportTimer.timeout,
|
||||
self,
|
||||
'processNextExportItem',
|
||||
sender=self._exportTimer,
|
||||
)
|
||||
|
||||
self.tabWidget = AppQTabWidget(parent=self, translatable=False)
|
||||
self.tabWidget.setTabsClosable(True)
|
||||
self.tabWidget.setElideMode(QtCore.Qt.TextElideMode.ElideRight)
|
||||
@@ -192,6 +276,18 @@ class QRCodeWindow(AppQMainWindow):
|
||||
"""Return the number of successfully generated QR-code tabs."""
|
||||
return self.tabWidget.count()
|
||||
|
||||
def isExporting(self) -> bool:
|
||||
"""Return whether incremental QR generation still owns pending items."""
|
||||
return self._exporting
|
||||
|
||||
def exportProcessedCount(self) -> int:
|
||||
"""Return the number of items attempted by the current export."""
|
||||
return self._exportProcessedCount
|
||||
|
||||
def exportGeneratedCount(self) -> int:
|
||||
"""Return the number of tabs generated by the current export."""
|
||||
return self._exportGeneratedCount
|
||||
|
||||
def _clearTabs(self):
|
||||
"""Remove and destroy every page currently owned by the tab widget."""
|
||||
while self.tabWidget.count():
|
||||
@@ -202,50 +298,143 @@ class QRCodeWindow(AppQMainWindow):
|
||||
if page is not None:
|
||||
page.deleteLater()
|
||||
|
||||
def appendExportItem(self, item: QRCodeExportItem) -> bool:
|
||||
"""Generate and append one captured profile without affecting its siblings."""
|
||||
try:
|
||||
uri = exportConfiguration(item.profile)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.warning(
|
||||
'unable to export profile %d for QR presentation (%s)',
|
||||
item.position,
|
||||
type(ex).__name__,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
if not uri:
|
||||
return False
|
||||
|
||||
try:
|
||||
image = createQRCodeImage(uri)
|
||||
except (segno.DataOverflowError, ValueError) as ex:
|
||||
logger.warning(
|
||||
'unable to create QR code for profile %d (%s)',
|
||||
item.position,
|
||||
type(ex).__name__,
|
||||
)
|
||||
|
||||
return False
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.warning(
|
||||
'unable to create QR code for profile %d (%s)',
|
||||
item.position,
|
||||
type(ex).__name__,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
page = _QRCodePage(image, parent=self.tabWidget)
|
||||
title = f'{item.position} - {item.remark}'
|
||||
|
||||
tabIndex = self.tabWidget.addTab(page, title)
|
||||
|
||||
self.tabWidget.setTabToolTip(tabIndex, title)
|
||||
|
||||
return True
|
||||
|
||||
def initTabByIndex(self, indexes: list[int]):
|
||||
"""Replace the tabs with QR pages for the selected profile indexes."""
|
||||
"""Synchronously initialize capped tabs for compatibility callers."""
|
||||
self.cancelExport()
|
||||
self._clearTabs()
|
||||
|
||||
profiles = Storage.UserServers()
|
||||
for item in captureQRCodeExportItems(indexes):
|
||||
self.appendExportItem(item)
|
||||
|
||||
for index in indexes:
|
||||
config = profiles[index]
|
||||
def startExportByIndex(self, indexes: Iterable[int]):
|
||||
"""Start one bounded, window-owned incremental QR export."""
|
||||
items = captureQRCodeExportItems(tuple(indexes))
|
||||
|
||||
try:
|
||||
uri = exportConfiguration(config)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
logger.warning(
|
||||
'unable to export profile %d for QR presentation (%s)',
|
||||
index + 1,
|
||||
type(ex).__name__,
|
||||
)
|
||||
continue
|
||||
self.cancelExport()
|
||||
self._clearTabs()
|
||||
self._exportProcessedCount = 0
|
||||
self._exportGeneratedCount = 0
|
||||
|
||||
if not uri:
|
||||
continue
|
||||
if not items:
|
||||
self.deleteLater()
|
||||
|
||||
try:
|
||||
image = createQRCodeImage(uri)
|
||||
except (segno.DataOverflowError, ValueError) as ex:
|
||||
logger.warning(
|
||||
'unable to create QR code for profile %d (%s)',
|
||||
index + 1,
|
||||
type(ex).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
continue
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
if len(items) == 1:
|
||||
if self.appendExportItem(items[0]):
|
||||
self._exportProcessedCount = 1
|
||||
self._exportGeneratedCount = 1
|
||||
self.show()
|
||||
else:
|
||||
self._exportProcessedCount = 1
|
||||
self.deleteLater()
|
||||
|
||||
continue
|
||||
return self
|
||||
|
||||
page = _QRCodePage(image, parent=self.tabWidget)
|
||||
title = f'{index + 1} - {config.itemRemark}'
|
||||
self._exportItems = items
|
||||
self._exporting = True
|
||||
self.show()
|
||||
self._exportTimer.start(0)
|
||||
|
||||
tabIndex = self.tabWidget.addTab(page, title)
|
||||
return self
|
||||
|
||||
self.tabWidget.setTabToolTip(tabIndex, title)
|
||||
def processNextExportItem(self):
|
||||
"""Attempt one captured profile, then yield to the Qt event loop."""
|
||||
if not self._exporting:
|
||||
return
|
||||
|
||||
if self._exportProcessedCount >= len(self._exportItems):
|
||||
self.finishExport()
|
||||
|
||||
return
|
||||
|
||||
item = self._exportItems[self._exportProcessedCount]
|
||||
generated = self.appendExportItem(item)
|
||||
|
||||
self._exportProcessedCount += 1
|
||||
self._exportGeneratedCount += int(generated)
|
||||
|
||||
if self._exportProcessedCount >= len(self._exportItems):
|
||||
self.finishExport()
|
||||
|
||||
return
|
||||
|
||||
self._exportTimer.start(0)
|
||||
|
||||
def finishExport(self):
|
||||
"""Release completed export state and discard an empty result window."""
|
||||
if not self._exporting:
|
||||
return
|
||||
|
||||
generatedCount = self._exportGeneratedCount
|
||||
|
||||
self._exportTimer.stop()
|
||||
self._exportItems = tuple()
|
||||
self._exporting = False
|
||||
|
||||
if generatedCount == 0:
|
||||
self.close()
|
||||
|
||||
def cancelExport(self):
|
||||
"""Cancel pending work without removing tabs already generated."""
|
||||
self._exportTimer.stop()
|
||||
self._exportItems = tuple()
|
||||
self._exporting = False
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Cancel an active export after Qt accepts closing this window."""
|
||||
super().closeEvent(event)
|
||||
|
||||
if event.isAccepted():
|
||||
self.cancelExport()
|
||||
|
||||
@QtCore.Slot(int)
|
||||
def handleTabCloseRequested(self, index: int):
|
||||
|
||||
+20
-4
@@ -59,11 +59,12 @@ strategy in an individual test.
|
||||
| AppQMainWindow lifecycle, subclass policies, geometry restoration, and migration | `test_main_window_geometry.py` |
|
||||
| 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, 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, handle, Python allocation, and RSS trends | `test_qt_stress.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` |
|
||||
| Opt-in release-confidence counts (100 app children, 100 external cores, 100k metrics, 40k logs, 20k navigation, 5k plugins, 1k dialogs) | `test_very_heavy.py` |
|
||||
| Opt-in release-confidence counts (100 app children, 100 external cores, 100k metrics, 40k logs, 20k navigation, 5k plugins, 1k dialogs, 1k real QR tabs) | `test_very_heavy.py` |
|
||||
|
||||
The lifecycle tests classify `AppQTransientDialog`, protocol/plugin editors,
|
||||
routing dialogs, subscription editors, message boxes, QR windows, and TUN
|
||||
@@ -109,7 +110,7 @@ Then run the desired test tier.
|
||||
python -m unittest discover -s tests -v
|
||||
|
||||
# Regular logic, persistence, plugin, controller, codec, and UI regressions
|
||||
python -m unittest tests.test_interface tests.test_models_and_services tests.test_repository_contracts tests.test_architecture_refactors tests.test_connection_startup_async tests.test_plugin_architecture tests.test_hysteria1_protocol tests.test_hysteria2_compatibility tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_subscription_scalability tests.test_profile_test_jobs tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_xray_asset_download tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_dialog_geometry tests.test_ui_behavior tests.test_qt_interactions tests.test_stylesheet_states tests.test_theme_transition tests.test_public_api -v
|
||||
python -m unittest tests.test_interface tests.test_models_and_services tests.test_repository_contracts tests.test_architecture_refactors tests.test_connection_startup_async tests.test_plugin_architecture tests.test_hysteria1_protocol tests.test_hysteria2_compatibility tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_subscription_scalability tests.test_profile_test_jobs tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_xray_asset_download tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_dialog_geometry tests.test_ui_behavior tests.test_qr_export_scalability tests.test_qt_interactions tests.test_stylesheet_states tests.test_theme_transition tests.test_public_api -v
|
||||
|
||||
# Direct Qt/process integration and destruction/lifetime checks
|
||||
python -m unittest tests.test_application_process tests.test_external_core tests.test_layout_matrix tests.test_qt_lifetime -v
|
||||
@@ -126,7 +127,7 @@ python -m unittest tests.test_very_heavy -v
|
||||
python -m unittest tests.test_log_manager_generation.VeryHeavyGenerationLogManagerTest -v
|
||||
|
||||
# Shared-state order-independence spot check
|
||||
python -m unittest tests.test_public_api tests.test_theme_transition tests.test_stylesheet_states tests.test_qt_interactions tests.test_ui_behavior tests.test_dialog_geometry tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_xray_asset_download tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_profile_test_jobs tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_hysteria2_compatibility tests.test_hysteria1_protocol tests.test_plugin_architecture tests.test_connection_startup_async tests.test_architecture_refactors tests.test_repository_contracts tests.test_models_and_services tests.test_interface -v
|
||||
python -m unittest tests.test_public_api tests.test_theme_transition tests.test_stylesheet_states tests.test_qt_interactions tests.test_qr_export_scalability tests.test_ui_behavior tests.test_dialog_geometry tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_xray_asset_download tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_profile_test_jobs tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_hysteria2_compatibility tests.test_hysteria1_protocol tests.test_plugin_architecture tests.test_connection_startup_async tests.test_architecture_refactors tests.test_repository_contracts tests.test_models_and_services tests.test_interface -v
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
@@ -155,6 +156,21 @@ final status and exit code as authoritative; expected diagnostic output still en
|
||||
per group by default and reports decode/parse CPU time, reconciliation preparation, worker wall time, and GUI-thread
|
||||
commit time. Pass `--url` explicitly to benchmark a live subscription; normal tests never use the network.
|
||||
|
||||
`tests/benchmarks/benchmark_qr_export.py` measures the real Segno-to-`QImage` renderer and the complete synchronous or
|
||||
incremental QR-window pipeline. The 5,000-item UI workloads are intentionally benchmarks rather than correctness tests;
|
||||
run each mode in a fresh process so allocator and Qt-widget state do not affect the next measurement:
|
||||
|
||||
```text
|
||||
python tests/benchmarks/benchmark_qr_export.py --mode images --count 5000
|
||||
python tests/benchmarks/benchmark_qr_export.py --mode synchronous --count 5000
|
||||
python tests/benchmarks/benchmark_qr_export.py --mode asynchronous --count 5000
|
||||
```
|
||||
|
||||
The regular Qt stress tier renders repeated real QR batches at the production cap. The opt-in release-confidence tier
|
||||
temporarily raises the cap to 1,000 and verifies real generation, event-loop yielding, result-window presentation,
|
||||
exact tab completion, and window-owned timer/state destruction. These tests intentionally assert behavior and cleanup
|
||||
rather than machine-dependent elapsed-time thresholds.
|
||||
|
||||
## Packaged-build smoke procedure
|
||||
|
||||
Packaged/Nuitka behavior is outside the source-level `unittest` fixtures. For an
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Benchmark real QR rendering and synchronous/asynchronous QR windows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
|
||||
|
||||
repositoryRoot = Path(__file__).resolve().parents[2]
|
||||
|
||||
if str(repositoryRoot) not in sys.path:
|
||||
sys.path.insert(0, str(repositoryRoot))
|
||||
|
||||
from Furious.Backends.Xray import XrayPlugin
|
||||
from Furious.Plugins import PluginRegistry, profileFromAny
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
from tests.support import application, collectAtBoundary, isolatedSettings
|
||||
|
||||
from importlib import import_module
|
||||
from unittest import mock
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
qrCodeModule = import_module('Furious.Window.QRCodeWindow')
|
||||
|
||||
|
||||
def profiles(count: int, registry: PluginRegistry):
|
||||
"""Return independent SOCKS profiles handled by the real exporter."""
|
||||
baseProfile = profileFromAny(
|
||||
'socks://benchmark.example:1080#Benchmark',
|
||||
registry=registry,
|
||||
)
|
||||
result = []
|
||||
|
||||
for index in range(count):
|
||||
profile = baseProfile.deepcopy()
|
||||
profile.metadata.displayName = f'Benchmark {index + 1}'
|
||||
result.append(profile)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def pumpUntil(predicate, timeout: float):
|
||||
"""Pump the real Qt event loop until completion or a bounded timeout."""
|
||||
app = application()
|
||||
deadline = time.perf_counter() + timeout
|
||||
|
||||
while time.perf_counter() < deadline:
|
||||
app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 20)
|
||||
|
||||
if predicate():
|
||||
return
|
||||
|
||||
QtCore.QThread.msleep(1)
|
||||
|
||||
raise TimeoutError(f'QR benchmark exceeded {timeout:g} seconds')
|
||||
|
||||
|
||||
class TrackingQRCodeWindow(qrCodeModule.QRCodeWindow):
|
||||
"""Record first native presentation milestones without changing behavior."""
|
||||
|
||||
shownAt = None
|
||||
paintedAt = None
|
||||
finishedAt = None
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Record the first result-window show event."""
|
||||
if type(self).shownAt is None:
|
||||
type(self).shownAt = time.perf_counter()
|
||||
|
||||
super().showEvent(event)
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Record the first result-window paint event."""
|
||||
if type(self).paintedAt is None:
|
||||
type(self).paintedAt = time.perf_counter()
|
||||
|
||||
super().paintEvent(event)
|
||||
|
||||
def finishExport(self):
|
||||
"""Record completion of the window-owned incremental export."""
|
||||
wasExporting = self.isExporting()
|
||||
|
||||
super().finishExport()
|
||||
|
||||
if wasExporting and not self.isExporting():
|
||||
type(self).finishedAt = time.perf_counter()
|
||||
|
||||
|
||||
def resetMilestones():
|
||||
"""Reset class-level measurements before an independent run."""
|
||||
TrackingQRCodeWindow.shownAt = None
|
||||
TrackingQRCodeWindow.paintedAt = None
|
||||
TrackingQRCodeWindow.finishedAt = None
|
||||
|
||||
|
||||
def elapsed(started: float, milestone: float | None):
|
||||
"""Return one relative duration, preserving an absent milestone as None."""
|
||||
return None if milestone is None else milestone - started
|
||||
|
||||
|
||||
def benchmarkImages(count: int):
|
||||
"""Render representative payloads without constructing Qt windows."""
|
||||
started = time.perf_counter()
|
||||
lastImage = None
|
||||
|
||||
for index in range(count):
|
||||
payload = (
|
||||
f'socks://benchmark-user:benchmark-password@node-{index}.example:1080'
|
||||
f'#Benchmark-QR-Code-{index}'
|
||||
)
|
||||
lastImage = qrCodeModule.createQRCodeImage(payload)
|
||||
|
||||
duration = time.perf_counter() - started
|
||||
|
||||
return {
|
||||
'mode': 'images',
|
||||
'count': count,
|
||||
'duration_s': duration,
|
||||
'codes_per_s': count / duration,
|
||||
'last_width': lastImage.width(),
|
||||
'last_height': lastImage.height(),
|
||||
}
|
||||
|
||||
|
||||
def benchmarkWindow(mode: str, count: int, timeout: float):
|
||||
"""Exercise the real exporter, QR renderer, tab pages, and presentation."""
|
||||
registry = PluginRegistry()
|
||||
registry.register(XrayPlugin())
|
||||
|
||||
try:
|
||||
setupStarted = time.perf_counter()
|
||||
serverProfiles = profiles(count, registry)
|
||||
profileSetupDuration = time.perf_counter() - setupStarted
|
||||
resetMilestones()
|
||||
window = TrackingQRCodeWindow()
|
||||
started = time.perf_counter()
|
||||
|
||||
with mock.patch.object(
|
||||
qrCodeModule,
|
||||
'MAXIMUM_QR_EXPORT_PROFILES',
|
||||
count,
|
||||
), mock.patch.object(
|
||||
qrCodeModule.Storage,
|
||||
'UserServers',
|
||||
return_value=serverProfiles,
|
||||
), mock.patch.object(
|
||||
qrCodeModule,
|
||||
'exportConfiguration',
|
||||
side_effect=registry.exportConfig,
|
||||
):
|
||||
if mode == 'synchronous':
|
||||
window.initTabByIndex(range(count))
|
||||
buildFinished = time.perf_counter()
|
||||
window.show()
|
||||
pumpUntil(
|
||||
lambda: TrackingQRCodeWindow.paintedAt is not None,
|
||||
timeout,
|
||||
)
|
||||
finishedAt = time.perf_counter()
|
||||
result = {
|
||||
'mode': mode,
|
||||
'count': count,
|
||||
'profile_setup_s': profileSetupDuration,
|
||||
'build_s': buildFinished - started,
|
||||
'result_show_s': elapsed(started, TrackingQRCodeWindow.shownAt),
|
||||
'result_first_paint_s': elapsed(
|
||||
started,
|
||||
TrackingQRCodeWindow.paintedAt,
|
||||
),
|
||||
'duration_s': finishedAt - started,
|
||||
'tabs': window.tabCount(),
|
||||
}
|
||||
else:
|
||||
window.startExportByIndex(range(count))
|
||||
returnedAt = time.perf_counter()
|
||||
pumpUntil(
|
||||
lambda: TrackingQRCodeWindow.finishedAt is not None,
|
||||
timeout,
|
||||
)
|
||||
|
||||
result = {
|
||||
'mode': mode,
|
||||
'count': count,
|
||||
'profile_setup_s': profileSetupDuration,
|
||||
'start_return_s': returnedAt - started,
|
||||
'result_show_s': elapsed(started, TrackingQRCodeWindow.shownAt),
|
||||
'result_first_paint_s': elapsed(
|
||||
started,
|
||||
TrackingQRCodeWindow.paintedAt,
|
||||
),
|
||||
'duration_s': elapsed(
|
||||
started,
|
||||
TrackingQRCodeWindow.finishedAt,
|
||||
),
|
||||
'tabs': window.tabCount(),
|
||||
}
|
||||
|
||||
if result['tabs'] != count:
|
||||
raise RuntimeError(f"expected {count} tabs, got {result['tabs']}")
|
||||
|
||||
window.close()
|
||||
collectAtBoundary()
|
||||
|
||||
return result
|
||||
finally:
|
||||
registry.shutdown()
|
||||
|
||||
|
||||
def main():
|
||||
"""Run one explicitly selected benchmark workload."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
'--mode',
|
||||
choices=('images', 'synchronous', 'asynchronous'),
|
||||
default='asynchronous',
|
||||
)
|
||||
parser.add_argument('--count', type=int, default=5_000)
|
||||
parser.add_argument('--timeout', type=float, default=900.0)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
if arguments.count < 1:
|
||||
parser.error('--count must be at least 1')
|
||||
|
||||
application()
|
||||
|
||||
with isolatedSettings():
|
||||
if arguments.mode == 'images':
|
||||
result = benchmarkImages(arguments.count)
|
||||
else:
|
||||
result = benchmarkWindow(
|
||||
arguments.mode,
|
||||
arguments.count,
|
||||
max(arguments.timeout, 1.0),
|
||||
)
|
||||
|
||||
print(' '.join(f'{key}={value}' for key, value in result.items()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,437 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Exercise bounded, incremental QR export with real Qt delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Models import CoreConfiguration, ServerProfile
|
||||
from Furious.Window.QRCodeWindow import (
|
||||
MAXIMUM_QR_EXPORT_PROFILES,
|
||||
QRCodeWindow,
|
||||
captureQRCodeExportItems,
|
||||
)
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
from shiboken6 import isValid
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import segno
|
||||
import unittest
|
||||
import weakref
|
||||
|
||||
from tests.support import (
|
||||
application,
|
||||
collectAtBoundary,
|
||||
isolatedSettings,
|
||||
processQtEvents,
|
||||
waitFor,
|
||||
)
|
||||
|
||||
|
||||
class QRCodeExportScalabilityTest(unittest.TestCase):
|
||||
"""Verify QR export remains bounded, responsive, stable, and disposable."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Create the process-wide offscreen QApplication."""
|
||||
application()
|
||||
|
||||
def setUp(self):
|
||||
"""Isolate settings used by top-level window presentation."""
|
||||
self.settingsContext = isolatedSettings()
|
||||
self.settingsContext.__enter__()
|
||||
|
||||
def tearDown(self):
|
||||
"""Drain deferred window deletion before restoring settings."""
|
||||
collectAtBoundary()
|
||||
self.settingsContext.__exit__(None, None, None)
|
||||
|
||||
@staticmethod
|
||||
def profile(index: int) -> ServerProfile:
|
||||
"""Return one independent deterministic profile fixture."""
|
||||
return ServerProfile.fromConfiguration(
|
||||
CoreConfiguration({'type': 'fixture', 'address': f'{index}.example'}),
|
||||
{'displayName': f'Profile {index + 1}'},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def profiles(cls, count: int) -> list[ServerProfile]:
|
||||
"""Return the requested deterministic profile list."""
|
||||
return [cls.profile(index) for index in range(count)]
|
||||
|
||||
@staticmethod
|
||||
def qrImage(*_args) -> QImage:
|
||||
"""Return a small valid square image for orchestration-only tests."""
|
||||
image = QImage(29, 29, QImage.Format.Format_Grayscale8)
|
||||
image.fill(255)
|
||||
|
||||
return image
|
||||
|
||||
def testCaptureLimitForOneFiftyFiftyOneAndThousands(self):
|
||||
"""Snapshot no more than fifty profiles regardless of selection size."""
|
||||
for selectedCount in (1, 50, 51, 2000):
|
||||
with self.subTest(selectedCount=selectedCount):
|
||||
profiles = []
|
||||
|
||||
for index in range(selectedCount):
|
||||
profile = mock.Mock(itemRemark=f'Profile {index + 1}')
|
||||
profile.deepcopy.return_value = mock.Mock()
|
||||
profiles.append(profile)
|
||||
|
||||
items = captureQRCodeExportItems(
|
||||
range(selectedCount),
|
||||
profiles=profiles,
|
||||
)
|
||||
expected = min(selectedCount, MAXIMUM_QR_EXPORT_PROFILES)
|
||||
|
||||
self.assertEqual(len(items), expected)
|
||||
self.assertEqual(
|
||||
sum(profile.deepcopy.call_count for profile in profiles),
|
||||
expected,
|
||||
)
|
||||
|
||||
if selectedCount > MAXIMUM_QR_EXPORT_PROFILES:
|
||||
self.assertTrue(
|
||||
all(
|
||||
profile.deepcopy.call_count == 0
|
||||
for profile in profiles[MAXIMUM_QR_EXPORT_PROFILES:]
|
||||
)
|
||||
)
|
||||
|
||||
def testSingleProfileExportRemainsImmediate(self):
|
||||
"""Keep one-profile export synchronous without scheduling a batch."""
|
||||
profiles = self.profiles(1)
|
||||
window = QRCodeWindow()
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
return_value='socks://single.example:1080#Single',
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.createQRCodeImage',
|
||||
side_effect=self.qrImage,
|
||||
):
|
||||
result = window.startExportByIndex([0])
|
||||
|
||||
self.assertIs(result, window)
|
||||
self.assertFalse(window.isExporting())
|
||||
self.assertEqual(window.exportProcessedCount(), 1)
|
||||
self.assertEqual(window.exportGeneratedCount(), 1)
|
||||
self.assertEqual(window.tabCount(), 1)
|
||||
self.assertTrue(window.isVisible())
|
||||
|
||||
window.close()
|
||||
|
||||
def testOperationAttemptsAtMostTheProductionCap(self):
|
||||
"""Attempt exactly the captured count and discard an empty result window."""
|
||||
for selectedCount in (50, 51, 1000):
|
||||
with self.subTest(selectedCount=selectedCount):
|
||||
profiles = self.profiles(selectedCount)
|
||||
window = QRCodeWindow()
|
||||
appendExportItem = mock.Mock(return_value=False)
|
||||
window.appendExportItem = appendExportItem
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
):
|
||||
result = window.startExportByIndex(range(selectedCount))
|
||||
|
||||
expected = min(selectedCount, MAXIMUM_QR_EXPORT_PROFILES)
|
||||
|
||||
self.assertIs(result, window)
|
||||
self.assertEqual(appendExportItem.call_count, 0)
|
||||
self.assertTrue(
|
||||
waitFor(lambda: not isValid(window), timeout=5.0),
|
||||
'empty QR operation did not close',
|
||||
)
|
||||
self.assertEqual(appendExportItem.call_count, expected)
|
||||
|
||||
def testGenerationYieldsToAnUnrelatedQtEvent(self):
|
||||
"""Deliver unrelated work after one attempt and before batch completion."""
|
||||
profiles = self.profiles(6)
|
||||
attempts = []
|
||||
marker = []
|
||||
window = QRCodeWindow()
|
||||
|
||||
def export(profile):
|
||||
"""Record one exported snapshot."""
|
||||
attempts.append(profile.itemRemark)
|
||||
|
||||
return f'socks://{len(attempts)}.example:1080#Fixture'
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
side_effect=export,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.createQRCodeImage',
|
||||
side_effect=self.qrImage,
|
||||
):
|
||||
result = window.startExportByIndex(range(len(profiles)))
|
||||
QtCore.QTimer.singleShot(0, lambda: marker.append(len(attempts)))
|
||||
|
||||
self.assertIs(result, window)
|
||||
self.assertEqual(attempts, [])
|
||||
self.assertTrue(window.isVisible())
|
||||
self.assertTrue(waitFor(lambda: bool(marker)))
|
||||
self.assertEqual(marker, [1])
|
||||
self.assertTrue(waitFor(lambda: not window.isExporting()))
|
||||
|
||||
self.assertEqual(len(attempts), len(profiles))
|
||||
self.assertEqual(window.exportProcessedCount(), len(profiles))
|
||||
self.assertEqual(window.exportGeneratedCount(), len(profiles))
|
||||
self.assertEqual(window.tabCount(), len(profiles))
|
||||
|
||||
window.close()
|
||||
|
||||
def testClosingBeforeFirstItemCancelsTheBatch(self):
|
||||
"""Close the retained result window before its first scheduled attempt."""
|
||||
profiles = self.profiles(5)
|
||||
attempts = []
|
||||
window = QRCodeWindow()
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
side_effect=lambda profile: attempts.append(profile) or 'fixture',
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.createQRCodeImage',
|
||||
side_effect=self.qrImage,
|
||||
):
|
||||
window.startExportByIndex(range(len(profiles)))
|
||||
self.assertEqual(window.tabCount(), 0)
|
||||
window.close()
|
||||
processQtEvents()
|
||||
|
||||
self.assertEqual(attempts, [])
|
||||
self.assertTrue(waitFor(lambda: not isValid(window)))
|
||||
|
||||
def testClosingMidwayStopsNewTabs(self):
|
||||
"""Retain no window after close prevents the next scheduled attempt."""
|
||||
for closeAfter in (3, 49):
|
||||
with self.subTest(closeAfter=closeAfter):
|
||||
profiles = self.profiles(50)
|
||||
attempts = []
|
||||
window = QRCodeWindow()
|
||||
|
||||
def export(profile):
|
||||
"""Schedule window close immediately after the target attempt."""
|
||||
attempts.append(profile.itemRemark)
|
||||
|
||||
if len(attempts) == closeAfter:
|
||||
QtCore.QTimer.singleShot(0, window.close)
|
||||
|
||||
return f'socks://{len(attempts)}.example:1080#Fixture'
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
side_effect=export,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.createQRCodeImage',
|
||||
side_effect=self.qrImage,
|
||||
):
|
||||
window.startExportByIndex(range(len(profiles)))
|
||||
self.assertTrue(waitFor(lambda: not isValid(window)))
|
||||
|
||||
self.assertEqual(len(attempts), closeAfter)
|
||||
|
||||
processQtEvents()
|
||||
self.assertEqual(len(attempts), closeAfter)
|
||||
|
||||
def testIndividualFailuresPreserveValidTabs(self):
|
||||
"""Continue after exporter, empty-data, and QR-overflow failures."""
|
||||
profiles = self.profiles(4)
|
||||
attempts = []
|
||||
window = QRCodeWindow()
|
||||
|
||||
def export(profile):
|
||||
"""Return each representative exporter outcome."""
|
||||
attempts.append(profile.itemRemark)
|
||||
|
||||
if profile.itemRemark == 'Profile 1':
|
||||
raise RuntimeError('fixture exporter failure')
|
||||
|
||||
if profile.itemRemark == 'Profile 2':
|
||||
return ''
|
||||
|
||||
if profile.itemRemark == 'Profile 3':
|
||||
return 'overflow'
|
||||
|
||||
return 'socks://valid.example:1080#Valid'
|
||||
|
||||
def createImage(uri):
|
||||
"""Raise only for the representative oversized payload."""
|
||||
if uri == 'overflow':
|
||||
raise segno.DataOverflowError('fixture overflow')
|
||||
|
||||
return self.qrImage()
|
||||
|
||||
with self.assertLogs(
|
||||
'Furious.Window.QRCodeWindow', level='WARNING'
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
side_effect=export,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.createQRCodeImage',
|
||||
side_effect=createImage,
|
||||
):
|
||||
window.startExportByIndex(range(len(profiles)))
|
||||
self.assertTrue(waitFor(lambda: not window.isExporting()))
|
||||
|
||||
self.assertEqual(len(attempts), 4)
|
||||
self.assertEqual(window.exportProcessedCount(), 4)
|
||||
self.assertEqual(window.exportGeneratedCount(), 1)
|
||||
self.assertEqual(window.tabCount(), 1)
|
||||
self.assertEqual(window.tabWidget.tabText(0), '4 - Profile 4')
|
||||
|
||||
window.close()
|
||||
|
||||
def testRepositoryMutationCannotRetargetCapturedProfiles(self):
|
||||
"""Export the initial snapshots after their source collection is replaced."""
|
||||
profiles = self.profiles(5)
|
||||
originalRemarks = [profile.itemRemark for profile in profiles]
|
||||
exportedRemarks = []
|
||||
window = QRCodeWindow()
|
||||
|
||||
def export(profile):
|
||||
"""Observe the immutable operation snapshot."""
|
||||
exportedRemarks.append(profile.itemRemark)
|
||||
|
||||
return f'socks://{len(exportedRemarks)}.example:1080#Fixture'
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
side_effect=export,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.createQRCodeImage',
|
||||
side_effect=self.qrImage,
|
||||
):
|
||||
window.startExportByIndex(range(len(profiles)))
|
||||
|
||||
for profile in profiles:
|
||||
profile.metadata.displayName = 'Mutated'
|
||||
|
||||
profiles.reverse()
|
||||
profiles.clear()
|
||||
|
||||
self.assertTrue(waitFor(lambda: not window.isExporting()))
|
||||
|
||||
self.assertEqual(exportedRemarks, originalRemarks)
|
||||
self.assertEqual(
|
||||
[window.tabWidget.tabText(index) for index in range(window.tabCount())],
|
||||
[f'{index + 1} - {remark}' for index, remark in enumerate(originalRemarks)],
|
||||
)
|
||||
|
||||
window.close()
|
||||
|
||||
def testClosingQRCodeWindowCancelsAndDestroysTheOperation(self):
|
||||
"""Stop after the first tab when the QR window closes during generation."""
|
||||
profiles = self.profiles(8)
|
||||
attempts = []
|
||||
window = QRCodeWindow()
|
||||
windowReference = weakref.ref(window)
|
||||
|
||||
def export(profile):
|
||||
"""Close the shown window before the next scheduled profile."""
|
||||
attempts.append(profile.itemRemark)
|
||||
|
||||
if len(attempts) == 1:
|
||||
QtCore.QTimer.singleShot(0, window.close)
|
||||
|
||||
return 'socks://close.example:1080#Close'
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
side_effect=export,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.createQRCodeImage',
|
||||
side_effect=self.qrImage,
|
||||
):
|
||||
window.startExportByIndex(range(len(profiles)))
|
||||
self.assertTrue(waitFor(lambda: not isValid(window)))
|
||||
|
||||
processQtEvents()
|
||||
self.assertEqual(attempts, ['Profile 1'])
|
||||
|
||||
del window
|
||||
collectAtBoundary()
|
||||
|
||||
self.assertIsNone(windowReference())
|
||||
|
||||
def testRepeatedWindowOwnedExportsReturnToBaseline(self):
|
||||
"""Destroy window-owned timers and captured state without retained callbacks."""
|
||||
iterations = 24
|
||||
windowReferences = []
|
||||
windowDestroyed = []
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
return_value='socks://lifetime.example:1080#Lifetime',
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.createQRCodeImage',
|
||||
side_effect=self.qrImage,
|
||||
):
|
||||
for _index in range(iterations):
|
||||
profiles = self.profiles(2)
|
||||
window = QRCodeWindow()
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
):
|
||||
result = window.startExportByIndex((0, 1))
|
||||
|
||||
self.assertIs(result, window)
|
||||
window.destroyed.connect(lambda *_args: windowDestroyed.append(True))
|
||||
windowReferences.append(weakref.ref(window))
|
||||
|
||||
self.assertTrue(waitFor(lambda: not window.isExporting()))
|
||||
window.close()
|
||||
self.assertTrue(waitFor(lambda: not isValid(window)))
|
||||
|
||||
del result, window
|
||||
collectAtBoundary()
|
||||
|
||||
self.assertEqual(windowDestroyed, [True] * iterations)
|
||||
self.assertTrue(all(reference() is None for reference in windowReferences))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -20,13 +20,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Frozenlib import Mixins
|
||||
from Furious.Models import CoreConfiguration, ServerProfile
|
||||
from Furious.Qt import AppQAction, AppQMenu, AppQTransientDialog
|
||||
from Furious.Backends.Hysteria2.Editor import Hysteria2Editor
|
||||
from Furious.Backends.Xray.RoutingWindow import RoutingPreviewDialog
|
||||
from Furious.Backends.Xray.VlessEditor import VlessEditor
|
||||
from Furious.Window.QRCodeWindow import (
|
||||
MAXIMUM_QR_EXPORT_PROFILES,
|
||||
QRCodeWindow,
|
||||
)
|
||||
|
||||
from PySide6 import QtCore, QtGui
|
||||
|
||||
from shiboken6 import isValid
|
||||
|
||||
from tests.support import (
|
||||
application,
|
||||
collectAtBoundary,
|
||||
@@ -34,8 +41,11 @@ from tests.support import (
|
||||
currentNativeHandleCount,
|
||||
isolatedSettings,
|
||||
qObjectCount,
|
||||
waitFor,
|
||||
)
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import unittest
|
||||
import weakref
|
||||
import tracemalloc
|
||||
@@ -315,5 +325,97 @@ class QtMemoryStressTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class QRCodeExportStressTest(unittest.TestCase):
|
||||
"""Exercise real QR generation, presentation, and cleanup in batches."""
|
||||
|
||||
BatchCount = 3
|
||||
ProfilesPerBatch = MAXIMUM_QR_EXPORT_PROFILES
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Create the process-wide offscreen QApplication."""
|
||||
application()
|
||||
|
||||
def setUp(self):
|
||||
"""Isolate settings used by top-level window presentation."""
|
||||
self.settingsContext = isolatedSettings()
|
||||
self.settingsContext.__enter__()
|
||||
|
||||
def tearDown(self):
|
||||
"""Drain deferred deletion before restoring settings."""
|
||||
collectAtBoundary()
|
||||
self.settingsContext.__exit__(None, None, None)
|
||||
|
||||
@staticmethod
|
||||
def profiles(count: int):
|
||||
"""Return deterministic profiles for a real QR rendering workload."""
|
||||
return [
|
||||
ServerProfile.fromConfiguration(
|
||||
CoreConfiguration(
|
||||
{'type': 'fixture', 'address': f'node-{index}.example'}
|
||||
),
|
||||
{'displayName': f'Profile {index + 1}'},
|
||||
)
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
def testRepeatedRealQRCodeBatchesYieldShowAndRelease(self):
|
||||
"""Render real QR tabs while keeping events and owners observable."""
|
||||
collectAtBoundary()
|
||||
baselineWindows = qObjectCount(QRCodeWindow)
|
||||
windowReferences = []
|
||||
windowDestroyed = []
|
||||
|
||||
for batch in range(self.BatchCount):
|
||||
profiles = self.profiles(self.ProfilesPerBatch)
|
||||
heartbeat = []
|
||||
window = QRCodeWindow()
|
||||
window.destroyed.connect(lambda *_args: windowDestroyed.append(True))
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
side_effect=lambda profile, _batch=batch: (
|
||||
f'socks://node-{_batch}-{profile.itemRemark}.example:1080'
|
||||
f'#Batch-{_batch}'
|
||||
),
|
||||
):
|
||||
result = window.startExportByIndex(range(len(profiles)))
|
||||
QtCore.QTimer.singleShot(
|
||||
0,
|
||||
lambda _window=window: heartbeat.append(_window.tabCount()),
|
||||
)
|
||||
|
||||
self.assertTrue(waitFor(lambda: bool(heartbeat), timeout=5.0))
|
||||
self.assertLess(heartbeat[0], len(profiles))
|
||||
self.assertIs(result, window)
|
||||
self.assertTrue(
|
||||
waitFor(
|
||||
lambda: window.tabCount() > 0,
|
||||
timeout=10.0,
|
||||
)
|
||||
)
|
||||
self.assertTrue(window.isVisible())
|
||||
self.assertTrue(
|
||||
waitFor(lambda: not window.isExporting(), timeout=30.0),
|
||||
'real QR batch did not complete',
|
||||
)
|
||||
|
||||
self.assertEqual(window.tabCount(), len(profiles))
|
||||
|
||||
windowReferences.append(weakref.ref(window))
|
||||
window.close()
|
||||
self.assertTrue(waitFor(lambda: not isValid(window)))
|
||||
|
||||
del result, window
|
||||
collectAtBoundary()
|
||||
|
||||
self.assertEqual(windowDestroyed, [True] * self.BatchCount)
|
||||
self.assertTrue(all(reference() is None for reference in windowReferences))
|
||||
self.assertEqual(qObjectCount(QRCodeWindow), baselineWindows)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -21,24 +21,32 @@ from __future__ import annotations
|
||||
|
||||
from Furious.Backends.ExternalCore import ConfigExternalCore, ExternalCoreProcess
|
||||
from Furious.Interface import ApplicationRunner
|
||||
from Furious.Models import LogCategory
|
||||
from Furious.Models import CoreConfiguration, LogCategory, ServerProfile
|
||||
from Furious.Plugins import FuriousPlugin, PluginMetadata, PluginRegistry
|
||||
from Furious.Qt import AppQDialog, AppQMessageBox, AppQTransientDialog
|
||||
from Furious.Utility import AppMainProcess
|
||||
from Furious.Service import APPLICATION_LOG_CATEGORY, LogManager, MetricsHistory
|
||||
from Furious.Widget import NavigationView
|
||||
from Furious.Window.QRCodeWindow import QRCodeWindow
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from shiboken6 import isValid
|
||||
|
||||
from tests.support import (
|
||||
application,
|
||||
collectAtBoundary,
|
||||
isolatedSettings,
|
||||
processQtEvents,
|
||||
resourceSnapshot,
|
||||
veryHeavyEnabled,
|
||||
waitFor,
|
||||
)
|
||||
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import gc
|
||||
import multiprocessing
|
||||
@@ -48,6 +56,8 @@ import threading
|
||||
import unittest
|
||||
import weakref
|
||||
|
||||
qrCodeModule = import_module('Furious.Window.QRCodeWindow')
|
||||
|
||||
|
||||
class _ReleaseApplication:
|
||||
"""Provide one picklable successful application-process fixture."""
|
||||
@@ -352,6 +362,71 @@ class VeryHeavyContractTest(unittest.TestCase):
|
||||
self.assertTrue(all(reference() is None for reference in references))
|
||||
self.assertFalse(AppQDialog._openDialogs)
|
||||
|
||||
def testOneThousandRealQRCodeTabsYieldShowAndReleaseOwners(self):
|
||||
"""Render a release-scale QR window without starving or retaining Qt owners."""
|
||||
count = 1_000
|
||||
profiles = [
|
||||
ServerProfile.fromConfiguration(
|
||||
CoreConfiguration(
|
||||
{'type': 'fixture', 'address': f'node-{index}.example'}
|
||||
),
|
||||
{'displayName': f'Profile {index + 1}'},
|
||||
)
|
||||
for index in range(count)
|
||||
]
|
||||
heartbeat = []
|
||||
windowDestroyed = []
|
||||
|
||||
with isolatedSettings(), mock.patch.object(
|
||||
qrCodeModule,
|
||||
'MAXIMUM_QR_EXPORT_PROFILES',
|
||||
count,
|
||||
), mock.patch.object(
|
||||
qrCodeModule.Storage,
|
||||
'UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch.object(
|
||||
qrCodeModule,
|
||||
'exportConfiguration',
|
||||
side_effect=lambda profile: (
|
||||
f'socks://{profile.itemRemark}.example:1080#Release-QR'
|
||||
),
|
||||
):
|
||||
window = QRCodeWindow()
|
||||
window.destroyed.connect(lambda *_args: windowDestroyed.append(True))
|
||||
result = window.startExportByIndex(range(count))
|
||||
windowReference = weakref.ref(window)
|
||||
|
||||
QtCore.QTimer.singleShot(
|
||||
0,
|
||||
lambda: heartbeat.append(window.tabCount()),
|
||||
)
|
||||
|
||||
self.assertTrue(waitFor(lambda: bool(heartbeat), timeout=5.0))
|
||||
self.assertLess(heartbeat[0], count)
|
||||
self.assertIs(result, window)
|
||||
self.assertTrue(
|
||||
waitFor(
|
||||
lambda: window.tabCount() > 0,
|
||||
timeout=10.0,
|
||||
)
|
||||
)
|
||||
self.assertTrue(window.isVisible())
|
||||
self.assertTrue(
|
||||
waitFor(lambda: not window.isExporting(), timeout=300.0),
|
||||
'release-scale QR export did not complete',
|
||||
)
|
||||
self.assertEqual(window.tabCount(), count)
|
||||
|
||||
window.close()
|
||||
self.assertTrue(waitFor(lambda: not isValid(window)))
|
||||
|
||||
del result, window
|
||||
collectAtBoundary()
|
||||
|
||||
self.assertEqual(windowDestroyed, [True])
|
||||
self.assertIsNone(windowReference())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user