mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
3715 lines
133 KiB
Python
3715 lines
133 KiB
Python
# 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 high-value Qt presentation and editor integration boundaries."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from Furious.Backends.Configuration import (
|
||
ConfigXray,
|
||
configXrayEmptyProxyOutboundObject,
|
||
)
|
||
from Furious.Backends.ExternalCore.Configuration import (
|
||
BLANK_CONFIG_EXTERNAL_CORE,
|
||
ConfigExternalCore,
|
||
)
|
||
from Furious.Backends.ExternalCore.Editor import (
|
||
ExternalCoreApplicationTun2socksInput,
|
||
ExternalCoreArgumentsInput,
|
||
ExternalCoreConfigurationGroup,
|
||
ExternalCoreEditor,
|
||
ExternalCoreEnvironmentInput,
|
||
ExternalCoreOtherGroup,
|
||
ExternalCoreShutdownTimeoutInput,
|
||
ExternalCoreTunRemoteAddressInput,
|
||
)
|
||
from Furious.Backends.Hysteria1.Editor import Hysteria1Editor
|
||
from Furious.Backends.Hysteria2.Editor import Hysteria2Editor
|
||
from Furious.Backends.Xray.AssetListView import XrayAssetListView
|
||
from Furious.Backends.Xray.RoutingWindow import (
|
||
RoutingRuleEditDialog,
|
||
RoutingRulesDialog,
|
||
RoutingTextEdit,
|
||
RoutingTextEditDialog,
|
||
UserRoutingTableView,
|
||
routingObjectFromProfile,
|
||
)
|
||
from Furious.Backends.Xray.Routing import customRoutingObjectFromSettings
|
||
from Furious.Backends.Xray.ShadowsocksEditor import ShadowsocksEditor
|
||
from Furious.Backends.Xray.SocksEditor import SocksEditor
|
||
from Furious.Backends.Xray.TrojanEditor import TrojanEditor
|
||
from Furious.Backends.Xray.TransportEditor import (
|
||
GuiVTransportPageXHttp,
|
||
GuiVTransportQGroupBox,
|
||
STREAM_NETWORK,
|
||
)
|
||
from Furious.Backends.Xray.VlessEditor import (
|
||
GuiVLESSGroupBoxBasic,
|
||
VlessEditor,
|
||
)
|
||
from Furious.Backends.Xray.VmessEditor import (
|
||
GuiVMessGroupBoxBasic,
|
||
VmessEditor,
|
||
)
|
||
from Furious.Actions.Connection import ConnectAction
|
||
from Furious.Controllers.ConnectionController import (
|
||
ConnectionError,
|
||
ConnectionController,
|
||
ConnectionState,
|
||
)
|
||
from Furious.Controllers.SettingsController import (
|
||
APPLICATION_THEME_SETTING,
|
||
LOG_AUTO_CLEAR_SETTING,
|
||
LOG_AUTO_SCROLL_DOWN_SETTING,
|
||
)
|
||
from Furious.Frozenlib import (
|
||
APPLICATION_NAME,
|
||
AppBuiltinProxyMode,
|
||
ApplicationTheme,
|
||
AppSettings,
|
||
Mixins,
|
||
)
|
||
from Furious.Models import (
|
||
CoreConfiguration,
|
||
ProfileMetadata,
|
||
Protocol,
|
||
ServerProfile,
|
||
)
|
||
from Furious.Plugins.API import RoutingOption
|
||
from Furious.Repository import Storage
|
||
from Furious.Repository.Routings import UserRoutings
|
||
from Furious.Qt import (
|
||
AppHue,
|
||
AppQComboBox,
|
||
AppQDialog,
|
||
AppQMessageBox,
|
||
AppQSwitch,
|
||
AppStyleSheet,
|
||
gettext as _,
|
||
)
|
||
from Furious.Service.LogManager import ALL_LOGS_FILTER
|
||
from Furious.Service import (
|
||
APPLICATION_LOG_CATEGORY,
|
||
CORE_LOG_CATEGORY,
|
||
TUN2SOCKS_LOG_CATEGORY,
|
||
LogManager,
|
||
)
|
||
from Furious.Window.LogPage import LogPage
|
||
from Furious.Window.QRCodeWindow import (
|
||
QRCodeWindow,
|
||
_QRCodePage,
|
||
createQRCodeImage,
|
||
)
|
||
from Furious.Window.SettingsPage import (
|
||
_ApplicationThemeSettingsCard,
|
||
_SystemProxySettingsCard,
|
||
SettingsPage,
|
||
)
|
||
from Furious.Window.SubscriptionPage import _SubscriptionEditorDialog
|
||
from Furious.Widget.ConnectionButton import ConnectionButton
|
||
from Furious.Widget.RoutingSelector import RoutingSelector
|
||
from Furious.Widget.ServerTableView import ServerTableView
|
||
|
||
from PySide6 import QtCore
|
||
from PySide6.QtGui import QImage
|
||
from PySide6.QtTest import QAbstractItemModelTester, QSignalSpy, QTest
|
||
from PySide6.QtWidgets import (
|
||
QStackedWidget,
|
||
QToolButton,
|
||
QComboBox,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QSizePolicy,
|
||
QStyle,
|
||
QStyleOptionComboBox,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from tests.support import (
|
||
application,
|
||
collectAtBoundary,
|
||
isolatedSettings,
|
||
processQtEvents,
|
||
waitFor,
|
||
)
|
||
|
||
import copy
|
||
import pathlib
|
||
import tempfile
|
||
import unittest
|
||
import weakref
|
||
|
||
from unittest import mock
|
||
import segno
|
||
import zxingcpp
|
||
|
||
|
||
class RoutingControllerFixture(QtCore.QObject):
|
||
"""Provide routing state without persistence or connection side effects."""
|
||
|
||
stateChanged = QtCore.Signal(object, str)
|
||
interactionEnabledChanged = QtCore.Signal(bool)
|
||
|
||
def __init__(self, options, routing):
|
||
"""Initialize one immutable selector state."""
|
||
super().__init__()
|
||
|
||
self._options = tuple(options)
|
||
self._routing = routing
|
||
self.interactionEnabled = True
|
||
self.selected = []
|
||
|
||
def state(self):
|
||
"""Return the current options and semantic selection."""
|
||
return self._options, self._routing
|
||
|
||
def refresh(self, *, force=False):
|
||
"""Return state without consulting plugins or reconnecting."""
|
||
if force:
|
||
self.stateChanged.emit(*self.state())
|
||
|
||
return self.state()
|
||
|
||
def selectRouting(self, routing):
|
||
"""Record explicit user selection attempts."""
|
||
self.selected.append(routing)
|
||
|
||
|
||
class ComboTranslationLayoutTest(unittest.TestCase):
|
||
"""Verify translated content can republish useful combo width hints."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Finish deferred widget deletion between tests."""
|
||
collectAtBoundary()
|
||
|
||
def assertCurrentComboTextFits(self, comboBox):
|
||
"""Assert the styled combo edit field can display its current text."""
|
||
option = QStyleOptionComboBox()
|
||
comboBox.initStyleOption(option)
|
||
textRect = comboBox.style().subControlRect(
|
||
QStyle.ComplexControl.CC_ComboBox,
|
||
option,
|
||
QStyle.SubControl.SC_ComboBoxEditField,
|
||
comboBox,
|
||
)
|
||
|
||
self.assertGreaterEqual(
|
||
textRect.width(),
|
||
comboBox.fontMetrics().horizontalAdvance(comboBox.currentText()),
|
||
)
|
||
|
||
def testContentAwareComboRefreshesHintWithoutChangingSemanticData(self):
|
||
"""Grow for longer translated text while retaining item identity."""
|
||
parent = QWidget()
|
||
layout = QHBoxLayout(parent)
|
||
combo = AppQComboBox(parent=parent)
|
||
combo.setContentWidthAdjustable()
|
||
combo.addItem('Short', 'semantic-route')
|
||
combo.setToolTip('Combo tooltip')
|
||
layout.addWidget(combo)
|
||
|
||
shortHint = combo.sizeHint().width()
|
||
translated = 'A significantly longer translated routing option'
|
||
translatedTooltip = 'Translated combo tooltip'
|
||
|
||
with mock.patch(
|
||
'Furious.Qt.QtWidgets._',
|
||
side_effect=lambda text: {
|
||
'Short': translated,
|
||
'Combo tooltip': translatedTooltip,
|
||
}.get(text, text),
|
||
):
|
||
combo.retranslate()
|
||
|
||
parent.resize(combo.sizeHint().width() + 100, 100)
|
||
parent.show()
|
||
processQtEvents()
|
||
|
||
self.assertEqual(combo.currentData(), 'semantic-route')
|
||
self.assertEqual(combo.currentText(), translated)
|
||
self.assertEqual(combo.toolTip(), translatedTooltip)
|
||
self.assertGreater(combo.sizeHint().width(), shortHint)
|
||
self.assertGreater(
|
||
combo.sizeHint().width(),
|
||
combo.fontMetrics().horizontalAdvance(translated),
|
||
)
|
||
self.assertGreaterEqual(combo.width(), combo.sizeHint().width())
|
||
|
||
parent.close()
|
||
parent.deleteLater()
|
||
|
||
def testRoutingRetranslationKeepsSelectionAndDoesNotReconnect(self):
|
||
"""Rebuild display text without forwarding a synthetic selection."""
|
||
option = RoutingOption(
|
||
'bypass-mainland',
|
||
'Bypass Mainland China',
|
||
translatable=True,
|
||
)
|
||
literalOption = RoutingOption('user-defined', 'Global')
|
||
controller = RoutingControllerFixture((option, literalOption), option.id)
|
||
parent = QWidget()
|
||
layout = QHBoxLayout(parent)
|
||
|
||
with mock.patch(
|
||
'Furious.Widget.RoutingSelector.AppRoutingController',
|
||
return_value=controller,
|
||
):
|
||
selector = RoutingSelector(parent=parent)
|
||
|
||
layout.addWidget(selector)
|
||
initialHint = selector.sizeHint().width()
|
||
translated = 'Обходить подключения к серверам материкового Китая'
|
||
translatedTooltip = 'Маршрутизация'
|
||
|
||
with mock.patch(
|
||
'Furious.Widget.RoutingSelector._',
|
||
side_effect=lambda text: {
|
||
option.displayName: translated,
|
||
'Routing': translatedTooltip,
|
||
}.get(text, text),
|
||
) as translate:
|
||
selector.retranslate()
|
||
|
||
parent.resize(selector.sizeHint().width() + 100, 100)
|
||
parent.show()
|
||
processQtEvents()
|
||
|
||
self.assertEqual(selector.currentData(), option.id)
|
||
self.assertEqual(selector.currentText(), translated)
|
||
self.assertEqual(
|
||
selector.itemText(selector.findData(literalOption.id)),
|
||
literalOption.displayName,
|
||
)
|
||
self.assertNotIn(
|
||
mock.call(literalOption.displayName),
|
||
translate.call_args_list,
|
||
)
|
||
self.assertEqual(selector.toolTip(), translatedTooltip)
|
||
self.assertGreater(selector.sizeHint().width(), initialHint)
|
||
self.assertGreater(
|
||
selector.sizeHint().width(),
|
||
selector.fontMetrics().horizontalAdvance(translated),
|
||
)
|
||
self.assertEqual(controller.selected, [])
|
||
|
||
parent.close()
|
||
parent.deleteLater()
|
||
|
||
def testTranslatedSettingsChoicesResizeWithoutChangingSemanticValues(self):
|
||
"""Resize translated Settings choices without applying fake changes."""
|
||
cases = (
|
||
(
|
||
_ApplicationThemeSettingsCard,
|
||
APPLICATION_THEME_SETTING,
|
||
ApplicationTheme.System.value,
|
||
'Follow System Appearance',
|
||
'setApplicationTheme',
|
||
),
|
||
(
|
||
_SystemProxySettingsCard,
|
||
'SystemProxyMode',
|
||
AppBuiltinProxyMode.Auto.value,
|
||
'Automatically Configure System Proxy',
|
||
'setSystemProxyMode',
|
||
),
|
||
)
|
||
|
||
with isolatedSettings(), mock.patch(
|
||
'Furious.Window.SettingsPage.AppSettingsController'
|
||
) as controllerFactory:
|
||
for cardType, settingName, semanticValue, sourceText, callbackName in cases:
|
||
AppSettings.set('Language', 'EN')
|
||
AppSettings.set(settingName, semanticValue)
|
||
parent = QWidget()
|
||
layout = QHBoxLayout(parent)
|
||
card = cardType(parent=parent)
|
||
layout.addWidget(card)
|
||
parent.resize(1800, 120)
|
||
parent.show()
|
||
processQtEvents()
|
||
|
||
comboBox = card.comboBox
|
||
callback = getattr(controllerFactory.return_value, callbackName)
|
||
indexChanges = []
|
||
comboBox.currentIndexChanged.connect(indexChanges.append)
|
||
|
||
self.assertEqual(
|
||
comboBox.sizeAdjustPolicy(),
|
||
QComboBox.SizeAdjustPolicy.AdjustToContents,
|
||
)
|
||
|
||
for language in ('EN', 'ZH', 'RU'):
|
||
AppSettings.set('Language', language)
|
||
comboBox.retranslate()
|
||
processQtEvents()
|
||
|
||
self.assertEqual(comboBox.currentData(), semanticValue)
|
||
self.assertEqual(comboBox.currentText(), _(sourceText, language))
|
||
self.assertCurrentComboTextFits(comboBox)
|
||
|
||
callback.assert_not_called()
|
||
self.assertEqual(indexChanges, [])
|
||
|
||
parent.close()
|
||
parent.deleteLater()
|
||
|
||
|
||
class SettingsPageOrganizationTest(unittest.TestCase):
|
||
"""Protect General/Application membership and existing action delegation."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Finish deferred Settings-page deletion between tests."""
|
||
collectAtBoundary()
|
||
|
||
def buildPage(self, *, platform='Windows', flatpakID='', isAdmin=False):
|
||
"""Build one isolated Settings page for the requested platform state."""
|
||
callbacks = {
|
||
'checkForUpdates': mock.Mock(),
|
||
'openAboutPage': mock.Mock(),
|
||
'restartAsAdmin': mock.Mock(),
|
||
'openApplicationFolder': mock.Mock(),
|
||
}
|
||
proxyBypassDialog = mock.Mock()
|
||
networkTestDialog = mock.Mock()
|
||
|
||
with (
|
||
isolatedSettings(),
|
||
mock.patch('Furious.Window.SettingsPage.PLATFORM', platform),
|
||
mock.patch(
|
||
'Furious.Window.SettingsPage.SystemRuntime.isAdmin',
|
||
return_value=isAdmin,
|
||
),
|
||
mock.patch(
|
||
'Furious.Window.SettingsPage.SystemRuntime.flatpakID',
|
||
return_value=flatpakID,
|
||
),
|
||
mock.patch(
|
||
'Furious.Window.SettingsPage.SystemRuntime.isAssetsFolderWritable',
|
||
return_value=False,
|
||
),
|
||
mock.patch(
|
||
'Furious.Window.SettingsPage.AppSettings.isStateON_',
|
||
return_value=False,
|
||
),
|
||
mock.patch.object(SettingsPage, '_buildPluginSections'),
|
||
mock.patch(
|
||
'Furious.Window.SettingsPage.AppSettingsController'
|
||
) as controllerFactory,
|
||
):
|
||
controllerFactory.return_value.tunModeAvailable.return_value = (
|
||
not flatpakID if platform == 'Linux' else isAdmin
|
||
)
|
||
page = SettingsPage(
|
||
tunSettingsDialogFactory=mock.Mock(),
|
||
proxyBypassDialog=proxyBypassDialog,
|
||
networkTestDialog=networkTestDialog,
|
||
**callbacks,
|
||
)
|
||
|
||
return page, callbacks
|
||
|
||
def testGeneralEndsWithSystemAndEnvironmentActions(self):
|
||
"""Keep preferences first and Application focused on maintenance/about."""
|
||
page, _callbacks = self.buildPage()
|
||
|
||
expectedGeneralCards = [
|
||
page.tunModeCard,
|
||
page.applicationThemeCard,
|
||
page.languageCard,
|
||
page.monochromeCard,
|
||
page.startupCard,
|
||
page.powerSaveCard,
|
||
page.restartCard,
|
||
page.openFolderCard,
|
||
]
|
||
|
||
self.assertEqual(page.generalSection.cards, expectedGeneralCards)
|
||
self.assertEqual(
|
||
page.applicationSection.cards,
|
||
[page.updateCard, page.aboutCard],
|
||
)
|
||
self.assertEqual(
|
||
[
|
||
page.generalSection.layout.itemAt(index + 1).widget()
|
||
for index in range(len(expectedGeneralCards))
|
||
],
|
||
expectedGeneralCards,
|
||
)
|
||
self.assertTrue(
|
||
all(card.parent() is page.generalSection for card in expectedGeneralCards)
|
||
)
|
||
|
||
page.close()
|
||
page.deleteLater()
|
||
|
||
def testMovedCardsKeepTheirInjectedActions(self):
|
||
"""Move presentation without replacing restart or folder behavior."""
|
||
page, callbacks = self.buildPage()
|
||
|
||
page.restartCard.button.click()
|
||
page.openFolderCard.button.click()
|
||
|
||
callbacks['restartAsAdmin'].assert_called_once_with()
|
||
callbacks['openApplicationFolder'].assert_called_once_with()
|
||
callbacks['checkForUpdates'].assert_not_called()
|
||
callbacks['openAboutPage'].assert_not_called()
|
||
|
||
page.close()
|
||
page.deleteLater()
|
||
|
||
def testFlatpakShowsDisabledTunModeCardRegardlessOfPrivileges(self):
|
||
"""Keep the Flatpak TUN mode toggle visible but always disabled."""
|
||
for isAdmin in (False, True):
|
||
with self.subTest(isAdmin=isAdmin):
|
||
page, _callbacks = self.buildPage(
|
||
platform='Linux',
|
||
flatpakID='io.github.LorenEteval.Furious',
|
||
isAdmin=isAdmin,
|
||
)
|
||
|
||
self.assertIn(page.tunModeCard, page.generalSection.cards)
|
||
self.assertFalse(page.tunModeCard.checkBox.isEnabled())
|
||
|
||
page.setConnectionControlsEnabled(True)
|
||
self.assertFalse(page.tunModeCard.checkBox.isEnabled())
|
||
|
||
page.close()
|
||
page.deleteLater()
|
||
|
||
def testMovedCardsPreservePlatformVisibility(self):
|
||
"""Retain the existing restart, Darwin, and Flatpak visibility gates."""
|
||
cases = (
|
||
('Windows', '', True, True),
|
||
('Darwin', '', True, False),
|
||
('Linux', '', False, True),
|
||
('Linux', 'io.github.LorenEteval.Furious', False, False),
|
||
)
|
||
|
||
for platform, flatpakID, hasRestart, hasOpenFolder in cases:
|
||
with self.subTest(platform=platform, flatpakID=flatpakID):
|
||
page, _callbacks = self.buildPage(
|
||
platform=platform,
|
||
flatpakID=flatpakID,
|
||
)
|
||
|
||
self.assertEqual(page.restartCard is not None, hasRestart)
|
||
self.assertEqual(page.openFolderCard is not None, hasOpenFolder)
|
||
|
||
for card in (page.restartCard, page.openFolderCard):
|
||
if card is not None:
|
||
self.assertIn(card, page.generalSection.cards)
|
||
self.assertNotIn(card, page.applicationSection.cards)
|
||
|
||
page.close()
|
||
page.deleteLater()
|
||
|
||
|
||
class ServerTableBehaviorTest(unittest.TestCase):
|
||
"""Protect filtered persistent movement and active-profile identity."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Release the cached repository and deferred table widgets."""
|
||
Storage._UserServersStorage.cache_clear()
|
||
collectAtBoundary()
|
||
|
||
@staticmethod
|
||
def _profile(name: str):
|
||
"""Build one deterministic profile for table behavior checks."""
|
||
return ServerProfile.fromConfiguration(
|
||
CoreConfiguration({'type': 'fixture'}),
|
||
{
|
||
'displayName': name,
|
||
},
|
||
)
|
||
|
||
def testKeyboardMovesPreserveSelectionFocusAndActiveProfile(self):
|
||
"""Keep repeated single- and multi-row shortcuts ready for reuse."""
|
||
cases = (
|
||
(
|
||
('visible first', 'hidden', 'visible second', 'visible third'),
|
||
[3],
|
||
QtCore.Qt.Key.Key_Up,
|
||
['visible third', 'hidden', 'visible first', 'visible second'],
|
||
[0],
|
||
2,
|
||
),
|
||
(
|
||
(
|
||
'visible first',
|
||
'hidden',
|
||
'visible second',
|
||
'visible third',
|
||
'visible fourth',
|
||
'visible fifth',
|
||
),
|
||
[2, 3],
|
||
QtCore.Qt.Key.Key_Down,
|
||
[
|
||
'visible first',
|
||
'hidden',
|
||
'visible fourth',
|
||
'visible fifth',
|
||
'visible second',
|
||
'visible third',
|
||
],
|
||
[4, 5],
|
||
0,
|
||
),
|
||
)
|
||
|
||
for names, selected, key, expected, expectedSelected, activated in cases:
|
||
with self.subTest(selected=selected, key=key), isolatedSettings():
|
||
Storage._UserServersStorage.cache_clear()
|
||
Storage.UserServers().extend(self._profile(name) for name in names)
|
||
AppSettings.set('ActivatedItemIndex', '0')
|
||
|
||
table = ServerTableView(
|
||
configurationEditorFactory=QWidget,
|
||
qrCodeWindowFactory=QWidget,
|
||
importActionsFactory=tuple,
|
||
)
|
||
|
||
table.search('^visible')
|
||
table.show()
|
||
table.activateWindow()
|
||
|
||
table.selectMultipleRows(selected, True)
|
||
table.selectionModel().setCurrentIndex(
|
||
table.proxyIndexFromSourceRow(selected[0]),
|
||
QtCore.QItemSelectionModel.SelectionFlag.NoUpdate,
|
||
)
|
||
table.setFocus()
|
||
processQtEvents()
|
||
|
||
for _iteration in range(2):
|
||
QTest.keyClick(
|
||
table,
|
||
key,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertTrue(table.hasFocus())
|
||
|
||
self.assertEqual(
|
||
[profile.itemRemark for profile in Storage.UserServers()],
|
||
expected,
|
||
)
|
||
self.assertEqual(Storage.UserActivatedItemIndex(), activated)
|
||
self.assertEqual(table.selectedIndex, expectedSelected)
|
||
|
||
table.cleanup()
|
||
table.close()
|
||
table.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."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Finish every deferred transient deletion between tests."""
|
||
collectAtBoundary()
|
||
|
||
@staticmethod
|
||
def xrayProfile(protocol: Protocol, user: dict) -> ServerProfile:
|
||
"""Return one minimal Xray profile for a basic-editor round trip."""
|
||
outbound = configXrayEmptyProxyOutboundObject(protocol)
|
||
server = outbound['settings']['vnext'][0]
|
||
|
||
server['address'] = 'server.example.com'
|
||
server['port'] = 443
|
||
server['users'][0].update(user)
|
||
|
||
return ServerProfile.fromConfiguration(
|
||
ConfigXray({'outbounds': [outbound]}),
|
||
ProfileMetadata(displayName='Fixture profile'),
|
||
)
|
||
|
||
def assertBindingPosition(
|
||
self,
|
||
layout,
|
||
binding,
|
||
row: int,
|
||
labelColumn: int,
|
||
inputColumn: int,
|
||
inputColumnSpan: int = 1,
|
||
):
|
||
"""Assert one binding's logical grid placement without pixel geometry."""
|
||
label, inputWidget = binding.widgets()
|
||
|
||
self.assertEqual(
|
||
layout.getItemPosition(layout.indexOf(label)),
|
||
(row, labelColumn, 1, 1),
|
||
)
|
||
self.assertEqual(
|
||
layout.getItemPosition(layout.indexOf(inputWidget)),
|
||
(row, inputColumn, 1, inputColumnSpan),
|
||
)
|
||
|
||
def testVlessBasicUsesTlsGridRowsWithoutChangingMapping(self):
|
||
"""Pair address/port and encryption/flow around full-width fields."""
|
||
profile = self.xrayProfile(
|
||
Protocol.VLESS,
|
||
{
|
||
'id': 'fixture-vless-id',
|
||
'encryption': 'none',
|
||
'flow': 'future-vless-flow',
|
||
},
|
||
)
|
||
original = copy.deepcopy(profile.connection)
|
||
group = GuiVLESSGroupBoxBasic()
|
||
layout = group._widget.currentWidget().layout()
|
||
|
||
group.factoryToInput(profile)
|
||
|
||
self.assertFalse(group.inputToFactory(profile))
|
||
self.assertEqual(profile.connection, original)
|
||
self.assertBindingPosition(layout, group._containers[0], 0, 0, 1, 3)
|
||
self.assertBindingPosition(layout, group._containers[1], 1, 0, 1)
|
||
self.assertBindingPosition(layout, group._containers[2], 1, 2, 3)
|
||
self.assertBindingPosition(layout, group._containers[3], 2, 0, 1, 3)
|
||
self.assertBindingPosition(layout, group._containers[4], 3, 0, 1)
|
||
self.assertBindingPosition(layout, group._containers[5], 3, 2, 3)
|
||
self.assertEqual(group._containers[4].text(), 'none')
|
||
self.assertEqual(group._containers[5].text(), 'future-vless-flow')
|
||
|
||
group.deleteLater()
|
||
|
||
def testVmessBasicUsesTlsGridRowsWithoutChangingMapping(self):
|
||
"""Pair address/port and security/alterId around the UUID row."""
|
||
profile = self.xrayProfile(
|
||
Protocol.VMess,
|
||
{
|
||
'id': 'fixture-vmess-id',
|
||
'security': 'future-vmess-security',
|
||
'alterId': 17,
|
||
},
|
||
)
|
||
original = copy.deepcopy(profile.connection)
|
||
group = GuiVMessGroupBoxBasic()
|
||
layout = group._widget.currentWidget().layout()
|
||
|
||
group.factoryToInput(profile)
|
||
|
||
self.assertFalse(group.inputToFactory(profile))
|
||
self.assertEqual(profile.connection, original)
|
||
self.assertBindingPosition(layout, group._containers[0], 0, 0, 1, 3)
|
||
self.assertBindingPosition(layout, group._containers[1], 1, 0, 1)
|
||
self.assertBindingPosition(layout, group._containers[2], 1, 2, 3)
|
||
self.assertBindingPosition(layout, group._containers[3], 2, 0, 1, 3)
|
||
self.assertBindingPosition(layout, group._containers[5], 3, 0, 1)
|
||
self.assertBindingPosition(layout, group._containers[4], 3, 2, 3)
|
||
self.assertEqual(group._containers[5].text(), 'future-vmess-security')
|
||
self.assertEqual(group._containers[4].value(), 17)
|
||
|
||
group.deleteLater()
|
||
|
||
def testXHttpHostAndPathShareOneRowWithoutChangingMapping(self):
|
||
"""Pair xhttp Host/Path while preserving switching and configuration."""
|
||
profile = self.xrayProfile(
|
||
Protocol.VLESS,
|
||
{
|
||
'id': 'fixture-xhttp-id',
|
||
'encryption': 'none',
|
||
'flow': '',
|
||
},
|
||
)
|
||
streamSettings = ConfigXray.getProxyOutboundStream(profile.connection)
|
||
streamSettings.update(
|
||
{
|
||
'network': 'xhttp',
|
||
'xhttpSettings': {
|
||
'host': 'cdn.example.com',
|
||
'path': '/a/representative/xhttp/path',
|
||
'mode': 'auto',
|
||
'extra': {'noGRPCHeader': True},
|
||
},
|
||
}
|
||
)
|
||
original = copy.deepcopy(profile.connection)
|
||
group = GuiVTransportQGroupBox()
|
||
|
||
group.factoryToInput(profile.connection)
|
||
|
||
xhttpIndex = STREAM_NETWORK.index('xhttp')
|
||
wsIndex = STREAM_NETWORK.index('ws')
|
||
page = group.page(xhttpIndex)
|
||
self.assertIsInstance(page, GuiVTransportPageXHttp)
|
||
layout = page.layout()
|
||
host = page._containers[2]
|
||
path = page._containers[3]
|
||
hostInput = host.widgets()[1]
|
||
pathInput = path.widgets()[1]
|
||
|
||
self.assertBindingPosition(layout, page._containers[0], 0, 0, 1)
|
||
self.assertBindingPosition(layout, page._containers[1], 1, 0, 1, 3)
|
||
self.assertBindingPosition(layout, page._containers[4], 3, 0, 1)
|
||
self.assertBindingPosition(layout, page._containers[5], 4, 0, 1, 3)
|
||
self.assertBindingPosition(layout, host, 2, 0, 1)
|
||
self.assertBindingPosition(layout, path, 2, 2, 3)
|
||
self.assertEqual(host.text(), 'cdn.example.com')
|
||
self.assertEqual(path.text(), '/a/representative/xhttp/path')
|
||
self.assertEqual(profile.connection, original)
|
||
|
||
group.handleActivated(wsIndex)
|
||
group.handleActivated(xhttpIndex)
|
||
|
||
self.assertIs(group.page(xhttpIndex), page)
|
||
self.assertIs(host.widgets()[1], hostInput)
|
||
self.assertIs(path.widgets()[1], pathInput)
|
||
self.assertFalse(group.inputToFactory(profile.connection))
|
||
self.assertEqual(profile.connection, original)
|
||
|
||
host.setText('edge.example.net')
|
||
path.setText('/updated/path')
|
||
|
||
self.assertTrue(group.inputToFactory(profile.connection))
|
||
self.assertEqual(
|
||
streamSettings['xhttpSettings']['host'],
|
||
'edge.example.net',
|
||
)
|
||
self.assertEqual(streamSettings['xhttpSettings']['path'], '/updated/path')
|
||
|
||
group.deleteLater()
|
||
|
||
def testExternalCoreEditorRoundTripsStructuredFields(self):
|
||
"""Keep arguments, environment, process paths, and TUN data distinct."""
|
||
configuration = ConfigExternalCore(copy.deepcopy(BLANK_CONFIG_EXTERNAL_CORE))
|
||
configuration.update(
|
||
{
|
||
'executable': 'C:/Program Files/Fixture/core.exe',
|
||
'workingDirectory': 'C:/Program Files/Fixture',
|
||
'arguments': ['--config', 'name with spaces.json'],
|
||
'environment': {'TOKEN': 'one=two', 'UNICODE': '测试'},
|
||
'useApplicationTun2socks': True,
|
||
'tunRemoteAddress': '2001:db8::42',
|
||
'futureExternalCoreField': {
|
||
'nested': ['preserve', 7],
|
||
},
|
||
}
|
||
)
|
||
profile = ServerProfile.fromConfiguration(
|
||
configuration,
|
||
ProfileMetadata(displayName='Fixture core'),
|
||
)
|
||
|
||
with isolatedSettings():
|
||
editor = ExternalCoreEditor()
|
||
original = copy.deepcopy(profile.connection)
|
||
editor.factoryToInput(profile)
|
||
|
||
self.assertEqual(profile.connection, original)
|
||
|
||
self.assertEqual(
|
||
editor._argumentsInput.values(),
|
||
['--config', 'name with spaces.json'],
|
||
)
|
||
self.assertEqual(
|
||
editor._environmentInput.values(),
|
||
{'TOKEN': 'one=two', 'UNICODE': '测试'},
|
||
)
|
||
self.assertTrue(editor._applicationTun2socksInput.isChecked())
|
||
|
||
tunLabel, tunSwitch = editor._applicationTun2socksInput.widgets()
|
||
|
||
self.assertTrue(tunLabel.text())
|
||
self.assertIsInstance(tunSwitch, AppQSwitch)
|
||
self.assertEqual(tunSwitch.size(), AppQSwitch.CompactControlSize)
|
||
self.assertTrue(editor._tunRemoteAddressInput.widgets()[1].isEnabled())
|
||
self.assertEqual(editor._tunRemoteAddressInput.text(), '2001:db8::42')
|
||
self.assertEqual(len(editor.groupBoxSequence()), 2)
|
||
|
||
editor._argumentsInput._input.setText(
|
||
'--mode direct --label "a value with spaces"'
|
||
)
|
||
editor._environmentInput._input.setPlainText('A=1\nB=two=three')
|
||
editor._tunRemoteAddressInput._input.setText('server.example.com')
|
||
|
||
self.assertTrue(editor.inputToFactory(profile))
|
||
self.assertEqual(
|
||
profile.connection['arguments'],
|
||
['--mode', 'direct', '--label', 'a value with spaces'],
|
||
)
|
||
self.assertEqual(
|
||
profile.connection['environment'],
|
||
{'A': '1', 'B': 'two=three'},
|
||
)
|
||
self.assertEqual(
|
||
profile.connection.tunRemoteAddress(),
|
||
'server.example.com',
|
||
)
|
||
self.assertEqual(
|
||
profile.connection['futureExternalCoreField'],
|
||
{'nested': ['preserve', 7]},
|
||
)
|
||
|
||
editor.close()
|
||
|
||
def testExternalCorePlaceholdersRetranslateWithoutChangingValues(self):
|
||
"""Translate field guidance without persisting it as configuration."""
|
||
configuration = ConfigExternalCore(copy.deepcopy(BLANK_CONFIG_EXTERNAL_CORE))
|
||
configuration.update(
|
||
{
|
||
'arguments': ['--config', 'fixture.json', '--verbose'],
|
||
'environment': {'TOKEN': 'fixture'},
|
||
'useApplicationTun2socks': True,
|
||
'tunRemoteAddress': 'server.example.com',
|
||
}
|
||
)
|
||
profile = ServerProfile.fromConfiguration(
|
||
configuration,
|
||
ProfileMetadata(displayName='Fixture core'),
|
||
)
|
||
|
||
with isolatedSettings():
|
||
AppSettings.set('Language', 'EN')
|
||
editor = ExternalCoreEditor()
|
||
editor.factoryToInput(profile)
|
||
original = copy.deepcopy(profile.connection)
|
||
basicGroup = editor.groupBoxSequence()[0]
|
||
workingDirectoryInput = basicGroup._containers[2]._input
|
||
placeholderInputs = (
|
||
(
|
||
workingDirectoryInput,
|
||
'Leave empty to use executable directory',
|
||
),
|
||
(
|
||
editor._argumentsInput._input,
|
||
'e.g. --config config.json --verbose',
|
||
),
|
||
(
|
||
editor._environmentInput._input,
|
||
'One per line, e.g. KEY=VALUE',
|
||
),
|
||
(
|
||
editor._tunRemoteAddressInput._input,
|
||
'Remote server hostname or IP address',
|
||
),
|
||
)
|
||
|
||
for widget, sourceText in placeholderInputs:
|
||
self.assertEqual(widget.placeholderText(), sourceText)
|
||
|
||
for language in ('ZH', 'RU', 'EN'):
|
||
AppSettings.set('Language', language)
|
||
Mixins.QTranslatable.retranslateAll()
|
||
|
||
for widget, sourceText in placeholderInputs:
|
||
self.assertEqual(widget.placeholderText(), _(sourceText, language))
|
||
|
||
self.assertEqual(
|
||
editor._argumentsInput.values(),
|
||
['--config', 'fixture.json', '--verbose'],
|
||
)
|
||
self.assertEqual(
|
||
editor._environmentInput.values(),
|
||
{'TOKEN': 'fixture'},
|
||
)
|
||
self.assertEqual(
|
||
editor._tunRemoteAddressInput.text(),
|
||
'server.example.com',
|
||
)
|
||
|
||
self.assertFalse(editor.inputToFactory(profile))
|
||
self.assertEqual(profile.connection, original)
|
||
self.assertEqual(workingDirectoryInput.text(), '')
|
||
|
||
editor.close()
|
||
|
||
def testExternalCoreSectionUsesCanonicalEditorInset(self):
|
||
"""Use canonical editor grids and insets for both External Core groups."""
|
||
editor = ExternalCoreEditor()
|
||
externalGroups = editor.groupBoxSequence()
|
||
referenceGroup = GuiVLESSGroupBoxBasic()
|
||
referenceLayout = referenceGroup._widget.currentWidget().layout()
|
||
|
||
self.assertEqual(len(externalGroups), 2)
|
||
|
||
for group in externalGroups:
|
||
externalLayout = group._widget.currentWidget().layout()
|
||
|
||
self.assertEqual(
|
||
externalLayout.contentsMargins(),
|
||
referenceLayout.contentsMargins(),
|
||
)
|
||
self.assertFalse(externalLayout.contentsMargins().isNull())
|
||
self.assertIsInstance(externalLayout, type(referenceLayout))
|
||
self.assertEqual(externalLayout.columnStretch(1), 1)
|
||
self.assertEqual(
|
||
externalLayout.verticalSpacing(),
|
||
referenceLayout.verticalSpacing(),
|
||
)
|
||
self.assertEqual(
|
||
externalLayout.rowStretch(externalLayout.rowCount() - 1),
|
||
1,
|
||
)
|
||
|
||
basicGroup, otherGroup = externalGroups
|
||
basicLayout = basicGroup._widget.currentWidget().layout()
|
||
otherLayout = otherGroup._widget.currentWidget().layout()
|
||
timeoutInput = otherGroup._containers[2].widgets()[1]
|
||
|
||
self.assertIsInstance(
|
||
otherGroup._containers[2], ExternalCoreShutdownTimeoutInput
|
||
)
|
||
|
||
for row, container in enumerate(basicGroup._containers):
|
||
self.assertBindingPosition(basicLayout, container, row, 0, 1, 3)
|
||
|
||
self.assertEqual(
|
||
timeoutInput.sizePolicy().horizontalPolicy(),
|
||
QSizePolicy.Policy.Fixed,
|
||
)
|
||
self.assertBindingPosition(otherLayout, otherGroup._containers[0], 0, 0, 1, 3)
|
||
self.assertBindingPosition(otherLayout, otherGroup._containers[1], 1, 0, 1, 3)
|
||
self.assertBindingPosition(otherLayout, otherGroup._containers[2], 2, 0, 1)
|
||
self.assertBindingPosition(otherLayout, otherGroup._containers[4], 4, 0, 1, 3)
|
||
self.assertGreater(basicGroup._containers[-1].widgets()[1].maximumWidth(), 520)
|
||
self.assertGreater(otherGroup._containers[0].widgets()[1].maximumWidth(), 360)
|
||
self.assertGreater(otherGroup._containers[1].widgets()[1].maximumWidth(), 360)
|
||
self.assertGreater(otherGroup._containers[4].widgets()[1].maximumWidth(), 420)
|
||
self.assertEqual(editor.size(), QtCore.QSize(1400, 600))
|
||
|
||
editor.close()
|
||
referenceGroup.deleteLater()
|
||
|
||
def testExternalCoreCompactRowsRemainUniformAndSeparated(self):
|
||
"""Keep compact controls uniform without collapsing adjacent grid rows."""
|
||
app = application()
|
||
originalStyleSheet = app.styleSheet()
|
||
|
||
try:
|
||
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
|
||
app.setStyleSheet(AppStyleSheet.forTheme(theme))
|
||
editor = ExternalCoreEditor()
|
||
|
||
try:
|
||
editor.show()
|
||
processQtEvents()
|
||
|
||
otherGroup = editor.groupBoxSequence()[1]
|
||
layout = otherGroup._widget.currentWidget().layout()
|
||
compactInputs = tuple(
|
||
otherGroup._containers[index].widgets()[1] for index in range(3)
|
||
)
|
||
naturalRowMinimum = max(
|
||
inputWidget.minimumSizeHint().height()
|
||
for inputWidget in compactInputs
|
||
)
|
||
timeoutPolicy = compactInputs[2].sizePolicy()
|
||
|
||
self.assertNotEqual(
|
||
timeoutPolicy.verticalPolicy(), QSizePolicy.Policy.Ignored
|
||
)
|
||
self.assertEqual(
|
||
timeoutPolicy.controlType(), QSizePolicy.ControlType.SpinBox
|
||
)
|
||
self.assertEqual(
|
||
tuple(layout.rowMinimumHeight(row) for row in range(3)),
|
||
(naturalRowMinimum,) * 3,
|
||
)
|
||
|
||
for size in (
|
||
QtCore.QSize(1200, 520),
|
||
QtCore.QSize(1400, 600),
|
||
QtCore.QSize(1600, 700),
|
||
):
|
||
editor.setFixedSize(size)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(
|
||
tuple(
|
||
inputWidget.height() for inputWidget in compactInputs
|
||
),
|
||
(compactInputs[0].height(),) * 3,
|
||
)
|
||
|
||
rowRectangles = []
|
||
|
||
for container in otherGroup._containers:
|
||
label, inputWidget = container.widgets()
|
||
rowRectangles.append(
|
||
label.geometry().united(inputWidget.geometry())
|
||
)
|
||
|
||
for currentRow, nextRow in zip(
|
||
rowRectangles, rowRectangles[1:]
|
||
):
|
||
self.assertFalse(currentRow.intersects(nextRow))
|
||
self.assertEqual(
|
||
nextRow.top() - currentRow.bottom() - 1,
|
||
layout.verticalSpacing(),
|
||
)
|
||
finally:
|
||
editor.close()
|
||
finally:
|
||
app.setStyleSheet(originalStyleSheet)
|
||
|
||
def testExternalCoreRowsRemainTopAlignedAsSectionGrows(self):
|
||
"""Assign surplus editor height below the compact form rows."""
|
||
groups = (
|
||
ExternalCoreConfigurationGroup(
|
||
ExternalCoreArgumentsInput(),
|
||
ExternalCoreEnvironmentInput(),
|
||
),
|
||
ExternalCoreOtherGroup(
|
||
ExternalCoreApplicationTun2socksInput(),
|
||
ExternalCoreTunRemoteAddressInput(),
|
||
),
|
||
)
|
||
|
||
for group in groups:
|
||
containers = group._containers
|
||
|
||
group.resize(760, 900)
|
||
group.show()
|
||
processQtEvents()
|
||
|
||
initialPositions = tuple(
|
||
container.widgets()[0].geometry().top() for container in containers
|
||
)
|
||
page = group._widget.currentWidget()
|
||
initialBlankHeight = (
|
||
page.height() - containers[-1].widgets()[0].geometry().bottom()
|
||
)
|
||
|
||
group.resize(760, 1200)
|
||
processQtEvents()
|
||
|
||
grownPositions = tuple(
|
||
container.widgets()[0].geometry().top() for container in containers
|
||
)
|
||
grownBlankHeight = (
|
||
page.height() - containers[-1].widgets()[0].geometry().bottom()
|
||
)
|
||
|
||
self.assertEqual(grownPositions, initialPositions)
|
||
self.assertGreater(grownBlankHeight, initialBlankHeight)
|
||
|
||
group.close()
|
||
group.deleteLater()
|
||
|
||
def testEveryProtocolEditorRetranslatesItsDedicatedWindowTitle(self):
|
||
"""Retain title source text when switching from Chinese to English."""
|
||
editorTypes = (
|
||
(ExternalCoreEditor, 'Add External Core'),
|
||
(Hysteria1Editor, 'Add Hysteria1 Server'),
|
||
(Hysteria2Editor, 'Add Hysteria2 Server'),
|
||
(ShadowsocksEditor, 'Add Shadowsocks Server'),
|
||
(SocksEditor, 'Add SOCKS Server'),
|
||
(TrojanEditor, 'Add Trojan Server'),
|
||
(VlessEditor, 'Add VLESS Server'),
|
||
(VmessEditor, 'Add VMess Server'),
|
||
)
|
||
|
||
with isolatedSettings():
|
||
AppSettings.set('Language', 'ZH')
|
||
editors = []
|
||
|
||
for editorType, sourceTitle in editorTypes:
|
||
editor = editorType(windowTitle=_(sourceTitle))
|
||
editors.append((editor, sourceTitle))
|
||
|
||
self.assertEqual(editor.windowTitle(), _(sourceTitle))
|
||
|
||
AppSettings.set('Language', 'EN')
|
||
|
||
Mixins.QTranslatable.retranslateAll()
|
||
|
||
for editor, sourceTitle in editors:
|
||
self.assertEqual(editor.windowTitle(), sourceTitle)
|
||
|
||
editor.close()
|
||
|
||
def testSubscriptionEditorNormalizesPresentationValues(self):
|
||
"""Return one complete subscription record from its visual controls."""
|
||
with isolatedSettings():
|
||
dialog = _SubscriptionEditorDialog(
|
||
{
|
||
'remark': ' Fixture subscription ',
|
||
'webURL': 'https://example.test/subscription',
|
||
'enabled': False,
|
||
'autoupdate': 'Every 6 hours',
|
||
'proxy': 'Direct',
|
||
'userAgent': ' Fixture/1.0 ',
|
||
'filter': ' keep.* ',
|
||
}
|
||
)
|
||
|
||
values = dialog.subscription()
|
||
|
||
self.assertIsInstance(dialog.enabledSwitch, AppQSwitch)
|
||
self.assertEqual(dialog.enabledSwitch.size(), AppQSwitch.ControlSize)
|
||
self.assertEqual(
|
||
dialog.enabledSwitch.parentWidget().objectName(),
|
||
'SubscriptionEditorForm',
|
||
)
|
||
self.assertEqual(values['remark'], 'Fixture subscription')
|
||
self.assertEqual(values['webURL'], 'https://example.test/subscription')
|
||
self.assertFalse(values['enabled'])
|
||
self.assertEqual(values['userAgent'], 'Fixture/1.0')
|
||
self.assertEqual(values['filter'], 'keep.*')
|
||
|
||
dialog.accept()
|
||
|
||
self.assertEqual(
|
||
dialog.result(),
|
||
_SubscriptionEditorDialog.DialogCode.Accepted,
|
||
)
|
||
|
||
|
||
class UnifiedLogPageTest(unittest.TestCase):
|
||
"""Prove bounded collection is eager while hidden-page rendering is lazy."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Finish deferred document and widget cleanup between tests."""
|
||
collectAtBoundary()
|
||
|
||
def assertRendered(self, page, *, highlighting=True):
|
||
"""Wait for the coalesced snapshot and optional highlighting batches."""
|
||
self.assertTrue(waitFor(lambda: not page._entriesDirty))
|
||
|
||
if highlighting:
|
||
self.assertTrue(waitFor(lambda: page._highlightNextBlock is None))
|
||
|
||
@staticmethod
|
||
def disposePage(page):
|
||
"""Release one persistent page and its owned timers."""
|
||
page.close()
|
||
page.deleteLater()
|
||
|
||
# Retain these two regressions with LogPage's disabled optional pause code.
|
||
# Uncomment both when restoring the control, handler, state and render guard;
|
||
# they verify frozen selection/export, retention changes and translations.
|
||
# def testPausedViewPreservesSelectionAcrossEvictionClearAndHide(self):
|
||
# """Pause presentation while the shared bounded stream keeps collecting."""
|
||
# with isolatedSettings():
|
||
# manager = LogManager(maximumEntries=3)
|
||
# page = LogPage(manager=manager)
|
||
# self.addCleanup(self.disposePage, page)
|
||
# manager.append('original text', APPLICATION_LOG_CATEGORY)
|
||
# page.show()
|
||
# self.assertRendered(page)
|
||
# page.textBrowser.selectAll()
|
||
# selected = page.textBrowser.textCursor().selectedText()
|
||
#
|
||
# QTest.mouseClick(page.pauseButton, QtCore.Qt.LeftButton)
|
||
# manager.clear()
|
||
# for number in range(10):
|
||
# manager.append(f'new {number}', APPLICATION_LOG_CATEGORY)
|
||
# processQtEvents()
|
||
# page.hide()
|
||
# page.show()
|
||
# processQtEvents()
|
||
#
|
||
# self.assertEqual(page.plainText(), 'original text')
|
||
# self.assertEqual(page.textBrowser.textCursor().selectedText(), selected)
|
||
# self.assertFalse(page.searchLineEdit.isEnabled())
|
||
# self.assertFalse(page.filterComboBox.isEnabled())
|
||
# self.assertFalse(page._updateTimer.isActive())
|
||
# self.assertFalse(page._highlightTimer.isActive())
|
||
# self.assertFalse(page._scrollTimer.isActive())
|
||
#
|
||
# QTest.mouseClick(page.pauseButton, QtCore.Qt.LeftButton)
|
||
# self.assertRendered(page)
|
||
# self.assertEqual(page.plainText().splitlines(), ['new 7', 'new 8', 'new 9'])
|
||
# self.assertTrue(page.searchLineEdit.isEnabled())
|
||
# self.assertTrue(page.filterComboBox.isEnabled())
|
||
#
|
||
# def testPausedFilteredViewExportsItsSnapshotAndRetranslates(self):
|
||
# """Export the frozen filtered document, then resume the same filter."""
|
||
# with isolatedSettings():
|
||
# AppSettings.set('Language', 'EN')
|
||
# manager = LogManager(maximumEntries=4)
|
||
# page = LogPage(manager=manager)
|
||
# self.addCleanup(self.disposePage, page)
|
||
# manager.append('core match', CORE_LOG_CATEGORY)
|
||
# manager.append('application match', APPLICATION_LOG_CATEGORY)
|
||
# page.filterComboBox.setCurrentIndex(
|
||
# page.filterComboBox.findData(CORE_LOG_CATEGORY)
|
||
# )
|
||
# page.searchLineEdit.setText('match')
|
||
# page.show()
|
||
# self.assertRendered(page)
|
||
# QTest.mouseClick(page.pauseButton, QtCore.Qt.LeftButton)
|
||
# manager.append('core later match', CORE_LOG_CATEGORY)
|
||
# processQtEvents()
|
||
# with mock.patch('Furious.Window.LogPage.saveAsFile') as save:
|
||
# page._fileMenu.actions()[0].trigger()
|
||
# save.assert_called_once_with('core match')
|
||
# for language in ('ZH', 'RU', 'EN'):
|
||
# AppSettings.set('Language', language)
|
||
# page.pauseButton.retranslate()
|
||
# self.assertEqual(page.pauseButton.text(), _('Resume Updates'))
|
||
# QTest.mouseClick(page.pauseButton, QtCore.Qt.LeftButton)
|
||
# self.assertRendered(page)
|
||
# self.assertEqual(
|
||
# page.plainText().splitlines(), ['core match', 'core later match']
|
||
# )
|
||
# self.assertEqual(page.pauseButton.text(), _('Pause Updates'))
|
||
|
||
def testSearchKeyboardKeepsEditingSeparateFromLogSelection(self):
|
||
"""Find focuses search; editing shortcuts act on the focused widget."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=3)
|
||
page = LogPage(manager=manager)
|
||
self.addCleanup(self.disposePage, page)
|
||
self.addCleanup(QTest.keyRelease, page.textBrowser, QtCore.Qt.Key_Control)
|
||
|
||
manager.append('alpha log', APPLICATION_LOG_CATEGORY)
|
||
page.show()
|
||
page.activateWindow()
|
||
self.assertRendered(page)
|
||
|
||
page.textBrowser.setFocus()
|
||
processQtEvents()
|
||
QTest.keyClick(page.textBrowser, QtCore.Qt.Key_F, QtCore.Qt.ControlModifier)
|
||
self.assertTrue(waitFor(page.searchLineEdit.hasFocus))
|
||
|
||
QTest.keyClicks(page.searchLineEdit, 'alpha')
|
||
QTest.keyClick(
|
||
page.searchLineEdit, QtCore.Qt.Key_A, QtCore.Qt.ControlModifier
|
||
)
|
||
QTest.keyClick(
|
||
page.searchLineEdit, QtCore.Qt.Key_C, QtCore.Qt.ControlModifier
|
||
)
|
||
|
||
self.assertEqual(application().clipboard().text(), 'alpha')
|
||
self.assertFalse(page.textBrowser.textCursor().hasSelection())
|
||
self.assertRendered(page)
|
||
|
||
page.textBrowser.setFocus()
|
||
QTest.keyClick(page.textBrowser, QtCore.Qt.Key_A, QtCore.Qt.ControlModifier)
|
||
QTest.keyClick(page.textBrowser, QtCore.Qt.Key_C, QtCore.Qt.ControlModifier)
|
||
|
||
self.assertEqual(application().clipboard().text(), 'alpha log')
|
||
|
||
def testFindShortcutFollowsTheVisiblePage(self):
|
||
"""Two persistent pages share one window without ambiguous Find actions."""
|
||
with isolatedSettings():
|
||
stack = QStackedWidget()
|
||
pages = [LogPage(manager=LogManager(maximumEntries=3)) for _ in range(2)]
|
||
|
||
for page in pages:
|
||
stack.addWidget(page)
|
||
|
||
self.addCleanup(stack.deleteLater)
|
||
self.addCleanup(stack.close)
|
||
self.addCleanup(QTest.keyRelease, stack, QtCore.Qt.Key_Control)
|
||
|
||
stack.show()
|
||
stack.activateWindow()
|
||
|
||
for page in (pages[0], pages[1], pages[0]):
|
||
stack.setCurrentWidget(page)
|
||
page.textBrowser.setFocus()
|
||
processQtEvents()
|
||
|
||
QTest.keyClick(
|
||
page.textBrowser, QtCore.Qt.Key_F, QtCore.Qt.ControlModifier
|
||
)
|
||
|
||
self.assertTrue(waitFor(page.searchLineEdit.hasFocus))
|
||
|
||
def testLabelsOwnTheirDynamicTranslation(self):
|
||
"""Let each AppQLabel translate itself without page-level setters."""
|
||
with isolatedSettings():
|
||
AppSettings.set('Language', 'EN')
|
||
page = LogPage(manager=LogManager(maximumEntries=5))
|
||
labels = (
|
||
(page.pageTitleLabel, 'Log'),
|
||
(page.filterLabel, 'Log Type'),
|
||
(page.autoScrollLabel, 'Auto Scroll Down'),
|
||
(page.autoClearLabel, 'Auto Clear Log'),
|
||
(page.highlightStatusLabel, 'Processing...'),
|
||
(page.emptyStateLabel, 'No logs match the current filters.'),
|
||
)
|
||
|
||
AppSettings.set('Language', 'ZH')
|
||
|
||
for label, sourceText in labels:
|
||
self.assertTrue(label.translatable)
|
||
|
||
label.retranslate()
|
||
|
||
self.assertEqual(label.text(), _(sourceText))
|
||
|
||
self.disposePage(page)
|
||
|
||
def testHiddenPageRendersOneOrderedSnapshotWhenShown(self):
|
||
"""Do not mutate the document while hidden; catch up exactly once."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=5)
|
||
page = LogPage(manager=manager)
|
||
|
||
manager.append('application one', APPLICATION_LOG_CATEGORY)
|
||
manager.append('core one', CORE_LOG_CATEGORY)
|
||
|
||
processQtEvents()
|
||
|
||
self.assertEqual(page.textBrowser.toPlainText(), '')
|
||
self.assertTrue(page._entriesDirty)
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(
|
||
page.textBrowser.toPlainText().splitlines(),
|
||
['application one', 'core one'],
|
||
)
|
||
|
||
page.hide()
|
||
manager.append('core two', CORE_LOG_CATEGORY)
|
||
|
||
processQtEvents()
|
||
|
||
self.assertNotIn('core two', page.textBrowser.toPlainText())
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(
|
||
page.textBrowser.toPlainText().splitlines(),
|
||
['application one', 'core one', 'core two'],
|
||
)
|
||
|
||
coreIndex = page.filterComboBox.findData(CORE_LOG_CATEGORY)
|
||
page.filterComboBox.setCurrentIndex(coreIndex)
|
||
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(
|
||
page.textBrowser.toPlainText().splitlines(),
|
||
['core one', 'core two'],
|
||
)
|
||
|
||
self.disposePage(page)
|
||
|
||
def testSearchComposesWithCategoryAndHandlesInvalidRegexLiterally(self):
|
||
"""Filter retained and live entries without weakening category selection."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=8)
|
||
page = LogPage(manager=manager)
|
||
|
||
manager.append('application alpha', APPLICATION_LOG_CATEGORY)
|
||
manager.append('core alpha', CORE_LOG_CATEGORY)
|
||
manager.append('application beta [literal]', APPLICATION_LOG_CATEGORY)
|
||
|
||
page.show()
|
||
self.assertRendered(page)
|
||
|
||
page.searchLineEdit.setText('alpha|beta')
|
||
self.assertRendered(page)
|
||
self.assertEqual(
|
||
page.plainText().splitlines(),
|
||
[
|
||
'application alpha',
|
||
'core alpha',
|
||
'application beta [literal]',
|
||
],
|
||
)
|
||
|
||
applicationIndex = page.filterComboBox.findData(APPLICATION_LOG_CATEGORY)
|
||
page.filterComboBox.setCurrentIndex(applicationIndex)
|
||
self.assertRendered(page)
|
||
self.assertEqual(
|
||
page.plainText().splitlines(),
|
||
['application alpha', 'application beta [literal]'],
|
||
)
|
||
|
||
page.searchLineEdit.setText('[literal')
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(page.plainText(), 'application beta [literal]')
|
||
self.assertTrue(page.searchLineEdit.toolTip())
|
||
|
||
manager.append('application [literal] live', APPLICATION_LOG_CATEGORY)
|
||
manager.append('application ignored', APPLICATION_LOG_CATEGORY)
|
||
self.assertRendered(page)
|
||
self.assertEqual(
|
||
page.plainText().splitlines(),
|
||
['application beta [literal]', 'application [literal] live'],
|
||
)
|
||
|
||
clearButton = page.searchLineEdit.findChild(QToolButton)
|
||
self.assertIsNotNone(clearButton)
|
||
|
||
QTest.mouseClick(clearButton, QtCore.Qt.MouseButton.LeftButton)
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(page.searchLineEdit.text(), '')
|
||
self.assertFalse(page.searchLineEdit.toolTip())
|
||
self.assertEqual(
|
||
page.filterComboBox.currentData(), APPLICATION_LOG_CATEGORY
|
||
)
|
||
self.assertEqual(
|
||
page.plainText().splitlines(),
|
||
[
|
||
'application alpha',
|
||
'application beta [literal]',
|
||
'application [literal] live',
|
||
'application ignored',
|
||
],
|
||
)
|
||
|
||
self.disposePage(page)
|
||
|
||
def testNoMatchGuidanceUsesExistingFilterControls(self):
|
||
"""Recover filtered logs by clearing search and selecting the desired category."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=5)
|
||
page = LogPage(manager=manager)
|
||
self.addCleanup(self.disposePage, page)
|
||
|
||
page.show()
|
||
self.assertRendered(page)
|
||
|
||
self.assertFalse(page.emptyState.isVisible())
|
||
|
||
manager.append('application one', APPLICATION_LOG_CATEGORY)
|
||
self.assertRendered(page)
|
||
|
||
page.filterComboBox.setCurrentIndex(
|
||
page.filterComboBox.findData(CORE_LOG_CATEGORY)
|
||
)
|
||
self.assertRendered(page)
|
||
|
||
self.assertTrue(page.emptyState.isVisible())
|
||
|
||
manager.append('core one', CORE_LOG_CATEGORY)
|
||
self.assertRendered(page)
|
||
|
||
self.assertFalse(page.emptyState.isVisible())
|
||
|
||
page.searchLineEdit.setText('[missing')
|
||
self.assertRendered(page)
|
||
|
||
self.assertTrue(page.emptyState.isVisible())
|
||
self.assertEqual(page.plainText(), '')
|
||
self.assertTrue(page.searchLineEdit.toolTip())
|
||
|
||
QTest.mouseClick(
|
||
page.searchLineEdit.findChild(QToolButton),
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
)
|
||
self.assertRendered(page)
|
||
|
||
self.assertFalse(page.emptyState.isVisible())
|
||
self.assertEqual(page.searchLineEdit.text(), '')
|
||
self.assertFalse(page.searchLineEdit.toolTip())
|
||
self.assertEqual(page.filterComboBox.currentData(), CORE_LOG_CATEGORY)
|
||
self.assertEqual(page.plainText(), 'core one')
|
||
self.assertTrue(page.searchLineEdit.hasFocus())
|
||
|
||
page.filterComboBox.setFocus()
|
||
QTest.keyClick(page.filterComboBox, QtCore.Qt.Key.Key_Home)
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(page.filterComboBox.currentData(), ALL_LOGS_FILTER)
|
||
self.assertEqual(
|
||
AppSettings.get('LogViewerSelectedCategory'), ALL_LOGS_FILTER
|
||
)
|
||
self.assertEqual(
|
||
page.plainText().splitlines(), ['application one', 'core one']
|
||
)
|
||
|
||
def testNoMatchGuidanceTracksEvictionAndHiddenCatchUp(self):
|
||
"""Refresh guidance when the last match is evicted or arrives while hidden."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=2)
|
||
page = LogPage(manager=manager)
|
||
self.addCleanup(self.disposePage, page)
|
||
|
||
manager.append('match first')
|
||
page.searchLineEdit.setText('match')
|
||
|
||
page.show()
|
||
self.assertRendered(page)
|
||
|
||
self.assertFalse(page.emptyState.isVisible())
|
||
|
||
manager.append('other one')
|
||
manager.append('other two')
|
||
self.assertRendered(page)
|
||
|
||
self.assertTrue(page.emptyState.isVisible())
|
||
self.assertEqual(page.plainText(), '')
|
||
|
||
page.hide()
|
||
manager.append('match live')
|
||
processQtEvents()
|
||
|
||
page.show()
|
||
self.assertRendered(page)
|
||
|
||
self.assertFalse(page.emptyState.isVisible())
|
||
self.assertEqual(page.plainText(), 'match live')
|
||
|
||
def testSearchPrunesEvictedMatchingEntriesIncrementally(self):
|
||
"""Remove a matching rendered prefix when manager retention evicts it."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=3)
|
||
page = LogPage(manager=manager)
|
||
|
||
manager.append('match first')
|
||
manager.append('other')
|
||
manager.append('match second')
|
||
|
||
page.searchLineEdit.setText('match')
|
||
page.show()
|
||
self.assertRendered(page)
|
||
self.assertEqual(
|
||
page.plainText().splitlines(),
|
||
['match first', 'match second'],
|
||
)
|
||
|
||
manager.append('other replacement')
|
||
self.assertRendered(page)
|
||
self.assertEqual(page.plainText(), 'match second')
|
||
|
||
self.disposePage(page)
|
||
|
||
def testLargeHiddenBacklogDefersOneBulkDocumentCatchUp(self):
|
||
"""Return from show before rendering and avoid per-entry replay."""
|
||
with isolatedSettings():
|
||
entryCount = 5_000
|
||
manager = LogManager(maximumEntries=entryCount)
|
||
|
||
page = LogPage(manager=manager)
|
||
page.resize(900, 420)
|
||
|
||
for index in range(entryCount):
|
||
manager.append(f'bulk entry {index:05d}')
|
||
|
||
self.assertEqual(page.textBrowser.toPlainText(), '')
|
||
|
||
page.show()
|
||
|
||
# showEvent only schedules the catch-up; it does not synchronously
|
||
# build and highlight thousands of QTextDocument blocks.
|
||
self.assertTrue(page._entriesDirty)
|
||
self.assertEqual(page.textBrowser.toPlainText(), '')
|
||
self.assertTrue(page.highlightOverlay.isVisible())
|
||
self.assertTrue(page.highlightSpinner.is_spinning)
|
||
|
||
self.assertRendered(page)
|
||
|
||
self.assertFalse(page.highlightOverlay.isVisible())
|
||
self.assertFalse(page.highlightSpinner.is_spinning)
|
||
|
||
lines = page.textBrowser.toPlainText().splitlines()
|
||
|
||
self.assertEqual(len(lines), entryCount)
|
||
self.assertEqual(lines[0], 'bulk entry 00000')
|
||
self.assertEqual(lines[-1], 'bulk entry 04999')
|
||
self.assertEqual(page.textBrowser.document().blockCount(), entryCount)
|
||
|
||
self.disposePage(page)
|
||
|
||
def testFollowingTailSurvivesHideAndCatchUp(self):
|
||
"""Restore the newest entry after collecting while the page is hidden."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=500)
|
||
|
||
page = LogPage(manager=manager)
|
||
page.resize(800, 320)
|
||
|
||
for index in range(200):
|
||
manager.append(f'before hide {index:03d}')
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
scrollbar = page.textBrowser.verticalScrollBar()
|
||
|
||
self.assertGreater(scrollbar.maximum(), 0)
|
||
# Rendering completion schedules a separate zero-delay scroll update.
|
||
self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum()))
|
||
self.assertTrue(page._followTail)
|
||
|
||
page.hide()
|
||
|
||
for index in range(25):
|
||
manager.append(f'while hidden {index:03d}')
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum()))
|
||
|
||
self.assertTrue(page._followTail)
|
||
self.assertTrue(page.plainText().endswith('while hidden 024'))
|
||
|
||
self.disposePage(page)
|
||
|
||
def testNavigationDuringProcessingPreservesTailIntent(self):
|
||
"""Hide after document replacement, before its deferred tail restore."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=1000)
|
||
stack = QStackedWidget()
|
||
page = LogPage(manager=manager)
|
||
other = QWidget()
|
||
|
||
stack.addWidget(page)
|
||
stack.addWidget(other)
|
||
stack.resize(800, 320)
|
||
|
||
self.addCleanup(stack.deleteLater)
|
||
self.addCleanup(stack.close)
|
||
|
||
for index in range(300):
|
||
manager.append(f'processing {index:03d}')
|
||
|
||
# This connection runs after the real render callback, before the
|
||
# zero-delay scroll/highlight timers, exactly at the race boundary.
|
||
def navigateAway():
|
||
stack.setCurrentWidget(other)
|
||
|
||
page._updateTimer.timeout.connect(navigateAway)
|
||
stack.show()
|
||
|
||
self.assertTrue(waitFor(lambda: stack.currentWidget() is other))
|
||
|
||
page._updateTimer.timeout.disconnect(navigateAway)
|
||
|
||
self.assertIsNotNone(page._highlightNextBlock)
|
||
self.assertTrue(page._followTail)
|
||
|
||
manager.append('arrived while away')
|
||
stack.setCurrentWidget(page)
|
||
|
||
self.assertRendered(page)
|
||
|
||
scrollbar = page.textBrowser.verticalScrollBar()
|
||
|
||
self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum()))
|
||
self.assertTrue(page.plainText().endswith('arrived while away'))
|
||
|
||
manager.append('still following')
|
||
|
||
self.assertRendered(page)
|
||
self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum()))
|
||
|
||
def testManualScrollImmediatelyBeforeHidePreservesReadingIntent(self):
|
||
"""Capture pending user input, but never infer intent from navigation."""
|
||
with isolatedSettings():
|
||
page = LogPage(manager=LogManager(maximumEntries=500))
|
||
self.addCleanup(self.disposePage, page)
|
||
|
||
page.resize(800, 320)
|
||
|
||
for index in range(200):
|
||
page.manager.append(f'history {index}')
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
scrollbar = page.textBrowser.verticalScrollBar()
|
||
scrollbar.setFocus()
|
||
|
||
QTest.keyClick(scrollbar, QtCore.Qt.Key.Key_Home)
|
||
page.hide()
|
||
|
||
self.assertFalse(page._followTail)
|
||
|
||
page.manager.append('arrived while reading')
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
self.assertEqual(scrollbar.value(), 0)
|
||
self.assertFalse(page._followTail)
|
||
|
||
QTest.keyClick(scrollbar, QtCore.Qt.Key.Key_End)
|
||
|
||
self.assertTrue(waitFor(lambda: page._followTail))
|
||
|
||
def testFilteredProcessingResumesAfterUnmatchedHiddenUpdates(self):
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=1000)
|
||
page = LogPage(manager=manager)
|
||
self.addCleanup(self.disposePage, page)
|
||
|
||
page.resize(800, 320)
|
||
page.filterComboBox.setCurrentIndex(
|
||
page.filterComboBox.findData(CORE_LOG_CATEGORY)
|
||
)
|
||
|
||
for index in range(300):
|
||
manager.append(f'core {index}', CORE_LOG_CATEGORY)
|
||
|
||
page._updateTimer.timeout.connect(page.hide)
|
||
page.show()
|
||
|
||
self.assertTrue(waitFor(lambda: not page.isVisible()))
|
||
|
||
page._updateTimer.timeout.disconnect(page.hide)
|
||
|
||
self.assertIsNotNone(page._highlightNextBlock)
|
||
|
||
manager.append('unmatched update', APPLICATION_LOG_CATEGORY)
|
||
processQtEvents()
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
self.assertTrue(page.plainText().endswith('core 299'))
|
||
|
||
scrollbar = page.textBrowser.verticalScrollBar()
|
||
|
||
self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum()))
|
||
|
||
def testLogPreferencesDefaultOnAndPersistAcrossPageRecreation(self):
|
||
"""Restore both switch preferences without rewriting them at startup."""
|
||
with isolatedSettings():
|
||
firstManager = LogManager(maximumEntries=20)
|
||
firstPage = LogPage(manager=firstManager)
|
||
|
||
self.assertTrue(firstPage.autoScrollSwitch.isChecked())
|
||
self.assertTrue(firstPage.autoClearSwitch.isChecked())
|
||
self.assertTrue(AppSettings.isStateON_(LOG_AUTO_SCROLL_DOWN_SETTING))
|
||
self.assertTrue(AppSettings.isStateON_(LOG_AUTO_CLEAR_SETTING))
|
||
|
||
firstPage.autoScrollSwitch.setChecked(False)
|
||
firstPage.autoClearSwitch.setChecked(False)
|
||
|
||
self.assertFalse(AppSettings.isStateON_(LOG_AUTO_SCROLL_DOWN_SETTING))
|
||
self.assertFalse(AppSettings.isStateON_(LOG_AUTO_CLEAR_SETTING))
|
||
self.assertFalse(firstManager.autoClearEnabled)
|
||
|
||
self.disposePage(firstPage)
|
||
|
||
processQtEvents()
|
||
|
||
secondManager = LogManager(maximumEntries=20)
|
||
secondPage = LogPage(manager=secondManager)
|
||
|
||
self.assertFalse(secondPage.autoScrollSwitch.isChecked())
|
||
self.assertFalse(secondPage.autoClearSwitch.isChecked())
|
||
self.assertFalse(secondManager.autoClearEnabled)
|
||
|
||
secondPage.autoScrollSwitch.setChecked(True)
|
||
secondPage.autoClearSwitch.setChecked(True)
|
||
|
||
self.assertTrue(AppSettings.isStateON_(LOG_AUTO_SCROLL_DOWN_SETTING))
|
||
self.assertTrue(AppSettings.isStateON_(LOG_AUTO_CLEAR_SETTING))
|
||
self.assertTrue(secondManager.autoClearEnabled)
|
||
|
||
self.disposePage(secondPage)
|
||
|
||
def testAutoScrollPreferenceMastersTailFollowing(self):
|
||
"""Never move a disabled viewer and resume at the newest entry on enable."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=500)
|
||
|
||
page = LogPage(manager=manager)
|
||
page.resize(800, 320)
|
||
|
||
for index in range(200):
|
||
manager.append(f'initial {index:03d}')
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
scrollbar = page.textBrowser.verticalScrollBar()
|
||
page.autoScrollSwitch.setChecked(False)
|
||
scrollbar.setValue(0)
|
||
manager.append('must not move the viewport')
|
||
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(scrollbar.value(), 0)
|
||
self.assertFalse(page._followTail)
|
||
|
||
page.autoScrollSwitch.setChecked(True)
|
||
|
||
self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum()))
|
||
self.assertTrue(page._followTail)
|
||
|
||
page.hide()
|
||
manager.append('arrived while hidden')
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum()))
|
||
self.assertTrue(page.plainText().endswith('arrived while hidden'))
|
||
|
||
self.disposePage(page)
|
||
|
||
def testHiddenCoreClearRebuildsWithoutForcingScroll(self):
|
||
"""Invalidate hidden runtime state and honor disabled tail follow on show."""
|
||
with isolatedSettings():
|
||
manager = LogManager(
|
||
maximumEntries=50,
|
||
autoClearMaximumEntries=3,
|
||
)
|
||
|
||
page = LogPage(manager=manager)
|
||
page.resize(800, 260)
|
||
|
||
coreIndex = page.filterComboBox.findData(CORE_LOG_CATEGORY)
|
||
|
||
page.filterComboBox.setCurrentIndex(coreIndex)
|
||
page.autoScrollSwitch.setChecked(False)
|
||
|
||
for index in range(3):
|
||
manager.append(f'old core {index}', CORE_LOG_CATEGORY)
|
||
|
||
manager.append('old tun2socks', TUN2SOCKS_LOG_CATEGORY)
|
||
|
||
processQtEvents()
|
||
|
||
manager.append('new core after clear', CORE_LOG_CATEGORY)
|
||
manager.append('application retained', APPLICATION_LOG_CATEGORY)
|
||
|
||
self.assertEqual(page.plainText(), '')
|
||
self.assertEqual(
|
||
tuple(entry.message for entry in manager.entries(CORE_LOG_CATEGORY)),
|
||
('new core after clear',),
|
||
)
|
||
self.assertEqual(manager.entries(TUN2SOCKS_LOG_CATEGORY), tuple())
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(page.plainText(), 'new core after clear')
|
||
self.assertFalse(page._followTail)
|
||
self.assertEqual(
|
||
tuple(
|
||
entry.message for entry in manager.entries(APPLICATION_LOG_CATEGORY)
|
||
),
|
||
('application retained',),
|
||
)
|
||
|
||
self.disposePage(page)
|
||
|
||
def testManualHistoryReadingDisablesAndThenResumesTail(self):
|
||
"""Do not yank an upward-scrolled viewport until it returns to bottom."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=500)
|
||
|
||
page = LogPage(manager=manager)
|
||
page.resize(800, 320)
|
||
|
||
for index in range(200):
|
||
manager.append(f'history {index:03d}')
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
scrollbar = page.textBrowser.verticalScrollBar()
|
||
scrollbar.setValue(0)
|
||
|
||
page._updateFollowTailFromScrollbar()
|
||
|
||
self.assertFalse(page._followTail)
|
||
|
||
manager.append('arrived while reading')
|
||
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(scrollbar.value(), 0)
|
||
self.assertLess(scrollbar.value(), scrollbar.maximum())
|
||
|
||
scrollbar.setValue(scrollbar.maximum())
|
||
|
||
page._updateFollowTailFromScrollbar()
|
||
|
||
self.assertTrue(page._followTail)
|
||
|
||
manager.append('tail resumed')
|
||
|
||
self.assertRendered(page)
|
||
self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum()))
|
||
self.assertTrue(page.plainText().endswith('tail resumed'))
|
||
|
||
self.disposePage(page)
|
||
|
||
def testFilteredTailCatchUpAndPruningRemainExact(self):
|
||
"""Keep filtered tail semantics and discard pruned filtered entries."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=40)
|
||
|
||
page = LogPage(manager=manager)
|
||
page.resize(800, 260)
|
||
|
||
for index in range(30):
|
||
manager.append(f'application {index:03d}', APPLICATION_LOG_CATEGORY)
|
||
manager.append(f'core {index:03d}', CORE_LOG_CATEGORY)
|
||
|
||
coreIndex = page.filterComboBox.findData(CORE_LOG_CATEGORY)
|
||
|
||
page.filterComboBox.setCurrentIndex(coreIndex)
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
scrollbar = page.textBrowser.verticalScrollBar()
|
||
|
||
self.assertTrue(page.plainText().endswith('core 029'))
|
||
self.assertEqual(scrollbar.value(), scrollbar.maximum())
|
||
|
||
page.hide()
|
||
|
||
for index in range(45):
|
||
manager.append(
|
||
f'new application {index:03d}',
|
||
APPLICATION_LOG_CATEGORY,
|
||
)
|
||
|
||
manager.append('new core tail', CORE_LOG_CATEGORY)
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum()))
|
||
|
||
self.assertEqual(page.plainText(), 'new core tail')
|
||
self.assertTrue(page._followTail)
|
||
|
||
self.disposePage(page)
|
||
|
||
def testIncrementalBatchesRehighlightChangedBoundaryBlocks(self):
|
||
"""Color paragraph boundaries invalidated by append and retention edits."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=24)
|
||
page = LogPage(manager=manager)
|
||
|
||
def appendEntries(start, count):
|
||
"""Append deterministic lines matched by several log rules."""
|
||
for index in range(start, start + count):
|
||
manager.append(
|
||
'2026/08/17 18:45:'
|
||
f'{index % 60:02d}.000000 from '
|
||
f'127.0.0.1:{5000 + index} accepted '
|
||
'//example.com:443 [http >> proxy]'
|
||
)
|
||
|
||
def missingFormats():
|
||
"""Return blocks that fell back to the default text format."""
|
||
document = page.textBrowser.document()
|
||
|
||
return tuple(
|
||
blockNumber
|
||
for blockNumber in range(document.blockCount())
|
||
if not document.findBlockByNumber(blockNumber).layout().formats()
|
||
)
|
||
|
||
appendEntries(0, 20)
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
appendEntries(20, 3)
|
||
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(missingFormats(), ())
|
||
|
||
# This batch prunes four old entries as it appends five new ones,
|
||
# exercising both the new first and previous last block boundaries.
|
||
appendEntries(23, 5)
|
||
|
||
self.assertRendered(page)
|
||
|
||
self.assertEqual(page.textBrowser.document().blockCount(), 24)
|
||
self.assertEqual(missingFormats(), ())
|
||
|
||
self.disposePage(page)
|
||
|
||
def testRepeatedVisibilityCyclesReuseTimersWithoutDuplicateEntries(self):
|
||
"""Keep one owned render pipeline stable through repeated page visits."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=500)
|
||
page = LogPage(manager=manager)
|
||
timers = (
|
||
page._updateTimer,
|
||
page._highlightTimer,
|
||
page._scrollTimer,
|
||
page._followStateTimer,
|
||
)
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
|
||
expected = []
|
||
|
||
for index in range(30):
|
||
page.hide()
|
||
|
||
line = f'visibility cycle {index:02d}'
|
||
|
||
expected.append(line)
|
||
manager.append(line)
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page, highlighting=False)
|
||
|
||
self.assertEqual(page.plainText().splitlines(), expected)
|
||
self.assertEqual(
|
||
(
|
||
page._updateTimer,
|
||
page._highlightTimer,
|
||
page._scrollTimer,
|
||
page._followStateTimer,
|
||
),
|
||
timers,
|
||
)
|
||
|
||
self.disposePage(page)
|
||
|
||
def testPageDestructionReleasesOwnedRenderTimers(self):
|
||
"""Do not retain the page or its persistent timers after destruction."""
|
||
with isolatedSettings():
|
||
manager = LogManager(maximumEntries=20)
|
||
page = LogPage(manager=manager)
|
||
references = tuple(
|
||
weakref.ref(value)
|
||
for value in (
|
||
page,
|
||
page._updateTimer,
|
||
page._highlightTimer,
|
||
page._scrollTimer,
|
||
page._followStateTimer,
|
||
page.autoScrollSwitch,
|
||
page.autoClearSwitch,
|
||
page.highlightOverlay,
|
||
page.highlightSpinner,
|
||
page.highlightStatusLabel,
|
||
)
|
||
)
|
||
|
||
page.show()
|
||
|
||
self.assertRendered(page)
|
||
self.disposePage(page)
|
||
|
||
del page
|
||
|
||
collectAtBoundary()
|
||
|
||
self.assertTrue(all(reference() is None for reference in references))
|
||
|
||
|
||
class RoutingChangeNoticeTest(unittest.TestCase):
|
||
"""Report only saved changes to the connected custom routing profile."""
|
||
|
||
def setUp(self):
|
||
app = application()
|
||
self.addCleanup(collectAtBoundary)
|
||
self.enterContext(isolatedSettings())
|
||
|
||
self.routings = {
|
||
unique: {
|
||
'remark': 'Same display name',
|
||
'domainStrategy': 'AsIs',
|
||
'enabled': True,
|
||
'rules': [
|
||
{'domain': ['a.test'], 'outboundTag': 'proxy'},
|
||
{'domain': ['b.test'], 'outboundTag': 'direct'},
|
||
],
|
||
}
|
||
for unique in ('active', 'other')
|
||
}
|
||
|
||
self.connection = mock.Mock()
|
||
self.connection.isConnected.return_value = True
|
||
self.routing = mock.Mock(routing='Custom:active')
|
||
|
||
self.enterContext(
|
||
mock.patch.object(app, 'connectionController', self.connection)
|
||
)
|
||
self.enterContext(mock.patch.object(app, 'routingController', self.routing))
|
||
self.enterContext(
|
||
mock.patch.object(Storage, 'UserRoutings', return_value=self.routings)
|
||
)
|
||
self.notice = self.enterContext(
|
||
mock.patch('Furious.Backends.Xray.RoutingWindow.showMBoxNewChangesNextTime')
|
||
)
|
||
|
||
self.view = UserRoutingTableView()
|
||
self.addCleanup(self.disposeView)
|
||
|
||
def disposeView(self):
|
||
from shiboken6 import isValid
|
||
|
||
if isValid(self.view):
|
||
self.view.deleteLater()
|
||
|
||
def openRules(self, row=0):
|
||
self.view.selectRow(row)
|
||
self.view.editSelectedRules()
|
||
return self.view.findChild(RoutingRulesDialog)
|
||
|
||
def testInlineChangesOnlyNotifyForConnectedSelectedRouting(self):
|
||
"""Real combo signals distinguish routing IDs and ignore no-op refreshes."""
|
||
with mock.patch('sys.excepthook') as exceptionHook:
|
||
self.view.flushAll()
|
||
self.view.setDomainStrategy(0, 'AsIs')
|
||
self.view.setEnabled(0, 'Enabled')
|
||
|
||
self.notice.assert_not_called()
|
||
|
||
strategy = self.view.indexWidget(self.view.sourceModel.index(0, 1))
|
||
strategy.setCurrentText('IPOnDemand')
|
||
|
||
self.assertEqual(self.routings['active']['domainStrategy'], 'IPOnDemand')
|
||
self.notice.assert_called_once_with(parent=self.view)
|
||
|
||
self.notice.reset_mock()
|
||
|
||
enabled = self.view.indexWidget(self.view.sourceModel.index(0, 2))
|
||
enabled.setCurrentIndex(1)
|
||
|
||
self.assertFalse(self.routings['active']['enabled'])
|
||
self.notice.assert_called_once_with(parent=self.view)
|
||
|
||
self.notice.reset_mock()
|
||
|
||
self.view.setDomainStrategy(1, 'IPIfNonMatch')
|
||
self.view.setEnabled(1, 'Disabled')
|
||
|
||
self.connection.isConnected.return_value = False
|
||
self.view.setDomainStrategy(0, 'AsIs')
|
||
self.view.setEnabled(0, 'Enabled')
|
||
|
||
self.connection.isConnected.return_value = True
|
||
self.routing.routing = 'Global'
|
||
self.view.setDomainStrategy(0, 'IPIfNonMatch')
|
||
|
||
self.notice.assert_not_called()
|
||
|
||
processQtEvents()
|
||
|
||
exceptionHook.assert_not_called()
|
||
|
||
def testRuleChangesNotifyOnCloseWithoutRetargetingSelection(self):
|
||
"""Closing commits live edits and identifies the edited route by ID."""
|
||
for change in ('add', 'edit', 'delete', 'move'):
|
||
with self.subTest(change=change), mock.patch(
|
||
'sys.excepthook'
|
||
) as exceptionHook:
|
||
dialog = self.openRules()
|
||
view = dialog.listView
|
||
|
||
if change == 'add':
|
||
view.appendRule({'domain': ['new.test'], 'outboundTag': 'proxy'})
|
||
elif change == 'edit':
|
||
view.setRule(0, {'domain': ['edited.test'], 'outboundTag': 'proxy'})
|
||
elif change == 'delete':
|
||
view.deleteRules([0])
|
||
else:
|
||
view.setCurrentIndex(view.rulesModel.index(0, 0))
|
||
view.moveSelectedRules('down')
|
||
|
||
self.view.selectRow(1)
|
||
self.notice.assert_not_called()
|
||
|
||
dialog.closeWindowButton.click()
|
||
processQtEvents()
|
||
|
||
self.notice.assert_called_once_with(parent=self.view)
|
||
exceptionHook.assert_not_called()
|
||
|
||
self.notice.reset_mock()
|
||
|
||
def testRuleNoticeSkipsUnchangedRevertedStaleAndInactiveEdits(self):
|
||
"""An open editor does not keep authority over a replaced route or connection."""
|
||
for case in (
|
||
'unchanged',
|
||
'reverted',
|
||
'disconnected',
|
||
'switched',
|
||
'replaced',
|
||
'other',
|
||
):
|
||
with self.subTest(case=case), mock.patch('sys.excepthook') as exceptionHook:
|
||
dialog = self.openRules(1 if case == 'other' else 0)
|
||
|
||
if case != 'unchanged':
|
||
dialog.listView.appendRule(
|
||
{'domain': ['new.test'], 'outboundTag': 'proxy'}
|
||
)
|
||
|
||
if case == 'reverted':
|
||
dialog.listView.deleteRules([len(dialog.listView.rules()) - 1])
|
||
elif case == 'disconnected':
|
||
self.connection.isConnected.return_value = False
|
||
elif case == 'switched':
|
||
self.routing.routing = 'Custom:other'
|
||
elif case == 'replaced':
|
||
self.routings['active'] = copy.deepcopy(self.routings['active'])
|
||
|
||
dialog.reject()
|
||
processQtEvents()
|
||
|
||
self.notice.assert_not_called()
|
||
exceptionHook.assert_not_called()
|
||
|
||
self.connection.isConnected.return_value = True
|
||
self.routing.routing = 'Custom:active'
|
||
|
||
def testExistingNoticeReconnectsOnlyWhenAcceptedAndDiesWithOwner(self):
|
||
"""Reuse the real asynchronous prompt without retaining the rules editor."""
|
||
from Furious.Qt.QtWidgets import (
|
||
MBoxNewChangesNextTime,
|
||
showMBoxNewChangesNextTime,
|
||
)
|
||
from shiboken6 import delete as deleteQObject, isValid
|
||
|
||
self.notice.side_effect = showMBoxNewChangesNextTime
|
||
|
||
for button in (
|
||
AppQMessageBox.StandardButton.No,
|
||
AppQMessageBox.StandardButton.Yes,
|
||
):
|
||
dialog = self.openRules()
|
||
dialog.listView.appendRule({'domain': ['new.test'], 'outboundTag': 'proxy'})
|
||
|
||
dialog.closeWindowButton.click()
|
||
processQtEvents()
|
||
|
||
prompt = self.view.findChild(MBoxNewChangesNextTime)
|
||
|
||
self.assertIsNotNone(prompt)
|
||
self.assertTrue(prompt.isVisible())
|
||
self.assertFalse(isValid(dialog))
|
||
self.assertIn(prompt, AppQDialog._openDialogs.values())
|
||
|
||
prompt.button(button).click()
|
||
processQtEvents()
|
||
|
||
self.assertFalse(isValid(prompt))
|
||
self.assertNotIn(prompt, AppQDialog._openDialogs.values())
|
||
|
||
if button == AppQMessageBox.StandardButton.No:
|
||
self.connection.startReconnection.assert_not_called()
|
||
else:
|
||
self.connection.startReconnection.assert_called_once_with()
|
||
|
||
self.view.setDomainStrategy(0, 'IPOnDemand')
|
||
prompt = self.view.findChild(MBoxNewChangesNextTime)
|
||
|
||
deleteQObject(self.view)
|
||
processQtEvents()
|
||
|
||
self.assertFalse(isValid(prompt))
|
||
self.assertNotIn(prompt, AppQDialog._openDialogs.values())
|
||
|
||
|
||
class DialogBehaviorTest(unittest.TestCase):
|
||
"""Exercise no-selection guards and QMessageBox-compatible results."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Finish every deferred transient deletion between tests."""
|
||
collectAtBoundary()
|
||
|
||
def testThemeFallbackWorksBeforeConnectionControllerExists(self):
|
||
"""Allow startup error dialogs to use the disconnected theme safely."""
|
||
with mock.patch(
|
||
'Furious.Qt.DynamicTheme.AppConnectionController',
|
||
return_value=None,
|
||
):
|
||
self.assertEqual(AppHue.currentColor(), AppHue.disconnectedColor())
|
||
self.assertEqual(
|
||
AppHue.currentWindowIcon().iconFileName,
|
||
':/Icons/bootstrap/rocket-takeoff-window.svg',
|
||
)
|
||
|
||
def testRoutingRulesActionsStayEnabledAndNoSelectionIsSafe(self):
|
||
"""Keep the compact top actions visible without creating a warning."""
|
||
dialog = RoutingRulesDialog({'rules': []})
|
||
|
||
self.assertTrue(dialog.addButton.isEnabled())
|
||
self.assertTrue(dialog.deleteButton.isEnabled())
|
||
self.assertTrue(dialog.moveButton.isEnabled())
|
||
self.assertTrue(dialog.closeWindowButton.isEnabled())
|
||
self.assertIsNotNone(dialog.layout().itemAt(0).layout())
|
||
self.assertIs(dialog.layout().itemAt(1).widget(), dialog.listView)
|
||
self.assertIs(dialog.listView.model(), dialog.listView.rulesModel)
|
||
self.assertIs(dialog.listView.rulesModel.parent(), dialog.listView)
|
||
self.assertEqual(dialog.listView.rulesModel.rowCount(), 0)
|
||
|
||
dialog.deleteRule()
|
||
dialog.editRule()
|
||
|
||
for action in dialog.moveMenu.actions():
|
||
action.trigger()
|
||
|
||
self.assertEqual(AppQDialog._openDialogs, {})
|
||
self.assertEqual(dialog.routing['rules'], [])
|
||
|
||
dialog.closeWindowButton.click()
|
||
|
||
def testRuleMovesPreserveIdentitySelectionAndPersistence(self):
|
||
"""Keep identical labels distinct through moves, edits and serialization."""
|
||
with isolatedSettings():
|
||
repository = UserRoutings()
|
||
routing = {
|
||
'rules': [
|
||
{'ruleTag': 'same', 'domain': [domain], 'outboundTag': 'proxy'}
|
||
for domain in ('a.test', 'b.test', 'c.test', 'd.test')
|
||
]
|
||
}
|
||
|
||
repository.data()['ordered'] = routing
|
||
|
||
original = list(routing['rules'])
|
||
|
||
dialog = RoutingRulesDialog(routing)
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
|
||
view = dialog.listView
|
||
model = view.rulesModel
|
||
|
||
tester = QAbstractItemModelTester(
|
||
model, QAbstractItemModelTester.FailureReportingMode.Warning
|
||
)
|
||
moved = QSignalSpy(model.rowsMoved)
|
||
resets = QSignalSpy(model.modelReset)
|
||
|
||
view.setCurrentIndex(model.index(1, 0))
|
||
selected = QtCore.QPersistentModelIndex(model.index(1, 0))
|
||
removed = QtCore.QPersistentModelIndex(model.index(2, 0))
|
||
root = QtCore.QModelIndex()
|
||
|
||
self.assertTrue(model.moveRows(root, 1, 2, root, 4))
|
||
|
||
self.assertEqual(routing['rules'], [original[i] for i in (0, 3, 1, 2)])
|
||
self.assertEqual(selected.row(), 2)
|
||
self.assertEqual(view.currentIndex().row(), 2)
|
||
self.assertEqual(view.selectedIndex, [2])
|
||
|
||
dialog.moveMenu.actions()[0].trigger()
|
||
dialog.moveMenu.actions()[0].trigger()
|
||
|
||
self.assertIs(routing['rules'][0], original[1])
|
||
self.assertEqual(view.currentIndex().row(), 0)
|
||
self.assertEqual(moved.count(), 3)
|
||
self.assertEqual(resets.count(), 0)
|
||
|
||
edited = dict(original[1], outboundTag='direct')
|
||
|
||
view.setRule(selected.row(), edited)
|
||
view.deleteRules([removed.row()])
|
||
|
||
self.assertFalse(removed.isValid())
|
||
self.assertIs(routing['rules'][0], edited)
|
||
self.assertEqual(model.data(model.index(0, 0)), view.ruleText(edited))
|
||
|
||
repository.sync()
|
||
|
||
restored = UserRoutings().data()['ordered']
|
||
|
||
self.assertEqual(restored, routing)
|
||
|
||
exported = routingObjectFromProfile(restored)
|
||
|
||
self.assertEqual(
|
||
[rule['domain'] for rule in exported['rules']],
|
||
[['b.test'], ['a.test'], ['d.test']],
|
||
)
|
||
|
||
with mock.patch.object(
|
||
Storage, 'UserRoutings', return_value={'ordered': restored}
|
||
):
|
||
self.assertEqual(
|
||
customRoutingObjectFromSettings('Custom:ordered'), exported
|
||
)
|
||
|
||
self.assertIsNotNone(tester)
|
||
|
||
def testRuleDescriptionsPreviewEveryFilledDestinationField(self):
|
||
rules = [
|
||
{'ruleTag': 'direct', 'outboundTag': 'direct', 'ip': ['1.14.96.57']},
|
||
{
|
||
'domain': ['geosite:cn', 'domain:example.test', 'full:example.org'],
|
||
'ip': ['geoip:private'],
|
||
'port': '53,443',
|
||
'vlessRoute': '100-200',
|
||
},
|
||
{'port': '443'},
|
||
{'vlessRoute': '0'},
|
||
{'domain': [], 'ip': [], 'port': ' ', 'sourceIP': ['10.0.0.1']},
|
||
]
|
||
|
||
dialog = RoutingRulesDialog({'rules': rules})
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
|
||
model = dialog.listView.rulesModel
|
||
|
||
self.assertEqual(
|
||
[model.data(model.index(row, 0)) for row in range(model.rowCount())],
|
||
[
|
||
'direct -> direct (ip: 1.14.96.57)',
|
||
'Untitled Rule -> proxy (domain: geosite:cn +2; ip: geoip:private; '
|
||
'port: 53,443; vless route: 100-200)',
|
||
'Untitled Rule -> proxy (port: 443)',
|
||
'Untitled Rule -> proxy (vless route: 0)',
|
||
'Untitled Rule -> proxy',
|
||
],
|
||
)
|
||
|
||
def testRuleDescriptionBudgetDoesNotHideOtherDestinationFieldsOrMutateRules(self):
|
||
rule = {
|
||
'domain': ['regexp:' + '域' * 200, 'domain:second.test'],
|
||
'ip': ['2001:db8:' + 'abcd:' * 100],
|
||
'port': '443,\n' * 100,
|
||
'vlessRoute': '100-200,' * 100,
|
||
}
|
||
|
||
original = copy.deepcopy(rule)
|
||
|
||
dialog = RoutingRulesDialog({'rules': [rule]})
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
|
||
view = dialog.listView
|
||
model = view.rulesModel
|
||
|
||
text = model.data(model.index(0, 0))
|
||
summary = text.split(' (', 1)[1][:-1]
|
||
|
||
self.assertLessEqual(len(summary), 96)
|
||
|
||
for label in ('domain:', 'ip:', 'port:', 'vless route:'):
|
||
self.assertIn(label, summary)
|
||
|
||
self.assertEqual(summary.count('…'), 4)
|
||
self.assertIn('+1', summary)
|
||
self.assertNotIn('\n', summary)
|
||
self.assertEqual(rule, original)
|
||
|
||
self.assertEqual(view.textElideMode(), QtCore.Qt.TextElideMode.ElideRight)
|
||
self.assertEqual(
|
||
view.horizontalScrollBarPolicy(),
|
||
QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff,
|
||
)
|
||
|
||
changed = QSignalSpy(model.dataChanged)
|
||
|
||
view.setRule(0, {'port': '80'})
|
||
|
||
self.assertEqual(changed.count(), 1)
|
||
self.assertEqual(
|
||
model.data(model.index(0, 0)), 'Untitled Rule -> proxy (port: 80)'
|
||
)
|
||
|
||
def testRuleModelRejectsInvalidMovesWithoutMutation(self):
|
||
dialog = RoutingRulesDialog({'rules': [{'ruleTag': str(i)} for i in range(4)]})
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
|
||
model = dialog.listView.rulesModel
|
||
before = list(model.rules())
|
||
root = QtCore.QModelIndex()
|
||
moved = QSignalSpy(model.rowsMoved)
|
||
|
||
for source, count, destination in (
|
||
(-1, 1, 2),
|
||
(0, 0, 2),
|
||
(3, 2, 0),
|
||
(0, 1, 5),
|
||
(1, 2, 2),
|
||
(1, 2, 3),
|
||
):
|
||
self.assertFalse(model.moveRows(root, source, count, root, destination))
|
||
|
||
self.assertFalse(model.moveRows(model.index(0, 0), 0, 1, root, 4))
|
||
|
||
self.assertEqual(model.rules(), before)
|
||
self.assertEqual(moved.count(), 0)
|
||
|
||
self.assertEqual(
|
||
dialog.listView.dragDropMode(), dialog.listView.DragDropMode.InternalMove
|
||
)
|
||
self.assertTrue(dialog.listView.dragEnabled())
|
||
self.assertTrue(dialog.listView.acceptDrops())
|
||
self.assertTrue(dialog.listView.showDropIndicator())
|
||
self.assertFalse(dialog.listView.dragDropOverwriteMode())
|
||
|
||
self.assertEqual(model.supportedDropActions(), QtCore.Qt.DropAction.MoveAction)
|
||
self.assertEqual(model.supportedDragActions(), QtCore.Qt.DropAction.MoveAction)
|
||
|
||
self.assertTrue(
|
||
model.flags(model.index(0, 0)) & QtCore.Qt.ItemFlag.ItemIsDragEnabled
|
||
)
|
||
self.assertFalse(
|
||
model.flags(model.index(0, 0)) & QtCore.Qt.ItemFlag.ItemIsDropEnabled
|
||
)
|
||
self.assertTrue(model.flags(root) & QtCore.Qt.ItemFlag.ItemIsDropEnabled)
|
||
|
||
def testRuleListRejectsExternalDragsEvenWithMatchingMimeType(self):
|
||
from PySide6.QtGui import QDragEnterEvent, QDropEvent
|
||
|
||
dialog = RoutingRulesDialog({'rules': [{'ruleTag': 'keep'}]})
|
||
other = RoutingRulesDialog({'rules': [{'ruleTag': 'other'}]})
|
||
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
self.addCleanup(other.deleteLater)
|
||
self.addCleanup(other.close)
|
||
|
||
dialog.show()
|
||
processQtEvents()
|
||
|
||
view = dialog.listView
|
||
otherModel = other.listView.rulesModel
|
||
mime = otherModel.mimeData([otherModel.index(0, 0)])
|
||
self.addCleanup(mime.deleteLater)
|
||
|
||
self.assertTrue(mime.hasFormat(view.rulesModel.mimeTypes()[0]))
|
||
|
||
point = view.visualRect(view.rulesModel.index(0, 0)).bottomLeft()
|
||
|
||
for action in (
|
||
QtCore.Qt.DropAction.MoveAction,
|
||
QtCore.Qt.DropAction.CopyAction,
|
||
):
|
||
enter = QDragEnterEvent(
|
||
point,
|
||
action,
|
||
mime,
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
QtCore.Qt.KeyboardModifier.NoModifier,
|
||
)
|
||
|
||
application().sendEvent(view.viewport(), enter)
|
||
|
||
self.assertFalse(enter.isAccepted())
|
||
|
||
drop = QDropEvent(
|
||
QtCore.QPointF(point),
|
||
action,
|
||
mime,
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
QtCore.Qt.KeyboardModifier.NoModifier,
|
||
)
|
||
|
||
application().sendEvent(view.viewport(), drop)
|
||
|
||
self.assertFalse(drop.isAccepted())
|
||
|
||
self.assertEqual(view.rules(), [{'ruleTag': 'keep'}])
|
||
self.assertEqual(otherModel.rules(), [{'ruleTag': 'other'}])
|
||
|
||
def testRuleMoveMenuPreservesMultiSelectionOrderAndCurrentRule(self):
|
||
expectedOrders = {
|
||
'up': [1, 2, 0, 4, 3, 5],
|
||
'down': [0, 3, 1, 2, 5, 4],
|
||
}
|
||
|
||
for position, order in expectedOrders.items():
|
||
with self.subTest(position=position):
|
||
# Equal labels and values must not collapse distinct rule rows.
|
||
rules = [{'ruleTag': 'same'} for _ in range(6)]
|
||
original = list(rules)
|
||
|
||
dialog = RoutingRulesDialog({'rules': rules})
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
|
||
view = dialog.listView
|
||
model = view.rulesModel
|
||
selection = view.selectionModel()
|
||
|
||
for row in (1, 2, 4):
|
||
selection.select(
|
||
model.index(row, 0),
|
||
QtCore.QItemSelectionModel.SelectionFlag.Select,
|
||
)
|
||
|
||
selection.setCurrentIndex(
|
||
model.index(2, 0), QtCore.QItemSelectionModel.SelectionFlag.NoUpdate
|
||
)
|
||
|
||
reset = QSignalSpy(model.modelReset)
|
||
actions = {
|
||
action.data(): action for action in dialog.moveMenu.actions()
|
||
}
|
||
|
||
self.assertIs(dialog.moveButton.popupMenu(), dialog.moveMenu)
|
||
self.assertEqual(list(actions), ['up', 'down'])
|
||
|
||
actions[position].trigger()
|
||
|
||
self.assertEqual(
|
||
[id(rule) for rule in rules], [id(original[i]) for i in order]
|
||
)
|
||
self.assertEqual(
|
||
view.selectedIndex, sorted(order.index(i) for i in (1, 2, 4))
|
||
)
|
||
self.assertIs(rules[view.currentIndex().row()], original[2])
|
||
self.assertEqual(reset.count(), 0)
|
||
|
||
def testRuleMovesStopAtBoundariesWithoutCrossingSelectedRows(self):
|
||
for position, order in (
|
||
('up', [0, 2, 1, 3, 5, 4]),
|
||
('down', [1, 0, 3, 2, 4, 5]),
|
||
):
|
||
with self.subTest(position=position):
|
||
rules = [{'ruleTag': str(i)} for i in range(6)]
|
||
original = list(rules)
|
||
|
||
dialog = RoutingRulesDialog({'rules': rules})
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
|
||
view = dialog.listView
|
||
|
||
for row in (0, 2, 5):
|
||
view.selectionModel().select(
|
||
view.rulesModel.index(row, 0),
|
||
QtCore.QItemSelectionModel.SelectionFlag.Select,
|
||
)
|
||
|
||
view.moveSelectedRules(position)
|
||
|
||
self.assertEqual(rules, [original[i] for i in order])
|
||
|
||
for _iteration in range(8):
|
||
view.moveSelectedRules(position)
|
||
|
||
final = [0, 2, 5, 1, 3, 4] if position == 'up' else [1, 3, 4, 0, 2, 5]
|
||
|
||
self.assertEqual(rules, [original[i] for i in final])
|
||
|
||
moved = QSignalSpy(view.rulesModel.rowsMoved)
|
||
|
||
view.moveSelectedRules(position)
|
||
|
||
self.assertEqual(moved.count(), 0)
|
||
|
||
view.selectAll()
|
||
for direction in ('up', 'down'):
|
||
view.moveSelectedRules(direction)
|
||
|
||
self.assertEqual(moved.count(), 0)
|
||
|
||
def testRuleMoveButtonAndShortcutsUseTheSameActions(self):
|
||
dialog = RoutingRulesDialog({'rules': [{'ruleTag': str(i)} for i in range(4)]})
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
|
||
view = dialog.listView
|
||
|
||
dialog.show()
|
||
dialog.activateWindow()
|
||
view.setCurrentIndex(view.rulesModel.index(2, 0))
|
||
view.setFocus()
|
||
processQtEvents()
|
||
|
||
for _iteration in range(2):
|
||
QTest.keyClick(
|
||
view, QtCore.Qt.Key.Key_Up, QtCore.Qt.KeyboardModifier.ControlModifier
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(
|
||
[rule['ruleTag'] for rule in view.rules()], ['2', '0', '1', '3']
|
||
)
|
||
self.assertEqual(view.selectedIndex, [0])
|
||
self.assertTrue(view.hasFocus())
|
||
|
||
QTest.keyClick(
|
||
view, QtCore.Qt.Key.Key_Down, QtCore.Qt.KeyboardModifier.ControlModifier
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(view.selectedIndex, [1])
|
||
|
||
dialog.addButton.setFocus()
|
||
processQtEvents()
|
||
|
||
QTest.keyClick(
|
||
dialog.addButton,
|
||
QtCore.Qt.Key.Key_Down,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
|
||
self.assertEqual(view.selectedIndex, [1])
|
||
|
||
QTest.mouseClick(dialog.moveButton, QtCore.Qt.MouseButton.LeftButton)
|
||
processQtEvents()
|
||
|
||
self.assertTrue(dialog.moveMenu.isVisible())
|
||
|
||
dialog.moveMenu.setActiveAction(dialog.moveMenu.actions()[-1])
|
||
QTest.keyClick(dialog.moveMenu, QtCore.Qt.Key.Key_Return)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(
|
||
[rule['ruleTag'] for rule in view.rules()], ['0', '1', '2', '3']
|
||
)
|
||
self.assertEqual(view.selectedIndex, [2])
|
||
dialog.activateWindow()
|
||
self.assertTrue(waitFor(view.hasFocus))
|
||
|
||
def testHomeAndRoutingMoveMenusRetranslateWithoutChangingActions(self):
|
||
with isolatedSettings():
|
||
AppSettings.set('Language', 'EN')
|
||
|
||
table = ServerTableView(
|
||
configurationEditorFactory=QWidget,
|
||
qrCodeWindowFactory=QWidget,
|
||
importActionsFactory=tuple,
|
||
)
|
||
dialog = RoutingRulesDialog({'rules': [{'ruleTag': 'user label'}]})
|
||
|
||
self.addCleanup(table.deleteLater)
|
||
self.addCleanup(table.close)
|
||
self.addCleanup(table.cleanup)
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
|
||
homeActions = table.moveMenu.actions()
|
||
ruleActions = dialog.moveMenu.actions()
|
||
homeLabels = ['Move To Top', 'Move Up', 'Move Down', 'Move To Bottom']
|
||
|
||
self.assertEqual([action.text() for action in homeActions], homeLabels)
|
||
self.assertEqual(
|
||
[action.text() for action in ruleActions], ['Move Up', 'Move Down']
|
||
)
|
||
|
||
for language in ('ZH', 'RU', 'EN'):
|
||
AppSettings.set('Language', language)
|
||
|
||
for action in homeActions + ruleActions:
|
||
action.retranslate()
|
||
dialog.moveButton.retranslate()
|
||
|
||
self.assertEqual(
|
||
[action.text() for action in homeActions],
|
||
[_(label, language) for label in homeLabels],
|
||
)
|
||
self.assertEqual(
|
||
[action.text() for action in ruleActions],
|
||
[_('Move Up', language), _('Move Down', language)],
|
||
)
|
||
self.assertEqual(dialog.moveButton.text(), _('Move', language))
|
||
self.assertEqual(
|
||
[action.data() for action in ruleActions], ['up', 'down']
|
||
)
|
||
self.assertEqual(dialog.listView.rules(), [{'ruleTag': 'user label'}])
|
||
|
||
def testOpenRuleEditorFollowsMovedRuleAndIgnoresRemovedRule(self):
|
||
dialog = RoutingRulesDialog(
|
||
{'rules': [{'ruleTag': 'first'}, {'ruleTag': 'second'}]}
|
||
)
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.addCleanup(dialog.close)
|
||
|
||
dialog.show()
|
||
|
||
view = dialog.listView
|
||
model = view.rulesModel
|
||
|
||
view.setCurrentIndex(model.index(0, 0))
|
||
dialog.editRule()
|
||
|
||
editor = dialog.findChild(RoutingRuleEditDialog)
|
||
editor.ruleTagEdit.setText('edited first')
|
||
|
||
self.assertTrue(model.moveRow(QtCore.QModelIndex(), 0, QtCore.QModelIndex(), 2))
|
||
|
||
editor.accept()
|
||
processQtEvents()
|
||
|
||
self.assertEqual(
|
||
[rule['ruleTag'] for rule in model.rules()], ['second', 'edited first']
|
||
)
|
||
|
||
dialog.editRule()
|
||
|
||
editor = dialog.findChild(RoutingRuleEditDialog)
|
||
view.deleteRules([view.currentIndex().row()])
|
||
|
||
editor.accept()
|
||
processQtEvents()
|
||
|
||
self.assertEqual([rule['ruleTag'] for rule in model.rules()], ['second'])
|
||
|
||
def testRoutingRuleGuidanceUsesPlaceholdersInsteadOfLabels(self):
|
||
"""Keep routing field names compact while retaining input guidance."""
|
||
dialog = RoutingRuleEditDialog({'type': 'field'})
|
||
|
||
self.assertEqual(dialog.ruleTagEdit.text(), '')
|
||
self.assertEqual(dialog.outboundEdit.text(), '')
|
||
|
||
placeholders = (
|
||
(dialog.ruleTagEdit, 'Optional rule name, e.g. block-ads'),
|
||
(dialog.outboundEdit, 'Outbound tag, e.g. proxy, direct, block'),
|
||
(dialog.balancerTagEdit, 'Balancer tag, e.g. balancer'),
|
||
(
|
||
dialog.domainEdit,
|
||
r'e.g. domain:xray.com, geosite:cn, regexp:\.google\.com$',
|
||
),
|
||
(dialog.ipEdit, 'e.g. 10.0.0.0/8, geoip:cn, !geoip:private'),
|
||
(dialog.portEdit, 'e.g. 53,443,1000-2000'),
|
||
(dialog.vlessRouteEdit, 'e.g. 53,443,1000-2000'),
|
||
(dialog.sourceIPEdit, 'e.g. 10.0.0.1, 192.168.1.0/24'),
|
||
(dialog.sourcePortEdit, 'e.g. 53,443,1000-2000'),
|
||
(dialog.localIPEdit, 'e.g. 192.168.0.25'),
|
||
(dialog.localPortEdit, 'e.g. 53,443,1000-2000'),
|
||
(dialog.userEdit, 'e.g. love@xray.com'),
|
||
(dialog.inboundTagEdit, 'e.g. tag-vmess'),
|
||
(dialog.processEdit, 'e.g. curl'),
|
||
(dialog.protocolEdit, 'e.g. http, tls, quic, bittorrent'),
|
||
)
|
||
|
||
for widget, placeholder in placeholders:
|
||
self.assertEqual(
|
||
widget.placeholderText(),
|
||
_(placeholder),
|
||
)
|
||
|
||
self.assertEqual(dialog.networkCombo.currentText(), '')
|
||
|
||
labels = {label.text() for label in dialog.findChildren(QLabel)}
|
||
|
||
self.assertIn('OutBound', labels)
|
||
self.assertIn('Domain', labels)
|
||
self.assertIn('VLESS Route', labels)
|
||
self.assertFalse(any('(' in label for label in labels))
|
||
|
||
dialog.close()
|
||
|
||
def testRoutingRulePlaceholdersRetranslateWithoutChangingValues(self):
|
||
"""Translate guidance while preserving technical syntax and user input."""
|
||
rule = {
|
||
'type': 'field',
|
||
'ruleTag': 'fixture-rule',
|
||
'outboundTag': 'fixture-outbound',
|
||
'domain': ['domain:example.test', 'geosite:cn'],
|
||
'ip': ['10.0.0.0/8'],
|
||
'port': '53,443,1000-2000',
|
||
}
|
||
|
||
with isolatedSettings():
|
||
AppSettings.set('Language', 'EN')
|
||
dialog = RoutingRuleEditDialog(rule)
|
||
expectedRule = dialog.routingRule()
|
||
|
||
AppSettings.set('Language', 'ZH')
|
||
Mixins.QTranslatable.retranslateAll()
|
||
|
||
self.assertEqual(dialog.routingRule(), expectedRule)
|
||
self.assertIn('domain:xray.com', dialog.domainEdit.placeholderText())
|
||
self.assertIn('geosite:cn', dialog.domainEdit.placeholderText())
|
||
self.assertIn('!geoip:private', dialog.ipEdit.placeholderText())
|
||
self.assertIn('53,443,1000-2000', dialog.portEdit.placeholderText())
|
||
self.assertIn(
|
||
'http, tls, quic, bittorrent', dialog.protocolEdit.placeholderText()
|
||
)
|
||
|
||
AppSettings.set('Language', 'RU')
|
||
Mixins.QTranslatable.retranslateAll()
|
||
|
||
self.assertEqual(dialog.routingRule(), expectedRule)
|
||
self.assertIn('block-ads', dialog.ruleTagEdit.placeholderText())
|
||
self.assertIn('tag-vmess', dialog.inboundTagEdit.placeholderText())
|
||
|
||
AppSettings.set('Language', 'EN')
|
||
Mixins.QTranslatable.retranslateAll()
|
||
|
||
dialog.close()
|
||
|
||
def testRoutingRuleSerializationStillUsesExistingParsingContract(self):
|
||
"""Keep comma/newline parsing and scalar routing fields unchanged."""
|
||
dialog = RoutingRuleEditDialog(
|
||
{
|
||
'type': 'field',
|
||
'ruleTag': 'fixture-rule',
|
||
'outboundTag': 'direct',
|
||
'balancerTag': 'balancer',
|
||
'network': 'tcp,udp',
|
||
'domain': ['domain:xray.com', 'geosite:cn'],
|
||
'ip': ['10.0.0.0/8', '!geoip:private'],
|
||
'port': '53,443,1000-2000',
|
||
'sourceIP': ['10.0.0.1'],
|
||
'sourcePort': '1024-65535',
|
||
'localIP': ['192.168.0.25'],
|
||
'localPort': '10808',
|
||
'user': ['love@xray.com'],
|
||
'vlessRoute': '1,14,14514',
|
||
'inboundTag': ['tag-vmess'],
|
||
'protocol': ['http', 'tls', 'quic', 'bittorrent'],
|
||
'process': ['curl'],
|
||
}
|
||
)
|
||
|
||
self.assertEqual(
|
||
dialog.routingRule(),
|
||
{
|
||
'type': 'field',
|
||
'outboundTag': 'direct',
|
||
'ruleTag': 'fixture-rule',
|
||
'network': 'tcp,udp',
|
||
'port': '53,443,1000-2000',
|
||
'sourcePort': '1024-65535',
|
||
'localPort': '10808',
|
||
'vlessRoute': '1,14,14514',
|
||
'balancerTag': 'balancer',
|
||
'domain': ['domain:xray.com', 'geosite:cn'],
|
||
'ip': ['10.0.0.0/8', '!geoip:private'],
|
||
'sourceIP': ['10.0.0.1'],
|
||
'localIP': ['192.168.0.25'],
|
||
'user': ['love@xray.com'],
|
||
'protocol': ['http', 'tls', 'quic', 'bittorrent'],
|
||
'inboundTag': ['tag-vmess'],
|
||
'process': ['curl'],
|
||
},
|
||
)
|
||
|
||
dialog.close()
|
||
|
||
def testRoutingTextEditorsPreservePlainTextLineBreaks(self):
|
||
"""Keep multiline routing values intact in the enlarged editor."""
|
||
text = 'aaa\nbb\ncccc'
|
||
editor = RoutingTextEdit(text)
|
||
dialog = RoutingTextEditDialog(text)
|
||
|
||
self.assertEqual(editor.toPlainText(), text)
|
||
self.assertEqual(dialog.text(), text)
|
||
|
||
editor.close()
|
||
dialog.close()
|
||
|
||
def testAssetListViewKeepsRawFilenameInItsOwnedModel(self):
|
||
"""Keep filesystem identity separate from formatted asset row text."""
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
assetDirectory = pathlib.Path(directory)
|
||
filename = 'geo data.dat'
|
||
(assetDirectory / filename).write_bytes(b'fixture')
|
||
|
||
with mock.patch(
|
||
'Furious.Backends.Xray.AssetListView.XRAY_ASSET_DIR',
|
||
assetDirectory,
|
||
):
|
||
view = XrayAssetListView()
|
||
|
||
self.assertIs(view.model(), view.assetModel)
|
||
self.assertIs(view.assetModel.parent(), view)
|
||
self.assertEqual(view.assetModel.rowCount(), 1)
|
||
|
||
view.show()
|
||
|
||
processQtEvents()
|
||
|
||
self.assertEqual(view.assetModel.rowCount(), 1)
|
||
self.assertGreaterEqual(
|
||
view.sizeHintForRow(0),
|
||
view.iconSize().height(),
|
||
)
|
||
self.assertEqual(view.filenameAt(0), filename)
|
||
self.assertIn(filename, view.assetModel.item(0).text())
|
||
|
||
view.close()
|
||
view.deleteLater()
|
||
|
||
def testMessageBoxButtonPublishesCompatibleResult(self):
|
||
"""Emit one clicked button and finish with its standard-button value."""
|
||
messageBox = AppQMessageBox(
|
||
text='Continue?',
|
||
buttons=(
|
||
AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No
|
||
),
|
||
)
|
||
clicked = []
|
||
finished = []
|
||
messageBox.buttonClicked.connect(clicked.append)
|
||
messageBox.finished.connect(finished.append)
|
||
yesButton = messageBox.button(AppQMessageBox.StandardButton.Yes)
|
||
|
||
yesButton.click()
|
||
|
||
processQtEvents()
|
||
|
||
self.assertEqual(clicked, [yesButton])
|
||
self.assertEqual(finished, [int(AppQMessageBox.StandardButton.Yes)])
|
||
self.assertIs(messageBox.clickedButton(), yesButton)
|
||
|
||
def testMessageBoxSeparatesNativeTitleHeadingAndBody(self):
|
||
"""Keep native metadata independent from visible semantic content."""
|
||
messageBox = AppQMessageBox(
|
||
title='Native window metadata',
|
||
heading='Unable to connect',
|
||
text='Xray-core: Invalid server configuration',
|
||
)
|
||
messageBox.setInformativeText('The outbound server address is missing.')
|
||
messageBox.show()
|
||
|
||
processQtEvents()
|
||
|
||
self.assertEqual(messageBox.windowTitle(), 'Native window metadata')
|
||
self.assertEqual(messageBox.heading(), 'Unable to connect')
|
||
self.assertEqual(messageBox.text(), 'Xray-core: Invalid server configuration')
|
||
self.assertEqual(
|
||
messageBox.informativeText(),
|
||
'The outbound server address is missing.',
|
||
)
|
||
self.assertEqual(messageBox.headingLabel.text(), 'Unable to connect')
|
||
self.assertEqual(
|
||
messageBox.textLabel.text(),
|
||
'Xray-core: Invalid server configuration',
|
||
)
|
||
self.assertFalse(messageBox.headingLabel.isHidden())
|
||
|
||
messageBox.setWindowTitle('Changed native metadata')
|
||
|
||
self.assertEqual(messageBox.heading(), 'Unable to connect')
|
||
|
||
messageBox.close()
|
||
|
||
def testMessageBoxKeepsGenericApplicationTitleAsMetadataOnly(self):
|
||
"""Do not promote the default application name into visible content."""
|
||
messageBox = AppQMessageBox(text='Operation completed')
|
||
messageBox.show()
|
||
|
||
processQtEvents()
|
||
|
||
self.assertEqual(messageBox.windowTitle(), APPLICATION_NAME)
|
||
self.assertEqual(messageBox.heading(), '')
|
||
self.assertTrue(messageBox.headingLabel.isHidden())
|
||
self.assertEqual(messageBox.textLabel.y(), 0)
|
||
|
||
positional = AppQMessageBox(
|
||
AppQMessageBox.Icon.Information,
|
||
'Native compatibility title',
|
||
'Compatibility body',
|
||
AppQMessageBox.StandardButton.Ok,
|
||
)
|
||
|
||
self.assertEqual(positional.windowTitle(), 'Native compatibility title')
|
||
self.assertEqual(positional.heading(), '')
|
||
self.assertEqual(positional.text(), 'Compatibility body')
|
||
|
||
messageBox.close()
|
||
positional.deleteLater()
|
||
|
||
def testConnectionErrorPreservesHeadingMessageAndDetails(self):
|
||
"""Map every structured connection-error field to visible dialog content."""
|
||
error = ConnectionError(
|
||
'Unable to connect',
|
||
'Xray-core: Invalid server configuration',
|
||
'The outbound server address is missing.',
|
||
)
|
||
opened = []
|
||
|
||
with mock.patch.object(
|
||
AppQMessageBox,
|
||
'open',
|
||
lambda messageBox: opened.append(messageBox),
|
||
):
|
||
ConnectAction.showError(error)
|
||
|
||
self.assertEqual(len(opened), 1)
|
||
|
||
messageBox = opened[0]
|
||
|
||
self.assertEqual(messageBox.windowTitle(), APPLICATION_NAME)
|
||
self.assertEqual(messageBox.heading(), error.title)
|
||
self.assertEqual(messageBox.text(), error.message)
|
||
self.assertEqual(messageBox.informativeText(), error.details)
|
||
|
||
messageBox.close()
|
||
messageBox.deleteLater()
|
||
|
||
def testMessageBoxHeadingSupportsLongContentIconsAndThemes(self):
|
||
"""Keep the semantic stack responsive across themes and icon variants."""
|
||
app = application()
|
||
originalStyleSheet = app.styleSheet()
|
||
longMessage = (
|
||
'Не удалось применить конфигурацию прокси-сервера. '
|
||
'Проверьте адрес, порт и параметры подключения, затем повторите попытку.'
|
||
)
|
||
|
||
try:
|
||
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
|
||
app.setStyleSheet(AppStyleSheet.forTheme(theme))
|
||
|
||
for icon in (
|
||
AppQMessageBox.Icon.Information,
|
||
AppQMessageBox.Icon.Warning,
|
||
AppQMessageBox.Icon.Critical,
|
||
AppQMessageBox.Icon.Question,
|
||
):
|
||
with self.subTest(theme=theme, icon=icon):
|
||
messageBox = AppQMessageBox(
|
||
icon=icon,
|
||
heading='Unable to connect',
|
||
text=longMessage,
|
||
)
|
||
messageBox.show()
|
||
|
||
processQtEvents()
|
||
|
||
self.assertFalse(messageBox.iconPixmap().isNull())
|
||
self.assertGreaterEqual(
|
||
messageBox.textLabel.height(),
|
||
messageBox.textLabel.fontMetrics().height(),
|
||
)
|
||
self.assertLessEqual(
|
||
messageBox.width(),
|
||
messageBox.MaximumSurfaceWidth,
|
||
)
|
||
|
||
messageBox.close()
|
||
messageBox.deleteLater()
|
||
finally:
|
||
app.setStyleSheet(originalStyleSheet)
|
||
|
||
def testMessageBoxButtonsHaveAdaptiveFluentLayoutAndRoles(self):
|
||
"""Keep one, two, and three actions slim, separated, and content-driven."""
|
||
configurations = (
|
||
(AppQMessageBox.StandardButton.Ok, 1),
|
||
(
|
||
AppQMessageBox.StandardButton.Ok | AppQMessageBox.StandardButton.Cancel,
|
||
2,
|
||
),
|
||
(
|
||
AppQMessageBox.StandardButton.Save
|
||
| AppQMessageBox.StandardButton.Discard
|
||
| AppQMessageBox.StandardButton.Cancel,
|
||
3,
|
||
),
|
||
)
|
||
widths = []
|
||
|
||
for buttons, count in configurations:
|
||
with self.subTest(buttonCount=count):
|
||
messageBox = AppQMessageBox(
|
||
icon=AppQMessageBox.Icon.Question,
|
||
text='Ready',
|
||
buttons=buttons,
|
||
)
|
||
messageBox.show()
|
||
|
||
processQtEvents()
|
||
|
||
actionButtons = messageBox.buttons()
|
||
|
||
self.assertEqual(len(actionButtons), count)
|
||
self.assertTrue(
|
||
all(
|
||
button.width() > button.height() * 2 for button in actionButtons
|
||
)
|
||
)
|
||
|
||
geometries = sorted(
|
||
(button.geometry() for button in actionButtons),
|
||
key=lambda geometry: geometry.x(),
|
||
)
|
||
|
||
for left, right in zip(geometries, geometries[1:]):
|
||
self.assertGreaterEqual(
|
||
right.left() - left.right() - 1,
|
||
messageBox.ButtonSpacing,
|
||
)
|
||
|
||
if count > 1:
|
||
self.assertLessEqual(
|
||
max(button.width() for button in actionButtons)
|
||
- min(button.width() for button in actionButtons),
|
||
1,
|
||
)
|
||
|
||
if buttons & AppQMessageBox.StandardButton.Discard:
|
||
discard = messageBox.button(AppQMessageBox.StandardButton.Discard)
|
||
self.assertEqual(
|
||
discard.property('messageBoxRole'),
|
||
'destructive',
|
||
)
|
||
|
||
widths.append(messageBox.width())
|
||
|
||
messageBox.close()
|
||
|
||
self.assertLess(widths[0], widths[2])
|
||
|
||
def testMessageBoxEscapeUsesConfiguredCancelResult(self):
|
||
"""Preserve Escape semantics without leaving masks or open-box owners."""
|
||
messageBox = AppQMessageBox(
|
||
text='Continue?',
|
||
buttons=(
|
||
AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.Cancel
|
||
),
|
||
)
|
||
|
||
finished = []
|
||
|
||
messageBox.finished.connect(finished.append)
|
||
messageBox.setEscapeButton(AppQMessageBox.StandardButton.Cancel)
|
||
messageBox.show()
|
||
|
||
QTest.keyClick(messageBox, QtCore.Qt.Key.Key_Escape)
|
||
|
||
processQtEvents()
|
||
|
||
self.assertEqual(
|
||
finished,
|
||
[int(AppQMessageBox.StandardButton.Cancel)],
|
||
)
|
||
self.assertEqual(AppQDialog._openDialogs, {})
|
||
|
||
|
||
class SharedConnectionPresentationTest(unittest.TestCase):
|
||
"""Keep Home and tray adapters synchronized to one controller state."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
application()
|
||
|
||
def tearDown(self):
|
||
collectAtBoundary()
|
||
|
||
def testHomeSelectionPolicyAndTrayPresentationShareController(self):
|
||
"""Apply selection only to Home while lifecycle text remains identical."""
|
||
controller = ConnectionController()
|
||
activation = mock.Mock(return_value=True)
|
||
|
||
with (
|
||
mock.patch(
|
||
'Furious.Widget.ConnectionButton.AppConnectionController',
|
||
return_value=controller,
|
||
),
|
||
mock.patch(
|
||
'Furious.Actions.Connection.AppConnectionController',
|
||
return_value=controller,
|
||
),
|
||
mock.patch.object(controller, 'toggle', return_value=True) as toggle,
|
||
):
|
||
home = ConnectionButton(activation)
|
||
tray = ConnectAction()
|
||
|
||
home.setSelectionCount(0)
|
||
|
||
self.assertTrue(home.isEnabled())
|
||
|
||
home.click()
|
||
toggle.assert_not_called()
|
||
|
||
home.setSelectionCount(2)
|
||
|
||
self.assertFalse(home.isEnabled())
|
||
|
||
home.setSelectionCount(1)
|
||
|
||
self.assertTrue(home.isEnabled())
|
||
|
||
home.click()
|
||
activation.assert_called_once()
|
||
toggle.assert_called_once()
|
||
|
||
for state, enabled in (
|
||
(ConnectionState.Connecting, False),
|
||
(ConnectionState.Connected, True),
|
||
(ConnectionState.Disconnecting, False),
|
||
(ConnectionState.Disconnected, True),
|
||
):
|
||
controller._setState(state)
|
||
|
||
processQtEvents()
|
||
|
||
self.assertEqual(home.text(), tray.text())
|
||
self.assertEqual(home.isEnabled(), enabled)
|
||
self.assertEqual(tray.isEnabled(), enabled)
|
||
|
||
tray.deleteLater()
|
||
home.deleteLater()
|
||
|
||
controller.deleteLater()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|