Add routing rule reordering and previews

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-09-17 23:55:21 +08:00
parent 7f525eb8d8
commit d842207066
6 changed files with 751 additions and 47 deletions
+5
View File
@@ -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.
+240 -32
View File
@@ -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
+5 -10
View File
@@ -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"
}
}
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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` |
+498 -2
View File
@@ -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'})