zapret-kvn/tests/test_app_nodes_page_view.py
loop-uh 120bbc5613
Some checks failed
Windows project source guards / test (push) Failing after 44s
fix: refresh managed DNS and cache server table rendering
2026-09-06 14:51:07 +03:00

337 lines
14 KiB
Python

"""NodesPage view tests (AC8 compact layout, AC11 apply_view_settings).
NOTE on the module name: unittest discover imports test modules in
alphabetical order, and ``tests/test_engine_process_stop.py`` (and others)
create a bare ``QCoreApplication`` at import time, which cannot host
widgets and cannot be upgraded in-process. This module must therefore be
imported *first* among app-creating modules so it can install a full
``QApplication`` (offscreen); being a ``QCoreApplication`` subclass, it
satisfies every later ``QCoreApplication.instance() or ...`` bootstrap.
Keep the ``test_app_*`` prefix so it sorts before ``test_engine_*``.
"""
import os
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import Qt
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication, QHeaderView
from xray_fluent.profiles.models import AppSettings, Node
from xray_fluent.ui.nodes_filter_proxy import SORT_KEYS
from xray_fluent.ui.nodes_table_model import COL_PING, COL_SPEED
from xray_fluent.ui.nodes_page import _COLUMN_WIDTHS, _FLAG_ICON_SIZE, _ROW_HEIGHT, NodesPage
from xray_fluent.ui.nodes_table_delegate import NodesActivityDelegate
from xray_fluent.ui.nodes_table_model import (
COL_ADDRESS,
COL_NAME,
COL_TYPE,
COLUMN_BY_KEY,
COLUMN_KEYS,
DEFAULT_VISIBLE_COLUMNS,
NODE_ID_ROLE,
)
_existing = QApplication.instance()
if _existing is not None and not isinstance(_existing, QApplication):
raise RuntimeError(
"A bare QCoreApplication was created before test_app_nodes_page_view "
"was imported; widget tests need a QApplication. Keep this module "
"alphabetically before any QCoreApplication-creating test module."
)
_APP = _existing or QApplication([])
def _proxy_order(page: NodesPage) -> list[str]:
proxy = page._proxy
return [proxy.index(row, 0).data(NODE_ID_ROLE) for row in range(proxy.rowCount())]
class NodesPageViewTestCase(unittest.TestCase):
def setUp(self) -> None:
self.page = NodesPage()
def tearDown(self) -> None:
self.page.deleteLater()
_APP.processEvents()
class NodesPageCompactLayoutTests(NodesPageViewTestCase):
"""AC8: compact table — 30px rows, ~18x13 flag icon, default visible columns."""
def test_row_height_constant_is_28(self) -> None:
self.assertEqual(_ROW_HEIGHT, 28)
def test_table_rows_are_28px(self) -> None:
self.page.set_nodes([Node(name="A")])
index = self.page._group_model.index(0, 0)
self.assertEqual(index.data(Qt.ItemDataRole.SizeHintRole).height(), 28)
def test_flag_icon_size_is_18x13(self) -> None:
self.assertEqual((_FLAG_ICON_SIZE.width(), _FLAG_ICON_SIZE.height()), (18, 13))
icon_size = self.page.table.iconSize()
self.assertEqual((icon_size.width(), icon_size.height()), (18, 13))
def test_default_visible_columns_include_type_and_masked_address(self) -> None:
expected = ["name", "type", "ping", "speed"]
self.assertEqual(list(DEFAULT_VISIBLE_COLUMNS), expected)
self.assertEqual(self.page.visible_column_keys(), expected)
def test_columns_are_movable_and_user_resizable(self) -> None:
header = self.page.table.horizontalHeader()
self.assertTrue(header.sectionsMovable())
# AC1: every column, including the "name" flex column, is Interactive;
# ResizeMode.Stretch is not used anywhere.
for col in range(header.count()):
self.assertEqual(
header.sectionResizeMode(col),
QHeaderView.ResizeMode.Interactive,
f"column {COLUMN_KEYS[col]} must be Interactive",
)
def test_type_column_has_compact_default_width(self) -> None:
self.assertEqual(_COLUMN_WIDTHS[COL_TYPE], 110)
def test_type_column_is_clamped_to_compact_interactive_range(self) -> None:
# AC7: the live drag is clamped only by the generous sanity bounds
# (type: 44..200); values inside the range are never rolled back.
header = self.page.table.horizontalHeader()
header.resizeSection(COL_TYPE, 150)
self.assertEqual(header.sectionSize(COL_TYPE), 150)
header.resizeSection(COL_TYPE, 300)
self.assertEqual(header.sectionSize(COL_TYPE), 200)
header.resizeSection(COL_TYPE, 20)
self.assertEqual(header.sectionSize(COL_TYPE), 90)
def test_reveal_button_masks_again_after_mouse_and_keyboard_release(self) -> None:
self.page.set_nodes(
[Node(id="secret", server="secret.example", port=8443, scheme="vless")]
)
index = self.page._table_model.index(0, COL_ADDRESS)
button = self.page.reveal_addresses_btn
QTest.mousePress(button, Qt.MouseButton.LeftButton)
self.assertEqual(index.data(), "secret.example:8443")
QTest.mouseRelease(button, Qt.MouseButton.LeftButton)
self.assertEqual(index.data(), "********")
button.setFocus()
QTest.keyPress(button, Qt.Key.Key_Space)
self.assertEqual(index.data(), "secret.example:8443")
QTest.keyRelease(button, Qt.Key.Key_Space)
self.assertEqual(index.data(), "********")
class NodesPageApplyViewSettingsTests(NodesPageViewTestCase):
"""AC11: apply_view_settings round-trip and view_prefs_changed emissions."""
def setUp(self) -> None:
super().setUp()
self.prefs: list[dict] = []
self.page.view_prefs_changed.connect(self.prefs.append)
self.nodes = [
Node(id="a", name="Alpha", group="EU", tags=["fast"], ping_ms=50, sort_order=0),
Node(id="b", name="Bravo", group="EU", tags=["fast"], ping_ms=200, sort_order=1),
Node(id="c", name="Charlie", group="EU", tags=["fast"], ping_ms=None, sort_order=2),
Node(id="d", name="Delta", group="US", tags=["slow"], ping_ms=500, sort_order=3),
]
def test_apply_view_settings_round_trip_without_pref_emissions(self) -> None:
settings = AppSettings(
nodes_sort_key="ping",
nodes_sort_desc=True,
nodes_group_filter="EU",
nodes_tag_filter="fast",
nodes_visible_columns=["name", "address", "ping"],
nodes_column_widths={"type": 110, "address": 240},
nodes_column_order=["name", "address", "type", "ping", "speed"],
)
self.page.apply_view_settings(settings)
self.assertEqual(self.prefs, [], "apply_view_settings must not emit view_prefs_changed")
# Group/tag filters are deferred until set_nodes populates the combos.
self.page.set_nodes(self.nodes)
self.assertEqual(self.prefs, [], "applying deferred filters must not emit view_prefs_changed")
self.assertEqual(self.page.group_filter.currentText(), "EU")
self.assertEqual(self.page.tag_filter.currentText(), "fast")
self.assertEqual(self.page.visible_column_keys(), ["name", "address", "ping"])
self.assertEqual(self.page.column_widths()["type"], 110)
self.assertEqual(self.page.column_widths()["address"], 240)
self.assertEqual(self.page.column_order()[:3], ["name", "address", "type"])
# Sort key "ping" descending is applied to the proxy; "d" is filtered
# out (group US), None-ping stays last even in descending order.
self.assertEqual(_proxy_order(self.page), ["b", "a", "c"])
# Reading the preferences back reproduces exactly what was applied.
self.page._emit_view_prefs()
self.assertEqual(len(self.prefs), 1)
self.assertEqual(
self.prefs[0],
{
"nodes_sort_key": "ping",
"nodes_sort_desc": True,
"nodes_group_filter": "EU",
"nodes_tag_filter": "fast",
"nodes_source_filter": "",
"nodes_visible_columns": ["name", "address", "ping"],
"nodes_column_widths": self.page.column_widths(),
"nodes_column_order": self.page.column_order(),
"nodes_column_layout_version": 2,
"nodes_group_by": "source",
"nodes_collapsed_groups": [],
"nodes_favorites_only": False,
},
)
def test_legacy_visibility_is_migrated_to_type_and_address(self) -> None:
settings = AppSettings(
nodes_visible_columns=["name", "ping", "speed"],
nodes_column_layout_version=0,
)
self.page.apply_view_settings(settings)
self.assertEqual(
self.page.visible_column_keys(),
["name", "type", "ping", "speed"],
)
def test_user_sort_change_emits_view_prefs(self) -> None:
self.page.set_nodes(self.nodes)
self.assertEqual(self.prefs, [])
# User picks the "ping" sort key in the combo box.
self.page.sort_combo.setCurrentIndex(SORT_KEYS.index("ping"))
self.assertEqual(len(self.prefs), 1)
self.assertEqual(self.prefs[0]["nodes_sort_key"], "ping")
self.assertFalse(self.prefs[0]["nodes_sort_desc"])
self.assertEqual(_proxy_order(self.page), ["a", "b", "d", "c"])
# User toggles the sort direction.
self.page.sort_order_btn.click()
self.assertEqual(len(self.prefs), 2)
self.assertEqual(self.prefs[1]["nodes_sort_key"], "ping")
self.assertTrue(self.prefs[1]["nodes_sort_desc"])
self.assertEqual(_proxy_order(self.page), ["d", "b", "a", "c"])
class NodesPageColumnLayoutTests(NodesPageViewTestCase):
def test_resize_preserves_all_column_widths(self):
self.page.resize(1000, 600)
self.page.show()
_APP.processEvents()
before = self.page.column_widths()
self.page.resize(1800, 800)
# Width redistribution is coalesced into the next frame.
QTest.qWait(50)
_APP.processEvents()
self.assertEqual(before, self.page.column_widths())
self.assertEqual(self.page.table.header().sectionSize(COL_NAME), 360)
header = self.page.table.header()
used = sum(header.sectionSize(i) for i in range(header.count()) if not header.isSectionHidden(i))
self.assertEqual(used, self.page.table.viewport().width())
def test_refresh_does_not_request_a_server_switch(self):
requests = []
self.page.selected_node_changed.connect(requests.append)
for _ in range(3):
self.page.set_nodes([Node(id='one'), Node(id='two')], 'one')
self.assertEqual(requests, [])
self.page._select_node('two')
self.assertEqual(requests, ['two'])
def test_find_shortcut_shows_search_and_escape_clears_it(self):
self.page.show()
self.page.activateWindow()
self.page.table.setFocus()
_APP.processEvents()
self.assertTrue(self.page.search_edit.isHidden())
QTest.keyClick(self.page.table, Qt.Key.Key_F, Qt.KeyboardModifier.ControlModifier)
self.assertFalse(self.page.search_edit.isHidden())
self.page.search_edit.setText('missing')
QTest.keyClick(self.page.search_edit, Qt.Key.Key_Escape)
self.assertTrue(self.page.search_edit.isHidden())
self.assertEqual(self.page.search_edit.text(), '')
def test_name_drag_does_not_resize_neighbors_and_is_persisted(self):
header = self.page.table.header()
others = [header.sectionSize(c) for c in (COL_TYPE, COL_PING, COL_SPEED)]
header.resizeSection(COL_NAME, 500)
self.assertEqual(self.page.column_widths()["name"], 500)
self.assertEqual(others, [header.sectionSize(c) for c in (COL_TYPE, COL_PING, COL_SPEED)])
header.resizeSection(COL_NAME, 5000)
self.assertEqual(header.sectionSize(COL_NAME), 640)
def test_programmatic_resize_does_not_save_preferences(self):
events = []
self.page.view_prefs_changed.connect(events.append)
self.page.resize(1600, 900)
self.page._relayout_flex_column()
self.assertEqual(events, [])
class NodesActiveRowFillSeamTests(NodesPageViewTestCase):
"""The translucent active-row fill must not double up at column edges."""
def test_fill_has_no_alpha_doubling_at_column_boundaries(self) -> None:
nodes = [
Node(id=f"n{i}", name=f"Node {i}", server=f"s{i}.example", port=443, scheme="vless")
for i in range(6)
]
self.page.set_nodes(nodes)
self.page._table_model.set_active_node_id("n2")
self.page.resize(1000, 400)
self.page.show()
_APP.processEvents()
table = self.page.table
proxy = self.page._proxy
row = next(
r for r in range(proxy.rowCount()) if proxy.index(r, 0).data(NODE_ID_ROLE) == "n2"
)
rect = table.visualRect(proxy.index(row, 0))
image = table.viewport().grab().toImage()
header = table.horizontalHeader()
boundaries = [
header.sectionPosition(col)
for col in range(header.count())
if not header.isSectionHidden(col)
and 0 < header.sectionPosition(col) < image.width() - 8
]
self.assertTrue(boundaries)
# Scan just inside the fill's top edge, above flag icons and glyphs.
y = rect.top() + 4
reference = image.pixelColor(boundaries[0] // 2, y)
for boundary in boundaries:
for x in range(boundary - 6, boundary + 7):
color = image.pixelColor(x, y)
deviation = max(
abs(color.red() - reference.red()),
abs(color.green() - reference.green()),
abs(color.blue() - reference.blue()),
)
self.assertLessEqual(
deviation,
3,
f"seam at x={x} near boundary {boundary}: "
f"{color.getRgb()} vs {reference.getRgb()}",
)
class NodesActivityDelegateTests(NodesPageViewTestCase):
def test_active_server_uses_stock_table_delegate_and_bold_font(self):
from qfluentwidgets import TableItemDelegate
self.page.set_nodes([Node(id="a", name="A")])
self.page.set_active_node("a")
self.assertIsInstance(self.page.table.itemDelegate(), TableItemDelegate)
index = self.page._group_model.index(1, 0)
self.assertTrue(index.data(Qt.ItemDataRole.FontRole).bold())
if __name__ == "__main__":
unittest.main()