mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
1189 lines
42 KiB
Python
1189 lines
42 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 real Qt input, focus, model/view, and shared-surface behavior."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from Furious.Controllers import ConnectionState
|
||
from Furious.Controllers.SettingsController import SettingsController
|
||
from Furious.Frozenlib import AppBuiltinProxyMode, AppSettings
|
||
from Furious.Models import CoreConfiguration, ServerProfile
|
||
from Furious.Plugins.API import RoutingOption
|
||
from Furious.Repository import Storage, SubscriptionGroup
|
||
from Furious.Qt import AppQDialog, AppQSwitch, gettext
|
||
from Furious.Widget.RoutingSelector import RoutingSelector
|
||
from Furious.Widget.ServerTableView import ServerTableView
|
||
from Furious.Widget.SubscriptionTableView import SubscriptionTableView
|
||
from Furious.Window.HomePage import HomePage
|
||
from Furious.Window.SettingsPage import (
|
||
_SystemProxySettingsCard,
|
||
_ToggleSettingsCard,
|
||
)
|
||
from Furious.Window.SubscriptionPage import _SubscriptionEditorDialog
|
||
|
||
from PySide6 import QtCore, QtGui
|
||
from PySide6.QtTest import QTest
|
||
from PySide6.QtWidgets import QLineEdit, QVBoxLayout, QWidget
|
||
|
||
from shiboken6 import isValid
|
||
|
||
from tests.support import (
|
||
application,
|
||
collectAtBoundary,
|
||
isolatedSettings,
|
||
processQtEvents,
|
||
waitFor,
|
||
)
|
||
|
||
from contextlib import ExitStack, contextmanager
|
||
|
||
import unittest
|
||
import weakref
|
||
|
||
from unittest import mock
|
||
|
||
|
||
class _ConnectionControllerFixture(QtCore.QObject):
|
||
"""Publish the application-lifetime signals consumed by real Home widgets."""
|
||
|
||
stateChanged = QtCore.Signal(object)
|
||
activeProfileChanged = QtCore.Signal(object)
|
||
interactionEnabledChanged = QtCore.Signal(bool)
|
||
|
||
def __init__(self):
|
||
"""Start in the deterministic disconnected and interactive state."""
|
||
super().__init__()
|
||
|
||
self.state = ConnectionState.Disconnected
|
||
self.activeProfile = None
|
||
self.interactionEnabled = True
|
||
self.toggleCount = 0
|
||
|
||
def isConnected(self) -> bool:
|
||
"""Return whether this fixture is in the connected state."""
|
||
return self.state is ConnectionState.Connected
|
||
|
||
def isConnecting(self) -> bool:
|
||
"""Return whether this fixture is in the connecting state."""
|
||
return self.state is ConnectionState.Connecting
|
||
|
||
def toggle(self):
|
||
"""Record a connection request without starting a runtime."""
|
||
self.toggleCount += 1
|
||
|
||
def setInteractionEnabled(self, enabled: bool):
|
||
"""Publish the same interaction gate as the real controller."""
|
||
self.interactionEnabled = bool(enabled)
|
||
self.interactionEnabledChanged.emit(self.interactionEnabled)
|
||
|
||
|
||
class _RoutingControllerFixture(QtCore.QObject):
|
||
"""Provide stable routing options while recording real combo-box requests."""
|
||
|
||
stateChanged = QtCore.Signal(object, str)
|
||
interactionEnabledChanged = QtCore.Signal(bool)
|
||
|
||
def __init__(self, options, routing):
|
||
"""Store one semantic routing snapshot."""
|
||
super().__init__()
|
||
|
||
self.options = tuple(options)
|
||
self.routing = routing
|
||
self.interactionEnabled = True
|
||
self.selections = []
|
||
|
||
def state(self):
|
||
"""Return the current semantic routing snapshot."""
|
||
return self.options, self.routing
|
||
|
||
def refresh(self, *, force=False):
|
||
"""Optionally republish the current routing snapshot."""
|
||
if force:
|
||
self.stateChanged.emit(*self.state())
|
||
|
||
return self.state()
|
||
|
||
def selectRouting(self, routing):
|
||
"""Record one user-originated routing selection."""
|
||
self.routing = routing
|
||
self.selections.append(routing)
|
||
|
||
def setInteractionEnabled(self, enabled: bool):
|
||
"""Publish whether routing may be changed."""
|
||
self.interactionEnabled = bool(enabled)
|
||
self.interactionEnabledChanged.emit(self.interactionEnabled)
|
||
|
||
|
||
class ServerTableQtInteractionTest(unittest.TestCase):
|
||
"""Protect identity-aware table behavior through actual Qt input events."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Release repository owners and deferred table/menu objects."""
|
||
Storage._UserServersStorage.cache_clear()
|
||
Storage._UserSubsStorage.cache_clear()
|
||
collectAtBoundary()
|
||
|
||
@staticmethod
|
||
def _profile(name: str):
|
||
"""Build one deterministic profile with a stable generated identity."""
|
||
return ServerProfile.fromConfiguration(
|
||
CoreConfiguration({'type': 'fixture'}),
|
||
{'displayName': name},
|
||
)
|
||
|
||
def _table(self, names):
|
||
"""Build and show one real server table backed by isolated repositories."""
|
||
Storage.UserServers().extend(self._profile(name) for name in names)
|
||
AppSettings.set('ActivatedItemIndex', '0')
|
||
|
||
table = ServerTableView(
|
||
configurationEditorFactory=QWidget,
|
||
qrCodeWindowFactory=QWidget,
|
||
importActionsFactory=tuple,
|
||
)
|
||
table.resize(960, 420)
|
||
table.show()
|
||
table.activateWindow()
|
||
|
||
processQtEvents()
|
||
|
||
return table
|
||
|
||
@staticmethod
|
||
def _destroyTable(table):
|
||
"""Use the table's production cleanup and Qt deferred-delete paths."""
|
||
table.cleanup()
|
||
table.close()
|
||
table.deleteLater()
|
||
|
||
def _clickProxyRow(self, table, row, modifiers=QtCore.Qt.NoModifier):
|
||
"""Select one visible row through the viewport's real mouse path."""
|
||
index = table.proxyModel.index(row, 0)
|
||
|
||
self.assertTrue(index.isValid())
|
||
|
||
rectangle = table.visualRect(index)
|
||
|
||
self.assertFalse(rectangle.isEmpty())
|
||
|
||
QTest.mouseClick(
|
||
table.viewport(),
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
modifiers,
|
||
rectangle.center(),
|
||
)
|
||
processQtEvents()
|
||
|
||
@staticmethod
|
||
def _selectedProfileIds(table):
|
||
"""Return stable source identities selected through the proxy view."""
|
||
return tuple(
|
||
Storage.UserServers()[row].metadata.profileId for row in table.selectedIndex
|
||
)
|
||
|
||
@staticmethod
|
||
def _currentProfileId(table):
|
||
"""Return the source identity represented by the current proxy index."""
|
||
row = table.sourceRowFromProxyIndex(table.currentIndex())
|
||
|
||
if 0 <= row < len(Storage.UserServers()):
|
||
return Storage.UserServers()[row].metadata.profileId
|
||
|
||
return None
|
||
|
||
def testMouseMultiSelectionAndRepeatedShortcutPreserveIdentityAndFocus(self):
|
||
"""Keep Ctrl-click selection/current identity ready across repeated moves."""
|
||
with isolatedSettings():
|
||
names = (
|
||
'visible first',
|
||
'hidden',
|
||
'visible second',
|
||
'visible third',
|
||
'visible fourth',
|
||
'visible fifth',
|
||
)
|
||
table = self._table(names)
|
||
|
||
try:
|
||
table.search('^visible')
|
||
processQtEvents()
|
||
|
||
self._clickProxyRow(table, 1)
|
||
self._clickProxyRow(
|
||
table,
|
||
2,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
|
||
selectedIds = set(self._selectedProfileIds(table))
|
||
currentId = self._currentProfileId(table)
|
||
|
||
for _iteration in range(2):
|
||
QTest.keyClick(
|
||
table,
|
||
QtCore.Qt.Key.Key_Down,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertTrue(table.hasFocus())
|
||
self.assertEqual(set(self._selectedProfileIds(table)), selectedIds)
|
||
self.assertEqual(self._currentProfileId(table), currentId)
|
||
|
||
self.assertEqual(
|
||
[profile.itemRemark for profile in Storage.UserServers()],
|
||
[
|
||
'visible first',
|
||
'hidden',
|
||
'visible fourth',
|
||
'visible fifth',
|
||
'visible second',
|
||
'visible third',
|
||
],
|
||
)
|
||
finally:
|
||
self._destroyTable(table)
|
||
|
||
def testTableShortcutDoesNotFireFromSiblingEditor(self):
|
||
"""Keep table commands inactive while the Home search editor owns focus."""
|
||
with isolatedSettings():
|
||
window = QWidget()
|
||
layout = QVBoxLayout(window)
|
||
editor = QLineEdit(window)
|
||
table = self._table(('one', 'two', 'three'))
|
||
|
||
layout.addWidget(editor)
|
||
layout.addWidget(table)
|
||
window.resize(960, 500)
|
||
window.show()
|
||
window.activateWindow()
|
||
processQtEvents()
|
||
|
||
try:
|
||
self._clickProxyRow(table, 1)
|
||
editor.setFocus()
|
||
|
||
self.assertTrue(
|
||
waitFor(lambda: application().focusWidget() is editor),
|
||
)
|
||
|
||
before = tuple(
|
||
profile.metadata.profileId for profile in Storage.UserServers()
|
||
)
|
||
|
||
QTest.keyClick(
|
||
editor,
|
||
QtCore.Qt.Key.Key_Down,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertIs(application().focusWidget(), editor)
|
||
self.assertEqual(
|
||
tuple(
|
||
profile.metadata.profileId for profile in Storage.UserServers()
|
||
),
|
||
before,
|
||
)
|
||
|
||
table.proxyModel.sort(
|
||
0,
|
||
QtCore.Qt.SortOrder.AscendingOrder,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertIs(application().focusWidget(), editor)
|
||
finally:
|
||
self._destroyTable(table)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def testNestedTableShortcutDoesNotFireFromSiblingEditor(self):
|
||
"""Confine the Advanced submenu's Ctrl+E action to the focused table."""
|
||
with isolatedSettings():
|
||
window = QWidget()
|
||
layout = QVBoxLayout(window)
|
||
editor = QLineEdit(window)
|
||
table = self._table(('one',))
|
||
editRequests = []
|
||
|
||
table.editSelectedItemConfiguration = lambda: editRequests.append(True)
|
||
|
||
layout.addWidget(editor)
|
||
layout.addWidget(table)
|
||
window.resize(960, 500)
|
||
window.show()
|
||
window.activateWindow()
|
||
processQtEvents()
|
||
|
||
try:
|
||
self._clickProxyRow(table, 0)
|
||
editor.setFocus()
|
||
|
||
self.assertTrue(
|
||
waitFor(lambda: application().focusWidget() is editor),
|
||
)
|
||
self.assertEqual(
|
||
table.customizeJSONConfigActionRef.shortcutContext(),
|
||
QtCore.Qt.ShortcutContext.WidgetShortcut,
|
||
)
|
||
|
||
QTest.keyClick(
|
||
editor,
|
||
QtCore.Qt.Key.Key_E,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(editRequests, [])
|
||
self.assertIs(application().focusWidget(), editor)
|
||
finally:
|
||
self._destroyTable(table)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def testContextMenuUsesOnlyMultithreadedDownloadSpeedAction(self):
|
||
"""Expose one download command backed by the multithreaded scheduler."""
|
||
with isolatedSettings():
|
||
table = self._table(('one',))
|
||
|
||
try:
|
||
actions = tuple(
|
||
action
|
||
for action in table.contextMenu.actions()
|
||
if not action.isSeparator()
|
||
and action.textEnglish == 'Test Download Speed'
|
||
)
|
||
|
||
self.assertEqual(len(actions), 1)
|
||
self.assertEqual(
|
||
actions[0].shortcut(),
|
||
QtGui.QKeySequence(
|
||
QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
QtCore.Qt.Key.Key_M,
|
||
)
|
||
),
|
||
)
|
||
|
||
with (
|
||
mock.patch.object(
|
||
table,
|
||
'testSelectedItemDownloadSpeedMulti',
|
||
) as multithreaded,
|
||
mock.patch.object(
|
||
table,
|
||
'testSelectedItemDownloadSpeed',
|
||
) as singleThreaded,
|
||
):
|
||
actions[0].trigger()
|
||
processQtEvents()
|
||
|
||
multithreaded.assert_called_once_with()
|
||
singleThreaded.assert_not_called()
|
||
finally:
|
||
self._destroyTable(table)
|
||
|
||
def testHeaderSortPreservesSelectionCurrentAndActiveIdentity(self):
|
||
"""Remap Qt persistent indexes when a real header click sorts rows."""
|
||
with isolatedSettings():
|
||
table = self._table(('zulu', 'alpha', 'mike'))
|
||
|
||
try:
|
||
AppSettings.set('ActivatedItemIndex', '2')
|
||
table.sourceModel.emitAllChanged()
|
||
|
||
self._clickProxyRow(table, 0)
|
||
self._clickProxyRow(
|
||
table,
|
||
2,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
|
||
selectedIds = set(self._selectedProfileIds(table))
|
||
currentId = self._currentProfileId(table)
|
||
activeId = Storage.UserServers()[2].metadata.profileId
|
||
header = table.horizontalHeader()
|
||
position = QtCore.QPoint(
|
||
header.sectionViewportPosition(0) + header.sectionSize(0) // 2,
|
||
header.height() // 2,
|
||
)
|
||
|
||
for _iteration in range(2):
|
||
QTest.mouseClick(
|
||
header.viewport(),
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
pos=position,
|
||
)
|
||
processQtEvents()
|
||
|
||
descending = (
|
||
header.sortIndicatorOrder()
|
||
== QtCore.Qt.SortOrder.DescendingOrder
|
||
)
|
||
|
||
self.assertEqual(
|
||
[profile.itemRemark for profile in Storage.UserServers()],
|
||
sorted(
|
||
('zulu', 'alpha', 'mike'),
|
||
reverse=descending,
|
||
),
|
||
)
|
||
self.assertEqual(set(self._selectedProfileIds(table)), selectedIds)
|
||
self.assertEqual(self._currentProfileId(table), currentId)
|
||
self.assertEqual(
|
||
Storage.UserServers()[
|
||
Storage.UserActivatedItemIndex()
|
||
].metadata.profileId,
|
||
activeId,
|
||
)
|
||
finally:
|
||
self._destroyTable(table)
|
||
|
||
def testFilteredShortcutUsesMappedSourceProfileIdentity(self):
|
||
"""Apply a real shortcut to the selected source object, not proxy row zero."""
|
||
with isolatedSettings():
|
||
table = self._table(('alpha', 'literal[', 'target'))
|
||
|
||
for index, profile in enumerate(Storage.UserServers()):
|
||
profile.metadata.latency = f'{index + 1} ms'
|
||
profile.metadata.speed = f'{index + 1} MiB/s'
|
||
|
||
try:
|
||
table.search('[')
|
||
processQtEvents()
|
||
|
||
self.assertEqual(table.proxyModel.rowCount(), 1)
|
||
|
||
self._clickProxyRow(table, 0)
|
||
|
||
selectedId = self._selectedProfileIds(table)
|
||
|
||
self.assertEqual(
|
||
selectedId,
|
||
(Storage.UserServers()[1].metadata.profileId,),
|
||
)
|
||
|
||
QTest.keyClick(
|
||
table,
|
||
QtCore.Qt.Key.Key_R,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(Storage.UserServers()[0].metadata.latency, '1 ms')
|
||
self.assertEqual(Storage.UserServers()[1].metadata.latency, '')
|
||
self.assertEqual(Storage.UserServers()[1].metadata.speed, '')
|
||
self.assertEqual(Storage.UserServers()[2].metadata.latency, '3 ms')
|
||
finally:
|
||
self._destroyTable(table)
|
||
|
||
def testFilterClearPreservesVisibleSelectionAndHiddenActiveIdentity(self):
|
||
"""Keep semantic selection and activation stable across proxy filtering."""
|
||
with isolatedSettings():
|
||
table = self._table(('alpha', 'beta', 'gamma'))
|
||
|
||
try:
|
||
AppSettings.set('ActivatedItemIndex', '1')
|
||
table.sourceModel.emitAllChanged()
|
||
self._clickProxyRow(table, 2)
|
||
|
||
selectedId = self._selectedProfileIds(table)
|
||
currentId = self._currentProfileId(table)
|
||
activeId = Storage.UserServers()[1].metadata.profileId
|
||
|
||
table.search('gamma')
|
||
processQtEvents()
|
||
|
||
self.assertEqual(table.proxyModel.rowCount(), 1)
|
||
self.assertEqual(self._selectedProfileIds(table), selectedId)
|
||
self.assertEqual(self._currentProfileId(table), currentId)
|
||
self.assertEqual(
|
||
Storage.UserServers()[
|
||
Storage.UserActivatedItemIndex()
|
||
].metadata.profileId,
|
||
activeId,
|
||
)
|
||
|
||
table.search('')
|
||
processQtEvents()
|
||
|
||
self.assertEqual(table.proxyModel.rowCount(), 3)
|
||
self.assertEqual(self._selectedProfileIds(table), selectedId)
|
||
self.assertEqual(self._currentProfileId(table), currentId)
|
||
self.assertEqual(
|
||
Storage.UserServers()[
|
||
Storage.UserActivatedItemIndex()
|
||
].metadata.profileId,
|
||
activeId,
|
||
)
|
||
finally:
|
||
self._destroyTable(table)
|
||
|
||
def testDynamicSubscriptionMenuReleasesReplacedActions(self):
|
||
"""Rebuild a shown context menu without retaining obsolete QActions."""
|
||
with isolatedSettings():
|
||
Storage.upsertSubscriptionGroup(
|
||
SubscriptionGroup(id='group-a', remark='Group A')
|
||
)
|
||
Storage.upsertSubscriptionGroup(
|
||
SubscriptionGroup(id='group-b', remark='Group B', sortOrder=1)
|
||
)
|
||
table = self._table(('manual',))
|
||
|
||
try:
|
||
self._clickProxyRow(table, 0)
|
||
table.contextMenu.popup(table.viewport().mapToGlobal(QtCore.QPoint()))
|
||
|
||
self.assertTrue(waitFor(table.contextMenu.isVisible))
|
||
|
||
firstActions = tuple(table._subscriptionActions)
|
||
firstReferences = tuple(weakref.ref(action) for action in firstActions)
|
||
|
||
self.assertEqual(
|
||
[action.textEnglish for action in firstActions],
|
||
['No subscription', 'Group A', 'Group B'],
|
||
)
|
||
|
||
table.contextMenu.hide()
|
||
Storage.removeSubscriptionGroup('group-b')
|
||
Storage.upsertSubscriptionGroup(
|
||
SubscriptionGroup(id='group-c', remark='Group C', sortOrder=1)
|
||
)
|
||
table.contextMenu.popup(table.viewport().mapToGlobal(QtCore.QPoint()))
|
||
|
||
self.assertTrue(waitFor(table.contextMenu.isVisible))
|
||
self.assertEqual(
|
||
[action.textEnglish for action in table._subscriptionActions],
|
||
['No subscription', 'Group A', 'Group C'],
|
||
)
|
||
|
||
del firstActions
|
||
collectAtBoundary()
|
||
|
||
self.assertTrue(
|
||
all(
|
||
reference() is None or not isValid(reference())
|
||
for reference in firstReferences
|
||
)
|
||
)
|
||
finally:
|
||
table.contextMenu.hide()
|
||
self._destroyTable(table)
|
||
|
||
|
||
class SubscriptionTableQtInteractionTest(unittest.TestCase):
|
||
"""Protect subscription ordering through real selection and shortcuts."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Release the subscription repository and deferred table objects."""
|
||
Storage._UserSubsStorage.cache_clear()
|
||
collectAtBoundary()
|
||
|
||
@staticmethod
|
||
def _table(parent=None):
|
||
"""Build one visible table with deterministic stable subscription IDs."""
|
||
for order, unique in enumerate(('A', 'B', 'C', 'D', 'E')):
|
||
Storage.upsertSubscriptionGroup(
|
||
SubscriptionGroup(
|
||
id=unique,
|
||
remark=unique,
|
||
sortOrder=order,
|
||
)
|
||
)
|
||
|
||
table = SubscriptionTableView(parent=parent)
|
||
table.resize(900, 360)
|
||
table.show()
|
||
table.activateWindow()
|
||
processQtEvents()
|
||
|
||
return table
|
||
|
||
def _clickRow(self, table, row, modifiers=QtCore.Qt.NoModifier):
|
||
"""Select one subscription row through the real viewport mouse path."""
|
||
index = table.sourceModel.index(row, 0)
|
||
rectangle = table.visualRect(index)
|
||
|
||
self.assertTrue(index.isValid())
|
||
self.assertFalse(rectangle.isEmpty())
|
||
|
||
QTest.mouseClick(
|
||
table.viewport(),
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
modifiers,
|
||
rectangle.center(),
|
||
)
|
||
processQtEvents()
|
||
|
||
@staticmethod
|
||
def _destroyTable(table):
|
||
"""Release the persistent table, menu, and owned actions through Qt."""
|
||
table.close()
|
||
table.deleteLater()
|
||
|
||
def testContextMenuHasOnlyWidgetScopedMoveShortcuts(self):
|
||
"""Expose only table-scoped Ctrl+Up and Ctrl+Down move commands."""
|
||
with isolatedSettings():
|
||
table = self._table()
|
||
|
||
try:
|
||
actions = tuple(
|
||
action
|
||
for action in table.contextMenu.actions()
|
||
if not action.isSeparator()
|
||
)
|
||
|
||
self.assertEqual(
|
||
[action.textEnglish for action in actions],
|
||
['Move Up', 'Move Down'],
|
||
)
|
||
self.assertNotIn('Delete', [action.textEnglish for action in actions])
|
||
|
||
for action, key in (
|
||
(table.moveUpActionRef, QtCore.Qt.Key.Key_Up),
|
||
(table.moveDownActionRef, QtCore.Qt.Key.Key_Down),
|
||
):
|
||
self.assertEqual(
|
||
action.shortcut(),
|
||
QtGui.QKeySequence(
|
||
QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
key,
|
||
)
|
||
),
|
||
)
|
||
self.assertEqual(
|
||
action.shortcutContext(),
|
||
QtCore.Qt.ShortcutContext.WidgetShortcut,
|
||
)
|
||
self.assertIn(action, table.actions())
|
||
self.assertIs(action.parent(), table)
|
||
finally:
|
||
self._destroyTable(table)
|
||
|
||
def testMultiMoveShortcutsPreserveIdentityCurrentItemAndFocus(self):
|
||
"""Keep noncontiguous selection ready across repeated keyboard moves."""
|
||
with isolatedSettings():
|
||
table = self._table()
|
||
|
||
try:
|
||
self._clickRow(table, 1)
|
||
self._clickRow(
|
||
table,
|
||
3,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
|
||
selected = {'B', 'D'}
|
||
current = 'D'
|
||
|
||
for expected in (
|
||
('B', 'A', 'D', 'C', 'E'),
|
||
('B', 'D', 'A', 'C', 'E'),
|
||
):
|
||
QTest.keyClick(
|
||
table,
|
||
QtCore.Qt.Key.Key_Up,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(tuple(Storage.UserSubs()), expected)
|
||
self.assertEqual(set(table.selectedUniques), selected)
|
||
self.assertEqual(table._currentUnique(), current)
|
||
self.assertTrue(table.hasFocus())
|
||
|
||
QTest.keyClick(
|
||
table,
|
||
QtCore.Qt.Key.Key_Down,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(
|
||
tuple(Storage.UserSubs()),
|
||
('A', 'B', 'D', 'C', 'E'),
|
||
)
|
||
self.assertEqual(set(table.selectedUniques), selected)
|
||
self.assertEqual(table._currentUnique(), current)
|
||
self.assertTrue(table.hasFocus())
|
||
finally:
|
||
self._destroyTable(table)
|
||
|
||
def testMoveShortcutDoesNotFireFromSiblingEditor(self):
|
||
"""Keep subscription movement inactive while a sibling editor has focus."""
|
||
with isolatedSettings():
|
||
window = QWidget()
|
||
layout = QVBoxLayout(window)
|
||
editor = QLineEdit(window)
|
||
table = self._table(parent=window)
|
||
|
||
layout.addWidget(editor)
|
||
layout.addWidget(table)
|
||
window.resize(900, 500)
|
||
window.show()
|
||
window.activateWindow()
|
||
processQtEvents()
|
||
|
||
try:
|
||
self._clickRow(table, 1)
|
||
editor.setFocus()
|
||
|
||
self.assertTrue(waitFor(lambda: application().focusWidget() is editor))
|
||
|
||
before = tuple(Storage.UserSubs())
|
||
|
||
QTest.keyClick(
|
||
editor,
|
||
QtCore.Qt.Key.Key_Down,
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(tuple(Storage.UserSubs()), before)
|
||
self.assertIs(application().focusWidget(), editor)
|
||
finally:
|
||
self._destroyTable(table)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
|
||
class SharedSettingsQtWorkflowTest(unittest.TestCase):
|
||
"""Exercise real Home/Settings controls around one shared controller."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Release repository caches and deferred page/card objects."""
|
||
Storage._UserServersStorage.cache_clear()
|
||
Storage._UserSubsStorage.cache_clear()
|
||
collectAtBoundary()
|
||
|
||
@contextmanager
|
||
def _home(self, settingsController, connectionController, routingController):
|
||
"""Build the smallest side-effect-free real Home composition."""
|
||
registry = mock.Mock()
|
||
registry.protocolDescriptors.return_value = ()
|
||
|
||
with ExitStack() as stack:
|
||
for target, value in (
|
||
('Furious.Window.HomePage.AppSettingsController', settingsController),
|
||
(
|
||
'Furious.Window.HomePage.AppConnectionController',
|
||
connectionController,
|
||
),
|
||
('Furious.Window.HomePage.AppRoutingController', routingController),
|
||
(
|
||
'Furious.Widget.ConnectionButton.AppConnectionController',
|
||
connectionController,
|
||
),
|
||
(
|
||
'Furious.Widget.RoutingSelector.AppRoutingController',
|
||
routingController,
|
||
),
|
||
('Furious.Window.HomePage.getPluginRegistry', registry),
|
||
):
|
||
stack.enter_context(mock.patch(target, return_value=value))
|
||
|
||
stack.enter_context(
|
||
mock.patch.object(HomePage, 'serverImportActions', return_value=())
|
||
)
|
||
|
||
home = HomePage()
|
||
|
||
try:
|
||
yield home
|
||
finally:
|
||
home.trafficStatsManager.cleanup()
|
||
home.userServersQTableWidget.cleanup()
|
||
home.close()
|
||
home.deleteLater()
|
||
|
||
def testHomeTunModeLabelExplainsMissingAdministratorPrivilege(self):
|
||
"""Use the same privilege-aware TUN presentation as Settings."""
|
||
with (
|
||
isolatedSettings(),
|
||
mock.patch(
|
||
'Furious.Controllers.SettingsController.PLATFORM',
|
||
'Windows',
|
||
),
|
||
mock.patch(
|
||
'Furious.Controllers.SettingsController.SystemRuntime.isAdmin',
|
||
return_value=False,
|
||
),
|
||
mock.patch(
|
||
'Furious.Controllers.SettingsController.showMBoxNewChangesNextTime'
|
||
),
|
||
mock.patch(
|
||
'Furious.Window.TunSettingsDialog.PLATFORM',
|
||
'Windows',
|
||
),
|
||
mock.patch(
|
||
'Furious.Window.TunSettingsDialog.ADMINISTRATOR_NAME',
|
||
'Administrator',
|
||
),
|
||
):
|
||
settingsController = SettingsController()
|
||
connectionController = _ConnectionControllerFixture()
|
||
routingController = _RoutingControllerFixture(
|
||
(RoutingOption('default', 'Default'),),
|
||
'default',
|
||
)
|
||
|
||
with self._home(
|
||
settingsController,
|
||
connectionController,
|
||
routingController,
|
||
) as home:
|
||
self.assertEqual(
|
||
gettext(home.tunModeLabel.text(), 'EN'),
|
||
'TUN Mode Disabled (Administrator)',
|
||
)
|
||
self.assertFalse(home.tunModeSwitch.isEnabled())
|
||
|
||
settingsController.deleteLater()
|
||
connectionController.deleteLater()
|
||
routingController.deleteLater()
|
||
|
||
def testHomeAndSettingsControlsSynchronizeWithoutDuplicateSignals(self):
|
||
"""Round-trip real proxy/TUN input without stale or recursive updates."""
|
||
with (
|
||
isolatedSettings(),
|
||
mock.patch('Furious.Controllers.SettingsController.PLATFORM', 'Linux'),
|
||
mock.patch(
|
||
'Furious.Controllers.SettingsController.SystemRuntime.flatpakID',
|
||
return_value='',
|
||
),
|
||
mock.patch(
|
||
'Furious.Controllers.SettingsController.showMBoxNewChangesNextTime'
|
||
),
|
||
):
|
||
AppSettings.set('SystemProxyMode', AppBuiltinProxyMode.Auto.value)
|
||
AppSettings.turnOFF('VPNMode')
|
||
|
||
settingsController = SettingsController()
|
||
connectionController = _ConnectionControllerFixture()
|
||
routingController = _RoutingControllerFixture(
|
||
(RoutingOption('default', 'Default'),),
|
||
'default',
|
||
)
|
||
|
||
with (
|
||
mock.patch(
|
||
'Furious.Window.SettingsPage.AppSettingsController',
|
||
return_value=settingsController,
|
||
),
|
||
self._home(
|
||
settingsController,
|
||
connectionController,
|
||
routingController,
|
||
) as home,
|
||
):
|
||
settingsSurface = QWidget()
|
||
settingsLayout = QVBoxLayout(settingsSurface)
|
||
proxyCard = _SystemProxySettingsCard('System Proxy')
|
||
tunCard = _ToggleSettingsCard(
|
||
'shield-check.svg',
|
||
'VPNMode',
|
||
settingsController.setTUNMode,
|
||
'TUN Mode',
|
||
)
|
||
|
||
settingsController.systemProxyModeChanged.connect(proxyCard.sync)
|
||
settingsController.tunModeChanged.connect(
|
||
tunCard.checkBox.syncCheckedAnimated
|
||
)
|
||
settingsLayout.addWidget(proxyCard)
|
||
settingsLayout.addWidget(tunCard)
|
||
|
||
proxyModes = []
|
||
tunStates = []
|
||
|
||
settingsController.systemProxyModeChanged.connect(proxyModes.append)
|
||
settingsController.tunModeChanged.connect(tunStates.append)
|
||
|
||
home.show()
|
||
settingsSurface.show()
|
||
processQtEvents()
|
||
|
||
QTest.keyClick(
|
||
home.systemProxyComboBox,
|
||
QtCore.Qt.Key.Key_Down,
|
||
)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(
|
||
home.systemProxyComboBox.currentData(),
|
||
AppBuiltinProxyMode.NoChanges.value,
|
||
)
|
||
self.assertEqual(
|
||
proxyCard.comboBox.currentData(),
|
||
AppBuiltinProxyMode.NoChanges.value,
|
||
)
|
||
self.assertEqual(proxyModes, [AppBuiltinProxyMode.NoChanges.value])
|
||
|
||
QTest.keyClick(proxyCard.comboBox, QtCore.Qt.Key.Key_Up)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(
|
||
home.systemProxyComboBox.currentData(),
|
||
AppBuiltinProxyMode.Auto.value,
|
||
)
|
||
self.assertEqual(
|
||
proxyModes,
|
||
[
|
||
AppBuiltinProxyMode.NoChanges.value,
|
||
AppBuiltinProxyMode.Auto.value,
|
||
],
|
||
)
|
||
|
||
QTest.mouseClick(
|
||
home.tunModeSwitch,
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
pos=home.tunModeSwitch.rect().center(),
|
||
)
|
||
|
||
QTest.qWait(40)
|
||
|
||
for switch in (home.tunModeSwitch, tunCard.checkBox):
|
||
self.assertGreater(switch.thumbPosition, 0.0)
|
||
self.assertLess(switch.thumbPosition, 1.0)
|
||
|
||
QTest.qWait(AppQSwitch.AnimationDuration)
|
||
processQtEvents()
|
||
|
||
self.assertTrue(home.tunModeSwitch.isChecked())
|
||
self.assertTrue(tunCard.checkBox.isChecked())
|
||
self.assertEqual(home.tunModeSwitch.thumbPosition, 1.0)
|
||
self.assertEqual(tunCard.checkBox.thumbPosition, 1.0)
|
||
self.assertEqual(tunStates, [True])
|
||
|
||
QTest.mouseClick(
|
||
tunCard.checkBox,
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
pos=tunCard.checkBox.rect().center(),
|
||
)
|
||
|
||
QTest.qWait(40)
|
||
|
||
for switch in (home.tunModeSwitch, tunCard.checkBox):
|
||
self.assertGreater(switch.thumbPosition, 0.0)
|
||
self.assertLess(switch.thumbPosition, 1.0)
|
||
|
||
QTest.qWait(AppQSwitch.AnimationDuration)
|
||
processQtEvents()
|
||
|
||
self.assertFalse(home.tunModeSwitch.isChecked())
|
||
self.assertFalse(tunCard.checkBox.isChecked())
|
||
self.assertEqual(home.tunModeSwitch.thumbPosition, 0.0)
|
||
self.assertEqual(tunCard.checkBox.thumbPosition, 0.0)
|
||
self.assertEqual(tunStates, [True, False])
|
||
|
||
QTest.mouseClick(
|
||
home.tunModeSwitch,
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
pos=home.tunModeSwitch.rect().center(),
|
||
)
|
||
QTest.qWait(20)
|
||
QTest.mouseClick(
|
||
home.tunModeSwitch,
|
||
QtCore.Qt.MouseButton.LeftButton,
|
||
pos=home.tunModeSwitch.rect().center(),
|
||
)
|
||
QTest.qWait(AppQSwitch.AnimationDuration + 40)
|
||
processQtEvents()
|
||
|
||
self.assertFalse(home.tunModeSwitch.isChecked())
|
||
self.assertFalse(tunCard.checkBox.isChecked())
|
||
self.assertEqual(home.tunModeSwitch.thumbPosition, 0.0)
|
||
self.assertEqual(tunCard.checkBox.thumbPosition, 0.0)
|
||
self.assertEqual(tunStates, [True, False, True, False])
|
||
|
||
connectionController.setInteractionEnabled(False)
|
||
processQtEvents()
|
||
|
||
self.assertFalse(home.systemProxyComboBox.isEnabled())
|
||
self.assertFalse(home.tunModeSwitch.isEnabled())
|
||
|
||
connectionController.setInteractionEnabled(True)
|
||
processQtEvents()
|
||
|
||
self.assertTrue(home.systemProxyComboBox.isEnabled())
|
||
self.assertTrue(home.tunModeSwitch.isEnabled())
|
||
|
||
settingsSurface.close()
|
||
settingsSurface.deleteLater()
|
||
|
||
settingsController.deleteLater()
|
||
connectionController.deleteLater()
|
||
routingController.deleteLater()
|
||
|
||
|
||
class RoutingSelectorQtInteractionTest(unittest.TestCase):
|
||
"""Verify semantic routing selection through real combo-box key events."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Drain deferred selector deletion between tests."""
|
||
collectAtBoundary()
|
||
|
||
def testKeyboardSelectionPublishesOnceAndExternalStateDoesNotLoop(self):
|
||
"""Separate one user request from blocked controller-driven refreshes."""
|
||
options = (
|
||
RoutingOption('route-a', 'Route A'),
|
||
RoutingOption('route-b', 'Route B', separatorBefore=True),
|
||
)
|
||
controller = _RoutingControllerFixture(options, 'route-a')
|
||
|
||
with mock.patch(
|
||
'Furious.Widget.RoutingSelector.AppRoutingController',
|
||
return_value=controller,
|
||
):
|
||
parent = QWidget()
|
||
layout = QVBoxLayout(parent)
|
||
selector = RoutingSelector(parent)
|
||
|
||
layout.addWidget(selector)
|
||
parent.show()
|
||
parent.activateWindow()
|
||
selector.setFocus()
|
||
processQtEvents()
|
||
|
||
QTest.keyClick(selector, QtCore.Qt.Key.Key_Down)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(selector.currentData(), 'route-b')
|
||
self.assertEqual(controller.selections, ['route-b'])
|
||
|
||
controller.routing = 'route-a'
|
||
controller.stateChanged.emit(*controller.state())
|
||
processQtEvents()
|
||
|
||
self.assertEqual(selector.currentData(), 'route-a')
|
||
self.assertEqual(controller.selections, ['route-b'])
|
||
|
||
controller.setInteractionEnabled(False)
|
||
processQtEvents()
|
||
|
||
self.assertFalse(selector.isEnabled())
|
||
|
||
parent.close()
|
||
parent.deleteLater()
|
||
|
||
controller.deleteLater()
|
||
|
||
|
||
class SubscriptionEditorQtInteractionTest(unittest.TestCase):
|
||
"""Protect editor completion and destruction through real dialog keys."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Finish transient native deletion and registry cleanup."""
|
||
collectAtBoundary()
|
||
|
||
def testEnterAcceptsValidEditorAndDestroysTransient(self):
|
||
"""Route Return through the default button and delete-on-close lifecycle."""
|
||
dialog = _SubscriptionEditorDialog(
|
||
{
|
||
'remark': 'Fixture',
|
||
'webURL': 'https://example.test/subscription',
|
||
}
|
||
)
|
||
reference = weakref.ref(dialog)
|
||
finished = []
|
||
|
||
dialog.finished.connect(finished.append)
|
||
dialog.open()
|
||
|
||
self.assertTrue(waitFor(dialog.isVisible))
|
||
|
||
dialog.urlEdit.setFocus()
|
||
QTest.keyClick(dialog.urlEdit, QtCore.Qt.Key.Key_Return)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(finished, [int(AppQDialog.DialogCode.Accepted)])
|
||
self.assertFalse(isValid(dialog))
|
||
self.assertEqual(AppQDialog._openDialogs, {})
|
||
|
||
del dialog
|
||
collectAtBoundary()
|
||
|
||
self.assertIsNone(reference())
|
||
|
||
def testEscapeRejectsEditsWithoutMutatingInputAndDestroysTransient(self):
|
||
"""Cancel through Escape without committing or retaining the editor."""
|
||
original = {
|
||
'remark': 'Original',
|
||
'webURL': 'https://example.test/original',
|
||
}
|
||
dialog = _SubscriptionEditorDialog(original)
|
||
reference = weakref.ref(dialog)
|
||
finished = []
|
||
|
||
dialog.finished.connect(finished.append)
|
||
dialog.open()
|
||
|
||
self.assertTrue(waitFor(dialog.isVisible))
|
||
|
||
dialog.remarkEdit.setText('Changed')
|
||
dialog.urlEdit.setText('https://example.test/changed')
|
||
QTest.keyClick(dialog.urlEdit, QtCore.Qt.Key.Key_Escape)
|
||
processQtEvents()
|
||
|
||
self.assertEqual(finished, [int(AppQDialog.DialogCode.Rejected)])
|
||
self.assertEqual(
|
||
original,
|
||
{
|
||
'remark': 'Original',
|
||
'webURL': 'https://example.test/original',
|
||
},
|
||
)
|
||
self.assertFalse(isValid(dialog))
|
||
self.assertEqual(AppQDialog._openDialogs, {})
|
||
|
||
del dialog
|
||
collectAtBoundary()
|
||
|
||
self.assertIsNone(reference())
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|