mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-12 09:18:26 +03:00
Refactor QR code generation and presentation
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
+200
-31
@@ -15,79 +15,248 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Provide the application window for QR code window."""
|
||||
"""Present exported server configurations as centered QR codes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Frozenlib import *
|
||||
from Furious.Repository import *
|
||||
from Furious.Frozenlib import APPLICATION_NAME
|
||||
from Furious.Plugins import exportConfiguration
|
||||
from Furious.Qt import *
|
||||
from Furious.Repository import Storage
|
||||
from Furious.Qt import AppQMainWindow, AppQTabWidget
|
||||
from Furious.Qt import gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
from PySide6.QtGui import QImage, QPixmap
|
||||
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
import io
|
||||
import pyqrcode
|
||||
import segno
|
||||
import logging
|
||||
|
||||
__all__ = ['QRCodeWindow']
|
||||
|
||||
_QR_ERROR_CORRECTION = 'H'
|
||||
_QR_PAGE_MARGIN = 28
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def createQRCodeImage(data: str) -> QImage:
|
||||
"""Render opaque text as one grayscale pixel per QR module."""
|
||||
if not isinstance(data, str) or not data:
|
||||
raise ValueError('QR code data must be a non-empty string')
|
||||
|
||||
qrcode = segno.make_qr(data, error=_QR_ERROR_CORRECTION)
|
||||
border = qrcode.default_border_size
|
||||
width, height = qrcode.symbol_size(scale=1, border=border)
|
||||
|
||||
if width != height:
|
||||
raise ValueError('QR code matrix must be square')
|
||||
|
||||
image = QImage(width, height, QImage.Format.Format_Grayscale8)
|
||||
image.fill(255)
|
||||
|
||||
for y, row in enumerate(qrcode.matrix_iter(scale=1, border=border)):
|
||||
image.scanLine(y)[:width] = bytes(0 if module else 255 for module in row)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
class _QRCodePage(QWidget):
|
||||
"""Own and responsively present one logical QR image inside a tab page."""
|
||||
|
||||
def __init__(self, image: QImage, parent=None):
|
||||
"""Create a centered, margin-managed QR presentation page."""
|
||||
super().__init__(parent)
|
||||
|
||||
if image.isNull() or image.width() != image.height():
|
||||
raise ValueError('QR code image must be a non-null square')
|
||||
|
||||
self.setObjectName('QRCodePage')
|
||||
|
||||
self._sourceImage = QImage(image)
|
||||
self._displayScale = 0
|
||||
|
||||
self.qrLabel = QLabel(parent=self)
|
||||
self.qrLabel.setObjectName('QRCodeImage')
|
||||
self.qrLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
self.qrLabel.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding,
|
||||
QSizePolicy.Policy.Expanding,
|
||||
)
|
||||
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(
|
||||
_QR_PAGE_MARGIN,
|
||||
_QR_PAGE_MARGIN,
|
||||
_QR_PAGE_MARGIN,
|
||||
_QR_PAGE_MARGIN,
|
||||
)
|
||||
self._layout.setSpacing(0)
|
||||
self._layout.addWidget(
|
||||
self.qrLabel,
|
||||
1,
|
||||
QtCore.Qt.AlignmentFlag.AlignCenter,
|
||||
)
|
||||
|
||||
self._refreshPixmap()
|
||||
|
||||
def sourceImage(self) -> QImage:
|
||||
"""Return an implicitly shared copy of the logical QR image."""
|
||||
return QImage(self._sourceImage)
|
||||
|
||||
def moduleSpan(self) -> int:
|
||||
"""Return the QR width expressed in modules including its quiet zone."""
|
||||
return self._sourceImage.width()
|
||||
|
||||
def displayScale(self) -> int:
|
||||
"""Return the integer scale currently used by the presentation pixmap."""
|
||||
return self._displayScale
|
||||
|
||||
def _targetScale(self) -> int:
|
||||
"""Return the largest integer scale that fits this page."""
|
||||
available = self._layout.contentsRect().size()
|
||||
availableSide = min(available.width(), available.height())
|
||||
|
||||
if availableSide <= 0:
|
||||
return 1
|
||||
|
||||
return max(1, availableSide // self.moduleSpan())
|
||||
|
||||
def _refreshPixmap(self):
|
||||
"""Scale the cached image without regenerating or smoothing QR modules."""
|
||||
displayScale = self._targetScale()
|
||||
|
||||
if displayScale == self._displayScale:
|
||||
return
|
||||
|
||||
self._displayScale = displayScale
|
||||
|
||||
targetSide = self.moduleSpan() * displayScale
|
||||
|
||||
self.qrLabel.setPixmap(
|
||||
QPixmap.fromImage(
|
||||
self._sourceImage.scaled(
|
||||
targetSide,
|
||||
targetSide,
|
||||
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
||||
QtCore.Qt.TransformationMode.FastTransformation,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Refresh only the displayed raster when the available page size changes."""
|
||||
super().resizeEvent(event)
|
||||
|
||||
self._refreshPixmap()
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Fit the QR pixmap after the tab layout receives its final geometry."""
|
||||
super().showEvent(event)
|
||||
|
||||
# A newly selected tab has its final page geometry before Qt lays out its
|
||||
# hidden children. Activate this page synchronously so its first rendered
|
||||
# pixmap uses the available tab area instead of the construction-time size.
|
||||
self._layout.activate()
|
||||
|
||||
self._refreshPixmap()
|
||||
|
||||
|
||||
class QRCodeWindow(AppQMainWindow):
|
||||
"""Present the QR code window."""
|
||||
"""Present exported configurations in independent QR-code tabs."""
|
||||
|
||||
DEFAULT_WINDOW_SIZE = QtCore.QSize(720, 680)
|
||||
MINIMUM_WINDOW_SIZE = QtCore.QSize(520, 520)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the QRCodeWindow."""
|
||||
"""Initialize the transient, resizable QR-code window."""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_(APPLICATION_NAME))
|
||||
self.setFixedSize(640, 640)
|
||||
self.setMinimumSize(self.MINIMUM_WINDOW_SIZE)
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||
|
||||
self.tabWidget = QTabWidget(self)
|
||||
self.tabWidget = AppQTabWidget(parent=self, translatable=False)
|
||||
self.tabWidget.setTabsClosable(True)
|
||||
self.tabWidget.setElideMode(QtCore.Qt.TextElideMode.ElideRight)
|
||||
self.tabWidget.tabCloseRequested.connect(self.handleTabCloseRequested)
|
||||
|
||||
self.setCentralWidget(self.tabWidget)
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
"""Apply the practical default size while preserving user resizing."""
|
||||
self.resize(self.DEFAULT_WINDOW_SIZE)
|
||||
|
||||
def tabCount(self) -> int:
|
||||
"""Return the tab count value used by the QR code window."""
|
||||
"""Return the number of successfully generated QR-code tabs."""
|
||||
return self.tabWidget.count()
|
||||
|
||||
def _clearTabs(self):
|
||||
"""Remove and destroy every page currently owned by the tab widget."""
|
||||
while self.tabWidget.count():
|
||||
page = self.tabWidget.widget(0)
|
||||
|
||||
self.tabWidget.removeTab(0)
|
||||
|
||||
if page is not None:
|
||||
page.deleteLater()
|
||||
|
||||
def initTabByIndex(self, indexes: list[int]):
|
||||
"""Handle init tab by index for the QR code window."""
|
||||
self.tabWidget.clear()
|
||||
"""Replace the tabs with QR pages for the selected profile indexes."""
|
||||
self._clearTabs()
|
||||
|
||||
profiles = Storage.UserServers()
|
||||
|
||||
for index in indexes:
|
||||
qrdata = io.BytesIO()
|
||||
|
||||
config = Storage.UserServers()[index]
|
||||
config = profiles[index]
|
||||
|
||||
try:
|
||||
uri = exportConfiguration(config)
|
||||
except Exception:
|
||||
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
|
||||
|
||||
if not uri:
|
||||
continue
|
||||
|
||||
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__,
|
||||
)
|
||||
|
||||
continue
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
uri = ''
|
||||
continue
|
||||
|
||||
if uri:
|
||||
qrcode = pyqrcode.create(uri)
|
||||
qrcode.png(qrdata, scale=5)
|
||||
page = _QRCodePage(image, parent=self.tabWidget)
|
||||
title = f'{index + 1} - {config.itemRemark}'
|
||||
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(qrdata.getvalue(), 'PNG')
|
||||
tabIndex = self.tabWidget.addTab(page, title)
|
||||
|
||||
widget = QLabel(parent=self.tabWidget)
|
||||
widget.setPixmap(pixmap)
|
||||
|
||||
self.tabWidget.addTab(widget, f'{index + 1} - ' + config.itemRemark)
|
||||
self.tabWidget.setTabToolTip(tabIndex, title)
|
||||
|
||||
@QtCore.Slot(int)
|
||||
def handleTabCloseRequested(self, index):
|
||||
"""Handle tab close requested."""
|
||||
def handleTabCloseRequested(self, index: int):
|
||||
"""Destroy one requested page and close after the final tab is removed."""
|
||||
page = self.tabWidget.widget(index)
|
||||
|
||||
if page is None:
|
||||
return
|
||||
|
||||
self.tabWidget.removeTab(index)
|
||||
|
||||
page.deleteLater()
|
||||
|
||||
if self.tabWidget.count() == 0:
|
||||
self.close()
|
||||
|
||||
+1
-2
@@ -17,8 +17,7 @@ dependencies = [
|
||||
"PySide6-Addons",
|
||||
"ujson",
|
||||
"pybase64",
|
||||
"pyqrcode",
|
||||
"pypng",
|
||||
"segno",
|
||||
"icmplib",
|
||||
"mss",
|
||||
"zxing-cpp<3",
|
||||
|
||||
+1
-2
@@ -2,8 +2,7 @@ PySide6-Essentials
|
||||
PySide6-Addons
|
||||
ujson
|
||||
pybase64
|
||||
pyqrcode
|
||||
pypng
|
||||
segno
|
||||
icmplib
|
||||
mss
|
||||
zxing-cpp<3
|
||||
|
||||
@@ -44,11 +44,12 @@ from Furious.Qt import (
|
||||
AppQTransientDialog,
|
||||
)
|
||||
from Furious.Qt.QtWidgets import _AppMessageBoxMask
|
||||
from Furious.Window.QRCodeWindow import QRCodeWindow
|
||||
from Furious.Window.QRCodeWindow import QRCodeWindow, _QRCodePage
|
||||
from Furious.Window.SubscriptionPage import _SubscriptionEditorDialog
|
||||
from Furious.Window.TextEditorWindow import TextEditorWindow
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from shiboken6 import isValid
|
||||
@@ -346,24 +347,33 @@ class QtLifetimeTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
def testQRCodeTopLevelWindowIsDeletedOnClose(self):
|
||||
"""Exercise the dedicated transient AppQMainWindow policy 100 times."""
|
||||
"""Destroy each transient QR window together with its page and label."""
|
||||
iterations = 100
|
||||
references, destroyed = [], []
|
||||
image = QImage(64, 64, QImage.Format.Format_Grayscale8)
|
||||
image.fill(QtCore.Qt.GlobalColor.white)
|
||||
|
||||
for _index in range(iterations):
|
||||
window = QRCodeWindow()
|
||||
window.destroyed.connect(lambda *_args: destroyed.append(True))
|
||||
page = _QRCodePage(image, parent=window.tabWidget)
|
||||
window.tabWidget.addTab(page, 'Lifetime fixture')
|
||||
|
||||
references.append(weakref.ref(window))
|
||||
for object_ in (window, page, page.qrLabel):
|
||||
object_.destroyed.connect(lambda *_args: destroyed.append(True))
|
||||
references.append(weakref.ref(object_))
|
||||
|
||||
window.show()
|
||||
window.close()
|
||||
|
||||
del window
|
||||
del object_, page, window
|
||||
|
||||
collectAtBoundary()
|
||||
|
||||
self.assertAllDestroyed(references, destroyed, iterations)
|
||||
self.assertAllDestroyed(
|
||||
references,
|
||||
destroyed,
|
||||
iterations * 3,
|
||||
)
|
||||
|
||||
def testTextEditorWindowIsIntentionallyReusableThenExplicitlyDestroyed(self):
|
||||
"""Reuse one persistent editor without duplicating menus or actions."""
|
||||
|
||||
@@ -83,6 +83,11 @@ from Furious.Service import (
|
||||
LogManager,
|
||||
)
|
||||
from Furious.Window.LogPage import LogPage
|
||||
from Furious.Window.QRCodeWindow import (
|
||||
QRCodeWindow,
|
||||
_QRCodePage,
|
||||
createQRCodeImage,
|
||||
)
|
||||
from Furious.Window.SettingsPage import (
|
||||
_ApplicationThemeSettingsCard,
|
||||
_SystemProxySettingsCard,
|
||||
@@ -92,12 +97,14 @@ from Furious.Widget.ConnectionButton import ConnectionButton
|
||||
from Furious.Widget.RoutingSelector import RoutingSelector
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QStyle,
|
||||
QStyleOptionComboBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
@@ -116,6 +123,8 @@ import unittest
|
||||
import weakref
|
||||
|
||||
from unittest import mock
|
||||
import segno
|
||||
import zxingcpp
|
||||
|
||||
|
||||
class RoutingControllerFixture(QtCore.QObject):
|
||||
@@ -315,6 +324,337 @@ class ComboTranslationLayoutTest(unittest.TestCase):
|
||||
parent.deleteLater()
|
||||
|
||||
|
||||
def _grayscaleBuffer(image: QImage):
|
||||
"""Return tightly packed grayscale pixels for zxing-cpp without Pillow."""
|
||||
grayscale = image.convertToFormat(QImage.Format.Format_Grayscale8)
|
||||
width = grayscale.width()
|
||||
height = grayscale.height()
|
||||
stride = grayscale.bytesPerLine()
|
||||
source = grayscale.constBits()
|
||||
pixels = bytearray(width * height)
|
||||
|
||||
for y in range(height):
|
||||
sourceStart = y * stride
|
||||
targetStart = y * width
|
||||
pixels[targetStart : targetStart + width] = source[
|
||||
sourceStart : sourceStart + width
|
||||
]
|
||||
|
||||
return memoryview(pixels).cast('B', (height, width))
|
||||
|
||||
|
||||
def _decodeQRCodeImage(image: QImage):
|
||||
"""Decode one generated image through the application's QR decoder."""
|
||||
return zxingcpp.read_barcode(
|
||||
_grayscaleBuffer(image),
|
||||
formats=zxingcpp.BarcodeFormat.QRCode,
|
||||
is_pure=True,
|
||||
)
|
||||
|
||||
|
||||
class QRCodeImageGenerationTest(unittest.TestCase):
|
||||
"""Verify the pure Segno matrix-to-QImage contract."""
|
||||
|
||||
def testLogicalImageMatchesSegnoMatrixAndQuietZone(self):
|
||||
"""Map matrix modules and the standard quiet zone to exact pixels."""
|
||||
payload = 'socks://user:password@example.com:1080#Matrix'
|
||||
qrCode = segno.make_qr(payload, error='H')
|
||||
border = qrCode.default_border_size
|
||||
matrix = tuple(tuple(row) for row in qrCode.matrix)
|
||||
image = createQRCodeImage(payload)
|
||||
|
||||
self.assertFalse(qrCode.is_micro)
|
||||
self.assertEqual(border, 4)
|
||||
self.assertEqual(
|
||||
image.size(),
|
||||
QtCore.QSize(*qrCode.symbol_size(scale=1, border=border)),
|
||||
)
|
||||
self.assertEqual(image.format(), QImage.Format.Format_Grayscale8)
|
||||
self.assertEqual(image.width(), len(matrix) + (2 * border))
|
||||
self.assertTrue(
|
||||
all(image.pixelColor(x, 0).red() == 255 for x in range(image.width()))
|
||||
)
|
||||
|
||||
darkY, darkX = next(
|
||||
(y, x)
|
||||
for y, row in enumerate(matrix)
|
||||
for x, module in enumerate(row)
|
||||
if module
|
||||
)
|
||||
lightY, lightX = next(
|
||||
(y, x)
|
||||
for y, row in enumerate(matrix)
|
||||
for x, module in enumerate(row)
|
||||
if not module
|
||||
)
|
||||
|
||||
self.assertEqual(image.pixelColor(border + darkX, border + darkY).red(), 0)
|
||||
self.assertEqual(image.pixelColor(border + lightX, border + lightY).red(), 255)
|
||||
|
||||
def testRepresentativePayloadsRoundTripWithoutEncodedIntermediate(self):
|
||||
"""Decode short, medium, long, special-character, and Unicode payloads."""
|
||||
payloads = (
|
||||
'socks://example.com:1080',
|
||||
(
|
||||
'vless://synthetic@example.com:443?'
|
||||
+ 'transport=xhttp&security=reality&' * 8
|
||||
+ '#Medium%20Profile'
|
||||
),
|
||||
'trojan://p%40ss%3Aword@example.com:443?allowInsecure=0#东京-Привет',
|
||||
'vmess://' + ('synthetic-payload-' * 45),
|
||||
)
|
||||
|
||||
widths = []
|
||||
|
||||
for payload in payloads:
|
||||
image = createQRCodeImage(payload)
|
||||
scale = 4
|
||||
scaled = image.scaled(
|
||||
image.width() * scale,
|
||||
image.height() * scale,
|
||||
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
||||
QtCore.Qt.TransformationMode.FastTransformation,
|
||||
)
|
||||
decoded = _decodeQRCodeImage(scaled)
|
||||
|
||||
self.assertIsNotNone(decoded)
|
||||
self.assertEqual(decoded.text, payload)
|
||||
widths.append(image.width())
|
||||
|
||||
self.assertGreater(len(set(widths)), 1)
|
||||
|
||||
def testIntegerFastScalingKeepsPixelsBinary(self):
|
||||
"""Keep modules and quiet zone black or white at integer display scales."""
|
||||
image = createQRCodeImage('socks://scale.example:1080#Scale')
|
||||
|
||||
for scale in (2, 5, 8):
|
||||
scaled = image.scaled(
|
||||
image.width() * scale,
|
||||
image.height() * scale,
|
||||
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
||||
QtCore.Qt.TransformationMode.FastTransformation,
|
||||
)
|
||||
pixels = _grayscaleBuffer(scaled)
|
||||
|
||||
self.assertEqual(scaled.width(), image.width() * scale)
|
||||
self.assertEqual(set(pixels.tobytes()), {0, 255})
|
||||
self.assertTrue(
|
||||
all(
|
||||
pixels[y, x] == 255
|
||||
for y in range(4 * scale)
|
||||
for x in range(scaled.width())
|
||||
)
|
||||
)
|
||||
|
||||
def testEmptyPayloadIsRejected(self):
|
||||
"""Reject an empty payload before asking Segno to encode it."""
|
||||
with self.assertRaises(ValueError):
|
||||
createQRCodeImage('')
|
||||
|
||||
|
||||
class QRCodeWindowBehaviorTest(unittest.TestCase):
|
||||
"""Verify responsive QR presentation without changing export semantics."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Create the process-wide headless QApplication."""
|
||||
application()
|
||||
|
||||
def tearDown(self):
|
||||
"""Finish deferred page and window deletion between tests."""
|
||||
collectAtBoundary()
|
||||
|
||||
@staticmethod
|
||||
def buildWindow(*uris):
|
||||
"""Create one isolated QR window from deterministic export results."""
|
||||
profiles = [
|
||||
mock.Mock(itemRemark=f'Profile {index + 1}') for index in range(len(uris))
|
||||
]
|
||||
|
||||
with mock.patch(
|
||||
'Furious.Window.QRCodeWindow.Storage.UserServers',
|
||||
return_value=profiles,
|
||||
), mock.patch(
|
||||
'Furious.Window.QRCodeWindow.exportConfiguration',
|
||||
side_effect=uris,
|
||||
):
|
||||
window = QRCodeWindow()
|
||||
window.initTabByIndex(list(range(len(profiles))))
|
||||
|
||||
return window
|
||||
|
||||
def testAllExportFailuresLeaveNoBrokenTabs(self):
|
||||
"""Leave an unshown empty window when every canonical export fails."""
|
||||
window = self.buildWindow('', '')
|
||||
|
||||
self.assertEqual(window.tabCount(), 0)
|
||||
self.assertFalse(window.isVisible())
|
||||
window.close()
|
||||
|
||||
def testOversizedPayloadDoesNotPreventOtherTabs(self):
|
||||
"""Skip one Segno overflow while preserving later valid profiles."""
|
||||
with self.assertLogs('Furious.Window.QRCodeWindow', level='WARNING'):
|
||||
window = self.buildWindow(
|
||||
'x' * 5000,
|
||||
'socks://valid.example:1080#Valid',
|
||||
)
|
||||
|
||||
self.assertEqual(window.tabCount(), 1)
|
||||
self.assertEqual(window.tabWidget.tabText(0), '2 - Profile 2')
|
||||
window.close()
|
||||
|
||||
def testPageCentersAndDecodesResponsiveSquarePixmap(self):
|
||||
"""Center one crisp, square, decodable QR at representative sizes."""
|
||||
uri = (
|
||||
'socks://user:password@example.com:1080'
|
||||
'#A%20representative%20Furious%20profile'
|
||||
)
|
||||
window = self.buildWindow(uri)
|
||||
window.show()
|
||||
processQtEvents()
|
||||
|
||||
page = window.tabWidget.widget(0)
|
||||
|
||||
self.assertIsInstance(page, _QRCodePage)
|
||||
self.assertIsInstance(page.layout(), QVBoxLayout)
|
||||
self.assertTrue(
|
||||
page.layout().itemAt(0).alignment() & QtCore.Qt.AlignmentFlag.AlignHCenter
|
||||
)
|
||||
self.assertTrue(
|
||||
page.layout().itemAt(0).alignment() & QtCore.Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
self.assertTrue(page.qrLabel.alignment() & QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
sourceKey = page.sourceImage().cacheKey()
|
||||
displayedSides = []
|
||||
|
||||
for size in (
|
||||
QRCodeWindow.MINIMUM_WINDOW_SIZE,
|
||||
QRCodeWindow.DEFAULT_WINDOW_SIZE,
|
||||
QtCore.QSize(920, 780),
|
||||
):
|
||||
window.resize(size)
|
||||
processQtEvents()
|
||||
|
||||
displayed = page.qrLabel.pixmap()
|
||||
available = page.layout().contentsRect().size()
|
||||
|
||||
self.assertFalse(displayed.isNull())
|
||||
self.assertEqual(displayed.width(), displayed.height())
|
||||
self.assertEqual(displayed.width() % page.moduleSpan(), 0)
|
||||
self.assertLessEqual(displayed.width(), available.width())
|
||||
self.assertLessEqual(displayed.height(), available.height())
|
||||
self.assertEqual(displayed.width(), page.moduleSpan() * page.displayScale())
|
||||
self.assertEqual(page.sourceImage().cacheKey(), sourceKey)
|
||||
|
||||
displayedSides.append(displayed.width())
|
||||
|
||||
self.assertEqual(displayedSides, sorted(displayedSides))
|
||||
decoded = _decodeQRCodeImage(page.qrLabel.pixmap().toImage())
|
||||
self.assertIsNotNone(decoded)
|
||||
self.assertEqual(decoded.text, uri)
|
||||
|
||||
window.close()
|
||||
|
||||
def testEveryTabUsesItsSettledLayoutScaleOnFirstVisit(self):
|
||||
"""Size hidden QR pages correctly the first time each tab becomes visible."""
|
||||
uris = tuple(
|
||||
f'socks://profile-{index}.example:1080#Profile%20{index}'
|
||||
for index in range(10)
|
||||
)
|
||||
window = self.buildWindow(*uris)
|
||||
window.show()
|
||||
processQtEvents()
|
||||
|
||||
for index in range(window.tabCount()):
|
||||
window.tabWidget.setCurrentIndex(index)
|
||||
processQtEvents()
|
||||
|
||||
page = window.tabWidget.widget(index)
|
||||
displayed = page.qrLabel.pixmap()
|
||||
available = page.layout().contentsRect().size()
|
||||
expectedScale = max(
|
||||
1,
|
||||
min(available.width(), available.height()) // page.moduleSpan(),
|
||||
)
|
||||
|
||||
self.assertFalse(displayed.isNull())
|
||||
self.assertGreater(expectedScale, 1)
|
||||
self.assertEqual(page.displayScale(), expectedScale)
|
||||
self.assertEqual(displayed.width(), page.moduleSpan() * expectedScale)
|
||||
|
||||
window.close()
|
||||
|
||||
def testMultipleTabsSkipFailuresAndDestroyClosedPages(self):
|
||||
"""Keep valid order while failed exports and closed pages leave no debris."""
|
||||
window = self.buildWindow(
|
||||
'socks://one.example:1080#One',
|
||||
'',
|
||||
'socks://three.example:1080#Three',
|
||||
)
|
||||
window.show()
|
||||
processQtEvents()
|
||||
|
||||
self.assertEqual(window.tabCount(), 2)
|
||||
self.assertEqual(window.tabWidget.tabText(0), '1 - Profile 1')
|
||||
self.assertEqual(window.tabWidget.tabText(1), '3 - Profile 3')
|
||||
|
||||
self.assertEqual(window.tabWidget.tabToolTip(0), '1 - Profile 1')
|
||||
self.assertEqual(window.tabWidget.tabToolTip(1), '3 - Profile 3')
|
||||
firstPage = window.tabWidget.widget(0)
|
||||
firstPageReference = weakref.ref(firstPage)
|
||||
firstPageDestroyed = []
|
||||
firstPage.destroyed.connect(lambda *_args: firstPageDestroyed.append(True))
|
||||
|
||||
window.handleTabCloseRequested(0)
|
||||
del firstPage
|
||||
collectAtBoundary()
|
||||
|
||||
self.assertEqual(window.tabCount(), 1)
|
||||
self.assertEqual(window.tabWidget.tabText(0), '3 - Profile 3')
|
||||
self.assertTrue(waitFor(lambda: firstPageReference() is None))
|
||||
self.assertEqual(firstPageDestroyed, [True])
|
||||
|
||||
windowReference = weakref.ref(window)
|
||||
windowDestroyed = []
|
||||
window.destroyed.connect(lambda *_args: windowDestroyed.append(True))
|
||||
|
||||
window.handleTabCloseRequested(0)
|
||||
del window
|
||||
collectAtBoundary()
|
||||
|
||||
self.assertTrue(waitFor(lambda: windowReference() is None))
|
||||
self.assertEqual(windowDestroyed, [True])
|
||||
|
||||
def testThemeChangesLeaveTheHighContrastQRSourceUntouched(self):
|
||||
"""Restyle the surrounding window without regenerating its QR content."""
|
||||
app = application()
|
||||
originalStyleSheet = app.styleSheet()
|
||||
window = self.buildWindow('socks://theme.example:1080#Theme')
|
||||
window.show()
|
||||
processQtEvents()
|
||||
|
||||
page = window.tabWidget.widget(0)
|
||||
sourceKey = page.sourceImage().cacheKey()
|
||||
|
||||
try:
|
||||
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
|
||||
app.setStyleSheet(AppStyleSheet.forTheme(theme))
|
||||
processQtEvents()
|
||||
|
||||
image = page.sourceImage()
|
||||
quietZone = image.pixelColor(0, 0)
|
||||
|
||||
self.assertEqual(page.sourceImage().cacheKey(), sourceKey)
|
||||
self.assertEqual(
|
||||
(quietZone.red(), quietZone.green(), quietZone.blue()),
|
||||
(255, 255, 255),
|
||||
)
|
||||
finally:
|
||||
app.setStyleSheet(originalStyleSheet)
|
||||
window.close()
|
||||
|
||||
|
||||
class EditorMappingTest(unittest.TestCase):
|
||||
"""Verify editor fields preserve structured configuration semantics."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user