From 5bbd00eda7a52bf4c8b0155deda2446e8b9d90f3 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Mon, 31 Aug 2026 13:31:15 +0800 Subject: [PATCH] Scale QR export without a progress dialog Signed-off-by: Loren Eteval --- Furious/Widget/ServerTableView.py | 4 +- Furious/Window/QRCodeWindow.py | 259 ++++++++++++-- tests/README.md | 24 +- tests/benchmarks/benchmark_qr_export.py | 264 ++++++++++++++ tests/test_qr_export_scalability.py | 437 ++++++++++++++++++++++++ tests/test_qt_stress.py | 102 ++++++ tests/test_very_heavy.py | 77 ++++- 7 files changed, 1124 insertions(+), 43 deletions(-) create mode 100644 tests/benchmarks/benchmark_qr_export.py create mode 100644 tests/test_qr_export_scalability.py diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index e12ba1d..0fbe955 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -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.""" diff --git a/Furious/Window/QRCodeWindow.py b/Furious/Window/QRCodeWindow.py index 6a1c1e9..b01b110 100644 --- a/Furious/Window/QRCodeWindow.py +++ b/Furious/Window/QRCodeWindow.py @@ -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): diff --git a/tests/README.md b/tests/README.md index 70b355c..3e0b0dd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 diff --git a/tests/benchmarks/benchmark_qr_export.py b/tests/benchmarks/benchmark_qr_export.py new file mode 100644 index 0000000..d6db0a8 --- /dev/null +++ b/tests/benchmarks/benchmark_qr_export.py @@ -0,0 +1,264 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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() diff --git a/tests/test_qr_export_scalability.py b/tests/test_qr_export_scalability.py new file mode 100644 index 0000000..0dc4b71 --- /dev/null +++ b/tests/test_qr_export_scalability.py @@ -0,0 +1,437 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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() diff --git a/tests/test_qt_stress.py b/tests/test_qt_stress.py index 92edf5f..d57dd83 100644 --- a/tests/test_qt_stress.py +++ b/tests/test_qt_stress.py @@ -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() diff --git a/tests/test_very_heavy.py b/tests/test_very_heavy.py index ce629c1..3e9c919 100644 --- a/tests/test_very_heavy.py +++ b/tests/test_very_heavy.py @@ -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()