mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
Add routing rule reordering and previews
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+5
-10
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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'})
|
||||
|
||||
Reference in New Issue
Block a user