zapret-kvn/tests/test_nodes_table_model.py
loop-uh 3ac9d59c0c
Some checks failed
Windows project source guards / test (push) Failing after 40s
Тема: позитивные статусы в цвете акцента
Новый токен positive_color()/positive_pair() — акцент, подстроенный под
читаемость (в тёмной теме тёмный акцент осветляется, в светлой светлый
затемняется). В цвет акцента теперь: «актуален»/«последняя версия» на
«Обновлениях», «Подключено» и сфера на панели, живой пинг в таблице,
«Работает» у Zapret, «рекомендуется» в стратегиях, VPN-трафик процессов.
Ошибки и предупреждения остаются красными/оранжевыми. Метки со стилями
перекрашиваются при смене акцента.
2026-09-26 15:16:04 +03:00

532 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import unittest
from PyQt6.QtCore import QCoreApplication, QItemSelectionModel, QPersistentModelIndex, Qt
from PyQt6.QtTest import QTest
from xray_fluent.profiles.models import Node
from xray_fluent.ui.theme import error_color, positive_color, success_color, warning_color
from xray_fluent.ui.nodes_table_model import (
ACTIVE_ROLE,
COL_ADDRESS,
COL_GROUP,
COL_LAST_USED,
COL_NAME,
COL_PING,
COL_SOURCE,
COL_SPEED,
COL_TAGS,
COL_TYPE,
COLUMN_BY_KEY,
COLUMN_KEYS,
COLUMN_SPECS,
DEFAULT_VISIBLE_COLUMNS,
GROUP_KEY_ROLE,
NODE_ID_ROLE,
PING_BUSY_ROLE,
SPEED_PROGRESS_ROLE,
NodesTableModel,
)
# Таймер пересортировки после метрик требует цикла событий.
_APP = QCoreApplication.instance() or QCoreApplication([])
# Окно троттлинга пересортировки по метрикам (300 мс) плюс запас.
_AFTER_RELAYOUT_WINDOW_MS = 400
class _SignalCounter:
"""Collects emissions of structural model signals."""
def __init__(self, model: NodesTableModel):
self.model = model
self.resets = 0
self.layouts = 0
self.inserts = []
self.removes = []
self.data_changes = []
model.modelReset.connect(self._on_reset)
model.layoutChanged.connect(self._on_layout)
model.rowsInserted.connect(lambda parent, first, last: self.inserts.append((first, last)))
model.rowsRemoved.connect(lambda parent, first, last: self.removes.append((first, last)))
model.dataChanged.connect(
lambda top, bottom, roles=None: self.data_changes.append(
(top.row(), bottom.row(), top.column(), bottom.column())
)
)
def _on_reset(self):
self.resets += 1
def _on_layout(self, *_args):
self.layouts += 1
def changed_rows(self) -> set[int]:
return {row for top, bottom, _l, _r in self.data_changes for row in range(top, bottom + 1)}
def _flat_model(nodes: list[Node]) -> NodesTableModel:
"""Модель без группировки: строка N — N-й сервер."""
model = NodesTableModel()
model.set_group_mode("none")
model.set_nodes(nodes)
return model
class NodesTableModelTests(unittest.TestCase):
def setUp(self) -> None:
self.node = Node(
id="node-1",
name="Test server",
scheme="vless",
server="example.com",
port=443,
ping_ms=42,
speed_mbps=12.5,
)
self.model = _flat_model([self.node])
# ── Columns (AC6) ──
def test_column_layout_has_address_and_no_status(self) -> None:
self.assertEqual(self.model.columnCount(), 9)
self.assertEqual(len(COLUMN_KEYS), 9)
headers = [
self.model.headerData(col, Qt.Orientation.Horizontal)
for col in range(self.model.columnCount())
]
self.assertEqual(
headers,
[
"Имя",
"Тип",
"Адрес",
"Группа",
"Теги",
"Пинг",
"Скорость",
"Последнее использование",
"Источник",
],
)
self.assertNotIn("Статус", headers)
self.assertNotIn("Сервер", headers)
self.assertNotIn("Порт", headers)
self.assertEqual(
(COL_NAME, COL_TYPE, COL_ADDRESS, COL_GROUP, COL_TAGS, COL_PING, COL_SPEED, COL_LAST_USED, COL_SOURCE),
(0, 1, 2, 3, 4, 5, 6, 7, 8),
)
self.assertEqual(
DEFAULT_VISIBLE_COLUMNS,
["name", "type", "ping", "speed"],
)
def test_address_cell_masks_server_and_port(self) -> None:
index = self.model.index(0, COL_ADDRESS)
self.assertEqual(index.data(Qt.ItemDataRole.DisplayRole), "********")
self.assertNotIn("example.com", index.data(Qt.ItemDataRole.DisplayRole))
def test_address_is_visible_only_while_model_reveal_is_active(self) -> None:
index = self.model.index(0, COL_ADDRESS)
self.model.set_endpoints_revealed(True)
self.assertEqual(index.data(Qt.ItemDataRole.DisplayRole), "example.com:443")
self.assertIn(
"Адрес: example.com:443",
self.model.index(0, COL_NAME).data(Qt.ItemDataRole.ToolTipRole),
)
self.model.set_endpoints_revealed(False)
self.assertEqual(index.data(Qt.ItemDataRole.DisplayRole), "********")
self.assertNotIn(
"example.com",
self.model.index(0, COL_NAME).data(Qt.ItemDataRole.ToolTipRole),
)
def test_ipv6_address_is_bracketed_while_revealed(self) -> None:
self.model.set_nodes(
[Node(id="ipv6", name="IPv6", server="2001:db8::1", port=443)]
)
self.model.set_endpoints_revealed(True)
self.assertEqual(
self.model.index(0, COL_ADDRESS).data(Qt.ItemDataRole.DisplayRole),
"[2001:db8::1]:443",
)
def test_type_falls_back_to_native_outbound_for_legacy_node(self) -> None:
self.model.set_nodes(
[Node(id="legacy", scheme="", outbound={"type": "hysteria2"})]
)
self.assertEqual(
self.model.index(0, COL_TYPE).data(Qt.ItemDataRole.DisplayRole),
"HYSTERIA2",
)
def test_name_tooltip_masks_server_and_port(self) -> None:
tooltip = self.model.index(0, COL_NAME).data(Qt.ItemDataRole.ToolTipRole)
self.assertIn("Адрес: ********", tooltip)
self.assertNotIn("example.com", tooltip)
def test_node_id_role_is_available_for_all_columns(self) -> None:
for col in range(self.model.columnCount()):
self.assertEqual(self.model.index(0, col).data(NODE_ID_ROLE), "node-1")
# ── Группировка: строки-заголовки ──
def test_default_mode_groups_by_source_with_header_rows(self) -> None:
model = NodesTableModel()
self.assertEqual(model.group_mode(), "source")
model.set_source_names({"sub-1": "Мой провайдер"})
model.set_nodes([self.node, Node(id="remote", name="Remote", subscription_id="sub-1")])
header = model.index(0, COL_NAME)
self.assertEqual(header.data(GROUP_KEY_ROLE), "source:local")
self.assertEqual(header.data(Qt.ItemDataRole.DisplayRole), "Локальные · 1")
self.assertIsNone(header.data(NODE_ID_ROLE))
self.assertTrue(header.data(Qt.ItemDataRole.FontRole).bold())
self.assertFalse(model.flags(header) & Qt.ItemFlag.ItemIsSelectable)
# Группы — по названию (casefold), ноды внутри — в порядке сортировки.
self.assertEqual(model.group_keys(), ["source:local", "source:sub-1"])
self.assertEqual(
[model.index(row, COL_NAME).data(GROUP_KEY_ROLE) or model.index(row, 0).data(NODE_ID_ROLE)
for row in range(model.rowCount())],
["source:local", "node-1", "source:sub-1", "remote"],
)
def test_collapsed_group_children_are_not_rows_but_stay_counted(self) -> None:
model = NodesTableModel()
model.set_nodes([Node(id="a", name="A"), Node(id="b", name="B")])
counter = _SignalCounter(model)
model.set_collapsed_groups({"source:local"})
self.assertEqual(counter.resets, 1)
self.assertEqual(model.rowCount(), 1)
self.assertIsNone(model.row_of_node("a"))
self.assertEqual(model.collapsed_group_of("a"), "source:local")
self.assertEqual(model.visible_node_count(), 2)
self.assertEqual(model.visible_node_ids(), ["a", "b"])
self.assertEqual(model.index(0, COL_NAME).data(), "Локальные · 2")
model.set_collapsed_groups(set())
self.assertEqual(model.row_of_node("b"), 2)
self.assertIsNone(model.collapsed_group_of("b"))
# ── set_nodes diff (AC2) ──
def test_set_nodes_add_and_remove_rebuild_once_in_catalog_order(self) -> None:
a = Node(id="a", name="A")
b = Node(id="b", name="B")
c = Node(id="c", name="C")
d = Node(id="d", name="D")
self.model.set_nodes([a, b, c])
counter = _SignalCounter(self.model)
# Добавление меняет состав строк — ровно один reset (выделение вид
# восстанавливает по ключам строк), без поштучных insert/remove.
self.model.set_nodes([a, b, c, d])
self.assertEqual(counter.resets, 1)
self.assertEqual(counter.inserts, [])
self.assertEqual(self.model.rowCount(), 4)
self.assertEqual(self.model.row_of_node("d"), 3)
self.model.set_nodes([a, c, d])
self.assertEqual(counter.resets, 2)
self.assertEqual(counter.removes, [])
self.assertEqual(self.model.rowCount(), 3)
self.assertIsNone(self.model.row_of_node("b"))
self.assertEqual(
[self.model.index(row, 0).data(NODE_ID_ROLE) for row in range(3)],
["a", "c", "d"],
)
def test_set_nodes_point_edit_emits_datachanged_for_that_row_only(self) -> None:
a = Node(id="a", name="A")
b = Node(id="b", name="B")
self.model.set_nodes([a, b])
counter = _SignalCounter(self.model)
edited = Node(id="b", name="B-renamed")
self.model.set_nodes([a, edited])
self.assertEqual(counter.resets, 0)
self.assertEqual(counter.layouts, 0)
self.assertEqual(counter.changed_rows(), {1})
self.assertEqual(self.model.index(1, COL_NAME).data(Qt.ItemDataRole.DisplayRole), "B-renamed")
def test_set_nodes_unchanged_catalog_emits_nothing(self) -> None:
a = Node(id="a", name="A")
b = Node(id="b", name="B")
self.model.set_nodes([a, b])
counter = _SignalCounter(self.model)
self.model.set_nodes([a, b])
self.assertEqual((counter.resets, counter.layouts, counter.data_changes), (0, 0, []))
def test_set_nodes_replacing_catalog_resets_once(self) -> None:
nodes = [Node(id=f"n{i}", name=f"N{i}") for i in range(4)]
self.model.set_nodes(nodes)
counter = _SignalCounter(self.model)
replacement = [nodes[0]] + [Node(id=f"x{i}", name=f"X{i}") for i in range(3)]
self.model.set_nodes(replacement)
self.assertEqual(counter.resets, 1)
self.assertEqual(self.model.rowCount(), 4)
# ── Ping batching (AC4) ──
def test_finish_ping_batch_repaints_only_finished_rows(self) -> None:
a = Node(id="a", name="A")
b = Node(id="b", name="B")
c = Node(id="c", name="C")
self.model.set_nodes([a, b, c])
self.model.set_ping_busy_ids({"a", "b", "c"})
counter = _SignalCounter(self.model)
a.ping_ms, c.ping_ms = 10, 20
self.model.finish_ping_batch({"a", "c"})
self.assertEqual(counter.changed_rows(), {0, 2})
for top, bottom, _left, _right in counter.data_changes:
self.assertEqual(top, bottom)
self.assertEqual((counter.resets, counter.layouts), (0, 0))
self.assertFalse(self.model.index(0, COL_PING).data(PING_BUSY_ROLE))
self.assertTrue(self.model.index(1, COL_PING).data(PING_BUSY_ROLE))
self.assertFalse(self.model.index(2, COL_PING).data(PING_BUSY_ROLE))
self.assertEqual(self.model.index(0, COL_PING).data(), "10 ms")
self.assertEqual(self.model.index(2, COL_PING).data(), "20 ms")
def test_single_ping_result_is_a_point_update(self) -> None:
nodes = [Node(id=f"n{i}", name=f"N{i}", sort_order=i) for i in range(5)]
self.model.set_nodes(nodes)
counter = _SignalCounter(self.model)
nodes[3].ping_ms = 77
self.model.finish_ping_batch({"n3"})
QTest.qWait(_AFTER_RELAYOUT_WINDOW_MS)
self.assertEqual(counter.changed_rows(), {3})
self.assertEqual((counter.resets, counter.layouts), (0, 0))
self.assertEqual(self.model.index(3, COL_PING).data(), "77 ms")
# ── Метрики при сортировке по ним: одна пересортировка на окно ──
def test_ping_sort_resorts_once_after_throttle_window_and_selection_follows(self) -> None:
nodes = [Node(id=f"n{i}", name=f"N{i}", ping_ms=10 * (i + 1), sort_order=i) for i in range(4)]
self.model.set_nodes(nodes)
self.model.set_sort("ping", False)
selection = QItemSelectionModel(self.model)
selection.select(
self.model.index(0, 0),
QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows,
)
persistent = QPersistentModelIndex(self.model.index(0, COL_PING))
counter = _SignalCounter(self.model)
# Пачка результатов подряд: таблица не прыгает на каждый ответ.
nodes[0].ping_ms = 500
self.model.finish_ping_batch({"n0"})
nodes[1].ping_ms = 400
self.model.finish_ping_batch({"n1"})
self.assertEqual(counter.layouts, 0)
self.assertEqual(self.model.visible_node_ids(), ["n0", "n1", "n2", "n3"])
QTest.qWait(_AFTER_RELAYOUT_WINDOW_MS)
self.assertEqual(counter.layouts, 1)
self.assertEqual(counter.resets, 0)
self.assertEqual(self.model.visible_node_ids(), ["n2", "n3", "n1", "n0"])
self.assertEqual(persistent.row(), 3)
self.assertEqual(persistent.column(), COL_PING)
self.assertEqual(persistent.data(NODE_ID_ROLE), "n0")
self.assertEqual({index.data(NODE_ID_ROLE) for index in selection.selectedRows()}, {"n0"})
def test_metrics_in_collapsed_group_do_not_touch_hidden_rows(self) -> None:
model = NodesTableModel()
nodes = [Node(id=f"n{i}", name=f"N{i}", sort_order=i) for i in range(3)]
model.set_nodes(nodes)
model.set_collapsed_groups({"source:local"})
counter = _SignalCounter(model)
nodes[1].ping_ms = 33
model.finish_ping_batch({"n1"})
model.set_ping_busy_ids({"n0", "n2"})
model.set_speed_progress_batch({"n0": 50})
model.set_active_node_id("n2")
QTest.qWait(_AFTER_RELAYOUT_WINDOW_MS)
self.assertEqual(counter.data_changes, [])
self.assertEqual((counter.resets, counter.layouts), (0, 0))
# После раскрытия строка сразу показывает свежие данные.
model.set_collapsed_groups(set())
self.assertEqual(model.index(model.row_of_node("n1"), COL_PING).data(), "33 ms")
# ── Active node (AC9) ──
def test_set_active_node_id_changes_exactly_two_rows(self) -> None:
a = Node(id="a", name="A")
b = Node(id="b", name="B")
c = Node(id="c", name="C")
self.model.set_nodes([a, b, c])
self.model.set_active_node_id("a")
counter = _SignalCounter(self.model)
self.model.set_active_node_id("c")
self.assertEqual(len(counter.data_changes), 2)
self.assertEqual({change[0] for change in counter.data_changes}, {0, 2})
for top_row, bottom_row, _left, _right in counter.data_changes:
self.assertEqual(top_row, bottom_row)
self.assertTrue(self.model.index(2, COL_NAME).data(ACTIVE_ROLE))
self.assertFalse(self.model.index(0, COL_NAME).data(ACTIVE_ROLE))
self.assertEqual(self.model.active_node_id(), "c")
# Выделение активной строки рисует делегат (заливка акцентом); модель
# не отдаёт FontRole для строк-серверов.
self.assertIsNone(self.model.index(2, COL_NAME).data(Qt.ItemDataRole.FontRole))
# ── Ping cell status semantics (AC7) ──
def test_ping_cell_is_green_for_alive_node(self) -> None:
node = Node(id="ok", name="OK", ping_ms=42, speed_mbps=12.5, is_alive=True)
self.model.set_nodes([node])
index = self.model.index(0, COL_PING)
brush = index.data(Qt.ItemDataRole.ForegroundRole)
self.assertIsNotNone(brush)
self.assertEqual(brush.color().name(), positive_color().name())
self.assertIn("Сервер работает", index.data(Qt.ItemDataRole.ToolTipRole))
def test_ping_cell_is_red_for_dead_node(self) -> None:
node = Node(id="dead", name="Dead", ping_ms=None, is_alive=False)
self.model.set_nodes([node])
index = self.model.index(0, COL_PING)
self.assertEqual(index.data(Qt.ItemDataRole.DisplayRole), "--")
brush = index.data(Qt.ItemDataRole.ForegroundRole)
self.assertIsNotNone(brush)
self.assertEqual(brush.color().name(), error_color().name())
self.assertIn("Сервер недоступен", index.data(Qt.ItemDataRole.ToolTipRole))
def test_ping_cell_is_orange_for_probably_blocked_node(self) -> None:
node = Node(
id="blocked",
name="Blocked",
ping_ms=42,
speed_mbps=None,
is_alive=True,
speed_history=[("2026-01-01T00:00:00+00:00", None)],
)
self.model.set_nodes([node])
index = self.model.index(0, COL_PING)
brush = index.data(Qt.ItemDataRole.ForegroundRole)
self.assertIsNotNone(brush)
self.assertEqual(brush.color().name(), warning_color().name())
self.assertIn("вероятно заблокирован", index.data(Qt.ItemDataRole.ToolTipRole))
# ── Existing activity behaviour ──
def test_ping_activity_is_model_data_not_a_cell_widget(self) -> None:
changes = []
self.model.dataChanged.connect(lambda *args: changes.append(args))
self.model.set_ping_busy_ids({self.node.id})
index = self.model.index(0, COL_PING)
self.assertTrue(index.data(PING_BUSY_ROLE))
self.assertTrue(self.model.is_ping_busy(self.node.id))
self.assertEqual(index.data(Qt.ItemDataRole.DisplayRole), "")
self.assertEqual(index.data(Qt.ItemDataRole.ToolTipRole), "Проверка пинга...")
self.assertEqual(len(changes), 1)
self.model.clear_ping_busy()
self.assertFalse(index.data(PING_BUSY_ROLE))
self.assertEqual(index.data(Qt.ItemDataRole.DisplayRole), "42 ms")
def test_speed_progress_is_clamped_and_cleared_addressably(self) -> None:
other = Node(id="node-2", name="Other")
self.model.set_nodes([self.node, other])
index = self.model.index(0, COL_SPEED)
self.model.set_speed_progress_batch({self.node.id: 140, other.id: -5})
self.assertEqual(index.data(SPEED_PROGRESS_ROLE), 100)
self.assertEqual(self.model.speed_progress(other.id), 0)
self.assertEqual(index.data(Qt.ItemDataRole.DisplayRole), "")
self.assertEqual(index.data(Qt.ItemDataRole.ToolTipRole), "Тест скорости: 100%")
# Результат одного сервера снимает прогресс только с него.
self.model.finish_speed(self.node.id)
self.assertIsNone(index.data(SPEED_PROGRESS_ROLE))
self.assertEqual(index.data(Qt.ItemDataRole.DisplayRole), "12.5 MB/s")
self.assertEqual(self.model.speed_progress(other.id), 0)
self.model.clear_speed_progress()
self.assertIsNone(self.model.speed_progress(other.id))
def test_progress_batch_and_result_each_emit_one_repaint(self) -> None:
other = Node(id="node-2", name="Other")
self.model.set_nodes([self.node, other])
changes = []
self.model.dataChanged.connect(lambda *args: changes.append(args))
self.model.set_speed_progress_batch({self.node.id: 25, other.id: 50})
self.assertEqual(len(changes), 1)
top, bottom = changes[0][0], changes[0][1]
self.assertEqual((top.row(), top.column()), (0, COL_SPEED))
self.assertEqual((bottom.row(), bottom.column()), (1, COL_SPEED))
self.assertEqual(self.model.index(0, COL_SPEED).data(SPEED_PROGRESS_ROLE), 25)
self.assertEqual(self.model.index(1, COL_SPEED).data(SPEED_PROGRESS_ROLE), 50)
changes.clear()
self.model.finish_speed(self.node.id)
self.assertEqual(len(changes), 1)
self.assertIsNone(self.model.index(0, COL_SPEED).data(SPEED_PROGRESS_ROLE))
def test_batch_ping_update_emits_one_table_range_change(self) -> None:
other = Node(id="node-2", name="Other")
self.model.set_nodes([self.node, other])
changes = []
self.model.dataChanged.connect(lambda top, bottom, roles: changes.append((top, bottom, roles)))
self.model.set_ping_busy_ids({self.node.id, other.id})
self.assertEqual(len(changes), 1)
top, bottom, _roles = changes[0]
self.assertEqual((top.row(), top.column()), (0, COL_PING))
self.assertEqual((bottom.row(), bottom.column()), (1, COL_PING))
def test_source_column_distinguishes_local_and_subscription_nodes(self) -> None:
subscribed = Node(id="node-2", name="Remote", subscription_id="sub-1")
self.model.set_source_names({"sub-1": "Provider"})
self.model.set_nodes([self.node, subscribed])
self.assertEqual(self.model.index(0, COL_SOURCE).data(Qt.ItemDataRole.DisplayRole), "Локальные")
self.assertEqual(self.model.index(1, COL_SOURCE).data(Qt.ItemDataRole.DisplayRole), "Provider")
class ColumnSpecsTests(unittest.TestCase):
"""AC7: generous maximum widths; "name" stays the single flex column."""
def test_maximum_widths_match_flex_layout_spec(self) -> None:
expected = {
"type": 200,
"address": 360,
"group": 800,
"tags": 1000,
"ping": 160,
"speed": 200,
"last_used": 480,
"source": 1000,
}
for key, maximum in expected.items():
self.assertEqual(
COLUMN_BY_KEY[key].maximum_width, maximum, f"maximum_width of {key}"
)
def test_name_is_the_single_flex_column(self) -> None:
flex = [spec.key for spec in COLUMN_SPECS if spec.stretch]
self.assertEqual(flex, [])
if __name__ == "__main__":
unittest.main()