# Copyright (C) 2024–present Loren Eteval & contributors # # This file is part of Furious. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Exercise real Qt input, focus, model/view, and shared-surface behavior.""" from __future__ import annotations from Furious.Actions.Import import ImportURIsProgressDialog, importURIs from Furious.Backends.Xray.Plugin import XrayPlugin from Furious.Controllers import ConnectionState from Furious.Controllers.SettingsController import SettingsController from Furious.Frozenlib import AppBuiltinProxyMode, AppSettings, Mixins from Furious.Models import CoreConfiguration, ServerProfile from Furious.Plugins import PluginRegistry from Furious.Plugins.API import RoutingOption from Furious.Repository import Storage, SubscriptionGroup from Furious.Repository.Servers import UserServers from Furious.Service import ProfileTestField, ProfileTestResult from Furious.Service.ProfileTesting import ProfileTestTarget from Furious.Qt import ( AppQAction, AppHue, AppQDialog, AppQSwitch, AppStyleSheet, gettext, ) from Furious.Widget.RoutingSelector import RoutingSelector from Furious.Widget.ServerTableView import ( DeleteServersProgressDialog, DuplicateServersProgressDialog, MBoxQuestionDelete, 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 QSignalSpy, QTest from PySide6.QtWidgets import ( QLineEdit, QStyle, QStyleOptionViewItem, QToolButton, 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 _PaintEventCounter(QtCore.QObject): """Count viewport paints without synthesizing an unrelated mouse event.""" def __init__(self, target): """Observe paint events delivered to *target*.""" super().__init__(target) self.target = target self.count = 0 def eventFilter(self, watched, event): """Count paints while preserving normal viewport event delivery.""" if watched is self.target and event.type() == QtCore.QEvent.Type.Paint: self.count += 1 return super().eventFilter(watched, event) 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 testSearchUsesCurrentDisplayedMetadataWithoutAStaleCache(self): """The fast row projection retains regex, literal and live-cell semantics.""" with isolatedSettings(): table = self._table(('alpha[1]', 'beta')) try: table.search('alpha[1]', regex=False) self.assertEqual(table.proxyModel.rowCount(), 1) table.search('ALPHA', caseSensitive=True) self.assertEqual(table.proxyModel.rowCount(), 0) table.search('ALPHA') self.assertEqual(table.proxyModel.rowCount(), 1) table.search('23 ms') self.assertEqual(table.proxyModel.rowCount(), 0) profile = Storage.UserServers()[1] profile.metadata.latency = '23 ms' table.sourceModel.emitRowChanged(1) self.assertEqual(table.proxyModel.rowCount(), 1) self.assertEqual( table.sourceRowFromProxyIndex(table.proxyModel.index(0, 0)), 1 ) profile.metadata.latency = '42 ms' table.sourceModel.emitRowChanged(1) self.assertEqual(table.proxyModel.rowCount(), 0) finally: self._destroyTable(table) def testConnectionStateRepaintsActiveRowWithoutMouseEvent(self): """Invalidate the active row's color through source and proxy models.""" poolType = type(Mixins.ConnectionAware.ObjectsPool) connectionController = _ConnectionControllerFixture() with ( isolatedSettings(), mock.patch.object( Mixins.ConnectionAware, 'ObjectsPool', poolType(), ), mock.patch( 'Furious.Qt.DynamicTheme.AppConnectionController', return_value=connectionController, ), mock.patch('Furious.Qt.DynamicTheme.SystemRuntime.isAdmin') as isAdmin, ): table = self._table(('target', 'zulu', 'alpha')) target = Storage.UserServers()[0] target.metadata.subscriptionSource = 'group-a' target.metadata.subscriptionManaged = True paintCounter = _PaintEventCounter(table.viewport()) table.viewport().installEventFilter(paintCounter) def assertVisualTransition(state, admin, expectedColor, resolvedTheme): """Assert one semantic color invalidation and resulting paint.""" connectionController.state = state isAdmin.return_value = admin sourceSpy = QSignalSpy(table.sourceModel.dataChanged) proxySpy = QSignalSpy(table.proxyModel.dataChanged) paintCounter.count = 0 callback = ( Mixins.ConnectionAware.callConnectedCallback if state is ConnectionState.Connected else Mixins.ConnectionAware.callDisconnectedCallback ) with mock.patch.object( application(), 'theme', return_value=resolvedTheme, ): callback() processQtEvents() activeRow = Storage.UserActivatedItemIndex() sourceIndex = table.sourceModel.index(activeRow, 0) proxyIndex = table.proxyIndexFromSourceRow(activeRow) expectedRoles = ( int(QtCore.Qt.ItemDataRole.ForegroundRole), int(QtCore.Qt.ItemDataRole.DecorationRole), ) self.assertTrue(sourceIndex.isValid()) self.assertTrue(proxyIndex.isValid()) self.assertEqual(sourceSpy.count(), 1) self.assertEqual(proxySpy.count(), 1) sourceArguments = sourceSpy.at(0) proxyArguments = proxySpy.at(0) self.assertEqual(sourceArguments[0].row(), activeRow) self.assertEqual(sourceArguments[0].column(), 0) self.assertEqual(sourceArguments[1].row(), activeRow) self.assertEqual( sourceArguments[1].column(), table.sourceModel.columnCount() - 1, ) self.assertEqual( tuple(int(role) for role in sourceArguments[2]), expectedRoles, ) self.assertEqual(proxyArguments[0].row(), proxyIndex.row()) self.assertEqual(proxyArguments[1].row(), proxyIndex.row()) self.assertEqual( tuple(int(role) for role in proxyArguments[2]), expectedRoles, ) self.assertEqual( table.sourceModel.data( sourceIndex, QtCore.Qt.ItemDataRole.ForegroundRole, ), QtGui.QColor(expectedColor), ) self.assertGreater( paintCounter.count, 0, 'the data change must schedule a paint without mouse movement', ) try: self.assertIsInstance(table, Mixins.ConnectionAware) self.assertEqual( table.sourceModel.data( table.sourceModel.index(0, 0), QtCore.Qt.ItemDataRole.ForegroundRole, ), QtGui.QColor(AppHue.disconnectedColor()), ) assertVisualTransition( ConnectionState.Connected, False, AppHue.ColorRGB.LIGHT_RED, 'Dark', ) assertVisualTransition( ConnectionState.Disconnected, False, AppHue.disconnectedColor(), 'Light', ) table.search('target') table.proxyModel.setSubscriptionFilter('group-a') table.sortByColumn(0, QtCore.Qt.SortOrder.DescendingOrder) processQtEvents() self.assertIs( Storage.UserServers()[Storage.UserActivatedItemIndex()], target, ) assertVisualTransition( ConnectionState.Connected, True, AppHue.ColorRGB.LIGHT_PURPLE, 'Light', ) assertVisualTransition( ConnectionState.Disconnected, True, AppHue.disconnectedColor(), 'Dark', ) finally: self._destroyTable(table) 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 testTestActionsUseOnlyMultithreadedDownloadSpeedAction(self): """Expose one download command backed by the multithreaded scheduler.""" with isolatedSettings(): table = self._table(('one',)) try: actions = tuple( action for action in table.testActions if isinstance(action, AppQAction) 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() @contextmanager def _deleteConfirmation(self, table): """Open the real asynchronous prompt and release it after each case.""" confirmation = MBoxQuestionDelete(parent=table) try: with mock.patch( 'Furious.Widget.SubscriptionTableView.MBoxQuestionDelete', return_value=confirmation, ): table.deleteSelectedItem() self.assertTrue(waitFor(confirmation.isVisible)) yield confirmation finally: if isValid(confirmation): confirmation.close() processQtEvents() def testDeleteConfirmationKeepsCapturedGroupsAfterReordering(self): """Delete the original IDs at their current rows despite selection changes.""" for position, expectedRows in ( (None, (1, 2)), ('down', (2, 3)), ('up', (0, 1)), ): with self.subTest(position=position), isolatedSettings(): Storage._UserSubsStorage.cache_clear() table = self._table() table.subsManager = mock.Mock() table.deleteUniqueCallback = mock.Mock() try: self._clickRow(table, 1) self._clickRow(table, 3, QtCore.Qt.KeyboardModifier.ControlModifier) with self._deleteConfirmation(table) as confirmation: if position is not None: table.moveSelectedGroups(position) table.clearSelection() table.setCurrentIndex(table.sourceModel.index(0, 0)) removed = QSignalSpy(table.sourceModel.rowsRemoved) changed = QSignalSpy(table.groupsChanged) confirmation.done(int(confirmation.StandardButton.Yes)) processQtEvents() self.assertEqual(tuple(Storage.UserSubs()), ('A', 'C', 'E')) self.assertEqual( [item['sortOrder'] for item in Storage.UserSubs().values()], [0, 1, 2], ) self.assertEqual( table.deleteUniqueCallback.call_args_list, [mock.call('B'), mock.call('D')], ) self.assertEqual( table.subsManager.removeAutoUpdate.call_args_list, [mock.call('B'), mock.call('D')], ) self.assertEqual(removed.count(), 2) self.assertEqual( [(removed.at(i)[1], removed.at(i)[2]) for i in range(2)], [(row, row) for row in expectedRows], ) self.assertEqual(changed.count(), 1) self.assertFalse(isValid(confirmation)) finally: self._destroyTable(table) def testDeleteConfirmationSkipsMissingGroupsAndPreservesNewGroups(self): """Ignore vanished targets without retargeting their replacement rows.""" for missing in (('B',), ('B', 'D')): with self.subTest(missing=missing), isolatedSettings(): Storage._UserSubsStorage.cache_clear() table = self._table() table.subsManager = mock.Mock() table.deleteUniqueCallback = mock.Mock() try: self._clickRow(table, 1) self._clickRow(table, 3, QtCore.Qt.KeyboardModifier.ControlModifier) with self._deleteConfirmation(table) as confirmation: for unique in missing: row = tuple(Storage.UserSubs()).index(unique) table.sourceModel.beginRemoveRows( QtCore.QModelIndex(), row, row ) Storage.removeSubscriptionGroup(unique) table.sourceModel.endRemoveRows() table.appendNewItem(unique='F', remark='F') removed = QSignalSpy(table.sourceModel.rowsRemoved) changed = QSignalSpy(table.groupsChanged) expectedCalls = [] if 'D' in missing else [mock.call('D')] confirmation.done(int(confirmation.StandardButton.Yes)) processQtEvents() self.assertEqual( tuple(Storage.UserSubs()), ('A', 'C', 'E', 'F') ) self.assertEqual( table.deleteUniqueCallback.call_args_list, expectedCalls ) self.assertEqual( table.subsManager.removeAutoUpdate.call_args_list, expectedCalls, ) self.assertEqual(removed.count(), len(expectedCalls)) self.assertEqual(changed.count(), len(expectedCalls)) self.assertFalse(isValid(confirmation)) finally: self._destroyTable(table) def testDeleteConfirmationRejectionLeavesGroupsUntouched(self): """Keep cancellation and window-close paths free of deletion side effects.""" for closeWindow in (False, True): with self.subTest(closeWindow=closeWindow), isolatedSettings(): Storage._UserSubsStorage.cache_clear() table = self._table() table.subsManager = mock.Mock() table.deleteUniqueCallback = mock.Mock() try: self._clickRow(table, 1) with self._deleteConfirmation(table) as confirmation: removed = QSignalSpy(table.sourceModel.rowsRemoved) changed = QSignalSpy(table.groupsChanged) if closeWindow: confirmation.close() else: confirmation.done(int(confirmation.StandardButton.No)) processQtEvents() self.assertEqual( tuple(Storage.UserSubs()), ('A', 'B', 'C', 'D', 'E') ) table.deleteUniqueCallback.assert_not_called() table.subsManager.removeAutoUpdate.assert_not_called() self.assertEqual(removed.count(), 0) self.assertEqual(changed.count(), 0) self.assertFalse(isValid(confirmation)) finally: self._destroyTable(table) 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, *, importActions=(), ): """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.Widget.ServerTableView.AppConnectionController', connectionController, ), ( '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=importActions ) ) home = HomePage() try: yield home finally: home.trafficStatsManager.cleanup() home.userServersQTableWidget.cleanup() home.close() home.deleteLater() def testHomeSearchDebouncesTypingAndRetainsStableProfileIdentity(self): """Search without Enter, then clear immediately without stale timer work.""" with isolatedSettings(): settings = SettingsController() connection = _ConnectionControllerFixture() routing = _RoutingControllerFixture( (RoutingOption('default', 'Default'),), 'default' ) try: with self._home(settings, connection, routing) as home: table = home.userServersQTableWidget profiles = [ ServerTableQtInteractionTest._profile(name) for name in ('alpha', 'beta') ] for profile in profiles: table.appendNewItemByFactory(profile) home.show() home.activateWindow() table.setFocus() processQtEvents() QTest.keyClick(table, QtCore.Qt.Key_F, QtCore.Qt.ControlModifier) self.assertTrue(waitFor(home.searchLineEdit.hasFocus)) with mock.patch.object( table, 'search', wraps=table.search ) as search: QTest.keyClicks(home.searchLineEdit, 'alpha') self.assertEqual(search.call_count, 0) self.assertTrue( waitFor(lambda: table.proxyModel.rowCount() == 1) ) self.assertEqual(search.call_count, 1) self.assertEqual( table.sourceRowFromProxyIndex(table.proxyModel.index(0, 0)), 0, ) home.searchLineEdit.setText('beta') home.searchLineEdit.clear() self.assertEqual(table.proxyModel.rowCount(), 2) self.assertFalse(home._searchTimer.isActive()) home.searchLineEdit.setText('beta') QTest.keyClick(home.searchLineEdit, QtCore.Qt.Key_Return) self.assertEqual(table.proxyModel.rowCount(), 1) self.assertFalse(home._searchTimer.isActive()) self.assertEqual( table.sourceRowFromProxyIndex(table.proxyModel.index(0, 0)), 1, ) self.assertEqual(list(Storage.UserServers()), profiles) home.searchLineEdit.setText('alpha') home.hide() self.assertFalse(home._searchTimer.isActive()) home.show() self.assertEqual(table.proxyModel.rowCount(), 1) self.assertEqual( table.sourceRowFromProxyIndex(table.proxyModel.index(0, 0)), 0 ) finally: settings.deleteLater() connection.deleteLater() routing.deleteLater() def testHomeFavoritesButtonFiltersWithoutChangingActivation(self): with isolatedSettings(): settings = SettingsController() connection = _ConnectionControllerFixture() routing = _RoutingControllerFixture( (RoutingOption('default', 'Default'),), 'default' ) try: with self._home(settings, connection, routing) as home: table = home.userServersQTableWidget profiles = [ ServerTableQtInteractionTest._profile(name) for name in ('alpha', 'beta') ] profiles[1].metadata.favorite = True table.appendNewItemsByFactories(profiles) activated = Storage.UserActivatedItemIndex() home.show() home.favoritesButton.setFocus() QTest.keyClick(home.favoritesButton, QtCore.Qt.Key_Space) self.assertTrue(home.favoritesButton.isChecked()) self.assertEqual( table._visibleProfileIds(), [profiles[1].metadata.profileId] ) self.assertEqual(Storage.UserActivatedItemIndex(), activated) table.search('alpha') self.assertEqual(table.proxyModel.rowCount(), 0) self.assertTrue(home.emptyState.isVisible()) QTest.keyClick(home.favoritesButton, QtCore.Qt.Key_Space) self.assertEqual( table._visibleProfileIds(), [profiles[0].metadata.profileId] ) finally: settings.deleteLater() connection.deleteLater() routing.deleteLater() def testHomeTestsMenuPreservesSelectionAndHighlight(self): """Keep mouse-opened tests tied to visibly selected, mapped profiles.""" with isolatedSettings(): settings = SettingsController() connection = _ConnectionControllerFixture() routing = _RoutingControllerFixture( (RoutingOption('default', 'Default'),), 'default' ) profiles = [ ServerTableQtInteractionTest._profile(name) for name in ('match zeta', 'hidden', 'match alpha') ] Storage.UserServers().extend(profiles) try: with self._home(settings, connection, routing) as home: home.resize(1000, 600) home.show() home.activateWindow() table = home.userServersQTableWidget home.searchLineEdit.setText('match') home.applySearch() table.sortByColumn(0, QtCore.Qt.AscendingOrder) processQtEvents() expectedIds = { p.metadata.profileId for p in (profiles[0], profiles[2]) } def selectionColor(): # Grab in logical widget coordinates; raw image pixels # otherwise need conversion for the device pixel ratio. return ( table.viewport() .grab(QtCore.QRect(sample, QtCore.QSize(1, 1))) .toImage() .pixelColor(0, 0) ) for theme in (AppStyleSheet.Light, AppStyleSheet.Dark): with self.subTest(theme=theme), mock.patch.object( application(), 'theme', return_value=theme ): home.setStyleSheet(AppStyleSheet.forTheme(theme)) table.setFocus() QTest.mouseClick( table.viewport(), QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, table.visualRect(table.proxyModel.index(0, 0)).center(), ) QTest.mouseClick( table.viewport(), QtCore.Qt.LeftButton, QtCore.Qt.ControlModifier, table.visualRect(table.proxyModel.index(1, 0)).center(), ) QTest.keyRelease(table, QtCore.Qt.Key_Control) processQtEvents() # The address is empty in this fixture. Sample its # background rather than a font-dependent remark glyph. index = table.proxyModel.index(0, 2) self.assertEqual(index.data(), '') sample = table.visualRect(index).center() before = selectionColor() self.assertEqual( before, QtGui.QColor( AppStyleSheet.paletteForTheme(theme)['selection'] ), ) # A real click can paint between press and release. QTest.mousePress(home.testButton, QtCore.Qt.LeftButton) processQtEvents() self.assertFalse(home.testMenu.isVisible()) self.assertEqual( selectionColor(), before, ) QTest.mouseRelease(home.testButton, QtCore.Qt.LeftButton) processQtEvents() self.assertTrue(home.testMenu.isVisible()) self.assertEqual( { Storage.UserServers()[i].metadata.profileId for i in table.selectedIndex }, expectedIds, ) self.assertEqual( selectionColor(), before, ) with mock.patch.object( table.profileTestManager, 'testPing' ) as testPing: home.testMenu.setActiveAction(table.testActions[0]) QTest.keyClick(home.testMenu, QtCore.Qt.Key_Return) processQtEvents() testPing.assert_called_once() self.assertEqual( { p.metadata.profileId for p in testPing.call_args.args[0] }, expectedIds, ) # macOS offscreen activates popups but does not # reactivate their owner on dismissal. Supply only # window activation; Qt must retain the focused child. home.activateWindow() self.assertTrue(waitFor(home.testButton.hasFocus)) self.assertEqual( selectionColor(), before, ) # A cancelled click must not open a menu or leave a # highlight override after focus moves elsewhere. QTest.mousePress(home.testButton, QtCore.Qt.LeftButton) QTest.mouseRelease( home.testButton, QtCore.Qt.LeftButton, pos=QtCore.QPoint(-10, -10), ) processQtEvents() self.assertFalse(home.testMenu.isVisible()) home.searchLineEdit.setFocus() processQtEvents() self.assertFalse(table.property('keepSelectionHighlighted')) self.assertEqual( selectionColor(), QtGui.QColor( AppStyleSheet.paletteForTheme(theme)['raised'] ), ) # Opening and dismissing from the keyboard returns to the button. home.testButton.setFocus(QtCore.Qt.TabFocusReason) processQtEvents() before = selectionColor() QTest.keyPress(home.testButton, QtCore.Qt.Key_Space) processQtEvents() self.assertEqual(selectionColor(), before) QTest.keyRelease(home.testButton, QtCore.Qt.Key_Space) processQtEvents() self.assertTrue(home.testMenu.isVisible()) QTest.keyClick(home.testMenu, QtCore.Qt.Key_Escape) processQtEvents() home.activateWindow() self.assertTrue(waitFor(home.testButton.hasFocus)) home.searchLineEdit.setFocus() with mock.patch.object( table.profileTestManager, 'testPing' ) as testPing: QTest.keyClick( home.searchLineEdit, QtCore.Qt.Key_P, QtCore.Qt.ControlModifier, ) QTest.keyRelease(home.searchLineEdit, QtCore.Qt.Key_Control) processQtEvents() testPing.assert_not_called() finally: settings.deleteLater() connection.deleteLater() routing.deleteLater() def testHomeEmptyStateRecoversFilteredProfilesAndReusesActions(self): """Use existing menus, search clear and group selection to recover profiles.""" with isolatedSettings(): AppSettings.set('Language', 'EN') settings = SettingsController() connection = _ConnectionControllerFixture() routing = _RoutingControllerFixture( (RoutingOption('default', 'Default'),), 'default' ) imported = [] action = AppQAction( 'Fixture import', callback=lambda: imported.append(True), shortcut=QtCore.QKeyCombination( QtCore.Qt.ControlModifier, QtCore.Qt.Key_V ), translatable=False, ) try: with self._home( settings, connection, routing, importActions=(action,) ) as home: home.resize(1000, 600) home.show() home.activateWindow() processQtEvents() self.assertTrue(home.emptyState.isVisible()) self.assertIn('No profiles yet', home.emptyStateLabel.text()) self.assertIs(home.importMenu.actions()[0], action) self.assertNotIn( action, home.userServersQTableWidget.contextMenu.actions() ) QTest.mouseClick(home.importButton, QtCore.Qt.LeftButton) processQtEvents() QTest.keyClick(home.importMenu, QtCore.Qt.Key_Down) QTest.keyClick(home.importMenu, QtCore.Qt.Key_Return) processQtEvents() self.assertEqual(imported, [True]) table = home.userServersQTableWidget home.activateWindow() table.setFocus() self.assertTrue(waitFor(table.hasFocus)) QTest.keyClick(table, QtCore.Qt.Key_V, QtCore.Qt.ControlModifier) processQtEvents() self.assertEqual(imported, [True, True]) self.assertEqual( action.shortcutContext(), QtCore.Qt.ShortcutContext.WidgetShortcut, ) profile = ServerTableQtInteractionTest._profile('alpha') home.userServersQTableWidget.appendNewItemByFactory(profile) processQtEvents() self.assertFalse(home.emptyState.isVisible()) home.searchLineEdit.setFocus() QTest.keyClicks(home.searchLineEdit, 'missing') QTest.keyClick(home.searchLineEdit, QtCore.Qt.Key_Return) processQtEvents() self.assertTrue(home.emptyState.isVisible()) QTest.mouseClick( home.searchLineEdit.findChild(QToolButton), QtCore.Qt.LeftButton ) processQtEvents() self.assertFalse(home.emptyState.isVisible()) self.assertTrue(home.searchLineEdit.hasFocus()) self.assertEqual(home.searchLineEdit.text(), '') self.assertIs(Storage.UserServers()[0], profile) home.subscriptionFilterComboBox.addItem( 'Empty group', 'missing-group' ) home.subscriptionFilterComboBox.setCurrentIndex(2) processQtEvents() self.assertTrue(home.emptyState.isVisible()) home.subscriptionFilterComboBox.setFocus() QTest.keyClick(home.subscriptionFilterComboBox, QtCore.Qt.Key_Home) processQtEvents() self.assertEqual(home.subscriptionFilterComboBox.currentIndex(), 0) self.assertEqual( home.userServersQTableWidget.proxyModel.rowCount(), 1 ) testActions = [ action for action in table.testActions if isinstance(action, AppQAction) ] contextActions = table.contextMenu.actions() testSection = home.testMenu.actions() self.assertEqual(len(testActions), 5) self.assertEqual( [action for action in testSection if not action.isSeparator()], testActions, ) self.assertEqual( [ None if action.isSeparator() else action.text() for action in testSection ], [ 'Test Ping Latency', 'Test Tcping Latency', 'Test Download Speed', None, 'Clear Test Results', None, 'Stop All Tests', ], ) self.assertTrue(testActions[-1].icon().isNull()) self.assertIs(home.testButton.popupMenu(), home.testMenu) for testAction in testActions: self.assertNotIn(testAction, contextActions) self.assertIn(testAction, table.actions()) table.setFocus() processQtEvents() for key, method in ( (QtCore.Qt.Key_P, 'testSelectedItemPingLatency'), (QtCore.Qt.Key_O, 'testSelectedItemTcpingLatency'), (QtCore.Qt.Key_M, 'testSelectedItemDownloadSpeedMulti'), (QtCore.Qt.Key_R, 'clearSelectedItemTestResult'), ): with mock.patch.object(table, method) as callback: QTest.keyClick(table, key, QtCore.Qt.ControlModifier) processQtEvents() callback.assert_called_once_with() manager = home.userServersQTableWidget.profileTestManager with mock.patch.object( manager._latencyScheduler, 'cancelAll' ) as cancel: table = home.userServersQTableWidget menu = home.testMenu QTest.mouseClick(home.testButton, QtCore.Qt.LeftButton) processQtEvents() menu.setActiveAction(table.testActions[-1]) QTest.keyClick(menu, QtCore.Qt.Key_Return) processQtEvents() cancel.assert_called_once_with() home.userServersQTableWidget.deleteItemByIndex( [0], showTrayMessage=False, showProgress=False ) processQtEvents() self.assertTrue(home.emptyState.isVisible()) self.assertIn('No profiles yet', home.emptyStateLabel.text()) finally: settings.deleteLater() connection.deleteLater() routing.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(), ) for switch in (home.tunModeSwitch, tunCard.checkBox): self.assertEqual( switch._animation.state(), QtCore.QAbstractAnimation.State.Running, ) QTest.qWait(AppQSwitch.AnimationDuration + 40) 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(), ) for switch in (home.tunModeSwitch, tunCard.checkBox): self.assertEqual( switch._animation.state(), QtCore.QAbstractAnimation.State.Running, ) 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]) 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()) class ProfileMutationBatchTest(unittest.TestCase): """Exercise real model mutations with bounded progress and stable targets.""" @classmethod def setUpClass(cls): application() def tearDown(self): collectAtBoundary() @staticmethod def profile(name): return ServerProfile.fromConfiguration( CoreConfiguration({'type': 'fixture'}), {'displayName': name} ) @contextmanager def table(self, count=0): controller = mock.Mock() controller.isConnected.return_value = False with ( isolatedSettings(), mock.patch.object(Storage, 'UserServers', return_value=[]), mock.patch( 'Furious.Widget.ServerTableView.AppConnectionController', return_value=controller, ), ): Storage.UserServers().extend( self.profile(f'profile-{index:04}') for index in range(count) ) AppSettings.set('ActivatedItemIndex', str(count - 1)) table = ServerTableView( configurationEditorFactory=QWidget, qrCodeWindowFactory=QWidget, importActionsFactory=tuple, ) table.sourceModel.refreshIndexes() try: yield table, controller finally: if isValid(table): table.cleanup() table.close() table.deleteLater() processQtEvents() def testFavoriteActionsPreserveIdentityAndComposeFilters(self): with self.table(4) as (table, controller): profiles = list(Storage.UserServers()) repository = UserServers() repository.data().extend(profiles) profiles[0].metadata.subscriptionSource = 'group-a' profiles[0].metadata.subscriptionManaged = True profiles[0].metadata.subscriptionProfileKey = 'remote-key' profiles[1].metadata.subscriptionSource = 'group-b' table.selectAll() metadataChanged = QSignalSpy(table.sourceModel.dataChanged) with mock.patch.object( Storage, '_UserServersStorage', return_value=repository ): add = next( action for action in table.contextMenu.actions() if not action.isSeparator() and action.textCompare('Add To Favorites') ) add.trigger() self.assertTrue(all(profile.metadata.favorite for profile in profiles)) self.assertEqual(metadataChanged.count(), 1) self.assertIn( QtCore.Qt.ItemDataRole.DecorationRole, metadataChanged.at(0)[2], ) remark = table.sourceModel.index(0, 0) self.assertEqual(remark.data(), profiles[0].itemRemark) self.assertFalse( remark.data(QtCore.Qt.ItemDataRole.DecorationRole).isNull() ) add.trigger() self.assertEqual(metadataChanged.count(), 1) table.filterFavorites(True) table.filterBySubscription('group-a') table.search('profile-0000') self.assertEqual( table._visibleProfileIds(), [profiles[0].metadata.profileId] ) table.selectAll() remove = next( action for action in table.contextMenu.actions() if not action.isSeparator() and action.textCompare('Remove From Favorites') ) remove.trigger() self.assertIsNone(remark.data(QtCore.Qt.ItemDataRole.DecorationRole)) self.assertEqual(table.proxyModel.rowCount(), 0) self.assertEqual(table._selectedProfileIds(), []) self.assertIs(Storage.UserServers()[0], profiles[0]) self.assertEqual( profiles[0].metadata.subscriptionProfileKey, 'remote-key' ) self.assertEqual(Storage.UserActivatedItemIndex(), 3) controller.startReconnection.assert_not_called() table.filterFavorites(False) self.assertEqual( table._visibleProfileIds(), [profiles[0].metadata.profileId] ) def testFavoriteSVGDecorationTracksTableThemeWithoutChangingRemark(self): with self.table(2) as (table, _controller): profiles = Storage.UserServers() profiles[0].metadata.favorite = True model = table.sourceModel remark = model.index(0, 0) self.assertEqual(remark.data(), profiles[0].itemRemark) self.assertEqual( remark.data(QtCore.Qt.ItemDataRole.ToolTipRole), profiles[0].itemRemark ) self.assertEqual(remark.data(model.SortRole), profiles[0].itemRemark) self.assertIsNone( model.index(1, 0).data(QtCore.Qt.ItemDataRole.DecorationRole) ) self.assertIsNone( model.index(0, 1).data(QtCore.Qt.ItemDataRole.DecorationRole) ) table.show() for theme in ( AppStyleSheet.Light, AppStyleSheet.Dark, AppStyleSheet.Light, ): with self.subTest(theme=theme): table.setStyleSheet(AppStyleSheet.forTheme(theme)) processQtEvents() color = table.palette().color(QtGui.QPalette.ColorRole.Text) index = table.proxyIndexFromSourceIndex(remark) option = QStyleOptionViewItem() option.initFrom(table) table.itemDelegate().initStyleOption(option, index) self.assertEqual(option.text, profiles[0].itemRemark) self.assertFalse(option.icon.isNull()) image = option.icon.pixmap(16, 16).toImage() colors = { image.pixelColor(x, y).name() for x in range(image.width()) for y in range(image.height()) if image.pixelColor(x, y).alpha() == 255 } self.assertEqual(colors, {QtGui.QColor(color).name()}) self.assertEqual( image.pixelColor( image.width() // 2, image.height() // 2 ).alpha(), 0, ) def testFavoriteSVGMatchesRemarkColorAcrossConnectionStatesAndPrivileges(self): with self.table(2) as (table, controller): for profile in Storage.UserServers(): profile.metadata.favorite = True table.activateItemByIndex(0, True) changes = QSignalSpy(table.sourceModel.dataChanged) colors = {} with ( mock.patch( 'Furious.Qt.DynamicTheme.AppConnectionController', return_value=controller, ), mock.patch('Furious.Qt.DynamicTheme.SystemRuntime.isAdmin') as isAdmin, ): table.show() for theme in (AppStyleSheet.Light, AppStyleSheet.Dark): table.setStyleSheet(AppStyleSheet.forTheme(theme)) for connected, admin in ( (False, False), (True, False), (True, True), (False, True), ): with self.subTest( theme=theme, connected=connected, admin=admin ): controller.isConnected.return_value = connected isAdmin.return_value = admin if connected: table.connectedCallback() else: table.disconnectedCallback() processQtEvents() self.assertIn( QtCore.Qt.ItemDataRole.DecorationRole, changes.at(changes.count() - 1)[2], ) index = table.proxyIndexFromSourceRow(0) foreground = index.data( QtCore.Qt.ItemDataRole.ForegroundRole ) self.assertEqual( foreground, QtGui.QColor(AppHue.currentColor()) ) colors[connected, admin] = foreground.name() option = QStyleOptionViewItem() option.initFrom(table) table.itemDelegate().initStyleOption(option, index) self.assertEqual( option.palette.color(QtGui.QPalette.ColorRole.Text), foreground, ) for mode in ( QtGui.QIcon.Mode.Normal, QtGui.QIcon.Mode.Active, ): for size in (16, 32): image = option.icon.pixmap( size, size, mode ).toImage() opaqueColors = { image.pixelColor(x, y).name() for x in range(image.width()) for y in range(image.height()) if image.pixelColor(x, y).alpha() == 255 } self.assertEqual(opaqueColors, {foreground.name()}) self.assertNotEqual(colors[True, False], colors[True, True]) self.assertEqual(colors[False, False], colors[False, True]) table.activateItemByIndex(1, True) processQtEvents() oldIndex = table.proxyIndexFromSourceRow(0) self.assertIsNone(oldIndex.data(QtCore.Qt.ItemDataRole.ForegroundRole)) image = ( oldIndex.data(QtCore.Qt.ItemDataRole.DecorationRole) .pixmap(16, 16) .toImage() ) opaqueColors = { image.pixelColor(x, y).name() for x in range(image.width()) for y in range(image.height()) if image.pixelColor(x, y).alpha() == 255 } self.assertEqual( opaqueColors, {table.palette().color(QtGui.QPalette.ColorRole.Text).name()}, ) self.assertEqual( table.proxyIndexFromSourceRow(1).data( QtCore.Qt.ItemDataRole.ForegroundRole ), QtGui.QColor(AppHue.currentColor()), ) def testSelectedFavoriteIconMatchesRenderedRemarkAndRestoresProfileColor(self): with self.table(2) as (table, controller): for profile in Storage.UserServers(): profile.metadata.favorite = True table.activateItemByIndex(0, True) table.resize(900, 220) focusWindow = QWidget() try: with ( mock.patch( 'Furious.Qt.DynamicTheme.AppConnectionController', return_value=controller, ), mock.patch( 'Furious.Qt.DynamicTheme.SystemRuntime.isAdmin' ) as isAdmin, ): table.show() for theme in (AppStyleSheet.Light, AppStyleSheet.Dark): table.setStyleSheet(AppStyleSheet.forTheme(theme)) for connected, admin in ( (False, False), (True, False), (True, True), ): controller.isConnected.return_value = connected isAdmin.return_value = admin activeRow = int(admin) table.activateItemByIndex(activeRow, True) table.sourceModel.emitAllChanged() for active, keepHighlight in ( (True, False), (False, False), (False, True), ): table.setProperty( 'keepSelectionHighlighted', keepHighlight ) if active: table.activateWindow() table.setFocus() else: focusWindow.show() focusWindow.activateWindow() focusWindow.setFocus() processQtEvents() self.assertEqual(table.isActiveWindow(), active) for selected in (False, True, False): table.clearSelection() if selected: table.selectAll() processQtEvents() backgrounds = {} for row in (0, 1): with self.subTest( theme=theme, connected=connected, admin=admin, active=active, keepHighlight=keepHighlight, selected=selected, row=row, ): if selected: role = ( 'selection_text' if active or keepHighlight else 'text' ) color = QtGui.QColor( AppStyleSheet.Palettes[theme][role] ) elif row == activeRow: color = QtGui.QColor( AppHue.currentColor() ) else: color = table.palette().color( QtGui.QPalette.ColorRole.Text ) index = table.proxyIndexFromSourceRow(row) cellRect = table.visualRect(index) image = ( table.viewport() .grab(cellRect) .toImage() ) if selected: backgrounds[row] = image.pixelColor( image.width() // 2, round(5 * image.devicePixelRatio()), ) option = QStyleOptionViewItem() option.initFrom(table) option.widget = table option.rect = cellRect option.decorationSize = table.iconSize() table.itemDelegate().initStyleOption( option, index ) for element in ( QStyle.SubElement.SE_ItemViewItemDecoration, QStyle.SubElement.SE_ItemViewItemText, ): region = ( table.style() .subElementRect( element, option, table ) .translated(-cellRect.topLeft()) ) ratio = image.devicePixelRatio() pixels = [ image.pixelColor(x, y) for x in range( round(region.left() * ratio), round( (region.right() + 1) * ratio ), ) for y in range( round(region.top() * ratio), round( (region.bottom() + 1) * ratio ), ) ] self.assertTrue(pixels) closest = min( max( abs(pixel.red() - color.red()), abs( pixel.green() - color.green() ), abs( pixel.blue() - color.blue() ), ) for pixel in pixels ) self.assertLessEqual( closest, 12, element.name ) if selected: self.assertEqual(backgrounds[0], backgrounds[1]) finally: focusWindow.close() focusWindow.deleteLater() table.setProperty('keepSelectionHighlighted', False) def testSmallDuplicationPublishesOneBatchOfIndependentManualCopies(self): with self.table(4) as (table, controller): originals = list(Storage.UserServers()) for profile in originals: profile.metadata.subscriptionSource = 'group-a' profile.metadata.subscriptionManaged = True profile.metadata.subscriptionProfileKey = profile.metadata.profileId originals[0].metadata.favorite = True table.sourceModel.sort(0, QtCore.Qt.DescendingOrder) table.search('000[01]') table.selectAll() selected = table._selectedProfileIds() inserted = QSignalSpy(table.sourceModel.rowsInserted) with mock.patch.object( table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs ) as reconcile: table.duplicateSelectedItem() self.assertEqual(reconcile.call_count, 1) self.assertEqual(inserted.count(), 1) self.assertEqual(inserted.at(0)[1:], [4, 5]) copies = Storage.UserServers()[4:] self.assertEqual( {profile.itemRemark for profile in copies}, {'profile-0000', 'profile-0001'}, ) self.assertTrue( all(profile.metadata.profileId not in selected for profile in copies) ) self.assertTrue( all( not profile.itemSubscriptionManaged and not profile.itemSubscription for profile in copies ) ) self.assertTrue( all(not profile.metadata.subscriptionProfileKey for profile in copies) ) self.assertTrue( next( profile for profile in copies if profile.itemRemark == 'profile-0000' ).metadata.favorite ) copies[0].connection['changed'] = True self.assertTrue( all('changed' not in profile.connection for profile in originals) ) self.assertEqual(table._selectedProfileIds(), selected) controller.startDisconnection.assert_not_called() def testDuplicationKeepsCapturedTargetsAcrossReorderRemovalAndCancel(self): with self.table(7) as (table, controller), mock.patch( 'Furious.Widget.ServerTableView.singleShotWeakly' ): originals = list(Storage.UserServers()) dialog = DuplicateServersProgressDialog( table, [profile.metadata.profileId for profile in originals] ) dialog.BatchSize = 2 dialog.BatchTimeBudget = 10 dialog.duplicateNext() table.sourceModel.sort(0, QtCore.Qt.DescendingOrder) table.deleteItemByIndex( [Storage.UserServers().index(originals[2])], showProgress=False ) table.clearSelection() table.appendNewItemByFactory(self.profile('newcomer')) dialog.duplicateNext() dialog.cancel() dialog.duplicateNext() dialog.duplicateNext() self.assertEqual(dialog.copiedCount, 3) self.assertTrue(dialog.finishedDuplication) self.assertEqual(dialog.profileIds, []) originalIds = {profile.metadata.profileId for profile in originals} copies = [ profile for profile in Storage.UserServers() if profile.metadata.profileId not in originalIds and profile.itemRemark != 'newcomer' ] self.assertEqual( sorted(profile.itemRemark for profile in copies), ['profile-0000', 'profile-0001', 'profile-0003'], ) def testLargeDuplicationCoalescesInsertionsAndSkipsInvalidProfiles(self): with self.table(1000) as (table, controller), mock.patch( 'Furious.Widget.ServerTableView.singleShotWeakly' ), mock.patch( 'Furious.Widget.ServerTableView.time.monotonic', return_value=10.0 ), mock.patch.object( table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs ) as reconcile: originals = list(Storage.UserServers()) originals[500].connection.clear() dialog = DuplicateServersProgressDialog( table, [profile.metadata.profileId for profile in originals] ) inserted = QSignalSpy(table.sourceModel.rowsInserted) for _batch in range(8): dialog.duplicateNext() self.assertEqual(dialog.copiedCount, 999) self.assertEqual(len(Storage.UserServers()), 1999) self.assertEqual(inserted.count(), 8) self.assertEqual(reconcile.call_count, 8) self.assertEqual( len({profile.metadata.profileId for profile in Storage.UserServers()}), 1999, ) self.assertIs( Storage.UserServers()[Storage.UserActivatedItemIndex()], originals[-1] ) def testDuplicationPreparationFailureKeepsOnlyCompletedBatches(self): with self.table(5) as (table, controller), mock.patch( 'Furious.Widget.ServerTableView.singleShotWeakly' ), mock.patch('Furious.Widget.ServerTableView.AppQMessageBox') as message: originals = list(Storage.UserServers()) dialog = DuplicateServersProgressDialog( table, [profile.metadata.profileId for profile in originals] ) dialog.BatchSize = 2 dialog.BatchTimeBudget = 10 dialog.duplicateNext() with mock.patch.object( originals[3], 'independentCopy', side_effect=ValueError('secret fixture'), ), self.assertLogs( 'Furious.Widget.ServerTableView', level='ERROR' ) as logged: dialog.duplicateNext() self.assertEqual(len(Storage.UserServers()), 7) self.assertEqual(dialog.copiedCount, 2) self.assertTrue(dialog.finishedDuplication) self.assertNotIn('secret fixture', '\n'.join(logged.output)) message.return_value.open.assert_called_once() def testLargeDuplicationYieldsToRealCancelAndOwnerDestruction(self): with self.table(1000) as (table, controller), mock.patch.object( DuplicateServersProgressDialog, 'BatchSize', 1 ): table.selectAll() table.duplicateSelectedItem() dialog = table.findChild(DuplicateServersProgressDialog) self.assertIsNotNone(dialog) self.assertTrue(waitFor(lambda: dialog.copiedCount > 0)) QTest.mouseClick(dialog.cancelButton, QtCore.Qt.LeftButton) self.assertTrue(waitFor(lambda: not isValid(dialog))) self.assertLess(len(Storage.UserServers()), 2000) count = len(Storage.UserServers()) table.duplicateSelectedItem() pending = table.findChild(DuplicateServersProgressDialog) self.assertIsNotNone(pending) table.deleteLater() QtCore.QCoreApplication.sendPostedEvents( table, QtCore.QEvent.DeferredDelete ) processQtEvents() self.assertFalse(isValid(pending)) self.assertEqual(len(Storage.UserServers()), count) def testTableCleanupStopsPendingDuplicationBeforeRepositoryShutdown(self): with self.table(1000) as (table, controller): table.selectAll() table.duplicateSelectedItem() dialog = table.findChild(DuplicateServersProgressDialog) self.assertIsNotNone(dialog) table.cleanup() processQtEvents() self.assertFalse(isValid(dialog)) self.assertEqual(len(Storage.UserServers()), 1000) def testProgressDialogsUseSharedRetranslationWithoutLosingProgress(self): with self.table(10) as (table, controller): AppSettings.set('Language', 'EN') dialogs = ( ( ImportURIsProgressDialog(('fixture',) * 10, parent=table), 'Import', 'Importing', 'currentIndex', ), ( DeleteServersProgressDialog(table, range(10), parent=table), 'Delete', 'Deleting', 'deletedCount', ), ( DuplicateServersProgressDialog(table, table._visibleProfileIds()), 'Duplicate', 'Duplicating', 'copiedCount', ), ) for dialog, title, status, counter in dialogs: setattr(dialog, counter, 7) for locale in ('RU', 'ZH', 'EN'): AppSettings.set('Language', locale) with ExitStack() as stack: translations = [ stack.enter_context( mock.patch.object( dialog.cancelButton, 'retranslate', wraps=dialog.cancelButton.retranslate, ) ) for dialog, *_fields in dialogs ] Mixins.QTranslatable.retranslateAll() for (dialog, title, status, counter), translated in zip( dialogs, translations ): self.assertEqual(translated.call_count, 1) self.assertEqual(dialog.windowTitle(), gettext(title)) self.assertEqual(dialog.cancelButton.text(), gettext('Cancel')) self.assertEqual( dialog.statusLabel.text(), gettext(status) + '... 7/10' ) self.assertEqual(getattr(dialog, counter), 7) def testImportCoalescesRowsAndProgressWithoutLosingInvalidInputPositions(self): profiles = [self.profile(str(index)) for index in range(600)] invalid = mock.Mock() invalid.isValid.return_value = False parsed = [ invalid if index in (200, 501) else item for index, item in enumerate(profiles) ] expected = [item for item in parsed if item is not invalid] with ( self.table() as (table, controller), mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), mock.patch('Furious.Actions.Import.profileFromAny', side_effect=parsed), mock.patch.object(ServerProfile, 'isValid', return_value=True), mock.patch('Furious.Actions.Import.time') as clock, mock.patch('Furious.Actions.Import.singleShotWeakly') as schedule, mock.patch('Furious.Actions.Import.MBoxImportMultiSuccess') as success, mock.patch.object( table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs ) as reconcile, mock.patch.object( table.sourceModel, 'refreshIndexes', wraps=table.sourceModel.refreshIndexes, ) as refresh, ): clock.monotonic.return_value = 10.0 failure = mock.Mock() dialog = ImportURIsProgressDialog( tuple('input' for _ in parsed), failure, parent=table ) inserted = QSignalSpy(table.sourceModel.rowsInserted) with mock.patch.object( dialog, 'updateStatus', wraps=dialog.updateStatus ) as status: dialog.importNext() self.assertEqual(dialog.currentIndex, 128) self.assertEqual(len(Storage.UserServers()), 128) status.assert_not_called() clock.monotonic.return_value = 10.101 dialog.importNext() self.assertEqual(dialog.currentIndex, 256) self.assertEqual(status.call_count, 1) self.assertIn('256/600', dialog.statusLabel.text()) for _index in range(4): dialog.importNext() self.assertEqual(status.call_count, 2) self.assertEqual(Storage.UserServers(), expected) self.assertEqual(dialog.imported, [item.itemRemark for item in expected]) self.assertEqual( [item.index for item in expected], list(range(len(expected))) ) self.assertEqual(inserted.count(), 5) self.assertEqual( [(inserted.at(i)[1], inserted.at(i)[2]) for i in range(5)], [(0, 127), (128, 254), (255, 382), (383, 509), (510, 597)], ) self.assertEqual(reconcile.call_count, 5) refresh.assert_not_called() self.assertEqual(schedule.call_count, 4) success.return_value.open.assert_called_once() failure.assert_not_called() self.assertEqual(Storage.UserActivatedItemIndex(), 0) def testSlowParserYieldsAndCancellationPreservesCommittedBatch(self): with ( self.table() as (table, controller), mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), mock.patch.object(ServerProfile, 'isValid', return_value=True), mock.patch('Furious.Actions.Import.time') as clock, mock.patch('Furious.Actions.Import.singleShotWeakly'), mock.patch('Furious.Actions.Import.MBoxImportSuccess') as success, ): clock.monotonic.return_value = 10.0 profile = self.profile('first') def parse(_uri): clock.monotonic.return_value += 0.010 return profile with mock.patch( 'Furious.Actions.Import.profileFromAny', side_effect=parse ) as parser: dialog = ImportURIsProgressDialog( ('first', 'second', 'third'), parent=table ) dialog.importNext() self.assertEqual(dialog.currentIndex, 1) self.assertEqual(Storage.UserServers(), [profile]) dialog.cancel() dialog.importNext() dialog.importNext() self.assertTrue(dialog.finishedImport) parser.assert_called_once() success.assert_not_called() def testInvalidImportReportsFailureOnce(self): invalid = mock.Mock() invalid.isValid.return_value = False with ( self.table() as (table, controller), mock.patch('Furious.Actions.Import.profileFromAny', return_value=invalid), mock.patch('Furious.Actions.Import.AppMainWindow') as window, ): failure = mock.Mock() dialog = ImportURIsProgressDialog( ('invalid', 'invalid'), failure, parent=table ) dialog.importNext() dialog.importNext() failure.assert_called_once_with() window.assert_not_called() self.assertEqual(Storage.UserServers(), []) def testDeletionCoalescesContiguousRowsAndDisconnectsActiveProfileOnce(self): with ( self.table(1000) as (table, controller), mock.patch('Furious.Widget.ServerTableView.time') as clock, mock.patch('Furious.Widget.ServerTableView.singleShotWeakly') as schedule, mock.patch.object( table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs ) as reconcile, ): clock.monotonic.return_value = 10.0 controller.isConnected.return_value = True profiles = list(Storage.UserServers()) removed = QSignalSpy(table.sourceModel.rowsRemoved) activated = QSignalSpy(table.activeServerChanged) dialog = DeleteServersProgressDialog(table, range(1000), parent=table) with mock.patch.object( dialog, 'updateStatus', wraps=dialog.updateStatus ) as status: dialog.deleteNext() self.assertIs( Storage.UserServers()[Storage.UserActivatedItemIndex()], profiles[-1], ) self.assertEqual(dialog.deletedCount, 128) controller.startDisconnection.assert_not_called() status.assert_not_called() clock.monotonic.return_value = 10.101 dialog.deleteNext() self.assertIn('256/1000', dialog.statusLabel.text()) for _index in range(7): dialog.deleteNext() self.assertEqual(status.call_count, 2) self.assertEqual(Storage.UserServers(), []) self.assertTrue(all(profile.deleted for profile in profiles)) self.assertEqual(removed.count(), 8) self.assertEqual(reconcile.call_count, 8) self.assertEqual(schedule.call_count, 7) self.assertEqual(Storage.UserActivatedItemIndex(), -1) self.assertEqual(activated.count(), 1) controller.startDisconnection.assert_called_once() def testDeletionKeepsCapturedTargetsAcrossSortRemovalAndCancellation(self): with ( self.table(6) as (table, controller), mock.patch('Furious.Widget.ServerTableView.singleShotWeakly'), ): profiles = list(Storage.UserServers()) dialog = DeleteServersProgressDialog(table, range(5), parent=table) dialog.BatchSize = 2 dialog.deleteNext() self.assertEqual(Storage.UserServers(), profiles[2:]) table.sourceModel.sort(0, QtCore.Qt.DescendingOrder) table.deleteItemByIndex([3], showProgress=False) newcomer = self.profile('new') table.appendNewItemByFactory(newcomer) dialog.deleteNext() dialog.cancel() dialog.deleteNext() self.assertEqual(dialog.deletedCount, 3) self.assertEqual( Storage.UserServers(), [profiles[5], profiles[4], newcomer] ) self.assertEqual( [profile.index for profile in Storage.UserServers()], [0, 1, 2] ) self.assertIs( Storage.UserServers()[Storage.UserActivatedItemIndex()], profiles[5] ) controller.startDisconnection.assert_not_called() def testSparseDeletionPreservesPersistentIndexesAndIgnoresInvalidRows(self): with self.table(10) as (table, controller): profiles = list(Storage.UserServers()) survivor = QtCore.QPersistentModelIndex(table.sourceModel.index(5, 0)) removed = QSignalSpy(table.sourceModel.rowsRemoved) count = table.deleteItemByIndex( [-1, 2, 3, 3, 6, 7, 8, 100], showProgress=False ) self.assertEqual(count, 5) self.assertEqual( Storage.UserServers(), [profiles[index] for index in (0, 1, 4, 5, 9)] ) self.assertEqual( [(removed.at(i)[1], removed.at(i)[2]) for i in range(2)], [(6, 8), (2, 3)], ) self.assertTrue(survivor.isValid()) self.assertEqual(survivor.row(), 3) self.assertEqual(Storage.UserActivatedItemIndex(), 4) def testRealCancelInputStopsImportBetweenBatchesAndDestroysDialog(self): with ( self.table() as (table, controller), mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), mock.patch( 'Furious.Actions.Import.profileFromAny', side_effect=lambda _uri: self.profile('input'), ), mock.patch.object(ServerProfile, 'isValid', return_value=True), mock.patch('Furious.Actions.Import.MBoxImportMultiSuccess') as success, ): dialog = ImportURIsProgressDialog( tuple('input' for _ in range(2000)), parent=table ) destroyed = QSignalSpy(dialog.destroyed) dialog.open() QtCore.QTimer.singleShot( 0, lambda: QTest.mouseClick(dialog.cancelButton, QtCore.Qt.LeftButton) ) self.assertTrue(waitFor(lambda: destroyed.count() == 1)) self.assertGreater(len(Storage.UserServers()), 0) self.assertLessEqual(len(Storage.UserServers()), dialog.BatchSize) success.assert_not_called() def testRealParserImportsValidProfilesAmongInvalidInputs(self): registry = PluginRegistry() registry.register(XrayPlugin()) with ( mock.patch( 'Furious.Plugins.Profile.getPluginRegistry', return_value=registry ), self.table() as (table, controller), mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), mock.patch('Furious.Actions.Import.MBoxImportMultiSuccess') as success, ): dialog = ImportURIsProgressDialog( ( 'socks://example.test:1080#first', 'invalid', 'socks://example.test:1081#second', ), parent=table, ) done = QSignalSpy(dialog.finished) dialog.open() self.assertTrue(waitFor(lambda: done.count() == 1)) self.assertEqual( [profile.itemRemark for profile in Storage.UserServers()], ['first', 'second'], ) self.assertTrue(all(profile.isValid() for profile in Storage.UserServers())) success.return_value.open.assert_called_once() def testInsertionPreservesInitialActiveNotificationAndRefreshesTestTargets(self): with self.table() as (table, controller): AppSettings.set('ActivatedItemIndex', '0') first, second = self.profile('first'), self.profile('second') target = ProfileTestTarget.capture(first) activated = QSignalSpy(table.activeServerChanged) table.appendNewItemsByFactories((first, second)) self.assertEqual(activated.count(), 1) self.assertIs(table.profileTestManager.resolveTarget(target), first) table.deleteItemByIndex((0,), showProgress=False) self.assertIsNone(table.profileTestManager.resolveTarget(target)) self.assertFalse( table.profileTestManager.applyResult( target, ProfileTestResult(ProfileTestField.Latency, 'stale') ) ) self.assertEqual(first.metadata.latency, '') def testDeleteConfirmationKeepsOriginalTargetAfterSorting(self): with self.table(4) as (table, controller): profiles = list(Storage.UserServers()) table.setCurrentIndex(table.proxyIndexFromSourceRow(0)) confirmation = MBoxQuestionDelete(parent=table) with mock.patch( 'Furious.Widget.ServerTableView.MBoxQuestionDelete', return_value=confirmation, ): table.deleteSelectedItem() table.sourceModel.sort(0, QtCore.Qt.DescendingOrder) confirmation.done(int(confirmation.StandardButton.Yes)) self.assertTrue(profiles[0].deleted) self.assertEqual( Storage.UserServers(), [profiles[3], profiles[2], profiles[1]] ) self.assertIs( Storage.UserServers()[Storage.UserActivatedItemIndex()], profiles[3] ) def testBatchPreparationFailureDoesNotPublishPartialRows(self): with self.table(1) as (table, controller): original = list(Storage.UserServers()) inserted = QSignalSpy(table.sourceModel.rowsInserted) with self.assertRaises(TypeError): table.appendNewItemsByFactories((self.profile('valid'), object())) self.assertEqual(Storage.UserServers(), original) self.assertEqual(inserted.count(), 0) def testSmallImportsCompleteDirectlyWithOneInsertion(self): for count in (1, 64): with ( self.subTest(count=count), self.table(2) as (table, controller), mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), mock.patch.object(ServerProfile, 'isValid', return_value=True), mock.patch('Furious.Actions.Import.MBoxImportSuccess') as singleSuccess, mock.patch( 'Furious.Actions.Import.MBoxImportMultiSuccess' ) as multiSuccess, mock.patch.object(ImportURIsProgressDialog, 'open') as progress, mock.patch.object( table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs, ) as reconcile, ): imported = [self.profile(f'new-{index}') for index in range(count)] inserted = QSignalSpy(table.sourceModel.rowsInserted) with mock.patch( 'Furious.Actions.Import.profileFromAny', side_effect=imported ): importURIs(*('input' for _ in range(count))) self.assertEqual(Storage.UserServers()[2:], imported) self.assertEqual(inserted.count(), 1) self.assertEqual((inserted.at(0)[1], inserted.at(0)[2]), (2, count + 1)) reconcile.assert_called_once_with() progress.assert_not_called() if count == 1: singleSuccess.return_value.open.assert_called_once() else: multiSuccess.return_value.open.assert_called_once() self.assertEqual(multiSuccess.return_value.rowIndex, 2) def testSmallImportRetainsInvalidInputAndFailureBehavior(self): invalid = mock.Mock() invalid.isValid.return_value = False with ( self.table() as (table, controller), mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), mock.patch('Furious.Actions.Import.MBoxImportSuccess') as success, mock.patch.object(ImportURIsProgressDialog, 'open') as progress, ): failure = mock.Mock() with mock.patch( 'Furious.Actions.Import.profileFromAny', return_value=invalid ): importURIs('invalid', 'invalid', failureCallback=failure) failure.assert_called_once_with() self.assertFalse(Storage.UserServers()) valid = self.profile('valid') with ( mock.patch( 'Furious.Actions.Import.profileFromAny', side_effect=(invalid, valid), ), mock.patch.object(ServerProfile, 'isValid', return_value=True), ): importURIs('invalid', 'valid', failureCallback=failure) self.assertEqual(Storage.UserServers(), [valid]) failure.assert_called_once_with() success.return_value.open.assert_called_once() progress.assert_not_called() def testImportsAboveCutoffUseTheExistingProgressBatches(self): with ( self.table() as (table, controller), mock.patch('Furious.Actions.Import.AppMainWindow', return_value=table), mock.patch('Furious.Actions.Import.profileFromAny') as parser, mock.patch.object( ImportURIsProgressDialog, 'open', autospec=True ) as progress, ): uris = tuple('input' for _ in range(65)) importURIs(*uris) progress.assert_called_once() dialog = progress.call_args.args[0] self.assertEqual(dialog.uris, uris) self.assertEqual(dialog.BatchSize, 128) self.assertEqual(dialog.currentIndex, 0) parser.assert_not_called() self.assertFalse(Storage.UserServers()) def testDeletionCutoffCountsOnlyDistinctValidTargets(self): for count in (1, 64, 65): with ( self.subTest(count=count), self.table(70) as (table, controller), mock.patch.object( DeleteServersProgressDialog, 'open', autospec=True ) as progress, ): profiles = list(Storage.UserServers()) indexes = [*range(count), *range(count), -1, 1000] deleted = table.deleteItemByIndex(indexes) if count <= 64: self.assertEqual(deleted, count) self.assertEqual(Storage.UserServers(), profiles[count:]) progress.assert_not_called() else: self.assertEqual(deleted, 0) self.assertEqual(Storage.UserServers(), profiles) progress.assert_called_once() dialog = progress.call_args.args[0] self.assertEqual(dialog.total, 65) self.assertEqual(dialog.BatchSize, 128) if __name__ == '__main__': unittest.main()