diff --git a/Furious/Backends/Xray/AGENTS.md b/Furious/Backends/Xray/AGENTS.md index c4dbe4c..00fb1ef 100644 --- a/Furious/Backends/Xray/AGENTS.md +++ b/Furious/Backends/Xray/AGENTS.md @@ -31,6 +31,11 @@ full JSON preservation, routing/assets/statistics, and protocol/transport/TLS pr custom document content and named-profile identity while composing runtime routing/API statistics. Trace the selected repository routing document separately from the connection's own routing branch; neither may be mutated as a side effect of preparing a launch. +- Routing-rule row moves mutate the live profile's rule list in matching order, with Qt move notifications preserving + selection. Internal drag-and-drop, the Move menu, and list-scoped Ctrl+Up/Ctrl+Down shortcuts use this same model + mutation and preserve selected-row order. Pending rule editors/confirmations use persistent model indexes so moving or deleting + a rule cannot retarget their completion. Verify this through `tests/test_ui_behavior.py` and repository/runtime order + round trips. - Statistics preparation is optional and may leave a valid runtime without a statistics target. Preserve that distinction from connection failure; later sampling uses the target captured for this runtime, not newly edited settings or an assumption based solely on the backend name. diff --git a/Furious/Backends/Xray/RoutingWindow.py b/Furious/Backends/Xray/RoutingWindow.py index acf89a7..cd6bf0e 100644 --- a/Furious/Backends/Xray/RoutingWindow.py +++ b/Furious/Backends/Xray/RoutingWindow.py @@ -704,6 +704,157 @@ class RoutingProfileEditDialog(AppQTransientDialog): } +class RoutingRulesModel(QtCore.QAbstractListModel): + """Expose the live routing rule list in backend matching order.""" + + def __init__(self, routing: dict, parent=None): + super().__init__(parent) + + self.routing = routing + + if not isinstance(routing.get('rules'), list): + routing['rules'] = [] + + def rules(self): + return self.routing['rules'] + + def rowCount(self, parent=QtCore.QModelIndex()): + return 0 if parent.isValid() else len(self.rules()) + + def flags(self, index): + flags = super().flags(index) + + if index.isValid(): + return flags | QtCore.Qt.ItemFlag.ItemIsDragEnabled + + # Only insertion between rows is meaningful for a flat rule list. + return flags | QtCore.Qt.ItemFlag.ItemIsDropEnabled + + def supportedDropActions(self): + return QtCore.Qt.DropAction.MoveAction + + def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): + if index.isValid() and 0 <= index.row() < self.rowCount(): + if role == QtCore.Qt.ItemDataRole.DisplayRole: + return self.ruleText(self.rules()[index.row()]) + + return None + + @staticmethod + def ruleText(rule: dict) -> str: + """Preview each filled destination field within a shared text budget.""" + name = rule.get('ruleTag', '') or 'Untitled Rule' + outbound = rule.get('outboundTag', 'proxy') + fields = [] + + # Use the same field names and order as the Destination Match editor. + for key, label in ( + ('domain', 'domain'), + ('ip', 'ip'), + ('port', 'port'), + ('vlessRoute', 'vless route'), + ): + value = rule.get(key) + + if value is None or value == '' or value == []: + continue + + extra = '' + + if isinstance(value, list): + extra = f' +{len(value) - 1}' if len(value) > 1 else '' + value = value[0] + + text = str(value).strip() + + if text: + fields.append((label, text, extra)) + + prefix = f'{name} -> {outbound}' + + if not fields: + return prefix + + # Reserve room for every label: a long domain must not crowd out Port + # or VLESS Route. The view's native elision handles narrower windows. + overhead = sum(len(label) + 2 for label, _text, _extra in fields) + overhead += 2 * (len(fields) - 1) + valueBudget = min(48, (96 - overhead) // len(fields)) + previews = [] + + for label, text, extra in fields: + limit = max(1, valueBudget - len(extra)) + preview = ' '.join(text[: limit + 1].split()) + + if len(text) > limit: + preview = preview[: limit - 1].rstrip() + '…' + + previews.append(f'{label}: {preview}{extra}') + + return f'{prefix} ({"; ".join(previews)})' + + def moveRows( + self, sourceParent, sourceRow, count, destinationParent, destinationChild + ): + """Move the actual rules; Qt retains selection and persistent indexes.""" + if ( + sourceParent.isValid() + or destinationParent.isValid() + or count <= 0 + or sourceRow < 0 + or sourceRow + count > self.rowCount() + or not 0 <= destinationChild <= self.rowCount() + or sourceRow <= destinationChild <= sourceRow + count + ): + return False + + if not self.beginMoveRows( + sourceParent, + sourceRow, + sourceRow + count - 1, + destinationParent, + destinationChild, + ): + return False + + rules = self.rules() + moved = rules[sourceRow : sourceRow + count] + + del rules[sourceRow : sourceRow + count] + + insertion = ( + destinationChild - count + if destinationChild > sourceRow + else destinationChild + ) + rules[insertion:insertion] = moved + + self.endMoveRows() + + return True + + def appendRule(self, rule): + row = self.rowCount() + + self.beginInsertRows(QtCore.QModelIndex(), row, row) + self.rules().append(rule) + self.endInsertRows() + + def setRule(self, row, rule): + self.rules()[row] = rule + + index = self.index(row, 0) + + self.dataChanged.emit(index, index, [QtCore.Qt.ItemDataRole.DisplayRole]) + + def deleteRules(self, rows): + for row in sorted(set(rows), reverse=True): + if 0 <= row < self.rowCount(): + self.beginRemoveRows(QtCore.QModelIndex(), row, row) + self.rules().pop(row) + self.endRemoveRows() + + class RoutingRulesListView(AppQListView): """Provide the model-based routing rules list.""" @@ -714,17 +865,23 @@ class RoutingRulesListView(AppQListView): super().__init__(parent) self.routing = routing - self.rulesModel = QtCore.QStringListModel(parent=self) + self.rulesModel = RoutingRulesModel(routing, parent=self) + self.setModel(self.rulesModel) self.setAlternatingRowColors(True) + self.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setSelectionBehavior(AppQListView.SelectionBehavior.SelectRows) self.setSelectionMode(AppQListView.SelectionMode.ExtendedSelection) self.setEditTriggers(AppQListView.EditTrigger.NoEditTriggers) - connectWeakly(self.doubleClicked, self, '_requestEdit') + self.setDragDropMode(AppQListView.DragDropMode.InternalMove) + self.setDragDropOverwriteMode(False) + self.setDropIndicatorShown(True) + self.setDefaultDropAction(QtCore.Qt.DropAction.MoveAction) - self.flushAll() + connectWeakly(self.doubleClicked, self, '_requestEdit') @QtCore.Slot(QtCore.QModelIndex) def _requestEdit(self, _index): @@ -733,12 +890,7 @@ class RoutingRulesListView(AppQListView): def rules(self): """Return the rules represented by the routing list.""" - rules = self.routing.setdefault('rules', list()) - - if not isinstance(rules, list): - self.routing['rules'] = rules = list() - - return rules + return self.rulesModel.rules() def ruleAt(self, index: int): """Return the rule represented by one list row.""" @@ -746,14 +898,7 @@ class RoutingRulesListView(AppQListView): def ruleText(self, rule: dict) -> str: """Return the display text for one routing rule.""" - name, outbound, domains, ips = ( - rule.get('ruleTag', '') or 'Untitled Rule', - rule.get('outboundTag', 'proxy'), - len(rule.get('domain', [])), - len(rule.get('ip', [])), - ) - - return f'{name} -> {outbound} ({domains} domains, {ips} IPs)' + return self.rulesModel.ruleText(rule) def selectedRuleText(self): """Select ed rule text.""" @@ -766,24 +911,43 @@ class RoutingRulesListView(AppQListView): def appendRule(self, rule: dict): """Append rule.""" - self.rules().append(rule) - self.flushAll() + self.rulesModel.appendRule(rule) def setRule(self, index: int, rule: dict): """Set rule.""" - self.rules()[index] = rule - self.flushAll() + self.rulesModel.setRule(index, rule) def deleteRules(self, indexes: list[int]): """Delete rules.""" - for i in range(len(indexes)): - self.rules().pop(indexes[i] - i) + self.rulesModel.deleteRules(indexes) - self.flushAll() + def moveSelectedRules(self, position: str): + """Move selected rows with Home's stable multi-selection semantics.""" + rows = self.selectedIndex - def flushAll(self): - """Refresh all.""" - self.rulesModel.setStringList([self.ruleText(rule) for rule in self.rules()]) + if not rows or position not in ('up', 'down'): + return + + backwards = position == 'down' + boundary = self.rulesModel.rowCount() - 1 if backwards else 0 + root = QtCore.QModelIndex() + + # Work toward the destination edge so unprocessed source rows keep + # their indexes. Adjacent selected rows never cross each other. + for row in reversed(rows) if backwards else rows: + if position == 'up': + target = max(boundary, row - 1) + else: + target = min(boundary, row + 1) + + destination = target + 1 if target > row else target + + self.rulesModel.moveRow(root, row, root, destination) + + boundary = target - 1 if backwards else target + 1 + + self.scrollTo(self.currentIndex()) + self.setFocus() class RoutingRulesDialog(AppQTransientDialog): @@ -817,6 +981,31 @@ class RoutingRulesDialog(AppQTransientDialog): connectWeakly(self.deleteButton.clicked, self, 'deleteRule') + self.moveMenu = AppQMenu(parent=self) + + for text, position, key in ( + (_('Move Up'), 'up', QtCore.Qt.Key.Key_Up), + (_('Move Down'), 'down', QtCore.Qt.Key.Key_Down), + ): + action = AppQAction(text, parent=self.moveMenu) + action.setData(position) + action.setShortcut( + QtCore.QKeyCombination(QtCore.Qt.KeyboardModifier.ControlModifier, key) + ) + action.setShortcutContext(QtCore.Qt.ShortcutContext.WidgetShortcut) + + self.moveMenu.addAction(action) + self.listView.addAction(action) + + connectWeakly(self.moveMenu.triggered, self, '_moveRules') + + self.moveButton = AppQMenuPushButton( + _('Move...'), + icon=bootstrapIcon('arrows-move.svg'), + popupMenu=self.moveMenu, + parent=self, + ) + self.closeWindowButton = AppQPushButton( _('Close Window'), icon=bootstrapIcon('window-x.svg'), @@ -827,6 +1016,7 @@ class RoutingRulesDialog(AppQTransientDialog): for button in ( self.addButton, self.deleteButton, + self.moveButton, self.closeWindowButton, ): button.setAutoDefault(False) @@ -837,6 +1027,7 @@ class RoutingRulesDialog(AppQTransientDialog): actionLayout.setSpacing(8) actionLayout.addWidget(self.addButton) actionLayout.addWidget(self.deleteButton) + actionLayout.addWidget(self.moveButton) actionLayout.addStretch(1) actionLayout.addWidget(self.closeWindowButton) @@ -848,6 +1039,11 @@ class RoutingRulesDialog(AppQTransientDialog): self.setLayout(layout) + @QtCore.Slot(QAction) + def _moveRules(self, action): + """Apply the same model mutation for menu and keyboard activation.""" + self.listView.moveSelectedRules(action.data()) + def addRule(self): """Add rule.""" rule = {'type': 'field'} @@ -871,13 +1067,18 @@ class RoutingRulesDialog(AppQTransientDialog): if len(indexes) != 1: return - index = indexes[0] - dialog = RoutingRuleEditDialog(self.listView.ruleAt(index), parent=self) + index = QtCore.QPersistentModelIndex( + self.listView.rulesModel.index(indexes[0], 0) + ) + dialog = RoutingRuleEditDialog(self.listView.ruleAt(index.row()), parent=self) def handleResultCode(_index, code): """Handle result code.""" - if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): - self.listView.setRule(_index, dialog.routingRule()) + if ( + code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted) + and _index.isValid() + ): + self.listView.setRule(_index.row(), dialog.routingRule()) dialog.finished.connect(functools.partial(handleResultCode, index)) dialog.open() @@ -889,12 +1090,19 @@ class RoutingRulesDialog(AppQTransientDialog): if len(indexes) == 0: return + indexes = [ + QtCore.QPersistentModelIndex(self.listView.rulesModel.index(row, 0)) + for row in indexes + ] + def handleResultCode(_indexes, code): """Handle result code.""" if code == PySide6Legacy.enumValueWrapper( AppQMessageBox.StandardButton.Yes ): - self.listView.deleteRules(_indexes) + self.listView.deleteRules( + [index.row() for index in _indexes if index.isValid()] + ) else: # Do not delete pass diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index ad4bfcb..66a0fb8 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -500,6 +500,7 @@ TRANSLATION = { }, "Move Up": { "source": [ + "Furious.Backends.Xray.RoutingWindow", "Furious.Widget.ServerTableView", "Furious.Widget.SubscriptionTableView" ], @@ -509,6 +510,7 @@ TRANSLATION = { }, "Move Down": { "source": [ + "Furious.Backends.Xray.RoutingWindow", "Furious.Widget.ServerTableView", "Furious.Widget.SubscriptionTableView" ], @@ -3215,13 +3217,14 @@ TRANSLATION = { }, "Move...": { "source": [ + "Furious.Backends.Xray.RoutingWindow", "Furious.Widget.ServerTableView" ], "RU": "Переместить...", "ZH": "移动...", "isReviewed": "True" }, - "Move to Top": { + "Move To Top": { "source": [ "Furious.Widget.ServerTableView" ], @@ -3229,7 +3232,7 @@ TRANSLATION = { "ZH": "移到顶部", "isReviewed": "True" }, - "Move to Bottom": { + "Move To Bottom": { "source": [ "Furious.Widget.ServerTableView" ], @@ -3340,13 +3343,5 @@ TRANSLATION = { "RU": "Нет записей журнала, соответствующих текущим фильтрам.", "ZH": "没有符合当前筛选条件的日志。", "isReviewed": "True" - }, - "Tests": { - "source": [ - "Furious.Window.HomePage" - ], - "RU": "Тесты", - "ZH": "测试", - "isReviewed": "True" } } diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 4f41e0e..bfd88d5 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -836,7 +836,7 @@ class ServerTableView( self.moveMenu = AppQMenu( AppQAction( - _('Move to Top'), + _('Move To Top'), callback=lambda: self.moveSelectedItems('top'), ), AppQAction( @@ -856,7 +856,7 @@ class ServerTableView( ), ), AppQAction( - _('Move to Bottom'), + _('Move To Bottom'), callback=lambda: self.moveSelectedItems('bottom'), ), parent=self, diff --git a/tests/README.md b/tests/README.md index fc93fb8..85444b5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -59,7 +59,7 @@ strategy in an individual test. | Theme cross-fade activation, interruption, multi-window cleanup, and animation policy | `test_theme_transition.py` | | AppQMainWindow lifecycle, subclass policies, geometry restoration, and migration | `test_main_window_geometry.py` | | AppQDialog first-presentation geometry, native show paths, centering, and async lifetime | `test_dialog_geometry.py` | -| Editor mappings, lazy log rendering, routing/message-box/connection UI | `test_ui_behavior.py` | +| Editor mappings, lazy log rendering, routing-rule internal-move drag restrictions, Move menu/shortcuts and persisted order, message-box/connection UI | `test_ui_behavior.py` | | Bounded, incremental, cancellable QR export and snapshot/lifetime safety | `test_qr_export_scalability.py` | | Real keyboard/mouse/focus, proxy mapping, shared Home/Settings state, Home empty/filter recovery and shared menus, direct small profile operations and batched imports/deletions with throttled progress and stable cancellation targets, and transient editor input | `test_qt_interactions.py` | | Direct Qt ownership and destruction across independent UI families | `test_qt_lifetime.py` | diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 7e68f93..2c1aadb 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -45,7 +45,9 @@ from Furious.Backends.Xray.RoutingWindow import ( RoutingRulesDialog, RoutingTextEdit, RoutingTextEditDialog, + routingObjectFromProfile, ) +from Furious.Backends.Xray.Routing import customRoutingObjectFromSettings from Furious.Backends.Xray.ShadowsocksEditor import ShadowsocksEditor from Furious.Backends.Xray.SocksEditor import SocksEditor from Furious.Backends.Xray.TrojanEditor import TrojanEditor @@ -88,6 +90,7 @@ from Furious.Models import ( ) from Furious.Plugins.API import RoutingOption from Furious.Repository import Storage +from Furious.Repository.Routings import UserRoutings from Furious.Qt import ( AppHue, AppQComboBox, @@ -122,7 +125,7 @@ from Furious.Widget.ServerTableView import ServerTableView from PySide6 import QtCore from PySide6.QtGui import QImage -from PySide6.QtTest import QTest +from PySide6.QtTest import QAbstractItemModelTester, QSignalSpy, QTest from PySide6.QtWidgets import ( QStackedWidget, QToolButton, @@ -2363,21 +2366,514 @@ class DialogBehaviorTest(unittest.TestCase): self.assertTrue(dialog.addButton.isEnabled()) self.assertTrue(dialog.deleteButton.isEnabled()) + self.assertTrue(dialog.moveButton.isEnabled()) self.assertTrue(dialog.closeWindowButton.isEnabled()) self.assertIsNotNone(dialog.layout().itemAt(0).layout()) self.assertIs(dialog.layout().itemAt(1).widget(), dialog.listView) self.assertIs(dialog.listView.model(), dialog.listView.rulesModel) self.assertIs(dialog.listView.rulesModel.parent(), dialog.listView) - self.assertEqual(dialog.listView.rulesModel.stringList(), []) + self.assertEqual(dialog.listView.rulesModel.rowCount(), 0) dialog.deleteRule() dialog.editRule() + for action in dialog.moveMenu.actions(): + action.trigger() + self.assertEqual(AppQDialog._openDialogs, {}) self.assertEqual(dialog.routing['rules'], []) dialog.closeWindowButton.click() + def testRuleMovesPreserveIdentitySelectionAndPersistence(self): + """Keep identical labels distinct through moves, edits and serialization.""" + with isolatedSettings(): + repository = UserRoutings() + routing = { + 'rules': [ + {'ruleTag': 'same', 'domain': [domain], 'outboundTag': 'proxy'} + for domain in ('a.test', 'b.test', 'c.test', 'd.test') + ] + } + + repository.data()['ordered'] = routing + + original = list(routing['rules']) + + dialog = RoutingRulesDialog(routing) + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + + view = dialog.listView + model = view.rulesModel + + tester = QAbstractItemModelTester( + model, QAbstractItemModelTester.FailureReportingMode.Warning + ) + moved = QSignalSpy(model.rowsMoved) + resets = QSignalSpy(model.modelReset) + + view.setCurrentIndex(model.index(1, 0)) + selected = QtCore.QPersistentModelIndex(model.index(1, 0)) + removed = QtCore.QPersistentModelIndex(model.index(2, 0)) + root = QtCore.QModelIndex() + + self.assertTrue(model.moveRows(root, 1, 2, root, 4)) + + self.assertEqual(routing['rules'], [original[i] for i in (0, 3, 1, 2)]) + self.assertEqual(selected.row(), 2) + self.assertEqual(view.currentIndex().row(), 2) + self.assertEqual(view.selectedIndex, [2]) + + dialog.moveMenu.actions()[0].trigger() + dialog.moveMenu.actions()[0].trigger() + + self.assertIs(routing['rules'][0], original[1]) + self.assertEqual(view.currentIndex().row(), 0) + self.assertEqual(moved.count(), 3) + self.assertEqual(resets.count(), 0) + + edited = dict(original[1], outboundTag='direct') + + view.setRule(selected.row(), edited) + view.deleteRules([removed.row()]) + + self.assertFalse(removed.isValid()) + self.assertIs(routing['rules'][0], edited) + self.assertEqual(model.data(model.index(0, 0)), view.ruleText(edited)) + + repository.sync() + + restored = UserRoutings().data()['ordered'] + + self.assertEqual(restored, routing) + + exported = routingObjectFromProfile(restored) + + self.assertEqual( + [rule['domain'] for rule in exported['rules']], + [['b.test'], ['a.test'], ['d.test']], + ) + + with mock.patch.object( + Storage, 'UserRoutings', return_value={'ordered': restored} + ): + self.assertEqual( + customRoutingObjectFromSettings('Custom:ordered'), exported + ) + + self.assertIsNotNone(tester) + + def testRuleDescriptionsPreviewEveryFilledDestinationField(self): + rules = [ + {'ruleTag': 'direct', 'outboundTag': 'direct', 'ip': ['1.14.96.57']}, + { + 'domain': ['geosite:cn', 'domain:example.test', 'full:example.org'], + 'ip': ['geoip:private'], + 'port': '53,443', + 'vlessRoute': '100-200', + }, + {'port': '443'}, + {'vlessRoute': '0'}, + {'domain': [], 'ip': [], 'port': ' ', 'sourceIP': ['10.0.0.1']}, + ] + + dialog = RoutingRulesDialog({'rules': rules}) + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + + model = dialog.listView.rulesModel + + self.assertEqual( + [model.data(model.index(row, 0)) for row in range(model.rowCount())], + [ + 'direct -> direct (ip: 1.14.96.57)', + 'Untitled Rule -> proxy (domain: geosite:cn +2; ip: geoip:private; ' + 'port: 53,443; vless route: 100-200)', + 'Untitled Rule -> proxy (port: 443)', + 'Untitled Rule -> proxy (vless route: 0)', + 'Untitled Rule -> proxy', + ], + ) + + def testRuleDescriptionBudgetDoesNotHideOtherDestinationFieldsOrMutateRules(self): + rule = { + 'domain': ['regexp:' + '域' * 200, 'domain:second.test'], + 'ip': ['2001:db8:' + 'abcd:' * 100], + 'port': '443,\n' * 100, + 'vlessRoute': '100-200,' * 100, + } + + original = copy.deepcopy(rule) + + dialog = RoutingRulesDialog({'rules': [rule]}) + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + + view = dialog.listView + model = view.rulesModel + + text = model.data(model.index(0, 0)) + summary = text.split(' (', 1)[1][:-1] + + self.assertLessEqual(len(summary), 96) + + for label in ('domain:', 'ip:', 'port:', 'vless route:'): + self.assertIn(label, summary) + + self.assertEqual(summary.count('…'), 4) + self.assertIn('+1', summary) + self.assertNotIn('\n', summary) + self.assertEqual(rule, original) + + self.assertEqual(view.textElideMode(), QtCore.Qt.TextElideMode.ElideRight) + self.assertEqual( + view.horizontalScrollBarPolicy(), + QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff, + ) + + changed = QSignalSpy(model.dataChanged) + + view.setRule(0, {'port': '80'}) + + self.assertEqual(changed.count(), 1) + self.assertEqual( + model.data(model.index(0, 0)), 'Untitled Rule -> proxy (port: 80)' + ) + + def testRuleModelRejectsInvalidMovesWithoutMutation(self): + dialog = RoutingRulesDialog({'rules': [{'ruleTag': str(i)} for i in range(4)]}) + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + + model = dialog.listView.rulesModel + before = list(model.rules()) + root = QtCore.QModelIndex() + moved = QSignalSpy(model.rowsMoved) + + for source, count, destination in ( + (-1, 1, 2), + (0, 0, 2), + (3, 2, 0), + (0, 1, 5), + (1, 2, 2), + (1, 2, 3), + ): + self.assertFalse(model.moveRows(root, source, count, root, destination)) + + self.assertFalse(model.moveRows(model.index(0, 0), 0, 1, root, 4)) + + self.assertEqual(model.rules(), before) + self.assertEqual(moved.count(), 0) + + self.assertEqual( + dialog.listView.dragDropMode(), dialog.listView.DragDropMode.InternalMove + ) + self.assertTrue(dialog.listView.dragEnabled()) + self.assertTrue(dialog.listView.acceptDrops()) + self.assertTrue(dialog.listView.showDropIndicator()) + self.assertFalse(dialog.listView.dragDropOverwriteMode()) + + self.assertEqual(model.supportedDropActions(), QtCore.Qt.DropAction.MoveAction) + self.assertEqual(model.supportedDragActions(), QtCore.Qt.DropAction.MoveAction) + + self.assertTrue( + model.flags(model.index(0, 0)) & QtCore.Qt.ItemFlag.ItemIsDragEnabled + ) + self.assertFalse( + model.flags(model.index(0, 0)) & QtCore.Qt.ItemFlag.ItemIsDropEnabled + ) + self.assertTrue(model.flags(root) & QtCore.Qt.ItemFlag.ItemIsDropEnabled) + + def testRuleListRejectsExternalDragsEvenWithMatchingMimeType(self): + from PySide6.QtGui import QDragEnterEvent, QDropEvent + + dialog = RoutingRulesDialog({'rules': [{'ruleTag': 'keep'}]}) + other = RoutingRulesDialog({'rules': [{'ruleTag': 'other'}]}) + + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + self.addCleanup(other.deleteLater) + self.addCleanup(other.close) + + dialog.show() + processQtEvents() + + view = dialog.listView + otherModel = other.listView.rulesModel + mime = otherModel.mimeData([otherModel.index(0, 0)]) + self.addCleanup(mime.deleteLater) + + self.assertTrue(mime.hasFormat(view.rulesModel.mimeTypes()[0])) + + point = view.visualRect(view.rulesModel.index(0, 0)).bottomLeft() + + for action in ( + QtCore.Qt.DropAction.MoveAction, + QtCore.Qt.DropAction.CopyAction, + ): + enter = QDragEnterEvent( + point, + action, + mime, + QtCore.Qt.MouseButton.LeftButton, + QtCore.Qt.KeyboardModifier.NoModifier, + ) + + application().sendEvent(view.viewport(), enter) + + self.assertFalse(enter.isAccepted()) + + drop = QDropEvent( + QtCore.QPointF(point), + action, + mime, + QtCore.Qt.MouseButton.LeftButton, + QtCore.Qt.KeyboardModifier.NoModifier, + ) + + application().sendEvent(view.viewport(), drop) + + self.assertFalse(drop.isAccepted()) + + self.assertEqual(view.rules(), [{'ruleTag': 'keep'}]) + self.assertEqual(otherModel.rules(), [{'ruleTag': 'other'}]) + + def testRuleMoveMenuPreservesMultiSelectionOrderAndCurrentRule(self): + expectedOrders = { + 'up': [1, 2, 0, 4, 3, 5], + 'down': [0, 3, 1, 2, 5, 4], + } + + for position, order in expectedOrders.items(): + with self.subTest(position=position): + # Equal labels and values must not collapse distinct rule rows. + rules = [{'ruleTag': 'same'} for _ in range(6)] + original = list(rules) + + dialog = RoutingRulesDialog({'rules': rules}) + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + + view = dialog.listView + model = view.rulesModel + selection = view.selectionModel() + + for row in (1, 2, 4): + selection.select( + model.index(row, 0), + QtCore.QItemSelectionModel.SelectionFlag.Select, + ) + + selection.setCurrentIndex( + model.index(2, 0), QtCore.QItemSelectionModel.SelectionFlag.NoUpdate + ) + + reset = QSignalSpy(model.modelReset) + actions = { + action.data(): action for action in dialog.moveMenu.actions() + } + + self.assertIs(dialog.moveButton.popupMenu(), dialog.moveMenu) + self.assertEqual(list(actions), ['up', 'down']) + + actions[position].trigger() + + self.assertEqual( + [id(rule) for rule in rules], [id(original[i]) for i in order] + ) + self.assertEqual( + view.selectedIndex, sorted(order.index(i) for i in (1, 2, 4)) + ) + self.assertIs(rules[view.currentIndex().row()], original[2]) + self.assertEqual(reset.count(), 0) + + def testRuleMovesStopAtBoundariesWithoutCrossingSelectedRows(self): + for position, order in ( + ('up', [0, 2, 1, 3, 5, 4]), + ('down', [1, 0, 3, 2, 4, 5]), + ): + with self.subTest(position=position): + rules = [{'ruleTag': str(i)} for i in range(6)] + original = list(rules) + + dialog = RoutingRulesDialog({'rules': rules}) + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + + view = dialog.listView + + for row in (0, 2, 5): + view.selectionModel().select( + view.rulesModel.index(row, 0), + QtCore.QItemSelectionModel.SelectionFlag.Select, + ) + + view.moveSelectedRules(position) + + self.assertEqual(rules, [original[i] for i in order]) + + for _iteration in range(8): + view.moveSelectedRules(position) + + final = [0, 2, 5, 1, 3, 4] if position == 'up' else [1, 3, 4, 0, 2, 5] + + self.assertEqual(rules, [original[i] for i in final]) + + moved = QSignalSpy(view.rulesModel.rowsMoved) + + view.moveSelectedRules(position) + + self.assertEqual(moved.count(), 0) + + view.selectAll() + for direction in ('up', 'down'): + view.moveSelectedRules(direction) + + self.assertEqual(moved.count(), 0) + + def testRuleMoveButtonAndShortcutsUseTheSameActions(self): + dialog = RoutingRulesDialog({'rules': [{'ruleTag': str(i)} for i in range(4)]}) + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + + view = dialog.listView + + dialog.show() + dialog.activateWindow() + view.setCurrentIndex(view.rulesModel.index(2, 0)) + view.setFocus() + processQtEvents() + + for _iteration in range(2): + QTest.keyClick( + view, QtCore.Qt.Key.Key_Up, QtCore.Qt.KeyboardModifier.ControlModifier + ) + processQtEvents() + + self.assertEqual( + [rule['ruleTag'] for rule in view.rules()], ['2', '0', '1', '3'] + ) + self.assertEqual(view.selectedIndex, [0]) + self.assertTrue(view.hasFocus()) + + QTest.keyClick( + view, QtCore.Qt.Key.Key_Down, QtCore.Qt.KeyboardModifier.ControlModifier + ) + processQtEvents() + + self.assertEqual(view.selectedIndex, [1]) + + dialog.addButton.setFocus() + processQtEvents() + + QTest.keyClick( + dialog.addButton, + QtCore.Qt.Key.Key_Down, + QtCore.Qt.KeyboardModifier.ControlModifier, + ) + + self.assertEqual(view.selectedIndex, [1]) + + QTest.mouseClick(dialog.moveButton, QtCore.Qt.MouseButton.LeftButton) + processQtEvents() + + self.assertTrue(dialog.moveMenu.isVisible()) + + dialog.moveMenu.setActiveAction(dialog.moveMenu.actions()[-1]) + QTest.keyClick(dialog.moveMenu, QtCore.Qt.Key.Key_Return) + processQtEvents() + + self.assertEqual( + [rule['ruleTag'] for rule in view.rules()], ['0', '1', '2', '3'] + ) + self.assertEqual(view.selectedIndex, [2]) + self.assertTrue(view.hasFocus()) + + def testHomeAndRoutingMoveMenusRetranslateWithoutChangingActions(self): + with isolatedSettings(): + AppSettings.set('Language', 'EN') + + table = ServerTableView( + configurationEditorFactory=QWidget, + qrCodeWindowFactory=QWidget, + importActionsFactory=tuple, + ) + dialog = RoutingRulesDialog({'rules': [{'ruleTag': 'user label'}]}) + + self.addCleanup(table.deleteLater) + self.addCleanup(table.close) + self.addCleanup(table.cleanup) + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + + homeActions = table.moveMenu.actions() + ruleActions = dialog.moveMenu.actions() + homeLabels = ['Move To Top', 'Move Up', 'Move Down', 'Move To Bottom'] + + self.assertEqual([action.text() for action in homeActions], homeLabels) + self.assertEqual( + [action.text() for action in ruleActions], ['Move Up', 'Move Down'] + ) + + for language in ('ZH', 'RU', 'EN'): + AppSettings.set('Language', language) + + for action in homeActions + ruleActions: + action.retranslate() + dialog.moveButton.retranslate() + + self.assertEqual( + [action.text() for action in homeActions], + [_(label, language) for label in homeLabels], + ) + self.assertEqual( + [action.text() for action in ruleActions], + [_('Move Up', language), _('Move Down', language)], + ) + self.assertEqual(dialog.moveButton.text(), _('Move...', language)) + self.assertEqual( + [action.data() for action in ruleActions], ['up', 'down'] + ) + self.assertEqual(dialog.listView.rules(), [{'ruleTag': 'user label'}]) + + def testOpenRuleEditorFollowsMovedRuleAndIgnoresRemovedRule(self): + dialog = RoutingRulesDialog( + {'rules': [{'ruleTag': 'first'}, {'ruleTag': 'second'}]} + ) + self.addCleanup(dialog.deleteLater) + self.addCleanup(dialog.close) + + dialog.show() + + view = dialog.listView + model = view.rulesModel + + view.setCurrentIndex(model.index(0, 0)) + dialog.editRule() + + editor = dialog.findChild(RoutingRuleEditDialog) + editor.ruleTagEdit.setText('edited first') + + self.assertTrue(model.moveRow(QtCore.QModelIndex(), 0, QtCore.QModelIndex(), 2)) + + editor.accept() + processQtEvents() + + self.assertEqual( + [rule['ruleTag'] for rule in model.rules()], ['second', 'edited first'] + ) + + dialog.editRule() + + editor = dialog.findChild(RoutingRuleEditDialog) + view.deleteRules([view.currentIndex().row()]) + + editor.accept() + processQtEvents() + + self.assertEqual([rule['ruleTag'] for rule in model.rules()], ['second']) + def testRoutingRuleGuidanceUsesPlaceholdersInsteadOfLabels(self): """Keep routing field names compact while retaining input guidance.""" dialog = RoutingRuleEditDialog({'type': 'field'})